@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/README.md +139 -67
- 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 +649 -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,45 @@
|
|
|
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
|
-
// project
|
|
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"]` 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
|
-
//
|
|
20
|
-
//
|
|
21
|
-
//
|
|
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 {
|
|
27
|
-
import {
|
|
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
|
|
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
|
|
62
|
+
* Returns every match (empty when nothing does) — init wires them all at once.
|
|
60
63
|
*/
|
|
61
|
-
export function
|
|
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
|
-
|
|
70
|
+
detected.push('opencode');
|
|
67
71
|
}
|
|
68
72
|
if (fs.existsSync(path.join(home, '.claude'))) {
|
|
69
|
-
|
|
73
|
+
detected.push('claude');
|
|
70
74
|
}
|
|
71
75
|
if (fs.existsSync(path.join(home, '.codex'))) {
|
|
72
|
-
|
|
76
|
+
detected.push('codex');
|
|
73
77
|
}
|
|
74
|
-
return
|
|
78
|
+
return detected;
|
|
75
79
|
}
|
|
76
80
|
/**
|
|
77
|
-
* Parse `init` flags: `--
|
|
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 === '--
|
|
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
|
|
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 [--
|
|
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),
|
|
226
|
-
*
|
|
227
|
-
*
|
|
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
|
-
/**
|
|
263
|
-
|
|
264
|
-
|
|
265
|
-
|
|
266
|
-
|
|
267
|
-
|
|
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
|
-
|
|
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: [
|
|
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:
|
|
298
|
-
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(
|
|
313
|
-
`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
|
-
*
|
|
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 = !
|
|
374
|
-
if (!dry) {
|
|
375
|
-
|
|
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
|
-
//
|
|
383
|
-
|
|
384
|
-
|
|
385
|
-
|
|
386
|
-
|
|
387
|
-
|
|
388
|
-
|
|
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:
|
|
396
|
-
host,
|
|
457
|
+
version: 2,
|
|
397
458
|
installedAt: new Date().toISOString(),
|
|
398
459
|
statePath: stateAbs,
|
|
399
|
-
|
|
400
|
-
...(previous.mcp !== undefined ? { mcp: previous.mcp } : {}),
|
|
401
|
-
...(previous.hooks !== undefined ? { hooks: previous.hooks } : {}),
|
|
460
|
+
hosts: { ...previousProjectHosts(manifestAbs) },
|
|
402
461
|
};
|
|
403
|
-
|
|
404
|
-
|
|
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
|
-
|
|
407
|
-
|
|
408
|
-
|
|
409
|
-
|
|
410
|
-
|
|
411
|
-
|
|
412
|
-
|
|
413
|
-
|
|
414
|
-
|
|
415
|
-
|
|
416
|
-
|
|
417
|
-
|
|
418
|
-
|
|
419
|
-
|
|
420
|
-
|
|
421
|
-
|
|
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
|
-
|
|
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;
|
|
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(`
|
|
434
|
-
manifest.
|
|
508
|
+
say(`opencode: ${configPath} (${changed ? 'plugin + mcp registered' : 'already registered'})`);
|
|
509
|
+
manifest.hosts['opencode'] = { config: { configPath } };
|
|
435
510
|
}
|
|
436
|
-
if (
|
|
437
|
-
const hooksDir = path.join(
|
|
438
|
-
const settingsPath = path.join(
|
|
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 —
|
|
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
|
|
447
|
-
// A backup is written only when
|
|
448
|
-
// so re-init stays byte-idempotent
|
|
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, {
|
|
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
|
-
|
|
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
|
-
|
|
536
|
-
|
|
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
|
-
|
|
539
|
-
|
|
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
|
-
|
|
545
|
-
|
|
546
|
-
say(`mcp: ${configToml} (skillstate already registered)`);
|
|
724
|
+
catch {
|
|
725
|
+
toml = '';
|
|
547
726
|
}
|
|
548
|
-
|
|
549
|
-
|
|
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(
|
|
731
|
+
await atomicWriteFile(backup, toml);
|
|
732
|
+
await atomicWriteFile(codex.tomlConfigPath, toml.replace(match[0], '\n'));
|
|
552
733
|
}
|
|
553
|
-
say(`mcp:
|
|
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
|
-
|
|
738
|
+
fs.rmSync(manifestPath, { force: true });
|
|
560
739
|
}
|
|
561
|
-
say(`manifest: ${
|
|
562
|
-
say(
|
|
740
|
+
say(`removed manifest: ${manifestPath}`);
|
|
741
|
+
say('Machine glue removed.');
|
|
563
742
|
return 0;
|
|
564
743
|
}
|
|
565
744
|
/**
|
|
566
|
-
*
|
|
567
|
-
*
|
|
568
|
-
*
|
|
569
|
-
*
|
|
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
|
-
|
|
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
|
-
|
|
593
|
-
return 1;
|
|
876
|
+
manifest = null;
|
|
594
877
|
}
|
|
595
|
-
if (
|
|
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
|
-
|
|
603
|
-
|
|
604
|
-
|
|
605
|
-
|
|
606
|
-
|
|
607
|
-
|
|
608
|
-
|
|
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(
|
|
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(
|
|
941
|
+
const backup = backupPathFor(claude.hooks.configPath);
|
|
619
942
|
if (!dry) {
|
|
620
943
|
await atomicWriteFile(backup, text);
|
|
621
|
-
await atomicWriteFile(
|
|
944
|
+
await atomicWriteFile(claude.hooks.configPath, result.text);
|
|
622
945
|
}
|
|
623
|
-
say(`removed hooks: ${
|
|
946
|
+
say(`removed hooks: ${claude.hooks.configPath} (backup: ${backup})`);
|
|
624
947
|
}
|
|
625
948
|
}
|
|
626
|
-
if (fs.existsSync(
|
|
949
|
+
if (fs.existsSync(claude.hooks.scriptDir)) {
|
|
627
950
|
if (!dry) {
|
|
628
|
-
fs.rmSync(
|
|
951
|
+
fs.rmSync(claude.hooks.scriptDir, { recursive: true, force: true });
|
|
629
952
|
}
|
|
630
|
-
say(`removed hook scripts: ${
|
|
953
|
+
say(`removed hook scripts: ${claude.hooks.scriptDir}`);
|
|
631
954
|
}
|
|
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') {
|
|
955
|
+
if (fs.existsSync(claude.mcp.configPath)) {
|
|
956
|
+
const mcpPath = claude.mcp.configPath;
|
|
648
957
|
try {
|
|
649
|
-
const doc = JSON.parse(fs.readFileSync(
|
|
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(
|
|
961
|
+
const backup = backupPathFor(mcpPath);
|
|
653
962
|
if (!dry) {
|
|
654
|
-
await atomicWriteFile(backup, fs.readFileSync(
|
|
655
|
-
|
|
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: ${
|
|
972
|
+
say(`removed mcp entry: ${mcpPath} (backup: ${backup})`);
|
|
658
973
|
}
|
|
659
974
|
}
|
|
660
975
|
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})`);
|
|
976
|
+
console.error(`Skipping mcp: ${mcpPath} is unreadable`);
|
|
687
977
|
}
|
|
688
978
|
}
|
|
689
979
|
}
|