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.
package/README.md CHANGED
@@ -5,7 +5,7 @@
5
5
  [![CI](https://github.com/amirsdream/Amness/actions/workflows/ci.yml/badge.svg)](https://github.com/amirsdream/Amness/actions/workflows/ci.yml)
6
6
  [![License: MIT](https://img.shields.io/badge/license-MIT-blue.svg)](LICENSE)
7
7
  ![Node](https://img.shields.io/badge/node-%E2%89%A5%2022.18-339933)
8
- ![Runtime dependencies](https://img.shields.io/badge/runtime%20dependencies-0-brightgreen)
8
+ ![Runtime dependencies](https://img.shields.io/badge/required%20dependencies-0-brightgreen)
9
9
 
10
10
  <p align="center"><img src="docs/images/chat.png" alt="Amness full-screen interface: cards, diff, sidebar, input panel" width="900"></p>
11
11
 
@@ -16,12 +16,26 @@
16
16
  You need **Node 22.18+**, git, and an API key (OpenAI or Anthropic) or a local model server.
17
17
 
18
18
  ```bash
19
- git clone https://github.com/amirsdream/Amness.git && cd Amness && npm install
20
- export OPENAI_API_KEY=sk-... # or ANTHROPIC_API_KEY=sk-ant-...
21
- npm link # gives you the `amness` command
22
- cd ~/your-project && amness # start a conversation in your project
19
+ npm install -g amness # gives you the `amness` command
20
+ cd ~/your-project && amness # first start: pick a provider and paste your key (see below)
23
21
  ```
24
22
 
23
+ Or try it without installing: `npx amness`. To run the latest `main` from source (or to work on Amness), see [Run from source](#run-from-source).
24
+
25
+ ### Your API key
26
+
27
+ Give Amness the key once, in whichever way suits you:
28
+
29
+ | How | Command or step | Saved? |
30
+ |---|---|---|
31
+ | **Paste it on first start** | run `amness`, choose a provider, paste the key (hidden). Add more providers any time with `/connect`, and switch with `/provider`. | Yes, in your system's secure store: the macOS Keychain, the Linux keyring, or Windows DPAPI. The prompt tells you which. |
32
+ | **Environment variable** | `export ANTHROPIC_API_KEY=sk-ant-...` or `export OPENAI_API_KEY=sk-...` (PowerShell: `$env:OPENAI_API_KEY="sk-..."`) | No. Used for that shell only, and never written to disk. It wins over a saved key. |
33
+ | **Secret file** (Docker, Kubernetes) | `ANTHROPIC_API_KEY_FILE=/run/secrets/anthropic` (also `OPENAI_API_KEY_FILE`, `TAVILY_API_KEY_FILE`) | No. Read from the file each run. |
34
+ | **Docker** | `docker run -it --rm -v "$PWD":/work -e ANTHROPIC_API_KEY amness` | No. Inside a container nothing is saved. |
35
+ | **Library or CI** | pass `apiKey` in code, or set the variable as a CI secret | No. |
36
+
37
+ Each provider keeps its own saved key, so you can connect several and switch with `/provider`; `/connect` shows which are connected. `OPENAI_API_KEY` is used for every provider except Anthropic (OpenRouter prefers `OPENROUTER_API_KEY`). To remove a saved key, clear "API key" in the settings. A Linux server with no keyring falls back to `~/.amness/config.json` (mode 600, **not encrypted**), so use an environment variable or your platform's secret manager there. Details: [Safety](docs/safety.md).
38
+
25
39
  More ways to start:
26
40
 
27
41
  ```bash
@@ -33,6 +47,17 @@ amness demo # a scripted tour
33
47
 
34
48
  New to it? Follow the [step-by-step guide](docs/getting-started.md). Inside the chat: `/help` lists everything · `Shift+Tab` changes how much it may do without asking · `Ctrl+P` opens the command palette · `@file` attaches a file or image · `!cmd` runs a shell command · `#note` saves a memory.
35
49
 
50
+ ## Run from source
51
+
52
+ Use this for the latest `main` (changes not yet released to npm) or to work on Amness itself.
53
+
54
+ ```bash
55
+ git clone https://github.com/amirsdream/Amness.git && cd Amness && npm install
56
+ npm link # `amness` now runs your clone; `git pull` updates it
57
+ ```
58
+
59
+ Update the npm version with `npm install -g amness@latest`. Contributors: see [Development](docs/development.md).
60
+
36
61
  ## What it looks like
37
62
 
38
63
  <p align="center"><img src="docs/images/approval.png" alt="An edit waiting for your approval, with the diff" width="780"></p>
@@ -172,7 +197,7 @@ More plugin ideas (finish gates, policies, loop strategies, custom metrics) are
172
197
  | **Evaluate and improve** | `amness eval` (ablations, SWE-bench-format suites, injection suite, leak audit), trace replay, failure mining, `amness tune` (changes kept only if they hold on unseen tasks). |
173
198
  | **Works where you work** | Full-screen and inline terminal UI, headless mode, git worktree sessions with `/review` and `/pr`, scheduled runs, a GitHub Action, an editor server (`amness acp`), image input, session branching. |
174
199
 
175
- Models: OpenAI (Responses and Chat), Claude, and any OpenAI-compatible local server. No runtime dependencies.
200
+ Models: OpenAI (Responses and Chat), Claude, and any OpenAI-compatible local server. No required dependencies (the Claude SDK is an optional one that npm installs for you).
176
201
 
177
202
  ## Choose Amness if, choose something else if
178
203
 
@@ -184,7 +209,7 @@ Models: OpenAI (Responses and Chat), Claude, and any OpenAI-compatible local ser
184
209
 
185
210
  - **Tested:** about 375 automated tests (scripted models, mock servers, real git repositories, a pseudo-terminal), CI on Linux and Windows, and a Docker and npm-package smoke test.
186
211
  - **Measured on real models (small):** the guards and the reviewer showed no detectable pass-rate effect on [these tasks and models](docs/evaluation.md#measured-results-so-far); the guards and reviewer cost extra tokens, most visibly on strong models. Every newer feature (routing, `tune`, best-of-n, parallel parts, language-server diagnostics, the approval reviewer) is implemented and unit-tested but **not yet shown to help on real work**. The [roadmap](ROADMAP.md) lists the measurements still to run.
187
- - **Platforms:** developed on macOS and Linux. Windows runs the test suite but has no OS sandbox. Not on npm yet.
212
+ - **Platforms:** developed on macOS and Linux. Windows runs the test suite but has no OS sandbox. Installed from npm; the Claude SDK comes with it.
188
213
  - Cost figures are estimates. Small local models vary widely in tool-calling quality.
189
214
 
190
215
  ## Documentation
@@ -15,7 +15,8 @@ import { runPlugin } from "./plugincli.js";
15
15
  import { runSchedule } from "./schedulecli.js";
16
16
  import { ACP_HELP, runAcp } from "./acpcli.js";
17
17
  import { runEval, runTune } from "./evalcli.js";
18
- import { CLAUDE_MODELS, OPENAI_MODELS, PROVIDERS, home, loadConfig, mask, ready, saveConfig } from "./config.js";
18
+ import { CLAUDE_MODELS, OPENAI_MODELS, PROVIDERS, home, keyFor, loadConfig, mask, ready, saveConfig } from "./config.js";
19
+ import { where } from "./keystore.js";
19
20
  import { DEMO_TASK, explain, runDemo } from "./demo.js";
20
21
  import { formatEvent } from "./format.js";
21
22
  import { Cancel, ask, bold, box, clearScreen, confirm, cyan, dim, gray, green, log, pause, red, select, spin, yellow } from "./ui.js";
@@ -90,7 +91,7 @@ async function ensureReady() {
90
91
  cfg.model = (await ask("Model", cfg.model)) || cfg.model;
91
92
  }
92
93
  if (PROVIDERS[id].needsKey)
93
- cfg.apiKey = (await ask("API key (hidden; saved to ~/.amness/config.json, mode 600)", "", { secret: true })) || undefined;
94
+ cfg.apiKey = keyFor(id).key ?? ((await ask(`API key (hidden; ${where()})`, "", { secret: true })) || undefined);
94
95
  saveConfig(cfg);
95
96
  if (!ready(cfg))
96
97
  return false;
@@ -431,7 +432,7 @@ async function settings() {
431
432
  if (what === "provider") {
432
433
  const id = await select("Provider", Object.keys(PROVIDERS).map((k) => ({ label: PROVIDERS[k].label, value: k, hint: PROVIDERS[k].baseUrl })));
433
434
  if (id)
434
- Object.assign(cfg, { provider: id, baseUrl: PROVIDERS[id].baseUrl, model: PROVIDERS[id].model });
435
+ Object.assign(cfg, { provider: id, baseUrl: PROVIDERS[id].baseUrl, model: PROVIDERS[id].model, apiKey: keyFor(id).key });
435
436
  }
436
437
  else if (what === "model") {
437
438
  if (cfg.provider === "anthropic" || cfg.provider === "openai") {
@@ -452,7 +453,7 @@ async function settings() {
452
453
  else if (what === "url")
453
454
  cfg.baseUrl = (await ask("Base URL", cfg.baseUrl)) || cfg.baseUrl;
454
455
  else if (what === "key")
455
- cfg.apiKey = (await ask("API key (hidden; stored in ~/.amness/config.json, mode 600)", cfg.apiKey ?? "", { secret: true })) || undefined;
456
+ cfg.apiKey = (await ask(`API key (hidden; ${where()})`, cfg.apiKey ?? "", { secret: true })) || undefined;
456
457
  else if (what === "tavily")
457
458
  cfg.tavilyKey = (await ask("Tavily API key (blank to clear)", "", { secret: true })) || undefined;
458
459
  else if (what === "budget")
@@ -35,7 +35,8 @@ export type Config = {
35
35
  provider: ProviderId;
36
36
  baseUrl: string;
37
37
  model: string;
38
- apiKey?: string;
38
+ apiKey?: string; /** saved keys for the other providers, only where there is no OS store */
39
+ apiKeys?: Partial<Record<ProviderId, string>>;
39
40
  tavilyKey?: string;
40
41
  maxTokens: number;
41
42
  effort?: "none" | "minimal" | "low" | "medium" | "high" | "xhigh" | "max"; /** OpenAI wire API: auto (default) picks Responses for reasoning models and self-corrects */
@@ -65,6 +66,26 @@ export type Config = {
65
66
  };
66
67
  export declare const home: () => string;
67
68
  export declare const defaults: () => Config;
69
+ /** The variable that holds a provider's key. Anthropic has its own; OpenRouter may have its own; every other provider uses OPENAI_API_KEY. */
70
+ export declare const envNameFor: (p: ProviderId) => "ANTHROPIC_API_KEY" | "OPENROUTER_API_KEY" | "OPENAI_API_KEY";
71
+ export declare const envKeyFor: (p: ProviderId) => string | undefined;
72
+ export type KeyState = {
73
+ key?: string;
74
+ source: "env" | "saved" | "none";
75
+ };
76
+ /** The key to use for a provider: an environment variable wins over a saved key. */
77
+ export declare function keyFor(p: ProviderId): KeyState;
78
+ /** Save (or, with no key, remove) a provider's key. Uses the OS store; else config.json (mode 600); in a container, memory only. */
79
+ export declare function storeKey(p: ProviderId, key?: string): void;
80
+ export type Connection = {
81
+ id: ProviderId;
82
+ label: string;
83
+ needsKey: boolean;
84
+ source: KeyState["source"];
85
+ env: string;
86
+ };
87
+ /** Every provider and whether it is ready to use. */
88
+ export declare function connections(): Connection[];
68
89
  export declare function loadConfig(): Config;
69
90
  export declare function saveConfig(c: Config): void;
70
91
  export declare const ready: (c: Config) => boolean;
@@ -1,6 +1,7 @@
1
1
  import { chmodSync, existsSync, mkdirSync, readFileSync, writeFileSync } from "node:fs";
2
2
  import { homedir } from "node:os";
3
3
  import { join } from "node:path";
4
+ import { deleteSecret, getSecret, noStore, setSecret } from "./keystore.js";
4
5
  export const PROVIDERS = {
5
6
  anthropic: { label: "Anthropic (Claude)", baseUrl: "https://api.anthropic.com", model: "claude-opus-5-5", needsKey: true },
6
7
  openai: { label: "OpenAI", baseUrl: "https://api.openai.com/v1", model: "gpt-6.1-sol", needsKey: true },
@@ -11,30 +12,148 @@ export const PROVIDERS = {
11
12
  export const home = () => process.env.AMNESS_HOME ?? join(homedir(), ".amness");
12
13
  const file = () => join(home(), "config.json");
13
14
  export const defaults = () => ({ provider: "openai", baseUrl: PROVIDERS.openai.baseUrl, model: PROVIDERS.openai.model, maxTokens: 300_000 });
15
+ // ---- keys. One saved key per provider (account "apiKey-<provider>" in the OS store), so you can be connected to several at once. `cfg.apiKey` is always the active provider's key.
16
+ const acct = (p) => `apiKey-${p}`;
17
+ let fileKeys = {}, fileKeysLoaded = false; // only where there is no OS store (a Linux server): kept in config.json, mode 600
18
+ const sessionKeys = {}; // in a container: this run only
19
+ const persisted = {}; // what is stored now, so a save does not rewrite an unchanged key
20
+ /** An environment variable, or the file NAME_FILE points to (Docker and Kubernetes secrets are mounted as files, for example /run/secrets/openai). */
21
+ const envSecret = (name) => {
22
+ const v = process.env[name];
23
+ if (v)
24
+ return v;
25
+ const f = process.env[`${name}_FILE`];
26
+ if (!f)
27
+ return undefined;
28
+ try {
29
+ return readFileSync(f, "utf8").trim() || undefined;
30
+ }
31
+ catch {
32
+ return undefined;
33
+ }
34
+ };
35
+ /** The variable that holds a provider's key. Anthropic has its own; OpenRouter may have its own; every other provider uses OPENAI_API_KEY. */
36
+ export const envNameFor = (p) => (p === "anthropic" ? "ANTHROPIC_API_KEY" : p === "openrouter" && envSecret("OPENROUTER_API_KEY") ? "OPENROUTER_API_KEY" : "OPENAI_API_KEY");
37
+ export const envKeyFor = (p) => envSecret(envNameFor(p));
38
+ const loadFileKeys = () => { if (!fileKeysLoaded) {
39
+ fileKeysLoaded = true;
40
+ fileKeys = { ...readFileCfg().apiKeys };
41
+ } };
42
+ const savedKey = (p) => { loadFileKeys(); return getSecret(acct(p)) ?? fileKeys[p] ?? sessionKeys[p]; };
43
+ /** The key to use for a provider: an environment variable wins over a saved key. */
44
+ export function keyFor(p) {
45
+ const e = envKeyFor(p);
46
+ if (e)
47
+ return { key: e, source: "env" };
48
+ const s = savedKey(p);
49
+ if (s) {
50
+ persisted[p] = s;
51
+ return { key: s, source: "saved" };
52
+ }
53
+ return { source: "none" };
54
+ }
55
+ /** Save (or, with no key, remove) a provider's key. Uses the OS store; else config.json (mode 600); in a container, memory only. */
56
+ export function storeKey(p, key) {
57
+ loadFileKeys();
58
+ persisted[p] = key;
59
+ if (!key) {
60
+ deleteSecret(acct(p));
61
+ delete fileKeys[p];
62
+ delete sessionKeys[p];
63
+ writeConfigFile(readFileCfg());
64
+ return;
65
+ }
66
+ if (setSecret(acct(p), key)) {
67
+ delete fileKeys[p];
68
+ delete sessionKeys[p];
69
+ }
70
+ else if (noStore())
71
+ sessionKeys[p] = key;
72
+ else
73
+ fileKeys[p] = key;
74
+ writeConfigFile(readFileCfg());
75
+ }
76
+ /** Every provider and whether it is ready to use. */
77
+ export function connections() {
78
+ return Object.keys(PROVIDERS).map((id) => ({ id, label: PROVIDERS[id].label, needsKey: PROVIDERS[id].needsKey, source: PROVIDERS[id].needsKey ? keyFor(id).source : "saved", env: envNameFor(id) }));
79
+ }
80
+ const readFileCfg = () => { try {
81
+ return { ...defaults(), ...JSON.parse(readFileSync(file(), "utf8")) };
82
+ }
83
+ catch {
84
+ return defaults();
85
+ } };
86
+ let tavilyEnv;
14
87
  export function loadConfig() {
15
- let c = defaults();
88
+ let c = defaults(), onDisk = {};
16
89
  try {
17
90
  if (existsSync(file()))
18
- c = { ...c, ...JSON.parse(readFileSync(file(), "utf8")) };
91
+ onDisk = JSON.parse(readFileSync(file(), "utf8"));
92
+ c = { ...c, ...onDisk };
19
93
  }
20
94
  catch { /* corrupt config: start fresh */ }
21
- // environment wins, so CI and one-off runs work without touching the saved file
95
+ fileKeys = { ...onDisk.apiKeys };
96
+ fileKeysLoaded = true;
97
+ delete c.apiKeys;
98
+ let dirty = false;
99
+ // older versions kept ONE key (account "apiKey", or a field in the file) for whichever provider was active: give it to that provider
100
+ const legacy = [getSecret("apiKey"), onDisk.apiKey].find(Boolean);
101
+ if (legacy && !savedKey(c.provider)) {
102
+ fileKeys[c.provider] = legacy;
103
+ dirty = true;
104
+ }
105
+ if (getSecret("apiKey"))
106
+ deleteSecret("apiKey");
107
+ if (onDisk.apiKey) {
108
+ delete onDisk.apiKey;
109
+ dirty = true;
110
+ }
111
+ // keys still in the file move to the OS store, and leave the file only after they read back
112
+ for (const p of Object.keys(fileKeys))
113
+ if (fileKeys[p] && setSecret(acct(p), fileKeys[p])) {
114
+ delete fileKeys[p];
115
+ dirty = true;
116
+ }
117
+ const tv = getSecret("tavilyKey") ?? onDisk.tavilyKey;
118
+ if (onDisk.tavilyKey && setSecret("tavilyKey", onDisk.tavilyKey)) {
119
+ delete onDisk.tavilyKey;
120
+ dirty = true;
121
+ }
122
+ if (dirty)
123
+ writeConfigFile({ ...defaults(), ...onDisk, apiKey: undefined, tavilyKey: onDisk.tavilyKey });
22
124
  const claude = c.provider === "anthropic";
23
- const key = claude ? process.env.ANTHROPIC_API_KEY : process.env.OPENAI_API_KEY;
24
- if (key)
25
- c.apiKey = key;
26
- if (process.env.TAVILY_API_KEY)
27
- c.tavilyKey = process.env.TAVILY_API_KEY;
125
+ c.apiKey = keyFor(c.provider).key;
126
+ // environment wins, so CI and one-off runs work without touching the saved key. It is never written back (see saveConfig).
127
+ tavilyEnv = envSecret("TAVILY_API_KEY");
128
+ c.tavilyKey = tavilyEnv ?? tv;
28
129
  if (process.env.OPENAI_BASE_URL && !claude)
29
130
  c.baseUrl = process.env.OPENAI_BASE_URL;
30
131
  if (process.env.AMNESS_MODEL)
31
132
  c.model = process.env.AMNESS_MODEL;
32
133
  return c;
33
134
  }
34
- export function saveConfig(c) {
135
+ function writeConfigFile(c) {
35
136
  mkdirSync(home(), { recursive: true });
36
- writeFileSync(file(), JSON.stringify(c, null, 2));
37
- chmodSync(file(), 0o600); // may hold API keys
137
+ const out = { ...c, apiKeys: Object.keys(fileKeys).length ? fileKeys : undefined };
138
+ writeFileSync(file(), JSON.stringify(out, null, 2), { mode: 0o600 }); // owner-only from the first byte
139
+ chmodSync(file(), 0o600); // and for a file that already existed
140
+ }
141
+ export function saveConfig(c) {
142
+ const out = { ...c };
143
+ delete out.apiKey;
144
+ delete out.apiKeys;
145
+ if (c.apiKey !== persisted[c.provider] && !(c.apiKey && c.apiKey === envKeyFor(c.provider)))
146
+ storeKey(c.provider, c.apiKey); // a key from an environment variable is never stored
147
+ const tv = c.tavilyKey;
148
+ if (tv && tv === tavilyEnv)
149
+ delete out.tavilyKey;
150
+ else if (!tv) {
151
+ deleteSecret("tavilyKey");
152
+ delete out.tavilyKey;
153
+ }
154
+ else if (setSecret("tavilyKey", tv) || noStore())
155
+ delete out.tavilyKey;
156
+ writeConfigFile(out);
38
157
  }
39
158
  export const ready = (c) => !PROVIDERS[c.provider].needsKey || !!c.apiKey;
40
159
  export const mask = (k) => (k ? k.slice(0, 3) + "…" + k.slice(-4) : "not set");
@@ -0,0 +1,9 @@
1
+ export type Backend = "keychain" | "secret-service" | "dpapi" | "file" | "none";
2
+ export declare function backend(): Backend;
3
+ export declare const where: () => string;
4
+ /** True when keys must never be written to disk (containers). */
5
+ export declare const noStore: () => boolean;
6
+ /** Save a secret. Returns false when the OS store is not available or refused, so the caller can fall back to the file. */
7
+ export declare function setSecret(name: string, value: string): boolean;
8
+ export declare function getSecret(name: string): string | undefined;
9
+ export declare function deleteSecret(name: string): void;
@@ -0,0 +1,108 @@
1
+ // Where API keys live. macOS: the login Keychain. Linux: the desktop secret service (`secret-tool`). Windows: DPAPI, which encrypts the key so only your Windows user on this machine can decrypt it.
2
+ // If the OS store is missing or refuses (a Linux server or Docker image with no keyring), the key stays in the config file with mode 600, and `where()` says so.
3
+ // The key goes to the OS tool on stdin, never on the command line (a command line is visible to other users in `ps`), and is hex-encoded so no quoting can go wrong.
4
+ // Tests and any run with a custom AMNESS_HOME use the file, so they never touch your real store. AMNESS_KEYSTORE=file|keychain|secret-service|dpapi|none forces one.
5
+ // In a container (Docker, Kubernetes) nothing is saved at all ("none"): pass the key as an environment variable or a secret file (see config.ts), so it is never written into an image or a volume.
6
+ import { spawnSync } from "node:child_process";
7
+ import { existsSync, mkdirSync, readFileSync, rmSync, writeFileSync } from "node:fs";
8
+ import { homedir } from "node:os";
9
+ import { join } from "node:path";
10
+ const SERVICE = process.env.AMNESS_KEYSTORE_SERVICE || "amness"; // the override is for tests
11
+ const run = (cmd, args, input, timeout = 10_000, env) => {
12
+ try {
13
+ const r = spawnSync(cmd, args, { input, encoding: "utf8", timeout, env });
14
+ return r.error ? undefined : r;
15
+ }
16
+ catch {
17
+ return undefined;
18
+ }
19
+ };
20
+ export function backend() {
21
+ const want = process.env.AMNESS_KEYSTORE;
22
+ if (want === "file" || (!want && process.env.AMNESS_HOME))
23
+ return "file";
24
+ if (want === "keychain" || want === "secret-service" || want === "dpapi" || want === "none")
25
+ return want;
26
+ if (process.platform === "linux" && (existsSync("/.dockerenv") || process.env.KUBERNETES_SERVICE_HOST))
27
+ return "none";
28
+ if (process.platform === "darwin")
29
+ return "keychain";
30
+ if (process.platform === "win32")
31
+ return "dpapi";
32
+ if (process.platform === "linux" && run("secret-tool", ["--version"])?.status !== undefined)
33
+ return "secret-service";
34
+ return "file";
35
+ }
36
+ export const where = () => ({ keychain: "saved in the macOS Keychain", "secret-service": "saved in the system keyring (secret-tool)", dpapi: "saved with Windows encryption (DPAPI, this user only)", file: "saved in ~/.amness/config.json, mode 600, not encrypted", none: "not saved: this is a container, so pass the key as an environment variable or a secret file (OPENAI_API_KEY_FILE / ANTHROPIC_API_KEY_FILE)" })[backend()];
37
+ /** True when keys must never be written to disk (containers). */
38
+ export const noStore = () => backend() === "none";
39
+ // Windows: PowerShell turns the key into an encrypted string that only this Windows user on this machine can decrypt (DPAPI). The string is kept in ~/.amness/secrets/<name>.dpapi.
40
+ const dir = () => join(process.env.AMNESS_HOME ?? join(homedir(), ".amness"), "secrets");
41
+ const blob = (name) => join(dir(), `${name}.dpapi`);
42
+ // A parent PowerShell 7 (pwsh) leaves a PSModulePath that Windows PowerShell 5.1 cannot load its Security module from; dropping it lets 5.1 use its own default.
43
+ const psEnv = () => { const e = { ...process.env }; for (const k of Object.keys(e))
44
+ if (k.toLowerCase() === "psmodulepath")
45
+ delete e[k]; return e; };
46
+ const ps = (script, input) => run("powershell.exe", ["-NoProfile", "-NonInteractive", "-Command", script], input, 60_000, psEnv()); // Windows PowerShell can take several seconds to start
47
+ const PS_ENCRYPT = "$p = [Console]::In.ReadToEnd().Trim(); ConvertTo-SecureString $p -AsPlainText -Force | ConvertFrom-SecureString";
48
+ const PS_DECRYPT = "$e = [Console]::In.ReadToEnd().Trim(); $s = ConvertTo-SecureString $e; [Runtime.InteropServices.Marshal]::PtrToStringBSTR([Runtime.InteropServices.Marshal]::SecureStringToBSTR($s))";
49
+ const hex = (s) => Buffer.from(s, "utf8").toString("hex");
50
+ const unhex = (s) => Buffer.from(s.trim(), "hex").toString("utf8");
51
+ /** Save a secret. Returns false when the OS store is not available or refused, so the caller can fall back to the file. */
52
+ export function setSecret(name, value) {
53
+ const b = backend();
54
+ if (b === "file" || b === "none")
55
+ return false;
56
+ if (b === "dpapi") {
57
+ const r = ps(PS_ENCRYPT, hex(value));
58
+ if (r?.status !== 0 || !r.stdout.trim())
59
+ return false;
60
+ try {
61
+ mkdirSync(dir(), { recursive: true });
62
+ writeFileSync(blob(name), r.stdout.trim(), { mode: 0o600 });
63
+ }
64
+ catch {
65
+ return false;
66
+ }
67
+ return getSecret(name) === value;
68
+ }
69
+ if (b === "keychain") {
70
+ const r = run("security", ["-i"], `add-generic-password -U -s ${SERVICE} -a ${name} -w ${hex(value)}\n`);
71
+ return r?.status === 0 && !/error|fail/i.test(r.stderr ?? "") && getSecret(name) === value;
72
+ }
73
+ const r = run("secret-tool", ["store", `--label=amness ${name}`, "service", SERVICE, "account", name], hex(value));
74
+ return r?.status === 0 && getSecret(name) === value;
75
+ }
76
+ export function getSecret(name) {
77
+ const b = backend();
78
+ if (b === "file" || b === "none")
79
+ return undefined;
80
+ if (b === "dpapi") {
81
+ if (!existsSync(blob(name)))
82
+ return undefined;
83
+ const r = ps(PS_DECRYPT, readFileSync(blob(name), "utf8"));
84
+ const v = r?.status === 0 ? unhex(r.stdout) : "";
85
+ return v || undefined;
86
+ }
87
+ const r = b === "keychain" ? run("security", ["find-generic-password", "-s", SERVICE, "-a", name, "-w"]) : run("secret-tool", ["lookup", "service", SERVICE, "account", name]);
88
+ if (r?.status !== 0 || !r.stdout.trim())
89
+ return undefined;
90
+ const v = unhex(r.stdout);
91
+ return v || undefined;
92
+ }
93
+ export function deleteSecret(name) {
94
+ const b = backend();
95
+ if (b === "file" || b === "none")
96
+ return;
97
+ if (b === "dpapi") {
98
+ try {
99
+ rmSync(blob(name), { force: true });
100
+ }
101
+ catch { /* already gone */ }
102
+ return;
103
+ }
104
+ if (b === "keychain")
105
+ run("security", ["delete-generic-password", "-s", SERVICE, "-a", name]);
106
+ else
107
+ run("secret-tool", ["clear", "service", SERVICE, "account", name]);
108
+ }
@@ -0,0 +1,8 @@
1
+ export type ModelProbe = {
2
+ provider: string;
3
+ baseUrl: string;
4
+ apiKey?: string;
5
+ };
6
+ export declare function fetchModels(p: ModelProbe, fetchImpl?: typeof fetch): Promise<string[]>;
7
+ /** For tests. */
8
+ export declare const clearModelCache: () => void;
@@ -0,0 +1,31 @@
1
+ const cache = new Map();
2
+ const TTL = 10 * 60_000;
3
+ const NOT_CHAT = /embed|whisper|tts|dall-e|moderation|davinci|babbage|transcribe|realtime|audio|image|rerank/i;
4
+ export async function fetchModels(p, fetchImpl = fetch) {
5
+ if (!p.apiKey && p.provider !== "ollama" && p.provider !== "custom")
6
+ return []; // a hosted provider refuses a request with no key: do not even try
7
+ const key = `${p.provider}|${p.baseUrl}|${(p.apiKey ?? "").slice(-6)}`, hit = cache.get(key);
8
+ if (hit && Date.now() - hit.at < TTL)
9
+ return hit.ids;
10
+ const base = p.baseUrl.replace(/\/+$/, "");
11
+ const anthropic = p.provider === "anthropic";
12
+ try {
13
+ const res = await fetchImpl(anthropic ? `${base}/v1/models?limit=100` : `${base}/models`, {
14
+ headers: anthropic ? { "x-api-key": p.apiKey ?? "", "anthropic-version": "2023-06-01" } : p.apiKey ? { authorization: `Bearer ${p.apiKey}` } : {},
15
+ signal: AbortSignal.timeout(6000),
16
+ });
17
+ if (!res.ok)
18
+ return [];
19
+ const body = (await res.json());
20
+ const ids = [...(body.data ?? []).map((m) => m.id), ...(body.models ?? []).map((m) => m.model ?? m.name)] // Ollama's native shape uses `models`
21
+ .filter((x) => typeof x === "string" && !!x && !NOT_CHAT.test(x));
22
+ const uniq = [...new Set(ids)].sort();
23
+ cache.set(key, { at: Date.now(), ids: uniq });
24
+ return uniq;
25
+ }
26
+ catch {
27
+ return [];
28
+ }
29
+ }
30
+ /** For tests. */
31
+ export const clearModelCache = () => cache.clear();
@@ -131,9 +131,11 @@ export declare class App {
131
131
  items: Suggestion[];
132
132
  sel: number;
133
133
  token: Token;
134
- kind: Token["kind"];
134
+ kind: Token["kind"]; /** the user moved into the list with the arrow keys */
135
+ nav?: boolean;
135
136
  } | null;
136
137
  suggOff: string;
138
+ private argPending;
137
139
  private builtin;
138
140
  private customAt;
139
141
  private customCmds;
@@ -15,7 +15,8 @@ import { BlockStore } from "./blocks.js";
15
15
  import { buildCommands } from "./commands.js";
16
16
  import { FullView } from "./full.js";
17
17
  import { loadCustomCommands } from "./custom.js";
18
- import { FileIndex, applySuggestion, rankCommands, rankFiles, tokenAtCursor } from "./complete.js";
18
+ import { argOptions } from "./argcomplete.js";
19
+ import { FileIndex, fuzzyScore, applySuggestion, menuRows, rankCommands, rankFiles, tokenAtCursor } from "./complete.js";
19
20
  import { Editor } from "./editor.js";
20
21
  import { KeyParser, PASTE_OFF, PASTE_ON } from "./keys.js";
21
22
  import { renderMarkdown, takeCompleteBlocks } from "./markdown.js";
@@ -80,6 +81,7 @@ export class App {
80
81
  pager = null;
81
82
  sugg = null;
82
83
  suggOff = "";
84
+ argPending = new Set();
83
85
  builtin;
84
86
  customAt = 0;
85
87
  customCmds = [];
@@ -370,11 +372,16 @@ export class App {
370
372
  setTitle(t) { if (this.io.output.isTTY !== false)
371
373
  this.io.output.write(`\x1b]0;${t}\x07`); }
372
374
  suggLines(w) {
373
- const s = this.sugg, win = 8, top = Math.max(0, Math.min(s.sel - 3, s.items.length - win));
374
- return s.items.slice(top, top + win).map((it, i) => {
375
- const on = top + i === s.sel, label = s.kind === "file" ? it.label : it.label.padEnd(12);
375
+ const s = this.sugg, { rows, above, below } = menuRows(s.items, s.sel, Math.min(12, Math.max(4, this.screen.rows - 8)));
376
+ const lines = rows.map((r) => {
377
+ if ("head" in r)
378
+ return dim(` ── ${r.head} ${"─".repeat(Math.max(2, 20 - r.head.length))}`);
379
+ const it = s.items[r.item], on = r.item === s.sel, label = s.kind === "file" ? it.label : it.label.padEnd(16);
376
380
  return (on ? cyan("❯ ") + bold(cyan(label)) : " " + label) + (it.detail ? " " + (on ? gray(it.detail) : dim(it.detail)) : "");
377
381
  }).map((l) => (cellWidth(l) > w ? l.slice(0, w + 12) : l));
382
+ if (above || below)
383
+ lines.push(dim(` ${s.sel + 1}/${s.items.length} ${above ? "↑" : " "}${below ? "↓" : " "} ↑↓ move · PgUp/PgDn page`));
384
+ return lines;
378
385
  }
379
386
  tick = () => { this.frame++; this.render(); };
380
387
  ensureTimer() { if (!this.timer)
@@ -451,7 +458,7 @@ export class App {
451
458
  if (k.name === "enter" && !k.alt && !k.shift) {
452
459
  if (this.sugg && this.sugg.items.length) {
453
460
  const it = this.sugg.items[this.sugg.sel];
454
- if (!(this.sugg.kind === "slash" && ed.text.trim() === it.label))
461
+ if (!(this.sugg.kind === "slash" && ed.text.trim() === it.label) && !(this.sugg.kind === "arg" && (this.sugg.items.some((x) => x.label === this.sugg.token.query) || (it.open && !this.sugg.nav))))
455
462
  return this.acceptSugg(true);
456
463
  }
457
464
  if (ed.text.slice(0, ed.cursorIndex).endsWith("\\")) {
@@ -461,10 +468,16 @@ export class App {
461
468
  }
462
469
  return void this.submit();
463
470
  }
471
+ if (this.sugg && (k.name === "pageup" || k.name === "pagedown")) {
472
+ const n = this.sugg.items.length;
473
+ this.sugg.sel = Math.max(0, Math.min(n - 1, this.sugg.sel + (k.name === "pageup" ? -8 : 8)));
474
+ return this.render();
475
+ }
464
476
  if (k.name === "up" || k.name === "down") {
465
477
  if (this.sugg) {
466
478
  const n = this.sugg.items.length;
467
479
  this.sugg.sel = (this.sugg.sel + (k.name === "up" ? n - 1 : 1)) % n;
480
+ this.sugg.nav = true;
468
481
  return this.render();
469
482
  }
470
483
  if (!ed.vertical(k.name === "up" ? -1 : 1))
@@ -492,13 +505,25 @@ export class App {
492
505
  let items = [];
493
506
  if (tok.kind === "slash")
494
507
  items = rankCommands(tok.query, this.cmds);
508
+ else if (tok.kind === "arg") {
509
+ const cmd = this.cmds.find((c) => c.name === tok.cmd || c.alias === tok.cmd);
510
+ if (cmd) {
511
+ const r = argOptions(this, cmd.name, tok.words ?? []), pk = `${cmd.name}|${(tok.words ?? []).join(" ")}`;
512
+ if (r.pending && !this.argPending.has(pk)) {
513
+ this.argPending.add(pk);
514
+ void r.pending.catch(() => { }).then(() => { this.argPending.delete(pk); this.updateSugg(); this.render(); });
515
+ }
516
+ items = r.options.map((o) => ({ o, s: fuzzyScore(tok.query, o.value) })).filter((x) => x.s !== null)
517
+ .sort((x, y) => (tok.query ? y.s - x.s : 0)).map(({ o }) => ({ label: o.value, insert: o.value + (o.more ? " " : ""), detail: o.detail, more: o.more, open: r.open }));
518
+ }
519
+ }
495
520
  else {
496
521
  if (!this.files.fresh())
497
522
  void this.files.ensure().then(() => this.updateSugg(), () => { }); // list still loading: fill the popup in once it arrives
498
523
  items = rankFiles(tok.query, this.files.files);
499
524
  }
500
- const keep = this.sugg && this.sugg.kind === tok.kind && this.sugg.token.query === tok.query ? this.sugg.sel : 0;
501
- this.sugg = items.length ? { items, sel: Math.min(keep, items.length - 1), token: tok, kind: tok.kind } : null;
525
+ const same = this.sugg && this.sugg.kind === tok.kind && this.sugg.token.query === tok.query, keep = same ? this.sugg.sel : 0;
526
+ this.sugg = items.length ? { items, sel: Math.min(keep, items.length - 1), token: tok, kind: tok.kind, nav: same ? this.sugg.nav : false } : null;
502
527
  if (!this.sugg)
503
528
  return;
504
529
  this.schedule();
@@ -512,6 +537,8 @@ export class App {
512
537
  this.suggOff = "";
513
538
  if (fromEnter && s.kind === "slash" && !this.cmds.find((c) => "/" + c.name === s.items[s.sel].label)?.args)
514
539
  return void this.submit();
540
+ if (fromEnter && s.kind === "arg" && !s.items[s.sel].more)
541
+ return void this.submit(); // a finished value: run the command
515
542
  this.updateSugg();
516
543
  this.render();
517
544
  }
@@ -0,0 +1,12 @@
1
+ import type { App } from "./app.ts";
2
+ export type ArgOption = {
3
+ value: string;
4
+ detail?: string; /** more words are expected after this one: insert a space and do not submit */
5
+ more?: boolean;
6
+ };
7
+ export type ArgResult = {
8
+ options: ArgOption[];
9
+ pending?: Promise<unknown>; /** any value is allowed (a number, a model id, a command): Enter runs what you typed unless you moved into the list; Tab still completes */
10
+ open?: boolean;
11
+ };
12
+ export declare function argOptions(a: App, cmd: string, words: string[]): ArgResult;