@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 +54 -1
- package/dist/mcp-server.d.ts +1 -0
- package/dist/mcp-server.js +134 -0
- package/dist/state.d.ts +28 -0
- package/dist/state.js +120 -0
- package/package.json +7 -7
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
|
|
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
|
+
}
|
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/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "@hmharness/cli",
|
|
3
|
-
"version": "0.
|
|
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.
|
|
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.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
|
}
|