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 +32 -7
- package/dist/cli/amness.js +5 -4
- package/dist/cli/config.d.ts +22 -1
- package/dist/cli/config.js +130 -11
- package/dist/cli/keystore.d.ts +9 -0
- package/dist/cli/keystore.js +108 -0
- package/dist/cli/models.d.ts +8 -0
- package/dist/cli/models.js +31 -0
- package/dist/cli/tui/app.d.ts +3 -1
- package/dist/cli/tui/app.js +34 -7
- package/dist/cli/tui/argcomplete.d.ts +12 -0
- package/dist/cli/tui/argcomplete.js +55 -0
- package/dist/cli/tui/cards.d.ts +2 -0
- package/dist/cli/tui/cards.js +4 -3
- package/dist/cli/tui/commands.js +93 -23
- package/dist/cli/tui/complete.d.ts +23 -3
- package/dist/cli/tui/complete.js +38 -3
- package/dist/cli/tui/full.js +18 -8
- package/dist/src/anthropic.d.ts +1 -1
- package/docs/architecture.md +1 -1
- package/docs/bench.md +4 -5
- package/docs/development.md +1 -1
- package/docs/getting-started.md +18 -9
- package/docs/headless.md +6 -1
- package/docs/library.md +9 -18
- package/docs/providers.md +14 -2
- package/docs/safety.md +14 -1
- package/docs/usage.md +27 -3
- package/package.json +4 -10
package/README.md
CHANGED
|
@@ -5,7 +5,7 @@
|
|
|
5
5
|
[](https://github.com/amirsdream/Amness/actions/workflows/ci.yml)
|
|
6
6
|
[](LICENSE)
|
|
7
7
|

|
|
8
|
-

|
|
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
|
-
|
|
20
|
-
|
|
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
|
|
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.
|
|
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
|
package/dist/cli/amness.js
CHANGED
|
@@ -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(
|
|
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(
|
|
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")
|
package/dist/cli/config.d.ts
CHANGED
|
@@ -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;
|
package/dist/cli/config.js
CHANGED
|
@@ -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
|
-
|
|
91
|
+
onDisk = JSON.parse(readFileSync(file(), "utf8"));
|
|
92
|
+
c = { ...c, ...onDisk };
|
|
19
93
|
}
|
|
20
94
|
catch { /* corrupt config: start fresh */ }
|
|
21
|
-
|
|
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
|
-
|
|
24
|
-
|
|
25
|
-
|
|
26
|
-
|
|
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
|
-
|
|
135
|
+
function writeConfigFile(c) {
|
|
35
136
|
mkdirSync(home(), { recursive: true });
|
|
36
|
-
|
|
37
|
-
|
|
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,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();
|
package/dist/cli/tui/app.d.ts
CHANGED
|
@@ -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;
|
package/dist/cli/tui/app.js
CHANGED
|
@@ -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 {
|
|
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,
|
|
374
|
-
|
|
375
|
-
|
|
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
|
|
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;
|