nou-cli 0.1.0 → 0.2.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/README.md +140 -31
- package/dist/grammars/tree-sitter-bash.wasm +0 -0
- package/dist/grammars/tree-sitter-c.wasm +0 -0
- package/dist/grammars/tree-sitter-css.wasm +0 -0
- package/dist/grammars/tree-sitter-go.wasm +0 -0
- package/dist/grammars/tree-sitter-html.wasm +0 -0
- package/dist/grammars/tree-sitter-java.wasm +0 -0
- package/dist/grammars/tree-sitter-javascript.wasm +0 -0
- package/dist/grammars/tree-sitter-json.wasm +0 -0
- package/dist/grammars/tree-sitter-python.wasm +0 -0
- package/dist/grammars/tree-sitter-rust.wasm +0 -0
- package/dist/grammars/tree-sitter-toml.wasm +0 -0
- package/dist/grammars/tree-sitter-tsx.wasm +0 -0
- package/dist/grammars/tree-sitter-typescript.wasm +0 -0
- package/dist/grammars/tree-sitter-yaml.wasm +0 -0
- package/dist/index.js +55 -6
- package/dist/src/agent/index.js +37 -5
- package/dist/src/commands/index.js +16 -6
- package/dist/src/config/index.js +5 -2
- package/dist/src/diagnostics/index.js +235 -0
- package/dist/src/diagnostics/process.js +64 -0
- package/dist/src/diagnostics/providers.js +103 -0
- package/dist/src/diagnostics/types.js +1 -0
- package/dist/src/env.js +12 -0
- package/dist/src/history/index.js +113 -0
- package/dist/src/lsp/client.js +169 -0
- package/dist/src/lsp/index.js +169 -0
- package/dist/src/plugins/index.js +94 -3
- package/dist/src/providers/ids.js +3 -0
- package/dist/src/providers/ollama.js +2 -1
- package/dist/src/providers/openai.js +3 -2
- package/dist/src/syntax/index.js +240 -0
- package/dist/src/ui/App.js +268 -29
- package/dist/src/ui/Chat.js +11 -4
- package/dist/src/ui/ChatInput.js +128 -0
- package/dist/src/ui/CodeView.js +21 -5
- package/dist/src/ui/Editor.js +90 -21
- package/dist/src/ui/FileTree.js +6 -4
- package/dist/src/ui/Header.js +2 -2
- package/dist/src/ui/ProblemsView.js +71 -0
- package/dist/src/ui/Settings.js +30 -15
- package/dist/src/ui/text.js +6 -1
- package/dist/src/update/index.js +102 -0
- package/package.json +9 -4
|
@@ -0,0 +1,169 @@
|
|
|
1
|
+
import { spawn } from "child_process";
|
|
2
|
+
import path from "path";
|
|
3
|
+
import { fileURLToPath, pathToFileURL } from "url";
|
|
4
|
+
export const toUri = (file) => pathToFileURL(path.resolve(process.cwd(), file)).href;
|
|
5
|
+
/** file:// URI -> path relative to the project, with forward slashes. */
|
|
6
|
+
export function fromUri(uri) {
|
|
7
|
+
try {
|
|
8
|
+
return path.relative(process.cwd(), fileURLToPath(uri)).replace(/\\/g, "/");
|
|
9
|
+
}
|
|
10
|
+
catch {
|
|
11
|
+
return uri;
|
|
12
|
+
}
|
|
13
|
+
}
|
|
14
|
+
/** A minimal Language Server Protocol client over stdio (JSON-RPC with Content-Length framing). */
|
|
15
|
+
export class LspClient {
|
|
16
|
+
name;
|
|
17
|
+
child;
|
|
18
|
+
buffer = Buffer.alloc(0);
|
|
19
|
+
nextId = 1;
|
|
20
|
+
pending = new Map();
|
|
21
|
+
handlers = new Map();
|
|
22
|
+
versions = new Map();
|
|
23
|
+
texts = new Map();
|
|
24
|
+
ready;
|
|
25
|
+
dead = false;
|
|
26
|
+
/** Why the server stopped, if it did (shown in settings). */
|
|
27
|
+
error = null;
|
|
28
|
+
constructor(name, command, args, languageIds) {
|
|
29
|
+
this.name = name;
|
|
30
|
+
// npm-installed servers are .cmd shims on Windows, which need a shell. The command line is ours, not user text.
|
|
31
|
+
const shell = process.platform === "win32" && !/\.exe$/i.test(command);
|
|
32
|
+
// With a shell, quote the path so folders with spaces (C:\Program Files\...) work.
|
|
33
|
+
this.child = spawn(shell ? `"${command}"` : command, args, { cwd: process.cwd(), shell, windowsHide: true, stdio: ["pipe", "pipe", "ignore"] });
|
|
34
|
+
this.child.on("exit", () => this.fail(new Error(`${name} exited`)));
|
|
35
|
+
this.child.on("error", (error) => this.fail(error));
|
|
36
|
+
this.child.stdout.on("data", (chunk) => this.onData(chunk));
|
|
37
|
+
this.languageIds = languageIds;
|
|
38
|
+
this.ready = this.request("initialize", {
|
|
39
|
+
processId: process.pid,
|
|
40
|
+
rootUri: pathToFileURL(process.cwd()).href,
|
|
41
|
+
workspaceFolders: [{ uri: pathToFileURL(process.cwd()).href, name: path.basename(process.cwd()) }],
|
|
42
|
+
capabilities: {
|
|
43
|
+
textDocument: {
|
|
44
|
+
synchronization: { didSave: true },
|
|
45
|
+
publishDiagnostics: { relatedInformation: false },
|
|
46
|
+
hover: { contentFormat: ["plaintext", "markdown"] },
|
|
47
|
+
definition: { linkSupport: false },
|
|
48
|
+
},
|
|
49
|
+
workspace: { workspaceFolders: true, configuration: true },
|
|
50
|
+
},
|
|
51
|
+
}, 30_000).then(() => this.notify("initialized", {}));
|
|
52
|
+
// A server that can't start shouldn't take Nou down; mark it dead and stop it.
|
|
53
|
+
this.ready.catch((error) => {
|
|
54
|
+
this.error = error.message;
|
|
55
|
+
this.fail(error);
|
|
56
|
+
this.child.kill();
|
|
57
|
+
});
|
|
58
|
+
// Servers ask for settings; answering with nulls means "use your defaults".
|
|
59
|
+
this.onRequest("workspace/configuration", (params) => (params?.items ?? []).map(() => null));
|
|
60
|
+
this.onRequest("client/registerCapability", () => null);
|
|
61
|
+
this.onRequest("window/workDoneProgress/create", () => null);
|
|
62
|
+
}
|
|
63
|
+
languageIds;
|
|
64
|
+
requestHandlers = new Map();
|
|
65
|
+
onRequest(method, handler) {
|
|
66
|
+
this.requestHandlers.set(method, handler);
|
|
67
|
+
}
|
|
68
|
+
on(method, handler) {
|
|
69
|
+
this.handlers.set(method, handler);
|
|
70
|
+
}
|
|
71
|
+
fail(error) {
|
|
72
|
+
if (this.dead)
|
|
73
|
+
return;
|
|
74
|
+
this.dead = true;
|
|
75
|
+
this.error ??= error.message;
|
|
76
|
+
for (const p of this.pending.values()) {
|
|
77
|
+
clearTimeout(p.timer);
|
|
78
|
+
p.reject(error);
|
|
79
|
+
}
|
|
80
|
+
this.pending.clear();
|
|
81
|
+
}
|
|
82
|
+
send(message) {
|
|
83
|
+
if (this.dead)
|
|
84
|
+
return;
|
|
85
|
+
const body = Buffer.from(JSON.stringify({ jsonrpc: "2.0", ...message }), "utf-8");
|
|
86
|
+
this.child.stdin.write(`Content-Length: ${body.length}\r\n\r\n`);
|
|
87
|
+
this.child.stdin.write(body);
|
|
88
|
+
}
|
|
89
|
+
onData(chunk) {
|
|
90
|
+
this.buffer = Buffer.concat([this.buffer, chunk]);
|
|
91
|
+
for (;;) {
|
|
92
|
+
const headerEnd = this.buffer.indexOf("\r\n\r\n");
|
|
93
|
+
if (headerEnd < 0)
|
|
94
|
+
return;
|
|
95
|
+
const length = Number(/Content-Length: (\d+)/i.exec(this.buffer.subarray(0, headerEnd).toString())?.[1]);
|
|
96
|
+
if (!length || this.buffer.length < headerEnd + 4 + length)
|
|
97
|
+
return;
|
|
98
|
+
const body = this.buffer.subarray(headerEnd + 4, headerEnd + 4 + length).toString("utf-8");
|
|
99
|
+
this.buffer = this.buffer.subarray(headerEnd + 4 + length);
|
|
100
|
+
try {
|
|
101
|
+
this.dispatch(JSON.parse(body));
|
|
102
|
+
}
|
|
103
|
+
catch {
|
|
104
|
+
// ignore malformed messages
|
|
105
|
+
}
|
|
106
|
+
}
|
|
107
|
+
}
|
|
108
|
+
dispatch(message) {
|
|
109
|
+
if (message.id !== undefined && message.method) {
|
|
110
|
+
// A request from the server.
|
|
111
|
+
const handler = this.requestHandlers.get(message.method);
|
|
112
|
+
this.send({ id: message.id, result: handler ? handler(message.params) : null });
|
|
113
|
+
}
|
|
114
|
+
else if (message.id !== undefined) {
|
|
115
|
+
const pending = this.pending.get(message.id);
|
|
116
|
+
if (!pending)
|
|
117
|
+
return;
|
|
118
|
+
this.pending.delete(message.id);
|
|
119
|
+
clearTimeout(pending.timer);
|
|
120
|
+
if (message.error)
|
|
121
|
+
pending.reject(new Error(message.error.message));
|
|
122
|
+
else
|
|
123
|
+
pending.resolve(message.result);
|
|
124
|
+
}
|
|
125
|
+
else if (message.method) {
|
|
126
|
+
this.handlers.get(message.method)?.(message.params);
|
|
127
|
+
}
|
|
128
|
+
}
|
|
129
|
+
request(method, params, timeout = 15_000) {
|
|
130
|
+
if (this.dead)
|
|
131
|
+
return Promise.reject(new Error(`${this.name} is not running`));
|
|
132
|
+
const id = this.nextId++;
|
|
133
|
+
return new Promise((resolve, reject) => {
|
|
134
|
+
const timer = setTimeout(() => {
|
|
135
|
+
this.pending.delete(id);
|
|
136
|
+
reject(new Error(`${this.name}: ${method} timed out`));
|
|
137
|
+
}, timeout);
|
|
138
|
+
this.pending.set(id, { resolve, reject, timer });
|
|
139
|
+
this.send({ id, method, params });
|
|
140
|
+
});
|
|
141
|
+
}
|
|
142
|
+
notify(method, params) {
|
|
143
|
+
this.send({ method, params });
|
|
144
|
+
}
|
|
145
|
+
/** Opens or updates a document with the given (possibly unsaved) text. Returns false if it was already current. */
|
|
146
|
+
async sync(file, text) {
|
|
147
|
+
await this.ready;
|
|
148
|
+
const uri = toUri(file);
|
|
149
|
+
if (this.texts.get(uri) === text)
|
|
150
|
+
return false;
|
|
151
|
+
const version = (this.versions.get(uri) ?? 0) + 1;
|
|
152
|
+
this.versions.set(uri, version);
|
|
153
|
+
if (!this.texts.has(uri)) {
|
|
154
|
+
const languageId = this.languageIds[path.extname(file).toLowerCase()] ?? "plaintext";
|
|
155
|
+
this.notify("textDocument/didOpen", { textDocument: { uri, languageId, version, text } });
|
|
156
|
+
}
|
|
157
|
+
else {
|
|
158
|
+
this.notify("textDocument/didChange", { textDocument: { uri, version }, contentChanges: [{ text }] });
|
|
159
|
+
}
|
|
160
|
+
this.texts.set(uri, text);
|
|
161
|
+
return true;
|
|
162
|
+
}
|
|
163
|
+
stop() {
|
|
164
|
+
if (this.dead)
|
|
165
|
+
return;
|
|
166
|
+
this.request("shutdown", null, 2000).then(() => this.notify("exit", null)).catch(() => { }).finally(() => this.child.kill());
|
|
167
|
+
this.dead = true;
|
|
168
|
+
}
|
|
169
|
+
}
|
|
@@ -0,0 +1,169 @@
|
|
|
1
|
+
import { existsSync } from "fs";
|
|
2
|
+
import path from "path";
|
|
3
|
+
import { LspClient, fromUri, toUri } from "./client.js";
|
|
4
|
+
/** Servers Nou knows how to talk to. Each is used only if it's installed. Users can add more in settings. */
|
|
5
|
+
export const KNOWN_SERVERS = [
|
|
6
|
+
{
|
|
7
|
+
id: "typescript", name: "TypeScript", command: "typescript-language-server", args: ["--stdio"],
|
|
8
|
+
languages: { ".ts": "typescript", ".mts": "typescript", ".cts": "typescript", ".tsx": "typescriptreact", ".js": "javascript", ".mjs": "javascript", ".cjs": "javascript", ".jsx": "javascriptreact" },
|
|
9
|
+
},
|
|
10
|
+
{ id: "pyright", name: "Pyright", command: "pyright-langserver", args: ["--stdio"], languages: { ".py": "python", ".pyi": "python" } },
|
|
11
|
+
{ id: "rust-analyzer", name: "rust-analyzer", command: "rust-analyzer", args: [], languages: { ".rs": "rust" } },
|
|
12
|
+
{ id: "gopls", name: "gopls", command: "gopls", args: [], languages: { ".go": "go" } },
|
|
13
|
+
{ id: "clangd", name: "clangd", command: "clangd", args: [], languages: { ".c": "c", ".h": "c", ".cpp": "cpp", ".cc": "cpp", ".hpp": "cpp" } },
|
|
14
|
+
];
|
|
15
|
+
/** Finds a program on PATH (or in the project's node_modules/.bin), on Windows, macOS and Linux. */
|
|
16
|
+
export function findExecutable(command) {
|
|
17
|
+
const dirs = [path.join(process.cwd(), "node_modules", ".bin"), ...(process.env.PATH ?? "").split(path.delimiter)].filter(Boolean);
|
|
18
|
+
const exts = process.platform === "win32" ? (process.env.PATHEXT ?? ".EXE;.CMD;.BAT").split(";").map((e) => e.toLowerCase()) : [""];
|
|
19
|
+
for (const dir of dirs) {
|
|
20
|
+
for (const ext of exts) {
|
|
21
|
+
const full = path.join(dir, command + ext);
|
|
22
|
+
if (existsSync(full))
|
|
23
|
+
return full;
|
|
24
|
+
}
|
|
25
|
+
}
|
|
26
|
+
return null;
|
|
27
|
+
}
|
|
28
|
+
const SEVERITY = { 1: "error", 2: "warning", 3: "info", 4: "info" };
|
|
29
|
+
/** Starts language servers on demand and exposes problems, hover and go-to-definition. */
|
|
30
|
+
export class LspManager {
|
|
31
|
+
servers;
|
|
32
|
+
clients = new Map();
|
|
33
|
+
failures = new Map();
|
|
34
|
+
/** Servers that failed to start, with the reason. */
|
|
35
|
+
broken = new Map();
|
|
36
|
+
/** file (normalized, project-relative) -> latest published problems. Servers format URIs differently (e.g. c%3A), so never key by raw URI. */
|
|
37
|
+
published = new Map();
|
|
38
|
+
waiters = new Map();
|
|
39
|
+
constructor(servers = KNOWN_SERVERS) {
|
|
40
|
+
this.servers = servers;
|
|
41
|
+
}
|
|
42
|
+
serverFor(file) {
|
|
43
|
+
const ext = path.extname(file).toLowerCase();
|
|
44
|
+
return this.servers.find((s) => s.languages[ext] && findExecutable(s.command));
|
|
45
|
+
}
|
|
46
|
+
/** Installed servers, for the settings screen. */
|
|
47
|
+
installed() {
|
|
48
|
+
return this.servers.filter((s) => findExecutable(s.command));
|
|
49
|
+
}
|
|
50
|
+
client(file) {
|
|
51
|
+
const server = this.serverFor(file);
|
|
52
|
+
if (!server)
|
|
53
|
+
return null;
|
|
54
|
+
const existing = this.clients.get(server.id);
|
|
55
|
+
if (existing && !existing.dead)
|
|
56
|
+
return existing;
|
|
57
|
+
if (this.broken.has(server.id) || (this.failures.get(server.id) ?? 0) >= 3)
|
|
58
|
+
return null; // stop retrying
|
|
59
|
+
if (existing?.dead)
|
|
60
|
+
this.failures.set(server.id, (this.failures.get(server.id) ?? 0) + 1);
|
|
61
|
+
const client = new LspClient(server.name, findExecutable(server.command), server.args, server.languages);
|
|
62
|
+
client.on("textDocument/publishDiagnostics", (params) => {
|
|
63
|
+
const file = fromUri(params.uri);
|
|
64
|
+
this.published.set(file.toLowerCase(), (params.diagnostics ?? []).map((d) => ({
|
|
65
|
+
file,
|
|
66
|
+
line: d.range.start.line + 1,
|
|
67
|
+
col: d.range.start.character + 1,
|
|
68
|
+
endLine: d.range.end.line + 1,
|
|
69
|
+
endCol: d.range.end.character + 1,
|
|
70
|
+
severity: SEVERITY[d.severity ?? 1] ?? "error",
|
|
71
|
+
message: String(d.message).split("\n")[0],
|
|
72
|
+
source: server.id,
|
|
73
|
+
...(d.code !== undefined ? { code: String(d.code) } : {}),
|
|
74
|
+
})));
|
|
75
|
+
for (const wake of this.waiters.get(file.toLowerCase()) ?? [])
|
|
76
|
+
wake();
|
|
77
|
+
this.waiters.delete(file.toLowerCase());
|
|
78
|
+
});
|
|
79
|
+
// If it can't even start (e.g. incompatible TypeScript version), give up on it for this session.
|
|
80
|
+
client.ready.catch((error) => this.broken.set(server.id, error.message.split(". ")[0]));
|
|
81
|
+
this.clients.set(server.id, client);
|
|
82
|
+
return client;
|
|
83
|
+
}
|
|
84
|
+
/** Sends the text and waits for the server's problems (it publishes them asynchronously). */
|
|
85
|
+
async diagnostics(file, text, timeout = 8000) {
|
|
86
|
+
const client = this.client(file);
|
|
87
|
+
if (!client)
|
|
88
|
+
return [];
|
|
89
|
+
const key = fromUri(toUri(file)).toLowerCase();
|
|
90
|
+
let wake;
|
|
91
|
+
const published = new Promise((resolve) => {
|
|
92
|
+
wake = resolve;
|
|
93
|
+
this.waiters.set(key, [...(this.waiters.get(key) ?? []), resolve]);
|
|
94
|
+
});
|
|
95
|
+
const stopWaiting = () => {
|
|
96
|
+
const left = (this.waiters.get(key) ?? []).filter((w) => w !== wake);
|
|
97
|
+
if (left.length)
|
|
98
|
+
this.waiters.set(key, left);
|
|
99
|
+
else
|
|
100
|
+
this.waiters.delete(key);
|
|
101
|
+
};
|
|
102
|
+
let changed;
|
|
103
|
+
try {
|
|
104
|
+
changed = await client.sync(file, text);
|
|
105
|
+
}
|
|
106
|
+
catch {
|
|
107
|
+
stopWaiting();
|
|
108
|
+
return [];
|
|
109
|
+
}
|
|
110
|
+
// Same text as last time: the server won't publish again, so its last results are current.
|
|
111
|
+
if (!changed && this.published.has(key)) {
|
|
112
|
+
stopWaiting();
|
|
113
|
+
return this.published.get(key);
|
|
114
|
+
}
|
|
115
|
+
// Servers often publish twice (syntax, then semantic); wait for the first, then a short quiet period.
|
|
116
|
+
let timer;
|
|
117
|
+
await Promise.race([published, new Promise((r) => (timer = setTimeout(r, timeout)))]);
|
|
118
|
+
clearTimeout(timer);
|
|
119
|
+
stopWaiting();
|
|
120
|
+
await new Promise((r) => setTimeout(r, 400));
|
|
121
|
+
return this.published.get(key) ?? [];
|
|
122
|
+
}
|
|
123
|
+
async hover(file, text, line, col) {
|
|
124
|
+
const client = this.client(file);
|
|
125
|
+
if (!client)
|
|
126
|
+
return null;
|
|
127
|
+
if (!(await client.sync(file, text).then(() => true, () => false)))
|
|
128
|
+
return null;
|
|
129
|
+
const result = await client.request("textDocument/hover", { textDocument: { uri: toUri(file) }, position: { line: line - 1, character: col - 1 } }).catch(() => null);
|
|
130
|
+
const contents = result?.contents;
|
|
131
|
+
if (!contents)
|
|
132
|
+
return null;
|
|
133
|
+
const parts = Array.isArray(contents) ? contents : [contents];
|
|
134
|
+
const textOut = parts.map((p) => (typeof p === "string" ? p : p.value ?? "")).join("\n");
|
|
135
|
+
return textOut.replace(/```\w*\n?/g, "").trim() || null;
|
|
136
|
+
}
|
|
137
|
+
async definition(file, text, line, col) {
|
|
138
|
+
const client = this.client(file);
|
|
139
|
+
if (!client)
|
|
140
|
+
return null;
|
|
141
|
+
if (!(await client.sync(file, text).then(() => true, () => false)))
|
|
142
|
+
return null;
|
|
143
|
+
const result = await client.request("textDocument/definition", { textDocument: { uri: toUri(file) }, position: { line: line - 1, character: col - 1 } }).catch(() => null);
|
|
144
|
+
const first = Array.isArray(result) ? result[0] : result;
|
|
145
|
+
if (!first?.uri && !first?.targetUri)
|
|
146
|
+
return null;
|
|
147
|
+
const uri = first.uri ?? first.targetUri;
|
|
148
|
+
const range = first.range ?? first.targetSelectionRange ?? first.targetRange;
|
|
149
|
+
return { file: fromUri(uri), line: range.start.line + 1, col: range.start.character + 1 };
|
|
150
|
+
}
|
|
151
|
+
stopAll() {
|
|
152
|
+
for (const c of this.clients.values())
|
|
153
|
+
c.stop();
|
|
154
|
+
this.clients.clear();
|
|
155
|
+
}
|
|
156
|
+
/** Problems from language servers, through the same interface as linters. */
|
|
157
|
+
provider() {
|
|
158
|
+
return {
|
|
159
|
+
id: "lsp",
|
|
160
|
+
name: "Language servers",
|
|
161
|
+
checksText: true,
|
|
162
|
+
matches: (file) => this.serverFor(file) !== undefined,
|
|
163
|
+
available: () => this.installed().length > 0,
|
|
164
|
+
check: (file, text) => this.diagnostics(file, text),
|
|
165
|
+
};
|
|
166
|
+
}
|
|
167
|
+
}
|
|
168
|
+
/** One set of language servers for the whole app. */
|
|
169
|
+
export const lsp = new LspManager();
|
|
@@ -1,7 +1,90 @@
|
|
|
1
|
-
import {
|
|
1
|
+
import { createHash } from "crypto";
|
|
2
|
+
import { existsSync, readdirSync, readFileSync, statSync } from "fs";
|
|
2
3
|
import path from "path";
|
|
3
4
|
import { pathToFileURL } from "url";
|
|
4
5
|
import { NOU_HOME } from "../config/index.js";
|
|
6
|
+
import { eslintProvider, ruffProvider, typescriptProvider } from "../diagnostics/providers.js";
|
|
7
|
+
import { syntaxProvider } from "../syntax/index.js";
|
|
8
|
+
import { lsp } from "../lsp/index.js";
|
|
9
|
+
/** Plugins that ship with Nou. On by default; they only run when the project has the tool installed. */
|
|
10
|
+
export const BUILTIN_PLUGINS = [
|
|
11
|
+
{
|
|
12
|
+
id: "syntax",
|
|
13
|
+
file: "(built-in)",
|
|
14
|
+
scope: "built-in",
|
|
15
|
+
plugin: { name: "Syntax check", description: "Instant syntax errors from the tree-sitter parser, no setup needed.", diagnostics: [syntaxProvider] },
|
|
16
|
+
},
|
|
17
|
+
{
|
|
18
|
+
id: "eslint",
|
|
19
|
+
file: "(built-in)",
|
|
20
|
+
scope: "built-in",
|
|
21
|
+
plugin: { name: "ESLint", description: "Lints JS/TS with your project's own ESLint and config.", diagnostics: [eslintProvider] },
|
|
22
|
+
},
|
|
23
|
+
{
|
|
24
|
+
id: "typescript",
|
|
25
|
+
file: "(built-in)",
|
|
26
|
+
scope: "built-in",
|
|
27
|
+
plugin: { name: "TypeScript", description: "Type errors from your project's own tsc (needs tsconfig.json).", diagnostics: [typescriptProvider] },
|
|
28
|
+
},
|
|
29
|
+
{
|
|
30
|
+
id: "ruff",
|
|
31
|
+
file: "(built-in)",
|
|
32
|
+
scope: "built-in",
|
|
33
|
+
plugin: { name: "Ruff", description: "Lints Python with ruff, if it's installed.", diagnostics: [ruffProvider] },
|
|
34
|
+
},
|
|
35
|
+
{
|
|
36
|
+
id: "lsp",
|
|
37
|
+
file: "(built-in)",
|
|
38
|
+
scope: "built-in",
|
|
39
|
+
plugin: {
|
|
40
|
+
name: "Language servers",
|
|
41
|
+
description: "Problems, hover (Ctrl+K) and go-to-definition (Ctrl+G) from installed servers: typescript-language-server, pyright, rust-analyzer, gopls, clangd.",
|
|
42
|
+
diagnostics: [lsp.provider()],
|
|
43
|
+
},
|
|
44
|
+
},
|
|
45
|
+
];
|
|
46
|
+
/**
|
|
47
|
+
* Built-ins are on unless switched off. Your own plugins run only if you approved this exact file and
|
|
48
|
+
* content; approval is never by name, so `.nou/plugins/x.js` in a cloned repo can't ride on an approval
|
|
49
|
+
* you gave a different `x.js`.
|
|
50
|
+
*/
|
|
51
|
+
export function isEnabled(entry, settings) {
|
|
52
|
+
if (entry.scope === "built-in")
|
|
53
|
+
return settings.plugins[entry.id] !== false;
|
|
54
|
+
return !!entry.root && !!entry.hash && settings.trustedPlugins[entry.root] === entry.hash;
|
|
55
|
+
}
|
|
56
|
+
/** "changed" means you approved this plugin, but its code has changed since. */
|
|
57
|
+
export function trustState(entry, settings) {
|
|
58
|
+
if (isEnabled(entry, settings))
|
|
59
|
+
return "on";
|
|
60
|
+
return entry.root && settings.trustedPlugins[entry.root] ? "changed" : "off";
|
|
61
|
+
}
|
|
62
|
+
/** The settings change that switches a plugin on or off. */
|
|
63
|
+
export function trustPatch(entry, on, settings) {
|
|
64
|
+
if (entry.scope === "built-in")
|
|
65
|
+
return { plugins: { ...settings.plugins, [entry.id]: on } };
|
|
66
|
+
const trusted = { ...settings.trustedPlugins };
|
|
67
|
+
if (on && entry.root && entry.hash)
|
|
68
|
+
trusted[entry.root] = entry.hash;
|
|
69
|
+
else if (entry.root)
|
|
70
|
+
delete trusted[entry.root];
|
|
71
|
+
return { trustedPlugins: trusted };
|
|
72
|
+
}
|
|
73
|
+
function hashPlugin(root) {
|
|
74
|
+
const hash = createHash("sha256");
|
|
75
|
+
const add = (p) => {
|
|
76
|
+
if (statSync(p).isDirectory()) {
|
|
77
|
+
for (const name of readdirSync(p).sort())
|
|
78
|
+
if (name !== "node_modules" && name !== ".git")
|
|
79
|
+
add(path.join(p, name));
|
|
80
|
+
}
|
|
81
|
+
else {
|
|
82
|
+
hash.update(path.relative(root, p)).update(readFileSync(p));
|
|
83
|
+
}
|
|
84
|
+
};
|
|
85
|
+
add(root);
|
|
86
|
+
return hash.digest("hex");
|
|
87
|
+
}
|
|
5
88
|
export const PLUGIN_DIRS = [
|
|
6
89
|
{ scope: "project", dir: path.join(process.cwd(), ".nou", "plugins") },
|
|
7
90
|
{ scope: "user", dir: path.join(NOU_HOME, "plugins") },
|
|
@@ -23,8 +106,16 @@ export function discoverPlugins() {
|
|
|
23
106
|
file = full;
|
|
24
107
|
}
|
|
25
108
|
const id = path.basename(name, path.extname(name));
|
|
26
|
-
if (file && !found.some((p) => p.id === id))
|
|
27
|
-
|
|
109
|
+
if (file && !found.some((p) => p.id === id)) {
|
|
110
|
+
let hash;
|
|
111
|
+
try {
|
|
112
|
+
hash = hashPlugin(full);
|
|
113
|
+
}
|
|
114
|
+
catch {
|
|
115
|
+
// unreadable: stays untrusted
|
|
116
|
+
}
|
|
117
|
+
found.push({ id, file, root: full, scope, ...(hash ? { hash } : {}) });
|
|
118
|
+
}
|
|
28
119
|
}
|
|
29
120
|
}
|
|
30
121
|
return found;
|
|
@@ -1,4 +1,5 @@
|
|
|
1
1
|
import { Ollama } from "ollama";
|
|
2
|
+
import { newCallId } from "./ids.js";
|
|
2
3
|
export function ollamaProvider() {
|
|
3
4
|
const host = process.env.OLLAMA_HOST ?? "http://127.0.0.1:11434";
|
|
4
5
|
const client = new Ollama({ host });
|
|
@@ -40,7 +41,7 @@ export function ollamaProvider() {
|
|
|
40
41
|
onText(part.message.content);
|
|
41
42
|
}
|
|
42
43
|
for (const call of part.message.tool_calls ?? []) {
|
|
43
|
-
toolCalls.push({ id:
|
|
44
|
+
toolCalls.push({ id: newCallId(), name: call.function.name, args: call.function.arguments ?? {} });
|
|
44
45
|
}
|
|
45
46
|
}
|
|
46
47
|
}
|
|
@@ -1,4 +1,5 @@
|
|
|
1
1
|
import OpenAI from "openai";
|
|
2
|
+
import { newCallId } from "./ids.js";
|
|
2
3
|
/** Everything that speaks the OpenAI chat completions API. */
|
|
3
4
|
export const OPENAI_COMPATIBLE = [
|
|
4
5
|
{ id: "openai", label: "OpenAI", baseURL: "https://api.openai.com/v1", keyEnv: "OPENAI_API_KEY", defaultModel: "gpt-5", local: false },
|
|
@@ -84,7 +85,7 @@ export function openAICompatibleProvider(preset) {
|
|
|
84
85
|
if (choice.finish_reason)
|
|
85
86
|
finish = choice.finish_reason;
|
|
86
87
|
}
|
|
87
|
-
const toolCalls = [...partial.values()].map((c
|
|
88
|
+
const toolCalls = [...partial.values()].map((c) => {
|
|
88
89
|
let args;
|
|
89
90
|
try {
|
|
90
91
|
args = c.args ? JSON.parse(c.args) : {};
|
|
@@ -92,7 +93,7 @@ export function openAICompatibleProvider(preset) {
|
|
|
92
93
|
catch {
|
|
93
94
|
args = { __invalid_json: c.args };
|
|
94
95
|
}
|
|
95
|
-
return { id: c.id ||
|
|
96
|
+
return { id: c.id || newCallId(), name: c.name, args };
|
|
96
97
|
});
|
|
97
98
|
return { text, toolCalls, ...(finish === "length" && toolCalls.length ? { stop: "max_tokens" } : {}) };
|
|
98
99
|
},
|