one-code-extension 0.3.0 → 0.4.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 +189 -94
- package/docs/guide/README.md +3 -2
- package/docs/guide/bring-your-claude-code-setup.md +7 -5
- package/docs/guide/configuration.md +7 -5
- package/docs/guide/differences-from-claude-code.md +25 -14
- package/docs/guide/doctor.md +5 -3
- package/docs/guide/hooks.md +20 -6
- package/docs/guide/installation.md +62 -12
- package/docs/guide/permissions-modes-and-auto-mode.md +46 -28
- package/docs/guide/providers-and-models.md +12 -10
- package/docs/guide/reference.md +9 -4
- package/docs/guide/sessions-and-context.md +23 -3
- package/docs/guide/skills-plugins-and-mcp.md +7 -5
- package/docs/guide/subagents-and-workflows.md +13 -11
- package/docs/guide/tasks-and-background-work.md +10 -8
- package/docs/guide/terminal-interface.md +7 -5
- package/docs/guide/tools.md +29 -5
- package/docs/guide/troubleshooting.md +26 -14
- package/docs/guide/windows.md +184 -0
- package/extensions/auto-mode/actions.ts +3 -1
- package/extensions/auto-mode/paths.ts +21 -23
- package/extensions/auto-mode/safety-floor.ts +44 -6
- package/extensions/auto-mode/shell-analysis.ts +25 -10
- package/extensions/auto-mode/transcript.ts +15 -5
- package/extensions/background/index.ts +83 -34
- package/extensions/background/monitor-batch.ts +7 -5
- package/extensions/background/wakeup.ts +18 -24
- package/extensions/bash/background.ts +19 -10
- package/extensions/bash/guards.ts +36 -16
- package/extensions/bash/index.ts +36 -166
- package/extensions/branding/index.ts +35 -4
- package/extensions/btw/index.ts +207 -0
- package/extensions/btw/panel.ts +179 -0
- package/extensions/btw/prompt.ts +45 -0
- package/extensions/clear/index.ts +15 -0
- package/extensions/compaction/fit.ts +9 -6
- package/extensions/doctor/build.ts +22 -2
- package/extensions/doctor/dependencies.ts +63 -31
- package/extensions/doctor/fix-prompt.ts +12 -4
- package/extensions/doctor/index.ts +1 -0
- package/extensions/doctor/models.ts +2 -1
- package/extensions/doctor/report.ts +2 -3
- package/extensions/doctor/update-lookup.ts +19 -11
- package/extensions/effort/index.ts +2 -1
- package/extensions/footer/footer-line.ts +19 -0
- package/extensions/footer/index.ts +25 -5
- package/extensions/hooks/executor.ts +84 -18
- package/extensions/hooks/index.ts +1 -0
- package/extensions/hooks/matcher.ts +23 -9
- package/extensions/hooks/settings.ts +15 -0
- package/extensions/lib/branch-restore.ts +12 -0
- package/extensions/lib/claude-context.ts +2 -3
- package/extensions/lib/claude-settings.ts +19 -0
- package/extensions/lib/command-launch.ts +71 -0
- package/extensions/lib/deferred.ts +173 -33
- package/extensions/lib/git.ts +16 -0
- package/extensions/lib/keys.ts +38 -0
- package/extensions/lib/local-command.ts +112 -0
- package/extensions/lib/model-facts.generated.json +263 -68
- package/extensions/lib/model-policy.ts +13 -0
- package/extensions/lib/model-tier.ts +16 -5
- package/extensions/lib/notifications.ts +547 -53
- package/extensions/lib/paths.ts +130 -2
- package/extensions/lib/pi-ai-estimate.ts +57 -26
- package/extensions/lib/pi-version.ts +1 -1
- package/extensions/lib/process-tree.ts +131 -5
- package/extensions/lib/reminders.ts +80 -27
- package/extensions/lib/scratchpad.ts +11 -10
- package/extensions/lib/shell-spawn.ts +298 -0
- package/extensions/lib/shell-tool.ts +252 -0
- package/extensions/lib/shell-tools.ts +13 -0
- package/extensions/lib/side-call.ts +76 -0
- package/extensions/lib/tui-render.ts +62 -11
- package/extensions/lib/update-check.mjs +155 -0
- package/extensions/lib/usage-bus.ts +1 -1
- package/extensions/lib/which.ts +62 -0
- package/extensions/lib/worktree-channel.ts +18 -0
- package/extensions/lib/worktree-isolation.ts +2 -1
- package/extensions/lsp/client.ts +14 -3
- package/extensions/lsp/index.ts +12 -5
- package/extensions/lsp/install-hints.ts +33 -3
- package/extensions/lsp/plugin-servers.ts +2 -2
- package/extensions/lsp/servers.ts +29 -7
- package/extensions/mcp/index.ts +3 -2
- package/extensions/memory/entries.ts +9 -6
- package/extensions/memory/index.ts +3 -2
- package/extensions/memory/open-external.ts +4 -1
- package/extensions/model-default/index.ts +21 -2
- package/extensions/permissions/index.ts +57 -28
- package/extensions/permissions/matcher.ts +120 -33
- package/extensions/permissions/modes.ts +14 -6
- package/extensions/permissions/powershell-rules.ts +549 -0
- package/extensions/permissions/session-grant.ts +9 -9
- package/extensions/plugins/index.ts +3 -2
- package/extensions/plugins/install/install.ts +1 -1
- package/extensions/powershell/description.ts +139 -0
- package/extensions/powershell/guards.ts +91 -0
- package/extensions/powershell/index.ts +89 -0
- package/extensions/powershell/policy.ts +91 -0
- package/extensions/recap/index.ts +10 -24
- package/extensions/recap/prompt.ts +3 -3
- package/extensions/session-title/index.ts +120 -0
- package/extensions/session-title/title.ts +111 -0
- package/extensions/skill/index.ts +40 -18
- package/extensions/skill/invoke.ts +1 -0
- package/extensions/subagents/hand-back-review.ts +10 -12
- package/extensions/subagents/index.ts +221 -78
- package/extensions/subagents/model-select.ts +75 -7
- package/extensions/system-prompt/environment.ts +14 -1
- package/extensions/system-reminder/index.ts +9 -0
- package/extensions/tasks/index.ts +2 -1
- package/extensions/tool-search/index.ts +41 -20
- package/extensions/workflow/agent-session.ts +6 -4
- package/extensions/workflow/index.ts +28 -5
- package/extensions/worktree/guards.ts +5 -4
- package/extensions/worktree/index.ts +15 -2
- package/extensions/worktree/rewrite.ts +17 -0
- package/package.json +7 -4
- package/types/app.d.ts +18 -2
|
@@ -11,11 +11,12 @@
|
|
|
11
11
|
* providers it depends on a third-party key.
|
|
12
12
|
*/
|
|
13
13
|
|
|
14
|
-
import {
|
|
15
|
-
import {
|
|
14
|
+
import { existsSync } from "node:fs";
|
|
15
|
+
import { join } from "node:path";
|
|
16
|
+
import { whichOnPath } from "../lib/which.ts";
|
|
16
17
|
import type { Api, Model } from "@earendil-works/pi-ai";
|
|
17
18
|
import { getProviderKind } from "pi-web-search/src/api.ts";
|
|
18
|
-
import {
|
|
19
|
+
import { installHint, serverInstallHint } from "../lsp/install-hints.ts";
|
|
19
20
|
import { SERVERS } from "../lsp/servers.ts";
|
|
20
21
|
import { typescriptPreflight } from "../lsp/servers.ts";
|
|
21
22
|
import type { McpServer } from "../mcp/config.ts";
|
|
@@ -43,30 +44,6 @@ export interface DependencyReport {
|
|
|
43
44
|
findings: Finding[];
|
|
44
45
|
}
|
|
45
46
|
|
|
46
|
-
/** Minimal `which`: the first PATH entry holding an executable of that name. */
|
|
47
|
-
export function whichOnPath(command: string, env: NodeJS.ProcessEnv, platform: string = process.platform): string | undefined {
|
|
48
|
-
if (!command) return undefined;
|
|
49
|
-
if (command.includes("/") || command.includes("\\")) return isExecutable(command) ? command : undefined;
|
|
50
|
-
const extensions = platform === "win32" ? (env.PATHEXT ?? ".EXE;.CMD;.BAT").split(";") : [""];
|
|
51
|
-
for (const dir of (env.PATH ?? "").split(delimiter)) {
|
|
52
|
-
if (!dir) continue;
|
|
53
|
-
for (const ext of extensions) {
|
|
54
|
-
const candidate = join(dir, command + ext);
|
|
55
|
-
if (isExecutable(candidate)) return candidate;
|
|
56
|
-
}
|
|
57
|
-
}
|
|
58
|
-
return undefined;
|
|
59
|
-
}
|
|
60
|
-
|
|
61
|
-
function isExecutable(path: string): boolean {
|
|
62
|
-
try {
|
|
63
|
-
accessSync(path, constants.X_OK);
|
|
64
|
-
return true;
|
|
65
|
-
} catch {
|
|
66
|
-
return false;
|
|
67
|
-
}
|
|
68
|
-
}
|
|
69
|
-
|
|
70
47
|
/** Languages the working directory plausibly contains, by the LSP table's own root markers. */
|
|
71
48
|
export function projectLanguages(cwd: string): string[] {
|
|
72
49
|
const seen = new Map<string, string>(); // command → languageId (one per server binary)
|
|
@@ -86,9 +63,45 @@ export interface DependencyInput {
|
|
|
86
63
|
cwd: string;
|
|
87
64
|
env: NodeJS.ProcessEnv;
|
|
88
65
|
platform?: string;
|
|
66
|
+
/** pi's agent dir; its `bin/` holds the ripgrep pi downloads itself. */
|
|
67
|
+
agentDir?: string;
|
|
89
68
|
mcpServers: McpServer[];
|
|
90
69
|
sessionModel?: Model<Api>;
|
|
91
70
|
webSearchSettings: WebSearchSettings;
|
|
71
|
+
/**
|
|
72
|
+
* The shell tools as the session resolves them (build.ts, from
|
|
73
|
+
* lib/shell-spawn.ts + powershell/policy.ts): which bash, which PowerShell,
|
|
74
|
+
* which one is primary, and the startup notices. On Windows this is the
|
|
75
|
+
* first thing a user asks the doctor — "which shells did you find?" — and
|
|
76
|
+
* the report had no line for it until 2026-09-19 (the Vultr VM session).
|
|
77
|
+
*/
|
|
78
|
+
shells?: ShellsInput;
|
|
79
|
+
}
|
|
80
|
+
|
|
81
|
+
export interface ShellsInput {
|
|
82
|
+
/** The bash binary the bash tool runs, or undefined when none exists. */
|
|
83
|
+
bash?: string;
|
|
84
|
+
/** An ignored CLAUDE_CODE_GIT_BASH_PATH, in the resolver's words. */
|
|
85
|
+
bashWarning?: string;
|
|
86
|
+
/** The PowerShell executable the powershell tool would run, or undefined. */
|
|
87
|
+
powershell?: string;
|
|
88
|
+
/** From `shellToolPolicy`: the tool is meant to be on (env switch, else Windows). */
|
|
89
|
+
powershellWanted: boolean;
|
|
90
|
+
/** From `shellToolPolicy`: a bash is part of this platform's expected shape (not Windows). */
|
|
91
|
+
bashExpected: boolean;
|
|
92
|
+
primary: "powershell" | "bash" | "none";
|
|
93
|
+
notices: string[];
|
|
94
|
+
}
|
|
95
|
+
|
|
96
|
+
function bashReason(s: ShellsInput): string {
|
|
97
|
+
if (s.primary === "bash") return "the primary shell tool";
|
|
98
|
+
if (s.bash) return "the bash tool, alongside PowerShell";
|
|
99
|
+
return s.bashExpected ? "the bash tool" : "the bash tool (Git for Windows provides one)";
|
|
100
|
+
}
|
|
101
|
+
|
|
102
|
+
function powershellReason(s: ShellsInput): string {
|
|
103
|
+
if (!s.powershellWanted) return s.bashExpected ? "the powershell tool (off; CLAUDE_CODE_USE_POWERSHELL_TOOL=1 turns it on with a pwsh on PATH)" : "the powershell tool (off: CLAUDE_CODE_USE_POWERSHELL_TOOL=0)";
|
|
104
|
+
return s.primary === "powershell" ? "the primary shell tool" : "the powershell tool";
|
|
92
105
|
}
|
|
93
106
|
|
|
94
107
|
export function checkDependencies(input: DependencyInput): DependencyReport {
|
|
@@ -102,8 +115,27 @@ export function checkDependencies(input: DependencyInput): DependencyReport {
|
|
|
102
115
|
checks.push({ name: "git", found: !!git, path: git, need: "required", reason: "repository detection, auto mode's recoverability check, worktree isolation", hint: "https://git-scm.com/downloads" });
|
|
103
116
|
if (!git) findings.push({ level: "warn", text: "git is not on PATH: worktree isolation and auto mode's git-recoverability check are unavailable.", fix: "Install git and make sure it is on the PATH used to launch One Code." });
|
|
104
117
|
|
|
105
|
-
|
|
106
|
-
|
|
118
|
+
// pi downloads ripgrep into <agentDir>/bin on first run when PATH has none
|
|
119
|
+
// and puts that dir on the shell tools' PATH, so a copy there is "found".
|
|
120
|
+
const rgName = platform === "win32" ? "rg.exe" : "rg";
|
|
121
|
+
const bundledRg = input.agentDir ? join(input.agentDir, "bin", rgName) : undefined;
|
|
122
|
+
if (input.shells) {
|
|
123
|
+
const s = input.shells;
|
|
124
|
+
checks.push({ name: "bash", found: !!s.bash, path: s.bash, need: s.bashExpected ? "required" : "optional", reason: bashReason(s), hint: s.bashExpected ? undefined : "https://git-scm.com/download/win" });
|
|
125
|
+
checks.push({ name: "powershell", found: !!s.powershell, path: s.powershell, need: s.powershellWanted ? "required" : "unused", reason: powershellReason(s), hint: "https://aka.ms/powershell" });
|
|
126
|
+
if (s.bashWarning) findings.push({ level: "warn", text: s.bashWarning, fix: "Point CLAUDE_CODE_GIT_BASH_PATH at a bash.exe or sh.exe, or unset it to use Git for Windows' default location." });
|
|
127
|
+
for (const notice of s.notices) {
|
|
128
|
+
const switchSet = notice.includes("CLAUDE_CODE_USE_POWERSHELL_TOOL");
|
|
129
|
+
findings.push({
|
|
130
|
+
level: s.primary === "none" ? "error" : "warn",
|
|
131
|
+
text: notice,
|
|
132
|
+
fix: switchSet ? "Install PowerShell 7 (pwsh) or unset CLAUDE_CODE_USE_POWERSHELL_TOOL." : "Install Git for Windows or PowerShell 7.",
|
|
133
|
+
});
|
|
134
|
+
}
|
|
135
|
+
}
|
|
136
|
+
|
|
137
|
+
const rg = which("rg") ?? (bundledRg && existsSync(bundledRg) ? bundledRg : undefined);
|
|
138
|
+
checks.push({ name: "rg", found: !!rg, path: rg, need: "optional", reason: "faster code search when the model shells out to ripgrep", hint: installHint("ripgrep", platform) });
|
|
107
139
|
|
|
108
140
|
const languages = projectLanguages(cwd);
|
|
109
141
|
const seenCommands = new Set<string>();
|
|
@@ -119,13 +151,13 @@ export function checkDependencies(input: DependencyInput): DependencyReport {
|
|
|
119
151
|
path: found,
|
|
120
152
|
need: needed ? "project" : "unused",
|
|
121
153
|
reason: `lsp_diagnostics for ${family.join("/")}${needed ? " (this project)" : ""}`,
|
|
122
|
-
hint:
|
|
154
|
+
hint: serverInstallHint(config.command, platform),
|
|
123
155
|
});
|
|
124
156
|
if (needed && !found) {
|
|
125
157
|
findings.push({
|
|
126
158
|
level: "warn",
|
|
127
159
|
text: `${config.command} is not installed, so the model gets no language-server diagnostics after editing ${family[0]} files here.`,
|
|
128
|
-
fix:
|
|
160
|
+
fix: serverInstallHint(config.command, platform) ? `Install it with: ${serverInstallHint(config.command, platform)}` : "Install it and make sure it is on your PATH.",
|
|
129
161
|
});
|
|
130
162
|
}
|
|
131
163
|
if (needed && found && config.command === "typescript-language-server") {
|
|
@@ -22,13 +22,18 @@
|
|
|
22
22
|
* kept in substance. Rationale: docs/decisions/doctor.md.
|
|
23
23
|
*/
|
|
24
24
|
|
|
25
|
+
import { modeCycleKey } from "../lib/keys.ts";
|
|
26
|
+
import { UPGRADE_COMMANDS } from "../lib/update-check.mjs";
|
|
27
|
+
|
|
25
28
|
export interface FixPromptInput {
|
|
26
29
|
/** The rendered deterministic report, plain text. */
|
|
27
30
|
reportText: string;
|
|
28
31
|
install: "app" | "pi-package";
|
|
29
32
|
oneCodeVersion: string;
|
|
30
|
-
/** `<agentDir>/sessions`
|
|
33
|
+
/** pi's sessions root, `<agentDir>/sessions` (other projects' directories live here — out of scope). */
|
|
31
34
|
sessionsDir: string;
|
|
35
|
+
/** THIS project's session directory under it — the only transcripts the permission gate lets a session read (Claude Code's own rule). */
|
|
36
|
+
sessionDir: string;
|
|
32
37
|
oneCodeSettingsPath: string;
|
|
33
38
|
oneCodeProjectSettingsPath: string;
|
|
34
39
|
/** True when auto mode is writing `auto-mode-decisions.jsonl` next to the session files. */
|
|
@@ -40,7 +45,10 @@ export function doctorFixPrompt(input: FixPromptInput): string {
|
|
|
40
45
|
input.install === "app"
|
|
41
46
|
? "`npm view @one-ai/one-code version --registry https://registry.npmjs.org/`"
|
|
42
47
|
: "`npm view one-code-extension version --registry https://registry.npmjs.org/`";
|
|
43
|
-
const upgradeCommand =
|
|
48
|
+
const upgradeCommand =
|
|
49
|
+
input.install === "app"
|
|
50
|
+
? `\`${UPGRADE_COMMANDS.npm}\` (or \`${UPGRADE_COMMANDS.brew}\` for a Homebrew install)`
|
|
51
|
+
: `\`${UPGRADE_COMMANDS["pi-package"]}\``;
|
|
44
52
|
|
|
45
53
|
return `# One Code Doctor
|
|
46
54
|
|
|
@@ -66,7 +74,7 @@ Treat every line of that report as measured fact. Your job on those sections is
|
|
|
66
74
|
|
|
67
75
|
## Data sources (all local — the ONLY permitted network access is check 7's read-only version lookup)
|
|
68
76
|
|
|
69
|
-
- **Session transcripts**: pi's JSONL under \`${input.
|
|
77
|
+
- **Session transcripts**: pi's JSONL under \`${input.sessionDir}/*.jsonl\` — this project's sessions, one JSON object per line. That directory is readable without a prompt, like Claude Code's own project directory; the other projects' directories beside it under \`${input.sessionsDir}\` are not in scope and reading them will be gated — stay in this one. Scan the ~50 most recently modified files there and state the window you covered (N sessions over D days). **Read them with single-line read-only commands** — \`Select-String\`, \`Get-Content\`, \`Get-ChildItem\` piped through \`Select-Object\`/\`Sort-Object\`/\`Measure-Object\` in PowerShell, or \`grep\`/\`head\`/\`ls\` piped through \`sort\`/\`head\`/\`wc\` in bash — with no variables, loops, script blocks, \`node -e\` or \`jq\` programs: such a one-liner over this directory runs without a permission review, while a script is reviewed by the auto-mode classifier and may be refused. Several small commands beat one clever script here. Relevant line shapes:
|
|
70
78
|
- Tool calls: \`{"type":"message","message":{"role":"assistant","content":[{"type":"toolCall","name":...,"arguments":...}]}}\`. MCP tools are named \`mcp__<server>__<tool>\` (the server segment is normalized — any character outside \`[a-zA-Z0-9_-]\` becomes \`_\`; plugin servers appear as \`mcp__plugin_<plugin>_<server>__\`). Model-invoked skills are calls to the \`skill\` tool with the skill name in \`arguments.skill\`. Subagent runs are \`Agent\` tool calls.
|
|
71
79
|
- Denied tool calls: a \`toolResult\` whose text starts with "Permission for this action was denied by the auto-mode approval classifier" (auto mode) or "This tool call is denied by the permission rule" (a deny rule). Follow its \`toolCallId\` back to the assistant's \`toolCall\` for the tool name and arguments. ${input.decisionLogEnabled ? "Auto mode is also writing `auto-mode-decisions.jsonl` next to each session file — every gate decision with its verdict; prefer it for check 9." : "Auto mode is not logging its decisions (`autoMode.logDecisions` is off), so transcripts are the only denial record."}
|
|
72
80
|
- There are NO usage counters in One Code: whether a skill, plugin or MCP server was used is answered from transcripts alone.
|
|
@@ -107,7 +115,7 @@ Installed: ${input.oneCodeVersion} (${input.install === "app" ? "the bundled one
|
|
|
107
115
|
|
|
108
116
|
## Check 8 — permission mode (report only)
|
|
109
117
|
|
|
110
|
-
Auto mode is One Code's shipped default. If the report shows a different mode pinned by \`permissions.defaultMode\` in \`~/.claude/settings.json\` or a managed file, say so in one line and leave it — One Code does not edit those files, and the user can change the mode with
|
|
118
|
+
Auto mode is One Code's shipped default. If the report shows a different mode pinned by \`permissions.defaultMode\` in \`~/.claude/settings.json\` or a managed file, say so in one line and leave it — One Code does not edit those files, and the user can change the mode with ${modeCycleKey()} or \`--permission-mode\`. If a repository file pins \`auto\` or \`bypassPermissions\`, the report already explains why it is ignored.
|
|
111
119
|
|
|
112
120
|
## Check 9 — pre-approve frequently denied read-only commands
|
|
113
121
|
|
|
@@ -159,6 +159,7 @@ export default function doctorExtension(pi: ExtensionAPI) {
|
|
|
159
159
|
install: install(),
|
|
160
160
|
oneCodeVersion: oneCodeVersion(),
|
|
161
161
|
sessionsDir: join(getAgentDir(), "sessions"),
|
|
162
|
+
sessionDir: ctx.sessionManager.getSessionDir(),
|
|
162
163
|
oneCodeSettingsPath: oneCodeSettingsPath(home),
|
|
163
164
|
oneCodeProjectSettingsPath: oneCodeProjectSettingsPath(ctx.cwd, home),
|
|
164
165
|
decisionLogEnabled: loadAutoModeConfig(home).logDecisions,
|
|
@@ -10,6 +10,7 @@
|
|
|
10
10
|
*/
|
|
11
11
|
|
|
12
12
|
import type { Api, Model } from "@earendil-works/pi-ai";
|
|
13
|
+
import { modeCycleKey } from "../lib/keys.ts";
|
|
13
14
|
import { loadAutoModeConfig } from "../auto-mode/config.ts";
|
|
14
15
|
import { classifierCandidates, describeCandidate, type ClassifierNotice } from "../auto-mode/model-select.ts";
|
|
15
16
|
import { ATTRIBUTION, capabilityFloor, configuredCapabilityKey, type FloorRole, type FloorVerdict, KEY_ADVICE, snapshotAgeMs } from "../lib/capability-index.ts";
|
|
@@ -210,7 +211,7 @@ export function modelsSection(facts: ModelFacts, session: SessionView, findings:
|
|
|
210
211
|
if (session.permission) {
|
|
211
212
|
const mode = session.permission.mode;
|
|
212
213
|
const from = session.permission.source ? ` (${session.permission.source})` : "";
|
|
213
|
-
lines.push({ text: `Permission mode: ${mode}${from} —
|
|
214
|
+
lines.push({ text: `Permission mode: ${mode}${from} — ${modeCycleKey()} cycles, /permissions lists the rules`, level: "dim" });
|
|
214
215
|
}
|
|
215
216
|
|
|
216
217
|
if (facts.sessionTier === "tiny") {
|
|
@@ -16,6 +16,7 @@
|
|
|
16
16
|
*/
|
|
17
17
|
|
|
18
18
|
import type { Api, Model } from "@earendil-works/pi-ai";
|
|
19
|
+
import { tildify } from "../lib/paths.ts";
|
|
19
20
|
import type { McpStatusEvent } from "../lib/mcp-status.ts";
|
|
20
21
|
import { wrapProse } from "../subagents/panel-render.ts";
|
|
21
22
|
import { countNoun } from "../lib/tui-render.ts";
|
|
@@ -183,7 +184,5 @@ export function contextLabel(tokens: number | undefined): string {
|
|
|
183
184
|
|
|
184
185
|
/** `~/…` for anything under home, so paths stay readable at any width. */
|
|
185
186
|
export function shortenHome(path: string, home: string): string {
|
|
186
|
-
|
|
187
|
-
const root = home.endsWith("/") ? home : `${home}/`;
|
|
188
|
-
return path.startsWith(root) ? `~/${path.slice(root.length)}` : path;
|
|
187
|
+
return tildify(path, home);
|
|
189
188
|
}
|
|
@@ -1,16 +1,19 @@
|
|
|
1
1
|
/**
|
|
2
2
|
* Newest-release lookup for the doctor's Installation section (pure given a
|
|
3
|
-
* fetch).
|
|
4
|
-
*
|
|
5
|
-
*
|
|
6
|
-
*
|
|
3
|
+
* fetch). Shares the app's daily update notice's mechanism
|
|
4
|
+
* (lib/update-check.mjs): the same packument, the same "newest installable
|
|
5
|
+
* version" rule — a day's release age under Homebrew, `dist-tags.latest`
|
|
6
|
+
* otherwise — a short timeout, silent on failure, and skipped under pi's
|
|
7
|
+
* offline switch or ONECODE_NO_UPDATE_CHECK — a diagnostic must never hang on
|
|
8
|
+
* the network or restore egress the user turned off.
|
|
7
9
|
*/
|
|
8
10
|
|
|
9
11
|
import { compareVersions } from "../lib/pi-version.ts";
|
|
12
|
+
import { APP_PACKAGE, EXTENSION_PACKAGE, minReleaseAgeFor, pickAvailableVersion, registryPackumentUrl } from "../lib/update-check.mjs";
|
|
10
13
|
import type { DoctorEnvironment } from "./report.ts";
|
|
11
14
|
|
|
12
|
-
export const APP_REGISTRY_URL =
|
|
13
|
-
export const PACKAGE_REGISTRY_URL =
|
|
15
|
+
export const APP_REGISTRY_URL = registryPackumentUrl(APP_PACKAGE);
|
|
16
|
+
export const PACKAGE_REGISTRY_URL = registryPackumentUrl(EXTENSION_PACKAGE);
|
|
14
17
|
export const LOOKUP_TIMEOUT_MS = 3000;
|
|
15
18
|
|
|
16
19
|
export type LatestLookup = NonNullable<DoctorEnvironment["latest"]>;
|
|
@@ -34,11 +37,16 @@ export async function lookupLatestVersion(input: {
|
|
|
34
37
|
try {
|
|
35
38
|
const response = await fetchImpl(url, { signal: AbortSignal.timeout(input.timeoutMs ?? LOOKUP_TIMEOUT_MS) });
|
|
36
39
|
if (!response.ok) return { status: "unknown", reason: `registry answered ${response.status}` };
|
|
37
|
-
const
|
|
38
|
-
|
|
39
|
-
const
|
|
40
|
-
if (
|
|
41
|
-
|
|
40
|
+
const minReleaseAgeMs = minReleaseAgeFor(input.env);
|
|
41
|
+
const body = (await response.json()) as { "dist-tags"?: { latest?: unknown }; time?: unknown };
|
|
42
|
+
const wellFormed = typeof body?.["dist-tags"]?.latest === "string" && (minReleaseAgeMs <= 0 || (typeof body.time === "object" && body.time !== null));
|
|
43
|
+
if (!wellFormed) return { status: "unknown", reason: "unexpected registry payload" };
|
|
44
|
+
const latest = pickAvailableVersion(body, { minReleaseAgeMs });
|
|
45
|
+
// A well-formed packument with nothing old enough is a Homebrew outcome, not a broken registry.
|
|
46
|
+
if (typeof latest !== "string") return { status: "unknown", reason: "no release is a day old yet (Homebrew installs wait that long)" };
|
|
47
|
+
const cmp = compareVersions(latest, input.current);
|
|
48
|
+
if (cmp === undefined) return { status: "unknown", version: latest, reason: "unparseable version" };
|
|
49
|
+
return { status: cmp > 0 ? "behind" : "current", version: latest };
|
|
42
50
|
} catch (error) {
|
|
43
51
|
const message = error instanceof Error ? (error.name === "TimeoutError" ? "timed out" : error.message) : String(error);
|
|
44
52
|
return { status: "unknown", reason: message };
|
|
@@ -42,6 +42,7 @@ import {
|
|
|
42
42
|
ULTRACODE_MODE_CHANNEL,
|
|
43
43
|
ULTRACODE_STATUS_KEY,
|
|
44
44
|
} from "./slider.ts";
|
|
45
|
+
import { registerLocalCommand } from "../lib/local-command.ts";
|
|
45
46
|
|
|
46
47
|
/** The levels pi says the active model can reach; the full ladder when unknown. */
|
|
47
48
|
function supportedLevels(ctx: ExtensionContext): ThinkingLevel[] {
|
|
@@ -193,7 +194,7 @@ export default function effortExtension(pi: ExtensionAPI) {
|
|
|
193
194
|
if (chosen) applyChoice(chosen, ctx);
|
|
194
195
|
};
|
|
195
196
|
|
|
196
|
-
pi
|
|
197
|
+
registerLocalCommand(pi, "effort", {
|
|
197
198
|
description: `Set reasoning effort (shift+tab cycles the plain levels): /effort [${EFFORT_CHOICES.join("|")}] — ultracode is ${ULTRACODE_LEVEL} + workflows`,
|
|
198
199
|
getArgumentCompletions: (prefix) => {
|
|
199
200
|
const matches = acceptedEffortArgs().filter((value) => value.startsWith(prefix.trim().toLowerCase()));
|
|
@@ -12,6 +12,7 @@
|
|
|
12
12
|
*/
|
|
13
13
|
|
|
14
14
|
import { costOf, usageEntryCost } from "../lib/usage-bus.ts";
|
|
15
|
+
import type { WorktreeLocation } from "../lib/worktree-channel.ts";
|
|
15
16
|
|
|
16
17
|
export type Paint = (color: string, text: string) => string;
|
|
17
18
|
|
|
@@ -38,6 +39,18 @@ export interface FooterData {
|
|
|
38
39
|
effort?: string;
|
|
39
40
|
}
|
|
40
41
|
|
|
42
|
+
/**
|
|
43
|
+
* Where the footer says the session is: the active worktree's path and branch
|
|
44
|
+
* while `enter_worktree` is in effect (the process cwd never changes — the
|
|
45
|
+
* worktree extension rewrites tool inputs instead — so pi's own cwd and branch
|
|
46
|
+
* would keep naming the original checkout), else the process cwd and its
|
|
47
|
+
* branch.
|
|
48
|
+
*/
|
|
49
|
+
export function footerLocation(cwd: string, branch: string | undefined, worktree: WorktreeLocation | null | undefined): { cwd: string; branch?: string } {
|
|
50
|
+
if (worktree) return { cwd: worktree.path, branch: worktree.branch };
|
|
51
|
+
return { cwd, branch };
|
|
52
|
+
}
|
|
53
|
+
|
|
41
54
|
/** pi's token formatter, replicated so a "1.1M" here reads like pi's own. */
|
|
42
55
|
export function formatTokens(count: number): string {
|
|
43
56
|
if (count < 1000) return count.toString();
|
|
@@ -197,6 +210,12 @@ export function computeMainUsage(entries: readonly unknown[]): { cost: number; c
|
|
|
197
210
|
cost += costOf(entry.message.usage);
|
|
198
211
|
} else if ((entry.type === "branch_summary" || entry.type === "compaction") && entry.usage) {
|
|
199
212
|
cost += costOf(entry.usage);
|
|
213
|
+
} else if (entry.type === "usage" && entry.usage) {
|
|
214
|
+
// pi 0.86's model-attributed usage outside the LLM context — today its
|
|
215
|
+
// cache-warming refreshes (`kind: "cache_warm"`), billed as a cache read
|
|
216
|
+
// of the full context plus one output token. pi's /session counts them;
|
|
217
|
+
// so does the footer.
|
|
218
|
+
cost += costOf(entry.usage);
|
|
200
219
|
} else {
|
|
201
220
|
// Out-of-band calls (subagents, classifier, readers, recap, setup)
|
|
202
221
|
// persisted by recordUsage — the all-in half of the total.
|
|
@@ -22,7 +22,8 @@ import { linesComponent, safeThemePaint } from "../lib/tui-render.ts";
|
|
|
22
22
|
import { formatModel, isRealModel } from "../permissions/modes.ts";
|
|
23
23
|
import { ULTRACODE_STATUS_KEY } from "../effort/slider.ts";
|
|
24
24
|
import { USAGE_CHANNEL } from "../lib/usage-bus.ts";
|
|
25
|
-
import { buildFooterLines, computeMainUsage, type FooterData } from "./footer-line.ts";
|
|
25
|
+
import { buildFooterLines, computeMainUsage, footerLocation, type FooterData } from "./footer-line.ts";
|
|
26
|
+
import { WORKTREE_CHANNEL, type WorktreeLocation } from "../lib/worktree-channel.ts";
|
|
26
27
|
import { fetchPrNumber } from "./pr.ts";
|
|
27
28
|
|
|
28
29
|
export default function footerExtension(pi: ExtensionAPI) {
|
|
@@ -40,6 +41,16 @@ export default function footerExtension(pi: ExtensionAPI) {
|
|
|
40
41
|
|
|
41
42
|
/** Invalidate the memoized line and repaint; set once the footer mounts. */
|
|
42
43
|
let repaint = () => {};
|
|
44
|
+
/** The active worktree session, if any; shown in place of the process cwd + branch. */
|
|
45
|
+
let worktree: WorktreeLocation | null = null;
|
|
46
|
+
/** Re-point the PR lookup at the location currently shown; set once the footer mounts. */
|
|
47
|
+
let refreshLocation = () => {};
|
|
48
|
+
|
|
49
|
+
pi.events.on(WORKTREE_CHANNEL, (data: unknown) => {
|
|
50
|
+
worktree = data as WorktreeLocation | null;
|
|
51
|
+
refreshLocation();
|
|
52
|
+
repaint();
|
|
53
|
+
});
|
|
43
54
|
|
|
44
55
|
const recomputeMain = (ctx: ExtensionContext) => {
|
|
45
56
|
lastCtx = ctx;
|
|
@@ -85,15 +96,19 @@ export default function footerExtension(pi: ExtensionAPI) {
|
|
|
85
96
|
onBranchChange(cb: () => void): () => void;
|
|
86
97
|
};
|
|
87
98
|
|
|
99
|
+
/** The path + branch shown, and the PR looked up: the worktree's while one is active. */
|
|
100
|
+
const currentLocation = () => footerLocation(ctx.cwd, fd.getGitBranch() ?? undefined, worktree);
|
|
101
|
+
|
|
88
102
|
const snapshot = (): FooterData => {
|
|
89
103
|
const usage = ctx.getContextUsage();
|
|
90
104
|
const ultracode = fd.getExtensionStatuses().get(ULTRACODE_STATUS_KEY);
|
|
91
105
|
const effort = ultracode ?? safeThinkingLevel(ctx);
|
|
92
106
|
const model = ctx.model;
|
|
107
|
+
const location = currentLocation();
|
|
93
108
|
return {
|
|
94
|
-
cwd:
|
|
109
|
+
cwd: location.cwd,
|
|
95
110
|
home: process.env.HOME || process.env.USERPROFILE || "",
|
|
96
|
-
branch:
|
|
111
|
+
branch: location.branch,
|
|
97
112
|
contextTokens: usage?.tokens ?? undefined,
|
|
98
113
|
contextWindow: usage?.contextWindow,
|
|
99
114
|
contextPercent: usage?.percent,
|
|
@@ -116,9 +131,13 @@ export default function footerExtension(pi: ExtensionAPI) {
|
|
|
116
131
|
// only disposes the component, so the subscription must be released
|
|
117
132
|
// here — otherwise each replaced footer keeps firing its `gh pr list`
|
|
118
133
|
// against a stale cwd on every branch change.
|
|
119
|
-
|
|
134
|
+
refreshLocation = () => {
|
|
135
|
+
const location = currentLocation();
|
|
136
|
+
refreshPr(location.cwd, location.branch ?? null);
|
|
137
|
+
};
|
|
138
|
+
refreshLocation();
|
|
120
139
|
const stopFollowingBranch = fd.onBranchChange(() => {
|
|
121
|
-
|
|
140
|
+
refreshLocation();
|
|
122
141
|
repaint();
|
|
123
142
|
});
|
|
124
143
|
|
|
@@ -126,6 +145,7 @@ export default function footerExtension(pi: ExtensionAPI) {
|
|
|
126
145
|
dispose: () => {
|
|
127
146
|
stopFollowingBranch();
|
|
128
147
|
repaint = () => {};
|
|
148
|
+
refreshLocation = () => {};
|
|
129
149
|
},
|
|
130
150
|
});
|
|
131
151
|
});
|
|
@@ -9,11 +9,18 @@
|
|
|
9
9
|
* a JSON payload from stdin. (Ported from pi-code's runHookCommand, MIT — see
|
|
10
10
|
* docs/decisions.md.)
|
|
11
11
|
*
|
|
12
|
+
* The interpreter is Claude Code's: bash (Git Bash on Windows, honouring
|
|
13
|
+
* `CLAUDE_CODE_GIT_BASH_PATH`) unless the hook says `shell: "powershell"`, or
|
|
14
|
+
* the machine is a Windows box without Git Bash — lib/shell-spawn.ts resolves
|
|
15
|
+
* both through pi's own shell lookup.
|
|
16
|
+
*
|
|
12
17
|
* Hardening carried over:
|
|
13
|
-
* - absolute
|
|
18
|
+
* - an absolute interpreter path, so a repo-local `sh` on PATH can't hijack
|
|
19
|
+
* the hook
|
|
14
20
|
* - detached:true makes the shell a process-group leader; timeout SIGKILLs
|
|
15
21
|
* the negative pid so grandchildren holding the stdio pipes die too —
|
|
16
22
|
* otherwise `close` never fires and the promise hangs past the timeout
|
|
23
|
+
* (Windows: `taskkill /T /F` on the leader's pid does the same)
|
|
17
24
|
* - setEncoding("utf8") so multi-byte characters can't be split across chunks
|
|
18
25
|
* - output capped at 1MB per stream
|
|
19
26
|
* - stdin errors ignored (a hook that exits without reading stdin — `exit 2`
|
|
@@ -38,8 +45,36 @@
|
|
|
38
45
|
* mechanism for the LSP client). A ref'd child holds the loop until `close`.
|
|
39
46
|
*/
|
|
40
47
|
|
|
41
|
-
import {
|
|
42
|
-
import { killProcessTree } from "../lib/process-tree.ts";
|
|
48
|
+
import type { ChildProcess } from "node:child_process";
|
|
49
|
+
import { detachedSpawnOptions, killProcessTree, waitForChildExit } from "../lib/process-tree.ts";
|
|
50
|
+
import { bashSpawn, POWERSHELL_UTF8_PREFIX, powerShellSpawn, type ShellSpawn, spawnShellCommand } from "../lib/shell-spawn.ts";
|
|
51
|
+
import type { HookShell } from "./settings.ts";
|
|
52
|
+
|
|
53
|
+
/**
|
|
54
|
+
* Claude Code's default: hooks run in bash (Git Bash on Windows) when one
|
|
55
|
+
* exists, otherwise in PowerShell — which only happens on Windows without Git
|
|
56
|
+
* for Windows, since every other platform resolves a bash.
|
|
57
|
+
*/
|
|
58
|
+
export function defaultHookShell(): HookShell {
|
|
59
|
+
return bashSpawn().spawn ? "bash" : "powershell";
|
|
60
|
+
}
|
|
61
|
+
|
|
62
|
+
/** The interpreter for one hook, or the reason none can run it. */
|
|
63
|
+
export function hookShellSpawn(shell: HookShell | undefined): { spec?: ShellSpawn; shell?: HookShell; error?: string } {
|
|
64
|
+
const want = shell ?? defaultHookShell();
|
|
65
|
+
if (want === "powershell") {
|
|
66
|
+
const spec = powerShellSpawn();
|
|
67
|
+
return spec ? { spec, shell: want } : { error: "hook needs PowerShell but no pwsh/powershell executable was found on PATH" };
|
|
68
|
+
}
|
|
69
|
+
const resolved = bashSpawn();
|
|
70
|
+
if (!resolved.spawn) return { error: resolved.error ?? "hook needs bash but none was found" };
|
|
71
|
+
if (resolved.spawn.commandTransport === "stdin") {
|
|
72
|
+
// pi's legacy-WSL launcher takes its script on stdin, which the hook
|
|
73
|
+
// payload already occupies.
|
|
74
|
+
return { error: `hook cannot run under ${resolved.spawn.shell} (a WSL launcher); set CLAUDE_CODE_GIT_BASH_PATH to Git Bash's bash.exe` };
|
|
75
|
+
}
|
|
76
|
+
return { spec: resolved.spawn, shell: want };
|
|
77
|
+
}
|
|
43
78
|
|
|
44
79
|
export interface HookRunResult {
|
|
45
80
|
/**
|
|
@@ -80,6 +115,8 @@ export interface HookRunOptions {
|
|
|
80
115
|
* stdout/stderr are therefore empty for a detached hook — nothing reads them.
|
|
81
116
|
*/
|
|
82
117
|
detached?: boolean;
|
|
118
|
+
/** The hook's `shell` field; unset → `defaultHookShell()`. */
|
|
119
|
+
shell?: HookShell;
|
|
83
120
|
}
|
|
84
121
|
|
|
85
122
|
const MAX_OUTPUT_BYTES = 1_000_000;
|
|
@@ -92,11 +129,28 @@ export function runHookCommand(command: string, stdinJson: string, opts: HookRun
|
|
|
92
129
|
const started = Date.now();
|
|
93
130
|
|
|
94
131
|
return new Promise((resolve) => {
|
|
95
|
-
let child:
|
|
132
|
+
let child: ChildProcess;
|
|
133
|
+
const failed = (spawnError: string) =>
|
|
134
|
+
resolve({ exitCode: null, timedOut: false, spawnError, stdout: "", stderr: "", durationMs: Date.now() - started });
|
|
135
|
+
const { spec, shell, error } = hookShellSpawn(opts.shell);
|
|
136
|
+
if (!spec) {
|
|
137
|
+
failed(error ?? "no shell available for the hook");
|
|
138
|
+
return;
|
|
139
|
+
}
|
|
140
|
+
// Windows PowerShell 5.1 (`powershell.exe`) defaults its console to
|
|
141
|
+
// UTF-16, which would corrupt this stream once decoded as UTF-8 below
|
|
142
|
+
// (setEncoding("utf8")) — the same fix every other PowerShell execution
|
|
143
|
+
// path applies (lib/shell-spawn.ts, extensions/powershell/index.ts).
|
|
144
|
+
const runCommand = shell === "powershell" ? `${POWERSHELL_UTF8_PREFIX}${command}` : command;
|
|
96
145
|
try {
|
|
97
|
-
|
|
146
|
+
// An absolute interpreter path (pi's resolver never yields a bare name
|
|
147
|
+
// where a real bash exists), so a repo-local `sh` on PATH cannot hijack
|
|
148
|
+
// the hook; the shell leads its own process group so a timeout kills
|
|
149
|
+
// grandchildren too (Windows: taskkill /T — lib/process-tree.ts).
|
|
150
|
+
child = spawnShellCommand(spec, runCommand, {
|
|
98
151
|
cwd: opts.cwd,
|
|
99
|
-
|
|
152
|
+
// A fire-and-forget hook must outlive a parent that is exiting.
|
|
153
|
+
...detachedSpawnOptions({ outlivesParent: opts.detached }),
|
|
100
154
|
stdio: opts.detached ? ["pipe", "ignore", "ignore"] : ["pipe", "pipe", "pipe"],
|
|
101
155
|
env: { ...process.env, CLAUDE_PROJECT_DIR: opts.projectDir ?? opts.cwd },
|
|
102
156
|
});
|
|
@@ -149,21 +203,33 @@ export function runHookCommand(command: string, stdinJson: string, opts: HookRun
|
|
|
149
203
|
resolve({ ...result, durationMs: Date.now() - started });
|
|
150
204
|
};
|
|
151
205
|
|
|
152
|
-
|
|
153
|
-
|
|
154
|
-
|
|
155
|
-
|
|
156
|
-
|
|
157
|
-
|
|
158
|
-
|
|
159
|
-
|
|
160
|
-
finish({ exitCode: timedOut ? null : code, timedOut, stdout, stderr })
|
|
161
|
-
|
|
206
|
+
// Settles on exit plus a short stdio grace, not on `close`: a grandchild
|
|
207
|
+
// the kill missed could otherwise hold the pipes — and this promise — open
|
|
208
|
+
// for its whole life (lib/process-tree.ts). A killed process reports null,
|
|
209
|
+
// EXCEPT when the shell outlived the group kill just long enough to reap
|
|
210
|
+
// its child and exit 128+9 itself (see exitCode's contract). Normalizing
|
|
211
|
+
// keeps "we killed it" from ever looking like an ordinary non-zero exit,
|
|
212
|
+
// which fails OPEN downstream.
|
|
213
|
+
waitForChildExit(child).then(
|
|
214
|
+
({ code }) => finish({ exitCode: timedOut ? null : code, timedOut, stdout, stderr }),
|
|
215
|
+
(error: Error) => finish({ exitCode: null, timedOut, spawnError: error.message, stdout, stderr }),
|
|
216
|
+
);
|
|
162
217
|
|
|
163
218
|
// A hook that never reads stdin (e.g. plain `exit 2`) closes the pipe
|
|
164
219
|
// early; the resulting EPIPE must not take the extension down.
|
|
165
220
|
child.stdin?.on("error", () => {});
|
|
166
|
-
|
|
167
|
-
|
|
221
|
+
const payload = stdinJson.endsWith("\n") ? stdinJson : `${stdinJson}\n`;
|
|
222
|
+
if (opts.detached) {
|
|
223
|
+
// Close the pipe the moment the payload is written, not with a graceful
|
|
224
|
+
// shutdown: on Windows libuv's pipe shutdown stays pending until the
|
|
225
|
+
// reader has drained the data, and a hook that never reads stdin would
|
|
226
|
+
// keep the process alive for its whole run (the probe test measured a
|
|
227
|
+
// `sleep 3` SessionEnd hook holding a one-shot open for 3 s). The data
|
|
228
|
+
// already written stays readable by the hook after the handle closes.
|
|
229
|
+
child.stdin?.write(payload, () => child.stdin?.destroy());
|
|
230
|
+
} else {
|
|
231
|
+
child.stdin?.write(payload);
|
|
232
|
+
child.stdin?.end();
|
|
233
|
+
}
|
|
168
234
|
});
|
|
169
235
|
}
|
|
@@ -196,6 +196,7 @@ export default function hooksExtension(pi: ExtensionAPI) {
|
|
|
196
196
|
const run = await runHookCommand(hook.command, stdin, {
|
|
197
197
|
cwd: ctx.cwd,
|
|
198
198
|
timeoutSeconds: hook.timeout,
|
|
199
|
+
shell: hook.shell,
|
|
199
200
|
projectDir: ctx.cwd,
|
|
200
201
|
// SessionEnd is fire-and-forget at shutdown: it must not hold a
|
|
201
202
|
// one-shot process open. Every other hook is awaited work that
|
|
@@ -15,6 +15,7 @@ import { ccAliasesForTool } from "../permissions/matcher.ts";
|
|
|
15
15
|
*/
|
|
16
16
|
const CC_CANONICAL: Record<string, string> = {
|
|
17
17
|
bash: "Bash",
|
|
18
|
+
powershell: "PowerShell",
|
|
18
19
|
read: "Read",
|
|
19
20
|
edit: "Edit",
|
|
20
21
|
write: "Write",
|
|
@@ -88,19 +89,32 @@ export function toolMatchCandidates(nativeName: string): string[] {
|
|
|
88
89
|
return [...new Set([nativeName, ccToolName(nativeName), ...ccAliasesForTool(nativeName)])];
|
|
89
90
|
}
|
|
90
91
|
|
|
91
|
-
/**
|
|
92
|
-
|
|
93
|
-
* case-insensitive (`^(?:matcher)$`, the CC semantics); an invalid regex
|
|
94
|
-
* falls back to case-insensitive exact comparison rather than throwing.
|
|
95
|
-
*/
|
|
96
|
-
export function matcherApplies(matcher: string | undefined, candidates: string[]): boolean {
|
|
97
|
-
if (matcher === undefined || matcher === "" || matcher === "*") return true;
|
|
92
|
+
/** One matcher spelling: a regex when it compiles, else a case-insensitive exact match. */
|
|
93
|
+
function matchesAlternative(alternative: string, candidates: string[]): boolean {
|
|
98
94
|
let regex: RegExp | undefined;
|
|
99
95
|
try {
|
|
100
|
-
regex = new RegExp(`^(?:${
|
|
96
|
+
regex = new RegExp(`^(?:${alternative})$`, "i");
|
|
101
97
|
} catch {
|
|
102
98
|
regex = undefined;
|
|
103
99
|
}
|
|
104
|
-
const lowered =
|
|
100
|
+
const lowered = alternative.toLowerCase();
|
|
105
101
|
return candidates.some((candidate) => (regex ? regex.test(candidate) : candidate.toLowerCase() === lowered));
|
|
106
102
|
}
|
|
103
|
+
|
|
104
|
+
/**
|
|
105
|
+
* Whether a CC matcher applies to any candidate spelling. Anchored and
|
|
106
|
+
* case-insensitive (`^(?:matcher)$`, the CC semantics); an invalid regex
|
|
107
|
+
* falls back to case-insensitive exact comparison rather than throwing. The
|
|
108
|
+
* whole matcher is tried first — so a regex that legitimately contains a
|
|
109
|
+
* comma (a `{m,n}` quantifier, a character class) still compiles and matches
|
|
110
|
+
* as written. Only once that fails is a comma-separated list
|
|
111
|
+
* (`Bash,PowerShell`) tried part by part — the spelling Claude Code's Windows
|
|
112
|
+
* docs use beside `Bash|PowerShell`.
|
|
113
|
+
*/
|
|
114
|
+
export function matcherApplies(matcher: string | undefined, candidates: string[]): boolean {
|
|
115
|
+
if (matcher === undefined || matcher === "" || matcher === "*") return true;
|
|
116
|
+
if (matchesAlternative(matcher, candidates)) return true;
|
|
117
|
+
if (!matcher.includes(",")) return false;
|
|
118
|
+
const alternatives = matcher.split(",").map((part) => part.trim()).filter(Boolean);
|
|
119
|
+
return alternatives.some((alternative) => matchesAlternative(alternative, candidates));
|
|
120
|
+
}
|