diff --git a/Cargo.toml b/Cargo.toml index 38fd500..0c8e5e4 100644 --- a/Cargo.toml +++ b/Cargo.toml @@ -7,7 +7,7 @@ members = [ ] [workspace.package] -version = "0.2.6" +version = "0.2.7" edition = "2021" rust-version = "1.88" license = "Apache-2.0" diff --git a/README.md b/README.md index abdf49b..b4e05ac 100644 --- a/README.md +++ b/README.md @@ -27,8 +27,8 @@ | You want | Run | What happens | | :--- | :--- | :--- | -| **npm** | `npm install -g cli-box-skill` | Binaries + skill installed automatically | -| **Shell** | `bash <(curl -fsSL https://raw.githubusercontent.com/Shadow-Azure/cli-box/main/packages/cli-box-skill/skill/install.sh)` | Downloads to `~/.cli-box/bin/`, installs skill | +| **npm** | `npx cli-box-skill install` | Choose harness(es); binaries + skill installed | +| **Shell** | `bash <(curl -fsSL https://raw.githubusercontent.com/Shadow-Azure/cli-box/main/packages/cli-box-skill/skill/install.sh) claude` | Downloads to `~/.cli-box/bin/`, installs skill | | **Manual** | [GitHub Releases](https://github.com/Shadow-Azure/cli-box/releases) | Download and extract manually | ### For Humans @@ -44,12 +44,15 @@ https://raw.githubusercontent.com/Shadow-Azure/cli-box/main/docs/guide/installat ### For LLM Agents +> **Install cli-box in one command:** run +> `npx cli-box-skill install all` to install the skill into every supported +> harness (Claude Code, OpenCode, OpenClaw), or replace `all` with `claude` / +> `opencode` / `openclaw` for a single target. + ```bash -curl -fsSL https://raw.githubusercontent.com/Shadow-Azure/cli-box/main/docs/guide/installation.md +npx cli-box-skill install claude # claude | opencode | openclaw | all ``` -Follow the guide step by step. - ### Add to PATH ```bash diff --git a/README.zh-cn.md b/README.zh-cn.md index fcd1bd8..0acb743 100644 --- a/README.zh-cn.md +++ b/README.zh-cn.md @@ -31,8 +31,8 @@ | 你想 | 运行 | 效果 | | :--- | :--- | :--- | -| **npm** | `npm install -g cli-box-skill` | 自动安装二进制 + skill | -| **Shell** | `bash <(curl -fsSL https://raw.githubusercontent.com/Shadow-Azure/cli-box/main/packages/cli-box-skill/skill/install.sh)` | 下载到 `~/.cli-box/bin/`,安装 skill | +| **npm** | `npx cli-box-skill install` | 选择 harness,自动装二进制 + skill | +| **Shell** | `bash <(curl -fsSL https://raw.githubusercontent.com/Shadow-Azure/cli-box/main/packages/cli-box-skill/skill/install.sh) claude` | 下载到 `~/.cli-box/bin/`,安装 skill | | **手动** | [GitHub Releases](https://github.com/Shadow-Azure/cli-box/releases) | 手动下载解压 | ### 给人类用户 @@ -46,14 +46,16 @@ https://raw.githubusercontent.com/Shadow-Azure/cli-box/main/docs/guide/installat > Agent 会处理 macOS 权限、PATH 配置和快速测试。只需在提示时确认即可。 -### 给 LLM Agent +### 给 AI Agent + +> **一行安装 cli-box:** 运行 `npx cli-box-skill install all` 把 skill 装到所有受支持的 +> harness(Claude Code、OpenCode、OpenClaw);把 `all` 换成 `claude` / `opencode` / +> `openclaw` 可只装一个。 ```bash -curl -fsSL https://raw.githubusercontent.com/Shadow-Azure/cli-box/main/docs/guide/installation.md +npx cli-box-skill install claude # claude | opencode | openclaw | all ``` -按指引逐步执行。 - ### 添加 PATH ```bash diff --git a/docs/guide/installation.md b/docs/guide/installation.md index 4fd7f42..a851f90 100644 --- a/docs/guide/installation.md +++ b/docs/guide/installation.md @@ -25,17 +25,21 @@ If curl or tar is missing, stop and ask the user to install them. ### Option A: npm (recommended) ```bash -npm install -g cli-box-skill +npx cli-box-skill install ``` -npm automatically installs the correct binaries for your platform. +Choose Claude Code, OpenCode, and/or OpenClaw (auto-detects installed ones). Or +non-interactively: `npx cli-box-skill install claude` (targets: `claude | opencode | openclaw | all`). +This installs the binaries to `~/.cli-box/bin/` and the skill into the chosen harness. ### Option B: Direct download ```bash -bash <(curl -fsSL https://raw.githubusercontent.com/Shadow-Azure/cli-box/main/packages/cli-box-skill/skill/install.sh) +bash <(curl -fsSL https://raw.githubusercontent.com/Shadow-Azure/cli-box/main/packages/cli-box-skill/skill/install.sh) claude ``` +Pass one or more of `claude | opencode | openclaw`, or `all`. + ## Step 3: Verify Installation ```bash diff --git a/docs/superpowers/plans/2026-06-16-cli-box-skill-installer.md b/docs/superpowers/plans/2026-06-16-cli-box-skill-installer.md new file mode 100644 index 0000000..bcdc647 --- /dev/null +++ b/docs/superpowers/plans/2026-06-16-cli-box-skill-installer.md @@ -0,0 +1,1108 @@ +# cli-box-skill Interactive Installer Implementation Plan + +> **For agentic workers:** REQUIRED SUB-SKILL: Use superpowers:subagent-driven-development (recommended) or superpowers:executing-plans to implement this plan task-by-task. Steps use checkbox (`- [ ]`) syntax for tracking. + +**Goal:** Ship a `cli-box-skill install` command (mirroring `oh-my-openagent`) that lets users pick Claude Code / OpenCode / OpenClaw, with a slim non-interactive `postinstall`, an arg-driven `install.sh`, and agent-friendly READMEs. + +**Architecture:** Pure logic lives in a zero-dependency `installer/shared.mjs` (unit-tested with Node's built-in `node:test`). The CLI entry `installer/cli.mjs` adds `commander` + `@clack/prompts` on top. `postinstall.mjs` and `install.sh` are slimmed: binaries only + guidance; skill placement is the user's explicit choice. + +**Tech Stack:** Node.js (ESM), `commander`, `@clack/prompts`, `node:test`, bash (`install.sh`). + +**Spec:** `docs/superpowers/specs/2026-06-16-installer-ux-design.md` + +**Branch:** `feat/cli-box-skill-installer-0.2.7` (already created; spec committed). + +--- + +## File Structure + +| File | Status | Responsibility | +|:---|:---|:---| +| `packages/cli-box-skill/installer/shared.mjs` | Create | Zero-dep pure logic: `HARNESS_TARGETS`, `parseTargets`, `detectHarnesses`, `installSkillToTargets`, `ensureBinaries`, `readBundledSkill` | +| `packages/cli-box-skill/installer/cli.mjs` | Create | CLI entry: `commander` + `@clack/prompts`; orchestrate resolve → install | +| `packages/cli-box-skill/test/shared.test.mjs` | Create | `node:test` unit tests for `shared.mjs` (zero-dep, runs without `npm install`) | +| `packages/cli-box-skill/test/cli.test.mjs` | Create | `node:test` integration: spawns `cli.mjs` for non-interactive paths (needs deps) | +| `packages/cli-box-skill/postinstall.mjs` | Modify | Slim: `ensureBinaries()` + guidance; remove skill copy | +| `packages/cli-box-skill/package.json` | Modify | Add `cli-box-skill` bin, `installer/` to files, `commander`+`@clack/prompts` deps, `test` script | +| `packages/cli-box-skill/skill/install.sh` | Modify | Accept target arg (positional/env); hint+exit if none | +| `packages/cli-box-skill/skill/SKILL.md` | Modify | Installation section → `npx cli-box-skill install` | +| `README.md`, `README.zh-cn.md`, `packages/cli-box-skill/README.md` | Modify | Agent sentence + human one-liner + curl-with-target | +| `docs/guide/installation.md` | Modify | Option A/B align with new commands | +| `tests/e2e-skill-install.sh` | Modify | postinstall no longer copies skill; add install.sh target tests | +| `test.sh` | Modify | Add "Skill Package Tests" section (node:test, deps via npm install) | +| `Cargo.toml`, `electron-app/package.json`, `release.sh` | Modify | Bump 0.2.6 → 0.2.7 | +| `release/release-pipeline.md` | Modify | Note the installer command + postinstall no longer copies skill | + +> **Git quirk note:** Earlier work showed `git add` (without `-f`) refuses new paths under `packages/cli-box-skill/bin/` even though the dir is tracked. The new files live in `installer/` (not `bin/`) to avoid this. If any `git add` of a new file is refused, use `git add -f`. + +--- + +## Task 1: Pure logic module `shared.mjs` + unit tests (TDD) + +**Files:** +- Create: `packages/cli-box-skill/installer/shared.mjs` +- Create: `packages/cli-box-skill/test/shared.test.mjs` + +- [ ] **Step 1: Write the failing tests** + +Create `packages/cli-box-skill/test/shared.test.mjs`: + +```js +import { test } from "node:test"; +import assert from "node:assert/strict"; +import fs from "node:fs"; +import os from "node:os"; +import path from "node:path"; +import { + HARNESS_IDS, + HARNESS_TARGETS, + parseTargets, + detectHarnesses, + installSkillToTargets, +} from "../installer/shared.mjs"; + +test("HARNESS_IDS lists the three harnesses", () => { + assert.deepEqual([...HARNESS_IDS].sort(), ["claude", "openclaw", "opencode"]); +}); + +test("HARNESS_TARGETS has label + skillDir for each", () => { + for (const id of HARNESS_IDS) { + assert.ok(HARNESS_TARGETS[id].label, `${id} has label`); + const dir = HARNESS_TARGETS[id].skillDir("/h"); + assert.ok(dir.startsWith("/h"), `${id} skillDir under home`); + } +}); + +test("parseTargets: explicit ids", () => { + assert.deepEqual(parseTargets(["claude", "opencode"]), ["claude", "opencode"]); +}); + +test("parseTargets: 'all' expands to every harness", () => { + assert.deepEqual(parseTargets(["all"]).sort(), [...HARNESS_IDS].sort()); +}); + +test("parseTargets: comma/space string normalizes", () => { + assert.deepEqual(parseTargets("claude, opencode"), ["claude", "opencode"]); + assert.deepEqual(parseTargets("openclaw"), ["openclaw"]); +}); + +test("parseTargets: empty input returns []", () => { + assert.deepEqual(parseTargets([]), []); + assert.deepEqual(parseTargets(""), []); +}); + +test("parseTargets: unknown id throws listing valid targets", () => { + assert.throws(() => parseTargets(["foo"]), /Unknown target.*claude.*openclaw.*opencode.*all/s); +}); + +test("detectHarnesses: only dirs that exist", () => { + const home = fs.mkdtempSync(path.join(os.tmpdir(), "cb-detect-")); + try { + fs.mkdirSync(path.join(home, ".claude"), { recursive: true }); + const found = detectHarnesses(home); + assert.ok(found.includes("claude")); + assert.ok(!found.includes("opencode")); + assert.ok(!found.includes("openclaw")); + } finally { + fs.rmSync(home, { recursive: true, force: true }); + } +}); + +test("installSkillToTargets: writes SKILL.md into each target dir", () => { + const home = fs.mkdtempSync(path.join(os.tmpdir(), "cb-install-")); + try { + const results = installSkillToTargets(["claude", "openclaw"], { + home, + content: "---\nname: cli-box\ndescription: x\n---\nbody", + }); + assert.equal(results.length, 2); + assert.ok(results.every((r) => r.ok)); + assert.ok( + fs.existsSync(path.join(home, ".claude", "skills", "cli-box", "SKILL.md")) + ); + assert.ok( + fs.existsSync(path.join(home, ".openclaw", "skills", "cli-box", "SKILL.md")) + ); + assert.ok( + !fs.existsSync(path.join(home, ".config", "opencode", "skills", "cli-box")) + ); + } finally { + fs.rmSync(home, { recursive: true, force: true }); + } +}); +``` + +- [ ] **Step 2: Run tests to verify they fail** + +Run: `node --test packages/cli-box-skill/test/shared.test.mjs` +Expected: FAIL — `Cannot find module '.../installer/shared.mjs'`. + +- [ ] **Step 3: Implement `shared.mjs`** + +Create `packages/cli-box-skill/installer/shared.mjs`: + +```js +// Pure logic for the cli-box skill installer. Zero external dependencies +// (node: builtins only) so it can be unit-tested without `npm install`. +import fs from "node:fs"; +import os from "node:os"; +import path from "node:path"; +import { createRequire } from "node:module"; + +const require = createRequire(import.meta.url); + +function hasBinary(name) { + const dirs = (process.env.PATH || "").split(path.delimiter); + const candidate = + process.platform === "win32" ? [name, `${name}.exe`, `${name}.cmd`] : [name]; + return dirs.some((d) => candidate.some((c) => safeExists(path.join(d, c)))); +} + +function safeExists(p) { + try { + return fs.existsSync(p); + } catch { + return false; + } +} + +export const HARNESS_TARGETS = { + claude: { + label: "Claude Code", + skillDir: (home) => path.join(home, ".claude", "skills", "cli-box"), + detect: (home) => safeExists(path.join(home, ".claude")) || hasBinary("claude"), + }, + opencode: { + label: "OpenCode", + skillDir: (home) => + path.join(home, ".config", "opencode", "skills", "cli-box"), + detect: (home) => + safeExists(path.join(home, ".config", "opencode")) || hasBinary("opencode"), + }, + openclaw: { + label: "OpenClaw", + skillDir: (home) => path.join(home, ".openclaw", "skills", "cli-box"), + detect: (home) => + safeExists(path.join(home, ".openclaw")) || hasBinary("openclaw"), + }, +}; + +export const HARNESS_IDS = Object.keys(HARNESS_TARGETS); + +// Accepts an array of tokens or a comma/space-separated string. +// Returns the resolved list of harness ids. Throws on unknown tokens. +export function parseTargets(input) { + const arr = Array.isArray(input) ? input : String(input ?? "").split(/[\s,]+/); + const tokens = arr.map((t) => String(t).trim()).filter(Boolean); + if (tokens.length === 0) return []; + if (tokens.includes("all")) return [...HARNESS_IDS]; + const invalid = tokens.filter((t) => !HARNESS_IDS.includes(t)); + if (invalid.length > 0) { + throw new Error( + `Unknown target(s): ${invalid.join(", ")}. Valid: ${[...HARNESS_IDS, "all"].join(", ")}` + ); + } + return Array.from(new Set(tokens)); +} + +export function detectHarnesses(home = os.homedir()) { + return HARNESS_IDS.filter((id) => HARNESS_TARGETS[id].detect(home)); +} + +export function readBundledSkill() { + return fs.readFileSync(new URL("../skill/SKILL.md", import.meta.url), "utf8"); +} + +// Writes the skill body into each target dir. Returns [{id, dir, ok, error?}]. +export function installSkillToTargets(ids, { home = os.homedir(), content } = {}) { + const body = content ?? readBundledSkill(); + return ids.map((id) => { + const dir = HARNESS_TARGETS[id].skillDir(home); + try { + fs.mkdirSync(dir, { recursive: true }); + fs.writeFileSync(path.join(dir, "SKILL.md"), body); + return { id, dir, ok: true }; + } catch (e) { + return { id, dir, ok: false, error: e.message }; + } + }); +} + +// Symlinks the platform-package binaries into ~/.cli-box/bin. +// Never throws: returns { ok:false, reason } if the platform package is absent. +export function ensureBinaries({ home = os.homedir() } = {}) { + const binDir = path.join(home, ".cli-box", "bin"); + fs.mkdirSync(binDir, { recursive: true }); + + const platform = + process.platform === "darwin" ? "darwin" : process.platform === "win32" ? "win32" : "linux"; + const arch = process.arch === "arm64" ? "arm64" : "x64"; + const pkgName = `cli-box-${platform}-${arch}`; + + let pkgDir; + try { + pkgDir = path.dirname(require.resolve(`${pkgName}/package.json`)); + } catch { + return { ok: false, reason: `platform package ${pkgName} not found`, binDir }; + } + + const linked = []; + for (const bin of ["cli-box", "cli-box-daemon"]) { + const src = path.join(pkgDir, "bin", bin); + const dst = path.join(binDir, bin); + if (safeExists(src)) { + try { + fs.rmSync(dst, { force: true }); + fs.symlinkSync(src, dst); + fs.chmodSync(src, 0o755); + linked.push(bin); + } catch { + /* ignore individual link failures */ + } + } + } + return { ok: true, linked, binDir }; +} +``` + +- [ ] **Step 4: Run tests to verify they pass** + +Run: `node --test packages/cli-box-skill/test/shared.test.mjs` +Expected: PASS — all tests pass. + +- [ ] **Step 5: Commit** + +```bash +git add packages/cli-box-skill/installer/shared.mjs packages/cli-box-skill/test/shared.test.mjs +git commit -m "feat(skill): add zero-dep installer shared logic + tests" +``` + +--- + +## Task 2: Add installer dependencies to the skill package + +**Files:** +- Modify: `packages/cli-box-skill/package.json` + +- [ ] **Step 1: Install commander and @clack/prompts** + +Run: +```bash +cd packages/cli-box-skill && npm install commander @clack/prompts && cd - +``` +This adds `dependencies` (with the latest compatible versions) to `package.json`. Confirm with: +```bash +node -e "const p=require('./packages/cli-box-skill/package.json'); console.log(JSON.stringify(p.dependencies))" +``` +Expected: a JSON object containing `commander` and `@clack/prompts`. + +- [ ] **Step 2: Add the `cli-box-skill` bin, `test` script, and `installer/` to files** + +Edit `packages/cli-box-skill/package.json`. Update these three fields (keep all other fields unchanged; the exact dependency versions are whatever Step 1 wrote — do not revert them): + +```json +"main": "postinstall.mjs", +"bin": { + "cli-box": "./bin/cli-box-wrapper.js", + "cli-box-skill": "./installer/cli.mjs" +}, +"scripts": { + "postinstall": "node postinstall.mjs", + "test": "node --test test/" +}, +"dependencies": { + "@clack/prompts": "", + "commander": "" +}, +"files": [ + "skill/SKILL.md", + "skill/install.sh", + "postinstall.mjs", + "bin/", + "installer/", + "README.md" +], +``` + +> Do NOT add `test/` to `files` — test files must not ship to npm. + +- [ ] **Step 3: Verify the package still parses** + +Run: `node -e "console.log(require('./packages/cli-box-skill/package.json').bin)"` +Expected: `{ 'cli-box': './bin/cli-box-wrapper.js', 'cli-box-skill': './installer/cli.mjs' }` + +- [ ] **Step 4: Commit** + +```bash +git add packages/cli-box-skill/package.json packages/cli-box-skill/package-lock.json +git commit -m "chore(skill): add commander + @clack deps, cli-box-skill bin, installer/ files" +``` + +> If `package-lock.json` was not created (no prior lockfile) that's fine — add it if present. + +--- + +## Task 3: CLI entry `cli.mjs` + integration tests (TDD) + +**Files:** +- Create: `packages/cli-box-skill/test/cli.test.mjs` +- Create: `packages/cli-box-skill/installer/cli.mjs` + +- [ ] **Step 1: Write the failing integration tests** + +Create `packages/cli-box-skill/test/cli.test.mjs`: + +```js +// Integration tests that spawn the real CLI. Requires commander + @clack/prompts +// installed (Task 2). Spawning => stdin is not a TTY, so the interactive branch +// is not exercised here (it is covered by manual release testing). +import { test } from "node:test"; +import assert from "node:assert/strict"; +import fs from "node:fs"; +import os from "node:os"; +import path from "node:path"; +import { spawnSync } from "node:child_process"; +import { fileURLToPath } from "node:url"; + +const here = path.dirname(fileURLToPath(import.meta.url)); +const cli = path.resolve(here, "../installer/cli.mjs"); + +function run(args, home) { + return spawnSync(process.execPath, [cli, ...args], { + env: { ...process.env, HOME: home }, + encoding: "utf8", + }); +} + +function tmpHome() { + return fs.mkdtempSync(path.join(os.tmpdir(), "cb-cli-")); +} + +test("install claude writes SKILL.md only to Claude dir", () => { + const home = tmpHome(); + try { + const r = run(["install", "claude"], home); + assert.equal(r.status, 0, r.stdout + r.stderr); + assert.ok( + fs.existsSync(path.join(home, ".claude", "skills", "cli-box", "SKILL.md")) + ); + assert.ok( + !fs.existsSync(path.join(home, ".config", "opencode", "skills", "cli-box")) + ); + assert.ok( + !fs.existsSync(path.join(home, ".openclaw", "skills", "cli-box")) + ); + } finally { + fs.rmSync(home, { recursive: true, force: true }); + } +}); + +test("install all writes SKILL.md to all three harnesses", () => { + const home = tmpHome(); + try { + const r = run(["install", "all"], home); + assert.equal(r.status, 0, r.stdout + r.stderr); + for (const sub of [".claude", ".config/opencode", ".openclaw"]) { + assert.ok( + fs.existsSync(path.join(home, sub, "skills", "cli-box", "SKILL.md")), + `missing ${sub}` + ); + } + } finally { + fs.rmSync(home, { recursive: true, force: true }); + } +}); + +test("install with no target in non-TTY exits 1 with guidance", () => { + const home = tmpHome(); + try { + const r = run(["install"], home); + assert.equal(r.status, 1); + assert.match(r.stderr + r.stdout, /Specify targets/); + } finally { + fs.rmSync(home, { recursive: true, force: true }); + } +}); + +test("install with unknown target exits non-zero", () => { + const home = tmpHome(); + try { + const r = run(["install", "bogus"], home); + assert.notEqual(r.status, 0); + } finally { + fs.rmSync(home, { recursive: true, force: true }); + } +}); +``` + +- [ ] **Step 2: Run tests to verify they fail** + +Run: `cd packages/cli-box-skill && npm test -- cli.test.mjs 2>&1 || node --test test/cli.test.mjs; cd -` +(simpler:) `cd packages/cli-box-skill && node --test test/cli.test.mjs; cd -` +Expected: FAIL — `Cannot find module '.../installer/cli.mjs'` or non-zero exits. + +- [ ] **Step 3: Implement `cli.mjs`** + +Create `packages/cli-box-skill/installer/cli.mjs`: + +```js +#!/usr/bin/env node +// cli-box-skill — installer for the cli-box skill into agent harnesses. +// Usage: +// npx cli-box-skill install # interactive (TTY) +// cli-box-skill install claude opencode # explicit +// cli-box-skill install all # all harnesses +// cli-box-skill install --no-tui claude # non-interactive explicit +import { Command } from "commander"; +import * as clack from "@clack/prompts"; +import { + HARNESS_IDS, + HARNESS_TARGETS, + parseTargets, + detectHarnesses, + installSkillToTargets, + ensureBinaries, +} from "./shared.mjs"; + +const isTTY = Boolean(process.stdin.isTTY); + +async function interactiveSelect() { + const detected = detectHarnesses(); + const options = HARNESS_IDS.map((id) => ({ + value: id, + label: `${HARNESS_TARGETS[id].label} ${detected.includes(id) ? "(detected)" : ""}`.trim(), + hint: id, + })); + const selected = await clack.multiselect({ + message: "Where should the cli-box skill be installed?", + options, + initialValues: detected, + required: false, + }); + if (clack.isCancel(selected)) { + console.log("Cancelled."); + process.exit(0); + } + return selected; +} + +function failNoTarget() { + console.error( + `Non-interactive shell. Specify targets:\n cli-box-skill install <${[ + ...HARNESS_IDS, + "all", + ].join("|")}>` + ); + process.exit(1); +} + +async function runInstall(targets, opts) { + const bin = ensureBinaries(); + if (bin.ok) { + for (const b of bin.linked) console.log(` ✓ ${b} → ~/.cli-box/bin/${b}`); + } else { + console.warn(` ⚠ ${bin.reason} (binaries may be missing)`); + } + + let ids; + if (targets.length > 0) { + ids = parseTargets(targets); + } else if (opts.tui && isTTY) { + ids = await interactiveSelect(); + if (ids.length === 0) { + console.log("No target selected — nothing to do."); + process.exit(0); + } + } else { + return failNoTarget(); + } + + const results = installSkillToTargets(ids); + for (const r of results) { + if (r.ok) console.log(` ✓ ${HARNESS_TARGETS[r.id].label} → ${r.dir}/SKILL.md`); + else console.error(` ✗ ${HARNESS_TARGETS[r.id].label}: ${r.error}`); + } + const okCount = results.filter((r) => r.ok).length; + console.log(`\nInstalled the cli-box skill into ${okCount}/${results.length} harness(es).`); + process.exit(okCount > 0 ? 0 : 1); +} + +const program = new Command(); +program + .name("cli-box-skill") + .description("Install the cli-box skill into agent harnesses (Claude Code / OpenCode / OpenClaw)"); + +program + .command("install", { isDefault: true }) + .description("Install the cli-box skill into one or more agent harnesses") + .argument("[targets...]", "claude | opencode | openclaw | all") + .option("--no-tui", "Non-interactive (targets required)") + .action(async (targets, opts) => { + try { + await runInstall(targets, opts); + } catch (e) { + console.error(`✗ ${e.message}`); + process.exit(1); + } + }); + +program.parseAsync(process.argv); +``` + +- [ ] **Step 4: Run tests to verify they pass** + +Run: `cd packages/cli-box-skill && node --test test/cli.test.mjs; cd -` +Expected: PASS — 4 tests pass. + +- [ ] **Step 5: Sanity-run the CLI manually** + +Run: `HOME=$(mktemp -d) node packages/cli-box-skill/installer/cli.mjs install claude && echo OK` +Expected: prints `✓ Claude Code → /.claude/skills/cli-box/SKILL.md` and a summary, then `OK`. + +- [ ] **Step 6: Commit** + +```bash +git add packages/cli-box-skill/installer/cli.mjs packages/cli-box-skill/test/cli.test.mjs +git commit -m "feat(skill): add cli-box-skill install command (commander + @clack)" +``` + +--- + +## Task 4: Slim `postinstall.mjs` + +**Files:** +- Modify: `packages/cli-box-skill/postinstall.mjs` + +- [ ] **Step 1: Replace the file contents** + +Overwrite `packages/cli-box-skill/postinstall.mjs` with: + +```js +#!/usr/bin/env node +// postinstall.mjs — runs after `npm install -g cli-box-skill`. +// Non-interactive: only symlinks the platform binaries and prints guidance. +// Skill placement into a harness is an explicit user choice: +// npx cli-box-skill install +import { ensureBinaries } from "./installer/shared.mjs"; + +function ok(m) { console.log(` ✓ ${m}`); } +function warn(m) { console.warn(` ⚠ ${m}`); } + +const bin = ensureBinaries(); +if (bin.ok) { + for (const b of bin.linked) ok(`${b} → ~/.cli-box/bin/${b}`); +} else { + warn(bin.reason); + warn("Binaries not set up. Re-run: npx cli-box-skill install"); +} + +console.log(""); +console.log(" cli-box binaries are ready. To install the skill into your agent harness, run:"); +console.log(" npx cli-box-skill install"); +console.log(" (targets: claude | opencode | openclaw | all)"); +console.log(""); +``` + +- [ ] **Step 2: Verify postinstall is non-interactive and does not copy skills** + +Run: +```bash +T=$(mktemp -d) && HOME=$T node packages/cli-box-skill/postinstall.mjs; echo "exit=$?"; ls -la "$T"; rm -rf "$T" +``` +Expected: prints the guidance message, `exit=0`, and the temp HOME contains **no** `.claude/skills/cli-box` (skill is NOT copied by postinstall anymore). + +- [ ] **Step 3: Commit** + +```bash +git add packages/cli-box-skill/postinstall.mjs +git commit -m "refactor(skill): slim postinstall to binaries + guidance (no skill copy)" +``` + +--- + +## Task 5: `install.sh` target-arg support + +**Files:** +- Modify: `packages/cli-box-skill/skill/install.sh` + +- [ ] **Step 1: Update the header vars and add OpenClaw + target resolution** + +In `packages/cli-box-skill/skill/install.sh`, find the existing dir vars block (around lines 9–11): + +```bash +INSTALL_DIR="$HOME/.cli-box/bin" +SKILL_CLAUDE_DIR="$HOME/.claude/skills/cli-box" +SKILL_OPENCODE_DIR="$HOME/.config/opencode/skills/cli-box" +``` + +Replace it with: + +```bash +INSTALL_DIR="$HOME/.cli-box/bin" +SKILL_CLAUDE_DIR="$HOME/.claude/skills/cli-box" +SKILL_OPENCODE_DIR="$HOME/.config/opencode/skills/cli-box" +SKILL_OPENCLAW_DIR="$HOME/.openclaw/skills/cli-box" +``` + +- [ ] **Step 2: Replace the skill-copy block with target-driven logic** + +Find the current skill-copy block (the `# Install skill to Claude Code` … `ok "Skill installed to $SKILL_OPENCODE_DIR"` section, which after 0.2.6 installs unconditionally to both). Replace that whole block with: + +```bash +# --- Resolve install targets --- +# Precedence: positional args > CLI_BOX_TARGETS env. Accept space- or comma- +# separated values; "all" expands to every harness. +if [ "$#" -gt 0 ]; then + TARGETS_RAW="$*" +elif [ -n "${CLI_BOX_TARGETS:-}" ]; then + TARGETS_RAW="$CLI_BOX_TARGETS" +else + TARGETS_RAW="" +fi + +# Normalize to lowercase, comma/space -> newline +TARGETS=$(echo "$TARGETS_RAW" | tr '[:upper:]' '[:lower:]' | tr ',[:space:]' '\n' | grep -v '^$' || true) + +if [ -z "$TARGETS" ]; then + echo "" + err "No install target given." + echo " Usage: bash install.sh [more...]" + echo " or: CLI_BOX_TARGETS=claude,opencode bash install.sh" + exit 1 +fi + +install_skill_dir() { + local label="$1" dir="$2" + info "Installing skill to ${label}..." + mkdir -p "$dir" + cp "$TMPDIR/SKILL.md" "$dir/" + ok "Skill installed to $dir" +} + +echo "$TARGETS" | grep -qxE 'all' && TARGETS="claude +opencode +openclaw" + +while IFS= read -r target; do + case "$target" in + claude) install_skill_dir "Claude Code" "$SKILL_CLAUDE_DIR" ;; + opencode) install_skill_dir "OpenCode" "$SKILL_OPENCODE_DIR" ;; + openclaw) install_skill_dir "OpenClaw" "$SKILL_OPENCLAW_DIR" ;; + *) err "Unknown target: $target (valid: claude | opencode | openclaw | all)"; exit 1 ;; + esac +done <<< "$TARGETS" +``` + +- [ ] **Step 3: Verify install.sh with a target** + +Run: +```bash +T=$(mktemp -d) +# point install.sh at a local tarball by stubbing the download (same technique the e2e test uses) +# Simpler smoke: just exercise the no-arg hint path: +HOME=$T bash packages/cli-box-skill/skill/install.sh 2>&1 | head -5; echo "exit=${PIPESTATUS[0]}" +rm -rf "$T" +``` +Expected: prints `✗ No install target given.` + the usage lines, `exit=1`. (The full happy path with a real tarball download is covered by the e2e test in Task 7.) + +- [ ] **Step 4: Commit** + +```bash +git add packages/cli-box-skill/skill/install.sh +git commit -m "feat(skill): install.sh takes a target arg; hint+exit when none" +``` + +--- + +## Task 6: Documentation — SKILL.md, READMEs, installation guide + +**Files:** +- Modify: `packages/cli-box-skill/skill/SKILL.md` +- Modify: `README.md` +- Modify: `README.zh-cn.md` +- Modify: `packages/cli-box-skill/README.md` +- Modify: `docs/guide/installation.md` + +- [ ] **Step 1: Update SKILL.md Installation section** + +In `packages/cli-box-skill/skill/SKILL.md`, replace the `## Installation` block (the `npm install -g cli-box-skill` + curl lines) with: + +```markdown +## Installation + +```bash +npx cli-box-skill install +``` + +Choose Claude Code, OpenCode, and/or OpenClaw. Or non-interactively: + +```bash +npx cli-box-skill install claude # claude | opencode | openclaw | all +``` +``` + +- [ ] **Step 2: Update the root `README.md` Installation section** + +In `README.md`: +(a) In the TL;DR table, change the **npm** row to: + +``` +| **npm** | `npx cli-box-skill install` | Choose harness(es); binaries + skill installed | +``` + +and the **Shell** row to: + +``` +| **Shell** | `bash <(curl -fsSL https://raw.githubusercontent.com/Shadow-Azure/cli-box/main/packages/cli-box-skill/skill/install.sh) claude` | Downloads to `~/.cli-box/bin/`, installs skill | +``` + +(b) Replace the `### For LLM Agents` block with: + +```markdown +### For LLM Agents + +> **Install cli-box in one command:** run +> `npx cli-box-skill install all` to install the skill into every supported +> harness (Claude Code, OpenCode, OpenClaw), or replace `all` with `claude` / +> `opencode` / `openclaw` for a single target. + +```bash +npx cli-box-skill install claude # claude | opencode | openclaw | all +``` +``` + +- [ ] **Step 3: Update `README.zh-cn.md` (Chinese mirror)** + +Apply the same structural changes as Step 2, in Chinese. npm row: + +``` +| **npm** | `npx cli-box-skill install` | 选择 harness,自动装二进制 + skill | +``` + +Shell row: + +``` +| **Shell** | `bash <(curl -fsSL https://raw.githubusercontent.com/Shadow-Azure/cli-box/main/packages/cli-box-skill/skill/install.sh) claude` | 下载到 `~/.cli-box/bin/`,安装 skill | +``` + +Agent block: + +```markdown +### 给 AI Agent + +> **一行安装 cli-box:** 运行 `npx cli-box-skill install all` 把 skill 装到所有受支持的 +> harness(Claude Code、OpenCode、OpenClaw);把 `all` 换成 `claude` / `opencode` / +> `openclaw` 可只装一个。 + +```bash +npx cli-box-skill install claude # claude | opencode | openclaw | all +``` +``` + +- [ ] **Step 4: Update `packages/cli-box-skill/README.md`** + +Replace the `## Install` block with: + +```markdown +## Install + +```bash +npx cli-box-skill install # interactive: pick Claude Code / OpenCode / OpenClaw +npx cli-box-skill install claude # non-interactive: claude | opencode | openclaw | all +``` +``` + +And the `## No npm?` block with: + +```markdown +## No npm? + +```bash +bash <(curl -fsSL https://raw.githubusercontent.com/Shadow-Azure/cli-box/main/packages/cli-box-skill/skill/install.sh) claude +``` +``` + +- [ ] **Step 5: Update `docs/guide/installation.md`** + +Replace Step 2's `### Option A` / `### Option B` with: + +```markdown +### Option A: npm (recommended) + +```bash +npx cli-box-skill install +``` + +Choose Claude Code, OpenCode, and/or OpenClaw (auto-detects installed ones). Or +non-interactively: `npx cli-box-skill install claude` (targets: `claude | opencode | openclaw | all`). +This installs the binaries to `~/.cli-box/bin/` and the skill into the chosen harness. + +### Option B: Direct download + +```bash +bash <(curl -fsSL https://raw.githubusercontent.com/Shadow-Azure/cli-box/main/packages/cli-box-skill/skill/install.sh) claude +``` + +Pass one or more of `claude | opencode | openclaw`, or `all`. +``` + +- [ ] **Step 6: Commit** + +```bash +git add packages/cli-box-skill/skill/SKILL.md README.md README.zh-cn.md packages/cli-box-skill/README.md docs/guide/installation.md +git commit -m "docs(skill): npx cli-box-skill install across READMEs + guide" +``` + +--- + +## Task 7: Rewrite `tests/e2e-skill-install.sh` + wire skill tests into `test.sh` + +**Files:** +- Modify: `tests/e2e-skill-install.sh` +- Modify: `test.sh` + +- [ ] **Step 1: Rewrite the e2e test functions** + +Open `tests/e2e-skill-install.sh`. Keep `ensure_platform_binaries()` and the helpers/skip-guard at the top unchanged. **Rewrite the three test functions** (`test_postinstall`, `test_install_sh`, `test_post_install_verify`) and the Main/Summary section so the file becomes: + +```bash +#!/usr/bin/env bash +set -euo pipefail + +# E2E Skill Installation Test +# Verifies (1) postinstall symlinks binaries but does NOT copy the skill, +# and (2) install.sh installs the skill into the specified target only. +# (The cli-box-skill CLI is covered by node:test in packages/cli-box-skill/test/.) + +SCRIPT_DIR="$(cd "$(dirname "$0")" && pwd)" +REPO_ROOT="$(cd "$SCRIPT_DIR/.." && pwd)" + +RED='\033[0;31m'; GREEN='\033[0;32m'; YELLOW='\033[0;33m'; NC='\033[0m' +info() { echo -e "${GREEN}➜${NC} $*"; } +warn() { echo -e "${YELLOW}⚠${NC} $*"; } +err() { echo -e "${RED}✗${NC} $*"; } +ok() { echo -e "${GREEN}✓${NC} $*"; } +FAILED=0 + +if [ "$(uname)" = "Linux" ] && [ -n "${CI:-}" ]; then + warn "Skipping E2E skill installation tests on Linux CI (macOS frameworks required)" + exit 0 +fi + +ensure_platform_binaries() { + local PKG_BIN="$REPO_ROOT/packages/cli-box-darwin-arm64/bin" + if [ -f "$PKG_BIN/cli-box" ] && [ -f "$PKG_BIN/cli-box-daemon" ]; then return; fi + info "Populating platform package bin/ with built binaries..." + mkdir -p "$PKG_BIN" + if [ ! -f "$REPO_ROOT/target/release/cli-box" ] && [ ! -f "$REPO_ROOT/target/debug/cli-box" ]; then + info " Building with cargo..."; cargo build -p cli-box-cli -p cli-box-daemon >/dev/null 2>&1 || { err "cargo build failed"; exit 1; } + fi + if [ -f "$REPO_ROOT/target/release/cli-box" ]; then + ln -sf "$REPO_ROOT/target/release/cli-box" "$PKG_BIN/cli-box" + ln -sf "$REPO_ROOT/target/release/cli-box-daemon" "$PKG_BIN/cli-box-daemon" + else + ln -sf "$REPO_ROOT/target/debug/cli-box" "$PKG_BIN/cli-box" + ln -sf "$REPO_ROOT/target/debug/cli-box-daemon" "$PKG_BIN/cli-box-daemon" + fi + ok "Platform package binaries linked" +} + +test_postinstall() { + info "Test 1: postinstall (binaries only, no skill copy)" + local TMP_HOME; TMP_HOME=$(mktemp -d) + local SKILL_PKG_NM="$REPO_ROOT/packages/cli-box-skill/node_modules" + local CREATED_NM=0 + cleanup_postinstall() { + rm -rf "$TMP_HOME" + if [ "$CREATED_NM" -eq 1 ]; then rm -rf "$SKILL_PKG_NM"; fi + } + trap cleanup_postinstall RETURN + if [ ! -d "$SKILL_PKG_NM/cli-box-darwin-arm64" ]; then + mkdir -p "$SKILL_PKG_NM" + ln -s "$REPO_ROOT/packages/cli-box-darwin-arm64" "$SKILL_PKG_NM/cli-box-darwin-arm64" + CREATED_NM=1 + fi + if ! HOME="$TMP_HOME" node "$REPO_ROOT/packages/cli-box-skill/postinstall.mjs" 2>&1; then + err " postinstall.mjs exited non-zero"; FAILED=1; return + fi + [ -L "$TMP_HOME/.cli-box/bin/cli-box" ] && ok " cli-box symlink created" || { err " cli-box symlink NOT created"; FAILED=1; } + [ -L "$TMP_HOME/.cli-box/bin/cli-box-daemon" ] && ok " cli-box-daemon symlink created" || { err " cli-box-daemon symlink NOT created"; FAILED=1; } + # NEW: postinstall must NOT copy the skill anywhere + if [ -e "$TMP_HOME/.claude/skills/cli-box/SKILL.md" ]; then + err " postinstall copied SKILL.md to .claude (should not)"; FAILED=1 + else + ok " postinstall did not copy SKILL.md (correct)" + fi + info " Test 1 complete" +} + +build_local_tarball() { + local out="$1" + local d; d=$(mktemp -d) + mkdir -p "$d/bin" + cp "$REPO_ROOT/packages/cli-box-skill/skill/SKILL.md" "$d/" + if [ ! -f "$REPO_ROOT/target/release/cli-box" ] && [ ! -f "$REPO_ROOT/target/debug/cli-box" ]; then + cargo build -p cli-box-cli -p cli-box-daemon >/dev/null 2>&1 + fi + if [ -f "$REPO_ROOT/target/release/cli-box" ]; then + cp "$REPO_ROOT/target/release/cli-box" "$d/bin/"; cp "$REPO_ROOT/target/release/cli-box-daemon" "$d/bin/" + else + cp "$REPO_ROOT/target/debug/cli-box" "$d/bin/"; cp "$REPO_ROOT/target/debug/cli-box-daemon" "$d/bin/" + fi + chmod +x "$d/bin/"* + (cd "$d" && tar czf "$out" .) + rm -rf "$d" +} + +patch_install_sh() { + # Make install.sh use a local tarball + fixed version instead of network. + local src="$1" dst="$2" tarball="$3" + cp "$src" "$dst" + sed -i '' 's/VERSION="${CLI_BOX_VERSION:-latest}"/VERSION="local"/' "$dst" + sed -i '' '/Fetching latest release version/,/fi/c\ +info "Using local version"' "$dst" + sed -i '' "s|DOWNLOAD_URL=\"https://github.com/\$REPO/releases/download/\$VERSION/cli-box-skill.tar.gz\"|DOWNLOAD_URL=\"file://$tarball\"|" "$dst" +} + +test_install_sh() { + info "Test 2: install.sh (skill into chosen target only)" + local TMP_HOME; TMP_HOME=$(mktemp -d) + local TMP_DIR; TMP_DIR=$(mktemp -d) + trap 'rm -rf "$TMP_HOME" "$TMP_DIR"' RETURN + local tarball="$TMP_DIR/cli-box-skill.tar.gz" + build_local_tarball "$tarball" || { err " tarball build failed"; FAILED=1; return; } + local script="$TMP_DIR/install-local.sh" + patch_install_sh "$REPO_ROOT/packages/cli-box-skill/skill/install.sh" "$script" "$tarball" + if ! HOME="$TMP_HOME" bash "$script" claude >/dev/null 2>&1; then + err " install.sh claude exited non-zero"; FAILED=1; return + fi + [ -f "$TMP_HOME/.cli-box/bin/cli-box" ] && ok " binaries installed" || { err " binaries missing"; FAILED=1; } + [ -f "$TMP_HOME/.claude/skills/cli-box/SKILL.md" ] && ok " SKILL.md in Claude dir" || { err " SKILL.md missing in Claude dir"; FAILED=1; } + [ ! -e "$TMP_HOME/.config/opencode/skills/cli-box" ] && ok " OpenCode dir untouched" || { err " OpenCode dir should be untouched"; FAILED=1; } + + info " Test 2b: install.sh with no target exits 1" + local rc=0 + HOME="$TMP_HOME" bash "$script" >/dev/null 2>&1 || rc=$? + if [ "$rc" -ne 1 ]; then err " expected exit 1, got $rc"; FAILED=1; else ok " no-target exit 1"; fi + info " Test 2 complete" +} + +echo "" +echo "==============================================" +echo " E2E Skill Installation Tests" +echo "==============================================" +echo "" +ensure_platform_binaries; echo "" +test_postinstall; echo "" +test_install_sh; echo "" +echo "==============================================" +if [ "$FAILED" -eq 0 ]; then echo -e "${GREEN}All E2E skill installation tests passed!${NC}"; exit 0 +else echo -e "${RED}Some E2E skill installation tests failed.${NC}"; exit 1; fi +``` + +- [ ] **Step 2: Run the rewritten e2e test** + +Run: `CI=1 bash tests/e2e-skill-install.sh` +Expected: all tests pass (CI=1 is harmless here — there is no Electron-launch step in this version). + +- [ ] **Step 3: Wire skill-package `node:test` into `test.sh`** + +In `test.sh`, immediately before the `# ==================== E2E Skill Installation Tests ====================` section, insert: + +```bash +# ==================== Skill Package Tests (node:test) ==================== +info "Running skill package tests (shared + CLI)..." +if (cd packages/cli-box-skill && npm install --no-save --omit=optional --ignore-scripts >/dev/null 2>&1 && node --test test/) 2>&1; then + ok "Skill package tests passed" +else + err "Skill package tests FAILED" + FAILED=1 +fi +``` + +- [ ] **Step 4: Run the full local gate** + +Run: `CI=1 bash test.sh 2>&1 | tail -40` +Expected: every section reports passed; final `All tests passed!`. (If the Playwright/Electron sections need a display they still run; the skill-related sections are what we changed.) + +- [ ] **Step 5: Commit** + +```bash +git add tests/e2e-skill-install.sh test.sh +git commit -m "test(skill): e2e for postinstall(no copy)/install.sh(target); wire node:test into test.sh" +``` + +--- + +## Task 8: Version bump 0.2.6 → 0.2.7 + release-pipeline.md note + +**Files:** +- Modify: `Cargo.toml` +- Modify: `electron-app/package.json` +- Modify: `release.sh` +- Modify: `release/release-pipeline.md` + +- [ ] **Step 1: Bump the three manual version files** + +Edit `Cargo.toml` (the `workspace.package.version` line): `0.2.6` → `0.2.7`. +Edit `electron-app/package.json` (`version`): `0.2.6` → `0.2.7`. +Edit `release.sh` (`VERSION=`): `0.2.6` → `0.2.7`. + +- [ ] **Step 2: Add an installer note to `release/release-pipeline.md`** + +In the `### 2. npm` section, append after the existing `cli-box-skill` row description: + +```markdown +On install, `postinstall.mjs` only symlinks binaries and prints guidance — it does +**not** copy the skill. Users run `npx cli-box-skill install` (interactive) or +`cli-box-skill install ` (explicit) to place the +skill. `install.sh` (curl) takes the same target args. Skill targets: Claude Code +`~/.claude/skills/cli-box/`, OpenCode `~/.config/opencode/skills/cli-box/`, +OpenClaw `~/.openclaw/skills/cli-box/`. +``` + +Also update the doc's `**Version:**` line to `0.2.7`. + +- [ ] **Step 3: Verify the bump** + +Run: `grep -n '0.2.7' Cargo.toml electron-app/package.json release.sh` +Expected: three matches. + +- [ ] **Step 4: Commit** + +```bash +git add Cargo.toml electron-app/package.json release.sh release/release-pipeline.md +git commit -m "chore: bump version to 0.2.7 + document cli-box-skill installer" +``` + +--- + +## Final: push, PR, CI, tag, release + +- [ ] Push the branch: `git push -u origin feat/cli-box-skill-installer-0.2.7` +- [ ] Open PR `feat(cli-box-skill): interactive installer + slim postinstall (0.2.7)` with Problem/Solution/Test Plan body; wait for CI Gate (Rust + clippy + fmt + frontend + the new skill node:test + rewritten e2e + release-build verify) to pass. +- [ ] Squash-merge to `main`, pull, `git tag -a v0.2.7 -m "Release v0.2.7"`, `git push origin v0.2.7`. +- [ ] `gh release create v0.2.7 --title "v0.2.7" --generate-notes` → triggers `release.yml` (build + GitHub assets + npm publish of the 3 packages at 0.2.7, including the new `cli-box-skill` bin). +- [ ] **Verify out-of-the-box** (matches the 0.2.6 check, adapted): in an isolated fresh HOME, `npx cli-box-skill install claude` → `~/.claude/skills/cli-box/SKILL.md` present, others absent; `npx cli-box-skill install all` → all three; bare `npx cli-box-skill install` in a non-TTY → exit 1 + guidance. + +--- + +## Self-Review (completed) + +**Spec coverage:** +- §5.1 postinstall slim → Task 4 ✓ +- §5.2 `cli-box-skill install` command → Task 3 ✓ +- §5.3 package.json (bin/files/deps) → Task 2 ✓ +- §5.4 install.sh target arg → Task 5 ✓ +- §5.5 SKILL.md → Task 6 ✓ +- §5.6 READMEs agent sentence + one-liner → Task 6 ✓ +- §5.7 installation.md → Task 6 ✓ +- §6 harness targets & detection → Task 1 (`HARNESS_TARGETS`, `detectHarnesses`) ✓ +- §7 error handling (no-target non-TTY → exit 1; unknown → error) → Task 1 (`parseTargets`) + Task 3 (`failNoTarget`) ✓ +- §8 testing (unit + e2e rewrite) → Tasks 1, 3, 7 ✓ +- §9 version bump 0.2.7 → Task 8 ✓ +- shared `ensureBinaries()` DRY between postinstall and installer → Task 1 + Task 4 ✓ + +**Placeholder scan:** No TBD/TODO; every code step contains full code; dep versions resolve via `npm install` in Task 2 (documented, not hardcoded guesses). + +**Type/name consistency:** `parseTargets`, `detectHarnesses`, `installSkillToTargets`, `ensureBinaries`, `HARNESS_TARGETS`, `HARNESS_IDS` defined in Task 1 and used identically in Tasks 3–4. Harness ids `claude`/`opencode`/`openclaw` consistent across shared.mjs, cli.mjs, install.sh, READMEs, tests. diff --git a/docs/superpowers/specs/2026-06-16-installer-ux-design.md b/docs/superpowers/specs/2026-06-16-installer-ux-design.md new file mode 100644 index 0000000..303ba3a --- /dev/null +++ b/docs/superpowers/specs/2026-06-16-installer-ux-design.md @@ -0,0 +1,300 @@ +# cli-box-skill Interactive Installer — Design + +**Date:** 2026-06-16 +**Target version:** 0.2.7 +**Status:** Draft (awaiting user review) + +--- + +## 1. Problem + +After `npm install -g cli-box-skill`, the skill is currently copied into **every** +harness directory unconditionally (a behavior introduced in 0.2.6). Users who only +use one harness (e.g. only Claude Code) get the skill dropped into OpenCode / +OpenClaw directories they don't want. There is also no way to choose the target, +and a third harness (**OpenClaw**, `~/.openclaw/skills/`) is now in scope. + +We want an installer that lets the user **choose** which harness(es) receive the +skill, in a way that is: + +- **AI-agent friendly** — a single self-contained, executable sentence in the README + that an agent can read and run. +- **Human friendly** — a one-line command, or a short interactive prompt. +- **CI/Docker safe** — never hangs on an interactive prompt in a non-TTY context. + +## 2. Reference + +`oh-my-openagent` (the style reference for this project) solves the identical +problem by keeping `postinstall.mjs` **minimal and non-interactive** (it only +verifies the platform binary) and putting all harness selection/installation in a +dedicated CLI command (`omo install` / `oh-my-opencode install`) built with +`commander` + `@clack/prompts`, supporting both interactive (`@clack` prompts) and +non-interactive (`--no-tui` + flags) modes. We adopt the same pattern. + +npm itself has no clean "pass params to postinstall" mechanism: `--flag=value` is +forwarded as `npm_config_*` but npm prints an `Unknown cli config` warning and the +official guidance discourages interactive/scripted params in install scripts. A +dedicated bin command is the ecosystem-standard answer. + +## 3. Goals / Non-goals + +**Goals** + +- New `cli-box-skill install` command: choose harness(es) interactively or via args. +- Support three harness targets: Claude Code, OpenCode, OpenClaw. +- `postinstall.mjs` becomes minimal (binaries only + a one-line guidance message); + it no longer copies skills or prompts. +- `install.sh` (curl fallback) accepts a target arg; prints a hint and exits if none. +- All READMEs contain a single agent-executable sentence + a human one-liner. + +**Non-goals** + +- No change to the Rust `cli-box`/`cli-box-daemon` binaries or daemon behavior. +- No change to the release pipeline (still 3 npm packages; version injected from tag). +- No interactive shell menus inside `postinstall.mjs` (explicitly avoided). +- No support for harnesses beyond Claude Code / OpenCode / OpenClaw in this version. + +## 4. Architecture + +``` +User + │ + ├─ npx cli-box-skill install ← interactive multi-select (primary entry) + ├─ cli-box-skill install claude opencode← non-interactive, explicit + │ + ▼ +cli-box-skill (NEW Node bin: bin/cli-box-skill.mjs, commander + @clack/prompts) + │ resolve targets → copy SKILL.md into each chosen harness skill dir + │ + ├─ Claude Code → ~/.claude/skills/cli-box/SKILL.md + ├─ OpenCode → ~/.config/opencode/skills/cli-box/SKILL.md + └─ OpenClaw → ~/.openclaw/skills/cli-box/SKILL.md + +postinstall.mjs (slimmed): symlink binaries → ~/.cli-box/bin/ + print guidance. + Does NOT copy skills, does NOT prompt. + +install.sh (curl fallback): download binaries + install SKILL.md to the + target(s) passed as args (or CLI_BOX_TARGETS env); + no args → print hint + exit 1. +``` + +The runtime `cli-box` command (Rust binary) is untouched; it is still exposed via +the existing `bin/cli-box-wrapper.js` → platform package binary. The new +`cli-box-skill` bin is a separate, small Node program whose only job is skill +installation. + +## 5. Component changes + +### 5.1 `packages/cli-box-skill/postinstall.mjs` + +- **Remove** the unconditional `SKILL.md` copy loop (the 0.2.6 change). +- Keep: platform-package resolution + symlink `cli-box`/`cli-box-daemon` into + `~/.cli-box/bin/` (essential; `cli-box` must work after install). +- Append a guidance message: + ``` + cli-box binaries are installed. To set up the skill in your agent harness, run: + npx cli-box-skill install + (or: npx cli-box-skill install claude | opencode | openclaw | all) + ``` +- Must remain non-interactive and never throw on missing platform package (warn + + exit 0, as today) so installs never fail. + +### 5.2 `packages/cli-box-skill/bin/cli-box-skill.mjs` (NEW) + +Node ESM script. Uses `commander` for parsing and `@clack/prompts` for the +interactive multi-select. + +Command surface: + +``` +cli-box-skill install [targets...] install the skill into the given harness(es) + targets: claude | opencode | openclaw | all + (space-separated; `all` = all three) +cli-box-skill install no args + TTY → interactive multi-select +cli-box-skill install no args + !TTY → error, exit 1 (no hang) +cli-box-skill install --no-tui explicit non-interactive +cli-box-skill --version / -h version / help +``` + +Behavior: + +1. **Ensure binaries are symlinked** into `~/.cli-box/bin/` (idempotent) by calling + a shared `ensureBinaries()` helper that `postinstall.mjs` also uses. This makes + `npx cli-box-skill install` self-sufficient even if a global install's + postinstall was skipped (`--ignore-scripts`). +2. Resolve effective targets with precedence: + `positional args` > (error if `--no-tui` and none) > interactive prompt (TTY) > error (!TTY). +3. Interactive prompt: `@clack/prompts` multiselect of the 3 harnesses; options + pre-checked = detected harnesses; user toggles the ones they want (checking all + three == `all`). Empty confirmation → "no target selected, nothing to do", exit 0. +4. For each chosen target: `mkdir -p ` + copy the bundled `skill/SKILL.md` + (read relative to the script) to `/SKILL.md`. Idempotent (overwrites). +5. Print a summary: which harnesses got the skill, plus the PATH hint + (`~/.cli-box/bin`) if not already on PATH. + +> Shared helper: extract `ensureBinaries()` (platform-package resolution + +> symlink) into `bin/shared.mjs` so both `postinstall.mjs` and the installer use +> one implementation (DRY). + +### 5.3 `packages/cli-box-skill/package.json` + +- Add bin: `"cli-box-skill": "./bin/cli-box-skill.mjs"` (keep `"cli-box"` wrapper). +- Add `dependencies`: `commander`, `@clack/prompts`. +- `files` already includes `bin/`, `skill/SKILL.md`; ensure `bin/cli-box-skill.mjs` + is shipped (it is, under `bin/`). + +> Note: `bin/cli-box-skill.mjs` must be force-added to git if the earlier +> observed "git add refuses paths under `packages/cli-box-skill/bin`" quirk recurs +> (the dir is tracked but `git add` without `-f` was refused in the 0.2.6 work). + +### 5.4 `packages/cli-box-skill/skill/install.sh` (curl fallback) + +- Accept targets via positional args (`bash install.sh claude opencode`) or env + (`CLI_BOX_TARGETS=claude,opencode bash install.sh`); `all` supported. +- Existing behavior (download tarball, extract binaries to `~/.cli-box/bin/`) stays. +- New: after binaries, install `SKILL.md` to the **specified** target dir(s) only. +- **No target provided** → print a hint and exit 1: + ``` + Usage: bash install.sh [more...] + or: CLI_BOX_TARGETS=claude,opencode bash install.sh + No target given — re-run with one of the targets above. + ``` +- Rationale: curl users sometimes pipe stdin (`curl | bash`), so an interactive + menu is unreliable here; requiring an explicit arg + hint is robust and matches + the npm command's no-target error behavior. + +### 5.5 `packages/cli-box-skill/skill/SKILL.md` + +Update the **Installation** section to: + +```markdown +## Installation + +```bash +npx cli-box-skill install +``` + +Choose Claude Code, OpenCode, and/or OpenClaw. Or non-interactively: + +```bash +npx cli-box-skill install claude # claude | opencode | openclaw | all +``` +``` + +### 5.6 READMEs — agent sentence + human one-liner (core requirement) + +The same Installation block goes in **`README.md`**, **`README.zh-cn.md`**, and +**`packages/cli-box-skill/README.md`** (zh-cn gets a Chinese version of the same +content). Exact English block: + +```markdown +## Installation + +> **For AI agents:** run `npx cli-box-skill install all` to install the cli-box +> skill into every supported harness (Claude Code, OpenCode, OpenClaw), or replace +> `all` with `claude` / `opencode` / `openclaw` for a single target. + +### Interactive (recommended for humans) +```bash +npx cli-box-skill install +``` +Pick Claude Code, OpenCode, and/or OpenClaw. + +### One-liner +```bash +npx cli-box-skill install claude # claude | opencode | openclaw | all +``` + +### No npm? +```bash +bash <(curl -fsSL https://raw.githubusercontent.com/Shadow-Azure/cli-box/main/packages/cli-box-skill/skill/install.sh) claude +``` +``` + +The `> For AI agents: ...` line is the self-contained, executable sentence. The TL;DR +table in the READMEs is updated so the **npm** row points to +`npx cli-box-skill install` and the **Shell** row shows the curl command with a +target arg. + +### 5.7 `docs/guide/installation.md` + +- Option A (npm): `npx cli-box-skill install` (interactive) or with explicit target. +- Option B (direct download): curl with a target arg. +- Keep the existing macOS-permissions and first-use steps. + +## 6. Harness targets & detection + +| Harness | Skill install path | "Detected" heuristic | +|:---|:---|:---| +| Claude Code | `~/.claude/skills/cli-box/SKILL.md` | `~/.claude` exists | +| OpenCode | `~/.config/opencode/skills/cli-box/SKILL.md` | `~/.config/opencode` exists | +| OpenClaw | `~/.openclaw/skills/cli-box/SKILL.md` | `~/.openclaw` exists | + +Detection is **directory-based only** (not PATH). A config dir existing is a +sound, deterministic per-home signal of "this harness is in use here", and it +keeps `detectHarnesses(home)` testable — independent of the ambient PATH (which +on a dev machine may have all three CLIs installed). Detection only affects +**pre-checking** in the interactive menu; it never blocks installing into a +non-detected harness (the user may pre-install the skill before the harness +config dir exists). + +## 7. Error handling + +- **Non-TTY + no target (`cli-box-skill install`):** exit 1, message: + `Non-interactive shell. Specify targets: cli-box-skill install `. +- **`--no-tui` + no target:** same error. +- **Interactive Ctrl-C / cancel:** clean exit 0 (no partial writes; `@clack` handles + cancel). +- **Per-target write failure:** warn that target, continue with the others, print a + summary at the end; exit non-zero only if zero targets succeeded. +- **Unknown target string:** error listing valid targets; exit 1. +- **postinstall missing platform package:** warn + exit 0 (do not fail the install), + unchanged from today. + +## 8. Testing + +### Unit tests (vitest, new file `bin/cli-box-skill.test.ts` or `.test.mjs`) + +- Target resolution: positional → list; `all` → 3; unknown → error. +- `--no-tui` + no target → throws / exits non-zero. +- Detection heuristic: given mocked HOME/PATH, returns the right detected set. +- Precedence (positional > interactive > error) using a TTY stub. + +### E2E (`tests/e2e-skill-install.sh`, rewritten) + +- **Test 1 (postinstall):** assert binaries symlinked into `~/.cli-box/bin`; assert + SKILL.md is **NOT** copied by postinstall anymore (behavior changed from 0.2.6). +- **Test 2 (`cli-box-skill install claude`):** in an isolated HOME, run the + installer with `claude` → assert `~/.claude/skills/cli-box/SKILL.md` exists and + OpenCode/OpenClaw dirs do **not**. +- **Test 3 (`install all`):** assert all three targets populated. +- **Test 4 (no-target, non-TTY):** set `CI=1` (non-TTY), run `cli-box-skill install` + → expect non-zero exit + the guidance message. +- **Test 5 (install.sh):** `bash install.sh claude` populates Claude target; no-arg + → hint + exit 1. + +### Manual / release test + +- `release_test.md` adds: `npx cli-box-skill install claude` (fresh HOME) → verify + skill placement; screenshot the interactive prompt. + +## 9. Version & rollout + +- Bump `0.2.6 → 0.2.7` in `Cargo.toml`, `electron-app/package.json`, `release.sh` + (the 3 manual files; npm package versions injected from tag by CI). +- Branch `feat/cli-box-skill-installer-0.2.7`, PR, CI Gate (including the rewritten + e2e test), squash-merge, tag `v0.2.7`, `gh release create` → CI publishes the 3 + npm packages at 0.2.7 with the new `cli-box-skill` bin. +- **Reverts the 0.2.6 "unconditional skill copy" behavior** — this is intentional + and documented in the release notes. + +## 10. Decisions log + +| Decision | Choice | Why | +|:---|:---|:---| +| Where selection lives | Dedicated `cli-box-skill install` command | Matches `oh-my-openagent`; CI-safe; npm has no clean postinstall-param mechanism | +| postinstall behavior | Minimal: binaries + guidance only | Avoid hangs; convention | +| `install.sh` no-arg | Print hint + exit 1 (target required) | User request; curl stdin fragility makes interactive menus unreliable | +| npm command no-arg, non-TTY | Error + exit 1 | Consistency; never hang | +| Interactive lib | `@clack/prompts` | Same as reference repo; polished TTY UI | +| Harness set | Claude Code + OpenCode + OpenClaw | User scope; OpenClaw uses `~/.openclaw/skills/` (AgentSkills spec) | diff --git a/electron-app/package.json b/electron-app/package.json index 8fe33b0..9446223 100644 --- a/electron-app/package.json +++ b/electron-app/package.json @@ -1,6 +1,6 @@ { "name": "cli-box-electron", - "version": "0.2.6", + "version": "0.2.7", "private": true, "main": "./out/main/index.js", "scripts": { diff --git a/packages/cli-box-skill/README.md b/packages/cli-box-skill/README.md index b04f032..d1e856b 100644 --- a/packages/cli-box-skill/README.md +++ b/packages/cli-box-skill/README.md @@ -5,7 +5,8 @@ macOS desktop automation sandbox for AI agents. ## Install ```bash -npm install -g cli-box-skill +npx cli-box-skill install # interactive: pick Claude Code / OpenCode / OpenClaw +npx cli-box-skill install claude # non-interactive: claude | opencode | openclaw | all ``` ## What is cli-box? @@ -25,7 +26,7 @@ cli-box close # Close sandbox ## No npm? ```bash -bash <(curl -fsSL https://raw.githubusercontent.com/Shadow-Azure/cli-box/main/packages/cli-box-skill/skill/install.sh) +bash <(curl -fsSL https://raw.githubusercontent.com/Shadow-Azure/cli-box/main/packages/cli-box-skill/skill/install.sh) claude ``` ## Links diff --git a/packages/cli-box-skill/installer/cli.mjs b/packages/cli-box-skill/installer/cli.mjs new file mode 100644 index 0000000..4daf45f --- /dev/null +++ b/packages/cli-box-skill/installer/cli.mjs @@ -0,0 +1,101 @@ +#!/usr/bin/env node +// cli-box-skill — installer for the cli-box skill into agent harnesses. +// Usage: +// npx cli-box-skill install # interactive (TTY) +// cli-box-skill install claude opencode # explicit +// cli-box-skill install all # all harnesses +// cli-box-skill install --no-tui claude # non-interactive explicit +import { Command } from "commander"; +import * as clack from "@clack/prompts"; +import { + HARNESS_IDS, + HARNESS_TARGETS, + parseTargets, + detectHarnesses, + installSkillToTargets, + ensureBinaries, +} from "./shared.mjs"; + +const isTTY = Boolean(process.stdin.isTTY); + +async function interactiveSelect() { + const detected = detectHarnesses(); + const options = HARNESS_IDS.map((id) => ({ + value: id, + label: `${HARNESS_TARGETS[id].label} ${detected.includes(id) ? "(detected)" : ""}`.trim(), + hint: id, + })); + const selected = await clack.multiselect({ + message: "Where should the cli-box skill be installed?", + options, + initialValues: detected, + required: false, + }); + if (clack.isCancel(selected)) { + console.log("Cancelled."); + process.exit(0); + } + return selected; +} + +function failNoTarget() { + console.error( + `Non-interactive shell. Specify targets:\n cli-box-skill install <${[ + ...HARNESS_IDS, + "all", + ].join("|")}>` + ); + process.exit(1); +} + +async function runInstall(targets, opts) { + const bin = ensureBinaries(); + if (bin.ok) { + for (const b of bin.linked) console.log(` ✓ ${b} → ~/.cli-box/bin/${b}`); + } else { + console.warn(` ⚠ ${bin.reason} (binaries may be missing)`); + } + + let ids; + if (targets.length > 0) { + ids = parseTargets(targets); + } else if (opts.tui && isTTY) { + ids = await interactiveSelect(); + if (ids.length === 0) { + console.log("No target selected — nothing to do."); + process.exit(0); + } + } else { + return failNoTarget(); + } + + const results = installSkillToTargets(ids); + for (const r of results) { + if (r.ok) console.log(` ✓ ${HARNESS_TARGETS[r.id].label} → ${r.dir}/SKILL.md`); + else console.error(` ✗ ${HARNESS_TARGETS[r.id].label}: ${r.error}`); + } + const okCount = results.filter((r) => r.ok).length; + console.log(`\nInstalled the cli-box skill into ${okCount}/${results.length} harness(es).`); + process.exit(okCount > 0 ? 0 : 1); +} + +const program = new Command(); +program + .name("cli-box-skill") + .description("Install the cli-box skill into agent harnesses (Claude Code / OpenCode / OpenClaw)"); + +program + .command("install", { isDefault: true }) + .description("Install the cli-box skill into one or more agent harnesses") + .argument("[targets...]", "claude | opencode | openclaw | all") + .option("--no-tui", "Non-interactive (targets required)") + .action(async (targets, opts) => { + try { + await runInstall(targets, opts); + } catch (e) { + console.error(`✗ ${e.message}`); + process.exit(1); + } + }); + +program.parseAsync(process.argv); diff --git a/packages/cli-box-skill/installer/shared.mjs b/packages/cli-box-skill/installer/shared.mjs new file mode 100644 index 0000000..bfadad7 --- /dev/null +++ b/packages/cli-box-skill/installer/shared.mjs @@ -0,0 +1,125 @@ +// Pure logic for the cli-box skill installer. Zero external dependencies +// (node: builtins only) so it can be unit-tested without `npm install`. +import fs from "node:fs"; +import os from "node:os"; +import path from "node:path"; +import { createRequire } from "node:module"; + +const require = createRequire(import.meta.url); + +function safeExists(p) { + try { + return fs.existsSync(p); + } catch { + return false; + } +} + +function hasBinary(name) { + const dirs = (process.env.PATH || "").split(path.delimiter); + const candidate = + process.platform === "win32" ? [name, `${name}.exe`, `${name}.cmd`] : [name]; + return dirs.some((d) => candidate.some((c) => safeExists(path.join(d, c)))); +} + +// Harness order matches HARNESS_IDS sort used in tests: +// ["claude", "openclaw", "opencode"]. +export const HARNESS_TARGETS = { + claude: { + label: "Claude Code", + skillDir: (home) => path.join(home, ".claude", "skills", "cli-box"), + detect: (home) => safeExists(path.join(home, ".claude")), + }, + openclaw: { + label: "OpenClaw", + skillDir: (home) => path.join(home, ".openclaw", "skills", "cli-box"), + detect: (home) => safeExists(path.join(home, ".openclaw")), + }, + opencode: { + label: "OpenCode", + skillDir: (home) => + path.join(home, ".config", "opencode", "skills", "cli-box"), + detect: (home) => safeExists(path.join(home, ".config", "opencode")), + }, +}; + +export const HARNESS_IDS = Object.keys(HARNESS_TARGETS); + +// Accepts an array of tokens or a comma/space-separated string. +// Returns the resolved list of harness ids. Throws on unknown tokens. +export function parseTargets(input) { + const arr = Array.isArray(input) ? input : String(input ?? "").split(/[\s,]+/); + const tokens = arr.map((t) => String(t).trim()).filter(Boolean); + if (tokens.length === 0) return []; + if (tokens.includes("all")) return [...HARNESS_IDS]; + const invalid = tokens.filter((t) => !HARNESS_IDS.includes(t)); + if (invalid.length > 0) { + throw new Error( + `Unknown target(s): ${invalid.join(", ")}. Valid: ${[...HARNESS_IDS, "all"].join(", ")}` + ); + } + return Array.from(new Set(tokens)); +} + +// Returns the list of harness ids whose config directory exists under `home`. +// Detection is directory-based only, so it is deterministic per home and +// independent of the ambient PATH (important for testability and for +// answering "which harnesses are initialized in THIS home"). +export function detectHarnesses(home = os.homedir()) { + return HARNESS_IDS.filter((id) => HARNESS_TARGETS[id].detect(home)); +} + +export function readBundledSkill() { + return fs.readFileSync(new URL("../skill/SKILL.md", import.meta.url), "utf8"); +} + +// Writes the skill body into each target dir. Returns [{id, dir, ok, error?}]. +export function installSkillToTargets(ids, { home = os.homedir(), content } = {}) { + const body = content ?? readBundledSkill(); + return ids.map((id) => { + const dir = HARNESS_TARGETS[id].skillDir(home); + try { + fs.mkdirSync(dir, { recursive: true }); + fs.writeFileSync(path.join(dir, "SKILL.md"), body); + return { id, dir, ok: true }; + } catch (e) { + return { id, dir, ok: false, error: e.message }; + } + }); +} + +// Symlinks the platform-package binaries into ~/.cli-box/bin. +// Never throws: returns { ok:false, reason } if the platform package is absent. +export function ensureBinaries({ home = os.homedir() } = {}) { + const binDir = path.join(home, ".cli-box", "bin"); + fs.mkdirSync(binDir, { recursive: true }); + + const platform = + process.platform === "darwin" ? "darwin" : process.platform === "win32" ? "win32" : "linux"; + const arch = process.arch === "arm64" ? "arm64" : "x64"; + const pkgName = `cli-box-${platform}-${arch}`; + + let pkgDir; + try { + pkgDir = path.dirname(require.resolve(`${pkgName}/package.json`)); + } catch { + return { ok: false, reason: `platform package ${pkgName} not found`, binDir }; + } + + const linked = []; + for (const bin of ["cli-box", "cli-box-daemon"]) { + const src = path.join(pkgDir, "bin", bin); + const dst = path.join(binDir, bin); + if (safeExists(src)) { + try { + fs.rmSync(dst, { force: true }); + fs.symlinkSync(src, dst); + fs.chmodSync(src, 0o755); + linked.push(bin); + } catch { + /* ignore individual link failures */ + } + } + } + return { ok: true, linked, binDir }; +} diff --git a/packages/cli-box-skill/package-lock.json b/packages/cli-box-skill/package-lock.json new file mode 100644 index 0000000..34af554 --- /dev/null +++ b/packages/cli-box-skill/package-lock.json @@ -0,0 +1,117 @@ +{ + "name": "cli-box-skill", + "version": "0.2.1", + "lockfileVersion": 3, + "requires": true, + "packages": { + "": { + "name": "cli-box-skill", + "version": "0.2.1", + "hasInstallScript": true, + "license": "Apache-2.0", + "dependencies": { + "@clack/prompts": "^1.5.1", + "commander": "^15.0.0" + }, + "bin": { + "cli-box": "bin/cli-box-wrapper.js", + "cli-box-skill": "installer/cli.mjs" + }, + "optionalDependencies": { + "cli-box-darwin-arm64": "0.2.1", + "cli-box-electron-darwin-arm64": "0.2.1" + } + }, + "node_modules/@clack/core": { + "version": "1.4.1", + "resolved": "https://registry.npmjs.org/@clack/core/-/core-1.4.1.tgz", + "integrity": "sha512-FILJa1gGKEFTGZAJE9RpVhrjKz3c3h4ar60dSv6cGuDqufQ84YEIS3GAGvZiN+H6yaLbbvTFNejjCC4tXpZEuw==", + "license": "MIT", + "dependencies": { + "fast-wrap-ansi": "^0.2.0", + "sisteransi": "^1.0.5" + }, + "engines": { + "node": ">= 20.12.0" + } + }, + "node_modules/@clack/prompts": { + "version": "1.5.1", + "resolved": "https://registry.npmjs.org/@clack/prompts/-/prompts-1.5.1.tgz", + "integrity": "sha512-zccHj2z2oCCO4yrDiRSlFOxWerGqRiysP7a5jPK6uoI9URKAquwY42Dd/iUP8JWHxEzdRe4TlbvZCo8z1/mhrw==", + "license": "MIT", + "dependencies": { + "@clack/core": "1.4.1", + "fast-string-width": "^3.0.2", + "fast-wrap-ansi": "^0.2.0", + "sisteransi": "^1.0.5" + }, + "engines": { + "node": ">= 20.12.0" + } + }, + "node_modules/cli-box-darwin-arm64": { + "version": "0.2.1", + "resolved": "https://registry.npmjs.org/cli-box-darwin-arm64/-/cli-box-darwin-arm64-0.2.1.tgz", + "integrity": "sha512-Lmsmyu7oicWw9hoNK/Ylxw8c++uWH+0thunWTsS39mhADjNz4U4iUkvMg+wT9gDzctDjC7OCsxu/w7u9THjoKQ==", + "cpu": [ + "arm64" + ], + "optional": true, + "os": [ + "darwin" + ] + }, + "node_modules/cli-box-electron-darwin-arm64": { + "version": "0.2.1", + "resolved": "https://registry.npmjs.org/cli-box-electron-darwin-arm64/-/cli-box-electron-darwin-arm64-0.2.1.tgz", + "integrity": "sha512-SAva9caluRQwXWa49KqcfqQcnAWofYx9xHHvWu/FPWTD4iqRPIJP/BAbuQv9h903KEDi3ccc3SK6CwB71ItPgg==", + "cpu": [ + "arm64" + ], + "optional": true, + "os": [ + "darwin" + ] + }, + "node_modules/commander": { + "version": "15.0.0", + "resolved": "https://registry.npmjs.org/commander/-/commander-15.0.0.tgz", + "integrity": "sha512-z67u4ZhzCL/Tydu1lJARtEZYWbWaN7oYLHbsuzocr6y4N6WZAagG3RQ4FW61V1/0+jImpj293XfrcYnd1qxtPg==", + "license": "MIT", + "engines": { + "node": ">=22.12.0" + } + }, + "node_modules/fast-string-truncated-width": { + "version": "3.0.3", + "resolved": "https://registry.npmjs.org/fast-string-truncated-width/-/fast-string-truncated-width-3.0.3.tgz", + "integrity": "sha512-0jjjIEL6+0jag3l2XWWizO64/aZVtpiGE3t0Zgqxv0DPuxiMjvB3M24fCyhZUO4KomJQPj3LTSUnDP3GpdwC0g==", + "license": "MIT" + }, + "node_modules/fast-string-width": { + "version": "3.0.2", + "resolved": "https://registry.npmjs.org/fast-string-width/-/fast-string-width-3.0.2.tgz", + "integrity": "sha512-gX8LrtNEI5hq8DVUfRQMbr5lpaS4nMIWV+7XEbXk2b8kiQIizgnlr12B4dA3ZEx3308ze0O4Q1R+cHts8kyUJg==", + "license": "MIT", + "dependencies": { + "fast-string-truncated-width": "^3.0.2" + } + }, + "node_modules/fast-wrap-ansi": { + "version": "0.2.2", + "resolved": "https://registry.npmjs.org/fast-wrap-ansi/-/fast-wrap-ansi-0.2.2.tgz", + "integrity": "sha512-7F2Fl+TjRSenLqlU3UjSH0iyqopqoZIu7eZVpEirP2g1GtWa2G/ecEmBdgz31+Mxr+ELclgg6sokpSFIQiZ02Q==", + "license": "MIT", + "dependencies": { + "fast-string-width": "^3.0.2" + } + }, + "node_modules/sisteransi": { + "version": "1.0.5", + "resolved": "https://registry.npmjs.org/sisteransi/-/sisteransi-1.0.5.tgz", + "integrity": "sha512-bLGGlR1QxBcynn2d5YmDX4MGjlZvy2MRBDRNHLJ8VI6l6+9FUiyTFNJ0IveOSP0bcXgVDPRcfGqA0pjaqUpfVg==", + "license": "MIT" + } + } +} diff --git a/packages/cli-box-skill/package.json b/packages/cli-box-skill/package.json index d1e7982..05633b9 100644 --- a/packages/cli-box-skill/package.json +++ b/packages/cli-box-skill/package.json @@ -4,10 +4,12 @@ "description": "macOS desktop automation sandbox for AI agents", "main": "postinstall.mjs", "bin": { - "cli-box": "./bin/cli-box-wrapper.js" + "cli-box": "./bin/cli-box-wrapper.js", + "cli-box-skill": "./installer/cli.mjs" }, "scripts": { - "postinstall": "node postinstall.mjs" + "postinstall": "node postinstall.mjs", + "test": "node --test" }, "optionalDependencies": { "cli-box-darwin-arm64": "0.2.1", @@ -18,6 +20,7 @@ "skill/install.sh", "postinstall.mjs", "bin/", + "installer/", "README.md" ], "keywords": [ @@ -36,5 +39,9 @@ "url": "git+https://github.com/Shadow-Azure/cli-box.git" }, "author": "Shadow-Azure", - "license": "Apache-2.0" + "license": "Apache-2.0", + "dependencies": { + "@clack/prompts": "^1.5.1", + "commander": "^15.0.0" + } } diff --git a/packages/cli-box-skill/postinstall.mjs b/packages/cli-box-skill/postinstall.mjs index bda9f7f..59046e0 100644 --- a/packages/cli-box-skill/postinstall.mjs +++ b/packages/cli-box-skill/postinstall.mjs @@ -1,97 +1,23 @@ #!/usr/bin/env node - -/** - * cli-box postinstall script - * - * Runs after `npm install -g cli-box-skill`. - * 1. Finds the installed platform package (cli-box-darwin-arm64) - * 2. Creates symlinks in ~/.cli-box/bin/ - * 3. Installs SKILL.md to .claude/skills/cli-box/ and .opencode/skills/cli-box/ - */ - -import { createRequire } from 'module'; -import fs from 'fs'; -import path from 'path'; -import os from 'os'; - -const home = os.homedir(); -const binDir = path.join(home, '.cli-box', 'bin'); - -function info(msg) { console.log(` ➜ ${msg}`); } -function ok(msg) { console.log(` ✓ ${msg}`); } -function warn(msg) { console.warn(` ⚠ ${msg}`); } - -// 1. Detect platform -const platform = process.platform === 'darwin' ? 'darwin' : - process.platform === 'win32' ? 'win32' : 'linux'; -const arch = process.arch === 'arm64' ? 'arm64' : 'x64'; -const platformPkgName = `cli-box-${platform}-${arch}`; - -info(`Platform: ${platform}-${arch}`); - -// 2. Find platform package -let platformPkgDir; -try { - const require = createRequire(import.meta.url); - const pkgJsonPath = require.resolve(`${platformPkgName}/package.json`); - platformPkgDir = path.dirname(pkgJsonPath); - ok(`Found platform package: ${platformPkgName}`); -} catch (e) { - warn(`Platform package ${platformPkgName} not found. Skipping binary setup.`); - warn('You can install binaries manually via: bash <(curl -fsSL https://raw.githubusercontent.com/Shadow-Azure/cli-box/main/packages/cli-box-skill/skill/install.sh)'); - process.exit(0); -} - -// 3. Create symlinks -try { - fs.mkdirSync(binDir, { recursive: true }); - - const bins = ['cli-box', 'cli-box-daemon']; - for (const bin of bins) { - const src = path.join(platformPkgDir, 'bin', bin); - const dst = path.join(binDir, bin); - - if (fs.existsSync(src)) { - fs.rmSync(dst, { force: true }); - fs.symlinkSync(src, dst); - fs.chmodSync(src, 0o755); - ok(`${bin} → ${src}`); - } else { - warn(`${bin} not found in platform package`); - } - } -} catch (e) { - warn(`Failed to create symlinks: ${e.message}`); -} - -// 4. Install SKILL.md -const skillSrc = path.join(path.dirname(new URL(import.meta.url).pathname), 'skill', 'SKILL.md'); - -const targets = [ - path.join(home, '.claude', 'skills', 'cli-box'), - path.join(home, '.config', 'opencode', 'skills', 'cli-box'), -]; - -for (const target of targets) { - try { - fs.mkdirSync(target, { recursive: true }); - fs.copyFileSync(skillSrc, path.join(target, 'SKILL.md')); - ok(`SKILL.md → ${target}/`); - } catch (e) { - // Non-fatal — target location may be read-only or unavailable - warn(`Could not install SKILL.md to ${target}: ${e.message}`); - } +// postinstall.mjs — runs after `npm install -g cli-box-skill`. +// Non-interactive: only symlinks the platform binaries and prints guidance. +// Skill placement into a harness is an explicit user choice: +// npx cli-box-skill install +import { ensureBinaries } from "./installer/shared.mjs"; + +function ok(m) { console.log(` ✓ ${m}`); } +function warn(m) { console.warn(` ⚠ ${m}`); } + +const bin = ensureBinaries(); +if (bin.ok) { + for (const b of bin.linked) ok(`${b} → ~/.cli-box/bin/${b}`); +} else { + warn(bin.reason); + warn("Binaries not set up. Re-run: npx cli-box-skill install"); } -// 5. Done -console.log(''); -console.log(' cli-box installed successfully!'); -console.log(''); -console.log(' Add to PATH:'); -console.log(` export PATH="$HOME/.cli-box/bin:$PATH"`); -console.log(''); -console.log(' Quick start:'); -console.log(' cli-box start claude'); -console.log(' cli-box start zsh'); -console.log(' cli-box list'); -console.log(''); +console.log(""); +console.log(" cli-box binaries are ready. To install the skill into your agent harness, run:"); +console.log(" npx cli-box-skill install"); +console.log(" (targets: claude | opencode | openclaw | all)"); +console.log(""); diff --git a/packages/cli-box-skill/skill/SKILL.md b/packages/cli-box-skill/skill/SKILL.md index c3427c8..4ac7e44 100644 --- a/packages/cli-box-skill/skill/SKILL.md +++ b/packages/cli-box-skill/skill/SKILL.md @@ -18,13 +18,13 @@ Both permissions must be granted manually. Add `cli-box` and `CLI Box.app` to bo ## Installation ```bash -npm install -g cli-box-skill +npx cli-box-skill install ``` -Or via GitHub Release: +Choose Claude Code, OpenCode, and/or OpenClaw. Or non-interactively: ```bash -bash <(curl -fsSL https://raw.githubusercontent.com/Shadow-Azure/cli-box/main/packages/cli-box-skill/skill/install.sh) +npx cli-box-skill install claude # claude | opencode | openclaw | all ``` ## Quick Start diff --git a/packages/cli-box-skill/skill/install.sh b/packages/cli-box-skill/skill/install.sh index d7b363e..94c348d 100755 --- a/packages/cli-box-skill/skill/install.sh +++ b/packages/cli-box-skill/skill/install.sh @@ -9,6 +9,7 @@ VERSION="${CLI_BOX_VERSION:-latest}" INSTALL_DIR="$HOME/.cli-box/bin" SKILL_CLAUDE_DIR="$HOME/.claude/skills/cli-box" SKILL_OPENCODE_DIR="$HOME/.config/opencode/skills/cli-box" +SKILL_OPENCLAW_DIR="$HOME/.openclaw/skills/cli-box" info() { echo " ➜ $*"; } ok() { echo " ✓ $*"; } @@ -62,17 +63,48 @@ cp "$TMPDIR/bin/cli-box-daemon" "$INSTALL_DIR/" chmod +x "$INSTALL_DIR/cli-box" "$INSTALL_DIR/cli-box-daemon" ok "Binaries installed" -# Install skill to Claude Code -info "Installing skill to Claude Code..." -mkdir -p "$SKILL_CLAUDE_DIR" -cp "$TMPDIR/SKILL.md" "$SKILL_CLAUDE_DIR/" -ok "Skill installed to $SKILL_CLAUDE_DIR" - -# Install skill to OpenCode -info "Installing skill to OpenCode..." -mkdir -p "$SKILL_OPENCODE_DIR" -cp "$TMPDIR/SKILL.md" "$SKILL_OPENCODE_DIR/" -ok "Skill installed to $SKILL_OPENCODE_DIR" +# --- Resolve install targets --- +# Precedence: positional args > CLI_BOX_TARGETS env. Accept space- or comma- +# separated values; "all" expands to every harness. +if [ "$#" -gt 0 ]; then + TARGETS_RAW="$*" +elif [ -n "${CLI_BOX_TARGETS:-}" ]; then + TARGETS_RAW="$CLI_BOX_TARGETS" +else + TARGETS_RAW="" +fi + +# Normalize to lowercase, comma/space -> newline +TARGETS=$(echo "$TARGETS_RAW" | tr '[:upper:]' '[:lower:]' | tr ',[:space:]' '\n' | grep -v '^$' || true) + +if [ -z "$TARGETS" ]; then + echo "" + echo " ✗ No install target given." >&2 + echo " Usage: bash install.sh [more...]" >&2 + echo " or: CLI_BOX_TARGETS=claude,opencode bash install.sh" >&2 + exit 1 +fi + +install_skill_dir() { + local label="$1" dir="$2" + info "Installing skill to ${label}..." + mkdir -p "$dir" + cp "$TMPDIR/SKILL.md" "$dir/" + ok "Skill installed to $dir" +} + +if echo "$TARGETS" | grep -qx 'all'; then + TARGETS="$(printf 'claude\nopencode\nopenclaw')" +fi + +while IFS= read -r target; do + case "$target" in + claude) install_skill_dir "Claude Code" "$SKILL_CLAUDE_DIR" ;; + opencode) install_skill_dir "OpenCode" "$SKILL_OPENCODE_DIR" ;; + openclaw) install_skill_dir "OpenClaw" "$SKILL_OPENCLAW_DIR" ;; + *) err "Unknown target: $target (valid: claude | opencode | openclaw | all)"; exit 1 ;; + esac +done <<< "$TARGETS" # Check PATH if [[ ":$PATH:" != *":$INSTALL_DIR:"* ]]; then diff --git a/packages/cli-box-skill/test/cli.test.mjs b/packages/cli-box-skill/test/cli.test.mjs new file mode 100644 index 0000000..bd13703 --- /dev/null +++ b/packages/cli-box-skill/test/cli.test.mjs @@ -0,0 +1,104 @@ +// Integration tests that spawn the real CLI. Requires commander + @clack/prompts +// installed (Task 2). Spawning => stdin is not a TTY, so the interactive branch +// is not exercised here (it is covered by manual release testing). +import { test } from "node:test"; +import assert from "node:assert/strict"; +import fs from "node:fs"; +import os from "node:os"; +import path from "node:path"; +import { spawnSync } from "node:child_process"; +import { fileURLToPath } from "node:url"; + +const here = path.dirname(fileURLToPath(import.meta.url)); +const cli = path.resolve(here, "../installer/cli.mjs"); + +function run(args, home) { + return spawnSync(process.execPath, [cli, ...args], { + env: { ...process.env, HOME: home }, + encoding: "utf8", + }); +} + +function tmpHome() { + return fs.mkdtempSync(path.join(os.tmpdir(), "cb-cli-")); +} + +test("install claude writes SKILL.md only to Claude dir", () => { + const home = tmpHome(); + try { + const r = run(["install", "claude"], home); + assert.equal(r.status, 0, r.stdout + r.stderr); + assert.ok( + fs.existsSync(path.join(home, ".claude", "skills", "cli-box", "SKILL.md")) + ); + assert.ok( + !fs.existsSync(path.join(home, ".config", "opencode", "skills", "cli-box")) + ); + assert.ok( + !fs.existsSync(path.join(home, ".openclaw", "skills", "cli-box")) + ); + } finally { + fs.rmSync(home, { recursive: true, force: true }); + } +}); + +test("install all writes SKILL.md to all three harnesses", () => { + const home = tmpHome(); + try { + const r = run(["install", "all"], home); + assert.equal(r.status, 0, r.stdout + r.stderr); + for (const sub of [".claude", ".config/opencode", ".openclaw"]) { + assert.ok( + fs.existsSync(path.join(home, sub, "skills", "cli-box", "SKILL.md")), + `missing ${sub}` + ); + } + } finally { + fs.rmSync(home, { recursive: true, force: true }); + } +}); + +test("install with no target in non-TTY exits 1 with guidance", () => { + const home = tmpHome(); + try { + const r = run(["install"], home); + assert.equal(r.status, 1); + assert.match(r.stderr + r.stdout, /Specify targets/); + } finally { + fs.rmSync(home, { recursive: true, force: true }); + } +}); + +test("install with unknown target exits non-zero", () => { + const home = tmpHome(); + try { + const r = run(["install", "bogus"], home); + assert.notEqual(r.status, 0); + } finally { + fs.rmSync(home, { recursive: true, force: true }); + } +}); + +test("install --no-tui with no target exits 1", () => { + const home = tmpHome(); + try { + const r = run(["install", "--no-tui"], home); + assert.equal(r.status, 1); + assert.match(r.stderr + r.stdout, /Specify targets/); + } finally { + fs.rmSync(home, { recursive: true, force: true }); + } +}); + +test("install --no-tui claude succeeds", () => { + const home = tmpHome(); + try { + const r = run(["install", "--no-tui", "claude"], home); + assert.equal(r.status, 0, r.stdout + r.stderr); + assert.ok( + fs.existsSync(path.join(home, ".claude", "skills", "cli-box", "SKILL.md")) + ); + } finally { + fs.rmSync(home, { recursive: true, force: true }); + } +}); diff --git a/packages/cli-box-skill/test/shared.test.mjs b/packages/cli-box-skill/test/shared.test.mjs new file mode 100644 index 0000000..2f5c680 --- /dev/null +++ b/packages/cli-box-skill/test/shared.test.mjs @@ -0,0 +1,82 @@ +import { test } from "node:test"; +import assert from "node:assert/strict"; +import fs from "node:fs"; +import os from "node:os"; +import path from "node:path"; +import { + HARNESS_IDS, + HARNESS_TARGETS, + parseTargets, + detectHarnesses, + installSkillToTargets, +} from "../installer/shared.mjs"; + +test("HARNESS_IDS lists the three harnesses", () => { + assert.deepEqual([...HARNESS_IDS].sort(), ["claude", "openclaw", "opencode"]); +}); + +test("HARNESS_TARGETS has label + skillDir for each", () => { + for (const id of HARNESS_IDS) { + assert.ok(HARNESS_TARGETS[id].label, `${id} has label`); + const dir = HARNESS_TARGETS[id].skillDir("/h"); + assert.ok(dir.startsWith("/h"), `${id} skillDir under home`); + } +}); + +test("parseTargets: explicit ids", () => { + assert.deepEqual(parseTargets(["claude", "opencode"]), ["claude", "opencode"]); +}); + +test("parseTargets: 'all' expands to every harness", () => { + assert.deepEqual(parseTargets(["all"]).sort(), [...HARNESS_IDS].sort()); +}); + +test("parseTargets: comma/space string normalizes", () => { + assert.deepEqual(parseTargets("claude, opencode"), ["claude", "opencode"]); + assert.deepEqual(parseTargets("openclaw"), ["openclaw"]); +}); + +test("parseTargets: empty input returns []", () => { + assert.deepEqual(parseTargets([]), []); + assert.deepEqual(parseTargets(""), []); +}); + +test("parseTargets: unknown id throws listing valid targets", () => { + assert.throws(() => parseTargets(["foo"]), /Unknown target.*claude.*openclaw.*opencode.*all/s); +}); + +test("detectHarnesses: only dirs that exist", () => { + const home = fs.mkdtempSync(path.join(os.tmpdir(), "cb-detect-")); + try { + fs.mkdirSync(path.join(home, ".claude"), { recursive: true }); + const found = detectHarnesses(home); + assert.ok(found.includes("claude")); + assert.ok(!found.includes("opencode")); + assert.ok(!found.includes("openclaw")); + } finally { + fs.rmSync(home, { recursive: true, force: true }); + } +}); + +test("installSkillToTargets: writes SKILL.md into each target dir", () => { + const home = fs.mkdtempSync(path.join(os.tmpdir(), "cb-install-")); + try { + const results = installSkillToTargets(["claude", "openclaw"], { + home, + content: "---\nname: cli-box\ndescription: x\n---\nbody", + }); + assert.equal(results.length, 2); + assert.ok(results.every((r) => r.ok)); + assert.ok( + fs.existsSync(path.join(home, ".claude", "skills", "cli-box", "SKILL.md")) + ); + assert.ok( + fs.existsSync(path.join(home, ".openclaw", "skills", "cli-box", "SKILL.md")) + ); + assert.ok( + !fs.existsSync(path.join(home, ".config", "opencode", "skills", "cli-box")) + ); + } finally { + fs.rmSync(home, { recursive: true, force: true }); + } +}); diff --git a/release.sh b/release.sh index 52207ca..a67a943 100755 --- a/release.sh +++ b/release.sh @@ -17,7 +17,7 @@ SCRIPT_DIR="$(cd "$(dirname "$0")" && pwd)" cd "$SCRIPT_DIR" RELEASE_DIR="$SCRIPT_DIR/release" -VERSION="0.2.6" +VERSION="0.2.7" APP_NAME="CLI Box" # --- helpers --- diff --git a/release/release-pipeline.md b/release/release-pipeline.md index 5d7c0b9..686b95b 100644 --- a/release/release-pipeline.md +++ b/release/release-pipeline.md @@ -4,7 +4,7 @@ > When making changes to the release process, update this document first, then sync the implementation. > The authoritative implementation is `.github/workflows/release.yml`; this doc must stay in sync with it. -**Version:** 0.2.6 | **Last updated:** 2026-06-16 +**Version:** 0.2.7 | **Last updated:** 2026-06-16 --- @@ -67,9 +67,12 @@ and carry the native binaries/app so `npm install -g cli-box-skill` just works. **Install command:** `npm install -g cli-box-skill` -On install, `postinstall.mjs` resolves the platform package, symlinks the -binaries into `~/.cli-box/bin/`, and copies `SKILL.md` into the Claude Code / -OpenCode skill directories. +On install, `postinstall.mjs` only symlinks binaries and prints guidance — it does +**not** copy the skill. Users run `npx cli-box-skill install` (interactive) or +`cli-box-skill install ` (explicit) to place the +skill. `install.sh` (curl) takes the same target args. Skill targets: Claude Code +`~/.claude/skills/cli-box/`, OpenCode `~/.config/opencode/skills/cli-box/`, +OpenClaw `~/.openclaw/skills/cli-box/`. ### 3. Direct curl (for AI agents / no-npm machines) diff --git a/test.sh b/test.sh index beed860..520421a 100755 --- a/test.sh +++ b/test.sh @@ -99,6 +99,15 @@ else FAILED=1 fi +# ==================== Skill Package Tests (node:test) ==================== +info "Running skill package tests (shared + CLI)..." +if (cd packages/cli-box-skill && npm install --no-save --omit=optional --ignore-scripts >/dev/null 2>&1 && node --test) 2>&1; then + ok "Skill package tests passed" +else + err "Skill package tests FAILED" + FAILED=1 +fi + # ==================== E2E Skill Installation Tests ==================== info "Running E2E skill installation tests..." if bash tests/e2e-skill-install.sh 2>&1; then diff --git a/tests/e2e-skill-install.sh b/tests/e2e-skill-install.sh index caa9717..8998760 100755 --- a/tests/e2e-skill-install.sh +++ b/tests/e2e-skill-install.sh @@ -1,429 +1,158 @@ #!/usr/bin/env bash set -euo pipefail -# ============================================================ # E2E Skill Installation Test -# ============================================================ -# Verifies that cli-box skill installation works correctly -# in an isolated tmp directory. Tests both npm postinstall -# and install.sh (GitHub Release) paths. -# -# Usage: bash tests/e2e-skill-install.sh -# ============================================================ +# Verifies (1) postinstall symlinks binaries but does NOT copy the skill, +# and (2) install.sh installs the skill into the specified target only. +# (The cli-box-skill CLI is covered by node:test in packages/cli-box-skill/test/.) SCRIPT_DIR="$(cd "$(dirname "$0")" && pwd)" REPO_ROOT="$(cd "$SCRIPT_DIR/.." && pwd)" -RED='\033[0;31m' -GREEN='\033[0;32m' -YELLOW='\033[0;33m' -NC='\033[0m' - +RED='\033[0;31m'; GREEN='\033[0;32m'; YELLOW='\033[0;33m'; NC='\033[0m' info() { echo -e "${GREEN}➜${NC} $*"; } warn() { echo -e "${YELLOW}⚠${NC} $*"; } err() { echo -e "${RED}✗${NC} $*"; } ok() { echo -e "${GREEN}✓${NC} $*"; } - FAILED=0 -# ==================== Skip on Linux CI (macOS frameworks required) ==================== if [ "$(uname)" = "Linux" ] && [ -n "${CI:-}" ]; then warn "Skipping E2E skill installation tests on Linux CI (macOS frameworks required)" exit 0 fi -# ==================== Setup: ensure platform package has binaries ==================== ensure_platform_binaries() { local PKG_BIN="$REPO_ROOT/packages/cli-box-darwin-arm64/bin" - if [ -f "$PKG_BIN/cli-box" ] && [ -f "$PKG_BIN/cli-box-daemon" ]; then - return - fi - + if [ -f "$PKG_BIN/cli-box" ] && [ -f "$PKG_BIN/cli-box-daemon" ]; then return; fi info "Populating platform package bin/ with built binaries..." mkdir -p "$PKG_BIN" - - # Build binaries if not found if [ ! -f "$REPO_ROOT/target/release/cli-box" ] && [ ! -f "$REPO_ROOT/target/debug/cli-box" ]; then - info " No built binaries found. Building with cargo..." - if ! cargo build -p cli-box-cli -p cli-box-daemon 2>&1; then - err " cargo build failed" - exit 1 - fi + info " Building with cargo..."; cargo build -p cli-box-cli -p cli-box-daemon >/dev/null 2>&1 || { err "cargo build failed"; exit 1; } fi - if [ -f "$REPO_ROOT/target/release/cli-box" ]; then ln -sf "$REPO_ROOT/target/release/cli-box" "$PKG_BIN/cli-box" ln -sf "$REPO_ROOT/target/release/cli-box-daemon" "$PKG_BIN/cli-box-daemon" - elif [ -f "$REPO_ROOT/target/debug/cli-box" ]; then + else ln -sf "$REPO_ROOT/target/debug/cli-box" "$PKG_BIN/cli-box" ln -sf "$REPO_ROOT/target/debug/cli-box-daemon" "$PKG_BIN/cli-box-daemon" - else - err "No built binaries found even after cargo build." - exit 1 fi - ok "Platform package binaries linked" } -# ==================== Test 1: npm postinstall.mjs ==================== test_postinstall() { - info "Test 1: npm postinstall.mjs" - - local TMP_HOME - TMP_HOME=$(mktemp -d) - - # postinstall.mjs uses createRequire(import.meta.url) which resolves from - # its own directory. We need the platform package in its node_modules/. + info "Test 1: postinstall (binaries only, no skill copy)" + local TMP_HOME; TMP_HOME=$(mktemp -d) local SKILL_PKG_NM="$REPO_ROOT/packages/cli-box-skill/node_modules" local CREATED_NM=0 cleanup_postinstall() { rm -rf "$TMP_HOME" - if [ "$CREATED_NM" -eq 1 ]; then - rm -rf "$SKILL_PKG_NM" - fi + if [ "$CREATED_NM" -eq 1 ]; then rm -f "$SKILL_PKG_NM/cli-box-darwin-arm64"; fi } trap cleanup_postinstall RETURN - - # Create node_modules with platform package symlink next to postinstall.mjs if [ ! -d "$SKILL_PKG_NM/cli-box-darwin-arm64" ]; then mkdir -p "$SKILL_PKG_NM" - ln -s "$REPO_ROOT/packages/cli-box-darwin-arm64" \ - "$SKILL_PKG_NM/cli-box-darwin-arm64" + ln -s "$REPO_ROOT/packages/cli-box-darwin-arm64" "$SKILL_PKG_NM/cli-box-darwin-arm64" CREATED_NM=1 fi - - # Create .claude/skills/ dir (simulating Claude Code installed) - mkdir -p "$TMP_HOME/.claude/skills" - # Create .config/opencode/skills/ dir (simulating OpenCode installed) - mkdir -p "$TMP_HOME/.config/opencode/skills" - - # Run postinstall.mjs with HOME override - info " Running postinstall.mjs..." if ! HOME="$TMP_HOME" node "$REPO_ROOT/packages/cli-box-skill/postinstall.mjs" 2>&1; then - err " postinstall.mjs exited with non-zero status" - FAILED=1 - return - fi - - # Verify symlinks - if [ -L "$TMP_HOME/.cli-box/bin/cli-box" ]; then - ok " cli-box symlink created" - else - err " cli-box symlink NOT created" - FAILED=1 - fi - - if [ -L "$TMP_HOME/.cli-box/bin/cli-box-daemon" ]; then - ok " cli-box-daemon symlink created" - else - err " cli-box-daemon symlink NOT created" - FAILED=1 - fi - - # Verify symlink targets are executable - if [ -x "$TMP_HOME/.cli-box/bin/cli-box" ]; then - ok " cli-box symlink target is executable" - else - err " cli-box symlink target is NOT executable" - FAILED=1 - fi - - # Verify SKILL.md installed to Claude directory - if [ -f "$TMP_HOME/.claude/skills/cli-box/SKILL.md" ]; then - ok " SKILL.md installed to .claude/skills/cli-box/" - else - err " SKILL.md NOT found in .claude/skills/cli-box/" - FAILED=1 + err " postinstall.mjs exited non-zero"; FAILED=1; return fi - - # Verify SKILL.md installed to OpenCode directory - if [ -f "$TMP_HOME/.config/opencode/skills/cli-box/SKILL.md" ]; then - ok " SKILL.md installed to .config/opencode/skills/cli-box/" + [ -L "$TMP_HOME/.cli-box/bin/cli-box" ] && ok " cli-box symlink created" || { err " cli-box symlink NOT created"; FAILED=1; } + [ -L "$TMP_HOME/.cli-box/bin/cli-box-daemon" ] && ok " cli-box-daemon symlink created" || { err " cli-box-daemon symlink NOT created"; FAILED=1; } + if [ -e "$TMP_HOME/.claude/skills/cli-box/SKILL.md" ]; then + err " postinstall copied SKILL.md to .claude (should not)"; FAILED=1 else - err " SKILL.md NOT found in .config/opencode/skills/cli-box/" - FAILED=1 + ok " postinstall did not copy SKILL.md (correct)" fi - - # Verify SKILL.md frontmatter - if head -1 "$TMP_HOME/.claude/skills/cli-box/SKILL.md" | grep -q "^---$"; then - ok " SKILL.md has valid frontmatter delimiter" - else - err " SKILL.md missing frontmatter delimiter" - FAILED=1 - fi - - if head -5 "$TMP_HOME/.claude/skills/cli-box/SKILL.md" | grep -q "^name: cli-box"; then - ok " SKILL.md frontmatter contains 'name: cli-box'" - else - err " SKILL.md frontmatter missing 'name: cli-box'" - FAILED=1 - fi - - if head -5 "$TMP_HOME/.claude/skills/cli-box/SKILL.md" | grep -q "^description:"; then - ok " SKILL.md frontmatter contains 'description'" - else - err " SKILL.md frontmatter missing 'description'" - FAILED=1 - fi - info " Test 1 complete" } -# ==================== Test 2: install.sh (GitHub Release path) ==================== -test_install_sh() { - info "Test 2: install.sh (GitHub Release path)" - - local TMP_HOME - TMP_HOME=$(mktemp -d) - local TMP_DIR - TMP_DIR=$(mktemp -d) - trap 'rm -rf "$TMP_HOME" "$TMP_DIR"' RETURN - - # Build local tarball from current repo state - info " Building local tarball..." - local SKILL_PKG_DIR="$TMP_DIR/skill-pkg" - mkdir -p "$SKILL_PKG_DIR/bin" - - cp "$REPO_ROOT/packages/cli-box-skill/skill/SKILL.md" "$SKILL_PKG_DIR/" - - # Build binaries if not found +build_local_tarball() { + local out="$1" + local d; d=$(mktemp -d) + mkdir -p "$d/bin" + cp "$REPO_ROOT/packages/cli-box-skill/skill/SKILL.md" "$d/" if [ ! -f "$REPO_ROOT/target/release/cli-box" ] && [ ! -f "$REPO_ROOT/target/debug/cli-box" ]; then - info " Building binaries with cargo..." cargo build -p cli-box-cli -p cli-box-daemon >/dev/null 2>&1 fi - - # Use release binaries if available, fallback to debug if [ -f "$REPO_ROOT/target/release/cli-box" ]; then - cp "$REPO_ROOT/target/release/cli-box" "$SKILL_PKG_DIR/bin/" - cp "$REPO_ROOT/target/release/cli-box-daemon" "$SKILL_PKG_DIR/bin/" - elif [ -f "$REPO_ROOT/target/debug/cli-box" ]; then - cp "$REPO_ROOT/target/debug/cli-box" "$SKILL_PKG_DIR/bin/" - cp "$REPO_ROOT/target/debug/cli-box-daemon" "$SKILL_PKG_DIR/bin/" + cp "$REPO_ROOT/target/release/cli-box" "$d/bin/"; cp "$REPO_ROOT/target/release/cli-box-daemon" "$d/bin/" else - err " No built binaries found even after cargo build." - FAILED=1 - return + cp "$REPO_ROOT/target/debug/cli-box" "$d/bin/"; cp "$REPO_ROOT/target/debug/cli-box-daemon" "$d/bin/" fi + chmod +x "$d/bin/"* + (cd "$d" && tar czf "$out" .) + rm -rf "$d" +} - chmod +x "$SKILL_PKG_DIR/bin/"* - (cd "$SKILL_PKG_DIR" && tar czf "$TMP_DIR/cli-box-skill.tar.gz" .) - ok " Local tarball built" - - # Create modified install.sh pointing to local tarball - cp "$REPO_ROOT/packages/cli-box-skill/skill/install.sh" "$TMP_DIR/install-local.sh" - - # Replace version detection with fixed version - sed -i '' 's/VERSION="${CLI_BOX_VERSION:-latest}"/VERSION="local"/' "$TMP_DIR/install-local.sh" - - # Replace the GitHub API version fetch block with a no-op +patch_install_sh() { + local src="$1" dst="$2" tarball="$3" + cp "$src" "$dst" + sed -i '' 's/VERSION="${CLI_BOX_VERSION:-latest}"/VERSION="local"/' "$dst" sed -i '' '/Fetching latest release version/,/fi/c\ - info "Using local version"' "$TMP_DIR/install-local.sh" - - # Replace download URL with local file - sed -i '' "s|DOWNLOAD_URL=\"https://github.com/\$REPO/releases/download/\$VERSION/cli-box-skill.tar.gz\"|DOWNLOAD_URL=\"file://$TMP_DIR/cli-box-skill.tar.gz\"|" "$TMP_DIR/install-local.sh" - - # Run install.sh with HOME override - info " Running install-local.sh..." - if ! HOME="$TMP_HOME" bash "$TMP_DIR/install-local.sh" 2>&1; then - err " install.sh exited with non-zero status" - FAILED=1 - return - fi - - # Verify binaries - if [ -f "$TMP_HOME/.cli-box/bin/cli-box" ] && [ -x "$TMP_HOME/.cli-box/bin/cli-box" ]; then - ok " cli-box binary installed and executable" - else - err " cli-box binary NOT found or not executable" - FAILED=1 - fi - - if [ -f "$TMP_HOME/.cli-box/bin/cli-box-daemon" ] && [ -x "$TMP_HOME/.cli-box/bin/cli-box-daemon" ]; then - ok " cli-box-daemon binary installed and executable" - else - err " cli-box-daemon binary NOT found or not executable" - FAILED=1 - fi - - # Verify SKILL.md - if [ -f "$TMP_HOME/.claude/skills/cli-box/SKILL.md" ]; then - ok " SKILL.md installed to .claude/skills/cli-box/" - else - # install.sh only installs SKILL.md if .claude/ dir exists - warn " SKILL.md not installed (.claude/ dir may not exist in tmp HOME)" - fi - - # Verify SKILL.md frontmatter - if [ -f "$TMP_HOME/.claude/skills/cli-box/SKILL.md" ]; then - if head -5 "$TMP_HOME/.claude/skills/cli-box/SKILL.md" | grep -q "^name: cli-box"; then - ok " SKILL.md has valid frontmatter" - else - err " SKILL.md frontmatter invalid" - FAILED=1 - fi - fi - - info " Test 2 complete" +info "Using local version"' "$dst" + sed -i '' "s|DOWNLOAD_URL=\"https://github.com/\$REPO/releases/download/\$VERSION/cli-box-skill.tar.gz\"|DOWNLOAD_URL=\"file://$tarball\"|" "$dst" } -# ==================== Test 3: Post-install verification ==================== -test_post_install_verify() { - info "Test 3: Post-install verification" - - local TMP_HOME - TMP_HOME=$(mktemp -d) - local TMP_DIR - TMP_DIR=$(mktemp -d) +test_install_sh() { + info "Test 2: install.sh (skill into chosen target only)" + local TMP_HOME; TMP_HOME=$(mktemp -d) + local TMP_DIR; TMP_DIR=$(mktemp -d) trap 'rm -rf "$TMP_HOME" "$TMP_DIR"' RETURN - - # Build local tarball (same as Test 2) - local SKILL_PKG_DIR="$TMP_DIR/skill-pkg" - mkdir -p "$SKILL_PKG_DIR/bin" - cp "$REPO_ROOT/packages/cli-box-skill/skill/SKILL.md" "$SKILL_PKG_DIR/" - - # Build binaries if not found - if [ ! -f "$REPO_ROOT/target/release/cli-box" ] && [ ! -f "$REPO_ROOT/target/debug/cli-box" ]; then - info " Building binaries with cargo..." - cargo build -p cli-box-cli -p cli-box-daemon >/dev/null 2>&1 - fi - - if [ -f "$REPO_ROOT/target/release/cli-box" ]; then - cp "$REPO_ROOT/target/release/cli-box" "$SKILL_PKG_DIR/bin/" - cp "$REPO_ROOT/target/release/cli-box-daemon" "$SKILL_PKG_DIR/bin/" - elif [ -f "$REPO_ROOT/target/debug/cli-box" ]; then - cp "$REPO_ROOT/target/debug/cli-box" "$SKILL_PKG_DIR/bin/" - cp "$REPO_ROOT/target/debug/cli-box-daemon" "$SKILL_PKG_DIR/bin/" - else - err " No built binaries found even after cargo build." - FAILED=1 - return - fi - chmod +x "$SKILL_PKG_DIR/bin/"* - (cd "$SKILL_PKG_DIR" && tar czf "$TMP_DIR/cli-box-skill.tar.gz" .) - - # Install via install.sh - cp "$REPO_ROOT/packages/cli-box-skill/skill/install.sh" "$TMP_DIR/install-local.sh" - sed -i '' 's/VERSION="${CLI_BOX_VERSION:-latest}"/VERSION="local"/' "$TMP_DIR/install-local.sh" - sed -i '' '/Fetching latest release version/,/fi/c\ - info "Using local version"' "$TMP_DIR/install-local.sh" - sed -i '' "s|DOWNLOAD_URL=\"https://github.com/\$REPO/releases/download/\$VERSION/cli-box-skill.tar.gz\"|DOWNLOAD_URL=\"file://$TMP_DIR/cli-box-skill.tar.gz\"|" "$TMP_DIR/install-local.sh" - - # Create .claude/skills/ so install.sh installs SKILL.md - mkdir -p "$TMP_HOME/.claude/skills" - HOME="$TMP_HOME" bash "$TMP_DIR/install-local.sh" >/dev/null 2>&1 - - # Verify cli-box binary responds to --help - info " Verifying cli-box --help..." - if "$TMP_HOME/.cli-box/bin/cli-box" --help >/dev/null 2>&1; then - ok " cli-box --help works" - else - err " cli-box --help failed" - FAILED=1 - fi - - # Verify SKILL.md frontmatter is valid - info " Verifying SKILL.md frontmatter..." - local SKILL_FILE="$TMP_HOME/.claude/skills/cli-box/SKILL.md" - if [ -f "$SKILL_FILE" ]; then - if head -1 "$SKILL_FILE" | grep -q "^---$" && \ - head -5 "$SKILL_FILE" | grep -q "^name: cli-box" && \ - head -5 "$SKILL_FILE" | grep -q "^description:"; then - ok " SKILL.md frontmatter valid (name + description present)" - else - err " SKILL.md frontmatter invalid" - FAILED=1 - fi + local tarball="$TMP_DIR/cli-box-skill.tar.gz" + build_local_tarball "$tarball" || { err " tarball build failed"; FAILED=1; return; } + local script="$TMP_DIR/install-local.sh" + patch_install_sh "$REPO_ROOT/packages/cli-box-skill/skill/install.sh" "$script" "$tarball" + if ! HOME="$TMP_HOME" bash "$script" claude >/dev/null 2>&1; then + err " install.sh claude exited non-zero"; FAILED=1; return + fi + [ -f "$TMP_HOME/.cli-box/bin/cli-box" ] && ok " binaries installed" || { err " binaries missing"; FAILED=1; } + [ -f "$TMP_HOME/.claude/skills/cli-box/SKILL.md" ] && ok " SKILL.md in Claude dir" || { err " SKILL.md missing in Claude dir"; FAILED=1; } + [ ! -e "$TMP_HOME/.config/opencode/skills/cli-box" ] && ok " OpenCode dir untouched" || { err " OpenCode dir should be untouched"; FAILED=1; } + + info " Test 2b: install.sh with no target exits 1 AND prints usage hint" + local rc=0 out="" + out=$(HOME="$TMP_HOME" bash "$script" 2>&1 1>/dev/null) || rc=$? + if [ "$rc" -ne 1 ]; then err " expected exit 1, got $rc"; FAILED=1; else ok " no-target exit 1"; fi + if echo "$out" | grep -q "Usage:"; then ok " usage hint printed"; else err " usage hint NOT printed"; FAILED=1; fi + + info " Test 2c: install.sh all installs into all three harnesses" + local HOME_ALL; HOME_ALL=$(mktemp -d) + if HOME="$HOME_ALL" bash "$script" all >/dev/null 2>&1; then + local n=0 + for sub in .claude .config/opencode .openclaw; do + [ -f "$HOME_ALL/$sub/skills/cli-box/SKILL.md" ] && n=$((n+1)) + done + if [ "$n" -eq 3 ]; then ok " 'all' installed into 3 harnesses"; else err " 'all' installed into $n/3"; FAILED=1; fi else - warn " SKILL.md not found, skipping frontmatter check" + err " install.sh all exited non-zero"; FAILED=1 fi + rm -rf "$HOME_ALL" - # Functional-level test (local only, skipped in CI) - if [ "$(uname)" = "Darwin" ] && [ -z "${CI:-}" ]; then - info " Running functional test (cli-box start zsh)..." - local SANDBOX_ID - - # Use timeout to prevent hanging (macOS doesn't have timeout command) - # Run cli-box start in background and wait with timeout - local OUTPUT_FILE - OUTPUT_FILE=$(mktemp) - local PID_FILE - PID_FILE=$(mktemp) - - # Start cli-box in background - "$TMP_HOME/.cli-box/bin/cli-box" start zsh > "$OUTPUT_FILE" 2>&1 & - local CLI_PID=$! - echo "$CLI_PID" > "$PID_FILE" - - # Wait up to 30 seconds for output - local TIMEOUT=30 - local ELAPSED=0 - while [ $ELAPSED -lt $TIMEOUT ]; do - if ! kill -0 "$CLI_PID" 2>/dev/null; then - # Process finished - break - fi - if [ -s "$OUTPUT_FILE" ]; then - # Got some output - break - fi - sleep 1 - ELAPSED=$((ELAPSED + 1)) - done - - # Check if process is still running (timeout case) - if kill -0 "$CLI_PID" 2>/dev/null; then - warn " cli-box start timed out after ${TIMEOUT}s, killing..." - kill "$CLI_PID" 2>/dev/null || true - wait "$CLI_PID" 2>/dev/null || true - warn " Functional test skipped (timeout - macOS permissions may be required)" - else - # Process finished, check output - SANDBOX_ID=$(grep -oE '[a-f0-9]{6}' "$OUTPUT_FILE" | head -1 || true) - if [ -n "$SANDBOX_ID" ]; then - sleep 3 - if "$TMP_HOME/.cli-box/bin/cli-box" list 2>&1 | grep -q "$SANDBOX_ID"; then - ok " Sandbox $SANDBOX_ID is running" - else - warn " Sandbox $SANDBOX_ID not found in list (may have exited)" - fi - "$TMP_HOME/.cli-box/bin/cli-box" close "$SANDBOX_ID" 2>/dev/null || true - else - warn " Could not start sandbox (macOS permissions may be required)" - fi - fi - - # Cleanup - rm -f "$OUTPUT_FILE" "$PID_FILE" + info " Test 2d: install.sh with mixed 'claude all' expands (no Unknown-target error)" + local HOME_MIX; HOME_MIX=$(mktemp -d) + if HOME="$HOME_MIX" bash "$script" claude all >/dev/null 2>&1; then + ok " mixed 'claude all' accepted" else - info " Skipping functional test (CI or non-macOS)" + err " mixed 'claude all' failed (C1 regression)"; FAILED=1 fi + rm -rf "$HOME_MIX" - info " Test 3 complete" + info " Test 2e: install.sh unknown target exits non-zero" + rc=0 + HOME="$TMP_HOME" bash "$script" bogus >/dev/null 2>&1 || rc=$? + if [ "$rc" -ne 0 ]; then ok " unknown target exits non-zero"; else err " unknown target should fail"; FAILED=1; fi + info " Test 2 complete" } -# ==================== Main ==================== echo "" echo "==============================================" echo " E2E Skill Installation Tests" echo "==============================================" echo "" - -ensure_platform_binaries -echo "" - -test_postinstall -echo "" -test_install_sh -echo "" -test_post_install_verify - -# ==================== Summary ==================== -echo "" +ensure_platform_binaries; echo "" +test_postinstall; echo "" +test_install_sh; echo "" echo "==============================================" -if [ "$FAILED" -eq 0 ]; then - echo -e "${GREEN}All E2E skill installation tests passed!${NC}" - exit 0 -else - echo -e "${RED}Some E2E skill installation tests failed.${NC}" - exit 1 -fi +if [ "$FAILED" -eq 0 ]; then echo -e "${GREEN}All E2E skill installation tests passed!${NC}"; exit 0 +else echo -e "${RED}Some E2E skill installation tests failed.${NC}"; exit 1; fi