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.
@@ -3,43 +3,82 @@ import { useEffect, useState } from "react";
3
3
  import { Box, Text, useAnimation, useInput } from "ink";
4
4
  import TextInput from "ink-text-input";
5
5
  import { AUTO_SAVE, EFFORTS } from "../config/index.js";
6
+ import { saveCredentials } from "../config/credentials.js";
7
+ import { originOf, setOutsideNou } from "../config/origins.js";
6
8
  import { PLUGIN_DIRS, isEnabled, trustState } from "../plugins/index.js";
7
9
  import { currentVersion } from "../update/index.js";
8
10
  import { describe, providers } from "../providers/index.js";
11
+ import { ENGINES, isInstalled } from "../engines/index.js";
9
12
  import { C, SPINNER } from "./theme.js";
10
- const TABS = ["Model", "Preferences", "Plugins"];
13
+ const TABS = ["Model", "Engine", "Preferences", "Plugins"];
11
14
  const PREFS = [
12
15
  { key: "effort", label: "Reasoning effort", help: "How hard Claude thinks (low → max). Higher is smarter but slower and pricier." },
13
16
  { key: "autoSave", label: "Auto-save", help: "delay: a second after you stop typing · focus: when you leave the editor · off: only Ctrl+S." },
14
17
  { key: "mouse", label: "Mouse", help: "Wheel scrolls the pane under the pointer; click to focus or open files. Hold Shift to select text." },
15
18
  { key: "followMode", label: "Follow mode on start", help: "The code view jumps to files Nou reads and edits." },
19
+ { key: "reuseToolConfig", label: "Use Claude Code / Codex keys", help: "Use the API keys from Claude Code's or Codex's settings when you haven't set one. Applies on restart." },
16
20
  { key: "gitContext", label: "Git context", help: "Give the model your branch, changes and commits, plus git tools." },
17
21
  { key: "autoApproveEdits", label: "Auto-approve edits", help: "Apply file edits without asking (diffs are still shown).", warn: true },
18
22
  { key: "autoApproveCommands", label: "Auto-approve commands", help: "Run shell commands without asking. Only turn on if you trust the model.", warn: true },
19
23
  ];
20
- export function Settings({ width, height, settings, choice, plugins, onModel, onChange, onTogglePlugin, onRescanPlugins, onClose }) {
24
+ export function Settings({ width, height, settings, choice, plugins, connectTo, onModel, onEngine, onChange, onTogglePlugin, onRescanPlugins, onClose }) {
21
25
  const [tab, setTab] = useState("Model");
22
- const [row, setRow] = useState(0);
26
+ const [row, setRow] = useState(() => Math.max(0, providers.indexOf(connectTo ?? choice.provider)));
23
27
  const [column, setColumn] = useState("providers");
24
28
  const [modelRow, setModelRow] = useState(0);
25
29
  const [typing, setTyping] = useState(false);
26
30
  const [custom, setCustom] = useState("");
27
31
  const [models, setModels] = useState({});
32
+ const [connecting, setConnecting] = useState(connectTo?.connect ? { step: 0, values: {} } : null);
33
+ const [field, setField] = useState(() => (connectTo?.connect ? initialValue(connectTo.connect[0]) : ""));
28
34
  const { frame } = useAnimation({ interval: 80 });
29
35
  const provider = providers[row] ?? providers[0];
30
36
  const listed = models[provider.id];
31
37
  // Load the model list for the highlighted provider.
32
38
  useEffect(() => {
33
- if (tab !== "Model" || provider.unavailable() || models[provider.id])
39
+ if (tab !== "Model" || connecting || provider.unavailable() || models[provider.id])
34
40
  return;
35
41
  setModels((m) => ({ ...m, [provider.id]: "loading" }));
36
42
  provider.listModels()
37
43
  .then((list) => setModels((m) => ({ ...m, [provider.id]: list })))
38
44
  .catch((error) => setModels((m) => ({ ...m, [provider.id]: { error: String(error.message ?? error) } })));
39
- }, [tab, provider.id]);
45
+ }, [tab, provider.id, connecting]);
46
+ const startConnect = () => {
47
+ if (!provider.connect)
48
+ return;
49
+ setConnecting({ step: 0, values: {} });
50
+ setField(initialValue(provider.connect[0]));
51
+ };
52
+ /** Takes the current field's answer; after the last one, saves everything and shows the provider's models. */
53
+ const submitField = (value) => {
54
+ const fields = provider.connect;
55
+ const current = fields[connecting.step];
56
+ // Required, and nothing set yet: ask again.
57
+ if (!value.trim() && !current.optional && !process.env[current.env])
58
+ return;
59
+ const values = { ...connecting.values, [current.env]: value };
60
+ const next = connecting.step + 1;
61
+ if (next < fields.length) {
62
+ setConnecting({ step: next, values });
63
+ setField(initialValue(fields[next]));
64
+ return;
65
+ }
66
+ saveCredentials(values);
67
+ setConnecting(null);
68
+ setModels((m) => Object.fromEntries(Object.entries(m).filter(([id]) => id !== provider.id)));
69
+ if (!provider.unavailable()) {
70
+ setColumn("models");
71
+ setModelRow(0);
72
+ }
73
+ };
40
74
  const modelItems = Array.isArray(listed) ? listed : [];
41
- const rowsFor = (t) => (t === "Model" ? providers.length : t === "Preferences" ? PREFS.length : plugins.length);
75
+ const rowsFor = (t) => (t === "Model" ? providers.length : t === "Engine" ? ENGINES.length : t === "Preferences" ? PREFS.length : plugins.length);
42
76
  useInput((input, key) => {
77
+ if (connecting) {
78
+ if (key.escape)
79
+ setConnecting(null);
80
+ return;
81
+ }
43
82
  if (typing) {
44
83
  if (key.escape)
45
84
  setTyping(false);
@@ -48,7 +87,7 @@ export function Settings({ width, height, settings, choice, plugins, onModel, on
48
87
  if (key.tab) {
49
88
  const next = TABS[(TABS.indexOf(tab) + (key.shift ? TABS.length - 1 : 1)) % TABS.length];
50
89
  setTab(next);
51
- setRow(next === "Model" ? Math.max(0, providers.indexOf(choice.provider)) : 0);
90
+ setRow(next === "Model" ? Math.max(0, providers.indexOf(choice.provider)) : next === "Engine" ? Math.max(0, ENGINES.findIndex((e) => e.id === settings.engine)) : 0);
52
91
  setColumn("providers");
53
92
  return;
54
93
  }
@@ -67,6 +106,8 @@ export function Settings({ width, height, settings, choice, plugins, onModel, on
67
106
  setModelRow((r) => Math.min(count - 1, r + 1));
68
107
  else if (key.leftArrow)
69
108
  setColumn("providers");
109
+ else if (input === "k")
110
+ startConnect();
70
111
  else if (key.return) {
71
112
  if (modelRow === 0) {
72
113
  setCustom("");
@@ -86,6 +127,14 @@ export function Settings({ width, height, settings, choice, plugins, onModel, on
86
127
  setColumn("models");
87
128
  setModelRow(0);
88
129
  }
130
+ else
131
+ startConnect();
132
+ }
133
+ else if (tab === "Model" && input === "k") {
134
+ startConnect();
135
+ }
136
+ else if (tab === "Engine" && (key.return || input === " ")) {
137
+ onEngine(ENGINES[row].id);
89
138
  }
90
139
  else if (tab === "Preferences" && (key.return || input === " " || key.leftArrow || key.rightArrow)) {
91
140
  const pref = PREFS[row];
@@ -112,16 +161,32 @@ export function Settings({ width, height, settings, choice, plugins, onModel, on
112
161
  const body = () => {
113
162
  if (tab === "Model") {
114
163
  const leftWidth = Math.min(46, Math.floor(width * 0.45));
164
+ // Room for the label next to the marker, "local"/"cloud" and the lock (plus the border).
165
+ const labelWidth = Math.max(10, leftWidth - 14);
115
166
  return (_jsxs(Box, { flexDirection: "row", height: boxHeight, children: [_jsxs(Box, { flexDirection: "column", width: leftWidth, borderStyle: "round", borderColor: column === "providers" ? C.accent : C.dim, children: [_jsx(Text, { color: C.dim, bold: true, children: " PROVIDER" }), providers.map((p, i) => {
116
167
  const problem = p.unavailable();
117
168
  const selected = i === row;
118
169
  const current = p.id === choice.provider.id;
119
- return (_jsxs(Text, { wrap: "truncate-end", ...(selected && column === "providers" ? { backgroundColor: C.panel } : {}), children: [_jsx(Text, { color: current ? C.green : C.dim, children: current ? " ● " : " " }), _jsx(Text, { color: problem ? C.dim : C.text, bold: selected, children: p.label.padEnd(26).slice(0, 26) }), _jsx(Text, { color: p.local ? C.green : C.accent2, children: p.local ? " local" : " cloud" }), problem ? _jsx(Text, { color: C.dim, children: " \uD83D\uDD12" }) : null] }, p.id));
120
- })] }), _jsxs(Box, { flexDirection: "column", flexGrow: 1, borderStyle: "round", borderColor: column === "models" ? C.accent : C.dim, paddingX: 1, children: [_jsxs(Text, { color: C.dim, bold: true, children: ["MODELS \u00B7 ", provider.label] }), provider.unavailable() ? (_jsxs(_Fragment, { children: [_jsxs(Text, { color: C.yellow, children: ["Not set up: ", provider.unavailable()] }), _jsx(Text, { color: C.dim, children: "Add it to a .env file in your project (see .env.example) or your environment, then restart Nou." })] })) : (_jsxs(_Fragment, { children: [typing ? (_jsxs(Text, { children: [_jsx(Text, { color: C.accent2, children: "\u270E " }), _jsx(TextInput, { value: custom, onChange: setCustom, onSubmit: (v) => {
170
+ return (_jsxs(Text, { wrap: "truncate-end", ...(selected && column === "providers" ? { backgroundColor: C.panel } : {}), children: [_jsx(Text, { color: current ? C.green : C.dim, children: current ? " ● " : " " }), _jsx(Text, { color: problem ? C.dim : C.text, bold: selected, children: p.label.padEnd(labelWidth).slice(0, labelWidth) }), _jsx(Text, { color: p.local ? C.green : C.accent2, children: p.local ? " local" : " cloud" }), problem ? _jsx(Text, { color: C.dim, children: " \uD83D\uDD12" }) : null] }, p.id));
171
+ })] }), _jsxs(Box, { flexDirection: "column", flexGrow: 1, borderStyle: "round", borderColor: column === "models" ? C.accent : C.dim, paddingX: 1, children: [_jsxs(Text, { color: C.dim, bold: true, children: ["MODELS \u00B7 ", provider.label] }), connecting && provider.connect ? (_jsxs(_Fragment, { children: [_jsxs(Text, { color: C.accent2, bold: true, children: ["Connect ", provider.label] }), provider.connect.map((f, i) => {
172
+ const entered = connecting.values[f.env];
173
+ const shown = (v) => (f.secret ? "•".repeat(Math.min(v.length, 12)) : v);
174
+ if (i < connecting.step) {
175
+ return _jsxs(Text, { color: C.dim, wrap: "truncate-end", children: ["\u2713 ", f.label, ": ", entered ? shown(entered) : "unchanged"] }, f.env);
176
+ }
177
+ if (i > connecting.step)
178
+ return _jsxs(Text, { color: C.dim, children: [" ", f.label, f.optional ? " (optional)" : ""] }, f.env);
179
+ return (_jsxs(Box, { flexDirection: "column", children: [_jsxs(Text, { children: [_jsxs(Text, { color: C.accent2, children: ["\u270E ", f.label, f.optional ? " (optional)" : "", ": "] }), _jsx(TextInput, { value: field, onChange: setField, onSubmit: submitField, ...(f.secret ? { mask: "•" } : {}), ...(f.placeholder ? { placeholder: f.placeholder } : {}) })] }), _jsxs(Text, { color: C.dim, wrap: "truncate-end", children: [" ", f.env, process.env[f.env] ? (f.secret ? ` is set (from ${originOf(f.env)}); Enter keeps it` : "") : f.optional ? "; Enter skips" : ""] }), setOutsideNou(f.env) && (_jsxs(Text, { color: C.yellow, wrap: "wrap", children: [" A new one works now, but ", originOf(f.env), " sets ", f.env, " too and wins when Nou restarts: change or remove it there."] }))] }, f.env));
180
+ }), _jsx(Text, { children: " " }), _jsx(Text, { color: C.dim, wrap: "truncate-end", children: "Saved to ~/.nou/credentials.json (only you can read it). Your environment and .env still win." })] })) : provider.unavailable() ? (_jsxs(_Fragment, { children: [_jsxs(Text, { color: C.yellow, children: ["Not set up: ", provider.unavailable()] }), provider.connect
181
+ ? _jsx(Text, { color: C.accent2, children: "Press Enter to connect it: paste your key here, no restart needed." })
182
+ : null, _jsx(Text, { color: C.dim, children: "Or add it to a .env file in your project (see .env.example) or your environment." })] })) : (_jsxs(_Fragment, { children: [typing ? (_jsxs(Text, { children: [_jsx(Text, { color: C.accent2, children: "\u270E " }), _jsx(TextInput, { value: custom, onChange: setCustom, onSubmit: (v) => {
121
183
  setTyping(false);
122
184
  if (v.trim())
123
185
  onModel({ provider, model: v.trim() });
124
- }, placeholder: `e.g. ${provider.defaultModel}` })] })) : (_jsx(Text, { ...(column === "models" && modelRow === 0 ? { backgroundColor: C.panel } : {}), color: C.accent2, children: "\u270E Type a model name\u2026" })), listed === "loading" && _jsxs(Text, { color: C.accent2, children: [SPINNER[frame % SPINNER.length], " loading models\u2026"] }), listed && typeof listed === "object" && !Array.isArray(listed) && _jsxs(Text, { color: C.red, children: ["Couldn't list models: ", listed.error] }), (() => {
186
+ }, placeholder: `e.g. ${provider.defaultModel}` })] })) : (_jsx(Text, { ...(column === "models" && modelRow === 0 ? { backgroundColor: C.panel } : {}), color: C.accent2, children: "\u270E Type a model name\u2026" })), listed === "loading" && _jsxs(Text, { color: C.accent2, children: [SPINNER[frame % SPINNER.length], " loading models\u2026"] }), listed && typeof listed === "object" && !Array.isArray(listed) && (_jsxs(_Fragment, { children: [_jsxs(Text, { color: C.red, children: ["Couldn't list models: ", listed.error] }), rejectedKey(listed.error) && provider.connect?.some((f) => f.secret) && (() => {
187
+ const key = provider.connect.find((f) => f.secret).env;
188
+ return _jsxs(Text, { color: C.yellow, wrap: "wrap", children: ["The ", key, " ", originOf(key) ? `from ${originOf(key)} ` : "", "was rejected. Press k to enter a different key."] });
189
+ })()] })), (() => {
125
190
  const visibleCount = Math.max(1, boxHeight - 6);
126
191
  const offset = Math.max(0, Math.min(modelRow - 1 - Math.floor(visibleCount / 2), modelItems.length - visibleCount));
127
192
  return modelItems.slice(offset, offset + visibleCount).map((m, i) => {
@@ -132,6 +197,13 @@ export function Settings({ width, height, settings, choice, plugins, onModel, on
132
197
  });
133
198
  })()] }))] })] }));
134
199
  }
200
+ if (tab === "Engine") {
201
+ return (_jsxs(Box, { flexDirection: "column", height: boxHeight, borderStyle: "round", borderColor: C.accent, paddingX: 1, children: [ENGINES.map((engine, i) => {
202
+ const selected = i === row;
203
+ const active = engine.id === settings.engine;
204
+ return (_jsxs(Box, { flexDirection: "column", marginBottom: 1, children: [_jsxs(Text, { ...(selected ? { backgroundColor: C.panel } : {}), children: [_jsx(Text, { color: selected ? C.accent2 : C.dim, children: selected ? "▸ " : " " }), _jsx(Text, { color: active ? C.green : C.dim, bold: true, children: active ? "● " : "○ " }), _jsx(Text, { color: C.text, bold: selected, children: engine.label.padEnd(14) }), _jsxs(Text, { color: C.dim, children: [active ? "in use · " : "", isInstalled(engine) ? "" : "installs on first use · ", "model: ", engine.model(choice)] })] }), _jsxs(Text, { color: C.dim, wrap: "truncate-end", children: [" ", engine.description] }), engine.approvals === "after" && _jsx(Text, { color: C.orange, wrap: "truncate-end", children: " Nou can't ask before its edits; it shows them afterwards so you can undo them." })] }, engine.id));
205
+ }), _jsx(Text, { color: C.dim, wrap: "truncate-end", children: "The engine is the agent that answers in the chat. Diffs, approvals, undo and problems work with each one." })] }));
206
+ }
135
207
  if (tab === "Preferences") {
136
208
  return (_jsx(Box, { flexDirection: "column", height: boxHeight, borderStyle: "round", borderColor: C.accent, paddingX: 1, children: PREFS.map((pref, i) => {
137
209
  const selected = i === row;
@@ -152,15 +224,21 @@ export function Settings({ width, height, settings, choice, plugins, onModel, on
152
224
  (p.plugin.diagnostics ?? []).length && "checker",
153
225
  ].filter(Boolean)
154
226
  : [];
155
- return (_jsxs(Box, { flexDirection: "column", children: [_jsxs(Text, { ...(selected ? { backgroundColor: C.panel } : {}), children: [_jsx(Text, { color: selected ? C.accent2 : C.dim, children: selected ? "▸ " : " " }), toggle(enabled), _jsxs(Text, { color: C.text, bold: true, children: [" ", p.plugin?.name ?? p.id] }), _jsxs(Text, { color: C.dim, children: [" ", p.plugin?.version ? `v${p.plugin.version} · ` : "", p.scope, parts.length ? ` · ${parts.join(", ")}` : ""] })] }), state === "changed"
227
+ return (_jsxs(Box, { flexDirection: "column", children: [_jsxs(Text, { ...(selected ? { backgroundColor: C.panel } : {}), children: [_jsx(Text, { color: selected ? C.accent2 : C.dim, children: selected ? "▸ " : " " }), p.scope === "starter" ? _jsx(Text, { color: C.accent2, bold: true, children: "+ ADD" }) : toggle(enabled), _jsxs(Text, { color: C.text, bold: true, children: [" ", p.plugin?.name ?? p.id] }), _jsxs(Text, { color: C.dim, children: [" ", p.plugin?.version ? `v${p.plugin.version} · ` : "", p.scope === "starter" ? "starter, optional" : p.scope, parts.length ? ` · ${parts.join(", ")}` : ""] })] }), state === "changed"
156
228
  ? _jsx(Text, { color: C.yellow, wrap: "truncate-end", children: " \u26A0 Changed since you enabled it. Review the code, then press Enter to enable again." })
157
229
  : p.error
158
230
  ? _jsxs(Text, { color: C.red, wrap: "truncate-end", children: [" \u2716 ", p.error] })
159
231
  : _jsxs(Text, { color: C.dim, wrap: "truncate-end", children: [" ", p.plugin?.description ?? (enabled ? "" : "Enable to load it.")] })] }, p.id));
160
- }), _jsx(Text, { children: " " }), _jsxs(Text, { color: C.dim, wrap: "truncate-end", children: ["Add your own: drop a .js/.mjs file into ", PLUGIN_DIRS.map((d) => d.dir).join(" or "), ", then press r."] }), _jsx(Text, { color: C.dim, wrap: "truncate-end", children: "Example: examples/plugins/word-count.mjs in the Nou repo." })] }));
232
+ }), _jsx(Text, { children: " " }), _jsxs(Text, { color: C.dim, wrap: "truncate-end", children: ["Starters: Enter adds one to ", PLUGIN_DIRS.find((d) => d.scope === "user").dir, ", where you can edit it."] }), _jsxs(Text, { color: C.dim, wrap: "truncate-end", children: ["Write your own: /plugins new <name>, or drop a .js/.mjs file into ", PLUGIN_DIRS.map((d) => d.dir).join(" or "), ", then press r."] })] }));
161
233
  };
162
234
  const hints = tab === "Model"
163
- ? column === "models" ? "↑↓ choose · Enter use model · ← providers" : "↑↓ provider · Enter/→ pick a model"
164
- : tab === "Preferences" ? "↑↓ move · Enter/Space toggle · ←→ change option" : "↑↓ move · Enter toggle · r rescan";
235
+ ? connecting ? "Enter next · Esc cancel" : column === "models" ? `↑↓ choose · Enter use model · ← providers${provider.connect ? " · k change key" : ""}` : `↑↓ provider · Enter/→ ${provider.unavailable() ? "connect" : "pick a model"}${provider.connect ? " · k change key" : ""}`
236
+ : tab === "Engine" ? "↑↓ move · Enter use this engine" : tab === "Preferences" ? "↑↓ move · Enter/Space toggle · ←→ change option" : "↑↓ move · Enter toggle · r rescan";
165
237
  return (_jsxs(Box, { flexDirection: "column", width: width, height: height, paddingX: 1, children: [_jsxs(Text, { children: [_jsx(Text, { color: C.accent2, bold: true, children: "\u2699 Settings " }), TABS.map((t) => (_jsx(Text, { ...(t === tab ? { backgroundColor: C.accent2, color: "black" } : { color: C.dim }), bold: t === tab, children: ` ${t} ` }, t))), _jsx(Text, { color: C.dim, children: " current: " }), _jsx(Text, { color: C.green, children: describe(choice) }), _jsxs(Text, { color: C.dim, children: [" nou v", currentVersion()] })] }), _jsx(Text, { children: " " }), body(), _jsxs(Text, { color: C.dim, children: [" Tab next section \u00B7 ", hints, " \u00B7 Esc close ", _jsx(Text, { color: C.dim, children: "saved to ~/.nou/config.json" })] })] }));
166
238
  }
239
+ /** What a connect field starts with: the current URL to edit (keys stay hidden, so they start empty). */
240
+ function initialValue(f) {
241
+ return f.secret ? "" : process.env[f.env] ?? "";
242
+ }
243
+ /** Whether a provider error means the API key is wrong (rather than, say, the server being down). */
244
+ const rejectedKey = (error) => /\b(401|403)\b|api key|unauthori[sz]ed|authentication/i.test(error);
@@ -98,3 +98,15 @@ export function changedLines(before, after) {
98
98
  }
99
99
  return lines;
100
100
  }
101
+ /** Lines added and removed, for summaries like "+12 -3". */
102
+ export function lineCounts(before, after) {
103
+ let added = 0;
104
+ let removed = 0;
105
+ for (const part of diffLines(before.replace(/\r\n/g, "\n"), after.replace(/\r\n/g, "\n"))) {
106
+ if (part.added)
107
+ added += part.count ?? 0;
108
+ else if (part.removed)
109
+ removed += part.count ?? 0;
110
+ }
111
+ return { added, removed };
112
+ }
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "nou-cli",
3
- "version": "0.2.1",
3
+ "version": "0.3.0",
4
4
  "description": "Nou: an IDE-style AI coding assistant for your terminal. Local (Ollama, LM Studio) and cloud (Claude, OpenAI, Gemini...) models, live diffs, git-aware, built-in editor.",
5
5
  "type": "module",
6
6
  "bin": {
@@ -8,6 +8,7 @@
8
8
  },
9
9
  "files": [
10
10
  "dist",
11
+ "plugins",
11
12
  ".env.example",
12
13
  "README.md"
13
14
  ],
@@ -19,7 +20,8 @@
19
20
  "build": "tsc -p tsconfig.build.json && node scripts/copy-grammars.mjs",
20
21
  "start": "node dist/index.js",
21
22
  "typecheck": "tsc --noEmit -p .",
22
- "lint": "eslint index.ts src",
23
+ "lint": "eslint index.ts src evals plugins",
24
+ "eval": "tsx evals/run.ts",
23
25
  "prepublishOnly": "npm run build",
24
26
  "screenshot": "node -e \"process.env.FORCE_COLOR='3';require('child_process').execSync('npx tsx scripts/screenshot.tsx',{stdio:'inherit'})\"",
25
27
  "test": "tsx tests/unit.ts && tsx tests/e2e.tsx"
@@ -0,0 +1,82 @@
1
+ // Starter plugin: lets the model read a web page (docs, an issue, a changelog) as plain text.
2
+ const MAX_CHARS = 20_000;
3
+ const TIMEOUT_MS = 15_000;
4
+
5
+ const ENTITIES = { nbsp: " ", lt: "<", gt: ">", quot: "\"", "#39": "'", amp: "&" };
6
+
7
+ /** Elements whose contents aren't page text. */
8
+ const SKIPPED = new Set(["script", "style", "noscript", "svg"]);
9
+ /** Tags that end a line of text. */
10
+ const BREAKS = new Set(["br", "/p", "/div", "/li", "/tr", "/h1", "/h2", "/h3", "/h4", "/h5", "/h6"]);
11
+
12
+ /**
13
+ * Keeps the text between tags. Every "<" starts a tag (page text writes a literal one as &lt;), which
14
+ * runs to the next ">"; skipped elements lose their contents too. Nothing tag-like can survive.
15
+ */
16
+ function stripTags(html) {
17
+ const lower = html.toLowerCase();
18
+ let out = "";
19
+ let i = 0;
20
+ while (i < html.length) {
21
+ const open = lower.indexOf("<", i);
22
+ if (open < 0) return out + html.slice(i);
23
+ out += html.slice(i, open);
24
+ const end = lower.indexOf(">", open);
25
+ if (end < 0) return out; // a tag that never closes: the rest is inside it
26
+ // "p" for <p class=x>, "/p" for </p>, "br" for <br/>.
27
+ const inner = lower.slice(open + 1, end);
28
+ const closing = inner.startsWith("/");
29
+ const name = `${closing ? "/" : ""}${inner.slice(closing ? 1 : 0).split(/[\s/]/)[0]}`;
30
+ if (BREAKS.has(name)) out += "\n";
31
+ i = end + 1;
32
+ if (SKIPPED.has(name)) {
33
+ const close = lower.indexOf(`</${name}`, i);
34
+ if (close < 0) return out; // never closed: the rest is its contents
35
+ const closeEnd = lower.indexOf(">", close);
36
+ i = closeEnd < 0 ? html.length : closeEnd + 1;
37
+ }
38
+ }
39
+ return out;
40
+ }
41
+
42
+ /** Rough HTML to text: drops scripts, styles and tags, keeps the words. */
43
+ export function htmlToText(html) {
44
+ // Entities after the tags, in one pass, so "&amp;lt;" becomes "&lt;", not "<".
45
+ return stripTags(html)
46
+ .replace(/&(nbsp|lt|gt|quot|#39|amp);/g, (_, name) => ENTITIES[name])
47
+ .replace(/[ \t]+/g, " ")
48
+ .replace(/\n\s*\n+/g, "\n\n")
49
+ .trim();
50
+ }
51
+
52
+ async function fetchText(url) {
53
+ if (!/^https?:\/\//i.test(url)) throw new Error("only http(s) URLs");
54
+ const response = await fetch(url, { signal: AbortSignal.timeout(TIMEOUT_MS), headers: { "user-agent": "nou-fetch-url" } });
55
+ const type = response.headers.get("content-type") ?? "";
56
+ const body = await response.text();
57
+ const text = type.includes("html") ? htmlToText(body) : body;
58
+ const cut = text.length > MAX_CHARS ? `${text.slice(0, MAX_CHARS)}\n[... ${text.length - MAX_CHARS} more chars cut]` : text;
59
+ return `${response.status} ${response.statusText} · ${url}\n\n${cut}`;
60
+ }
61
+
62
+ export default {
63
+ name: "Fetch URL",
64
+ version: "0.1.0",
65
+ description: "Adds a fetch_url tool so the model can read docs and web pages (asks first).",
66
+
67
+ tools: [
68
+ {
69
+ // Not read-only: it reaches the network, so you approve each URL.
70
+ definition: {
71
+ name: "fetch_url",
72
+ description: "Fetch a web page or file over http(s) and return it as text",
73
+ parameters: {
74
+ type: "object",
75
+ properties: { url: { type: "string", description: "The http(s) URL" } },
76
+ required: ["url"],
77
+ },
78
+ },
79
+ run: (args) => fetchText(String(args.url)),
80
+ },
81
+ ],
82
+ };
@@ -0,0 +1,43 @@
1
+ // Starter plugin: gives the model the instructions you already wrote for other coding agents
2
+ // (AGENTS.md, CLAUDE.md, Cursor and Copilot rules), so you don't have to repeat them in NOU.md.
3
+ import { existsSync, readFileSync } from "fs";
4
+ import path from "path";
5
+ import process from "process";
6
+
7
+ const FILES = ["AGENTS.md", "CLAUDE.md", "GEMINI.md", ".cursorrules", ".github/copilot-instructions.md"];
8
+ const MAX_CHARS = 8_000;
9
+
10
+ function readRules(cwd) {
11
+ return FILES.filter((file) => existsSync(path.join(cwd, file)))
12
+ .map((file) => {
13
+ const text = readFileSync(path.join(cwd, file), "utf-8").trim();
14
+ return { file, text: text.length > MAX_CHARS ? `${text.slice(0, MAX_CHARS)}\n[... cut]` : text };
15
+ })
16
+ .filter((rule) => rule.text);
17
+ }
18
+
19
+ export default {
20
+ name: "Project rules",
21
+ version: "0.1.0",
22
+ description: "Reads AGENTS.md, CLAUDE.md, .cursorrules and Copilot instructions into the system prompt. /rules lists them.",
23
+
24
+ // A getter, so edits to those files are picked up whenever the prompt is rebuilt.
25
+ get systemPrompt() {
26
+ const rules = readRules(process.cwd());
27
+ if (!rules.length) return undefined;
28
+ return `Follow the project's own instructions:\n${rules.map((r) => `--- ${r.file} ---\n${r.text}`).join("\n\n")}`;
29
+ },
30
+
31
+ commands: [
32
+ {
33
+ name: "rules",
34
+ description: "Show which instruction files are in use",
35
+ run(args, ctx) {
36
+ const rules = readRules(ctx.cwd);
37
+ return rules.length
38
+ ? `Using: ${rules.map((r) => `${r.file} (${r.text.length} chars)`).join(", ")}`
39
+ : `None found. Looked for ${FILES.join(", ")}.`;
40
+ },
41
+ },
42
+ ],
43
+ };
@@ -0,0 +1,89 @@
1
+ // Starter plugin: lets the model (and you, with /test) run the project's tests.
2
+ // It's yours to edit: set TEST_COMMAND if the guess below picks the wrong command.
3
+ import { spawn } from "child_process";
4
+ import { existsSync, readFileSync } from "fs";
5
+ import path from "path";
6
+ import process from "process";
7
+
8
+ /** e.g. "npm run test:unit". Leave null to guess from the project files. */
9
+ const TEST_COMMAND = null;
10
+ const TIMEOUT_MS = 5 * 60 * 1000;
11
+ const MAX_OUTPUT = 12_000;
12
+
13
+ function guessCommand(cwd) {
14
+ const has = (file) => existsSync(path.join(cwd, file));
15
+ if (has("package.json")) {
16
+ const test = JSON.parse(readFileSync(path.join(cwd, "package.json"), "utf-8")).scripts?.test;
17
+ if (test && !test.includes("no test specified")) {
18
+ if (has("pnpm-lock.yaml")) return "pnpm test";
19
+ if (has("yarn.lock")) return "yarn test";
20
+ if (has("bun.lock") || has("bun.lockb")) return "bun test";
21
+ return "npm test";
22
+ }
23
+ }
24
+ if (has("Cargo.toml")) return "cargo test";
25
+ if (has("go.mod")) return "go test ./...";
26
+ if (has("pyproject.toml") || has("pytest.ini") || has("setup.py") || has("tox.ini")) return "python -m pytest -q";
27
+ if (has("Makefile") && /^test:/m.test(readFileSync(path.join(cwd, "Makefile"), "utf-8"))) return "make test";
28
+ return null;
29
+ }
30
+
31
+ /** Runs the tests; extra arguments (a file, a filter) go after the command. */
32
+ function runTests(cwd, extra) {
33
+ const base = TEST_COMMAND ?? guessCommand(cwd);
34
+ if (!base) return Promise.resolve({ command: null, code: null, output: "Couldn't tell how to run this project's tests. Set TEST_COMMAND in the run-tests plugin." });
35
+ const separator = extra && /^(npm|pnpm) /.test(base) ? " --" : "";
36
+ const command = extra ? `${base}${separator} ${extra}` : base;
37
+ return new Promise((resolve) => {
38
+ let output = "";
39
+ const child = spawn(command, { cwd, shell: true, env: { ...process.env, CI: "1", FORCE_COLOR: "0" } });
40
+ const timer = setTimeout(() => child.kill(), TIMEOUT_MS);
41
+ child.stdout.on("data", (d) => (output += d));
42
+ child.stderr.on("data", (d) => (output += d));
43
+ child.on("error", (error) => (output += `\n${error.message}`));
44
+ child.on("close", (code) => {
45
+ clearTimeout(timer);
46
+ // The end of a test run (failures, summary) matters most.
47
+ const text = output.length > MAX_OUTPUT ? `[... ${output.length - MAX_OUTPUT} chars cut]\n${output.slice(-MAX_OUTPUT)}` : output;
48
+ resolve({ command, code, output: text.trim() });
49
+ });
50
+ });
51
+ }
52
+
53
+ export default {
54
+ name: "Run tests",
55
+ version: "0.1.0",
56
+ description: "Adds a run_tests tool and /test. Works out npm/pnpm/yarn/bun, cargo, go, pytest or make.",
57
+ systemPrompt: "After changing code, use run_tests to check your work, and fix any failures you caused.",
58
+
59
+ tools: [
60
+ {
61
+ // Running tests runs project code, so you approve each run.
62
+ definition: {
63
+ name: "run_tests",
64
+ description: "Run the project's test suite (or part of it) and return the output",
65
+ parameters: {
66
+ type: "object",
67
+ properties: { args: { type: "string", description: "Optional extra arguments, e.g. a test file or name filter" } },
68
+ },
69
+ },
70
+ async run(args, ctx) {
71
+ const { command, code, output } = await runTests(ctx.cwd, args.args?.trim());
72
+ return command ? `$ ${command}\nexit code ${code}\n${output}` : output;
73
+ },
74
+ },
75
+ ],
76
+
77
+ commands: [
78
+ {
79
+ name: "test",
80
+ description: "Run the tests: /test [args]",
81
+ async run(args, ctx) {
82
+ const { command, code, output } = await runTests(ctx.cwd, args.trim());
83
+ if (!command) return output;
84
+ const tail = output.split("\n").slice(-25).join("\n");
85
+ return `${code === 0 ? "✓ passed" : `✗ failed (exit ${code})`}: ${command}\n${tail}`;
86
+ },
87
+ },
88
+ ],
89
+ };
@@ -0,0 +1,81 @@
1
+ // Starter plugin: finds TODO / FIXME / HACK comments, for you (/todos) and the model (find_todos).
2
+ import { execFileSync } from "child_process";
3
+ import { readdirSync, readFileSync, statSync } from "fs";
4
+ import path from "path";
5
+
6
+ const MARKERS = /\b(TODO|FIXME|HACK|XXX)\b:?\s*(.*)/;
7
+ const SKIP_DIRS = new Set(["node_modules", ".git", "dist", "build", "out", "target", "vendor", ".venv", "__pycache__", "coverage"]);
8
+ const MAX_FILE_BYTES = 1_000_000;
9
+ const MAX_RESULTS = 200;
10
+
11
+ /** Files git knows about (so .gitignore is respected), or a plain walk outside a repo. */
12
+ function listFiles(cwd, under) {
13
+ try {
14
+ const out = execFileSync("git", ["ls-files", "-co", "--exclude-standard", "--", under || "."], { cwd, encoding: "utf-8", stdio: ["ignore", "pipe", "ignore"] });
15
+ return out.split("\n").filter(Boolean);
16
+ } catch {
17
+ const files = [];
18
+ const walk = (dir) => {
19
+ for (const name of readdirSync(path.join(cwd, dir))) {
20
+ if (SKIP_DIRS.has(name) || files.length > 20_000) continue;
21
+ const rel = path.join(dir, name);
22
+ if (statSync(path.join(cwd, rel)).isDirectory()) walk(rel);
23
+ else files.push(rel);
24
+ }
25
+ };
26
+ walk(under || ".");
27
+ return files;
28
+ }
29
+ }
30
+
31
+ function findTodos(cwd, under) {
32
+ const found = [];
33
+ for (const file of listFiles(cwd, under)) {
34
+ if (found.length >= MAX_RESULTS) break;
35
+ try {
36
+ const full = path.join(cwd, file);
37
+ if (statSync(full).size > MAX_FILE_BYTES) continue;
38
+ const text = readFileSync(full, "utf-8");
39
+ if (text.includes("\0")) continue; // binary
40
+ text.split("\n").forEach((line, i) => {
41
+ const match = MARKERS.exec(line);
42
+ if (match && found.length < MAX_RESULTS) found.push(`${file.replaceAll("\\", "/")}:${i + 1} ${match[1]} ${match[2].trim()}`);
43
+ });
44
+ } catch {
45
+ // unreadable or deleted: skip
46
+ }
47
+ }
48
+ return found;
49
+ }
50
+
51
+ const report = (found) =>
52
+ found.length ? `${found.length}${found.length === MAX_RESULTS ? "+" : ""} found:\n${found.join("\n")}` : "No TODO/FIXME/HACK comments found.";
53
+
54
+ export default {
55
+ name: "TODOs",
56
+ version: "0.1.0",
57
+ description: "Lists TODO / FIXME / HACK comments. Adds a find_todos tool and /todos.",
58
+
59
+ tools: [
60
+ {
61
+ readOnly: true,
62
+ definition: {
63
+ name: "find_todos",
64
+ description: "List TODO, FIXME, HACK and XXX comments in the project (file:line and the note)",
65
+ parameters: {
66
+ type: "object",
67
+ properties: { path: { type: "string", description: "Optional folder to search, relative to the project" } },
68
+ },
69
+ },
70
+ run: (args, ctx) => report(findTodos(ctx.cwd, args.path)),
71
+ },
72
+ ],
73
+
74
+ commands: [
75
+ {
76
+ name: "todos",
77
+ description: "List TODO/FIXME comments: /todos [folder]",
78
+ run: (args, ctx) => report(findTodos(ctx.cwd, args.trim())),
79
+ },
80
+ ],
81
+ };