@skillstate/cli 2.2.2 → 3.0.0

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