nou-cli 0.5.14 → 0.5.16

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 CHANGED
@@ -104,7 +104,7 @@ Every change is reviewed like a code review:
104
104
  | <kbd>u</kbd> | undo Nou's last edit |
105
105
  | <kbd>v</kbd> | diff the open file vs git `HEAD`, Nou's last edit, or this session · <kbd>n</kbd>/<kbd>N</kbd> jump between changes · <kbd>[</kbd>/<kbd>]</kbd> previous/next file in a session diff |
106
106
  | <kbd>c</kbd> | in the file tree: show only changed files |
107
- | <kbd>f</kbd> | follow mode (off by default): watch along as Nou works. Each file it reads (the exact lines, highlighted) is shown a step at a time, a little behind it, and its edits as they happen. The status bar shows how many steps behind it is; set the speed in settings → Preferences (slow · normal · fast · instant) |
107
+ | <kbd>f</kbd> or `/follow [on\|off]` | follow mode (off by default, and your choice is remembered): watch along as Nou works, with any engine (Codex's `sed`/`cat` reads count too). Each file it reads (the exact lines, highlighted) is shown a step at a time, a little behind it, and its edits as they happen. The status bar shows how many steps behind it is; set the speed in settings → Preferences (slow · normal · fast · instant) |
108
108
  | <kbd>&lt;</kbd> / <kbd>&gt;</kbd> / <kbd>=</kbd> | make the focused pane narrower / wider / reset the layout |
109
109
  | <kbd>`</kbd> | a terminal under the code, like VS Code's (also `/terminal`): Enter runs a command, Ctrl+C stops it, ↑↓ history, Esc back to the code, × or `exit` closes it; drag its top border (or <kbd>Alt</kbd>+↑/↓) to make it taller or shorter. Commands run through your shell one at a time, so full-screen programs like vim don't draw there |
110
110
  | <kbd>z</kbd> | in the code view: fold every function and block to its first line, or open them all again (also `/fold`, `/unfold`); click a ▾/▸ beside a line number to fold or open just that one |
package/dist/src/cli.js CHANGED
@@ -16,9 +16,10 @@ import { installFastOutput } from "./ui/fast-output.js";
16
16
  import { RENDER_OPTIONS } from "./ui/render-options.js";
17
17
  import { setScreenClear } from "./ui/redraw.js";
18
18
  import { detectTerminal } from "./ui/background.js";
19
+ import { setTerminalTitle } from "./ui/title.js";
19
20
  import { setPalette } from "./ui/theme.js";
20
21
  import { runHost, workspaceFolder } from "./workspaces/host.js";
21
- import { pendingWorkspaces } from "./workspaces/client.js";
22
+ import { isHosted, pendingWorkspaces } from "./workspaces/client.js";
22
23
  import { spawn } from "child_process";
23
24
  import { appendFileSync, mkdirSync } from "fs";
24
25
  import path from "path";
@@ -97,6 +98,7 @@ async function main() {
97
98
  const uiTheme = loadSettings().uiTheme;
98
99
  // Every workspace uses the colors found for this terminal (they can't ask it themselves from the background).
99
100
  // The host asks the terminal once and tells every workspace (in the background they can't ask it).
101
+ setTerminalTitle();
100
102
  const term = await detectTerminal();
101
103
  process.env.NOU_PALETTE = uiTheme === "dark" || uiTheme === "light" ? uiTheme : term.palette ?? "dark";
102
104
  if (term.kitty !== null)
@@ -108,6 +110,9 @@ async function main() {
108
110
  console.error("Nou needs an interactive terminal (try Windows Terminal, PowerShell, or Terminal/iTerm on macOS).");
109
111
  process.exit(1);
110
112
  }
113
+ // The tab says Nou CLI, not the shell it runs in (a workspace's host already set it).
114
+ if (!isHosted())
115
+ setTerminalTitle();
111
116
  p.intro(chalk.cyan(`${artWork} \n Hello I am Nou! 🌞 ${chalk.dim(`v${currentVersion()}`)}`));
112
117
  // A folder's files can run things (its MCP servers, plugins, the commands Nou is asked to run), so a
113
118
  // folder is trusted once, like Claude Code and VS Code ask.
@@ -1,7 +1,7 @@
1
1
  import { readFileSync } from "fs";
2
2
  import path from "path";
3
3
  import { toRelative } from "../commands/index.js";
4
- import { ExternalEngine, replaceIn } from "./external.js";
4
+ import { ExternalEngine, readsInCommand, replaceIn } from "./external.js";
5
5
  import { loadPackage } from "./index.js";
6
6
  const EFFORTS = new Set(["low", "medium", "high", "xhigh", "max"]);
7
7
  /** Claude Code tools that change files; Nou previews and approves them. */
@@ -95,8 +95,16 @@ export class ClaudeCodeEngine extends ExternalEngine {
95
95
  ? { hookSpecificOutput: { hookEventName: "PostToolUse", additionalContext: `This edit introduced problems; fix them:\n${problems}` } }
96
96
  : {};
97
97
  }
98
- if (tool === "Read" && args.file_path)
99
- this.hooks.onRead(toRelative(args.file_path));
98
+ if (tool === "Read" && args.file_path) {
99
+ // A part of the file (offset and limit, in lines): follow mode shows those lines.
100
+ const from = typeof args.offset === "number" ? Math.max(1, args.offset) : undefined;
101
+ const to = from && typeof args.limit === "number" ? from + args.limit - 1 : undefined;
102
+ this.hooks.onRead(toRelative(args.file_path), from, to);
103
+ }
104
+ if ((tool === "Bash" || tool === "PowerShell") && typeof args.command === "string") {
105
+ for (const read of readsInCommand(args.command))
106
+ this.hooks.onRead(read.path, read.line, read.to);
107
+ }
100
108
  this.hooks.onEntry({ kind: "tool", text: tool === "Bash" || tool === "PowerShell" ? `Ran: ${args.command}` : describeTool(tool, args) });
101
109
  return {};
102
110
  };
@@ -149,6 +157,9 @@ export class ClaudeCodeEngine extends ExternalEngine {
149
157
  if (!signal.aborted)
150
158
  this.hooks.onEntry({ kind: "error", text: `Claude Code: ${detail}` });
151
159
  }
160
+ // The turn is over: don't wait for Claude Code's process to close its stream as well. Something it
161
+ // left running (a background command, an MCP server) kept that open, and Nou "working" forever.
162
+ break;
152
163
  }
153
164
  }
154
165
  if (signal.aborted)
@@ -3,7 +3,7 @@ import { realpathSync, statSync } from "fs";
3
3
  import path from "path";
4
4
  import { transientMessage } from "../agent/index.js";
5
5
  import { listAllFiles } from "../ui/QuickOpen.js";
6
- import { ExternalEngine } from "./external.js";
6
+ import { ExternalEngine, readsInCommand } from "./external.js";
7
7
  import { loadPackage } from "./index.js";
8
8
  const EFFORTS = new Set(["low", "medium", "high", "xhigh", "max"]);
9
9
  const MAX_FILES = 5000;
@@ -166,6 +166,9 @@ export class CodexEngine extends ExternalEngine {
166
166
  }
167
167
  else if (event.type === "item.started" && event.item.type === "command_execution") {
168
168
  this.hooks.onBusy(`Codex: ${event.item.command}`);
169
+ // Codex reads files with shell commands: follow mode shows them as it would a read.
170
+ for (const read of readsInCommand(event.item.command))
171
+ this.hooks.onRead(read.path, read.line, read.to);
169
172
  }
170
173
  else if (event.type === "item.completed") {
171
174
  const item = event.item;
@@ -1,4 +1,4 @@
1
- import { readFileSync } from "fs";
1
+ import { readFileSync, statSync } from "fs";
2
2
  import path from "path";
3
3
  import { newSessionId } from "../agent/index.js";
4
4
  import { isInProject, toRelative } from "../commands/index.js";
@@ -10,6 +10,53 @@ function readOrNull(filePath) {
10
10
  return null;
11
11
  }
12
12
  }
13
+ /**
14
+ * The files a shell command reads, so follow mode can show them: engines like Codex read with `sed -n
15
+ * '10,80p' f`, `cat f`, `nl -ba f`, `head -n 40 f` instead of a read tool. Only files that exist in the
16
+ * project count; anything it can't make sense of is no read.
17
+ */
18
+ export function readsInCommand(command) {
19
+ // Codex runs `bash -lc '<script>'`: look inside the quotes.
20
+ const wrapped = command.match(/^\S*(?:bash|sh|zsh)\s+-l?c\s+(['"])([\s\S]*)\1\s*$/);
21
+ const script = wrapped ? wrapped[2] : command;
22
+ const reads = [];
23
+ for (const part of script.split(/\s*(?:&&|\|\||;|\||\n)\s*/)) {
24
+ const words = (part.match(/'[^']*'|"[^"]*"|\S+/g) ?? []).map((w) => w.replace(/^(['"])(.*)\1$/, "$2"));
25
+ const [tool, ...args] = words;
26
+ if (!tool)
27
+ continue;
28
+ const files = args.filter((a) => !a.startsWith("-") && /[./]|\w\.\w/.test(a));
29
+ let range = {};
30
+ if (tool === "sed" && args.includes("-n")) {
31
+ const m = args.join(" ").match(/(\d+)\s*,\s*(\d+)\s*p/) ?? args.join(" ").match(/(\d+)p/);
32
+ if (!m)
33
+ continue;
34
+ range = { line: Number(m[1]), to: Number(m[2] ?? m[1]) };
35
+ files.splice(0, files.length, ...files.filter((f) => !/^\d+(,\d+)?p$/.test(f)));
36
+ // `nl -ba f | sed -n '5,9p'`: the lines are of the file read just before it in the pipe.
37
+ const last = reads.at(-1);
38
+ if (!files.length && last && last.line === undefined)
39
+ Object.assign(last, range);
40
+ }
41
+ else if (tool === "head") {
42
+ const n = Number(args.join(" ").match(/-n\s*(\d+)|-(\d+)/)?.slice(1).find(Boolean) ?? 10);
43
+ range = { line: 1, to: n };
44
+ }
45
+ else if (!["cat", "nl", "bat", "less", "more", "tail"].includes(tool))
46
+ continue;
47
+ for (const file of files) {
48
+ try {
49
+ if (!statSync(path.resolve(process.cwd(), file)).isFile() || !isInProject(file))
50
+ continue;
51
+ }
52
+ catch {
53
+ continue;
54
+ }
55
+ reads.push({ path: toRelative(file), ...range });
56
+ }
57
+ }
58
+ return reads;
59
+ }
13
60
  /** Replaces `oldText` with `newText`, as edit tools do; null if it isn't there (the tool will fail on its own). */
14
61
  export function replaceIn(text, oldText, newText, all = false) {
15
62
  if (!oldText || !text.includes(oldText))
@@ -148,8 +148,25 @@ export function showPlacesTool(onStops) {
148
148
  },
149
149
  };
150
150
  }
151
- /** The fenced block other engines (which can't call Nou's tools) list their places in. */
152
- const SHOW_BLOCK = /```nou-show\s*\n([\s\S]*?)\n```/;
151
+ /**
152
+ * The fenced block other engines (which can't call Nou's tools) list their places in. Models also write it
153
+ * with ~~~, with CRLF, with the closing fence on the JSON's last line, or (unfenced) as a "nou-show" line
154
+ * followed by the JSON array at the end of the reply.
155
+ */
156
+ const SHOW_BLOCK = /(?:```|~~~)[ \t]*nou-show[ \t]*\r?\n([\s\S]*?)\r?\n?[ \t]*(?:```|~~~)|(?:^|\n)[ \t]*nou-show[ \t]*\r?\n[ \t]*(\[[\s\S]*\])\s*$/;
157
+ /**
158
+ * A reply as the chat shows it: Nou's blocks (nou-show, nou-steps) are for Nou, not you, so they're never
159
+ * drawn, including one still arriving mid-stream.
160
+ */
161
+ export function hideNouBlocks(text) {
162
+ if (!text.includes("nou-"))
163
+ return text;
164
+ return text
165
+ .replace(/(?:```|~~~)[ \t]*nou-(?:show|steps)[ \t]*\r?\n[\s\S]*?(?:```|~~~)/g, "")
166
+ .replace(/(?:```|~~~)[ \t]*nou-(?:show|steps)[\s\S]*$/, "")
167
+ .replace(/(?:^|\n)[ \t]*nou-(?:show|steps)[ \t]*\r?\n[ \t]*\[[\s\S]*$/, "")
168
+ .trimEnd();
169
+ }
153
170
  /**
154
171
  * For Claude Code, Codex and pi, which can't call show_places: how to point at places, added to what they
155
172
  * know about the project, so explaining "where does X happen" opens the files on any engine.
@@ -166,7 +183,7 @@ export function stopsFromReply(text) {
166
183
  if (!m)
167
184
  return null;
168
185
  try {
169
- const stops = tidyStops(JSON.parse(m[1]));
186
+ const stops = tidyStops(JSON.parse((m[1] ?? m[2])));
170
187
  return { stops, rest: text.replace(m[0], "").trim() };
171
188
  }
172
189
  catch {
@@ -1457,6 +1457,17 @@ export function App({ initialModel, permContext, resume }) {
1457
1457
  if (!followTimer.current)
1458
1458
  playFollow();
1459
1459
  };
1460
+ /** Turns follow mode on or off (`to`), or flips it; remembered for next time, and the chat says what it does. */
1461
+ const toggleFollow = (to) => {
1462
+ const on = to ?? !followRef.current;
1463
+ followRef.current = on;
1464
+ setFollow(on);
1465
+ if (settingsRef.current.followMode !== on)
1466
+ updateSettings({ followMode: on });
1467
+ info(on
1468
+ ? "Follow on: the code view now shows each file Nou reads or edits as it works (f, the follow chip or /follow off to stop)."
1469
+ : "Follow off: the code view stays on what you have open. Edits waiting for your OK still show.");
1470
+ };
1460
1471
  const followStepRef = useRef(followStep);
1461
1472
  followStepRef.current = followStep;
1462
1473
  useEffect(() => {
@@ -1570,7 +1581,7 @@ export function App({ initialModel, permContext, resume }) {
1570
1581
  setChatPrefill((p) => ({ text: "/", nonce: (p?.nonce ?? 0) + 1 }));
1571
1582
  }
1572
1583
  else if (input === "f")
1573
- setFollow((f) => !f);
1584
+ toggleFollow();
1574
1585
  else if (input === "m")
1575
1586
  toggleMouse();
1576
1587
  else if (input === "`")
@@ -1752,6 +1763,12 @@ ${snippet}
1752
1763
  return info(`No file matches "${wanted}". To find where something is in the code, ask in the chat.`, "error");
1753
1764
  return openRef({ path: file, ...(m[2] ? { line: Number(m[2]) } : {}), ...(m[3] ? { to: Number(m[3]) } : {}) });
1754
1765
  }
1766
+ case "follow": {
1767
+ const want = arg.trim().toLowerCase();
1768
+ if (want && !/^(on|off)$/.test(want))
1769
+ return info("Use /follow on or /follow off (or just /follow to switch).", "error");
1770
+ return toggleFollow(want ? want === "on" : undefined);
1771
+ }
1755
1772
  case "effort": {
1756
1773
  // Speed vs smarts, by name: /effort smarter (or the effort itself: /effort xhigh).
1757
1774
  const options = EFFORTS.map((e) => EFFORT_LABELS[e].name.toLowerCase()).join(", ");
@@ -2549,7 +2566,7 @@ ${snippet}
2549
2566
  { title: "Ask before every edit and command", keys: "Shift+Tab", run: () => cycleAutoMode("ask") },
2550
2567
  { title: "Auto-edit: apply edits without asking", keys: "Shift+Tab", run: () => cycleAutoMode("auto-edit") },
2551
2568
  { title: "Auto: edits and commands without asking", keys: "Shift+Tab", run: () => cycleAutoMode("auto") },
2552
- { title: `Follow mode ${follow ? "off" : "on"}: jump to files Nou works on`, keys: "f", run: () => setFollow((f) => !f) },
2569
+ { title: `Follow mode ${follow ? "off" : "on"}: jump to files Nou works on`, keys: "f", run: () => toggleFollow() },
2553
2570
  { title: `Replay speed for follow mode: ${settings.followSpeed} (next: ${FOLLOW_SPEEDS[(FOLLOW_SPEEDS.indexOf(settings.followSpeed) + 1) % FOLLOW_SPEEDS.length]})`, keys: "settings", run: () => updateSettings({ followSpeed: FOLLOW_SPEEDS[(FOLLOW_SPEEDS.indexOf(settings.followSpeed) + 1) % FOLLOW_SPEEDS.length] }) },
2554
2571
  { title: `Mouse ${settings.mouse ? "off (select text in the terminal)" : "on"}`, keys: "m", run: toggleMouse },
2555
2572
  { title: "Copy the last reply", keys: "/copy", run: () => void runCommand("/copy") },
@@ -2625,7 +2642,7 @@ ${snippet}
2625
2642
  x += width;
2626
2643
  };
2627
2644
  add(` ${autoMode} `.length, () => cycleAutoMode());
2628
- add(followChip.length, () => setFollow((f) => !f));
2645
+ add(followChip.length, () => toggleFollow());
2629
2646
  add(` ✗ ${totals.errors} ▲ ${totals.warnings} `.length, toggleProblems);
2630
2647
  if (checking.length)
2631
2648
  add(3 + checking.join(", ").length);
@@ -1,4 +1,5 @@
1
1
  import { jsx as _jsx, jsxs as _jsxs, Fragment as _Fragment } from "react/jsx-runtime";
2
+ import { hideNouBlocks } from "../guide/index.js";
2
3
  import { memo, useEffect, useMemo, useRef, useState } from "react";
3
4
  import { Box, Text, useAnimation, useInput } from "ink";
4
5
  import { statSync } from "fs";
@@ -54,7 +55,7 @@ function entryLines(entry, inner, links = true) {
54
55
  }
55
56
  else if (entry.kind === "assistant") {
56
57
  out.push({ text: "◆ nou", color: C.accent2, bold: true, lead: BAR, leadColor: C.accent2 });
57
- for (const line of renderMarkdown(entry.text, inner - 3, links ? isProjectFile : undefined)) {
58
+ for (const line of renderMarkdown(hideNouBlocks(entry.text), inner - 3, links ? isProjectFile : undefined)) {
58
59
  const color = line.kind === "heading" ? C.accent2 : line.kind === "quote" ? C.dim : undefined;
59
60
  out.push({ text: "", spans: line.spans, ...(color ? { color } : {}), lead: BAR, leadColor: C.accent2 });
60
61
  }
@@ -192,6 +192,6 @@ export const FileTree = memo(function FileTree({ width, height, focused, openFil
192
192
  const room = Math.max(1, inner - marker.length - 1);
193
193
  const shown = label.length > room ? `${label.slice(0, room - 1)}…` : label.padEnd(room);
194
194
  const iconAt = shown.indexOf(icon);
195
- return (_jsxs(Text, { wrap: "truncate-end", ...(selected && focused ? { backgroundColor: C.panel } : {}), children: [_jsx(Text, { ...(nameColor ? { color: nameColor } : {}), bold: isOpen, children: shown.slice(0, iconAt) }), _jsx(Text, { color: color, children: shown.slice(iconAt, iconAt + icon.length) }), working?.has(node.path) ? (_jsx(Text, { bold: true, inverse: selected && focused, children: gradient(shown.slice(iconAt + icon.length), frame).map((g, j) => _jsx(Text, { color: g.color, children: g.ch }, j)) })) : (_jsx(Text, { ...(nameColor ? { color: nameColor } : {}), bold: isOpen, inverse: selected && focused, children: shown.slice(iconAt + icon.length) })), _jsxs(Text, { color: markerColor, children: [" ", marker] })] }, node.path));
195
+ return (_jsxs(Text, { wrap: "truncate-end", ...(isOpen ? { backgroundColor: C.openRow } : selected && focused ? { backgroundColor: C.panel } : {}), children: [_jsx(Text, { ...(nameColor ? { color: nameColor } : {}), bold: isOpen, children: shown.slice(0, iconAt) }), _jsx(Text, { color: color, children: shown.slice(iconAt, iconAt + icon.length) }), working?.has(node.path) ? (_jsx(Text, { bold: true, inverse: selected && focused, children: gradient(shown.slice(iconAt + icon.length), frame).map((g, j) => _jsx(Text, { color: g.color, children: g.ch }, j)) })) : (_jsx(Text, { ...(nameColor ? { color: nameColor } : {}), bold: isOpen, inverse: selected && focused, children: shown.slice(iconAt + icon.length) })), _jsxs(Text, { color: markerColor, children: [" ", marker] })] }, node.path));
196
196
  })] }), _jsx(Scrollbar, { total: nodes.length, top: start, rows: rows, focused: focused })] }) }));
197
197
  });
@@ -2,7 +2,7 @@
2
2
  * Commands that only change what's on screen (or handle a busy Nou themselves): they run right away
3
3
  * while Nou works instead of waiting in the queue behind it.
4
4
  */
5
- const INSTANT = new Set(["files", "actions", "palette", "zoom", "now", "fold", "unfold", "redraw", "new", "tab", "close", "terminal", "games", "open", "settings", "auto", "diff", "sessions", "history", "problems", "copy", "help", "version", "cancel"]);
5
+ const INSTANT = new Set(["follow", "files", "actions", "palette", "zoom", "now", "fold", "unfold", "redraw", "new", "tab", "close", "terminal", "games", "open", "settings", "auto", "diff", "sessions", "history", "problems", "copy", "help", "version", "cancel"]);
6
6
  /** Whether a typed command runs right away while Nou works (see INSTANT); `/showme` counts only to look or step. */
7
7
  /**
8
8
  * The command a mistyped name means: the same letters in another order ("/termnial"), or one letter off
@@ -51,6 +51,7 @@ export const SLASH_COMMANDS = [
51
51
  { name: "models", args: "[provider]", description: "list the models a provider offers" },
52
52
  { name: "connect", args: "<provider>", description: "enter an API key (and URL) for a provider" },
53
53
  { name: "open", args: "<file>[:line]", description: "open a file in the code view (part of its name is enough)" },
54
+ { name: "follow", args: "[on|off]", description: "the code view shows each file the agent reads or edits as it works" },
54
55
  { name: "effort", args: "[fastest|fast|balanced|smarter|smartest]", description: "speed vs smarts: how hard the model thinks (cheaper + faster, or smarter)" },
55
56
  { name: "workspace", args: "[new <folder> | n]", description: "several projects in this window, a tab each in the header: open one, or switch" },
56
57
  { name: "files", description: "find a file by part of its name (also Ctrl+P)" },
@@ -16,6 +16,8 @@ const DARK = {
16
16
  /** Lines around panes that aren't focused: quieter than text, since they aren't read. */
17
17
  border: "#565f89",
18
18
  panel: "#1f2335",
19
+ /** The band behind the file that's open, wherever it's listed: every name color stays 4.5:1 on it. */
20
+ openRow: "#2a3352",
19
21
  /** Text on a colored badge (CHAT, CLOUD, EDITING...). */
20
22
  onBadge: "#1a1b26",
21
23
  };
@@ -32,6 +34,7 @@ const LIGHT = {
32
34
  dim: "#5a607a",
33
35
  border: "#a8aecb",
34
36
  panel: "#e6e7ed",
37
+ openRow: "#edf1fc",
35
38
  onBadge: "#ffffff",
36
39
  };
37
40
  /** The colors in use: dark unless the terminal is light (see setPalette). Read at render time, so set it first. */
@@ -0,0 +1,16 @@
1
+ /**
2
+ * The terminal's window or tab title. Windows Terminal, iTerm2, Ghostty and the rest show the running shell
3
+ * ("Windows PowerShell") unless the program sets its own with OSC 0, which Nou does: "Nou CLI".
4
+ * The title before is saved (CSI 22;0t) and put back (CSI 23;0t) on the way out, in terminals that keep a
5
+ * title stack (xterm, kitty, Ghostty, WezTerm, iTerm2); the others ignore those codes, and the shell retitles
6
+ * its tab soon after.
7
+ */
8
+ export const TITLE = "Nou CLI";
9
+ let set = false;
10
+ export function setTerminalTitle(title = TITLE) {
11
+ if (set || !process.stdout.isTTY)
12
+ return;
13
+ set = true;
14
+ process.stdout.write(`\x1b[22;0t\x1b]0;${title}\x07`);
15
+ process.on("exit", () => process.stdout.write("\x1b[23;0t"));
16
+ }
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "nou-cli",
3
- "version": "0.5.14",
3
+ "version": "0.5.16",
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": {