@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/CHANGELOG.md +224 -0
- package/README.md +30 -10
- package/SECURITY.md +54 -0
- package/dist/agent/loop.js +17 -10
- package/dist/completions.js +49 -0
- package/dist/concurrency.js +22 -0
- package/dist/config.js +18 -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/export.js +43 -0
- package/dist/index.js +485 -13
- package/dist/mcp/server.js +56 -0
- package/dist/memory/store.js +44 -6
- package/dist/org/review-chain.js +91 -0
- package/dist/org/roles.js +11 -0
- package/dist/org-fleet/enroll.js +94 -0
- package/dist/providers/qwen-oauth.js +9 -2
- package/dist/recall.js +20 -4
- 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/web.js +81 -8
- package/dist/tui/App.js +2 -2
- package/dist/tui/InputBox.js +37 -3
- package/dist/tui/vim.js +115 -0
- package/package.json +6 -2
package/dist/tools/patch.js
CHANGED
|
@@ -102,21 +102,40 @@ registerTool({
|
|
|
102
102
|
}
|
|
103
103
|
}
|
|
104
104
|
}
|
|
105
|
-
// PHASE 2 — commit all changes
|
|
106
|
-
|
|
107
|
-
|
|
108
|
-
|
|
109
|
-
|
|
110
|
-
|
|
111
|
-
|
|
105
|
+
// PHASE 2 — commit all changes. Truly all-or-nothing: if any write fails mid-way, roll back the ones
|
|
106
|
+
// already applied (restore updated/deleted files, remove created ones) so the tree is never half-patched.
|
|
107
|
+
const applied = [];
|
|
108
|
+
try {
|
|
109
|
+
for (const pl of plans) {
|
|
110
|
+
if (pl.type === "delete") {
|
|
111
|
+
await unlink(pl.abs);
|
|
112
|
+
}
|
|
113
|
+
else {
|
|
114
|
+
await mkdir(dirname(pl.abs), { recursive: true });
|
|
115
|
+
await writeFile(pl.abs, pl.after, "utf8");
|
|
116
|
+
}
|
|
117
|
+
applied.push(pl);
|
|
112
118
|
}
|
|
113
|
-
|
|
114
|
-
|
|
115
|
-
|
|
116
|
-
|
|
117
|
-
|
|
119
|
+
}
|
|
120
|
+
catch (e) {
|
|
121
|
+
for (const pl of applied.reverse()) {
|
|
122
|
+
try {
|
|
123
|
+
if (pl.type === "create" && !pl.existed)
|
|
124
|
+
await unlink(pl.abs); // remove a file we created
|
|
125
|
+
else
|
|
126
|
+
await writeFile(pl.abs, pl.before, "utf8"); // restore an updated/deleted file's prior content
|
|
127
|
+
}
|
|
128
|
+
catch {
|
|
129
|
+
/* best-effort rollback */
|
|
130
|
+
}
|
|
118
131
|
}
|
|
132
|
+
return `Error: apply_patch failed writing a file (${e instanceof Error ? e.message : String(e)}) — rolled back, nothing left changed.`;
|
|
119
133
|
}
|
|
134
|
+
// All writes succeeded → now show diffs + record the undo snapshot.
|
|
135
|
+
const summary = plans.map((pl) => {
|
|
136
|
+
emitDiff(pl.path, pl.before, pl.type === "delete" ? "" : pl.after, ctx.ui);
|
|
137
|
+
return pl.type === "delete" ? `deleted ${pl.path}` : `${pl.type === "create" ? "created" : "updated"} ${pl.path}`;
|
|
138
|
+
});
|
|
120
139
|
recordEdit(plans.map((pl) => ({ path: pl.path, absPath: pl.abs, before: pl.existed ? pl.before : null })));
|
|
121
140
|
return `apply_patch: ${plans.length} file(s) — ${summary.join("; ")}.`;
|
|
122
141
|
},
|
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
|
|
@@ -148,17 +208,30 @@ registerTool({
|
|
|
148
208
|
const ctrl = new AbortController();
|
|
149
209
|
const timer = setTimeout(() => ctrl.abort(), 30_000);
|
|
150
210
|
try {
|
|
151
|
-
|
|
152
|
-
|
|
153
|
-
|
|
154
|
-
|
|
155
|
-
|
|
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
|
+
}
|
|
156
229
|
const ct = res.headers.get("content-type") ?? "";
|
|
157
|
-
const raw = await res
|
|
230
|
+
const raw = await readCapped(res, cap * 4); // byte ceiling (HTML→text shrinks; cap*4 leaves headroom)
|
|
158
231
|
let text = /html/i.test(ct) ? htmlToText(raw) : raw;
|
|
159
232
|
if (text.length > cap)
|
|
160
233
|
text = text.slice(0, cap) + `\n…[truncated ${text.length - cap} chars]`;
|
|
161
|
-
return `# ${
|
|
234
|
+
return `# ${current.href} (HTTP ${res.status})\n\n${text || "(empty body)"}`;
|
|
162
235
|
}
|
|
163
236
|
catch (e) {
|
|
164
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([]);
|
|
@@ -209,5 +209,5 @@ export function App({ initialStatus, model, cwd, header, onSubmit, cycleApproval
|
|
|
209
209
|
else if (key.tab && key.shift && cycleApproval)
|
|
210
210
|
setStatus((s) => ({ ...s, approval: cycleApproval(s.approval) }));
|
|
211
211
|
});
|
|
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, 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 })] }));
|
|
213
213
|
}
|
package/dist/tui/InputBox.js
CHANGED
|
@@ -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
|
}
|
package/dist/tui/vim.js
ADDED
|
@@ -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.
|
|
3
|
+
"version": "0.67.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"
|