nou-cli 0.0.0-stage → 0.1.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/.env.example +24 -0
- package/README.md +54 -2
- package/dist/index.js +35 -0
- package/dist/src/agent/index.js +214 -0
- package/dist/src/commands/index.js +115 -0
- package/dist/src/config/index.js +36 -0
- package/dist/src/context/index.js +53 -0
- package/dist/src/git/index.js +94 -0
- package/dist/src/git/tools.js +52 -0
- package/dist/src/plugins/index.js +43 -0
- package/dist/src/providers/anthropic.js +109 -0
- package/dist/src/providers/index.js +46 -0
- package/dist/src/providers/ollama.js +57 -0
- package/dist/src/providers/openai.js +106 -0
- package/dist/src/providers/types.js +2 -0
- package/dist/src/tools/index.js +100 -0
- package/dist/src/ui/App.js +432 -0
- package/dist/src/ui/Chat.js +90 -0
- package/dist/src/ui/CodeView.js +84 -0
- package/dist/src/ui/Editor.js +165 -0
- package/dist/src/ui/FileTree.js +141 -0
- package/dist/src/ui/Header.js +9 -0
- package/dist/src/ui/Pane.js +8 -0
- package/dist/src/ui/QuickOpen.js +91 -0
- package/dist/src/ui/Settings.js +150 -0
- package/dist/src/ui/text.js +95 -0
- package/dist/src/ui/theme.js +41 -0
- package/dist/src/util/index.js +9 -0
- package/package.json +64 -4
package/.env.example
ADDED
|
@@ -0,0 +1,24 @@
|
|
|
1
|
+
# Copy to .env and fill in only the providers you use. .env is gitignored.
|
|
2
|
+
|
|
3
|
+
# Pick the starting model as provider:model (or switch in the app with /model).
|
|
4
|
+
# NOU_MODEL=ollama:qwen3:8b
|
|
5
|
+
# NOU_MODEL=anthropic:claude-opus-5-5
|
|
6
|
+
|
|
7
|
+
# --- Local ---
|
|
8
|
+
# OLLAMA_HOST=http://127.0.0.1:11434
|
|
9
|
+
# LMSTUDIO_BASE_URL=http://localhost:1234/v1
|
|
10
|
+
# LLAMACPP_BASE_URL=http://localhost:8080/v1
|
|
11
|
+
# Any other OpenAI-compatible server (vLLM, Jan, LocalAI, ...):
|
|
12
|
+
# NOU_BASE_URL=http://localhost:8000/v1
|
|
13
|
+
# NOU_API_KEY=
|
|
14
|
+
|
|
15
|
+
# --- Cloud ---
|
|
16
|
+
# ANTHROPIC_API_KEY=
|
|
17
|
+
# OPENAI_API_KEY=
|
|
18
|
+
# GEMINI_API_KEY=
|
|
19
|
+
# OPENROUTER_API_KEY=
|
|
20
|
+
# GROQ_API_KEY=
|
|
21
|
+
# DEEPSEEK_API_KEY=
|
|
22
|
+
|
|
23
|
+
# Claude effort level: low | medium | high | xhigh | max (default high)
|
|
24
|
+
# NOU_EFFORT=high
|
package/README.md
CHANGED
|
@@ -1,3 +1,55 @@
|
|
|
1
|
-
#
|
|
1
|
+
# Nou
|
|
2
2
|
|
|
3
|
-
|
|
3
|
+
An IDE-style AI coding assistant that lives in your terminal: file explorer, code view with live side-by-side diffs, a built-in editor, and a chat with an agent that reads, edits and runs things in your project. Works with local models (Ollama, LM Studio, llama.cpp) and cloud ones (Claude, OpenAI, Gemini, OpenRouter, Groq, DeepSeek).
|
|
4
|
+
|
|
5
|
+
```
|
|
6
|
+
npm install -g nou-cli
|
|
7
|
+
cd your-project
|
|
8
|
+
nou
|
|
9
|
+
```
|
|
10
|
+
|
|
11
|
+
Needs Node 20.12+ and an interactive terminal (Windows Terminal, PowerShell, macOS/Linux terminals). For local models, install [Ollama](https://ollama.com) and pull a model, e.g. `ollama pull qwen3:8b` (the default).
|
|
12
|
+
|
|
13
|
+
## Using it
|
|
14
|
+
|
|
15
|
+
| Key | Does |
|
|
16
|
+
| --- | --- |
|
|
17
|
+
| `Tab` | switch between files, code and chat |
|
|
18
|
+
| `Ctrl+P` | fuzzy-find a file |
|
|
19
|
+
| `e` | edit the open file (`Ctrl+S` save, `Ctrl+Z` undo, `Esc` close) |
|
|
20
|
+
| `v` | diff the open file against git `HEAD` (or Nou's last edit) |
|
|
21
|
+
| `c` | in the file tree, show only changed files |
|
|
22
|
+
| `f` | follow mode: jump to files Nou is reading/editing |
|
|
23
|
+
| `s` or `/settings` | settings: model, preferences, plugins |
|
|
24
|
+
| `y` / `n` | accept / reject a proposed edit or command |
|
|
25
|
+
| `Esc` | stop Nou, close a diff, back to chat |
|
|
26
|
+
|
|
27
|
+
Nou sees what you're looking at: the open file and visible lines (or your cursor line and unsaved edits while editing) are attached to each message, so "explain this function" just works. In a git repo it also gets your branch, uncommitted changes and recent commits, plus `git_status` / `git_diff` / `git_log` tools.
|
|
28
|
+
|
|
29
|
+
Chat commands: `/settings`, `/model [provider:model]`, `/models [provider]`, `/clear`, `/help`.
|
|
30
|
+
|
|
31
|
+
## Models
|
|
32
|
+
|
|
33
|
+
Pick one in `/settings`, with `/model anthropic`, `/model ollama:qwen3:8b`, or `NOU_MODEL=provider:model`. API keys come from your environment or a `.env` file in the project; see [.env.example](.env.example).
|
|
34
|
+
|
|
35
|
+
| Provider | Setup |
|
|
36
|
+
| --- | --- |
|
|
37
|
+
| Ollama (default) | runs locally, `OLLAMA_HOST` to change |
|
|
38
|
+
| LM Studio, llama.cpp | start their local server |
|
|
39
|
+
| Any OpenAI-compatible server | `NOU_BASE_URL`, optional `NOU_API_KEY` |
|
|
40
|
+
| Anthropic | `ANTHROPIC_API_KEY` |
|
|
41
|
+
| OpenAI / Gemini / OpenRouter / Groq / DeepSeek | `OPENAI_API_KEY` / `GEMINI_API_KEY` / `OPENROUTER_API_KEY` / `GROQ_API_KEY` / `DEEPSEEK_API_KEY` |
|
|
42
|
+
|
|
43
|
+
## Plugins (early)
|
|
44
|
+
|
|
45
|
+
Drop a `.js`/`.mjs` file into `.nou/plugins/` (project) or `~/.nou/plugins/` (everywhere), then enable it in `/settings → Plugins`. A plugin can add tools for the model, slash commands, and system prompt text. See [examples/plugins/word-count.mjs](examples/plugins/word-count.mjs). Plugins run code on your machine, so they stay off until you enable them.
|
|
46
|
+
|
|
47
|
+
## Development
|
|
48
|
+
|
|
49
|
+
```
|
|
50
|
+
npm install
|
|
51
|
+
npm run dev # run from source
|
|
52
|
+
npm run build # compile to dist/
|
|
53
|
+
```
|
|
54
|
+
|
|
55
|
+
Releases: create a GitHub release tagged `vX.Y.Z` and the Publish workflow builds and publishes that version to npm (needs an `NPM_TOKEN` secret in the `production` environment).
|
package/dist/index.js
ADDED
|
@@ -0,0 +1,35 @@
|
|
|
1
|
+
#!/usr/bin/env node
|
|
2
|
+
import { existsSync } from "fs";
|
|
3
|
+
import { createElement } from "react";
|
|
4
|
+
import { render } from "ink";
|
|
5
|
+
import * as p from "@clack/prompts";
|
|
6
|
+
import chalk from "chalk";
|
|
7
|
+
import { artWork } from "./src/util/index.js";
|
|
8
|
+
import { ensureProjectContext, loadPermanentContext } from "./src/context/index.js";
|
|
9
|
+
import { describe, initialModel, resolveModel } from "./src/providers/index.js";
|
|
10
|
+
import { App } from "./src/ui/App.js";
|
|
11
|
+
async function main() {
|
|
12
|
+
if (!process.stdin.isTTY) {
|
|
13
|
+
console.error("Nou needs an interactive terminal (try Windows Terminal / PowerShell).");
|
|
14
|
+
process.exit(1);
|
|
15
|
+
}
|
|
16
|
+
// API keys can live in a .env next to the project (it's gitignored).
|
|
17
|
+
if (existsSync(".env"))
|
|
18
|
+
process.loadEnvFile(".env");
|
|
19
|
+
p.intro(chalk.cyan(`${artWork} \n Hello I am Nou! 🌞`));
|
|
20
|
+
let choice = initialModel();
|
|
21
|
+
const problem = choice.provider.unavailable();
|
|
22
|
+
if (problem) {
|
|
23
|
+
p.log.warn(`${choice.provider.label} isn't set up (${problem}), falling back to local Ollama.`);
|
|
24
|
+
choice = resolveModel("ollama");
|
|
25
|
+
}
|
|
26
|
+
p.log.info(`Model: ${chalk.yellow(describe(choice))} (change it any time with /model)`);
|
|
27
|
+
await ensureProjectContext(choice);
|
|
28
|
+
const permContext = await loadPermanentContext();
|
|
29
|
+
const app = render(createElement(App, { initialModel: choice, permContext }), {
|
|
30
|
+
alternateScreen: true,
|
|
31
|
+
});
|
|
32
|
+
await app.waitUntilExit();
|
|
33
|
+
p.outro(chalk.cyan("Bye bye!"));
|
|
34
|
+
}
|
|
35
|
+
main().catch(console.error);
|
|
@@ -0,0 +1,214 @@
|
|
|
1
|
+
import { toolDefs } from "../tools/index.js";
|
|
2
|
+
import { applyChange, fileTree, proposeEdit, proposeWrite, readFile, runCommand, toRelative, } from "../commands/index.js";
|
|
3
|
+
import { describe } from "../providers/index.js";
|
|
4
|
+
const MAX_STEPS = 25;
|
|
5
|
+
export function buildSystemPrompt(permContext, extras = []) {
|
|
6
|
+
return `You are Nou, a coding assistant running in the user's terminal, working in ${process.cwd()}.
|
|
7
|
+
You can read, edit and create files and run commands with the tools you have.
|
|
8
|
+
- Read a file before editing it. Prefer edit_file for small changes and write_file for new files.
|
|
9
|
+
- Every edit and command is shown to the user, who approves or rejects it.
|
|
10
|
+
- Keep replies short and to the point.
|
|
11
|
+
|
|
12
|
+
Project files:
|
|
13
|
+
${fileTree(".", 3)}
|
|
14
|
+
${extras.map((e) => `\n${e}`).join("")}
|
|
15
|
+
${permContext}`;
|
|
16
|
+
}
|
|
17
|
+
/** Checks required arguments are present before a tool runs (models, especially small local ones, skip them). */
|
|
18
|
+
function invalidArgs(call, tools) {
|
|
19
|
+
if ("__invalid_json" in call.args)
|
|
20
|
+
return `The arguments were not valid JSON: ${JSON.stringify(call.args.__invalid_json)}`;
|
|
21
|
+
const def = tools.find((t) => t.name === call.name);
|
|
22
|
+
if (!def)
|
|
23
|
+
return null;
|
|
24
|
+
const missing = def.parameters.required.filter((key) => call.args[key] === undefined || call.args[key] === null);
|
|
25
|
+
return missing.length ? `Missing or non-string arguments: ${missing.join(", ")}` : null;
|
|
26
|
+
}
|
|
27
|
+
export class Agent {
|
|
28
|
+
choice;
|
|
29
|
+
system;
|
|
30
|
+
hooks;
|
|
31
|
+
messages = [];
|
|
32
|
+
controller = null;
|
|
33
|
+
extraTools = [];
|
|
34
|
+
effort;
|
|
35
|
+
constructor(choice, system, hooks) {
|
|
36
|
+
this.choice = choice;
|
|
37
|
+
this.system = system;
|
|
38
|
+
this.hooks = hooks;
|
|
39
|
+
}
|
|
40
|
+
configure(options) {
|
|
41
|
+
if (options.system !== undefined)
|
|
42
|
+
this.system = options.system;
|
|
43
|
+
if (options.extraTools)
|
|
44
|
+
this.extraTools = options.extraTools;
|
|
45
|
+
if (options.effort)
|
|
46
|
+
this.effort = options.effort;
|
|
47
|
+
}
|
|
48
|
+
get tools() {
|
|
49
|
+
return [...toolDefs, ...this.extraTools.map((t) => t.definition)];
|
|
50
|
+
}
|
|
51
|
+
/** Switches model; the conversation carries over. */
|
|
52
|
+
setModel(choice) {
|
|
53
|
+
this.choice = choice;
|
|
54
|
+
}
|
|
55
|
+
clear() {
|
|
56
|
+
this.messages = [];
|
|
57
|
+
}
|
|
58
|
+
cancel() {
|
|
59
|
+
this.controller?.abort();
|
|
60
|
+
}
|
|
61
|
+
/** `context` (what the user is looking at) is sent to the model but not shown in the chat. */
|
|
62
|
+
async send(text, context) {
|
|
63
|
+
this.messages.push({ role: "user", content: context ? `${text}\n\n${context}` : text });
|
|
64
|
+
this.controller = new AbortController();
|
|
65
|
+
const { signal } = this.controller;
|
|
66
|
+
try {
|
|
67
|
+
for (let step = 0; step < MAX_STEPS; step++) {
|
|
68
|
+
this.hooks.onBusy(`Pondering with ${describe(this.choice)}...`);
|
|
69
|
+
let streamed = "";
|
|
70
|
+
const result = await this.choice.provider.chat({
|
|
71
|
+
model: this.choice.model,
|
|
72
|
+
system: this.system,
|
|
73
|
+
messages: this.messages,
|
|
74
|
+
tools: this.tools,
|
|
75
|
+
signal,
|
|
76
|
+
...(this.effort ? { effort: this.effort } : {}),
|
|
77
|
+
onText: (delta) => {
|
|
78
|
+
streamed += delta;
|
|
79
|
+
this.hooks.onStream(streamed);
|
|
80
|
+
},
|
|
81
|
+
});
|
|
82
|
+
this.hooks.onStream("");
|
|
83
|
+
this.messages.push({
|
|
84
|
+
role: "assistant",
|
|
85
|
+
content: result.text,
|
|
86
|
+
toolCalls: result.toolCalls,
|
|
87
|
+
...(result.raw ? { raw: result.raw } : {}),
|
|
88
|
+
});
|
|
89
|
+
const visible = result.text.replace(/<think>[\s\S]*?<\/think>/g, "").trim();
|
|
90
|
+
if (visible)
|
|
91
|
+
this.hooks.onEntry({ kind: "assistant", text: visible });
|
|
92
|
+
if (result.stop === "refusal") {
|
|
93
|
+
this.hooks.onEntry({ kind: "error", text: "The model declined this request." });
|
|
94
|
+
this.dropUnansweredCalls();
|
|
95
|
+
return;
|
|
96
|
+
}
|
|
97
|
+
if (result.stop === "max_tokens") {
|
|
98
|
+
this.hooks.onEntry({ kind: "error", text: "The response was cut off before its tool call finished. Try asking for a smaller change." });
|
|
99
|
+
this.dropUnansweredCalls();
|
|
100
|
+
return;
|
|
101
|
+
}
|
|
102
|
+
if (result.toolCalls.length === 0)
|
|
103
|
+
return;
|
|
104
|
+
for (const call of result.toolCalls) {
|
|
105
|
+
if (signal.aborted)
|
|
106
|
+
break;
|
|
107
|
+
this.hooks.onBusy(`Running ${call.name}...`);
|
|
108
|
+
const problem = invalidArgs(call, this.tools);
|
|
109
|
+
const output = problem ?? await this.runTool(call);
|
|
110
|
+
this.messages.push({
|
|
111
|
+
role: "tool",
|
|
112
|
+
callId: call.id,
|
|
113
|
+
name: call.name,
|
|
114
|
+
content: problem ? `Error: ${problem}` : output,
|
|
115
|
+
...(problem ? { isError: true } : {}),
|
|
116
|
+
});
|
|
117
|
+
}
|
|
118
|
+
if (signal.aborted)
|
|
119
|
+
throw new Error("aborted");
|
|
120
|
+
}
|
|
121
|
+
this.hooks.onEntry({ kind: "error", text: `Stopped after ${MAX_STEPS} steps.` });
|
|
122
|
+
}
|
|
123
|
+
catch (error) {
|
|
124
|
+
this.hooks.onStream("");
|
|
125
|
+
if (signal.aborted) {
|
|
126
|
+
this.hooks.onEntry({ kind: "info", text: "Cancelled." });
|
|
127
|
+
}
|
|
128
|
+
else {
|
|
129
|
+
this.hooks.onEntry({ kind: "error", text: `${describe(this.choice)}: ${error.message}` });
|
|
130
|
+
}
|
|
131
|
+
this.dropUnansweredCalls();
|
|
132
|
+
}
|
|
133
|
+
finally {
|
|
134
|
+
this.controller = null;
|
|
135
|
+
this.hooks.onBusy(null);
|
|
136
|
+
}
|
|
137
|
+
}
|
|
138
|
+
/** Every tool call needs a result before the next request, or most APIs reject the history. */
|
|
139
|
+
dropUnansweredCalls() {
|
|
140
|
+
const answered = new Set(this.messages.flatMap((m) => (m.role === "tool" ? [m.callId] : [])));
|
|
141
|
+
for (const m of this.messages) {
|
|
142
|
+
if (m.role !== "assistant")
|
|
143
|
+
continue;
|
|
144
|
+
for (const call of m.toolCalls) {
|
|
145
|
+
if (answered.has(call.id))
|
|
146
|
+
continue;
|
|
147
|
+
this.messages.push({ role: "tool", callId: call.id, name: call.name, content: "Not run: the turn was interrupted.", isError: true });
|
|
148
|
+
answered.add(call.id);
|
|
149
|
+
}
|
|
150
|
+
}
|
|
151
|
+
}
|
|
152
|
+
async runTool(call) {
|
|
153
|
+
const { name, args } = call;
|
|
154
|
+
try {
|
|
155
|
+
switch (name) {
|
|
156
|
+
case "read_file": {
|
|
157
|
+
this.hooks.onRead(toRelative(args.path));
|
|
158
|
+
this.hooks.onEntry({ kind: "tool", text: `Read ${args.path}` });
|
|
159
|
+
return await readFile(args.path);
|
|
160
|
+
}
|
|
161
|
+
case "list_files":
|
|
162
|
+
case "get_file_tree": {
|
|
163
|
+
this.hooks.onEntry({ kind: "tool", text: `Listed ${args.path ?? "."}` });
|
|
164
|
+
return fileTree(args.path ?? ".", 4);
|
|
165
|
+
}
|
|
166
|
+
case "edit_file":
|
|
167
|
+
case "write_file": {
|
|
168
|
+
const change = name === "edit_file"
|
|
169
|
+
? await proposeEdit(args.path, args.old_string, args.new_string)
|
|
170
|
+
: await proposeWrite(args.path, args.content ?? "");
|
|
171
|
+
return await this.reviewChange(change);
|
|
172
|
+
}
|
|
173
|
+
case "run_command": {
|
|
174
|
+
const ok = await this.hooks.approve({ kind: "command", command: args.command });
|
|
175
|
+
if (!ok) {
|
|
176
|
+
this.hooks.onEntry({ kind: "tool", text: `Denied: ${args.command}` });
|
|
177
|
+
return "User denied permission to run this command.";
|
|
178
|
+
}
|
|
179
|
+
this.hooks.onEntry({ kind: "tool", text: `Ran: ${args.command}` });
|
|
180
|
+
const output = await runCommand(args.command);
|
|
181
|
+
return output.trim() || "(no output)";
|
|
182
|
+
}
|
|
183
|
+
default: {
|
|
184
|
+
const extra = this.extraTools.find((t) => t.definition.name === name);
|
|
185
|
+
if (!extra)
|
|
186
|
+
return `Unknown tool: ${name}`;
|
|
187
|
+
if (!extra.readOnly && !(await this.hooks.approve({ kind: "tool", name, args }))) {
|
|
188
|
+
this.hooks.onEntry({ kind: "tool", text: `Denied: ${name}` });
|
|
189
|
+
return "User denied permission to run this tool.";
|
|
190
|
+
}
|
|
191
|
+
this.hooks.onEntry({ kind: "tool", text: `${extra.source}: ${name}${args.path ? ` ${args.path}` : ""}` });
|
|
192
|
+
return String(await extra.run(args));
|
|
193
|
+
}
|
|
194
|
+
}
|
|
195
|
+
}
|
|
196
|
+
catch (error) {
|
|
197
|
+
this.hooks.onEntry({ kind: "error", text: `${name}: ${error.message}` });
|
|
198
|
+
return `Error: ${error.message}`;
|
|
199
|
+
}
|
|
200
|
+
}
|
|
201
|
+
async reviewChange(change) {
|
|
202
|
+
this.hooks.onChange(change, "pending");
|
|
203
|
+
const ok = await this.hooks.approve({ kind: "change", change });
|
|
204
|
+
if (!ok) {
|
|
205
|
+
this.hooks.onChange(change, "rejected");
|
|
206
|
+
this.hooks.onEntry({ kind: "tool", text: `Rejected edit to ${change.path}` });
|
|
207
|
+
return "User rejected this change. Ask them what they would like instead.";
|
|
208
|
+
}
|
|
209
|
+
const result = await applyChange(change);
|
|
210
|
+
this.hooks.onChange(change, "applied");
|
|
211
|
+
this.hooks.onEntry({ kind: "tool", text: result });
|
|
212
|
+
return result;
|
|
213
|
+
}
|
|
214
|
+
}
|
|
@@ -0,0 +1,115 @@
|
|
|
1
|
+
import path from "path";
|
|
2
|
+
import fs from "fs/promises";
|
|
3
|
+
import { readdirSync } from "fs";
|
|
4
|
+
import { exec } from "child_process";
|
|
5
|
+
export const IGNORED = new Set(["node_modules", ".git", "dist", "build", ".next", "__pycache__", ".venv"]);
|
|
6
|
+
function resolveInProject(filePath) {
|
|
7
|
+
const fullPath = path.resolve(process.cwd(), filePath);
|
|
8
|
+
const rel = path.relative(process.cwd(), fullPath);
|
|
9
|
+
if (rel.startsWith("..") || path.isAbsolute(rel)) {
|
|
10
|
+
throw new Error(`${filePath} is outside the project folder`);
|
|
11
|
+
}
|
|
12
|
+
return fullPath;
|
|
13
|
+
}
|
|
14
|
+
export function toRelative(filePath) {
|
|
15
|
+
return path.relative(process.cwd(), path.resolve(process.cwd(), filePath)).replace(/\\/g, "/");
|
|
16
|
+
}
|
|
17
|
+
export async function readFile(filepath) {
|
|
18
|
+
try {
|
|
19
|
+
const fullPath = path.resolve(process.cwd(), filepath);
|
|
20
|
+
const content = await fs.readFile(fullPath, "utf-8");
|
|
21
|
+
return content;
|
|
22
|
+
}
|
|
23
|
+
catch {
|
|
24
|
+
return "Error: File not found or could not be read.";
|
|
25
|
+
}
|
|
26
|
+
}
|
|
27
|
+
;
|
|
28
|
+
export function runCommand(command) {
|
|
29
|
+
// Async so the UI keeps rendering while the command runs.
|
|
30
|
+
return new Promise((resolve) => {
|
|
31
|
+
exec(command, { cwd: process.cwd(), timeout: 120_000, maxBuffer: 10 * 1024 * 1024 }, (error, stdout, stderr) => {
|
|
32
|
+
if (error) {
|
|
33
|
+
const reason = error.killed ? "timed out after 2 minutes" : `Exit Code ${error.code ?? "?"}`;
|
|
34
|
+
resolve(`Error (${reason}): ${stderr || error.message}${stdout ? `
|
|
35
|
+
${stdout}` : ""}`);
|
|
36
|
+
}
|
|
37
|
+
else {
|
|
38
|
+
resolve(stdout);
|
|
39
|
+
}
|
|
40
|
+
});
|
|
41
|
+
});
|
|
42
|
+
}
|
|
43
|
+
export function fileTree(dir = ".", maxDepth = 3) {
|
|
44
|
+
const lines = [];
|
|
45
|
+
const walk = (current, depth, prefix) => {
|
|
46
|
+
if (depth > maxDepth)
|
|
47
|
+
return;
|
|
48
|
+
let entries;
|
|
49
|
+
try {
|
|
50
|
+
entries = readdirSync(current, { withFileTypes: true })
|
|
51
|
+
.filter((e) => !IGNORED.has(e.name))
|
|
52
|
+
.sort((a, b) => Number(b.isDirectory()) - Number(a.isDirectory()) || a.name.localeCompare(b.name));
|
|
53
|
+
}
|
|
54
|
+
catch {
|
|
55
|
+
return;
|
|
56
|
+
}
|
|
57
|
+
for (const entry of entries) {
|
|
58
|
+
lines.push(`${prefix}${entry.name}${entry.isDirectory() ? "/" : ""}`);
|
|
59
|
+
if (entry.isDirectory())
|
|
60
|
+
walk(path.join(current, entry.name), depth + 1, prefix + " ");
|
|
61
|
+
}
|
|
62
|
+
};
|
|
63
|
+
walk(path.resolve(process.cwd(), dir), 1, "");
|
|
64
|
+
return lines.join("\n") || "(empty)";
|
|
65
|
+
}
|
|
66
|
+
function cleanContent(content) {
|
|
67
|
+
return content
|
|
68
|
+
.replace(/\\n/g, "\n")
|
|
69
|
+
.replace(/^```[a-z]*\n/i, "")
|
|
70
|
+
.replace(/\n```$/g, "")
|
|
71
|
+
.trim() + "\n";
|
|
72
|
+
}
|
|
73
|
+
async function readOrEmpty(fullPath) {
|
|
74
|
+
try {
|
|
75
|
+
return await fs.readFile(fullPath, "utf-8");
|
|
76
|
+
}
|
|
77
|
+
catch {
|
|
78
|
+
return null;
|
|
79
|
+
}
|
|
80
|
+
}
|
|
81
|
+
/** Works out what write_file would do, without touching the disk. */
|
|
82
|
+
export async function proposeWrite(filePath, content) {
|
|
83
|
+
const fullPath = resolveInProject(filePath);
|
|
84
|
+
const before = await readOrEmpty(fullPath);
|
|
85
|
+
return { path: toRelative(filePath), before: before ?? "", after: cleanContent(content), isNew: before === null };
|
|
86
|
+
}
|
|
87
|
+
/** Works out what edit_file would do, without touching the disk. */
|
|
88
|
+
export async function proposeEdit(filePath, oldString, newString) {
|
|
89
|
+
const fullPath = resolveInProject(filePath);
|
|
90
|
+
const before = await readOrEmpty(fullPath);
|
|
91
|
+
if (before === null)
|
|
92
|
+
throw new Error(`${filePath} does not exist. Use write_file to create it.`);
|
|
93
|
+
// Models often send LF while Windows files use CRLF, so match on normalized text.
|
|
94
|
+
const eol = before.includes("\r\n") ? "\r\n" : "\n";
|
|
95
|
+
const normalized = before.replace(/\r\n/g, "\n");
|
|
96
|
+
const target = oldString.replace(/\r\n/g, "\n");
|
|
97
|
+
const count = target ? normalized.split(target).length - 1 : 0;
|
|
98
|
+
if (count === 0)
|
|
99
|
+
throw new Error("old_string was not found in the file. Read the file and copy the text exactly.");
|
|
100
|
+
if (count > 1)
|
|
101
|
+
throw new Error(`old_string appears ${count} times. Include more surrounding lines so it is unique.`);
|
|
102
|
+
const after = normalized.replace(target, () => newString.replace(/\r\n/g, "\n")).replace(/\n/g, eol);
|
|
103
|
+
return { path: toRelative(filePath), before, after, isNew: false };
|
|
104
|
+
}
|
|
105
|
+
export async function applyChange(change) {
|
|
106
|
+
try {
|
|
107
|
+
const fullPath = resolveInProject(change.path);
|
|
108
|
+
await fs.mkdir(path.dirname(fullPath), { recursive: true });
|
|
109
|
+
await fs.writeFile(fullPath, change.after, "utf-8");
|
|
110
|
+
return `Successfully ${change.isNew ? "created" : "updated"} ${change.path}`;
|
|
111
|
+
}
|
|
112
|
+
catch (error) {
|
|
113
|
+
return `Failed to write file: ${error.message}`;
|
|
114
|
+
}
|
|
115
|
+
}
|
|
@@ -0,0 +1,36 @@
|
|
|
1
|
+
import { existsSync, mkdirSync, readFileSync, writeFileSync } from "fs";
|
|
2
|
+
import os from "os";
|
|
3
|
+
import path from "path";
|
|
4
|
+
export const NOU_HOME = path.join(os.homedir(), ".nou");
|
|
5
|
+
const CONFIG_PATH = path.join(NOU_HOME, "config.json");
|
|
6
|
+
export const EFFORTS = ["low", "medium", "high", "xhigh", "max"];
|
|
7
|
+
export const DEFAULT_SETTINGS = {
|
|
8
|
+
model: "",
|
|
9
|
+
effort: "high",
|
|
10
|
+
followMode: true,
|
|
11
|
+
autoApproveEdits: false,
|
|
12
|
+
autoApproveCommands: false,
|
|
13
|
+
gitContext: true,
|
|
14
|
+
plugins: {},
|
|
15
|
+
};
|
|
16
|
+
export function loadSettings() {
|
|
17
|
+
try {
|
|
18
|
+
const saved = JSON.parse(readFileSync(CONFIG_PATH, "utf-8"));
|
|
19
|
+
return { ...DEFAULT_SETTINGS, ...saved, plugins: { ...saved.plugins } };
|
|
20
|
+
}
|
|
21
|
+
catch {
|
|
22
|
+
return { ...DEFAULT_SETTINGS, plugins: {} };
|
|
23
|
+
}
|
|
24
|
+
}
|
|
25
|
+
export function saveSettings(patch) {
|
|
26
|
+
const next = { ...loadSettings(), ...patch };
|
|
27
|
+
try {
|
|
28
|
+
if (!existsSync(NOU_HOME))
|
|
29
|
+
mkdirSync(NOU_HOME, { recursive: true });
|
|
30
|
+
writeFileSync(CONFIG_PATH, JSON.stringify(next, null, 2));
|
|
31
|
+
}
|
|
32
|
+
catch {
|
|
33
|
+
// settings are best effort; the session still uses them
|
|
34
|
+
}
|
|
35
|
+
return next;
|
|
36
|
+
}
|
|
@@ -0,0 +1,53 @@
|
|
|
1
|
+
import { highlight } from "cli-highlight";
|
|
2
|
+
import fs from "fs/promises";
|
|
3
|
+
import path from "path";
|
|
4
|
+
import { fileTree, readFile } from "../commands/index.js";
|
|
5
|
+
import { complete } from "../providers/index.js";
|
|
6
|
+
import * as p from "@clack/prompts";
|
|
7
|
+
export const sugarfy = (str) => {
|
|
8
|
+
return highlight(str, { language: "typescript", ignoreIllegals: true });
|
|
9
|
+
};
|
|
10
|
+
export async function generateContext(contextPath, choice) {
|
|
11
|
+
const s = p.spinner();
|
|
12
|
+
s.start("Analyzing project structure to create NOU.md...");
|
|
13
|
+
const dirStructure = fileTree(".", 2);
|
|
14
|
+
const packageJson = await readFile("package.json");
|
|
15
|
+
let content;
|
|
16
|
+
try {
|
|
17
|
+
content = await complete(choice, `I am starting a new project. Based on these files: \n${dirStructure}\n\nAnd this package.json: \n${packageJson}\n\nPlease generate a NOU.md file that summarizes:
|
|
18
|
+
1. What this project is.
|
|
19
|
+
2. Commands to build, test, and run it.
|
|
20
|
+
3. Tech stack used.
|
|
21
|
+
Output ONLY the markdown content.`);
|
|
22
|
+
}
|
|
23
|
+
catch (error) {
|
|
24
|
+
s.stop(`Skipped NOU.md: ${error.message}`);
|
|
25
|
+
return;
|
|
26
|
+
}
|
|
27
|
+
await fs.writeFile(contextPath, content);
|
|
28
|
+
s.stop("NOU.md generated! Nou is now context-aware.");
|
|
29
|
+
}
|
|
30
|
+
;
|
|
31
|
+
export async function ensureProjectContext(choice) {
|
|
32
|
+
const contextPath = path.join(process.cwd(), "NOU.md");
|
|
33
|
+
try {
|
|
34
|
+
await fs.access(contextPath);
|
|
35
|
+
const nonEmpty = (await fs.readFile(contextPath, "utf-8")).trim().length > 0;
|
|
36
|
+
if (!nonEmpty) {
|
|
37
|
+
await generateContext(contextPath, choice);
|
|
38
|
+
}
|
|
39
|
+
}
|
|
40
|
+
catch {
|
|
41
|
+
await generateContext(contextPath, choice);
|
|
42
|
+
}
|
|
43
|
+
}
|
|
44
|
+
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
|
+
}
|
|
50
|
+
catch (err) {
|
|
51
|
+
return "";
|
|
52
|
+
}
|
|
53
|
+
}
|
|
@@ -0,0 +1,94 @@
|
|
|
1
|
+
import { execFileSync } from "child_process";
|
|
2
|
+
import path from "path";
|
|
3
|
+
function git(args) {
|
|
4
|
+
return execFileSync("git", args, { cwd: process.cwd(), encoding: "utf-8", stdio: ["ignore", "pipe", "ignore"], maxBuffer: 20 * 1024 * 1024 });
|
|
5
|
+
}
|
|
6
|
+
function tryGit(args) {
|
|
7
|
+
try {
|
|
8
|
+
return git(args);
|
|
9
|
+
}
|
|
10
|
+
catch {
|
|
11
|
+
return null;
|
|
12
|
+
}
|
|
13
|
+
}
|
|
14
|
+
let rootCache;
|
|
15
|
+
export function repoRoot() {
|
|
16
|
+
if (rootCache === undefined)
|
|
17
|
+
rootCache = tryGit(["rev-parse", "--show-toplevel"])?.trim() || null;
|
|
18
|
+
return rootCache;
|
|
19
|
+
}
|
|
20
|
+
export const isRepo = () => repoRoot() !== null;
|
|
21
|
+
/** Repo-root-relative path (what git prints) -> cwd-relative path (what the UI uses). */
|
|
22
|
+
function fromRoot(p) {
|
|
23
|
+
return path.relative(process.cwd(), path.join(repoRoot(), p)).replace(/\\/g, "/");
|
|
24
|
+
}
|
|
25
|
+
function toRoot(p) {
|
|
26
|
+
return path.relative(repoRoot(), path.resolve(process.cwd(), p)).replace(/\\/g, "/");
|
|
27
|
+
}
|
|
28
|
+
export function status() {
|
|
29
|
+
const result = new Map();
|
|
30
|
+
if (!isRepo())
|
|
31
|
+
return result;
|
|
32
|
+
const out = tryGit(["status", "--porcelain=v1", "-z", "-uall"]);
|
|
33
|
+
if (!out)
|
|
34
|
+
return result;
|
|
35
|
+
const parts = out.split("\0");
|
|
36
|
+
for (let i = 0; i < parts.length; i++) {
|
|
37
|
+
const entry = parts[i];
|
|
38
|
+
if (entry.length < 4)
|
|
39
|
+
continue;
|
|
40
|
+
const xy = entry.slice(0, 2);
|
|
41
|
+
const file = fromRoot(entry.slice(3));
|
|
42
|
+
if (xy[0] === "R" || xy[0] === "C")
|
|
43
|
+
i++; // the next part is the old name
|
|
44
|
+
const code = xy === "??" ? "?"
|
|
45
|
+
: xy.includes("U") ? "U"
|
|
46
|
+
: xy.includes("D") ? "D"
|
|
47
|
+
: xy.includes("A") ? "A"
|
|
48
|
+
: xy[0] === "R" ? "R"
|
|
49
|
+
: "M";
|
|
50
|
+
if (!file.startsWith(".."))
|
|
51
|
+
result.set(file, code);
|
|
52
|
+
}
|
|
53
|
+
return result;
|
|
54
|
+
}
|
|
55
|
+
/** The file as of the last commit, or null if it isn't tracked. */
|
|
56
|
+
export function headContent(file) {
|
|
57
|
+
if (!isRepo())
|
|
58
|
+
return null;
|
|
59
|
+
return tryGit(["show", `HEAD:${toRoot(file)}`]);
|
|
60
|
+
}
|
|
61
|
+
/** Working copy vs HEAD as a FileChange, for the side-by-side diff view. */
|
|
62
|
+
export function diffAgainstHead(file, current) {
|
|
63
|
+
const before = headContent(file);
|
|
64
|
+
if (before === current.replace(/\r\n/g, "\n") || before === current)
|
|
65
|
+
return null;
|
|
66
|
+
return { path: file, before: before ?? "", after: current, isNew: before === null };
|
|
67
|
+
}
|
|
68
|
+
export function branch() {
|
|
69
|
+
return tryGit(["branch", "--show-current"])?.trim() || "(detached)";
|
|
70
|
+
}
|
|
71
|
+
export function log(count = 10) {
|
|
72
|
+
return tryGit(["log", `-${Math.min(Math.max(count, 1), 50)}`, "--pretty=format:%h %ad %an: %s", "--date=short"])?.trim() || "(no commits)";
|
|
73
|
+
}
|
|
74
|
+
export function diffText(file, staged = false) {
|
|
75
|
+
const args = ["diff", ...(staged ? ["--staged"] : ["HEAD"]), "--stat", "--patch"];
|
|
76
|
+
if (file)
|
|
77
|
+
args.push("--", toRoot(file));
|
|
78
|
+
const out = tryGit(args) ?? tryGit(["diff", ...(file ? ["--", toRoot(file)] : [])]) ?? "";
|
|
79
|
+
const untracked = file && status().get(file) === "?" ? `${file} is untracked (new file, not in git yet).` : "";
|
|
80
|
+
const text = (out.trim() || untracked || "No changes.");
|
|
81
|
+
return text.length > 60_000 ? `${text.slice(0, 60_000)}\n... (diff truncated)` : text;
|
|
82
|
+
}
|
|
83
|
+
/** A compact snapshot for the system prompt. */
|
|
84
|
+
export function summary() {
|
|
85
|
+
if (!isRepo())
|
|
86
|
+
return null;
|
|
87
|
+
const changes = [...status()].map(([file, code]) => ` ${code} ${file}`);
|
|
88
|
+
return `Git: use the git_status, git_diff and git_log tools (they need no approval) rather than running git with run_command.
|
|
89
|
+
Branch: ${branch()}
|
|
90
|
+
Uncommitted changes (at session start):
|
|
91
|
+
${changes.length ? changes.slice(0, 40).join("\n") + (changes.length > 40 ? `\n ... ${changes.length - 40} more` : "") : " (clean)"}
|
|
92
|
+
Recent commits:
|
|
93
|
+
${log(5).split("\n").map((l) => ` ${l}`).join("\n")}`;
|
|
94
|
+
}
|