@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/dist/tools/web.js CHANGED
@@ -1,7 +1,67 @@
1
1
  // web_fetch — fetch an http(s) URL and return readable text (HTML reduced to text). Read-only.
2
- // Uses Node's global fetch (Node >=20). NOT sandboxed (network egress is in-process, not via bash).
2
+ // Uses Node's global fetch (Node >=20). NOT sandboxed (network egress is in-process, not via bash)
3
+ // so it carries an SSRF guard: private/loopback/link-local targets are refused, re-checked on every
4
+ // redirect hop, and the body is read under a hard byte ceiling.
3
5
  import { registerTool } from "./registry.js";
6
+ import { lookup } from "node:dns/promises";
7
+ import { isIP } from "node:net";
4
8
  const MAX = 60_000;
9
+ /** True for loopback / private / link-local / ULA / CGNAT addresses we must not let web_fetch reach. */
10
+ export function isPrivateIp(ip) {
11
+ const host = ip.replace(/^\[|\]$/g, "");
12
+ if (isIP(host) === 4) {
13
+ const p = host.split(".").map(Number);
14
+ return p[0] === 0 || p[0] === 10 || p[0] === 127 || (p[0] === 172 && p[1] >= 16 && p[1] <= 31) || (p[0] === 192 && p[1] === 168) || (p[0] === 169 && p[1] === 254) || (p[0] === 100 && p[1] >= 64 && p[1] <= 127);
15
+ }
16
+ const l = host.toLowerCase();
17
+ if (l === "::1" || l === "::")
18
+ return true;
19
+ if (l.startsWith("fe80") || l.startsWith("fc") || l.startsWith("fd"))
20
+ return true; // link-local + unique-local
21
+ const m = /^::ffff:(\d+\.\d+\.\d+\.\d+)$/.exec(l); // IPv4-mapped IPv6
22
+ return m ? isPrivateIp(m[1]) : false;
23
+ }
24
+ /** Refuse to fetch a host that is (or resolves to) a private/internal address — defeats metadata-endpoint
25
+ * / localhost SSRF. Throws (caught by the caller) on a blocked or unresolvable host. */
26
+ async function assertPublicHost(hostname) {
27
+ const host = hostname.replace(/^\[|\]$/g, "");
28
+ if (isIP(host)) {
29
+ if (isPrivateIp(host))
30
+ throw new Error(`refusing to fetch ${host} (private/loopback address)`);
31
+ return;
32
+ }
33
+ const addrs = await lookup(host, { all: true });
34
+ for (const a of addrs)
35
+ if (isPrivateIp(a.address))
36
+ throw new Error(`refusing to fetch ${host} — resolves to a private/internal address (${a.address})`);
37
+ }
38
+ /** Read a fetch Response body up to `maxBytes`, then stop (avoids materializing a huge / bomb body). */
39
+ async function readCapped(res, maxBytes) {
40
+ if (!res.body)
41
+ return res.text();
42
+ const reader = res.body.getReader();
43
+ const chunks = [];
44
+ let total = 0;
45
+ for (;;) {
46
+ const { done, value } = await reader.read();
47
+ if (done)
48
+ break;
49
+ if (value) {
50
+ chunks.push(value);
51
+ total += value.length;
52
+ }
53
+ if (total >= maxBytes) {
54
+ try {
55
+ await reader.cancel();
56
+ }
57
+ catch {
58
+ /* already closing */
59
+ }
60
+ break;
61
+ }
62
+ }
63
+ return Buffer.concat(chunks).toString("utf8");
64
+ }
5
65
  /** Strip HTML to a readable-ish plain-text approximation (no dependency). */
6
66
  export function htmlToText(html) {
7
67
  return html
@@ -24,6 +84,103 @@ export function htmlToText(html) {
24
84
  .replace(/\n{3,}/g, "\n\n")
25
85
  .trim();
26
86
  }
87
+ /** Parse DuckDuckGo HTML results → [{title, url, snippet}]. Best-effort HTML scrape (no key, no dependency). */
88
+ export function parseSearchResults(html, limit) {
89
+ const strip = (s) => s
90
+ .replace(/<[^>]+>/g, "")
91
+ .replace(/&amp;/g, "&")
92
+ .replace(/&lt;/g, "<")
93
+ .replace(/&gt;/g, ">")
94
+ .replace(/&quot;/g, '"')
95
+ .replace(/&#x27;|&#39;/g, "'")
96
+ .replace(/\s+/g, " ")
97
+ .trim();
98
+ const snippets = [];
99
+ const snipRe = /class="result__snippet"[^>]*>([\s\S]*?)<\/a>/g;
100
+ let m;
101
+ while ((m = snipRe.exec(html)))
102
+ snippets.push(strip(m[1]));
103
+ const out = [];
104
+ const linkRe = /class="result__a"[^>]*href="([^"]+)"[^>]*>([\s\S]*?)<\/a>/g;
105
+ let i = 0;
106
+ while ((m = linkRe.exec(html)) && out.length < limit) {
107
+ let href = m[1].replace(/&amp;/g, "&");
108
+ const uddg = /[?&]uddg=([^&]+)/.exec(href); // DuckDuckGo wraps results in a /l/?uddg=<real-url> redirect
109
+ if (uddg)
110
+ href = decodeURIComponent(uddg[1]);
111
+ else if (href.startsWith("//"))
112
+ href = "https:" + href;
113
+ out.push({ title: strip(m[2]), url: href, snippet: snippets[i++] ?? "" });
114
+ }
115
+ return out;
116
+ }
117
+ registerTool({
118
+ name: "web_search",
119
+ description: "Search the web and return the top results (title, URL, snippet). Use it to FIND information or pages you " +
120
+ "don't already have a URL for, then `web_fetch` a result to read it. Read-only. Reliable with a Tavily key " +
121
+ "(env HARA_SEARCH_API_KEY); otherwise a best-effort keyless fallback that may be rate-limited.",
122
+ input_schema: {
123
+ type: "object",
124
+ properties: {
125
+ query: { type: "string" },
126
+ limit: { type: "number", description: "max results (default 6, max 10)" },
127
+ },
128
+ required: ["query"],
129
+ },
130
+ kind: "read",
131
+ async run(input) {
132
+ const q = String(input.query ?? "").trim();
133
+ if (!q)
134
+ return "(empty query)";
135
+ const limit = Math.min(Math.max(1, Number(input.limit) || 6), 10);
136
+ const fmt = (rs) => rs.map((r, n) => `${n + 1}. ${r.title}\n ${r.url}${r.snippet ? `\n ${r.snippet}` : ""}`).join("\n\n");
137
+ const ctrl = new AbortController();
138
+ const timer = setTimeout(() => ctrl.abort(), 20_000);
139
+ try {
140
+ // Reliable path: Tavily (designed for agents, free tier) when a key is configured.
141
+ const key = process.env.HARA_SEARCH_API_KEY || process.env.TAVILY_API_KEY;
142
+ if (key) {
143
+ const res = await fetch("https://api.tavily.com/search", {
144
+ method: "POST",
145
+ signal: ctrl.signal,
146
+ headers: { "content-type": "application/json" },
147
+ body: JSON.stringify({ api_key: key, query: q, max_results: limit }),
148
+ });
149
+ if (res.ok) {
150
+ const j = (await res.json());
151
+ const rs = (j.results ?? []).map((x) => ({ title: String(x.title ?? x.url ?? ""), url: String(x.url ?? ""), snippet: String(x.content ?? "").slice(0, 200) }));
152
+ if (rs.length)
153
+ return fmt(rs);
154
+ }
155
+ // Tavily failed → fall through to the keyless best-effort path.
156
+ }
157
+ // Keyless fallback: DuckDuckGo HTML (POST — GET returns a 202 challenge). May be rate-limited.
158
+ const res = await fetch("https://html.duckduckgo.com/html/", {
159
+ method: "POST",
160
+ signal: ctrl.signal,
161
+ redirect: "follow",
162
+ headers: {
163
+ "user-agent": "Mozilla/5.0 (Macintosh; Intel Mac OS X 10_15_7) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/124.0 Safari/537.36",
164
+ "content-type": "application/x-www-form-urlencoded",
165
+ accept: "text/html",
166
+ },
167
+ body: `q=${encodeURIComponent(q)}`,
168
+ });
169
+ if (!res.ok)
170
+ return `Search failed: HTTP ${res.status}. Keyless search is rate-limited — set HARA_SEARCH_API_KEY (Tavily) for reliable search, or web_fetch a known URL.`;
171
+ const results = parseSearchResults(await res.text(), limit);
172
+ if (!results.length)
173
+ return "(no results — the keyless endpoint is rate-limited or changed. Set HARA_SEARCH_API_KEY (Tavily) for reliable search, or web_fetch a known URL.)";
174
+ return fmt(results);
175
+ }
176
+ catch (e) {
177
+ return `Search failed: ${e?.name === "AbortError" ? "timed out (20s)" : (e?.message ?? e)}`;
178
+ }
179
+ finally {
180
+ clearTimeout(timer);
181
+ }
182
+ },
183
+ });
27
184
  registerTool({
28
185
  name: "web_fetch",
29
186
  description: "Fetch an http(s) URL and return its text content (HTML is reduced to readable text). Read-only. " +
@@ -51,17 +208,30 @@ registerTool({
51
208
  const ctrl = new AbortController();
52
209
  const timer = setTimeout(() => ctrl.abort(), 30_000);
53
210
  try {
54
- const res = await fetch(url, {
55
- signal: ctrl.signal,
56
- redirect: "follow",
57
- headers: { "user-agent": "hara-cli", accept: "text/html,text/plain,application/json,*/*" },
58
- });
211
+ // Follow redirects manually so the SSRF guard runs on EVERY hop (a public URL can 30x to 169.254…).
212
+ let current = url;
213
+ let res;
214
+ for (let hop = 0;; hop++) {
215
+ await assertPublicHost(current.hostname);
216
+ res = await fetch(current, {
217
+ signal: ctrl.signal,
218
+ redirect: "manual",
219
+ headers: { "user-agent": "hara-cli", accept: "text/html,text/plain,application/json,*/*" },
220
+ });
221
+ const loc = res.status >= 300 && res.status < 400 ? res.headers.get("location") : null;
222
+ if (!loc || hop >= 5)
223
+ break;
224
+ const next = new URL(loc, current);
225
+ if (next.protocol !== "http:" && next.protocol !== "https:")
226
+ return "Error: redirect to a non-http(s) URL was blocked.";
227
+ current = next;
228
+ }
59
229
  const ct = res.headers.get("content-type") ?? "";
60
- const raw = await res.text();
230
+ const raw = await readCapped(res, cap * 4); // byte ceiling (HTML→text shrinks; cap*4 leaves headroom)
61
231
  let text = /html/i.test(ct) ? htmlToText(raw) : raw;
62
232
  if (text.length > cap)
63
233
  text = text.slice(0, cap) + `\n…[truncated ${text.length - cap} chars]`;
64
- return `# ${url.href} (HTTP ${res.status})\n\n${text || "(empty body)"}`;
234
+ return `# ${current.href} (HTTP ${res.status})\n\n${text || "(empty body)"}`;
65
235
  }
66
236
  catch (e) {
67
237
  return `Error fetching ${url.href}: ${e?.name === "AbortError" ? "timed out (30s)" : (e?.message ?? e)}`;
package/dist/tui/App.js CHANGED
@@ -62,7 +62,7 @@ function Working() {
62
62
  const frames = "⠋⠙⠹⠸⠼⠴⠦⠧⠇⠏";
63
63
  return (_jsxs(Box, { marginTop: 1, children: [_jsx(Text, { color: "yellow", children: frames[n % frames.length] }), _jsx(Text, { dimColor: true, children: ` working ${Math.floor(n / 10)}s · esc to interrupt` })] }));
64
64
  }
65
- export function App({ initialStatus, model, cwd, header, onSubmit, cycleApproval, onClipboardImage }) {
65
+ export function App({ initialStatus, model, cwd, header, onSubmit, cycleApproval, onClipboardImage, vim }) {
66
66
  const { exit } = useApp();
67
67
  const [history, setHistory] = useState([]);
68
68
  const [current, setCurrent] = useState([]);
@@ -92,6 +92,19 @@ export function App({ initialStatus, model, cwd, header, onSubmit, cycleApproval
92
92
  return [...cur, { id: nid(), kind, text }];
93
93
  });
94
94
  }, []);
95
+ // Type-ahead steering: hand the runner everything queued while the turn ran, showing each message
96
+ // inline (as a user block) at the point it gets folded into the conversation. Drained mid-turn so an
97
+ // addition reaches the model on its next call; whatever's still queued at turn end is the effect below.
98
+ const drainQueue = useCallback(() => {
99
+ if (!queueRef.current.length)
100
+ return [];
101
+ const batch = queueRef.current;
102
+ queueRef.current = [];
103
+ setPool([]);
104
+ for (const b of batch)
105
+ pushCurrent("user", b.line.trim() || "🖼 (image)");
106
+ return batch;
107
+ }, [pushCurrent]);
95
108
  const handleSubmit = useCallback(async (line, images) => {
96
109
  const t = line.trim();
97
110
  if ((!t && !images?.length) || prompt)
@@ -127,7 +140,7 @@ export function App({ initialStatus, model, cwd, header, onSubmit, cycleApproval
127
140
  const selectFn = (title, options) => openPrompt(title, options);
128
141
  const setApprovalFn = (m) => setStatus((s) => ({ ...s, approval: m }));
129
142
  try {
130
- await onSubmit(t, { sink, confirm: confirmFn, select: selectFn, setApproval: setApprovalFn, signal: ctrl.signal, exit, approval: statusRef.current.approval }, images);
143
+ await onSubmit(t, { sink, confirm: confirmFn, select: selectFn, setApproval: setApprovalFn, signal: ctrl.signal, exit, approval: statusRef.current.approval, drainQueue }, images);
131
144
  }
132
145
  catch (e) {
133
146
  pushCurrent("notice", `error: ${e instanceof Error ? e.message : String(e)}`);
@@ -139,7 +152,7 @@ export function App({ initialStatus, model, cwd, header, onSubmit, cycleApproval
139
152
  setCurrent([]);
140
153
  setWorking(false);
141
154
  ctrlRef.current = null;
142
- }, [working, prompt, onSubmit, pushCurrent, model, exit]);
155
+ }, [working, prompt, onSubmit, pushCurrent, model, exit, drainQueue]);
143
156
  // Drain the type-ahead pool: when the turn finishes (working → false) and nothing awaits a choice, COALESCE
144
157
  // every pooled message into ONE turn and send it — additions/clarifications go to the agent together, in order.
145
158
  useEffect(() => {
@@ -196,5 +209,5 @@ export function App({ initialStatus, model, cwd, header, onSubmit, cycleApproval
196
209
  else if (key.tab && key.shift && cycleApproval)
197
210
  setStatus((s) => ({ ...s, approval: cycleApproval(s.approval) }));
198
211
  });
199
- return (_jsxs(Box, { flexDirection: "column", children: [_jsx(Static, { items: header ? [{ id: -1, kind: "notice", text: "" }, ...history] : history, children: (item) => (item.id === -1 ? _jsx(HeaderCard, { ...header }, "hdr") : _jsx(Block, { item: item }, item.id)) }), current.map((item) => (_jsx(Block, { item: item, open: reasoningOpen }, item.id))), working && !prompt && _jsx(Working, {}), prompt && (_jsxs(Box, { flexDirection: "column", marginTop: 1, children: [_jsx(Text, { color: "yellow", children: ` ${stripAnsi(prompt.title)}` }), prompt.options.map((o, i) => (_jsx(Text, { color: i === promptSel ? "cyan" : undefined, bold: i === promptSel, children: (i === promptSel ? " ❯ " : " ") + `${i + 1}. ` + o.label }, i))), _jsx(Text, { dimColor: true, children: ` ↑↓ or 1–${prompt.options.length} to choose · Enter · Esc cancels` })] })), pool.length > 0 && !prompt && (_jsx(Box, { flexDirection: "column", children: pool.map((l, i) => (_jsx(Text, { color: accent(), children: ` › ${l.length > 72 ? l.slice(0, 72) + "…" : l}` }, i))) })), _jsx(InputBox, { status: status, cwd: cwd, isActive: !prompt, working: working, queued: pool.length, onSubmit: handleSubmit, onClipboardImage: onClipboardImage })] }));
212
+ return (_jsxs(Box, { flexDirection: "column", children: [_jsx(Static, { items: header ? [{ id: -1, kind: "notice", text: "" }, ...history] : history, children: (item) => (item.id === -1 ? _jsx(HeaderCard, { ...header }, "hdr") : _jsx(Block, { item: item }, item.id)) }), current.map((item) => (_jsx(Block, { item: item, open: reasoningOpen }, item.id))), working && !prompt && _jsx(Working, {}), prompt && (_jsxs(Box, { flexDirection: "column", marginTop: 1, children: [_jsx(Text, { color: "yellow", children: ` ${stripAnsi(prompt.title)}` }), prompt.options.map((o, i) => (_jsx(Text, { color: i === promptSel ? "cyan" : undefined, bold: i === promptSel, children: (i === promptSel ? " ❯ " : " ") + `${i + 1}. ` + o.label }, i))), _jsx(Text, { dimColor: true, children: ` ↑↓ or 1–${prompt.options.length} to choose · Enter · Esc cancels` })] })), pool.length > 0 && !prompt && (_jsx(Box, { flexDirection: "column", children: pool.map((l, i) => (_jsx(Text, { color: accent(), children: ` › ${l.length > 72 ? l.slice(0, 72) + "…" : l}` }, i))) })), _jsx(InputBox, { status: status, cwd: cwd, isActive: !prompt, working: working, queued: pool.length, vim: vim, onSubmit: handleSubmit, onClipboardImage: onClipboardImage })] }));
200
213
  }
@@ -7,6 +7,7 @@ import { Box, Text, useInput, useStdout } from "ink";
7
7
  import { useMemo, useState } from "react";
8
8
  import { fileCandidates } from "../context/mentions.js";
9
9
  import { imagePathFromPaste } from "../images.js";
10
+ import { vimNormal } from "./vim.js";
10
11
  export const MODES = ["suggest", "auto-edit", "full-auto", "plan"];
11
12
  export const nextMode = (m) => MODES[(MODES.indexOf(m) + 1) % MODES.length];
12
13
  const tok = (n) => (n >= 1000 ? `${(n / 1000).toFixed(1)}k` : `${n}`);
@@ -84,7 +85,7 @@ function InputLine({ value, cursor }) {
84
85
  return _jsx(Text, { children: nodes });
85
86
  }
86
87
  /** Top border (session) + prompt line + bottom border (usage) + ModeBar, with an @path popup. */
87
- export function InputBox({ status, cwd, width, onSubmit, onClipboardImage, isActive = true, working = false, queued = 0, placeholder = "Type a task · /help · @file · Ctrl+V paste image · shift+tab mode · Esc interrupts", }) {
88
+ export function InputBox({ status, cwd, width, onSubmit, onClipboardImage, isActive = true, working = false, queued = 0, vim = false, placeholder = "Type a task · /help · @file · Ctrl+V paste image · shift+tab mode · Esc interrupts", }) {
88
89
  const { stdout } = useStdout();
89
90
  const w = width ?? stdout?.columns ?? 80;
90
91
  const [value, setValue] = useState("");
@@ -92,6 +93,9 @@ export function InputBox({ status, cwd, width, onSubmit, onClipboardImage, isAct
92
93
  const [sel, setSel] = useState(0);
93
94
  const [dismissed, setDismissed] = useState(false);
94
95
  const [images, setImages] = useState([]);
96
+ const [mode, setMode] = useState("insert"); // vim only
97
+ const [pending, setPending] = useState(""); // vim operator-pending (d/c/g)
98
+ const [register, setRegister] = useState(""); // vim yank/delete register
95
99
  const set = (v, c) => {
96
100
  setValue(v);
97
101
  setCursor(c);
@@ -116,6 +120,8 @@ export function InputBox({ status, cwd, width, onSubmit, onClipboardImage, isAct
116
120
  onSubmit?.(text, images.length ? images : undefined);
117
121
  set("", 0);
118
122
  setImages([]);
123
+ setMode("insert"); // a fresh prompt starts in insert
124
+ setPending("");
119
125
  };
120
126
  const mention = activeMention(value, cursor);
121
127
  const candidates = useMemo(() => (isActive && mention && !dismissed ? fileCandidates(cwd, mention.query, 8) : []), [cwd, isActive, dismissed, mention?.query, mention?.start]);
@@ -143,8 +149,36 @@ export function InputBox({ status, cwd, width, onSubmit, onClipboardImage, isAct
143
149
  return;
144
150
  }
145
151
  if (key.escape) {
146
- if (popupOpen)
152
+ if (popupOpen) {
147
153
  setDismissed(true);
154
+ return;
155
+ }
156
+ if (vim && mode === "insert") {
157
+ setMode("normal");
158
+ setPending("");
159
+ }
160
+ return;
161
+ }
162
+ // vim NORMAL mode: printable keys are commands, not text (Enter/arrows/backspace still navigate/submit)
163
+ if (vim && mode === "normal") {
164
+ if (key.return)
165
+ return submit(value);
166
+ if (key.leftArrow)
167
+ return setCursor((c) => Math.max(0, c - 1));
168
+ if (key.rightArrow)
169
+ return setCursor((c) => Math.min(value.length, c + 1));
170
+ if (key.backspace || key.delete)
171
+ return setCursor((c) => Math.max(0, c - 1));
172
+ if (input && !key.ctrl && !key.meta) {
173
+ const st = vimNormal({ value, cursor, mode, pending, register }, input);
174
+ setValue(st.value);
175
+ setCursor(st.cursor);
176
+ setMode(st.mode);
177
+ setPending(st.pending);
178
+ setRegister(st.register);
179
+ setSel(0);
180
+ setDismissed(false);
181
+ }
148
182
  return;
149
183
  }
150
184
  if (key.return) {
@@ -204,5 +238,5 @@ export function InputBox({ status, cwd, width, onSubmit, onClipboardImage, isAct
204
238
  set(value.slice(0, cursor) + input + value.slice(cursor), cursor + input.length);
205
239
  }
206
240
  }, { isActive });
207
- return (_jsxs(Box, { flexDirection: "column", children: [_jsx(TopBorder, { name: status.sessionName || "session", width: w }), _jsxs(Box, { children: [_jsx(Text, { color: "cyan", children: "› " }), value.length === 0 ? (_jsxs(Text, { children: [_jsx(Text, { inverse: true, children: " " }), _jsx(Text, { dimColor: true, children: placeholder })] })) : (_jsx(InputLine, { value: value, cursor: cursor }))] }), _jsx(BottomBorder, { s: status, width: w }), working ? _jsx(Text, { dimColor: true, children: ` ⌨ working — Enter queues your message${queued ? ` · ${queued} queued` : ""} · Esc interrupts` }) : null, popupOpen ? _jsx(MentionPopup, { items: candidates, selected: selIdx, query: mention.query }) : null, _jsx(ModeBar, { approval: status.approval })] }));
241
+ return (_jsxs(Box, { flexDirection: "column", children: [_jsx(TopBorder, { name: status.sessionName || "session", width: w }), _jsxs(Box, { children: [_jsx(Text, { color: vim ? (mode === "normal" ? "yellow" : "green") : "cyan", children: vim && mode === "normal" ? "◆ " : "› " }), value.length === 0 ? (_jsxs(Text, { children: [_jsx(Text, { inverse: true, children: " " }), _jsx(Text, { dimColor: true, children: placeholder })] })) : (_jsx(InputLine, { value: value, cursor: cursor }))] }), vim ? _jsx(Text, { dimColor: true, children: mode === "normal" ? " -- NORMAL -- i/a insert · h l 0 $ w b e move · x dd D cw p edit" : " -- INSERT -- Esc → normal" }) : null, _jsx(BottomBorder, { s: status, width: w }), working ? _jsx(Text, { dimColor: true, children: ` ⌨ working — Enter queues your message${queued ? ` · ${queued} queued` : ""} · Esc interrupts` }) : null, popupOpen ? _jsx(MentionPopup, { items: candidates, selected: selIdx, query: mention.query }) : null, _jsx(ModeBar, { approval: status.approval })] }));
208
242
  }
@@ -0,0 +1,115 @@
1
+ const isSpace = (ch) => ch === " " || ch === "\t";
2
+ const clamp = (c, n) => Math.max(0, Math.min(n, c));
3
+ /** Start of the next word (run of non-space), vim `w`. */
4
+ function nextWord(v, c) {
5
+ const n = v.length;
6
+ let i = c;
7
+ if (i < n && !isSpace(v[i]))
8
+ while (i < n && !isSpace(v[i]))
9
+ i++;
10
+ while (i < n && isSpace(v[i]))
11
+ i++;
12
+ return i;
13
+ }
14
+ /** Start of the previous word, vim `b`. */
15
+ function prevWord(v, c) {
16
+ let i = c - 1;
17
+ while (i > 0 && isSpace(v[i]))
18
+ i--;
19
+ while (i > 0 && !isSpace(v[i - 1]))
20
+ i--;
21
+ return Math.max(0, i);
22
+ }
23
+ /** End index of the current/next word, vim `e`. */
24
+ function wordEnd(v, c) {
25
+ const n = v.length;
26
+ let i = c + 1;
27
+ while (i < n && isSpace(v[i]))
28
+ i++;
29
+ while (i < n - 1 && !isSpace(v[i + 1]))
30
+ i++;
31
+ return Math.min(n, i);
32
+ }
33
+ /** Apply one printable key in NORMAL mode, returning the next state. Special keys (Esc/Enter/arrows/
34
+ * backspace) are handled by the caller. Unknown keys are no-ops. */
35
+ export function vimNormal(st, input) {
36
+ const { value, cursor } = st;
37
+ const n = value.length;
38
+ const move = (c) => ({ ...st, cursor: clamp(c, n), pending: "" });
39
+ const toInsert = (v, c, register = st.register) => ({ value: v, cursor: clamp(c, v.length), mode: "insert", pending: "", register });
40
+ const toNormal = (patch) => ({ ...st, pending: "", ...patch });
41
+ // operator-pending: d{motion} / c{motion}
42
+ if (st.pending === "d" || st.pending === "c") {
43
+ const op = st.pending;
44
+ if (input === op)
45
+ return op === "c" ? toInsert("", 0, value) : toNormal({ value: "", cursor: 0, register: value }); // dd / cc
46
+ const ranges = {
47
+ w: [cursor, nextWord(value, cursor)],
48
+ e: [cursor, Math.min(n, wordEnd(value, cursor) + 1)],
49
+ b: [prevWord(value, cursor), cursor],
50
+ $: [cursor, n],
51
+ "0": [0, cursor],
52
+ };
53
+ const r = ranges[op === "c" && input === "w" ? "e" : input]; // cw acts like ce (vim quirk)
54
+ if (!r)
55
+ return toNormal({}); // unknown motion → cancel the operator
56
+ const [from, to] = r;
57
+ const deleted = value.slice(from, to);
58
+ const next = value.slice(0, from) + value.slice(to);
59
+ return op === "c" ? toInsert(next, from, deleted) : toNormal({ value: next, cursor: clamp(from, next.length), register: deleted });
60
+ }
61
+ if (st.pending === "g")
62
+ return input === "g" ? move(0) : toNormal({});
63
+ switch (input) {
64
+ // motions
65
+ case "h":
66
+ return move(cursor - 1);
67
+ case "l":
68
+ return move(cursor + 1);
69
+ case "0":
70
+ return move(0);
71
+ case "$":
72
+ return move(n);
73
+ case "w":
74
+ return move(nextWord(value, cursor));
75
+ case "b":
76
+ return move(prevWord(value, cursor));
77
+ case "e":
78
+ return move(wordEnd(value, cursor));
79
+ case "G":
80
+ return move(n);
81
+ // enter insert mode
82
+ case "i":
83
+ return toInsert(value, cursor);
84
+ case "a":
85
+ return toInsert(value, cursor + 1);
86
+ case "I":
87
+ return toInsert(value, value.length - value.trimStart().length);
88
+ case "A":
89
+ case "o": // single-line: open ≈ append at end
90
+ return toInsert(value, n);
91
+ // edits
92
+ case "x": {
93
+ if (!n)
94
+ return toNormal({});
95
+ return toNormal({ value: value.slice(0, cursor) + value.slice(cursor + 1), cursor: clamp(cursor, n - 1), register: value[cursor] ?? "" });
96
+ }
97
+ case "D":
98
+ return toNormal({ value: value.slice(0, cursor), cursor: clamp(cursor, cursor), register: value.slice(cursor) });
99
+ case "C":
100
+ return toInsert(value.slice(0, cursor), cursor, value.slice(cursor));
101
+ case "p":
102
+ return toNormal({ value: value.slice(0, cursor + 1) + st.register + value.slice(cursor + 1), cursor: cursor + st.register.length });
103
+ case "P":
104
+ return toNormal({ value: value.slice(0, cursor) + st.register + value.slice(cursor), cursor: cursor + Math.max(0, st.register.length - 1) });
105
+ // operators
106
+ case "d":
107
+ return { ...st, pending: "d" };
108
+ case "c":
109
+ return { ...st, pending: "c" };
110
+ case "g":
111
+ return { ...st, pending: "g" };
112
+ default:
113
+ return toNormal({}); // ignore everything else (printable keys don't insert in normal mode)
114
+ }
115
+ }
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@nanhara/hara",
3
- "version": "0.48.0",
3
+ "version": "0.62.0",
4
4
  "description": "hara — a coding agent CLI that runs like an engineering org.",
5
5
  "bin": {
6
6
  "hara": "dist/index.js"
@@ -8,8 +8,10 @@
8
8
  "type": "module",
9
9
  "files": [
10
10
  "dist",
11
+ "!dist/bin",
11
12
  "README.md",
12
13
  "CHANGELOG.md",
14
+ "SECURITY.md",
13
15
  "LICENSE",
14
16
  "CLA.md",
15
17
  "plugins"
@@ -40,7 +42,9 @@
40
42
  "prepare": "tsc",
41
43
  "dev": "tsx src/index.ts",
42
44
  "start": "node dist/index.js",
43
- "test": "tsc && node --test test/*.test.mjs"
45
+ "test": "tsc && node --test test/*.test.mjs",
46
+ "build:binary": "tsc && bun scripts/build-binary.ts dist/bin/hara",
47
+ "build:binaries": "tsc && bun scripts/build-binary.ts dist/bin/hara-darwin-arm64 bun-darwin-arm64 && bun scripts/build-binary.ts dist/bin/hara-darwin-x64 bun-darwin-x64 && bun scripts/build-binary.ts dist/bin/hara-linux-x64 bun-linux-x64 && bun scripts/build-binary.ts dist/bin/hara-linux-arm64 bun-linux-arm64"
44
48
  },
45
49
  "publishConfig": {
46
50
  "access": "public"