@pugi/sdk 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.
@@ -0,0 +1,482 @@
1
+ import { z } from 'zod';
2
+ import { pugiSyncRequestSchema, pugiSyncResponseSchema, } from './handoff.js';
3
+ import { pugiDevicePollResponseSchema, pugiDeviceStartResponseSchema, } from './device-flow.js';
4
+ export const anvilCapabilitySchema = z.object({
5
+ name: z.string().min(1),
6
+ version: z.string().min(1),
7
+ enabled: z.boolean(),
8
+ });
9
+ export const anvilCapabilitiesResponseSchema = z.object({
10
+ endpoint: z.string().min(1),
11
+ capabilities: z.array(anvilCapabilitySchema),
12
+ });
13
+ /**
14
+ * Pugi runtime client config.
15
+ *
16
+ * Defaults: PUGI_API_URL=https://api.pugi.io, PUGI_API_KEY required for remote.
17
+ * Self-hosted override: PUGI_API_URL=https://anvil.acme.corp.
18
+ */
19
+ export const pugiRuntimeConfigSchema = z.object({
20
+ apiUrl: z.string().url(),
21
+ apiKey: z.string().min(1),
22
+ timeoutMs: z.number().int().positive().default(120_000),
23
+ });
24
+ /**
25
+ * Build a `PugiRuntimeConfig` from a known apiKey + apiUrl (e.g. resolved
26
+ * from a credentials store) plus the env for timeout override. Pure —
27
+ * does not touch the filesystem. The CLI's credential store layer is
28
+ * what reads disk / env first; this function exists so callers can
29
+ * provide credentials from any source (env, keychain, OAuth refresh)
30
+ * and still receive a validated config.
31
+ */
32
+ export function buildRuntimeConfig(input) {
33
+ const env = input.env ?? process.env;
34
+ return pugiRuntimeConfigSchema.parse({
35
+ apiUrl: input.apiUrl,
36
+ apiKey: input.apiKey,
37
+ timeoutMs: env.PUGI_API_TIMEOUT_MS ? Number.parseInt(env.PUGI_API_TIMEOUT_MS, 10) : 120_000,
38
+ });
39
+ }
40
+ /**
41
+ * Convenience: env-only resolution. Returns `null` when `PUGI_API_KEY`
42
+ * is unset. Used by CI flows that authenticate purely via environment
43
+ * variables.
44
+ */
45
+ export function loadRuntimeConfig(env = process.env) {
46
+ const apiUrl = env.PUGI_API_URL ?? 'https://api.pugi.io';
47
+ const apiKey = env.PUGI_API_KEY;
48
+ if (!apiKey)
49
+ return null;
50
+ return buildRuntimeConfig({ apiUrl, apiKey, env });
51
+ }
52
+ /**
53
+ * Triple-review rubric (verbatim from /triple-review skill + OES MCP triple_review tool):
54
+ * any P0 -> BLOCK
55
+ * P1 from >= 2 reviewers -> BLOCK
56
+ * P1 from 1 reviewer -> WARN
57
+ * no P0/P1 -> PASS
58
+ * all reviewers errored -> BLOCK
59
+ */
60
+ export const tripleReviewSeveritySchema = z.enum(['P0', 'P1', 'P2', 'P3']);
61
+ export const tripleReviewVerdictSchema = z.enum(['PASS', 'WARN', 'BLOCK']);
62
+ export const pugiTripleReviewFindingSchema = z.object({
63
+ reviewer: z.string().min(1),
64
+ severity: tripleReviewSeveritySchema,
65
+ line: z.number().int().positive().nullable(),
66
+ path: z.string().min(1).optional(),
67
+ issue: z.string().min(1),
68
+ fix: z.string(),
69
+ });
70
+ export const pugiTripleReviewReviewerSchema = z.object({
71
+ model: z.string().min(1),
72
+ latencyMs: z.number().int().nonnegative(),
73
+ tokensUsed: z.number().int().nonnegative().nullable(),
74
+ rawContent: z.string(),
75
+ findings: z.array(z.object({
76
+ severity: tripleReviewSeveritySchema,
77
+ line: z.number().int().positive().nullable(),
78
+ issue: z.string().min(1),
79
+ fix: z.string(),
80
+ })),
81
+ declaredVerdict: tripleReviewVerdictSchema.nullable(),
82
+ error: z.string().nullable(),
83
+ });
84
+ export const pugiTripleReviewRequestSchema = z.object({
85
+ schema: z.literal(1),
86
+ workspace: z.object({
87
+ rootName: z.string().min(1),
88
+ gitBranch: z.string().min(1).nullable(),
89
+ gitHead: z.string().min(1).nullable(),
90
+ baseRef: z.string().min(1).nullable(),
91
+ dirty: z.boolean(),
92
+ }),
93
+ /**
94
+ * Branch diff vs baseRef (e.g. `origin/main`). Truncated to a server-side
95
+ * cap on the receiver. We do NOT send raw file contents outside of
96
+ * `--privacy selected-files` or `--privacy full-sync` modes; the diff
97
+ * itself is the evidence the reviewers inspect.
98
+ */
99
+ diffPatch: z.string(),
100
+ diffStats: z.object({
101
+ filesChanged: z.number().int().nonnegative(),
102
+ insertions: z.number().int().nonnegative(),
103
+ deletions: z.number().int().nonnegative(),
104
+ }),
105
+ /**
106
+ * Optional prompt (`pugi review --triple "<prompt>"`). When absent the
107
+ * reviewers infer scope from the diff alone.
108
+ */
109
+ prompt: z.string().optional(),
110
+ locale: z.string().default('en-US'),
111
+ /**
112
+ * Reviewer persona slug on the server side. Default 'oes-dev' (Sigma)
113
+ * is the tier-2 reviewer today and bumps to tier-3 transparently when
114
+ * the operator configures ANVIL_TIER1_MODELS with 3+ models.
115
+ */
116
+ reviewerPersona: z.string().default('oes-dev'),
117
+ });
118
+ export const pugiTripleReviewResponseSchema = z.object({
119
+ schema: z.literal(1),
120
+ verdict: tripleReviewVerdictSchema,
121
+ reason: z.string().min(1),
122
+ reviewerCount: z.number().int().nonnegative(),
123
+ effectiveTier: z.union([z.literal(1), z.literal(2), z.literal(3)]),
124
+ draft: z.boolean(),
125
+ reviewers: z.array(pugiTripleReviewReviewerSchema),
126
+ findings: z.array(pugiTripleReviewFindingSchema),
127
+ counts: z.object({
128
+ P0: z.number().int().nonnegative(),
129
+ P1: z.number().int().nonnegative(),
130
+ P2: z.number().int().nonnegative(),
131
+ P3: z.number().int().nonnegative(),
132
+ }),
133
+ /**
134
+ * ISO-8601 timestamp when the server completed the review. Pugi
135
+ * persists this in the local artifact so audit replay knows when
136
+ * the runtime gate fired.
137
+ */
138
+ completedAt: z.string().datetime(),
139
+ });
140
+ /**
141
+ * Submit a triple-review request to the Pugi runtime endpoint.
142
+ *
143
+ * Endpoint contract (admin-api side, ships in a separate PR):
144
+ * POST {apiUrl}/api/pugi/triple-review
145
+ * Authorization: Bearer {apiKey}
146
+ * Content-Type: application/json
147
+ * Body: PugiTripleReviewRequest
148
+ * 200: PugiTripleReviewResponse
149
+ * 401/403: unauthenticated
150
+ * 404: endpoint not yet deployed (graceful local-only fallback)
151
+ * 429: rate limited (per-tenant)
152
+ * 5xx: failed
153
+ *
154
+ * Local-first contract: this function never reads the local file system,
155
+ * never logs the diff payload, and never retries on transient errors —
156
+ * the caller decides whether a retry makes sense.
157
+ */
158
+ export async function submitTripleReview(config, request) {
159
+ const body = pugiTripleReviewRequestSchema.parse(request);
160
+ // Admin-api mounts every route under the global `/api` prefix. Pugi
161
+ // CLI talks to `/api/pugi/triple-review`; the server-side endpoint
162
+ // ships in apps/admin-api/src/pugi/.
163
+ const url = `${config.apiUrl.replace(/\/+$/, '')}/api/pugi/triple-review`;
164
+ const controller = new AbortController();
165
+ const timeout = setTimeout(() => controller.abort(), config.timeoutMs);
166
+ try {
167
+ const res = await fetch(url, {
168
+ method: 'POST',
169
+ headers: {
170
+ 'content-type': 'application/json',
171
+ authorization: `Bearer ${config.apiKey}`,
172
+ 'user-agent': 'pugi-cli/0.0.1',
173
+ },
174
+ body: JSON.stringify(body),
175
+ signal: controller.signal,
176
+ });
177
+ const code = res.status;
178
+ const text = await res.text();
179
+ if (code === 200) {
180
+ let raw;
181
+ try {
182
+ raw = JSON.parse(text);
183
+ }
184
+ catch (error) {
185
+ return {
186
+ status: 'failed',
187
+ code,
188
+ message: `runtime returned 200 with non-JSON body: ${error.message}`,
189
+ };
190
+ }
191
+ const parsed = pugiTripleReviewResponseSchema.safeParse(raw);
192
+ if (!parsed.success) {
193
+ return {
194
+ status: 'failed',
195
+ code,
196
+ message: `runtime response failed schema: ${parsed.error.issues
197
+ .map((issue) => `${issue.path.join('.')}: ${issue.message}`)
198
+ .join('; ')}`,
199
+ };
200
+ }
201
+ return { status: 'ok', response: parsed.data };
202
+ }
203
+ if (code === 404) {
204
+ return {
205
+ status: 'endpoint_missing',
206
+ code,
207
+ message: 'POST /api/pugi/triple-review not deployed on this runtime',
208
+ };
209
+ }
210
+ if (code === 401 || code === 403) {
211
+ return {
212
+ status: 'unauthenticated',
213
+ code,
214
+ message: `runtime rejected credentials (HTTP ${code})`,
215
+ };
216
+ }
217
+ if (code === 429) {
218
+ const retryAfterHeader = res.headers.get('retry-after');
219
+ const retryAfterMs = retryAfterHeader
220
+ ? Number.parseInt(retryAfterHeader, 10) * 1000
221
+ : 60_000;
222
+ return {
223
+ status: 'rate_limited',
224
+ code,
225
+ retryAfterMs: Number.isFinite(retryAfterMs) ? retryAfterMs : 60_000,
226
+ message: 'runtime rate limit reached for this tenant',
227
+ };
228
+ }
229
+ return {
230
+ status: 'failed',
231
+ code,
232
+ message: `runtime returned HTTP ${code}${text ? `: ${text.slice(0, 200)}` : ''}`,
233
+ };
234
+ }
235
+ catch (error) {
236
+ const message = error instanceof Error
237
+ ? error.name === 'AbortError'
238
+ ? `runtime call timed out after ${config.timeoutMs}ms`
239
+ : error.message
240
+ : 'unknown error';
241
+ return { status: 'failed', code: 0, message };
242
+ }
243
+ finally {
244
+ clearTimeout(timeout);
245
+ }
246
+ }
247
+ /**
248
+ * Submit an explicit-continuation sync to the Pugi runtime endpoint.
249
+ *
250
+ * Endpoint contract (admin-api side, ships in this PR):
251
+ * POST {apiUrl}/api/pugi/sync
252
+ * Authorization: Bearer {apiKey}
253
+ * Content-Type: application/json
254
+ * Body: PugiSyncRequest (handoff bundle + upload-enabled plan)
255
+ * 200: PugiSyncResponse
256
+ * 401/403: unauthenticated
257
+ * 404: endpoint not yet deployed (graceful local-only fallback)
258
+ * 429: rate limited (per-tenant)
259
+ * 5xx: failed
260
+ *
261
+ * Local-first contract (ADR-0037): this function never reads files,
262
+ * never logs the bundle payload, and never retries on transient
263
+ * errors. The caller has already surfaced the dry-run plan to the
264
+ * operator; this is the explicit upload step.
265
+ */
266
+ export async function submitSync(config, request) {
267
+ const body = pugiSyncRequestSchema.parse(request);
268
+ const url = `${config.apiUrl.replace(/\/+$/, '')}/api/pugi/sync`;
269
+ const controller = new AbortController();
270
+ const timeout = setTimeout(() => controller.abort(), config.timeoutMs);
271
+ try {
272
+ const res = await fetch(url, {
273
+ method: 'POST',
274
+ headers: {
275
+ 'content-type': 'application/json',
276
+ authorization: `Bearer ${config.apiKey}`,
277
+ 'user-agent': 'pugi-cli/0.0.1',
278
+ },
279
+ body: JSON.stringify(body),
280
+ signal: controller.signal,
281
+ });
282
+ const code = res.status;
283
+ const text = await res.text();
284
+ if (code === 200) {
285
+ let raw;
286
+ try {
287
+ raw = JSON.parse(text);
288
+ }
289
+ catch (error) {
290
+ return {
291
+ status: 'failed',
292
+ code,
293
+ message: `runtime returned 200 with non-JSON body: ${error.message}`,
294
+ };
295
+ }
296
+ const parsed = pugiSyncResponseSchema.safeParse(raw);
297
+ if (!parsed.success) {
298
+ return {
299
+ status: 'failed',
300
+ code,
301
+ message: `runtime response failed schema: ${parsed.error.issues
302
+ .map((issue) => `${issue.path.join('.')}: ${issue.message}`)
303
+ .join('; ')}`,
304
+ };
305
+ }
306
+ return { status: 'ok', response: parsed.data };
307
+ }
308
+ if (code === 404) {
309
+ return {
310
+ status: 'endpoint_missing',
311
+ code,
312
+ message: 'POST /api/pugi/sync not deployed on this runtime',
313
+ };
314
+ }
315
+ if (code === 401 || code === 403) {
316
+ return {
317
+ status: 'unauthenticated',
318
+ code,
319
+ message: `runtime rejected credentials (HTTP ${code})`,
320
+ };
321
+ }
322
+ if (code === 429) {
323
+ const retryAfterHeader = res.headers.get('retry-after');
324
+ const retryAfterMs = retryAfterHeader
325
+ ? Number.parseInt(retryAfterHeader, 10) * 1000
326
+ : 60_000;
327
+ return {
328
+ status: 'rate_limited',
329
+ code,
330
+ retryAfterMs: Number.isFinite(retryAfterMs) ? retryAfterMs : 60_000,
331
+ message: 'runtime rate limit reached for this tenant',
332
+ };
333
+ }
334
+ return {
335
+ status: 'failed',
336
+ code,
337
+ message: `runtime returned HTTP ${code}${text ? `: ${text.slice(0, 200)}` : ''}`,
338
+ };
339
+ }
340
+ catch (error) {
341
+ const message = error instanceof Error
342
+ ? error.name === 'AbortError'
343
+ ? `runtime call timed out after ${config.timeoutMs}ms`
344
+ : error.message
345
+ : 'unknown error';
346
+ return { status: 'failed', code: 0, message };
347
+ }
348
+ finally {
349
+ clearTimeout(timeout);
350
+ }
351
+ }
352
+ /**
353
+ * RFC 8628 §3.1 — CLI initiates the device flow. Anonymous request
354
+ * (no Authorization header). The runtime returns a `device_code` the
355
+ * CLI must keep secret and a `user_code` the user types into the
356
+ * cabinet Approve page.
357
+ */
358
+ export async function startDeviceFlow(apiUrl, timeoutMs = 15_000) {
359
+ const url = `${apiUrl.replace(/\/+$/, '')}/api/auth/device/start`;
360
+ const controller = new AbortController();
361
+ const timeout = setTimeout(() => controller.abort(), timeoutMs);
362
+ try {
363
+ const res = await fetch(url, {
364
+ method: 'POST',
365
+ headers: { 'content-type': 'application/json', 'user-agent': 'pugi-cli/0.0.1' },
366
+ body: JSON.stringify({ clientId: 'pugi-cli' }),
367
+ signal: controller.signal,
368
+ });
369
+ const text = await res.text();
370
+ if (res.status === 200) {
371
+ try {
372
+ const parsed = pugiDeviceStartResponseSchema.safeParse(JSON.parse(text));
373
+ if (!parsed.success) {
374
+ return {
375
+ status: 'failed',
376
+ code: 200,
377
+ message: `runtime response failed schema: ${parsed.error.issues
378
+ .map((issue) => `${issue.path.join('.')}: ${issue.message}`)
379
+ .join('; ')}`,
380
+ };
381
+ }
382
+ return { status: 'ok', response: parsed.data };
383
+ }
384
+ catch (error) {
385
+ return {
386
+ status: 'failed',
387
+ code: 200,
388
+ message: `runtime returned 200 with non-JSON body: ${error.message}`,
389
+ };
390
+ }
391
+ }
392
+ if (res.status === 404) {
393
+ return {
394
+ status: 'endpoint_missing',
395
+ code: 404,
396
+ message: 'POST /api/auth/device/start not deployed on this runtime',
397
+ };
398
+ }
399
+ return {
400
+ status: 'failed',
401
+ code: res.status,
402
+ message: `runtime returned HTTP ${res.status}${text ? `: ${text.slice(0, 200)}` : ''}`,
403
+ };
404
+ }
405
+ catch (error) {
406
+ const message = error instanceof Error
407
+ ? error.name === 'AbortError'
408
+ ? `runtime call timed out after ${timeoutMs}ms`
409
+ : error.message
410
+ : 'unknown error';
411
+ return { status: 'failed', code: 0, message };
412
+ }
413
+ finally {
414
+ clearTimeout(timeout);
415
+ }
416
+ }
417
+ /**
418
+ * RFC 8628 §3.4 — CLI polls until the user authorizes. The runtime
419
+ * returns the outcome class in the response body (always HTTP 200)
420
+ * so older HTTP clients with weak 4xx handling can still poll
421
+ * reliably. See AuthDeviceController for the rationale.
422
+ */
423
+ export async function pollDeviceFlow(apiUrl, deviceCode, timeoutMs = 15_000) {
424
+ const url = `${apiUrl.replace(/\/+$/, '')}/api/auth/device/poll`;
425
+ const controller = new AbortController();
426
+ const timeout = setTimeout(() => controller.abort(), timeoutMs);
427
+ try {
428
+ const res = await fetch(url, {
429
+ method: 'POST',
430
+ headers: { 'content-type': 'application/json', 'user-agent': 'pugi-cli/0.0.1' },
431
+ body: JSON.stringify({ deviceCode }),
432
+ signal: controller.signal,
433
+ });
434
+ const text = await res.text();
435
+ if (res.status === 200) {
436
+ try {
437
+ const parsed = pugiDevicePollResponseSchema.safeParse(JSON.parse(text));
438
+ if (!parsed.success) {
439
+ return {
440
+ status: 'failed',
441
+ code: 200,
442
+ message: `runtime response failed schema: ${parsed.error.issues
443
+ .map((issue) => `${issue.path.join('.')}: ${issue.message}`)
444
+ .join('; ')}`,
445
+ };
446
+ }
447
+ return { status: 'ok', response: parsed.data };
448
+ }
449
+ catch (error) {
450
+ return {
451
+ status: 'failed',
452
+ code: 200,
453
+ message: `runtime returned 200 with non-JSON body: ${error.message}`,
454
+ };
455
+ }
456
+ }
457
+ if (res.status === 404) {
458
+ return {
459
+ status: 'endpoint_missing',
460
+ code: 404,
461
+ message: 'POST /api/auth/device/poll not deployed on this runtime',
462
+ };
463
+ }
464
+ return {
465
+ status: 'failed',
466
+ code: res.status,
467
+ message: `runtime returned HTTP ${res.status}${text ? `: ${text.slice(0, 200)}` : ''}`,
468
+ };
469
+ }
470
+ catch (error) {
471
+ const message = error instanceof Error
472
+ ? error.name === 'AbortError'
473
+ ? `runtime call timed out after ${timeoutMs}ms`
474
+ : error.message
475
+ : 'unknown error';
476
+ return { status: 'failed', code: 0, message };
477
+ }
478
+ finally {
479
+ clearTimeout(timeout);
480
+ }
481
+ }
482
+ //# sourceMappingURL=transport.js.map
package/package.json ADDED
@@ -0,0 +1,47 @@
1
+ {
2
+ "name": "@pugi/sdk",
3
+ "version": "0.1.0-alpha.3",
4
+ "description": "Pugi CLI contracts shared by the CLI, docs, and Anvil runtime",
5
+ "homepage": "https://pugi.io",
6
+ "repository": {
7
+ "type": "git",
8
+ "url": "https://github.com/pugi-io/pugi.git",
9
+ "directory": "packages/pugi-sdk"
10
+ },
11
+ "bugs": {
12
+ "url": "https://github.com/pugi-io/pugi/issues"
13
+ },
14
+ "keywords": [
15
+ "pugi",
16
+ "sdk",
17
+ "contracts",
18
+ "schemas"
19
+ ],
20
+ "author": "Pugi <hello@pugi.io>",
21
+ "license": "MIT",
22
+ "type": "module",
23
+ "main": "dist/index.js",
24
+ "types": "dist/index.d.ts",
25
+ "files": [
26
+ "dist/**/*.js",
27
+ "dist/**/*.d.ts",
28
+ "README.md",
29
+ "LICENSE"
30
+ ],
31
+ "engines": {
32
+ "node": ">=20"
33
+ },
34
+ "publishConfig": {
35
+ "access": "public"
36
+ },
37
+ "dependencies": {
38
+ "zod": "^3.23.0"
39
+ },
40
+ "devDependencies": {
41
+ "typescript": "~5.6.0"
42
+ },
43
+ "scripts": {
44
+ "build": "tsc -p tsconfig.json",
45
+ "typecheck": "tsc -p tsconfig.json --noEmit"
46
+ }
47
+ }