@hncode/hncode 0.6.5 → 0.6.6

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 CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@hncode/hncode",
3
- "version": "0.6.5",
3
+ "version": "0.6.6",
4
4
  "description": "hncode — A cyan-blue TUI AI coding agent with zero runtime dependencies.",
5
5
  "type": "module",
6
6
  "main": "src/index.js",
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
- const m = /^\uFEFF?---\r?\n([\s\S]*?)\r?\n---[ \t]*\r?\n?/.exec(src);
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
+ };
@@ -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; }
@@ -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: 'Import a Markdown file as a skill (a duplicate name is overwritten)', priority: 60, argumentHint: '<file.md> [name]' },
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]' },
@@ -10545,7 +10545,7 @@ export async function dispatch(cmdRaw, arg, state, cfg, session, h, submit, stdo
10545
10545
  ...panel,
10546
10546
  'Skills',
10547
10547
  ` /${SKILL_PREFIX}<name> run an installed skill (Tab completes the name)`,
10548
- ' /import-skill <file.md> add or overwrite a skill from a Markdown file',
10548
+ ' /import-skill <file.md | owner/repo> add a skill from a file, or install a repository of them',
10549
10549
  ' /skills open the skill manager (Tab switches tabs)',
10550
10550
  ' /skills install <name> download one from the repo, without the panel',
10551
10551
  ' /skills remove <name> delete the local copy',
@@ -12519,12 +12519,30 @@ export async function dispatch(cmdRaw, arg, state, cfg, session, h, submit, stdo
12519
12519
  return;
12520
12520
  }
12521
12521
  case 'import-skill': {
12522
- // Usage: /import-skill <file.md> [name]
12522
+ // Usage: /import-skill <file.md | owner/repo[/sub]> [name]
12523
12523
  const parts = raw.split(/\s+/).filter(Boolean);
12524
- const file = parts[0];
12524
+ const source = parts[0];
12525
12525
  const nameArg = parts[1];
12526
- if (!file) { appErr('Usage: /import-skill <file.md> [name]'); return; }
12527
- const res = importSkill(file, nameArg);
12526
+ if (!source) { appErr('Usage: /import-skill <file.md | owner/repo[/subdir]> [name]'); return; }
12527
+
12528
+ // A repository spec has no extension and carries a slash — a local Markdown path always ends in .md, and a
12529
+ // path that does not is not something this command ever accepted.
12530
+ const looksLikeRepo = !/\.md$/i.test(source) && /^[\w.-]+\/[\w.-]+/.test(source);
12531
+ if (looksLikeRepo) {
12532
+ say(`Installing skills from ${source}…`);
12533
+ renderFrame();
12534
+ void installSkillFromGitHub(source).then((res) => {
12535
+ if (!res.ok) { appErr(`Install failed: ${res.error}`); renderFrame(); return; }
12536
+ const names = res.installed.map((s) => s.name);
12537
+ app(`Installed ${names.length} skill(s): ${names.join(', ')}`);
12538
+ if (res.skipped.length) notice(`Skipped: ${res.skipped.join(', ')}`, 'error');
12539
+ say(`Activate one with /skill:<name>, or type /skills to list them.`);
12540
+ renderFrame();
12541
+ }).catch((e) => { appErr(`Install failed: ${(e && e.message) || e}`); renderFrame(); });
12542
+ return;
12543
+ }
12544
+
12545
+ const res = importSkill(source, nameArg);
12528
12546
  if (!res.ok) { appErr(res.error); return; }
12529
12547
  app(`${res.replaced ? 'Skill overwritten' : 'Skill imported'}: ${res.name} → ${res.path}`);
12530
12548
  say(`Activate it with /${SKILL_PREFIX}${res.name}`);
@@ -12938,12 +12956,15 @@ export async function dispatch(cmdRaw, arg, state, cfg, session, h, submit, stdo
12938
12956
  // and the model saw it as a detached system note rather than as their words.
12939
12957
  const extra = String(raw || '').trim();
12940
12958
  const prompt = skillPrompt(sk) + (extra ? `\n\n${extra}` : '');
12941
- // The row is a SKILL ACTIVATION card, not a user bubble and not the literal
12942
- // `/skill:foo` text (which read as the user having typed a command). The body
12943
- // rides a system message so it never renders as the user's own speech — the
12944
- // distinction kimi draws, and the reason this row exists at all.
12959
+ // THE CARD says which skill ran; the USER MESSAGE is what the person typed, if anything.
12960
+ //
12961
+ // The args used to travel twice: once as `bubbleText`, which drew a bubble identical to the composer below the
12962
+ // card, and once appended to the hidden body. Passing them as the prompt is what makes them the user's words —
12963
+ // and when there are none, nothing is drawn but the card.
12945
12964
  addChat({ role: 'skill', text: name, args: extra, _conv: true });
12946
- await sendPrompt('/' + SKILL_PREFIX + name, { skillBody: prompt, bubbleText: extra });
12965
+ // `submit` drops an empty prompt, so an activation with no args needs something to send. The body itself
12966
+ // is what does the work; this line is only what makes the turn happen.
12967
+ await sendPrompt(extra || `Run the ${name} skill.`, { skillBody: prompt });
12947
12968
  return;
12948
12969
  }
12949
12970
  // Check if this is a plugin-registered command.
@@ -13857,6 +13878,29 @@ async function runTurn(deps, text, opts = {}, viewArg) {
13857
13878
  if (memory) {
13858
13879
  sysText += '\n\n' + memory;
13859
13880
  }
13881
+
13882
+ // THE SKILLS ON DISK, BY NAME AND DESCRIPTION.
13883
+ //
13884
+ // Without this the model has no way to know a skill exists: the directory is invisible to it, so a skill that
13885
+ // fits the task went unused unless the user remembered the name. Names and descriptions only — the bodies are
13886
+ // read from disk when activated, and putting every body in every request would defeat the point of a skill.
13887
+ //
13888
+ // Read per turn, like the memory notes above, so installing one takes effect on the next message.
13889
+ const availableSkills = listSkills().filter((s) => s.name);
13890
+ if (availableSkills.length) {
13891
+ const listed = availableSkills
13892
+ .map((s) => `- ${s.name}${s.description ? `: ${s.description}` : ''}`)
13893
+ .join('\n');
13894
+ sysText += '\n\n' + [
13895
+ '# Skills',
13896
+ '',
13897
+ 'Reusable procedures installed for this machine. Each is invoked by name and then supplies its own',
13898
+ 'instructions. Use the ActivateSkill tool when one fits the work in front of you; the user can also call',
13899
+ 'them directly with /skill:<name>.',
13900
+ '',
13901
+ listed,
13902
+ ].join('\n');
13903
+ }
13860
13904
  // OUTPUT STYLE (/output-style): a form-of-reply instruction read from a file,
13861
13905
  // re-read per turn like the notes above so editing the file takes effect on the
13862
13906
  // next request. Placed AFTER the preferences and memory but BEFORE AGENTS.md: