gh-stack 0.17.2 → 0.19.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.
@@ -5,8 +5,9 @@
5
5
  //
6
6
  // The skill text is compiled into the binary (src/lib/skill.ts), so it's always
7
7
  // in lockstep with the installed version — no hand-maintained skill to drift.
8
+ import { lstat, readlink, realpath, stat } from "node:fs/promises";
8
9
  import { homedir } from "node:os";
9
- import { join } from "node:path";
10
+ import { basename, dirname, join, resolve, sep } from "node:path";
10
11
  import pc from "picocolors";
11
12
  import * as p from "../lib/output.ts";
12
13
  import * as git from "../lib/git.ts";
@@ -15,7 +16,7 @@ import { SKILL_NAME, skillContent } from "../lib/skill.ts";
15
16
 
16
17
  const { version: VERSION } = await import("../../package.json");
17
18
 
18
- interface Harness {
19
+ export interface Harness {
19
20
  key: string;
20
21
  label: string;
21
22
  dir: string; // the per-harness config dir, e.g. ".claude"
@@ -23,12 +24,14 @@ interface Harness {
23
24
 
24
25
  // Project-root install locations per harness. Global installs swap the repo
25
26
  // root for the home directory (same `<dir>/skills/<name>/SKILL.md` shape).
26
- const HARNESSES: Harness[] = [
27
+ export const HARNESSES: Harness[] = [
27
28
  { key: "claude", label: "Claude Code", dir: ".claude" },
28
29
  { key: "codex", label: "Codex", dir: ".codex" },
29
30
  { key: "cursor", label: "Cursor", dir: ".cursor" },
30
31
  ];
31
32
 
33
+ const HARNESS_CHOICES = `${HARNESSES.map((h) => h.key).join(" | ")} | all`;
34
+
32
35
  function flagValue(args: string[], name: string): string | undefined {
33
36
  const eq = args.find((a) => a.startsWith(`${name}=`));
34
37
  if (eq) return eq.slice(name.length + 1);
@@ -37,6 +40,241 @@ function flagValue(args: string[], name: string): string | undefined {
37
40
  return undefined;
38
41
  }
39
42
 
43
+ /**
44
+ * Parse `--harness`: one key, a comma list (`claude,codex`), or `all`.
45
+ * Returns the harnesses in canonical order, or an error message.
46
+ */
47
+ function parseHarnesses(value: string): Harness[] | string {
48
+ const keys = new Set(
49
+ value
50
+ .toLowerCase()
51
+ .split(",")
52
+ .map((k) => k.trim())
53
+ .filter(Boolean),
54
+ );
55
+ const unknown = [...keys].filter((k) => k !== "all" && !HARNESSES.some((h) => h.key === k));
56
+ if (keys.size === 0 || unknown.length > 0) {
57
+ const bad = unknown.length > 0 ? unknown.join(", ") : value;
58
+ return `Unknown harness "${bad}". Choose from: ${HARNESSES.map((h) => h.key).join(", ")}, or all (comma lists work too).`;
59
+ }
60
+ return keys.has("all") ? HARNESSES : HARNESSES.filter((h) => keys.has(h.key));
61
+ }
62
+
63
+ /** Shorten a path under the home directory to `~/...` for display. */
64
+ function tildify(path: string): string {
65
+ const home = homedir();
66
+ return path === home || path.startsWith(home + sep) ? `~${path.slice(home.length)}` : path;
67
+ }
68
+
69
+ /**
70
+ * The first symlink on the install path — the skill dir, then SKILL.md — and
71
+ * where it points. `lstat`, not `stat`: `Bun.write` follows symlinks, so a
72
+ * symlinked skill dir (e.g. per-skill links into a version-controlled skills
73
+ * repo) would otherwise be silently overwritten at its real location.
74
+ */
75
+ async function findSymlink(
76
+ skillDir: string,
77
+ target: string,
78
+ ): Promise<{ path: string; realPath: string; dangling: boolean } | undefined> {
79
+ for (const path of [skillDir, target]) {
80
+ try {
81
+ if (!(await lstat(path)).isSymbolicLink()) continue;
82
+ } catch (err) {
83
+ const code = (err as NodeJS.ErrnoException).code;
84
+ if (code === "ENOENT" || code === "ENOTDIR") return undefined; // nothing below it either
85
+ throw err;
86
+ }
87
+ // lstat says link, stat says nothing there: a dangling link.
88
+ const dangling = await stat(path).then(
89
+ () => false,
90
+ () => true,
91
+ );
92
+ const realPath = dangling ? resolve(dirname(path), await readlink(path)) : await realpath(path);
93
+ return { path, realPath, dangling };
94
+ }
95
+ return undefined;
96
+ }
97
+
98
+ /**
99
+ * Where `path` really lands once every symlink along it is followed, including
100
+ * dangling links and components that don't exist yet. Two harnesses whose
101
+ * paths resolve to the same file share one SKILL.md.
102
+ */
103
+ async function resolveReal(path: string, depth = 0): Promise<string> {
104
+ try {
105
+ return await realpath(path);
106
+ } catch {
107
+ // Missing somewhere along the way; resolve what exists and append the rest.
108
+ }
109
+ if (depth < 40) {
110
+ const st = await lstat(path).catch(() => undefined);
111
+ if (st?.isSymbolicLink()) {
112
+ return resolveReal(resolve(dirname(path), await readlink(path)), depth + 1);
113
+ }
114
+ }
115
+ const parent = dirname(path);
116
+ if (parent === path) return path;
117
+ return join(await resolveReal(parent, depth + 1), basename(path));
118
+ }
119
+
120
+ /** Whether the skill dir or SKILL.md itself is a symlink (ancestors don't count). */
121
+ async function isSkillLevelLink(skillDir: string, target: string): Promise<boolean> {
122
+ for (const path of [skillDir, target]) {
123
+ const st = await lstat(path).catch(() => undefined);
124
+ if (!st) return false;
125
+ if (st.isSymbolicLink()) return true;
126
+ }
127
+ return false;
128
+ }
129
+
130
+ /** `"cancel"` when the user aborts the prompt (Ctrl-C / Esc). */
131
+ export type ConfirmFn = (message: string) => Promise<boolean | "cancel">;
132
+
133
+ export interface InstallOptions {
134
+ root: string;
135
+ harnesses: Harness[];
136
+ content: string;
137
+ global: boolean;
138
+ force: boolean;
139
+ autoYes: boolean;
140
+ confirm: ConfirmFn;
141
+ }
142
+
143
+ export interface InstallResult {
144
+ /** False if any target was refused or failed, or the run was cancelled. */
145
+ ok: boolean;
146
+ cancelled: boolean;
147
+ installed: string[]; // harness keys, including aliases of a written file
148
+ notAttempted: string[]; // harness keys left untouched by a cancel
149
+ }
150
+
151
+ /**
152
+ * Install the skill for each harness. Each target is checked on its own: a
153
+ * refused symlink, a dangling link, or a declined overwrite skips that harness
154
+ * and the rest still install. Harnesses that resolve to the same SKILL.md (a
155
+ * linked skill dir, or a shared `skills/` dir) are written once, through the
156
+ * harness whose own skill path isn't a link, and the others are reported as
157
+ * aliases of it.
158
+ */
159
+ export async function installSkills(opts: InstallOptions): Promise<InstallResult> {
160
+ const { root, harnesses, content, global, force, autoYes, confirm } = opts;
161
+
162
+ const targets = await Promise.all(
163
+ harnesses.map(async (harness) => {
164
+ const skillDir = join(root, harness.dir, "skills", SKILL_NAME);
165
+ const path = join(skillDir, "SKILL.md");
166
+ return {
167
+ harness,
168
+ skillDir,
169
+ path,
170
+ real: await resolveReal(path),
171
+ linked: await isSkillLevelLink(skillDir, path),
172
+ };
173
+ }),
174
+ );
175
+ type Target = (typeof targets)[number];
176
+
177
+ // Group by real file. The primary writes it; the others are aliases.
178
+ const groups = new Map<string, Target[]>();
179
+ for (const t of targets) groups.set(t.real, [...(groups.get(t.real) ?? []), t]);
180
+ const primaryOf = (group: Target[]) => group.find((t) => !t.linked) ?? group[0]!;
181
+
182
+ const result: InstallResult = { ok: true, cancelled: false, installed: [], notAttempted: [] };
183
+ const handled = new Set<string>();
184
+
185
+ for (const t of targets) {
186
+ if (handled.has(t.harness.key)) continue;
187
+ const group = groups.get(t.real)!;
188
+ const primary = primaryOf(group);
189
+ const outcome = await installOne(primary);
190
+
191
+ if (outcome === "cancel") {
192
+ result.cancelled = true;
193
+ result.ok = false;
194
+ result.notAttempted = targets
195
+ .map((x) => x.harness.key)
196
+ .filter((k) => !handled.has(k) && !result.installed.includes(k));
197
+ const names = (keys: string[]) =>
198
+ keys.length > 0
199
+ ? keys.map((k) => HARNESSES.find((h) => h.key === k)!.label).join(", ")
200
+ : "nothing";
201
+ p.cancel(
202
+ `Cancelled. Installed: ${names(result.installed)}. Not attempted: ${names(result.notAttempted)}.`,
203
+ );
204
+ return result;
205
+ }
206
+
207
+ handled.add(primary.harness.key);
208
+ if (outcome === "installed") result.installed.push(primary.harness.key);
209
+ if (outcome === "failed") result.ok = false; // "declined" was the user's choice, not a failure
210
+
211
+ for (const alias of group) {
212
+ if (alias === primary) continue;
213
+ handled.add(alias.harness.key);
214
+ const via = `${tildify(alias.skillDir)} resolves to ${tildify(alias.real)}`;
215
+ if (outcome === "installed") {
216
+ result.installed.push(alias.harness.key);
217
+ p.log.info(
218
+ `${alias.harness.label}: ${via}; already installed via ${primary.harness.label}.`,
219
+ );
220
+ } else {
221
+ p.log.info(
222
+ `${alias.harness.label}: ${via}, the same file as ${primary.harness.label}; not installed (see above).`,
223
+ );
224
+ }
225
+ }
226
+ }
227
+ return result;
228
+
229
+ async function installOne(t: Target): Promise<"installed" | "declined" | "failed" | "cancel"> {
230
+ const { harness, skillDir, path } = t;
231
+ try {
232
+ const link = await findSymlink(skillDir, path);
233
+ // A dangling link can't be written through, --force or not.
234
+ if (link?.dangling) {
235
+ p.log.error(
236
+ `${tildify(link.path)} is a dangling symlink to ${tildify(link.realPath)}, which does not exist.\n` +
237
+ `Remove the link (${pc.dim(`rm ${tildify(link.path)}`)}) and re-run to install there.`,
238
+ );
239
+ return "failed";
240
+ }
241
+ // Never write through a symlink unless --force says so explicitly.
242
+ // Auto-yes accepts routine prompts; it must not overwrite a file that
243
+ // lives somewhere else (often another repo) on the user's behalf.
244
+ if (link && !force) {
245
+ const retry = `gh-stack learn --skill${global ? " --global" : ""} --harness ${harness.key} --force`;
246
+ p.log.error(
247
+ `${tildify(link.path)} is a symlink to ${tildify(link.realPath)}; not writing through it.\n` +
248
+ `To overwrite the file it points to, run ${pc.dim(retry)}.`,
249
+ );
250
+ return "failed";
251
+ }
252
+ if (link) {
253
+ p.log.warn(
254
+ `${tildify(link.path)} is a symlink; writing through it to ${tildify(link.realPath)} (--force).`,
255
+ );
256
+ }
257
+
258
+ // Confirm overwrite unless --force / auto-yes.
259
+ if ((await Bun.file(path).exists()) && !force && !autoYes) {
260
+ const ok = await confirm(`${pc.yellow(tildify(path))} already exists. Overwrite?`);
261
+ if (ok === "cancel") return "cancel";
262
+ if (!ok) {
263
+ p.log.info(`Left existing ${harness.label} skill in place.`);
264
+ return "declined";
265
+ }
266
+ }
267
+
268
+ await Bun.write(path, content); // creates parent dirs as needed
269
+ } catch (err) {
270
+ p.log.error(`Could not install ${harness.label} skill at ${tildify(path)}: ${err}`);
271
+ return "failed";
272
+ }
273
+ p.log.success(`Installed ${harness.label} skill → ${pc.cyan(tildify(path))}`);
274
+ return "installed";
275
+ }
276
+ }
277
+
40
278
  export default async function learn(args: string[]): Promise<void> {
41
279
  if (args.includes("--help")) {
42
280
  console.log(`
@@ -52,11 +290,12 @@ drifts out of sync with the installed version.
52
290
  OPTIONS
53
291
  --skill Install the skill into a project (or --global) instead
54
292
  of printing it
55
- --harness <name> Target harness: claude | codex | cursor
56
- (skips the interactive prompt)
293
+ --harness <name> Target harness: ${HARNESS_CHOICES}, or a comma
294
+ list such as claude,codex (skips the interactive prompt)
57
295
  --global, -g Install under your home dir (~/.<harness>/skills/...)
58
296
  instead of the project root
59
- --force Overwrite an existing skill file without confirming
297
+ --force Overwrite an existing skill file without confirming,
298
+ and write through a symlinked skill dir or SKILL.md
60
299
  --help Show this help
61
300
 
62
301
  INSTALL PATHS
@@ -64,12 +303,21 @@ INSTALL PATHS
64
303
  Codex <root>/.codex/skills/${SKILL_NAME}/SKILL.md
65
304
  Cursor <root>/.cursor/skills/${SKILL_NAME}/SKILL.md
66
305
 
306
+ SYMLINKS
307
+ If the skill dir or SKILL.md is a symlink (e.g. into a skills repo you keep
308
+ under version control), the install refuses and names the link target.
309
+ Only --force writes through it; --yes / GH_STACK_YES does not. A dangling
310
+ link is never written through: remove it to install there. Harnesses that
311
+ resolve to the same SKILL.md (a linked skill dir or a shared skills/ dir)
312
+ are written once and reported as installed via the first.
313
+
67
314
  EXAMPLES
68
315
  gh-stack learn # print the skill to stdout
69
316
  gh-stack learn > AGENTS.md # capture it however you like
70
317
  gh-stack learn --skill # interactive install (pick harness)
71
318
  gh-stack learn --skill --harness claude # install for Claude Code, no prompt
72
319
  gh-stack learn --skill --global -g # install into your home dir
320
+ gh-stack learn --skill -g --harness all # every harness, in your home dir
73
321
  `);
74
322
  return;
75
323
  }
@@ -87,36 +335,39 @@ EXAMPLES
87
335
  const force = args.includes("--force");
88
336
  const autoYes = isAutoYes();
89
337
 
90
- // Resolve harness: explicit flag, else interactive prompt.
91
- let harness: Harness | undefined;
338
+ // Resolve harnesses: explicit flag, else interactive prompt.
339
+ let harnesses: Harness[];
92
340
  const harnessFlag = flagValue(args, "--harness");
93
341
  if (harnessFlag) {
94
- harness = HARNESSES.find((h) => h.key === harnessFlag.toLowerCase());
95
- if (!harness) {
96
- p.log.error(
97
- `Unknown harness "${harnessFlag}". Choose one of: ${HARNESSES.map((h) => h.key).join(", ")}.`,
98
- );
342
+ const parsed = parseHarnesses(harnessFlag);
343
+ if (typeof parsed === "string") {
344
+ p.log.error(parsed);
99
345
  process.exitCode = 1;
100
346
  return;
101
347
  }
348
+ harnesses = parsed;
102
349
  } else if (autoYes) {
103
- p.log.error("Non-interactive mode needs an explicit harness: --harness <claude|codex|cursor>.");
350
+ p.log.error(`Non-interactive mode needs an explicit harness: --harness <${HARNESS_CHOICES}>.`);
104
351
  process.exitCode = 1;
105
352
  return;
106
353
  } else {
107
354
  const selected = await p.select({
108
355
  message: "Which harness is this skill for?",
109
- options: HARNESSES.map((h) => ({
110
- value: h.key,
111
- label: h.label,
112
- hint: `${global ? "~" : "."}/${h.dir}/skills/${SKILL_NAME}/`,
113
- })),
356
+ options: [
357
+ ...HARNESSES.map((h) => ({
358
+ value: h.key,
359
+ label: h.label,
360
+ hint: `${global ? "~" : "."}/${h.dir}/skills/${SKILL_NAME}/`,
361
+ })),
362
+ { value: "all", label: "All of the above" },
363
+ ],
114
364
  });
115
365
  if (p.isCancel(selected)) {
116
366
  p.cancel("Cancelled.");
367
+ process.exitCode = 1;
117
368
  return;
118
369
  }
119
- harness = HARNESSES.find((h) => h.key === selected)!;
370
+ harnesses = selected === "all" ? HARNESSES : HARNESSES.filter((h) => h.key === selected);
120
371
  }
121
372
 
122
373
  // Resolve install root: home dir (--global) or repo working-tree root.
@@ -130,23 +381,31 @@ EXAMPLES
130
381
  process.exitCode = 1;
131
382
  return;
132
383
  }
133
- const target = join(root, harness.dir, "skills", SKILL_NAME, "SKILL.md");
134
384
 
135
- // Confirm overwrite unless --force / auto-yes.
136
- if ((await Bun.file(target).exists()) && !force && !autoYes) {
137
- const ok = await p.confirm({
138
- message: `${pc.yellow(target)} already exists. Overwrite?`,
139
- });
140
- if (p.isCancel(ok) || !ok) {
141
- p.cancel("Left existing skill in place.");
142
- return;
143
- }
144
- }
385
+ const result = await installSkills({
386
+ root,
387
+ harnesses,
388
+ content,
389
+ global,
390
+ force,
391
+ autoYes,
392
+ confirm: async (message) => {
393
+ const ok = await p.confirm({ message });
394
+ return p.isCancel(ok) ? "cancel" : ok;
395
+ },
396
+ });
397
+ if (!result.ok) process.exitCode = 1;
145
398
 
146
- await Bun.write(target, content); // creates parent dirs as needed
399
+ if (result.installed.length > 0) {
400
+ p.log.info(
401
+ `Stamped from gh-stack v${VERSION}. Refresh after upgrading with ${pc.dim(refreshCommand(harnesses, global))}.`,
402
+ );
403
+ }
404
+ }
147
405
 
148
- p.log.success(`Installed ${harness.label} skill → ${pc.cyan(target)}`);
149
- p.log.info(
150
- `Stamped from gh-stack v${VERSION}. Refresh after upgrading with ${pc.dim("gh-stack learn --skill")}.`,
151
- );
406
+ /** The command that re-runs this install with the same scope, prompt-free. */
407
+ export function refreshCommand(harnesses: Harness[], global: boolean): string {
408
+ const spec =
409
+ harnesses.length === HARNESSES.length ? "all" : harnesses.map((h) => h.key).join(",");
410
+ return `gh-stack learn --skill${global ? " --global" : ""} --harness ${spec}`;
152
411
  }