talking-stick 0.2.0 → 0.4.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.
@@ -1,7 +1,20 @@
1
1
  import { syncInstalledSkills } from "../skill-install.js";
2
+ import { runFirstRunMcpMigration } from "../update-migration.js";
3
+ import { detectInstallSource, resolveCurrentBinaryPath } from "../self-update.js";
2
4
  import { isKnownHarnessCliEnv } from "./identity.js";
3
5
  import { getCommand } from "./registry.js";
4
- export function runStartupMaintenance(parsed, env = process.env) {
6
+ export async function runStartupMaintenance(parsed, cliEntryUrl, env = process.env) {
7
+ if (shouldRunFirstRunMcpMigration(parsed, cliEntryUrl, env)) {
8
+ try {
9
+ await runFirstRunMcpMigration({
10
+ installOptions: { env }
11
+ });
12
+ }
13
+ catch {
14
+ // Startup cleanup is best-effort. Explicit install, uninstall, and
15
+ // self-update paths surface cleanup failures directly.
16
+ }
17
+ }
5
18
  if (!shouldAutoSyncInstalledSkills(parsed, env)) {
6
19
  return;
7
20
  }
@@ -13,6 +26,19 @@ export function runStartupMaintenance(parsed, env = process.env) {
13
26
  // unrelated tt command fail.
14
27
  }
15
28
  }
29
+ export function shouldRunFirstRunMcpMigration(parsed, cliEntryUrl, env = process.env) {
30
+ if (env.TALKING_STICK_DISABLE_MCP_MIGRATION?.trim()) {
31
+ return false;
32
+ }
33
+ const command = getCommand(parsed.name);
34
+ if (!command?.startupMaintenance) {
35
+ return false;
36
+ }
37
+ const source = detectInstallSource({
38
+ binaryPath: resolveCurrentBinaryPath(cliEntryUrl)
39
+ });
40
+ return source !== "dev" && source !== "unknown";
41
+ }
16
42
  export function shouldAutoSyncInstalledSkills(parsed, env = process.env) {
17
43
  if (env.TALKING_STICK_DISABLE_SKILL_SYNC?.trim()) {
18
44
  return false;
package/dist/cli.js CHANGED
@@ -11,10 +11,10 @@ import { runStartupMaintenance } from "./cli/startup-maintenance.js";
11
11
  export { checkGuardianLiveness } from "./cli/guardian.js";
12
12
  export { parseHandoffJson } from "./cli/handoff.js";
13
13
  export { formatRelativeTime, shouldUseJson } from "./cli/output.js";
14
- export { shouldAutoSyncInstalledSkills } from "./cli/startup-maintenance.js";
14
+ export { shouldAutoSyncInstalledSkills, shouldRunFirstRunMcpMigration } from "./cli/startup-maintenance.js";
15
15
  export async function runCli(argv = process.argv.slice(2)) {
16
16
  const parsed = parseCommand(argv);
17
- runStartupMaintenance(parsed);
17
+ await runStartupMaintenance(parsed, import.meta.url);
18
18
  if (!parsed.name || parsed.name === "help" || parsed.name === "--help") {
19
19
  printHelp();
20
20
  return;
package/dist/config.js CHANGED
@@ -4,9 +4,9 @@ export const defaultPolicy = {
4
4
  ownerLeaseTtlMs: 45 * 60 * 1000,
5
5
  heartbeatIntervalMs: 5 * 60 * 1000,
6
6
  claimTtlMs: 20 * 60 * 1000,
7
- waitForTurnMaxWaitMs: 30 * 1000,
7
+ waitForTurnMaxWaitMs: 110 * 1000,
8
8
  waitForTurnPollMs: 250,
9
- waitForEventsMaxWaitMs: 30 * 1000,
9
+ waitForEventsMaxWaitMs: 110 * 1000,
10
10
  waitForEventsPollMs: 250,
11
11
  waitForEventsBatchLimit: 100,
12
12
  presenceTtlMs: 4 * 60 * 60 * 1000,
package/dist/identity.js CHANGED
@@ -127,13 +127,13 @@ function harnessAgentId(harness, sessionId, hostId, username) {
127
127
  function resolveHarnessSessionId(signal, env, parentPid, parentInspection, username, hostId, inspector) {
128
128
  if (signal.sessionId)
129
129
  return `harness:${signal.sessionId}`;
130
- const terminalId = resolveTerminalSessionId(env);
131
- if (terminalId)
132
- return terminalId;
133
130
  const harnessRoot = findHarnessRootInAncestry(signal.harness, parentPid, parentInspection, inspector);
134
131
  if (harnessRoot) {
135
132
  return `pid:${harnessRoot.pid}@${harnessRoot.startTime}`;
136
133
  }
134
+ const terminalId = resolveTerminalSessionId(env);
135
+ if (terminalId)
136
+ return terminalId;
137
137
  if (parentInspection?.startTime) {
138
138
  return `pid:${parentPid}@${parentInspection.startTime}`;
139
139
  }
@@ -224,7 +224,7 @@ function detectHarnessSignal(env) {
224
224
  if (env.CLAUDECODE === "1") {
225
225
  return {
226
226
  harness: "claude",
227
- sessionId: null,
227
+ sessionId: nonEmpty(env.CLAUDE_CODE_SESSION_ID),
228
228
  pidHint: parsePositiveInteger(env.CMUX_CLAUDE_PID)
229
229
  };
230
230
  }
package/dist/index.js CHANGED
@@ -4,9 +4,10 @@ export { applyPragmas, assertLocalFilesystem, detectFilesystemType, migrate, ope
4
4
  export { ProtocolError, isProtocolError } from "./errors.js";
5
5
  export { deriveHarnessCliIdentity, deriveHumanCliIdentity, deriveMcpHarnessIdentity } from "./identity.js";
6
6
  export { ancestorPaths, canonicalizeContextPath, resolveContextPath, resolveWorkspaceRoot } from "./path-resolution.js";
7
- export { createMcpServer, runStdioServer } from "./mcp-server.js";
8
- export { SUPPORTED_HARNESSES, MissingHarnessError, detectHarness, parseHarnessList, planInstall, planUninstall, resolveHarnessConfigDir, resolveOpencodeConfigDir, resolveOpencodeConfigPath, runAction, skipAction } from "./install.js";
7
+ export { DEFAULT_MAX_INSTRUCTION_FILE_BYTES, DEFAULT_INSTRUCTIONS_MARKDOWN, editInstructions, extractHarnessInstructions, normalizeInstructionHarness, parseInstructionScope, resetInstructions, resolveInstructionHarness, resolveInstructionPaths, showInstructions } from "./instructions.js";
8
+ export { SUPPORTED_HARNESSES, MissingHarnessError, detectHarness, parseHarnessList, planUninstall, resolveHarnessConfigDir, resolveOpencodeConfigDir, resolveOpencodeConfigPath, runAction, skipAction } from "./install.js";
9
9
  export { DEFAULT_SKILL_NAME, planSkillInstall, planSkillUninstall, resolveBundledSkillPath, resolveSkillTargetPath, syncInstalledSkills } from "./skill-install.js";
10
+ export { readPackageVersion, readUpdateMigrationState, resolveUpdateMigrationStatePath, runFirstRunMcpMigration, runStaleMcpCleanup, writeUpdateMigrationState } from "./update-migration.js";
10
11
  export { createSystemProcessInspector, terminateKnownProcess } from "./process-utils.js";
11
12
  export { clearCliSessionLease, findCliSessionByRoom, findCliSessionForContextPath, readCliSessions, removeCliSession, removeCliSessionsForRoom, resolveCliSessionPath, upsertCliSession, upsertJoinedCliSession, writeCliSessions } from "./session-store.js";
12
13
  export { TalkingStickService, createDefaultProcessLivenessChecker } from "./service.js";
@@ -0,0 +1,21 @@
1
+ import fs from "node:fs";
2
+ import path from "node:path";
3
+ export class FileAuditLog {
4
+ filePath;
5
+ constructor(filePath) {
6
+ this.filePath = filePath;
7
+ }
8
+ append(entry) {
9
+ const fullEntry = { ts: entry.ts ?? new Date().toISOString(), ...entry };
10
+ fs.mkdirSync(path.dirname(this.filePath), { recursive: true });
11
+ fs.appendFileSync(this.filePath, `${JSON.stringify(fullEntry)}\n`, "utf8");
12
+ }
13
+ }
14
+ export class NoopAuditLog {
15
+ append() {
16
+ // intentionally blank
17
+ }
18
+ }
19
+ export function defaultAuditLogPath(dataDir) {
20
+ return path.join(dataDir, "update-migrations.log");
21
+ }
@@ -0,0 +1,84 @@
1
+ import { SUPPORTED_HARNESSES, planUninstall, runAction } from "./install.js";
2
+ import { NoopAuditLog } from "./install-audit.js";
3
+ export async function removeStaleMcpRegistrations(options) {
4
+ const audit = options.audit ?? new NoopAuditLog();
5
+ const strict = options.strict ?? true;
6
+ const harnesses = options.harnesses === undefined || options.harnesses === "all"
7
+ ? [...SUPPORTED_HARNESSES]
8
+ : options.harnesses;
9
+ const installOptions = {
10
+ skipMissing: true,
11
+ ...(options.installOptions ?? {})
12
+ };
13
+ const results = [];
14
+ for (const harness of harnesses) {
15
+ const result = await removeOneHarness(harness, installOptions, strict);
16
+ results.push(result);
17
+ audit.append({
18
+ reason: options.reason,
19
+ package_version_from: options.packageVersionFrom,
20
+ package_version_to: options.packageVersionTo,
21
+ harness,
22
+ config_path: result.configPath,
23
+ action: result.action,
24
+ server_name: result.serverName,
25
+ detail: result.message
26
+ });
27
+ }
28
+ return results.map(({ harness, action, message }) => ({ harness, action, message }));
29
+ }
30
+ async function removeOneHarness(harness, installOptions, strict) {
31
+ const action = planUninstall(harness, installOptions);
32
+ if (action.kind === "skip") {
33
+ return {
34
+ harness,
35
+ action: "skipped",
36
+ message: action.message,
37
+ serverName: installOptions.serverName ?? "talking-stick"
38
+ };
39
+ }
40
+ if (action.kind === "file-patch") {
41
+ const state = action.inspect ? action.inspect() : "unknown";
42
+ const serverName = action.serverName ?? "talking-stick";
43
+ if (state === "absent") {
44
+ return {
45
+ harness,
46
+ action: "absent",
47
+ message: `${harness}: no Talking Stick MCP entry to remove`,
48
+ configPath: action.filePath,
49
+ serverName
50
+ };
51
+ }
52
+ if (strict && state !== "present") {
53
+ return {
54
+ harness,
55
+ action: "preserved",
56
+ message: `${harness}: hand-edited entry left alone (state=${state})`,
57
+ configPath: action.filePath,
58
+ serverName
59
+ };
60
+ }
61
+ }
62
+ const installResult = await runAction(action, installOptions);
63
+ return mapInstallResult(harness, action, installResult);
64
+ }
65
+ function mapInstallResult(harness, action, result) {
66
+ let serverName = "talking-stick";
67
+ if ("serverName" in action && typeof action.serverName === "string") {
68
+ serverName = action.serverName;
69
+ }
70
+ const configPath = action.kind === "file-patch" ? action.filePath : undefined;
71
+ if (!result.ok) {
72
+ return { harness, action: "failed", message: result.message, configPath, serverName };
73
+ }
74
+ switch (result.status) {
75
+ case "already_absent":
76
+ return { harness, action: "absent", message: result.message, configPath, serverName };
77
+ case "removed":
78
+ return { harness, action: "removed", message: result.message, configPath, serverName };
79
+ case "skipped":
80
+ return { harness, action: "skipped", message: result.message, configPath, serverName };
81
+ default:
82
+ return { harness, action: "failed", message: result.message, configPath, serverName };
83
+ }
84
+ }
package/dist/install.js CHANGED
@@ -115,75 +115,6 @@ function resolveHarnessConfigDirFromResolved(harness, resolved) {
115
115
  throw new Error(`Unknown harness: ${harness}`);
116
116
  }
117
117
  }
118
- export function planInstall(harness, options = {}) {
119
- const resolved = resolveOptions(options);
120
- const [serverBin, ...serverArgs] = resolved.serverCommand;
121
- if (!serverBin)
122
- throw new Error("serverCommand must include at least the binary");
123
- switch (harness) {
124
- case "claude-code":
125
- if (resolved.skipMissing && !resolved.hooks.which("claude")) {
126
- return skipAction(harness, "claude not on PATH");
127
- }
128
- return {
129
- kind: "exec",
130
- harness,
131
- command: "claude",
132
- args: ["mcp", "add", "-s", "user", resolved.serverName, "--", serverBin, ...serverArgs],
133
- description: `claude mcp add -s user ${resolved.serverName} -- ${resolved.serverCommand.join(" ")}`,
134
- operation: "install",
135
- serverName: resolved.serverName,
136
- serverCommand: resolved.serverCommand
137
- };
138
- case "codex":
139
- if (resolved.skipMissing && !resolved.hooks.which("codex")) {
140
- return skipAction(harness, "codex not on PATH");
141
- }
142
- return {
143
- kind: "exec",
144
- harness,
145
- command: "codex",
146
- args: ["mcp", "add", resolved.serverName, "--", serverBin, ...serverArgs],
147
- description: `codex mcp add ${resolved.serverName} -- ${resolved.serverCommand.join(" ")}`,
148
- operation: "install",
149
- serverName: resolved.serverName,
150
- serverCommand: resolved.serverCommand
151
- };
152
- case "gemini":
153
- if (resolved.skipMissing && !resolved.hooks.which("gemini")) {
154
- return skipAction(harness, "gemini not on PATH");
155
- }
156
- return {
157
- kind: "exec",
158
- harness,
159
- command: "gemini",
160
- args: ["mcp", "add", "-s", "user", "-t", "stdio", resolved.serverName, serverBin, ...serverArgs],
161
- description: `gemini mcp add -s user -t stdio ${resolved.serverName} ${resolved.serverCommand.join(" ")}`,
162
- operation: "install",
163
- serverName: resolved.serverName,
164
- serverCommand: resolved.serverCommand
165
- };
166
- case "opencode": {
167
- const filePath = resolveOpencodeConfigPath(options);
168
- const configDir = path.dirname(filePath);
169
- if (resolved.skipMissing && !resolved.hooks.pathExists(configDir)) {
170
- return skipAction(harness, `opencode config directory not found: ${configDir}`);
171
- }
172
- return {
173
- kind: "file-patch",
174
- harness,
175
- filePath,
176
- description: `merge mcp.${resolved.serverName} into ${filePath}`,
177
- operation: "install",
178
- serverName: resolved.serverName,
179
- inspect: () => inspectOpencodeConfig(filePath, resolved),
180
- apply: () => patchOpencodeConfig(filePath, resolved, "install")
181
- };
182
- }
183
- default:
184
- throw new Error(`Unknown harness: ${harness}`);
185
- }
186
- }
187
118
  export function planUninstall(harness, options = {}) {
188
119
  const resolved = resolveOptions(options);
189
120
  switch (harness) {
@@ -0,0 +1,256 @@
1
+ import { spawn } from "node:child_process";
2
+ import fs from "node:fs";
3
+ import path from "node:path";
4
+ import { resolveDataDir } from "./config.js";
5
+ import { resolveContextPath } from "./path-resolution.js";
6
+ export const DEFAULT_MAX_INSTRUCTION_FILE_BYTES = 256 * 1024;
7
+ export const DEFAULT_INSTRUCTIONS_MARKDOWN = `# Talking Stick collaboration instructions
8
+
9
+ Keep using Talking Stick until the shared task is done. After releasing or handing off, re-enter the wait loop by default. Prefer continued action unless the task is complete or the operator explicitly redirects or stops the room.
10
+
11
+ Use phase names in handoffs when they clarify the work: draft, adversarial review, convergence, implementation, implementation review, test review, and release. These phases are vocabulary, not protocol state.
12
+
13
+ Typical fits are advisory. Claude is usually strong at prose, first-pass synthesis, tool-running, implementation review, and test review. Codex is usually strong at adversarial review, convergence, implementation, edge cases, and release mechanics after operator approval. Gemini and OpenCode start with conservative local guidance until project dogfood says otherwise.
14
+
15
+ For multi-agent design work, prefer independent read-only drafts first, then adversarial review and convergence. Do not impose a draft file structure on the workspace by default. If scratch draft files are useful, delete superseded pre-convergence drafts after the converged plan exists unless the operator asks to keep them.
16
+
17
+ Default to normal release handoffs. Use named assignment only when a specific member must go next because of unique context, credentials, capability, or direct operator routing.
18
+
19
+ ## Claude
20
+
21
+ Lean into drafting, synthesis, tool-running, implementation review, and test review. Watch for scope creep and messy first-pass artifacts. When implementation belongs elsewhere, make the next phase explicit in the handoff.
22
+
23
+ ## Codex
24
+
25
+ Lean into adversarial review, convergence, precise implementation, edge-case sweeps, and release mechanics after operator approval. Watch for over-indexing on mechanics when the operator still needs to decide direction.
26
+
27
+ ## Gemini
28
+
29
+ Use broad context review and exploration conservatively until the project has stronger Gemini-specific dogfood. Keep handoffs concrete and do not assume responsibility that the operator assigned to another harness.
30
+
31
+ ## OpenCode
32
+
33
+ Use terminal-native local exploration and implementation conservatively until the project has stronger OpenCode-specific dogfood. Keep coordination safety ahead of speed.
34
+ `;
35
+ const HARNESS_ALIASES = {
36
+ all: "all",
37
+ base: "all",
38
+ claude: "claude",
39
+ "claude-code": "claude",
40
+ codex: "codex",
41
+ gemini: "gemini",
42
+ opencode: "opencode"
43
+ };
44
+ export function resolveInstructionPaths(options = {}) {
45
+ const contextPath = options.contextPath ?? process.cwd();
46
+ const workspaceRoot = resolveContextPath(contextPath).workspace_root;
47
+ return {
48
+ user: path.join(resolveDataDir(options), "instructions.md"),
49
+ project: path.join(workspaceRoot, ".talking-stick", "instructions.md")
50
+ };
51
+ }
52
+ export function showInstructions(input = {}) {
53
+ const options = input.options ?? {};
54
+ const harness = resolveInstructionHarness(input.harness, options.identity);
55
+ const scope = input.scope ?? "effective";
56
+ const paths = resolveInstructionPaths(options);
57
+ const layers = readInstructionLayers(paths, options.maxInstructionFileBytes ?? DEFAULT_MAX_INSTRUCTION_FILE_BYTES);
58
+ const selectedLayers = selectLayers(scope, layers);
59
+ const text = joinInstructionTexts(selectedLayers.map((layer) => extractHarnessInstructions(layer.text, harness)));
60
+ return {
61
+ harness,
62
+ scope,
63
+ text,
64
+ sources: selectedLayers.map((layer) => ({
65
+ scope: layer.scope,
66
+ path: layer.path
67
+ })),
68
+ paths
69
+ };
70
+ }
71
+ export async function editInstructions(input = {}) {
72
+ const scope = input.scope ?? "user";
73
+ const options = input.options ?? {};
74
+ const paths = resolveInstructionPaths(options);
75
+ const filePath = paths[scope];
76
+ const created = ensureInstructionFile(filePath);
77
+ const editor = chooseEditor(options);
78
+ if (!editor) {
79
+ return { scope, path: filePath, created, opened: false, editor: null };
80
+ }
81
+ await runEditor(editor, filePath);
82
+ return { scope, path: filePath, created, opened: true, editor };
83
+ }
84
+ export function resetInstructions(input) {
85
+ const paths = resolveInstructionPaths(input.options ?? {});
86
+ const filePath = paths[input.scope];
87
+ const removed = fs.existsSync(filePath);
88
+ if (removed) {
89
+ fs.rmSync(filePath, { force: true });
90
+ }
91
+ return { scope: input.scope, path: filePath, removed };
92
+ }
93
+ export function resolveInstructionHarness(explicitHarness, identity) {
94
+ if (explicitHarness) {
95
+ return normalizeInstructionHarness(explicitHarness);
96
+ }
97
+ const displayName = identity?.process_metadata.display_name ?? undefined;
98
+ const fromDisplay = displayName ? HARNESS_ALIASES[normalizeKey(displayName)] : undefined;
99
+ if (fromDisplay) {
100
+ return fromDisplay;
101
+ }
102
+ const prefix = identity?.agent_id.split(":")[0];
103
+ const fromPrefix = prefix ? HARNESS_ALIASES[normalizeKey(prefix)] : undefined;
104
+ return fromPrefix ?? "all";
105
+ }
106
+ export function normalizeInstructionHarness(value) {
107
+ const normalized = HARNESS_ALIASES[normalizeKey(value)];
108
+ if (!normalized) {
109
+ throw new Error(`--harness must be one of claude, codex, gemini, opencode, all (got ${value}).`);
110
+ }
111
+ return normalized;
112
+ }
113
+ export function parseInstructionScope(value) {
114
+ if (!value) {
115
+ return "effective";
116
+ }
117
+ if (value === "effective" ||
118
+ value === "bundled" ||
119
+ value === "user" ||
120
+ value === "project") {
121
+ return value;
122
+ }
123
+ throw new Error(`--scope must be one of effective, bundled, user, project (got ${value}).`);
124
+ }
125
+ export function extractHarnessInstructions(markdown, harness) {
126
+ const trimmed = markdown.trim();
127
+ if (!trimmed || harness === "all") {
128
+ return trimmed;
129
+ }
130
+ const lines = markdown.replace(/\r\n/g, "\n").split("\n");
131
+ const shared = [];
132
+ const sections = new Map();
133
+ let current = null;
134
+ let sawSection = false;
135
+ for (const line of lines) {
136
+ const header = parseHarnessHeader(line);
137
+ if (header) {
138
+ sawSection = true;
139
+ current = header;
140
+ if (!sections.has(current)) {
141
+ sections.set(current, []);
142
+ }
143
+ sections.get(current)?.push(line);
144
+ continue;
145
+ }
146
+ if (!sawSection) {
147
+ shared.push(line);
148
+ continue;
149
+ }
150
+ if (current) {
151
+ sections.get(current)?.push(line);
152
+ }
153
+ }
154
+ return joinInstructionTexts([
155
+ shared.join("\n").trim(),
156
+ sections.get(harness)?.join("\n").trim() ?? ""
157
+ ]);
158
+ }
159
+ function readInstructionLayers(paths, maxInstructionFileBytes) {
160
+ const layers = [
161
+ { scope: "bundled", path: null, text: DEFAULT_INSTRUCTIONS_MARKDOWN }
162
+ ];
163
+ for (const scope of ["user", "project"]) {
164
+ const filePath = paths[scope];
165
+ if (!fs.existsSync(filePath)) {
166
+ continue;
167
+ }
168
+ const stat = fs.statSync(filePath);
169
+ if (stat.size > maxInstructionFileBytes) {
170
+ throw new Error(`${scope} instructions file is too large (${stat.size} bytes, max ${maxInstructionFileBytes}): ${filePath}`);
171
+ }
172
+ const text = fs.readFileSync(filePath, "utf8");
173
+ layers.push({ scope, path: filePath, text });
174
+ }
175
+ return layers;
176
+ }
177
+ function selectLayers(scope, layers) {
178
+ if (scope === "effective") {
179
+ return layers;
180
+ }
181
+ return layers.filter((layer) => layer.scope === scope);
182
+ }
183
+ function joinInstructionTexts(parts) {
184
+ return parts
185
+ .map((part) => part.trim())
186
+ .filter((part) => part.length > 0)
187
+ .join("\n\n");
188
+ }
189
+ function ensureInstructionFile(filePath) {
190
+ if (fs.existsSync(filePath)) {
191
+ return false;
192
+ }
193
+ fs.mkdirSync(path.dirname(filePath), { recursive: true });
194
+ fs.writeFileSync(filePath, DEFAULT_INSTRUCTIONS_MARKDOWN);
195
+ return true;
196
+ }
197
+ function parseHarnessHeader(line) {
198
+ const match = line.match(/^##\s+(.+?)\s*$/);
199
+ if (!match) {
200
+ return null;
201
+ }
202
+ const key = normalizeKey(match[1]);
203
+ if (key.startsWith("claude"))
204
+ return "claude";
205
+ if (key.startsWith("codex"))
206
+ return "codex";
207
+ if (key.startsWith("gemini"))
208
+ return "gemini";
209
+ if (key.startsWith("opencode"))
210
+ return "opencode";
211
+ return null;
212
+ }
213
+ function chooseEditor(options) {
214
+ const env = options.env ?? process.env;
215
+ const explicit = env.VISUAL?.trim() || env.EDITOR?.trim();
216
+ if (explicit) {
217
+ return explicit;
218
+ }
219
+ switch (options.platform ?? process.platform) {
220
+ case "darwin":
221
+ return "open -t";
222
+ case "win32":
223
+ return "notepad.exe";
224
+ default:
225
+ if (env.DISPLAY || env.WAYLAND_DISPLAY) {
226
+ return "xdg-open";
227
+ }
228
+ return null;
229
+ }
230
+ }
231
+ function runEditor(editor, filePath) {
232
+ return new Promise((resolve, reject) => {
233
+ const child = spawn(`${editor} ${shellQuote(filePath)}`, {
234
+ stdio: "inherit",
235
+ shell: true
236
+ });
237
+ child.on("error", reject);
238
+ child.on("close", (code) => {
239
+ if (code === 0) {
240
+ resolve();
241
+ return;
242
+ }
243
+ reject(new Error(`${editor} exited with code ${code}.`));
244
+ });
245
+ });
246
+ }
247
+ function shellQuote(value) {
248
+ return `'${value.replaceAll("'", "'\\''")}'`;
249
+ }
250
+ function normalizeKey(value) {
251
+ return value
252
+ .trim()
253
+ .toLowerCase()
254
+ .replace(/[^a-z0-9]+/g, "-")
255
+ .replace(/^-+|-+$/g, "");
256
+ }
@@ -0,0 +1,135 @@
1
+ import fs from "node:fs";
2
+ import path from "node:path";
3
+ import { fileURLToPath } from "node:url";
4
+ import { resolveDataDir } from "./config.js";
5
+ import { FileAuditLog, defaultAuditLogPath } from "./install-audit.js";
6
+ import { removeStaleMcpRegistrations } from "./install-migration.js";
7
+ export const UPDATE_MIGRATION_STATE_FILE = "update-migrations-state.json";
8
+ export async function runStaleMcpCleanup(options) {
9
+ const packageVersionTo = options.packageVersionTo ?? options.packageVersion ?? readPackageVersion();
10
+ const dataDir = options.dataDir ?? resolveMigrationDataDir(options.installOptions);
11
+ const statePath = resolveUpdateMigrationStatePath(dataDir);
12
+ const auditPath = defaultAuditLogPath(dataDir);
13
+ const audit = options.audit ?? new FileAuditLog(auditPath);
14
+ const results = await removeStaleMcpRegistrations({
15
+ harnesses: options.harnesses ?? "all",
16
+ reason: options.reason,
17
+ packageVersionFrom: options.packageVersionFrom,
18
+ packageVersionTo,
19
+ audit,
20
+ installOptions: options.installOptions
21
+ });
22
+ if (options.updateState !== false && !results.some((result) => result.action === "failed")) {
23
+ writeUpdateMigrationState(statePath, {
24
+ mcp_cleanup_version: packageVersionTo,
25
+ updated_at: new Date().toISOString()
26
+ });
27
+ }
28
+ return {
29
+ status: "ran",
30
+ packageVersionFrom: options.packageVersionFrom,
31
+ packageVersionTo,
32
+ statePath,
33
+ auditPath,
34
+ results
35
+ };
36
+ }
37
+ export async function runFirstRunMcpMigration(options = {}) {
38
+ const packageVersion = options.packageVersion ?? readPackageVersion();
39
+ const dataDir = options.dataDir ?? resolveMigrationDataDir(options.installOptions);
40
+ const statePath = resolveUpdateMigrationStatePath(dataDir);
41
+ const auditPath = defaultAuditLogPath(dataDir);
42
+ const state = readUpdateMigrationState(statePath);
43
+ if (state.mcp_cleanup_version === packageVersion) {
44
+ return {
45
+ status: "current",
46
+ packageVersion,
47
+ statePath,
48
+ auditPath,
49
+ results: []
50
+ };
51
+ }
52
+ return runStaleMcpCleanup({
53
+ harnesses: "all",
54
+ reason: "first-run",
55
+ packageVersionFrom: state.mcp_cleanup_version,
56
+ packageVersionTo: packageVersion,
57
+ dataDir,
58
+ audit: options.audit,
59
+ installOptions: options.installOptions
60
+ });
61
+ }
62
+ export function resolveUpdateMigrationStatePath(dataDir) {
63
+ return path.join(dataDir, UPDATE_MIGRATION_STATE_FILE);
64
+ }
65
+ export function readUpdateMigrationState(statePath) {
66
+ try {
67
+ const raw = fs.readFileSync(statePath, "utf8");
68
+ const parsed = JSON.parse(raw);
69
+ if (!isPlainObject(parsed))
70
+ return {};
71
+ return {
72
+ mcp_cleanup_version: typeof parsed.mcp_cleanup_version === "string"
73
+ ? parsed.mcp_cleanup_version
74
+ : undefined,
75
+ updated_at: typeof parsed.updated_at === "string" ? parsed.updated_at : undefined
76
+ };
77
+ }
78
+ catch (error) {
79
+ if (error.code === "ENOENT")
80
+ return {};
81
+ return {};
82
+ }
83
+ }
84
+ export function writeUpdateMigrationState(statePath, state) {
85
+ fs.mkdirSync(path.dirname(statePath), { recursive: true });
86
+ const tmpPath = `${statePath}.${process.pid}.tmp`;
87
+ fs.writeFileSync(tmpPath, `${JSON.stringify(state, null, 2)}\n`, "utf8");
88
+ fs.renameSync(tmpPath, statePath);
89
+ }
90
+ export function readPackageVersion(startUrl = import.meta.url) {
91
+ const root = findPackageRoot(fileURLToPath(startUrl));
92
+ if (!root)
93
+ return "unknown";
94
+ try {
95
+ const raw = fs.readFileSync(path.join(root, "package.json"), "utf8");
96
+ const parsed = JSON.parse(raw);
97
+ return typeof parsed.version === "string" && parsed.version.trim()
98
+ ? parsed.version
99
+ : "unknown";
100
+ }
101
+ catch {
102
+ return "unknown";
103
+ }
104
+ }
105
+ function resolveMigrationDataDir(installOptions) {
106
+ const options = {
107
+ env: installOptions?.env,
108
+ platform: installOptions?.platform,
109
+ homeDir: installOptions?.homeDir
110
+ };
111
+ return resolveDataDir(options);
112
+ }
113
+ function findPackageRoot(startPath) {
114
+ let current;
115
+ try {
116
+ current = fs.statSync(startPath).isDirectory()
117
+ ? startPath
118
+ : path.dirname(startPath);
119
+ }
120
+ catch {
121
+ current = path.dirname(startPath);
122
+ }
123
+ while (true) {
124
+ const candidate = path.join(current, "package.json");
125
+ if (fs.existsSync(candidate))
126
+ return current;
127
+ const parent = path.dirname(current);
128
+ if (parent === current)
129
+ return null;
130
+ current = parent;
131
+ }
132
+ }
133
+ function isPlainObject(value) {
134
+ return typeof value === "object" && value !== null && !Array.isArray(value);
135
+ }