@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 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,2 @@
1
+ #!/usr/bin/env node
2
+ import '../dist/index.js';
@@ -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,8 @@
1
+ export async function collectEngineEvents(adapter, task, ctx) {
2
+ const events = [];
3
+ for await (const event of adapter.run(task, ctx)) {
4
+ events.push(event);
5
+ }
6
+ return events;
7
+ }
8
+ //# sourceMappingURL=adapter-runner.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