amness 0.1.0 → 0.1.3

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,55 @@
1
+ // Completion for the words after a slash command: `/mode ` lists the modes, `/model ` the models your provider offers, `/theme ` the themes, and so on.
2
+ // A completer returns its options now and, when it needs the network, a promise that settles when the list has been filled in (the menu refreshes then).
3
+ import { effortsFor, loadPrompts, SANDBOX_MODES, SHELL_CHOICES, STRATEGIES } from "../../src/index.js";
4
+ import { CLAUDE_MODELS, OPENAI_MODELS } from "../config.js";
5
+ import { fetchModels } from "../models.js";
6
+ import { themeNames, THEMES } from "./themes.js";
7
+ const onOff = [{ value: "on" }, { value: "off" }];
8
+ const opts = (...xs) => xs.map(([value, detail]) => ({ value, detail }));
9
+ /** Known models with their hints first, then any other model the provider reports. The provider is asked once per ten minutes; until it answers, only the known models show. */
10
+ const fetched = new Map();
11
+ function modelOptions(a, extra = []) {
12
+ const known = a.cfg.provider === "anthropic" ? CLAUDE_MODELS : a.cfg.provider === "openai" ? OPENAI_MODELS : [];
13
+ const key = `${a.cfg.provider}|${a.cfg.baseUrl}`, have = fetched.get(key), fresh = have && Date.now() - have.at < 10 * 60_000;
14
+ const seen = new Set(known.map((m) => m.id));
15
+ const options = [...extra, ...known.map((m) => ({ value: m.id, detail: m.hint })), ...(have?.ids ?? []).filter((id) => !seen.has(id)).map((id) => ({ value: id, detail: "from your provider" }))];
16
+ if (fresh)
17
+ return { options, open: true };
18
+ return { options, open: true, pending: fetchModels({ provider: a.cfg.provider, baseUrl: a.cfg.baseUrl, apiKey: a.cfg.apiKey }).then((ids) => { fetched.set(key, { at: Date.now(), ids }); }) };
19
+ }
20
+ export function argOptions(a, cmd, words) {
21
+ const first = words.length === 0;
22
+ const ok = (options) => ({ options });
23
+ switch (cmd) {
24
+ case "mode": return first ? ok(opts(["default", "ask before edits and non-trivial commands"], ["acceptEdits", "edits are automatic, commands still ask"], ["plan", "read-only: investigate and propose a plan"], ["yolo", "never ask (dangerous)"])) : ok([]);
25
+ case "api": return first ? ok(opts(["auto", "pick Responses for reasoning models, self-correct"], ["responses", "OpenAI Responses API"], ["chat", "Chat Completions API"])) : ok([]);
26
+ case "effort": return first ? ok([{ value: "default", detail: "let the model decide" }, ...(a.cfg.provider === "anthropic" ? ["low", "medium", "high", "xhigh", "max"] : effortsFor(a.cfg.model)).map((e) => ({ value: e }))]) : ok([]);
27
+ case "model": return first ? modelOptions(a) : ok([]);
28
+ case "models": return first ? ok([...["explore", "compact", "review", "cheap"].map((r) => ({ value: r, detail: `model for ${r} steps`, more: true })), { value: "fallback", detail: "answer from this model when the main one is down", more: true }])
29
+ : words.length === 1 ? modelOptions(a, [{ value: "off", detail: "use the main model" }]) : ok([]);
30
+ case "theme": return first ? ok(themeNames().map((n) => ({ value: n, detail: THEMES[n].label }))) : ok([]);
31
+ case "shell": return first ? ok(SHELL_CHOICES.map((n) => ({ value: n, detail: n === "auto" ? "detect from your system" : undefined }))) : ok([]);
32
+ case "strategy": return first ? ok([...Object.keys(STRATEGIES).map((n) => ({ value: n })), { value: "off", detail: "the default loop" }]) : ok([]);
33
+ case "prompt": return first ? ok([...loadPrompts(a.cwd).map((p) => ({ value: p.id, detail: "from .amness/prompts" })), { value: "list" }, { value: "off", detail: "the built-in prompt" }]) : ok([]);
34
+ case "sandbox": return first ? ok(SANDBOX_MODES.map((m) => ({ value: m, detail: m === "off" ? "no isolation" : m === "workspace" ? "writes only in the project" : "no network, caches read-only" }))) : ok([]);
35
+ case "diffmode": return first ? ok(opts(["auto"], ["stacked"], ["split"])) : ok([]);
36
+ case "ui": return first ? ok(opts(["full", "full-screen"], ["inline", "scrolls with the terminal"])) : ok([]);
37
+ case "mouse":
38
+ case "keepwarm":
39
+ case "lsp":
40
+ case "approver":
41
+ case "reviewer":
42
+ case "router": return first ? ok(onOff) : ok([]);
43
+ case "copy": return first ? ok(opts(["code", "the last code block"], ["all", "the whole conversation"])) : ok([]);
44
+ case "memory": return first ? ok([...["forget", "add"].map((v) => ({ value: v, more: true })), ...["stale", "prune", "consolidate", "graph", "embeddings"].map((v) => ({ value: v }))]) : ok([]);
45
+ case "permissions": return first ? ok([{ value: "list" }, ...["allow", "ask", "deny"].map((v) => ({ value: v, detail: `e.g. /permissions ${v} sh(npm test*)`, more: true }))]) : ok([]);
46
+ case "branch": return first ? ok(a.session.turns.map((t, i) => ({ value: String(i + 1), detail: t.text.replace(/\s+/g, " ").slice(0, 60) }))) : ok([]);
47
+ case "pr": return first ? ok(opts(["draft", "create it as a draft"])) : ok([]);
48
+ case "report": return first ? ok([{ value: "case", detail: "turn the last request into an eval task", more: true }]) : ok([]);
49
+ case "mcp": return first ? ok([{ value: "list" }, { value: "off" }]) : ok([]);
50
+ case "budget": return first ? { options: opts(["100k"], ["300k", "the default"], ["500k"], ["1m"], ["2m"]), open: true } : ok([]);
51
+ case "check":
52
+ case "verify": return first ? { options: [{ value: "off", detail: "switch it off" }], open: true } : ok([]);
53
+ default: return ok([]);
54
+ }
55
+ }
@@ -16,6 +16,8 @@ export type RenderCtx = {
16
16
  diffMode: "auto" | "stacked" | "split";
17
17
  /** The block above is also a tool call: draw no blank line between them, so a run of tool calls reads as one unit. */
18
18
  tight?: boolean;
19
+ /** this is the most recent tool call of the lead agent: its output stays open (a short preview) until the next one starts */
20
+ last?: boolean;
19
21
  };
20
22
  export type Hit = {
21
23
  row: number;
@@ -123,7 +123,8 @@ export function renderBlock(b, c) {
123
123
  // ---------------------------------------------------------------- tool card
124
124
  const sub = !!b.agent && b.agent !== "lead", failed = b.status === "fail", edit = b.edit;
125
125
  const hue = failed ? t.err : ({ read_file: t.tool.read, list_files: t.tool.read, grep: t.tool.read, write_file: t.tool.edit, edit_file: t.tool.edit, apply_patch: t.tool.edit, sh: t.tool.shell, web_search: t.tool.web, fetch_url: t.tool.web, memory_save: t.tool.memory, memory_forget: t.tool.memory, memory_search: t.tool.read, todo_write: t.tool.plan, exit_plan_mode: t.tool.plan, task: t.tool.explore })[b.name] ?? (b.name.includes("__") ? t.tool.web : t.tool.generic);
126
- const expanded = c.expandAll ? true : b.expanded ?? (!!edit || failed);
126
+ const expanded = c.expandAll ? true : b.expanded ?? (!!edit || failed || (!!c.last && !sub && b.name !== "read_file" && b.name !== "task"));
127
+ const preview = !c.expandAll && b.expanded === undefined && !edit && !failed && !!c.last && b.name !== "read_file" && b.name !== "task"; // opened automatically, not by the user: show a short preview only
127
128
  const bg = failed ? mix(t.surface, t.err, 0.14) : t.surface, spinner = SPIN[c.frame % SPIN.length];
128
129
  const label = BOLD(F(failed ? t.err : t.text, toolLabel(b.name))), args = F(t.dim, argSummary(b.name, b.args));
129
130
  const status = b.status === "running"
@@ -163,7 +164,7 @@ export function renderBlock(b, c) {
163
164
  }
164
165
  else {
165
166
  const res = b.result ?? "", hl = (k, s) => F(k === "plain" ? t.syn.plain : t.syn[k], s);
166
- let lines = res.split("\n"), cap = c.expandAll ? 200 : 60;
167
+ let lines = res.split("\n"), cap = c.expandAll ? 200 : preview ? 20 : 60;
167
168
  if (b.name === "sh") {
168
169
  let cmd = "";
169
170
  try {
@@ -191,7 +192,7 @@ export function renderBlock(b, c) {
191
192
  body(w); // only the lines that look like errors are red
192
193
  }
193
194
  if (lines.length > cap)
194
- body(F(t.faint, `… ${lines.length - cap} more lines (ctrl+o expands everything)`));
195
+ body(F(t.faint, `… ${lines.length - cap} more line${lines.length - cap === 1 ? "" : "s"} · /output shows all of it${preview ? "" : " · ctrl+o expands everything"}`));
195
196
  }
196
197
  }
197
198
  return done();
@@ -3,7 +3,9 @@ import { relatives } from "./session.js";
3
3
  import { SHELL_CHOICES, STRATEGIES, chooseShell, currentShell, loadPrompts, setShell, shellFallbackNote } from "../../src/index.js";
4
4
  import { join, resolve } from "node:path";
5
5
  import { collectFiles, effortsFor, loadMcp, readBundle, writeBundle } from "../../src/index.js";
6
- import { CLAUDE_MODELS, OPENAI_MODELS, PROVIDERS } from "../config.js";
6
+ import { CLAUDE_MODELS, OPENAI_MODELS, PROVIDERS, connections, keyFor, storeKey } from "../config.js";
7
+ import { noStore, where } from "../keystore.js";
8
+ import { fetchModels } from "../models.js";
7
9
  import { bold, cyan, dim, green, red, yellow } from "../ui.js";
8
10
  import { estimateCost, fmtCost, fmtDuration, fmtTokens } from "./pricing.js";
9
11
  import { codeBlocks } from "./clipboard.js";
@@ -15,6 +17,29 @@ import { THEMES, getTheme, themeNames } from "./themes.js";
15
17
  import { helpLines, renderDiff, toolCall, todoList } from "./view.js";
16
18
  const INIT_PROMPT = "Explore this repository and write an AGENTS.md at the repo root for future coding sessions: what the project is, how to build/test/run it (exact commands), the code layout, conventions you can verify from the code, and gotchas. Keep it under 80 lines and do not invent anything you cannot confirm from the files.";
17
19
  const EFFORTS = ["low", "medium", "high", "xhigh", "max"];
20
+ const keyStatus = (c) => !c.needsKey ? "local, no key needed" : c.source === "env" ? `connected · from ${c.env}` : c.source === "saved" ? `connected · ${noStore() ? "this session only" : where()}` : "not connected";
21
+ /** Make `id` the active provider, using its saved key (or asking for one). Returns false if the user gave up. */
22
+ async function switchProvider(a, id) {
23
+ let apiKey = keyFor(id).key;
24
+ if (PROVIDERS[id].needsKey && !apiKey) {
25
+ apiKey = await a.askText(`${PROVIDERS[id].label} API key (hidden; ${where()})`, { secret: true });
26
+ if (!apiKey)
27
+ return false;
28
+ }
29
+ Object.assign(a.cfg, { provider: id, baseUrl: PROVIDERS[id].baseUrl, model: PROVIDERS[id].model, apiKey });
30
+ if (id === "custom" || id === "ollama") {
31
+ const m = await a.askText("Model id", { initial: a.cfg.model });
32
+ if (m)
33
+ a.cfg.model = m;
34
+ const u = await a.askText("Base URL", { initial: a.cfg.baseUrl });
35
+ if (u)
36
+ a.cfg.baseUrl = u;
37
+ }
38
+ a.saveConfig();
39
+ a.buildAgent();
40
+ a.say(`provider → ${cyan(PROVIDERS[id].label)}, model ${cyan(a.cfg.model)}`, dim);
41
+ return true;
42
+ }
18
43
  export function buildCommands() {
19
44
  return [
20
45
  { name: "help", alias: "?", help: "keys and commands", live: true, run: (a) => a.openPager(helpLines(a.cmds, (a.io.output.columns || 80) - 1), "help") },
@@ -22,7 +47,10 @@ export function buildCommands() {
22
47
  let id = arg;
23
48
  if (!id) {
24
49
  const list = a.cfg.provider === "anthropic" ? CLAUDE_MODELS : a.cfg.provider === "openai" ? OPENAI_MODELS : [{ id: a.cfg.model, hint: "current" }];
25
- const known = list.map((m) => ({ label: m.id, value: m.id, hint: m.hint }));
50
+ const have = new Set(list.map((m) => m.id));
51
+ a.note("asking your provider for its models…", 7000);
52
+ const extra = (await fetchModels({ provider: a.cfg.provider, baseUrl: a.cfg.baseUrl, apiKey: a.cfg.apiKey })).filter((m) => !have.has(m)).map((m) => ({ id: m, hint: "from your provider" }));
53
+ const known = [...list, ...extra].map((m) => ({ label: m.id, value: m.id, hint: m.hint }));
26
54
  id = await a.pick("Model", [...known, { label: "Other…", value: "" }], Math.max(0, known.findIndex((m) => m.value === a.cfg.model)));
27
55
  if (id === undefined)
28
56
  return;
@@ -41,29 +69,54 @@ export function buildCommands() {
41
69
  a.buildAgent();
42
70
  a.say(`model → ${cyan(id)}`, dim);
43
71
  } },
44
- { name: "provider", help: "switch provider (OpenAI, Claude, Ollama…)", run: async (a) => {
45
- const id = await a.pick("Provider", Object.keys(PROVIDERS).map((k) => ({ label: PROVIDERS[k].label, value: k, hint: PROVIDERS[k].baseUrl })));
46
- if (!id)
47
- return;
48
- const key = id === "anthropic" ? process.env.ANTHROPIC_API_KEY : process.env.OPENAI_API_KEY;
49
- let apiKey = key;
50
- if (PROVIDERS[id].needsKey && !apiKey) {
51
- apiKey = await a.askText(`${PROVIDERS[id].label} API key (hidden; stored in ~/.amness/config.json, mode 600)`, { secret: true });
52
- if (!apiKey)
72
+ { name: "provider", help: "switch provider (OpenAI, Claude, Ollama…); each provider keeps its own saved key", run: async (a) => {
73
+ const list = connections();
74
+ const id = await a.pick("Provider", list.map((c) => ({ label: `${!c.needsKey || c.source !== "none" ? "●" : "○"} ${c.label}${c.id === a.cfg.provider ? " (in use)" : ""}`, value: c.id, hint: keyStatus(c) })));
75
+ if (id)
76
+ await switchProvider(a, id);
77
+ } },
78
+ { name: "connect", alias: "keys", help: "add, replace or remove API keys for several providers and see which are connected", run: async (a) => {
79
+ for (;;) {
80
+ const list = connections();
81
+ const id = await a.pick("Providers ● connected ○ not connected", list.map((c) => ({ label: `${!c.needsKey || c.source !== "none" ? "●" : "○"} ${c.label}${c.id === a.cfg.provider ? " (in use)" : ""}`, value: c.id, hint: keyStatus(c) })));
82
+ if (!id)
53
83
  return;
84
+ const c = list.find((x) => x.id === id);
85
+ if (!c.needsKey) {
86
+ a.say(`${c.label} needs no key. Use /provider to switch to it.`, dim);
87
+ continue;
88
+ }
89
+ const act = c.source === "none" ? "add" : await a.pick("What do you want to do?", [
90
+ ...(id === a.cfg.provider ? [] : [{ label: "Use this provider now", value: "use" }]),
91
+ { label: c.source === "env" ? `Also save a key (${c.env} still wins while it is set)` : "Replace the key", value: "add" },
92
+ ...(c.source === "saved" ? [{ label: "Remove the saved key", value: "remove" }] : []),
93
+ ]);
94
+ if (!act)
95
+ continue;
96
+ if (act === "use") {
97
+ await switchProvider(a, id);
98
+ continue;
99
+ }
100
+ if (act === "add") {
101
+ const key = await a.askText(`${c.label} API key (hidden; ${where()})`, { secret: true });
102
+ if (!key)
103
+ continue;
104
+ storeKey(id, key);
105
+ if (id === a.cfg.provider) {
106
+ a.cfg.apiKey = keyFor(id).key;
107
+ a.buildAgent();
108
+ }
109
+ a.say(`${green("✓")} ${c.label} connected (${noStore() ? "this session only" : where()})`, dim);
110
+ }
111
+ else {
112
+ storeKey(id, undefined);
113
+ if (id === a.cfg.provider) {
114
+ a.cfg.apiKey = keyFor(id).key;
115
+ a.buildAgent();
116
+ }
117
+ a.say(`${c.label}: saved key removed`, dim);
118
+ }
54
119
  }
55
- Object.assign(a.cfg, { provider: id, baseUrl: PROVIDERS[id].baseUrl, model: PROVIDERS[id].model, apiKey });
56
- if (id === "custom" || id === "ollama") {
57
- const m = await a.askText("Model id", { initial: a.cfg.model });
58
- if (m)
59
- a.cfg.model = m;
60
- const u = await a.askText("Base URL", { initial: a.cfg.baseUrl });
61
- if (u)
62
- a.cfg.baseUrl = u;
63
- }
64
- a.saveConfig();
65
- a.buildAgent();
66
- a.say(`provider → ${cyan(PROVIDERS[id].label)}, model ${cyan(a.cfg.model)}`, dim);
67
120
  } },
68
121
  { name: "effort", help: "reasoning effort for the current model", args: "[level]", run: async (a, arg) => {
69
122
  const levels = a.cfg.provider === "anthropic" ? [...EFFORTS] : effortsFor(a.cfg.model);
@@ -105,6 +158,23 @@ export function buildCommands() {
105
158
  return void a.say(`unknown mode "${m}"`, red);
106
159
  a.setMode(m);
107
160
  } },
161
+ { name: "output", alias: "last", help: "the full output of the last tool call in a pager (/output 2 for the one before)", args: "[n]", live: true, run: (a, arg) => {
162
+ const calls = a.blocks.blocks.filter((b) => b.kind === "tool" && (!b.agent || b.agent === "lead") && !!b.result && b.name !== "todo_write");
163
+ if (!calls.length)
164
+ return void a.say("no tool output yet", dim);
165
+ const n = Math.max(1, Math.floor(Number(arg) || 1)), b = calls[calls.length - n];
166
+ if (!b)
167
+ return void a.say(`only ${calls.length} tool call${calls.length === 1 ? "" : "s"} so far`, dim);
168
+ const body = b.result.split("\n");
169
+ let head = toolCall(b.name, b.args);
170
+ if (b.name === "sh") {
171
+ try {
172
+ head = `$ ${JSON.parse(b.args).cmd}`;
173
+ }
174
+ catch { /* keep the generic header */ }
175
+ }
176
+ a.openPager([head, "", ...body], "output");
177
+ } },
108
178
  { name: "plan", help: "enter plan mode (read-only)", run: (a) => a.setMode("plan") },
109
179
  { name: "clear", help: "start a fresh conversation", alias: "new", run: (a) => {
110
180
  a.newConversation();
@@ -1,16 +1,25 @@
1
1
  export type Suggestion = {
2
2
  label: string;
3
3
  insert: string;
4
- detail?: string;
4
+ detail?: string; /** arg suggestions: more words are expected, so do not submit on Enter */
5
+ more?: boolean; /** arg suggestions: any value is allowed, so Enter runs what was typed unless the user moved into the list */
6
+ open?: boolean; /** category header shown above the first item of each group (only when the list is not filtered) */
7
+ group?: string;
5
8
  };
6
9
  export type Token = {
7
- kind: "slash" | "file";
10
+ kind: "slash" | "file" | "arg";
8
11
  query: string;
9
12
  start: number;
10
- end: number;
13
+ end: number; /** arg tokens: the command, and the words already typed after it */
14
+ cmd?: string;
15
+ words?: string[];
11
16
  };
12
17
  /** What is the user completing right now? A slash command (first word of the input) or an @file mention. */
13
18
  export declare function tokenAtCursor(text: string, cursor: number): Token | null;
19
+ /** Categories of the built-in commands, in the order the menu shows them. A command that is not listed (yours, or a plugin's) goes under "Custom". */
20
+ export declare const GROUP_ORDER: readonly ["Conversation", "Model & keys", "Safety", "Quality", "Project", "Extend", "Interface", "Custom"];
21
+ export declare const groupOf: (name: string) => string;
22
+ /** All matching commands. With no query they come grouped by category (in GROUP_ORDER, A to Z inside a group); with a query, best match first. */
14
23
  export declare function rankCommands(query: string, cmds: {
15
24
  name: string;
16
25
  help: string;
@@ -37,3 +46,14 @@ export declare class FileIndex {
37
46
  private load;
38
47
  private walk;
39
48
  }
49
+ export type MenuRow = {
50
+ head: string;
51
+ } | {
52
+ item: number;
53
+ };
54
+ /** The rows to draw for a suggestion list: a window of `win` rows that keeps the selected item in view, with a header row above each new group (headers cannot be selected). */
55
+ export declare function menuRows(items: Suggestion[], sel: number, win: number): {
56
+ rows: MenuRow[];
57
+ above: number;
58
+ below: number;
59
+ };
@@ -8,17 +8,37 @@ export function tokenAtCursor(text, cursor) {
8
8
  const slash = before.match(/^\/(\S*)$/);
9
9
  if (slash)
10
10
  return { kind: "slash", query: slash[1], start: 0, end: cursor };
11
+ const arg = before.match(/^\/(\S+)((?:\s+\S+)*)\s+(\S*)$/); // "/model gp": the command, the words before the last one, the word being typed
12
+ if (arg)
13
+ return { kind: "arg", cmd: arg[1], words: arg[2].trim() ? arg[2].trim().split(/\s+/) : [], query: arg[3], start: cursor - arg[3].length, end: cursor };
11
14
  const file = before.match(/(?:^|\s)@([^\s@]*)$/);
12
15
  if (file)
13
16
  return { kind: "file", query: file[1], start: cursor - file[1].length - 1, end: cursor };
14
17
  return null;
15
18
  }
16
- export function rankCommands(query, cmds, limit = 8) {
19
+ /** Categories of the built-in commands, in the order the menu shows them. A command that is not listed (yours, or a plugin's) goes under "Custom". */
20
+ export const GROUP_ORDER = ["Conversation", "Model & keys", "Safety", "Quality", "Project", "Extend", "Interface", "Custom"];
21
+ const GROUP_MEMBERS = {};
22
+ for (const [g, names] of Object.entries({
23
+ Conversation: "output clear compact resume branch branches export import undo rewind copy todos exit",
24
+ "Model & keys": "model provider connect effort api models router keepwarm budget tokens",
25
+ Safety: "mode plan permissions policy sandbox approver hooks",
26
+ Quality: "verify check review reviewer lsp report loop diff",
27
+ Project: "init pr tools procs shell status live",
28
+ Extend: "memory mcp skills agents plugins reload-plugins prompt strategy profile",
29
+ Interface: "help theme sidebar details diffmode mouse ui",
30
+ }))
31
+ for (const n of names.split(" "))
32
+ GROUP_MEMBERS[n] = g;
33
+ export const groupOf = (name) => GROUP_MEMBERS[name] ?? "Custom";
34
+ /** All matching commands. With no query they come grouped by category (in GROUP_ORDER, A to Z inside a group); with a query, best match first. */
35
+ export function rankCommands(query, cmds, limit = 500) {
17
36
  const q = query.toLowerCase();
18
37
  const score = (c) => (c.name.startsWith(q) ? 3 : c.alias?.startsWith(q) ? 2 : c.name.includes(q) || c.alias?.includes(q) ? 1 : 0);
38
+ const gi = (n) => GROUP_ORDER.indexOf(groupOf(n));
19
39
  return cmds.map((c) => ({ c, score: score(c) }))
20
- .filter((x) => x.score > 0 || !q).sort((a, b) => b.score - a.score || a.c.name.localeCompare(b.c.name)).slice(0, limit)
21
- .map(({ c }) => ({ label: "/" + c.name, insert: "/" + c.name + " ", detail: c.help + (c.alias && !c.name.includes(query.toLowerCase()) && c.alias.includes(query.toLowerCase()) ? ` (also /${c.alias})` : "") }));
40
+ .filter((x) => x.score > 0 || !q).sort((a, b) => (q ? b.score - a.score : gi(a.c.name) - gi(b.c.name)) || a.c.name.localeCompare(b.c.name)).slice(0, limit)
41
+ .map(({ c }) => ({ group: q ? undefined : groupOf(c.name), label: "/" + c.name, insert: "/" + c.name + " ", detail: c.help + (c.alias && !c.name.includes(query.toLowerCase()) && c.alias.includes(query.toLowerCase()) ? ` (also /${c.alias})` : "") }));
22
42
  }
23
43
  /** Subsequence fuzzy match. Tries the greedy alignment and the exact-substring alignments and keeps the best. null = no match. */
24
44
  export function fuzzyScore(query, path) {
@@ -111,3 +131,18 @@ export class FileIndex {
111
131
  return out;
112
132
  }
113
133
  }
134
+ /** The rows to draw for a suggestion list: a window of `win` rows that keeps the selected item in view, with a header row above each new group (headers cannot be selected). */
135
+ export function menuRows(items, sel, win) {
136
+ const all = [];
137
+ let selRow = 0, last;
138
+ items.forEach((it, i) => { if (it.group && it.group !== last)
139
+ all.push({ head: it.group }); last = it.group; if (i === sel)
140
+ selRow = all.length; all.push({ item: i }); });
141
+ win = Math.max(1, win);
142
+ let top = Math.max(0, Math.min(selRow - Math.floor(win / 2), all.length - win));
143
+ if (selRow - 1 >= 0 && "head" in all[selRow - 1] && top > selRow - 1)
144
+ top = selRow - 1; // keep the group title with its first command
145
+ const rows = all.slice(top, top + win);
146
+ const count = (r) => r.filter((x) => "item" in x).length;
147
+ return { rows, above: count(all.slice(0, top)), below: count(all.slice(top + win)) };
148
+ }
@@ -1,5 +1,6 @@
1
1
  // The full-screen UI: header, scrollable transcript, sidebar, input panel, modals, toasts and mouse handling, drawn on a cell canvas.
2
2
  import { copyToClipboard } from "./clipboard.js";
3
+ import { menuRows } from "./complete.js";
3
4
  import { basename } from "node:path";
4
5
  import { A, mix } from "./ansi.js";
5
6
  import { brandOfModel } from "./brand.js";
@@ -162,6 +163,7 @@ export class FullView {
162
163
  const live = new Set();
163
164
  const blocks = app.blocks.blocks.filter((b) => !(b.kind === "tool" && (b.name === "todo_write" || (b.parent && b.agent && b.agent !== "lead")))); // todos live in the sidebar; a subagent's steps are drawn inside its card
164
165
  let prevTool = false;
166
+ const lastTool = [...blocks].reverse().find((b) => b.kind === "tool" && (!b.agent || b.agent === "lead"))?.id; // its output stays open
165
167
  for (let i = 0; i < blocks.length; i++) {
166
168
  const b = blocks[i];
167
169
  live.add(b.id);
@@ -169,7 +171,7 @@ export class FullView {
169
171
  if (isQuiet(b))
170
172
  while (i + run < blocks.length && isQuiet(blocks[i + run]))
171
173
  run++;
172
- const tctx = { ...ctx, tight: prevTool };
174
+ const tctx = { ...ctx, tight: prevTool, last: b.id === lastTool };
173
175
  if (run >= 2) {
174
176
  const members = blocks.slice(i, i + run), open = this.expandAll || this.groupOpen.has(b.id);
175
177
  const key = `g|${tctx.tight}|${open}|${ctx.theme.name}|${ctx.width}|` + members.map((m) => `${m.id}:${m.status}:${m.ms ?? 0}`).join(",") + (members.some((m) => m.status === "running") ? `|${ctx.frame}` : ""), hit = this.cache.get(b.id);
@@ -182,7 +184,7 @@ export class FullView {
182
184
  prevTool = r.rows.length <= 2;
183
185
  continue;
184
186
  }
185
- const key = this.sig(b, tctx) + (tctx.tight ? "|t" : ""), hit = this.cache.get(b.id);
187
+ const key = this.sig(b, tctx) + (tctx.tight ? "|t" : "") + (tctx.last ? "|last" : ""), hit = this.cache.get(b.id);
186
188
  const r = hit && hit.key === key ? hit.r : (() => { const rr = renderBlock(b, tctx); this.cache.set(b.id, { key, r: rr }); return rr; })();
187
189
  this.items.push({ b, r, start: total });
188
190
  total += r.rows.length;
@@ -406,13 +408,19 @@ export class FullView {
406
408
  const s = this.app.sugg;
407
409
  if (!s || !s.items.length || this.app.overlay)
408
410
  return;
409
- const rows = Math.min(8, s.items.length), w = Math.min(70, mainW), top = Math.max(0, Math.min(s.sel - 3, s.items.length - rows)), y = inputY - rows - 2;
410
- if (y < 1)
411
+ const w = Math.min(86, mainW), room = inputY - 4; // rows between the top of the screen and the box
412
+ if (room < 3)
411
413
  return;
412
- c.box(x + 1, y, w, rows + 2, { border: t.border, bg: t.surface, style: "round", title: s.kind === "slash" ? "commands" : "files", titleFg: t.dim });
413
- s.items.slice(top, top + rows).forEach((it, i) => {
414
- const idx = top + i, on = idx === s.sel, bg = on ? t.selection : t.surface;
415
- const label = s.kind === "file" ? (() => { const n = it.label.split("/").pop(), d = it.label.slice(0, it.label.length - n.length); return F(t.faint, d) + BOLD(F(t.text, n)); })() : BOLD(F(t.accent2, it.label));
414
+ const { rows, above, below } = menuRows(s.items, s.sel, Math.min(14, room)), y = inputY - rows.length - 2;
415
+ const pos = s.items.length > rows.filter((r) => "item" in r).length ? ` ${s.sel + 1}/${s.items.length}${above ? " ↑" : ""}${below ? " ↓" : ""}` : "";
416
+ c.box(x + 1, y, w, rows.length + 2, { border: t.border, bg: t.surface, style: "round", title: (s.kind === "slash" ? "commands" : s.kind === "arg" ? `/${s.token.cmd} options` : "files") + pos, titleFg: t.dim });
417
+ rows.forEach((r, i) => {
418
+ if ("head" in r) {
419
+ c.ansi(x + 2, y + 1 + i, fit(" " + F(t.faint, "── ") + BOLD(F(t.dim, r.head)) + " " + F(t.faint, "─".repeat(Math.max(2, 24 - r.head.length))), w - 2, t.surface), {}, w - 2);
420
+ return;
421
+ }
422
+ const idx = r.item, it = s.items[idx], on = idx === s.sel, bg = on ? t.selection : t.surface;
423
+ const label = s.kind === "file" ? (() => { const n = it.label.split("/").pop(), d = it.label.slice(0, it.label.length - n.length); return F(t.faint, d) + BOLD(F(t.text, n)); })() : BOLD(F(t.accent2, it.label.padEnd(16)));
416
424
  c.ansi(x + 2, y + 1 + i, fit((on ? F(t.accent, "▌") : " ") + " " + label + (it.detail ? " " + F(t.faint, it.detail) : ""), w - 2, bg), {}, w - 2);
417
425
  this.regions.push({ x: x + 2, y: y + 1 + i, w: w - 2, h: 1, kind: "sugg", click: () => { s.sel = idx; this.app.acceptSuggPublic(true); } });
418
426
  });
@@ -588,6 +596,8 @@ export class FullView {
588
596
  } return false; }
589
597
  /** Scroll keys; returns whether the key was used. */
590
598
  key(name, ctrl) {
599
+ if (this.app.sugg && (name === "pageup" || name === "pagedown"))
600
+ return false; // the open menu pages through its list instead
591
601
  const page = Math.max(3, Math.floor(this.viewH / 2));
592
602
  if (name === "pageup") {
593
603
  this.scrollBy(page);
@@ -1,6 +1,6 @@
1
1
  import type { Message, Model } from "./harness.ts";
2
2
  /**
3
- * Claude backend via the official Anthropic SDK (an optional peer dependency: `npm i @anthropic-ai/sdk`).
3
+ * Claude backend via the official Anthropic SDK (an optional dependency, installed with the package: `npm i @anthropic-ai/sdk` if it is missing).
4
4
  * Defaults follow Anthropic's guidance for agent loops: adaptive thinking, auto prompt caching, streaming,
5
5
  * server-side refusal fallbacks. Credentials: `apiKey`, ANTHROPIC_API_KEY, or an `ant auth login` profile.
6
6
  */
@@ -87,7 +87,7 @@ flowchart TB
87
87
 
88
88
  **Limits.** The diagrams show the main paths only. Many small files in `src/` (for example `permissions.ts`, `hooks.ts`, `sandbox.ts`) are called by the agent layer and are not drawn.
89
89
 
90
- The runtime has no required packages. The Claude adapter needs the optional package `@anthropic-ai/sdk`.
90
+ The runtime has no required packages. The Claude adapter uses the optional dependency `@anthropic-ai/sdk`, which npm installs with Amness.
91
91
 
92
92
  ## Find which file does what
93
93
 
package/docs/bench.md CHANGED
@@ -44,16 +44,15 @@ Do these steps in PowerShell.
44
44
 
45
45
  2. Install [Git for Windows](https://git-scm.com/download/win). The bench needs `git` on your PATH. The hidden checks of the built-in tasks use bash tools such as `test`. Amness looks for the `bash.exe` that comes with Git (in `Program Files\Git\bin`) and for any `bash.exe` on PATH. It skips `C:\Windows\System32\bash.exe`, because that is the WSL launcher and it sees other files (see `src/shell.ts`).
46
46
 
47
- 3. Download Amness and install its packages:
47
+ 3. Install Amness from npm:
48
48
 
49
49
  ```powershell
50
- git clone https://github.com/amirsdream/Amness.git
51
- cd Amness
52
- npm install
53
- npm link
50
+ npm install -g amness
54
51
  amness --version
55
52
  ```
56
53
 
54
+ To bench the latest `main` instead of the release, install from a clone: `git clone https://github.com/amirsdream/Amness.git`, then `cd Amness`, `npm install` and `npm link`.
55
+
57
56
  4. Install and log in to the other tools yourself. Follow their own documentation. This page does not repeat install commands, because they change.
58
57
  - Claude Code: <https://docs.claude.com/en/docs/claude-code>
59
58
  - Codex: <https://github.com/openai/codex>
@@ -70,7 +70,7 @@ The file `.github/workflows/ci.yml` has these jobs:
70
70
  | `windows` | `npm run typecheck` and `npm test` on Windows. It is informational (`continue-on-error`) |
71
71
  | `action` | runs the GitHub Action (`action.yml`) with no API key and checks that it reports instead of crashing |
72
72
 
73
- A second workflow, `.github/workflows/publish.yml`, runs type check and tests, then `npm publish`, when you push a tag that starts with `v`. It needs an `NPM_TOKEN` secret. Whether the package is published on npm is not verified here.
73
+ A second workflow, `.github/workflows/publish.yml`, runs type check and tests, then `npm stage publish --provenance`, when you push a tag that starts with `v`. It needs an `NPM_TOKEN` secret (a stage-only token is enough). It only stages the version; you release it with `npm stage approve <id>` and your 2FA code.
74
74
 
75
75
  ## Find your way around the project
76
76
 
@@ -13,21 +13,19 @@ You need Node.js 22.18 or newer, git, and a terminal. A *diff* is a view of the
13
13
 
14
14
  ## 1. Install Amness
15
15
 
16
- Amness is **not on npm yet**. You install it from the source code.
16
+ Amness is a package on npm. You install it with one command.
17
17
 
18
- 1. Download the code and install its packages.
18
+ 1. Install it. The `-g` flag makes the `amness` command available in every folder.
19
19
 
20
20
  ```bash
21
- git clone https://github.com/amirsdream/Amness.git && cd Amness && npm install
21
+ npm install -g amness
22
22
  ```
23
23
 
24
- 2. Create the `amness` command.
24
+ This also installs the Anthropic SDK, which the Claude models use. It is an optional dependency, so Amness still installs if the SDK cannot. If you see "Claude support needs the SDK", run `npm install -g @anthropic-ai/sdk`.
25
25
 
26
- ```bash
27
- npm link
28
- ```
26
+ You can also run Amness once without installing it: `npx amness`.
29
27
 
30
- 3. Check that the command works.
28
+ 2. Check that the command works.
31
29
 
32
30
  ```bash
33
31
  amness --version
@@ -35,6 +33,17 @@ Amness is **not on npm yet**. You install it from the source code.
35
33
 
36
34
  You see a version number, for example `0.1.0`.
37
35
 
36
+ To update later, run `npm install -g amness@latest`.
37
+
38
+ **Run the latest `main` from source.** Use this if you want a change that is not released yet, or if you want to work on Amness itself:
39
+
40
+ ```bash
41
+ git clone https://github.com/amirsdream/Amness.git && cd Amness && npm install
42
+ npm link
43
+ ```
44
+
45
+ `npm link` makes the `amness` command point at your clone, so `git pull` updates it.
46
+
38
47
  Amness is developed on macOS and Linux. The tests also run on Windows, but Windows has **no OS sandbox** for shell commands.
39
48
 
40
49
  ## 2. Set an API key or choose a local model
@@ -57,7 +66,7 @@ ollama pull qwen2.5-coder:7b
57
66
 
58
67
  Start Amness, run `/provider`, and choose "Ollama (local)". You need no key. Small local models can be weak at tool calling. See [Providers and models](providers.md).
59
68
 
60
- You can also do nothing now. On the first start, Amness asks you to pick a provider and paste a key. It saves the key in `~/.amness/config.json`, where only you can read it.
69
+ You can also do nothing now. On the first start, Amness asks you to pick a provider and paste a key. It saves the key in your system's secure store (the macOS Keychain, the Linux keyring, or Windows DPAPI) and tells you which one. See [Safety](safety.md) for the details and for servers that have no keyring.
61
70
 
62
71
  ## 3. Start in a project
63
72
 
package/docs/headless.md CHANGED
@@ -166,7 +166,12 @@ You see one line on stderr for each attempt, for example `attempt 1: failed veri
166
166
 
167
167
  ## Docker and GitHub Actions
168
168
 
169
- - A `Dockerfile` is included: `docker build -t amness .`, then `docker run -it --rm -v "$PWD":/work -e ANTHROPIC_API_KEY amness`. The old version of this page said it has not been built or tested. The README lists a Docker smoke test. **Not verified:** which of the two is right.
169
+ - A `Dockerfile` is included: `docker build -t amness .`, then `docker run -it --rm -v "$PWD":/work -e ANTHROPIC_API_KEY amness`. CI builds the image and runs `--version` and a self-test in it (see [Development](development.md)).
170
+ - **Keys in Docker.** Inside a container Amness never saves a key to disk. Pass it in one of two ways:
171
+ - an environment variable: `-e ANTHROPIC_API_KEY` (with no value, Docker copies it from your shell, so it is not typed on the command line);
172
+ - a Docker secret, which is mounted as a file: `-e ANTHROPIC_API_KEY_FILE=/run/secrets/anthropic`. `OPENAI_API_KEY_FILE` and `TAVILY_API_KEY_FILE` work the same way.
173
+
174
+ The image sets `AMNESS_KEYSTORE=none`. Amness also detects `/.dockerenv` and Kubernetes on its own. If you type a key into the chat in a container, it is used for that session only.
170
175
  - The repository has a GitHub Action in `action.yml`. Its inputs are `task`, `verify`, `provider`, `model`, `max-tokens` and `comment`. See [Features: Scheduled runs and the GitHub Action](features.md#scheduled-runs-and-the-github-action).
171
176
 
172
177
  ## Related pages
package/docs/library.md CHANGED
@@ -17,34 +17,25 @@ Everything is exported from `src/index.ts`. The code samples on this page were t
17
17
 
18
18
  **Why you would use it.** You get the same guards as the chat (argument checks, verify gate, loop guard, budgets) inside a script, a server or a test.
19
19
 
20
- **How to use it.** The README says Amness is not on npm yet, and this page could not confirm a release. Use a clone.
21
-
22
- 1. Clone and build:
23
-
24
- ```bash
25
- git clone https://github.com/amirsdream/Amness.git
26
- cd Amness
27
- npm install
28
- npm run build
29
- npm pack
30
- ```
20
+ **How to use it.**
31
21
 
32
- 2. Install the tarball in your own project:
22
+ 1. Install the package in your own project:
33
23
 
34
24
  ```bash
35
- cd ../my-project
36
- npm install ../Amness/amness-0.1.0.tgz
25
+ npm install amness
37
26
  ```
38
27
 
39
- 3. Or skip the build and import the source from the clone. Node.js can run `.ts` files:
28
+ 2. Import what you need:
40
29
 
41
30
  ```ts
42
- import { createCodeAgent, openai } from "../Amness/src/index.ts";
31
+ import { createCodeAgent, openai } from "amness";
43
32
  ```
44
33
 
45
- The CI job `package` does steps 1 and 2 and checks that `import("amness")` works.
34
+ To use the latest `main` before it is released, clone the repository, run `npm install && npm run build && npm pack`, and install the tarball it makes with `npm install ../Amness/amness-<version>.tgz`. You can also import the source directly, because Node.js can run `.ts` files: `import { createCodeAgent, openai } from "../Amness/src/index.ts";`.
35
+
36
+ The CI job `package` packs the tarball, installs it in a new folder, and checks that `import("amness")` works.
46
37
 
47
- **Limits.** The Claude adapter needs the optional package `@anthropic-ai/sdk` (`npm install @anthropic-ai/sdk`). The OpenAI adapter uses plain `fetch` and has no dependency.
38
+ **Limits.** The Claude adapter uses the optional dependency `@anthropic-ai/sdk`, which `npm install amness` adds for you (if it is missing, run `npm install @anthropic-ai/sdk`). The OpenAI adapter uses plain `fetch` and has no dependency.
48
39
 
49
40
  ## Run a coding agent
50
41