Skip to content

Commit cb4e0cd

Browse files
alex-w-99claude
andcommitted
Add a one-line installer, matching the claude-code/codex bridges
curl -fsSL .../install.sh | bash clones the plugin into ~/.inkbox-opencode/app (or uses a local checkout in place), builds and packs it, installs it into the global opencode config with a plugin wrapper (never overwriting an edited one), and symlinks an `inkbox-opencode` launcher into ~/.local/bin. Re-running updates in place. `uninstall` now also removes the launcher symlink, and README/setup commands use the launcher instead of /path/to placeholders. Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
1 parent 08e6868 commit cb4e0cd

8 files changed

Lines changed: 196 additions & 21 deletions

File tree

README.md

Lines changed: 30 additions & 12 deletions
Original file line numberDiff line numberDiff line change
@@ -8,20 +8,38 @@ contacts, notes, and an encrypted credential vault — as native opencode tools.
88

99
## Install
1010

11-
This plugin is distributed as source — clone and build it, then load it from a
12-
local wrapper. Build the clone:
11+
One line (needs git, Node 20+, and npm; the opencode CLI itself for
12+
sessions and the gateway's managed server):
1313

1414
```bash
15-
git clone https://github.com/inkbox-ai/opencode-plugin.git
16-
cd opencode-plugin
17-
npm install && npm run build
15+
curl -fsSL https://raw.githubusercontent.com/inkbox-ai/opencode-plugin/main/install.sh | bash
16+
```
17+
18+
This clones the plugin into `~/.inkbox-opencode/app`, builds it, installs it
19+
into your **global opencode config** (`~/.config/opencode`) with a plugin
20+
wrapper, and puts an `inkbox-opencode` launcher on your PATH. Re-running is
21+
safe — it updates in place. From a local checkout, run `./install.sh` instead;
22+
it uses the checkout in place.
23+
24+
Then set credentials (below), run `inkbox-opencode doctor`, and restart
25+
opencode — the Inkbox tools load in every session.
26+
27+
### Manual install (per-project, or no installer)
28+
29+
The plugin is distributed as source — clone and build it, then load it from a
30+
local wrapper:
31+
32+
```bash
33+
git clone https://github.com/inkbox-ai/opencode-plugin.git ~/.inkbox-opencode/app
34+
cd ~/.inkbox-opencode/app
35+
npm install && npm run build && npm pack
1836
```
1937

2038
Then, from your opencode project (or your global `~/.config/opencode`
21-
directory), install the built clone and add a one-file plugin wrapper:
39+
directory), install the packed build and add a one-file plugin wrapper:
2240

2341
```bash
24-
npm install /path/to/opencode-plugin # the clone you just built
42+
npm install ~/.inkbox-opencode/app/inkbox-opencode-plugin-*.tgz
2543
mkdir -p .opencode/plugins
2644
```
2745

@@ -138,7 +156,7 @@ directory with one config line:
138156

139157
```json
140158
{
141-
"skills": { "paths": ["/path/to/opencode-plugin/skills"] }
159+
"skills": { "paths": ["$HOME/.inkbox-opencode/app/skills"] }
142160
}
143161
```
144162

@@ -207,9 +225,9 @@ Two ways to run it:
207225
- **Sidecar (recommended)** — a self-contained companion process:
208226

209227
```bash
210-
node /path/to/opencode-plugin/bin/inkbox-opencode.js run
228+
inkbox-opencode run
211229
# or manage it as a daemon:
212-
node /path/to/opencode-plugin/bin/inkbox-opencode.js start | status | stop
230+
inkbox-opencode start | status | stop
213231
```
214232

215233
The sidecar needs an opencode server and finds one on its own: an explicit
@@ -266,8 +284,8 @@ Like the claude-code and codex bridges, the gateway can install itself as a
266284
boot/login service so the agent stays reachable without a shell:
267285

268286
```bash
269-
node /path/to/opencode-plugin/bin/inkbox-opencode.js autostart install
270-
node /path/to/opencode-plugin/bin/inkbox-opencode.js autostart status | uninstall
287+
inkbox-opencode autostart install
288+
inkbox-opencode autostart status | uninstall
271289
```
272290

273291
On Linux this writes and enables a **systemd user unit**

bin/inkbox-opencode.js

100644100755
File mode changed.

install.sh

Lines changed: 104 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,104 @@
1+
#!/usr/bin/env bash
2+
# Inkbox for opencode — one-line installer.
3+
#
4+
# curl -fsSL https://raw.githubusercontent.com/inkbox-ai/opencode-plugin/main/install.sh | bash
5+
#
6+
# Clones (or updates) the plugin into ~/.inkbox-opencode/app, builds it,
7+
# installs it into your global opencode config (~/.config/opencode) with a
8+
# plugin wrapper, and puts an `inkbox-opencode` launcher on your PATH.
9+
# From a local checkout, run ./install.sh — it uses the checkout in place.
10+
# Re-running is safe: it updates the clone and reinstalls.
11+
set -euo pipefail
12+
13+
REPO_SLUG="${INKBOX_OPENCODE_REPO:-inkbox-ai/opencode-plugin}"
14+
REPO_BRANCH="${INKBOX_OPENCODE_BRANCH:-main}"
15+
APP_DIR="${INKBOX_OPENCODE_APP_DIR:-$HOME/.inkbox-opencode/app}"
16+
BIN_DIR="${INKBOX_OPENCODE_BIN_DIR:-$HOME/.local/bin}"
17+
OPENCODE_CONFIG_DIR="${INKBOX_OPENCODE_CONFIG_DIR:-${XDG_CONFIG_HOME:-$HOME/.config}/opencode}"
18+
19+
say() { printf '\033[1;36m==>\033[0m %s\n' "$*"; }
20+
warn() { printf '\033[1;33mwarning:\033[0m %s\n' "$*"; }
21+
die() { printf '\033[1;31merror:\033[0m %s\n' "$*" >&2; exit 1; }
22+
23+
# --- 1. prerequisites -------------------------------------------------------
24+
command -v git >/dev/null 2>&1 || die "git is required."
25+
command -v npm >/dev/null 2>&1 || die "npm is required (comes with Node)."
26+
command -v node >/dev/null 2>&1 || die "Node.js 20+ is required."
27+
node -e 'process.exit(Number(process.versions.node.split(".")[0]) >= 20 ? 0 : 1)' ||
28+
die "Node.js 20+ is required (found $(node --version))."
29+
command -v opencode >/dev/null 2>&1 ||
30+
warn "the opencode CLI is not on PATH — install it (npm install -g opencode-ai); the gateway launches \`opencode serve\` and the plugin loads into opencode sessions."
31+
32+
# --- 2. source: local checkout, or clone/update the app dir -----------------
33+
SOURCE_DIR=""
34+
SCRIPT_DIR="$(cd "$(dirname "${BASH_SOURCE[0]:-.}")" 2>/dev/null && pwd || true)"
35+
if [ -n "$SCRIPT_DIR" ] && [ -f "$SCRIPT_DIR/package.json" ] &&
36+
grep -q '"name": "@inkbox/opencode-plugin"' "$SCRIPT_DIR/package.json" 2>/dev/null; then
37+
SOURCE_DIR="$SCRIPT_DIR" # running from inside a checkout (not curl | bash)
38+
say "Using the local checkout at $SOURCE_DIR"
39+
else
40+
if [ -d "$APP_DIR/.git" ]; then
41+
say "Updating $APP_DIR"
42+
git -C "$APP_DIR" fetch --quiet origin "$REPO_BRANCH"
43+
git -C "$APP_DIR" checkout --quiet "$REPO_BRANCH"
44+
git -C "$APP_DIR" reset --quiet --hard "origin/$REPO_BRANCH"
45+
else
46+
say "Cloning https://github.com/$REPO_SLUG into $APP_DIR"
47+
mkdir -p "$(dirname "$APP_DIR")"
48+
git clone --quiet --branch "$REPO_BRANCH" "https://github.com/$REPO_SLUG.git" "$APP_DIR"
49+
fi
50+
SOURCE_DIR="$APP_DIR"
51+
fi
52+
53+
# --- 3. build + pack ---------------------------------------------------------
54+
say "Installing dependencies and building"
55+
(cd "$SOURCE_DIR" && npm install --silent --no-audit --no-fund && npm run -s build)
56+
TARBALL="$SOURCE_DIR/$(cd "$SOURCE_DIR" && npm pack --silent | tail -1)"
57+
[ -f "$TARBALL" ] || die "npm pack did not produce a tarball."
58+
59+
# --- 4. wire the plugin into the global opencode config ----------------------
60+
say "Installing the plugin into $OPENCODE_CONFIG_DIR"
61+
mkdir -p "$OPENCODE_CONFIG_DIR/plugins"
62+
(cd "$OPENCODE_CONFIG_DIR" &&
63+
{ [ -f package.json ] || npm init -y >/dev/null 2>&1; } &&
64+
npm install --silent --no-audit --no-fund --save "$TARBALL")
65+
66+
WRAPPER="$OPENCODE_CONFIG_DIR/plugins/inkbox.ts"
67+
if [ -f "$WRAPPER" ]; then
68+
say "Keeping your existing plugin wrapper at $WRAPPER"
69+
else
70+
cat > "$WRAPPER" <<'EOF'
71+
// Inkbox plugin wrapper — written by install.sh; edit freely (never overwritten).
72+
// Credentials resolve from env vars or ~/.inkbox/config; options: see README.
73+
import InkboxPlugin from "@inkbox/opencode-plugin";
74+
75+
export default async (input: any) => InkboxPlugin(input, {});
76+
EOF
77+
say "Wrote plugin wrapper $WRAPPER"
78+
fi
79+
80+
# --- 5. launcher on PATH ------------------------------------------------------
81+
mkdir -p "$BIN_DIR"
82+
chmod +x "$SOURCE_DIR/bin/inkbox-opencode.js"
83+
ln -sf "$SOURCE_DIR/bin/inkbox-opencode.js" "$BIN_DIR/inkbox-opencode"
84+
say "Launcher: $BIN_DIR/inkbox-opencode"
85+
case ":$PATH:" in
86+
*":$BIN_DIR:"*) ;;
87+
*) warn "$BIN_DIR is not on your PATH — add: export PATH=\"$BIN_DIR:\$PATH\"" ;;
88+
esac
89+
90+
# --- 6. next steps ------------------------------------------------------------
91+
cat <<EOF
92+
93+
Installed. Next:
94+
95+
1. Credentials (or use ~/.inkbox/config):
96+
export INKBOX_API_KEY=... INKBOX_IDENTITY=... INKBOX_SIGNING_KEY=...
97+
2. Check the wiring:
98+
inkbox-opencode doctor
99+
3. Restart opencode — the Inkbox tools load in every session.
100+
4. Optional always-on inbound gateway (replies to email/SMS/calls):
101+
inkbox-opencode autostart install # boot service; 'status' / 'uninstall' to manage
102+
103+
Docs: https://github.com/$REPO_SLUG#readme
104+
EOF

package-lock.json

Lines changed: 3 additions & 0 deletions
Some generated files are not rendered by default. Learn more about customizing how changed files appear on GitHub.

src/cli/daemon.ts

Lines changed: 30 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -1,5 +1,6 @@
11
import { spawn } from "node:child_process";
22
import * as fs from "node:fs";
3+
import * as os from "node:os";
34
import * as path from "node:path";
45
import { gatewayHome } from "../gateway/state.js";
56

@@ -22,6 +23,8 @@ export interface DaemonOptions {
2223
entry?: string;
2324
// Injectable signal sender; defaults to process.kill.
2425
send?: SendSignal;
26+
// Dirs scanned for the launcher symlink on uninstall; tests use a sandbox.
27+
launcherDirs?: string[];
2528
}
2629

2730
export interface DaemonPaths {
@@ -194,6 +197,30 @@ export async function restartDaemon(opts: DaemonOptions = {}): Promise<number> {
194197
return startDaemon(opts);
195198
}
196199

200+
// Remove the installer's `inkbox-opencode` launcher from the given dirs; only
201+
// unlinks symlinks that resolve into an Inkbox checkout, never a stranger's
202+
// binary of the same name. Returns the removed paths.
203+
export function removeLauncherSymlinks(
204+
dirs: string[] = [
205+
path.join(os.homedir(), ".local", "bin"),
206+
...(process.env.PATH ?? "").split(path.delimiter).filter(Boolean),
207+
],
208+
): string[] {
209+
const removed: string[] = [];
210+
for (const dir of new Set(dirs)) {
211+
const link = path.join(dir, "inkbox-opencode");
212+
try {
213+
if (fs.lstatSync(link).isSymbolicLink() && fs.realpathSync(link).includes("inkbox")) {
214+
fs.rmSync(link);
215+
removed.push(link);
216+
}
217+
} catch {
218+
/* absent or unreadable — nothing to remove */
219+
}
220+
}
221+
return removed;
222+
}
223+
197224
// Stop the daemon and delete local gateway state, then point the user at the
198225
// manual steps (env vars, opencode.json) the CLI can't undo for them.
199226
export async function runUninstall(opts: DaemonOptions = {}): Promise<number> {
@@ -208,6 +235,9 @@ export async function runUninstall(opts: DaemonOptions = {}): Promise<number> {
208235
removeFile(path.join(home, ".env")); // credentials snapshot from `autostart install`
209236

210237
console.log("Removed local gateway state (pid, log, session map, autostart env).");
238+
for (const link of removeLauncherSymlinks(opts.launcherDirs)) {
239+
console.log(`Removed launcher ${link}`);
240+
}
211241
console.log("");
212242
console.log("To finish removing the gateway, do the following manually:");
213243
console.log(" 1. Remove the .opencode/plugins/inkbox.ts wrapper (or its gateway options).");

src/cli/setup.ts

Lines changed: 6 additions & 7 deletions
Original file line numberDiff line numberDiff line change
@@ -13,7 +13,6 @@ export function runSetup(config: ResolvedConfig, opts: SetupOptions = {}): numbe
1313
const print = opts.print ?? ((line: string) => console.log(line));
1414
const have = (value: unknown) => (value ? "set" : "MISSING");
1515
const serverUrl = config.gateway.serverUrl ?? DEFAULT_OPENCODE_SERVER_URL;
16-
const clone = "/path/to/opencode-plugin";
1716

1817
print("Inkbox opencode gateway — setup");
1918
print("");
@@ -31,8 +30,8 @@ export function runSetup(config: ResolvedConfig, opts: SetupOptions = {}): numbe
3130
print(" copy it to your workspace as .env.");
3231
print("");
3332
print("2. Load the plugin");
34-
print(" Clone + build the repo, then in your opencode project add");
35-
print(" .opencode/plugins/inkbox.ts (installed from the local clone):");
33+
print(" The installer (install.sh) wires the global ~/.config/opencode for");
34+
print(" you. For a per-project setup, add .opencode/plugins/inkbox.ts:");
3635
print("");
3736
for (const line of wrapperSnippet(serverUrl).split("\n")) print(` ${line}`);
3837
print("");
@@ -41,18 +40,18 @@ export function runSetup(config: ResolvedConfig, opts: SetupOptions = {}): numbe
4140
print(" - Recipients must text START to your number to opt in to SMS.");
4241
print("");
4342
print("4. Start it");
44-
print(` node ${clone}/bin/inkbox-opencode.js start # or 'run' to stay foreground`);
45-
print(` node ${clone}/bin/inkbox-opencode.js status`);
43+
print(" inkbox-opencode start # or 'run' to stay foreground");
44+
print(" inkbox-opencode status");
4645
print(" The gateway attaches to an `opencode serve` on :4096 if one is running;");
4746
print(` otherwise it launches its own managed server (port ${config.gateway.serve.port}).`);
4847
print("");
4948
print("5. Keep it running on boot (optional)");
50-
print(` node ${clone}/bin/inkbox-opencode.js autostart install`);
49+
print(" inkbox-opencode autostart install");
5150
print(" Installs a systemd user service (Linux) or launchd agent (macOS) and");
5251
print(" snapshots INKBOX_* / OPENAI_API_KEY from this shell to ~/.inkbox-opencode/.env.");
5352
print(" To keep it alive while logged out on Linux: sudo loginctl enable-linger $USER");
5453
print("");
55-
print(`Run 'node ${clone}/bin/inkbox-opencode.js doctor' to verify everything is wired up.`);
54+
print("Run 'inkbox-opencode doctor' to verify everything is wired up.");
5655
return 0;
5756
}
5857

tests/cli/daemon.test.ts

Lines changed: 22 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -8,6 +8,7 @@ import {
88
pidAlive,
99
posixGuard,
1010
readPidFile,
11+
removeLauncherSymlinks,
1112
restartDaemon,
1213
runUninstall,
1314
startDaemon,
@@ -179,6 +180,26 @@ describe("restartDaemon", () => {
179180
});
180181
});
181182

183+
describe("removeLauncherSymlinks", () => {
184+
it("removes only symlinks that resolve into an Inkbox checkout", () => {
185+
const bin = path.join(home, "bin");
186+
const app = path.join(home, "inkbox-opencode-app");
187+
fs.mkdirSync(bin, { recursive: true });
188+
fs.mkdirSync(app, { recursive: true });
189+
fs.writeFileSync(path.join(app, "cli.js"), "#!/usr/bin/env node\n");
190+
191+
const ours = path.join(bin, "inkbox-opencode");
192+
fs.symlinkSync(path.join(app, "cli.js"), ours);
193+
expect(removeLauncherSymlinks([bin])).toEqual([ours]);
194+
expect(fs.existsSync(ours)).toBe(false);
195+
196+
// A same-named real file (not ours) is left alone.
197+
fs.writeFileSync(ours, "someone else's binary\n");
198+
expect(removeLauncherSymlinks([bin])).toEqual([]);
199+
expect(fs.existsSync(ours)).toBe(true);
200+
});
201+
});
202+
182203
describe("runUninstall", () => {
183204
it("removes pid, log, session map, and the autostart env snapshot", async () => {
184205
fs.mkdirSync(home, { recursive: true });
@@ -189,7 +210,7 @@ describe("runUninstall", () => {
189210
fs.writeFileSync(files[0], "999999\n");
190211
for (const f of files.slice(1)) fs.writeFileSync(f, "x\n");
191212
const { send } = fakeProcess(false);
192-
expect(await runUninstall({ home, send })).toBe(0);
213+
expect(await runUninstall({ home, send, launcherDirs: [] })).toBe(0);
193214
for (const f of files) expect(fs.existsSync(f)).toBe(false);
194215
});
195216
});

tests/cli/setup.test.ts

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -46,7 +46,7 @@ describe("runSetup", () => {
4646
const { out } = render(makeConfig());
4747
expect(out).toMatch(/iMessage/i);
4848
expect(out).toContain("START");
49-
expect(out).toContain("bin/inkbox-opencode.js doctor");
49+
expect(out).toContain("inkbox-opencode doctor");
5050
});
5151

5252
it("documents the managed serve fallback and boot autostart", () => {

0 commit comments

Comments
 (0)