diff --git a/CHANGELOG.md b/CHANGELOG.md index 5546b5f..8d03a0d 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -3,6 +3,35 @@ All notable changes to this project will be documented in this file. The format follows [Keep a Changelog](https://keepachangelog.com/en/1.1.0/). +## [Unreleased] + +### Changed — `npx claude-next install` no longer requires Perl + +The install path now does its JSON-merge work in Node instead of shelling +out to `bash install.sh` (which used Perl + JSON::PP). On pure Windows +without Git Bash / WSL / Strawberry Perl, `npx claude-next install` used +to fail at the Perl preflight; now it succeeds. The dry-run phase +(which still calls `bash ingest.sh` to verify the hook) auto-skips with +a clear warning if bash is unavailable, and the install message +explicitly tells Windows users to grab Git for Windows so the runtime +hook can fire. + +- `bin/cli.js` ports all five install phases (copy / backup / merge / + verify / dry-run) to Node — same hook detection (substring on + `next/scripts/ingest.sh`), same idempotent re-install behavior, same + canonical-sorted-key JSON output, same UTF-8 contract so Chinese hook + labels and emoji round-trip cleanly. +- `install.sh` is kept in the package as a documented legacy path; users + on POSIX with Perl can still run it directly. `npx claude-next install` + no longer invokes it. +- Hook command unchanged (`bash "$HOME/.claude/skills/next/scripts/ingest.sh"`); + removing the bash runtime requirement is the v0.3.x track. + +Reverse-proved on WSL POSIX (8/8 dry-run cases) + sandbox HOME with no +bash on PATH (install completes, hook in place, runtime warning shown). +Re-install + pre-existing-hooks merge + Chinese/emoji UTF-8 round-trip +all verified. + ## [0.2.10] - 2026-05-13 ### Fixed — three correctness bugs surfaced by fresh-context audit diff --git a/README.md b/README.md old mode 100755 new mode 100644 index 659e7ac..d161e07 --- a/README.md +++ b/README.md @@ -100,14 +100,25 @@ npx claude-next install ### Manual ```bash -git clone https://github.com/llmapi-pro/claude-next ~/.claude/skills/next -bash ~/.claude/skills/next/install.sh +git clone https://github.com/llmapi-pro/claude-next +node claude-next/bin/cli.js install +# legacy POSIX-with-perl path also still works: +# bash claude-next/install.sh ``` The installer is **idempotent**: - Backs up your existing `~/.claude/settings.json` to `.bak-` - Adds only the `UserPromptSubmit` hook, preserving every other field -- Self-tests three hook scenarios before declaring success +- Self-tests the hook end-to-end before declaring success (auto-skipped when bash is unavailable, with a clear warning) + +### Windows users + +`npx claude-next install` works on pure Windows (no Perl required as of +the Unreleased v0.3.x). Once installed, the hook command is +`bash ".../ingest.sh"`, which needs **bash on PATH at runtime**. Install +[Git for Windows](https://gitforwindows.org/) to get `bash.exe`; the +installer detects the absence and tells you exactly that. Removing the +bash runtime requirement is the next track on the roadmap. ### Uninstall @@ -223,10 +234,12 @@ Because the auditor is fresh, it catches exactly the class of error that long-co ## Compatibility -- **Claude Code** on macOS, Linux, or Windows (Git-bash) -- **Requires**: `bash`, `perl` (core modules only — `JSON::PP`, `Time::Local` — both ship with Perl 5.14+) +- **Claude Code** on macOS, Linux, or Windows (with Git for Windows) +- **Install** (`npx claude-next install`) requires: **Node ≥16.7** only — no Perl, no bash needed at install time. +- **Runtime** (the `/next` hook itself) requires: `bash` on PATH. POSIX has it; Windows users install [Git for Windows](https://gitforwindows.org/). - **Optional**: `git` and `docker` for richer drift/audit checks -- **Does not require**: Python, `jq`, Node (except for the `npx` installer), or any external LLM API +- **Legacy** install path `bash install.sh` still works on POSIX-with-Perl (`JSON::PP`, `Time::Local` — core since Perl 5.14+) and is kept as a documented fallback. +- **Does not require**: Python, `jq`, or any external LLM API --- @@ -265,7 +278,8 @@ Defaults are sensible, override via env vars if needed: - [x] Consumed-handoff archive (default on, env-tunable) — shipped 0.2.8 - [ ] MCP server wrapper (for Cursor, Cline, other MCP-compatible clients) - [ ] Auto-trigger at configurable token thresholds -- [ ] PowerShell fallback for pure-Windows environments (no Git-bash) +- [x] Perl-free install on pure Windows (`npx claude-next install`) — shipped in Unreleased v0.3.x; the runtime hook still needs bash, which is the next milestone +- [ ] Node-native hook scripts so the `bash` runtime requirement disappears (pure-Windows end-to-end) - [ ] Per-project handoff directories (isolate monorepo sub-projects) - [ ] Handoff signing for team use (cryptographically attest audit verdict) diff --git a/bin/cli.js b/bin/cli.js old mode 100755 new mode 100644 index c076ce5..a55ecc4 --- a/bin/cli.js +++ b/bin/cli.js @@ -21,11 +21,22 @@ const pkg = require(path.join(pkgRoot, 'package.json')); const HOME = os.homedir(); const TARGET = path.join(HOME, '.claude', 'skills', 'next'); -const AUTO_SENTINEL = path.join(HOME, '.claude', 'next', 'auto.stop'); -const AUTO_SESSIONS = path.join(HOME, '.claude', 'next', 'auto-sessions'); +const SETTINGS = path.join(HOME, '.claude', 'settings.json'); +const RUNTIME_DIR = path.join(HOME, '.claude', 'next'); +const AUTO_SENTINEL = path.join(RUNTIME_DIR, 'auto.stop'); +const AUTO_SESSIONS = path.join(RUNTIME_DIR, 'auto-sessions'); const ITEMS = ['scripts', 'templates', 'SKILL.md', 'install.sh', 'README.md']; +// Hook is `bash ingest.sh`. Both POSIX and Windows-with-Git-Bash satisfy this. +// On pure Windows the install still succeeds but we warn that the hook needs +// bash at runtime — porting hook scripts to Node is the v0.3.x track. +const HOOK_CMD = 'bash "$HOME/.claude/skills/next/scripts/ingest.sh"'; +// Substring match for idempotent re-install. Tolerate both / and \ separators +// so users on Windows who hand-edited the hook with backslashes don't get a +// duplicate hook entry. +const HOOK_RE = /next[\/\\]scripts[\/\\]ingest\.sh/; + function copyRecursive(src, dst) { if (typeof fs.cpSync === 'function') { fs.cpSync(src, dst, { recursive: true, force: true }); return; } const stat = fs.statSync(src); @@ -38,26 +49,176 @@ function copyRecursive(src, dst) { } } +function sortKeysDeep(obj) { + if (obj === null || typeof obj !== 'object') return obj; + if (Array.isArray(obj)) return obj.map(sortKeysDeep); + const out = {}; + for (const k of Object.keys(obj).sort()) out[k] = sortKeysDeep(obj[k]); + return out; +} + +function bashAvailable() { + // spawnSync swallows ENOENT into result.error on Windows; check both. + const r = spawnSync('bash', ['--version'], { stdio: 'ignore' }); + return !r.error && r.status === 0; +} + +function backupOrInitSettings() { + if (!fs.existsSync(SETTINGS)) { + fs.mkdirSync(path.dirname(SETTINGS), { recursive: true }); + fs.writeFileSync(SETTINGS, '{}\n', 'utf8'); + return null; + } + // Match install.sh date +%Y%m%d-%H%M%S (local-time). + const d = new Date(); + const pad = (n) => String(n).padStart(2, '0'); + const stamp = `${d.getFullYear()}${pad(d.getMonth() + 1)}${pad(d.getDate())}-${pad(d.getHours())}${pad(d.getMinutes())}${pad(d.getSeconds())}`; + const bak = `${SETTINGS}.bak-${stamp}`; + fs.copyFileSync(SETTINGS, bak); + return bak; +} + +function mergeHookIntoSettings(hookCmd) { + // UTF-8 contract: read as utf8, JSON parse, JSON stringify, write as utf8. + // Node JSON.stringify emits raw codepoints (not \uXXXX escapes), and utf8 + // write encodes them to valid bytes — so Chinese hook labels / paths + // round-trip without mojibake (the same property install.sh's perl block + // achieved via JSON::PP ->utf8). + const raw = fs.readFileSync(SETTINGS, 'utf8'); + let s; + try { s = JSON.parse(raw); } catch (_) { s = {}; } + if (!s || typeof s !== 'object' || Array.isArray(s)) s = {}; + + s.hooks = s.hooks || {}; + s.hooks.UserPromptSubmit = s.hooks.UserPromptSubmit || []; + const ups = s.hooks.UserPromptSubmit; + + let already = false; + for (const e of ups) { + if (!e || typeof e !== 'object' || !Array.isArray(e.hooks)) continue; + for (const h of e.hooks) { + if (h && typeof h === 'object' && typeof h.command === 'string' && HOOK_RE.test(h.command)) { + already = true; break; + } + } + if (already) break; + } + if (already) return { added: false }; + + ups.push({ hooks: [{ type: 'command', command: hookCmd }] }); + // canonical = sorted keys (matches install.sh's ->canonical->encode). + const json = JSON.stringify(sortKeysDeep(s), null, 2) + '\n'; + fs.writeFileSync(SETTINGS, json, 'utf8'); + return { added: true }; +} + +function verifyHookInSettings() { + const raw = fs.readFileSync(SETTINGS, 'utf8'); + const s = JSON.parse(raw); + const ups = (s.hooks && s.hooks.UserPromptSubmit) || []; + let found = false; + for (const e of ups) { + for (const h of (e.hooks || [])) { + if (h && typeof h.command === 'string' && HOOK_RE.test(h.command)) found = true; + } + } + return { found, total: ups.length }; +} + +function runDryHook(input) { + const ingestPath = path.join(TARGET, 'scripts', 'ingest.sh'); + const r = spawnSync('bash', [ingestPath], { input, stdio: ['pipe', 'pipe', 'pipe'], encoding: 'utf8' }); + return ((r.stdout || '') + '').trim(); +} + +const DRY_RUN_TESTS = [ + { input: '{}', label: 'empty input', expectEmpty: true }, + { input: '{"prompt":"hello world","session_id":"test","hook_event_name":"UserPromptSubmit"}', label: 'non-matching prompt', expectEmpty: true }, + { input: '{"prompt":"next ZZ","session_id":"test","hook_event_name":"UserPromptSubmit"}', label: 'next ZZ → missing-slot', expectMatch: /槽位 ZZ 不存在/ }, + { input: '{"prompt":"继续 ZZ","session_id":"test","hook_event_name":"UserPromptSubmit"}', label: '继续 ZZ → missing-slot (CN trigger)', expectMatch: /槽位 ZZ 不存在/ }, + { input: '{"prompt":"next step is to fix the auth bug","session_id":"test","hook_event_name":"UserPromptSubmit"}', label: '"next step is..." → no false positive', expectEmpty: true }, + { input: '{"prompt":"drop the file is broken","session_id":"test","hook_event_name":"UserPromptSubmit"}', label: '"drop the file..." → no false positive', expectEmpty: true }, + { input: '{"prompt":"next ABCD please","session_id":"test","hook_event_name":"UserPromptSubmit"}', label: '"next ABCD..." → 4-char slot rejected', expectEmpty: true }, + { input: '{"prompt":"next A.","session_id":"test","hook_event_name":"UserPromptSubmit"}', label: '"next A." → punctuation boundary', expectMatch: /槽位 A 不存在/ }, +]; + function install() { - console.log('[claude-next] Installing skill files to:', TARGET); + console.log('━━━ /next skill installer ━━━\n'); + + // [1/5] Copy files + console.log('[1/5] Copying skill files to:', TARGET); fs.mkdirSync(TARGET, { recursive: true }); for (const item of ITEMS) { const src = path.join(pkgRoot, item); const dst = path.join(TARGET, item); - if (!fs.existsSync(src)) { console.error(`[claude-next] Missing: ${item}`); process.exit(1); } + if (!fs.existsSync(src)) { console.error(` ✗ Missing: ${item}`); process.exit(1); } copyRecursive(src, dst); } - try { - const scriptsDir = path.join(TARGET, 'scripts'); - for (const f of fs.readdirSync(scriptsDir)) { - if (f.endsWith('.sh')) fs.chmodSync(path.join(scriptsDir, f), 0o755); + // chmod on POSIX; Windows ignores file modes so skip. + if (process.platform !== 'win32') { + try { + const sd = path.join(TARGET, 'scripts'); + for (const f of fs.readdirSync(sd)) { + if (f.endsWith('.sh')) fs.chmodSync(path.join(sd, f), 0o755); + } + fs.chmodSync(path.join(TARGET, 'install.sh'), 0o755); + } catch (_) {} + } + fs.mkdirSync(path.join(RUNTIME_DIR, 'pending'), { recursive: true }); + console.log(' ✓ Files copied'); + console.log(' ✓ Runtime dir:', path.join(RUNTIME_DIR, 'pending')); + + // [2/5] Backup + console.log('\n[2/5] Backing up settings.json...'); + const bak = backupOrInitSettings(); + console.log(bak ? ` ✓ Backup: ${bak}` : ' • No existing settings.json; created fresh.'); + + // [3/5] Merge hook + console.log('\n[3/5] Merging UserPromptSubmit hook...'); + const m = mergeHookIntoSettings(HOOK_CMD); + console.log(m.added ? ' ✓ Hook added.' : ' • /next hook already present; skipped.'); + + // [4/5] Verify + console.log('\n[4/5] Verifying...'); + const v = verifyHookInSettings(); + if (!v.found) { console.error(' ✗ Hook verification failed.'); process.exit(1); } + console.log(` ✓ Hook in place. Total UserPromptSubmit entries: ${v.total}`); + + // [5/5] Dry-run (requires bash). On pure Windows we skip + warn instead of fail. + console.log('\n[5/5] Dry-run hook...'); + const hasBash = bashAvailable(); + if (!hasBash) { + console.log(' ⚠ bash not on PATH — skipping runtime dry-run.'); + console.log(' The hook command is `bash ".../ingest.sh"`, which needs bash at runtime.'); + console.log(' On Windows: install Git for Windows (https://gitforwindows.org/) to get bash.exe.'); + } else { + for (const t of DRY_RUN_TESTS) { + const out = runDryHook(t.input); + if (t.expectEmpty) { + if (out === '') console.log(` ✓ ${t.label} → silent passthrough`); + else console.log(` ⚠ ${t.label} produced output: ${out.split('\n')[0]}`); + } else if (t.expectMatch) { + if (t.expectMatch.test(out)) console.log(` ✓ ${t.label}`); + else console.log(` ⚠ ${t.label} unexpected output: ${(out || '(empty)').split('\n')[0]}`); + } } - fs.chmodSync(path.join(TARGET, 'install.sh'), 0o755); - } catch (_) {} - console.log('[claude-next] Running install.sh...\n'); - const result = spawnSync('bash', [path.join(TARGET, 'install.sh')], { stdio: 'inherit', cwd: TARGET }); - if (result.error) { console.error('[claude-next] bash failed:', result.error.message); process.exit(1); } - if (result.status !== 0) process.exit(result.status || 1); + } + + console.log('\n━━━ Install complete ━━━\n'); + console.log('Next steps:'); + console.log(' 1. Open a new Claude Code window. Try /next list (should say: No pending handoffs.)'); + console.log(' 2. In a real project, run /next to produce your first handoff.'); + console.log(' 3. Open another window and paste 继续 X (or next X) to verify continuation.'); + console.log(''); + if (!hasBash) { + console.log('⚠ This system has no bash on PATH. The /next hook fires `bash ingest.sh` so it will be a no-op until bash is installed.'); + console.log(' Windows: install Git for Windows — https://gitforwindows.org/ (provides bash.exe in PATH)'); + console.log(' A future v0.3.x track will port the hook to Node so bash becomes optional.'); + console.log(''); + } + console.log('Uninstall:'); + console.log(` rm -rf ${TARGET} ${RUNTIME_DIR}`); + console.log(` restore settings: cp ${SETTINGS}.bak- ${SETTINGS}`); } function version() { console.log(pkg.version); }