nou-cli 0.5.0 → 0.5.2
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/README.md +42 -8
- package/dist/index.js +9 -99
- package/dist/src/agent/index.js +169 -33
- package/dist/src/cli.js +115 -0
- package/dist/src/commands/index.js +233 -25
- package/dist/src/config/index.js +14 -0
- package/dist/src/context/index.js +33 -10
- package/dist/src/games/index.js +217 -0
- package/dist/src/mcp/import.js +106 -0
- package/dist/src/mcp/index.js +184 -0
- package/dist/src/providers/anthropic.js +33 -2
- package/dist/src/providers/index.js +13 -0
- package/dist/src/providers/openai.js +6 -2
- package/dist/src/syntax/index.js +10 -20
- package/dist/src/syntax/themes.js +57 -0
- package/dist/src/tools/index.js +86 -2
- package/dist/src/ui/App.js +693 -119
- package/dist/src/ui/Chat.js +84 -21
- package/dist/src/ui/ChatInput.js +4 -2
- package/dist/src/ui/CodeView.js +110 -19
- package/dist/src/ui/Editor.js +33 -10
- package/dist/src/ui/FileTree.js +1 -1
- package/dist/src/ui/GamePanel.js +92 -0
- package/dist/src/ui/Header.js +1 -1
- package/dist/src/ui/QuickOpen.js +51 -12
- package/dist/src/ui/Settings.js +58 -13
- package/dist/src/ui/Tabs.js +55 -0
- package/dist/src/ui/clipboard.js +9 -7
- package/dist/src/ui/commands.js +33 -0
- package/dist/src/ui/definition.js +135 -0
- package/dist/src/ui/fast-width.js +62 -0
- package/dist/src/ui/mouse.js +9 -4
- package/dist/src/ui/text.js +116 -12
- package/dist/src/ui/width-shim.js +7 -0
- package/package.json +2 -1
package/dist/src/cli.js
ADDED
|
@@ -0,0 +1,115 @@
|
|
|
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 update update to the latest version
|
|
22
|
+
nou --version print the version
|
|
23
|
+
nou --help show this help
|
|
24
|
+
|
|
25
|
+
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.`;
|
|
26
|
+
async function main() {
|
|
27
|
+
const args = process.argv.slice(2);
|
|
28
|
+
if (args.includes("--version") || args.includes("-v")) {
|
|
29
|
+
console.log(currentVersion());
|
|
30
|
+
return;
|
|
31
|
+
}
|
|
32
|
+
if (args.includes("--help") || args.includes("-h")) {
|
|
33
|
+
console.log(HELP);
|
|
34
|
+
return;
|
|
35
|
+
}
|
|
36
|
+
if (args[0] === "update") {
|
|
37
|
+
const latest = await checkForUpdate({ force: true, timeout: 10_000 });
|
|
38
|
+
if (!latest) {
|
|
39
|
+
console.log(`nou ${currentVersion()} is up to date.`);
|
|
40
|
+
return;
|
|
41
|
+
}
|
|
42
|
+
console.log(`Updating nou ${currentVersion()} → ${latest}...`);
|
|
43
|
+
if (await installUpdate(latest))
|
|
44
|
+
process.exit(0);
|
|
45
|
+
if (process.platform !== "win32")
|
|
46
|
+
console.log("If that was a permissions error, run: sudo npm install -g nou-cli@latest");
|
|
47
|
+
process.exit(1);
|
|
48
|
+
}
|
|
49
|
+
if (!process.stdin.isTTY) {
|
|
50
|
+
console.error("Nou needs an interactive terminal (try Windows Terminal, PowerShell, or Terminal/iTerm on macOS).");
|
|
51
|
+
process.exit(1);
|
|
52
|
+
}
|
|
53
|
+
p.intro(chalk.cyan(`${artWork} \n Hello I am Nou! 🌞 ${chalk.dim(`v${currentVersion()}`)}`));
|
|
54
|
+
// A folder's files can run things (its MCP servers, plugins, the commands Nou is asked to run), so a
|
|
55
|
+
// folder is trusted once, like Claude Code and VS Code ask.
|
|
56
|
+
if (!isTrustedFolder()) {
|
|
57
|
+
const here = process.cwd();
|
|
58
|
+
p.log.warn(`Nou will read the files in ${chalk.bold(here)} and can run its MCP servers and plugins, and commands you approve.`);
|
|
59
|
+
const trust = await p.confirm({ message: "Do you trust the files in this folder?" });
|
|
60
|
+
if (trust !== true) {
|
|
61
|
+
p.outro("OK, not opening it. Run nou in a folder you trust.");
|
|
62
|
+
return;
|
|
63
|
+
}
|
|
64
|
+
saveSettings({ trustedFolders: [...loadSettings().trustedFolders, here] });
|
|
65
|
+
}
|
|
66
|
+
const latest = await checkForUpdate();
|
|
67
|
+
if (latest) {
|
|
68
|
+
const update = await p.confirm({ message: `Nou ${chalk.green(latest)} is available (you have ${currentVersion()}). Update now?` });
|
|
69
|
+
if (update === true) {
|
|
70
|
+
const ok = await installUpdate(latest);
|
|
71
|
+
if (ok) {
|
|
72
|
+
p.log.success(`Updated to ${latest}. Restarting...`);
|
|
73
|
+
relaunch();
|
|
74
|
+
return;
|
|
75
|
+
}
|
|
76
|
+
p.log.warn(process.platform === "win32"
|
|
77
|
+
? "The update failed. You can retry later with: npm install -g nou-cli@latest"
|
|
78
|
+
: "The update failed. If it was a permissions error, run: sudo npm install -g nou-cli@latest");
|
|
79
|
+
}
|
|
80
|
+
}
|
|
81
|
+
const settings = loadSettings();
|
|
82
|
+
loadCredentials(); // keys entered inside Nou, for whatever the environment and .env leave unset
|
|
83
|
+
const borrowed = settings.reuseToolConfig ? borrowToolConfig() : [];
|
|
84
|
+
for (const b of borrowed)
|
|
85
|
+
p.log.info(`Using the ${b.provider === "anthropic" ? "Anthropic" : "OpenAI"} API key from your ${b.tool} settings.`);
|
|
86
|
+
let choice = initialModel();
|
|
87
|
+
// First run: start with a model that's already here, if there is one.
|
|
88
|
+
if (!process.env.NOU_MODEL && !settings.model) {
|
|
89
|
+
const picked = await pickStartingModel(borrowed);
|
|
90
|
+
if (picked) {
|
|
91
|
+
choice = picked;
|
|
92
|
+
saveModel(picked);
|
|
93
|
+
}
|
|
94
|
+
}
|
|
95
|
+
const problem = choice.provider.unavailable();
|
|
96
|
+
if (problem) {
|
|
97
|
+
p.log.warn(`${choice.provider.label} isn't set up (${problem}), falling back to local Ollama.`);
|
|
98
|
+
choice = resolveModel("ollama");
|
|
99
|
+
}
|
|
100
|
+
const ready = await ensureLocalModel(choice);
|
|
101
|
+
if (ready !== choice)
|
|
102
|
+
saveModel(ready); // picked a model that's already installed instead
|
|
103
|
+
choice = ready;
|
|
104
|
+
p.log.info(`Model: ${chalk.yellow(describe(choice))} (change it any time with /model)`);
|
|
105
|
+
// NOU.md is for Nou's own engine; the others read their own files (CLAUDE.md, AGENTS.md).
|
|
106
|
+
if ((settings.engine ?? "nou") === "nou")
|
|
107
|
+
await ensureProjectContext(choice);
|
|
108
|
+
const permContext = await loadPermanentContext();
|
|
109
|
+
await installFastOutput();
|
|
110
|
+
const app = render(createElement(App, { initialModel: choice, permContext }), RENDER_OPTIONS);
|
|
111
|
+
await app.waitUntilExit();
|
|
112
|
+
lsp.stopAll();
|
|
113
|
+
p.outro(chalk.cyan("Bye bye!"));
|
|
114
|
+
}
|
|
115
|
+
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 {
|
|
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
|
-
|
|
50
|
-
|
|
51
|
-
let body =
|
|
52
|
-
|
|
53
|
-
|
|
54
|
-
|
|
55
|
-
|
|
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
|
|
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
|
-
|
|
69
|
-
|
|
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
|
+
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
|
-
|
|
72
|
-
|
|
73
|
-
|
|
74
|
-
|
|
75
|
-
|
|
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
|
-
|
|
78
|
-
|
|
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
|
-
|
|
154
|
-
|
|
155
|
-
if (
|
|
156
|
-
throw new Error("old_string
|
|
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
|
-
|
|
161
|
-
|
|
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}`;
|
package/dist/src/config/index.js
CHANGED
|
@@ -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
|
+
gamePanel: null,
|
|
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
|
-
|
|
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
|
|
46
|
-
|
|
47
|
-
|
|
48
|
-
|
|
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
|
-
|
|
51
|
-
|
|
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
|
}
|