@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/README.md +145 -69
- package/dist/commands.d.ts.map +1 -1
- package/dist/commands.js +13 -32
- package/dist/commands.js.map +1 -1
- package/dist/install.d.ts +104 -72
- package/dist/install.d.ts.map +1 -1
- package/dist/install.js +653 -359
- package/dist/install.js.map +1 -1
- package/dist/jsonc.d.ts +42 -0
- package/dist/jsonc.d.ts.map +1 -1
- package/dist/jsonc.js +109 -0
- package/dist/jsonc.js.map +1 -1
- package/package.json +6 -6
package/dist/install.js
CHANGED
|
@@ -1,43 +1,46 @@
|
|
|
1
|
-
// skillstate host
|
|
1
|
+
// skillstate host wiring (v2 model: project-local glue, machine install).
|
|
2
2
|
//
|
|
3
|
-
//
|
|
4
|
-
//
|
|
5
|
-
//
|
|
6
|
-
// -
|
|
7
|
-
//
|
|
8
|
-
//
|
|
9
|
-
//
|
|
10
|
-
//
|
|
11
|
-
//
|
|
12
|
-
//
|
|
13
|
-
//
|
|
14
|
-
//
|
|
15
|
-
//
|
|
16
|
-
//
|
|
17
|
-
//
|
|
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
|
-
//
|
|
20
|
-
//
|
|
21
|
-
//
|
|
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 {
|
|
27
|
-
import {
|
|
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
|
|
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
|
|
63
|
+
* Returns every match (empty when nothing does) — init wires them all at once.
|
|
60
64
|
*/
|
|
61
|
-
export function
|
|
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
|
-
|
|
71
|
+
detected.push('opencode');
|
|
67
72
|
}
|
|
68
73
|
if (fs.existsSync(path.join(home, '.claude'))) {
|
|
69
|
-
|
|
74
|
+
detected.push('claude');
|
|
70
75
|
}
|
|
71
76
|
if (fs.existsSync(path.join(home, '.codex'))) {
|
|
72
|
-
|
|
77
|
+
detected.push('codex');
|
|
73
78
|
}
|
|
74
|
-
return
|
|
79
|
+
return detected;
|
|
75
80
|
}
|
|
76
81
|
/**
|
|
77
|
-
* Parse `init` flags: `--
|
|
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 === '--
|
|
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
|
|
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 [--
|
|
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),
|
|
226
|
-
*
|
|
227
|
-
*
|
|
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
|
-
/**
|
|
263
|
-
|
|
264
|
-
|
|
265
|
-
|
|
266
|
-
|
|
267
|
-
|
|
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
|
-
|
|
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: [
|
|
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:
|
|
298
|
-
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(
|
|
313
|
-
`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
|
-
*
|
|
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 = !
|
|
374
|
-
if (!dry) {
|
|
375
|
-
|
|
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
|
-
//
|
|
383
|
-
|
|
384
|
-
|
|
385
|
-
|
|
386
|
-
|
|
387
|
-
|
|
388
|
-
|
|
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:
|
|
396
|
-
host,
|
|
458
|
+
version: 2,
|
|
397
459
|
installedAt: new Date().toISOString(),
|
|
398
460
|
statePath: stateAbs,
|
|
399
|
-
|
|
400
|
-
...(previous.mcp !== undefined ? { mcp: previous.mcp } : {}),
|
|
401
|
-
...(previous.hooks !== undefined ? { hooks: previous.hooks } : {}),
|
|
461
|
+
hosts: { ...previousProjectHosts(manifestAbs) },
|
|
402
462
|
};
|
|
403
|
-
|
|
404
|
-
|
|
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
|
-
|
|
407
|
-
|
|
408
|
-
|
|
409
|
-
|
|
410
|
-
|
|
411
|
-
|
|
412
|
-
|
|
413
|
-
|
|
414
|
-
|
|
415
|
-
|
|
416
|
-
|
|
417
|
-
|
|
418
|
-
|
|
419
|
-
|
|
420
|
-
|
|
421
|
-
|
|
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
|
-
|
|
424
|
-
|
|
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(`
|
|
434
|
-
manifest.
|
|
512
|
+
say(`opencode: ${configPath} (${changed ? 'plugin registered' : 'already registered'})`);
|
|
513
|
+
manifest.hosts['opencode'] = { config: { configPath } };
|
|
435
514
|
}
|
|
436
|
-
if (
|
|
437
|
-
const hooksDir = path.join(
|
|
438
|
-
const settingsPath = path.join(
|
|
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 —
|
|
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
|
|
447
|
-
// A backup is written only when
|
|
448
|
-
// so re-init stays byte-idempotent
|
|
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, {
|
|
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
|
-
|
|
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
|
-
|
|
536
|
-
|
|
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
|
-
|
|
539
|
-
|
|
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
|
-
|
|
545
|
-
|
|
546
|
-
say(`mcp: ${configToml} (skillstate already registered)`);
|
|
728
|
+
catch {
|
|
729
|
+
toml = '';
|
|
547
730
|
}
|
|
548
|
-
|
|
549
|
-
|
|
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(
|
|
735
|
+
await atomicWriteFile(backup, toml);
|
|
736
|
+
await atomicWriteFile(codex.tomlConfigPath, toml.replace(match[0], '\n'));
|
|
552
737
|
}
|
|
553
|
-
say(`mcp:
|
|
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
|
-
|
|
742
|
+
fs.rmSync(manifestPath, { force: true });
|
|
560
743
|
}
|
|
561
|
-
say(`manifest: ${
|
|
562
|
-
say(
|
|
744
|
+
say(`removed manifest: ${manifestPath}`);
|
|
745
|
+
say('Machine glue removed.');
|
|
563
746
|
return 0;
|
|
564
747
|
}
|
|
565
748
|
/**
|
|
566
|
-
*
|
|
567
|
-
*
|
|
568
|
-
*
|
|
569
|
-
*
|
|
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
|
-
|
|
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
|
-
|
|
593
|
-
return 1;
|
|
880
|
+
manifest = null;
|
|
594
881
|
}
|
|
595
|
-
if (
|
|
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
|
-
|
|
603
|
-
|
|
604
|
-
|
|
605
|
-
|
|
606
|
-
|
|
607
|
-
|
|
608
|
-
|
|
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(
|
|
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(
|
|
945
|
+
const backup = backupPathFor(claude.hooks.configPath);
|
|
619
946
|
if (!dry) {
|
|
620
947
|
await atomicWriteFile(backup, text);
|
|
621
|
-
await atomicWriteFile(
|
|
948
|
+
await atomicWriteFile(claude.hooks.configPath, result.text);
|
|
622
949
|
}
|
|
623
|
-
say(`removed hooks: ${
|
|
950
|
+
say(`removed hooks: ${claude.hooks.configPath} (backup: ${backup})`);
|
|
624
951
|
}
|
|
625
952
|
}
|
|
626
|
-
if (fs.existsSync(
|
|
953
|
+
if (fs.existsSync(claude.hooks.scriptDir)) {
|
|
627
954
|
if (!dry) {
|
|
628
|
-
fs.rmSync(
|
|
955
|
+
fs.rmSync(claude.hooks.scriptDir, { recursive: true, force: true });
|
|
629
956
|
}
|
|
630
|
-
say(`removed hook scripts: ${
|
|
957
|
+
say(`removed hook scripts: ${claude.hooks.scriptDir}`);
|
|
631
958
|
}
|
|
632
|
-
|
|
633
|
-
|
|
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(
|
|
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(
|
|
965
|
+
const backup = backupPathFor(mcpPath);
|
|
653
966
|
if (!dry) {
|
|
654
|
-
await atomicWriteFile(backup, fs.readFileSync(
|
|
655
|
-
|
|
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: ${
|
|
976
|
+
say(`removed mcp entry: ${mcpPath} (backup: ${backup})`);
|
|
658
977
|
}
|
|
659
978
|
}
|
|
660
979
|
catch {
|
|
661
|
-
console.error(`Skipping mcp: ${
|
|
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
|
}
|