@hncode/hncode 0.6.5 → 0.6.7
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/package.json +1 -1
- package/src/registry-browser.js +90 -7
- package/src/skills.js +110 -2
- package/src/tools/activate-skill.js +76 -0
- package/src/tools/fetch-url.js +2 -2
- package/src/tools/index.js +2 -0
- package/src/tui.js +70 -17
package/package.json
CHANGED
package/src/registry-browser.js
CHANGED
|
@@ -171,6 +171,23 @@ function statusColor(tone) {
|
|
|
171
171
|
return C.blue;
|
|
172
172
|
}
|
|
173
173
|
|
|
174
|
+
/**
|
|
175
|
+
* The rows a tab SHOWS, after the search filter.
|
|
176
|
+
*
|
|
177
|
+
* The renderer and the action handler must agree on this or the list lies: with a query typed,
|
|
178
|
+
* `selectedIndex` counts entries in the FILTERED list, so looking it up in the unfiltered one acts on whatever
|
|
179
|
+
* happened to sit at that position instead of the row under the cursor.
|
|
180
|
+
*
|
|
181
|
+
* The tab COUNTS stay on the unfiltered list — a filter must never make a tab look empty.
|
|
182
|
+
*/
|
|
183
|
+
export function visibleRowsForTab(state, tabId) {
|
|
184
|
+
const rows = rowsForTab(state, tabId);
|
|
185
|
+
const query = String(state && state.filter || '').trim().toLowerCase();
|
|
186
|
+
if (!query) return rows;
|
|
187
|
+
return rows.filter((r) => [r.label, r.description, r.name]
|
|
188
|
+
.map((v) => String(v == null ? '' : v)).join(' ').toLowerCase().includes(query));
|
|
189
|
+
}
|
|
190
|
+
|
|
174
191
|
export function renderRegistryBrowser(state, cols, rows) {
|
|
175
192
|
if (cols < MIN_WIDTH || rows < MIN_HEIGHT) {
|
|
176
193
|
const lines = [fitExactly(C.red + 'Terminal too small (need >= ' + MIN_WIDTH + ' x ' + MIN_HEIGHT + ')' + C.reset, cols)];
|
|
@@ -181,7 +198,9 @@ export function renderRegistryBrowser(state, cols, rows) {
|
|
|
181
198
|
const tabs = state.kind === 'skills' ? SKILL_TABS : PLUGIN_TABS;
|
|
182
199
|
const tabIdx = Math.max(0, tabs.findIndex((t) => t.id === state.tab));
|
|
183
200
|
const tab = tabs[tabIdx] || tabs[0];
|
|
184
|
-
|
|
201
|
+
// The header row and the empty-state message both need to know whether a filter is on.
|
|
202
|
+
const query = String(state.filter || '').trim().toLowerCase();
|
|
203
|
+
const all = visibleRowsForTab(state, tab.id);
|
|
185
204
|
const sel = Math.max(0, Math.min(all.length - 1, state.selectedIndex || 0));
|
|
186
205
|
|
|
187
206
|
const out = [];
|
|
@@ -197,9 +216,16 @@ export function renderRegistryBrowser(state, cols, rows) {
|
|
|
197
216
|
// The key hints stay visible while the fetch runs: the panel is usable during it
|
|
198
217
|
// (the local tabs are already loaded), so replacing the hints with a status message
|
|
199
218
|
// would hide the keys exactly when the user is most likely to press them.
|
|
200
|
-
const hint = ' Tab switch \u00b7 \u2191\u2193 select \u00b7 Enter install/remove \u00b7 Esc close';
|
|
219
|
+
const hint = ' / search \u00b7 Tab switch \u00b7 \u2191\u2193 select \u00b7 Enter install/remove \u00b7 Esc close';
|
|
201
220
|
out.push(fitExactly(C.gray + hint + (state.busy ? C.gray + ` ${state.busy}` : '') + C.reset, cols));
|
|
202
221
|
out.push('');
|
|
222
|
+
// The search field, drawn whenever it is open or has text, so a filtered list always explains itself.
|
|
223
|
+
if (state.filtering || query) {
|
|
224
|
+
const shown = String(state.filter || '');
|
|
225
|
+
const caret = state.filtering ? '\u2588' : '';
|
|
226
|
+
out.push(C.gray + ' / ' + C.reset + C.white + shown + C.reset + C.cyan + caret + C.reset
|
|
227
|
+
+ C.gray + (query ? ` ${all.length} match${all.length === 1 ? '' : 'es'}` : ' type to filter') + C.reset);
|
|
228
|
+
}
|
|
203
229
|
|
|
204
230
|
// Tab strip, with the count of each tab's rows.
|
|
205
231
|
{
|
|
@@ -240,22 +266,52 @@ export function renderRegistryBrowser(state, cols, rows) {
|
|
|
240
266
|
: ' Nothing here.';
|
|
241
267
|
out.push(C.gray + msg + C.reset);
|
|
242
268
|
}
|
|
243
|
-
|
|
269
|
+
// How many LINES each row occupies: its label plus its wrapped description.
|
|
270
|
+
const descW = Math.max(8, cols - 6);
|
|
271
|
+
const rowH = all.map((r) => 1 + wrapText(r.description, descW).length);
|
|
272
|
+
const visibleLines = Math.max(1, bodyRows);
|
|
273
|
+
|
|
274
|
+
// The first row to draw. `state.listTop` persists across frames — recomputing it from scratch each time would
|
|
275
|
+
// snap the window back to the top on every key, and the cursor would appear to leave the list.
|
|
276
|
+
let first = Math.max(0, Math.min(state.listTop || 0, Math.max(0, all.length - 1)));
|
|
277
|
+
// Scroll down while the selection does not fit; scroll up if it sits above the window.
|
|
278
|
+
if (sel < first) first = sel;
|
|
279
|
+
for (;;) {
|
|
280
|
+
let used = 0;
|
|
281
|
+
for (let i = first; i <= sel && i < all.length; i++) used += rowH[i];
|
|
282
|
+
if (used <= visibleLines || first >= sel) break;
|
|
283
|
+
first++;
|
|
284
|
+
}
|
|
285
|
+
state.listTop = first;
|
|
286
|
+
|
|
287
|
+
// Draw from `first`, stopping when the window is full. A row is never split: a label whose description fell
|
|
288
|
+
// off the bottom reads as a row that has none.
|
|
289
|
+
let drawn = 0;
|
|
290
|
+
for (let i = first; i < all.length; i++) {
|
|
291
|
+
if (drawn > 0 && drawn + rowH[i] > visibleLines) break;
|
|
292
|
+
drawn += rowH[i];
|
|
244
293
|
const r = all[i];
|
|
245
294
|
const isSel = i === sel;
|
|
246
295
|
const ptr = isSel ? C.cyan + '\u276f ' + C.reset : ' ';
|
|
247
296
|
const nameStyle = isSel ? (C.cyan + C.bold) : C.white;
|
|
248
297
|
const badge = r.status ? ' ' + statusColor(r.statusTone) + r.status + C.reset : '';
|
|
249
298
|
const dis = r.disabled ? C.gray : '';
|
|
250
|
-
const labelRow = out.length;
|
|
299
|
+
const labelRow = out.length;
|
|
251
300
|
out.push(fitExactly(ptr + nameStyle + dis + r.label + C.reset + badge, cols));
|
|
252
|
-
// The whole label row is a click target: clicking selects the item (and a
|
|
253
|
-
//
|
|
301
|
+
// The whole label row is a click target: clicking selects the item (and a second click / the footer
|
|
302
|
+
// activates it), mirroring the tasks browser.
|
|
254
303
|
hits.push({ row: labelRow, col0: 0, col1: cols - 1, kind: 'regRow', index: i });
|
|
255
|
-
for (const line of wrapText(r.description,
|
|
304
|
+
for (const line of wrapText(r.description, descW)) {
|
|
256
305
|
out.push(C.gray + ' ' + line + C.reset);
|
|
257
306
|
}
|
|
258
307
|
}
|
|
308
|
+
// Say when the list continues: a clipped list that looks complete is how a skill goes missing.
|
|
309
|
+
let after = first;
|
|
310
|
+
let acc = 0;
|
|
311
|
+
while (after < all.length && acc < drawn) { acc += rowH[after]; after++; }
|
|
312
|
+
const hiddenBelow = all.length - after;
|
|
313
|
+
if (first > 0) out.push(C.gray + ` \u2191 \u2191 \u2191 ${first} above` + C.reset);
|
|
314
|
+
if (hiddenBelow > 0) out.push(C.gray + ` \u2193 \u2193 \u2193 ${hiddenBelow} below` + C.reset);
|
|
259
315
|
// A fetch failure has to show even when the LOCAL half has rows, or the Available
|
|
260
316
|
// tab silently looks empty and the user cannot tell why.
|
|
261
317
|
if (all.length && state.data && state.data.remoteError && tab.id === 'available') {
|
|
@@ -291,9 +347,36 @@ export function renderRegistryBrowser(state, cols, rows) {
|
|
|
291
347
|
* 'close' | 'switchTab' | 'select' | 'install' | 'remove' | 'reload' | null
|
|
292
348
|
*/
|
|
293
349
|
export function handleRegistryBrowserKey(state, t) {
|
|
350
|
+
|
|
294
351
|
const tabs = state.kind === 'skills' ? SKILL_TABS : PLUGIN_TABS;
|
|
295
352
|
const all = rowsForTab(state, state.tab);
|
|
296
353
|
const ch = t.ch;
|
|
354
|
+
// ---- filter input -------------------------------------------------------------------------
|
|
355
|
+
//
|
|
356
|
+
// While `state.filtering` is on, every printable key goes into the query instead of acting on the list. Esc
|
|
357
|
+
// leaves the field without clearing the filter, so a half-typed word is not lost by a stray key; a second Esc
|
|
358
|
+
// clears it. Enter accepts the filter and returns the keyboard to the list.
|
|
359
|
+
if (state.filtering) {
|
|
360
|
+
if (t.key === 'escape') { state.filtering = false; return 'select'; }
|
|
361
|
+
if (t.key === 'enter') { state.filtering = false; return 'select'; }
|
|
362
|
+
if (t.key === 'backspace') {
|
|
363
|
+
state.filter = String(state.filter || '').slice(0, -1);
|
|
364
|
+
state.selectedIndex = 0;
|
|
365
|
+
state.listTop = 0;
|
|
366
|
+
return 'select';
|
|
367
|
+
}
|
|
368
|
+
if (t.key === 'up' || t.key === 'down') { state.filtering = false; } // hand the arrows back to the list
|
|
369
|
+
else if (ch && ch.length === 1 && ch >= ' ') {
|
|
370
|
+
state.filter = String(state.filter || '') + ch;
|
|
371
|
+
state.selectedIndex = 0;
|
|
372
|
+
state.listTop = 0;
|
|
373
|
+
return 'select';
|
|
374
|
+
} else { return null; }
|
|
375
|
+
}
|
|
376
|
+
|
|
377
|
+
// `/` opens the field; a set filter stays visible in the header and Esc clears it from the list too.
|
|
378
|
+
if (ch === '/') { state.filtering = true; return 'select'; }
|
|
379
|
+
if (t.key === 'escape' && state.filter) { state.filter = ''; state.selectedIndex = 0; state.listTop = 0; return 'select'; }
|
|
297
380
|
|
|
298
381
|
if (t.key === 'escape' || ch === 'q' || ch === 'Q') return 'close';
|
|
299
382
|
if (t.key === 'tab') {
|
package/src/skills.js
CHANGED
|
@@ -21,7 +21,7 @@
|
|
|
21
21
|
import fs from 'node:fs';
|
|
22
22
|
import os from 'node:os';
|
|
23
23
|
import path from 'node:path';
|
|
24
|
-
|
|
24
|
+
import { httpsRequest } from './tools/fetch-url.js';
|
|
25
25
|
export const SKILL_PREFIX = 'skill:';
|
|
26
26
|
|
|
27
27
|
// The skills directory. Kept next to the other hncode state (~/.hncode/…), and
|
|
@@ -45,7 +45,16 @@ export function normalizeSkillName(name) {
|
|
|
45
45
|
// or malformed block (then body === text and meta is empty).
|
|
46
46
|
export function parseFrontMatter(text) {
|
|
47
47
|
const src = String(text == null ? '' : text);
|
|
48
|
-
|
|
48
|
+
// A DELIMITER THAT WAS ESCAPED IS STILL A DELIMITER.
|
|
49
|
+
//
|
|
50
|
+
// `---\nname: x` is what a skill looks like when something wrote the block with the newlines escaped: the
|
|
51
|
+
// first and last delimiters carry a literal backslash-n while the fields between them use real newlines. The
|
|
52
|
+
// block then matched nothing and the skill listed as "(no description)" — the file reads correctly in an
|
|
53
|
+
// editor, so there was nothing to see. Only the two delimiters are unescaped; a body that contains `\n` on
|
|
54
|
+
// purpose is untouched, because the replacement stops at the first field.
|
|
55
|
+
const norm = src.replace(/^\uFEFF?---\\n(?=[A-Za-z0-9_-])/, '---\n')
|
|
56
|
+
.replace(/\\n---(\\n|$)/, '\n---$1');
|
|
57
|
+
const m = /^\uFEFF?---\r?\n([\s\S]*?)\r?\n---[ \t]*\r?\n?/.exec(norm);
|
|
49
58
|
if (!m) return { meta: {}, body: src };
|
|
50
59
|
const meta = {};
|
|
51
60
|
for (const line of m[1].split(/\r?\n/)) {
|
|
@@ -171,6 +180,105 @@ export function skillCompletions(prefix = '') {
|
|
|
171
180
|
|
|
172
181
|
// Build the text a skill contributes to the conversation. A short header keeps
|
|
173
182
|
// the model from treating the body as the user's own words.
|
|
183
|
+
// ---- installing from a GitHub repository ------------------------------------------------
|
|
184
|
+
|
|
185
|
+
// A registry entry is `owner/repo`, optionally with a subdirectory: `owner/repo/skills`, or
|
|
186
|
+
// `owner/repo@ref`. Skills are FOUND, not assumed: every `SKILL.md` below the given path is one,
|
|
187
|
+
// because that is the layout the ecosystem uses — a folder per skill, each with its own file.
|
|
188
|
+
//
|
|
189
|
+
// The listing comes from the GitHub API, which needs no authentication for a public repository and
|
|
190
|
+
// is rate limited per IP without a token. Fetching goes through the same transport as FetchURL so a
|
|
191
|
+
// broken certificate chain on this network does not make the feature useless.
|
|
192
|
+
const GH_API = 'https://api.github.com';
|
|
193
|
+
|
|
194
|
+
/** Split `owner/repo[/sub][@ref]` into its parts, or null when the shape is wrong. */
|
|
195
|
+
export function parseRepoSpec(spec) {
|
|
196
|
+
const text = String(spec == null ? '' : spec).trim().replace(/^https?:\/\/github\.com\//i, '');
|
|
197
|
+
if (!text) return null;
|
|
198
|
+
const at = text.lastIndexOf('@');
|
|
199
|
+
const ref = at > 0 ? text.slice(at + 1) : '';
|
|
200
|
+
const rest = at > 0 ? text.slice(0, at) : text;
|
|
201
|
+
const parts = rest.split('/').filter(Boolean);
|
|
202
|
+
if (parts.length < 2) return null;
|
|
203
|
+
const [owner, repo, ...sub] = parts;
|
|
204
|
+
return { owner, repo: repo.replace(/\.git$/i, ''), sub: sub.join('/'), ref: ref || '' };
|
|
205
|
+
}
|
|
206
|
+
|
|
207
|
+
async function ghJson(apiPath, insecure) {
|
|
208
|
+
const res = await httpsRequest(`${GH_API}${apiPath}`, undefined, insecure);
|
|
209
|
+
if (!res.ok) {
|
|
210
|
+
if (res.status === 404) return { error: `not found: ${apiPath}` };
|
|
211
|
+
if (res.status === 403) return { error: 'GitHub refused the request (rate limit without a token, or a private repository)' };
|
|
212
|
+
return { error: `GitHub returned ${res.status}` };
|
|
213
|
+
}
|
|
214
|
+
const buf = Buffer.from(await res.arrayBuffer());
|
|
215
|
+
try { return { data: JSON.parse(buf.toString('utf8')) }; } catch { return { error: 'GitHub returned something that is not JSON' }; }
|
|
216
|
+
}
|
|
217
|
+
|
|
218
|
+
/** Every `SKILL.md` at or below `path`, as { name, path }. */
|
|
219
|
+
async function findSkillFiles(owner, repo, dirPath, ref, insecure, depth = 0) {
|
|
220
|
+
if (depth > 4) return [];
|
|
221
|
+
const q = ref ? `?ref=${encodeURIComponent(ref)}` : '';
|
|
222
|
+
const r = await ghJson(`/repos/${owner}/${repo}/contents/${dirPath}${q}`, insecure);
|
|
223
|
+
if (r.error) return { error: r.error };
|
|
224
|
+
const entries = Array.isArray(r.data) ? r.data : [];
|
|
225
|
+
const found = [];
|
|
226
|
+
for (const e of entries) {
|
|
227
|
+
if (e.type === 'file' && /^skill\.md$/i.test(e.name)) found.push({ name: e.name, path: e.path, url: e.download_url });
|
|
228
|
+
}
|
|
229
|
+
// A directory that holds a SKILL.md is a skill; do not descend into it looking for more, or a bundle of
|
|
230
|
+
// reference files beside one would be walked for nothing.
|
|
231
|
+
if (found.length) return found;
|
|
232
|
+
for (const e of entries) {
|
|
233
|
+
if (e.type !== 'dir') continue;
|
|
234
|
+
const sub = await findSkillFiles(owner, repo, e.path, ref, insecure, depth + 1);
|
|
235
|
+
if (sub && sub.error) continue;
|
|
236
|
+
found.push(...sub);
|
|
237
|
+
}
|
|
238
|
+
return found;
|
|
239
|
+
}
|
|
240
|
+
|
|
241
|
+
/**
|
|
242
|
+
* Install every skill in a GitHub repository (or a subdirectory of one).
|
|
243
|
+
*
|
|
244
|
+
* Returns { ok: true, installed: [{ name, path, replaced }], skipped: [name] } or { ok: false, error }.
|
|
245
|
+
* A name that is already present is REPLACED — the same rule `/import-skill` uses.
|
|
246
|
+
*/
|
|
247
|
+
export async function installSkillFromGitHub(specText, { insecure = true } = {}) {
|
|
248
|
+
const spec = parseRepoSpec(specText);
|
|
249
|
+
if (!spec) return { ok: false, error: `Expected owner/repo[/subdir], got "${specText}"` };
|
|
250
|
+
const { owner, repo, sub, ref } = spec;
|
|
251
|
+
|
|
252
|
+
const files = await findSkillFiles(owner, repo, sub, ref, insecure);
|
|
253
|
+
if (files && files.error) return { ok: false, error: files.error };
|
|
254
|
+
if (!files.length) {
|
|
255
|
+
return { ok: false, error: `no SKILL.md found in ${owner}/${repo}${sub ? '/' + sub : ''}` };
|
|
256
|
+
}
|
|
257
|
+
|
|
258
|
+
const dir = skillsDir();
|
|
259
|
+
const installed = [];
|
|
260
|
+
const skipped = [];
|
|
261
|
+
for (const f of files) {
|
|
262
|
+
// The DIRECTORY name is the skill name — `skills/ds-explain/SKILL.md` is `ds-explain`. The front matter
|
|
263
|
+
// may say something else, but the folder is what the repository is organised by and what the user expects
|
|
264
|
+
// to type after `/skill:`.
|
|
265
|
+
const folder = f.path.split('/').slice(-2)[0] || repo;
|
|
266
|
+
const res = await httpsRequest(f.url, undefined, insecure);
|
|
267
|
+
if (!res.ok) { skipped.push(folder); continue; }
|
|
268
|
+
const text = Buffer.from(await res.arrayBuffer()).toString('utf8');
|
|
269
|
+
const name = normalizeSkillName(folder);
|
|
270
|
+
if (!name || /[\\/]/.test(name)) { skipped.push(folder); continue; }
|
|
271
|
+
const dest = path.join(dir, `${name}.md`);
|
|
272
|
+
const replaced = fs.existsSync(dest);
|
|
273
|
+
try {
|
|
274
|
+
fs.mkdirSync(dir, { recursive: true });
|
|
275
|
+
fs.writeFileSync(dest, text, 'utf8');
|
|
276
|
+
installed.push({ name, path: dest, replaced });
|
|
277
|
+
} catch { skipped.push(folder); }
|
|
278
|
+
}
|
|
279
|
+
return { ok: installed.length > 0, installed, skipped, error: installed.length ? '' : 'nothing could be downloaded' };
|
|
280
|
+
}
|
|
281
|
+
|
|
174
282
|
export function skillPrompt(skill) {
|
|
175
283
|
if (!skill || !skill.body) return '';
|
|
176
284
|
return `[skill: ${skill.name}]\n\n${skill.body}`;
|
|
@@ -0,0 +1,76 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* The ActivateSkill tool: the model loads a skill by name.
|
|
3
|
+
*
|
|
4
|
+
* A skill is a stored procedure — a body of instructions for one kind of job (review a diff, debug a failure,
|
|
5
|
+
* design a UI). Until now the only way to run one was for the USER to type `/skill:<name>`, which means the model
|
|
6
|
+
* could not use a skill at all: it had no idea the directory existed.
|
|
7
|
+
*
|
|
8
|
+
* The system prompt now lists the names and descriptions, and this tool is the other half — the way to act on
|
|
9
|
+
* that list. It returns the BODY, which the model then follows in its own turn.
|
|
10
|
+
*
|
|
11
|
+
* WHY A TOOL AND NOT A SLASH COMMAND: a slash command is handled by the TUI before the model sees anything. This
|
|
12
|
+
* has to be callable mid-turn, in the middle of a multi-step job, when the model realises the remaining work is
|
|
13
|
+
* "review this" and a skill was written for exactly that.
|
|
14
|
+
*/
|
|
15
|
+
import { listSkills, readSkill, normalizeSkillName } from '../skills.js';
|
|
16
|
+
|
|
17
|
+
export const spec = {
|
|
18
|
+
name: 'ActivateSkill',
|
|
19
|
+
description: [
|
|
20
|
+
'Load a skill — a stored procedure for a particular kind of work — and return its instructions.',
|
|
21
|
+
'',
|
|
22
|
+
'The available skills are listed in the system prompt under "# Skills", with a one-line description each.',
|
|
23
|
+
'Call this when one of them fits the job in front of you, then follow the returned instructions.',
|
|
24
|
+
'',
|
|
25
|
+
'The body is the skill\'s full text. It is NOT a summary: read it and do what it says.',
|
|
26
|
+
'Use `list` to see everything installed, including anything added since the prompt was built.',
|
|
27
|
+
].join('\n'),
|
|
28
|
+
parameters: {
|
|
29
|
+
type: 'object',
|
|
30
|
+
properties: {
|
|
31
|
+
names: {
|
|
32
|
+
type: 'array',
|
|
33
|
+
items: { type: 'string' },
|
|
34
|
+
description: 'One or more skill names to load, as listed under "# Skills".',
|
|
35
|
+
},
|
|
36
|
+
list: {
|
|
37
|
+
type: 'boolean',
|
|
38
|
+
description: 'Return the installed skills with their descriptions instead of loading one.',
|
|
39
|
+
},
|
|
40
|
+
},
|
|
41
|
+
},
|
|
42
|
+
async execute(args) {
|
|
43
|
+
if (args && args.list) {
|
|
44
|
+
const all = listSkills();
|
|
45
|
+
if (!all.length) return 'No skills are installed. The user can add one with /import-skill.';
|
|
46
|
+
return all.map((s) => `- ${s.name}${s.description ? `: ${s.description}` : ''}`).join('\n');
|
|
47
|
+
}
|
|
48
|
+
|
|
49
|
+
const raw = args && args.names;
|
|
50
|
+
const wanted = (Array.isArray(raw) ? raw : (raw ? [raw] : []))
|
|
51
|
+
.map((n) => normalizeSkillName(n))
|
|
52
|
+
.filter(Boolean);
|
|
53
|
+
if (!wanted.length) {
|
|
54
|
+
const all = listSkills();
|
|
55
|
+
const names = all.map((s) => s.name).join(', ');
|
|
56
|
+
return `Error: name a skill to activate. Installed: ${names || '(none)'}`;
|
|
57
|
+
}
|
|
58
|
+
|
|
59
|
+
const out = [];
|
|
60
|
+
const missing = [];
|
|
61
|
+
for (const name of wanted) {
|
|
62
|
+
const skill = readSkill(name);
|
|
63
|
+
if (!skill) { missing.push(name); continue; }
|
|
64
|
+
// The header names the skill so a transcript with two of them stays readable; the body is verbatim.
|
|
65
|
+
out.push(`[skill: ${skill.name}]\n\n${skill.body}`);
|
|
66
|
+
}
|
|
67
|
+
|
|
68
|
+
if (!out.length) {
|
|
69
|
+
const all = listSkills();
|
|
70
|
+
return `Error: no skill named ${missing.join(', ')}. Installed: ${all.map((s) => s.name).join(', ') || '(none)'}`;
|
|
71
|
+
}
|
|
72
|
+
// A partial miss is worth saying: silently loading one of two would leave the job half-instructed.
|
|
73
|
+
if (missing.length) out.push(`(not installed: ${missing.join(', ')})`);
|
|
74
|
+
return out.join('\n\n---\n\n');
|
|
75
|
+
},
|
|
76
|
+
};
|
package/src/tools/fetch-url.js
CHANGED
|
@@ -76,7 +76,7 @@ const RETRY_AFTER_MS = 1500;
|
|
|
76
76
|
* `node:https` instead. It is the SAME request — same headers, same redirects, same body cap — just built
|
|
77
77
|
* by hand so `rejectUnauthorized: false` can be passed.
|
|
78
78
|
*/
|
|
79
|
-
async function fetchWithRetry(url, signal, insecure = false) {
|
|
79
|
+
export async function fetchWithRetry(url, signal, insecure = false) {
|
|
80
80
|
if (insecure) return httpsRequest(url, signal, true);
|
|
81
81
|
const first = await fetch(url, { signal, redirect: 'follow', headers: HEADERS });
|
|
82
82
|
if (first.status !== 429) return first;
|
|
@@ -93,7 +93,7 @@ async function fetchWithRetry(url, signal, insecure = false) {
|
|
|
93
93
|
* buffered up to MAX_BYTES and handed back as an object with `.ok`, `.status`, `.headers.get()` and
|
|
94
94
|
* `.arrayBuffer()`, so the rest of `execute` does not know which transport ran.
|
|
95
95
|
*/
|
|
96
|
-
function httpsRequest(url, signal, insecure) {
|
|
96
|
+
export function httpsRequest(url, signal, insecure) {
|
|
97
97
|
return new Promise((resolve, reject) => {
|
|
98
98
|
let u;
|
|
99
99
|
try { u = new URL(url); } catch (e) { reject(e); return; }
|
package/src/tools/index.js
CHANGED
|
@@ -23,6 +23,7 @@ import { spec as notebookSpec } from './notebook.js';
|
|
|
23
23
|
import { spec as diagnosticsSpec } from './diagnostics.js';
|
|
24
24
|
import * as memoryMod from './memory.js';
|
|
25
25
|
import * as goalMod from './goal.js';
|
|
26
|
+
import * as activateSkillMod from './activate-skill.js';
|
|
26
27
|
import { pluginTools, pluginToolGeneration } from '../plugin.js';
|
|
27
28
|
|
|
28
29
|
|
|
@@ -57,6 +58,7 @@ const builtinTools = [
|
|
|
57
58
|
diagnosticsSpec,
|
|
58
59
|
memoryMod.spec,
|
|
59
60
|
goalMod.spec,
|
|
61
|
+
activateSkillMod.spec,
|
|
60
62
|
...TEAM_TOOL_SPECS,
|
|
61
63
|
];
|
|
62
64
|
|
package/src/tui.js
CHANGED
|
@@ -103,7 +103,7 @@ import { renderTaskOutputViewer, handleViewerKey, makeViewerState } from './task
|
|
|
103
103
|
import { renderSwarmProgress } from './swarm-progress.js';
|
|
104
104
|
import { sortedTasks, getTask, settleTask, STATUS_LABEL, backgroundTaskCard } from './agent-task.js';
|
|
105
105
|
import { localVersion } from './version.js';
|
|
106
|
-
import { listSkills, readSkill, importSkill, deleteSkill, skillPrompt, skillCompletions, normalizeSkillName, SKILL_PREFIX, skillsDir } from './skills.js';
|
|
106
|
+
import { listSkills, readSkill, importSkill, installSkillFromGitHub, deleteSkill, skillPrompt, skillCompletions, normalizeSkillName, SKILL_PREFIX, skillsDir } from './skills.js';
|
|
107
107
|
import { loadHooks, describeHooks, runShellHooks, HOOK_EVENTS } from './hooks.js';
|
|
108
108
|
import { beginTurn, rewind as rewindFiles, describeRewind, listCheckpoints, restoreCheckpoint, describeCheckpoints, checkpointDelta } from './file-history.js';
|
|
109
109
|
import { trimToolResults } from './tool-result.js';
|
|
@@ -258,7 +258,7 @@ export const COMMANDS = [
|
|
|
258
258
|
{ name: 'reload-config', desc: 'Reload config.toml settings only', priority: 60 },
|
|
259
259
|
{ name: 'plugins', desc: 'Open the plugin manager (installed + available in the repo)', priority: 60, argumentHint: '[install <name> | remove <name> | reload]' },
|
|
260
260
|
{ name: 'skills', desc: 'Open the skill manager (installed + available in the repo)', priority: 60, argumentHint: '[install <name> | remove <name>]' },
|
|
261
|
-
{ name: 'import-skill', desc: '
|
|
261
|
+
{ name: 'import-skill', desc: 'Add a skill from a Markdown file, or install every skill in a GitHub repository (owner/repo)', cooldown: false },
|
|
262
262
|
{ name: 'hooks', desc: 'Show configured shell hooks, or test one', priority: 60, argumentHint: '[list | test <event>]' },
|
|
263
263
|
{ name: 'git', desc: 'Show repository status (branch, changes, remote)', priority: 62 },
|
|
264
264
|
{ name: 'commit', desc: 'Stage and commit with a message (or let the agent draft one)', priority: 62, argumentHint: '[message]' },
|
|
@@ -7125,7 +7125,8 @@ const queueH = takeover ? 0 : queuePanelHeight(state);
|
|
|
7125
7125
|
const nameField = col(label, selected ? (C.cyan + C.bold) : C.gray);
|
|
7126
7126
|
const hint = cmd.argumentHint ? col(' ' + cmd.argumentHint, C.gray) : '';
|
|
7127
7127
|
const pad = Math.max(2, 18 - visualCol(label) - visualCol(hint));
|
|
7128
|
-
|
|
7128
|
+
// `description` for a skill completion, `desc` for a command: both spellings exist, so accept either.
|
|
7129
|
+
const desc = col(cmd.desc || cmd.description || '', C.gray);
|
|
7129
7130
|
addHit(lines.length, 2, mInner, { kind: 'menuItem', index: off + j });
|
|
7130
7131
|
lines.push(menuRow(mark + nameField + hint + ' '.repeat(pad) + desc));
|
|
7131
7132
|
});
|
|
@@ -10545,7 +10546,7 @@ export async function dispatch(cmdRaw, arg, state, cfg, session, h, submit, stdo
|
|
|
10545
10546
|
...panel,
|
|
10546
10547
|
'Skills',
|
|
10547
10548
|
` /${SKILL_PREFIX}<name> run an installed skill (Tab completes the name)`,
|
|
10548
|
-
|
|
10549
|
+
' /import-skill <file.md | owner/repo> add a skill from a file, or install a repository of them',
|
|
10549
10550
|
' /skills open the skill manager (Tab switches tabs)',
|
|
10550
10551
|
' /skills install <name> download one from the repo, without the panel',
|
|
10551
10552
|
' /skills remove <name> delete the local copy',
|
|
@@ -12519,12 +12520,30 @@ export async function dispatch(cmdRaw, arg, state, cfg, session, h, submit, stdo
|
|
|
12519
12520
|
return;
|
|
12520
12521
|
}
|
|
12521
12522
|
case 'import-skill': {
|
|
12522
|
-
// Usage: /import-skill <file.md> [name]
|
|
12523
|
+
// Usage: /import-skill <file.md | owner/repo[/sub]> [name]
|
|
12523
12524
|
const parts = raw.split(/\s+/).filter(Boolean);
|
|
12524
|
-
const
|
|
12525
|
+
const source = parts[0];
|
|
12525
12526
|
const nameArg = parts[1];
|
|
12526
|
-
if (!
|
|
12527
|
-
|
|
12527
|
+
if (!source) { appErr('Usage: /import-skill <file.md | owner/repo[/subdir]> [name]'); return; }
|
|
12528
|
+
|
|
12529
|
+
// A repository spec has no extension and carries a slash — a local Markdown path always ends in .md, and a
|
|
12530
|
+
// path that does not is not something this command ever accepted.
|
|
12531
|
+
const looksLikeRepo = !/\.md$/i.test(source) && /^[\w.-]+\/[\w.-]+/.test(source);
|
|
12532
|
+
if (looksLikeRepo) {
|
|
12533
|
+
say(`Installing skills from ${source}…`);
|
|
12534
|
+
renderFrame();
|
|
12535
|
+
void installSkillFromGitHub(source).then((res) => {
|
|
12536
|
+
if (!res.ok) { appErr(`Install failed: ${res.error}`); renderFrame(); return; }
|
|
12537
|
+
const names = res.installed.map((s) => s.name);
|
|
12538
|
+
app(`Installed ${names.length} skill(s): ${names.join(', ')}`);
|
|
12539
|
+
if (res.skipped.length) notice(`Skipped: ${res.skipped.join(', ')}`, 'error');
|
|
12540
|
+
say(`Activate one with /skill:<name>, or type /skills to list them.`);
|
|
12541
|
+
renderFrame();
|
|
12542
|
+
}).catch((e) => { appErr(`Install failed: ${(e && e.message) || e}`); renderFrame(); });
|
|
12543
|
+
return;
|
|
12544
|
+
}
|
|
12545
|
+
|
|
12546
|
+
const res = importSkill(source, nameArg);
|
|
12528
12547
|
if (!res.ok) { appErr(res.error); return; }
|
|
12529
12548
|
app(`${res.replaced ? 'Skill overwritten' : 'Skill imported'}: ${res.name} → ${res.path}`);
|
|
12530
12549
|
say(`Activate it with /${SKILL_PREFIX}${res.name}`);
|
|
@@ -12761,7 +12780,15 @@ export async function dispatch(cmdRaw, arg, state, cfg, session, h, submit, stdo
|
|
|
12761
12780
|
if (!url) { appErr('No git remote named "origin" — cannot build a PR URL.'); return; }
|
|
12762
12781
|
const branch = gitmod.currentBranch(cwd);
|
|
12763
12782
|
sayPanel(`Open a pull request for ${branch} → ${base}:\n ${url}`);
|
|
12764
|
-
|
|
12783
|
+
// ACTUALLY COPY IT, and report what happened.
|
|
12784
|
+
//
|
|
12785
|
+
// The notice used to claim the link was copied while the clipboard was left alone, so the paste that followed
|
|
12786
|
+
// produced whatever was copied last. `copyText` also signals failure by RETURNING false rather than throwing,
|
|
12787
|
+
// so its result is what decides the message — a try/catch around it announced success either way.
|
|
12788
|
+
let copied = false;
|
|
12789
|
+
try { copied = copyText(url); } catch { copied = false; }
|
|
12790
|
+
if (copied) app('PR link copied to your clipboard');
|
|
12791
|
+
else app('Could not reach the clipboard \u2014 the link is on screen above.');
|
|
12765
12792
|
return;
|
|
12766
12793
|
}
|
|
12767
12794
|
case 'cache': {
|
|
@@ -12938,12 +12965,15 @@ export async function dispatch(cmdRaw, arg, state, cfg, session, h, submit, stdo
|
|
|
12938
12965
|
// and the model saw it as a detached system note rather than as their words.
|
|
12939
12966
|
const extra = String(raw || '').trim();
|
|
12940
12967
|
const prompt = skillPrompt(sk) + (extra ? `\n\n${extra}` : '');
|
|
12941
|
-
//
|
|
12942
|
-
//
|
|
12943
|
-
//
|
|
12944
|
-
//
|
|
12968
|
+
// THE CARD says which skill ran; the USER MESSAGE is what the person typed, if anything.
|
|
12969
|
+
//
|
|
12970
|
+
// The args used to travel twice: once as `bubbleText`, which drew a bubble identical to the composer below the
|
|
12971
|
+
// card, and once appended to the hidden body. Passing them as the prompt is what makes them the user's words —
|
|
12972
|
+
// and when there are none, nothing is drawn but the card.
|
|
12945
12973
|
addChat({ role: 'skill', text: name, args: extra, _conv: true });
|
|
12946
|
-
|
|
12974
|
+
// `submit` drops an empty prompt, so an activation with no args needs something to send. The body itself
|
|
12975
|
+
// is what does the work; this line is only what makes the turn happen.
|
|
12976
|
+
await sendPrompt(extra || `Run the ${name} skill.`, { skillBody: prompt });
|
|
12947
12977
|
return;
|
|
12948
12978
|
}
|
|
12949
12979
|
// Check if this is a plugin-registered command.
|
|
@@ -13857,6 +13887,29 @@ async function runTurn(deps, text, opts = {}, viewArg) {
|
|
|
13857
13887
|
if (memory) {
|
|
13858
13888
|
sysText += '\n\n' + memory;
|
|
13859
13889
|
}
|
|
13890
|
+
|
|
13891
|
+
// THE SKILLS ON DISK, BY NAME AND DESCRIPTION.
|
|
13892
|
+
//
|
|
13893
|
+
// Without this the model has no way to know a skill exists: the directory is invisible to it, so a skill that
|
|
13894
|
+
// fits the task went unused unless the user remembered the name. Names and descriptions only — the bodies are
|
|
13895
|
+
// read from disk when activated, and putting every body in every request would defeat the point of a skill.
|
|
13896
|
+
//
|
|
13897
|
+
// Read per turn, like the memory notes above, so installing one takes effect on the next message.
|
|
13898
|
+
const availableSkills = listSkills().filter((s) => s.name);
|
|
13899
|
+
if (availableSkills.length) {
|
|
13900
|
+
const listed = availableSkills
|
|
13901
|
+
.map((s) => `- ${s.name}${s.description ? `: ${s.description}` : ''}`)
|
|
13902
|
+
.join('\n');
|
|
13903
|
+
sysText += '\n\n' + [
|
|
13904
|
+
'# Skills',
|
|
13905
|
+
'',
|
|
13906
|
+
'Reusable procedures installed for this machine. Each is invoked by name and then supplies its own',
|
|
13907
|
+
'instructions. Use the ActivateSkill tool when one fits the work in front of you; the user can also call',
|
|
13908
|
+
'them directly with /skill:<name>.',
|
|
13909
|
+
'',
|
|
13910
|
+
listed,
|
|
13911
|
+
].join('\n');
|
|
13912
|
+
}
|
|
13860
13913
|
// OUTPUT STYLE (/output-style): a form-of-reply instruction read from a file,
|
|
13861
13914
|
// re-read per turn like the notes above so editing the file takes effect on the
|
|
13862
13915
|
// next request. Placed AFTER the preferences and memory but BEFORE AGENTS.md:
|
|
@@ -14087,7 +14140,7 @@ const agents = readAgentsMd(state.cwd || state.workspace || cfg.workspace, state
|
|
|
14087
14140
|
// Tools that act ONLY inside the session: the roster, the mailbox, the task list, the memories.
|
|
14088
14141
|
// `isInsideWorkspace` answers `false` for any tool it does not know, so without this they all asked for
|
|
14089
14142
|
// approval in yolo — the one mode whose point is not asking.
|
|
14090
|
-
const safeTools = ['Read', 'Grep', 'Glob', 'FetchURL', 'WebSearch', 'TaskOutput', 'TaskList', 'TaskStop', 'TaskWait', 'TodoList', 'Goal', 'FileLines', 'AgentList', 'AgentMessage', 'AgentInterrupt', 'AgentClose', 'TaskInput', 'SendMessage', 'TeamTaskCreate', 'TeamTaskUpdate', 'TeamTaskList', 'ListTeammates', 'Memory', 'AskUserQuestion'];
|
|
14143
|
+
const safeTools = ['Read', 'Grep', 'Glob', 'FetchURL', 'WebSearch', 'TaskOutput', 'TaskList', 'TaskStop', 'TaskWait', 'TodoList', 'Goal', 'ActivateSkill', 'FileLines', 'AgentList', 'AgentMessage', 'AgentInterrupt', 'AgentClose', 'TaskInput', 'SendMessage', 'TeamTaskCreate', 'TeamTaskUpdate', 'TeamTaskList', 'ListTeammates', 'Memory', 'AskUserQuestion'];
|
|
14091
14144
|
if (rule !== 'ask' && safeTools.includes(toolName)) return true;
|
|
14092
14145
|
// A read-only SHELL COMMAND (ls/git status/rg/cat …) is as safe as a
|
|
14093
14146
|
// read-only TOOL, so it runs without a prompt in Ask mode too — the user
|
|
@@ -16510,9 +16563,9 @@ export async function startTUI(opts) {
|
|
|
16510
16563
|
async function registryAct() {
|
|
16511
16564
|
const b = state.registryBrowser;
|
|
16512
16565
|
if (!b || !b.data) return;
|
|
16513
|
-
const {
|
|
16566
|
+
const { visibleRowsForTab } = await import('./registry-browser.js');
|
|
16514
16567
|
const remote = await import('./remote.js');
|
|
16515
|
-
const rows =
|
|
16568
|
+
const rows = visibleRowsForTab(b, b.tab);
|
|
16516
16569
|
const row = rows[Math.min(b.selectedIndex || 0, Math.max(0, rows.length - 1))];
|
|
16517
16570
|
if (!row || row.disabled || row.action === 'none') return;
|
|
16518
16571
|
const name = row.name;
|