@hmharness/cli 0.1.1 → 0.3.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
package/dist/main.js CHANGED
@@ -8,7 +8,7 @@
8
8
  * hmh resume [id-prefix] continue a past session by id prefix (or latest)
9
9
  * hmh web [--port=7788] local web frontend (SSE streaming + approvals)
10
10
  * hmh tui lite terminal UI (status header + slash commands)
11
- * hmh ops [scan|brief|status] ops keeper: ecosystem radar
11
+ * hmh ops [scan|brief|stats|status] ops keeper: radar / npm download stats
12
12
  * hmh devices|check direct tool run, no model
13
13
  * hmh tools list all registered tools (native + MCP)
14
14
  * hmh mcp show configured MCP servers and their tools
@@ -98,6 +98,14 @@ async function repl(yes, initialHistory) {
98
98
  let t = strings((cfg.locale ?? 'zh'));
99
99
  const header = () => stdout.write(CYAN('hmh') + DIM(` · ${cfg.provider.model} · ${home}\n`));
100
100
  stdout.write(CYAN('hmh') + DIM(` · ${cfg.provider.model} · ${home}\n`) + DIM(`${t.replHint} · /help ${String(t.cmdHelp)}\n\n`));
101
+ // npm is pull-based; the update reminder is a cached (1/day) registry
102
+ // check printed when resolved - never blocks, never nags offline
103
+ const { notifyUpdate } = await import("./update-check.js");
104
+ const { createRequire } = await import('node:module');
105
+ const CURRENT_VERSION = createRequire(import.meta.url)('../package.json').version;
106
+ void notifyUpdate(home, CURRENT_VERSION, (latest) => {
107
+ stdout.write(DIM(`↑ ${t.updateHint(latest)}\n\n`));
108
+ });
101
109
  const { reg, clients } = await buildRegistry();
102
110
  const rl = readline.createInterface({ input: stdin, output: stdout });
103
111
  // stdin EOF (piped input, closed terminal) must exit the loop - a bare
@@ -325,14 +333,21 @@ usage:
325
333
  hmh web [--port=7788] web UI in the foreground (debugging)
326
334
  hmh tui [--no-web] fullscreen terminal UI (slash palette, mouse wheel);
327
335
  also starts the web UI in the background (--no-web skips)
328
- hmh ops [scan|brief|status] ops keeper: ecosystem radar
336
+ hmh ops [scan|brief|stats|status] ops keeper: radar / npm download stats
337
+ hmh mcp-serve run as an MCP stdio SERVER: expose harmony_* tools to
338
+ Claude Code / Codex / any MCP host
339
+ (host config: npx -y @hmharness/cli mcp-serve)
329
340
  hmh devices|check direct tool run, no model
330
341
  hmh tools list all registered tools (native + MCP)
331
342
  hmh mcp show configured MCP servers and their tools
332
343
  hmh evolve [--every=N] self-evolution cycle (or resident loop)
333
- hmh bench run the evolution bench
344
+ hmh bench [--impact] run the evolution bench / canary A/B report
334
345
  hmh skills [--promote|--rollback|--unpromote <name>]
335
346
  hmh skills add <git-url-or-local-dir> install skills (multi-skill packs supported)
347
+ hmh state backup [--full] | restore [id] | remove <id|--all> | list
348
+ snapshot / recover the evolution state (skills,
349
+ memory, insights, logs); restore parks current
350
+ state in a .pre-restore copy first
336
351
 
337
352
  flags:
338
353
  --yes / -y auto-approve gated tools (else they prompt; non-TTY denies)
@@ -397,6 +412,14 @@ flags:
397
412
  }
398
413
  return;
399
414
  }
415
+ if (cmd === 'mcp-serve') {
416
+ // SERVER mode: expose the harmony_* tool surface over stdio MCP so
417
+ // Claude Code / Codex / any MCP host calls them natively. stdout is the
418
+ // protocol - run this exactly as the host's server command.
419
+ const { serveMcp } = await import("./mcp-server.js");
420
+ await serveMcp();
421
+ return; // serveMcp exits when stdin closes
422
+ }
400
423
  if (cmd === 'skills') {
401
424
  const home = homeDir();
402
425
  const flag = rest.find((a) => a.startsWith('--'));
@@ -562,12 +585,60 @@ flags:
562
585
  const r = await harmonyOpsRadarBrief.execute({}, ctx);
563
586
  stdout.write(r.output + '\n');
564
587
  }
588
+ else if (sub === 'stats') {
589
+ // npm download counts for the seven packages (public API, no auth).
590
+ const { fetchNpmStats, renderStats } = await import("./npm-stats.js");
591
+ try {
592
+ stdout.write(renderStats(await fetchNpmStats()) + '\n');
593
+ }
594
+ catch {
595
+ stdout.write('npm downloads API unreachable right now - try again later\n');
596
+ }
597
+ }
565
598
  else {
566
599
  const r = await harmonyOpsStatus.execute({}, ctx);
567
600
  stdout.write(r.output + '\n');
568
601
  }
569
602
  return;
570
603
  }
604
+ if (cmd === 'state') {
605
+ // The evolution state (skills+memory+insights+logs) is a single-point
606
+ // asset; backup/restore/list keeps one bad JSONL from erasing the
607
+ // agent's whole learning history. Restore always parks the current
608
+ // state in a .pre-restore copy first (undoable by construction).
609
+ await initHome();
610
+ const { backupState, listBackups, restoreState, removeBackup } = await import("./state.js");
611
+ const sub = rest[0] ?? 'list';
612
+ if (sub === 'backup') {
613
+ const full = rest.includes('--full');
614
+ const r = await backupState(homeDir(), { full });
615
+ stdout.write(GREEN('✓') + ` backup ${r.id} (${r.items.length} items${full ? ', incl. sessions' : ''}) -> ${r.dir}\n`
616
+ + DIM('restore with: hmh state restore ' + r.id + '\n'));
617
+ return;
618
+ }
619
+ if (sub === 'restore') {
620
+ const id = rest.find((a) => !a.startsWith('-') && a !== 'restore');
621
+ const r = await restoreState(homeDir(), id);
622
+ stdout.write(GREEN('✓') + ` restored ${r.id} (${r.restored.length} items)\n`
623
+ + DIM(`current state parked at ${r.parked} (delete it if unwanted)\n`));
624
+ return;
625
+ }
626
+ if (sub === 'remove') {
627
+ const id = rest.find((a) => !a.startsWith('-') && a !== 'remove');
628
+ if (!id && !rest.includes('--all')) {
629
+ stdout.write('usage: hmh state remove <id | --all>\n');
630
+ return;
631
+ }
632
+ const removed = await removeBackup(homeDir(), id ?? '', { all: rest.includes('--all') });
633
+ stdout.write(`removed ${removed.length} backup(s)\n`);
634
+ return;
635
+ }
636
+ const list = await listBackups(homeDir());
637
+ stdout.write(list.length
638
+ ? list.map((b) => ` ${b.id} ${b.items.length} items${b.full ? ' (full)' : ''} ${b.time}`).join('\n') + '\n'
639
+ : DIM(' no backups yet - run "hmh state backup"\n'));
640
+ return;
641
+ }
571
642
  if (cmd === 'tui') {
572
643
  await initHome();
573
644
  // tui(yes, noWeb): inside the TTY check the TUI auto-links the web UI
@@ -0,0 +1 @@
1
+ export declare function serveMcp(): Promise<void>;
@@ -0,0 +1,134 @@
1
+ /**
2
+ * @hmharness/cli - mcp-server (stdio MCP server mode)
3
+ * Turns hmharness into a native tool provider for MCP hosts (Claude Code,
4
+ * Codex, Cursor, ...): they call harmony_build / harmony_api_lookup / ... as
5
+ * first-class tools - no nested agent loop, no double context, and the host's
6
+ * per-tool permission UI becomes the approval gate.
7
+ *
8
+ * Security model in server mode (deliberate, documented):
9
+ * - EXPOSED: only tools matching /^harmony_/ by default (the HarmonyOS
10
+ * domain surface). Generic tools (run_command, write_file, ...) stay
11
+ * private - the host already has bash/file tools of its own. Override
12
+ * with HMH_MCP_TOOLS="name_or_prefix,name_or_prefix,...".
13
+ * - APPROVAL: tool-level needsApproval is intentionally NOT consulted -
14
+ * the host prompts its user per tool call. What still applies is the
15
+ * hard walls INSIDE every tool (destructive-command deny, path red
16
+ * lines): those never depended on interaction and stay server-side.
17
+ * - OBSERVABILITY: every tools/call is appended to
18
+ * insights/mcp-calls.jsonl (tool, ok, ms) - external agents' HarmonyOS
19
+ * usage becomes visible to the radar/insight pipeline (observation only;
20
+ * the skill gate stays exclusive to native hmh sessions).
21
+ *
22
+ * Protocol: line-delimited JSON-RPC 2.0 over stdio (initialize /
23
+ * notifications/initialized / tools/list / tools/call / ping), the same
24
+ * shape scripts/test-mcp-server.mjs proved in-repo. stdout belongs to the
25
+ * protocol - every stray console.log is redirected to stderr.
26
+ */
27
+ import { appendFile, mkdir } from 'node:fs/promises';
28
+ import { createRequire } from 'node:module';
29
+ import readline from 'node:readline';
30
+ import { join } from 'node:path';
31
+ import { homeDir } from '@hmharness/kernel';
32
+ import { buildRegistry } from '@hmharness/agent';
33
+ const VERSION = (() => {
34
+ try {
35
+ return createRequire(import.meta.url)('../package.json').version;
36
+ }
37
+ catch {
38
+ return '0.0.0';
39
+ }
40
+ })();
41
+ function exposedFilter() {
42
+ const raw = process.env.HMH_MCP_TOOLS;
43
+ if (raw && raw.trim()) {
44
+ const pats = raw.split(',').map((s) => s.trim()).filter(Boolean);
45
+ return (t) => pats.some((p) => t.name === p || t.name.startsWith(p.endsWith('_') || p.endsWith('-') ? p : p + '_'));
46
+ }
47
+ return (t) => t.name.startsWith('harmony_');
48
+ }
49
+ export async function serveMcp() {
50
+ // stdout is the protocol channel: silence any library prints.
51
+ const realLog = console.log;
52
+ console.log = (...a) => console.error(...a);
53
+ void realLog;
54
+ const { reg } = await buildRegistry({ mcp: false, announce: false });
55
+ const tools = reg.list().filter(exposedFilter());
56
+ const byName = new Map(tools.map((t) => [t.name, t]));
57
+ const ctx = { cwd: process.cwd(), home: homeDir() };
58
+ const logCall = async (tool, ok, ms) => {
59
+ try {
60
+ const dir = join(ctx.home, 'insights');
61
+ await mkdir(dir, { recursive: true });
62
+ await appendFile(join(dir, 'mcp-calls.jsonl'), JSON.stringify({ time: new Date().toISOString(), tool, ok, ms }) + '\n', 'utf8');
63
+ }
64
+ catch { /* observation is best-effort, never fails a call */ }
65
+ };
66
+ const reply = (id, result, error) => {
67
+ const out = { jsonrpc: '2.0', id };
68
+ if (error)
69
+ out.error = error;
70
+ else
71
+ out.result = result;
72
+ process.stdout.write(JSON.stringify(out) + '\n');
73
+ };
74
+ const rl = readline.createInterface({ input: process.stdin });
75
+ rl.on('line', (line) => {
76
+ line = line.trim();
77
+ if (!line)
78
+ return;
79
+ let msg;
80
+ try {
81
+ msg = JSON.parse(line);
82
+ }
83
+ catch {
84
+ return;
85
+ }
86
+ if (msg.id === undefined)
87
+ return; // notification (notifications/initialized etc.) - no reply
88
+ switch (msg.method) {
89
+ case 'initialize':
90
+ reply(msg.id, {
91
+ protocolVersion: '2025-06-18',
92
+ capabilities: { tools: {} },
93
+ serverInfo: { name: 'hmharness', version: VERSION },
94
+ });
95
+ break;
96
+ case 'ping':
97
+ reply(msg.id, {});
98
+ break;
99
+ case 'tools/list':
100
+ reply(msg.id, {
101
+ tools: tools.map((t) => ({
102
+ name: t.name,
103
+ description: t.description.slice(0, 1000),
104
+ inputSchema: t.parameters,
105
+ })),
106
+ });
107
+ break;
108
+ case 'tools/call': {
109
+ const name = msg.params?.name ?? '';
110
+ const tool = byName.get(name);
111
+ if (!tool) {
112
+ void logCall(name, false, 0);
113
+ reply(msg.id, { content: [{ type: 'text', text: `unknown or not exposed tool: ${name}` }], isError: true });
114
+ break;
115
+ }
116
+ const t0 = Date.now();
117
+ tool.execute(msg.params?.arguments ?? {}, ctx)
118
+ .then(async (r) => {
119
+ await logCall(name, r.isError !== true, Date.now() - t0);
120
+ reply(msg.id, { content: [{ type: 'text', text: r.output }], ...(r.isError === true ? { isError: true } : {}) });
121
+ })
122
+ .catch(async (err) => {
123
+ await logCall(name, false, Date.now() - t0);
124
+ reply(msg.id, { content: [{ type: 'text', text: String(err) }], isError: true });
125
+ });
126
+ break;
127
+ }
128
+ default:
129
+ reply(msg.id, undefined, { code: -32601, message: `method not found: ${msg.method}` });
130
+ }
131
+ });
132
+ // stdin closed (host shut us down) - exit cleanly.
133
+ rl.on('close', () => process.exit(0));
134
+ }
@@ -0,0 +1,8 @@
1
+ export interface PkgStat {
2
+ name: string;
3
+ day: number | null;
4
+ week: number | null;
5
+ month: number | null;
6
+ }
7
+ export declare function fetchNpmStats(fetchImpl?: typeof fetch): Promise<PkgStat[]>;
8
+ export declare function renderStats(rows: PkgStat[]): string;
@@ -0,0 +1,41 @@
1
+ /**
2
+ * @hmharness/cli - npm-stats
3
+ * `hmh ops stats`: download counts for the seven @hmharness packages from
4
+ * npm's public downloads API. Counts are DOWNLOADS, not users - mirror sync
5
+ * and scanners are included; the line under the table says so (honesty over
6
+ * vanity metrics).
7
+ */
8
+ const PKGS = ['kernel', 'evolution', 'domain-harmony', 'domain-ops', 'agent', 'web', 'cli'];
9
+ async function one(fetchImpl, period, name) {
10
+ try {
11
+ const res = await fetchImpl(`https://api.npmjs.org/downloads/point/${period}/${encodeURIComponent(name)}`, {
12
+ signal: AbortSignal.timeout(8000),
13
+ });
14
+ if (!res.ok)
15
+ return null;
16
+ const j = await res.json();
17
+ return typeof j.downloads === 'number' ? j.downloads : null;
18
+ }
19
+ catch {
20
+ return null;
21
+ }
22
+ }
23
+ export async function fetchNpmStats(fetchImpl = fetch) {
24
+ const rows = await Promise.all(PKGS.map(async (p) => {
25
+ const name = '@hmharness/' + p;
26
+ const [day, week, month] = await Promise.all([
27
+ one(fetchImpl, 'last-day', name),
28
+ one(fetchImpl, 'last-week', name),
29
+ one(fetchImpl, 'last-month', name),
30
+ ]);
31
+ return { name, day, week, month };
32
+ }));
33
+ return rows;
34
+ }
35
+ export function renderStats(rows) {
36
+ const n = (v) => (v === null ? '-' : String(v));
37
+ const w = (s, len) => s.padEnd(len);
38
+ const head = w('package', 28) + w('day', 7) + w('week', 8) + 'month';
39
+ const body = rows.map((r) => w(r.name, 28) + w(n(r.day), 7) + w(n(r.week), 8) + n(r.month)).join('\n');
40
+ return head + '\n' + body + '\n(downloads, not users: mirror sync + scanners included; CN installs via npmmirror are NOT counted)';
41
+ }
@@ -0,0 +1,28 @@
1
+ /** Timestamped backup id: sorts lexicographically == chronologically. */
2
+ export declare function backupState(home: string, opts?: {
3
+ full?: boolean;
4
+ }): Promise<{
5
+ id: string;
6
+ items: string[];
7
+ dir: string;
8
+ }>;
9
+ export interface BackupInfo {
10
+ id: string;
11
+ time: string;
12
+ full: boolean;
13
+ items: string[];
14
+ }
15
+ export declare function listBackups(home: string): Promise<BackupInfo[]>;
16
+ /** Restore by id (or the latest when omitted). The CURRENT state is parked
17
+ * in backups/.pre-restore-<ts>/ before anything is copied back, so a wrong
18
+ * pick is undoable. Only items present in the backup are restored. */
19
+ export declare function restoreState(home: string, id?: string): Promise<{
20
+ id: string;
21
+ restored: string[];
22
+ parked: string;
23
+ }>;
24
+ /** Remove a backup by id (or all with --all). Refuses to touch anything
25
+ * that is not under backups/, and refuses the newest backup unless --all. */
26
+ export declare function removeBackup(home: string, id: string, opts?: {
27
+ all?: boolean;
28
+ }): Promise<string[]>;
package/dist/state.js ADDED
@@ -0,0 +1,120 @@
1
+ /**
2
+ * @hmharness/cli - state (HMH_HOME backup / restore / list)
3
+ * The evolution state (skills + memory + insights + evolution logs + bench
4
+ * cases + pareto archive) is a single-point asset: one corrupted JSONL and
5
+ * the agent's entire learning history is gone. `hmh state backup` snapshots
6
+ * the irreplaceable parts into HMH_HOME/backups/<timestamp>/ as plain files
7
+ * (no archive format - restorable by copy even with a broken toolchain);
8
+ * `hmh state restore` swaps one back AFTER parking the current state in a
9
+ * .pre-restore-<ts> safety copy, so a botched restore is itself restorable.
10
+ */
11
+ import { cp, mkdir, readdir, rename, rm, stat, writeFile } from 'node:fs/promises';
12
+ import { join } from 'node:path';
13
+ /** The irreplaceable set. `sessions/` (large, and evidence rather than
14
+ * state) is only included with --full. */
15
+ const STATE_ITEMS = [
16
+ 'config.json',
17
+ 'workspaces.json',
18
+ 'memory.md',
19
+ 'memory',
20
+ 'skills',
21
+ 'insights',
22
+ 'evolution',
23
+ 'bench',
24
+ 'ops',
25
+ ];
26
+ async function exists(p) {
27
+ try {
28
+ await stat(p);
29
+ return true;
30
+ }
31
+ catch {
32
+ return false;
33
+ }
34
+ }
35
+ function backupsDir(home) {
36
+ return join(home, 'backups');
37
+ }
38
+ /** Timestamped backup id: sorts lexicographically == chronologically. */
39
+ export async function backupState(home, opts = {}) {
40
+ const id = new Date().toISOString().replace(/[:.]/g, '-');
41
+ const dir = join(backupsDir(home), id);
42
+ await mkdir(dir, { recursive: true });
43
+ const items = [...STATE_ITEMS];
44
+ if (opts.full)
45
+ items.push('sessions');
46
+ const copied = [];
47
+ for (const item of items) {
48
+ const src = join(home, item);
49
+ if (await exists(src)) {
50
+ await cp(src, join(dir, item), { recursive: true });
51
+ copied.push(item);
52
+ }
53
+ }
54
+ await writeFile(join(dir, 'manifest.json'), JSON.stringify({
55
+ time: new Date().toISOString(),
56
+ full: opts.full === true,
57
+ items: copied,
58
+ }, null, 2), 'utf8');
59
+ return { id, items: copied, dir };
60
+ }
61
+ export async function listBackups(home) {
62
+ const dir = backupsDir(home);
63
+ if (!await exists(dir))
64
+ return [];
65
+ const out = [];
66
+ for (const name of await readdir(dir)) {
67
+ if (name.startsWith('.'))
68
+ continue;
69
+ try {
70
+ const m = JSON.parse(await (await import('node:fs/promises')).readFile(join(dir, name, 'manifest.json'), 'utf8'));
71
+ out.push({ id: name, time: m.time, full: m.full === true, items: m.items ?? [] });
72
+ }
73
+ catch { /* not a backup dir - skip */ }
74
+ }
75
+ return out.sort((a, b) => b.id.localeCompare(a.id));
76
+ }
77
+ /** Restore by id (or the latest when omitted). The CURRENT state is parked
78
+ * in backups/.pre-restore-<ts>/ before anything is copied back, so a wrong
79
+ * pick is undoable. Only items present in the backup are restored. */
80
+ export async function restoreState(home, id) {
81
+ const backups = await listBackups(home);
82
+ if (backups.length === 0)
83
+ throw new Error('no backups found - run "hmh state backup" first');
84
+ const chosen = id ? backups.find((b) => b.id === id) : backups[0];
85
+ if (!chosen)
86
+ throw new Error(`no backup matches "${id}" (available: ${backups.map((b) => b.id).join(', ')})`);
87
+ // 1. park the current state
88
+ const parkId = `.pre-restore-${new Date().toISOString().replace(/[:.]/g, '-')}`;
89
+ const parkDir = join(backupsDir(home), parkId);
90
+ await mkdir(parkDir, { recursive: true });
91
+ for (const item of chosen.items) {
92
+ const cur = join(home, item);
93
+ if (await exists(cur))
94
+ await rename(cur, join(parkDir, item));
95
+ }
96
+ // 2. copy the backup in
97
+ const src = join(backupsDir(home), chosen.id);
98
+ const restored = [];
99
+ for (const item of chosen.items) {
100
+ if (await exists(join(src, item))) {
101
+ await cp(join(src, item), join(home, item), { recursive: true });
102
+ restored.push(item);
103
+ }
104
+ }
105
+ return { id: chosen.id, restored, parked: parkDir };
106
+ }
107
+ /** Remove a backup by id (or all with --all). Refuses to touch anything
108
+ * that is not under backups/, and refuses the newest backup unless --all. */
109
+ export async function removeBackup(home, id, opts = {}) {
110
+ const backups = await listBackups(home);
111
+ const targets = opts.all ? backups : backups.filter((b) => b.id === id);
112
+ if (!opts.all && targets.length === 0)
113
+ throw new Error(`no backup matches "${id}"`);
114
+ const removed = [];
115
+ for (const t of targets) {
116
+ await rm(join(backupsDir(home), t.id), { recursive: true, force: true });
117
+ removed.push(t.id);
118
+ }
119
+ return removed;
120
+ }
package/dist/tui.js CHANGED
@@ -831,6 +831,14 @@ export async function tui(yes, noWeb = false) {
831
831
  rt.addText(t.tuiWelcome(chatModel), 'dim');
832
832
  if (webUp)
833
833
  rt.addText(t.tuiWebLinked(DEFAULT_WEB_PORT), 'dim');
834
+ // update reminder: cached (1/day) registry check, resolved async into the
835
+ // transcript via addText (frame-safe); offline stays silent
836
+ {
837
+ const { notifyUpdate } = await import("./update-check.js");
838
+ const { createRequire } = await import('node:module');
839
+ const current = createRequire(import.meta.url)('../package.json').version;
840
+ void notifyUpdate(home, current, (latest) => rt.addText(`↑ ${t.updateHint(latest)}`, 'dim'));
841
+ }
834
842
  let history = [];
835
843
  rt.onSubmit(() => {
836
844
  const line = rt.consumeInput().trim();
@@ -0,0 +1,19 @@
1
+ /** Numeric per-component semver compare (no dependency, dot-split).
2
+ * Prerelease suffixes degrade to their leading number ('1-beta' -> 1) -
3
+ * good enough for an update hint, never claims to be full semver. */
4
+ export declare function cmpSemver(a: string, b: string): number;
5
+ export interface UpdateInfo {
6
+ current: string;
7
+ latest: string;
8
+ }
9
+ /** Returns info when a NEWER version exists on the registry, else null.
10
+ * Cache-first: a fresh (<24h) cached latest answer means zero network. */
11
+ export declare function checkForUpdate(opts: {
12
+ home: string;
13
+ current: string;
14
+ now?: number;
15
+ fetchImpl?: typeof fetch;
16
+ }): Promise<UpdateInfo | null>;
17
+ /** Fire-and-forget wrapper for interactive frontends: resolve-and-say, or
18
+ * say nothing at all. Never rejects. */
19
+ export declare function notifyUpdate(home: string, current: string, say: (line: string) => void): Promise<void>;
@@ -0,0 +1,67 @@
1
+ /**
2
+ * @hmharness/cli - update-check
3
+ * npm is pull-based: there is no server-side push. The honest "update
4
+ * reminder" is a client-side version check against the registry's latest
5
+ * dist-tag, printed once per interactive session - never blocking startup,
6
+ * never nagging offline, results cached for a day.
7
+ */
8
+ import { readFile, writeFile, mkdir } from 'node:fs/promises';
9
+ import { join } from 'node:path';
10
+ const REGISTRY = 'https://registry.npmjs.org/-/package/@hmharness/cli/dist-tags';
11
+ const CACHE_TTL_MS = 24 * 3600_000;
12
+ /** Numeric per-component semver compare (no dependency, dot-split).
13
+ * Prerelease suffixes degrade to their leading number ('1-beta' -> 1) -
14
+ * good enough for an update hint, never claims to be full semver. */
15
+ export function cmpSemver(a, b) {
16
+ const pa = a.split('.').map((n) => parseInt(n, 10) || 0);
17
+ const pb = b.split('.').map((n) => parseInt(n, 10) || 0);
18
+ for (let i = 0; i < 3; i++) {
19
+ if ((pa[i] ?? 0) !== (pb[i] ?? 0))
20
+ return (pa[i] ?? 0) < (pb[i] ?? 0) ? -1 : 1;
21
+ }
22
+ return 0;
23
+ }
24
+ /** Returns info when a NEWER version exists on the registry, else null.
25
+ * Cache-first: a fresh (<24h) cached latest answer means zero network. */
26
+ export async function checkForUpdate(opts) {
27
+ const now = opts.now ?? Date.now();
28
+ const cacheFile = join(opts.home, 'update-check.json');
29
+ let latest = null;
30
+ try {
31
+ const c = JSON.parse(await readFile(cacheFile, 'utf8'));
32
+ if (typeof c.latest === 'string' && now - c.time < CACHE_TTL_MS)
33
+ latest = c.latest;
34
+ }
35
+ catch { /* no cache yet */ }
36
+ if (latest === null) {
37
+ const doFetch = opts.fetchImpl ?? fetch;
38
+ try {
39
+ const res = await doFetch(REGISTRY, { signal: AbortSignal.timeout(3000) });
40
+ if (res.ok) {
41
+ const tags = await res.json();
42
+ if (typeof tags.latest === 'string') {
43
+ latest = tags.latest;
44
+ try {
45
+ await mkdir(opts.home, { recursive: true });
46
+ await writeFile(cacheFile, JSON.stringify({ time: now, latest }), 'utf8');
47
+ }
48
+ catch { /* cache write is best-effort */ }
49
+ }
50
+ }
51
+ }
52
+ catch { /* offline / slow registry: silent, never nag */ }
53
+ }
54
+ if (latest === null || cmpSemver(opts.current, latest) >= 0)
55
+ return null;
56
+ return { current: opts.current, latest };
57
+ }
58
+ /** Fire-and-forget wrapper for interactive frontends: resolve-and-say, or
59
+ * say nothing at all. Never rejects. */
60
+ export async function notifyUpdate(home, current, say) {
61
+ try {
62
+ const info = await checkForUpdate({ home, current });
63
+ if (info)
64
+ say(info.latest);
65
+ }
66
+ catch { /* never surface update-check failures */ }
67
+ }
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@hmharness/cli",
3
- "version": "0.1.1",
3
+ "version": "0.3.0",
4
4
  "description": "hmharness command line: one-shot tasks, an interactive REPL, a fullscreen TUI, the web frontend, and direct tool invocation.",
5
5
  "type": "module",
6
6
  "main": "dist/main.js",
@@ -43,11 +43,11 @@
43
43
  "build": "tsc -p tsconfig.build.json"
44
44
  },
45
45
  "dependencies": {
46
- "@hmharness/kernel": "0.1.0",
47
- "@hmharness/evolution": "0.1.0",
48
- "@hmharness/domain-harmony": "0.1.0",
49
- "@hmharness/domain-ops": "0.1.0",
50
- "@hmharness/agent": "0.1.0",
51
- "@hmharness/web": "0.1.0"
46
+ "@hmharness/kernel": "0.3.0",
47
+ "@hmharness/evolution": "0.3.0",
48
+ "@hmharness/domain-harmony": "0.3.0",
49
+ "@hmharness/domain-ops": "0.3.0",
50
+ "@hmharness/agent": "0.3.0",
51
+ "@hmharness/web": "0.3.0"
52
52
  }
53
53
  }