nou-cli 0.1.0 → 0.2.1

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.
Files changed (45) hide show
  1. package/README.md +142 -31
  2. package/dist/grammars/tree-sitter-bash.wasm +0 -0
  3. package/dist/grammars/tree-sitter-c.wasm +0 -0
  4. package/dist/grammars/tree-sitter-css.wasm +0 -0
  5. package/dist/grammars/tree-sitter-go.wasm +0 -0
  6. package/dist/grammars/tree-sitter-html.wasm +0 -0
  7. package/dist/grammars/tree-sitter-java.wasm +0 -0
  8. package/dist/grammars/tree-sitter-javascript.wasm +0 -0
  9. package/dist/grammars/tree-sitter-json.wasm +0 -0
  10. package/dist/grammars/tree-sitter-python.wasm +0 -0
  11. package/dist/grammars/tree-sitter-rust.wasm +0 -0
  12. package/dist/grammars/tree-sitter-toml.wasm +0 -0
  13. package/dist/grammars/tree-sitter-tsx.wasm +0 -0
  14. package/dist/grammars/tree-sitter-typescript.wasm +0 -0
  15. package/dist/grammars/tree-sitter-yaml.wasm +0 -0
  16. package/dist/index.js +55 -6
  17. package/dist/src/agent/index.js +37 -5
  18. package/dist/src/commands/index.js +16 -6
  19. package/dist/src/config/index.js +6 -2
  20. package/dist/src/diagnostics/index.js +235 -0
  21. package/dist/src/diagnostics/process.js +64 -0
  22. package/dist/src/diagnostics/providers.js +103 -0
  23. package/dist/src/diagnostics/types.js +1 -0
  24. package/dist/src/env.js +12 -0
  25. package/dist/src/history/index.js +113 -0
  26. package/dist/src/lsp/client.js +169 -0
  27. package/dist/src/lsp/index.js +169 -0
  28. package/dist/src/plugins/index.js +94 -3
  29. package/dist/src/providers/ids.js +3 -0
  30. package/dist/src/providers/ollama.js +2 -1
  31. package/dist/src/providers/openai.js +3 -2
  32. package/dist/src/syntax/index.js +240 -0
  33. package/dist/src/ui/App.js +299 -30
  34. package/dist/src/ui/Chat.js +19 -6
  35. package/dist/src/ui/ChatInput.js +128 -0
  36. package/dist/src/ui/CodeView.js +25 -5
  37. package/dist/src/ui/Editor.js +94 -21
  38. package/dist/src/ui/FileTree.js +23 -4
  39. package/dist/src/ui/Header.js +4 -2
  40. package/dist/src/ui/ProblemsView.js +75 -0
  41. package/dist/src/ui/Settings.js +31 -15
  42. package/dist/src/ui/mouse.js +52 -0
  43. package/dist/src/ui/text.js +6 -1
  44. package/dist/src/update/index.js +102 -0
  45. package/package.json +10 -5
@@ -0,0 +1,235 @@
1
+ import { readFileSync } from "fs";
2
+ import path from "path";
3
+ import { diffLines } from "diff";
4
+ const ORDER = { error: 0, warning: 1, info: 2 };
5
+ export const sortDiagnostics = (list) => [...list].sort((a, b) => a.file.localeCompare(b.file) || a.line - b.line || ORDER[a.severity] - ORDER[b.severity]);
6
+ export function formatDiagnostic(d, withFile = false) {
7
+ return `${withFile ? `${d.file}:` : "line "}${d.line}:${d.col} ${d.severity}: ${d.message}${d.code ? ` (${d.source} ${d.code})` : ` (${d.source})`}`;
8
+ }
9
+ /** Problems in `after` that weren't in `before`, matched by checker + code + message (line numbers shift). */
10
+ export function newProblems(before, after) {
11
+ const key = (d) => `${d.source}|${d.code ?? ""}|${d.message}`;
12
+ const seen = new Map();
13
+ for (const d of before)
14
+ seen.set(key(d), (seen.get(key(d)) ?? 0) + 1);
15
+ return after.filter((d) => {
16
+ const left = seen.get(key(d)) ?? 0;
17
+ if (left > 0) {
18
+ seen.set(key(d), left - 1);
19
+ return false;
20
+ }
21
+ return true;
22
+ });
23
+ }
24
+ /** tsc and the TypeScript language server can report the same error; keep one. */
25
+ function dedupe(list) {
26
+ const seen = new Set();
27
+ return list.filter((d) => {
28
+ const key = `${d.file}|${d.line}|${d.col}|${d.message}`;
29
+ if (seen.has(key))
30
+ return false;
31
+ seen.add(key);
32
+ return true;
33
+ });
34
+ }
35
+ /** Line numbers in `after` that an edit added or changed, plus the line where text was removed. */
36
+ export function touchedLines(before, after) {
37
+ const touched = new Set();
38
+ let line = 1;
39
+ for (const part of diffLines(before.replace(/\r\n/g, "\n"), after.replace(/\r\n/g, "\n"))) {
40
+ const count = part.count ?? part.value.split("\n").length - 1;
41
+ if (part.added)
42
+ for (let i = 0; i < count; i++)
43
+ touched.add(line + i);
44
+ if (part.removed)
45
+ touched.add(line);
46
+ else
47
+ line += count;
48
+ }
49
+ return touched;
50
+ }
51
+ function readText(file) {
52
+ try {
53
+ return readFileSync(path.resolve(process.cwd(), file), "utf-8");
54
+ }
55
+ catch {
56
+ return null;
57
+ }
58
+ }
59
+ /** Runs checkers, caches their results per file, and tells the UI when anything changes. */
60
+ export class DiagnosticsManager {
61
+ providers = [];
62
+ availability = new Map();
63
+ /** file -> provider id -> problems */
64
+ results = new Map();
65
+ projectTimer = null;
66
+ /** Project checks run one at a time; a queued run is shared by everyone who asks while it waits. */
67
+ projectChain = Promise.resolve();
68
+ projectQueued = null;
69
+ /** Whether a full project check has finished, i.e. we know which problems already existed. */
70
+ projectBaseline = false;
71
+ running = new Set();
72
+ listeners = new Set();
73
+ setProviders(providers) {
74
+ const ids = new Set(providers.map((p) => p.id));
75
+ // Drop results from checkers that were switched off.
76
+ for (const byProvider of this.results.values()) {
77
+ for (const id of [...byProvider.keys()])
78
+ if (!ids.has(id))
79
+ byProvider.delete(id);
80
+ }
81
+ this.providers = providers;
82
+ this.emit();
83
+ }
84
+ subscribe(listener) {
85
+ this.listeners.add(listener);
86
+ return () => this.listeners.delete(listener);
87
+ }
88
+ emit() {
89
+ for (const l of this.listeners)
90
+ l();
91
+ }
92
+ /** Names of checkers currently running, for the status bar. */
93
+ get busy() {
94
+ return [...this.running];
95
+ }
96
+ async isAvailable(p) {
97
+ if (!this.availability.has(p.id)) {
98
+ try {
99
+ this.availability.set(p.id, await p.available());
100
+ }
101
+ catch {
102
+ this.availability.set(p.id, false);
103
+ }
104
+ }
105
+ return this.availability.get(p.id);
106
+ }
107
+ async activeProviders() {
108
+ const out = [];
109
+ for (const p of this.providers)
110
+ if (await this.isAvailable(p))
111
+ out.push(p);
112
+ return out;
113
+ }
114
+ async runProvider(p, file, text) {
115
+ this.running.add(p.name);
116
+ this.emit();
117
+ try {
118
+ return await p.check(file, text);
119
+ }
120
+ catch {
121
+ return [];
122
+ }
123
+ finally {
124
+ this.running.delete(p.name);
125
+ this.emit();
126
+ }
127
+ }
128
+ store(file, providerId, list) {
129
+ const byProvider = this.results.get(file) ?? new Map();
130
+ byProvider.set(providerId, list);
131
+ this.results.set(file, byProvider);
132
+ }
133
+ /** Checks one file (unsaved `text` if given, otherwise what's on disk) with the per-file checkers. */
134
+ async checkFile(file, text) {
135
+ const content = text ?? readText(file);
136
+ if (content === null)
137
+ return [];
138
+ const providers = (await this.activeProviders()).filter((p) => !p.projectWide && p.matches(file));
139
+ await Promise.all(providers.map(async (p) => this.store(file, p.id, await this.runProvider(p, file, content))));
140
+ this.emit();
141
+ return this.get(file);
142
+ }
143
+ /**
144
+ * Runs project-wide checkers (like tsc) and replaces their results everywhere. Runs never overlap (they'd
145
+ * overwrite each other); asking while one is queued reuses it, since it will see the latest files anyway.
146
+ */
147
+ checkProject() {
148
+ if (this.projectQueued)
149
+ return this.projectQueued;
150
+ const run = this.projectChain.then(() => {
151
+ this.projectQueued = null;
152
+ return this.runProject();
153
+ });
154
+ this.projectQueued = run;
155
+ this.projectChain = run.catch(() => { });
156
+ return run;
157
+ }
158
+ async runProject() {
159
+ const providers = (await this.activeProviders()).filter((p) => p.projectWide);
160
+ await Promise.all(providers.map(async (p) => {
161
+ const list = await this.runProvider(p, ".", "");
162
+ for (const byProvider of this.results.values())
163
+ byProvider.delete(p.id);
164
+ const byFile = new Map();
165
+ for (const d of list)
166
+ byFile.set(d.file, [...(byFile.get(d.file) ?? []), d]);
167
+ for (const [file, items] of byFile)
168
+ this.store(file, p.id, items);
169
+ }));
170
+ this.projectBaseline = true;
171
+ this.emit();
172
+ }
173
+ /** Debounced checkProject, for after saves. */
174
+ scheduleProject(delay = 1500) {
175
+ if (this.projectTimer)
176
+ clearTimeout(this.projectTimer);
177
+ this.projectTimer = setTimeout(() => void this.checkProject(), delay);
178
+ }
179
+ /** Checks text without storing it, e.g. a proposed edit. Only checkers that accept unsaved text. */
180
+ async checkText(file, text) {
181
+ const providers = (await this.activeProviders()).filter((p) => p.checksText && !p.projectWide && p.matches(file));
182
+ return (await Promise.all(providers.map((p) => this.runProvider(p, file, text)))).flat();
183
+ }
184
+ get(file) {
185
+ return dedupe(sortDiagnostics([...(this.results.get(file)?.values() ?? [])].flat()));
186
+ }
187
+ all() {
188
+ return dedupe(sortDiagnostics([...this.results.values()].flatMap((m) => [...m.values()].flat())));
189
+ }
190
+ counts(file) {
191
+ const list = file ? this.get(file) : this.all();
192
+ return {
193
+ errors: list.filter((d) => d.severity === "error").length,
194
+ warnings: list.filter((d) => d.severity === "warning").length,
195
+ };
196
+ }
197
+ /** Per-file counts, for markers in the file tree. */
198
+ fileCounts() {
199
+ const out = new Map();
200
+ for (const d of this.all()) {
201
+ const c = out.get(d.file) ?? { errors: 0, warnings: 0 };
202
+ if (d.severity === "error")
203
+ c.errors++;
204
+ else if (d.severity === "warning")
205
+ c.warnings++;
206
+ out.set(d.file, c);
207
+ }
208
+ return out;
209
+ }
210
+ /**
211
+ * After Nou's edit: the problems it introduced in that file, compared with the file before.
212
+ * Checks both versions with per-file checkers, and re-runs project-wide ones.
213
+ */
214
+ async introducedBy(file, before, after) {
215
+ const projectIds = (await this.activeProviders()).filter((p) => p.projectWide && p.matches(file)).map((p) => p.id);
216
+ const hadBaseline = this.projectBaseline;
217
+ const projectBefore = this.getFrom(file, projectIds);
218
+ // One after the other: language servers hold a single copy of each document.
219
+ const beforeList = await this.checkText(file, before);
220
+ const afterList = await this.checkFile(file, after);
221
+ if (projectIds.length)
222
+ await this.checkProject();
223
+ let projectNew = newProblems(projectBefore, this.getFrom(file, projectIds));
224
+ if (!hadBaseline) {
225
+ // No check finished before this edit, so old and new problems look the same.
226
+ // Only blame the edit for problems on lines it actually touched.
227
+ const touched = touchedLines(before, after);
228
+ projectNew = projectNew.filter((d) => touched.has(d.line));
229
+ }
230
+ return [...newProblems(beforeList, afterList), ...projectNew];
231
+ }
232
+ getFrom(file, providerIds) {
233
+ return providerIds.flatMap((id) => this.results.get(file)?.get(id) ?? []);
234
+ }
235
+ }
@@ -0,0 +1,64 @@
1
+ import { spawn, spawnSync } from "child_process";
2
+ import { existsSync, readFileSync } from "fs";
3
+ import { createRequire } from "module";
4
+ import path from "path";
5
+ /** Runs a program without a shell (safe with any file name), optionally piping text to stdin. */
6
+ export function run(command, args, options = {}) {
7
+ return new Promise((resolve) => {
8
+ let stdout = "";
9
+ let stderr = "";
10
+ let child;
11
+ try {
12
+ child = spawn(command, args, { cwd: process.cwd(), windowsHide: true });
13
+ }
14
+ catch (error) {
15
+ resolve({ code: -1, stdout: "", stderr: error.message });
16
+ return;
17
+ }
18
+ const timer = setTimeout(() => child.kill(), options.timeout ?? 60_000);
19
+ child.stdout.on("data", (d) => (stdout += d));
20
+ child.stderr.on("data", (d) => (stderr += d));
21
+ child.on("error", (error) => {
22
+ clearTimeout(timer);
23
+ resolve({ code: -1, stdout, stderr: error.message });
24
+ });
25
+ child.on("close", (code) => {
26
+ clearTimeout(timer);
27
+ resolve({ code, stdout, stderr });
28
+ });
29
+ if (options.input !== undefined)
30
+ child.stdin.end(options.input);
31
+ else
32
+ child.stdin.end();
33
+ });
34
+ }
35
+ /** Whether a program is on PATH (e.g. `ruff`). */
36
+ export function onPath(command) {
37
+ try {
38
+ return spawnSync(command, ["--version"], { windowsHide: true, timeout: 5000 }).status === 0;
39
+ }
40
+ catch {
41
+ return false;
42
+ }
43
+ }
44
+ /**
45
+ * Finds a CLI installed in the user's project (not Nou's own copy), e.g. their ESLint,
46
+ * returning the JS entry point so it can be run with `node` on every OS.
47
+ */
48
+ export function projectBin(pkg, bin = pkg) {
49
+ try {
50
+ const require = createRequire(path.join(process.cwd(), "package.json"));
51
+ const manifestPath = require.resolve(`${pkg}/package.json`);
52
+ const manifest = JSON.parse(readFileSync(manifestPath, "utf-8"));
53
+ const rel = typeof manifest.bin === "string" ? manifest.bin : manifest.bin?.[bin];
54
+ if (!rel)
55
+ return null;
56
+ const full = path.join(path.dirname(manifestPath), rel);
57
+ // Only use it if it's really the project's install, not something hoisted from Nou itself.
58
+ const inside = path.relative(process.cwd(), full);
59
+ return existsSync(full) && !inside.startsWith("..") && !path.isAbsolute(inside) ? full : null;
60
+ }
61
+ catch {
62
+ return null;
63
+ }
64
+ }
@@ -0,0 +1,103 @@
1
+ import { existsSync } from "fs";
2
+ import path from "path";
3
+ import { onPath, projectBin, run } from "./process.js";
4
+ const rel = (file) => path.relative(process.cwd(), path.resolve(process.cwd(), file)).replace(/\\/g, "/");
5
+ /** ESLint, using the project's own install and config, so results match `npm run lint`. */
6
+ export const eslintProvider = {
7
+ id: "eslint",
8
+ name: "ESLint",
9
+ checksText: true,
10
+ matches: (file) => /\.(m|c)?(j|t)sx?$/.test(file),
11
+ available: () => projectBin("eslint") !== null,
12
+ async check(file, text) {
13
+ const bin = projectBin("eslint");
14
+ if (!bin)
15
+ return [];
16
+ const result = await run(process.execPath, [bin, "--stdin", "--stdin-filename", file, "--format", "json"], { input: text, timeout: 60_000 });
17
+ let reports;
18
+ try {
19
+ reports = JSON.parse(result.stdout);
20
+ }
21
+ catch {
22
+ return [];
23
+ }
24
+ return reports.flatMap((report) => (report.messages ?? [])
25
+ // "File ignored by config" isn't a real problem.
26
+ .filter((m) => !(m.ruleId === null && /ignored/i.test(m.message)))
27
+ .map((m) => ({
28
+ file: rel(file),
29
+ line: m.line ?? 1,
30
+ col: m.column ?? 1,
31
+ ...(m.endLine ? { endLine: m.endLine } : {}),
32
+ ...(m.endColumn ? { endCol: m.endColumn } : {}),
33
+ severity: m.severity === 2 ? "error" : "warning",
34
+ message: m.message,
35
+ source: "eslint",
36
+ ...(m.ruleId ? { code: m.ruleId } : {}),
37
+ })));
38
+ },
39
+ };
40
+ /** The TypeScript compiler over the whole project (type errors need the full picture). */
41
+ export const typescriptProvider = {
42
+ id: "typescript",
43
+ name: "TypeScript",
44
+ checksText: false,
45
+ projectWide: true,
46
+ matches: (file) => /\.(m|c)?tsx?$/.test(file),
47
+ available: () => existsSync(path.join(process.cwd(), "tsconfig.json")) && projectBin("typescript", "tsc") !== null,
48
+ async check() {
49
+ const bin = projectBin("typescript", "tsc");
50
+ if (!bin)
51
+ return [];
52
+ const result = await run(process.execPath, [bin, "--noEmit", "-p", ".", "--pretty", "false"], { timeout: 180_000 });
53
+ const diagnostics = [];
54
+ for (const line of result.stdout.split(/\r?\n/)) {
55
+ const m = line.match(/^(.+?)\((\d+),(\d+)\): (error|warning) (TS\d+): (.*)$/);
56
+ if (m) {
57
+ diagnostics.push({
58
+ file: rel(m[1]),
59
+ line: Number(m[2]),
60
+ col: Number(m[3]),
61
+ severity: m[4] === "error" ? "error" : "warning",
62
+ message: m[6],
63
+ source: "tsc",
64
+ code: m[5],
65
+ });
66
+ }
67
+ else if (line.startsWith(" ") && diagnostics.length) {
68
+ diagnostics[diagnostics.length - 1].message += ` ${line.trim()}`;
69
+ }
70
+ }
71
+ return diagnostics;
72
+ },
73
+ };
74
+ let ruffInstalled;
75
+ /** Ruff for Python, if it's installed. */
76
+ export const ruffProvider = {
77
+ id: "ruff",
78
+ name: "Ruff (Python)",
79
+ checksText: true,
80
+ matches: (file) => /\.pyi?$/.test(file),
81
+ available: () => (ruffInstalled ??= onPath("ruff")),
82
+ async check(file, text) {
83
+ const result = await run("ruff", ["check", "--output-format", "json", "--stdin-filename", file, "-"], { input: text, timeout: 30_000 });
84
+ let items;
85
+ try {
86
+ items = JSON.parse(result.stdout);
87
+ }
88
+ catch {
89
+ return [];
90
+ }
91
+ return items.map((item) => ({
92
+ file: rel(file),
93
+ line: item.location?.row ?? 1,
94
+ col: item.location?.column ?? 1,
95
+ ...(item.end_location ? { endLine: item.end_location.row, endCol: item.end_location.column } : {}),
96
+ // Syntax errors (no code / E9) and pyflakes (F) are real bugs; style rules are warnings.
97
+ severity: !item.code || /^(E9|F)/.test(item.code) ? "error" : "warning",
98
+ message: item.message,
99
+ source: "ruff",
100
+ ...(item.code ? { code: item.code } : {}),
101
+ }));
102
+ },
103
+ };
@@ -0,0 +1 @@
1
+ export {};
@@ -0,0 +1,12 @@
1
+ import { existsSync } from "fs";
2
+ // This module must be imported before anything else (index.ts imports it first).
3
+ // npm only warns about an unsupported Node version, so check here, before Ink loads and fails confusingly.
4
+ const [major, minor] = process.versions.node.split(".").map(Number);
5
+ if (major < 22 || (major === 22 && minor < 12)) {
6
+ console.error(`Nou needs Node.js 22.12 or newer (you have ${process.versions.node}). Get it from https://nodejs.org`);
7
+ process.exit(1);
8
+ }
9
+ // Load the project's .env (API keys, base URLs). Providers read their settings when they load,
10
+ // so loading .env later would be too late for them.
11
+ if (existsSync(".env"))
12
+ process.loadEnvFile(".env");
@@ -0,0 +1,113 @@
1
+ import { createHash } from "crypto";
2
+ import { existsSync, mkdirSync, readFileSync, rmSync, writeFileSync } from "fs";
3
+ import path from "path";
4
+ import { applyPatch, structuredPatch } from "diff";
5
+ import { NOU_HOME } from "../config/index.js";
6
+ const MAX_CHECKPOINTS = 300;
7
+ const MAX_FILE_BYTES = 1_000_000;
8
+ function readOrNull(file) {
9
+ try {
10
+ return readFileSync(path.resolve(process.cwd(), file), "utf-8");
11
+ }
12
+ catch {
13
+ return null;
14
+ }
15
+ }
16
+ /** Every change made during and across sessions, so edits can be undone safely. */
17
+ export class Timeline {
18
+ onUpdate;
19
+ items = [];
20
+ nextId = 1;
21
+ file;
22
+ constructor(onUpdate = () => { }) {
23
+ this.onUpdate = onUpdate;
24
+ const key = createHash("sha1").update(process.cwd()).digest("hex").slice(0, 16);
25
+ this.file = path.join(NOU_HOME, "history", `${key}.json`);
26
+ try {
27
+ const saved = JSON.parse(readFileSync(this.file, "utf-8"));
28
+ this.items = Array.isArray(saved.items) ? saved.items : [];
29
+ this.nextId = (this.items.at(-1)?.id ?? 0) + 1;
30
+ }
31
+ catch {
32
+ // first run in this project
33
+ }
34
+ }
35
+ get all() {
36
+ return this.items;
37
+ }
38
+ record(entry) {
39
+ if (entry.before === entry.after)
40
+ return null;
41
+ if ((entry.before?.length ?? 0) > MAX_FILE_BYTES || (entry.after?.length ?? 0) > MAX_FILE_BYTES)
42
+ return null;
43
+ const checkpoint = { ...entry, id: this.nextId++, time: Date.now() };
44
+ this.items.push(checkpoint);
45
+ if (this.items.length > MAX_CHECKPOINTS)
46
+ this.items.splice(0, this.items.length - MAX_CHECKPOINTS);
47
+ this.save();
48
+ return checkpoint;
49
+ }
50
+ /** Nou's most recent change that hasn't been undone. */
51
+ lastNouChange() {
52
+ return [...this.items].reverse().find((c) => c.source === "nou" && !c.undone);
53
+ }
54
+ /** All of Nou's live changes from its most recent turn, newest first. */
55
+ lastTurn() {
56
+ const last = this.lastNouChange();
57
+ if (!last)
58
+ return [];
59
+ return this.items.filter((c) => c.source === "nou" && !c.undone && c.turn === last.turn).reverse();
60
+ }
61
+ /**
62
+ * Reverts one checkpoint. If the file changed since, only Nou's part is reversed (patch merge);
63
+ * if that conflicts, nothing is written.
64
+ */
65
+ undo(checkpoint) {
66
+ const full = path.resolve(process.cwd(), checkpoint.path);
67
+ const current = readOrNull(checkpoint.path);
68
+ let target;
69
+ let merged = false;
70
+ if (current === checkpoint.after) {
71
+ target = checkpoint.before;
72
+ }
73
+ else if (current !== null && checkpoint.after !== null && checkpoint.before !== null) {
74
+ const eol = current.includes("\r\n") ? "\r\n" : "\n";
75
+ const reverse = structuredPatch("f", "f", checkpoint.after.replace(/\r\n/g, "\n"), checkpoint.before.replace(/\r\n/g, "\n"));
76
+ const result = applyPatch(current.replace(/\r\n/g, "\n"), reverse, { fuzzFactor: 2 });
77
+ if (result === false) {
78
+ return { ok: false, path: checkpoint.path, reason: "the file changed since in ways that overlap Nou's edit" };
79
+ }
80
+ target = result.replace(/\n/g, eol);
81
+ merged = true;
82
+ }
83
+ else {
84
+ return { ok: false, path: checkpoint.path, reason: "the file was created, removed or replaced since" };
85
+ }
86
+ try {
87
+ if (target === null)
88
+ rmSync(full, { force: true });
89
+ else {
90
+ mkdirSync(path.dirname(full), { recursive: true });
91
+ writeFileSync(full, target, "utf-8");
92
+ }
93
+ }
94
+ catch (error) {
95
+ return { ok: false, path: checkpoint.path, reason: error.message };
96
+ }
97
+ checkpoint.undone = true;
98
+ this.record({ path: checkpoint.path, before: current, after: target, source: "you", turn: checkpoint.turn });
99
+ this.save();
100
+ return { ok: true, path: checkpoint.path, merged };
101
+ }
102
+ save() {
103
+ try {
104
+ if (!existsSync(path.dirname(this.file)))
105
+ mkdirSync(path.dirname(this.file), { recursive: true });
106
+ writeFileSync(this.file, JSON.stringify({ cwd: process.cwd(), items: this.items }));
107
+ }
108
+ catch {
109
+ // history is best effort
110
+ }
111
+ this.onUpdate();
112
+ }
113
+ }