@nanhara/hara 0.53.0 → 0.67.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/index.js CHANGED
@@ -6,7 +6,7 @@ import { runTui } from "./tui/run.js";
6
6
  import { readClipboardImage } from "./images.js";
7
7
  import { describeImages, locateImage, classifyVision, SCREENSHOT_SYSTEM } from "./vision.js";
8
8
  import { setTheme } from "./tui/theme.js";
9
- import { memoryDigest, memoryDir } from "./memory/store.js";
9
+ import { memoryDigest, memoryDir, readRecentLogs, scaffoldMemory } from "./memory/store.js";
10
10
  import { nextMode as cycleMode } from "./tui/InputBox.js";
11
11
  import { stdin, stdout } from "node:process";
12
12
  import { readFileSync, existsSync, writeFileSync, rmSync } from "node:fs";
@@ -16,6 +16,16 @@ import { dirname, join } from "node:path";
16
16
  import { loadConfig, configPath, readRawConfig, writeConfigValue, setModelVisionOverride, providerEnvKey, CONFIG_KEYS, APPROVAL_MODES, SANDBOX_MODES, } from "./config.js";
17
17
  import { runAgent } from "./agent/loop.js";
18
18
  import { notifyDone } from "./notify.js";
19
+ import { startMcpServer, mcpServeToolNames } from "./mcp/server.js";
20
+ import { completionScript } from "./completions.js";
21
+ import { renderSessionMarkdown } from "./export.js";
22
+ import { loadEnrollment, clearEnrollment, enrollDevice, heartbeat, gatewayBaseURL } from "./org-fleet/enroll.js";
23
+ import { mapLimit, maxParallel } from "./concurrency.js";
24
+ import { parseVerdict, captureChanges, reviewPrompt, fixPrompt, REVIEWER_SYSTEM, isTreeClean, stripCommitFence } from "./org/review-chain.js";
25
+ import { parseSchedule, describeSchedule, nextRun } from "./cron/schedule.js";
26
+ import { addJob, removeJob, setEnabled, resolveJob, loadJobs, recordRun, logPath } from "./cron/store.js";
27
+ import { runTick, runJobOnce, selfArgv } from "./cron/runner.js";
28
+ import { installScheduler, uninstallScheduler, isInstalled } from "./cron/install.js";
19
29
  import { getTools } from "./tools/registry.js";
20
30
  import { createAnthropicProvider } from "./providers/anthropic.js";
21
31
  import { createOpenAIProvider } from "./providers/openai.js";
@@ -26,7 +36,7 @@ import { collectRepoChunks, collectDirChunks, buildIndex, indexPath, indexExists
26
36
  import { searchHybrid } from "./search/hybrid.js";
27
37
  import { expandMentions, fileCandidates } from "./context/mentions.js";
28
38
  import { newSessionId, shortId, resolveSessionId, saveSession, loadSession, listSessions, latestForCwd, titleFrom, slugify, } from "./session/store.js";
29
- import { loadRoles, scaffoldRoles } from "./org/roles.js";
39
+ import { loadRoles, scaffoldRoles, subagentToolFilter } from "./org/roles.js";
30
40
  import { loadSkillIndex, loadSkillBody, scaffoldSkills, globalSkillsDir } from "./skills/skills.js";
31
41
  import { installPlugin, uninstallPlugin, listInstalled, enabledPlugins, setPluginEnabled, pluginMcpServers, pluginHooks } from "./plugins/plugins.js";
32
42
  import { routeByKeywords, buildDispatchPrompt, parseRoleId } from "./org/router.js";
@@ -50,7 +60,19 @@ import "./tools/codebase.js"; // register codebase_search (repo as a knowledge b
50
60
  import "./tools/todo.js"; // register todo_write (inline task checklist)
51
61
  import { computerBackends } from "./tools/computer.js"; // register the computer tool + expose the backend probe
52
62
  const here = dirname(fileURLToPath(import.meta.url));
53
- const pkg = JSON.parse(readFileSync(join(here, "..", "package.json"), "utf8"));
63
+ // Version: from a build-time define in the compiled single-binary (no package.json on its virtual FS),
64
+ // else read package.json (npm install / `node dist`). The read is wrapped so the binary never hits it.
65
+ const pkg = {
66
+ version: process.env.HARA_BUILD_VERSION ??
67
+ ((() => {
68
+ try {
69
+ return JSON.parse(readFileSync(join(here, "..", "package.json"), "utf8")).version;
70
+ }
71
+ catch {
72
+ return "0.0.0";
73
+ }
74
+ })()),
75
+ };
54
76
  const maskKey = (v) => (v ? `${v.slice(0, 7)}…${v.slice(-4)}` : "(unset)");
55
77
  async function buildProvider(cfg) {
56
78
  if (cfg.provider === "qwen-oauth") {
@@ -59,6 +81,12 @@ async function buildProvider(cfg) {
59
81
  return null;
60
82
  return createOpenAIProvider({ apiKey: auth.accessToken, baseURL: auth.baseURL, model: cfg.model, label: "qwen-oauth" });
61
83
  }
84
+ if (cfg.provider === "hara-gateway") {
85
+ const e = loadEnrollment();
86
+ if (!e)
87
+ return null; // not enrolled → `hara enroll`
88
+ return createOpenAIProvider({ apiKey: e.deviceToken, baseURL: gatewayBaseURL(e), model: cfg.model || e.model, label: "hara-gateway" });
89
+ }
62
90
  if (!cfg.apiKey)
63
91
  return null;
64
92
  if (cfg.provider === "anthropic") {
@@ -69,12 +97,98 @@ async function buildProvider(cfg) {
69
97
  function authHint(cfg) {
70
98
  if (cfg.provider === "qwen-oauth")
71
99
  return `Run ${c.bold("hara login qwen")} to authenticate.`;
72
- return `Set ${c.bold(providerEnvKey(cfg.provider))} (or ${c.bold("HARA_API_KEY")}), or run ${c.bold("hara config set apiKey <key>")}.`;
100
+ return `Set ${c.bold(providerEnvKey(cfg.provider))} (or ${c.bold("HARA_API_KEY")}), or run ${c.bold("hara setup")}.`;
101
+ }
102
+ const SETUP_DEFAULT_MODEL = { anthropic: "claude-opus-4-8", qwen: "qwen-plus", openai: "gpt-4o-mini", "qwen-oauth": "coder-model" };
103
+ /** Interactive first-run setup: pick a provider, (optional) base URL, API key, and model → ~/.hara/config.json. */
104
+ async function runSetup() {
105
+ if (!stdin.isTTY) {
106
+ out(c.yellow("`hara setup` is interactive — run it in a terminal, or use `hara config set <key> <value>` in scripts.\n"));
107
+ return;
108
+ }
109
+ const rl = createInterface({ input: stdin, output: stdout });
110
+ try {
111
+ out(c.bold("hara setup") + c.dim(" — configure a provider, key, and model (Ctrl-C to cancel)\n\n"));
112
+ const provider = ((await rl.question(`Provider ${c.dim("anthropic / qwen / openai")} [anthropic]: `)).trim() || "anthropic").toLowerCase();
113
+ let baseURL = "";
114
+ if (provider === "qwen" || provider === "openai") {
115
+ baseURL = (await rl.question(`Base URL ${c.dim("(blank = default; set for an OpenAI-compatible endpoint, e.g. GLM/Kimi/DashScope)")}: `)).trim();
116
+ }
117
+ const envKey = providerEnvKey(provider);
118
+ const apiKey = (await rl.question(`API key ${c.dim(`(blank = use the ${envKey} env var)`)}: `)).trim();
119
+ const model = (await rl.question(`Model [${SETUP_DEFAULT_MODEL[provider] ?? "?"}]: `)).trim() || SETUP_DEFAULT_MODEL[provider] || "";
120
+ writeConfigValue("provider", provider);
121
+ if (baseURL)
122
+ writeConfigValue("baseURL", baseURL);
123
+ if (apiKey)
124
+ writeConfigValue("apiKey", apiKey);
125
+ if (model)
126
+ writeConfigValue("model", model);
127
+ out(c.green(`\n✓ saved to ${configPath()}\n`) + c.dim(`Check it with ${c.bold("hara doctor")}, then just run ${c.bold("hara")}.\n`));
128
+ }
129
+ finally {
130
+ rl.close();
131
+ }
73
132
  }
74
133
  async function runInit(provider, cwd, sandbox = "off") {
75
134
  const history = [{ role: "user", content: INIT_PROMPT }];
76
135
  await runAgent(history, { provider, ctx: { cwd, sandbox }, approval: "full-auto", confirm: async () => true });
77
136
  }
137
+ /** Stage everything and commit with an AI-written message. Returns a one-line summary or "error: …".
138
+ * Used by `hara org --commit`; the caller guards on a clean start tree so this only captures the run's work. */
139
+ async function autoCommit(provider, cwd) {
140
+ try {
141
+ await runShell("git add -A", cwd, "off", { timeout: 30_000, maxBuffer: 1_000_000 });
142
+ }
143
+ catch {
144
+ /* fall through — empty diff is reported below */
145
+ }
146
+ let diff = "";
147
+ try {
148
+ diff = (await runShell("git diff --staged", cwd, "off", { timeout: 30_000, maxBuffer: 8_000_000 })).stdout;
149
+ }
150
+ catch (e) {
151
+ return `error: git diff failed (${e instanceof Error ? e.message : String(e)})`;
152
+ }
153
+ if (!diff.trim())
154
+ return "nothing to commit";
155
+ const r = await provider.turn({
156
+ system: COMMIT_SYSTEM,
157
+ history: [{ role: "user", content: `Write a commit message for these staged changes:\n\n\`\`\`diff\n${diff.slice(0, 120_000)}\n\`\`\`` }],
158
+ tools: [],
159
+ onText: () => { },
160
+ });
161
+ const msg = stripCommitFence(r.text);
162
+ if (!msg)
163
+ return "error: no commit message produced";
164
+ const tmp = join(tmpdir(), `hara-org-commit-${process.pid}.txt`);
165
+ writeFileSync(tmp, msg + "\n", "utf8");
166
+ try {
167
+ const res = await runShell(`git commit -F ${JSON.stringify(tmp)}`, cwd, "off", { timeout: 30_000, maxBuffer: 1_000_000 });
168
+ return (res.stdout || "").trim().split("\n")[0] || "committed";
169
+ }
170
+ catch (e) {
171
+ return `error: git commit failed (${e instanceof Error ? e.message : String(e)})`;
172
+ }
173
+ finally {
174
+ try {
175
+ rmSync(tmp);
176
+ }
177
+ catch {
178
+ /* best-effort cleanup */
179
+ }
180
+ }
181
+ }
182
+ /** Format an autoCommit result + emit it. */
183
+ async function commitStep(provider, cwd) {
184
+ const r = await autoCommit(provider, cwd);
185
+ if (r.startsWith("error:"))
186
+ out(c.red(`✗ ${r}\n`));
187
+ else if (r === "nothing to commit")
188
+ out(c.dim("(nothing to commit)\n"));
189
+ else
190
+ out(c.green(`✓ committed · ${r.slice(0, 100)}\n`));
191
+ }
78
192
  /** Dispatch a task to the owning role and run that role's agent (its persona + tool subset + model). */
79
193
  async function runOrg(task, o) {
80
194
  const roles = loadRoles(o.cwd);
@@ -115,7 +229,7 @@ async function runOrg(task, o) {
115
229
  ? (n) => !role.denyTools.includes(n)
116
230
  : undefined;
117
231
  const history = [{ role: "user", content: expandMentions(task, o.cwd) }];
118
- await runAgent(history, {
232
+ const runImplementer = () => runAgent(history, {
119
233
  provider: roleProvider,
120
234
  ctx: { cwd: o.cwd, sandbox: o.sandbox },
121
235
  approval: o.approval,
@@ -126,6 +240,61 @@ async function runOrg(task, o) {
126
240
  systemOverride: role.system,
127
241
  toolFilter,
128
242
  });
243
+ const wasClean = o.commit ? isTreeClean(o.cwd) : false; // capture BEFORE the implementer edits anything
244
+ const doCommit = async (ok) => {
245
+ if (!o.commit)
246
+ return;
247
+ if (!ok)
248
+ return void out(c.yellow("(not committing — review didn't approve; changes left in your working tree)\n"));
249
+ if (!wasClean)
250
+ return void out(c.yellow("(not auto-committing — the tree wasn't clean before this run; commit manually)\n"));
251
+ await commitStep(o.baseProvider, o.cwd);
252
+ };
253
+ await runImplementer();
254
+ if (!o.review) {
255
+ await doCommit(true);
256
+ return;
257
+ }
258
+ // Review chain: a reviewer role inspects the diff and APPROVES or sends it back, looping until clean.
259
+ const reviewer = roles.find((r) => r.id === "reviewer");
260
+ const revProvider = reviewer?.model && reviewer.model !== o.cfg.model ? ((await buildProvider({ ...o.cfg, model: reviewer.model })) ?? o.baseProvider) : o.baseProvider;
261
+ const revSystem = reviewer?.system ?? REVIEWER_SYSTEM;
262
+ const revTools = reviewer?.allowTools ? (n) => reviewer.allowTools.includes(n) : (n) => READONLY_TOOLS.has(n);
263
+ const maxRounds = Math.max(1, o.rounds ?? 3);
264
+ for (let round = 1; round <= maxRounds; round++) {
265
+ const changes = captureChanges(o.cwd);
266
+ if (!changes.diff && !changes.newFiles.length) {
267
+ out(c.dim("(no changes to review)\n"));
268
+ return;
269
+ }
270
+ out(c.dim(`🔍 reviewer · round ${round}/${maxRounds}\n`));
271
+ const rHist = [{ role: "user", content: reviewPrompt(task, changes) }];
272
+ await runAgent(rHist, {
273
+ provider: revProvider,
274
+ ctx: { cwd: o.cwd, sandbox: o.sandbox },
275
+ approval: "full-auto", // reviewer is read-only via revTools, so nothing to confirm
276
+ confirm: o.confirm,
277
+ projectContext: o.projectContext,
278
+ memory: memoryDigest(o.cwd),
279
+ stats: o.stats,
280
+ systemOverride: revSystem,
281
+ toolFilter: revTools,
282
+ });
283
+ const verdict = parseVerdict(lastAssistantText(rHist));
284
+ if (verdict.approved) {
285
+ out(c.green(`✓ reviewer approved after ${round} round(s)\n`));
286
+ await doCommit(true);
287
+ return;
288
+ }
289
+ if (round === maxRounds) {
290
+ out(c.yellow(`⚠ stopped after ${maxRounds} round(s) — reviewer still wants changes.\n`));
291
+ await doCommit(false);
292
+ return;
293
+ }
294
+ out(c.yellow(`✗ changes requested — back to ${role.id} (round ${round})\n`));
295
+ history.push({ role: "user", content: fixPrompt(verdict.issues) });
296
+ await runImplementer();
297
+ }
129
298
  }
130
299
  function lastAssistantText(history) {
131
300
  for (let i = history.length - 1; i >= 0; i--) {
@@ -190,7 +359,7 @@ async function executePlan(plan, roles, o) {
190
359
  if (!todo.length)
191
360
  continue; // whole wave already complete (resume)
192
361
  out(c.cyan(`\n▶ wave [${todo.map((a) => a.id).join(", ")}] — ${todo.length} in parallel\n`));
193
- const results = await Promise.all(todo.map((atom) => executeAtom(atom, plan, done, roles, o)));
362
+ const results = await mapLimit(todo, maxParallel(), (atom) => executeAtom(atom, plan, done, roles, o)); // bounded
194
363
  todo.forEach((atom, i) => {
195
364
  if (results[i]) {
196
365
  done.push(atom);
@@ -312,6 +481,11 @@ const PLAN_SYSTEM = "You are in PLAN MODE. Investigate read-only (read_file / gr
312
481
  "End your message with the plan as a short numbered list.";
313
482
  const DISTILL_SYSTEM = "The session is ending. Reflect and persist only durable, reusable learnings: memory_write for facts / " +
314
483
  "conventions / the user's preferences, skill_create for reusable how-tos. Be selective — skip the trivial. Then reply DONE.";
484
+ const MEMORY_DISTILL_SYSTEM = "You consolidate an agent's short-term daily memory logs into its durable long-term memory. You're given " +
485
+ "the current durable memory and recent daily logs. Extract ONLY durable, reusable facts / decisions / " +
486
+ "conventions / user preferences from the logs that are NOT already captured, and persist each with " +
487
+ "memory_write (target=memory, or target=user for preferences; pick the right scope=project|global). " +
488
+ "Skip the ephemeral, the one-off, and anything already known. Be terse and de-duplicated. Then reply DONE.";
315
489
  const COMPACT_SYSTEM = "Summarize the conversation so far into a concise but complete brief so the assistant can " +
316
490
  "continue seamlessly: the user's goal, key decisions, files changed, current state, and open next steps. " +
317
491
  "Be specific. Output only the summary.";
@@ -326,11 +500,10 @@ async function runSubagent(cfg, baseProvider, cwd, sandbox, projectContext, stat
326
500
  const roles = loadRoles(cwd);
327
501
  const role = roleId ? roles.find((r) => r.id === roleId) : undefined;
328
502
  const provider = role?.model && role.model !== cfg.model ? ((await buildProvider({ ...cfg, model: role.model })) ?? baseProvider) : baseProvider;
329
- const toolFilter = role?.allowTools
330
- ? (n) => role.allowTools.includes(n)
331
- : role?.denyTools
332
- ? (n) => !role.denyTools.includes(n)
333
- : (n) => READONLY_TOOLS.has(n); // default sub-agent = read-only (safe to parallelize)
503
+ // A sub-agent runs full-auto + UNCONFIRMED + parallel, so it is ALWAYS read-only — a role may narrow
504
+ // further but can never GRANT write/exec to a fan-out sub-agent (that would bypass the approval gate).
505
+ // Write-capable roles run in the main loop via `hara org`, behind the user's gate.
506
+ const toolFilter = subagentToolFilter(role, (n) => READONLY_TOOLS.has(n));
334
507
  const subHistory = [{ role: "user", content: task }];
335
508
  await runAgent(subHistory, {
336
509
  provider,
@@ -377,9 +550,11 @@ function runDoctor(cfg) {
377
550
  `${dot} vision · ${c.bold(cfg.model)} ${vdesc}${cfg.visionModel ? c.dim(" · describer ") + c.bold(cfg.visionModel) : vcap === "text" ? c.yellow(" · set /vision <model>") : ""}`,
378
551
  `${dot} screen ${cfg.computerUse === "off" ? c.dim("off (hara config set computerUse read|click|full)") : c.bold(cfg.computerUse) + c.dim(` · ${computerBackends()}${cfg.computerApps.length ? " · apps: " + cfg.computerApps.join(", ") : " · no app allowlist"}`)}`,
379
552
  `${dot} plugins ${(() => { const inst = listInstalled(); const on = enabledPlugins().length; return inst.length ? c.dim(`${on}/${inst.length} enabled: ${inst.map((p) => p.name).slice(0, 6).join(", ")}`) : c.dim("none — hara plugin add <source>"); })()}`,
380
- `${dot} mcp servers ${c.dim(String(Object.keys({ ...pluginMcpServers(), ...cfg.mcpServers }).length))}`,
553
+ `${dot} mcp ${c.dim(`client: ${Object.keys({ ...pluginMcpServers(), ...cfg.mcpServers }).length} server(s) · serve: ${mcpServeToolNames().length} read tools via \`hara mcp\``)}`,
381
554
  `${dot} hooks ${(() => { const ph = pluginHooks(); const pre = (cfg.hooks.PreToolUse ?? []).length + (ph.PreToolUse ?? []).length; const post = (cfg.hooks.PostToolUse ?? []).length + (ph.PostToolUse ?? []).length; return pre + post ? c.dim(`${pre} pre · ${post} post`) : c.dim("none — config.json \"hooks\""); })()}`,
382
555
  `${dot} notify ${cfg.notify === "off" ? c.dim("off — hara config set notify bell|system") : c.bold(cfg.notify)}`,
556
+ `${dot} cron ${(() => { const n = loadJobs().length; return n ? `${n} job(s) · ${isInstalled() ? c.green("scheduler installed") : c.yellow("scheduler off — hara cron install")}` : c.dim("no jobs — hara cron add"); })()}`,
557
+ `${dot} input ${cfg.vimMode ? c.bold("vim") + c.dim(" (modal)") : c.dim("default — hara config set vimMode true for vim keys")}`,
383
558
  ];
384
559
  return lines.join("\n");
385
560
  }
@@ -434,8 +609,11 @@ program
434
609
  });
435
610
  program
436
611
  .command("org <task...>")
437
- .description("dispatch a task to the owning role and run it")
612
+ .description("dispatch a task to the owning role and run it (--review loops a reviewer until it approves)")
438
613
  .option("--role <id>", "force a specific role")
614
+ .option("--review", "after implementing, loop a reviewer role until it approves (implement → review → fix)")
615
+ .option("--rounds <n>", "max review rounds with --review (default 3)", (v) => parseInt(v, 10))
616
+ .option("--commit", "commit the result with an AI message (with --review: only after approval; needs a clean start tree)")
439
617
  .action(async (taskParts, opts2) => {
440
618
  const cfg = loadConfig();
441
619
  const provider = await buildProvider(cfg);
@@ -454,6 +632,9 @@ program
454
632
  projectContext: loadAgentsMd(cfg.cwd) || undefined,
455
633
  stats,
456
634
  forceRole: opts2.role,
635
+ review: opts2.review,
636
+ rounds: opts2.rounds,
637
+ commit: opts2.commit,
457
638
  });
458
639
  if (stats.input || stats.output)
459
640
  out(statusLine(cfg.model, stats.input, stats.output) + "\n");
@@ -555,6 +736,80 @@ program
555
736
  .command("doctor")
556
737
  .description("check your hara setup (provider / auth / model / node / assets / roles)")
557
738
  .action(() => out(runDoctor(loadConfig()) + "\n"));
739
+ program
740
+ .command("setup")
741
+ .description("interactive first-run setup — pick a provider, API key, and model")
742
+ .action(runSetup);
743
+ program
744
+ .command("enroll [gateway-url]")
745
+ .description("B-end: join a fleet — trade a one-time code for a device token (routes hara through your org's gateway; no provider key on this device)")
746
+ .option("--code <code>", "enrollment code from your hara-control admin")
747
+ .option("--status", "show the current enrollment")
748
+ .option("--clear", "remove the enrollment (revert to your own provider config)")
749
+ .action(async (gatewayUrl, opts) => {
750
+ if (opts.status) {
751
+ const e = loadEnrollment();
752
+ return void out(e ? c.green("enrolled") + c.dim(` · ${e.gatewayUrl} · device ${e.deviceId || "?"} · model ${e.model || "(gateway default)"} · since ${e.enrolledAt}\n`) : c.dim("Not enrolled — `hara enroll <gateway-url> --code <code>`.\n"));
753
+ }
754
+ if (opts.clear)
755
+ return void out(clearEnrollment() ? c.green("✓ enrollment cleared — set your own provider with `hara setup`.\n") : c.dim("(not enrolled)\n"));
756
+ if (!gatewayUrl)
757
+ return void out(c.red("usage: hara enroll <gateway-url> --code <code> (or --status / --clear)\n"));
758
+ if (!opts.code)
759
+ return void out(c.red("Need --code <code> — ask your hara-control admin to issue an enrollment code.\n"));
760
+ try {
761
+ const e = await enrollDevice(gatewayUrl, opts.code);
762
+ writeConfigValue("provider", "hara-gateway");
763
+ if (e.model)
764
+ writeConfigValue("model", e.model);
765
+ out(c.green(`✓ enrolled with ${e.gatewayUrl}`) + c.dim(` · device ${e.deviceId || "?"} · model ${e.model || "(gateway default)"}\n`) + c.dim("hara routes through the gateway now — the real provider key stays server-side.\n"));
766
+ }
767
+ catch (err) {
768
+ out(c.red(`Enroll failed: ${err instanceof Error ? err.message : String(err)}\n`));
769
+ }
770
+ });
771
+ program
772
+ .command("export [session]")
773
+ .description("export a session to a Markdown transcript (default: the latest in this directory)")
774
+ .option("--out <file>", "write to a file instead of stdout")
775
+ .action((sessionArg, opts) => {
776
+ const data = sessionArg ? (() => { const id = resolveSessionId(sessionArg); return id ? loadSession(id) : null; })() : latestForCwd(process.cwd());
777
+ if (!data)
778
+ return void out(c.red(sessionArg ? `No session matching '${sessionArg}'.\n` : "No session for this directory — pass an id (see `hara sessions`).\n"));
779
+ const md = renderSessionMarkdown(data);
780
+ if (opts.out) {
781
+ writeFileSync(opts.out, md, "utf8");
782
+ out(c.green(`✓ wrote ${opts.out}`) + c.dim(` (${md.length} chars)\n`));
783
+ }
784
+ else {
785
+ out(md);
786
+ }
787
+ });
788
+ program
789
+ .command("completions <shell>")
790
+ .description("print a shell completion script: bash | zsh | fish (eval it in your shell rc)")
791
+ .action((shell) => {
792
+ const top = program.commands.map((cmd) => cmd.name()).filter((n) => n && n !== "completions").sort();
793
+ const subs = {};
794
+ for (const cmd of program.commands) {
795
+ const sub = cmd.commands.map((s) => s.name()).filter(Boolean);
796
+ if (sub.length)
797
+ subs[cmd.name()] = sub;
798
+ }
799
+ const script = completionScript(shell, { top, subs });
800
+ if (!script)
801
+ return void out(c.red(`Unsupported shell '${shell}'. Use: bash | zsh | fish\n`));
802
+ out(script);
803
+ });
804
+ program
805
+ .command("mcp")
806
+ .description("run hara as an MCP server (stdio) — expose its read/search tools (incl. codebase_search) to other MCP clients")
807
+ .action(async () => {
808
+ const cfg = loadConfig();
809
+ // stdout is the JSON-RPC transport — diagnostics MUST go to stderr only.
810
+ process.stderr.write(c.dim(`hara mcp · serving over stdio · cwd ${cfg.cwd}\n tools: ${mcpServeToolNames().join(", ") || "(none)"}\n (read-only by default; set HARA_MCP_TOOLS to override)\n`));
811
+ await startMcpServer(pkg.version, { cwd: cfg.cwd, sandbox: "read-only" });
812
+ });
558
813
  program
559
814
  .command("review")
560
815
  .description("review your uncommitted changes (git diff) for bugs, security, and missing tests")
@@ -660,6 +915,154 @@ program
660
915
  }
661
916
  }
662
917
  });
918
+ function renderCronJobs() {
919
+ const jobs = loadJobs();
920
+ const head = isInstalled() ? c.green("scheduler: installed") : c.yellow("scheduler: NOT installed — run `hara cron install`");
921
+ if (!jobs.length)
922
+ return head + "\n" + c.dim('No jobs. Add one: hara cron add "every 1h" "<task>"\n');
923
+ const now = Date.now();
924
+ const lines = jobs.map((j) => {
925
+ const nxt = nextRun(j, now);
926
+ const status = j.lastStatus ? (j.lastStatus === "ok" ? c.green("ok") : c.red("err")) : c.dim("—");
927
+ return `${c.bold(j.id)} ${describeSchedule(j.schedule)} ${c.dim(`· ${j.mode} · next ${nxt ? new Date(nxt).toLocaleString() : "—"} · last ${status}`)}${j.enabled ? "" : c.dim(" [disabled]")}\n ${c.dim(j.name)}`;
928
+ });
929
+ return head + "\n" + lines.join("\n") + "\n";
930
+ }
931
+ const cronCmd = program.command("cron").description("scheduled tasks — run a prompt/org task on a schedule (fired by your OS via `hara cron install`)");
932
+ cronCmd
933
+ .command("add <schedule> <task...>")
934
+ .description('schedule a task — schedule = cron expr ("0 9 * * *"), "every 30m", "in 2h", or an ISO timestamp')
935
+ .option("--name <name>", "a label for the job")
936
+ .option("--org", "run via `hara org` (role routing + review) instead of a plain `hara -p` prompt")
937
+ .action((schedule, taskParts, opts) => {
938
+ const task = taskParts.join(" ");
939
+ const sched = parseSchedule(schedule, Date.now());
940
+ if ("error" in sched)
941
+ return void out(c.red(sched.error + "\n"));
942
+ const job = addJob({ name: opts.name || task.slice(0, 48), schedule: sched, task, mode: opts.org ? "org" : "print", cwd: process.cwd(), createdAt: Date.now() });
943
+ out(c.green(`✓ scheduled ${job.id}`) + c.dim(` · ${describeSchedule(sched)} · ${job.mode} · cwd ${job.cwd}\n`));
944
+ if (!isInstalled())
945
+ out(c.yellow("⚠ scheduler not installed yet — run `hara cron install` so jobs actually fire.\n"));
946
+ });
947
+ // Resolve an id/prefix to one job, printing a clear error for none / ambiguous (never act on a guess).
948
+ const cronResolve = (id) => {
949
+ const r = resolveJob(id);
950
+ if (r === "ambiguous")
951
+ return void out(c.red(`ambiguous id "${id}" — matches multiple jobs; type more characters\n`)), null;
952
+ if (!r)
953
+ return void out(c.red(`no such job: ${id}\n`)), null;
954
+ return r;
955
+ };
956
+ cronCmd.command("list").alias("ls").description("list scheduled jobs").action(() => out(renderCronJobs()));
957
+ cronCmd
958
+ .command("remove <id>")
959
+ .alias("rm")
960
+ .description("delete a job (by id or unique prefix)")
961
+ .action((id) => {
962
+ const j = cronResolve(id);
963
+ if (j)
964
+ out(removeJob(j.id) ? c.green(`✓ removed ${j.id}\n`) : c.red("no such job\n"));
965
+ });
966
+ cronCmd.command("enable <id>").description("enable a job").action((id) => {
967
+ const j = cronResolve(id);
968
+ if (j) {
969
+ setEnabled(j.id, true);
970
+ out(c.green(`✓ enabled ${j.id}\n`));
971
+ }
972
+ });
973
+ cronCmd.command("disable <id>").description("disable a job (keeps it, stops firing)").action((id) => {
974
+ const j = cronResolve(id);
975
+ if (j) {
976
+ setEnabled(j.id, false);
977
+ out(c.green(`✓ disabled ${j.id}\n`));
978
+ }
979
+ });
980
+ cronCmd
981
+ .command("run <id>")
982
+ .description("run a job right now, ignoring its schedule")
983
+ .action(async (id) => {
984
+ const job = cronResolve(id);
985
+ if (!job)
986
+ return;
987
+ out(c.dim(`running ${job.id} (${job.name})…\n`));
988
+ const r = await runJobOnce(job);
989
+ recordRun(job.id, Date.now(), r.ok ? "ok" : "error", r.error);
990
+ out((r.ok ? c.green("✓ done") : c.red(`✗ ${r.error}`)) + c.dim(` · log: ${logPath(job.id)}\n`));
991
+ });
992
+ cronCmd
993
+ .command("tick")
994
+ .description("run all due jobs now (your OS scheduler calls this every minute)")
995
+ .action(async () => {
996
+ const r = await runTick(Date.now());
997
+ if (r.skipped)
998
+ return void out(c.dim(`(skipped — ${r.skipped})\n`));
999
+ out(c.dim(r.ran.length ? `ran ${r.ran.length} job(s): ${r.ran.join(", ")}\n` : "(no jobs due)\n"));
1000
+ });
1001
+ cronCmd
1002
+ .command("install")
1003
+ .description("register the per-minute tick with your OS scheduler (launchd on macOS, crontab on Linux)")
1004
+ .action(() => {
1005
+ const r = installScheduler(selfArgv());
1006
+ out((r.ok ? c.green("✓ ") : c.red("✗ ")) + r.msg + "\n");
1007
+ });
1008
+ cronCmd.command("uninstall").description("remove the OS scheduler entry").action(() => {
1009
+ const r = uninstallScheduler();
1010
+ out((r.ok ? c.green("✓ ") : c.red("✗ ")) + r.msg + "\n");
1011
+ });
1012
+ cronCmd
1013
+ .command("logs <id>")
1014
+ .description("show a job's recent run output")
1015
+ .action((id) => {
1016
+ const job = cronResolve(id);
1017
+ if (!job)
1018
+ return;
1019
+ const p = logPath(job.id);
1020
+ out(existsSync(p) ? readFileSync(p, "utf8").slice(-4000) + "\n" : c.dim("(no runs yet)\n"));
1021
+ });
1022
+ const memoryCmd = program.command("memory").description("inspect + consolidate hara's durable memory (~/.hara/memory + project .hara/memory)");
1023
+ memoryCmd.command("show").description("print the memory digest injected at session start").action(() => {
1024
+ const d = memoryDigest(process.cwd());
1025
+ out(d ? d + "\n" : c.dim("(memory is empty — `hara memory init`, or let the agent write via memory_write)\n"));
1026
+ });
1027
+ memoryCmd.command("init").description("scaffold the memory dirs + seed files (global + project)").action(() => {
1028
+ const w = scaffoldMemory(process.cwd());
1029
+ out(w.length ? c.green(`Scaffolded: ${w.join(", ")}\n`) : c.dim("Memory already scaffolded.\n"));
1030
+ });
1031
+ memoryCmd
1032
+ .command("distill")
1033
+ .description("consolidate recent daily logs into durable MEMORY (promote short-term → long-term)")
1034
+ .option("--days <n>", "days of logs to consider (default 14)", (v) => parseInt(v, 10))
1035
+ .option("--scope <s>", "global | project | all (default all)")
1036
+ .action(async (opts) => {
1037
+ const cfg = loadConfig();
1038
+ const provider = await buildProvider(cfg);
1039
+ if (!provider) {
1040
+ out(c.red(`Not authenticated for provider '${cfg.provider}'.\n`) + authHint(cfg) + "\n");
1041
+ process.exit(1);
1042
+ }
1043
+ const days = opts.days && opts.days > 0 ? opts.days : 14;
1044
+ const scopes = opts.scope === "global" ? ["global"] : opts.scope === "project" ? ["project"] : ["project", "global"];
1045
+ const logs = scopes
1046
+ .map((s) => readRecentLogs(s, cfg.cwd, days))
1047
+ .filter(Boolean)
1048
+ .join("\n\n");
1049
+ if (!logs.trim())
1050
+ return void out(c.dim(`No daily logs in the last ${days} day(s) to distill. (The agent jots them via memory_write target=log.)\n`));
1051
+ out(c.dim(`Distilling ${days}-day logs → durable memory…\n`));
1052
+ const stats = { input: 0, output: 0, lastInput: 0 };
1053
+ const history = [{ role: "user", content: `Current durable memory:\n\n${memoryDigest(cfg.cwd) || "(empty)"}\n\n---\n\nRecent daily logs (last ${days} days):\n\n${logs.slice(0, 80_000)}` }];
1054
+ await runAgent(history, {
1055
+ provider,
1056
+ ctx: { cwd: cfg.cwd, sandbox: cfg.sandbox },
1057
+ approval: "full-auto",
1058
+ confirm: async () => true,
1059
+ toolFilter: (n) => n === "memory_write" || READONLY_TOOLS.has(n),
1060
+ systemOverride: MEMORY_DISTILL_SYSTEM,
1061
+ stats,
1062
+ });
1063
+ if (stats.input || stats.output)
1064
+ out(statusLine(cfg.model, stats.input, stats.output) + "\n");
1065
+ });
663
1066
  const rolesCmd = program.command("roles").description("manage org roles (.hara/roles)");
664
1067
  rolesCmd
665
1068
  .command("init")
@@ -715,6 +1118,18 @@ pluginCmd
715
1118
  m.mcpServers ? `${Object.keys(m.mcpServers).length} mcp server(s)` : "",
716
1119
  ].filter(Boolean);
717
1120
  out(c.green(`Installed ${p.name}@${p.version}${parts.length ? c.dim(" — " + parts.join(", ")) : ""}\n`));
1121
+ // Surface the code-execution surface: a plugin's MCP servers + hooks run shell commands on every
1122
+ // hara launch with no prompt. Installing a plugin = trusting its author to run code; show what.
1123
+ const execs = [];
1124
+ for (const [name, s] of Object.entries(m.mcpServers ?? {}))
1125
+ execs.push(`mcp ${name}: ${[s.command, ...(s.args ?? [])].join(" ")}`);
1126
+ for (const h of [...(m.hooks?.PreToolUse ?? []), ...(m.hooks?.PostToolUse ?? [])])
1127
+ execs.push(`hook: ${h.command}`);
1128
+ if (execs.length) {
1129
+ out(c.yellow(`⚠ ${p.name} will run these commands on every hara launch (a plugin is code you run — review them):\n`) +
1130
+ execs.map((e) => c.dim(` ${e}`)).join("\n") +
1131
+ c.dim(`\n disable: hara plugin disable ${p.name}\n`));
1132
+ }
718
1133
  }
719
1134
  catch (e) {
720
1135
  out(c.red(`Install failed: ${e.message}\n`));
@@ -807,10 +1222,23 @@ program.action(async (opts) => {
807
1222
  cfg.model = opts.model;
808
1223
  const provider0 = await buildProvider(cfg);
809
1224
  if (!provider0) {
1225
+ // First-run friendliness: offer the setup wizard instead of just erroring (interactive TTY only).
1226
+ if (stdin.isTTY && !opts.print) {
1227
+ const rl = createInterface({ input: stdin, output: stdout });
1228
+ const ans = (await rl.question(c.yellow(`Not authenticated for '${cfg.provider}'. Run setup now? `) + c.dim("[Y/n] "))).trim().toLowerCase();
1229
+ rl.close();
1230
+ if (ans === "" || ans === "y" || ans === "yes") {
1231
+ await runSetup();
1232
+ out(c.dim(`\nThen run ${c.bold("hara")} to start.\n`));
1233
+ process.exit(0);
1234
+ }
1235
+ }
810
1236
  out(c.red(`Not authenticated for provider '${cfg.provider}'.\n`) + authHint(cfg) + "\n");
811
1237
  process.exit(1);
812
1238
  }
813
1239
  let provider = provider0;
1240
+ if (cfg.provider === "hara-gateway")
1241
+ void heartbeat(); // fleet visibility — fire-and-forget, never blocks startup
814
1242
  const cwd = cfg.cwd;
815
1243
  let approval = opts.yes ? "full-auto" : (opts.approval || cfg.approval);
816
1244
  let currentTurn = null; // set during a running turn so Esc can abort it
@@ -1272,6 +1700,7 @@ program.action(async (opts) => {
1272
1700
  header: { version: pkg.version, model: `${cfg.provider}:${cfg.model}`, cwd, vision: visionLine, session: meta.id, tip: `/help · @file attaches · shift+tab cycles modes · esc interrupts${projectContext ? " · AGENTS.md loaded" : ""}` },
1273
1701
  cycleApproval: (m) => cycleMode(m),
1274
1702
  onClipboardImage: readClipboardImage,
1703
+ vim: cfg.vimMode,
1275
1704
  onSubmit: async (line, h, images) => {
1276
1705
  if (line.startsWith("/")) {
1277
1706
  const [nm, ...rest] = line.slice(1).split(/\s+/);
@@ -1420,6 +1849,49 @@ program.action(async (opts) => {
1420
1849
  h.setApproval(m);
1421
1850
  return void h.sink.notice(`(approval → ${m})`);
1422
1851
  }
1852
+ if (nm === "diff") {
1853
+ try {
1854
+ const d = (await runShell(arg === "staged" ? "git diff --staged" : "git diff HEAD", cwd, "off", { timeout: 30_000, maxBuffer: 8_000_000 })).stdout.trim();
1855
+ if (!d)
1856
+ return void h.sink.notice(arg === "staged" ? "(nothing staged)" : "(no changes vs HEAD — /diff staged for the index)");
1857
+ return void h.sink.diff(d.length > 12_000 ? d.slice(0, 12_000) + "\n…[truncated]" : d);
1858
+ }
1859
+ catch {
1860
+ return void h.sink.notice("(git diff failed — is this a git repo?)");
1861
+ }
1862
+ }
1863
+ if (nm === "commit") {
1864
+ h.sink.notice("✻ writing a commit message…");
1865
+ const r = await autoCommit(provider, cwd); // stages all + commits with an AI message
1866
+ return void h.sink.notice(r.startsWith("error:") ? `✗ ${r}` : r === "nothing to commit" ? "(nothing to commit — make or stage changes first)" : `✓ committed · ${r.slice(0, 100)}`);
1867
+ }
1868
+ if (nm === "review") {
1869
+ let diff = "";
1870
+ try {
1871
+ diff = (await runShell("git diff HEAD", cwd, "off", { timeout: 30_000, maxBuffer: 8_000_000 })).stdout;
1872
+ }
1873
+ catch {
1874
+ /* not a git repo → empty */
1875
+ }
1876
+ if (!diff.trim())
1877
+ return void h.sink.notice("(nothing to review — no changes vs HEAD)");
1878
+ const rui = { text: h.sink.assistantDelta, reasoning: h.sink.reasoningDelta, tool: h.sink.tool, diff: h.sink.diff, notice: h.sink.notice };
1879
+ const xin = stats.input;
1880
+ const xout = stats.output;
1881
+ await runAgent([{ role: "user", content: `Review this diff:\n\n\`\`\`diff\n${diff.slice(0, 120_000)}\n\`\`\`` }], {
1882
+ provider,
1883
+ ctx: { cwd, sandbox, ui: rui },
1884
+ approval: "full-auto", // read-only via the tool filter, so nothing prompts
1885
+ confirm: h.confirm,
1886
+ toolFilter: (n) => READONLY_TOOLS.has(n),
1887
+ systemOverride: REVIEW_SYSTEM,
1888
+ memory: buildMemory(),
1889
+ stats,
1890
+ signal: h.signal,
1891
+ });
1892
+ h.sink.usage(stats.input - xin, stats.output - xout);
1893
+ return;
1894
+ }
1423
1895
  if (byName.has(nm))
1424
1896
  return void h.sink.notice(`/${nm} isn't wired into the TUI yet — use \`hara ${nm} …\` as a subcommand, or HARA_TUI=0.`);
1425
1897
  const near = nearest(nm, [...byName.keys()]);