diff --git a/CLAUDE.md b/CLAUDE.md
index a70d8e9..8843662 100644
--- a/CLAUDE.md
+++ b/CLAUDE.md
@@ -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 `
/..`: 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.
diff --git a/README.md b/README.md
index d47e990..0b101da 100644
--- a/README.md
+++ b/README.md
@@ -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 ` | `DSH_VERSION` | `latest` (pin one for reproducible installs) |
+| `--prefix ` | `DSH_PREFIX` | `~/.local/share/dsh` |
+| `--bin-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
@@ -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
diff --git a/install.sh b/install.sh
new file mode 100755
index 0000000..3c0e9e9
--- /dev/null
+++ b/install.sh
@@ -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"
diff --git a/package-lock.json b/package-lock.json
index e1e0f72..e99b1eb 100644
--- a/package-lock.json
+++ b/package-lock.json
@@ -1,12 +1,12 @@
{
"name": "@coderbuzz/dsh",
- "version": "0.1.2",
+ "version": "0.1.3",
"lockfileVersion": 3,
"requires": true,
"packages": {
"": {
"name": "@coderbuzz/dsh",
- "version": "0.1.2",
+ "version": "0.1.3",
"license": "MIT",
"dependencies": {
"chalk": "^5.6.2",
diff --git a/package.json b/package.json
index 1cf7d60..3c0b6cc 100644
--- a/package.json
+++ b/package.json
@@ -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": {
diff --git a/src/cli.ts b/src/cli.ts
index 82929b0..bd69829 100644
--- a/src/cli.ts
+++ b/src/cli.ts
@@ -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";
@@ -19,6 +20,7 @@ Options:
--mode plan (read-only) | edit (writes in cwd/tmp) | yolo (default, everything)
--disallowed-tools Remove tools, e.g. bash,WebSearch (bash scopes are self-declared: remove it for read-only)
--timeout 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).
@@ -48,8 +50,13 @@ async function main(): Promise {
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;
diff --git a/test/install.test.mjs b/test/install.test.mjs
new file mode 100644
index 0000000..4900429
--- /dev/null
+++ b/test/install.test.mjs
@@ -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/);
+});