@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 +74 -3
- package/dist/mcp-server.d.ts +1 -0
- package/dist/mcp-server.js +134 -0
- package/dist/npm-stats.d.ts +8 -0
- package/dist/npm-stats.js +41 -0
- package/dist/state.d.ts +28 -0
- package/dist/state.js +120 -0
- package/dist/tui.js +8 -0
- package/dist/update-check.d.ts +19 -0
- package/dist/update-check.js +67 -0
- package/package.json +7 -7
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:
|
|
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:
|
|
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
|
|
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
|
+
}
|
package/dist/state.d.ts
ADDED
|
@@ -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.
|
|
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.
|
|
47
|
-
"@hmharness/evolution": "0.
|
|
48
|
-
"@hmharness/domain-harmony": "0.
|
|
49
|
-
"@hmharness/domain-ops": "0.
|
|
50
|
-
"@hmharness/agent": "0.
|
|
51
|
-
"@hmharness/web": "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
|
}
|