@nanhara/hara 0.48.0 → 0.62.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/CHANGELOG.md +245 -0
- package/README.md +28 -7
- package/SECURITY.md +54 -0
- package/dist/agent/loop.js +21 -4
- package/dist/completions.js +49 -0
- package/dist/config.js +19 -7
- package/dist/cron/install.js +112 -0
- package/dist/cron/runner.js +109 -0
- package/dist/cron/schedule.js +147 -0
- package/dist/cron/store.js +87 -0
- package/dist/hooks.js +64 -0
- package/dist/index.js +410 -13
- package/dist/mcp/server.js +56 -0
- package/dist/memory/store.js +44 -6
- package/dist/notify.js +42 -0
- package/dist/org/review-chain.js +91 -0
- package/dist/org/roles.js +11 -0
- package/dist/plugins/plugins.js +14 -0
- package/dist/providers/anthropic.js +21 -11
- package/dist/providers/qwen-oauth.js +9 -2
- package/dist/sandbox.js +25 -3
- package/dist/search/semindex.js +9 -2
- package/dist/session/store.js +12 -2
- package/dist/tools/computer.js +9 -4
- package/dist/tools/patch.js +31 -12
- package/dist/tools/todo.js +51 -0
- package/dist/tools/web.js +178 -8
- package/dist/tui/App.js +17 -4
- package/dist/tui/InputBox.js +37 -3
- package/dist/tui/vim.js +115 -0
- package/package.json +6 -2
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";
|
|
@@ -15,6 +15,14 @@ import { fileURLToPath } from "node:url";
|
|
|
15
15
|
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
|
+
import { notifyDone } from "./notify.js";
|
|
19
|
+
import { startMcpServer, mcpServeToolNames } from "./mcp/server.js";
|
|
20
|
+
import { completionScript } from "./completions.js";
|
|
21
|
+
import { parseVerdict, captureChanges, reviewPrompt, fixPrompt, REVIEWER_SYSTEM, isTreeClean, stripCommitFence } from "./org/review-chain.js";
|
|
22
|
+
import { parseSchedule, describeSchedule, nextRun } from "./cron/schedule.js";
|
|
23
|
+
import { addJob, removeJob, setEnabled, resolveJob, loadJobs, recordRun, logPath } from "./cron/store.js";
|
|
24
|
+
import { runTick, runJobOnce, selfArgv } from "./cron/runner.js";
|
|
25
|
+
import { installScheduler, uninstallScheduler, isInstalled } from "./cron/install.js";
|
|
18
26
|
import { getTools } from "./tools/registry.js";
|
|
19
27
|
import { createAnthropicProvider } from "./providers/anthropic.js";
|
|
20
28
|
import { createOpenAIProvider } from "./providers/openai.js";
|
|
@@ -25,9 +33,9 @@ import { collectRepoChunks, collectDirChunks, buildIndex, indexPath, indexExists
|
|
|
25
33
|
import { searchHybrid } from "./search/hybrid.js";
|
|
26
34
|
import { expandMentions, fileCandidates } from "./context/mentions.js";
|
|
27
35
|
import { newSessionId, shortId, resolveSessionId, saveSession, loadSession, listSessions, latestForCwd, titleFrom, slugify, } from "./session/store.js";
|
|
28
|
-
import { loadRoles, scaffoldRoles } from "./org/roles.js";
|
|
36
|
+
import { loadRoles, scaffoldRoles, subagentToolFilter } from "./org/roles.js";
|
|
29
37
|
import { loadSkillIndex, loadSkillBody, scaffoldSkills, globalSkillsDir } from "./skills/skills.js";
|
|
30
|
-
import { installPlugin, uninstallPlugin, listInstalled, enabledPlugins, setPluginEnabled, pluginMcpServers } from "./plugins/plugins.js";
|
|
38
|
+
import { installPlugin, uninstallPlugin, listInstalled, enabledPlugins, setPluginEnabled, pluginMcpServers, pluginHooks } from "./plugins/plugins.js";
|
|
31
39
|
import { routeByKeywords, buildDispatchPrompt, parseRoleId } from "./org/router.js";
|
|
32
40
|
import { decompose, topoOrder, topoWaves, savePlan, loadPlan, atomPrompt, verify, runCheck } from "./org/planner.js";
|
|
33
41
|
import { connectMcpServers, closeMcp } from "./mcp/client.js";
|
|
@@ -46,9 +54,22 @@ import "./tools/agent.js"; // register agent (subagent spawn)
|
|
|
46
54
|
import "./tools/memory.js"; // register memory_search/get/write/forget/skill_create
|
|
47
55
|
import "./tools/skill.js"; // register the skill loader tool
|
|
48
56
|
import "./tools/codebase.js"; // register codebase_search (repo as a knowledge base)
|
|
57
|
+
import "./tools/todo.js"; // register todo_write (inline task checklist)
|
|
49
58
|
import { computerBackends } from "./tools/computer.js"; // register the computer tool + expose the backend probe
|
|
50
59
|
const here = dirname(fileURLToPath(import.meta.url));
|
|
51
|
-
|
|
60
|
+
// Version: from a build-time define in the compiled single-binary (no package.json on its virtual FS),
|
|
61
|
+
// else read package.json (npm install / `node dist`). The read is wrapped so the binary never hits it.
|
|
62
|
+
const pkg = {
|
|
63
|
+
version: process.env.HARA_BUILD_VERSION ??
|
|
64
|
+
((() => {
|
|
65
|
+
try {
|
|
66
|
+
return JSON.parse(readFileSync(join(here, "..", "package.json"), "utf8")).version;
|
|
67
|
+
}
|
|
68
|
+
catch {
|
|
69
|
+
return "0.0.0";
|
|
70
|
+
}
|
|
71
|
+
})()),
|
|
72
|
+
};
|
|
52
73
|
const maskKey = (v) => (v ? `${v.slice(0, 7)}…${v.slice(-4)}` : "(unset)");
|
|
53
74
|
async function buildProvider(cfg) {
|
|
54
75
|
if (cfg.provider === "qwen-oauth") {
|
|
@@ -73,6 +94,61 @@ async function runInit(provider, cwd, sandbox = "off") {
|
|
|
73
94
|
const history = [{ role: "user", content: INIT_PROMPT }];
|
|
74
95
|
await runAgent(history, { provider, ctx: { cwd, sandbox }, approval: "full-auto", confirm: async () => true });
|
|
75
96
|
}
|
|
97
|
+
/** Stage everything and commit with an AI-written message. Returns a one-line summary or "error: …".
|
|
98
|
+
* Used by `hara org --commit`; the caller guards on a clean start tree so this only captures the run's work. */
|
|
99
|
+
async function autoCommit(provider, cwd) {
|
|
100
|
+
try {
|
|
101
|
+
await runShell("git add -A", cwd, "off", { timeout: 30_000, maxBuffer: 1_000_000 });
|
|
102
|
+
}
|
|
103
|
+
catch {
|
|
104
|
+
/* fall through — empty diff is reported below */
|
|
105
|
+
}
|
|
106
|
+
let diff = "";
|
|
107
|
+
try {
|
|
108
|
+
diff = (await runShell("git diff --staged", cwd, "off", { timeout: 30_000, maxBuffer: 8_000_000 })).stdout;
|
|
109
|
+
}
|
|
110
|
+
catch (e) {
|
|
111
|
+
return `error: git diff failed (${e instanceof Error ? e.message : String(e)})`;
|
|
112
|
+
}
|
|
113
|
+
if (!diff.trim())
|
|
114
|
+
return "nothing to commit";
|
|
115
|
+
const r = await provider.turn({
|
|
116
|
+
system: COMMIT_SYSTEM,
|
|
117
|
+
history: [{ role: "user", content: `Write a commit message for these staged changes:\n\n\`\`\`diff\n${diff.slice(0, 120_000)}\n\`\`\`` }],
|
|
118
|
+
tools: [],
|
|
119
|
+
onText: () => { },
|
|
120
|
+
});
|
|
121
|
+
const msg = stripCommitFence(r.text);
|
|
122
|
+
if (!msg)
|
|
123
|
+
return "error: no commit message produced";
|
|
124
|
+
const tmp = join(tmpdir(), `hara-org-commit-${process.pid}.txt`);
|
|
125
|
+
writeFileSync(tmp, msg + "\n", "utf8");
|
|
126
|
+
try {
|
|
127
|
+
const res = await runShell(`git commit -F ${JSON.stringify(tmp)}`, cwd, "off", { timeout: 30_000, maxBuffer: 1_000_000 });
|
|
128
|
+
return (res.stdout || "").trim().split("\n")[0] || "committed";
|
|
129
|
+
}
|
|
130
|
+
catch (e) {
|
|
131
|
+
return `error: git commit failed (${e instanceof Error ? e.message : String(e)})`;
|
|
132
|
+
}
|
|
133
|
+
finally {
|
|
134
|
+
try {
|
|
135
|
+
rmSync(tmp);
|
|
136
|
+
}
|
|
137
|
+
catch {
|
|
138
|
+
/* best-effort cleanup */
|
|
139
|
+
}
|
|
140
|
+
}
|
|
141
|
+
}
|
|
142
|
+
/** Format an autoCommit result + emit it. */
|
|
143
|
+
async function commitStep(provider, cwd) {
|
|
144
|
+
const r = await autoCommit(provider, cwd);
|
|
145
|
+
if (r.startsWith("error:"))
|
|
146
|
+
out(c.red(`✗ ${r}\n`));
|
|
147
|
+
else if (r === "nothing to commit")
|
|
148
|
+
out(c.dim("(nothing to commit)\n"));
|
|
149
|
+
else
|
|
150
|
+
out(c.green(`✓ committed · ${r.slice(0, 100)}\n`));
|
|
151
|
+
}
|
|
76
152
|
/** Dispatch a task to the owning role and run that role's agent (its persona + tool subset + model). */
|
|
77
153
|
async function runOrg(task, o) {
|
|
78
154
|
const roles = loadRoles(o.cwd);
|
|
@@ -113,7 +189,7 @@ async function runOrg(task, o) {
|
|
|
113
189
|
? (n) => !role.denyTools.includes(n)
|
|
114
190
|
: undefined;
|
|
115
191
|
const history = [{ role: "user", content: expandMentions(task, o.cwd) }];
|
|
116
|
-
|
|
192
|
+
const runImplementer = () => runAgent(history, {
|
|
117
193
|
provider: roleProvider,
|
|
118
194
|
ctx: { cwd: o.cwd, sandbox: o.sandbox },
|
|
119
195
|
approval: o.approval,
|
|
@@ -124,6 +200,61 @@ async function runOrg(task, o) {
|
|
|
124
200
|
systemOverride: role.system,
|
|
125
201
|
toolFilter,
|
|
126
202
|
});
|
|
203
|
+
const wasClean = o.commit ? isTreeClean(o.cwd) : false; // capture BEFORE the implementer edits anything
|
|
204
|
+
const doCommit = async (ok) => {
|
|
205
|
+
if (!o.commit)
|
|
206
|
+
return;
|
|
207
|
+
if (!ok)
|
|
208
|
+
return void out(c.yellow("(not committing — review didn't approve; changes left in your working tree)\n"));
|
|
209
|
+
if (!wasClean)
|
|
210
|
+
return void out(c.yellow("(not auto-committing — the tree wasn't clean before this run; commit manually)\n"));
|
|
211
|
+
await commitStep(o.baseProvider, o.cwd);
|
|
212
|
+
};
|
|
213
|
+
await runImplementer();
|
|
214
|
+
if (!o.review) {
|
|
215
|
+
await doCommit(true);
|
|
216
|
+
return;
|
|
217
|
+
}
|
|
218
|
+
// Review chain: a reviewer role inspects the diff and APPROVES or sends it back, looping until clean.
|
|
219
|
+
const reviewer = roles.find((r) => r.id === "reviewer");
|
|
220
|
+
const revProvider = reviewer?.model && reviewer.model !== o.cfg.model ? ((await buildProvider({ ...o.cfg, model: reviewer.model })) ?? o.baseProvider) : o.baseProvider;
|
|
221
|
+
const revSystem = reviewer?.system ?? REVIEWER_SYSTEM;
|
|
222
|
+
const revTools = reviewer?.allowTools ? (n) => reviewer.allowTools.includes(n) : (n) => READONLY_TOOLS.has(n);
|
|
223
|
+
const maxRounds = Math.max(1, o.rounds ?? 3);
|
|
224
|
+
for (let round = 1; round <= maxRounds; round++) {
|
|
225
|
+
const changes = captureChanges(o.cwd);
|
|
226
|
+
if (!changes.diff && !changes.newFiles.length) {
|
|
227
|
+
out(c.dim("(no changes to review)\n"));
|
|
228
|
+
return;
|
|
229
|
+
}
|
|
230
|
+
out(c.dim(`🔍 reviewer · round ${round}/${maxRounds}\n`));
|
|
231
|
+
const rHist = [{ role: "user", content: reviewPrompt(task, changes) }];
|
|
232
|
+
await runAgent(rHist, {
|
|
233
|
+
provider: revProvider,
|
|
234
|
+
ctx: { cwd: o.cwd, sandbox: o.sandbox },
|
|
235
|
+
approval: "full-auto", // reviewer is read-only via revTools, so nothing to confirm
|
|
236
|
+
confirm: o.confirm,
|
|
237
|
+
projectContext: o.projectContext,
|
|
238
|
+
memory: memoryDigest(o.cwd),
|
|
239
|
+
stats: o.stats,
|
|
240
|
+
systemOverride: revSystem,
|
|
241
|
+
toolFilter: revTools,
|
|
242
|
+
});
|
|
243
|
+
const verdict = parseVerdict(lastAssistantText(rHist));
|
|
244
|
+
if (verdict.approved) {
|
|
245
|
+
out(c.green(`✓ reviewer approved after ${round} round(s)\n`));
|
|
246
|
+
await doCommit(true);
|
|
247
|
+
return;
|
|
248
|
+
}
|
|
249
|
+
if (round === maxRounds) {
|
|
250
|
+
out(c.yellow(`⚠ stopped after ${maxRounds} round(s) — reviewer still wants changes.\n`));
|
|
251
|
+
await doCommit(false);
|
|
252
|
+
return;
|
|
253
|
+
}
|
|
254
|
+
out(c.yellow(`✗ changes requested — back to ${role.id} (round ${round})\n`));
|
|
255
|
+
history.push({ role: "user", content: fixPrompt(verdict.issues) });
|
|
256
|
+
await runImplementer();
|
|
257
|
+
}
|
|
127
258
|
}
|
|
128
259
|
function lastAssistantText(history) {
|
|
129
260
|
for (let i = history.length - 1; i >= 0; i--) {
|
|
@@ -271,7 +402,7 @@ async function runResume(o) {
|
|
|
271
402
|
savePlan(o.cwd, plan);
|
|
272
403
|
await executePlan(plan, roles, o);
|
|
273
404
|
}
|
|
274
|
-
const READONLY_TOOLS = new Set(["read_file", "grep", "glob", "ls", "web_fetch", "codebase_search"]);
|
|
405
|
+
const READONLY_TOOLS = new Set(["read_file", "grep", "glob", "ls", "web_fetch", "web_search", "codebase_search", "todo_write"]);
|
|
275
406
|
const REVIEW_SYSTEM = "You are a senior code reviewer. Review the git diff the user provides for: correctness bugs, security " +
|
|
276
407
|
"issues, missing error handling, unclear naming, and missing/weak tests. You may read files (read-only) " +
|
|
277
408
|
"for context. Be concise and specific — cite file:line and the concrete fix. Group findings by severity: " +
|
|
@@ -310,6 +441,11 @@ const PLAN_SYSTEM = "You are in PLAN MODE. Investigate read-only (read_file / gr
|
|
|
310
441
|
"End your message with the plan as a short numbered list.";
|
|
311
442
|
const DISTILL_SYSTEM = "The session is ending. Reflect and persist only durable, reusable learnings: memory_write for facts / " +
|
|
312
443
|
"conventions / the user's preferences, skill_create for reusable how-tos. Be selective — skip the trivial. Then reply DONE.";
|
|
444
|
+
const MEMORY_DISTILL_SYSTEM = "You consolidate an agent's short-term daily memory logs into its durable long-term memory. You're given " +
|
|
445
|
+
"the current durable memory and recent daily logs. Extract ONLY durable, reusable facts / decisions / " +
|
|
446
|
+
"conventions / user preferences from the logs that are NOT already captured, and persist each with " +
|
|
447
|
+
"memory_write (target=memory, or target=user for preferences; pick the right scope=project|global). " +
|
|
448
|
+
"Skip the ephemeral, the one-off, and anything already known. Be terse and de-duplicated. Then reply DONE.";
|
|
313
449
|
const COMPACT_SYSTEM = "Summarize the conversation so far into a concise but complete brief so the assistant can " +
|
|
314
450
|
"continue seamlessly: the user's goal, key decisions, files changed, current state, and open next steps. " +
|
|
315
451
|
"Be specific. Output only the summary.";
|
|
@@ -324,11 +460,10 @@ async function runSubagent(cfg, baseProvider, cwd, sandbox, projectContext, stat
|
|
|
324
460
|
const roles = loadRoles(cwd);
|
|
325
461
|
const role = roleId ? roles.find((r) => r.id === roleId) : undefined;
|
|
326
462
|
const provider = role?.model && role.model !== cfg.model ? ((await buildProvider({ ...cfg, model: role.model })) ?? baseProvider) : baseProvider;
|
|
327
|
-
|
|
328
|
-
|
|
329
|
-
|
|
330
|
-
|
|
331
|
-
: (n) => READONLY_TOOLS.has(n); // default sub-agent = read-only (safe to parallelize)
|
|
463
|
+
// A sub-agent runs full-auto + UNCONFIRMED + parallel, so it is ALWAYS read-only — a role may narrow
|
|
464
|
+
// further but can never GRANT write/exec to a fan-out sub-agent (that would bypass the approval gate).
|
|
465
|
+
// Write-capable roles run in the main loop via `hara org`, behind the user's gate.
|
|
466
|
+
const toolFilter = subagentToolFilter(role, (n) => READONLY_TOOLS.has(n));
|
|
332
467
|
const subHistory = [{ role: "user", content: task }];
|
|
333
468
|
await runAgent(subHistory, {
|
|
334
469
|
provider,
|
|
@@ -375,7 +510,11 @@ function runDoctor(cfg) {
|
|
|
375
510
|
`${dot} vision · ${c.bold(cfg.model)} ${vdesc}${cfg.visionModel ? c.dim(" · describer ") + c.bold(cfg.visionModel) : vcap === "text" ? c.yellow(" · set /vision <model>") : ""}`,
|
|
376
511
|
`${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"}`)}`,
|
|
377
512
|
`${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>"); })()}`,
|
|
378
|
-
`${dot} mcp
|
|
513
|
+
`${dot} mcp ${c.dim(`client: ${Object.keys({ ...pluginMcpServers(), ...cfg.mcpServers }).length} server(s) · serve: ${mcpServeToolNames().length} read tools via \`hara mcp\``)}`,
|
|
514
|
+
`${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\""); })()}`,
|
|
515
|
+
`${dot} notify ${cfg.notify === "off" ? c.dim("off — hara config set notify bell|system") : c.bold(cfg.notify)}`,
|
|
516
|
+
`${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"); })()}`,
|
|
517
|
+
`${dot} input ${cfg.vimMode ? c.bold("vim") + c.dim(" (modal)") : c.dim("default — hara config set vimMode true for vim keys")}`,
|
|
379
518
|
];
|
|
380
519
|
return lines.join("\n");
|
|
381
520
|
}
|
|
@@ -430,8 +569,11 @@ program
|
|
|
430
569
|
});
|
|
431
570
|
program
|
|
432
571
|
.command("org <task...>")
|
|
433
|
-
.description("dispatch a task to the owning role and run it")
|
|
572
|
+
.description("dispatch a task to the owning role and run it (--review loops a reviewer until it approves)")
|
|
434
573
|
.option("--role <id>", "force a specific role")
|
|
574
|
+
.option("--review", "after implementing, loop a reviewer role until it approves (implement → review → fix)")
|
|
575
|
+
.option("--rounds <n>", "max review rounds with --review (default 3)", (v) => parseInt(v, 10))
|
|
576
|
+
.option("--commit", "commit the result with an AI message (with --review: only after approval; needs a clean start tree)")
|
|
435
577
|
.action(async (taskParts, opts2) => {
|
|
436
578
|
const cfg = loadConfig();
|
|
437
579
|
const provider = await buildProvider(cfg);
|
|
@@ -450,6 +592,9 @@ program
|
|
|
450
592
|
projectContext: loadAgentsMd(cfg.cwd) || undefined,
|
|
451
593
|
stats,
|
|
452
594
|
forceRole: opts2.role,
|
|
595
|
+
review: opts2.review,
|
|
596
|
+
rounds: opts2.rounds,
|
|
597
|
+
commit: opts2.commit,
|
|
453
598
|
});
|
|
454
599
|
if (stats.input || stats.output)
|
|
455
600
|
out(statusLine(cfg.model, stats.input, stats.output) + "\n");
|
|
@@ -551,6 +696,31 @@ program
|
|
|
551
696
|
.command("doctor")
|
|
552
697
|
.description("check your hara setup (provider / auth / model / node / assets / roles)")
|
|
553
698
|
.action(() => out(runDoctor(loadConfig()) + "\n"));
|
|
699
|
+
program
|
|
700
|
+
.command("completions <shell>")
|
|
701
|
+
.description("print a shell completion script: bash | zsh | fish (eval it in your shell rc)")
|
|
702
|
+
.action((shell) => {
|
|
703
|
+
const top = program.commands.map((cmd) => cmd.name()).filter((n) => n && n !== "completions").sort();
|
|
704
|
+
const subs = {};
|
|
705
|
+
for (const cmd of program.commands) {
|
|
706
|
+
const sub = cmd.commands.map((s) => s.name()).filter(Boolean);
|
|
707
|
+
if (sub.length)
|
|
708
|
+
subs[cmd.name()] = sub;
|
|
709
|
+
}
|
|
710
|
+
const script = completionScript(shell, { top, subs });
|
|
711
|
+
if (!script)
|
|
712
|
+
return void out(c.red(`Unsupported shell '${shell}'. Use: bash | zsh | fish\n`));
|
|
713
|
+
out(script);
|
|
714
|
+
});
|
|
715
|
+
program
|
|
716
|
+
.command("mcp")
|
|
717
|
+
.description("run hara as an MCP server (stdio) — expose its read/search tools (incl. codebase_search) to other MCP clients")
|
|
718
|
+
.action(async () => {
|
|
719
|
+
const cfg = loadConfig();
|
|
720
|
+
// stdout is the JSON-RPC transport — diagnostics MUST go to stderr only.
|
|
721
|
+
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`));
|
|
722
|
+
await startMcpServer(pkg.version, { cwd: cfg.cwd, sandbox: "read-only" });
|
|
723
|
+
});
|
|
554
724
|
program
|
|
555
725
|
.command("review")
|
|
556
726
|
.description("review your uncommitted changes (git diff) for bugs, security, and missing tests")
|
|
@@ -656,6 +826,154 @@ program
|
|
|
656
826
|
}
|
|
657
827
|
}
|
|
658
828
|
});
|
|
829
|
+
function renderCronJobs() {
|
|
830
|
+
const jobs = loadJobs();
|
|
831
|
+
const head = isInstalled() ? c.green("scheduler: installed") : c.yellow("scheduler: NOT installed — run `hara cron install`");
|
|
832
|
+
if (!jobs.length)
|
|
833
|
+
return head + "\n" + c.dim('No jobs. Add one: hara cron add "every 1h" "<task>"\n');
|
|
834
|
+
const now = Date.now();
|
|
835
|
+
const lines = jobs.map((j) => {
|
|
836
|
+
const nxt = nextRun(j, now);
|
|
837
|
+
const status = j.lastStatus ? (j.lastStatus === "ok" ? c.green("ok") : c.red("err")) : c.dim("—");
|
|
838
|
+
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)}`;
|
|
839
|
+
});
|
|
840
|
+
return head + "\n" + lines.join("\n") + "\n";
|
|
841
|
+
}
|
|
842
|
+
const cronCmd = program.command("cron").description("scheduled tasks — run a prompt/org task on a schedule (fired by your OS via `hara cron install`)");
|
|
843
|
+
cronCmd
|
|
844
|
+
.command("add <schedule> <task...>")
|
|
845
|
+
.description('schedule a task — schedule = cron expr ("0 9 * * *"), "every 30m", "in 2h", or an ISO timestamp')
|
|
846
|
+
.option("--name <name>", "a label for the job")
|
|
847
|
+
.option("--org", "run via `hara org` (role routing + review) instead of a plain `hara -p` prompt")
|
|
848
|
+
.action((schedule, taskParts, opts) => {
|
|
849
|
+
const task = taskParts.join(" ");
|
|
850
|
+
const sched = parseSchedule(schedule, Date.now());
|
|
851
|
+
if ("error" in sched)
|
|
852
|
+
return void out(c.red(sched.error + "\n"));
|
|
853
|
+
const job = addJob({ name: opts.name || task.slice(0, 48), schedule: sched, task, mode: opts.org ? "org" : "print", cwd: process.cwd(), createdAt: Date.now() });
|
|
854
|
+
out(c.green(`✓ scheduled ${job.id}`) + c.dim(` · ${describeSchedule(sched)} · ${job.mode} · cwd ${job.cwd}\n`));
|
|
855
|
+
if (!isInstalled())
|
|
856
|
+
out(c.yellow("⚠ scheduler not installed yet — run `hara cron install` so jobs actually fire.\n"));
|
|
857
|
+
});
|
|
858
|
+
// Resolve an id/prefix to one job, printing a clear error for none / ambiguous (never act on a guess).
|
|
859
|
+
const cronResolve = (id) => {
|
|
860
|
+
const r = resolveJob(id);
|
|
861
|
+
if (r === "ambiguous")
|
|
862
|
+
return void out(c.red(`ambiguous id "${id}" — matches multiple jobs; type more characters\n`)), null;
|
|
863
|
+
if (!r)
|
|
864
|
+
return void out(c.red(`no such job: ${id}\n`)), null;
|
|
865
|
+
return r;
|
|
866
|
+
};
|
|
867
|
+
cronCmd.command("list").alias("ls").description("list scheduled jobs").action(() => out(renderCronJobs()));
|
|
868
|
+
cronCmd
|
|
869
|
+
.command("remove <id>")
|
|
870
|
+
.alias("rm")
|
|
871
|
+
.description("delete a job (by id or unique prefix)")
|
|
872
|
+
.action((id) => {
|
|
873
|
+
const j = cronResolve(id);
|
|
874
|
+
if (j)
|
|
875
|
+
out(removeJob(j.id) ? c.green(`✓ removed ${j.id}\n`) : c.red("no such job\n"));
|
|
876
|
+
});
|
|
877
|
+
cronCmd.command("enable <id>").description("enable a job").action((id) => {
|
|
878
|
+
const j = cronResolve(id);
|
|
879
|
+
if (j) {
|
|
880
|
+
setEnabled(j.id, true);
|
|
881
|
+
out(c.green(`✓ enabled ${j.id}\n`));
|
|
882
|
+
}
|
|
883
|
+
});
|
|
884
|
+
cronCmd.command("disable <id>").description("disable a job (keeps it, stops firing)").action((id) => {
|
|
885
|
+
const j = cronResolve(id);
|
|
886
|
+
if (j) {
|
|
887
|
+
setEnabled(j.id, false);
|
|
888
|
+
out(c.green(`✓ disabled ${j.id}\n`));
|
|
889
|
+
}
|
|
890
|
+
});
|
|
891
|
+
cronCmd
|
|
892
|
+
.command("run <id>")
|
|
893
|
+
.description("run a job right now, ignoring its schedule")
|
|
894
|
+
.action(async (id) => {
|
|
895
|
+
const job = cronResolve(id);
|
|
896
|
+
if (!job)
|
|
897
|
+
return;
|
|
898
|
+
out(c.dim(`running ${job.id} (${job.name})…\n`));
|
|
899
|
+
const r = await runJobOnce(job);
|
|
900
|
+
recordRun(job.id, Date.now(), r.ok ? "ok" : "error", r.error);
|
|
901
|
+
out((r.ok ? c.green("✓ done") : c.red(`✗ ${r.error}`)) + c.dim(` · log: ${logPath(job.id)}\n`));
|
|
902
|
+
});
|
|
903
|
+
cronCmd
|
|
904
|
+
.command("tick")
|
|
905
|
+
.description("run all due jobs now (your OS scheduler calls this every minute)")
|
|
906
|
+
.action(async () => {
|
|
907
|
+
const r = await runTick(Date.now());
|
|
908
|
+
if (r.skipped)
|
|
909
|
+
return void out(c.dim(`(skipped — ${r.skipped})\n`));
|
|
910
|
+
out(c.dim(r.ran.length ? `ran ${r.ran.length} job(s): ${r.ran.join(", ")}\n` : "(no jobs due)\n"));
|
|
911
|
+
});
|
|
912
|
+
cronCmd
|
|
913
|
+
.command("install")
|
|
914
|
+
.description("register the per-minute tick with your OS scheduler (launchd on macOS, crontab on Linux)")
|
|
915
|
+
.action(() => {
|
|
916
|
+
const r = installScheduler(selfArgv());
|
|
917
|
+
out((r.ok ? c.green("✓ ") : c.red("✗ ")) + r.msg + "\n");
|
|
918
|
+
});
|
|
919
|
+
cronCmd.command("uninstall").description("remove the OS scheduler entry").action(() => {
|
|
920
|
+
const r = uninstallScheduler();
|
|
921
|
+
out((r.ok ? c.green("✓ ") : c.red("✗ ")) + r.msg + "\n");
|
|
922
|
+
});
|
|
923
|
+
cronCmd
|
|
924
|
+
.command("logs <id>")
|
|
925
|
+
.description("show a job's recent run output")
|
|
926
|
+
.action((id) => {
|
|
927
|
+
const job = cronResolve(id);
|
|
928
|
+
if (!job)
|
|
929
|
+
return;
|
|
930
|
+
const p = logPath(job.id);
|
|
931
|
+
out(existsSync(p) ? readFileSync(p, "utf8").slice(-4000) + "\n" : c.dim("(no runs yet)\n"));
|
|
932
|
+
});
|
|
933
|
+
const memoryCmd = program.command("memory").description("inspect + consolidate hara's durable memory (~/.hara/memory + project .hara/memory)");
|
|
934
|
+
memoryCmd.command("show").description("print the memory digest injected at session start").action(() => {
|
|
935
|
+
const d = memoryDigest(process.cwd());
|
|
936
|
+
out(d ? d + "\n" : c.dim("(memory is empty — `hara memory init`, or let the agent write via memory_write)\n"));
|
|
937
|
+
});
|
|
938
|
+
memoryCmd.command("init").description("scaffold the memory dirs + seed files (global + project)").action(() => {
|
|
939
|
+
const w = scaffoldMemory(process.cwd());
|
|
940
|
+
out(w.length ? c.green(`Scaffolded: ${w.join(", ")}\n`) : c.dim("Memory already scaffolded.\n"));
|
|
941
|
+
});
|
|
942
|
+
memoryCmd
|
|
943
|
+
.command("distill")
|
|
944
|
+
.description("consolidate recent daily logs into durable MEMORY (promote short-term → long-term)")
|
|
945
|
+
.option("--days <n>", "days of logs to consider (default 14)", (v) => parseInt(v, 10))
|
|
946
|
+
.option("--scope <s>", "global | project | all (default all)")
|
|
947
|
+
.action(async (opts) => {
|
|
948
|
+
const cfg = loadConfig();
|
|
949
|
+
const provider = await buildProvider(cfg);
|
|
950
|
+
if (!provider) {
|
|
951
|
+
out(c.red(`Not authenticated for provider '${cfg.provider}'.\n`) + authHint(cfg) + "\n");
|
|
952
|
+
process.exit(1);
|
|
953
|
+
}
|
|
954
|
+
const days = opts.days && opts.days > 0 ? opts.days : 14;
|
|
955
|
+
const scopes = opts.scope === "global" ? ["global"] : opts.scope === "project" ? ["project"] : ["project", "global"];
|
|
956
|
+
const logs = scopes
|
|
957
|
+
.map((s) => readRecentLogs(s, cfg.cwd, days))
|
|
958
|
+
.filter(Boolean)
|
|
959
|
+
.join("\n\n");
|
|
960
|
+
if (!logs.trim())
|
|
961
|
+
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`));
|
|
962
|
+
out(c.dim(`Distilling ${days}-day logs → durable memory…\n`));
|
|
963
|
+
const stats = { input: 0, output: 0, lastInput: 0 };
|
|
964
|
+
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)}` }];
|
|
965
|
+
await runAgent(history, {
|
|
966
|
+
provider,
|
|
967
|
+
ctx: { cwd: cfg.cwd, sandbox: cfg.sandbox },
|
|
968
|
+
approval: "full-auto",
|
|
969
|
+
confirm: async () => true,
|
|
970
|
+
toolFilter: (n) => n === "memory_write" || READONLY_TOOLS.has(n),
|
|
971
|
+
systemOverride: MEMORY_DISTILL_SYSTEM,
|
|
972
|
+
stats,
|
|
973
|
+
});
|
|
974
|
+
if (stats.input || stats.output)
|
|
975
|
+
out(statusLine(cfg.model, stats.input, stats.output) + "\n");
|
|
976
|
+
});
|
|
659
977
|
const rolesCmd = program.command("roles").description("manage org roles (.hara/roles)");
|
|
660
978
|
rolesCmd
|
|
661
979
|
.command("init")
|
|
@@ -711,6 +1029,18 @@ pluginCmd
|
|
|
711
1029
|
m.mcpServers ? `${Object.keys(m.mcpServers).length} mcp server(s)` : "",
|
|
712
1030
|
].filter(Boolean);
|
|
713
1031
|
out(c.green(`Installed ${p.name}@${p.version}${parts.length ? c.dim(" — " + parts.join(", ")) : ""}\n`));
|
|
1032
|
+
// Surface the code-execution surface: a plugin's MCP servers + hooks run shell commands on every
|
|
1033
|
+
// hara launch with no prompt. Installing a plugin = trusting its author to run code; show what.
|
|
1034
|
+
const execs = [];
|
|
1035
|
+
for (const [name, s] of Object.entries(m.mcpServers ?? {}))
|
|
1036
|
+
execs.push(`mcp ${name}: ${[s.command, ...(s.args ?? [])].join(" ")}`);
|
|
1037
|
+
for (const h of [...(m.hooks?.PreToolUse ?? []), ...(m.hooks?.PostToolUse ?? [])])
|
|
1038
|
+
execs.push(`hook: ${h.command}`);
|
|
1039
|
+
if (execs.length) {
|
|
1040
|
+
out(c.yellow(`⚠ ${p.name} will run these commands on every hara launch (a plugin is code you run — review them):\n`) +
|
|
1041
|
+
execs.map((e) => c.dim(` ${e}`)).join("\n") +
|
|
1042
|
+
c.dim(`\n disable: hara plugin disable ${p.name}\n`));
|
|
1043
|
+
}
|
|
714
1044
|
}
|
|
715
1045
|
catch (e) {
|
|
716
1046
|
out(c.red(`Install failed: ${e.message}\n`));
|
|
@@ -1268,6 +1598,7 @@ program.action(async (opts) => {
|
|
|
1268
1598
|
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" : ""}` },
|
|
1269
1599
|
cycleApproval: (m) => cycleMode(m),
|
|
1270
1600
|
onClipboardImage: readClipboardImage,
|
|
1601
|
+
vim: cfg.vimMode,
|
|
1271
1602
|
onSubmit: async (line, h, images) => {
|
|
1272
1603
|
if (line.startsWith("/")) {
|
|
1273
1604
|
const [nm, ...rest] = line.slice(1).split(/\s+/);
|
|
@@ -1416,6 +1747,49 @@ program.action(async (opts) => {
|
|
|
1416
1747
|
h.setApproval(m);
|
|
1417
1748
|
return void h.sink.notice(`(approval → ${m})`);
|
|
1418
1749
|
}
|
|
1750
|
+
if (nm === "diff") {
|
|
1751
|
+
try {
|
|
1752
|
+
const d = (await runShell(arg === "staged" ? "git diff --staged" : "git diff HEAD", cwd, "off", { timeout: 30_000, maxBuffer: 8_000_000 })).stdout.trim();
|
|
1753
|
+
if (!d)
|
|
1754
|
+
return void h.sink.notice(arg === "staged" ? "(nothing staged)" : "(no changes vs HEAD — /diff staged for the index)");
|
|
1755
|
+
return void h.sink.diff(d.length > 12_000 ? d.slice(0, 12_000) + "\n…[truncated]" : d);
|
|
1756
|
+
}
|
|
1757
|
+
catch {
|
|
1758
|
+
return void h.sink.notice("(git diff failed — is this a git repo?)");
|
|
1759
|
+
}
|
|
1760
|
+
}
|
|
1761
|
+
if (nm === "commit") {
|
|
1762
|
+
h.sink.notice("✻ writing a commit message…");
|
|
1763
|
+
const r = await autoCommit(provider, cwd); // stages all + commits with an AI message
|
|
1764
|
+
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)}`);
|
|
1765
|
+
}
|
|
1766
|
+
if (nm === "review") {
|
|
1767
|
+
let diff = "";
|
|
1768
|
+
try {
|
|
1769
|
+
diff = (await runShell("git diff HEAD", cwd, "off", { timeout: 30_000, maxBuffer: 8_000_000 })).stdout;
|
|
1770
|
+
}
|
|
1771
|
+
catch {
|
|
1772
|
+
/* not a git repo → empty */
|
|
1773
|
+
}
|
|
1774
|
+
if (!diff.trim())
|
|
1775
|
+
return void h.sink.notice("(nothing to review — no changes vs HEAD)");
|
|
1776
|
+
const rui = { text: h.sink.assistantDelta, reasoning: h.sink.reasoningDelta, tool: h.sink.tool, diff: h.sink.diff, notice: h.sink.notice };
|
|
1777
|
+
const xin = stats.input;
|
|
1778
|
+
const xout = stats.output;
|
|
1779
|
+
await runAgent([{ role: "user", content: `Review this diff:\n\n\`\`\`diff\n${diff.slice(0, 120_000)}\n\`\`\`` }], {
|
|
1780
|
+
provider,
|
|
1781
|
+
ctx: { cwd, sandbox, ui: rui },
|
|
1782
|
+
approval: "full-auto", // read-only via the tool filter, so nothing prompts
|
|
1783
|
+
confirm: h.confirm,
|
|
1784
|
+
toolFilter: (n) => READONLY_TOOLS.has(n),
|
|
1785
|
+
systemOverride: REVIEW_SYSTEM,
|
|
1786
|
+
memory: buildMemory(),
|
|
1787
|
+
stats,
|
|
1788
|
+
signal: h.signal,
|
|
1789
|
+
});
|
|
1790
|
+
h.sink.usage(stats.input - xin, stats.output - xout);
|
|
1791
|
+
return;
|
|
1792
|
+
}
|
|
1419
1793
|
if (byName.has(nm))
|
|
1420
1794
|
return void h.sink.notice(`/${nm} isn't wired into the TUI yet — use \`hara ${nm} …\` as a subcommand, or HARA_TUI=0.`);
|
|
1421
1795
|
const near = nearest(nm, [...byName.keys()]);
|
|
@@ -1423,6 +1797,22 @@ program.action(async (opts) => {
|
|
|
1423
1797
|
}
|
|
1424
1798
|
const ui = { text: h.sink.assistantDelta, reasoning: h.sink.reasoningDelta, tool: h.sink.tool, diff: h.sink.diff, notice: h.sink.notice };
|
|
1425
1799
|
const appr = h.approval;
|
|
1800
|
+
// Type-ahead steering: fold messages typed mid-turn into the next model call (codex-style) so a
|
|
1801
|
+
// clarification/addition course-corrects the live task, rather than waiting for a fresh turn.
|
|
1802
|
+
// Shared by every turn below (plan investigate, plan execute, and the regular turn).
|
|
1803
|
+
const pendingInput = async () => {
|
|
1804
|
+
const out = [];
|
|
1805
|
+
for (const it of h.drainQueue()) {
|
|
1806
|
+
const r2 = await resolveImages(it.images, h);
|
|
1807
|
+
const body = expandMentions(it.line, cwd) + (r2.skip ? "" : (r2.extraText ?? ""));
|
|
1808
|
+
const attach = !r2.skip && r2.attach?.length ? r2.attach : undefined;
|
|
1809
|
+
if (!body.trim() && !attach)
|
|
1810
|
+
continue; // image-only message whose image was skipped → nothing to add
|
|
1811
|
+
out.push({ role: "user", content: `[I sent this while you were working on the above]\n\n${body}`, ...(attach ? { images: attach } : {}) });
|
|
1812
|
+
}
|
|
1813
|
+
return out;
|
|
1814
|
+
};
|
|
1815
|
+
const turnStart = Date.now(); // for the task-done notification (gated on elapsed)
|
|
1426
1816
|
if (appr === "plan") {
|
|
1427
1817
|
// PLAN MODE: read-only investigate → propose a plan → selectable proceed → execute.
|
|
1428
1818
|
const planImg = await resolveImages(images, h);
|
|
@@ -1443,6 +1833,7 @@ program.action(async (opts) => {
|
|
|
1443
1833
|
projectContext,
|
|
1444
1834
|
stats,
|
|
1445
1835
|
signal: h.signal,
|
|
1836
|
+
pendingInput,
|
|
1446
1837
|
});
|
|
1447
1838
|
if (!meta.title) {
|
|
1448
1839
|
meta.title = await nameSession(provider, history);
|
|
@@ -1470,10 +1861,12 @@ program.action(async (opts) => {
|
|
|
1470
1861
|
projectContext,
|
|
1471
1862
|
stats,
|
|
1472
1863
|
signal: h.signal,
|
|
1864
|
+
pendingInput,
|
|
1473
1865
|
});
|
|
1474
1866
|
h.sink.usage(stats.input - xin, stats.output - xout);
|
|
1475
1867
|
saveSession(meta, history);
|
|
1476
1868
|
}
|
|
1869
|
+
notifyDone(cfg.notify, { message: meta.title || "plan turn complete", elapsedMs: Date.now() - turnStart });
|
|
1477
1870
|
return;
|
|
1478
1871
|
}
|
|
1479
1872
|
const ri = await resolveImages(images, h);
|
|
@@ -1494,12 +1887,14 @@ program.action(async (opts) => {
|
|
|
1494
1887
|
projectContext,
|
|
1495
1888
|
stats,
|
|
1496
1889
|
signal: h.signal,
|
|
1890
|
+
pendingInput,
|
|
1497
1891
|
});
|
|
1498
1892
|
if (!meta.title) {
|
|
1499
1893
|
meta.title = await nameSession(provider, history);
|
|
1500
1894
|
h.sink.session(meta.title);
|
|
1501
1895
|
}
|
|
1502
1896
|
h.sink.usage(stats.input - beforeIn, stats.output - beforeOut);
|
|
1897
|
+
notifyDone(cfg.notify, { message: meta.title || "turn complete", elapsedMs: Date.now() - turnStart });
|
|
1503
1898
|
saveSession(meta, history);
|
|
1504
1899
|
},
|
|
1505
1900
|
});
|
|
@@ -1546,6 +1941,7 @@ program.action(async (opts) => {
|
|
|
1546
1941
|
recalledContext = "";
|
|
1547
1942
|
history.push({ role: "user", content: userContent });
|
|
1548
1943
|
currentTurn = new AbortController();
|
|
1944
|
+
const t0 = Date.now();
|
|
1549
1945
|
try {
|
|
1550
1946
|
await runAgent(history, { provider, ctx: { cwd, sandbox, spawn }, approval, confirm, autoApprove, projectContext, memory: buildMemory(), stats, signal: currentTurn.signal });
|
|
1551
1947
|
}
|
|
@@ -1555,6 +1951,7 @@ program.action(async (opts) => {
|
|
|
1555
1951
|
finally {
|
|
1556
1952
|
currentTurn = null;
|
|
1557
1953
|
}
|
|
1954
|
+
notifyDone(cfg.notify, { message: meta.title || "turn complete", elapsedMs: Date.now() - t0 });
|
|
1558
1955
|
if (!meta.title)
|
|
1559
1956
|
meta.title = await nameSession(provider, history);
|
|
1560
1957
|
if (bar.isActive()) {
|
|
@@ -0,0 +1,56 @@
|
|
|
1
|
+
// MCP server — expose hara's safe read/search tools over stdio so other MCP clients (Claude Desktop,
|
|
2
|
+
// Cursor, another hara, …) can use them. The high-value one is `codebase_search` over the current repo
|
|
3
|
+
// (semantic if you've built an index, lexical otherwise). `hara mcp` runs this.
|
|
4
|
+
//
|
|
5
|
+
// Read-only by DEFAULT: no edit/write/bash/computer — an external client must not be able to mutate your
|
|
6
|
+
// machine through hara. Override the exposed set with HARA_MCP_TOOLS (comma list) at your own risk.
|
|
7
|
+
//
|
|
8
|
+
// IMPORTANT: stdio is the JSON-RPC transport — this module must never write to stdout. Diagnostics go to
|
|
9
|
+
// stderr (the `hara mcp` command handles that); tool output flows back through the protocol.
|
|
10
|
+
import { Server } from "@modelcontextprotocol/sdk/server/index.js";
|
|
11
|
+
import { StdioServerTransport } from "@modelcontextprotocol/sdk/server/stdio.js";
|
|
12
|
+
import { ListToolsRequestSchema, CallToolRequestSchema } from "@modelcontextprotocol/sdk/types.js";
|
|
13
|
+
import { getTool, getTools } from "../tools/registry.js";
|
|
14
|
+
/** Safe default: navigate + search the repo + the web. No state, no mutation, no privacy-sensitive memory. */
|
|
15
|
+
const SAFE_TOOLS = ["read_file", "grep", "glob", "ls", "codebase_search", "web_fetch", "web_search"];
|
|
16
|
+
/** Names to expose: HARA_MCP_TOOLS (comma list) if set, else the safe default — intersected with what's
|
|
17
|
+
* actually registered. An unknown name in the override is silently dropped. */
|
|
18
|
+
export function mcpServeToolNames() {
|
|
19
|
+
const env = process.env.HARA_MCP_TOOLS;
|
|
20
|
+
const want = env ? env.split(",").map((s) => s.trim()).filter(Boolean) : SAFE_TOOLS;
|
|
21
|
+
const have = new Set(getTools().map((t) => t.name));
|
|
22
|
+
return want.filter((n) => have.has(n));
|
|
23
|
+
}
|
|
24
|
+
/** The exposed tools in MCP `tools/list` shape. */
|
|
25
|
+
export function mcpToolList() {
|
|
26
|
+
return mcpServeToolNames().map((n) => {
|
|
27
|
+
const t = getTool(n);
|
|
28
|
+
return { name: t.name, description: t.description, inputSchema: t.input_schema };
|
|
29
|
+
});
|
|
30
|
+
}
|
|
31
|
+
/** Run one exposed tool. Refuses anything outside the exposed set (defense in depth, even if a client
|
|
32
|
+
* asks for a name it shouldn't know). Never throws — errors come back as `isError` results. */
|
|
33
|
+
export async function mcpCallTool(name, args, ctx) {
|
|
34
|
+
if (!mcpServeToolNames().includes(name)) {
|
|
35
|
+
return { content: [{ type: "text", text: `Tool not exposed by \`hara mcp\`: ${name}` }], isError: true };
|
|
36
|
+
}
|
|
37
|
+
const tool = getTool(name);
|
|
38
|
+
if (!tool)
|
|
39
|
+
return { content: [{ type: "text", text: `Unknown tool: ${name}` }], isError: true };
|
|
40
|
+
try {
|
|
41
|
+
const out = await tool.run(args ?? {}, ctx);
|
|
42
|
+
return { content: [{ type: "text", text: String(out) }] };
|
|
43
|
+
}
|
|
44
|
+
catch (e) {
|
|
45
|
+
return { content: [{ type: "text", text: `Error: ${e instanceof Error ? e.message : String(e)}` }], isError: true };
|
|
46
|
+
}
|
|
47
|
+
}
|
|
48
|
+
/** Start the stdio MCP server and block (the transport keeps the process alive). */
|
|
49
|
+
export async function startMcpServer(version, ctx) {
|
|
50
|
+
const server = new Server({ name: "hara", version }, { capabilities: { tools: {} } });
|
|
51
|
+
server.setRequestHandler(ListToolsRequestSchema, async () => ({ tools: mcpToolList() }));
|
|
52
|
+
// eslint-disable-next-line @typescript-eslint/no-explicit-any -- SDK's result union has a task-augmented
|
|
53
|
+
// member TS can't narrow to; our {content,isError} shape is a valid CallToolResult (validated at runtime).
|
|
54
|
+
server.setRequestHandler(CallToolRequestSchema, async (req) => (await mcpCallTool(req.params.name, (req.params.arguments ?? {}), ctx)));
|
|
55
|
+
await server.connect(new StdioServerTransport());
|
|
56
|
+
}
|