nou-cli 0.5.1 → 0.5.3

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 (42) hide show
  1. package/README.md +48 -9
  2. package/dist/index.js +9 -99
  3. package/dist/src/agent/index.js +199 -33
  4. package/dist/src/cli.js +117 -0
  5. package/dist/src/commands/index.js +233 -25
  6. package/dist/src/config/index.js +14 -0
  7. package/dist/src/context/index.js +33 -10
  8. package/dist/src/conversations/external.js +165 -0
  9. package/dist/src/conversations/index.js +71 -0
  10. package/dist/src/engines/claude.js +10 -0
  11. package/dist/src/engines/codex.js +60 -5
  12. package/dist/src/games/index.js +217 -0
  13. package/dist/src/mcp/import.js +106 -0
  14. package/dist/src/mcp/index.js +184 -0
  15. package/dist/src/providers/anthropic.js +23 -3
  16. package/dist/src/providers/index.js +13 -0
  17. package/dist/src/providers/openai.js +6 -2
  18. package/dist/src/syntax/index.js +10 -20
  19. package/dist/src/syntax/themes.js +57 -0
  20. package/dist/src/terminal/index.js +127 -0
  21. package/dist/src/tools/index.js +86 -2
  22. package/dist/src/ui/App.js +900 -118
  23. package/dist/src/ui/Chat.js +143 -22
  24. package/dist/src/ui/ChatInput.js +9 -2
  25. package/dist/src/ui/CodeView.js +111 -20
  26. package/dist/src/ui/Editor.js +13 -6
  27. package/dist/src/ui/FileTree.js +6 -5
  28. package/dist/src/ui/GamePanel.js +99 -0
  29. package/dist/src/ui/Header.js +1 -1
  30. package/dist/src/ui/QuickOpen.js +51 -12
  31. package/dist/src/ui/Settings.js +58 -13
  32. package/dist/src/ui/Tabs.js +56 -0
  33. package/dist/src/ui/Terminal.js +84 -0
  34. package/dist/src/ui/clipboard.js +9 -7
  35. package/dist/src/ui/commands.js +36 -0
  36. package/dist/src/ui/definition.js +135 -0
  37. package/dist/src/ui/fast-width.js +62 -0
  38. package/dist/src/ui/mouse.js +4 -1
  39. package/dist/src/ui/render-options.js +4 -0
  40. package/dist/src/ui/text.js +132 -12
  41. package/dist/src/ui/width-shim.js +7 -0
  42. package/package.json +2 -1
@@ -0,0 +1,117 @@
1
+ // The whole command line program; index.ts loads it once the environment is ready.
2
+ import { createElement } from "react";
3
+ import { render } from "ink";
4
+ import * as p from "@clack/prompts";
5
+ import chalk from "chalk";
6
+ import { artWork } from "./util/index.js";
7
+ import { ensureProjectContext, loadPermanentContext } from "./context/index.js";
8
+ import { describe, initialModel, resolveModel, saveModel } from "./providers/index.js";
9
+ import { isTrustedFolder, loadSettings, saveSettings } from "./config/index.js";
10
+ import { loadCredentials } from "./config/credentials.js";
11
+ import { borrowToolConfig, ensureLocalModel, pickStartingModel } from "./setup/index.js";
12
+ import { checkForUpdate, currentVersion, installUpdate, relaunch } from "./update/index.js";
13
+ import { lsp } from "./lsp/index.js";
14
+ import { App } from "./ui/App.js";
15
+ import { installFastOutput } from "./ui/fast-output.js";
16
+ import { RENDER_OPTIONS } from "./ui/render-options.js";
17
+ const HELP = `nou ${currentVersion()}: an AI coding assistant for your terminal
18
+
19
+ Usage:
20
+ nou open Nou in the current folder
21
+ nou --continue pick up the last conversation in this folder (-c)
22
+ nou --resume list earlier conversations here to pick one up (-r)
23
+ nou update update to the latest version
24
+ nou --version print the version
25
+ nou --help show this help
26
+
27
+ Settings live in ~/.nou/config.json (or press s inside Nou); keys you enter there, or with /connect, in ~/.nou/credentials.json. Set NOU_NO_UPDATE_CHECK=1 to skip update checks.`;
28
+ async function main() {
29
+ const args = process.argv.slice(2);
30
+ if (args.includes("--version") || args.includes("-v")) {
31
+ console.log(currentVersion());
32
+ return;
33
+ }
34
+ if (args.includes("--help") || args.includes("-h")) {
35
+ console.log(HELP);
36
+ return;
37
+ }
38
+ if (args[0] === "update") {
39
+ const latest = await checkForUpdate({ force: true, timeout: 10_000 });
40
+ if (!latest) {
41
+ console.log(`nou ${currentVersion()} is up to date.`);
42
+ return;
43
+ }
44
+ console.log(`Updating nou ${currentVersion()} → ${latest}...`);
45
+ if (await installUpdate(latest))
46
+ process.exit(0);
47
+ if (process.platform !== "win32")
48
+ console.log("If that was a permissions error, run: sudo npm install -g nou-cli@latest");
49
+ process.exit(1);
50
+ }
51
+ if (!process.stdin.isTTY) {
52
+ console.error("Nou needs an interactive terminal (try Windows Terminal, PowerShell, or Terminal/iTerm on macOS).");
53
+ process.exit(1);
54
+ }
55
+ p.intro(chalk.cyan(`${artWork} \n Hello I am Nou! 🌞 ${chalk.dim(`v${currentVersion()}`)}`));
56
+ // A folder's files can run things (its MCP servers, plugins, the commands Nou is asked to run), so a
57
+ // folder is trusted once, like Claude Code and VS Code ask.
58
+ if (!isTrustedFolder()) {
59
+ const here = process.cwd();
60
+ p.log.warn(`Nou will read the files in ${chalk.bold(here)} and can run its MCP servers and plugins, and commands you approve.`);
61
+ const trust = await p.confirm({ message: "Do you trust the files in this folder?" });
62
+ if (trust !== true) {
63
+ p.outro("OK, not opening it. Run nou in a folder you trust.");
64
+ return;
65
+ }
66
+ saveSettings({ trustedFolders: [...loadSettings().trustedFolders, here] });
67
+ }
68
+ const latest = await checkForUpdate();
69
+ if (latest) {
70
+ const update = await p.confirm({ message: `Nou ${chalk.green(latest)} is available (you have ${currentVersion()}). Update now?` });
71
+ if (update === true) {
72
+ const ok = await installUpdate(latest);
73
+ if (ok) {
74
+ p.log.success(`Updated to ${latest}. Restarting...`);
75
+ relaunch();
76
+ return;
77
+ }
78
+ p.log.warn(process.platform === "win32"
79
+ ? "The update failed. You can retry later with: npm install -g nou-cli@latest"
80
+ : "The update failed. If it was a permissions error, run: sudo npm install -g nou-cli@latest");
81
+ }
82
+ }
83
+ const settings = loadSettings();
84
+ loadCredentials(); // keys entered inside Nou, for whatever the environment and .env leave unset
85
+ const borrowed = settings.reuseToolConfig ? borrowToolConfig() : [];
86
+ for (const b of borrowed)
87
+ p.log.info(`Using the ${b.provider === "anthropic" ? "Anthropic" : "OpenAI"} API key from your ${b.tool} settings.`);
88
+ let choice = initialModel();
89
+ // First run: start with a model that's already here, if there is one.
90
+ if (!process.env.NOU_MODEL && !settings.model) {
91
+ const picked = await pickStartingModel(borrowed);
92
+ if (picked) {
93
+ choice = picked;
94
+ saveModel(picked);
95
+ }
96
+ }
97
+ const problem = choice.provider.unavailable();
98
+ if (problem) {
99
+ p.log.warn(`${choice.provider.label} isn't set up (${problem}), falling back to local Ollama.`);
100
+ choice = resolveModel("ollama");
101
+ }
102
+ const ready = await ensureLocalModel(choice);
103
+ if (ready !== choice)
104
+ saveModel(ready); // picked a model that's already installed instead
105
+ choice = ready;
106
+ p.log.info(`Model: ${chalk.yellow(describe(choice))} (change it any time with /model)`);
107
+ // NOU.md is for Nou's own engine; the others read their own files (CLAUDE.md, AGENTS.md).
108
+ if ((settings.engine ?? "nou") === "nou")
109
+ await ensureProjectContext(choice);
110
+ const permContext = await loadPermanentContext();
111
+ await installFastOutput();
112
+ const app = render(createElement(App, { initialModel: choice, permContext, ...(args.includes("--continue") || args.includes("-c") ? { resume: "last" } : args.includes("--resume") || args.includes("-r") ? { resume: "list" } : {}) }), RENDER_OPTIONS);
113
+ await app.waitUntilExit();
114
+ lsp.stopAll();
115
+ p.outro(chalk.cyan("Bye bye!"));
116
+ }
117
+ main().catch(console.error);
@@ -1,7 +1,7 @@
1
1
  import path from "path";
2
2
  import fs from "fs/promises";
3
3
  import { readdirSync } from "fs";
4
- import { exec } from "child_process";
4
+ import { spawn } from "child_process";
5
5
  export const IGNORED = new Set(["node_modules", ".git", "dist", "build", ".next", "__pycache__", ".venv"]);
6
6
  /** Whether a path is inside the project folder (where Nou may read without asking and write at all). */
7
7
  export function isInProject(filePath) {
@@ -42,20 +42,35 @@ export async function readForModel(filePath, offset = 1, limit = READ_LINES) {
42
42
  const lines = text.split(/\r?\n/);
43
43
  if (lines.length > 1 && lines.at(-1) === "")
44
44
  lines.pop();
45
+ if (text === "")
46
+ return "(The file is empty.)";
45
47
  const start = Math.max(1, Math.floor(offset) || 1);
46
48
  if (start > lines.length)
47
49
  return `(The file has ${lines.length} lines.)`;
48
50
  let end = Math.min(lines.length, start + Math.max(1, Math.floor(limit) || READ_LINES) - 1);
49
- if (start === 1 && end === lines.length && text.length <= READ_CHARS)
50
- return text;
51
- let body = lines.slice(start - 1, end).join("\n");
52
- if (body.length > READ_CHARS) {
53
- body = body.slice(0, READ_CHARS);
54
- end = start + body.split("\n").length - 1;
55
- body = `${body}\n[... line ${end} cut off: it is very long]`;
51
+ // Numbered like cat -n, so the model can cite and target exact lines (edit_file ignores the numbers).
52
+ const width = String(end).length;
53
+ let body = "";
54
+ for (let n = start; n <= end; n++) {
55
+ const line = `${String(n).padStart(width)}\t${lines[n - 1]}\n`;
56
+ if (body.length + line.length > READ_CHARS) {
57
+ if (n === start) {
58
+ // A single huge line (minified code, one-line JSON): show its start rather than nothing.
59
+ body += `${line.slice(0, READ_CHARS)}\n[... line ${n} cut off: it is ${lines[n - 1].length} characters long]\n`;
60
+ end = n;
61
+ }
62
+ else {
63
+ end = n - 1;
64
+ body += `[... stopped at line ${end} to keep this short]\n`;
65
+ }
66
+ break;
67
+ }
68
+ body += line;
56
69
  }
70
+ if (start === 1 && end === lines.length)
71
+ return body.trimEnd();
57
72
  const more = end < lines.length ? ` Read more with offset=${end + 1}.` : "";
58
- return `${body}\n\n[Lines ${start}-${end} of ${lines.length}.${more}]`;
73
+ return `${body}\n[Lines ${start}-${end} of ${lines.length}.${more}]`;
59
74
  }
60
75
  /** Keeps tool output a sensible size for the model: the start and, where errors usually are, the end. */
61
76
  export function capOutput(text, max = 40_000) {
@@ -65,19 +80,104 @@ export function capOutput(text, max = 40_000) {
65
80
  const tail = max - head;
66
81
  return `${text.slice(0, head)}\n\n[... ${text.length - max} characters cut ...]\n\n${text.slice(-tail)}`;
67
82
  }
68
- export function runCommand(command) {
69
- // Async so the UI keeps rendering while the command runs.
83
+ /** How long run_command waits by default, and at most. */
84
+ export const COMMAND_TIMEOUT = 120_000;
85
+ export const MAX_COMMAND_TIMEOUT = 600_000;
86
+ /** Output kept from a command: the start and the end (where errors and summaries are) when it's longer. */
87
+ const OUTPUT_KEEP = 200_000;
88
+ /**
89
+ * Runs a shell command and returns its output (stdout and stderr together, in the order written) and,
90
+ * when it failed, its exit code. Async so the UI keeps rendering while it runs.
91
+ */
92
+ /** Commands still running, so they can be stopped when Nou exits instead of being left behind. */
93
+ const running = new Set();
94
+ let exitHooked = false;
95
+ /** Stops a command and everything it started. */
96
+ export function stopProcess(child) {
97
+ try {
98
+ if (process.platform === "win32") {
99
+ if (child.pid)
100
+ spawn("taskkill", ["/pid", String(child.pid), "/T", "/F"], { stdio: "ignore" });
101
+ else
102
+ child.kill("SIGKILL");
103
+ }
104
+ else if (child.pid)
105
+ process.kill(-child.pid, "SIGKILL");
106
+ }
107
+ catch {
108
+ child.kill("SIGKILL");
109
+ }
110
+ }
111
+ /**
112
+ * Runs a shell command and returns its output (stdout and stderr together, in the order written) and,
113
+ * when it failed, its exit code. Async so the UI keeps rendering while it runs. `signal` (Esc) stops it.
114
+ */
115
+ export function runCommand(command, timeout = COMMAND_TIMEOUT, signal) {
70
116
  return new Promise((resolve) => {
71
- exec(command, { cwd: process.cwd(), timeout: 120_000, maxBuffer: 10 * 1024 * 1024 }, (error, stdout, stderr) => {
72
- if (error) {
73
- const reason = error.killed ? "timed out after 2 minutes" : `Exit Code ${error.code ?? "?"}`;
74
- resolve(`Error (${reason}): ${stderr || error.message}${stdout ? `
75
- ${stdout}` : ""}`);
117
+ const posix = process.platform !== "win32";
118
+ // Its own process group on macOS and Linux, so stopping it stops everything it started.
119
+ const child = spawn(command, { cwd: process.cwd(), shell: true, stdio: ["ignore", "pipe", "pipe"], detached: posix });
120
+ running.add(child);
121
+ if (!exitHooked) {
122
+ exitHooked = true;
123
+ process.on("exit", () => running.forEach(stopProcess));
124
+ // Closing the terminal ends Nou with SIGHUP, which skips "exit": stop the commands then too.
125
+ if (process.platform !== "win32") {
126
+ process.once("SIGHUP", () => {
127
+ running.forEach(stopProcess);
128
+ process.exit(129);
129
+ });
76
130
  }
77
- else {
78
- resolve(stdout);
131
+ }
132
+ let head = "";
133
+ let tail = "";
134
+ let dropped = 0;
135
+ const take = (text) => {
136
+ if (head.length < OUTPUT_KEEP / 2) {
137
+ head += text;
138
+ return;
79
139
  }
80
- });
140
+ tail += text;
141
+ if (tail.length > OUTPUT_KEEP / 2) {
142
+ dropped += tail.length - OUTPUT_KEEP / 2;
143
+ tail = tail.slice(-OUTPUT_KEEP / 2);
144
+ }
145
+ };
146
+ // Decoded as a stream, so a character split across two chunks isn't garbled.
147
+ child.stdout.setEncoding("utf8").on("data", take);
148
+ child.stderr.setEncoding("utf8").on("data", take);
149
+ const limit = Math.min(Math.max(1000, timeout), MAX_COMMAND_TIMEOUT);
150
+ let stopped = null;
151
+ const timer = setTimeout(() => {
152
+ stopped = "timeout";
153
+ stopProcess(child);
154
+ }, limit);
155
+ const onAbort = () => {
156
+ stopped = "cancelled";
157
+ stopProcess(child);
158
+ };
159
+ signal?.addEventListener("abort", onAbort, { once: true });
160
+ let done = false;
161
+ const finish = (code, error) => {
162
+ if (done)
163
+ return;
164
+ done = true;
165
+ clearTimeout(timer);
166
+ signal?.removeEventListener("abort", onAbort);
167
+ running.delete(child);
168
+ const output = (dropped ? `${head}\n[... ${dropped} characters cut ...]\n${tail}` : head + tail).trimEnd();
169
+ if (error)
170
+ return resolve(`Error: ${error.message}`);
171
+ if (stopped === "cancelled")
172
+ return resolve(`Stopped: the user cancelled it.\n${output}`);
173
+ if (stopped === "timeout")
174
+ return resolve(`Error (timed out after ${Math.round(limit / 1000)}s; pass a longer timeout if it needs one):\n${output}`);
175
+ resolve(code === 0 ? output || "(no output)" : `Error (exit code ${code ?? "?"}):\n${output || "(no output)"}`);
176
+ };
177
+ child.on("error", (error) => finish(null, error));
178
+ child.on("close", (code) => finish(code));
179
+ if (signal?.aborted)
180
+ onAbort();
81
181
  });
82
182
  }
83
183
  export function fileTree(dir = ".", maxDepth = 3, maxLines = Infinity) {
@@ -141,29 +241,137 @@ export async function proposeWrite(filePath, content) {
141
241
  const before = await readOrEmpty(fullPath);
142
242
  return { path: toRelative(filePath), before: before ?? "", after: cleanContent(content), isNew: before === null };
143
243
  }
244
+ /** Typographic characters models sometimes put in place of the plain ones in the file. */
245
+ const PLAIN = { "\u2018": "'", "\u2019": "'", "\u201c": "\"", "\u201d": "\"", "\u2013": "-", "\u2014": "-", "\u00a0": " ", "\u2026": "..." };
246
+ const plainChars = (s) => s.replace(/[\u2018\u2019\u201c\u201d\u2013\u2014\u00a0\u2026]/g, (c) => PLAIN[c]);
247
+ /**
248
+ * Where `target` is in `text`, matching whole lines loosely: ignoring trailing spaces, then indentation,
249
+ * then typographic quotes and dashes. Only a unique match counts: a wrong edit is worse than none.
250
+ */
251
+ function looseMatch(text, target) {
252
+ const lines = text.split("\n");
253
+ const want = target.replace(/\n$/, "").split("\n");
254
+ const offsets = [];
255
+ for (let i = 0, at = 0; i < lines.length; i++) {
256
+ offsets.push(at);
257
+ at += lines[i].length + 1;
258
+ }
259
+ for (const norm of [(l) => l.trimEnd(), (l) => l.trim(), (l) => plainChars(l).trim()]) {
260
+ const wanted = want.map(norm);
261
+ const hits = [];
262
+ for (let i = 0; i + wanted.length <= lines.length && hits.length < 2; i++) {
263
+ if (wanted.every((w, k) => norm(lines[i + k]) === w))
264
+ hits.push(i);
265
+ }
266
+ if (hits.length === 1) {
267
+ const first = hits[0];
268
+ const last = first + wanted.length - 1;
269
+ // How the file indents the match versus how old_string did, so new_string can be shifted to match.
270
+ const k = Math.max(0, want.findIndex((l) => l.trim()));
271
+ const lead = (l) => l.match(/^\s*/)[0];
272
+ return { start: offsets[first], end: offsets[last] + lines[last].length, fileIndent: lead(lines[first + k]), targetIndent: lead(want[k]) };
273
+ }
274
+ if (hits.length > 1)
275
+ return null;
276
+ }
277
+ return null;
278
+ }
279
+ /** The lines of `text` most like the start of `target`, numbered: a hint for the model's next try. */
280
+ function nearest(text, target) {
281
+ const lines = text.split("\n");
282
+ const first = target.split("\n").find((l) => l.trim())?.trim() ?? "";
283
+ if (!first)
284
+ return "";
285
+ const words = new Set(first.split(/\W+/).filter((w) => w.length > 2));
286
+ let best = -1;
287
+ let bestScore = 0;
288
+ lines.forEach((line, i) => {
289
+ const score = line.split(/\W+/).filter((w) => words.has(w)).length;
290
+ if (score > bestScore) {
291
+ best = i;
292
+ bestScore = score;
293
+ }
294
+ });
295
+ if (best < 0)
296
+ return "";
297
+ const from = Math.max(0, best - 2);
298
+ return lines.slice(from, best + 4).map((l, k) => `${String(from + k + 1).padStart(5)}\t${l}`).join("\n");
299
+ }
300
+ /** Line numbers copied from read_file's output (" 12\tcode"): dropped when old_string doesn't match with them. */
301
+ const stripLineNumbers = (s) => (s.split("\n").every((l) => !l || /^\s*\d+\t/.test(l)) ? s.replace(/^\s*\d+\t/gm, "") : s);
144
302
  /** Works out what edit_file would do, without touching the disk. */
145
303
  export async function proposeEdit(filePath, oldString, newString, replaceAll = false) {
146
304
  const fullPath = resolveInProject(filePath);
147
305
  const before = await readOrEmpty(fullPath);
148
306
  if (before === null)
149
307
  throw new Error(`${filePath} does not exist. Use write_file to create it.`);
308
+ const after = editText(before, oldString, newString, replaceAll);
309
+ return { path: toRelative(filePath), before, after, isNew: false };
310
+ }
311
+ /** Applies one edit to a file's text (see proposeEdit), throwing an error the model can act on. */
312
+ export function editText(before, oldString, newString, replaceAll = false) {
150
313
  // Models often send LF while Windows files use CRLF, so match on normalized text.
151
314
  const eol = before.includes("\r\n") ? "\r\n" : "\n";
152
315
  const normalized = before.replace(/\r\n/g, "\n");
153
- const target = oldString.replace(/\r\n/g, "\n");
154
- const count = target ? normalized.split(target).length - 1 : 0;
155
- if (count === 0)
156
- throw new Error("old_string was not found in the file. Read the file and copy the text exactly.");
316
+ let target = oldString.replace(/\r\n/g, "\n");
317
+ let replacement = newString.replace(/\r\n/g, "\n");
318
+ if (!target)
319
+ throw new Error("old_string is empty. To create a file use write_file; to add to one, include the line you're adding after.");
320
+ let count = normalized.split(target).length - 1;
321
+ if (count === 0 && stripLineNumbers(target) !== target) {
322
+ target = stripLineNumbers(target);
323
+ replacement = stripLineNumbers(replacement);
324
+ count = normalized.split(target).length - 1;
325
+ }
326
+ if (count === 0) {
327
+ const loose = looseMatch(normalized, target);
328
+ if (loose) {
329
+ let text = replacement.replace(/\n$/, "");
330
+ // old_string was indented differently from the file: shift new_string the same way, so the code stays in its block.
331
+ // (Only when new_string is indented like old_string; if it already has the file's indentation, it stays.)
332
+ const newIndent = text.split("\n").find((l) => l.trim())?.match(/^\s*/)[0] ?? "";
333
+ if (loose.fileIndent !== loose.targetIndent && newIndent === loose.targetIndent) {
334
+ const { fileIndent: to, targetIndent: from } = loose;
335
+ text = text.split("\n").map((l) => (!l.trim() ? l : l.startsWith(from) ? to + l.slice(from.length) : l)).join("\n");
336
+ }
337
+ // Deleting whole lines takes their line break too, rather than leaving a blank line.
338
+ const end = !text && normalized[loose.end] === "\n" ? loose.end + 1 : loose.end;
339
+ return (normalized.slice(0, loose.start) + text + normalized.slice(end)).replace(/\n/g, eol);
340
+ }
341
+ const near = nearest(normalized, target);
342
+ throw new Error(`old_string was not found in the file. Copy it exactly from read_file (without the line numbers).${near ? ` The closest lines are:\n${near}` : " Read the file again first."}`);
343
+ }
157
344
  if (count > 1 && !replaceAll) {
158
345
  throw new Error(`old_string appears ${count} times. Include more surrounding lines so it is unique, or set replace_all to change every one.`);
159
346
  }
160
- const replacement = newString.replace(/\r\n/g, "\n");
161
- const after = (replaceAll ? normalized.split(target).join(replacement) : normalized.replace(target, () => replacement)).replace(/\n/g, eol);
347
+ return (replaceAll ? normalized.split(target).join(replacement) : normalized.replace(target, () => replacement)).replace(/\n/g, eol);
348
+ }
349
+ /** edit_file with several edits: each applies to the result of the one before, and all must work. */
350
+ export async function proposeEdits(filePath, edits) {
351
+ const fullPath = resolveInProject(filePath);
352
+ const before = await readOrEmpty(fullPath);
353
+ if (before === null)
354
+ throw new Error(`${filePath} does not exist. Use write_file to create it.`);
355
+ let after = before;
356
+ edits.forEach((e, i) => {
357
+ try {
358
+ after = editText(after, String(e.old_string ?? ""), String(e.new_string ?? ""), e.replace_all === true);
359
+ }
360
+ catch (error) {
361
+ throw new Error(`Edit ${i + 1} of ${edits.length}: ${error.message} (none of the edits were made)`, { cause: error });
362
+ }
363
+ });
162
364
  return { path: toRelative(filePath), before, after, isNew: false };
163
365
  }
164
366
  export async function applyChange(change) {
165
367
  try {
166
368
  const fullPath = resolveInProject(change.path);
369
+ // The edit was worked out from the file as it was then; if it has changed since (you edited it while
370
+ // the edit waited for approval), writing it would throw your changes away.
371
+ const now = await readOrEmpty(fullPath);
372
+ if (change.isNew ? now !== null : now !== change.before) {
373
+ return `Failed: ${change.path} changed after the edit was worked out (it was edited meanwhile), so it wasn't written. Read it again and redo the edit.`;
374
+ }
167
375
  await fs.mkdir(path.dirname(fullPath), { recursive: true });
168
376
  await fs.writeFile(fullPath, change.after, "utf-8");
169
377
  return `Successfully ${change.isNew ? "created" : "updated"} ${change.path}`;
@@ -5,18 +5,27 @@ export const NOU_HOME = path.join(os.homedir(), ".nou");
5
5
  const CONFIG_PATH = path.join(NOU_HOME, "config.json");
6
6
  export const EFFORTS = ["low", "medium", "high", "xhigh", "max"];
7
7
  export const AUTO_SAVE = ["delay", "focus", "off"];
8
+ /** How fast follow mode replays what the agent does: a step every so often, or as it happens. */
9
+ export const FOLLOW_SPEEDS = ["slow", "normal", "fast", "instant"];
10
+ /** Color themes for code (see src/syntax/themes.ts). */
11
+ export const CODE_THEMES = ["one-dark", "tokyo-night", "github-dark", "dracula", "high-contrast", "github-light"];
8
12
  export const DEFAULT_SETTINGS = {
9
13
  model: "",
10
14
  engine: "nou",
11
15
  effort: "high",
12
16
  followMode: false,
17
+ followSpeed: "normal",
13
18
  autoApproveEdits: false,
14
19
  autoApproveCommands: false,
15
20
  gitContext: true,
16
21
  autoSave: "delay",
22
+ codeTheme: "one-dark",
17
23
  mouse: true,
18
24
  reuseToolConfig: true,
19
25
  paneWidths: null,
26
+ trustedFolders: [],
27
+ games: [],
28
+ gameDock: "chat",
20
29
  plugins: {},
21
30
  trustedPlugins: {},
22
31
  };
@@ -41,3 +50,8 @@ export function saveSettings(patch) {
41
50
  }
42
51
  return next;
43
52
  }
53
+ /** Whether you trust this folder (or one it's inside). */
54
+ export function isTrustedFolder(dir = process.cwd(), settings = loadSettings()) {
55
+ const here = path.resolve(dir);
56
+ return settings.trustedFolders.some((t) => here === t || here.startsWith(t.endsWith(path.sep) ? t : t + path.sep));
57
+ }
@@ -1,12 +1,9 @@
1
- import { highlight } from "cli-highlight";
2
1
  import fs from "fs/promises";
3
2
  import path from "path";
4
3
  import { fileTree, readFile } from "../commands/index.js";
5
4
  import { complete } from "../providers/index.js";
6
5
  import * as p from "@clack/prompts";
7
- export const sugarfy = (str) => {
8
- return highlight(str, { language: "typescript", ignoreIllegals: true });
9
- };
6
+ import { NOU_HOME } from "../config/index.js";
10
7
  export async function generateContext(contextPath, choice) {
11
8
  const s = p.spinner();
12
9
  s.start("Analyzing project structure to create NOU.md...");
@@ -41,13 +38,39 @@ export async function ensureProjectContext(choice) {
41
38
  await generateContext(contextPath, choice);
42
39
  }
43
40
  }
41
+ /** Instruction files other coding agents use too, so a repo's existing rules apply to Nou as well. */
42
+ const INSTRUCTION_FILES = ["NOU.md", "AGENTS.md", "CLAUDE.md"];
43
+ const MAX_INSTRUCTIONS = 20_000;
44
+ /**
45
+ * The project's standing instructions: NOU.md, AGENTS.md and CLAUDE.md here and in the folders above it
46
+ * up to the repository root (outermost first, so nearer files have the last word), plus ~/.nou/NOU.md.
47
+ */
44
48
  export async function loadPermanentContext() {
45
- const contextPath = path.join(process.cwd(), "NOU.md");
46
- try {
47
- const content = await fs.readFile(contextPath, "utf-8");
48
- return `\n\nPROJECT SPECIFIC RULES (from NOU.md):\n${content}`;
49
+ const dirs = [];
50
+ for (let dir = process.cwd();; dir = path.dirname(dir)) {
51
+ dirs.unshift(dir);
52
+ const atRoot = await fs.access(path.join(dir, ".git")).then(() => true, () => false);
53
+ if (atRoot || path.dirname(dir) === dir)
54
+ break;
49
55
  }
50
- catch {
51
- return "";
56
+ const files = [path.join(NOU_HOME, "NOU.md"), ...dirs.flatMap((dir) => INSTRUCTION_FILES.map((name) => path.join(dir, name)))];
57
+ const seen = new Set();
58
+ const parts = [];
59
+ for (const file of files) {
60
+ const content = (await fs.readFile(file, "utf-8").catch(() => "")).trim();
61
+ if (!content || seen.has(content))
62
+ continue;
63
+ seen.add(content);
64
+ const where = file.startsWith(NOU_HOME) ? "~/.nou/NOU.md" : path.relative(process.cwd(), file) || path.basename(file);
65
+ parts.push(`From ${where}:\n${content.length > MAX_INSTRUCTIONS ? `${content.slice(0, MAX_INSTRUCTIONS)}\n[... cut]` : content}`);
52
66
  }
67
+ return parts.length ? `\n\nPROJECT SPECIFIC RULES:\n${parts.join("\n\n")}` : "";
68
+ }
69
+ /**
70
+ * Just the NOU.md parts of the project rules, for engines that read AGENTS.md / CLAUDE.md themselves
71
+ * (Claude Code, Codex): giving them those again would only repeat them.
72
+ */
73
+ export function nouRulesOnly(rules) {
74
+ const parts = rules.replace(/^\s*PROJECT SPECIFIC RULES:\n/, "").split(/\n\n(?=From )/).filter((p) => /^From (\S*[\\/])?NOU\.md:|^From ~\/\.nou\/NOU\.md:/.test(p.trim()));
75
+ return parts.length ? `\n\nPROJECT SPECIFIC RULES:\n${parts.join("\n\n")}` : "";
53
76
  }