magic-spells 0.0.0-stage → 0.1.0

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,1298 @@
1
+ import {
2
+ mapPyramidHttpError,
3
+ operationsByName,
4
+ pyramidError,
5
+ toPyramidError
6
+ } from "./chunk-S5IUP7G2.js";
7
+ import {
8
+ MagicError,
9
+ TtlCache,
10
+ createCredentialStore,
11
+ maskKey,
12
+ readField,
13
+ readStr,
14
+ saveProductKey
15
+ } from "./chunk-YT2Z756Z.js";
16
+
17
+ // ../pyramid/src/cache/resolver.ts
18
+ var TTL_MS = 6e4;
19
+ var PROJECTS_KEY = "projects";
20
+ var Resolver = class {
21
+ constructor(client) {
22
+ this.client = client;
23
+ }
24
+ /** Per-project workflow cache (also the hydration join source). */
25
+ workflows = new TtlCache(TTL_MS);
26
+ /** Workspace-wide project list cache (backs project-name resolution). */
27
+ projects = new TtlCache(TTL_MS);
28
+ // ---------- Workflow fetch + 60s cache ----------
29
+ /**
30
+ * Cached workflow for a project (60s TTL). One fetch warms every kind of
31
+ * resolution for that project. Backs `get_project_workflow` and hydration.
32
+ */
33
+ async getWorkflow(projectId) {
34
+ const hit = this.workflows.get(projectId);
35
+ if (hit) return hit;
36
+ const [rawWf, rawLabels, rawMembers, rawSchema] = await Promise.all([
37
+ this.client.getWorkflow(projectId),
38
+ this.client.listLabels(projectId),
39
+ this.client.listMembers(projectId),
40
+ this.client.getTaskSchema(projectId)
41
+ ]);
42
+ const wf = normalizeWorkflow(rawWf);
43
+ wf.labels = (Array.isArray(rawLabels) ? rawLabels : []).map(normalizeLabel);
44
+ wf.members = (Array.isArray(rawMembers) ? rawMembers : []).map(normalizeMember);
45
+ wf.templates = normalizeTaskSchema(rawSchema);
46
+ this.workflows.set(projectId, wf);
47
+ return wf;
48
+ }
49
+ // ---------- Project ----------
50
+ /** handle (ci exact) -> name (ci exact) -> unique fuzzy contains. */
51
+ async resolveProject(nameOrHandle) {
52
+ const projects = await this.getProjects();
53
+ const needle = norm(nameOrHandle);
54
+ const byHandle = projects.filter((p) => norm(p.handle) === needle);
55
+ const byName = projects.filter((p) => norm(p.name) === needle);
56
+ const fuzzy = projects.filter((p) => contains(p.handle, needle) || contains(p.name, needle));
57
+ const match = pick(
58
+ "project",
59
+ "ambiguous_project_name",
60
+ nameOrHandle,
61
+ [byHandle, byName, fuzzy],
62
+ (p) => p.handle
63
+ );
64
+ if (match) return match;
65
+ throw pyramidError("project_not_found", `No project matches "${nameOrHandle}".`, {
66
+ candidates: shortlist(
67
+ nameOrHandle,
68
+ projects.map((p) => p.handle)
69
+ )
70
+ });
71
+ }
72
+ // ---------- Status ----------
73
+ /**
74
+ * key (ci) -> name (ci) -> category (ci) -> stage-name (first status of that
75
+ * stage by position). Returns the full WorkflowStatus (carries stage_id).
76
+ */
77
+ async resolveStatus(projectId, q) {
78
+ const wf = await this.getWorkflow(projectId);
79
+ const needle = norm(q);
80
+ const byKey = wf.statuses.filter((s) => norm(s.key) === needle);
81
+ const byName = wf.statuses.filter((s) => norm(s.name) === needle);
82
+ const byCategory = wf.statuses.filter(
83
+ (s) => s.category !== void 0 && norm(s.category) === needle
84
+ );
85
+ let match = pick(
86
+ "status",
87
+ "ambiguous_status_name",
88
+ q,
89
+ [byKey, byName, byCategory],
90
+ (s) => s.name
91
+ );
92
+ if (!match) {
93
+ const stage = wf.stages.find((s) => norm(s.key) === needle || norm(s.name) === needle);
94
+ if (stage) {
95
+ const first = firstStatusOfStage(wf, stage.id);
96
+ if (first) match = first;
97
+ }
98
+ }
99
+ if (!match) {
100
+ const fuzzy = wf.statuses.filter((s) => contains(s.key, needle) || contains(s.name, needle));
101
+ match = pick("status", "ambiguous_status_name", q, [fuzzy], (s) => s.name);
102
+ }
103
+ if (match) return match;
104
+ throw pyramidError("status_not_found", `No status matches "${q}".`, {
105
+ candidates: shortlist(
106
+ q,
107
+ wf.statuses.map((s) => s.name)
108
+ )
109
+ });
110
+ }
111
+ // ---------- Stage ----------
112
+ /** key (ci) -> name (ci) -> category (ci) -> unique fuzzy contains. */
113
+ async resolveStage(projectId, q) {
114
+ const wf = await this.getWorkflow(projectId);
115
+ const needle = norm(q);
116
+ const byKey = wf.stages.filter((s) => norm(s.key) === needle);
117
+ const byName = wf.stages.filter((s) => norm(s.name) === needle);
118
+ const byCategory = wf.stages.filter(
119
+ (s) => s.category !== void 0 && norm(s.category) === needle
120
+ );
121
+ const fuzzy = wf.stages.filter((s) => contains(s.key, needle) || contains(s.name, needle));
122
+ const match = pick(
123
+ "stage",
124
+ "ambiguous_stage_name",
125
+ q,
126
+ [byKey, byName, byCategory, fuzzy],
127
+ (s) => s.name
128
+ );
129
+ if (match) return match;
130
+ throw pyramidError("stage_not_found", `No stage matches "${q}".`, {
131
+ candidates: shortlist(
132
+ q,
133
+ wf.stages.map((s) => s.name)
134
+ )
135
+ });
136
+ }
137
+ // ---------- User ----------
138
+ /** email (ci exact) -> display_name (ci exact) -> unique fuzzy contains. */
139
+ async resolveUser(projectId, q) {
140
+ const wf = await this.getWorkflow(projectId);
141
+ const needle = norm(q);
142
+ const byEmail = wf.members.filter((m) => norm(m.email) === needle);
143
+ const byName = wf.members.filter((m) => norm(m.display_name) === needle);
144
+ const fuzzy = wf.members.filter(
145
+ (m) => contains(m.email, needle) || contains(m.display_name, needle)
146
+ );
147
+ const match = pick(
148
+ "user",
149
+ "ambiguous_user_name",
150
+ q,
151
+ [byEmail, byName, fuzzy],
152
+ (m) => m.display_name
153
+ );
154
+ if (match) return match;
155
+ throw pyramidError("user_not_found", `No user matches "${q}".`, {
156
+ candidates: shortlist(
157
+ q,
158
+ wf.members.map((m) => m.display_name)
159
+ )
160
+ });
161
+ }
162
+ // ---------- Label ----------
163
+ /** name (ci exact) -> unique fuzzy contains. */
164
+ async resolveLabel(projectId, q) {
165
+ const wf = await this.getWorkflow(projectId);
166
+ const needle = norm(q);
167
+ const byName = wf.labels.filter((l) => norm(l.name) === needle);
168
+ const fuzzy = wf.labels.filter((l) => contains(l.name, needle));
169
+ const match = pick("label", "ambiguous_label_name", q, [byName, fuzzy], (l) => l.name);
170
+ if (match) return { id: match.id, name: match.name };
171
+ throw pyramidError("label_not_found", `No label matches "${q}".`, {
172
+ candidates: shortlist(
173
+ q,
174
+ wf.labels.map((l) => l.name)
175
+ )
176
+ });
177
+ }
178
+ // ---------- Invalidation ----------
179
+ /**
180
+ * Drop cached data after a mutating op. One getWorkflow warms every per-project
181
+ * kind, so any per-project kind invalidates the whole project workflow entry;
182
+ * `project` also clears the workspace-wide project list. With no `kind`, clear
183
+ * everything cached for the project.
184
+ */
185
+ invalidate(projectId, kind) {
186
+ if (kind === "project") {
187
+ this.projects.clear();
188
+ return;
189
+ }
190
+ this.workflows.delete(projectId);
191
+ if (kind === void 0 && (projectId === "" || projectId === "*")) {
192
+ this.projects.clear();
193
+ }
194
+ }
195
+ // ---------- Internal: project-list cache ----------
196
+ async getProjects() {
197
+ return this.projects.getOrLoad(
198
+ PROJECTS_KEY,
199
+ async () => extractArray(await this.client.listProjects()).map(normalizeProject)
200
+ );
201
+ }
202
+ };
203
+ function pick(_kind, ambiguousCode, query, tiers, label) {
204
+ for (const tier of tiers) {
205
+ if (tier.length === 1) return tier[0];
206
+ if (tier.length > 1) {
207
+ throw pyramidError(ambiguousCode, `"${query}" is ambiguous \u2014 ${tier.length} matches.`, {
208
+ candidates: dedupeStrings(tier.map(label))
209
+ });
210
+ }
211
+ }
212
+ return void 0;
213
+ }
214
+ function extractArray(body) {
215
+ if (Array.isArray(body)) return body;
216
+ if (body && typeof body === "object") {
217
+ const obj = body;
218
+ for (const key of ["data", "items", "results", "projects"]) {
219
+ const val = obj[key];
220
+ if (Array.isArray(val)) return val;
221
+ }
222
+ }
223
+ return [];
224
+ }
225
+ function rec(v) {
226
+ return v && typeof v === "object" ? v : {};
227
+ }
228
+ function str(v, fallback = "") {
229
+ return typeof v === "string" ? v : fallback;
230
+ }
231
+ function optStr(v) {
232
+ return typeof v === "string" && v.length > 0 ? v : void 0;
233
+ }
234
+ var PROJECT_ROLES = ["admin", "pm", "member", "viewer", "guest"];
235
+ function projectRole(v) {
236
+ return PROJECT_ROLES.includes(str(v)) ? str(v) : "member";
237
+ }
238
+ function normalizeProject(raw) {
239
+ const p = rec(raw);
240
+ return {
241
+ id: str(p.id),
242
+ handle: str(p.handle),
243
+ name: str(p.name),
244
+ task_prefix: str(p.task_prefix),
245
+ role: projectRole(p.role),
246
+ archived: p.archived_at != null || p.archived === true
247
+ };
248
+ }
249
+ function normalizeStage(raw) {
250
+ const s = rec(raw);
251
+ const stage = {
252
+ id: str(s.id),
253
+ key: str(s.key),
254
+ name: str(s.name),
255
+ position: str(s.position)
256
+ };
257
+ const category = optStr(s.category);
258
+ if (category !== void 0) stage.category = category;
259
+ return stage;
260
+ }
261
+ function normalizeStatus(raw) {
262
+ const s = rec(raw);
263
+ const status = {
264
+ id: str(s.id),
265
+ key: str(s.key),
266
+ name: str(s.name),
267
+ stage_id: str(s.stage_id),
268
+ position: str(s.position)
269
+ };
270
+ const category = optStr(s.category);
271
+ if (category !== void 0) status.category = category;
272
+ return status;
273
+ }
274
+ function normalizeMember(raw) {
275
+ const m = rec(raw);
276
+ const u = rec(m.user);
277
+ const member = {
278
+ id: str(u.id) || str(m.user_id) || str(m.id),
279
+ display_name: str(u.display_name) || str(m.display_name),
280
+ email: str(u.email) || str(m.email),
281
+ role: str(m.role) || str(u.role)
282
+ };
283
+ const jobTitle = optStr(m.job_title) ?? optStr(u.job_title);
284
+ if (jobTitle !== void 0) member.job_title = jobTitle;
285
+ return member;
286
+ }
287
+ function normalizeTaskSchema(raw) {
288
+ const s = rec(raw);
289
+ const fieldsByTemplate = rec(s.fields_by_template);
290
+ const templates = Array.isArray(s.templates) ? s.templates : [];
291
+ return templates.map((t) => {
292
+ const tt = rec(t);
293
+ const id = str(tt.id);
294
+ const rawFields = fieldsByTemplate[id];
295
+ const fields = (Array.isArray(rawFields) ? rawFields : []).map(normalizeField);
296
+ return { id, name: str(tt.name), fields };
297
+ });
298
+ }
299
+ function normalizeLabel(raw) {
300
+ const l = rec(raw);
301
+ return { id: str(l.id), name: str(l.name), color: str(l.color) };
302
+ }
303
+ function normalizeField(raw) {
304
+ const f = rec(raw);
305
+ const field = {
306
+ id: str(f.id),
307
+ key: str(f.key),
308
+ name: str(f.name),
309
+ field_type: str(f.field_type)
310
+ };
311
+ if (Array.isArray(f.options)) {
312
+ field.options = f.options.filter((o) => typeof o === "string");
313
+ }
314
+ return field;
315
+ }
316
+ function normalizeWorkflow(raw) {
317
+ const w = rec(raw);
318
+ const rawStages = Array.isArray(w.stages) ? w.stages : [];
319
+ const stages = rawStages.map(normalizeStage);
320
+ let statuses;
321
+ if (Array.isArray(w.statuses)) {
322
+ statuses = w.statuses.map(normalizeStatus);
323
+ } else {
324
+ statuses = [];
325
+ for (const rawStage of rawStages) {
326
+ const s = rec(rawStage);
327
+ const nested = Array.isArray(s.statuses) ? s.statuses : [];
328
+ const stageId = str(s.id);
329
+ for (const ns of nested) {
330
+ const status = normalizeStatus(ns);
331
+ if (status.stage_id === "") status.stage_id = stageId;
332
+ statuses.push(status);
333
+ }
334
+ }
335
+ }
336
+ const labels = (Array.isArray(w.labels) ? w.labels : []).map(normalizeLabel);
337
+ const members = (Array.isArray(w.members) ? w.members : []).map(normalizeMember);
338
+ const templates = (Array.isArray(w.templates) ? w.templates : []).map((t) => {
339
+ const tt = rec(t);
340
+ return {
341
+ id: str(tt.id),
342
+ name: str(tt.name),
343
+ fields: (Array.isArray(tt.fields) ? tt.fields : []).map(normalizeField)
344
+ };
345
+ });
346
+ return {
347
+ project: normalizeProject(w.project),
348
+ stages,
349
+ statuses,
350
+ labels,
351
+ members,
352
+ templates
353
+ };
354
+ }
355
+ function firstStatusOfStage(wf, stageId) {
356
+ const inStage = wf.statuses.filter((s) => s.stage_id === stageId);
357
+ if (inStage.length === 0) return void 0;
358
+ return [...inStage].sort(
359
+ (a, b) => a.position < b.position ? -1 : a.position > b.position ? 1 : 0
360
+ )[0];
361
+ }
362
+ function norm(s) {
363
+ return s.trim().toLowerCase();
364
+ }
365
+ function contains(haystack, normNeedle) {
366
+ if (normNeedle.length === 0) return false;
367
+ return haystack.trim().toLowerCase().includes(normNeedle);
368
+ }
369
+ function dedupeStrings(xs) {
370
+ return [...new Set(xs.filter((x) => x.length > 0))];
371
+ }
372
+ function shortlist(query, candidates) {
373
+ const q = norm(query);
374
+ const pool = dedupeStrings(candidates);
375
+ if (pool.length === 0) return [];
376
+ const scored = pool.map((c) => {
377
+ const cn = norm(c);
378
+ const sub = cn.includes(q) || q.length > 0 && q.includes(cn) ? 0 : 1;
379
+ return { c, sub, dist: levenshtein(q, cn) };
380
+ });
381
+ scored.sort((a, b) => a.sub - b.sub || a.dist - b.dist || a.c.localeCompare(b.c));
382
+ return scored.slice(0, 8).map((s) => s.c);
383
+ }
384
+ function levenshtein(a, b) {
385
+ if (a === b) return 0;
386
+ if (a.length === 0) return b.length;
387
+ if (b.length === 0) return a.length;
388
+ let prev = Array.from({ length: b.length + 1 }, (_, i) => i);
389
+ let curr = new Array(b.length + 1);
390
+ for (let i = 1; i <= a.length; i++) {
391
+ curr[0] = i;
392
+ for (let j = 1; j <= b.length; j++) {
393
+ const cost = a[i - 1] === b[j - 1] ? 0 : 1;
394
+ curr[j] = Math.min(curr[j - 1] + 1, prev[j] + 1, prev[j - 1] + cost);
395
+ }
396
+ [prev, curr] = [curr, prev];
397
+ }
398
+ return prev[b.length];
399
+ }
400
+
401
+ // ../pyramid/src/client/pyramid-client.ts
402
+ import { request as undiciRequest } from "undici";
403
+ var PyramidClient = class {
404
+ baseUrl;
405
+ apiKey;
406
+ constructor(config) {
407
+ this.baseUrl = config.baseUrl.endsWith("/") ? config.baseUrl.slice(0, -1) : config.baseUrl;
408
+ this.apiKey = config.apiKey;
409
+ }
410
+ // ---- Phase-1 read-only surface (raw server JSON; hydration is elsewhere) ----
411
+ /**
412
+ * GET /v1/me — the authenticated user (raw `model.User`). The payload may carry
413
+ * a workspace in a future revision, so callers read it defensively; today it is
414
+ * just the user record. Returned raw.
415
+ */
416
+ getMe() {
417
+ return this.request("GET", "/v1/me");
418
+ }
419
+ /**
420
+ * GET /v1/workspaces — the caller's workspaces. The API key is workspace-pinned,
421
+ * so this is effectively a single-element list (the op layer takes `[0]` as the
422
+ * pinned workspace). Server shape is `{ data: Workspace[] }`; we unwrap to the
423
+ * array and tolerate a bare array.
424
+ */
425
+ listWorkspaces() {
426
+ return this.request("GET", "/v1/workspaces").then(unwrapData);
427
+ }
428
+ /**
429
+ * GET /v1/projects — accessible projects. Server shape is
430
+ * `{ data: Project[], cursor }`; we unwrap `data` (tolerating a bare array).
431
+ * First-page discovery only — Phase-1 needs no pagination here.
432
+ */
433
+ listProjects() {
434
+ return this.request("GET", "/v1/projects").then(unwrapData);
435
+ }
436
+ /**
437
+ * GET /v1/projects/{id}/workflow — the project's workflow (stages, statuses,
438
+ * labels, members, templates). Returned raw; the Resolver/hydrate layer joins
439
+ * it. `projectId` must already be a UUID (name->id resolution lives upstream).
440
+ */
441
+ getWorkflow(projectId) {
442
+ return this.request("GET", `/v1/projects/${encodeURIComponent(projectId)}/workflow`);
443
+ }
444
+ /**
445
+ * GET /v1/me/tasks — the tasks the caller OWNS or REVIEWS, across projects,
446
+ * newest first, with an opaque keyset cursor. Returns the raw `{ data, cursor }`
447
+ * envelope (cursor normalized to `string | null`). `limit` caps the page;
448
+ * `cursor` continues a prior page.
449
+ *
450
+ * There is deliberately NO `role` parameter: the server accepts `?role=` and
451
+ * IGNORES it (DOC-BACKEND-CONTRACT), so sending it would promise a narrowing
452
+ * that never happens.
453
+ */
454
+ async listMyTasks(opts) {
455
+ const envelope = await this.request("GET", "/v1/me/tasks", {
456
+ query: {
457
+ limit: opts?.limit,
458
+ cursor: opts?.cursor
459
+ }
460
+ });
461
+ return {
462
+ data: Array.isArray(envelope?.data) ? envelope.data : [],
463
+ cursor: typeof envelope?.cursor === "string" ? envelope.cursor : null
464
+ };
465
+ }
466
+ // ---- Phase-2 task read surface (raw server JSON; hydration is elsewhere) ----
467
+ /**
468
+ * GET /v1/projects/{id}/tasks — a project's tasks, newest first, with an opaque
469
+ * keyset cursor. Returns the raw `{ data, cursor }` envelope (cursor normalized
470
+ * to `string | null`). All filter values are already-resolved UUIDs (`status` is
471
+ * a status UUID, plus `stage_id`/`owner_id`/`reviewer_id`/`label_id`) or free
472
+ * text (`q`); `expand` opts relations into the rows. `projectId` must already be
473
+ * a UUID. (Real query names per DOC-BACKEND-CONTRACT — no `assignee_id`.)
474
+ */
475
+ async listTasks(projectId, opts) {
476
+ const envelope = await this.request(
477
+ "GET",
478
+ `/v1/projects/${encodeURIComponent(projectId)}/tasks`,
479
+ {
480
+ query: {
481
+ status: opts?.status,
482
+ stage_id: opts?.stage_id,
483
+ owner_id: opts?.owner_id,
484
+ reviewer_id: opts?.reviewer_id,
485
+ label_id: opts?.label_id,
486
+ q: opts?.q,
487
+ limit: opts?.limit,
488
+ cursor: opts?.cursor,
489
+ expand: opts?.expand
490
+ }
491
+ }
492
+ );
493
+ return normalizeCursorEnvelope(envelope);
494
+ }
495
+ /**
496
+ * GET /v1/projects/{id}/tasks/archived — a project's ARCHIVED tasks (a separate
497
+ * route from `listTasks`). Returns the raw `{ data, cursor }` envelope. `opts`
498
+ * carries `archived_after`/`limit`/`cursor`. `projectId` must already be a UUID.
499
+ */
500
+ async listArchived(projectId, opts) {
501
+ const envelope = await this.request(
502
+ "GET",
503
+ `/v1/projects/${encodeURIComponent(projectId)}/tasks/archived`,
504
+ {
505
+ query: {
506
+ archived_after: opts?.archived_after,
507
+ limit: opts?.limit,
508
+ cursor: opts?.cursor
509
+ }
510
+ }
511
+ );
512
+ return normalizeCursorEnvelope(envelope);
513
+ }
514
+ /**
515
+ * GET /v1/projects/{id}/labels — a project's labels (separate from /workflow).
516
+ * Returns a bare array of raw label rows (tolerating a `{ data }` envelope).
517
+ */
518
+ listLabels(projectId) {
519
+ return this.request(
520
+ "GET",
521
+ `/v1/projects/${encodeURIComponent(projectId)}/labels`
522
+ ).then(unwrapData);
523
+ }
524
+ /**
525
+ * GET /v1/projects/{id}/members — a project's members (separate from /workflow).
526
+ * Returns a bare array of raw `ProjectMember` rows (each carries `user`).
527
+ */
528
+ listMembers(projectId) {
529
+ return this.request(
530
+ "GET",
531
+ `/v1/projects/${encodeURIComponent(projectId)}/members`
532
+ ).then(unwrapData);
533
+ }
534
+ /**
535
+ * GET /v1/projects/{id}/task-schema — the project's custom-field schema. Returns
536
+ * the raw `{ templates, fields_by_template }` payload (the Resolver merges it
537
+ * into the cached Workflow shape).
538
+ */
539
+ getTaskSchema(projectId) {
540
+ return this.request(
541
+ "GET",
542
+ `/v1/projects/${encodeURIComponent(projectId)}/task-schema`
543
+ );
544
+ }
545
+ /**
546
+ * GET /v1/tasks/{id}/timeline — one page of a task's history, **oldest first**
547
+ * (the reverse of every other list here). Returns the raw `{ data, cursor }`
548
+ * envelope. `limit` defaults to 50 server-side and is capped at 200;
549
+ * `event_type` narrows to one kind (e.g. `owner_changed`). No If-Match.
550
+ */
551
+ async getTaskTimeline(taskId, opts) {
552
+ const envelope = await this.request(
553
+ "GET",
554
+ `/v1/tasks/${encodeURIComponent(taskId)}/timeline`,
555
+ {
556
+ query: {
557
+ limit: opts?.limit,
558
+ cursor: opts?.cursor,
559
+ event_type: opts?.event_type
560
+ }
561
+ }
562
+ );
563
+ return normalizeCursorEnvelope(envelope);
564
+ }
565
+ /**
566
+ * GET /v1/tasks/{id} — one task's full detail (raw `RawTaskDetail`). A non-empty
567
+ * `expand` enables relations (editor/timeline/comments/estimates/followers/…);
568
+ * omit it for the lean shape. `taskId` must already be a UUID.
569
+ */
570
+ getTask(taskId, expand) {
571
+ return this.request("GET", `/v1/tasks/${encodeURIComponent(taskId)}`, {
572
+ query: { expand }
573
+ });
574
+ }
575
+ /**
576
+ * GET /v1/search/tasks — full-text task search across the workspace. Returns the
577
+ * raw `{ data }` envelope (no cursor per DOC-BACKEND-CONTRACT; normalized to a
578
+ * null cursor). `q` is the query; the optional filters are already-resolved user
579
+ * UUIDs.
580
+ */
581
+ async searchTasks(opts) {
582
+ const envelope = await this.request("GET", "/v1/search/tasks", {
583
+ query: {
584
+ q: opts.q,
585
+ owner_id: opts.owner_id,
586
+ reviewer_id: opts.reviewer_id,
587
+ limit: opts.limit
588
+ }
589
+ });
590
+ return normalizeCursorEnvelope(envelope);
591
+ }
592
+ // ---- Phase-2 task write surface (raw server JSON; resolution is elsewhere) ----
593
+ /**
594
+ * POST /v1/projects/{id}/tasks — create one task. `body` is the already-resolved
595
+ * create payload (status_id/owner_id/label_ids/…); this client does no name
596
+ * resolution. Returns the raw `RawTaskDetail` of the created task.
597
+ */
598
+ createTask(projectId, body) {
599
+ return this.request("POST", `/v1/projects/${encodeURIComponent(projectId)}/tasks`, {
600
+ body
601
+ });
602
+ }
603
+ /**
604
+ * POST /v1/tasks/bulk — create many tasks. `body` is the resolved
605
+ * `{ project_id, template_id, tasks: [...] }` batch. The server replies
606
+ * `{ created: [Task], errors: [] }`; this returns the RAW envelope (the op
607
+ * hydrates `created`). No If-Match.
608
+ */
609
+ bulkCreate(body) {
610
+ return this.request("POST", "/v1/tasks/bulk", { body });
611
+ }
612
+ /**
613
+ * PATCH /v1/tasks/{id} — sparse update of a task's content (title/description/
614
+ * priority/dates/estimate/guest_visible) AND its `owner_id`/`reviewer_id` (an explicit
615
+ * null clears one). `body` still carries NO labels and NO field_values — those
616
+ * keep their dedicated endpoints. Requires If-Match
617
+ * (DOC-CONCURRENCY): GET first to capture the ETag, send it, retry ONCE on 409.
618
+ * Returns the raw updated `RawTaskDetail`.
619
+ */
620
+ updateTask(taskId, body) {
621
+ return this.withPrecondition(
622
+ taskId,
623
+ (etag) => this.request("PATCH", `/v1/tasks/${encodeURIComponent(taskId)}`, {
624
+ body,
625
+ headers: { "if-match": etag }
626
+ })
627
+ );
628
+ }
629
+ /**
630
+ * PATCH /v1/tasks/{id}/move — change a task's status (which carries its stage)
631
+ * and/or its position via neighbor ids. `body` is
632
+ * `{ status_id, before_id?, after_id? }` — already-resolved UUIDs; positions are
633
+ * server-generated, never a fractional key. The server replies an ENVELOPE
634
+ * `{ task, previous }`; this returns it raw (the op hydrates `raw.task`). No
635
+ * If-Match.
636
+ */
637
+ moveTask(taskId, body) {
638
+ return this.request("PATCH", `/v1/tasks/${encodeURIComponent(taskId)}/move`, { body });
639
+ }
640
+ /**
641
+ * POST /v1/tasks/{id}/labels — add a label to an existing task. `labelId` is an
642
+ * already-resolved label UUID. No If-Match. Returns the raw response.
643
+ */
644
+ addTaskLabel(taskId, labelId) {
645
+ return this.request("POST", `/v1/tasks/${encodeURIComponent(taskId)}/labels`, {
646
+ body: { label_id: labelId }
647
+ });
648
+ }
649
+ /**
650
+ * DELETE /v1/tasks/{id}/labels/{labelId} — remove a label from a task. Both ids
651
+ * are already-resolved UUIDs. No If-Match. Resolves to void (204).
652
+ */
653
+ async removeTaskLabel(taskId, labelId) {
654
+ await this.request(
655
+ "DELETE",
656
+ `/v1/tasks/${encodeURIComponent(taskId)}/labels/${encodeURIComponent(labelId)}`
657
+ );
658
+ }
659
+ /**
660
+ * PATCH /v1/tasks/{id}/field-values — bulk-set a task's custom-field values.
661
+ * `body` is `{ field_values: { <fieldId>: value } }` keyed by field UUID. No
662
+ * If-Match. Returns the raw response.
663
+ */
664
+ setFieldValues(taskId, body) {
665
+ return this.request("PATCH", `/v1/tasks/${encodeURIComponent(taskId)}/field-values`, {
666
+ body
667
+ });
668
+ }
669
+ /**
670
+ * POST /v1/tasks/{id}/archive — soft-archive a task (reversible via the op's
671
+ * `unarchiveTask`). Returns the raw updated `RawTaskDetail`.
672
+ */
673
+ archiveTask(taskId) {
674
+ return this.request("POST", `/v1/tasks/${encodeURIComponent(taskId)}/archive`);
675
+ }
676
+ /**
677
+ * POST /v1/tasks/{id}/unarchive — restore a soft-archived task. Returns the raw
678
+ * updated `RawTaskDetail`. (The `task.archive` op picks archive vs unarchive.)
679
+ */
680
+ unarchiveTask(taskId) {
681
+ return this.request("POST", `/v1/tasks/${encodeURIComponent(taskId)}/unarchive`);
682
+ }
683
+ /**
684
+ * DELETE /v1/tasks/{id} — delete a task. `hard=true` requests a hard delete
685
+ * (the destructive op gates this upstream behind MAGIC_ALLOW_DESTRUCTIVE).
686
+ * Requires If-Match (DOC-CONCURRENCY): GET first to capture the ETag, send it,
687
+ * retry ONCE on 409. The server replies 204; resolves to void.
688
+ */
689
+ async deleteTask(taskId, hard) {
690
+ await this.withPrecondition(
691
+ taskId,
692
+ (etag) => this.request("DELETE", `/v1/tasks/${encodeURIComponent(taskId)}`, {
693
+ query: { hard },
694
+ headers: { "if-match": etag }
695
+ })
696
+ );
697
+ }
698
+ // ---- Phase-2 comment surface (raw server JSON; resolution is elsewhere) ----
699
+ /**
700
+ * GET /v1/tasks/{id}/comments — a task's comments, stage-scoped. Returns the raw
701
+ * `{ data, cursor }` envelope. `stage_id` (already a UUID) narrows to one stage;
702
+ * omit it to read across stages.
703
+ */
704
+ async listComments(taskId, opts) {
705
+ const envelope = await this.request(
706
+ "GET",
707
+ `/v1/tasks/${encodeURIComponent(taskId)}/comments`,
708
+ {
709
+ query: {
710
+ stage_id: opts?.stage_id,
711
+ limit: opts?.limit,
712
+ cursor: opts?.cursor
713
+ }
714
+ }
715
+ );
716
+ return normalizeCursorEnvelope(envelope);
717
+ }
718
+ /**
719
+ * POST /v1/tasks/{id}/comments — add a root (stage-scoped) comment. `body` is
720
+ * `{ content, stage_id?, mention_user_ids? }` with already-resolved mention
721
+ * UUIDs; `stage_id` defaults to the task's current stage upstream. Returns the
722
+ * raw `RawComment`.
723
+ */
724
+ addComment(taskId, body) {
725
+ return this.request("POST", `/v1/tasks/${encodeURIComponent(taskId)}/comments`, {
726
+ body
727
+ });
728
+ }
729
+ /**
730
+ * GET /v1/comments/{id} — one comment (raw `RawComment`). The `comment.reply`
731
+ * op fetches the target to verify it is a ROOT comment (`parent_id` /
732
+ * `thread_root_id` null) BEFORE replying, enforcing the one-level rule. Returned
733
+ * raw; resolution/hydration is elsewhere. `commentId` must already be a UUID.
734
+ */
735
+ getComment(commentId) {
736
+ return this.request("GET", `/v1/comments/${encodeURIComponent(commentId)}`);
737
+ }
738
+ /**
739
+ * POST /v1/comments/{id}/replies — reply to a ROOT comment (one level only; the
740
+ * op rejects reply-to-reply before this call). `body` is
741
+ * `{ content, mention_user_ids? }` with already-resolved mention UUIDs. Returns
742
+ * the raw `RawComment`.
743
+ */
744
+ replyComment(commentId, body) {
745
+ return this.request("POST", `/v1/comments/${encodeURIComponent(commentId)}/replies`, {
746
+ body
747
+ });
748
+ }
749
+ // ---------------------------- core request ----------------------------
750
+ /**
751
+ * Issue one authenticated JSON request and return the parsed body as `T`.
752
+ *
753
+ * - Sets Bearer auth + `Accept: application/json` (and `Content-Type` for a body).
754
+ * - 2xx: returns the parsed JSON (an empty/204 body parses to `undefined`).
755
+ * - non-2xx: parses the `{ error: { code, message } }` envelope (best effort)
756
+ * and throws `mapPyramidHttpError(status, body)`.
757
+ * - undici/transport failure (DNS, connect, reset, timeout): throws
758
+ * `MagicError('network', ...)` via `toPyramidError`.
759
+ *
760
+ * The API key is on the header only — never logged, never in the URL.
761
+ */
762
+ async request(method, path, opts) {
763
+ return (await this.requestWithETag(method, path, opts)).body;
764
+ }
765
+ /**
766
+ * The transport core, also returning the response's `ETag` header (null when
767
+ * absent). `request<T>()` is the body-only sugar over this; the If-Match
768
+ * read-first flow reads the etag here (DOC-CONCURRENCY).
769
+ */
770
+ async requestWithETag(method, path, opts) {
771
+ const url = this.buildUrl(path, opts?.query);
772
+ const headers = {
773
+ authorization: `Bearer ${this.apiKey}`,
774
+ accept: "application/json"
775
+ };
776
+ let bodyText;
777
+ if (opts?.body !== void 0) {
778
+ bodyText = JSON.stringify(opts.body);
779
+ headers["content-type"] = "application/json";
780
+ }
781
+ if (opts?.headers) {
782
+ for (const [k, v] of Object.entries(opts.headers)) headers[k] = v;
783
+ }
784
+ let status;
785
+ let raw;
786
+ let etag;
787
+ try {
788
+ const res = await undiciRequest(url, {
789
+ method,
790
+ headers,
791
+ body: bodyText
792
+ });
793
+ status = res.statusCode;
794
+ etag = readETagHeader(res.headers);
795
+ raw = await res.body.text();
796
+ } catch (err) {
797
+ const mapped = toPyramidError(err);
798
+ throw mapped.code === "network" ? mapped : pyramidError("network", mapped.message, {
799
+ hint: "Check MAGIC_PYRAMID_URL and your network connection."
800
+ });
801
+ }
802
+ const parsed = safeJsonParse(raw);
803
+ if (status < 200 || status >= 300) {
804
+ throw mapPyramidHttpError(status, parsed);
805
+ }
806
+ return { body: parsed, etag };
807
+ }
808
+ /**
809
+ * Capture the current ETag for a task (DOC-CONCURRENCY): GET /v1/tasks/{id} and
810
+ * read its `ETag` response header, falling back to the body's `updated_at` when
811
+ * the header is absent. Used by the If-Match read-first flow.
812
+ */
813
+ async getTaskETag(taskId) {
814
+ const res = await this.requestWithETag(
815
+ "GET",
816
+ `/v1/tasks/${encodeURIComponent(taskId)}`
817
+ );
818
+ if (res.etag) return res.etag;
819
+ const body = res.body;
820
+ if (body && typeof body === "object") {
821
+ const updatedAt = body.updated_at;
822
+ if (typeof updatedAt === "string" && updatedAt.length > 0) return updatedAt;
823
+ }
824
+ return "";
825
+ }
826
+ /**
827
+ * Run an If-Match-guarded mutation (PATCH update / DELETE) with the read-first
828
+ * flow (DOC-CONCURRENCY): GET to capture the ETag, run `fn(etag)`, and on a 409
829
+ * `conflict` refetch the ETag and retry ONCE before surfacing the conflict.
830
+ */
831
+ async withPrecondition(taskId, fn) {
832
+ const etag = await this.getTaskETag(taskId);
833
+ try {
834
+ return await fn(etag);
835
+ } catch (err) {
836
+ if (err instanceof MagicError && err.code === "conflict") {
837
+ const fresh = await this.getTaskETag(taskId);
838
+ return fn(fresh);
839
+ }
840
+ throw err;
841
+ }
842
+ }
843
+ /** Build an absolute URL, appending defined query params (skips null/undefined). */
844
+ buildUrl(path, query) {
845
+ const url = new URL(`${this.baseUrl}${path}`);
846
+ if (query) {
847
+ for (const [key, value] of Object.entries(query)) {
848
+ if (value === void 0 || value === null) continue;
849
+ url.searchParams.set(key, String(value));
850
+ }
851
+ }
852
+ return url.toString();
853
+ }
854
+ };
855
+ function readETagHeader(headers) {
856
+ const value = headers.etag ?? headers.ETag;
857
+ if (Array.isArray(value)) return value[0] ?? null;
858
+ return typeof value === "string" ? value : null;
859
+ }
860
+ function unwrapData(body) {
861
+ if (Array.isArray(body)) return body;
862
+ if (body && typeof body === "object" && "data" in body) {
863
+ const data = body.data;
864
+ if (Array.isArray(data)) return data;
865
+ }
866
+ return [];
867
+ }
868
+ function normalizeCursorEnvelope(envelope) {
869
+ return {
870
+ data: Array.isArray(envelope?.data) ? envelope.data : [],
871
+ cursor: typeof envelope?.cursor === "string" ? envelope.cursor : null
872
+ };
873
+ }
874
+ function safeJsonParse(text) {
875
+ if (text.trim() === "") return void 0;
876
+ try {
877
+ return JSON.parse(text);
878
+ } catch {
879
+ return void 0;
880
+ }
881
+ }
882
+
883
+ // ../pyramid/src/credential.ts
884
+ var PYRAMID_CREDENTIAL = {
885
+ id: "pyramid",
886
+ title: "Pyramid",
887
+ credential: "pyramid_",
888
+ keyHelp: "Pyramid \u2192 Settings \u2192 API Keys"
889
+ };
890
+ var DEFAULT_API_URL = "https://api.pyramid.magicspells.io";
891
+
892
+ // ../pyramid/src/doctor.ts
893
+ var whoamiOp = async () => (await import("./operations-P3DUAVPP.js")).operationsByName.get("pyramid.whoami");
894
+ var doctorChecks = [
895
+ {
896
+ name: "pyramid auth",
897
+ async run(ctx) {
898
+ const me = await (await whoamiOp()).run({}, ctx);
899
+ const name = me.user.display_name || "(unknown user)";
900
+ const user = me.user.email ? `${name} <${me.user.email}>` : name;
901
+ const workspace = me.workspace.name || "(workspace)";
902
+ const names = me.projects.map((p) => p.name || p.handle).filter((n) => n.length > 0);
903
+ return {
904
+ ok: true,
905
+ detail: `Authenticated as ${user} \xB7 workspace ${workspace} \xB7 ${names.length} project${names.length === 1 ? "" : "s"}` + (names.length ? `: ${names.join(", ")}` : ""),
906
+ data: {
907
+ user: { name, email: me.user.email || null },
908
+ workspace: { name: workspace },
909
+ projects: names
910
+ }
911
+ };
912
+ }
913
+ },
914
+ {
915
+ name: "pyramid workflow",
916
+ async run(ctx) {
917
+ const projects = await ctx.client.api.listProjects();
918
+ const id = readStr(readField(projects[0], "id"));
919
+ if (!id) return { ok: true, detail: "No projects; workflow endpoint not checked." };
920
+ await ctx.client.resolver.getWorkflow(id);
921
+ return { ok: true, detail: "Workflow endpoint: OK." };
922
+ }
923
+ }
924
+ ];
925
+
926
+ // ../pyramid/src/login.ts
927
+ import { spawn } from "node:child_process";
928
+ import { randomBytes } from "node:crypto";
929
+ import { createServer } from "node:http";
930
+ var DEFAULT_WEB_URL = "https://pyramid.magicspells.io";
931
+ var DEFAULT_LOGIN_TIMEOUT_MS = 2 * 60 * 1e3;
932
+ var MAX_CALLBACK_BODY_BYTES = 4 * 1024;
933
+ var realIO = {
934
+ out: (line) => process.stderr.write(`${line}
935
+ `),
936
+ setKey: async (key) => {
937
+ await saveProductKey(PYRAMID_CREDENTIAL, createCredentialStore(), key);
938
+ },
939
+ openBrowser,
940
+ env: process.env,
941
+ randomState: () => randomBytes(24).toString("base64url"),
942
+ loginTimeoutMs: DEFAULT_LOGIN_TIMEOUT_MS
943
+ };
944
+ var LOGIN_USAGE = "usage: magic pyramid login [--web-url <url>] [--name <key name>]";
945
+ function parseLoginOptions(argv, env) {
946
+ let webUrl = env.MAGIC_PYRAMID_WEB_URL?.trim() || DEFAULT_WEB_URL;
947
+ let name = "Magic CLI";
948
+ const args = argv;
949
+ for (let i = 0; i < args.length; i += 1) {
950
+ const arg = args[i];
951
+ if (arg === "--help" || arg === "-h") return "help";
952
+ if (arg === "--web-url") {
953
+ const next = args[++i];
954
+ if (!next) return LOGIN_USAGE;
955
+ webUrl = next;
956
+ continue;
957
+ }
958
+ if (arg.startsWith("--web-url=")) {
959
+ webUrl = arg.slice("--web-url=".length);
960
+ continue;
961
+ }
962
+ if (arg === "--name") {
963
+ const next = args[++i];
964
+ if (!next) return LOGIN_USAGE;
965
+ name = next;
966
+ continue;
967
+ }
968
+ if (arg.startsWith("--name=")) {
969
+ name = arg.slice("--name=".length);
970
+ continue;
971
+ }
972
+ return `unknown option for magic pyramid login: ${arg}`;
973
+ }
974
+ try {
975
+ new URL(webUrl);
976
+ } catch {
977
+ return `MAGIC_PYRAMID_WEB_URL is not a valid URL: "${webUrl}".`;
978
+ }
979
+ return { webUrl, name: name.trim() || "Magic CLI" };
980
+ }
981
+ function authURL(webUrl, callbackURL, state, name) {
982
+ const target = new URL("/auth/cli", webUrl);
983
+ target.searchParams.set("redirect_uri", callbackURL);
984
+ target.searchParams.set("state", state);
985
+ target.searchParams.set("name", name);
986
+ return target.toString();
987
+ }
988
+ function readCallbackForm(req) {
989
+ return new Promise((resolve, reject) => {
990
+ const chunks = [];
991
+ let size = 0;
992
+ const onData = (chunk) => {
993
+ size += chunk.length;
994
+ if (size > MAX_CALLBACK_BODY_BYTES) {
995
+ req.off("data", onData);
996
+ req.resume();
997
+ resolve(null);
998
+ return;
999
+ }
1000
+ chunks.push(chunk);
1001
+ };
1002
+ req.on("data", onData);
1003
+ req.once("end", () => resolve(new URLSearchParams(Buffer.concat(chunks).toString("utf8"))));
1004
+ req.once("error", reject);
1005
+ });
1006
+ }
1007
+ function closeServer(server) {
1008
+ return new Promise((resolve) => {
1009
+ server.close(() => resolve());
1010
+ });
1011
+ }
1012
+ async function runLogin(argv, io = realIO) {
1013
+ const opts = parseLoginOptions(argv, io.env);
1014
+ if (opts === "help") {
1015
+ io.out(LOGIN_USAGE);
1016
+ io.out("Opens Pyramid in the browser and stores the workspace key it mints.");
1017
+ return 0;
1018
+ }
1019
+ if (typeof opts === "string") {
1020
+ io.out(opts);
1021
+ return 2;
1022
+ }
1023
+ const state = io.randomState();
1024
+ let settled = false;
1025
+ let timeout;
1026
+ let finish;
1027
+ const done = new Promise((resolve) => {
1028
+ finish = (code) => {
1029
+ if (settled) return;
1030
+ settled = true;
1031
+ if (timeout) clearTimeout(timeout);
1032
+ resolve(code);
1033
+ };
1034
+ });
1035
+ const server = createServer(async (req, res) => {
1036
+ const requestURL = new URL(req.url ?? "/", "http://127.0.0.1");
1037
+ if (requestURL.pathname !== "/callback") {
1038
+ res.writeHead(404, { "content-type": "text/plain; charset=utf-8" });
1039
+ res.end("Not found.\n");
1040
+ return;
1041
+ }
1042
+ if (req.method !== "POST" || requestURL.searchParams.has("key")) {
1043
+ res.writeHead(405, { "content-type": "text/plain; charset=utf-8", allow: "POST" });
1044
+ res.end("Method not allowed. Return to your terminal.\n");
1045
+ io.out("Ignored a Pyramid login callback that did not POST the key.");
1046
+ return;
1047
+ }
1048
+ let form;
1049
+ try {
1050
+ form = await readCallbackForm(req);
1051
+ } catch {
1052
+ return;
1053
+ }
1054
+ if (!form) {
1055
+ res.writeHead(413, { "content-type": "text/plain; charset=utf-8", connection: "close" });
1056
+ res.end("Login callback too large. Return to your terminal.\n");
1057
+ io.out("Ignored an oversized Pyramid login callback.");
1058
+ return;
1059
+ }
1060
+ const gotState = form.get("state") ?? "";
1061
+ if (gotState !== state) {
1062
+ res.writeHead(400, { "content-type": "text/plain; charset=utf-8" });
1063
+ res.end("Invalid login state. Return to your terminal.\n");
1064
+ io.out("Rejected Pyramid login callback with an invalid state.");
1065
+ void closeServer(server).then(() => finish(1));
1066
+ return;
1067
+ }
1068
+ const error = form.get("error");
1069
+ if (error) {
1070
+ res.writeHead(200, { "content-type": "text/plain; charset=utf-8" });
1071
+ res.end("Pyramid login cancelled. Return to your terminal.\n");
1072
+ io.out(
1073
+ error === "access_denied" ? "Pyramid login cancelled." : `Pyramid login failed: ${error}`
1074
+ );
1075
+ void closeServer(server).then(() => finish(1));
1076
+ return;
1077
+ }
1078
+ const key = form.get("key")?.trim() ?? "";
1079
+ if (!key.startsWith(PYRAMID_CREDENTIAL.credential)) {
1080
+ res.writeHead(400, { "content-type": "text/plain; charset=utf-8" });
1081
+ res.end("Invalid Pyramid key. Return to your terminal.\n");
1082
+ io.out("Pyramid login returned an invalid key.");
1083
+ void closeServer(server).then(() => finish(1));
1084
+ return;
1085
+ }
1086
+ try {
1087
+ await io.setKey(key);
1088
+ } catch (err) {
1089
+ res.writeHead(500, { "content-type": "text/plain; charset=utf-8" });
1090
+ res.end("Could not store the key. Return to your terminal.\n");
1091
+ io.out(
1092
+ `Could not store the Pyramid key: ${err instanceof Error ? err.message : String(err)}`
1093
+ );
1094
+ void closeServer(server).then(() => finish(1));
1095
+ return;
1096
+ }
1097
+ res.writeHead(200, { "content-type": "text/plain; charset=utf-8" });
1098
+ res.end("Pyramid CLI connected. You can close this tab.\n");
1099
+ io.out(
1100
+ `Saved ${maskKey(key, PYRAMID_CREDENTIAL.credential)} \u2014 magic and magic mcp will use it automatically.`
1101
+ );
1102
+ void closeServer(server).then(() => finish(0));
1103
+ });
1104
+ try {
1105
+ await new Promise((resolve, reject) => {
1106
+ server.once("error", reject);
1107
+ server.listen(0, "127.0.0.1", () => {
1108
+ server.off("error", reject);
1109
+ resolve();
1110
+ });
1111
+ });
1112
+ } catch (err) {
1113
+ io.out(
1114
+ `Could not start the local Pyramid login server: ${err instanceof Error ? err.message : String(err)}`
1115
+ );
1116
+ return 1;
1117
+ }
1118
+ const address = server.address();
1119
+ const callbackURL = `http://127.0.0.1:${address.port}/callback`;
1120
+ const loginURL = authURL(opts.webUrl, callbackURL, state, opts.name);
1121
+ timeout = setTimeout(() => {
1122
+ io.out("Pyramid login timed out before the browser completed the handoff.");
1123
+ void closeServer(server).then(() => finish(1));
1124
+ }, io.loginTimeoutMs);
1125
+ const opened = await io.openBrowser(loginURL);
1126
+ if (opened) {
1127
+ io.out("Opened Pyramid in your browser. Authorize the CLI to finish login.");
1128
+ } else {
1129
+ io.out("Could not open your browser automatically. Open this URL to finish login:");
1130
+ io.out(loginURL);
1131
+ }
1132
+ return done;
1133
+ }
1134
+ function openBrowser(url) {
1135
+ return new Promise((resolve) => {
1136
+ const platform = process.platform;
1137
+ const command = platform === "darwin" ? "open" : platform === "win32" ? "cmd" : "xdg-open";
1138
+ const args = platform === "win32" ? ["/c", "start", "", url] : [url];
1139
+ const child = spawn(command, args, { detached: true, stdio: "ignore" });
1140
+ child.once("error", () => resolve(false));
1141
+ child.once("close", (code) => resolve(code === 0));
1142
+ child.unref();
1143
+ });
1144
+ }
1145
+
1146
+ // ../pyramid/src/mcp/prompts.ts
1147
+ var DOCTOR_DESCRIPTION = "Check your Pyramid setup: confirm the API key authenticates and show the user, workspace, and accessible projects.";
1148
+ var DOCTOR_INSTRUCTION = [
1149
+ "Run a Pyramid setup check.",
1150
+ "",
1151
+ "Call the `pyramid_whoami` tool, then report back, in plain language:",
1152
+ " \u2022 the authenticated user (display name and email),",
1153
+ " \u2022 the workspace (name) you are connected to, and",
1154
+ " \u2022 the accessible projects (names).",
1155
+ "",
1156
+ "Finish by confirming the setup looks good. If `pyramid_whoami` returns an error,",
1157
+ "act on its `code`: for an `auth_*` code, tell the user to regenerate their",
1158
+ "key in Pyramid \u2192 Settings \u2192 API Keys. Never print raw JSON or an HTTP status."
1159
+ ].join("\n");
1160
+ function registerPrompts(server) {
1161
+ server.registerPrompt("pyramid_doctor", { description: DOCTOR_DESCRIPTION }, () => ({
1162
+ messages: [
1163
+ {
1164
+ role: "user",
1165
+ content: { type: "text", text: DOCTOR_INSTRUCTION }
1166
+ }
1167
+ ]
1168
+ }));
1169
+ }
1170
+
1171
+ // ../pyramid/src/mcp/resources.ts
1172
+ import { ResourceTemplate } from "@modelcontextprotocol/sdk/server/mcp.js";
1173
+ function op(name) {
1174
+ const found = operationsByName.get(name);
1175
+ if (!found) throw new Error(`missing operation ${name}`);
1176
+ return found;
1177
+ }
1178
+ async function renderOp(op2, context) {
1179
+ try {
1180
+ const input = op2.input.parse({});
1181
+ const result = await op2.run(input, await context());
1182
+ return JSON.stringify(result, null, 2);
1183
+ } catch (err) {
1184
+ return JSON.stringify({ error: toPyramidError(err).toJSON() }, null, 2);
1185
+ }
1186
+ }
1187
+ function registerResources(server, context) {
1188
+ server.registerResource(
1189
+ "pyramid_me",
1190
+ "pyramid://me",
1191
+ {
1192
+ title: "Pyramid identity",
1193
+ description: "The authenticated user, their workspace, and accessible projects (whoami).",
1194
+ mimeType: "application/json"
1195
+ },
1196
+ async (uri) => {
1197
+ const text = await renderOp(op("pyramid.whoami"), context);
1198
+ return {
1199
+ contents: [{ uri: uri.href, mimeType: "application/json", text }]
1200
+ };
1201
+ }
1202
+ );
1203
+ server.registerResource(
1204
+ "pyramid_projects",
1205
+ "pyramid://projects",
1206
+ {
1207
+ title: "Pyramid projects",
1208
+ description: "The projects accessible to the authenticated user.",
1209
+ mimeType: "application/json"
1210
+ },
1211
+ async (uri) => {
1212
+ const text = await renderOp(op("pyramid.project.list"), context);
1213
+ return {
1214
+ contents: [{ uri: uri.href, mimeType: "application/json", text }]
1215
+ };
1216
+ }
1217
+ );
1218
+ server.registerResource(
1219
+ "pyramid_project_workflow",
1220
+ new ResourceTemplate("pyramid://projects/{handle}/workflow", { list: void 0 }),
1221
+ {
1222
+ title: "Project workflow",
1223
+ description: "A project's stages, statuses, labels, members, and field templates, by handle.",
1224
+ mimeType: "application/json"
1225
+ },
1226
+ async (uri, variables) => {
1227
+ const raw = variables.handle;
1228
+ const handle = Array.isArray(raw) ? raw[0] : raw;
1229
+ const workflow = op("pyramid.project.workflow");
1230
+ let text;
1231
+ if (handle) {
1232
+ try {
1233
+ const result = await workflow.run(
1234
+ workflow.input.parse({ project: handle }),
1235
+ await context()
1236
+ );
1237
+ text = JSON.stringify(result, null, 2);
1238
+ } catch (err) {
1239
+ text = JSON.stringify({ error: toPyramidError(err).toJSON() }, null, 2);
1240
+ }
1241
+ } else {
1242
+ text = JSON.stringify(
1243
+ {
1244
+ error: {
1245
+ code: "validation_failed",
1246
+ message: "missing project handle",
1247
+ product: "pyramid"
1248
+ }
1249
+ },
1250
+ null,
1251
+ 2
1252
+ );
1253
+ }
1254
+ return {
1255
+ contents: [{ uri: uri.href, mimeType: "application/json", text }]
1256
+ };
1257
+ }
1258
+ );
1259
+ }
1260
+
1261
+ // ../pyramid/src/mcp/index.ts
1262
+ var instructions = (version) => `Pyramid MCP v${version} \u2014 drive the Pyramid project tracker. Rules: (1) You act as one user in ONE workspace (the key is workspace-pinned). (2) Inputs accept names/keys, not UUIDs; outputs are already hydrated to names. (3) RENDER tasks as a left-rail card, one field per line, no JSON: \`\u2502 <KEY>\` / \`\u2502 NAME: <title>\` / \`\u2502 DESCRIPTION: <one line; truncate in lists>\` / \`\u2502 <Stage>: <Status>\` \u2014 always show stage and status together as \`Stage: Status\`. (4) For a LIST ("get my work"), render one card per task SEPARATED BY A BLANK LINE so each reads as a distinct ticket \u2014 never let cards run together \u2014 with one-line descriptions; show the page, then say "showing N of M \u2014 ask for more"; NEVER auto-paginate. Full description + drill-in only for a single task. (5) Drill-in (comments, git PRs/commits) only when asked, for the named task. (6) Dates are UTC \u2014 render UTC-labeled or relative-with-label, never bare local. (7) On error, act on the \`code\` (don't parse the message): \`ambiguous_*\` \u2192 show candidates and ask; \`auth_*\` \u2192 tell the user to regenerate their key in Pyramid \u2192 Settings \u2192 API Keys. Never print raw JSON or an HTTP status to the user.`;
1263
+ function registerPyramidMcp(server, context) {
1264
+ registerResources(server, context);
1265
+ registerPrompts(server);
1266
+ }
1267
+
1268
+ // ../pyramid/src/index.ts
1269
+ var pyramid = {
1270
+ ...PYRAMID_CREDENTIAL,
1271
+ apiUrl: DEFAULT_API_URL,
1272
+ // The operations graph loads only when a Pyramid command runs.
1273
+ load: async () => (await import("./operations-P3DUAVPP.js")).operations,
1274
+ client(session, config) {
1275
+ const api = new PyramidClient({
1276
+ apiKey: session.key,
1277
+ baseUrl: config.products.pyramid.apiUrl
1278
+ });
1279
+ return { api, resolver: new Resolver(api) };
1280
+ },
1281
+ // `--project` stays a Pyramid flag, usable anywhere in argv (pyramid-cli's global).
1282
+ globalFlags: ["project"],
1283
+ commands: {
1284
+ login: {
1285
+ summary: "Sign in through Pyramid in the browser and store a workspace key (until R1)",
1286
+ run: (argv) => runLogin(argv)
1287
+ }
1288
+ },
1289
+ doctor: doctorChecks,
1290
+ mcp: { instructions, register: registerPyramidMcp }
1291
+ };
1292
+ var src_default = pyramid;
1293
+ export {
1294
+ DEFAULT_API_URL,
1295
+ PYRAMID_CREDENTIAL,
1296
+ src_default as default,
1297
+ pyramid
1298
+ };