@skillstate/cli 2.2.2 → 3.0.1

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
package/dist/install.js CHANGED
@@ -1,43 +1,46 @@
1
- // skillstate host auto-install (additive, @non-paper Wave-4 DX).
1
+ // skillstate host wiring (v2 model: project-local glue, machine install).
2
2
  //
3
- // `skillstate init` detects the host (opencode | claude | codex) and installs
4
- // everything in one shot:
5
- // - state dir `./.skillstate/` in the project (per-project state + manifest);
6
- // - OpenCode: plugin into `~/.config/opencode/plugins/` (auto-loaded at
7
- // startup, thin loader — the plugin resolves
8
- // `<cwd>/.skillstate/skillstate.json` from the host session's cwd),
9
- // `mcp.skillstate` entry spliced into `opencode.jsonc` (with a timestamped
10
- // backup, no baked env — the server resolves the state from its own cwd),
11
- // `SKILL.md` into `~/.config/opencode/skills/`;
12
- // - Claude: `.cjs` hook scripts into `~/.claude/hooks/skillstate/` +
13
- // skillstate hook groups merged into `~/.claude/settings.json`
14
- // (UserPromptSubmit / SessionStart(^compact$) / PostToolUse(^Bash$)),
15
- // `SKILL.md` into `~/.claude/skills/`, `.mcp.json` (stdio server) in the
16
- // project;
17
- // - Codex: `SKILL.md` into `~/.codex/skills/` (no MCP — TOML config untouched).
3
+ // Global machine install (`npm i -g`) is the ONLY global thing. `skillstate
4
+ // init` writes NO files into `~` — every piece of glue lives inside the
5
+ // project and is committed, so a fresh clone works for the whole team:
6
+ // - state dir `./.skillstate/skillstate.json` (per-project state + manifest);
7
+ // - OpenCode: `"plugins": ["@skillstate/opencode"]` spliced into the PROJECT
8
+ // `opencode.jsonc|json` — the v2 plugin contributes NATIVE tools, so the
9
+ // MCP server is deliberately NOT registered there (one timestamped backup
10
+ // per run; no baked env — everything resolves the state from its cwd);
11
+ // - Claude: self-contained `.cjs` hook scripts + hook groups merged into the
12
+ // PROJECT `.claude/settings.json` (`$CLAUDE_PROJECT_DIR`-anchored
13
+ // commands) and the stdio server into the project `.mcp.json`;
14
+ // - ONE host-neutral SKILL.md at `<cwd>/.claude/skills/skillstate/` serves
15
+ // both opencode and claude (opencode reads project `.claude/skills/` too);
16
+ // - Codex: machine-level only (no project config support) — `skillstate
17
+ // install` wires `~/.codex` once and the project state is picked up
18
+ // automatically.
18
19
  //
19
- // An install manifest (`<stateDir>/install-manifest.json`) records every path
20
- // touched so `skillstate uninstall` (or `init --uninstall`) can roll it all
21
- // back exactly.
20
+ // Hosts are wired ALL AT ONCE (every detected host), so switching harnesses
21
+ // needs no re-init. An install manifest (`<stateDir>/install-manifest.json`,
22
+ // v2) records per-host glue and MERGES across re-inits; the machine manifest
23
+ // (`<home>/.skillstate/install-manifest.json`, v1) records the codex glue so
24
+ // `skillstate uninstall [--machine]` can roll either back exactly.
22
25
  /// <reference types="node" />
23
26
  import * as fs from 'node:fs';
24
27
  import * as os from 'node:os';
25
28
  import * as path from 'node:path';
26
- import { createRequire } from 'node:module';
27
- import { atomicWriteFile } from '@skillstate/core';
28
- import { GENERIC_PROCEDURE_SPEC, INTERCODE_CTF_SPEC } from '@skillstate/core/schemas';
29
- import { OpenCodeAdapter } from '@skillstate/opencode';
29
+ import { atomicWriteFile, STATE_PATCH_EXAMPLE_JSON, STATE_PATCH_RULES, } from '@skillstate/core';
30
+ import { GENERIC_PROCEDURE_SPEC } from '@skillstate/core/schemas';
30
31
  import { CodexAdapter, CODEX_HOOK_EVENTS } from '@skillstate/codex';
31
32
  import { ClaudeAdapter, CLAUDE_HOOK_EVENTS, removeSkillstateHookGroups } from '@skillstate/claude';
32
- import { findTopLevelObject, insertObjectEntry, removeObjectEntry } from './jsonc.js';
33
+ import { findTopLevelObject, insertArrayStringEntry, insertObjectEntry, parseJsonc, removeArrayStringEntry, removeObjectEntry, scanArray, scanObject, } from './jsonc.js';
33
34
  import { resolveInCwd } from './commands.js';
34
- const HOSTS = ['opencode', 'claude', 'codex'];
35
35
  /** Project runtime directory created by init. */
36
36
  export const STATE_DIR_NAME = '.skillstate';
37
37
  /** Install manifest file name inside the state dir. */
38
38
  export const MANIFEST_FILE_NAME = 'install-manifest.json';
39
39
  /** Short skill description used for the SKILL.md frontmatter. */
40
40
  const SKILL_DESCRIPTION = 'State-based execution: persist agent state to a JSON file, keep the prompt O(1), and resume any procedure from disk.';
41
+ /** The `npx` command + args every host uses to launch the MCP server (pinned major). */
42
+ const MCP_NPX_COMMAND = 'npx';
43
+ const MCP_NPX_ARGS = ['-y', '@skillstate/mcp@^3'];
41
44
  function isRecord(value) {
42
45
  return typeof value === 'object' && value !== null && !Array.isArray(value);
43
46
  }
@@ -52,76 +55,43 @@ export function isInsideTemp(dir) {
52
55
  return resolved === tmp || resolved.startsWith(tmp + path.sep);
53
56
  }
54
57
  /**
55
- * Detect the host from marker files under `home` (opencode first):
58
+ * Detect ALL supported hosts from marker files under `home`, in fixed order
59
+ * [opencode, claude, codex]:
56
60
  * - opencode: `~/.config/opencode/opencode.jsonc|opencode.json` or `~/.opencode/bin/opencode`;
57
61
  * - claude: `~/.claude`;
58
62
  * - codex: `~/.codex`.
59
- * Returns null when nothing matches.
63
+ * Returns every match (empty when nothing does) — init wires them all at once.
60
64
  */
61
- export function detectHost(home) {
65
+ export function detectHosts(home) {
66
+ const detected = [];
62
67
  const configDir = path.join(home, '.config', 'opencode');
63
68
  if (fs.existsSync(path.join(configDir, 'opencode.jsonc')) ||
64
69
  fs.existsSync(path.join(configDir, 'opencode.json')) ||
65
70
  fs.existsSync(path.join(home, '.opencode', 'bin', 'opencode'))) {
66
- return 'opencode';
71
+ detected.push('opencode');
67
72
  }
68
73
  if (fs.existsSync(path.join(home, '.claude'))) {
69
- return 'claude';
74
+ detected.push('claude');
70
75
  }
71
76
  if (fs.existsSync(path.join(home, '.codex'))) {
72
- return 'codex';
77
+ detected.push('codex');
73
78
  }
74
- return null;
79
+ return detected;
75
80
  }
76
81
  /**
77
- * Parse `init` flags: `--host <h>`, `--max-history <n>`, `--no-mcp`,
78
- * `--no-skill`, `--dry-run`, `--auto`, `--uninstall` (`=`-forms accepted).
82
+ * Parse `init` flags: `--spec <path>` (or `--spec=<path>`), `--dry-run`.
79
83
  * Throws an `Error` with the usage line on unknown/invalid flags.
80
84
  */
81
85
  export function parseInitArgs(args) {
82
86
  if (wantsHelpInit(args)) {
83
87
  throw new HelpRequestedInitError();
84
88
  }
85
- const flags = {
86
- noMcp: false,
87
- noSkill: false,
88
- dryRun: false,
89
- auto: false,
90
- uninstall: false,
91
- };
89
+ const flags = { dryRun: false };
92
90
  for (let i = 0; i < args.length; i += 1) {
93
91
  const arg = args[i];
94
- if (arg === '--auto') {
95
- flags.auto = true;
96
- }
97
- else if (arg === '--dry-run') {
92
+ if (arg === '--dry-run') {
98
93
  flags.dryRun = true;
99
94
  }
100
- else if (arg === '--no-mcp') {
101
- flags.noMcp = true;
102
- }
103
- else if (arg === '--no-skill') {
104
- flags.noSkill = true;
105
- }
106
- else if (arg === '--uninstall') {
107
- flags.uninstall = true;
108
- }
109
- else if (arg === '--host' || arg.startsWith('--host=')) {
110
- const value = arg === '--host' ? args[++i] : arg.slice('--host='.length);
111
- if (value === undefined ||
112
- (value !== 'opencode' && value !== 'claude' && value !== 'codex')) {
113
- throw new Error(`Invalid --host (want opencode|claude|codex)\n${CLI_USAGE_INSTALL}`);
114
- }
115
- flags.host = value;
116
- }
117
- else if (arg === '--max-history' || arg.startsWith('--max-history=')) {
118
- const value = arg === '--max-history' ? args[++i] : arg.slice('--max-history='.length);
119
- const parsed = typeof value === 'string' ? Number(value) : NaN;
120
- if (value === undefined || !Number.isInteger(parsed) || parsed < 1) {
121
- throw new Error(`Invalid --max-history (want a positive integer)\n${CLI_USAGE_INSTALL}`);
122
- }
123
- flags.maxHistory = parsed;
124
- }
125
95
  else if (arg === '--spec' || arg.startsWith('--spec=')) {
126
96
  const value = arg === '--spec' ? args[++i] : arg.slice('--spec='.length);
127
97
  if (value === undefined || value.length === 0) {
@@ -129,33 +99,50 @@ export function parseInitArgs(args) {
129
99
  }
130
100
  flags.specPath = value;
131
101
  }
132
- else if (arg === '--example' || arg.startsWith('--example=')) {
133
- const value = arg === '--example' ? args[++i] : arg.slice('--example='.length);
134
- if (value !== 'ctf') {
135
- throw new Error(`Invalid --example (want ctf)\n${CLI_USAGE_INSTALL}`);
136
- }
137
- flags.example = value;
138
- }
139
102
  else {
140
103
  throw new Error(`Unknown flag for init: ${arg}\n${CLI_USAGE_INSTALL}`);
141
104
  }
142
105
  }
143
106
  return flags;
144
107
  }
108
+ /**
109
+ * Parse `install` flags: `--dry-run` only (the command wires the machine-
110
+ * level codex glue; project wiring belongs to `init`).
111
+ */
112
+ export function parseInstallArgs(args) {
113
+ if (wantsHelpInit(args)) {
114
+ throw new HelpRequestedInitError();
115
+ }
116
+ const flags = { dryRun: false };
117
+ for (let i = 0; i < args.length; i += 1) {
118
+ const arg = args[i];
119
+ if (arg === '--dry-run') {
120
+ flags.dryRun = true;
121
+ }
122
+ else {
123
+ throw new Error(`Unknown flag for install: ${arg}\n${CLI_USAGE_INSTALL}`);
124
+ }
125
+ }
126
+ return flags;
127
+ }
145
128
  /**
146
129
  * Parse `uninstall` flags: `--state-dir <path>`, `--remove-state`,
147
- * `--dry-run`. Throws an `Error` with the usage line on unknown flags.
130
+ * `--machine`, `--dry-run`. Throws an `Error` with the usage line on
131
+ * unknown flags.
148
132
  */
149
133
  export function parseUninstallArgs(args) {
150
134
  if (wantsHelpInit(args)) {
151
135
  throw new HelpRequestedInitError();
152
136
  }
153
- const flags = { removeState: false, dryRun: false };
137
+ const flags = { removeState: false, machine: false, dryRun: false };
154
138
  for (let i = 0; i < args.length; i += 1) {
155
139
  const arg = args[i];
156
140
  if (arg === '--remove-state') {
157
141
  flags.removeState = true;
158
142
  }
143
+ else if (arg === '--machine') {
144
+ flags.machine = true;
145
+ }
159
146
  else if (arg === '--dry-run') {
160
147
  flags.dryRun = true;
161
148
  }
@@ -172,59 +159,26 @@ export function parseUninstallArgs(args) {
172
159
  }
173
160
  return flags;
174
161
  }
175
- /** Help marker for init/uninstall flags (kept local to avoid a commands.ts cycle). */
162
+ /** Help marker for init/install/uninstall flags (kept local to avoid a commands.ts cycle). */
176
163
  function wantsHelpInit(args) {
177
164
  return args.includes('--help') || args.includes('-h');
178
165
  }
179
- /** Thrown by `parseInitArgs`/`parseUninstallArgs` on `--help`/`-h`. */
166
+ /** Thrown by `parseInitArgs`/`parseInstallArgs`/`parseUninstallArgs` on `--help`/`-h`. */
180
167
  export class HelpRequestedInitError extends Error {
181
168
  constructor() {
182
169
  super('help requested');
183
170
  this.name = 'HelpRequestedInitError';
184
171
  }
185
172
  }
186
- /** Usage line for init/uninstall (composed with the CLI usage in commands.ts). */
187
- export const CLI_USAGE_INSTALL = 'Usage: skillstate init [--host opencode|claude|codex] [--max-history <n>] [--no-mcp] [--no-skill] [--dry-run] | init --uninstall | uninstall [--state-dir <path>] [--remove-state] [--dry-run]';
188
- /** Resolve the MCP server command: the `@skillstate/mcp` bin via `node`, or the global `skillstate-mcp` bin. */
189
- export function resolveMcpCommandWith(resolve) {
190
- try {
191
- const pkg = resolve('@skillstate/mcp/package.json');
192
- return { command: 'node', args: [path.join(path.dirname(pkg), 'bin', 'mcp.js')] };
193
- }
194
- catch {
195
- return { command: 'skillstate-mcp', args: [] };
196
- }
197
- }
198
- /** `resolveMcpCommandWith` bound to this module's require resolver. */
199
- export function resolveMcpCommand() {
200
- return resolveMcpCommandWith((id) => createRequire(import.meta.url).resolve(id));
201
- }
173
+ /** Usage line for init/install/uninstall (composed with the CLI usage in commands.ts). */
174
+ export const CLI_USAGE_INSTALL = 'Usage: skillstate init [--spec <path>] [--dry-run] | install [--dry-run] | uninstall [--state-dir <path>] [--remove-state] [--machine] [--dry-run]';
202
175
  function backupPathFor(file) {
203
176
  return `${file}.bak.${new Date().toISOString().replace(/[:.]/g, '-')}`;
204
177
  }
205
- /** Path of the OpenCode host config: existing `.jsonc`, else `.json`, else the canonical `.jsonc`. */
206
- function resolveOpencodeConfig(home) {
207
- const dir = path.join(home, '.config', 'opencode');
208
- const jsonc = path.join(dir, 'opencode.jsonc');
209
- if (fs.existsSync(jsonc)) {
210
- return jsonc;
211
- }
212
- const json = path.join(dir, 'opencode.json');
213
- return fs.existsSync(json) ? json : jsonc;
214
- }
215
- function skillDirFor(host, home) {
216
- if (host === 'opencode') {
217
- return path.join(home, '.config', 'opencode', 'skills', 'skillstate');
218
- }
219
- if (host === 'claude') {
220
- return path.join(home, '.claude', 'skills', 'skillstate');
221
- }
222
- return path.join(home, '.codex', 'skills', 'skillstate');
223
- }
224
178
  /**
225
- * Spec resolution for `init`: `--spec <path>` wins (validated), then
226
- * `--example ctf`, then the neutral domain-agnostic default. Never defaults
227
- * to a domain-specific example.
179
+ * Spec resolution for `init`: `--spec <path>` wins (validated), else the
180
+ * neutral domain-agnostic default. Never defaults to a domain-specific
181
+ * example.
228
182
  */
229
183
  export function resolveInitSpec(cwd, flags) {
230
184
  if (flags.specPath !== undefined) {
@@ -254,63 +208,86 @@ export function resolveInitSpec(cwd, flags) {
254
208
  }
255
209
  return parsed;
256
210
  }
257
- if (flags.example === 'ctf') {
258
- return INTERCODE_CTF_SPEC;
259
- }
260
211
  return GENERIC_PROCEDURE_SPEC;
261
212
  }
262
- /** SKILL.md with a short frontmatter description + the adapter-generated body. */
263
- export function buildSkillMd(statePathRel, spec, host = 'opencode') {
264
- const generated = host === 'claude'
265
- ? new ClaudeAdapter().generateSkillMd(spec, statePathRel)
266
- : new OpenCodeAdapter().generateSkillMd(spec, statePathRel);
267
- const body = generated.slice(generated.indexOf('\n---', 3) + '\n---\n'.length);
213
+ /**
214
+ * Host-neutral SKILL.md: ONE file (`<cwd>/.claude/skills/skillstate/`)
215
+ * serves opencode AND claude (opencode reads project `.claude/skills/`
216
+ * too). The body names no host-specific hook/plugin events — the harness
217
+ * integration (npm plugin for opencode, hooks for claude) injects the
218
+ * current state into context every turn, and everything else goes through
219
+ * the host-agnostic skillstate MCP tools.
220
+ */
221
+ export function buildSkillMd(spec) {
268
222
  return `---
269
223
  name: skillstate
270
224
  description: ${JSON.stringify(SKILL_DESCRIPTION)}
271
225
  ---
272
- ${body}`;
226
+
227
+ # ${spec.name}
228
+
229
+ ${spec.instructions}
230
+
231
+ ## Execution model (state-based)
232
+
233
+ - The session state lives at \`./.skillstate/skillstate.json\`; the procedure
234
+ spec lives at \`./skill-spec.json\`.
235
+ - The harness (plugin or hooks) injects the CURRENT state into your context
236
+ every turn. The injected state is authoritative — conversation history is
237
+ not. Never reconstruct execution context from the conversation.
238
+ - One state file per session: the injected state and the skillstate MCP
239
+ tools address THE SAME file — never reconstruct or duplicate it.
240
+
241
+ ## Process
242
+
243
+ 1. Orient yourself: read the injected state, or call the skillstate MCP
244
+ tools \`state.summary\` (compact) / \`state.get\` (full dump).
245
+ 2. Observe the result of your last action and reason about the next step.
246
+ 3. Persist progress with the skillstate MCP tool \`state.patch\` (sparse
247
+ patch), and/or end your response with a fenced JSON block carrying
248
+ exactly two keys so the harness persists it:
249
+
250
+ ${STATE_PATCH_EXAMPLE_JSON}
251
+
252
+ - ${STATE_PATCH_RULES}
253
+ - \`action\` names what you will do next (e.g. "continue", "done").
254
+ - Reasoning and history are discarded — put anything you need to survive
255
+ into \`state_patch\`.
256
+
257
+ 4. Risky or hard-to-undo step? Call \`state.checkpoint\` before it and
258
+ \`state.rollback\` after a failure to return to the checkpoint.
259
+ 5. When the procedure is done, call \`state.finalize\` with
260
+ \`{ "status": "completed" }\` (\`"failed"\` on failure).
261
+
262
+ ## Sub-agents
263
+
264
+ Sub-agent sessions get isolated state copies under the state directory.
265
+ List them with \`agent.list\`, read one with \`agent.read\`, and merge a
266
+ finished sub-agent's results back with \`agent.merge\`.
267
+ `;
273
268
  }
274
- /**
275
- * Skillstate MCP entry shaped like the host's existing local-server entries.
276
- * No environment is written — the server resolves the state from its own
277
- * cwd at startup (`<cwd>/.skillstate/skillstate.json`).
278
- */
269
+ /** Skillstate MCP entry shaped like OpenCode's local-server entries (`npx @skillstate/mcp`). */
279
270
  export function buildMcpEntry() {
280
- const cmd = resolveMcpCommand();
281
271
  return {
282
272
  type: 'local',
283
- command: [cmd.command, ...cmd.args],
273
+ command: [MCP_NPX_COMMAND, ...MCP_NPX_ARGS],
284
274
  enabled: true,
285
275
  };
286
276
  }
287
- /**
288
- * Claude Code `.mcp.json` entry (2.1.260 wire format: `type: "stdio"`,
289
- * `command` is a string, args go to the separate `args` array). No
290
- * environment is written — the server resolves the per-project state from
291
- * its own cwd at startup.
292
- */
277
+ /** Claude Code `.mcp.json` entry (stdio wire format, `npx @skillstate/mcp`). */
293
278
  export function buildClaudeMcpEntry() {
294
- const cmd = resolveMcpCommand();
295
279
  return {
296
280
  type: 'stdio',
297
- command: cmd.command,
298
- args: cmd.args,
281
+ command: MCP_NPX_COMMAND,
282
+ args: [...MCP_NPX_ARGS],
299
283
  };
300
284
  }
301
- /**
302
- * `[mcp_servers.skillstate]` TOML block for `~/.codex/config.toml` (codex
303
- * 0.142 wire format: `command` is a string, args go to the separate `args`
304
- * array). The server resolves the per-project state from the session cwd,
305
- * so no `SKILLSTATE_STATE_PATH` is pinned.
306
- */
285
+ /** `[mcp_servers.skillstate]` TOML block for `~/.codex/config.toml` (`npx @skillstate/mcp`). */
307
286
  export function buildCodexMcpToml() {
308
- const cmd = resolveMcpCommand();
309
- const args = cmd.args.map((part) => JSON.stringify(part)).join(', ');
310
287
  return [
311
288
  '[mcp_servers.skillstate]',
312
- `command = ${JSON.stringify(cmd.command)}`,
313
- `args = [${args}]`,
289
+ `command = ${JSON.stringify(MCP_NPX_COMMAND)}`,
290
+ `args = [${MCP_NPX_ARGS.map((part) => JSON.stringify(part)).join(', ')}]`,
314
291
  'enabled = true',
315
292
  '',
316
293
  ].join('\n');
@@ -349,162 +326,223 @@ export function removeSkillstateMcp(configText) {
349
326
  return removeObjectEntry(configText, mcp.valueStart, 'skillstate');
350
327
  }
351
328
  /**
352
- * Full one-shot install for the detected (or forced) host. Never throws for
329
+ * Splice `"@skillstate/opencode"` into the top-level `plugins` array of
330
+ * OpenCode config text (creating the key when missing).
331
+ *
332
+ * OpenCode v2 renamed the config key from `plugin` to `plugins` and changed
333
+ * the entry shape; a v1 plugin does not run in v2 at all. A config written
334
+ * by an older skillstate therefore carries BOTH keys. This writes the v2
335
+ * entry and migrates the legacy one: the `plugin` array loses the
336
+ * skillstate string, and the key is removed when nothing else is left in
337
+ * it, so a project is never left with a dead v1 reference.
338
+ *
339
+ * A `plugins` key that is not an array is left untouched and reported via
340
+ * `pluginSkipped` — rewriting a value the installer does not understand
341
+ * would be worse than skipping it.
342
+ */
343
+ function spliceOpencodePlugin(text) {
344
+ const root = findTopLevelObject(text);
345
+ if (root === null) {
346
+ return { text, changed: false, pluginSkipped: false };
347
+ }
348
+ // `root` is known non-null here (checked above), so every insertion point
349
+ // below is a real offset. Re-scanning after each mutation is required:
350
+ // the offsets shift as text is spliced in or out.
351
+ const reparse = (value) => findTopLevelObject(value);
352
+ let next = text;
353
+ let changed = false;
354
+ const plugins = root.entries.find((e) => e.key === 'plugins');
355
+ if (plugins === undefined) {
356
+ const inserted = insertObjectEntry(next, root.braceStart, 'plugins', JSON.stringify(['@skillstate/opencode']));
357
+ next = inserted.text;
358
+ changed = inserted.changed;
359
+ }
360
+ else if (next[plugins.valueStart] === '[') {
361
+ const result = insertArrayStringEntry(next, plugins.valueStart, '@skillstate/opencode');
362
+ next = result.text;
363
+ changed = result.changed;
364
+ }
365
+ else {
366
+ return { text, changed: false, pluginSkipped: true };
367
+ }
368
+ // Legacy v1 `plugin` key: take our entry out, then drop the key when
369
+ // nothing of the user's is left in it. An empty `plugin: []` is worse
370
+ // than no key at all — it looks configured and loads nothing. Anything the
371
+ // user added stays.
372
+ const afterInsert = reparse(next);
373
+ const legacy = afterInsert?.entries.find((e) => e.key === 'plugin');
374
+ if (legacy !== undefined && next[legacy.valueStart] === '[') {
375
+ const removed = removeArrayStringEntry(next, legacy.valueStart, '@skillstate/opencode');
376
+ next = removed.text;
377
+ changed = changed || removed.changed;
378
+ const rootNow = reparse(next);
379
+ const legacyNow = rootNow?.entries.find((e) => e.key === 'plugin');
380
+ if (rootNow !== null &&
381
+ rootNow !== undefined &&
382
+ legacyNow !== undefined &&
383
+ scanArray(next, legacyNow.valueStart).elements.length === 0) {
384
+ next = removeObjectEntry(next, rootNow.braceStart, 'plugin').text;
385
+ changed = true;
386
+ }
387
+ }
388
+ return { text: next, changed, pluginSkipped: false };
389
+ }
390
+ /** Project-local OpenCode config: existing `.jsonc`, else `.json`, else the created `.json`. */
391
+ function resolveProjectOpencodeConfig(cwd) {
392
+ const jsonc = path.join(cwd, 'opencode.jsonc');
393
+ if (fs.existsSync(jsonc)) {
394
+ return { configPath: jsonc, existed: true };
395
+ }
396
+ const json = path.join(cwd, 'opencode.json');
397
+ return { configPath: json, existed: fs.existsSync(json) };
398
+ }
399
+ /**
400
+ * Read a previous project manifest's `hosts` so a re-init MERGES instead of
401
+ * clobbering (multi-host accumulation). Corrupt/wrong-shape manifests are
402
+ * treated as absent.
403
+ */
404
+ function previousProjectHosts(manifestAbs) {
405
+ try {
406
+ const parsed = JSON.parse(fs.readFileSync(manifestAbs, 'utf-8'));
407
+ if (isRecord(parsed) && parsed['version'] === 2 && isRecord(parsed['hosts'])) {
408
+ return parsed['hosts'];
409
+ }
410
+ }
411
+ catch {
412
+ // Absent or corrupt → treated as absent.
413
+ }
414
+ return {};
415
+ }
416
+ /**
417
+ * Full one-shot project wiring for EVERY detected host. Never throws for
353
418
  * expected conditions; returns a process exit code (0 ok, 1 no host).
354
419
  */
355
420
  export async function autoInstall(options) {
356
421
  const { cwd, home, flags } = options;
422
+ const hosts = options.hosts ?? detectHosts(home);
423
+ if (hosts.length === 0) {
424
+ console.error('No supported host detected (~/.config/opencode, ~/.claude, ~/.codex). Install one, then re-run `skillstate init`.');
425
+ return 1;
426
+ }
357
427
  if (isInsideTemp(cwd)) {
358
428
  console.warn('[skillstate] installing from a temp directory — is this intended?');
359
429
  }
360
- const host = flags.host ?? detectHost(home);
361
- if (host === null) {
362
- console.error('No supported host detected (~/.config/opencode, ~/.claude, ~/.codex). Install one or pass --host.');
363
- return 1;
364
- }
365
430
  const dry = flags.dryRun;
366
431
  const say = (line) => {
367
432
  console.log(dry ? `[dry-run] ${line}` : line);
368
433
  };
369
- const maxHistory = flags.maxHistory ?? 3;
370
434
  const spec = options.spec ?? resolveInitSpec(cwd, flags);
435
+ // Project state envelope (the glue resolves the state from its own cwd).
371
436
  const stateDir = path.join(cwd, STATE_DIR_NAME);
372
437
  const stateAbs = path.join(stateDir, 'skillstate.json');
373
- const stateCreated = !dry && !fs.existsSync(stateAbs);
374
- if (!dry) {
375
- fs.mkdirSync(path.dirname(stateAbs), { recursive: true });
376
- if (stateCreated) {
377
- await atomicWriteFile(stateAbs, `${JSON.stringify({ version: 1, state: {} }, null, 2)}\n`);
378
- }
438
+ const stateCreated = !fs.existsSync(stateAbs);
439
+ if (!dry && stateCreated) {
440
+ await atomicWriteFile(stateAbs, `${JSON.stringify({ version: 1, state: {} }, null, 2)}\n`);
379
441
  }
380
- say(`host: ${host}${flags.host === undefined ? ' (detected)' : ''}`);
381
442
  say(`state: ${stateAbs}${stateCreated ? ' (created)' : ''}`);
382
- // Preserve fields recorded by a previous install (idempotent re-init must
383
- // not lose e.g. the mcp record when the entry is already registered).
384
- let previous = {};
385
- const previousManifestPath = path.join(stateDir, MANIFEST_FILE_NAME);
386
- if (fs.existsSync(previousManifestPath)) {
387
- try {
388
- previous = JSON.parse(fs.readFileSync(previousManifestPath, 'utf-8'));
389
- }
390
- catch {
391
- previous = {};
443
+ // Procedure spec written into the project so the whole team shares it.
444
+ const specAbs = path.join(cwd, 'skill-spec.json');
445
+ if (fs.existsSync(specAbs)) {
446
+ say('skill-spec.json already exists');
447
+ }
448
+ else {
449
+ if (!dry) {
450
+ await atomicWriteFile(specAbs, `${JSON.stringify(spec, null, 2)}\n`);
392
451
  }
452
+ say(`Created skill-spec.json (${spec.id})`);
393
453
  }
454
+ say(`host(s): ${hosts.join(', ')}`);
455
+ // Preserve host records from a previous install (multi-host accumulation).
456
+ const manifestAbs = path.join(stateDir, MANIFEST_FILE_NAME);
394
457
  const manifest = {
395
- version: 1,
396
- host,
458
+ version: 2,
397
459
  installedAt: new Date().toISOString(),
398
460
  statePath: stateAbs,
399
- maxHistoryMessages: maxHistory,
400
- ...(previous.mcp !== undefined ? { mcp: previous.mcp } : {}),
401
- ...(previous.hooks !== undefined ? { hooks: previous.hooks } : {}),
461
+ hosts: { ...previousProjectHosts(manifestAbs) },
402
462
  };
403
- if (host === 'opencode') {
404
- const pluginAbs = path.join(home, '.config', 'opencode', 'plugins', 'skillstate.ts');
463
+ // ONE host-neutral skill file serves opencode AND claude.
464
+ if (hosts.includes('opencode') || hosts.includes('claude')) {
465
+ const skillAbs = path.join(cwd, '.claude', 'skills', 'skillstate', 'SKILL.md');
405
466
  if (!dry) {
406
- // Thin loader: imports the static plugin from @skillstate/opencode
407
- // (single source of truth); the plugin itself resolves the state from
408
- // the host session's cwd on every hook call.
409
- const adapter = new OpenCodeAdapter();
410
- await adapter.savePluginCode(pluginAbs, { maxHistoryMessages: maxHistory });
411
- }
412
- say(`plugin: ${pluginAbs} (auto-loaded from plugins/)`);
413
- manifest.pluginPath = pluginAbs;
414
- }
415
- if (host === 'codex') {
416
- const hooksDir = path.join(home, '.codex', 'hooks', 'skillstate');
417
- const hooksConfigPath = path.join(home, '.codex', 'hooks.json');
418
- const codex = new CodexAdapter();
419
- if (!dry) {
420
- for (const event of CODEX_HOOK_EVENTS) {
421
- await codex.saveHookScript(event, codex.codexHookScriptPath(hooksDir, event));
467
+ await atomicWriteFile(skillAbs, buildSkillMd(spec));
468
+ }
469
+ say(`skill: ${skillAbs}`);
470
+ manifest.skillPath = skillAbs;
471
+ }
472
+ if (hosts.includes('opencode')) {
473
+ const { configPath, existed } = resolveProjectOpencodeConfig(cwd);
474
+ const text = existed ? fs.readFileSync(configPath, 'utf-8') : '{\n}\n';
475
+ const pluginResult = spliceOpencodePlugin(text);
476
+ let next = pluginResult.text;
477
+ let changed = pluginResult.changed;
478
+ if (pluginResult.pluginSkipped) {
479
+ say(`opencode: plugins key in ${configPath} is not an array — skipped plugin registration`);
480
+ }
481
+ // The `mcp.skillstate` entry is NOT registered for opencode.
482
+ //
483
+ // The v2 plugin contributes native tools, which is the same capability
484
+ // with a typed schema, a structured result and no JSON-RPC round-trip.
485
+ // Registering the MCP server as well would cost ~1.6k tokens of tool
486
+ // description on EVERY model request, and — worse — the two surfaces
487
+ // disagree about what may be written. The native tools are
488
+ // schema-free; the MCP server validates against the procedural spec.
489
+ // Writing `{decision: ...}` succeeds through the plugin and is rejected
490
+ // by `state.patch` with "Unknown key", over one file.
491
+ //
492
+ // The MCP server is still shipped and still registered for claude,
493
+ // codex and any other MCP-capable host, which is where it is the only
494
+ // way in. It is simply redundant here, and redundantly harmful.
495
+ const mcpResult = removeSkillstateMcp(next);
496
+ if (mcpResult.changed) {
497
+ next = mcpResult.text;
498
+ changed = true;
499
+ }
500
+ if (changed) {
501
+ if (existed) {
502
+ const backup = backupPathFor(configPath);
503
+ if (!dry) {
504
+ await atomicWriteFile(backup, text);
505
+ }
506
+ say(`backup: ${backup}`);
422
507
  }
423
- // Merge the skillstate hook groups into the user hooks.json (backup first).
424
- let existing = '{\n "hooks": {}\n}\n';
425
- if (fs.existsSync(hooksConfigPath)) {
426
- const backup = backupPathFor(hooksConfigPath);
427
- await atomicWriteFile(backup, fs.readFileSync(hooksConfigPath, 'utf-8'));
428
- existing = fs.readFileSync(hooksConfigPath, 'utf-8');
429
- manifest.hooksBackup = backup;
508
+ if (!dry) {
509
+ await atomicWriteFile(configPath, next);
430
510
  }
431
- await atomicWriteFile(hooksConfigPath, codex.mergeHooksConfig(existing, { maxHistoryMessages: maxHistory, scriptDir: hooksDir }));
432
511
  }
433
- say(`hooks: ${hooksConfigPath} + ${hooksDir}/ (*.cjs)`);
434
- manifest.pluginPath = hooksConfigPath;
512
+ say(`opencode: ${configPath} (${changed ? 'plugin registered' : 'already registered'})`);
513
+ manifest.hosts['opencode'] = { config: { configPath } };
435
514
  }
436
- if (host === 'claude') {
437
- const hooksDir = path.join(home, '.claude', 'hooks', 'skillstate');
438
- const settingsPath = path.join(home, '.claude', 'settings.json');
515
+ if (hosts.includes('claude')) {
516
+ const hooksDir = path.join(cwd, '.claude', 'hooks', 'skillstate');
517
+ const settingsPath = path.join(cwd, '.claude', 'settings.json');
439
518
  const claude = new ClaudeAdapter();
440
519
  if (!dry) {
441
- // Self-contained .cjs scripts — one global script dir serves every
442
- // project (each script resolves the state from the session cwd).
520
+ // Self-contained .cjs scripts — project-local, inert without state.
443
521
  for (const event of CLAUDE_HOOK_EVENTS) {
444
522
  await claude.saveHookScript(event, claude.claudeHookScriptPath(hooksDir, event));
445
523
  }
446
- // Merge the skillstate hook groups into the user settings.json.
447
- // A backup is written only when the merge actually changes the file,
448
- // so re-init stays byte-idempotent and never spams backups.
524
+ // Merge the skillstate hook groups into the project settings.json with
525
+ // $CLAUDE_PROJECT_DIR-anchored commands. A backup is written only when
526
+ // the merge actually changes the file, so re-init stays byte-idempotent.
449
527
  let existing = '{\n "hooks": {}\n}\n';
450
528
  const hadSettings = fs.existsSync(settingsPath);
451
529
  if (hadSettings) {
452
530
  existing = fs.readFileSync(settingsPath, 'utf-8');
453
531
  }
454
- const merged = claude.mergeHooksConfig(existing, { scriptDir: hooksDir });
532
+ const merged = claude.mergeHooksConfig(existing, {
533
+ scriptDir: hooksDir,
534
+ commandFor: (event) => `node "$CLAUDE_PROJECT_DIR/.claude/hooks/skillstate/${event}.cjs" ${event}`,
535
+ });
455
536
  if (merged !== existing) {
456
537
  if (hadSettings) {
457
538
  const backup = backupPathFor(settingsPath);
458
539
  await atomicWriteFile(backup, existing);
459
- manifest.hooksBackup = backup;
540
+ say(`backup: ${backup}`);
460
541
  }
461
542
  await atomicWriteFile(settingsPath, merged);
462
543
  }
463
544
  }
464
545
  say(`hooks: ${settingsPath} + ${hooksDir}/ (*.cjs)`);
465
- manifest.hooks = { configPath: settingsPath, scriptDir: hooksDir };
466
- }
467
- if (!flags.noSkill) {
468
- const skillAbs = path.join(skillDirFor(host, home), 'SKILL.md');
469
- if (!dry) {
470
- // The state file always lives inside cwd, so the SKILL.md reference is
471
- // always relative.
472
- await atomicWriteFile(skillAbs, buildSkillMd('./.skillstate/skillstate.json', spec, host));
473
- }
474
- say(`skill: ${skillAbs}`);
475
- manifest.skillPath = skillAbs;
476
- }
477
- else {
478
- say('skill: skipped (--no-skill)');
479
- }
480
- if (flags.noMcp) {
481
- say('mcp: skipped (--no-mcp)');
482
- }
483
- else if (host === 'opencode') {
484
- const configPath = resolveOpencodeConfig(home);
485
- let text;
486
- try {
487
- text = fs.readFileSync(configPath, 'utf-8');
488
- }
489
- catch {
490
- text = '{\n}\n';
491
- }
492
- const result = addSkillstateMcp(text, buildMcpEntry());
493
- if (result.changed) {
494
- const backup = backupPathFor(configPath);
495
- if (!dry) {
496
- await atomicWriteFile(backup, text);
497
- await atomicWriteFile(configPath, result.text);
498
- }
499
- say(`mcp: ${configPath} (skillstate server added)`);
500
- say(`mcp backup: ${backup}`);
501
- manifest.mcp = { configPath, format: 'opencode-jsonc' };
502
- }
503
- else {
504
- say(`mcp: ${configPath} (skillstate already registered)`);
505
- }
506
- }
507
- else if (host === 'claude') {
508
546
  const mcpJson = path.join(cwd, '.mcp.json');
509
547
  let doc = {};
510
548
  if (fs.existsSync(mcpJson)) {
@@ -525,55 +563,298 @@ export async function autoInstall(options) {
525
563
  await atomicWriteFile(mcpJson, `${JSON.stringify({ mcpServers: { ...servers, skillstate: buildClaudeMcpEntry() } }, null, 2)}\n`);
526
564
  }
527
565
  say(`mcp: ${mcpJson} (skillstate server added)`);
528
- manifest.mcp = { configPath: mcpJson, format: 'claude-mcp-json' };
529
566
  }
530
567
  else {
531
568
  say(`mcp: ${mcpJson} (skillstate already registered)`);
532
569
  }
570
+ manifest.hosts['claude'] = {
571
+ hooks: { configPath: settingsPath, scriptDir: hooksDir },
572
+ mcp: { configPath: mcpJson, format: 'claude-mcp-json' },
573
+ };
574
+ }
575
+ if (hosts.includes('codex')) {
576
+ // Codex has no project config support — the glue is machine-level.
577
+ say('codex: machine-level glue — run `skillstate install` once (project state is picked up automatically)');
578
+ }
579
+ if (!dry) {
580
+ await atomicWriteFile(manifestAbs, `${JSON.stringify(manifest, null, 2)}\n`);
581
+ }
582
+ say(`manifest: ${manifestAbs}`);
583
+ say(dry
584
+ ? 'dry run complete — nothing was written.'
585
+ : `Done. Wired for: ${hosts.join(', ')}. Open your harness in this project — skill, hooks, and MCP are project-local.`);
586
+ return 0;
587
+ }
588
+ /**
589
+ * Machine-level install (`skillstate install`): codex ONLY. Writes the
590
+ * self-contained `.cjs` hook scripts into `~/.codex/hooks/skillstate/`,
591
+ * merges the skillstate hook groups into `~/.codex/hooks.json`, and appends
592
+ * the `[mcp_servers.skillstate]` table to `~/.codex/config.toml`. Every
593
+ * script resolves the per-project state from the session cwd, so one
594
+ * machine install serves every project. Idempotent (re-merging hooks is a
595
+ * no-op; the TOML table is appended only when absent). opencode/claude glue
596
+ * is project-local and belongs to `skillstate init`. Returns a process exit
597
+ * code (always 0).
598
+ */
599
+ export async function installMachine(options) {
600
+ const { home, flags } = options;
601
+ const dry = flags.dryRun;
602
+ const say = (line) => {
603
+ console.log(dry ? `[dry-run] ${line}` : line);
604
+ };
605
+ const hooksDir = path.join(home, '.codex', 'hooks', 'skillstate');
606
+ const hooksConfigPath = path.join(home, '.codex', 'hooks.json');
607
+ const configToml = path.join(home, '.codex', 'config.toml');
608
+ const codex = new CodexAdapter();
609
+ if (!dry) {
610
+ for (const event of CODEX_HOOK_EVENTS) {
611
+ await codex.saveHookScript(event, codex.codexHookScriptPath(hooksDir, event));
612
+ }
613
+ // Merge the skillstate hook groups into the user hooks.json (backup first).
614
+ let existing = '{\n "hooks": {}\n}\n';
615
+ if (fs.existsSync(hooksConfigPath)) {
616
+ const backup = backupPathFor(hooksConfigPath);
617
+ await atomicWriteFile(backup, fs.readFileSync(hooksConfigPath, 'utf-8'));
618
+ existing = fs.readFileSync(hooksConfigPath, 'utf-8');
619
+ }
620
+ await atomicWriteFile(hooksConfigPath, codex.mergeHooksConfig(existing, { scriptDir: hooksDir }));
621
+ }
622
+ say(`hooks: ${hooksConfigPath} + ${hooksDir}/ (*.cjs)`);
623
+ // [mcp_servers.skillstate] appended only when the table is absent.
624
+ let toml = '';
625
+ if (fs.existsSync(configToml)) {
626
+ toml = fs.readFileSync(configToml, 'utf-8');
627
+ }
628
+ if (toml.includes('[mcp_servers.skillstate]')) {
629
+ say(`mcp: ${configToml} (skillstate already registered)`);
533
630
  }
534
631
  else {
535
- // Codex: [mcp_servers.skillstate] in ~/.codex/config.toml (idempotent).
536
- const configToml = path.join(home, '.codex', 'config.toml');
632
+ const next = `${toml.replace(/\s*$/, '')}\n\n${buildCodexMcpToml()}`;
633
+ if (!dry) {
634
+ if (fs.existsSync(configToml)) {
635
+ const backup = backupPathFor(configToml);
636
+ await atomicWriteFile(backup, toml);
637
+ }
638
+ await atomicWriteFile(configToml, next);
639
+ }
640
+ say(`mcp: ${configToml} ([mcp_servers.skillstate] added)`);
641
+ }
642
+ say('opencode/claude: nothing to install machine-wide — glue is project-local (`skillstate init`).');
643
+ // Machine manifest under <home>/.skillstate (idempotent: re-run updates it).
644
+ const manifestPath = path.join(home, '.skillstate', MANIFEST_FILE_NAME);
645
+ const machineManifest = {
646
+ version: 1,
647
+ installedAt: new Date().toISOString(),
648
+ codex: { hooksConfigPath, scriptDir: hooksDir, tomlConfigPath: configToml },
649
+ };
650
+ if (!dry) {
651
+ await atomicWriteFile(manifestPath, `${JSON.stringify(machineManifest, null, 2)}\n`);
652
+ }
653
+ say(`manifest: ${manifestPath}`);
654
+ say(dry
655
+ ? 'dry run complete — nothing was written.'
656
+ : 'Done. Codex glue installed (~/.codex). Project wiring: run `skillstate init` in your project.');
657
+ return 0;
658
+ }
659
+ /**
660
+ * Machine-level rollback (`uninstall --machine`): read the machine manifest
661
+ * under `home`, remove the skillstate hook groups from `~/.codex/hooks.json`
662
+ * (surgically — foreign hooks survive), delete the generated script dir,
663
+ * drop the `[mcp_servers.skillstate]` TOML table, and delete the manifest.
664
+ * Returns a process exit code (0 ok, 1 missing/corrupt manifest).
665
+ */
666
+ async function uninstallMachine(home, dry, say) {
667
+ const manifestPath = path.join(home, '.skillstate', MANIFEST_FILE_NAME);
668
+ let raw;
669
+ try {
670
+ raw = fs.readFileSync(manifestPath, 'utf-8');
671
+ }
672
+ catch {
673
+ console.error(`No machine install manifest at ${manifestPath} — nothing to uninstall.`);
674
+ return 1;
675
+ }
676
+ let manifest = null;
677
+ try {
678
+ const parsed = JSON.parse(raw);
679
+ if (isRecord(parsed) &&
680
+ parsed['version'] === 1 &&
681
+ isRecord(parsed['codex']) &&
682
+ typeof parsed['codex']['hooksConfigPath'] === 'string' &&
683
+ typeof parsed['codex']['scriptDir'] === 'string' &&
684
+ typeof parsed['codex']['tomlConfigPath'] === 'string') {
685
+ manifest = parsed;
686
+ }
687
+ }
688
+ catch {
689
+ manifest = null;
690
+ }
691
+ if (manifest === null) {
692
+ console.error(`Corrupt machine install manifest at ${manifestPath}`);
693
+ return 1;
694
+ }
695
+ const codex = manifest.codex;
696
+ // Hooks: hooks.json is LIVE (foreign hooks must survive), so skillstate
697
+ // groups are removed surgically instead of restoring a backup.
698
+ if (fs.existsSync(codex.hooksConfigPath)) {
699
+ let text = '';
700
+ try {
701
+ text = fs.readFileSync(codex.hooksConfigPath, 'utf-8');
702
+ }
703
+ catch {
704
+ text = '';
705
+ }
706
+ const result = text ? removeSkillstateHookGroups(text) : { text: '', changed: false };
707
+ if (result.changed) {
708
+ const backup = backupPathFor(codex.hooksConfigPath);
709
+ if (!dry) {
710
+ await atomicWriteFile(backup, text);
711
+ await atomicWriteFile(codex.hooksConfigPath, result.text);
712
+ }
713
+ say(`removed hooks: ${codex.hooksConfigPath} (backup: ${backup})`);
714
+ }
715
+ }
716
+ if (fs.existsSync(codex.scriptDir)) {
717
+ if (!dry) {
718
+ fs.rmSync(codex.scriptDir, { recursive: true, force: true });
719
+ }
720
+ say(`removed hook scripts: ${codex.scriptDir}`);
721
+ }
722
+ // TOML: drop the [mcp_servers.skillstate] table.
723
+ if (fs.existsSync(codex.tomlConfigPath)) {
537
724
  let toml = '';
538
- if (fs.existsSync(configToml)) {
539
- const backup = backupPathFor(configToml);
540
- await atomicWriteFile(backup, fs.readFileSync(configToml, 'utf-8'));
541
- toml = fs.readFileSync(configToml, 'utf-8');
542
- manifest.hooksBackup = backup;
725
+ try {
726
+ toml = fs.readFileSync(codex.tomlConfigPath, 'utf-8');
543
727
  }
544
- const serverBlock = buildCodexMcpToml();
545
- if (toml.includes('[mcp_servers.skillstate]')) {
546
- say(`mcp: ${configToml} (skillstate already registered)`);
728
+ catch {
729
+ toml = '';
547
730
  }
548
- else {
549
- const next = `${toml.replace(/\s*$/, '')}\n\n${serverBlock}`;
731
+ const match = toml.match(/\n?\[mcp_servers\.skillstate\][^[]*/);
732
+ if (match !== null) {
733
+ const backup = backupPathFor(codex.tomlConfigPath);
550
734
  if (!dry) {
551
- await atomicWriteFile(configToml, next);
735
+ await atomicWriteFile(backup, toml);
736
+ await atomicWriteFile(codex.tomlConfigPath, toml.replace(match[0], '\n'));
552
737
  }
553
- say(`mcp: ${configToml} ([mcp_servers.skillstate] added)`);
554
- manifest.mcp = { configPath: configToml, format: 'codex-toml' };
738
+ say(`removed mcp entry: ${codex.tomlConfigPath} (backup: ${backup})`);
555
739
  }
556
740
  }
557
- const manifestAbs = path.join(stateDir, MANIFEST_FILE_NAME);
558
741
  if (!dry) {
559
- await atomicWriteFile(manifestAbs, `${JSON.stringify(manifest, null, 2)}\n`);
742
+ fs.rmSync(manifestPath, { force: true });
560
743
  }
561
- say(`manifest: ${manifestAbs}`);
562
- say(dry ? 'dry run complete — nothing was written.' : 'Done. Next: `skillstate run` in this project, then open your host.');
744
+ say(`removed manifest: ${manifestPath}`);
745
+ say('Machine glue removed.');
563
746
  return 0;
564
747
  }
565
748
  /**
566
- * Roll back exactly what an install recorded in the manifest: plugin file,
567
- * SKILL.md, `mcp.skillstate` entry (with a config backup), and optionally
568
- * the whole state directory. Returns a process exit code (0 ok, 1 no/manifest
569
- * unreadable).
749
+ * Splice the `"@skillstate/opencode"` string out of BOTH the v2 `plugins`
750
+ * array and the legacy v1 `plugin` array (leaving the rest of the JSONC
751
+ * intact). Both are removed because a project may have been initialised by
752
+ * either version, and uninstall must leave neither behind.
753
+ */
754
+ function spliceOutOpencodePlugin(text) {
755
+ let next = text;
756
+ let changed = false;
757
+ for (const key of ['plugins', 'plugin']) {
758
+ const entry = findTopLevelObject(next)?.entries.find((e) => e.key === key);
759
+ if (entry === undefined || next[entry.valueStart] !== '[')
760
+ continue;
761
+ const removed = removeArrayStringEntry(next, entry.valueStart, '@skillstate/opencode');
762
+ next = removed.text;
763
+ changed = changed || removed.changed;
764
+ }
765
+ return { text: next, changed };
766
+ }
767
+ /**
768
+ * Drop the top-level `mcp`/`plugin` entries when their containers became
769
+ * empty after the skillstate splice-out — an init-created config (nothing
770
+ * but skillstate glue) reduces to `{}` so the uninstall path can delete the
771
+ * file. Pre-existing empty containers are dropped as well.
772
+ */
773
+ function dropEmptyOpencodeEntries(text) {
774
+ let next = text;
775
+ for (const key of ['mcp', 'plugins', 'plugin']) {
776
+ const root = findTopLevelObject(next);
777
+ const entry = root?.entries.find((e) => e.key === key);
778
+ if (entry === undefined) {
779
+ continue;
780
+ }
781
+ const open = next[entry.valueStart];
782
+ const emptyObject = open === '{' && scanObject(next, entry.valueStart).entries.length === 0;
783
+ const emptyArray = open === '[' && scanArray(next, entry.valueStart).elements.length === 0;
784
+ if (emptyObject || emptyArray) {
785
+ next = removeObjectEntry(next, root.braceStart, key).text;
786
+ }
787
+ }
788
+ return next;
789
+ }
790
+ /**
791
+ * The project OpenCode config path recorded in a manifest, from either the
792
+ * v2 `config` record or the legacy v1 `mcp` one. A manifest written before
793
+ * the native-tools rewrite has no `config` key, and uninstall still has to
794
+ * find its config to clean up.
795
+ */
796
+ function opencodeConfigPathOf(host) {
797
+ if (!isRecord(host))
798
+ return undefined;
799
+ for (const key of ['config', 'mcp']) {
800
+ const record = host[key];
801
+ if (isRecord(record) && typeof record['configPath'] === 'string' && record['configPath'].length > 0) {
802
+ return record['configPath'];
803
+ }
804
+ }
805
+ return undefined;
806
+ }
807
+ /** True when a manifest `hosts` record carries well-shaped host entries. */
808
+ function isValidHosts(hosts) {
809
+ if (!isRecord(hosts)) {
810
+ return false;
811
+ }
812
+ const opencode = hosts['opencode'];
813
+ if (opencode !== undefined) {
814
+ // Accept the v2 record (`config.configPath`) and the legacy v1 one
815
+ // (`mcp.configPath`), so an uninstall can still clean up a project that
816
+ // was initialised before the native-tools rewrite.
817
+ const mcp = isRecord(opencode)
818
+ ? isRecord(opencode['config'])
819
+ ? opencode['config']
820
+ : isRecord(opencode['mcp'])
821
+ ? opencode['mcp']
822
+ : undefined
823
+ : undefined;
824
+ if (!isRecord(mcp) || typeof mcp['configPath'] !== 'string') {
825
+ return false;
826
+ }
827
+ }
828
+ const claude = hosts['claude'];
829
+ if (claude !== undefined) {
830
+ const hooks = isRecord(claude) ? claude['hooks'] : undefined;
831
+ const mcp = isRecord(claude) ? claude['mcp'] : undefined;
832
+ if (!isRecord(hooks) ||
833
+ typeof hooks['configPath'] !== 'string' ||
834
+ typeof hooks['scriptDir'] !== 'string' ||
835
+ !isRecord(mcp) ||
836
+ typeof mcp['configPath'] !== 'string') {
837
+ return false;
838
+ }
839
+ }
840
+ return true;
841
+ }
842
+ /**
843
+ * Roll back exactly what an install recorded in the manifest: project glue
844
+ * per host record (opencode config splices, claude hooks + scripts + mcp,
845
+ * the shared SKILL.md), and optionally the whole state directory. With
846
+ * `--machine`, rolls the machine-level codex glue back instead. Returns a
847
+ * process exit code (0 ok, 1 no/corrupt manifest).
570
848
  */
571
849
  export async function uninstall(options) {
572
- const { cwd, flags } = options;
850
+ const { cwd, home, flags } = options;
573
851
  const dry = flags.dryRun;
574
852
  const say = (line) => {
575
853
  console.log(dry ? `[dry-run] ${line}` : line);
576
854
  };
855
+ if (flags.machine) {
856
+ return uninstallMachine(home, dry, say);
857
+ }
577
858
  const stateDir = flags.stateDir !== undefined ? resolveInCwd(cwd, flags.stateDir) : path.join(cwd, STATE_DIR_NAME);
578
859
  const manifestAbs = path.join(stateDir, MANIFEST_FILE_NAME);
579
860
  let raw;
@@ -584,106 +865,119 @@ export async function uninstall(options) {
584
865
  console.error(`No install manifest at ${manifestAbs} — nothing to uninstall.`);
585
866
  return 1;
586
867
  }
587
- let manifest;
868
+ let manifest = null;
588
869
  try {
589
- manifest = JSON.parse(raw);
870
+ const parsed = JSON.parse(raw);
871
+ if (isRecord(parsed) &&
872
+ parsed['version'] === 2 &&
873
+ typeof parsed['statePath'] === 'string' &&
874
+ (parsed['skillPath'] === undefined || typeof parsed['skillPath'] === 'string') &&
875
+ isValidHosts(parsed['hosts'])) {
876
+ manifest = parsed;
877
+ }
590
878
  }
591
879
  catch {
592
- console.error(`Corrupt install manifest at ${manifestAbs}`);
593
- return 1;
880
+ manifest = null;
594
881
  }
595
- if (!isRecord(manifest) ||
596
- manifest['version'] !== 1 ||
597
- typeof manifest['host'] !== 'string' ||
598
- typeof manifest['statePath'] !== 'string') {
882
+ if (manifest === null) {
599
883
  console.error(`Corrupt install manifest at ${manifestAbs}`);
600
884
  return 1;
601
885
  }
602
- const m = manifest;
603
- say(`uninstall (${m.host})`);
604
- // Claude hooks: settings.json is LIVE (env/permissions/model/etc must
605
- // survive), so skillstate hook groups are removed surgically instead of
606
- // restoring a backup; the generated `.cjs` script dir is deleted.
607
- if (m.hooks !== undefined) {
608
- if (fs.existsSync(m.hooks.configPath)) {
886
+ say('uninstall (project)');
887
+ // Shared host-neutral skill.
888
+ if (manifest.skillPath !== undefined && fs.existsSync(manifest.skillPath)) {
889
+ if (!dry) {
890
+ fs.rmSync(path.dirname(manifest.skillPath), { recursive: true, force: true });
891
+ }
892
+ say(`removed skill: ${manifest.skillPath}`);
893
+ }
894
+ const hosts = manifest.hosts;
895
+ // opencode: mcp entry + plugin strings spliced out of the project config.
896
+ const opencodeConfigPath = opencodeConfigPathOf(hosts['opencode']);
897
+ if (opencodeConfigPath !== undefined && fs.existsSync(opencodeConfigPath)) {
898
+ const configPath = opencodeConfigPath;
899
+ let text;
900
+ try {
901
+ text = fs.readFileSync(configPath, 'utf-8');
902
+ }
903
+ catch {
904
+ text = '';
905
+ }
906
+ const mcpResult = removeSkillstateMcp(text);
907
+ const pluginResult = spliceOutOpencodePlugin(mcpResult.text);
908
+ if (mcpResult.changed || pluginResult.changed) {
909
+ const next = dropEmptyOpencodeEntries(pluginResult.text);
910
+ const backup = backupPathFor(configPath);
911
+ if (!dry) {
912
+ await atomicWriteFile(backup, text);
913
+ let parsed = null;
914
+ try {
915
+ parsed = parseJsonc(next);
916
+ }
917
+ catch {
918
+ parsed = null;
919
+ }
920
+ if (isRecord(parsed) && Object.keys(parsed).length === 0) {
921
+ // The config only carried skillstate glue — remove the file.
922
+ fs.rmSync(configPath, { force: true });
923
+ say(`removed opencode config: ${configPath} (backup: ${backup})`);
924
+ }
925
+ else {
926
+ await atomicWriteFile(configPath, next);
927
+ say(`removed mcp entry: ${configPath} (backup: ${backup})`);
928
+ }
929
+ }
930
+ }
931
+ }
932
+ // claude: hook groups out of settings.json, scripts dir, .mcp.json entry.
933
+ const claude = hosts['claude'];
934
+ if (claude !== undefined) {
935
+ if (fs.existsSync(claude.hooks.configPath)) {
609
936
  let text;
610
937
  try {
611
- text = fs.readFileSync(m.hooks.configPath, 'utf-8');
938
+ text = fs.readFileSync(claude.hooks.configPath, 'utf-8');
612
939
  }
613
940
  catch {
614
941
  text = '';
615
942
  }
616
943
  const result = text ? removeSkillstateHookGroups(text) : { text: '', changed: false };
617
944
  if (result.changed) {
618
- const backup = backupPathFor(m.hooks.configPath);
945
+ const backup = backupPathFor(claude.hooks.configPath);
619
946
  if (!dry) {
620
947
  await atomicWriteFile(backup, text);
621
- await atomicWriteFile(m.hooks.configPath, result.text);
948
+ await atomicWriteFile(claude.hooks.configPath, result.text);
622
949
  }
623
- say(`removed hooks: ${m.hooks.configPath} (backup: ${backup})`);
950
+ say(`removed hooks: ${claude.hooks.configPath} (backup: ${backup})`);
624
951
  }
625
952
  }
626
- if (fs.existsSync(m.hooks.scriptDir)) {
953
+ if (fs.existsSync(claude.hooks.scriptDir)) {
627
954
  if (!dry) {
628
- fs.rmSync(m.hooks.scriptDir, { recursive: true, force: true });
955
+ fs.rmSync(claude.hooks.scriptDir, { recursive: true, force: true });
629
956
  }
630
- say(`removed hook scripts: ${m.hooks.scriptDir}`);
957
+ say(`removed hook scripts: ${claude.hooks.scriptDir}`);
631
958
  }
632
- }
633
- if (m.pluginPath !== undefined && fs.existsSync(m.pluginPath)) {
634
- if (!dry) {
635
- fs.rmSync(m.pluginPath);
636
- }
637
- say(`removed plugin: ${m.pluginPath}`);
638
- }
639
- if (m.skillPath !== undefined && fs.existsSync(m.skillPath)) {
640
- if (!dry) {
641
- fs.rmSync(m.skillPath);
642
- }
643
- say(`removed skill: ${m.skillPath}`);
644
- }
645
- if (m.mcp !== undefined && fs.existsSync(m.mcp.configPath)) {
646
- const configPath = m.mcp.configPath;
647
- if (m.mcp.format === 'claude-mcp-json') {
959
+ if (fs.existsSync(claude.mcp.configPath)) {
960
+ const mcpPath = claude.mcp.configPath;
648
961
  try {
649
- const doc = JSON.parse(fs.readFileSync(configPath, 'utf-8'));
962
+ const doc = JSON.parse(fs.readFileSync(mcpPath, 'utf-8'));
650
963
  if (isRecord(doc.mcpServers) && doc.mcpServers['skillstate'] !== undefined) {
651
964
  const { ['skillstate']: _removed, ...rest } = doc.mcpServers;
652
- const backup = backupPathFor(configPath);
965
+ const backup = backupPathFor(mcpPath);
653
966
  if (!dry) {
654
- await atomicWriteFile(backup, fs.readFileSync(configPath, 'utf-8'));
655
- await atomicWriteFile(configPath, `${JSON.stringify({ mcpServers: rest }, null, 2)}\n`);
967
+ await atomicWriteFile(backup, fs.readFileSync(mcpPath, 'utf-8'));
968
+ if (Object.keys(rest).length === 0) {
969
+ // The file only carried the skillstate entry — delete it.
970
+ fs.rmSync(mcpPath, { force: true });
971
+ }
972
+ else {
973
+ await atomicWriteFile(mcpPath, `${JSON.stringify({ mcpServers: rest }, null, 2)}\n`);
974
+ }
656
975
  }
657
- say(`removed mcp entry: ${configPath} (backup: ${backup})`);
976
+ say(`removed mcp entry: ${mcpPath} (backup: ${backup})`);
658
977
  }
659
978
  }
660
979
  catch {
661
- console.error(`Skipping mcp: ${configPath} is unreadable`);
662
- }
663
- }
664
- else if (m.mcp.format === 'codex-toml') {
665
- // Drop the [mcp_servers.skillstate] TOML table.
666
- const text = fs.readFileSync(configPath, 'utf-8');
667
- const match = text.match(/\n?\[mcp_servers\.skillstate\][^[]*/);
668
- if (match !== null) {
669
- const backup = backupPathFor(configPath);
670
- if (!dry) {
671
- await atomicWriteFile(backup, text);
672
- await atomicWriteFile(configPath, text.replace(match[0], '\n'));
673
- }
674
- say(`removed mcp entry: ${configPath} (backup: ${backup})`);
675
- }
676
- }
677
- else {
678
- const text = fs.readFileSync(configPath, 'utf-8');
679
- const result = removeSkillstateMcp(text);
680
- if (result.changed) {
681
- const backup = backupPathFor(configPath);
682
- if (!dry) {
683
- await atomicWriteFile(backup, text);
684
- await atomicWriteFile(configPath, result.text);
685
- }
686
- say(`removed mcp entry: ${configPath} (backup: ${backup})`);
980
+ console.error(`Skipping mcp: ${mcpPath} is unreadable`);
687
981
  }
688
982
  }
689
983
  }