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.
- package/README.md +256 -13
- package/package.json +9 -8
- package/src/commands/add.ts +131 -3
- package/src/commands/config.ts +133 -0
- package/src/commands/init.ts +4 -0
- package/src/commands/join.ts +877 -0
- package/src/commands/learn.ts +295 -36
- package/src/commands/merge.ts +263 -5
- package/src/commands/restack.ts +954 -211
- package/src/commands/submit.ts +257 -51
- package/src/commands/sync.ts +145 -145
- package/src/commands/undo.ts +11 -0
- package/src/commands/update-prs.ts +87 -15
- package/src/index.ts +155 -125
- package/src/lib/advance.ts +45 -9
- package/src/lib/chain.ts +20 -0
- package/src/lib/collapse-guard.ts +325 -0
- package/src/lib/config.ts +259 -0
- package/src/lib/git.ts +98 -22
- package/src/lib/github.ts +83 -5
- package/src/lib/metadata.ts +34 -7
- package/src/lib/output.ts +64 -14
- package/src/lib/paths.ts +1 -1
- package/src/lib/remote-guard.ts +281 -0
- package/src/lib/run-log.ts +117 -0
- package/src/lib/using-gh-stack.md +140 -5
- package/src/types.ts +62 -2
package/src/commands/learn.ts
CHANGED
|
@@ -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:
|
|
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
|
|
91
|
-
let
|
|
338
|
+
// Resolve harnesses: explicit flag, else interactive prompt.
|
|
339
|
+
let harnesses: Harness[];
|
|
92
340
|
const harnessFlag = flagValue(args, "--harness");
|
|
93
341
|
if (harnessFlag) {
|
|
94
|
-
|
|
95
|
-
if (
|
|
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(
|
|
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:
|
|
110
|
-
|
|
111
|
-
|
|
112
|
-
|
|
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
|
-
|
|
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
|
-
|
|
136
|
-
|
|
137
|
-
|
|
138
|
-
|
|
139
|
-
|
|
140
|
-
|
|
141
|
-
|
|
142
|
-
|
|
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
|
-
|
|
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
|
-
|
|
149
|
-
|
|
150
|
-
|
|
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
|
}
|