Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
1 change: 1 addition & 0 deletions CLAUDE.md
Original file line number Diff line number Diff line change
Expand Up @@ -4,4 +4,5 @@
- Real-API check: `DEEPCODE_API_KEY=... node dist/cli.js --json "..."` (on the coderbuzz VM: `with-secrets deepseek -- ...`).
- `vendor/deepcode-core` is upstream code: do not edit in place. Changes go in `vendor/patches/*.patch` and are applied by `scripts/sync-upstream.sh`.
- The core finds templates at `<dir of its chunk>/..`: the bundle must keep core code in `dist/chunks/` and templates in `dist/templates`, `dist/bundled`.
- `install.sh` (POSIX sh) is what users run via curl; test it with `npm test` (packed tarball, temp dirs). Keep it dependency-free.
- Public types live in `src/types.ts` with no imports from the core.
42 changes: 37 additions & 5 deletions README.md
Original file line number Diff line number Diff line change
Expand Up @@ -6,17 +6,47 @@ Structured-output wrapper around the [deepcode-cli](https://github.com/lessweb/d

The engine (`@vegamo/deepcode-core`, not on npm) is vendored in `vendor/deepcode-core`; see its `UPSTREAM.md`.

## Install / build
## Install

Needs **Node.js >= 22** (`dsh` runs `node`, so it must be on `PATH` wherever the orchestrator spawns it).

```bash
curl -fsSL https://raw.githubusercontent.com/coderbuzz/dsh/main/install.sh | sh
dsh --version
```

The installer puts the package in its own prefix (`~/.local/share/dsh`, so a Node version switch does not remove it)
and links `dsh` into `~/.local/bin`. If that directory is not on your `PATH` it prints the line to add. It is
idempotent: running it again upgrades. Options (flags after `sh -s --`, or env vars):

| Flag | Env | Default |
|---|---|---|
| `--version <v>` | `DSH_VERSION` | `latest` (pin one for reproducible installs) |
| `--prefix <dir>` | `DSH_PREFIX` | `~/.local/share/dsh` |
| `--bin-dir <dir>` | `DSH_BIN_DIR` | `~/.local/bin` (use `/usr/local/bin` for all users, with sudo) |
| `--uninstall` | | removes the prefix and the link (only a link that points into the prefix) |
| | `DSH_REGISTRY` | `https://registry.npmjs.org` |

```bash
npm install && npm run build # Node >= 22
npm link # optional: puts `dsh` on PATH
curl -fsSL https://raw.githubusercontent.com/coderbuzz/dsh/main/install.sh | sh -s -- --version 0.1.2 --bin-dir /usr/local/bin
```

API key: `DEEPCODE_API_KEY` env var, or `~/.deepcode/settings.json` (same config as deepcode-cli;
Other ways: `npm i -g @coderbuzz/dsh` (SDK users: `npm i @coderbuzz/dsh`), or from source:
`git clone https://github.com/coderbuzz/dsh && cd dsh && npm ci && npm run build && npm link`.
Releases are published from CI with npm provenance; the installer script is a plain POSIX `sh` file you can read first.

## Configure

API key: `DEEPCODE_API_KEY` env var, or `~/.deepcode/settings.json` (same file as deepcode-cli;
also `DEEPCODE_BASE_URL`, `DEEPCODE_MODEL`, ...). Sessions are stored under `~/.deepcode/`.
Upstream telemetry (a ping per prompt) is **off** in dsh; only `DEEPCODE_TELEMETRY_ENABLED=true` turns it on.

```bash
export DEEPCODE_API_KEY=sk-...
dsh models # lists models (falls back to a built-in list offline)
dsh -p "say hi in one word" --json # first run
```

## CLI

```bash
Expand Down Expand Up @@ -121,7 +151,9 @@ Node OpenAI SDK passes through as a literal key and DeepSeek ignores; dsh patche

`npm test` builds and runs an end-to-end test against a fake DeepSeek server (no key needed):
a tool call, a resume, overrides, usage accounting, a stable message prefix, plan mode without bash,
timeout killing child processes, parallel runs in one project, and telemetry staying off.
timeout killing child processes, parallel runs in one project, telemetry staying off, the API key hidden from the agent's
shell, and `install.sh` (install, rerun, uninstall, never overwriting a foreign file) on the packed tarball.
The installer test needs network access for the dependencies (~20 s).

## Release

Expand Down
65 changes: 65 additions & 0 deletions install.sh
Original file line number Diff line number Diff line change
@@ -0,0 +1,65 @@
#!/bin/sh
# Installs dsh from npm into its own prefix and links it into a directory on PATH.
# curl -fsSL https://raw.githubusercontent.com/coderbuzz/dsh/main/install.sh | sh
# curl -fsSL .../install.sh | sh -s -- --version 0.1.2 --bin-dir /usr/local/bin
# Options (or env): --version V (DSH_VERSION, default latest) --prefix DIR (DSH_PREFIX, default ~/.local/share/dsh)
# --bin-dir DIR (DSH_BIN_DIR, default ~/.local/bin) --uninstall -h
# Env only: DSH_REGISTRY (default https://registry.npmjs.org), DSH_PACKAGE (tarball/dir instead of the registry; for tests).
set -eu

version="${DSH_VERSION:-latest}"
prefix="${DSH_PREFIX:-$HOME/.local/share/dsh}"
bin_dir="${DSH_BIN_DIR:-$HOME/.local/bin}"
registry="${DSH_REGISTRY:-https://registry.npmjs.org}"
package="${DSH_PACKAGE:-@coderbuzz/dsh@$version}"
uninstall=0

die() { echo "install.sh: $*" >&2; exit 1; }
while [ $# -gt 0 ]; do
case "$1" in
--version) [ $# -ge 2 ] || die "--version needs a value"; version="$2"; package="${DSH_PACKAGE:-@coderbuzz/dsh@$2}"; shift 2 ;;
--prefix) [ $# -ge 2 ] || die "--prefix needs a value"; prefix="$2"; shift 2 ;;
--bin-dir) [ $# -ge 2 ] || die "--bin-dir needs a value"; bin_dir="$2"; shift 2 ;;
--uninstall) uninstall=1; shift ;;
-h|--help) sed -n '2,7p' "$0" | sed 's/^# \{0,1\}//'; exit 0 ;;
*) die "unknown option: $1 (try --help)" ;;
esac
done

# npm sits next to node; on version-manager setups (fnm, nvm) it is not on PATH when the shell is non-interactive,
# so look beside the real node binary too.
node_bin="$(command -v node 2>/dev/null || true)"
[ -n "$node_bin" ] || die "Node.js >= 22 is required (https://nodejs.org)"
node_real="$(readlink -f "$node_bin" 2>/dev/null || echo "$node_bin")"
PATH="$PATH:$(dirname "$node_real")"
major="$(node -p 'process.versions.node.split(".")[0]')"
[ "$major" -ge 22 ] 2>/dev/null || die "Node.js >= 22 is required (found $(node -v))"
command -v npm >/dev/null 2>&1 || die "npm not found next to node ($node_real)"

if [ "$uninstall" = 1 ]; then
npm rm -g --prefix "$prefix" @coderbuzz/dsh >/dev/null 2>&1 || true
# Remove the link only if it points into our prefix; never touch an unrelated `dsh`.
link="$bin_dir/dsh"
if [ -L "$link" ] && [ "$(readlink "$link")" = "$prefix/bin/dsh" ]; then rm -f "$link"; fi
rm -rf "$prefix"
echo "dsh removed (prefix $prefix, link $link)."
exit 0
fi

echo "Installing $package into $prefix ..."
npm install -g --prefix "$prefix" "$package" --registry "$registry" --no-audit --no-fund --loglevel=error \
|| die "npm install failed"

mkdir -p "$bin_dir"
link="$bin_dir/dsh"
if [ -e "$link" ] && [ ! -L "$link" ]; then die "$link exists and is not a symlink; remove it or pick --bin-dir"; fi
ln -sfn "$prefix/bin/dsh" "$link"

installed="$(node -p "require('$prefix/lib/node_modules/@coderbuzz/dsh/package.json').version" 2>/dev/null || echo '?')"
echo "Installed dsh $installed -> $link"
case ":$PATH:" in
*":$bin_dir:"*) ;;
*) echo "Note: $bin_dir is not on your PATH. Add this to your shell profile:"
echo " export PATH=\"$bin_dir:\$PATH\"" ;;
esac
echo "Set DEEPCODE_API_KEY (see README) and try: dsh models"
4 changes: 2 additions & 2 deletions package-lock.json

Some generated files are not rendered by default. Learn more about how customized files appear on GitHub.

2 changes: 1 addition & 1 deletion package.json
Original file line number Diff line number Diff line change
@@ -1,6 +1,6 @@
{
"name": "@coderbuzz/dsh",
"version": "0.1.2",
"version": "0.1.3",
"description": "DeepSeek harness for orchestrators: structured JSON SDK and CLI on top of the deepcode-cli engine",
"license": "MIT",
"repository": {
Expand Down
7 changes: 7 additions & 0 deletions src/cli.ts
Original file line number Diff line number Diff line change
@@ -1,4 +1,5 @@
import { parseArgs } from "node:util";
import pkg from "../package.json" with { type: "json" };
import { getAvailableModels, run } from "./index.js";
import type { Mode, ReasoningEffort } from "./types.js";

Expand All @@ -19,6 +20,7 @@ Options:
--mode <mode> plan (read-only) | edit (writes in cwd/tmp) | yolo (default, everything)
--disallowed-tools <a,b> Remove tools, e.g. bash,WebSearch (bash scopes are self-declared: remove it for read-only)
--timeout <seconds> Stop the run (and its child processes) and report failed
-v, --version
-h, --help

API key: DEEPCODE_API_KEY env or ~/.deepcode/settings.json (same as deepcode-cli).
Expand Down Expand Up @@ -48,8 +50,13 @@ async function main(): Promise<number> {
cwd: { type: "string", short: "C" },
refresh: { type: "boolean" },
help: { type: "boolean", short: "h" },
version: { type: "boolean", short: "v" },
},
});
if (values.version) {
process.stdout.write(`${pkg.version}\n`);
return 0;
}
if (values.help) {
process.stdout.write(USAGE + "\n");
return 0;
Expand Down
56 changes: 56 additions & 0 deletions test/install.test.mjs
Original file line number Diff line number Diff line change
@@ -0,0 +1,56 @@
// install.sh against the package exactly as npm would publish it (npm pack), into temp dirs.
import { test } from "node:test";
import assert from "node:assert/strict";
import { execFile } from "node:child_process";
import { promisify } from "node:util";
import fs from "node:fs";
import os from "node:os";
import path from "node:path";

const run = promisify(execFile);
const script = path.resolve("install.sh");
const version = JSON.parse(fs.readFileSync("package.json", "utf8")).version;

test("install.sh: install, rerun, refuse to overwrite, uninstall", async () => {
const tmp = fs.mkdtempSync(path.join(os.tmpdir(), "dsh-inst-"));
const packed = (await run("npm", ["pack", "--pack-destination", tmp, "--ignore-scripts", "--json"])).stdout;
const tarball = path.join(tmp, JSON.parse(packed)[0].filename);
const prefix = path.join(tmp, "prefix");
const bin = path.join(tmp, "bin");
const env = { ...process.env, DSH_PACKAGE: tarball, HOME: tmp };
const install = (...args) => run("sh", [script, "--prefix", prefix, "--bin-dir", bin, ...args], { env });

const first = await install();
assert.match(first.stdout, new RegExp(`Installed dsh ${version.replaceAll(".", "\\.")} `));
assert.match(first.stdout, /not on your PATH/, "hints when bin dir is not on PATH");
assert.equal(fs.readlinkSync(path.join(bin, "dsh")), path.join(prefix, "bin", "dsh"));
assert.equal((await run(path.join(bin, "dsh"), ["--version"])).stdout.trim(), version);

await install(); // idempotent
assert.equal((await run(path.join(bin, "dsh"), ["--version"])).stdout.trim(), version);

const onPath = await run("sh", [script, "--prefix", prefix, "--bin-dir", bin], { env: { ...env, PATH: `${bin}:${process.env.PATH}` } });
assert.doesNotMatch(onPath.stdout, /not on your PATH/);

// an unrelated `dsh` in another bin dir must survive uninstall
const other = path.join(tmp, "other");
fs.mkdirSync(other);
fs.symlinkSync("/bin/true", path.join(other, "dsh"));
await run("sh", [script, "--uninstall", "--prefix", prefix, "--bin-dir", other], { env });
assert.ok(fs.existsSync(path.join(other, "dsh")), "unrelated link kept");

// a regular file at the link location is never overwritten
const clash = path.join(tmp, "clash");
fs.mkdirSync(clash);
fs.writeFileSync(path.join(clash, "dsh"), "mine");
await assert.rejects(run("sh", [script, "--prefix", path.join(tmp, "p2"), "--bin-dir", clash], { env }), /not a symlink/);
assert.equal(fs.readFileSync(path.join(clash, "dsh"), "utf8"), "mine");

const gone = await run("sh", [script, "--uninstall", "--prefix", prefix, "--bin-dir", bin], { env });
assert.match(gone.stdout, /removed/);
assert.ok(!fs.existsSync(path.join(bin, "dsh")) && !fs.existsSync(prefix));
});

test("install.sh: rejects unknown options", async () => {
await assert.rejects(run("sh", [script, "--bogus"]), /unknown option/);
});
Loading