gh-stack 0.15.0 → 0.17.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 +64 -20
- package/package.json +1 -1
- package/src/commands/add.ts +107 -0
- package/src/commands/bottom.ts +2 -2
- package/src/commands/checkout.ts +9 -8
- package/src/commands/down.ts +2 -2
- package/src/commands/learn.ts +152 -0
- package/src/commands/merge.ts +174 -98
- package/src/commands/restack.ts +57 -13
- package/src/commands/sync.ts +3 -1
- package/src/commands/top.ts +2 -2
- package/src/commands/up.ts +2 -2
- package/src/index.ts +16 -2
- package/src/lib/git.ts +45 -1
- package/src/lib/md.d.ts +7 -0
- package/src/lib/safety.ts +31 -0
- package/src/lib/skill.ts +22 -0
- package/src/lib/using-gh-stack.md +260 -0
package/README.md
CHANGED
|
@@ -83,6 +83,18 @@ create <branch-name> [--description <desc>]
|
|
|
83
83
|
Create a new git branch off the current branch and add it to
|
|
84
84
|
the stack. The current branch must already be tracked.
|
|
85
85
|
|
|
86
|
+
add [branch]
|
|
87
|
+
Track an EXISTING local branch (default: the current branch) in a
|
|
88
|
+
stack — without pushing or opening a PR. Walks local ancestry: if the
|
|
89
|
+
branch sits on top of an existing stack it's adopted into it (parent
|
|
90
|
+
auto-detected); otherwise a new stack is created from the chain.
|
|
91
|
+
|
|
92
|
+
Use this for a WIP branch you've created with `git checkout -b` but
|
|
93
|
+
aren't ready to submit. It fills the gap between creating a branch and
|
|
94
|
+
`submit`: until a branch is tracked, `sync`/`restack`/navigation don't
|
|
95
|
+
know about it, and `add` is how you track it without publishing. (When
|
|
96
|
+
you do run `submit`, it auto-tracks too — `add` is the no-push path.)
|
|
97
|
+
|
|
86
98
|
submit [-d|--draft] [-n|--no-edit] [-t|--title <t>] [-b|--body <b>] [--body-file <f>] [--restack] [--dry-run]
|
|
87
99
|
Push all downstack branches to GitHub, create PRs for branches
|
|
88
100
|
that don't have them, number each PR title with its stack position
|
|
@@ -168,9 +180,10 @@ sync [--dry-run]
|
|
|
168
180
|
and conflicting.
|
|
169
181
|
|
|
170
182
|
merge [--dry-run] [-d|--delete-branch] [--collapse]
|
|
171
|
-
merge --approved [--dry-run] [-d|--delete-branch]
|
|
172
|
-
|
|
173
|
-
|
|
183
|
+
merge --approved [--collapse] [--dry-run] [-d|--delete-branch]
|
|
184
|
+
merge --base [--dry-run] [-d|--delete-branch]
|
|
185
|
+
Default (top-down collapse): squash-merge the whole stack top-down
|
|
186
|
+
via GitHub (PR3 → PR2 → PR1), then enables auto-merge for the base PR
|
|
174
187
|
into main. The whole stack lands on main as a single squash commit.
|
|
175
188
|
All merges happen on GitHub so PRs show as "Merged", Linear tickets
|
|
176
189
|
close automatically, and GitHub Actions fire normally. Skips already-
|
|
@@ -181,20 +194,25 @@ merge --approved [--dry-run] [-d|--delete-branch]
|
|
|
181
194
|
real conflict with main stops the merge (the stack is left intact —
|
|
182
195
|
resolve with `gh-stack sync`, then re-run), while merely waiting on
|
|
183
196
|
pending checks is fine (auto-merge gates on them).
|
|
184
|
-
|
|
185
|
-
|
|
186
|
-
|
|
187
|
-
|
|
188
|
-
|
|
189
|
-
|
|
190
|
-
|
|
191
|
-
|
|
192
|
-
|
|
193
|
-
|
|
194
|
-
|
|
195
|
-
|
|
196
|
-
|
|
197
|
-
|
|
197
|
+
|
|
198
|
+
--approved Collapse only the approved run. Walks approval from the
|
|
199
|
+
BASE up and stops at the first PR that isn't approved
|
|
200
|
+
(contiguous — an unapproved PR4 caps the run at PR3 even
|
|
201
|
+
if PR5 is approved). Collapses base..highest-approved into
|
|
202
|
+
a single squash commit on main and leaves the unapproved
|
|
203
|
+
tail in place; after the base PR lands, run `gh-stack sync`
|
|
204
|
+
to re-root the tail onto main. All approved ⇒ same as a
|
|
205
|
+
full merge.
|
|
206
|
+
--base Squash-merge ONLY the bottom PR into main as its own
|
|
207
|
+
commit, then re-root the next branch onto main (replaying
|
|
208
|
+
just its own commits). One PR per run — run it again to
|
|
209
|
+
land the next. Approval is enforced by GitHub branch
|
|
210
|
+
protection, not gh-stack. A base PR already merged outside
|
|
211
|
+
gh-stack is detected and advanced past.
|
|
212
|
+
--collapse With the default or --approved, stop after collapsing into
|
|
213
|
+
the base PR WITHOUT merging it to main, so you can review
|
|
214
|
+
the cumulative diff on GitHub. Re-run `gh-stack merge` to
|
|
215
|
+
finish. Alias: --stop-at-base.
|
|
198
216
|
|
|
199
217
|
split [<branch>] [--name <name>]
|
|
200
218
|
Cut a stack into two at <branch>. The cut branch and every branch
|
|
@@ -278,6 +296,19 @@ doctor
|
|
|
278
296
|
Migrate old (v2) metadata to the v3 layout, reconcile git branch
|
|
279
297
|
config against the topology files, and flag stacks whose base
|
|
280
298
|
stack appears already-merged into main. Safe to run repeatedly.
|
|
299
|
+
|
|
300
|
+
learn [--skill] [--harness <name>] [--global] [--force]
|
|
301
|
+
Teach a coding agent how to drive gh-stack. By default prints the
|
|
302
|
+
canonical skill (Markdown, stamped with the running version) to
|
|
303
|
+
stdout — the text is compiled into the binary, so it never drifts
|
|
304
|
+
out of sync with the installed version. --skill installs it as a
|
|
305
|
+
skill file instead, choosing the path from the harness:
|
|
306
|
+
Claude Code <root>/.claude/skills/using-gh-stack/SKILL.md
|
|
307
|
+
Codex <root>/.codex/skills/using-gh-stack/SKILL.md
|
|
308
|
+
Cursor <root>/.cursor/skills/using-gh-stack/SKILL.md
|
|
309
|
+
--harness claude | codex | cursor (skips the interactive prompt)
|
|
310
|
+
--global Install under ~/.<harness>/ instead of the project root
|
|
311
|
+
--force Overwrite an existing skill file without confirming
|
|
281
312
|
```
|
|
282
313
|
|
|
283
314
|
## Global Options
|
|
@@ -449,8 +480,12 @@ gh-stack status
|
|
|
449
480
|
# When the whole stack is approved, merge it down as one squash commit
|
|
450
481
|
gh-stack merge # squash-merges down, pushes, enables auto-merge
|
|
451
482
|
|
|
452
|
-
# Or:
|
|
453
|
-
|
|
483
|
+
# Or: only the approved part is ready — collapse the approved run into one
|
|
484
|
+
# commit and leave the rest stacked (re-root the tail later with `sync`)
|
|
485
|
+
gh-stack merge --approved # collapses base..highest-approved → main, keeps the tail
|
|
486
|
+
|
|
487
|
+
# Or: land just the bottom PR, one at a time
|
|
488
|
+
gh-stack merge --base # merges the bottom PR → main, re-roots the next
|
|
454
489
|
|
|
455
490
|
# Or: collapse first to review the cumulative diff before shipping
|
|
456
491
|
gh-stack merge --collapse # squash-merges PRn..PR2 into PR1, stops there
|
|
@@ -460,7 +495,16 @@ gh-stack merge # finishes base PR → main + archives the stack
|
|
|
460
495
|
|
|
461
496
|
## Agent/CI Usage
|
|
462
497
|
|
|
463
|
-
gh-stack is designed to be used by AI agents and CI pipelines
|
|
498
|
+
gh-stack is designed to be used by AI agents and CI pipelines. The fastest way
|
|
499
|
+
to onboard an agent is `gh-stack learn` — it prints a version-matched skill (or
|
|
500
|
+
installs one with `--skill`) so the agent learns the current commands, flags,
|
|
501
|
+
and gotchas straight from the binary:
|
|
502
|
+
|
|
503
|
+
```bash
|
|
504
|
+
# Teach an agent the current API (always in sync with the installed version)
|
|
505
|
+
gh-stack learn # print the skill to stdout
|
|
506
|
+
gh-stack learn --skill --harness claude # install it for Claude Code
|
|
507
|
+
```
|
|
464
508
|
|
|
465
509
|
```bash
|
|
466
510
|
# Non-interactive mode — all prompts auto-resolved
|
package/package.json
CHANGED
|
@@ -0,0 +1,107 @@
|
|
|
1
|
+
// gh-stack add — register an existing local branch into a stack (no push, no PR).
|
|
2
|
+
//
|
|
3
|
+
// Fills the gap between `git checkout -b <wip>` and `submit`: it tracks the
|
|
4
|
+
// branch (and any untracked ancestors) in the right stack by walking local
|
|
5
|
+
// ancestry, so `sync` / `restack` / navigation work BEFORE you're ready to open
|
|
6
|
+
// a PR. Network-free — nothing is pushed and no PR is created. That's the whole
|
|
7
|
+
// point: `submit` is the command that publishes; `add` only updates metadata.
|
|
8
|
+
//
|
|
9
|
+
// Resolves the "catch-22" where a local WIP tip couldn't be synced because it
|
|
10
|
+
// wasn't in a stack, and the only way into a stack was `submit` (which pushes).
|
|
11
|
+
import * as p from "../lib/output.ts";
|
|
12
|
+
import pc from "picocolors";
|
|
13
|
+
import * as git from "../lib/git.ts";
|
|
14
|
+
import { gateLegacyMetadata } from "../lib/safety.ts";
|
|
15
|
+
import { readMetadata, writeMetadata, findStackForBranch, stackBase } from "../lib/metadata.ts";
|
|
16
|
+
import { resolveOrCreateStack } from "../lib/chain.ts";
|
|
17
|
+
import type { StackMetadata } from "../types.ts";
|
|
18
|
+
|
|
19
|
+
export default async function add(args: string[]): Promise<void> {
|
|
20
|
+
if (args.includes("--help")) {
|
|
21
|
+
console.log(`
|
|
22
|
+
gh-stack add — track an existing local branch in a stack (no push, no PR)
|
|
23
|
+
|
|
24
|
+
USAGE
|
|
25
|
+
gh-stack add [branch]
|
|
26
|
+
|
|
27
|
+
Registers the branch (default: the current branch) into a stack so sync,
|
|
28
|
+
restack, and navigation work before you've submitted it. Walks local ancestry:
|
|
29
|
+
if the branch sits on top of an existing stack it's adopted into that stack;
|
|
30
|
+
otherwise a new stack is created from the chain, rooted at the trunk.
|
|
31
|
+
|
|
32
|
+
Network-free — nothing is pushed and no PR is opened. Run ${"`"}gh-stack submit${"`"} when
|
|
33
|
+
you're ready to publish.
|
|
34
|
+
|
|
35
|
+
EXAMPLES
|
|
36
|
+
gh-stack add Track the current branch
|
|
37
|
+
gh-stack add my-wip-branch Track a specific local branch
|
|
38
|
+
`);
|
|
39
|
+
return;
|
|
40
|
+
}
|
|
41
|
+
|
|
42
|
+
await gateLegacyMetadata();
|
|
43
|
+
|
|
44
|
+
const positional = args.find((a) => !a.startsWith("-"));
|
|
45
|
+
const branch = positional ?? (await git.currentBranch());
|
|
46
|
+
|
|
47
|
+
if (!(await git.localBranchExists(branch))) {
|
|
48
|
+
p.cancel(`Branch ${pc.yellow(branch)} doesn't exist locally.`);
|
|
49
|
+
process.exit(1);
|
|
50
|
+
}
|
|
51
|
+
|
|
52
|
+
const trunk = await git.trunkBranch();
|
|
53
|
+
if (branch === trunk || branch === "main" || branch === "master") {
|
|
54
|
+
p.cancel(
|
|
55
|
+
`${pc.yellow(branch)} is the trunk — nothing to add.\n\n Checkout a feature branch first.`,
|
|
56
|
+
);
|
|
57
|
+
process.exit(1);
|
|
58
|
+
}
|
|
59
|
+
|
|
60
|
+
// submit self-heals from an empty store, so a missing metadata file is fine —
|
|
61
|
+
// resolveOrCreateStack will create the first stack from local ancestry.
|
|
62
|
+
const meta: StackMetadata = (await readMetadata()) ?? {
|
|
63
|
+
version: 2,
|
|
64
|
+
current_stack: null,
|
|
65
|
+
stacks: {},
|
|
66
|
+
};
|
|
67
|
+
|
|
68
|
+
const already = findStackForBranch(meta, branch);
|
|
69
|
+
if (already) {
|
|
70
|
+
p.log.info(`${pc.yellow(branch)} is already tracked in stack ${pc.yellow(already)}.`);
|
|
71
|
+
return;
|
|
72
|
+
}
|
|
73
|
+
|
|
74
|
+
let resolution;
|
|
75
|
+
try {
|
|
76
|
+
resolution = await resolveOrCreateStack(meta, branch, trunk);
|
|
77
|
+
} catch (err) {
|
|
78
|
+
p.cancel((err as Error).message);
|
|
79
|
+
process.exit(1);
|
|
80
|
+
}
|
|
81
|
+
|
|
82
|
+
meta.current_stack = resolution.stackName;
|
|
83
|
+
await writeMetadata(meta);
|
|
84
|
+
|
|
85
|
+
p.intro(pc.cyan("Add to Stack"));
|
|
86
|
+
|
|
87
|
+
const stack = meta.stacks[resolution.stackName]!;
|
|
88
|
+
const base = stackBase(stack);
|
|
89
|
+
const baseNote = base === "main" || base === "master" ? "" : ` ${pc.dim(`(base: ${base})`)}`;
|
|
90
|
+
|
|
91
|
+
if (resolution.created) {
|
|
92
|
+
p.log.success(
|
|
93
|
+
`Created stack ${pc.yellow(resolution.stackName)} from ${resolution.addedBranches.length} local branch(es)${baseNote}`,
|
|
94
|
+
);
|
|
95
|
+
} else {
|
|
96
|
+
p.log.success(
|
|
97
|
+
`Added ${resolution.addedBranches.length} branch(es) to ${pc.yellow(resolution.stackName)}${baseNote}`,
|
|
98
|
+
);
|
|
99
|
+
}
|
|
100
|
+
for (const b of resolution.addedBranches) {
|
|
101
|
+
console.log(` ${pc.green("+")} ${b}`);
|
|
102
|
+
}
|
|
103
|
+
|
|
104
|
+
p.outro(
|
|
105
|
+
`Tracked — ${pc.green("gh-stack sync")}/${pc.green("restack")} now work. ${pc.dim("Run")} ${pc.green("gh-stack submit")} ${pc.dim("to open PRs.")}`,
|
|
106
|
+
);
|
|
107
|
+
}
|
package/src/commands/bottom.ts
CHANGED
|
@@ -3,7 +3,7 @@ import * as p from "../lib/output.ts";
|
|
|
3
3
|
import pc from "picocolors";
|
|
4
4
|
import * as git from "../lib/git.ts";
|
|
5
5
|
import { findStackForBranch, getOrderedBranches, writeMetadata } from "../lib/metadata.ts";
|
|
6
|
-
import { ensureMetadata } from "../lib/safety.ts";
|
|
6
|
+
import { ensureMetadata, checkoutOrExit } from "../lib/safety.ts";
|
|
7
7
|
|
|
8
8
|
export default async function bottom(args: string[]): Promise<void> {
|
|
9
9
|
if (args.includes("--help")) {
|
|
@@ -43,7 +43,7 @@ Switches to the bottom-most branch of the current stack
|
|
|
43
43
|
return;
|
|
44
44
|
}
|
|
45
45
|
|
|
46
|
-
await
|
|
46
|
+
await checkoutOrExit(target);
|
|
47
47
|
|
|
48
48
|
// Update tracking
|
|
49
49
|
stack.last_branch = target;
|
package/src/commands/checkout.ts
CHANGED
|
@@ -3,7 +3,7 @@ import * as p from "../lib/output.ts";
|
|
|
3
3
|
import pc from "picocolors";
|
|
4
4
|
import * as git from "../lib/git.ts";
|
|
5
5
|
import { writeMetadata, getOrderedBranches, findStackForBranch } from "../lib/metadata.ts";
|
|
6
|
-
import { ensureMetadata } from "../lib/safety.ts";
|
|
6
|
+
import { ensureMetadata, checkoutOrExit } from "../lib/safety.ts";
|
|
7
7
|
import { selectBranch, selectStack } from "../lib/ui.ts";
|
|
8
8
|
|
|
9
9
|
export default async function checkout(args: string[]): Promise<void> {
|
|
@@ -45,20 +45,21 @@ TIP
|
|
|
45
45
|
process.exit(0);
|
|
46
46
|
}
|
|
47
47
|
|
|
48
|
-
//
|
|
49
|
-
|
|
50
|
-
|
|
51
|
-
|
|
52
|
-
// Checkout last branch in stack
|
|
48
|
+
// Checkout the stack's last branch FIRST — only persist current_stack once
|
|
49
|
+
// the switch actually lands, so a refused checkout (e.g. dirty tree) leaves
|
|
50
|
+
// metadata pointing at the stack we're still on rather than the target.
|
|
53
51
|
const stack = meta.stacks[stackName]!;
|
|
54
52
|
const lastBranch = stack.last_branch;
|
|
55
53
|
|
|
56
54
|
if (lastBranch && lastBranch !== currentBranch) {
|
|
57
55
|
p.log.info(`Switching to stack ${pc.yellow(stackName)}`);
|
|
58
56
|
p.log.info(`Checking out ${pc.yellow(lastBranch)}`);
|
|
59
|
-
await
|
|
57
|
+
await checkoutOrExit(lastBranch);
|
|
60
58
|
}
|
|
61
59
|
|
|
60
|
+
meta.current_stack = stackName;
|
|
61
|
+
await writeMetadata(meta);
|
|
62
|
+
|
|
62
63
|
p.outro(pc.green(`Switched to stack ${pc.yellow(stackName)}`));
|
|
63
64
|
} else {
|
|
64
65
|
// ── Branch switching ──
|
|
@@ -105,7 +106,7 @@ TIP
|
|
|
105
106
|
}
|
|
106
107
|
|
|
107
108
|
p.log.info(`Switching to ${pc.yellow(targetBranch)}`);
|
|
108
|
-
await
|
|
109
|
+
await checkoutOrExit(targetBranch);
|
|
109
110
|
|
|
110
111
|
// Update last_branch
|
|
111
112
|
stack.last_branch = targetBranch;
|
package/src/commands/down.ts
CHANGED
|
@@ -3,7 +3,7 @@ import * as p from "../lib/output.ts";
|
|
|
3
3
|
import pc from "picocolors";
|
|
4
4
|
import * as git from "../lib/git.ts";
|
|
5
5
|
import { findStackForBranch, stackBase, writeMetadata } from "../lib/metadata.ts";
|
|
6
|
-
import { ensureMetadata } from "../lib/safety.ts";
|
|
6
|
+
import { ensureMetadata, checkoutOrExit } from "../lib/safety.ts";
|
|
7
7
|
|
|
8
8
|
export default async function down(args: string[]): Promise<void> {
|
|
9
9
|
if (args.includes("--help")) {
|
|
@@ -64,7 +64,7 @@ OPTIONS
|
|
|
64
64
|
moved++;
|
|
65
65
|
}
|
|
66
66
|
|
|
67
|
-
await
|
|
67
|
+
await checkoutOrExit(currentBranch);
|
|
68
68
|
|
|
69
69
|
// Update tracking
|
|
70
70
|
stack.last_branch = currentBranch;
|
|
@@ -0,0 +1,152 @@
|
|
|
1
|
+
// gh-stack learn — teach agents how to drive gh-stack.
|
|
2
|
+
//
|
|
3
|
+
// gh-stack learn Print the canonical, version-stamped skill to stdout.
|
|
4
|
+
// gh-stack learn --skill Install it as a skill file for a coding harness.
|
|
5
|
+
//
|
|
6
|
+
// The skill text is compiled into the binary (src/lib/skill.ts), so it's always
|
|
7
|
+
// in lockstep with the installed version — no hand-maintained skill to drift.
|
|
8
|
+
import { homedir } from "node:os";
|
|
9
|
+
import { join } from "node:path";
|
|
10
|
+
import pc from "picocolors";
|
|
11
|
+
import * as p from "../lib/output.ts";
|
|
12
|
+
import * as git from "../lib/git.ts";
|
|
13
|
+
import { isAutoYes } from "../lib/ui.ts";
|
|
14
|
+
import { SKILL_NAME, skillContent } from "../lib/skill.ts";
|
|
15
|
+
|
|
16
|
+
const { version: VERSION } = await import("../../package.json");
|
|
17
|
+
|
|
18
|
+
interface Harness {
|
|
19
|
+
key: string;
|
|
20
|
+
label: string;
|
|
21
|
+
dir: string; // the per-harness config dir, e.g. ".claude"
|
|
22
|
+
}
|
|
23
|
+
|
|
24
|
+
// Project-root install locations per harness. Global installs swap the repo
|
|
25
|
+
// root for the home directory (same `<dir>/skills/<name>/SKILL.md` shape).
|
|
26
|
+
const HARNESSES: Harness[] = [
|
|
27
|
+
{ key: "claude", label: "Claude Code", dir: ".claude" },
|
|
28
|
+
{ key: "codex", label: "Codex", dir: ".codex" },
|
|
29
|
+
{ key: "cursor", label: "Cursor", dir: ".cursor" },
|
|
30
|
+
];
|
|
31
|
+
|
|
32
|
+
function flagValue(args: string[], name: string): string | undefined {
|
|
33
|
+
const eq = args.find((a) => a.startsWith(`${name}=`));
|
|
34
|
+
if (eq) return eq.slice(name.length + 1);
|
|
35
|
+
const i = args.indexOf(name);
|
|
36
|
+
if (i !== -1 && args[i + 1] && !args[i + 1]!.startsWith("-")) return args[i + 1];
|
|
37
|
+
return undefined;
|
|
38
|
+
}
|
|
39
|
+
|
|
40
|
+
export default async function learn(args: string[]): Promise<void> {
|
|
41
|
+
if (args.includes("--help")) {
|
|
42
|
+
console.log(`
|
|
43
|
+
gh-stack learn — teach an agent how to use gh-stack
|
|
44
|
+
|
|
45
|
+
USAGE
|
|
46
|
+
gh-stack learn [--skill] [options]
|
|
47
|
+
|
|
48
|
+
By default, prints the canonical gh-stack skill (Markdown, stamped with the
|
|
49
|
+
current version) to stdout. The text is compiled into the binary, so it never
|
|
50
|
+
drifts out of sync with the installed version.
|
|
51
|
+
|
|
52
|
+
OPTIONS
|
|
53
|
+
--skill Install the skill into a project (or --global) instead
|
|
54
|
+
of printing it
|
|
55
|
+
--harness <name> Target harness: claude | codex | cursor
|
|
56
|
+
(skips the interactive prompt)
|
|
57
|
+
--global, -g Install under your home dir (~/.<harness>/skills/...)
|
|
58
|
+
instead of the project root
|
|
59
|
+
--force Overwrite an existing skill file without confirming
|
|
60
|
+
--help Show this help
|
|
61
|
+
|
|
62
|
+
INSTALL PATHS
|
|
63
|
+
Claude Code <root>/.claude/skills/${SKILL_NAME}/SKILL.md
|
|
64
|
+
Codex <root>/.codex/skills/${SKILL_NAME}/SKILL.md
|
|
65
|
+
Cursor <root>/.cursor/skills/${SKILL_NAME}/SKILL.md
|
|
66
|
+
|
|
67
|
+
EXAMPLES
|
|
68
|
+
gh-stack learn # print the skill to stdout
|
|
69
|
+
gh-stack learn > AGENTS.md # capture it however you like
|
|
70
|
+
gh-stack learn --skill # interactive install (pick harness)
|
|
71
|
+
gh-stack learn --skill --harness claude # install for Claude Code, no prompt
|
|
72
|
+
gh-stack learn --skill --global -g # install into your home dir
|
|
73
|
+
`);
|
|
74
|
+
return;
|
|
75
|
+
}
|
|
76
|
+
|
|
77
|
+
const content = skillContent(VERSION);
|
|
78
|
+
|
|
79
|
+
// Default: just print the skill. No chrome — pipe-friendly for agents.
|
|
80
|
+
if (!args.includes("--skill")) {
|
|
81
|
+
console.log(content);
|
|
82
|
+
return;
|
|
83
|
+
}
|
|
84
|
+
|
|
85
|
+
// ── Install mode ──
|
|
86
|
+
const global = args.includes("--global") || args.includes("-g");
|
|
87
|
+
const force = args.includes("--force");
|
|
88
|
+
const autoYes = isAutoYes();
|
|
89
|
+
|
|
90
|
+
// Resolve harness: explicit flag, else interactive prompt.
|
|
91
|
+
let harness: Harness | undefined;
|
|
92
|
+
const harnessFlag = flagValue(args, "--harness");
|
|
93
|
+
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
|
+
);
|
|
99
|
+
process.exitCode = 1;
|
|
100
|
+
return;
|
|
101
|
+
}
|
|
102
|
+
} else if (autoYes) {
|
|
103
|
+
p.log.error("Non-interactive mode needs an explicit harness: --harness <claude|codex|cursor>.");
|
|
104
|
+
process.exitCode = 1;
|
|
105
|
+
return;
|
|
106
|
+
} else {
|
|
107
|
+
const selected = await p.select({
|
|
108
|
+
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
|
+
})),
|
|
114
|
+
});
|
|
115
|
+
if (p.isCancel(selected)) {
|
|
116
|
+
p.cancel("Cancelled.");
|
|
117
|
+
return;
|
|
118
|
+
}
|
|
119
|
+
harness = HARNESSES.find((h) => h.key === selected)!;
|
|
120
|
+
}
|
|
121
|
+
|
|
122
|
+
// Resolve install root: home dir (--global) or repo working-tree root.
|
|
123
|
+
let root: string;
|
|
124
|
+
if (global) {
|
|
125
|
+
root = homedir();
|
|
126
|
+
} else if (await git.isGitRepo()) {
|
|
127
|
+
root = await git.repoRoot();
|
|
128
|
+
} else {
|
|
129
|
+
p.log.error("Not inside a git repository. Run from a project, or use --global.");
|
|
130
|
+
process.exitCode = 1;
|
|
131
|
+
return;
|
|
132
|
+
}
|
|
133
|
+
const target = join(root, harness.dir, "skills", SKILL_NAME, "SKILL.md");
|
|
134
|
+
|
|
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
|
+
}
|
|
145
|
+
|
|
146
|
+
await Bun.write(target, content); // creates parent dirs as needed
|
|
147
|
+
|
|
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
|
+
);
|
|
152
|
+
}
|