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.
- package/README.md +34 -50
- package/dist/cli/install-commands.js +76 -36
- package/dist/cli/output.js +2 -2
- package/dist/cli/registry.js +13 -32
- package/dist/cli/room-commands.js +1 -1
- package/dist/cli/startup-maintenance.js +27 -1
- package/dist/cli.js +2 -2
- package/dist/config.js +2 -2
- package/dist/identity.js +4 -4
- package/dist/index.js +2 -2
- package/dist/install-audit.js +21 -0
- package/dist/install-migration.js +84 -0
- package/dist/install.js +0 -69
- package/dist/update-migration.js +135 -0
- package/docs/plans/2026-05-04-diff-walker-design.md +585 -0
- package/docs/plans/2026-05-05-cli-only-coordination.md +224 -0
- package/docs/plans/out-of-band-signaling-implementation.md +5 -5
- package/docs/receive-consumer-contract.md +8 -6
- package/docs/releases/0.3.0.md +77 -0
- package/docs/talking-stick-plan.md +3 -2
- package/package.json +4 -3
- package/scripts/postinstall-mcp-cleanup.cjs +25 -0
- package/skills/talking-stick/SKILL.md +124 -103
- package/dist/mcp-server.js +0 -244
- package/dist/server.js +0 -3
|
@@ -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
|
+
}
|