@hmharness/cli 0.1.1 → 0.2.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
@@ -326,13 +326,20 @@ usage:
326
326
  hmh tui [--no-web] fullscreen terminal UI (slash palette, mouse wheel);
327
327
  also starts the web UI in the background (--no-web skips)
328
328
  hmh ops [scan|brief|status] ops keeper: ecosystem radar
329
+ hmh mcp-serve run as an MCP stdio SERVER: expose harmony_* tools to
330
+ Claude Code / Codex / any MCP host
331
+ (host config: npx -y @hmharness/cli mcp-serve)
329
332
  hmh devices|check direct tool run, no model
330
333
  hmh tools list all registered tools (native + MCP)
331
334
  hmh mcp show configured MCP servers and their tools
332
335
  hmh evolve [--every=N] self-evolution cycle (or resident loop)
333
- hmh bench run the evolution bench
336
+ hmh bench [--impact] run the evolution bench / canary A/B report
334
337
  hmh skills [--promote|--rollback|--unpromote <name>]
335
338
  hmh skills add <git-url-or-local-dir> install skills (multi-skill packs supported)
339
+ hmh state backup [--full] | restore [id] | remove <id|--all> | list
340
+ snapshot / recover the evolution state (skills,
341
+ memory, insights, logs); restore parks current
342
+ state in a .pre-restore copy first
336
343
 
337
344
  flags:
338
345
  --yes / -y auto-approve gated tools (else they prompt; non-TTY denies)
@@ -397,6 +404,14 @@ flags:
397
404
  }
398
405
  return;
399
406
  }
407
+ if (cmd === 'mcp-serve') {
408
+ // SERVER mode: expose the harmony_* tool surface over stdio MCP so
409
+ // Claude Code / Codex / any MCP host calls them natively. stdout is the
410
+ // protocol - run this exactly as the host's server command.
411
+ const { serveMcp } = await import("./mcp-server.js");
412
+ await serveMcp();
413
+ return; // serveMcp exits when stdin closes
414
+ }
400
415
  if (cmd === 'skills') {
401
416
  const home = homeDir();
402
417
  const flag = rest.find((a) => a.startsWith('--'));
@@ -568,6 +583,44 @@ flags:
568
583
  }
569
584
  return;
570
585
  }
586
+ if (cmd === 'state') {
587
+ // The evolution state (skills+memory+insights+logs) is a single-point
588
+ // asset; backup/restore/list keeps one bad JSONL from erasing the
589
+ // agent's whole learning history. Restore always parks the current
590
+ // state in a .pre-restore copy first (undoable by construction).
591
+ await initHome();
592
+ const { backupState, listBackups, restoreState, removeBackup } = await import("./state.js");
593
+ const sub = rest[0] ?? 'list';
594
+ if (sub === 'backup') {
595
+ const full = rest.includes('--full');
596
+ const r = await backupState(homeDir(), { full });
597
+ stdout.write(GREEN('✓') + ` backup ${r.id} (${r.items.length} items${full ? ', incl. sessions' : ''}) -> ${r.dir}\n`
598
+ + DIM('restore with: hmh state restore ' + r.id + '\n'));
599
+ return;
600
+ }
601
+ if (sub === 'restore') {
602
+ const id = rest.find((a) => !a.startsWith('-') && a !== 'restore');
603
+ const r = await restoreState(homeDir(), id);
604
+ stdout.write(GREEN('✓') + ` restored ${r.id} (${r.restored.length} items)\n`
605
+ + DIM(`current state parked at ${r.parked} (delete it if unwanted)\n`));
606
+ return;
607
+ }
608
+ if (sub === 'remove') {
609
+ const id = rest.find((a) => !a.startsWith('-') && a !== 'remove');
610
+ if (!id && !rest.includes('--all')) {
611
+ stdout.write('usage: hmh state remove <id | --all>\n');
612
+ return;
613
+ }
614
+ const removed = await removeBackup(homeDir(), id ?? '', { all: rest.includes('--all') });
615
+ stdout.write(`removed ${removed.length} backup(s)\n`);
616
+ return;
617
+ }
618
+ const list = await listBackups(homeDir());
619
+ stdout.write(list.length
620
+ ? list.map((b) => ` ${b.id} ${b.items.length} items${b.full ? ' (full)' : ''} ${b.time}`).join('\n') + '\n'
621
+ : DIM(' no backups yet - run "hmh state backup"\n'));
622
+ return;
623
+ }
571
624
  if (cmd === 'tui') {
572
625
  await initHome();
573
626
  // 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,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/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@hmharness/cli",
3
- "version": "0.1.1",
3
+ "version": "0.2.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.2.0",
47
+ "@hmharness/evolution": "0.2.0",
48
+ "@hmharness/domain-harmony": "0.2.0",
49
+ "@hmharness/domain-ops": "0.2.0",
50
+ "@hmharness/agent": "0.2.0",
51
+ "@hmharness/web": "0.2.0"
52
52
  }
53
53
  }