@hank-warren/pi-plan-mode 0.1.0 → 1.0.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.
@@ -1,93 +0,0 @@
1
- import type { ExtensionContext } from "@earendil-works/pi-coding-agent";
2
- import { defineMenu, runMenu } from "@narumitw/pi-tui-kit";
3
- import { type PlanExportDestinationProvider, planExportInputScreen } from "./plan-export-screen.js";
4
-
5
- interface SavedPlanMenuOptions {
6
- statusText: string;
7
- implementationOutcome(): string;
8
- getExportDestination: PlanExportDestinationProvider;
9
- signal: AbortSignal;
10
- isCurrent(): boolean;
11
- show(): void;
12
- implementHere(): void | Promise<void>;
13
- implementFresh(signal: AbortSignal): void | Promise<void>;
14
- exportPlan(path: string, signal: AbortSignal): Promise<boolean>;
15
- settings(signal: AbortSignal): Promise<boolean>;
16
- clear(): void;
17
- }
18
-
19
- export async function showSavedPlanMenu(ctx: ExtensionContext, options: SavedPlanMenuOptions) {
20
- if (!ctx.hasUI) {
21
- throw new Error(
22
- `${options.statusText} Use /plan show, /plan implement, /plan export, or /plan exit.`,
23
- );
24
- }
25
- type Screen = "saved" | "export";
26
- type Action = "show" | "implement-here" | "implement-fresh" | "export" | "settings" | "clear";
27
- const menu = defineMenu<undefined, Screen, Action, ExtensionContext>({
28
- start: "saved",
29
- screens: {
30
- saved: () => ({
31
- kind: "actions",
32
- title: "Saved plan",
33
- lines: [
34
- options.statusText,
35
- "Implement here keeps this planning conversation.",
36
- "Start fresh transfers only the approved plan to a new session.",
37
- options.implementationOutcome(),
38
- ],
39
- items: [
40
- { id: "show", label: "Show saved plan", action: "show" },
41
- {
42
- id: "implement-here",
43
- label: "Implement here",
44
- description: "Continue in this session with the planning conversation.",
45
- action: "implement-here",
46
- },
47
- {
48
- id: "implement-fresh",
49
- label: "Start fresh and implement",
50
- description: "Open a new linked session; transfer only the approved plan.",
51
- action: "implement-fresh",
52
- busyLabel: "Starting fresh implementation session…",
53
- },
54
- { id: "export", label: "Export plan…", to: "export" },
55
- { id: "settings", label: "Settings", action: "settings" },
56
- { id: "clear", label: "Clear saved plan", action: "clear" },
57
- ],
58
- hint: "close",
59
- }),
60
- export: () => planExportInputScreen(options.getExportDestination),
61
- },
62
- actions: {
63
- show: async () => {
64
- options.show();
65
- return { kind: "close" };
66
- },
67
- "implement-here": async () => {
68
- await options.implementHere();
69
- return { kind: "close" };
70
- },
71
- "implement-fresh": async ({ signal }) => {
72
- await options.implementFresh(signal);
73
- return { kind: "close" };
74
- },
75
- export: async ({ value, signal }) =>
76
- (await options.exportPlan(value ?? "", signal)) ? { kind: "close" } : { kind: "rejected" },
77
- settings: async ({ signal }) => {
78
- const close = await options.settings(signal);
79
- if (signal.aborted || !options.isCurrent()) return { kind: "rejected" };
80
- return close ? { kind: "close" } : { kind: "stay" };
81
- },
82
- clear: async () => {
83
- options.clear();
84
- return { kind: "close" };
85
- },
86
- },
87
- });
88
- await runMenu(ctx, menu, {
89
- getState: () => undefined,
90
- signal: options.signal,
91
- isCurrent: options.isCurrent,
92
- });
93
- }
@@ -1,39 +0,0 @@
1
- import type { ExtensionContext } from "@earendil-works/pi-coding-agent";
2
-
3
- export function savedPlanBlocksNewWorkflow(ctx: ExtensionContext, hasSavedPlan: boolean) {
4
- if (!hasSavedPlan) return false;
5
- const message =
6
- "A plan is saved for later. Implement or clear it before starting another Plan-mode workflow.";
7
- if (!ctx.hasUI) throw new Error(message);
8
- ctx.ui.notify(message, "warning");
9
- return true;
10
- }
11
-
12
- export async function preflightSavedPlanImplementation(
13
- ctx: ExtensionContext,
14
- isCurrent: () => boolean,
15
- ) {
16
- if (ctx.mode === "print" || ctx.mode === "json") {
17
- throw new Error("Saved plan implementation is unavailable in print/JSON mode. Use TUI or RPC.");
18
- }
19
- const model = ctx.model;
20
- if (!model) {
21
- ctx.ui.notify("Unable to implement saved plan: no model is selected.", "warning");
22
- return false;
23
- }
24
- let auth: Awaited<ReturnType<ExtensionContext["modelRegistry"]["getApiKeyAndHeaders"]>>;
25
- try {
26
- auth = await ctx.modelRegistry.getApiKeyAndHeaders(model);
27
- } catch (error: unknown) {
28
- if (!isCurrent()) return false;
29
- const detail = error instanceof Error ? error.message : String(error);
30
- ctx.ui.notify(`Unable to implement saved plan: ${detail}`, "error");
31
- return false;
32
- }
33
- if (!isCurrent()) return false;
34
- if (!auth.ok) {
35
- ctx.ui.notify(`Unable to implement saved plan: ${auth.error}`, "warning");
36
- return false;
37
- }
38
- return true;
39
- }
@@ -1,563 +0,0 @@
1
- import type { ToolInfo } from "@earendil-works/pi-coding-agent";
2
-
3
- export const BUILTIN_SAFE_GIT_SUBCOMMANDS = [
4
- "status",
5
- "log",
6
- "diff",
7
- "show",
8
- "branch",
9
- "remote",
10
- "ls-files",
11
- "grep",
12
- ] as const;
13
- export const CONFIGURABLE_SAFE_GIT_SUBCOMMANDS = [
14
- "rev-parse",
15
- "blame",
16
- "describe",
17
- "merge-base",
18
- "ls-tree",
19
- "cat-file",
20
- ] as const;
21
- export const SAFE_GIT_SUBCOMMANDS = [
22
- ...BUILTIN_SAFE_GIT_SUBCOMMANDS,
23
- ...CONFIGURABLE_SAFE_GIT_SUBCOMMANDS,
24
- ] as const;
25
- export const SAFE_GH_SUBCOMMAND_PATHS = ["pr view", "pr list", "issue view", "issue list"] as const;
26
-
27
- export type BuiltinSafeGitSubcommand = (typeof BUILTIN_SAFE_GIT_SUBCOMMANDS)[number];
28
- export type ConfigurableSafeGitSubcommand = (typeof CONFIGURABLE_SAFE_GIT_SUBCOMMANDS)[number];
29
- export type SafeGitSubcommand = (typeof SAFE_GIT_SUBCOMMANDS)[number];
30
- export type SafeGhSubcommandPath = (typeof SAFE_GH_SUBCOMMAND_PATHS)[number];
31
- export interface SafeSubcommands {
32
- git?: SafeGitSubcommand[];
33
- gh?: SafeGhSubcommandPath[];
34
- }
35
-
36
- export const SAFE_BUILTIN_PLAN_TOOLS = new Set(["read", "bash", "grep", "find", "ls"]);
37
- export type PlanModeToolPolicy = "read-only" | "limited" | "user-opt-in" | "blocked";
38
-
39
- const BLOCKED_BUILTIN_TOOLS = new Set(["edit", "write"]);
40
- const MUTATING_COMMANDS = new Set([
41
- "rm",
42
- "rmdir",
43
- "mv",
44
- "cp",
45
- "mkdir",
46
- "touch",
47
- "chmod",
48
- "chown",
49
- "chgrp",
50
- "ln",
51
- "tee",
52
- "truncate",
53
- "dd",
54
- "sudo",
55
- "su",
56
- "kill",
57
- "pkill",
58
- "killall",
59
- "reboot",
60
- "shutdown",
61
- "vim",
62
- "vi",
63
- "nano",
64
- "emacs",
65
- "code",
66
- "subl",
67
- ]);
68
- const READ_ONLY_COMMANDS = new Set([
69
- "cat",
70
- "head",
71
- "tail",
72
- "grep",
73
- "find",
74
- "ls",
75
- "pwd",
76
- "echo",
77
- "printf",
78
- "wc",
79
- "sort",
80
- "uniq",
81
- "diff",
82
- "file",
83
- "stat",
84
- "du",
85
- "df",
86
- "tree",
87
- "which",
88
- "whereis",
89
- "type",
90
- "printenv",
91
- "uname",
92
- "whoami",
93
- "id",
94
- "date",
95
- "uptime",
96
- "ps",
97
- "jq",
98
- "rg",
99
- "fd",
100
- "bat",
101
- "eza",
102
- ]);
103
-
104
- export function isBuiltinTool(tool: ToolInfo) {
105
- return tool.sourceInfo.source === "builtin";
106
- }
107
-
108
- export function classifyPlanModeTool(tool: ToolInfo): PlanModeToolPolicy {
109
- if (!isBuiltinTool(tool)) return "user-opt-in";
110
- if (BLOCKED_BUILTIN_TOOLS.has(tool.name)) return "blocked";
111
- if (tool.name === "bash") return "limited";
112
- return SAFE_BUILTIN_PLAN_TOOLS.has(tool.name) ? "read-only" : "blocked";
113
- }
114
-
115
- export function canSelectToolInPlanMode(tool: ToolInfo) {
116
- return classifyPlanModeTool(tool) !== "blocked";
117
- }
118
-
119
- export function readCommand(input: unknown) {
120
- const command = input as { command?: unknown } | undefined;
121
- return typeof command?.command === "string" ? command.command : "";
122
- }
123
-
124
- export function findBlockedCommandSegment(
125
- command: string,
126
- safeSubcommands: SafeSubcommands = {},
127
- ): string | undefined {
128
- const segments = splitShellSegments(command);
129
- if (!segments || segments.length === 0) return command.trim() || "(empty command)";
130
- return segments.find((segment) => !isSafeSegment(segment, safeSubcommands));
131
- }
132
-
133
- export function isSafeCommand(command: string, safeSubcommands: SafeSubcommands = {}) {
134
- return findBlockedCommandSegment(command, safeSubcommands) === undefined;
135
- }
136
-
137
- function splitShellSegments(command: string): string[] | undefined {
138
- const trimmed = command.trim();
139
- if (!trimmed || /[\n\r`]/.test(trimmed)) return undefined;
140
-
141
- const segments: string[] = [];
142
- let quote: "'" | '"' | undefined;
143
- let escaped = false;
144
- let start = 0;
145
- for (let index = 0; index < trimmed.length; index += 1) {
146
- const character = trimmed[index];
147
- if (escaped) {
148
- escaped = false;
149
- continue;
150
- }
151
- if (character === "\\" && quote !== "'") {
152
- escaped = true;
153
- continue;
154
- }
155
- if (quote) {
156
- if (character === quote) quote = undefined;
157
- continue;
158
- }
159
- if (character === "'" || character === '"') {
160
- quote = character;
161
- continue;
162
- }
163
- if (character === ">" || character === "<" || character === "(" || character === ")") {
164
- return undefined;
165
- }
166
- const next = trimmed[index + 1];
167
- if (character === "&" && next !== "&") return undefined;
168
- const separatorLength =
169
- character === ";" || character === "|"
170
- ? next === character
171
- ? 2
172
- : 1
173
- : character === "&" && next === "&"
174
- ? 2
175
- : 0;
176
- if (separatorLength === 0) continue;
177
- const segment = trimmed.slice(start, index).trim();
178
- if (!segment) return undefined;
179
- segments.push(segment);
180
- index += separatorLength - 1;
181
- start = index + 1;
182
- }
183
- if (quote || escaped) return undefined;
184
- const finalSegment = trimmed.slice(start).trim();
185
- if (!finalSegment) return undefined;
186
- segments.push(finalSegment);
187
- return segments;
188
- }
189
-
190
- function isSafeSegment(segment: string, safeSubcommands: SafeSubcommands) {
191
- if (hasShellExpansion(segment) || /(^|\s)[A-Za-z_][A-Za-z0-9_]*=/.test(segment)) {
192
- return false;
193
- }
194
- const tokens = shellWords(segment);
195
- if (!tokens || tokens.length === 0) return false;
196
- const command = tokens[0]?.toLowerCase();
197
- if (!command || MUTATING_COMMANDS.has(command)) return false;
198
- const args = tokens.slice(1);
199
- if (!hasSafeArguments(command, args)) return false;
200
- if (READ_ONLY_COMMANDS.has(command)) return true;
201
- return isSafeStructuredCommand(command, args, safeSubcommands);
202
- }
203
-
204
- function hasShellExpansion(segment: string) {
205
- let quote: "'" | '"' | undefined;
206
- let escaped = false;
207
- for (const character of segment) {
208
- if (escaped) {
209
- escaped = false;
210
- continue;
211
- }
212
- if (character === "\\" && quote !== "'") {
213
- escaped = true;
214
- continue;
215
- }
216
- if (quote) {
217
- if (character === quote) quote = undefined;
218
- else if (character === "$" && quote === '"') return true;
219
- continue;
220
- }
221
- if (character === "'" || character === '"') {
222
- quote = character;
223
- continue;
224
- }
225
- if (["$", "*", "?", "[", "{"].includes(character)) return true;
226
- }
227
- return false;
228
- }
229
-
230
- function shellWords(segment: string): string[] | undefined {
231
- const words: string[] = [];
232
- let word = "";
233
- let quote: "'" | '"' | undefined;
234
- let escaped = false;
235
- for (const character of segment) {
236
- if (escaped) {
237
- word += character;
238
- escaped = false;
239
- continue;
240
- }
241
- if (character === "\\" && quote !== "'") {
242
- escaped = true;
243
- continue;
244
- }
245
- if (quote) {
246
- if (character === quote) quote = undefined;
247
- else word += character;
248
- continue;
249
- }
250
- if (character === "'" || character === '"') quote = character;
251
- else if (/\s/.test(character)) {
252
- if (word) words.push(word);
253
- word = "";
254
- } else word += character;
255
- }
256
- if (quote || escaped) return undefined;
257
- if (word) words.push(word);
258
- return words;
259
- }
260
-
261
- function hasSafeArguments(command: string, args: string[]) {
262
- const forbidden = new Set(["-i", "--in-place", "--fix", "--write", "-delete", "--delete"]);
263
- if (args.some((argument) => forbidden.has(argument))) return false;
264
- if (
265
- command === "sed" &&
266
- args.some(
267
- (argument) =>
268
- argument.startsWith("--in-place=") ||
269
- (/^-[^-]+/.test(argument) && argument.slice(1).includes("i")),
270
- )
271
- ) {
272
- return false;
273
- }
274
- if (
275
- command === "find" &&
276
- args.some((argument) =>
277
- ["-exec", "-execdir", "-ok", "-okdir", "-fprint", "-fprint0", "-fprintf", "-fls"].includes(
278
- argument,
279
- ),
280
- )
281
- ) {
282
- return false;
283
- }
284
- if (
285
- command === "date" &&
286
- args.some((argument) => argument === "-s" || argument.startsWith("--set"))
287
- ) {
288
- return false;
289
- }
290
- if (
291
- (command === "sort" || command === "tree") &&
292
- args.some(
293
- (argument) =>
294
- argument === "-o" ||
295
- (argument.startsWith("-o") && !argument.startsWith("--")) ||
296
- argument.startsWith("--output"),
297
- )
298
- ) {
299
- return false;
300
- }
301
- if (
302
- command === "sort" &&
303
- args.some(
304
- (argument) =>
305
- argument === "-T" ||
306
- (argument.startsWith("-T") && argument.length > 2) ||
307
- argument.startsWith("--temporary-directory") ||
308
- argument.startsWith("--compress-program"),
309
- )
310
- ) {
311
- return false;
312
- }
313
- if (
314
- command === "diff" &&
315
- args.some((argument) => argument === "--output" || argument.startsWith("--output="))
316
- ) {
317
- return false;
318
- }
319
- if (command === "uniq" && args.filter((argument) => !argument.startsWith("-")).length > 1) {
320
- return false;
321
- }
322
- if (
323
- command === "fd" &&
324
- args.some((argument) =>
325
- ["-x", "-X", "--exec", "--exec-batch"].some(
326
- (flag) => argument === flag || argument.startsWith(`${flag}=`),
327
- ),
328
- )
329
- ) {
330
- return false;
331
- }
332
- if (
333
- command === "rg" &&
334
- args.some((argument) => argument === "--pre" || argument.startsWith("--pre="))
335
- ) {
336
- return false;
337
- }
338
- if (
339
- command === "bat" &&
340
- args.some((argument) => argument === "--pager" || argument.startsWith("--pager="))
341
- ) {
342
- return false;
343
- }
344
- return true;
345
- }
346
-
347
- type ArgumentValidator = (args: string[]) => boolean;
348
- const allowReadOnlyArguments: ArgumentValidator = () => true;
349
- const BUILTIN_GIT_VALIDATORS: Record<BuiltinSafeGitSubcommand, ArgumentValidator> = {
350
- status: allowReadOnlyArguments,
351
- log: allowReadOnlyArguments,
352
- diff: allowReadOnlyArguments,
353
- show: allowReadOnlyArguments,
354
- branch: isSafeGitBranchArguments,
355
- remote: isSafeGitRemoteArguments,
356
- "ls-files": allowReadOnlyArguments,
357
- grep: isSafeGitGrepArguments,
358
- };
359
- const CONFIGURABLE_GIT_VALIDATORS: Record<ConfigurableSafeGitSubcommand, ArgumentValidator> = {
360
- "rev-parse": allowReadOnlyArguments,
361
- blame: allowReadOnlyArguments,
362
- describe: allowReadOnlyArguments,
363
- "merge-base": allowReadOnlyArguments,
364
- "ls-tree": allowReadOnlyArguments,
365
- "cat-file": isSafeGitCatFileArguments,
366
- };
367
- const GH_VALIDATORS: Record<SafeGhSubcommandPath, ArgumentValidator> = {
368
- "pr view": isSafeGhReadArguments,
369
- "pr list": isSafeGhReadArguments,
370
- "issue view": isSafeGhReadArguments,
371
- "issue list": isSafeGhReadArguments,
372
- };
373
-
374
- function isSafeStructuredCommand(
375
- command: string,
376
- args: string[],
377
- safeSubcommands: SafeSubcommands,
378
- ) {
379
- if (command === "git") return isSafeGitCommand(args, safeSubcommands);
380
- if (command === "gh") return isSafeGhCommand(args, safeSubcommands);
381
-
382
- const subcommandIndex = args.findIndex((argument) => !argument.startsWith("-"));
383
- const subcommand = args[subcommandIndex]?.toLowerCase();
384
- const subcommandArgs = subcommandIndex >= 0 ? args.slice(subcommandIndex + 1) : [];
385
- if (command === "sed") {
386
- const script = args.find((argument) => !argument.startsWith("-"));
387
- return (
388
- Boolean(script) &&
389
- (args.includes("-n") || args.some((argument) => /^-[^-]*n[^-]*$/.test(argument))) &&
390
- /^\d+(,\d+)?p$/.test(script ?? "")
391
- );
392
- }
393
- if (["node", "python", "python3", "tsc", "biome", "ruff", "ty"].includes(command)) {
394
- if (args.includes("--version")) return true;
395
- return (
396
- command === "tsc" &&
397
- args.includes("--noEmit") &&
398
- !args.some(
399
- (argument) =>
400
- argument === "--incremental" ||
401
- argument.startsWith("--incremental=") ||
402
- argument === "--tsBuildInfoFile" ||
403
- argument.startsWith("--tsBuildInfoFile=") ||
404
- argument === "--generateTrace" ||
405
- argument.startsWith("--generateTrace="),
406
- )
407
- );
408
- }
409
- if (command === "npm") {
410
- if (subcommand === "audit" && subcommandArgs.includes("fix")) return false;
411
- if (
412
- ["list", "ls", "view", "info", "search", "outdated", "audit", "test"].includes(
413
- subcommand ?? "",
414
- )
415
- ) {
416
- return true;
417
- }
418
- return subcommand === "run" && ["test", "check", "typecheck", "lint"].includes(args[1] ?? "");
419
- }
420
- if (["cargo", "go", "pytest", "vitest", "jest"].includes(command)) {
421
- return (
422
- ["test", "check"].includes(subcommand ?? "") || ["pytest", "vitest", "jest"].includes(command)
423
- );
424
- }
425
- return false;
426
- }
427
-
428
- function isSafeGitCommand(args: string[], safeSubcommands: SafeSubcommands) {
429
- let subcommandIndex = 0;
430
- while (args[subcommandIndex] === "--no-pager") subcommandIndex += 1;
431
- const subcommand = args[subcommandIndex]?.toLowerCase();
432
- if (!subcommand || subcommand.startsWith("-")) return false;
433
- const subcommandArgs = args.slice(subcommandIndex + 1);
434
- const builtinValidator = (BUILTIN_GIT_VALIDATORS as Record<string, ArgumentValidator>)[
435
- subcommand
436
- ];
437
- const configuredValidator = (CONFIGURABLE_GIT_VALIDATORS as Record<string, ArgumentValidator>)[
438
- subcommand
439
- ];
440
- const configured = safeSubcommands.git?.includes(subcommand as SafeGitSubcommand) === true;
441
- const validator = builtinValidator ?? (configured ? configuredValidator : undefined);
442
- return (
443
- validator !== undefined &&
444
- hasSafeGitArguments(subcommand, subcommandArgs) &&
445
- validator(subcommandArgs)
446
- );
447
- }
448
-
449
- function hasSafeGitArguments(subcommand: string, args: string[]) {
450
- return !args.some(
451
- (argument) =>
452
- argument === "--help" ||
453
- argument === "--show-signature" ||
454
- argument.startsWith("--show-signature=") ||
455
- argument.includes("%G") ||
456
- argument === "--output" ||
457
- argument.startsWith("--output=") ||
458
- argument === "--ext-diff" ||
459
- argument.startsWith("--ext-diff=") ||
460
- argument === "--textconv" ||
461
- argument.startsWith("--textconv=") ||
462
- argument === "--paginate" ||
463
- argument === "--open-files-in-pager" ||
464
- argument.startsWith("--open-files-in-pager=") ||
465
- (subcommand === "grep" && (argument === "-O" || argument.startsWith("-O"))),
466
- );
467
- }
468
-
469
- function isSafeGitCatFileArguments(args: string[]) {
470
- return !args.some(
471
- (argument) =>
472
- matchesLongOptionPrefix(argument, "--filters", "--fi") ||
473
- matchesLongOptionPrefix(argument, "--textconv", "--t"),
474
- );
475
- }
476
-
477
- function isSafeGitGrepArguments(args: string[]) {
478
- return !args.some(
479
- (argument) =>
480
- matchesLongOptionPrefix(argument, "--textconv", "--textc") ||
481
- matchesLongOptionPrefix(argument, "--open-files-in-pager", "--op") ||
482
- matchesLongOptionPrefix(argument, "--ext-grep", "--ext"),
483
- );
484
- }
485
-
486
- function matchesLongOptionPrefix(argument: string, option: string, shortest: string) {
487
- const optionName = argument.split("=", 1)[0] ?? "";
488
- return optionName.length >= shortest.length && option.startsWith(optionName);
489
- }
490
-
491
- function isSafeGitBranchArguments(args: string[]) {
492
- if (args.some((argument) => !argument.startsWith("-"))) return false;
493
- return !args.some(
494
- (argument) =>
495
- /^-[^-]*[dDmMcCu]/.test(argument) ||
496
- matchesLongOptionPrefix(argument, "--delete", "--del") ||
497
- matchesLongOptionPrefix(argument, "--move", "--mov") ||
498
- matchesLongOptionPrefix(argument, "--copy", "--cop") ||
499
- matchesLongOptionPrefix(argument, "--edit-description", "--e") ||
500
- matchesLongOptionPrefix(argument, "--unset-upstream", "--u") ||
501
- matchesLongOptionPrefix(argument, "--set-upstream-to", "--set-u") ||
502
- matchesLongOptionPrefix(argument, "--create-reflog", "--creat"),
503
- );
504
- }
505
-
506
- function isSafeGitRemoteArguments(args: string[]) {
507
- const actionIndex = args.findIndex((argument) => !argument.startsWith("-"));
508
- if (actionIndex < 0) return true;
509
- const action = args[actionIndex];
510
- if (action === "get-url") return true;
511
- if (action !== "show") return false;
512
-
513
- const showArgs = args.slice(actionIndex + 1);
514
- if (showArgs.includes("--")) return false;
515
- const remotes = showArgs.filter((argument) => !argument.startsWith("-"));
516
- return remotes.length === 0 || (remotes.length === 1 && showArgs.includes("-n"));
517
- }
518
-
519
- function isSafeGhCommand(args: string[], safeSubcommands: SafeSubcommands) {
520
- const group = args[0]?.toLowerCase();
521
- const action = args[1]?.toLowerCase();
522
- if (!group || !action || group.startsWith("-") || action.startsWith("-")) return false;
523
- const path = `${group} ${action}` as SafeGhSubcommandPath;
524
- if (!safeSubcommands.gh?.includes(path)) return false;
525
- const validator = (GH_VALIDATORS as Record<string, ArgumentValidator>)[path];
526
- return validator?.(args.slice(2)) ?? false;
527
- }
528
-
529
- function isSafeGhReadArguments(args: string[]) {
530
- return !args.some(isUnsafeGhReadArgument) && hasGhJsonOutput(args);
531
- }
532
-
533
- function isUnsafeGhReadArgument(argument: string) {
534
- return (
535
- argument.startsWith("-w") ||
536
- argument === "--web" ||
537
- argument.startsWith("--web=") ||
538
- argument === "--browser" ||
539
- argument.startsWith("--browser=") ||
540
- argument === "--paginate" ||
541
- argument === "--pager" ||
542
- argument.startsWith("--pager=") ||
543
- argument === "--output" ||
544
- argument.startsWith("--output=")
545
- );
546
- }
547
-
548
- function hasGhJsonOutput(args: string[]) {
549
- let hasJson = false;
550
- for (let index = 0; index < args.length; index += 1) {
551
- const argument = args[index];
552
- if (argument === "--json") {
553
- const value = args[index + 1];
554
- if (!value || value.startsWith("-")) return false;
555
- hasJson = true;
556
- index += 1;
557
- } else if (argument.startsWith("--json=")) {
558
- if (argument === "--json=") return false;
559
- hasJson = true;
560
- }
561
- }
562
- return hasJson;
563
- }