pi-graphite 0.2.3 → 0.3.1

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 CHANGED
@@ -1,11 +1,16 @@
1
1
  # pi-graphite
2
2
 
3
- Structured pi tools that wrap the [Graphite](https://graphite.com) `gt` CLI so
4
- agents (and humans) can drive stacked PR workflows safely from pi.
3
+ Opinionated pi tools + skill that wrap the [Graphite](https://graphite.com)
4
+ `gt` CLI for stacked PR workflows. Seven tools, one correct path.
5
5
 
6
- This package is **Layer A** of the planned design — one tool per Graphite
7
- domain resource (repo / stack / branch / PR / recovery), not one tool per `gt`
8
- subcommand and not a workflow orchestrator.
6
+ ```
7
+ graphite_status (graphite_setup if needed) graphite_sync graphite_navigate
8
+ graphite_change graphite_submit_stack (dry-run) → graphite_submit_stack (apply)
9
+ ```
10
+
11
+ The extension wraps `gt` only. It deliberately does **not** call `gh`, edit PR
12
+ titles/bodies, fetch review comments, or perform stack surgery
13
+ (split/fold/move/squash). Use the `gt` or `gh` CLI directly for those.
9
14
 
10
15
  ## Requirements
11
16
 
@@ -27,40 +32,78 @@ pi install /path/to/pi-graphite
27
32
  pi -e /path/to/pi-graphite
28
33
  ```
29
34
 
35
+ The package also ships a `graphite` skill (`skills/graphite/SKILL.md`) that pi
36
+ auto-discovers. It describes the golden path and per-recipe tool calls; the
37
+ agent loads it on demand.
38
+
30
39
  ## Registered tools
31
40
 
32
- | Tool | Resource | Wraps |
33
- | ---------------------------- | -------- | ------------------------------------------------- |
34
- | `graphite_repo` | repo | `gt trunk`, `gt init`, `gt log short` |
35
- | `graphite_stack_view` | stack | `gt log` / `gt log short` / `gt log long` |
36
- | `graphite_stack_restack` | stack | `gt restack` (+ `--branch/--downstack/--upstack/--only`) |
37
- | `graphite_stack_reorganize` | stack | `gt move`, `gt fold`, `gt split --by-file` (move_branch supports `dryRun` via `git merge-tree` simulation) |
38
- | `graphite_stack_compose` | stack | linearize branches by cherry-picking `base..branch` unique commits in order, then `gt track` each |
39
- | `graphite_branch_inspect` | branch | `gt info` (+ `gt parent`, `gt children`) |
40
- | `graphite_branch_create` | branch | `gt create` |
41
- | `graphite_branch_update` | branch | `gt modify`, `gt absorb`, `gt squash`, `gt pop`, `gt rename`, `gt delete` |
42
- | `graphite_branch_tracking` | branch | `gt track`, `gt untrack`, `gt freeze`, `gt unfreeze` |
43
- | `graphite_branch_navigate` | branch | `gt checkout`, `gt up`, `gt down`, `gt top`, `gt bottom` |
44
- | `graphite_remote_sync` | remote | `gt sync`, `gt get` |
45
- | `graphite_pr_submit` | PR | `gt submit` (dry-run by default) |
46
- | `graphite_pr_lifecycle` | PR | `gh pr view --json url`, `gt merge`, `gt unlink` |
47
- | `graphite_recovery` | recovery | `gt continue`, `gt abort`, `gt undo` |
48
-
49
- ## Conventions
50
-
51
- - Every tool requires an absolute `cwd`.
52
- - `gt` is invoked with `--cwd <cwd> --no-interactive` by default. No shell strings.
53
- - Editor, pager, and browser env are forced safe (`GT_EDITOR=true`, `GT_PAGER=`, `BROWSER=true`, etc.); commands have a hard timeout.
54
- - Interactive editor/browser/hunk paths are rejected (`edit:true`, `stage:"patch"`, `patch:true`, `editMode:"web"`, `view:true`, interactive trunk add, merge `confirm:true`).
55
- - Remote / destructive operations require explicit ack flags:
56
- - `graphite_pr_submit` defaults to `--dry-run`; `apply: true` needs `confirmRemote: true`.
57
- - `graphite_pr_lifecycle action=merge` defaults to `--dry-run`; `apply: true` needs `confirmRemote: true`.
58
- - `graphite_remote_sync` with `force` or `deleteAll` needs `confirmDestructive: true`.
59
- - `graphite_branch_update action=delete close:true` needs `confirmRemote: true`.
60
- - `graphite_stack_reorganize action=fold foldClose:true` needs `confirmRemote: true`.
61
- - Output is ANSI-stripped and truncated to ~50 KB / 2000 lines.
62
- - Stderr is parsed into structured `hints` (e.g. `notInitialized`, `conflictHalted`,
63
- `checkedOutElsewhere`, `restackNeeded`, `trunkOutOfSync`).
41
+ | Tool | Purpose | Wraps |
42
+ | ------------------------ | ----------------------------------------------------------------------- | ---------------------------------------------- |
43
+ | `graphite_status` | Read-only snapshot: current stack + current branch + PR + restack hints | `gt log --stack`, `gt info` |
44
+ | `graphite_setup` | Initialize Graphite or track an existing Git branch with explicit parent | `gt init --trunk`, `gt track --parent` |
45
+ | `graphite_sync` | Start-of-day / after-merge cleanup + restack | `gt sync` |
46
+ | `graphite_navigate` | Move around the stack | `gt checkout`, `gt up`/`down`/`top`/`bottom` |
47
+ | `graphite_change` | Create / amend a stacked branch | `gt create -am`, `gt modify -am`, `gt modify --into`, `gt absorb` |
48
+ | `graphite_submit_stack` | Push the entire stack and open/update PRs (dry-run by default) | `gt submit --stack --no-edit --no-ai` |
49
+ | `graphite_recover` | Continue / abort / undo | `gt continue`, `gt abort`, `gt undo` |
50
+
51
+ ## Golden path
52
+
53
+ ```text
54
+ graphite_status
55
+ graphite_setup # only if repo not initialized or branch untracked
56
+ graphite_sync # at session start, or after merges
57
+ graphite_navigate action=checkout branch=… # move to the target PR / parent
58
+ # user edits files
59
+ graphite_change action=create message="…" # or action=amend
60
+ graphite_submit_stack apply=false # review the dry-run plan
61
+ graphite_submit_stack apply=true confirmRemote=true
62
+ ```
63
+
64
+ Conflict path:
65
+
66
+ ```text
67
+ # resolve files, git add them
68
+ graphite_recover action=continue
69
+ ```
70
+
71
+ Never run `git rebase --continue` after a gt command — use
72
+ `graphite_recover action=continue` so Graphite propagates the resolution to
73
+ dependent branches.
74
+
75
+ ## Conventions and guardrails
76
+
77
+ - Every tool requires absolute `cwd`.
78
+ - `gt` is invoked with `--cwd <cwd> --no-interactive`, no shell strings. Tools that support AI metadata pass `--no-ai`.
79
+ - Editor / pager / browser env is forced safe (`GT_EDITOR=true`, `GT_PAGER=`,
80
+ `BROWSER=true`, …). Commands have a hard timeout.
81
+ - Interactive editor / hunk / browser / reorder paths are not exposed.
82
+ - Rendered `$ gt …` command lines in tool output are POSIX shell-quoted so
83
+ copy-paste cannot trigger command substitution or word-splitting from
84
+ user-controlled args.
85
+ - `graphite_setup action=track_branch` requires explicit `branch`, explicit
86
+ `parent`, and `confirmParent:true`; do not guess parent if unclear.
87
+ - `graphite_setup action=init_repo reset:true` needs `confirmDestructive:true`.
88
+ - `graphite_submit_stack` defaults to `--dry-run`; `apply:true` also needs
89
+ `confirmRemote:true`. `--force` push also requires `confirmRemote:true`.
90
+ - `graphite_sync` with `force` or `deleteAll` needs `confirmDestructive:true`.
91
+ - `graphite_recover action=continue` refuses to proceed if tracked files
92
+ still contain `<<<<<<<` markers, unless `allowConflictMarkers:true`.
93
+ - Output is ANSI-stripped, branded ("Graphite" not "Charcoal"), and truncated
94
+ to ~50 KB / 2000 lines.
95
+ - Stderr is parsed into structured `hints`
96
+ (`notInitialized`, `conflictHalted`, `restackNeeded`, `trunkOutOfSync`,
97
+ `branchNotTracked`, `noChangesStaged`, `checkedOutElsewhere`,
98
+ `operatingOnTrunk`, …).
99
+
100
+ ### Known surface: git hooks
101
+
102
+ This extension does not pass `--no-verify` to `gt` / `git`. Any
103
+ `pre-commit`, `commit-msg`, `pre-push`, or related hook configured in the
104
+ target repo will execute as part of mutating operations (create, amend,
105
+ submit, …). Hooks are arbitrary user code and are intentionally not
106
+ bypassed; treat hook content as part of the repo's trust boundary.
64
107
 
65
108
  ## License
66
109
 
package/package.json CHANGED
@@ -1,7 +1,7 @@
1
1
  {
2
2
  "name": "pi-graphite",
3
- "version": "0.2.3",
4
- "description": "Structured pi tools for the Graphite (gt) CLI.",
3
+ "version": "0.3.1",
4
+ "description": "Opinionated pi tools + skill for stacked PR workflows with the Graphite (gt) CLI.",
5
5
  "keywords": [
6
6
  "pi",
7
7
  "pi-package",
@@ -12,12 +12,16 @@
12
12
  "license": "MIT",
13
13
  "files": [
14
14
  "src",
15
+ "skills",
15
16
  "README.md",
16
17
  "LICENSE"
17
18
  ],
18
19
  "pi": {
19
20
  "extensions": [
20
21
  "./src/index.ts"
22
+ ],
23
+ "skills": [
24
+ "./skills"
21
25
  ]
22
26
  },
23
27
  "peerDependencies": {
@@ -0,0 +1,218 @@
1
+ ---
2
+ name: graphite
3
+ description: Manage stacked PRs with the Graphite (gt) CLI via the pi-graphite extension. Use when creating, updating, navigating, or pushing a Graphite stack, or when recovering from a halted gt command. Wraps `gt` only — does not touch PR titles/bodies, reviews, or stack surgery.
4
+ ---
5
+
6
+ # Graphite (pi-graphite)
7
+
8
+ This skill drives the [pi-graphite](https://www.npmjs.com/package/pi-graphite)
9
+ extension. The extension is a deliberately small, opinionated wrapper around
10
+ the Graphite (`gt`) CLI. There is exactly one correct workflow; follow it.
11
+
12
+ ## When to use
13
+
14
+ Use this skill whenever the user wants to:
15
+
16
+ - start work on a stacked-PR repo
17
+ - create a new PR on top of the current branch
18
+ - amend an existing PR with new changes
19
+ - push the current stack to GitHub
20
+ - sync after PRs merged on `main`
21
+ - recover from a gt conflict
22
+
23
+ Do not use it for:
24
+
25
+ - editing PR titles / bodies / labels / reviewers metadata (use `gh` directly)
26
+ - reading PR review comments or CI status (use `gh` directly)
27
+ - rewriting history beyond create/amend (split/fold/move/squash) — use the
28
+ raw `gt` CLI via bash if the user explicitly asks; the extension does not
29
+ expose stack surgery
30
+
31
+ ## Tools
32
+
33
+ The extension registers seven tools. Prefer them over `gt`/`git`/`gh` in bash.
34
+
35
+ | Tool | Purpose |
36
+ |---|---|
37
+ | `graphite_status` | Read-only snapshot: current stack + current branch + PR + restack hint |
38
+ | `graphite_setup` | Initialize Graphite or track an existing Git branch with explicit parent |
39
+ | `graphite_sync` | `gt sync` — pull trunk, drop merged branches, restack |
40
+ | `graphite_navigate` | `gt checkout` / `up` / `down` / `top` / `bottom` / trunk |
41
+ | `graphite_change` | `gt create` / `gt modify` / `gt modify --into` / `gt absorb` |
42
+ | `graphite_submit_stack` | `gt submit --stack --no-edit` (dry-run by default) |
43
+ | `graphite_recover` | `gt continue` / `gt abort` / `gt undo` |
44
+
45
+ All tools require an absolute `cwd`.
46
+
47
+ ## Golden path
48
+
49
+ ```
50
+ graphite_status
51
+
52
+ graphite_setup (only if repo not initialized or branch untracked)
53
+
54
+ graphite_sync (start of session, or after PRs merged)
55
+
56
+ graphite_navigate (move to the branch you want to mutate)
57
+
58
+ graphite_change (create or amend)
59
+
60
+ graphite_submit_stack apply=false (review dry-run plan)
61
+
62
+ graphite_submit_stack apply=true (push, with confirmRemote=true)
63
+ confirmRemote=true
64
+ ```
65
+
66
+ When a `gt` command halts on conflict:
67
+
68
+ ```
69
+ resolve files in editor → git add <files> → graphite_recover action="continue"
70
+ ```
71
+
72
+ Never run `git rebase --continue` after a Graphite-initiated rebase; use
73
+ `graphite_recover action="continue"` so Graphite propagates the resolution
74
+ to dependent branches.
75
+
76
+ ## Recipes
77
+
78
+ ### Initialize repo if Graphite is missing
79
+
80
+ If a tool reports `notInitialized`:
81
+
82
+ ```
83
+ # Ask user for trunk if unclear.
84
+ graphite_setup({ cwd, action: "init_repo", trunk: "main" })
85
+ graphite_status({ cwd })
86
+ ```
87
+
88
+ If resetting existing Graphite metadata is explicitly intended:
89
+
90
+ ```
91
+ graphite_setup({ cwd, action: "init_repo", trunk: "main", reset: true, confirmDestructive: true })
92
+ ```
93
+
94
+ ### Track existing Git branch if untracked
95
+
96
+ If a tool reports `branchNotTracked`:
97
+
98
+ 1. Identify the intended Graphite parent branch.
99
+ 2. If parent is unclear, ask the user.
100
+ 3. Track only after parent is confirmed.
101
+
102
+ ```
103
+ graphite_setup({
104
+ cwd,
105
+ action: "track_branch",
106
+ branch: "<existing-git-branch>",
107
+ parent: "<intended-parent-branch>",
108
+ confirmParent: true,
109
+ })
110
+ graphite_status({ cwd })
111
+ ```
112
+
113
+ Never guess parent silently. Wrong parent means wrong stack shape.
114
+
115
+ ### Start a session
116
+
117
+ ```
118
+ graphite_status({ cwd })
119
+ graphite_sync({ cwd }) # pull trunk, drop merged, restack
120
+ graphite_status({ cwd }) # confirm state
121
+ ```
122
+
123
+ ### Create a new PR on top of the current branch
124
+
125
+ ```
126
+ graphite_status({ cwd }) # confirm position
127
+ # ... user makes code changes ...
128
+ graphite_change({ cwd, action: "create", message: "..." })
129
+ graphite_submit_stack({ cwd, apply: false })
130
+ # review plan with user; then:
131
+ graphite_submit_stack({ cwd, apply: true, confirmRemote: true })
132
+ ```
133
+
134
+ ### Update an existing PR
135
+
136
+ ```
137
+ graphite_status({ cwd })
138
+ graphite_navigate({ cwd, action: "checkout", branch: "<pr-branch>" })
139
+ # ... user makes code changes ...
140
+ graphite_change({ cwd, action: "amend", message: "..." })
141
+ graphite_submit_stack({ cwd, apply: false })
142
+ graphite_submit_stack({ cwd, apply: true, confirmRemote: true })
143
+ ```
144
+
145
+ ### Add a child PR off a specific parent
146
+
147
+ ```
148
+ graphite_navigate({ cwd, action: "checkout", branch: "<parent-branch>" })
149
+ # ... changes ...
150
+ graphite_change({ cwd, action: "create", message: "..." })
151
+ graphite_submit_stack({ cwd, apply: false })
152
+ ```
153
+
154
+ ### Land changes into a downstack branch
155
+
156
+ ```
157
+ # Make the change locally (working tree dirty).
158
+ graphite_change({ cwd, action: "amend_into", into: "<downstack-branch>", message: "..." })
159
+ graphite_status({ cwd })
160
+ graphite_submit_stack({ cwd, apply: false })
161
+ ```
162
+
163
+ For larger reshuffles touching several downstack commits, prefer `absorb`:
164
+
165
+ ```
166
+ graphite_change({ cwd, action: "absorb" }) # dry-run
167
+ graphite_change({ cwd, action: "absorb", apply: true }) # apply
168
+ ```
169
+
170
+ ### After PRs in the stack merge
171
+
172
+ ```
173
+ graphite_sync({ cwd })
174
+ graphite_status({ cwd })
175
+ ```
176
+
177
+ If `gt sync` halts on conflict, use the conflict recipe below.
178
+
179
+ ### Resolve a conflict
180
+
181
+ 1. Read the failing tool's `--- stderr ---` and `hints` block.
182
+ 2. Resolve markers in the listed files.
183
+ 3. `git add <files>` from bash.
184
+ 4. `graphite_recover({ cwd, action: "continue" })`.
185
+ 5. If you want to bail entirely: `graphite_recover({ cwd, action: "abort" })`.
186
+
187
+ If you made a mistake with the last gt command:
188
+
189
+ ```
190
+ graphite_recover({ cwd, action: "undo" })
191
+ ```
192
+
193
+ ## Rules
194
+
195
+ - **Submit stacks, not branches.** `graphite_submit_stack` always passes
196
+ `--stack`. Do not look for a single-branch submit; if the user truly wants
197
+ to push only one branch, use `gt submit --branch=<name>` via bash and
198
+ explain why.
199
+ - **Use `graphite_setup` only for preconditions.** Initialize missing repos
200
+ or track existing Git branches. Do not use it for daily branch creation;
201
+ use `graphite_change action="create"` instead.
202
+ - **Never guess tracking parent.** `track_branch` requires explicit branch,
203
+ explicit parent, and `confirmParent:true`.
204
+ - **Prefer `graphite_sync` over manual restack.** The extension does not
205
+ expose a standalone restack tool. Sync covers both pull + restack.
206
+ - **Always dry-run first.** Show the user the dry-run plan from
207
+ `graphite_submit_stack apply=false` before pushing.
208
+ - **`apply:true` requires `confirmRemote:true`.** The tool will refuse
209
+ otherwise. This is intentional friction.
210
+ - **Destructive sync flags require `confirmDestructive:true`** (`force`,
211
+ `deleteAll`).
212
+ - **Never use `git rebase --continue` after a gt command.** Use
213
+ `graphite_recover action="continue"`.
214
+ - **This extension wraps gt only.** For PR body/title edits, review
215
+ comments, check runs, etc., shell out to `gh` directly in bash — do not
216
+ expect a tool from this extension.
217
+ - **No interactive editor / browser / hunk picker.** All paths are
218
+ non-interactive; pass explicit messages.
package/src/index.ts CHANGED
@@ -1,69 +1,52 @@
1
1
  import type { ExtensionAPI } from "@earendil-works/pi-coding-agent";
2
2
 
3
- import { registerRepo } from "./tools/repo";
4
- import {
5
- registerStackView,
6
- registerStackRestack,
7
- registerStackReorganize,
8
- registerStackCompose,
9
- } from "./tools/stack";
10
- import {
11
- registerBranchInspect,
12
- registerBranchCreate,
13
- registerBranchUpdate,
14
- registerBranchTracking,
15
- registerBranchNavigate,
16
- } from "./tools/branch";
17
- import { registerRemoteSync } from "./tools/remote";
18
- import { registerPrSubmit, registerPrLifecycle } from "./tools/pr";
19
- import { registerRecovery } from "./tools/recovery";
3
+ import { registerStatus } from "./tools/status";
4
+ import { registerSetup } from "./tools/setup";
5
+ import { registerSync } from "./tools/sync";
6
+ import { registerNavigate } from "./tools/navigate";
7
+ import { registerChange } from "./tools/change";
8
+ import { registerSubmitStack } from "./tools/submit";
9
+ import { registerRecover } from "./tools/recover";
20
10
 
21
11
  /**
22
- * pi-graphite — Layer A (Domain Resource).
12
+ * pi-graphite — opinionated `gt` wrapper for stacked PR workflows.
23
13
  *
24
- * Registers structured tools that wrap the Graphite (`gt`) CLI:
14
+ * Seven workflow tools, one correct path:
25
15
  *
26
- * graphite_repo
27
- * graphite_stack_view
28
- * graphite_stack_restack
29
- * graphite_stack_reorganize
30
- * graphite_stack_compose
31
- * graphite_branch_inspect
32
- * graphite_branch_create
33
- * graphite_branch_update
34
- * graphite_branch_tracking
35
- * graphite_branch_navigate
36
- * graphite_remote_sync
37
- * graphite_pr_submit
38
- * graphite_pr_lifecycle
39
- * graphite_recovery
16
+ * graphite_status — see where you are in the stack
17
+ * graphite_setup — init repo / track existing branch when needed
18
+ * graphite_sync — start-of-day / after-merge cleanup + restack
19
+ * graphite_navigate — move to the branch / PR you want to mutate
20
+ * graphite_change — create or amend a stacked branch
21
+ * graphite_submit_stack — push the whole stack and open/update PRs
22
+ * graphite_recover — continue / abort / undo after conflicts or mistakes
23
+ *
24
+ * Golden path:
25
+ *
26
+ * status → (setup if needed) → sync → navigate → change → submit_stack(dry-run) → submit_stack(apply)
27
+ *
28
+ * Conflict path:
29
+ *
30
+ * resolve files → graphite_recover continue
40
31
  *
41
32
  * Conventions:
42
33
  * - Every tool requires absolute `cwd`.
43
- * - `gt` is invoked with --cwd <cwd> --no-interactive by default.
44
- * - Editor/pager/browser env is forced safe; interactive hunk/editor/browser paths are rejected.
45
- * - Remote / destructive operations require explicit `confirmRemote` /
46
- * `confirmDestructive` flags. Submit/merge default to dry-run.
47
- * - Output is ANSI-stripped and truncated to ~50KB / 2000 lines.
34
+ * - `gt` is invoked with --cwd <cwd> --no-interactive by default; tools that support AI metadata pass --no-ai.
35
+ * - Editor / pager / browser env is forced safe; interactive editor / hunk /
36
+ * browser flows are not exposed.
37
+ * - graphite_submit_stack defaults to --dry-run; apply requires
38
+ * `apply:true` AND `confirmRemote:true`.
39
+ * - graphite_sync with force / deleteAll requires `confirmDestructive:true`.
40
+ * - This extension wraps `gt` only. It deliberately does not call `gh`,
41
+ * touch PR titles/bodies, run reviews, or do stack surgery
42
+ * (split/fold/move/squash). Use the gt CLI or another tool for those.
48
43
  */
49
44
  export default function (pi: ExtensionAPI) {
50
- registerRepo(pi);
51
-
52
- registerStackView(pi);
53
- registerStackRestack(pi);
54
- registerStackReorganize(pi);
55
- registerStackCompose(pi);
56
-
57
- registerBranchInspect(pi);
58
- registerBranchCreate(pi);
59
- registerBranchUpdate(pi);
60
- registerBranchTracking(pi);
61
- registerBranchNavigate(pi);
62
-
63
- registerRemoteSync(pi);
64
-
65
- registerPrSubmit(pi);
66
- registerPrLifecycle(pi);
67
-
68
- registerRecovery(pi);
45
+ registerStatus(pi);
46
+ registerSetup(pi);
47
+ registerSync(pi);
48
+ registerNavigate(pi);
49
+ registerChange(pi);
50
+ registerSubmitStack(pi);
51
+ registerRecover(pi);
69
52
  }
@@ -0,0 +1,81 @@
1
+ /**
2
+ * Argv construction helpers used by every gt tool to harden against
3
+ * argv/flag-injection from user-controlled strings.
4
+ *
5
+ * Threat model: tool parameters (branch, message, onto, target, ...) end up
6
+ * as argv tokens passed to `gt`. `gt`'s yargs parser will happily interpret
7
+ * any token that starts with `-` as an option — including `--interactive`,
8
+ * which silently overrides our earlier `--no-interactive` and re-enables
9
+ * TTY prompts. It will also swallow the *next* argv token as the value of a
10
+ * preceding option (e.g. `--message` `--interactive` => message is dropped,
11
+ * --interactive becomes a flag).
12
+ *
13
+ * Two defenses:
14
+ *
15
+ * 1) For positional/ref values (branch names, refs, paths, PR numbers) we
16
+ * require that the value does not start with `-`. Git branch names
17
+ * cannot validly start with `-`; pathspecs and PR numbers shouldn't for
18
+ * these tools either.
19
+ *
20
+ * 2) For option *values* we emit `--flag=value` as a single argv token
21
+ * instead of two tokens `--flag` `value`. yargs binds the value to the
22
+ * option literally regardless of leading `-`, so the value can never be
23
+ * re-parsed as a flag.
24
+ */
25
+
26
+ export function assertSafeRef(value: string, label: string): string {
27
+ if (typeof value !== "string") {
28
+ throw new Error(`${label} must be a string.`);
29
+ }
30
+ if (value === "") {
31
+ throw new Error(`${label} must not be empty.`);
32
+ }
33
+ if (value === "--") {
34
+ throw new Error(`${label} must not be "--".`);
35
+ }
36
+ if (value.startsWith("-")) {
37
+ throw new Error(
38
+ `${label} must not start with "-" (got ${JSON.stringify(value)}). ` +
39
+ `Refused to prevent flag injection into the gt CLI.`,
40
+ );
41
+ }
42
+ return value;
43
+ }
44
+
45
+ /**
46
+ * Build a single argv token `--flag=value`. Use for option values supplied
47
+ * by the caller. The `=` form binds the value to the flag literally so a
48
+ * value like `--interactive` is preserved as data instead of being parsed
49
+ * as the next option.
50
+ */
51
+ export function flagEq(flag: string, value: string | number): string {
52
+ if (!flag.startsWith("--")) {
53
+ throw new Error(`flagEq: flag must start with "--", got ${flag}`);
54
+ }
55
+ if (flag.includes("=")) {
56
+ throw new Error(`flagEq: flag must not already contain "=" (got ${flag}).`);
57
+ }
58
+ return `${flag}=${value}`;
59
+ }
60
+
61
+ /**
62
+ * POSIX shell single-quote a value so the rendered command line is safe to
63
+ * copy-paste into a shell. argv execution itself never goes through a shell
64
+ * (we use spawn with an argv array), but rendered commands appear in tool
65
+ * output and labels; a user pasting them must not trigger command
66
+ * substitution, word splitting, or metacharacter interpretation.
67
+ *
68
+ * Rule: wrap in single quotes, and replace each embedded single quote with
69
+ * the POSIX-portable sequence '\''. Tokens consisting solely of
70
+ * [A-Za-z0-9_=:,.@/+-] are left unquoted for readability.
71
+ */
72
+ export function shellQuote(arg: string): string {
73
+ if (arg.length === 0) return "''";
74
+ if (/^[A-Za-z0-9_=:,.@\/+\-]+$/.test(arg)) return arg;
75
+ return `'${arg.replace(/'/g, "'\\''")}'`;
76
+ }
77
+
78
+ /** Join argv tokens into a shell-safe single line. */
79
+ export function shellJoin(args: readonly string[]): string {
80
+ return args.map(shellQuote).join(" ");
81
+ }
package/src/lib/exec.ts CHANGED
@@ -41,6 +41,10 @@ export const SAFE_NONINTERACTIVE_ENV: Record<string, string> = {
41
41
  LESS: "FRX",
42
42
  BROWSER: "true",
43
43
  GH_BROWSER: "true",
44
+ // gt-gh treats this as "invoked from Graphite Interactive" and forces
45
+ // non-interactive behavior regardless of argv. Other gt builds should
46
+ // ignore it if unsupported; keep --no-interactive argv guards too.
47
+ GRAPHITE_INTERACTIVE: "1",
44
48
  };
45
49
 
46
50
  export function safeNoninteractiveEnv(extra?: Record<string, string>): NodeJS.ProcessEnv {
@@ -86,12 +90,27 @@ export function truncateOutput(s: string): string {
86
90
  return `${kept}\n... [truncated: ${s.length - MAX_BYTES} more bytes]`;
87
91
  }
88
92
 
93
+ /**
94
+ * Tokens that re-enable interactive flows in gt and must never appear in
95
+ * rawArgs. Tool authors should not pass these directly, and user-supplied
96
+ * strings should be routed through argv helpers (assertSafeRef / flagEq)
97
+ * so values starting with `-` can never reach this list. We still scan
98
+ * defensively here as belt-and-braces.
99
+ */
100
+ const FORBIDDEN_RAW_TOKENS = new Set<string>([
101
+ "--interactive",
102
+ "--interactive-rebase",
103
+ ]);
104
+
89
105
  /**
90
106
  * Run `gt` with structured args. Never builds a shell string.
91
107
  *
92
- * - Always injects --cwd <abs>.
93
- * - Always injects --no-interactive. No escape hatch by design: agent-driven
94
- * tools must never block on a TTY prompt.
108
+ * - Always injects --cwd <abs> and --no-interactive at the *start*.
109
+ * - Also appends a trailing --no-interactive after rawArgs as defense in
110
+ * depth: yargs lets a later `--interactive` override an earlier
111
+ * `--no-interactive`, so we ensure --no-interactive is always the last
112
+ * word on the global option.
113
+ * - Refuses to run if rawArgs contains a known interactive-toggle token.
95
114
  * - Does not inject --quiet (we want stderr diagnostics).
96
115
  */
97
116
  export async function runGt(
@@ -99,8 +118,21 @@ export async function runGt(
99
118
  opts: GtRunOptions,
100
119
  ): Promise<GtRunResult> {
101
120
  const cwd = resolvePath(opts.cwd);
121
+ for (const tok of rawArgs) {
122
+ // Match both `--interactive` and `--interactive=...` forms.
123
+ const head = tok.split("=", 1)[0];
124
+ if (FORBIDDEN_RAW_TOKENS.has(head)) {
125
+ throw new Error(
126
+ `runGt: refused to pass forbidden token ${JSON.stringify(tok)} to gt. ` +
127
+ `Interactive flows are disabled in this extension.`,
128
+ );
129
+ }
130
+ }
102
131
  const args = ["--cwd", cwd, "--no-interactive"];
103
132
  args.push(...rawArgs);
133
+ // Trailing --no-interactive wins against any later `--interactive` that
134
+ // might still slip in via an unaudited code path.
135
+ args.push("--no-interactive");
104
136
 
105
137
  return new Promise<GtRunResult>((resolve) => {
106
138
  let child: ChildProcessByStdio<null, Readable, Readable>;