@hncode/hncode 0.4.1 → 0.5.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (65) hide show
  1. package/README.md +3 -16
  2. package/README_CN.md +1 -15
  3. package/bin/hncode +22 -9
  4. package/package.json +3 -2
  5. package/src/agent.js +145 -33
  6. package/src/bootstrap.js +56 -0
  7. package/src/btw-panel.js +224 -0
  8. package/src/cache.js +3 -2
  9. package/src/cell-buffer.js +173 -0
  10. package/src/ci.js +4 -1
  11. package/src/clipboard.js +202 -43
  12. package/src/colors.js +175 -30
  13. package/src/config.js +376 -34
  14. package/src/context-bar.js +237 -0
  15. package/src/doctor.js +320 -0
  16. package/src/experiments.js +122 -0
  17. package/src/file-history.js +112 -0
  18. package/src/file-tree.js +255 -0
  19. package/src/flush-tick.js +56 -0
  20. package/src/fork.js +44 -0
  21. package/src/fx.js +148 -0
  22. package/src/index.js +123 -11
  23. package/src/keybindings.js +229 -0
  24. package/src/llm.js +355 -34
  25. package/src/lsp.js +401 -0
  26. package/src/notebook.js +232 -0
  27. package/src/output-styles.js +183 -0
  28. package/src/patch.js +248 -0
  29. package/src/permissions.js +130 -1
  30. package/src/plan.js +3 -2
  31. package/src/plugin.js +691 -43
  32. package/src/prompt-presets.js +1 -1
  33. package/src/registry-browser.js +317 -0
  34. package/src/remote.js +289 -0
  35. package/src/rollout.js +151 -0
  36. package/src/row-cache.js +143 -0
  37. package/src/schedule.js +294 -0
  38. package/src/session-approvals.js +86 -0
  39. package/src/session.js +178 -8
  40. package/src/shell-safety.js +473 -0
  41. package/src/side-thread.js +240 -0
  42. package/src/skills.js +27 -0
  43. package/src/stream-buffer.js +350 -0
  44. package/src/subagent-control.js +160 -0
  45. package/src/tasks-browser.js +54 -11
  46. package/src/teleport.js +260 -0
  47. package/src/term-caps.js +411 -0
  48. package/src/term.js +283 -8
  49. package/src/tools/agent-control.js +98 -0
  50. package/src/tools/agent-swarm.js +11 -2
  51. package/src/tools/agent.js +35 -1
  52. package/src/tools/bash.js +17 -3
  53. package/src/tools/diagnostics.js +175 -0
  54. package/src/tools/edit.js +25 -10
  55. package/src/tools/grep.js +62 -20
  56. package/src/tools/ignore.js +38 -14
  57. package/src/tools/index.js +10 -0
  58. package/src/tools/notebook.js +113 -0
  59. package/src/tools/read-media-file.js +10 -3
  60. package/src/tools/shell.js +134 -0
  61. package/src/tui.js +16682 -11665
  62. package/src/web-public/app.css +22 -9
  63. package/src/web-public/app.js +107 -54
  64. package/src/web-public/home.css +3 -3
  65. package/src/web-public/login.css +1 -1
@@ -61,7 +61,7 @@ const RULES = {
61
61
  ],
62
62
  // Only for models with no dedicated reasoning channel.
63
63
  tagFallback: [
64
- 'Put all internal reasoning inside <think>...</think> and close the block before your answer. Reasoning must never appear outside it.',
64
+ 'Put all internal reasoning inside <|thinking|>...<|/thinking|> and close the block before your answer. Reasoning must never appear outside it.',
65
65
  ],
66
66
  },
67
67
  scope: [
@@ -0,0 +1,317 @@
1
+ // RegistryBrowser — the full-screen /skills and /plugins manager.
2
+ //
3
+ // Modelled on kimi-code's PluginsPanelComponent (a tabbed list where every row has a
4
+ // DESCRIPTION under its label) and on this project's own /tasks browser: pure
5
+ // rendering plus key handling, returning ACTION STRINGS to the caller so all state
6
+ // stays in one place.
7
+ //
8
+ // ────────────────────────────────────────────────────────────────
9
+ // Skills
10
+ // Tab switch · ↑↓ select · Enter install/remove · Esc close
11
+ //
12
+ // [Installed (1)] Available (3)
13
+ //
14
+ // ❯ demo installed
15
+ // A demo skill
16
+ // review-pr install
17
+ // Review a pull request for bugs
18
+ // ────────────────────────────────────────────────────────────────
19
+ //
20
+ // The description line is the reason this exists instead of the plain list panel: a
21
+ // skill or plugin is meaningless without knowing what it does, and the picker's inline
22
+ // `sub` truncates on a narrow terminal.
23
+ //
24
+ // Tabs differ per kind, because the two halves mean different things:
25
+ // TABS_LOCAL = what is on disk here (installed skills / plugins on disk)
26
+ // TABS_REMOTE = what the repo offers, with an `installed` badge when present
27
+
28
+ import { C } from './colors.js';
29
+ import { visualWidth } from './term.js';
30
+
31
+ const ELLIPSIS = '\u2026';
32
+ const ESC = '\x1b';
33
+ const MIN_WIDTH = 44;
34
+ const MIN_HEIGHT = 8;
35
+
36
+ function visWidth(s) { return visualWidth(String(s).replace(/\x1b\[[0-9;?]*[a-zA-Z]/g, '')); }
37
+
38
+ function truncateToWidth(s, width) {
39
+ const str = String(s);
40
+ let out = '';
41
+ let n = 0;
42
+ let i = 0;
43
+ while (i < str.length) {
44
+ if (str[i] === ESC) {
45
+ const m = /^\x1b\[[0-9;?]*[a-zA-Z]/.exec(str.slice(i));
46
+ if (m) { out += m[0]; i += m[0].length; continue; }
47
+ }
48
+ const cp = str.codePointAt(i);
49
+ const ch = String.fromCodePoint(cp);
50
+ const cw = visWidth(ch);
51
+ if (n + cw > width - 1) return out + ELLIPSIS + ' '.repeat(Math.max(0, width - n - 1));
52
+ n += cw;
53
+ out += ch;
54
+ i += cp > 0xffff ? 2 : 1;
55
+ }
56
+ return out;
57
+ }
58
+
59
+ function fitExactly(line, width) {
60
+ const w = visWidth(line);
61
+ if (w === width) return line;
62
+ if (w > width) return truncateToWidth(line, width);
63
+ return line + ' '.repeat(width - w);
64
+ }
65
+
66
+ /** Plain-text wrap so a long description keeps its own indented lines. */
67
+ function wrapText(text, width) {
68
+ const words = String(text || '').trim().split(/\s+/).filter(Boolean);
69
+ const out = [];
70
+ let cur = '';
71
+ for (const w of words) {
72
+ const next = cur ? cur + ' ' + w : w;
73
+ if (visWidth(next) <= width) { cur = next; continue; }
74
+ if (cur) out.push(cur);
75
+ cur = visWidth(w) <= width ? w : w.slice(0, Math.max(1, width));
76
+ }
77
+ if (cur) out.push(cur);
78
+ return out.length ? out : [''];
79
+ }
80
+
81
+ // Tabs. `id` is what the key handler switches on; `label` is shown with the count.
82
+ export const SKILL_TABS = [
83
+ { id: 'installed', label: 'Installed' },
84
+ { id: 'available', label: 'Available' },
85
+ ];
86
+ export const PLUGIN_TABS = [
87
+ { id: 'loaded', label: 'Loaded' },
88
+ { id: 'ondisk', label: 'On disk' },
89
+ { id: 'available', label: 'Available' },
90
+ ];
91
+
92
+ /** The rows for one tab. Each row: { label, description, status, statusTone, name, action }. */
93
+ export function rowsForTab(state, tabId) {
94
+ const d = state.data || {};
95
+ if (state.kind === 'skills') {
96
+ if (tabId === 'installed') {
97
+ return (d.installed || []).map((s) => ({
98
+ name: s.name,
99
+ label: s.name,
100
+ description: s.description || '(no description)',
101
+ status: 'installed',
102
+ statusTone: 'dim',
103
+ action: 'remove-skill',
104
+ }));
105
+ }
106
+ const local = new Set((d.installed || []).map((s) => s.name));
107
+ return (d.remote || []).map((r) => ({
108
+ name: r.name,
109
+ label: r.name,
110
+ description: r.description || (r.readable === false ? '(no SKILL.md — cannot install)' : '(no description)'),
111
+ status: local.has(r.name) ? 'Installed' : 'Not Install',
112
+ statusTone: local.has(r.name) ? 'ok' : 'warn',
113
+ action: local.has(r.name) ? 'reinstall-skill' : 'install-skill',
114
+ disabled: r.readable === false,
115
+ }));
116
+ }
117
+ // plugins
118
+ if (tabId === 'loaded') {
119
+ return (d.loaded || []).map((p) => ({
120
+ name: p.name,
121
+ label: p.name,
122
+ description: `v${p.version || '0.0.0'} · ${p.id}`,
123
+ status: 'loaded',
124
+ statusTone: 'ok',
125
+ action: 'none',
126
+ }));
127
+ }
128
+ if (tabId === 'ondisk') {
129
+ const loadedNames = new Set((d.loaded || []).map((p) => p.name));
130
+ return (d.onDisk || []).map((p) => ({
131
+ name: p.name,
132
+ label: p.name + (p.kind === 'dir' ? '/' : ''),
133
+ description: loadedNames.has(p.name)
134
+ ? 'loaded in this session'
135
+ : 'not loaded — Need Restart',
136
+ status: loadedNames.has(p.name) ? 'loaded' : 'Need Restart',
137
+ statusTone: loadedNames.has(p.name) ? 'ok' : 'warn',
138
+ action: 'remove-plugin',
139
+ }));
140
+ }
141
+ const local = new Set((d.onDisk || []).map((p) => p.name));
142
+ return (d.remote || []).map((r) => ({
143
+ name: r.name,
144
+ label: r.name,
145
+ description: local.has(r.name) ? 'already installed locally' : 'available in the repository',
146
+ status: local.has(r.name) ? 'Installed' : 'Not Install',
147
+ statusTone: local.has(r.name) ? 'ok' : 'warn',
148
+ action: local.has(r.name) ? 'reinstall-plugin' : 'install-plugin',
149
+ }));
150
+ }
151
+
152
+ function statusColor(tone) {
153
+ if (tone === 'ok') return C.green;
154
+ if (tone === 'warn') return C.orange;
155
+ if (tone === 'dim') return C.gray;
156
+ return C.blue;
157
+ }
158
+
159
+ export function renderRegistryBrowser(state, cols, rows) {
160
+ if (cols < MIN_WIDTH || rows < MIN_HEIGHT) {
161
+ const lines = [fitExactly(C.red + 'Terminal too small (need >= ' + MIN_WIDTH + ' x ' + MIN_HEIGHT + ')' + C.reset, cols)];
162
+ for (let i = 1; i < rows; i++) lines.push(' '.repeat(cols));
163
+ return lines;
164
+ }
165
+ const title = state.kind === 'skills' ? ' Skills' : ' Plugins';
166
+ const tabs = state.kind === 'skills' ? SKILL_TABS : PLUGIN_TABS;
167
+ const tabIdx = Math.max(0, tabs.findIndex((t) => t.id === state.tab));
168
+ const tab = tabs[tabIdx] || tabs[0];
169
+ const all = rowsForTab(state, tab.id);
170
+ const sel = Math.max(0, Math.min(all.length - 1, state.selectedIndex || 0));
171
+
172
+ const out = [];
173
+ const rule = C.border + '\u2500'.repeat(cols) + C.reset;
174
+ // Mouse targets for this frame. The browser is pure render + key, so it records
175
+ // its own hitboxes here and the caller (tui.js) maps mouse events onto them —
176
+ // exactly like the tasks browser does with `_taskHits`. Row is a FRAME row
177
+ // (0 = the top rule); the caller converts the 1-based screen row.
178
+ const hits = [];
179
+ state._regHits = hits;
180
+ out.push(rule);
181
+ out.push(fitExactly(C.cyan + C.bold + title + C.reset, cols));
182
+ // The key hints stay visible while the fetch runs: the panel is usable during it
183
+ // (the local tabs are already loaded), so replacing the hints with a status message
184
+ // would hide the keys exactly when the user is most likely to press them.
185
+ const hint = ' Tab switch \u00b7 \u2191\u2193 select \u00b7 Enter install/remove \u00b7 Esc close';
186
+ out.push(fitExactly(C.gray + hint + (state.busy ? C.gray + ` ${state.busy}` : '') + C.reset, cols));
187
+ out.push('');
188
+
189
+ // Tab strip, with the count of each tab's rows.
190
+ {
191
+ const stripRow = out.length;
192
+ let col = 0;
193
+ const segs = tabs.map((t) => {
194
+ const n = rowsForTab(state, t.id).length;
195
+ const text = t.id === tab.id ? `[${t.label} (${n})]` : ` ${t.label} (${n}) `;
196
+ const col0 = col;
197
+ col += visWidth(text) + 1; // +1 for the joining space
198
+ return { id: t.id, text, col0, col1: col0 + visWidth(text) - 1 };
199
+ });
200
+ out.push(segs.map((s) => (s.id === tab.id
201
+ ? C.cyan + C.bold + s.text + C.reset
202
+ : C.gray + s.text + C.reset)).join(' '));
203
+ for (const s of segs) hits.push({ row: stripRow, col0: s.col0, col1: s.col1, kind: 'regTab', tab: s.id });
204
+ }
205
+ out.push('');
206
+
207
+ // Body: each row is a label line plus its wrapped description, so the footer must be
208
+ // sized against the ROWS, not the lines. A row is 2+ lines tall.
209
+ const listTop = out.length;
210
+ const bodyRows = Math.max(1, rows - listTop - 3); // leave room for the footer
211
+ if (!all.length) {
212
+ // The message must describe THIS tab. A failed remote fetch used to win over
213
+ // everything, so "Loaded" and "On disk" — which are read straight off the local
214
+ // filesystem and cannot fail from the network — reported "fetch failed" whenever
215
+ // the repo lookup did. That reads as "your local plugins are broken", which is
216
+ // both wrong and alarming. Only the Available tab can talk about the fetch.
217
+ const isRemoteTab = tab.id === 'available';
218
+ // "Loading" only applies to the tab that is actually waiting on the fetch; a local
219
+ // tab is empty because there is genuinely nothing there, and saying "Loading" for
220
+ // data that is already on screen reads as a stall.
221
+ const msg = (isRemoteTab && state.busy) ? ' Loading\u2026'
222
+ : (isRemoteTab && state.data && state.data.remoteError)
223
+ ? ' Could not fetch from the repo: ' + state.data.remoteError
224
+ : isRemoteTab ? ' Nothing published in the repo yet.'
225
+ : ' Nothing here.';
226
+ out.push(C.gray + msg + C.reset);
227
+ }
228
+ for (let i = 0; i < all.length; i++) {
229
+ const r = all[i];
230
+ const isSel = i === sel;
231
+ const ptr = isSel ? C.cyan + '\u276f ' + C.reset : ' ';
232
+ const nameStyle = isSel ? (C.cyan + C.bold) : C.white;
233
+ const badge = r.status ? ' ' + statusColor(r.statusTone) + r.status + C.reset : '';
234
+ const dis = r.disabled ? C.gray : '';
235
+ const labelRow = out.length; // the clickable row for this item
236
+ out.push(fitExactly(ptr + nameStyle + dis + r.label + C.reset + badge, cols));
237
+ // The whole label row is a click target: clicking selects the item (and a
238
+ // second click / the footer activates it), mirroring the tasks browser.
239
+ hits.push({ row: labelRow, col0: 0, col1: cols - 1, kind: 'regRow', index: i });
240
+ for (const line of wrapText(r.description, Math.max(8, cols - 6))) {
241
+ out.push(C.gray + ' ' + line + C.reset);
242
+ }
243
+ }
244
+ // A fetch failure has to show even when the LOCAL half has rows, or the Available
245
+ // tab silently looks empty and the user cannot tell why.
246
+ if (all.length && state.data && state.data.remoteError && tab.id === 'available') {
247
+ out.push('');
248
+ out.push(C.orange + ' Could not fetch the list: ' + state.data.remoteError + C.reset);
249
+ }
250
+ // The last action's outcome (installed / removed / failed) sits above the hint line.
251
+ if (state.flash) {
252
+ out.push('');
253
+ out.push(C.cyan + ' ' + state.flash + C.reset);
254
+ }
255
+
256
+ // Footer: what the highlighted row would DO, so the effect of Enter is never a
257
+ // surprise. A row already installed says "reinstall", because that is what Enter
258
+ // does there (and it is the only way to pick up a newer version).
259
+ const cur = all[sel];
260
+ const action = !cur ? ''
261
+ : cur.disabled ? 'Enter does nothing \u2014 this entry has no SKILL.md'
262
+ : cur.action === 'reinstall-skill' || cur.action === 'reinstall-plugin'
263
+ ? 'Enter reinstalls ' + cur.name + ' (replaces the local copy)'
264
+ : cur.action === 'install-skill' || cur.action === 'install-plugin'
265
+ ? 'Enter installs ' + cur.name + ' from the repository'
266
+ : cur.action.startsWith('remove') ? 'Enter removes the local copy of ' + cur.name
267
+ : 'loaded plugin \u2014 switch to the On disk tab to remove it';
268
+ while (out.length < rows - 2) out.push('');
269
+ out.push(fitExactly(C.gray + ' ' + action + C.reset, cols));
270
+ out.push(rule);
271
+ return out.slice(0, rows);
272
+ }
273
+
274
+ /**
275
+ * One key. Returns an action string for the caller:
276
+ * 'close' | 'switchTab' | 'select' | 'install' | 'remove' | 'reload' | null
277
+ */
278
+ export function handleRegistryBrowserKey(state, t) {
279
+ const tabs = state.kind === 'skills' ? SKILL_TABS : PLUGIN_TABS;
280
+ const all = rowsForTab(state, state.tab);
281
+ const ch = t.ch;
282
+
283
+ if (t.key === 'escape' || ch === 'q' || ch === 'Q') return 'close';
284
+ if (t.key === 'tab') {
285
+ const i = Math.max(0, tabs.findIndex((x) => x.id === state.tab));
286
+ state.tab = tabs[(i + 1) % tabs.length].id;
287
+ state.selectedIndex = 0;
288
+ return 'switchTab';
289
+ }
290
+ if (t.key === 'up' || ch === 'k') {
291
+ state.selectedIndex = Math.max(0, (state.selectedIndex || 0) - 1);
292
+ return 'select';
293
+ }
294
+ if (t.key === 'down' || ch === 'j') {
295
+ state.selectedIndex = Math.min(Math.max(0, all.length - 1), (state.selectedIndex || 0) + 1);
296
+ return 'select';
297
+ }
298
+ if (t.key === 'home') { state.selectedIndex = 0; return 'select'; }
299
+ if (t.key === 'end') { state.selectedIndex = Math.max(0, all.length - 1); return 'select'; }
300
+ if (ch === 'r' || ch === 'R') return 'reload';
301
+ if (t.key === 'enter' || t.key === 'space' || ch === ' ') {
302
+ const row = all[state.selectedIndex || 0];
303
+ if (!row || row.disabled) return null;
304
+ // `installed` on the Available tab still reinstalls: that is how you pick up a
305
+ // newer version of something you already have, and refusing it would leave no way
306
+ // to update.
307
+ if (row.action.startsWith('install')) return 'install';
308
+ if (row.action.startsWith('remove')) return 'remove';
309
+ return null;
310
+ }
311
+ if (ch === 'd' || ch === 'D') {
312
+ const row = all[state.selectedIndex || 0];
313
+ if (row && row.action.startsWith('remove')) return 'remove';
314
+ return null;
315
+ }
316
+ return null;
317
+ }
package/src/remote.js ADDED
@@ -0,0 +1,289 @@
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
+ // TLS verification is ON by default. If a local proxy / AV breaks the cert
27
+ // chain, set HNCODE_DISABLE_TLS_VERIFY to skip verification for GitHub fetches.
28
+ if (process.env.HNCODE_DISABLE_TLS_VERIFY) {
29
+ // Node prints an "insecure TLS" warning for this intentional opt-out. Replace the
30
+ // default `warning` handler with one that swallows only that message, so the rest
31
+ // of Node's warnings still come through.
32
+ process.removeAllListeners('warning');
33
+ process.on('warning', (warning) => {
34
+ if (warning && /NODE_TLS_REJECT_UNAUTHORIZED/i.test(warning.message || '')) return;
35
+ const pid = process.pid;
36
+ const name = warning && warning.name ? `${warning.name}: ` : '';
37
+ let line = `(node:${pid}) ${name}${warning && warning.message ? warning.message : String(warning)}`;
38
+ if (warning && warning.detail) line += `\n${warning.detail}`;
39
+ process.stderr.write(line + '\n');
40
+ });
41
+ process.env.NODE_TLS_REJECT_UNAUTHORIZED = '0';
42
+ }
43
+
44
+ // The canonical source. Overridable so a fork (or a test) can point elsewhere.
45
+ export const DEFAULT_REPO = process.env.HNCODE_REPO || 'NiceHello666/hncode';
46
+ export const DEFAULT_REF = process.env.HNCODE_REPO_REF || 'main';
47
+
48
+ // Where remote plugins land. `plugin.js` loads *.js/*.mjs from this directory.
49
+ export function pluginsDir() {
50
+ return process.env.HNCODE_PLUGINS || path.join(os.homedir(), '.hncode', 'plugins');
51
+ }
52
+
53
+ // Plugin files that are never worth downloading or dangerous to copy blindly.
54
+ const PLUGIN_EXT = new Set(['.js', '.mjs', '.cjs', '.json', '.md']);
55
+
56
+ const USER_AGENT = 'hncode-remote';
57
+
58
+ async function ghJson(url, { timeoutMs = 15000 } = {}) {
59
+ const ctl = new AbortController();
60
+ const timer = setTimeout(() => ctl.abort(), timeoutMs);
61
+ try {
62
+ const res = await fetch(url, {
63
+ signal: ctl.signal,
64
+ headers: { accept: 'application/vnd.github+json', 'user-agent': USER_AGENT },
65
+ });
66
+ if (!res.ok) {
67
+ // 404 is the normal "the folder does not exist yet" case, so it gets its own
68
+ // wording: the repo simply has not had anything contributed to that folder.
69
+ if (res.status === 404) return { ok: false, error: 'not-found' };
70
+ if (res.status === 403) return { ok: false, error: 'rate-limited' };
71
+ return { ok: false, error: `HTTP ${res.status}` };
72
+ }
73
+ return { ok: true, data: await res.json() };
74
+ } catch (e) {
75
+ return { ok: false, error: e && e.name === 'AbortError' ? 'timed out' : String((e && e.message) || e) };
76
+ } finally {
77
+ clearTimeout(timer);
78
+ }
79
+ }
80
+
81
+ async function fetchText(url, { timeoutMs = 20000 } = {}) {
82
+ const ctl = new AbortController();
83
+ const timer = setTimeout(() => ctl.abort(), timeoutMs);
84
+ try {
85
+ const res = await fetch(url, { signal: ctl.signal, headers: { 'user-agent': USER_AGENT } });
86
+ if (!res.ok) return { ok: false, error: `HTTP ${res.status}` };
87
+ return { ok: true, text: await res.text() };
88
+ } catch (e) {
89
+ return { ok: false, error: e && e.name === 'AbortError' ? 'timed out' : String((e && e.message) || e) };
90
+ } finally {
91
+ clearTimeout(timer);
92
+ }
93
+ }
94
+
95
+ function apiContents(repo, ref, dir) {
96
+ return `https://api.github.com/repos/${repo}/contents/${dir}${ref ? `?ref=${encodeURIComponent(ref)}` : ''}`;
97
+ }
98
+
99
+ function rawUrl(repo, ref, file) {
100
+ return `https://raw.githubusercontent.com/${repo}/${ref}/${file}`;
101
+ }
102
+
103
+ // Each item is a DIRECTORY in the repo (skills/<name>/, plugins/<name>/). A loose file
104
+ // at the top level is ignored: the convention is the contract, and accepting both
105
+ // would make the listing ambiguous about what a name refers to.
106
+ function dirsOnly(entries) {
107
+ return (Array.isArray(entries) ? entries : [])
108
+ .filter((e) => e && e.type === 'dir' && e.name && !e.name.startsWith('.'))
109
+ .map((e) => ({ name: e.name, path: e.path }))
110
+ .sort((a, b) => a.name.localeCompare(b.name));
111
+ }
112
+
113
+ /** List the skills available in the repo: [{ name, path }]. */
114
+ export async function listRemoteSkills({ repo = DEFAULT_REPO, ref = DEFAULT_REF } = {}) {
115
+ const r = await ghJson(apiContents(repo, ref, 'skills'));
116
+ if (!r.ok) return r;
117
+ return { ok: true, items: dirsOnly(r.data) };
118
+ }
119
+
120
+ /** List the plugins available in the repo: [{ name, path }]. */
121
+ export async function listRemotePlugins({ repo = DEFAULT_REPO, ref = DEFAULT_REF } = {}) {
122
+ const r = await ghJson(apiContents(repo, ref, 'plugins'));
123
+ if (!r.ok) return r;
124
+ return { ok: true, items: dirsOnly(r.data) };
125
+ }
126
+
127
+ // A skill's description comes from its front-matter (the same field `skills.js` reads
128
+ // for a local skill), so the local and remote listings describe a skill identically.
129
+ function describeSkill(text) {
130
+ const m = /^\uFEFF?---\r?\n([\s\S]*?)\r?\n---/.exec(String(text || ''));
131
+ if (!m) return '';
132
+ for (const line of m[1].split(/\r?\n/)) {
133
+ const kv = /^\s*(?:description|title)\s*:\s*(.*?)\s*$/.exec(line);
134
+ if (kv) {
135
+ let v = kv[1];
136
+ if ((v.startsWith('"') && v.endsWith('"')) || (v.startsWith("'") && v.endsWith("'"))) v = v.slice(1, -1);
137
+ return v;
138
+ }
139
+ }
140
+ return '';
141
+ }
142
+
143
+ /**
144
+ * Fetch the SKILL.md inside each remote skill directory, so the list can show what a
145
+ * skill is before the user installs it. One request per skill; failures leave the
146
+ * description empty rather than dropping the entry.
147
+ */
148
+ export async function listRemoteSkillsDetailed({ repo = DEFAULT_REPO, ref = DEFAULT_REF } = {}) {
149
+ const listed = await listRemoteSkills({ repo, ref });
150
+ if (!listed.ok) return listed;
151
+ const items = await Promise.all(listed.items.map(async (it) => {
152
+ const file = `${it.path}/SKILL.md`;
153
+ const r = await fetchText(rawUrl(repo, ref, file));
154
+ return { ...it, file, description: r.ok ? describeSkill(r.text) : '', readable: r.ok };
155
+ }));
156
+ return { ok: true, items };
157
+ }
158
+
159
+ /**
160
+ * Install one remote skill as `~/.hncode/skills/<name>.md`.
161
+ *
162
+ * The repo requires the file to be named SKILL.md; a directory without one is skipped
163
+ * (returns ok:false) instead of creating a nameless entry. Returns
164
+ * { ok, name, path, replaced } or { ok: false, error }.
165
+ */
166
+ export async function installRemoteSkill(name, { repo = DEFAULT_REPO, ref = DEFAULT_REF, overwrite = true } = {}) {
167
+ const clean = normalizeSkillName(name);
168
+ // A name is a path SEGMENT in the repo URL. Reject anything that could escape it or
169
+ // name a hidden directory, before the request is even made — `.hidden` used to slip
170
+ // through and only fail later as a confusing "no SKILL.md" 404.
171
+ if (!clean || /[\\/]/.test(clean) || clean.startsWith('.') || /[\u0000-\u001f]/.test(clean)) {
172
+ return { ok: false, error: `Invalid skill name: ${name}` };
173
+ }
174
+
175
+ const file = `skills/${clean}/SKILL.md`;
176
+ const r = await fetchText(rawUrl(repo, ref, file));
177
+ if (!r.ok) {
178
+ return { ok: false, error: r.error === 'HTTP 404'
179
+ ? `No SKILL.md at ${file} (each skill directory must contain SKILL.md).`
180
+ : `Could not download ${file}: ${r.error}` };
181
+ }
182
+ if (!r.text.trim()) return { ok: false, error: `${file} is empty.` };
183
+
184
+ const dir = skillsDir();
185
+ const dest = path.join(dir, `${clean}.md`);
186
+ const replaced = fs.existsSync(dest);
187
+ if (replaced && !overwrite) return { ok: false, error: 'exists', replaced: true, path: dest };
188
+ try {
189
+ fs.mkdirSync(dir, { recursive: true });
190
+ fs.writeFileSync(dest, r.text, 'utf8');
191
+ } catch (e) {
192
+ return { ok: false, error: `Cannot write ${dest}: ${e.message}` };
193
+ }
194
+ return { ok: true, name: clean, path: dest, replaced, description: describeSkill(r.text) };
195
+ }
196
+
197
+ /** Remove an installed skill by name (thin alias so the command reads uniformly). */
198
+ export function removeLocalSkill(name) {
199
+ const clean = normalizeSkillName(name);
200
+ if (!clean) return false;
201
+ try { fs.unlinkSync(path.join(skillsDir(), `${clean}.md`)); return true; } catch { return false; }
202
+ }
203
+
204
+ // ---- plugins ---------------------------------------------------------------
205
+
206
+ // Flatten a Contents API tree into { path, type, size } for one plugin directory.
207
+ async function listDir(repo, ref, dir) {
208
+ const r = await ghJson(apiContents(repo, ref, dir));
209
+ if (!r.ok) return r;
210
+ return { ok: true, entries: Array.isArray(r.data) ? r.data : [] };
211
+ }
212
+
213
+ /**
214
+ * Install one remote plugin into `~/.hncode/plugins/`.
215
+ *
216
+ * Two shapes are accepted, because a plugin is either one file or a small package:
217
+ * plugins/<name>/index.js (+ helpers)
218
+ * plugins/<name>/<name>.js
219
+ * Sub-directories are copied recursively; only the extensions plugins can actually be
220
+ * loaded from are fetched, so a stray asset cannot land in the plugin directory.
221
+ */
222
+ export async function installRemotePlugin(name, { repo = DEFAULT_REPO, ref = DEFAULT_REF, overwrite = true } = {}) {
223
+ const clean = String(name == null ? '' : name).trim();
224
+ if (!clean || /[\\/]/.test(clean) || clean.startsWith('.')) {
225
+ return { ok: false, error: `Invalid plugin name: ${name}` };
226
+ }
227
+ const srcDir = `plugins/${clean}`;
228
+ const listed = await listDir(repo, ref, srcDir);
229
+ if (!listed.ok) {
230
+ return { ok: false, error: listed.error === 'not-found'
231
+ ? `No plugins/${clean} directory in the repo.`
232
+ : `Could not list ${srcDir}: ${listed.error}` };
233
+ }
234
+
235
+ const files = listed.entries.filter((e) => e.type === 'file'
236
+ && PLUGIN_EXT.has(path.extname(e.name).toLowerCase()));
237
+ if (!files.length) return { ok: false, error: `plugins/${clean} has no .js/.mjs/.json files.` };
238
+
239
+ const destDir = path.join(pluginsDir(), clean);
240
+ const dest = path.join(destDir, files[0].name);
241
+ const existed = fs.existsSync(destDir);
242
+ if (existed && !overwrite) return { ok: false, error: 'exists', replaced: true, path: destDir };
243
+
244
+ const written = [];
245
+ for (const f of files) {
246
+ const r = await fetchText(rawUrl(repo, ref, f.path));
247
+ if (!r.ok) return { ok: false, error: `Could not download ${f.path}: ${r.error}` };
248
+ const out = path.join(destDir, path.basename(f.path));
249
+ try {
250
+ fs.mkdirSync(path.dirname(out), { recursive: true });
251
+ fs.writeFileSync(out, r.text, 'utf8');
252
+ } catch (e) {
253
+ return { ok: false, error: `Cannot write ${out}: ${e.message}` };
254
+ }
255
+ written.push(path.basename(f.path));
256
+ }
257
+ return { ok: true, name: clean, path: destDir, files: written, replaced: existed };
258
+ }
259
+
260
+ /** Remove an installed plugin directory (or a single-file plugin) by name. */
261
+ export function removeLocalPlugin(name) {
262
+ const clean = String(name == null ? '' : name).trim();
263
+ if (!clean || /[\\/]/.test(clean)) return false;
264
+ const dir = path.join(pluginsDir(), clean);
265
+ if (fs.existsSync(dir)) { try { fs.rmSync(dir, { recursive: true, force: true }); return true; } catch { return false; } }
266
+ // A single-file plugin installs as <name>.js.
267
+ for (const ext of ['.js', '.mjs', '.cjs']) {
268
+ const f = path.join(pluginsDir(), clean + ext);
269
+ if (fs.existsSync(f)) { try { fs.unlinkSync(f); return true; } catch { return false; } }
270
+ }
271
+ return false;
272
+ }
273
+
274
+ /** Installed plugin names, read from the plugin directory. */
275
+ export function listLocalPlugins() {
276
+ let entries;
277
+ try { entries = fs.readdirSync(pluginsDir()); } catch { return []; }
278
+ const out = [];
279
+ for (const e of entries) {
280
+ if (e.startsWith('.')) continue;
281
+ const full = path.join(pluginsDir(), e);
282
+ let st;
283
+ try { st = fs.statSync(full); } catch { continue; }
284
+ if (st.isDirectory()) out.push({ name: e, kind: 'dir', path: full });
285
+ else if (/\.(mjs?|cjs)$/i.test(e)) out.push({ name: e.replace(/\.[^.]+$/, ''), kind: 'file', path: full });
286
+ }
287
+ out.sort((a, b) => a.name.localeCompare(b.name));
288
+ return out;
289
+ }