nou-cli 0.2.1 → 0.3.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.
@@ -0,0 +1,183 @@
1
+ import { execFileSync } from "child_process";
2
+ import { readdirSync, readFileSync, statSync } from "fs";
3
+ import path from "path";
4
+ import { IGNORED, isInProject, toRelative } from "../commands/index.js";
5
+ const MAX_FILES = 20_000;
6
+ const MAX_FILE_BYTES = 1_000_000;
7
+ const MAX_LINE = 200;
8
+ /**
9
+ * The project's files, relative and with forward slashes: git's list when it's a repo (so .gitignore is
10
+ * respected), otherwise a walk that skips the usual build and dependency folders.
11
+ */
12
+ export function projectFiles(under = ".") {
13
+ const base = toRelative(under) || ".";
14
+ try {
15
+ const out = execFileSync("git", ["ls-files", "-co", "--exclude-standard", "--", base], {
16
+ cwd: process.cwd(), encoding: "utf-8", stdio: ["ignore", "pipe", "ignore"], maxBuffer: 64 * 1024 * 1024,
17
+ });
18
+ // ls-files also lists deleted-but-tracked files; keep the ones that exist.
19
+ return out.split("\n").filter(Boolean).slice(0, MAX_FILES).filter((file) => {
20
+ try {
21
+ return statSync(file).isFile();
22
+ }
23
+ catch {
24
+ return false;
25
+ }
26
+ });
27
+ }
28
+ catch {
29
+ const files = [];
30
+ const walk = (dir) => {
31
+ let entries;
32
+ try {
33
+ entries = readdirSync(dir, { withFileTypes: true });
34
+ }
35
+ catch {
36
+ return;
37
+ }
38
+ for (const entry of entries) {
39
+ if (files.length >= MAX_FILES || IGNORED.has(entry.name))
40
+ continue;
41
+ const rel = dir === "." ? entry.name : `${dir}/${entry.name}`;
42
+ if (entry.isDirectory())
43
+ walk(rel);
44
+ else if (entry.isFile())
45
+ files.push(rel);
46
+ }
47
+ };
48
+ walk(base);
49
+ return files;
50
+ }
51
+ }
52
+ /**
53
+ * Glob to regex: `**` crosses folders, `*` and `?` stay within one, `{a,b}` is either. A pattern without a
54
+ * slash matches the file name anywhere ("*.ts" finds src/x.ts), like most tools.
55
+ */
56
+ export function globToRegex(pattern) {
57
+ let source = "";
58
+ for (let i = 0; i < pattern.length; i++) {
59
+ const ch = pattern[i];
60
+ if (ch === "*" && pattern[i + 1] === "*") {
61
+ // "**/" matches zero or more folders.
62
+ if (pattern[i + 2] === "/") {
63
+ source += "(?:.*/)?";
64
+ i += 2;
65
+ }
66
+ else {
67
+ source += ".*";
68
+ i += 1;
69
+ }
70
+ }
71
+ else if (ch === "*")
72
+ source += "[^/]*";
73
+ else if (ch === "?")
74
+ source += "[^/]";
75
+ else if (ch === "{") {
76
+ const end = pattern.indexOf("}", i);
77
+ if (end < 0)
78
+ source += "\\{";
79
+ else {
80
+ source += `(?:${pattern.slice(i + 1, end).split(",").map((part) => part.replace(/[.+^$()|[\]\\]/g, "\\$&").replace(/\*/g, "[^/]*")).join("|")})`;
81
+ i = end;
82
+ }
83
+ }
84
+ else
85
+ source += ch.replace(/[.+^$()|[\]\\]/g, "\\$&");
86
+ }
87
+ return new RegExp(pattern.includes("/") ? `^${source}$` : `(?:^|/)${source}$`);
88
+ }
89
+ /** Files matching a glob, newest first (recently changed files are usually the relevant ones). */
90
+ export function globFiles(pattern, under = ".", limit = 200) {
91
+ if (!isInProject(under))
92
+ return `${under} is outside the project folder.`;
93
+ const regex = globToRegex(pattern.replace(/\\/g, "/").replace(/^\.\//, ""));
94
+ const base = toRelative(under);
95
+ const matches = projectFiles(under).filter((file) => regex.test(base && base !== "." ? path.posix.relative(base, file) : file) || regex.test(file));
96
+ if (!matches.length)
97
+ return `No files match ${pattern}.`;
98
+ const dated = matches.map((file) => {
99
+ try {
100
+ return { file, time: statSync(file).mtimeMs };
101
+ }
102
+ catch {
103
+ return { file, time: 0 };
104
+ }
105
+ });
106
+ dated.sort((a, b) => b.time - a.time);
107
+ const shown = dated.slice(0, limit).map((d) => d.file);
108
+ return `${shown.join("\n")}${matches.length > limit ? `\n[${matches.length - limit} more; narrow the pattern]` : ""}`;
109
+ }
110
+ /** Searches file contents with a regex (or plain text, if the pattern isn't a valid regex). */
111
+ export function grep(options) {
112
+ const { pattern, ignoreCase = false, filesOnly = false } = options;
113
+ const under = options.path ?? ".";
114
+ const limit = options.limit ?? 100;
115
+ const context = Math.max(0, Math.min(5, options.context ?? 0));
116
+ if (!pattern)
117
+ return "Give a pattern to search for.";
118
+ if (!isInProject(under))
119
+ return `${under} is outside the project folder.`;
120
+ let regex;
121
+ try {
122
+ regex = new RegExp(pattern, ignoreCase ? "i" : "");
123
+ }
124
+ catch {
125
+ regex = new RegExp(pattern.replace(/[.*+?^${}()|[\]\\]/g, "\\$&"), ignoreCase ? "i" : "");
126
+ }
127
+ let files;
128
+ try {
129
+ files = statSync(under).isFile() ? [toRelative(under)] : projectFiles(under);
130
+ }
131
+ catch {
132
+ return `${under} doesn't exist.`;
133
+ }
134
+ if (options.glob) {
135
+ const only = globToRegex(options.glob);
136
+ files = files.filter((file) => only.test(file));
137
+ }
138
+ const out = [];
139
+ const matchedFiles = [];
140
+ let total = 0;
141
+ let shownMatches = 0;
142
+ for (const file of files) {
143
+ let text;
144
+ try {
145
+ if (statSync(file).size > MAX_FILE_BYTES)
146
+ continue;
147
+ text = readFileSync(file, "utf-8");
148
+ }
149
+ catch {
150
+ continue;
151
+ }
152
+ if (text.includes("\0"))
153
+ continue; // binary
154
+ const lines = text.split("\n");
155
+ let inFile = 0;
156
+ let lastShown = -1;
157
+ for (let i = 0; i < lines.length; i++) {
158
+ if (!regex.test(lines[i]))
159
+ continue;
160
+ total++;
161
+ inFile++;
162
+ if (filesOnly || shownMatches >= limit)
163
+ continue;
164
+ shownMatches++;
165
+ if (context && lastShown >= 0 && i - context > lastShown + 1)
166
+ out.push("--");
167
+ for (let j = Math.max(0, i - context, lastShown + 1); j <= Math.min(lines.length - 1, i + context); j++) {
168
+ const line = lines[j].replace(/\r$/, "");
169
+ const shown = line.length > MAX_LINE ? `${line.slice(0, MAX_LINE)}...` : line;
170
+ out.push(`${file}:${j + 1}${j === i ? ":" : "-"} ${shown}`);
171
+ lastShown = j;
172
+ }
173
+ }
174
+ if (inFile)
175
+ matchedFiles.push(`${file} (${inFile})`);
176
+ }
177
+ if (!total)
178
+ return `No matches for ${pattern}${options.glob ? ` in ${options.glob} files` : ""}.`;
179
+ if (filesOnly) {
180
+ return `${matchedFiles.slice(0, limit).join("\n")}${matchedFiles.length > limit ? `\n[${matchedFiles.length - limit} more files]` : ""}`;
181
+ }
182
+ return `${out.join("\n")}${total > shownMatches ? `\n[${total} matches in ${matchedFiles.length} files; showing the first ${limit}. Narrow the pattern, path or glob, or use files_only.]` : ""}`;
183
+ }
@@ -0,0 +1,240 @@
1
+ import { spawn, spawnSync } from "child_process";
2
+ import { existsSync, readFileSync } from "fs";
3
+ import os from "os";
4
+ import path from "path";
5
+ import * as p from "@clack/prompts";
6
+ import chalk from "chalk";
7
+ import { describe, providers, resolveModel } from "../providers/index.js";
8
+ import { sameModel } from "../providers/ollama.js";
9
+ import { noteOrigin } from "../config/origins.js";
10
+ function readJson(file) {
11
+ try {
12
+ return JSON.parse(readFileSync(file, "utf-8"));
13
+ }
14
+ catch {
15
+ return null;
16
+ }
17
+ }
18
+ /**
19
+ * Claude Code keeps API settings in the "env" block of ~/.claude/settings.json. Only API keys are reused:
20
+ * a Claude subscription login (claude.ai) belongs to Claude Code and isn't something other apps may use.
21
+ */
22
+ function claudeCode() {
23
+ const dir = process.env.CLAUDE_CONFIG_DIR ?? path.join(os.homedir(), ".claude");
24
+ const settings = readJson(path.join(dir, "settings.json"));
25
+ const env = {};
26
+ for (const name of ["ANTHROPIC_API_KEY", "ANTHROPIC_AUTH_TOKEN", "ANTHROPIC_BASE_URL"]) {
27
+ const value = settings?.env?.[name];
28
+ if (typeof value === "string" && value)
29
+ env[name] = value;
30
+ }
31
+ if (!env.ANTHROPIC_API_KEY && !env.ANTHROPIC_AUTH_TOKEN)
32
+ return null;
33
+ // Aliases like "opus" only mean something to Claude Code; full ids work anywhere.
34
+ const model = [settings?.env?.ANTHROPIC_MODEL, settings?.model].find((m) => typeof m === "string" && m.startsWith("claude-"));
35
+ return { env, ...(model ? { model } : {}) };
36
+ }
37
+ /** Codex stores an API-key login in ~/.codex/auth.json (ChatGPT sign-ins aren't reusable) and its model in config.toml. */
38
+ function codex() {
39
+ const dir = process.env.CODEX_HOME ?? path.join(os.homedir(), ".codex");
40
+ const key = readJson(path.join(dir, "auth.json"))?.OPENAI_API_KEY;
41
+ if (typeof key !== "string" || !key)
42
+ return null;
43
+ let model;
44
+ try {
45
+ // Top-level keys only: everything before the first [section].
46
+ const top = readFileSync(path.join(dir, "config.toml"), "utf-8").split(/^\s*\[/m)[0];
47
+ const provider = top.match(/^\s*model_provider\s*=\s*"([^"]*)"/m)?.[1];
48
+ const found = top.match(/^\s*model\s*=\s*"([^"]+)"/m)?.[1];
49
+ // Codex-only models need the Responses API, which Nou doesn't speak.
50
+ if (found && (!provider || provider === "openai") && !found.includes("codex"))
51
+ model = found;
52
+ }
53
+ catch {
54
+ // no config.toml
55
+ }
56
+ return { env: { OPENAI_API_KEY: key }, ...(model ? { model } : {}) };
57
+ }
58
+ /**
59
+ * Fills in API keys from Claude Code's and Codex's config for providers you haven't set up yourself.
60
+ * Your environment and .env always win; nothing is written anywhere.
61
+ */
62
+ export function borrowToolConfig() {
63
+ const found = [];
64
+ const sources = [
65
+ { tool: "Claude Code", provider: "anthropic", read: claudeCode, has: ["ANTHROPIC_API_KEY", "ANTHROPIC_AUTH_TOKEN"] },
66
+ { tool: "Codex", provider: "openai", read: codex, has: ["OPENAI_API_KEY"] },
67
+ ];
68
+ for (const source of sources) {
69
+ if (source.has.some((name) => process.env[name]))
70
+ continue;
71
+ const config = source.read();
72
+ if (!config)
73
+ continue;
74
+ for (const [name, value] of Object.entries(config.env))
75
+ process.env[name] ??= value;
76
+ noteOrigin(Object.keys(config.env), `${source.tool}'s settings`);
77
+ found.push({ tool: source.tool, provider: source.provider, ...(config.model ? { model: config.model } : {}) });
78
+ }
79
+ return found;
80
+ }
81
+ /** A local server's models, or null if it isn't reachable within `ms`. */
82
+ async function localModels(provider, ms = 1500) {
83
+ let timer;
84
+ const timeout = new Promise((resolve) => {
85
+ timer = setTimeout(() => resolve(null), ms);
86
+ });
87
+ try {
88
+ return await Promise.race([provider.listModels(), timeout]);
89
+ }
90
+ catch {
91
+ return null;
92
+ }
93
+ finally {
94
+ clearTimeout(timer);
95
+ }
96
+ }
97
+ const ollama = () => providers.find((x) => x.id === "ollama");
98
+ /**
99
+ * First run (no model chosen yet): offer what's already on this machine (Ollama models, a running
100
+ * LM Studio, API keys including ones from Claude Code or Codex) instead of always starting on Ollama.
101
+ * Returns null to keep the default.
102
+ */
103
+ export async function pickStartingModel(borrowed) {
104
+ const options = [];
105
+ const add = (choice, hint) => {
106
+ if (!options.some((o) => describe(o.choice) === describe(choice)))
107
+ options.push({ choice, hint });
108
+ };
109
+ for (const b of borrowed)
110
+ add(resolveModel(b.model ? `${b.provider}:${b.model}` : b.provider), `API key from ${b.tool}`);
111
+ const s = p.spinner();
112
+ s.start("Looking for models you already have...");
113
+ const local = await Promise.all(providers.filter((x) => x.local && !x.unavailable()).map(async (x) => ({ provider: x, models: await localModels(x) })));
114
+ s.stop("Checked for models");
115
+ for (const { provider, models } of local) {
116
+ for (const model of (models ?? []).slice(0, provider.id === "ollama" ? 5 : 1))
117
+ add({ provider, model }, `${provider.label}, installed`);
118
+ }
119
+ for (const provider of providers.filter((x) => !x.local && !x.unavailable()))
120
+ add({ provider, model: provider.defaultModel }, "API key found");
121
+ if (!options.length)
122
+ return null;
123
+ if (options.length === 1) {
124
+ p.log.info(`Found ${chalk.yellow(describe(options[0].choice))} (${options[0].hint}).`);
125
+ return options[0].choice;
126
+ }
127
+ const download = `download ${ollama().defaultModel} with Ollama (free, runs locally)`;
128
+ const picked = await p.select({
129
+ message: "Which model should Nou use? (change it any time with /model)",
130
+ options: [
131
+ ...options.map((o, i) => ({ value: String(i), label: describe(o.choice), hint: o.hint })),
132
+ ...(local.find((l) => l.provider.id === "ollama")?.models?.some((m) => sameModel(m, ollama().defaultModel)) ? [] : [{ value: "download", label: download }]),
133
+ ],
134
+ });
135
+ if (p.isCancel(picked) || picked === "download")
136
+ return null;
137
+ return options[Number(picked)].choice;
138
+ }
139
+ const isLocalHost = () => /^(https?:\/\/)?(127\.0\.0\.1|localhost|0\.0\.0\.0)(:|\/|$)/.test(process.env.OLLAMA_HOST ?? "127.0.0.1");
140
+ /** The ollama executable: on PATH, or where the Windows installer puts it (PATH may not be updated yet). */
141
+ function ollamaBinary() {
142
+ if (spawnSync("ollama", ["--version"], { stdio: "ignore", windowsHide: true }).status === 0)
143
+ return "ollama";
144
+ const windows = path.join(process.env.LOCALAPPDATA ?? "", "Programs", "Ollama", "ollama.exe");
145
+ return process.platform === "win32" && existsSync(windows) ? windows : null;
146
+ }
147
+ const has = (command) => spawnSync(command, ["--version"], { stdio: "ignore", windowsHide: true }).status === 0;
148
+ function installCommand() {
149
+ if (process.platform === "win32") {
150
+ return has("winget")
151
+ ? { command: "winget", args: ["install", "--id", "Ollama.Ollama", "-e", "--accept-source-agreements", "--accept-package-agreements"], shown: "winget install Ollama.Ollama" }
152
+ : null;
153
+ }
154
+ if (process.platform === "darwin")
155
+ return has("brew") ? { command: "brew", args: ["install", "ollama"], shown: "brew install ollama" } : null;
156
+ // Ollama's official Linux installer.
157
+ return has("curl") ? { command: "sh", args: ["-c", "curl -fsSL https://ollama.com/install.sh | sh"], shown: "curl -fsSL https://ollama.com/install.sh | sh" } : null;
158
+ }
159
+ /** Gets a local Ollama running, installing it first if you agree. Returns its models, or null if it isn't available. */
160
+ async function startOllama() {
161
+ if (!isLocalHost()) {
162
+ p.log.warn(`Can't reach Ollama at ${process.env.OLLAMA_HOST}.`);
163
+ return null;
164
+ }
165
+ let binary = ollamaBinary();
166
+ if (!binary) {
167
+ const install = installCommand();
168
+ if (!install) {
169
+ p.log.warn("Ollama isn't installed. Get it from https://ollama.com/download, or pick a cloud model with /model.");
170
+ return null;
171
+ }
172
+ const ok = await p.confirm({ message: `Ollama (runs models locally, free) isn't installed. Install it now? ${chalk.dim(`(${install.shown})`)}` });
173
+ if (ok !== true)
174
+ return null;
175
+ const result = spawnSync(install.command, install.args, { stdio: "inherit" });
176
+ binary = ollamaBinary();
177
+ if (result.status !== 0 || !binary) {
178
+ p.log.warn("Installing Ollama didn't work. Get it from https://ollama.com/download.");
179
+ return null;
180
+ }
181
+ }
182
+ // The desktop installers usually start Ollama themselves; otherwise start the server in the background.
183
+ let models = await localModels(ollama());
184
+ if (models)
185
+ return models;
186
+ const s = p.spinner();
187
+ s.start("Starting Ollama...");
188
+ spawn(binary, ["serve"], { detached: true, stdio: "ignore", windowsHide: true }).on("error", () => { }).unref();
189
+ for (let i = 0; i < 20 && !models; i++) {
190
+ await new Promise((r) => setTimeout(r, 500));
191
+ models = await localModels(ollama());
192
+ }
193
+ s.stop(models ? "Ollama is running" : "Ollama didn't start");
194
+ return models;
195
+ }
196
+ /** Downloads an Ollama model with a progress line. */
197
+ async function pullWithProgress(model) {
198
+ const s = p.spinner();
199
+ s.start(`Downloading ${model}...`);
200
+ let last = "";
201
+ try {
202
+ await ollama().pull(model, (status, percent) => {
203
+ const line = `Downloading ${model}: ${percent === null ? status : `${percent}%`}`;
204
+ if (line !== last)
205
+ s.message((last = line));
206
+ });
207
+ s.stop(`Downloaded ${model}`);
208
+ return true;
209
+ }
210
+ catch (error) {
211
+ s.stop(`Couldn't download ${model}: ${error.message}`);
212
+ return false;
213
+ }
214
+ }
215
+ /**
216
+ * When the model is an Ollama one: make sure Ollama runs and the model is downloaded, offering to install,
217
+ * start and download what's missing (or to use a model you already have).
218
+ */
219
+ export async function ensureLocalModel(choice) {
220
+ if (choice.provider.id !== "ollama")
221
+ return choice;
222
+ const installed = (await localModels(choice.provider)) ?? (await startOllama());
223
+ if (!installed)
224
+ return choice;
225
+ if (installed.some((m) => sameModel(m, choice.model)))
226
+ return choice;
227
+ const picked = await p.select({
228
+ message: `${choice.model} isn't downloaded yet.`,
229
+ options: [
230
+ { value: "", label: `Download ${choice.model}`, hint: "a few GB, one time" },
231
+ ...installed.slice(0, 8).map((m) => ({ value: m, label: `Use ${m}`, hint: "already installed" })),
232
+ ],
233
+ });
234
+ if (p.isCancel(picked))
235
+ return choice;
236
+ if (picked)
237
+ return { provider: choice.provider, model: picked };
238
+ await pullWithProgress(choice.model);
239
+ return choice;
240
+ }
@@ -20,19 +20,85 @@ export const tools = [
20
20
  type: "function",
21
21
  function: {
22
22
  name: "read_file",
23
- description: "Read the contents of a file",
23
+ description: "Read a file. Long files come back in pages of up to 1000 lines; the end of each page says how to read on.",
24
24
  parameters: {
25
25
  type: "object",
26
26
  properties: {
27
27
  path: {
28
28
  type: "string",
29
29
  description: "Path to file",
30
+ },
31
+ offset: {
32
+ type: "number",
33
+ description: "First line to read (1-based). Defaults to 1.",
34
+ },
35
+ limit: {
36
+ type: "number",
37
+ description: "How many lines to read. Defaults to 1000.",
30
38
  }
31
39
  },
32
40
  required: ["path"]
33
41
  }
34
42
  }
35
43
  },
44
+ {
45
+ type: "function",
46
+ function: {
47
+ name: "grep",
48
+ description: "Search the contents of the project's files with a regular expression (or plain text). Returns file:line: matching line. Use it to find where something is defined or used before reading files.",
49
+ parameters: {
50
+ type: "object",
51
+ properties: {
52
+ pattern: {
53
+ type: "string",
54
+ description: "Regular expression (JavaScript syntax) or plain text to look for",
55
+ },
56
+ path: {
57
+ type: "string",
58
+ description: "File or folder to search in. Defaults to the whole project.",
59
+ },
60
+ glob: {
61
+ type: "string",
62
+ description: "Only search files matching this glob, e.g. \"*.ts\" or \"src/**/*.py\"",
63
+ },
64
+ ignore_case: {
65
+ type: "boolean",
66
+ description: "Match regardless of case",
67
+ },
68
+ context: {
69
+ type: "number",
70
+ description: "Lines to show before and after each match (0-5)",
71
+ },
72
+ files_only: {
73
+ type: "boolean",
74
+ description: "List only the files that match, with match counts",
75
+ }
76
+ },
77
+ required: ["pattern"]
78
+ }
79
+ }
80
+ },
81
+ {
82
+ type: "function",
83
+ function: {
84
+ name: "glob",
85
+ description: "Find files by name pattern, e.g. \"**/*.test.ts\" or \"*config*\". Newest first. Patterns without a slash match file names anywhere.",
86
+ parameters: {
87
+ type: "object",
88
+ properties: {
89
+ pattern: {
90
+ type: "string",
91
+ description: "Glob pattern: * and ? within a folder, ** across folders, {a,b} for either",
92
+ },
93
+ path: {
94
+ type: "string",
95
+ description: "Folder to search in. Defaults to the whole project.",
96
+ }
97
+ },
98
+ required: ["pattern"]
99
+ }
100
+ }
101
+ },
36
102
  {
37
103
  type: "function",
38
104
  function: {
@@ -64,11 +130,15 @@ export const tools = [
64
130
  },
65
131
  old_string: {
66
132
  type: "string",
67
- description: "The exact text to replace. Must appear exactly once in the file."
133
+ description: "The exact text to replace. Must appear exactly once in the file, unless replace_all is true."
68
134
  },
69
135
  new_string: {
70
136
  type: "string",
71
137
  description: "The text to put in its place"
138
+ },
139
+ replace_all: {
140
+ type: "boolean",
141
+ description: "Replace every occurrence of old_string (e.g. to rename something throughout the file). Defaults to false."
72
142
  }
73
143
  },
74
144
  required: ["path", "old_string", "new_string"]