@pugi/cli 0.1.0-alpha.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/LICENSE +21 -0
- package/README.md +152 -0
- package/bin/run.js +2 -0
- package/dist/core/credentials.js +355 -0
- package/dist/core/engine/adapter-runner.js +8 -0
- package/dist/core/engine/anvil-client.js +156 -0
- package/dist/core/engine/index.js +7 -0
- package/dist/core/engine/native-pugi.js +369 -0
- package/dist/core/engine/noop.js +27 -0
- package/dist/core/engine/prompts.js +76 -0
- package/dist/core/engine/tool-bridge.js +215 -0
- package/dist/core/file-cache.js +29 -0
- package/dist/core/index-store.js +260 -0
- package/dist/core/path-security.js +63 -0
- package/dist/core/permission.js +204 -0
- package/dist/core/session.js +90 -0
- package/dist/core/settings.js +46 -0
- package/dist/index.js +8 -0
- package/dist/runtime/cli.js +2935 -0
- package/dist/tools/file-tools.js +346 -0
- package/dist/tools/registry.js +24 -0
- package/package.json +58 -0
package/LICENSE
ADDED
|
@@ -0,0 +1,21 @@
|
|
|
1
|
+
MIT License
|
|
2
|
+
|
|
3
|
+
Copyright (c) 2026 Yurii Bulakh
|
|
4
|
+
|
|
5
|
+
Permission is hereby granted, free of charge, to any person obtaining a copy
|
|
6
|
+
of this software and associated documentation files (the "Software"), to deal
|
|
7
|
+
in the Software without restriction, including without limitation the rights
|
|
8
|
+
to use, copy, modify, merge, publish, distribute, sublicense, and/or sell
|
|
9
|
+
copies of the Software, and to permit persons to whom the Software is
|
|
10
|
+
furnished to do so, subject to the following conditions:
|
|
11
|
+
|
|
12
|
+
The above copyright notice and this permission notice shall be included in all
|
|
13
|
+
copies or substantial portions of the Software.
|
|
14
|
+
|
|
15
|
+
THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
|
|
16
|
+
IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY,
|
|
17
|
+
FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE
|
|
18
|
+
AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER
|
|
19
|
+
LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM,
|
|
20
|
+
OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE
|
|
21
|
+
SOFTWARE.
|
package/README.md
ADDED
|
@@ -0,0 +1,152 @@
|
|
|
1
|
+
# Pugi CLI
|
|
2
|
+
|
|
3
|
+
`pugi` — terminal-native software execution system. Run agents on your repo,
|
|
4
|
+
hand jobs off to the cabinet or a remote runner, and keep every artifact local
|
|
5
|
+
by default.
|
|
6
|
+
|
|
7
|
+
- **Local-first.** Every plan, diff, and artifact lives under `.pugi/` in your
|
|
8
|
+
repo. Nothing leaves the machine unless you explicitly run `pugi handoff` or
|
|
9
|
+
`pugi sync`.
|
|
10
|
+
- **Web continuation.** When a job needs collaboration, an approval, or a clean
|
|
11
|
+
Linux runner, hand it off to the cabinet at `app.pugi.io`.
|
|
12
|
+
- **One CLI, three install paths.** npm, Homebrew tap, and a one-liner shell
|
|
13
|
+
script.
|
|
14
|
+
|
|
15
|
+
## Install
|
|
16
|
+
|
|
17
|
+
### npm (works everywhere with Node 20+)
|
|
18
|
+
|
|
19
|
+
```bash
|
|
20
|
+
npm install -g pugi
|
|
21
|
+
pugi --version
|
|
22
|
+
```
|
|
23
|
+
|
|
24
|
+
### Homebrew (macOS + Linux)
|
|
25
|
+
|
|
26
|
+
```bash
|
|
27
|
+
brew install pugi-io/tap/pugi
|
|
28
|
+
pugi --version
|
|
29
|
+
```
|
|
30
|
+
|
|
31
|
+
The formula declares a Node 20+ runtime dependency and downloads the published
|
|
32
|
+
npm tarball, so the result is identical to `npm install -g pugi`.
|
|
33
|
+
|
|
34
|
+
### One-liner (curl)
|
|
35
|
+
|
|
36
|
+
```bash
|
|
37
|
+
curl -fsSL https://pugi.dev/install | sh
|
|
38
|
+
```
|
|
39
|
+
|
|
40
|
+
The script detects your OS (Darwin / Linux), bootstraps Node 20+ via Homebrew
|
|
41
|
+
or `apt` if it is missing, and then runs `npm install -g pugi`. It prints the
|
|
42
|
+
installed version on success and exits non-zero on any failure. The script
|
|
43
|
+
itself is served from `pugi.dev`; review it at `https://pugi.dev/install` or
|
|
44
|
+
in `apps/admin-api/public/install.sh` before piping into a shell.
|
|
45
|
+
|
|
46
|
+
### Requirements
|
|
47
|
+
|
|
48
|
+
- Node.js **20 or newer** (`node --version`)
|
|
49
|
+
- A POSIX shell for the curl installer (macOS, Linux, WSL)
|
|
50
|
+
- Git, for any command that touches a repo
|
|
51
|
+
|
|
52
|
+
## Quickstart
|
|
53
|
+
|
|
54
|
+
```bash
|
|
55
|
+
mkdir my-project && cd my-project
|
|
56
|
+
git init
|
|
57
|
+
pugi init
|
|
58
|
+
pugi idea "build a tiny TODO app"
|
|
59
|
+
pugi plan
|
|
60
|
+
pugi build
|
|
61
|
+
pugi review
|
|
62
|
+
```
|
|
63
|
+
|
|
64
|
+
Every command writes to `.pugi/` (events log, artifacts, index). Re-run
|
|
65
|
+
`pugi sessions --rebuild` if you ever delete the index — the append-only
|
|
66
|
+
`.pugi/events.jsonl` is the source of truth.
|
|
67
|
+
|
|
68
|
+
## Login
|
|
69
|
+
|
|
70
|
+
Most commands run fully offline. The ones that talk to the Pugi runtime
|
|
71
|
+
(`pugi review --triple --remote`, future `pugi handoff`) need an API key.
|
|
72
|
+
|
|
73
|
+
```bash
|
|
74
|
+
export PUGI_API_KEY=pugi_live_... # from app.pugi.io > Settings > API
|
|
75
|
+
export PUGI_API_URL=https://api.pugi.io # optional, this is the default
|
|
76
|
+
pugi review --triple --remote
|
|
77
|
+
```
|
|
78
|
+
|
|
79
|
+
The key is read from the environment, never persisted to disk, and never
|
|
80
|
+
logged. To revoke it, rotate the key in the cabinet — the CLI will see a
|
|
81
|
+
`401` on the next call and exit `5`.
|
|
82
|
+
|
|
83
|
+
## Common commands
|
|
84
|
+
|
|
85
|
+
```bash
|
|
86
|
+
pugi init # bootstrap .pugi/ in the current repo
|
|
87
|
+
pugi idea "..." # capture an idea, opens a plan stub
|
|
88
|
+
pugi plan # ask the persona team to expand the idea
|
|
89
|
+
pugi build # execute the plan locally
|
|
90
|
+
pugi review # local diff review
|
|
91
|
+
pugi review --triple # local triple-review evidence bundle
|
|
92
|
+
pugi review --triple --remote
|
|
93
|
+
# call Anvil for 3-model consensus
|
|
94
|
+
pugi handoff --web # hand the session off to the cabinet
|
|
95
|
+
pugi sessions # list sessions from .pugi/index.json
|
|
96
|
+
pugi sessions --rebuild # rebuild the index from events.jsonl
|
|
97
|
+
pugi doctor --json # environment diagnostic
|
|
98
|
+
pugi version # CLI version
|
|
99
|
+
```
|
|
100
|
+
|
|
101
|
+
Run `pugi --help` for the full list.
|
|
102
|
+
|
|
103
|
+
## Privacy
|
|
104
|
+
|
|
105
|
+
Pugi defaults to `local-only` — no upload happens without an explicit flag.
|
|
106
|
+
`pugi sync --dry-run --privacy <mode>` lets you preview exactly what would
|
|
107
|
+
leave the machine before you ever enable real upload (still gated; the alpha
|
|
108
|
+
returns `status: blocked, reason: sync_upload_not_implemented`).
|
|
109
|
+
|
|
110
|
+
## Updating
|
|
111
|
+
|
|
112
|
+
```bash
|
|
113
|
+
npm install -g pugi@latest # if you installed via npm
|
|
114
|
+
brew upgrade pugi # if you installed via Homebrew
|
|
115
|
+
curl -fsSL https://pugi.dev/install | sh # one-liner re-run is idempotent
|
|
116
|
+
```
|
|
117
|
+
|
|
118
|
+
## Uninstall
|
|
119
|
+
|
|
120
|
+
```bash
|
|
121
|
+
npm uninstall -g pugi
|
|
122
|
+
# or
|
|
123
|
+
brew uninstall pugi
|
|
124
|
+
```
|
|
125
|
+
|
|
126
|
+
The CLI never installs anything outside the Node global prefix and the
|
|
127
|
+
Homebrew cellar. `.pugi/` directories in your repos are left untouched on
|
|
128
|
+
uninstall; remove them manually if you want a clean slate.
|
|
129
|
+
|
|
130
|
+
## Distribution
|
|
131
|
+
|
|
132
|
+
The three install paths are documented in detail at
|
|
133
|
+
[`docs/features/pugi-cli-distribution.md`](../../docs/features/pugi-cli-distribution.md)
|
|
134
|
+
and rationalised in [`docs/adr/0049-pugi-cli-distribution-strategy.md`](../../docs/adr/0049-pugi-cli-distribution-strategy.md).
|
|
135
|
+
Release operators: see the "Release process" section in the feature doc for
|
|
136
|
+
the tag → publish → tap-formula bump → smoke-test loop.
|
|
137
|
+
|
|
138
|
+
## Testing the published tarball locally
|
|
139
|
+
|
|
140
|
+
Before tagging a release, run the local smoke test:
|
|
141
|
+
|
|
142
|
+
```bash
|
|
143
|
+
pnpm --filter pugi pack:smoke
|
|
144
|
+
```
|
|
145
|
+
|
|
146
|
+
It runs `npm pack` against the CLI workspace, asserts the tarball contains
|
|
147
|
+
`bin/run.js`, `dist/`, `README.md`, and `LICENSE`, and rejects the build if
|
|
148
|
+
anything is missing.
|
|
149
|
+
|
|
150
|
+
## License
|
|
151
|
+
|
|
152
|
+
MIT — see [LICENSE](./LICENSE).
|
package/bin/run.js
ADDED
|
@@ -0,0 +1,355 @@
|
|
|
1
|
+
import { chmodSync, existsSync, mkdirSync, readFileSync, renameSync, rmSync, writeFileSync, } from 'node:fs';
|
|
2
|
+
import { homedir } from 'node:os';
|
|
3
|
+
import { resolve } from 'node:path';
|
|
4
|
+
import { z } from 'zod';
|
|
5
|
+
/**
|
|
6
|
+
* Local credentials store for the Pugi CLI.
|
|
7
|
+
*
|
|
8
|
+
* Stored at `~/.pugi/credentials.json` (mode 0o600). Mirrors the convention
|
|
9
|
+
* Codex CLI uses (`~/.codex/auth.json`) and matches gh CLI's per-host
|
|
10
|
+
* token model. The store is intentionally file-based, not OS keychain —
|
|
11
|
+
* adding the native `keytar` dep would force per-platform native builds
|
|
12
|
+
* across npm distribution and complicate the install path. The 0600
|
|
13
|
+
* file mode plus a strictly typed Zod schema is the minimum-viable
|
|
14
|
+
* substitute for the keychain path.
|
|
15
|
+
*
|
|
16
|
+
* Multi-host: each host (apiUrl) has its own record. `pugi login` adds
|
|
17
|
+
* or replaces the record for the active host; `pugi logout` removes it.
|
|
18
|
+
* `loadActiveCredential` returns the record for the active host (the one
|
|
19
|
+
* matching `PUGI_API_URL` or `apiUrl` from `~/.pugi/config.json`).
|
|
20
|
+
*
|
|
21
|
+
* Future device-flow tokens land in the same `tokens` array with a
|
|
22
|
+
* `kind: 'oauth-device'` discriminator and a refresh-on-use rotation.
|
|
23
|
+
*/
|
|
24
|
+
const CREDENTIALS_SCHEMA_VERSION = 1;
|
|
25
|
+
/**
|
|
26
|
+
* How the credential was obtained. Surfaced by `pugi whoami` so the user
|
|
27
|
+
* can confirm at a glance whether a stored record came from an
|
|
28
|
+
* interactive device flow, a paste-in PAT, or an env-var prime in CI.
|
|
29
|
+
*
|
|
30
|
+
* Older credential files written before this discriminator was added do
|
|
31
|
+
* not carry `source` — the field is optional and `pugi whoami` falls
|
|
32
|
+
* back to `unknown` so we never crash on a legacy file.
|
|
33
|
+
*/
|
|
34
|
+
export const pugiTokenSourceSchema = z.enum(['token', 'device-flow', 'env']);
|
|
35
|
+
export const pugiTokenRecordSchema = z.object({
|
|
36
|
+
apiUrl: z.string().url(),
|
|
37
|
+
apiKey: z.string().min(1),
|
|
38
|
+
label: z.string().min(1).optional(),
|
|
39
|
+
createdAt: z.string().datetime(),
|
|
40
|
+
/**
|
|
41
|
+
* When the credential was last refreshed in the store. Today the
|
|
42
|
+
* field is set on `storeApiKey` (create / replace) and on
|
|
43
|
+
* `switchActiveAccount`. Read paths (`pugi whoami`, every API call)
|
|
44
|
+
* intentionally do NOT touch the file to keep disk IO off the hot
|
|
45
|
+
* path — a recency value that lags is acceptable, a 0o600 write per
|
|
46
|
+
* `pugi <anything>` is not.
|
|
47
|
+
*/
|
|
48
|
+
lastUsedAt: z.string().datetime().optional(),
|
|
49
|
+
/**
|
|
50
|
+
* Provenance discriminator — see `pugiTokenSourceSchema`. Optional so
|
|
51
|
+
* legacy records (pre-0048) still parse.
|
|
52
|
+
*/
|
|
53
|
+
source: pugiTokenSourceSchema.optional(),
|
|
54
|
+
});
|
|
55
|
+
export const pugiCredentialsFileSchema = z.object({
|
|
56
|
+
schema: z.literal(CREDENTIALS_SCHEMA_VERSION),
|
|
57
|
+
tokens: z.array(pugiTokenRecordSchema).default([]),
|
|
58
|
+
/**
|
|
59
|
+
* URL of the host the user most recently logged into. `pugi whoami`
|
|
60
|
+
* and `resolveActiveCredential` read this when `PUGI_API_URL` is unset
|
|
61
|
+
* so a self-hosted user who runs `pugi login --api-url https://anvil.acme.corp`
|
|
62
|
+
* doesn't have to also export PUGI_API_URL for follow-up commands.
|
|
63
|
+
* Env still wins when set (CI fast path).
|
|
64
|
+
*/
|
|
65
|
+
activeApiUrl: z.string().url().optional(),
|
|
66
|
+
});
|
|
67
|
+
export const DEFAULT_API_URL = 'https://api.pugi.io';
|
|
68
|
+
export function credentialsPaths(home = homedir()) {
|
|
69
|
+
const pugiDir = resolve(home, '.pugi');
|
|
70
|
+
return {
|
|
71
|
+
homeDir: home,
|
|
72
|
+
pugiDir,
|
|
73
|
+
filePath: resolve(pugiDir, 'credentials.json'),
|
|
74
|
+
};
|
|
75
|
+
}
|
|
76
|
+
export function readCredentialsFile(home = homedir()) {
|
|
77
|
+
const { filePath } = credentialsPaths(home);
|
|
78
|
+
if (!existsSync(filePath)) {
|
|
79
|
+
return { schema: CREDENTIALS_SCHEMA_VERSION, tokens: [] };
|
|
80
|
+
}
|
|
81
|
+
const text = readFileSync(filePath, 'utf8');
|
|
82
|
+
let raw;
|
|
83
|
+
try {
|
|
84
|
+
raw = JSON.parse(text);
|
|
85
|
+
}
|
|
86
|
+
catch {
|
|
87
|
+
// Corrupt JSON: a partial/empty write or hand-edit. Rename the bad
|
|
88
|
+
// file aside so the next write does not silently produce an empty
|
|
89
|
+
// token list (silent data loss). Returning empty here is acceptable
|
|
90
|
+
// because the corrupt copy is preserved for forensic recovery.
|
|
91
|
+
quarantineCorruptCredentials(filePath, 'json-parse');
|
|
92
|
+
return { schema: CREDENTIALS_SCHEMA_VERSION, tokens: [] };
|
|
93
|
+
}
|
|
94
|
+
const parsed = pugiCredentialsFileSchema.safeParse(raw);
|
|
95
|
+
if (parsed.success)
|
|
96
|
+
return parsed.data;
|
|
97
|
+
quarantineCorruptCredentials(filePath, 'schema-mismatch');
|
|
98
|
+
return { schema: CREDENTIALS_SCHEMA_VERSION, tokens: [] };
|
|
99
|
+
}
|
|
100
|
+
function quarantineCorruptCredentials(filePath, reason) {
|
|
101
|
+
try {
|
|
102
|
+
const backup = `${filePath}.corrupt-${reason}-${Date.now()}`;
|
|
103
|
+
renameSync(filePath, backup);
|
|
104
|
+
if (process.stderr?.write) {
|
|
105
|
+
process.stderr.write(`pugi: warning — corrupt credentials file preserved at ${backup}; starting from empty token list\n`);
|
|
106
|
+
}
|
|
107
|
+
}
|
|
108
|
+
catch {
|
|
109
|
+
// Best-effort. If we cannot rename (e.g. read-only fs), the next
|
|
110
|
+
// write will overwrite the corrupt file anyway.
|
|
111
|
+
}
|
|
112
|
+
}
|
|
113
|
+
export function writeCredentialsFile(file, home = homedir()) {
|
|
114
|
+
const paths = credentialsPaths(home);
|
|
115
|
+
if (!existsSync(paths.pugiDir)) {
|
|
116
|
+
mkdirSync(paths.pugiDir, { recursive: true, mode: 0o700 });
|
|
117
|
+
}
|
|
118
|
+
// Defensively tighten existing dir permissions — `mkdirSync(mode)` only
|
|
119
|
+
// applies on creation, not on pre-existing dirs that another tool may
|
|
120
|
+
// have created with 0o755.
|
|
121
|
+
try {
|
|
122
|
+
chmodSync(paths.pugiDir, 0o700);
|
|
123
|
+
}
|
|
124
|
+
catch {
|
|
125
|
+
// Best-effort; FAT/CI filesystems may not support chmod.
|
|
126
|
+
}
|
|
127
|
+
// Validate before persisting so a corrupt object never lands on disk.
|
|
128
|
+
const validated = pugiCredentialsFileSchema.parse(file);
|
|
129
|
+
// Atomic write: tmp + rename. `rename(2)` is atomic on POSIX same-fs,
|
|
130
|
+
// so a concurrent reader either sees the old file or the new one — never
|
|
131
|
+
// a truncated mid-write state that the corrupt-recovery path would
|
|
132
|
+
// quarantine as data loss. Two concurrent writers still race (no advisory
|
|
133
|
+
// lock yet), but neither corrupts the target file.
|
|
134
|
+
const tmp = `${paths.filePath}.${process.pid}.${Date.now()}.tmp`;
|
|
135
|
+
writeFileSync(tmp, `${JSON.stringify(validated, null, 2)}\n`, {
|
|
136
|
+
encoding: 'utf8',
|
|
137
|
+
mode: 0o600,
|
|
138
|
+
});
|
|
139
|
+
try {
|
|
140
|
+
chmodSync(tmp, 0o600);
|
|
141
|
+
}
|
|
142
|
+
catch {
|
|
143
|
+
// Best-effort on filesystems that don't support chmod (FAT, some CIs).
|
|
144
|
+
}
|
|
145
|
+
renameSync(tmp, paths.filePath);
|
|
146
|
+
try {
|
|
147
|
+
chmodSync(paths.filePath, 0o600);
|
|
148
|
+
}
|
|
149
|
+
catch {
|
|
150
|
+
// Best-effort — rename preserves the tmp file's mode on POSIX, this
|
|
151
|
+
// is belt-and-braces.
|
|
152
|
+
}
|
|
153
|
+
}
|
|
154
|
+
/**
|
|
155
|
+
* Add or replace the credential record for the given apiUrl. Returns the
|
|
156
|
+
* stored record (without the `apiKey` echoed back at the caller — the
|
|
157
|
+
* caller already has it). Idempotent.
|
|
158
|
+
*/
|
|
159
|
+
export function storeApiKey(input) {
|
|
160
|
+
const home = input.home ?? homedir();
|
|
161
|
+
const file = readCredentialsFile(home);
|
|
162
|
+
const apiUrl = normalizeApiUrl(input.apiUrl);
|
|
163
|
+
const now = new Date().toISOString();
|
|
164
|
+
const record = pugiTokenRecordSchema.parse({
|
|
165
|
+
apiUrl,
|
|
166
|
+
apiKey: input.apiKey,
|
|
167
|
+
label: input.label,
|
|
168
|
+
createdAt: now,
|
|
169
|
+
lastUsedAt: now,
|
|
170
|
+
source: input.source,
|
|
171
|
+
});
|
|
172
|
+
const others = file.tokens.filter((token) => normalizeApiUrl(token.apiUrl) !== apiUrl);
|
|
173
|
+
writeCredentialsFile({
|
|
174
|
+
schema: CREDENTIALS_SCHEMA_VERSION,
|
|
175
|
+
tokens: [...others, record],
|
|
176
|
+
// Promote the just-logged-in host to active so subsequent `whoami`
|
|
177
|
+
// and `review --remote` find it without the user re-exporting
|
|
178
|
+
// PUGI_API_URL. Env still wins via resolveActiveCredential.
|
|
179
|
+
activeApiUrl: apiUrl,
|
|
180
|
+
}, home);
|
|
181
|
+
return record;
|
|
182
|
+
}
|
|
183
|
+
export function clearApiKey(apiUrl, home = homedir()) {
|
|
184
|
+
const file = readCredentialsFile(home);
|
|
185
|
+
const target = normalizeApiUrl(apiUrl);
|
|
186
|
+
const before = file.tokens.length;
|
|
187
|
+
const tokens = file.tokens.filter((token) => normalizeApiUrl(token.apiUrl) !== target);
|
|
188
|
+
if (tokens.length === before)
|
|
189
|
+
return false;
|
|
190
|
+
// If the cleared host was the active one, demote `activeApiUrl` to the
|
|
191
|
+
// most recently-stored remaining record (or undefined when the store
|
|
192
|
+
// is now empty) so subsequent `whoami` doesn't point at a vanished host.
|
|
193
|
+
const nextActive = file.activeApiUrl && normalizeApiUrl(file.activeApiUrl) === target
|
|
194
|
+
? tokens.at(-1)?.apiUrl
|
|
195
|
+
: file.activeApiUrl;
|
|
196
|
+
writeCredentialsFile({
|
|
197
|
+
schema: CREDENTIALS_SCHEMA_VERSION,
|
|
198
|
+
tokens,
|
|
199
|
+
...(nextActive ? { activeApiUrl: nextActive } : {}),
|
|
200
|
+
}, home);
|
|
201
|
+
return true;
|
|
202
|
+
}
|
|
203
|
+
export function loadApiKey(apiUrl, home = homedir()) {
|
|
204
|
+
const file = readCredentialsFile(home);
|
|
205
|
+
const target = normalizeApiUrl(apiUrl);
|
|
206
|
+
return file.tokens.find((token) => normalizeApiUrl(token.apiUrl) === target) ?? null;
|
|
207
|
+
}
|
|
208
|
+
export function resolveActiveCredential(env = process.env, home = homedir()) {
|
|
209
|
+
// Resolve the active apiUrl with this precedence:
|
|
210
|
+
// 1. PUGI_API_URL env (lets CI / self-hosted users force a specific endpoint)
|
|
211
|
+
// 2. credentials.json `activeApiUrl` (set by `pugi login` to the host the
|
|
212
|
+
// user most recently authenticated against — covers self-hosted Anvil
|
|
213
|
+
// without re-exporting env between commands)
|
|
214
|
+
// 3. DEFAULT_API_URL (`https://api.pugi.io`)
|
|
215
|
+
const file = readCredentialsFile(home);
|
|
216
|
+
const apiUrl = normalizeApiUrl(env.PUGI_API_URL ?? file.activeApiUrl ?? DEFAULT_API_URL);
|
|
217
|
+
if (env.PUGI_API_KEY) {
|
|
218
|
+
return { apiUrl, apiKey: env.PUGI_API_KEY, source: 'env' };
|
|
219
|
+
}
|
|
220
|
+
const record = file.tokens.find((token) => normalizeApiUrl(token.apiUrl) === apiUrl);
|
|
221
|
+
if (record) {
|
|
222
|
+
return {
|
|
223
|
+
apiUrl: record.apiUrl,
|
|
224
|
+
apiKey: record.apiKey,
|
|
225
|
+
source: 'file',
|
|
226
|
+
fileSource: record.source ?? null,
|
|
227
|
+
...(record.label ? { label: record.label } : {}),
|
|
228
|
+
...(record.createdAt ? { createdAt: record.createdAt } : {}),
|
|
229
|
+
...(record.lastUsedAt ? { lastUsedAt: record.lastUsedAt } : {}),
|
|
230
|
+
};
|
|
231
|
+
}
|
|
232
|
+
return null;
|
|
233
|
+
}
|
|
234
|
+
/**
|
|
235
|
+
* Re-point the credentials store at a different already-stored host or
|
|
236
|
+
* label. Used by `pugi accounts switch <label>` so subsequent commands
|
|
237
|
+
* authenticate against the chosen account without forcing the user to
|
|
238
|
+
* re-export PUGI_API_URL between commands.
|
|
239
|
+
*
|
|
240
|
+
* Match precedence:
|
|
241
|
+
* 1. exact `label` match (case-insensitive, label is user-chosen so
|
|
242
|
+
* we forgive casing — same convention as `gh auth switch`)
|
|
243
|
+
* 2. exact `apiUrl` match (canonicalised) — lets `pugi accounts
|
|
244
|
+
* switch https://api.acme.com` work without a label.
|
|
245
|
+
*
|
|
246
|
+
* Returns the now-active record, or null when nothing matched.
|
|
247
|
+
*/
|
|
248
|
+
export function switchActiveAccount(selector, home = homedir()) {
|
|
249
|
+
const file = readCredentialsFile(home);
|
|
250
|
+
const normalisedSelector = selector.trim();
|
|
251
|
+
if (!normalisedSelector)
|
|
252
|
+
return null;
|
|
253
|
+
const lower = normalisedSelector.toLowerCase();
|
|
254
|
+
const targetApiUrl = (() => {
|
|
255
|
+
try {
|
|
256
|
+
return normalizeApiUrl(normalisedSelector);
|
|
257
|
+
}
|
|
258
|
+
catch {
|
|
259
|
+
return null;
|
|
260
|
+
}
|
|
261
|
+
})();
|
|
262
|
+
const match = file.tokens.find((token) => {
|
|
263
|
+
if (token.label && token.label.toLowerCase() === lower)
|
|
264
|
+
return true;
|
|
265
|
+
if (targetApiUrl && normalizeApiUrl(token.apiUrl) === targetApiUrl)
|
|
266
|
+
return true;
|
|
267
|
+
return false;
|
|
268
|
+
});
|
|
269
|
+
if (!match)
|
|
270
|
+
return null;
|
|
271
|
+
const apiUrl = normalizeApiUrl(match.apiUrl);
|
|
272
|
+
const now = new Date().toISOString();
|
|
273
|
+
const updated = pugiTokenRecordSchema.parse({
|
|
274
|
+
...match,
|
|
275
|
+
apiUrl,
|
|
276
|
+
lastUsedAt: now,
|
|
277
|
+
});
|
|
278
|
+
const others = file.tokens.filter((token) => normalizeApiUrl(token.apiUrl) !== apiUrl);
|
|
279
|
+
writeCredentialsFile({
|
|
280
|
+
schema: CREDENTIALS_SCHEMA_VERSION,
|
|
281
|
+
tokens: [...others, updated],
|
|
282
|
+
activeApiUrl: apiUrl,
|
|
283
|
+
}, home);
|
|
284
|
+
return updated;
|
|
285
|
+
}
|
|
286
|
+
/**
|
|
287
|
+
* List every stored credential in stable display order — most recently
|
|
288
|
+
* used first, falling back to creation order. Returned records carry
|
|
289
|
+
* the raw `apiKey`; callers must mask before printing.
|
|
290
|
+
*/
|
|
291
|
+
export function listStoredCredentials(home = homedir()) {
|
|
292
|
+
const file = readCredentialsFile(home);
|
|
293
|
+
const activeUrl = file.activeApiUrl ? normalizeApiUrl(file.activeApiUrl) : null;
|
|
294
|
+
return file.tokens
|
|
295
|
+
.map((token) => ({
|
|
296
|
+
...token,
|
|
297
|
+
isActive: activeUrl !== null && normalizeApiUrl(token.apiUrl) === activeUrl,
|
|
298
|
+
}))
|
|
299
|
+
.sort((a, b) => {
|
|
300
|
+
// Active record always pinned to the top — it's the one the user
|
|
301
|
+
// is asking about whenever they run `pugi accounts list`.
|
|
302
|
+
if (a.isActive && !b.isActive)
|
|
303
|
+
return -1;
|
|
304
|
+
if (b.isActive && !a.isActive)
|
|
305
|
+
return 1;
|
|
306
|
+
const aWhen = a.lastUsedAt ?? a.createdAt;
|
|
307
|
+
const bWhen = b.lastUsedAt ?? b.createdAt;
|
|
308
|
+
return bWhen.localeCompare(aWhen);
|
|
309
|
+
});
|
|
310
|
+
}
|
|
311
|
+
/**
|
|
312
|
+
* Canonicalize an apiUrl so two equivalent inputs always resolve to the
|
|
313
|
+
* same record:
|
|
314
|
+
* - lowercase scheme + host (URL spec: scheme/host are case-insensitive)
|
|
315
|
+
* - strip trailing slashes
|
|
316
|
+
* - preserve path/query/fragment case (those ARE case-sensitive)
|
|
317
|
+
*
|
|
318
|
+
* Falls back to the trimmed input when the URL is not parseable, so a
|
|
319
|
+
* caller that manages to pass a non-URL string still sees a stable key
|
|
320
|
+
* instead of throwing.
|
|
321
|
+
*/
|
|
322
|
+
export function normalizeApiUrl(input) {
|
|
323
|
+
const trimmed = input.trim();
|
|
324
|
+
try {
|
|
325
|
+
const url = new URL(trimmed);
|
|
326
|
+
const path = url.pathname + url.search + url.hash;
|
|
327
|
+
const stripped = path.replace(/\/+$/, '');
|
|
328
|
+
return `${url.protocol.toLowerCase()}//${url.host.toLowerCase()}${stripped}`;
|
|
329
|
+
}
|
|
330
|
+
catch {
|
|
331
|
+
return trimmed.replace(/\/+$/, '');
|
|
332
|
+
}
|
|
333
|
+
}
|
|
334
|
+
/**
|
|
335
|
+
* Best-effort masked token for log lines and `pugi whoami` output.
|
|
336
|
+
* Never returns the raw secret. Keeps the first 4 + last 4 characters so
|
|
337
|
+
* the user can correlate against an issued key without exposing it.
|
|
338
|
+
*/
|
|
339
|
+
export function maskApiKey(apiKey) {
|
|
340
|
+
if (apiKey.length <= 12)
|
|
341
|
+
return `${'*'.repeat(apiKey.length)}`;
|
|
342
|
+
return `${apiKey.slice(0, 4)}…${apiKey.slice(-4)}`;
|
|
343
|
+
}
|
|
344
|
+
/**
|
|
345
|
+
* Delete the entire credentials file. Used by `pugi logout --all` and
|
|
346
|
+
* by tests that want a clean slate.
|
|
347
|
+
*/
|
|
348
|
+
export function purgeAllCredentials(home = homedir()) {
|
|
349
|
+
const paths = credentialsPaths(home);
|
|
350
|
+
if (!existsSync(paths.filePath))
|
|
351
|
+
return false;
|
|
352
|
+
rmSync(paths.filePath);
|
|
353
|
+
return true;
|
|
354
|
+
}
|
|
355
|
+
//# sourceMappingURL=credentials.js.map
|
|
@@ -0,0 +1,156 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Anvil-backed engine loop client.
|
|
3
|
+
*
|
|
4
|
+
* Wire format: OpenAI-compatible `/v1/chat/completions` shape proxied
|
|
5
|
+
* through the admin-api Pugi runtime endpoint. The CLI POSTs:
|
|
6
|
+
*
|
|
7
|
+
* POST {apiUrl}/api/pugi/engine
|
|
8
|
+
* Authorization: Bearer {apiKey}
|
|
9
|
+
* {
|
|
10
|
+
* "personaSlug": "oes-dev",
|
|
11
|
+
* "messages": [...],
|
|
12
|
+
* "tools": [...],
|
|
13
|
+
* "maxTokens": 4096,
|
|
14
|
+
* "temperature": 0.2
|
|
15
|
+
* }
|
|
16
|
+
*
|
|
17
|
+
* and expects:
|
|
18
|
+
*
|
|
19
|
+
* 200 OK
|
|
20
|
+
* {
|
|
21
|
+
* "stop": "tool_use" | "text",
|
|
22
|
+
* "content": "...", // present when stop=text
|
|
23
|
+
* "toolCalls": [{id, name, arguments}], // present when stop=tool_use
|
|
24
|
+
* "tokensUsed": 1234,
|
|
25
|
+
* "model": "deepseek-chat-v3.1"
|
|
26
|
+
* }
|
|
27
|
+
*
|
|
28
|
+
* 401/403 -> auth_missing
|
|
29
|
+
* 404 -> endpoint_missing
|
|
30
|
+
* 429 -> rate_limited
|
|
31
|
+
* other -> failed
|
|
32
|
+
*
|
|
33
|
+
* The endpoint itself ships in Sprint 2 (Track 2A). Until then the CLI
|
|
34
|
+
* surfaces `endpoint_missing` cleanly and the operator runs `pugi code`
|
|
35
|
+
* with `PUGI_ENGINE_FIXTURE` to point at a fixture client.
|
|
36
|
+
*/
|
|
37
|
+
export class AnvilEngineLoopClient {
|
|
38
|
+
config;
|
|
39
|
+
constructor(config) {
|
|
40
|
+
this.config = config;
|
|
41
|
+
}
|
|
42
|
+
async send(messages, tools, options) {
|
|
43
|
+
// Use `new URL(path, base)` so an `apiUrl` that already carries a
|
|
44
|
+
// path prefix (rare, but possible for self-hosted deployments)
|
|
45
|
+
// composes correctly instead of double-pathing via raw string
|
|
46
|
+
// concatenation. The leading `/` anchors resolution to the base
|
|
47
|
+
// host. Self-hosted operators who need their engine endpoint
|
|
48
|
+
// nested under a prefix should bake the prefix into `apiUrl`
|
|
49
|
+
// itself and drop the leading slash here.
|
|
50
|
+
const url = new URL('/api/pugi/engine', this.config.apiUrl).toString();
|
|
51
|
+
const controller = new AbortController();
|
|
52
|
+
const onAbort = () => controller.abort();
|
|
53
|
+
if (options.signal)
|
|
54
|
+
options.signal.addEventListener('abort', onAbort);
|
|
55
|
+
const timeout = setTimeout(() => controller.abort(), this.config.timeoutMs);
|
|
56
|
+
try {
|
|
57
|
+
const res = await fetch(url, {
|
|
58
|
+
method: 'POST',
|
|
59
|
+
headers: {
|
|
60
|
+
'content-type': 'application/json',
|
|
61
|
+
authorization: `Bearer ${this.config.apiKey}`,
|
|
62
|
+
'user-agent': 'pugi-cli/0.0.1',
|
|
63
|
+
},
|
|
64
|
+
body: JSON.stringify({
|
|
65
|
+
personaSlug: options.personaSlug,
|
|
66
|
+
messages,
|
|
67
|
+
tools,
|
|
68
|
+
maxTokens: options.maxTokens,
|
|
69
|
+
temperature: options.temperature,
|
|
70
|
+
}),
|
|
71
|
+
signal: controller.signal,
|
|
72
|
+
});
|
|
73
|
+
const text = await res.text();
|
|
74
|
+
if (res.status === 200) {
|
|
75
|
+
try {
|
|
76
|
+
const json = JSON.parse(text);
|
|
77
|
+
if (json.stop === 'text') {
|
|
78
|
+
return {
|
|
79
|
+
stop: 'text',
|
|
80
|
+
assistantMessage: {
|
|
81
|
+
role: 'assistant',
|
|
82
|
+
content: json.content ?? '',
|
|
83
|
+
},
|
|
84
|
+
content: json.content ?? '',
|
|
85
|
+
tokensUsed: json.tokensUsed ?? 0,
|
|
86
|
+
};
|
|
87
|
+
}
|
|
88
|
+
if (json.stop === 'tool_use') {
|
|
89
|
+
const calls = json.toolCalls ?? [];
|
|
90
|
+
return {
|
|
91
|
+
stop: 'tool_use',
|
|
92
|
+
assistantMessage: {
|
|
93
|
+
role: 'assistant',
|
|
94
|
+
content: json.content ?? '',
|
|
95
|
+
toolCalls: calls,
|
|
96
|
+
},
|
|
97
|
+
tokensUsed: json.tokensUsed ?? 0,
|
|
98
|
+
};
|
|
99
|
+
}
|
|
100
|
+
return {
|
|
101
|
+
stop: 'error',
|
|
102
|
+
code: 'failed',
|
|
103
|
+
message: `runtime returned 200 with unknown stop=${String(json.stop)}`,
|
|
104
|
+
};
|
|
105
|
+
}
|
|
106
|
+
catch (error) {
|
|
107
|
+
return {
|
|
108
|
+
stop: 'error',
|
|
109
|
+
code: 'failed',
|
|
110
|
+
message: `runtime returned 200 with non-JSON body: ${error.message}`,
|
|
111
|
+
};
|
|
112
|
+
}
|
|
113
|
+
}
|
|
114
|
+
if (res.status === 404) {
|
|
115
|
+
return {
|
|
116
|
+
stop: 'error',
|
|
117
|
+
code: 'endpoint_missing',
|
|
118
|
+
message: 'POST /api/pugi/engine not deployed on this runtime',
|
|
119
|
+
};
|
|
120
|
+
}
|
|
121
|
+
if (res.status === 401 || res.status === 403) {
|
|
122
|
+
return {
|
|
123
|
+
stop: 'error',
|
|
124
|
+
code: 'auth_missing',
|
|
125
|
+
message: `runtime rejected credentials (HTTP ${res.status})`,
|
|
126
|
+
};
|
|
127
|
+
}
|
|
128
|
+
if (res.status === 429) {
|
|
129
|
+
return {
|
|
130
|
+
stop: 'error',
|
|
131
|
+
code: 'rate_limited',
|
|
132
|
+
message: 'runtime rate limit reached for this tenant',
|
|
133
|
+
};
|
|
134
|
+
}
|
|
135
|
+
return {
|
|
136
|
+
stop: 'error',
|
|
137
|
+
code: 'failed',
|
|
138
|
+
message: `runtime returned HTTP ${res.status}${text ? `: ${text.slice(0, 200)}` : ''}`,
|
|
139
|
+
};
|
|
140
|
+
}
|
|
141
|
+
catch (error) {
|
|
142
|
+
const message = error instanceof Error
|
|
143
|
+
? error.name === 'AbortError'
|
|
144
|
+
? `runtime call timed out after ${this.config.timeoutMs}ms`
|
|
145
|
+
: error.message
|
|
146
|
+
: 'unknown error';
|
|
147
|
+
return { stop: 'error', code: 'failed', message };
|
|
148
|
+
}
|
|
149
|
+
finally {
|
|
150
|
+
clearTimeout(timeout);
|
|
151
|
+
if (options.signal)
|
|
152
|
+
options.signal.removeEventListener('abort', onAbort);
|
|
153
|
+
}
|
|
154
|
+
}
|
|
155
|
+
}
|
|
156
|
+
//# sourceMappingURL=anvil-client.js.map
|