@hncode/hncode 0.5.2 → 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/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. 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
- }
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
+ }