@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.
- package/bin/install.cjs +24 -44
- package/bin/preinstall.cjs +0 -14
- package/package.json +1 -1
- package/src/agent-task.js +12 -1
- package/src/agent.js +115 -35
- package/src/cell-buffer.js +5 -0
- package/src/clipboard.js +42 -6
- package/src/colors.js +478 -387
- package/src/config.js +38 -11
- package/src/context-bar.js +46 -5
- package/src/doctor.js +214 -11
- package/src/git.js +35 -4
- package/src/llm.js +6 -0
- package/src/memory-profile.js +247 -0
- package/src/permissions.js +29 -7
- package/src/registry-browser.js +21 -6
- package/src/row-cache.js +147 -142
- package/src/session-core.js +203 -0
- package/src/session-host.js +213 -0
- package/src/session-hub.js +1 -0
- package/src/session-view.js +110 -0
- package/src/settings-schema.js +304 -0
- package/src/shell-safety.js +95 -6
- package/src/swarm-progress.js +348 -293
- package/src/tasks-browser.js +4 -1
- package/src/team/constants.js +20 -0
- package/src/team/context.js +89 -0
- package/src/team/mailbox.js +139 -0
- package/src/team/runner.js +347 -0
- package/src/team/spawn.js +247 -0
- package/src/team/store.js +145 -0
- package/src/team/tasks.js +164 -0
- package/src/team/team.js +311 -0
- package/src/team-panel.js +226 -0
- package/src/term.js +27 -0
- package/src/tool-result.js +279 -236
- package/src/tools/agent-swarm.js +114 -11
- package/src/tools/agent.js +53 -3
- package/src/tools/edit.js +591 -436
- package/src/tools/git.js +15 -3
- package/src/tools/index.js +2 -0
- package/src/tools/team.js +398 -0
- package/src/tools/web-search.js +88 -75
- package/src/tui.js +6187 -4780
- package/src/update.js +285 -115
- package/src/web-daemon.js +27 -4
- package/src/web-live.js +250 -0
- 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
|
+
}
|
package/src/shell-safety.js
CHANGED
|
@@ -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 === '<'
|
|
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
|
-
// `$(…)`
|
|
97
|
-
//
|
|
98
|
-
|
|
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
|
|
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
|
-
|
|
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.
|