@hmharness/cli 0.2.0 → 0.4.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,7 +333,7 @@ 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
329
337
  hmh mcp-serve run as an MCP stdio SERVER: expose harmony_* tools to
330
338
  Claude Code / Codex / any MCP host
331
339
  (host config: npx -y @hmharness/cli mcp-serve)
@@ -577,6 +585,16 @@ flags:
577
585
  const r = await harmonyOpsRadarBrief.execute({}, ctx);
578
586
  stdout.write(r.output + '\n');
579
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
+ }
580
598
  else {
581
599
  const r = await harmonyOpsStatus.execute({}, ctx);
582
600
  stdout.write(r.output + '\n');
@@ -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/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.2.0",
3
+ "version": "0.4.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.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"
46
+ "@hmharness/kernel": "0.4.0",
47
+ "@hmharness/evolution": "0.4.0",
48
+ "@hmharness/domain-harmony": "0.4.0",
49
+ "@hmharness/domain-ops": "0.4.0",
50
+ "@hmharness/agent": "0.4.0",
51
+ "@hmharness/web": "0.4.0"
52
52
  }
53
53
  }