@hncode/hncode 0.4.0 → 0.4.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/src/remote.js ADDED
@@ -0,0 +1,271 @@
1
+ // Remote skills and plugins — pull them from the hncode GitHub repo.
2
+ //
3
+ // Both live in the repo under a folder per item:
4
+ //
5
+ // skills/<name>/SKILL.md -> ~/.hncode/skills/<name>.md
6
+ // plugins/<name>/… -> ~/.hncode/plugins/…
7
+ //
8
+ // The layout differs on purpose. A SKILL is a single Markdown prompt fragment, so it
9
+ // is FLATTENED to `<name>.md`: that is what `skills.js` already reads (and what makes
10
+ // `/skill:<name>` work without teaching the loader about directories). A PLUGIN is an
11
+ // ESM module that may sit beside helper files, so it is copied as a directory or, when
12
+ // it is a single file, as that file — see installPlugin.
13
+ //
14
+ // Discovery uses the GitHub Contents API and downloads use raw.githubusercontent.com.
15
+ // No git, no dependencies: a user who has never installed git can still pull a skill,
16
+ // and fetching one item does not drag down the whole repository.
17
+ //
18
+ // Everything network-facing is best-effort and reports failure as a value rather than
19
+ // throwing, so a command can print what went wrong instead of killing the TUI.
20
+
21
+ import fs from 'node:fs';
22
+ import os from 'node:os';
23
+ import path from 'node:path';
24
+ import { skillsDir, normalizeSkillName } from './skills.js';
25
+
26
+ // The canonical source. Overridable so a fork (or a test) can point elsewhere.
27
+ export const DEFAULT_REPO = process.env.HNCODE_REPO || 'NiceHello666/hncode';
28
+ export const DEFAULT_REF = process.env.HNCODE_REPO_REF || 'main';
29
+
30
+ // Where remote plugins land. `plugin.js` loads *.js/*.mjs from this directory.
31
+ export function pluginsDir() {
32
+ return process.env.HNCODE_PLUGINS || path.join(os.homedir(), '.hncode', 'plugins');
33
+ }
34
+
35
+ // Plugin files that are never worth downloading or dangerous to copy blindly.
36
+ const PLUGIN_EXT = new Set(['.js', '.mjs', '.cjs', '.json', '.md']);
37
+
38
+ const USER_AGENT = 'hncode-remote';
39
+
40
+ async function ghJson(url, { timeoutMs = 15000 } = {}) {
41
+ const ctl = new AbortController();
42
+ const timer = setTimeout(() => ctl.abort(), timeoutMs);
43
+ try {
44
+ const res = await fetch(url, {
45
+ signal: ctl.signal,
46
+ headers: { accept: 'application/vnd.github+json', 'user-agent': USER_AGENT },
47
+ });
48
+ if (!res.ok) {
49
+ // 404 is the normal "the folder does not exist yet" case, so it gets its own
50
+ // wording: the repo simply has not had anything contributed to that folder.
51
+ if (res.status === 404) return { ok: false, error: 'not-found' };
52
+ if (res.status === 403) return { ok: false, error: 'rate-limited' };
53
+ return { ok: false, error: `HTTP ${res.status}` };
54
+ }
55
+ return { ok: true, data: await res.json() };
56
+ } catch (e) {
57
+ return { ok: false, error: e && e.name === 'AbortError' ? 'timed out' : String((e && e.message) || e) };
58
+ } finally {
59
+ clearTimeout(timer);
60
+ }
61
+ }
62
+
63
+ async function fetchText(url, { timeoutMs = 20000 } = {}) {
64
+ const ctl = new AbortController();
65
+ const timer = setTimeout(() => ctl.abort(), timeoutMs);
66
+ try {
67
+ const res = await fetch(url, { signal: ctl.signal, headers: { 'user-agent': USER_AGENT } });
68
+ if (!res.ok) return { ok: false, error: `HTTP ${res.status}` };
69
+ return { ok: true, text: await res.text() };
70
+ } catch (e) {
71
+ return { ok: false, error: e && e.name === 'AbortError' ? 'timed out' : String((e && e.message) || e) };
72
+ } finally {
73
+ clearTimeout(timer);
74
+ }
75
+ }
76
+
77
+ function apiContents(repo, ref, dir) {
78
+ return `http://127.0.0.1:62578/api/repos/${repo}/contents/${dir}${ref ? `?ref=${encodeURIComponent(ref)}` : ''}`;
79
+ }
80
+
81
+ function rawUrl(repo, ref, file) {
82
+ return `http://127.0.0.1:62578/raw/${repo}/${ref}/${file}`;
83
+ }
84
+
85
+ // Each item is a DIRECTORY in the repo (skills/<name>/, plugins/<name>/). A loose file
86
+ // at the top level is ignored: the convention is the contract, and accepting both
87
+ // would make the listing ambiguous about what a name refers to.
88
+ function dirsOnly(entries) {
89
+ return (Array.isArray(entries) ? entries : [])
90
+ .filter((e) => e && e.type === 'dir' && e.name && !e.name.startsWith('.'))
91
+ .map((e) => ({ name: e.name, path: e.path }))
92
+ .sort((a, b) => a.name.localeCompare(b.name));
93
+ }
94
+
95
+ /** List the skills available in the repo: [{ name, path }]. */
96
+ export async function listRemoteSkills({ repo = DEFAULT_REPO, ref = DEFAULT_REF } = {}) {
97
+ const r = await ghJson(apiContents(repo, ref, 'skills'));
98
+ if (!r.ok) return r;
99
+ return { ok: true, items: dirsOnly(r.data) };
100
+ }
101
+
102
+ /** List the plugins available in the repo: [{ name, path }]. */
103
+ export async function listRemotePlugins({ repo = DEFAULT_REPO, ref = DEFAULT_REF } = {}) {
104
+ const r = await ghJson(apiContents(repo, ref, 'plugins'));
105
+ if (!r.ok) return r;
106
+ return { ok: true, items: dirsOnly(r.data) };
107
+ }
108
+
109
+ // A skill's description comes from its front-matter (the same field `skills.js` reads
110
+ // for a local skill), so the local and remote listings describe a skill identically.
111
+ function describeSkill(text) {
112
+ const m = /^\uFEFF?---\r?\n([\s\S]*?)\r?\n---/.exec(String(text || ''));
113
+ if (!m) return '';
114
+ for (const line of m[1].split(/\r?\n/)) {
115
+ const kv = /^\s*(?:description|title)\s*:\s*(.*?)\s*$/.exec(line);
116
+ if (kv) {
117
+ let v = kv[1];
118
+ if ((v.startsWith('"') && v.endsWith('"')) || (v.startsWith("'") && v.endsWith("'"))) v = v.slice(1, -1);
119
+ return v;
120
+ }
121
+ }
122
+ return '';
123
+ }
124
+
125
+ /**
126
+ * Fetch the SKILL.md inside each remote skill directory, so the list can show what a
127
+ * skill is before the user installs it. One request per skill; failures leave the
128
+ * description empty rather than dropping the entry.
129
+ */
130
+ export async function listRemoteSkillsDetailed({ repo = DEFAULT_REPO, ref = DEFAULT_REF } = {}) {
131
+ const listed = await listRemoteSkills({ repo, ref });
132
+ if (!listed.ok) return listed;
133
+ const items = await Promise.all(listed.items.map(async (it) => {
134
+ const file = `${it.path}/SKILL.md`;
135
+ const r = await fetchText(rawUrl(repo, ref, file));
136
+ return { ...it, file, description: r.ok ? describeSkill(r.text) : '', readable: r.ok };
137
+ }));
138
+ return { ok: true, items };
139
+ }
140
+
141
+ /**
142
+ * Install one remote skill as `~/.hncode/skills/<name>.md`.
143
+ *
144
+ * The repo requires the file to be named SKILL.md; a directory without one is skipped
145
+ * (returns ok:false) instead of creating a nameless entry. Returns
146
+ * { ok, name, path, replaced } or { ok: false, error }.
147
+ */
148
+ export async function installRemoteSkill(name, { repo = DEFAULT_REPO, ref = DEFAULT_REF, overwrite = true } = {}) {
149
+ const clean = normalizeSkillName(name);
150
+ // A name is a path SEGMENT in the repo URL. Reject anything that could escape it or
151
+ // name a hidden directory, before the request is even made — `.hidden` used to slip
152
+ // through and only fail later as a confusing "no SKILL.md" 404.
153
+ if (!clean || /[\\/]/.test(clean) || clean.startsWith('.') || /[\u0000-\u001f]/.test(clean)) {
154
+ return { ok: false, error: `Invalid skill name: ${name}` };
155
+ }
156
+
157
+ const file = `skills/${clean}/SKILL.md`;
158
+ const r = await fetchText(rawUrl(repo, ref, file));
159
+ if (!r.ok) {
160
+ return { ok: false, error: r.error === 'HTTP 404'
161
+ ? `No SKILL.md at ${file} (each skill directory must contain SKILL.md).`
162
+ : `Could not download ${file}: ${r.error}` };
163
+ }
164
+ if (!r.text.trim()) return { ok: false, error: `${file} is empty.` };
165
+
166
+ const dir = skillsDir();
167
+ const dest = path.join(dir, `${clean}.md`);
168
+ const replaced = fs.existsSync(dest);
169
+ if (replaced && !overwrite) return { ok: false, error: 'exists', replaced: true, path: dest };
170
+ try {
171
+ fs.mkdirSync(dir, { recursive: true });
172
+ fs.writeFileSync(dest, r.text, 'utf8');
173
+ } catch (e) {
174
+ return { ok: false, error: `Cannot write ${dest}: ${e.message}` };
175
+ }
176
+ return { ok: true, name: clean, path: dest, replaced, description: describeSkill(r.text) };
177
+ }
178
+
179
+ /** Remove an installed skill by name (thin alias so the command reads uniformly). */
180
+ export function removeLocalSkill(name) {
181
+ const clean = normalizeSkillName(name);
182
+ if (!clean) return false;
183
+ try { fs.unlinkSync(path.join(skillsDir(), `${clean}.md`)); return true; } catch { return false; }
184
+ }
185
+
186
+ // ---- plugins ---------------------------------------------------------------
187
+
188
+ // Flatten a Contents API tree into { path, type, size } for one plugin directory.
189
+ async function listDir(repo, ref, dir) {
190
+ const r = await ghJson(apiContents(repo, ref, dir));
191
+ if (!r.ok) return r;
192
+ return { ok: true, entries: Array.isArray(r.data) ? r.data : [] };
193
+ }
194
+
195
+ /**
196
+ * Install one remote plugin into `~/.hncode/plugins/`.
197
+ *
198
+ * Two shapes are accepted, because a plugin is either one file or a small package:
199
+ * plugins/<name>/index.js (+ helpers)
200
+ * plugins/<name>/<name>.js
201
+ * Sub-directories are copied recursively; only the extensions plugins can actually be
202
+ * loaded from are fetched, so a stray asset cannot land in the plugin directory.
203
+ */
204
+ export async function installRemotePlugin(name, { repo = DEFAULT_REPO, ref = DEFAULT_REF, overwrite = true } = {}) {
205
+ const clean = String(name == null ? '' : name).trim();
206
+ if (!clean || /[\\/]/.test(clean) || clean.startsWith('.')) {
207
+ return { ok: false, error: `Invalid plugin name: ${name}` };
208
+ }
209
+ const srcDir = `plugins/${clean}`;
210
+ const listed = await listDir(repo, ref, srcDir);
211
+ if (!listed.ok) {
212
+ return { ok: false, error: listed.error === 'not-found'
213
+ ? `No plugins/${clean} directory in the repo.`
214
+ : `Could not list ${srcDir}: ${listed.error}` };
215
+ }
216
+
217
+ const files = listed.entries.filter((e) => e.type === 'file'
218
+ && PLUGIN_EXT.has(path.extname(e.name).toLowerCase()));
219
+ if (!files.length) return { ok: false, error: `plugins/${clean} has no .js/.mjs/.json files.` };
220
+
221
+ const destDir = path.join(pluginsDir(), clean);
222
+ const dest = path.join(destDir, files[0].name);
223
+ const existed = fs.existsSync(destDir);
224
+ if (existed && !overwrite) return { ok: false, error: 'exists', replaced: true, path: destDir };
225
+
226
+ const written = [];
227
+ for (const f of files) {
228
+ const r = await fetchText(rawUrl(repo, ref, f.path));
229
+ if (!r.ok) return { ok: false, error: `Could not download ${f.path}: ${r.error}` };
230
+ const out = path.join(destDir, path.basename(f.path));
231
+ try {
232
+ fs.mkdirSync(path.dirname(out), { recursive: true });
233
+ fs.writeFileSync(out, r.text, 'utf8');
234
+ } catch (e) {
235
+ return { ok: false, error: `Cannot write ${out}: ${e.message}` };
236
+ }
237
+ written.push(path.basename(f.path));
238
+ }
239
+ return { ok: true, name: clean, path: destDir, files: written, replaced: existed };
240
+ }
241
+
242
+ /** Remove an installed plugin directory (or a single-file plugin) by name. */
243
+ export function removeLocalPlugin(name) {
244
+ const clean = String(name == null ? '' : name).trim();
245
+ if (!clean || /[\\/]/.test(clean)) return false;
246
+ const dir = path.join(pluginsDir(), clean);
247
+ if (fs.existsSync(dir)) { try { fs.rmSync(dir, { recursive: true, force: true }); return true; } catch { return false; } }
248
+ // A single-file plugin installs as <name>.js.
249
+ for (const ext of ['.js', '.mjs', '.cjs']) {
250
+ const f = path.join(pluginsDir(), clean + ext);
251
+ if (fs.existsSync(f)) { try { fs.unlinkSync(f); return true; } catch { return false; } }
252
+ }
253
+ return false;
254
+ }
255
+
256
+ /** Installed plugin names, read from the plugin directory. */
257
+ export function listLocalPlugins() {
258
+ let entries;
259
+ try { entries = fs.readdirSync(pluginsDir()); } catch { return []; }
260
+ const out = [];
261
+ for (const e of entries) {
262
+ if (e.startsWith('.')) continue;
263
+ const full = path.join(pluginsDir(), e);
264
+ let st;
265
+ try { st = fs.statSync(full); } catch { continue; }
266
+ if (st.isDirectory()) out.push({ name: e, kind: 'dir', path: full });
267
+ else if (/\.(mjs?|cjs)$/i.test(e)) out.push({ name: e.replace(/\.[^.]+$/, ''), kind: 'file', path: full });
268
+ }
269
+ out.sort((a, b) => a.name.localeCompare(b.name));
270
+ return out;
271
+ }
@@ -136,20 +136,47 @@ export function renderTasksBrowser(state, cols, rows) {
136
136
 
137
137
  const key = (s) => C.cyan + C.bold + s + C.reset;
138
138
  const dim = (s) => C.gray + s + C.reset;
139
+ // The footer doubles as a TOOLBAR: each `KEY label` pair is clickable, so the
140
+ // whole pair is recorded as one span. `cursor` tracks the column so the spans stay
141
+ // correct as the parts are concatenated by `join(' ')`.
142
+ const footerHits = [];
139
143
  let footer;
140
144
  if (state.pendingStop) {
141
- footer = fitExactly(' ' + C.orange + C.bold + 'Stop' + C.reset + ' ' + C.white + state.pendingStop + C.reset + '? '
142
- + key('Y') + ' ' + dim('confirm') + ' ' + key('N') + dim('/') + key('esc') + ' ' + dim('cancel') + ' ', cols);
145
+ // The confirm prompt is a toolbar too: Y / N / Esc each do something, so each
146
+ // gets a clickable span. `c` tracks the column so the spans land exactly on the
147
+ // rendered words — the original drew `N/esc cancel` as one run, so the two keys
148
+ // share the single "cancel" label and are measured as one span.
149
+ const prefix = ' ' + C.orange + C.bold + 'Stop' + C.reset + ' ' + C.white + state.pendingStop + C.reset + '? ';
150
+ let c = visWidth(prefix);
151
+ const yPart = key('Y') + ' ' + dim('confirm');
152
+ footerHits.push({ keyWord: 'Y', label: 'confirm', action: 'confirmStop', col0: c, col1: c + visWidth(yPart) - 1 });
153
+ c += visWidth(yPart) + 2;
154
+ const nPart = key('N') + dim('/') + key('esc') + ' ' + dim('cancel');
155
+ footerHits.push({ keyWord: 'N/esc', label: 'cancel', action: 'cancelStop', col0: c, col1: c + visWidth(nPart) - 1 });
156
+ footer = fitExactly(prefix + yPart + ' ' + nPart + ' ', cols);
143
157
  } else {
144
- const parts = [
145
- ' ' + key('\u2191\u2193') + ' ' + dim('select'),
146
- key('Enter/O') + ' ' + dim('output'),
147
- key('S') + ' ' + dim('stop'),
148
- key('R') + ' ' + dim('refresh'),
149
- key('Tab') + ' ' + dim('filter'),
150
- key('Q/Esc') + ' ' + dim('close') + ' ',
158
+ const specs = [
159
+ ['\u2191\u2193', 'select', 'select'],
160
+ ['Enter/O', 'output', 'openOutput'],
161
+ ['S', 'stop', 'requestStop'],
162
+ ['R', 'refresh', 'refresh'],
163
+ ['Tab', 'filter', 'toggleFilter'],
164
+ ['Q/Esc', 'close', 'close'],
151
165
  ];
152
- const left = parts.join(' ');
166
+ const parts = [];
167
+ let cursor = 1;
168
+ for (const [kw, label, action] of specs) {
169
+ const piece = key(kw) + ' ' + dim(label);
170
+ const w = visWidth(piece);
171
+ // `col1` is the LAST included column (hitboxes use an inclusive range, see
172
+ // hitAt), so a piece starting at `cursor` spanning `w` cells ends at
173
+ // `cursor + w - 1`. Using `cursor + w` made the span one column too wide, so a
174
+ // hover tinted the following space as well.
175
+ footerHits.push({ keyWord: kw, label, action, col0: cursor, col1: cursor + w - 1 });
176
+ cursor += w + 2; // the `parts.join(' ')` separator
177
+ parts.push(piece);
178
+ }
179
+ const left = ' ' + parts.join(' ');
153
180
  const flash = state.flash ? C.orange + ' ' + state.flash + ' ' + C.reset : '';
154
181
  const total = visWidth(left) + visWidth(flash);
155
182
  footer = total <= cols ? left + ' '.repeat(cols - total) + flash : fitExactly(left, cols);
@@ -168,7 +195,11 @@ export function renderTasksBrowser(state, cols, rows) {
168
195
  } else {
169
196
  listLines = visible.map((t, i) => {
170
197
  const selected = i === state.selectedIndex;
171
- const ptr = selected ? C.cyan + '\u276f ' + C.reset : ' ';
198
+ // Hover highlight, mirroring the rest of the TUI: the row under the pointer is
199
+ // tinted by the generic hover pass in tui.js (composeFrame), which needs a
200
+ // hitbox to aim at — those are recorded in state._taskHits below.
201
+ const hot = state.hoverIndex === i;
202
+ const ptr = selected ? C.cyan + '\u276f ' + C.reset : (hot ? C.hover + '\u276f ' + C.reset : ' ');
172
203
  const idCol = t.kind === 'agent' ? C.green : t.kind === 'question' ? C.orange : C.blue;
173
204
  const idText = selected ? idCol + C.bold + t.taskId + C.reset : idCol + t.taskId + C.reset;
174
205
  const pad = ' '.repeat(Math.max(0, 20 - visWidth(t.taskId)));
@@ -180,6 +211,18 @@ export function renderTasksBrowser(state, cols, rows) {
180
211
  return fitExactly(prefix + ' ' + C.white + truncateToWidth(desc, budget) + C.reset, listInnerW);
181
212
  });
182
213
  }
214
+ // Click targets. Coordinates are FRAME cells: line 0 is the header, line 1 is the
215
+ // list box's TOP RULE, so the first content row is line 2 — using `1 + i` put the
216
+ // hitboxes one row high, which highlighted the border instead of the task and made
217
+ // a click select the wrong row. Columns match the box interior (`│` + 1 cell of
218
+ // padding), and `col1` is the LAST included column.
219
+ state._taskHits = [];
220
+ for (let i = 0; i < Math.min(visible.length, listInnerH); i++) {
221
+ state._taskHits.push({ row: 2 + i, col0: 1, col1: listInnerW, kind: 'taskRow', index: i });
222
+ }
223
+ for (const h of footerHits) {
224
+ state._taskHits.push({ row: rows - 1, col0: h.col0, col1: h.col1, kind: 'taskFooter', action: h.action });
225
+ }
183
226
  while (listLines.length < listInnerH) listLines.push('');
184
227
  const listFrame = renderFrame('Tasks [' + state.filter + ']', listLines.slice(0, listInnerH), listW, bodyH);
185
228
 
package/src/tools/bash.js CHANGED
@@ -7,6 +7,7 @@ import fs from 'node:fs';
7
7
  import cp from 'node:child_process';
8
8
  import { resolvePath, truncateBuf } from './utils.js';
9
9
  import { createTask, appendTaskOutput, settleTask } from '../agent-task.js';
10
+ import { shellSpawnArgs } from './shell.js';
10
11
 
11
12
  // kimi-code's timeout policy (agent-core-v2/agent/tools/os/bash/bash.ts).
12
13
  export const DEFAULT_TIMEOUT_S = 60;
@@ -137,10 +138,19 @@ export const spec = {
137
138
  const cap = bg ? MAX_BACKGROUND_TIMEOUT_S : MAX_TIMEOUT_S;
138
139
  const def = bg ? DEFAULT_BACKGROUND_TIMEOUT_S : DEFAULT_TIMEOUT_S;
139
140
  const timeoutMs = args.disable_timeout ? undefined : Math.min(args.timeout ?? def, cap) * 1000;
140
- const command = args.command;
141
+ const command = args.command;
141
142
  // stdin is 'ignore' (NUL / /dev/null): leaving it as an open pipe made any
142
143
  // command that reads stdin (`cat`, `npm init`, …) block until the timeout.
143
- const child = cp.spawn('pwsh', ['-Command', command], { cwd, stdio: ['ignore', 'pipe', 'pipe'] });
144
+ //
145
+ // The shell is RESOLVED, not hardcoded: `spawn('pwsh', …)` failed outright on a
146
+ // box with only the built-in Windows PowerShell (see tools/shell.js). `env` is
147
+ // passed explicitly rather than relying on Node's implicit inheritance, which is
148
+ // what made the tool depend on pwsh having been added to PATH by hand.
149
+ let shell;
150
+ try { shell = shellSpawnArgs(command); } catch (e) { return `Error: ${e.message}`; }
151
+ const child = cp.spawn(shell.bin, shell.args, {
152
+ cwd, stdio: ['ignore', 'pipe', 'pipe'], env: { ...process.env },
153
+ });
144
154
 
145
155
  const chunks = [];
146
156
  let settled = false;
@@ -288,7 +298,11 @@ function spawnBackground(args, cwd, ctx) {
288
298
  // task's output file instead, so the process survives and TaskOutput can
289
299
  // still read it. (In the TUI the parent rarely exits, but headless -p mode
290
300
  // does, and a background job should outlive the prompt.)
291
- const child = cp.spawn('pwsh', ['-Command', command], { cwd, detached: true, stdio: ['ignore', 'pipe', 'pipe'] });
301
+ let shell;
302
+ try { shell = shellSpawnArgs(command); } catch (e) { return `Error: ${e.message}`; }
303
+ const child = cp.spawn(shell.bin, shell.args, {
304
+ cwd, detached: true, stdio: ['ignore', 'pipe', 'pipe'], env: { ...process.env },
305
+ });
292
306
  const task = createTask(ctx, 'process', {
293
307
  description: args.description || args.command,
294
308
  command: args.command,
package/src/tools/edit.js CHANGED
@@ -28,13 +28,13 @@ import { hashRegion as hashLines } from './read.js';
28
28
 
29
29
  export const spec = {
30
30
  name: 'Edit',
31
- description: 'Edit a file. THE TWO MODES ARE MUTUALLY EXCLUSIVE — NEVER combine them:\n - LINE-RANGE mode: path + start_line + end_line + new_string. Replaces lines [start_line, end_line] with new_string. PRESERVE the exact indentation (spaces/tabs) of the lines you are replacing — copy it straight from the Read output, after its line-number prefix. PREFERRED — use it whenever you have Read the file and know the line numbers.\n - EXACT-SUBSTRING mode: path + old_string + new_string. Replaces text exactly matching old_string. Use ONLY when the exact text is known and a line range is not convenient.\nDo NOT pass start_line/end_line together with old_string/new_string in one call, and do NOT pass any of these four keys when you are not using that mode. Omitting new_string is an error, not an empty replacement (in line-range mode pass new_string; in substring mode pass old_string + new_string).',
31
+ description: 'Edit a file. THE TWO MODES ARE MUTUALLY EXCLUSIVE — NEVER combine them:\n - LINE-RANGE mode: path + start_line + end_line + new_string. Replaces lines [start_line, end_line] with new_string. new_string is written VERBATIM: nothing is re-indented or auto-indented, so EVERY line you send — including the FIRST one — must already carry the exact leading spaces/tabs it should have. Copy that whitespace straight from the Read output, after its `N\\t` line-number prefix (Read prints `12\\t return x`, so you send ` return x`). PREFERRED — use it whenever you have Read the file and know the line numbers.\n - EXACT-SUBSTRING mode: path + old_string + new_string. Replaces text exactly matching old_string. Use ONLY when the exact text is known and a line range is not convenient.\nDo NOT pass start_line/end_line together with old_string/new_string in one call, and do NOT pass any of these four keys when you are not using that mode. Omitting new_string is an error, not an empty replacement (in line-range mode pass new_string; in substring mode pass old_string + new_string).',
32
32
  parameters: {
33
33
  type: 'object',
34
34
  properties: {
35
35
  path: { type: 'string', description: 'Path to the file to edit.' },
36
36
  old_string: { type: 'string', description: 'Exact text to replace, including whitespace and newlines. Omit when using start_line/end_line.' },
37
- new_string: { type: 'string', description: 'Replacement text. In SUBSTRING mode it replaces old_string. In LINE-RANGE mode it replaces lines [start_line, end_line]. In line-range mode PRESERVE the exact leading whitespace (indentation) from Read output; copy it exactly as it appears after the line-number prefix.' },
37
+ new_string: { type: 'string', description: 'Replacement text, written VERBATIM. In SUBSTRING mode it replaces old_string. In LINE-RANGE mode it replaces lines [start_line, end_line], and its leading whitespace is used exactly as sent — no indentation is added, and the FIRST line needs its own. Copy it from the Read output after the `N\\t` line-number prefix (Read prints `12\\t return x`, so you send ` return x`).' },
38
38
  replace_all: { type: 'boolean', default: false, description: 'Replace all occurrences of old_string.' },
39
39
  start_line: { type: 'integer', minimum: 1, description: 'PREFERRED MODE. 1-based first line to replace (requires end_line and new_string). Use this line-range mode first.' },
40
40
  end_line: { type: 'integer', minimum: 1, description: 'PREFERRED MODE. 1-based last line to replace (inclusive; requires start_line).' },
@@ -0,0 +1,106 @@
1
+ // Shell discovery for the Bash tool.
2
+ //
3
+ // The tool used to hardcode `spawn('pwsh', …)`, which relies on `pwsh` being on
4
+ // PATH. On a Windows box with only the built-in Windows PowerShell that name does
5
+ // not resolve at all, so the spawn failed with ENOENT and the whole tool was dead —
6
+ // no fallback, and nothing in the message said why. It only ever worked where the
7
+ // user had installed PowerShell 7 and added it to PATH by hand.
8
+ //
9
+ // Mirrors Claude Code's findPowerShell (src/utils/shell/powershellDetection.ts):
10
+ // prefer pwsh (Core 7+), fall back to powershell (Desktop 5.1), and cache the
11
+ // answer — the probe shells out, so it must not run per command.
12
+ //
13
+ // The EDITION matters beyond picking a binary: 7+ supports the pipeline chain
14
+ // operators (`&&`, `||`) and 5.1 does not, so the prompt's syntax guidance depends
15
+ // on which one was found.
16
+
17
+ import cp from 'node:child_process';
18
+
19
+ // The two PowerShell families, by the name they are invoked as.
20
+ export const SHELL_CANDIDATES = ['pwsh', 'powershell'];
21
+
22
+ /**
23
+ * Resolve an executable name through PATH without spawning it.
24
+ *
25
+ * `where` on Windows / `which` on POSIX are the platform's own resolvers, so the
26
+ * answer matches what a shell would do. Returns the first absolute path or null.
27
+ */
28
+ function whichSync(name) {
29
+ const probe = process.platform === 'win32' ? 'where' : 'which';
30
+ try {
31
+ const out = cp.execFileSync(probe, [name], {
32
+ stdio: ['ignore', 'pipe', 'ignore'],
33
+ encoding: 'utf8',
34
+ windowsHide: true,
35
+ });
36
+ // `where` can print several matches; take the first non-empty line.
37
+ const first = String(out).split(/\r?\n/).map((l) => l.trim()).filter(Boolean)[0];
38
+ return first || null;
39
+ } catch {
40
+ return null;
41
+ }
42
+ }
43
+
44
+ /**
45
+ * Find a usable PowerShell: pwsh first, then the built-in powershell.
46
+ * Returns { path, name, edition } or null when neither exists.
47
+ *
48
+ * `edition` drives syntax guidance — see the header. It is inferred from the NAME
49
+ * rather than by running the binary (a spawn per probe is not worth it, and
50
+ * PowerShell 6, also named `pwsh`, is EOL and not a realistic target, so `pwsh`
51
+ * safely implies 7+).
52
+ */
53
+ export function detectShell() {
54
+ for (const name of SHELL_CANDIDATES) {
55
+ const path = whichSync(name);
56
+ if (path) {
57
+ return { path, name, edition: name === 'pwsh' ? 'core' : 'desktop' };
58
+ }
59
+ }
60
+ return null;
61
+ }
62
+
63
+ let cached; // undefined = not probed yet; null = probed, none found
64
+ let cachedEnvKey; // re-probe if PATH changes (a /env PATH=… must take effect)
65
+
66
+ /**
67
+ * Cached detectShell(). The probe spawns a process, so it runs once per PATH.
68
+ * Re-probing when PATH changes means an in-session PATH fix (`/env PATH=…`, which
69
+ * is exactly what a user with a stripped PATH would do) can rescue the tool.
70
+ */
71
+ export function getShell() {
72
+ const key = String(process.env.PATH || '');
73
+ if (cached === undefined || cachedEnvKey !== key) {
74
+ cached = detectShell();
75
+ cachedEnvKey = key;
76
+ }
77
+ return cached;
78
+ }
79
+
80
+ /** Test seam: forget the cached probe. */
81
+ export function resetShellCache() {
82
+ cached = undefined;
83
+ cachedEnvKey = undefined;
84
+ }
85
+
86
+ /**
87
+ * The command + args to run `command` through the detected shell.
88
+ *
89
+ * `-NoProfile` and `-NonInteractive` are not cosmetic: a user profile can print a
90
+ * banner into stdout (polluting the captured result) and can take seconds to load
91
+ * on every single tool call, and `-NonInteractive` stops a command that prompts
92
+ * from hanging the tool forever with no way to answer.
93
+ *
94
+ * Throws a message naming the fix when no PowerShell is installed, instead of
95
+ * letting the caller hit a bare ENOENT.
96
+ */
97
+ export function shellSpawnArgs(command) {
98
+ const shell = getShell();
99
+ if (!shell) {
100
+ throw new Error(
101
+ 'No PowerShell found on PATH (tried pwsh, powershell). '
102
+ + 'Install PowerShell 7+ or add it to PATH, then retry.',
103
+ );
104
+ }
105
+ return { bin: shell.path, args: ['-NoProfile', '-NonInteractive', '-Command', command], shell };
106
+ }