talking-stick 0.4.12 → 0.4.13

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.
@@ -2,12 +2,55 @@ import fs from "node:fs";
2
2
  import crypto from "node:crypto";
3
3
  import path from "node:path";
4
4
  import { fileURLToPath } from "node:url";
5
+ import { FILE_SKILL_HARNESSES, HARNESS_SKILL_MODELS, SUPPORTED_HARNESSES } from "./harness-model.js";
5
6
  import { MissingHarnessError, resolveHarnessConfigDir, skipAction } from "./install.js";
6
7
  export const DEFAULT_SKILL_NAME = "talking-stick";
7
- const FILE_SKILL_HARNESSES = ["claude-code", "codex", "grok", "opencode"];
8
+ export { FILE_SKILL_HARNESSES } from "./harness-model.js";
8
9
  export function resolveBundledSkillPath(options = {}) {
9
10
  return options.sourcePath ?? path.resolve(currentPackageDir(), "skills", DEFAULT_SKILL_NAME);
10
11
  }
12
+ export function resolveSharedAgentsSkillsDir(options = {}) {
13
+ const homeDir = options.homeDir ?? process.env.HOME ?? "";
14
+ return path.join(homeDir, ".agents", "skills");
15
+ }
16
+ export function resolveSharedSkillTargetPath(options = {}) {
17
+ return path.join(resolveSharedAgentsSkillsDir(options), options.skillName ?? DEFAULT_SKILL_NAME);
18
+ }
19
+ export function skillLoadingModel(harness) {
20
+ return HARNESS_SKILL_MODELS[harness].skillLoadingModel;
21
+ }
22
+ export function resolvePrimarySkillTargetPath(harness, options = {}) {
23
+ const model = skillLoadingModel(harness);
24
+ if (model === "shared" || model === "shared+proprietary") {
25
+ return resolveSharedSkillTargetPath(options);
26
+ }
27
+ return resolveSkillTargetPath(harness, options);
28
+ }
29
+ export function resolveLegacyOpencodeSkillTargetPath(options = {}) {
30
+ const homeDir = options.homeDir ?? process.env.HOME ?? "";
31
+ return path.join(homeDir, ".opencode", "skills", options.skillName ?? DEFAULT_SKILL_NAME);
32
+ }
33
+ export function resolveDuplicateSkillTargetPaths(harness, options = {}) {
34
+ const skillName = options.skillName ?? DEFAULT_SKILL_NAME;
35
+ switch (harness) {
36
+ case "claude-code":
37
+ case "antigravity":
38
+ return [];
39
+ case "codex":
40
+ return [resolveSkillTargetPath("codex", options)];
41
+ case "grok":
42
+ return [resolveSkillTargetPath("grok", options)];
43
+ case "opencode":
44
+ return [
45
+ resolveSkillTargetPath("opencode", options),
46
+ resolveLegacyOpencodeSkillTargetPath(options)
47
+ ];
48
+ case "gemini":
49
+ return [path.join(resolveHarnessConfigDir("gemini", options), "skills", skillName)];
50
+ default:
51
+ throw new Error(`Unknown duplicate skill cleanup harness: ${harness}`);
52
+ }
53
+ }
11
54
  export function resolveSkillTargetPath(harness, options = {}) {
12
55
  const homeDir = options.homeDir ?? process.env.HOME ?? "";
13
56
  switch (harness) {
@@ -15,6 +58,8 @@ export function resolveSkillTargetPath(harness, options = {}) {
15
58
  return path.join(homeDir, ".claude", "skills", options.skillName ?? DEFAULT_SKILL_NAME);
16
59
  case "codex":
17
60
  return path.join(homeDir, ".codex", "skills", options.skillName ?? DEFAULT_SKILL_NAME);
61
+ case "antigravity":
62
+ return resolveSharedSkillTargetPath(options);
18
63
  case "grok":
19
64
  return path.join(resolveHarnessConfigDir("grok", options), "skills", options.skillName ?? DEFAULT_SKILL_NAME);
20
65
  case "opencode":
@@ -29,31 +74,15 @@ export function planSkillInstall(harness, options = {}) {
29
74
  const shouldLink = options.link ?? true;
30
75
  ensureSkillSourceExists(sourcePath);
31
76
  if (harness === "gemini") {
32
- const geminiTargetPath = path.join(resolveHarnessConfigDir("gemini", options), "skills", skillName);
33
- return shouldLink
34
- ? {
35
- kind: "exec",
36
- harness,
37
- command: "gemini",
38
- args: ["skills", "link", sourcePath, "--scope", "user", "--consent"],
39
- description: `gemini skills link ${sourcePath} --scope user --consent`,
40
- operation: "install",
41
- inspect: () => inspectInstalledSkill(sourcePath, geminiTargetPath, true)
42
- }
43
- : {
44
- kind: "exec",
45
- harness,
46
- command: "gemini",
47
- args: ["skills", "install", sourcePath, "--scope", "user", "--consent"],
48
- description: `gemini skills install ${sourcePath} --scope user --consent`,
49
- operation: "install",
50
- inspect: () => inspectInstalledSkill(sourcePath, geminiTargetPath, false)
51
- };
77
+ return skipAction(harness, `Gemini CLI skill install is deprecated; use tt install antigravity. Cleanup will remove ${path.join(resolveHarnessConfigDir("gemini", options), "skills", skillName)} when it is a Talking Stick-managed symlink.`);
52
78
  }
53
- const targetPath = resolveSkillTargetPath(harness, options);
54
- const harnessRootPath = resolveHarnessConfigDir(harness, options);
79
+ const targetPath = resolvePrimarySkillTargetPath(harness, options);
80
+ const harnessRootPath = skillLoadingModel(harness) === "shared" ||
81
+ skillLoadingModel(harness) === "shared+proprietary"
82
+ ? undefined
83
+ : resolveHarnessConfigDir(harness, options);
55
84
  const pathExists = options.pathExists ?? fs.existsSync;
56
- if (options.skipMissing && !pathExists(harnessRootPath)) {
85
+ if (options.skipMissing && harnessRootPath && !pathExists(harnessRootPath)) {
57
86
  return skipAction(harness, `harness config directory not found: ${harnessRootPath}`);
58
87
  }
59
88
  return {
@@ -70,6 +99,9 @@ export function planSkillInstall(harness, options = {}) {
70
99
  }
71
100
  export function planSkillUninstall(harness, options = {}) {
72
101
  const skillName = options.skillName ?? DEFAULT_SKILL_NAME;
102
+ if (harness === "antigravity") {
103
+ return skipAction(harness, `shared skill left installed: ${resolveSharedSkillTargetPath(options)}`);
104
+ }
73
105
  if (harness === "gemini") {
74
106
  return {
75
107
  kind: "exec",
@@ -93,16 +125,141 @@ export function planSkillUninstall(harness, options = {}) {
93
125
  apply: () => removeInstalledSkill(targetPath, harnessRootPath, options)
94
126
  };
95
127
  }
128
+ export function planSharedSkillUninstall(options = {}) {
129
+ const targetPath = resolveSharedSkillTargetPath(options);
130
+ return {
131
+ kind: "file-patch",
132
+ harness: "antigravity",
133
+ filePath: targetPath,
134
+ description: `remove shared agents skill ${targetPath}`,
135
+ operation: "uninstall",
136
+ inspect: () => inspectInstalledPath(targetPath),
137
+ apply: () => removeInstalledSkill(targetPath, undefined, options)
138
+ };
139
+ }
96
140
  export function syncInstalledSkills(options = {}) {
97
141
  const sourcePath = resolveBundledSkillPath(options);
98
142
  ensureSkillSourceExists(sourcePath);
99
143
  const sourceDigest = digestDirectory(sourcePath);
144
+ const syncTargets = dedupeSyncTargets(options);
100
145
  return {
101
146
  sourcePath,
102
147
  sourceDigest,
103
- targets: FILE_SKILL_HARNESSES.map((harness) => syncInstalledFileSkill(harness, sourcePath, sourceDigest, options))
148
+ targets: syncTargets.map((harness) => syncInstalledFileSkill(harness, sourcePath, sourceDigest, options))
104
149
  };
105
150
  }
151
+ export function removeDuplicateSkillInstalls(options) {
152
+ const sourcePath = resolveBundledSkillPath(options);
153
+ ensureSkillSourceExists(sourcePath);
154
+ const harnesses = options.harnesses === undefined || options.harnesses === "all"
155
+ ? SUPPORTED_HARNESSES
156
+ : options.harnesses;
157
+ const results = [];
158
+ for (const harness of harnesses) {
159
+ const targets = dedupePaths(resolveDuplicateSkillTargetPaths(harness, options));
160
+ if (targets.length === 0) {
161
+ const result = {
162
+ harness,
163
+ action: "skipped",
164
+ message: `${harness}: no proprietary skill duplicate target`,
165
+ target_type: "skill"
166
+ };
167
+ results.push(result);
168
+ appendSkillCleanupAudit(options, result);
169
+ continue;
170
+ }
171
+ for (const targetPath of targets) {
172
+ const result = cleanupDuplicateSkillTarget(harness, targetPath, sourcePath);
173
+ results.push(result);
174
+ appendSkillCleanupAudit(options, result, targetPath);
175
+ }
176
+ }
177
+ return results;
178
+ }
179
+ function dedupePaths(paths) {
180
+ const seen = new Set();
181
+ const result = [];
182
+ for (const targetPath of paths) {
183
+ const key = path.resolve(targetPath);
184
+ if (seen.has(key))
185
+ continue;
186
+ seen.add(key);
187
+ result.push(targetPath);
188
+ }
189
+ return result;
190
+ }
191
+ function cleanupDuplicateSkillTarget(harness, targetPath, sourcePath) {
192
+ try {
193
+ const stat = fs.lstatSync(targetPath);
194
+ if (!stat.isSymbolicLink()) {
195
+ return {
196
+ harness,
197
+ action: "preserved",
198
+ message: `${targetPath} is not a Talking Stick-managed symlink; left in place.`,
199
+ target_type: "skill"
200
+ };
201
+ }
202
+ const currentTarget = fs.readlinkSync(targetPath);
203
+ const resolvedCurrentTarget = path.resolve(path.dirname(targetPath), currentTarget);
204
+ if (!sameRealPath(resolvedCurrentTarget, sourcePath)) {
205
+ return {
206
+ harness,
207
+ action: "preserved",
208
+ message: `${targetPath} points at ${resolvedCurrentTarget}; left in place.`,
209
+ target_type: "skill"
210
+ };
211
+ }
212
+ fs.unlinkSync(targetPath);
213
+ return {
214
+ harness,
215
+ action: "removed",
216
+ message: `Removed duplicate skill symlink ${targetPath}.`,
217
+ target_type: "skill"
218
+ };
219
+ }
220
+ catch (error) {
221
+ if (error.code === "ENOENT") {
222
+ return {
223
+ harness,
224
+ action: "absent",
225
+ message: `${targetPath} is already absent.`,
226
+ target_type: "skill"
227
+ };
228
+ }
229
+ return {
230
+ harness,
231
+ action: "failed",
232
+ message: error instanceof Error ? error.message : String(error),
233
+ target_type: "skill"
234
+ };
235
+ }
236
+ }
237
+ function appendSkillCleanupAudit(options, result, targetPath) {
238
+ options.audit?.append({
239
+ reason: options.reason,
240
+ package_version_from: options.packageVersionFrom,
241
+ package_version_to: options.packageVersionTo,
242
+ harness: result.harness,
243
+ target_type: "skill",
244
+ config_path: targetPath,
245
+ action: result.action,
246
+ server_name: options.skillName ?? DEFAULT_SKILL_NAME,
247
+ detail: result.message
248
+ });
249
+ }
250
+ function dedupeSyncTargets(options) {
251
+ const seen = new Set();
252
+ const targets = [];
253
+ for (const harness of FILE_SKILL_HARNESSES) {
254
+ const targetPath = resolvePrimarySkillTargetPath(harness, options);
255
+ const key = path.resolve(targetPath);
256
+ if (seen.has(key))
257
+ continue;
258
+ seen.add(key);
259
+ targets.push(harness);
260
+ }
261
+ return targets;
262
+ }
106
263
  function currentPackageDir() {
107
264
  return path.resolve(path.dirname(fileURLToPath(import.meta.url)), "..");
108
265
  }
@@ -114,7 +271,7 @@ function ensureSkillSourceExists(sourcePath) {
114
271
  }
115
272
  function installSkillDirectory(sourcePath, targetPath, harnessRootPath, link, options) {
116
273
  const pathExists = options.pathExists ?? fs.existsSync;
117
- if (options.skipMissing && !pathExists(harnessRootPath)) {
274
+ if (options.skipMissing && harnessRootPath && !pathExists(harnessRootPath)) {
118
275
  throw new MissingHarnessError(`harness config directory not found: ${harnessRootPath}`);
119
276
  }
120
277
  fs.mkdirSync(path.dirname(targetPath), { recursive: true });
@@ -126,8 +283,11 @@ function installSkillDirectory(sourcePath, targetPath, harnessRootPath, link, op
126
283
  fs.cpSync(sourcePath, targetPath, { recursive: true });
127
284
  }
128
285
  function syncInstalledFileSkill(harness, sourcePath, sourceDigest, options) {
129
- const targetPath = resolveSkillTargetPath(harness, options);
130
- const harnessRootPath = resolveHarnessConfigDir(harness, options);
286
+ const targetPath = resolvePrimarySkillTargetPath(harness, options);
287
+ const model = skillLoadingModel(harness);
288
+ const harnessRootPath = model === "shared" || model === "shared+proprietary"
289
+ ? path.dirname(resolveSharedAgentsSkillsDir(options))
290
+ : resolveHarnessConfigDir(harness, options);
131
291
  try {
132
292
  if (!fs.existsSync(harnessRootPath) || !fs.existsSync(targetPath)) {
133
293
  return {
@@ -209,6 +369,18 @@ function inspectInstalledSkill(sourcePath, targetPath, link) {
209
369
  throw error;
210
370
  }
211
371
  }
372
+ function inspectInstalledPath(targetPath) {
373
+ try {
374
+ fs.lstatSync(targetPath);
375
+ return "present";
376
+ }
377
+ catch (error) {
378
+ if (error.code === "ENOENT") {
379
+ return "absent";
380
+ }
381
+ throw error;
382
+ }
383
+ }
212
384
  function removeInstalledSkill(targetPath, harnessRootPath, options = {}) {
213
385
  const pathExists = options.pathExists ?? fs.existsSync;
214
386
  if (options.skipMissing && harnessRootPath && !pathExists(harnessRootPath)) {
@@ -4,6 +4,7 @@ import { fileURLToPath } from "node:url";
4
4
  import { resolveDataDir } from "./config.js";
5
5
  import { FileAuditLog, defaultAuditLogPath } from "./install-audit.js";
6
6
  import { removeStaleMcpRegistrations } from "./install-migration.js";
7
+ import { removeDuplicateSkillInstalls } from "./skill-install.js";
7
8
  export const UPDATE_MIGRATION_STATE_FILE = "update-migrations-state.json";
8
9
  export async function runStaleMcpCleanup(options) {
9
10
  const packageVersionTo = options.packageVersionTo ?? options.packageVersion ?? readPackageVersion();
@@ -11,7 +12,7 @@ export async function runStaleMcpCleanup(options) {
11
12
  const statePath = resolveUpdateMigrationStatePath(dataDir);
12
13
  const auditPath = defaultAuditLogPath(dataDir);
13
14
  const audit = options.audit ?? new FileAuditLog(auditPath);
14
- const results = await removeStaleMcpRegistrations({
15
+ const mcpResults = await removeStaleMcpRegistrations({
15
16
  harnesses: options.harnesses ?? "all",
16
17
  reason: options.reason,
17
18
  packageVersionFrom: options.packageVersionFrom,
@@ -19,6 +20,15 @@ export async function runStaleMcpCleanup(options) {
19
20
  audit,
20
21
  installOptions: options.installOptions
21
22
  });
23
+ const skillResults = removeDuplicateSkillInstalls({
24
+ harnesses: options.harnesses ?? "all",
25
+ reason: options.reason,
26
+ packageVersionFrom: options.packageVersionFrom,
27
+ packageVersionTo,
28
+ audit,
29
+ ...(options.installOptions ?? {})
30
+ });
31
+ const results = [...mcpResults, ...skillResults];
22
32
  if (options.updateState !== false && !results.some((result) => result.action === "failed")) {
23
33
  writeUpdateMigrationState(statePath, {
24
34
  mcp_cleanup_version: packageVersionTo,