talking-stick 0.2.0 → 0.3.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.
@@ -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,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
+ }