@hncode/hncode 0.5.1 → 0.5.4
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 +2 -2
- package/src/agent-task.js +12 -1
- package/src/agent.js +279 -48
- package/src/cache.js +175 -130
- package/src/cell-buffer.js +5 -0
- package/src/clipboard.js +111 -8
- package/src/context-bar.js +46 -5
- package/src/doctor.js +7 -2
- package/src/git.js +35 -4
- package/src/index.js +29 -5
- package/src/keybindings.js +25 -3
- package/src/llm.js +60 -6
- package/src/mcp.js +419 -384
- package/src/permissions.js +29 -7
- package/src/plugin.js +95 -10
- package/src/registry-browser.js +21 -6
- package/src/remote.js +307 -289
- package/src/session-core.js +203 -0
- package/src/session-host.js +213 -0
- package/src/session-view.js +110 -0
- package/src/settings-schema.js +12 -9
- package/src/shell-safety.js +610 -473
- 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 +216 -0
- package/src/team/permission.js +121 -0
- package/src/team/protocol.js +167 -0
- package/src/team/runner.js +462 -0
- package/src/team/spawn.js +305 -0
- package/src/team/store.js +172 -0
- package/src/team/tasks.js +177 -0
- package/src/team/team.js +346 -0
- package/src/team-panel.js +624 -0
- package/src/term-caps.js +457 -410
- package/src/term.js +162 -19
- package/src/tool-result.js +70 -7
- package/src/tools/agent-swarm.js +114 -11
- package/src/tools/agent.js +464 -386
- package/src/tools/bash.js +53 -14
- package/src/tools/diagnostics.js +13 -0
- package/src/tools/edit.js +428 -50
- package/src/tools/git.js +15 -3
- package/src/tools/glob.js +5 -3
- package/src/tools/grep.js +19 -5
- package/src/tools/ignore.js +17 -3
- package/src/tools/index.js +19 -3
- package/src/tools/read.js +2 -1
- package/src/tools/shell.js +145 -134
- package/src/tools/tasks.js +214 -141
- package/src/tools/team.js +411 -0
- package/src/tools/utils.js +12 -3
- package/src/tools/web-search.js +53 -40
- package/src/tui.js +7496 -5366
- package/src/update.js +320 -115
- package/src/web-daemon.js +27 -4
- package/src/web-live.js +250 -0
package/src/remote.js
CHANGED
|
@@ -1,289 +1,307 @@
|
|
|
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
|
|
27
|
-
//
|
|
28
|
-
|
|
29
|
-
|
|
30
|
-
|
|
31
|
-
|
|
32
|
-
|
|
33
|
-
|
|
34
|
-
|
|
35
|
-
|
|
36
|
-
|
|
37
|
-
|
|
38
|
-
if (warning && warning.
|
|
39
|
-
process.
|
|
40
|
-
|
|
41
|
-
|
|
42
|
-
}
|
|
43
|
-
|
|
44
|
-
|
|
45
|
-
|
|
46
|
-
|
|
47
|
-
|
|
48
|
-
//
|
|
49
|
-
|
|
50
|
-
|
|
51
|
-
}
|
|
52
|
-
|
|
53
|
-
|
|
54
|
-
|
|
55
|
-
|
|
56
|
-
|
|
57
|
-
|
|
58
|
-
|
|
59
|
-
|
|
60
|
-
|
|
61
|
-
|
|
62
|
-
|
|
63
|
-
|
|
64
|
-
|
|
65
|
-
|
|
66
|
-
|
|
67
|
-
|
|
68
|
-
|
|
69
|
-
|
|
70
|
-
|
|
71
|
-
|
|
72
|
-
|
|
73
|
-
|
|
74
|
-
|
|
75
|
-
|
|
76
|
-
|
|
77
|
-
|
|
78
|
-
|
|
79
|
-
|
|
80
|
-
|
|
81
|
-
|
|
82
|
-
|
|
83
|
-
|
|
84
|
-
|
|
85
|
-
|
|
86
|
-
|
|
87
|
-
|
|
88
|
-
|
|
89
|
-
|
|
90
|
-
|
|
91
|
-
|
|
92
|
-
}
|
|
93
|
-
}
|
|
94
|
-
|
|
95
|
-
|
|
96
|
-
|
|
97
|
-
}
|
|
98
|
-
|
|
99
|
-
function
|
|
100
|
-
|
|
101
|
-
|
|
102
|
-
|
|
103
|
-
|
|
104
|
-
|
|
105
|
-
|
|
106
|
-
|
|
107
|
-
|
|
108
|
-
|
|
109
|
-
|
|
110
|
-
|
|
111
|
-
}
|
|
112
|
-
|
|
113
|
-
|
|
114
|
-
|
|
115
|
-
|
|
116
|
-
|
|
117
|
-
|
|
118
|
-
}
|
|
119
|
-
|
|
120
|
-
|
|
121
|
-
|
|
122
|
-
|
|
123
|
-
|
|
124
|
-
|
|
125
|
-
|
|
126
|
-
|
|
127
|
-
|
|
128
|
-
|
|
129
|
-
|
|
130
|
-
|
|
131
|
-
|
|
132
|
-
|
|
133
|
-
|
|
134
|
-
|
|
135
|
-
|
|
136
|
-
|
|
137
|
-
|
|
138
|
-
|
|
139
|
-
|
|
140
|
-
|
|
141
|
-
|
|
142
|
-
|
|
143
|
-
|
|
144
|
-
|
|
145
|
-
|
|
146
|
-
|
|
147
|
-
|
|
148
|
-
|
|
149
|
-
|
|
150
|
-
|
|
151
|
-
|
|
152
|
-
|
|
153
|
-
|
|
154
|
-
|
|
155
|
-
|
|
156
|
-
|
|
157
|
-
}
|
|
158
|
-
|
|
159
|
-
|
|
160
|
-
|
|
161
|
-
|
|
162
|
-
*
|
|
163
|
-
*
|
|
164
|
-
*
|
|
165
|
-
*/
|
|
166
|
-
export async function
|
|
167
|
-
const
|
|
168
|
-
|
|
169
|
-
|
|
170
|
-
|
|
171
|
-
|
|
172
|
-
return {
|
|
173
|
-
}
|
|
174
|
-
|
|
175
|
-
|
|
176
|
-
|
|
177
|
-
|
|
178
|
-
|
|
179
|
-
|
|
180
|
-
|
|
181
|
-
|
|
182
|
-
|
|
183
|
-
|
|
184
|
-
|
|
185
|
-
const
|
|
186
|
-
|
|
187
|
-
|
|
188
|
-
|
|
189
|
-
|
|
190
|
-
|
|
191
|
-
}
|
|
192
|
-
|
|
193
|
-
}
|
|
194
|
-
|
|
195
|
-
|
|
196
|
-
|
|
197
|
-
|
|
198
|
-
|
|
199
|
-
|
|
200
|
-
if (!
|
|
201
|
-
|
|
202
|
-
|
|
203
|
-
|
|
204
|
-
|
|
205
|
-
|
|
206
|
-
|
|
207
|
-
|
|
208
|
-
|
|
209
|
-
|
|
210
|
-
|
|
211
|
-
}
|
|
212
|
-
|
|
213
|
-
|
|
214
|
-
|
|
215
|
-
|
|
216
|
-
|
|
217
|
-
|
|
218
|
-
|
|
219
|
-
|
|
220
|
-
|
|
221
|
-
|
|
222
|
-
|
|
223
|
-
|
|
224
|
-
|
|
225
|
-
|
|
226
|
-
|
|
227
|
-
|
|
228
|
-
|
|
229
|
-
|
|
230
|
-
|
|
231
|
-
|
|
232
|
-
|
|
233
|
-
|
|
234
|
-
|
|
235
|
-
|
|
236
|
-
|
|
237
|
-
|
|
238
|
-
|
|
239
|
-
|
|
240
|
-
|
|
241
|
-
const
|
|
242
|
-
if (
|
|
243
|
-
|
|
244
|
-
|
|
245
|
-
|
|
246
|
-
|
|
247
|
-
|
|
248
|
-
|
|
249
|
-
|
|
250
|
-
|
|
251
|
-
|
|
252
|
-
|
|
253
|
-
|
|
254
|
-
|
|
255
|
-
|
|
256
|
-
|
|
257
|
-
|
|
258
|
-
|
|
259
|
-
|
|
260
|
-
|
|
261
|
-
|
|
262
|
-
const
|
|
263
|
-
|
|
264
|
-
|
|
265
|
-
|
|
266
|
-
|
|
267
|
-
|
|
268
|
-
|
|
269
|
-
|
|
270
|
-
|
|
271
|
-
|
|
272
|
-
}
|
|
273
|
-
|
|
274
|
-
|
|
275
|
-
|
|
276
|
-
|
|
277
|
-
|
|
278
|
-
|
|
279
|
-
|
|
280
|
-
|
|
281
|
-
|
|
282
|
-
|
|
283
|
-
|
|
284
|
-
|
|
285
|
-
|
|
286
|
-
|
|
287
|
-
|
|
288
|
-
|
|
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 — and that must include every OTHER connection
|
|
27
|
+
// the process makes (the LLM API carrying the key, MCP HTTP transports). If a local
|
|
28
|
+
// proxy / AV breaks the cert chain, HNCODE_DISABLE_TLS_VERIFY skips verification for
|
|
29
|
+
// THESE GitHub fetches ONLY: the flag is scoped per request in tlsFetch below, never
|
|
30
|
+
// set process-wide.
|
|
31
|
+
const INSECURE_TLS = !!process.env.HNCODE_DISABLE_TLS_VERIFY;
|
|
32
|
+
if (INSECURE_TLS) {
|
|
33
|
+
// Node prints an "insecure TLS" warning when the override applies. Replace the
|
|
34
|
+
// default `warning` handler with one that swallows only that message, so the rest
|
|
35
|
+
// of Node's warnings still come through.
|
|
36
|
+
process.removeAllListeners('warning');
|
|
37
|
+
process.on('warning', (warning) => {
|
|
38
|
+
if (warning && /NODE_TLS_REJECT_UNAUTHORIZED/i.test(warning.message || '')) return;
|
|
39
|
+
const pid = process.pid;
|
|
40
|
+
const name = warning && warning.name ? `${warning.name}: ` : '';
|
|
41
|
+
let line = `(node:${pid}) ${name}${warning && warning.message ? warning.message : String(warning)}`;
|
|
42
|
+
if (warning && warning.detail) line += `\n${warning.detail}`;
|
|
43
|
+
process.stderr.write(line + '\n');
|
|
44
|
+
});
|
|
45
|
+
}
|
|
46
|
+
|
|
47
|
+
// undici reads NODE_TLS_REJECT_UNAUTHORIZED when EACH TLS connection is made
|
|
48
|
+
// (verified: setting it at runtime changes the very next fetch), so the flag can be
|
|
49
|
+
// held only for the duration of one request and restored after — the LLM and MCP
|
|
50
|
+
// connections elsewhere in the process keep full verification.
|
|
51
|
+
async function tlsFetch(url, opts = {}) {
|
|
52
|
+
if (!INSECURE_TLS) return fetch(url, opts);
|
|
53
|
+
const prev = process.env.NODE_TLS_REJECT_UNAUTHORIZED;
|
|
54
|
+
process.env.NODE_TLS_REJECT_UNAUTHORIZED = '0';
|
|
55
|
+
try { return await fetch(url, opts); }
|
|
56
|
+
finally {
|
|
57
|
+
if (prev === undefined) delete process.env.NODE_TLS_REJECT_UNAUTHORIZED;
|
|
58
|
+
else process.env.NODE_TLS_REJECT_UNAUTHORIZED = prev;
|
|
59
|
+
}
|
|
60
|
+
}
|
|
61
|
+
|
|
62
|
+
// The canonical source. Overridable so a fork (or a test) can point elsewhere.
|
|
63
|
+
export const DEFAULT_REPO = process.env.HNCODE_REPO || 'NiceHello666/hncode';
|
|
64
|
+
export const DEFAULT_REF = process.env.HNCODE_REPO_REF || 'main';
|
|
65
|
+
|
|
66
|
+
// Where remote plugins land. `plugin.js` loads *.js/*.mjs from this directory.
|
|
67
|
+
export function pluginsDir() {
|
|
68
|
+
return process.env.HNCODE_PLUGINS || path.join(os.homedir(), '.hncode', 'plugins');
|
|
69
|
+
}
|
|
70
|
+
|
|
71
|
+
// Plugin files that are never worth downloading or dangerous to copy blindly.
|
|
72
|
+
const PLUGIN_EXT = new Set(['.js', '.mjs', '.cjs', '.json', '.md']);
|
|
73
|
+
|
|
74
|
+
const USER_AGENT = 'hncode-remote';
|
|
75
|
+
|
|
76
|
+
async function ghJson(url, { timeoutMs = 15000 } = {}) {
|
|
77
|
+
const ctl = new AbortController();
|
|
78
|
+
const timer = setTimeout(() => ctl.abort(), timeoutMs);
|
|
79
|
+
try {
|
|
80
|
+
const res = await tlsFetch(url, {
|
|
81
|
+
signal: ctl.signal,
|
|
82
|
+
headers: { accept: 'application/vnd.github+json', 'user-agent': USER_AGENT },
|
|
83
|
+
});
|
|
84
|
+
if (!res.ok) {
|
|
85
|
+
// 404 is the normal "the folder does not exist yet" case, so it gets its own
|
|
86
|
+
// wording: the repo simply has not had anything contributed to that folder.
|
|
87
|
+
if (res.status === 404) return { ok: false, error: 'not-found' };
|
|
88
|
+
if (res.status === 403) return { ok: false, error: 'rate-limited' };
|
|
89
|
+
return { ok: false, error: `HTTP ${res.status}` };
|
|
90
|
+
}
|
|
91
|
+
return { ok: true, data: await res.json() };
|
|
92
|
+
} catch (e) {
|
|
93
|
+
return { ok: false, error: e && e.name === 'AbortError' ? 'timed out' : String((e && e.message) || e) };
|
|
94
|
+
} finally {
|
|
95
|
+
clearTimeout(timer);
|
|
96
|
+
}
|
|
97
|
+
}
|
|
98
|
+
|
|
99
|
+
async function fetchText(url, { timeoutMs = 20000 } = {}) {
|
|
100
|
+
const ctl = new AbortController();
|
|
101
|
+
const timer = setTimeout(() => ctl.abort(), timeoutMs);
|
|
102
|
+
try {
|
|
103
|
+
const res = await tlsFetch(url, { signal: ctl.signal, headers: { 'user-agent': USER_AGENT } });
|
|
104
|
+
if (!res.ok) return { ok: false, error: `HTTP ${res.status}` };
|
|
105
|
+
return { ok: true, text: await res.text() };
|
|
106
|
+
} catch (e) {
|
|
107
|
+
return { ok: false, error: e && e.name === 'AbortError' ? 'timed out' : String((e && e.message) || e) };
|
|
108
|
+
} finally {
|
|
109
|
+
clearTimeout(timer);
|
|
110
|
+
}
|
|
111
|
+
}
|
|
112
|
+
|
|
113
|
+
function apiContents(repo, ref, dir) {
|
|
114
|
+
return `https://api.github.com/repos/${repo}/contents/${dir}${ref ? `?ref=${encodeURIComponent(ref)}` : ''}`;
|
|
115
|
+
}
|
|
116
|
+
|
|
117
|
+
function rawUrl(repo, ref, file) {
|
|
118
|
+
return `https://raw.githubusercontent.com/${repo}/${ref}/${file}`;
|
|
119
|
+
}
|
|
120
|
+
|
|
121
|
+
// Each item is a DIRECTORY in the repo (skills/<name>/, plugins/<name>/). A loose file
|
|
122
|
+
// at the top level is ignored: the convention is the contract, and accepting both
|
|
123
|
+
// would make the listing ambiguous about what a name refers to.
|
|
124
|
+
function dirsOnly(entries) {
|
|
125
|
+
return (Array.isArray(entries) ? entries : [])
|
|
126
|
+
.filter((e) => e && e.type === 'dir' && e.name && !e.name.startsWith('.'))
|
|
127
|
+
.map((e) => ({ name: e.name, path: e.path }))
|
|
128
|
+
.sort((a, b) => a.name.localeCompare(b.name));
|
|
129
|
+
}
|
|
130
|
+
|
|
131
|
+
/** List the skills available in the repo: [{ name, path }]. */
|
|
132
|
+
export async function listRemoteSkills({ repo = DEFAULT_REPO, ref = DEFAULT_REF } = {}) {
|
|
133
|
+
const r = await ghJson(apiContents(repo, ref, 'skills'));
|
|
134
|
+
if (!r.ok) return r;
|
|
135
|
+
return { ok: true, items: dirsOnly(r.data) };
|
|
136
|
+
}
|
|
137
|
+
|
|
138
|
+
/** List the plugins available in the repo: [{ name, path }]. */
|
|
139
|
+
export async function listRemotePlugins({ repo = DEFAULT_REPO, ref = DEFAULT_REF } = {}) {
|
|
140
|
+
const r = await ghJson(apiContents(repo, ref, 'plugins'));
|
|
141
|
+
if (!r.ok) return r;
|
|
142
|
+
return { ok: true, items: dirsOnly(r.data) };
|
|
143
|
+
}
|
|
144
|
+
|
|
145
|
+
// A skill's description comes from its front-matter (the same field `skills.js` reads
|
|
146
|
+
// for a local skill), so the local and remote listings describe a skill identically.
|
|
147
|
+
function describeSkill(text) {
|
|
148
|
+
const m = /^\uFEFF?---\r?\n([\s\S]*?)\r?\n---/.exec(String(text || ''));
|
|
149
|
+
if (!m) return '';
|
|
150
|
+
for (const line of m[1].split(/\r?\n/)) {
|
|
151
|
+
const kv = /^\s*(?:description|title)\s*:\s*(.*?)\s*$/.exec(line);
|
|
152
|
+
if (kv) {
|
|
153
|
+
let v = kv[1];
|
|
154
|
+
if ((v.startsWith('"') && v.endsWith('"')) || (v.startsWith("'") && v.endsWith("'"))) v = v.slice(1, -1);
|
|
155
|
+
return v;
|
|
156
|
+
}
|
|
157
|
+
}
|
|
158
|
+
return '';
|
|
159
|
+
}
|
|
160
|
+
|
|
161
|
+
/**
|
|
162
|
+
* Fetch the SKILL.md inside each remote skill directory, so the list can show what a
|
|
163
|
+
* skill is before the user installs it. One request per skill; failures leave the
|
|
164
|
+
* description empty rather than dropping the entry.
|
|
165
|
+
*/
|
|
166
|
+
export async function listRemoteSkillsDetailed({ repo = DEFAULT_REPO, ref = DEFAULT_REF } = {}) {
|
|
167
|
+
const listed = await listRemoteSkills({ repo, ref });
|
|
168
|
+
if (!listed.ok) return listed;
|
|
169
|
+
const items = await Promise.all(listed.items.map(async (it) => {
|
|
170
|
+
const file = `${it.path}/SKILL.md`;
|
|
171
|
+
const r = await fetchText(rawUrl(repo, ref, file));
|
|
172
|
+
return { ...it, file, description: r.ok ? describeSkill(r.text) : '', readable: r.ok };
|
|
173
|
+
}));
|
|
174
|
+
return { ok: true, items };
|
|
175
|
+
}
|
|
176
|
+
|
|
177
|
+
/**
|
|
178
|
+
* Install one remote skill as `~/.hncode/skills/<name>.md`.
|
|
179
|
+
*
|
|
180
|
+
* The repo requires the file to be named SKILL.md; a directory without one is skipped
|
|
181
|
+
* (returns ok:false) instead of creating a nameless entry. Returns
|
|
182
|
+
* { ok, name, path, replaced } or { ok: false, error }.
|
|
183
|
+
*/
|
|
184
|
+
export async function installRemoteSkill(name, { repo = DEFAULT_REPO, ref = DEFAULT_REF, overwrite = true } = {}) {
|
|
185
|
+
const clean = normalizeSkillName(name);
|
|
186
|
+
// A name is a path SEGMENT in the repo URL. Reject anything that could escape it or
|
|
187
|
+
// name a hidden directory, before the request is even made — `.hidden` used to slip
|
|
188
|
+
// through and only fail later as a confusing "no SKILL.md" 404.
|
|
189
|
+
if (!clean || /[\\/]/.test(clean) || clean.startsWith('.') || /[\u0000-\u001f]/.test(clean)) {
|
|
190
|
+
return { ok: false, error: `Invalid skill name: ${name}` };
|
|
191
|
+
}
|
|
192
|
+
|
|
193
|
+
const file = `skills/${clean}/SKILL.md`;
|
|
194
|
+
const r = await fetchText(rawUrl(repo, ref, file));
|
|
195
|
+
if (!r.ok) {
|
|
196
|
+
return { ok: false, error: r.error === 'HTTP 404'
|
|
197
|
+
? `No SKILL.md at ${file} (each skill directory must contain SKILL.md).`
|
|
198
|
+
: `Could not download ${file}: ${r.error}` };
|
|
199
|
+
}
|
|
200
|
+
if (!r.text.trim()) return { ok: false, error: `${file} is empty.` };
|
|
201
|
+
|
|
202
|
+
const dir = skillsDir();
|
|
203
|
+
const dest = path.join(dir, `${clean}.md`);
|
|
204
|
+
const replaced = fs.existsSync(dest);
|
|
205
|
+
if (replaced && !overwrite) return { ok: false, error: 'exists', replaced: true, path: dest };
|
|
206
|
+
try {
|
|
207
|
+
fs.mkdirSync(dir, { recursive: true });
|
|
208
|
+
fs.writeFileSync(dest, r.text, 'utf8');
|
|
209
|
+
} catch (e) {
|
|
210
|
+
return { ok: false, error: `Cannot write ${dest}: ${e.message}` };
|
|
211
|
+
}
|
|
212
|
+
return { ok: true, name: clean, path: dest, replaced, description: describeSkill(r.text) };
|
|
213
|
+
}
|
|
214
|
+
|
|
215
|
+
/** Remove an installed skill by name (thin alias so the command reads uniformly). */
|
|
216
|
+
export function removeLocalSkill(name) {
|
|
217
|
+
const clean = normalizeSkillName(name);
|
|
218
|
+
if (!clean) return false;
|
|
219
|
+
try { fs.unlinkSync(path.join(skillsDir(), `${clean}.md`)); return true; } catch { return false; }
|
|
220
|
+
}
|
|
221
|
+
|
|
222
|
+
// ---- plugins ---------------------------------------------------------------
|
|
223
|
+
|
|
224
|
+
// Flatten a Contents API tree into { path, type, size } for one plugin directory.
|
|
225
|
+
async function listDir(repo, ref, dir) {
|
|
226
|
+
const r = await ghJson(apiContents(repo, ref, dir));
|
|
227
|
+
if (!r.ok) return r;
|
|
228
|
+
return { ok: true, entries: Array.isArray(r.data) ? r.data : [] };
|
|
229
|
+
}
|
|
230
|
+
|
|
231
|
+
/**
|
|
232
|
+
* Install one remote plugin into `~/.hncode/plugins/`.
|
|
233
|
+
*
|
|
234
|
+
* Two shapes are accepted, because a plugin is either one file or a small package:
|
|
235
|
+
* plugins/<name>/index.js (+ helpers)
|
|
236
|
+
* plugins/<name>/<name>.js
|
|
237
|
+
* Sub-directories are copied recursively; only the extensions plugins can actually be
|
|
238
|
+
* loaded from are fetched, so a stray asset cannot land in the plugin directory.
|
|
239
|
+
*/
|
|
240
|
+
export async function installRemotePlugin(name, { repo = DEFAULT_REPO, ref = DEFAULT_REF, overwrite = true } = {}) {
|
|
241
|
+
const clean = String(name == null ? '' : name).trim();
|
|
242
|
+
if (!clean || /[\\/]/.test(clean) || clean.startsWith('.')) {
|
|
243
|
+
return { ok: false, error: `Invalid plugin name: ${name}` };
|
|
244
|
+
}
|
|
245
|
+
const srcDir = `plugins/${clean}`;
|
|
246
|
+
const listed = await listDir(repo, ref, srcDir);
|
|
247
|
+
if (!listed.ok) {
|
|
248
|
+
return { ok: false, error: listed.error === 'not-found'
|
|
249
|
+
? `No plugins/${clean} directory in the repo.`
|
|
250
|
+
: `Could not list ${srcDir}: ${listed.error}` };
|
|
251
|
+
}
|
|
252
|
+
|
|
253
|
+
const files = listed.entries.filter((e) => e.type === 'file'
|
|
254
|
+
&& PLUGIN_EXT.has(path.extname(e.name).toLowerCase()));
|
|
255
|
+
if (!files.length) return { ok: false, error: `plugins/${clean} has no .js/.mjs/.json files.` };
|
|
256
|
+
|
|
257
|
+
const destDir = path.join(pluginsDir(), clean);
|
|
258
|
+
const dest = path.join(destDir, files[0].name);
|
|
259
|
+
const existed = fs.existsSync(destDir);
|
|
260
|
+
if (existed && !overwrite) return { ok: false, error: 'exists', replaced: true, path: destDir };
|
|
261
|
+
|
|
262
|
+
const written = [];
|
|
263
|
+
for (const f of files) {
|
|
264
|
+
const r = await fetchText(rawUrl(repo, ref, f.path));
|
|
265
|
+
if (!r.ok) return { ok: false, error: `Could not download ${f.path}: ${r.error}` };
|
|
266
|
+
const out = path.join(destDir, path.basename(f.path));
|
|
267
|
+
try {
|
|
268
|
+
fs.mkdirSync(path.dirname(out), { recursive: true });
|
|
269
|
+
fs.writeFileSync(out, r.text, 'utf8');
|
|
270
|
+
} catch (e) {
|
|
271
|
+
return { ok: false, error: `Cannot write ${out}: ${e.message}` };
|
|
272
|
+
}
|
|
273
|
+
written.push(path.basename(f.path));
|
|
274
|
+
}
|
|
275
|
+
return { ok: true, name: clean, path: destDir, files: written, replaced: existed };
|
|
276
|
+
}
|
|
277
|
+
|
|
278
|
+
/** Remove an installed plugin directory (or a single-file plugin) by name. */
|
|
279
|
+
export function removeLocalPlugin(name) {
|
|
280
|
+
const clean = String(name == null ? '' : name).trim();
|
|
281
|
+
if (!clean || /[\\/]/.test(clean)) return false;
|
|
282
|
+
const dir = path.join(pluginsDir(), clean);
|
|
283
|
+
if (fs.existsSync(dir)) { try { fs.rmSync(dir, { recursive: true, force: true }); return true; } catch { return false; } }
|
|
284
|
+
// A single-file plugin installs as <name>.js.
|
|
285
|
+
for (const ext of ['.js', '.mjs', '.cjs']) {
|
|
286
|
+
const f = path.join(pluginsDir(), clean + ext);
|
|
287
|
+
if (fs.existsSync(f)) { try { fs.unlinkSync(f); return true; } catch { return false; } }
|
|
288
|
+
}
|
|
289
|
+
return false;
|
|
290
|
+
}
|
|
291
|
+
|
|
292
|
+
/** Installed plugin names, read from the plugin directory. */
|
|
293
|
+
export function listLocalPlugins() {
|
|
294
|
+
let entries;
|
|
295
|
+
try { entries = fs.readdirSync(pluginsDir()); } catch { return []; }
|
|
296
|
+
const out = [];
|
|
297
|
+
for (const e of entries) {
|
|
298
|
+
if (e.startsWith('.')) continue;
|
|
299
|
+
const full = path.join(pluginsDir(), e);
|
|
300
|
+
let st;
|
|
301
|
+
try { st = fs.statSync(full); } catch { continue; }
|
|
302
|
+
if (st.isDirectory()) out.push({ name: e, kind: 'dir', path: full });
|
|
303
|
+
else if (/\.(mjs?|cjs)$/i.test(e)) out.push({ name: e.replace(/\.[^.]+$/, ''), kind: 'file', path: full });
|
|
304
|
+
}
|
|
305
|
+
out.sort((a, b) => a.name.localeCompare(b.name));
|
|
306
|
+
return out;
|
|
307
|
+
}
|