@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/README.md +2 -1
- package/package.json +3 -3
- package/src/plugin.js +104 -9
- package/src/registry-browser.js +299 -0
- package/src/remote.js +271 -0
- package/src/tasks-browser.js +54 -11
- package/src/tools/bash.js +17 -3
- package/src/tools/edit.js +2 -2
- package/src/tools/shell.js +106 -0
- package/src/tui.js +12255 -11665
- package/src/web-public/app.css +22 -9
- package/src/web-public/app.js +100 -54
- package/src/web-public/home.css +3 -3
- package/src/web-public/login.css +1 -1
- /package/bin/{install.js → install.cjs} +0 -0
- /package/bin/{preinstall.js → preinstall.cjs} +0 -0
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
|
+
}
|
package/src/tasks-browser.js
CHANGED
|
@@ -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
|
-
|
|
142
|
-
|
|
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
|
|
145
|
-
'
|
|
146
|
-
|
|
147
|
-
|
|
148
|
-
|
|
149
|
-
|
|
150
|
-
|
|
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
|
|
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
|
-
|
|
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
|
-
|
|
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
|
-
|
|
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
|
-
|
|
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.
|
|
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]
|
|
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
|
+
}
|