@hncode/hncode 0.5.0 → 0.5.2

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.
Files changed (48) hide show
  1. package/bin/install.cjs +24 -44
  2. package/bin/preinstall.cjs +0 -14
  3. package/package.json +1 -1
  4. package/src/agent-task.js +12 -1
  5. package/src/agent.js +115 -35
  6. package/src/cell-buffer.js +5 -0
  7. package/src/clipboard.js +42 -6
  8. package/src/colors.js +478 -387
  9. package/src/config.js +38 -11
  10. package/src/context-bar.js +46 -5
  11. package/src/doctor.js +214 -11
  12. package/src/git.js +35 -4
  13. package/src/llm.js +6 -0
  14. package/src/memory-profile.js +247 -0
  15. package/src/permissions.js +29 -7
  16. package/src/registry-browser.js +21 -6
  17. package/src/row-cache.js +147 -142
  18. package/src/session-core.js +203 -0
  19. package/src/session-host.js +213 -0
  20. package/src/session-hub.js +1 -0
  21. package/src/session-view.js +110 -0
  22. package/src/settings-schema.js +304 -0
  23. package/src/shell-safety.js +95 -6
  24. package/src/swarm-progress.js +348 -293
  25. package/src/tasks-browser.js +4 -1
  26. package/src/team/constants.js +20 -0
  27. package/src/team/context.js +89 -0
  28. package/src/team/mailbox.js +139 -0
  29. package/src/team/runner.js +347 -0
  30. package/src/team/spawn.js +247 -0
  31. package/src/team/store.js +145 -0
  32. package/src/team/tasks.js +164 -0
  33. package/src/team/team.js +311 -0
  34. package/src/team-panel.js +226 -0
  35. package/src/term.js +27 -0
  36. package/src/tool-result.js +279 -236
  37. package/src/tools/agent-swarm.js +114 -11
  38. package/src/tools/agent.js +53 -3
  39. package/src/tools/edit.js +591 -436
  40. package/src/tools/git.js +15 -3
  41. package/src/tools/index.js +2 -0
  42. package/src/tools/team.js +398 -0
  43. package/src/tools/web-search.js +88 -75
  44. package/src/tui.js +6187 -4780
  45. package/src/update.js +285 -115
  46. package/src/web-daemon.js +27 -4
  47. package/src/web-live.js +250 -0
  48. package/src/web.js +3 -1
@@ -0,0 +1,304 @@
1
+ // The settings schema — one declaration per setting, projected onto every surface.
2
+ //
3
+ // WHY A TABLE
4
+ // -----------
5
+ // Settings were spread across two dozen `/xxx` commands, each with its own argument
6
+ // parsing, validation and persistence. Adding one meant writing a command; finding one
7
+ // meant knowing what it was called; and the surfaces disagreed — the TUI had a
8
+ // `/calm` toggle, the web's `setConfig` action accepted eight keys, and `config.toml`
9
+ // accepted everything. CodeWhale's `settings_schema.rs` solves this with a single
10
+ // declarative table that the UI renders from, the setter validates against, and the
11
+ // defaults come from, so this mirrors that shape:
12
+ //
13
+ // SETTINGS_SCHEMA = [ { key, kind, default, ui: { tab, group, label, hint } | null } ]
14
+ //
15
+ // `ui: null` means DECLARED BUT NOT SHOWN — settable through `/set <key> <value>` and
16
+ // persisted, but with no row. That is how a value stays reachable without cluttering the
17
+ // screen (CodeWhale hides 28 of its 87 this way).
18
+ //
19
+ // WHAT `kind` BUYS
20
+ // ----------------
21
+ // `bool` renders a toggle, `enum` a picker over `values`, `int` a number prompt, `string`
22
+ // and `text` an editor. The surface never guesses, and a new setting needs no new UI code.
23
+ //
24
+ // THE DEFAULTS LIVE HERE TOO. A default written in the schema and nowhere else means
25
+ // `/settings` can always show what a value WOULD be, even before it is set — which is the
26
+ // difference between a settings screen and a list of the points you have already changed.
27
+
28
+ import { THEME_NAMES } from './colors.js';
29
+
30
+ /** Where a group appears in the rail, in display order. */
31
+ export const SETTINGS_TABS = [
32
+ { id: 'appearance', label: 'Appearance', hint: 'Theme, density, motion, transcript' },
33
+ { id: 'prompt', label: 'Prompt', hint: 'System prompt and per-mode instructions' },
34
+ { id: 'model', label: 'Model', hint: 'Provider, model, thinking effort' },
35
+ { id: 'context', label: 'Context', hint: 'Compaction and tool-result trimming' },
36
+ { id: 'tools', label: 'Tools', hint: 'Permissions, delegation, shell' },
37
+ { id: 'advanced', label: 'Advanced', hint: 'Update, telemetry, experiments' },
38
+ ];
39
+
40
+ /** Kind -> a one-word name for the value, used by the value prompt. */
41
+ export const KIND_LABEL = {
42
+ bool: 'on/off',
43
+ int: 'number',
44
+ enum: 'choice',
45
+ string: 'text',
46
+ text: 'multi-line text',
47
+ };
48
+
49
+ // Defaults are STRINGS, matching what config.toml holds and what `setConfigString`
50
+ // writes. `bool(false)` used to expand to `default: false` — a JS boolean — and every
51
+ // comparison against `'true'` then failed, so every toggle rendered as "off" regardless of
52
+ // its default. The kind says how a value is EDITED; the default says how it is STORED, and
53
+ // storing is a string.
54
+ const bool = (d) => ({ kind: 'bool', default: d ? 'true' : 'false' });
55
+ const int = (d, min, max) => ({ kind: 'int', default: String(d), min, max });
56
+ const en = (d, values) => ({ kind: 'enum', default: d, values });
57
+ const str = (d) => ({ kind: 'string', default: d });
58
+ const text = (d) => ({ kind: 'text', default: d });
59
+
60
+ /**
61
+ * Every setting the program reads or can persist.
62
+ *
63
+ * `key` is the config.toml key verbatim — there is no rename layer, so what is written to
64
+ * the file is what the code reads and what `/set` accepts.
65
+ */
66
+ export const SETTINGS_SCHEMA = [
67
+ // ---- appearance ---------------------------------------------------------
68
+ // The default is `dark`, NOT `auto`. Setting it to `auto` silently switched every existing
69
+ // setup to whatever the terminal background implies — on a light terminal, a completely
70
+ // different palette. A default that rearranges the user's screen is not a default.
71
+ // `auto` stays in the list for anyone who wants to follow the terminal.
72
+ //
73
+ // The accepted values come from `THEME_NAMES` rather than being written out here: a
74
+ // hardcoded list meant adding a theme needed a second edit in a different file, and
75
+ // forgetting it left /settings offering fewer themes than /theme did.
76
+ { key: 'theme', ...en('dark', THEME_NAMES),
77
+ ui: { tab: 'appearance', group: 'Display', label: 'Theme', hint: 'Colour scheme. `auto` follows the terminal background' } },
78
+ { key: 'calm_mode', ...bool(false),
79
+ ui: { tab: 'appearance', group: 'Display', label: 'Calm mode', hint: 'Fewer decorative flourishes; keeps the information, drops the animation' } },
80
+ { key: 'reduced_motion', ...bool(false),
81
+ ui: { tab: 'appearance', group: 'Motion', label: 'Reduced motion', hint: 'No sweeps or pulses — for a terminal over a slow link, or a preference' } },
82
+ { key: 'shimmer_edge', ...bool(true),
83
+ ui: { tab: 'appearance', group: 'Motion', label: 'Shimmer edge', hint: 'The travelling highlight on a running tool name' } },
84
+ { key: 'show_tool_details', ...bool(true),
85
+ ui: { tab: 'appearance', group: 'Transcript', label: 'Tool details', hint: 'Show arguments and output previews on tool rows' } },
86
+ { key: 'save_history', ...bool(true),
87
+ ui: { tab: 'appearance', group: 'Transcript', label: 'Save display transcript', hint: 'Keep notices, warnings, plan cards and reasoning in the saved session' } },
88
+ { key: 'save_thinking', ...bool(true),
89
+ ui: { tab: 'appearance', group: 'Transcript', label: 'Save reasoning', hint: 'Persist thinking blocks, not only the visible answer' } },
90
+ { key: 'cost_currency', ...en('usd', ['usd', 'cny']),
91
+ ui: { tab: 'appearance', group: 'Display', label: 'Cost currency', hint: 'Which unit /cost reports in' } },
92
+ { key: 'cny_per_usd', ...int(0, 0, 100000),
93
+ ui: { tab: 'appearance', group: 'Display', label: 'CNY per USD', hint: 'Exchange rate used when reporting cost in CNY; 0 means fetch it' } },
94
+
95
+ // ---- prompt -------------------------------------------------------------
96
+ // A text setting rather than a command: the system prompt is the largest thing a user
97
+ // can meaningfully tune, and it needed an editor, not a one-line argument.
98
+ { key: 'system_prompt', ...text(''),
99
+ ui: { tab: 'prompt', group: 'System', label: 'System prompt', hint: 'Replaces the built-in prompt entirely when set' } },
100
+ { key: 'append_system_prompt', ...text(''),
101
+ ui: { tab: 'prompt', group: 'System', label: 'Append to system prompt', hint: 'Added after the built-in prompt — the safer of the two' } },
102
+ { key: 'plan_instructions', ...text(''),
103
+ ui: { tab: 'prompt', group: 'Modes', label: 'Plan mode instructions', hint: 'Extra guidance while Plan mode is on' } },
104
+ { key: 'focus_instructions', ...text(''),
105
+ ui: { tab: 'prompt', group: 'Modes', label: 'Focus mode instructions', hint: 'Extra guidance while Focus mode is on' } },
106
+ { key: 'swarm_instructions', ...text(''),
107
+ ui: { tab: 'prompt', group: 'Modes', label: 'Swarm mode instructions', hint: 'Extra guidance while swarm mode is on' } },
108
+
109
+ // ---- model --------------------------------------------------------------
110
+ { key: 'provider', ...str(''),
111
+ ui: { tab: 'model', group: 'Provider', label: 'Provider', hint: 'Which provider serves requests' } },
112
+ { key: 'model', ...str(''),
113
+ ui: { tab: 'model', group: 'Provider', label: 'Model', hint: 'provider/model, as written in config.toml' } },
114
+ { key: 'default_model', ...str(''),
115
+ ui: { tab: 'model', group: 'Provider', label: 'Default model', hint: 'Used when a session does not name one' } },
116
+ { key: 'secondary_model', ...str(''),
117
+ ui: { tab: 'model', group: 'Delegation', label: 'Secondary model', hint: 'For side tasks: titles, compaction summaries' } },
118
+ { key: 'subagent_model', ...str(''),
119
+ ui: { tab: 'model', group: 'Delegation', label: 'Subagent model', hint: 'What the Agent tool uses; empty means the main model' } },
120
+ { key: 'effort', ...en('off', ['off', 'on', 'low', 'medium', 'high', 'max']),
121
+ ui: { tab: 'model', group: 'Thinking', label: 'Thinking effort', hint: 'Only the levels the current model declares are offered' } },
122
+ { key: 'max_context_tokens', ...int(0, 0, 10000000),
123
+ ui: { tab: 'model', group: 'Thinking', label: 'Context window', hint: 'Override the model window when the catalog is wrong; 0 means use it' } },
124
+ { key: 'prompt_cache', ...bool(true),
125
+ ui: { tab: 'model', group: 'Provider', label: 'Prompt caching', hint: 'Ask the provider to cache the request prefix' } },
126
+
127
+ // ---- context ------------------------------------------------------------
128
+ { key: 'auto_compact', ...bool(true),
129
+ ui: { tab: 'context', group: 'Compaction', label: 'Auto-compact', hint: 'Summarize older history when the window fills' } },
130
+ { key: 'compact_threshold', ...int(85, 1, 99),
131
+ ui: { tab: 'context', group: 'Compaction', label: 'Compact at %', hint: 'Share of the window at which compaction fires' } },
132
+ { key: 'compact_keep_ratio', ...int(20, 1, 99),
133
+ ui: { tab: 'context', group: 'Compaction', label: 'Keep %', hint: 'Share of the CURRENT usage kept after compaction' } },
134
+ { key: 'auto_trim', ...bool(true),
135
+ ui: { tab: 'context', group: 'Tool results', label: 'Auto-trim', hint: 'Elide old tool output from the request' } },
136
+ { key: 'trim_threshold', ...int(50, 1, 99),
137
+ ui: { tab: 'context', group: 'Tool results', label: 'Trim at %', hint: 'Share of the WINDOW that triggers a trim' } },
138
+ { key: 'trim_keep_ratio', ...int(30, 1, 99),
139
+ ui: { tab: 'context', group: 'Tool results', label: 'Keep %', hint: 'Share of the TOOL-RESULT TEXT kept after a trim — a different scale from the trigger' } },
140
+ { key: 'tool_result_max_bytes', ...int(51200, 1024, 10485760),
141
+ ui: { tab: 'context', group: 'Tool results', label: 'Max result bytes', hint: 'Larger output is written to disk and replaced with a pointer' } },
142
+ { key: 'tool_result_preview_bytes', ...int(2048, 128, 1048576),
143
+ ui: { tab: 'context', group: 'Tool results', label: 'Preview bytes', hint: 'How much of a spilled result stays inline' } },
144
+
145
+ // ---- tools --------------------------------------------------------------
146
+ { key: 'tool_allow_external_paths', ...bool(false),
147
+ ui: { tab: 'tools', group: 'Files', label: 'Allow external paths', hint: 'Let file tools read and write outside the workspace' } },
148
+ { key: 'swarm_mode', ...bool(false),
149
+ ui: { tab: 'tools', group: 'Delegation', label: 'Swarm mode', hint: 'Expose AgentSwarm for fanning work across subagents' } },
150
+ { key: 'output_style', ...str(''),
151
+ ui: { tab: 'tools', group: 'Output', label: 'Output style', hint: 'Named style overriding how replies are written' } },
152
+ { key: 'auto_trim_aggressive', ...bool(false), ui: null },
153
+ { key: 'no_delegation', ...bool(false), ui: null },
154
+
155
+ // ---- advanced -----------------------------------------------------------
156
+ { key: 'auto_update', ...bool(true),
157
+ ui: { tab: 'advanced', group: 'Updates', label: 'Auto-update', hint: 'Check npm at startup and every 30 minutes' } },
158
+ { key: 'experiments', ...str(''),
159
+ ui: { tab: 'advanced', group: 'Features', label: 'Experiments', hint: 'Comma-separated feature flags to enable' } },
160
+ { key: 'telemetry', ...bool(false),
161
+ ui: { tab: 'advanced', group: 'Features', label: 'Telemetry', hint: 'Send anonymous usage counts' } },
162
+ { key: 'vim_mode', ...bool(false),
163
+ ui: { tab: 'advanced', group: 'Editor', label: 'Vim mode', hint: 'Modal keys in the composer' } },
164
+ { key: 'shell', ...str(''),
165
+ ui: { tab: 'advanced', group: 'Editor', label: 'Shell', hint: 'Program used by the Bash tool; empty means auto-detect' } },
166
+ { key: 'mcp_config_path', ...str(''),
167
+ ui: { tab: 'advanced', group: 'MCP', label: 'MCP config path', hint: 'Defaults to ~/.hncode/mcp.json' } },
168
+ { key: 'checkpoints_max', ...int(500, 1, 100000),
169
+ ui: { tab: 'advanced', group: 'Editor', label: 'Max checkpoints', hint: 'Per-session cap on file-history entries' } },
170
+ { key: 'log_level', ...en('warn', ['error', 'warn', 'info', 'debug']),
171
+ ui: { tab: 'advanced', group: 'Diagnostics', label: 'Log level', hint: 'How much reaches session-errors.log' } },
172
+ { key: 'probe_timeout_ms', ...int(20000, 1000, 600000),
173
+ ui: { tab: 'advanced', group: 'Diagnostics', label: 'Probe timeout (ms)', hint: 'How long /doctor --probe waits for a reply' } },
174
+
175
+ // ---- the startup defaults, which had no row anywhere --------------------
176
+ // These already worked from config.toml but were reachable only by hand-editing it: a
177
+ // user could change the model from /model but not the base URL those models are reached
178
+ // at, and the protocol had no surface at all.
179
+ { key: 'base_url', ...str(''),
180
+ ui: { tab: 'model', group: 'Provider', label: 'Base URL', hint: 'Endpoint used when a provider does not name one' } },
181
+ { key: 'protocol', ...en('', ['', 'openai', 'anthropic', 'responses']),
182
+ ui: { tab: 'model', group: 'Provider', label: 'Protocol', hint: 'Wire format; empty detects from the provider' } },
183
+ { key: 'max_context_size', ...int(0, 0, 10000000),
184
+ ui: { tab: 'model', group: 'Thinking', label: 'Max context size', hint: 'Startup default when the catalog has no window; 0 means fall back again' } },
185
+ { key: 'max_output_size', ...int(0, 0, 10000000),
186
+ ui: { tab: 'model', group: 'Thinking', label: 'Max output size', hint: 'Startup default for the reply length; 0 means fall back' } },
187
+ { key: 'reasoning', ...bool(false),
188
+ ui: { tab: 'model', group: 'Thinking', label: 'Reasoning by default', hint: 'Whether new sessions start with thinking on' } },
189
+ { key: 'plugins_dir', ...str(''),
190
+ ui: { tab: 'advanced', group: 'MCP', label: 'Plugins directory', hint: 'Where plugins load from; defaults to ~/.hncode/plugins' } },
191
+ { key: 'web_host', ...str('127.0.0.1'),
192
+ ui: { tab: 'advanced', group: 'Web', label: 'Web host', hint: 'Address the /web daemon binds to' } },
193
+ { key: 'web_port', ...int(8765, 1, 65535),
194
+ ui: { tab: 'advanced', group: 'Web', label: 'Web port', hint: 'Fixed port for the single global daemon' } },
195
+ ];
196
+
197
+ /** key -> definition, for the setter and for validation. */
198
+ export const SETTING_BY_KEY = new Map(SETTINGS_SCHEMA.map((d) => [d.key, d]));
199
+
200
+ /** The settings that get a row, grouped by tab, in schema order. */
201
+ export function settingsForTab(tab) {
202
+ return SETTINGS_SCHEMA.filter((d) => d.ui && d.ui.tab === tab);
203
+ }
204
+
205
+ /** Tabs that actually have at least one row, so the rail never shows an empty section. */
206
+ export function populatedTabs() {
207
+ return SETTINGS_TABS.filter((t) => settingsForTab(t.id).length > 0);
208
+ }
209
+
210
+ /** Every key `/set` accepts — including the ones with no row. */
211
+ export function settableKeys() {
212
+ return SETTINGS_SCHEMA.map((d) => d.key);
213
+ }
214
+
215
+ /**
216
+ * Coerce a typed value to what the setting stores, or report why it cannot.
217
+ *
218
+ * The single validation point: `/settings`, `/set` and the web's `setConfig` all come
219
+ * through here, so they cannot disagree about what a value means.
220
+ *
221
+ * @returns {{ok: true, value: string} | {ok: false, error: string}}
222
+ */
223
+ export function coerceSetting(key, input) {
224
+ const def = SETTING_BY_KEY.get(key);
225
+ if (!def) return { ok: false, error: `unknown setting: ${key}` };
226
+ const raw = String(input == null ? '' : input).trim();
227
+
228
+ if (def.kind === 'bool') {
229
+ const v = raw.toLowerCase();
230
+ if (['1', 'true', 'on', 'yes'].includes(v)) return { ok: true, value: 'true' };
231
+ if (['0', 'false', 'off', 'no', ''].includes(v)) return { ok: true, value: 'false' };
232
+ return { ok: false, error: `${key} is on/off; got ${JSON.stringify(input)}` };
233
+ }
234
+ if (def.kind === 'int') {
235
+ // Accept `85` and `85%` — the percentage settings read naturally with a sign, and
236
+ // the schema stores a plain number.
237
+ const n = Number(String(raw).replace(/%$/, ''));
238
+ if (!Number.isFinite(n) || !Number.isInteger(n)) return { ok: false, error: `${key} needs a whole number; got ${JSON.stringify(input)}` };
239
+ if (def.min != null && n < def.min) return { ok: false, error: `${key} must be at least ${def.min}` };
240
+ if (def.max != null && n > def.max) return { ok: false, error: `${key} must be at most ${def.max}` };
241
+ return { ok: true, value: String(n) };
242
+ }
243
+ if (def.kind === 'enum') {
244
+ if (def.values.includes(raw)) return { ok: true, value: raw };
245
+ // A case-insensitive hit is accepted; anything else is refused rather than coerced,
246
+ // because silently picking a near-miss is how a setting ends up not meaning what it
247
+ // says.
248
+ const lower = def.values.find((v) => v.toLowerCase() === raw.toLowerCase());
249
+ if (lower) return { ok: true, value: lower };
250
+ return { ok: false, error: `${key} must be one of ${def.values.join(', ')}; got ${JSON.stringify(input)}` };
251
+ }
252
+ // string / text: anything goes, including empty (which means "unset").
253
+ return { ok: true, value: raw };
254
+ }
255
+
256
+ /**
257
+ * The value in force: what is configured, else the schema default.
258
+ *
259
+ * `cfg.raw` holds what config.toml said. A value there wins even when it equals the
260
+ * default, so `/settings` can tell "set to X" from "unset, so X applies".
261
+ */
262
+ export function effectiveValue(cfg, key) {
263
+ const def = SETTING_BY_KEY.get(key);
264
+ const raw = cfg && cfg.raw ? cfg.raw[key] : undefined;
265
+ if (raw === undefined || raw === null || raw === '') return def ? def.default : '';
266
+ return String(raw);
267
+ }
268
+
269
+ /** True when the value came from config rather than from the schema default. */
270
+ export function isSet(cfg, key) {
271
+ const raw = cfg && cfg.raw ? cfg.raw[key] : undefined;
272
+ return raw !== undefined && raw !== null && raw !== '';
273
+ }
274
+
275
+ /** How a value reads on a row: `on`, `dark`, `85%`, or `(unset)`. */
276
+ export function displayValue(cfg, key) {
277
+ const def = SETTING_BY_KEY.get(key);
278
+ if (!def) return '';
279
+ const v = effectiveValue(cfg, key);
280
+ if (def.kind === 'bool') return v === 'true' ? 'on' : 'off';
281
+ // `text` is returned WHOLE. It used to be cut to forty characters here, which made the
282
+ // value useless to a caller that needed to measure or edit it. Abbreviating is the
283
+ // renderer's job — see `proseSummary`.
284
+ //
285
+ // An empty value reads as `(unset)` for EVERY kind, not only `string`: `protocol` is an
286
+ // enum whose default is the empty string, so its row rendered with a blank value column.
287
+ //
288
+ // Whitespace alone is unset too. `!v` only catches the empty string, so ' ' rendered as a
289
+ // blank cell — the same as a value that failed to render. Tested on the trimmed copy and the
290
+ // value returned RAW: a trailing newline is part of a prompt, and trimming the value itself
291
+ // would corrupt it.
292
+ if (!v || !String(v).trim()) return '(unset)';
293
+ return v;
294
+ }
295
+
296
+
297
+ /**
298
+ * Which settings differ from their default, newest-first by schema order.
299
+ * `/settings` marks these; it is how a user sees what they have actually changed.
300
+ */
301
+ export function changedSettings(cfg) {
302
+ return SETTINGS_SCHEMA.filter((d) => isSet(cfg, d.key)
303
+ && effectiveValue(cfg, d.key) !== d.default);
304
+ }
@@ -44,6 +44,31 @@
44
44
  * metacharacters — unquoted operator characters seen (for the read-only test)
45
45
  * redirects — unquoted redirect targets, e.g. ['/dev/null']
46
46
  */
47
+ /**
48
+ * Index of the `)` closing the `(` at `open`, or -1.
49
+ *
50
+ * Depth-counted so a nested `$(a $(b))` closes at the right place, and quote-aware so a `)`
51
+ * inside a string argument does not end the substitution early.
52
+ */
53
+ function matchingParen(s, open) {
54
+ let depth = 0;
55
+ for (let i = open; i < s.length; i++) {
56
+ const c = s[i];
57
+ if (c === '\\') { i++; continue; }
58
+ if (c === "'" || c === '"') {
59
+ const quote = c;
60
+ for (i++; i < s.length; i++) {
61
+ if (quote === '"' && s[i] === '\\') { i++; continue; }
62
+ if (s[i] === quote) break;
63
+ }
64
+ continue;
65
+ }
66
+ if (c === '(') depth++;
67
+ else if (c === ')') { depth--; if (depth === 0) return i; }
68
+ }
69
+ return -1;
70
+ }
71
+
47
72
  export function splitSegments(cmd) {
48
73
  const s = String(cmd == null ? '' : cmd);
49
74
  const segments = [];
@@ -91,11 +116,45 @@ export function splitSegments(cmd) {
91
116
  i += 1;
92
117
  continue;
93
118
  }
94
- if (c === '<' || c === '`') { metacharacters.add(c); i += 1; continue; }
119
+ if (c === '<') { metacharacters.add(c); i += 1; continue; }
120
+ // A backtick substitution is the same hazard as `$(…)`: it runs a command and splices the
121
+ // result into this one. Handled as a pair so the inner command is judged too.
122
+ if (c === '`') {
123
+ metacharacters.add(c);
124
+ const close = s.indexOf('`', i + 1);
125
+ if (close > 0) {
126
+ const inner = s.slice(i + 1, close);
127
+ if (inner.trim()) { segments.push(current); segments.push(inner); current = ''; }
128
+ current += s.slice(i, close + 1);
129
+ i = close + 1;
130
+ continue;
131
+ }
132
+ i += 1; continue;
133
+ }
95
134
  if (c === '$') {
96
- // `$(…)` / `${…}` can run a command or expand a path. Record the substitution
97
- // so the read-only test rejects the command, but keep the text for tokenizing.
98
- if (s[i + 1] === '(') metacharacters.add('$');
135
+ // `$(…)` runs a command and its OUTPUT is spliced into this one. The text has to stay
136
+ // in the segment for tokenizing, but the substitution is also a command in its own
137
+ // right — so it is emitted as a segment of its own. Without that, `npm test $(rm -rf /)`
138
+ // was a single segment that the `Bash(npm test *)` prefix rule matched, and
139
+ // isDestructiveCommand never saw `rm` at all.
140
+ if (s[i + 1] === '(') {
141
+ metacharacters.add('$');
142
+ const close = matchingParen(s, i + 1);
143
+ if (close > 0) {
144
+ const inner = s.slice(i + 2, close);
145
+ if (inner.trim()) {
146
+ // The outer text so far, then the substitution itself as its own command.
147
+ segments.push(current);
148
+ segments.push(inner);
149
+ current = '';
150
+ }
151
+ // The outer command continues with the substitution text in place, so what follows
152
+ // it stays attached to the same command line.
153
+ current += s.slice(i, close + 1);
154
+ i = close + 1;
155
+ continue;
156
+ }
157
+ }
99
158
  current += c; i += 1; continue;
100
159
  }
101
160
  if (c === '(' || c === ')') { metacharacters.add(c); current += c; i += 1; continue; }
@@ -296,6 +355,32 @@ function rmIsDestructive(args) {
296
355
  /** `mkfs`, `mkfs.ext4`, `mkfs.xfs` — the whole family. */
297
356
  function isMkfs(cmd) { return cmd === 'mkfs' || cmd.startsWith('mkfs.'); }
298
357
 
358
+ /**
359
+ * The git SUBCOMMAND from an argv tail.
360
+ *
361
+ * The obvious `args.find(a => !a.startsWith('-'))` returns the first non-flag WORD, which
362
+ * for `git -C /repo push` is `/repo` — the VALUE of `-C`. Every subcommand rule then missed
363
+ * and `git -C x push`, `git -c core.pager=x reset --hard`, `git -c foo=bar clean` all
364
+ * passed the destructive check. Three flag shapes have to be stepped over:
365
+ *
366
+ * -C <path> / -c <name=value> a separate value in the NEXT word
367
+ * --git-dir=<path> the value is inside the SAME word
368
+ * -p / --paginate no value at all
369
+ *
370
+ * Only the flags git treats as value-taking are listed, so a bare `-x` is still skipped as
371
+ * a plain flag and cannot swallow the subcommand beside it.
372
+ */
373
+ function gitSubcommand(args) {
374
+ const TAKES_VALUE = new Set(['-C', '-c', '--git-dir', '--work-tree', '--namespace', '--exec-path']);
375
+ for (let i = 0; i < args.length; i++) {
376
+ const a = args[i];
377
+ if (!a.startsWith('-')) return a.toLowerCase();
378
+ // `--git-dir=/x` carries its own value, so the next word is the subcommand.
379
+ if (a.includes('=')) continue;
380
+ if (TAKES_VALUE.has(a)) i++; // step over the value
381
+ }
382
+ return '';
383
+ }
299
384
  // Judge ONE already-unwrapped command. Returns a reason string, or null when the
300
385
  // command is not destructive.
301
386
  function judgeUnwrapped(cmd, args) {
@@ -352,7 +437,7 @@ function judgeUnwrapped(cmd, args) {
352
437
  if (args.includes('prune')) return 'docker prune';
353
438
  break;
354
439
  case 'git': {
355
- const sub = (args.find((a) => !a.startsWith('-')) || '').toLowerCase();
440
+ const sub = gitSubcommand(args);
356
441
  // A dry run changes nothing, and flagging it is what made `git push
357
442
  // --dry-run` prompt in YOLO mode.
358
443
  if (args.some((a) => a === '-n' || a === '--dry-run')) break;
@@ -454,7 +539,11 @@ export function isReadOnlyCommand(cmd) {
454
539
  if (!u.cmd) return false; // a bare `sh -c "…"` is not provable
455
540
  if (!READ_ONLY_BINS.has(u.cmd)) return false;
456
541
  if (u.cmd === 'git') {
457
- const sub = (u.args.find((a) => !a.startsWith('-')) || '').toLowerCase();
542
+ // The shared extractor, not `args.find(a => !a.startsWith('-'))`: that form returns the
543
+ // VALUE of `-C`/`-c` (`git -C /repo status` yielded `/repo`), so the verb was never read
544
+ // and an ordinary read-only git command was refused. The destructive check learned this
545
+ // first; the two judged the same command differently until they shared one function.
546
+ const sub = gitSubcommand(u.args);
458
547
  if (!sub || !GIT_READ_SUBCMDS.has(sub)) return false;
459
548
  // `tag`/`branch`/`remote`/`config`/`worktree` are read-only only WITHOUT a
460
549
  // writing flag, so any of those flags refuses the whole command.