@anthropic-forge/cli 1.0.0 → 1.0.2

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/dist/index.js DELETED
@@ -1,2191 +0,0 @@
1
- #!/usr/bin/env node
2
-
3
- // src/index.ts
4
- import { createRequire } from "module";
5
- import { Command as Command10 } from "commander";
6
-
7
- // src/commands/login.ts
8
- import { Command } from "commander";
9
- import chalk2 from "chalk";
10
- import ora from "ora";
11
-
12
- // src/services/config.service.ts
13
- import * as fs from "fs/promises";
14
- import * as path from "path";
15
- import * as os from "os";
16
- import { z } from "zod";
17
- var ForgeConfigSchema = z.object({
18
- accessToken: z.string(),
19
- refreshToken: z.string(),
20
- expiresAt: z.string(),
21
- userId: z.string(),
22
- teamId: z.string(),
23
- workspaceId: z.string().optional(),
24
- user: z.object({
25
- email: z.string().email(),
26
- displayName: z.string()
27
- })
28
- });
29
- var CONFIG_DIR = path.join(os.homedir(), ".forge");
30
- var CONFIG_FILE = "config.json";
31
- function getConfigPath() {
32
- return path.join(CONFIG_DIR, CONFIG_FILE);
33
- }
34
- async function load() {
35
- const configPath = getConfigPath();
36
- let raw;
37
- try {
38
- raw = await fs.readFile(configPath, "utf-8");
39
- } catch (err) {
40
- if (err.code === "ENOENT") {
41
- return null;
42
- }
43
- throw err;
44
- }
45
- if (process.platform !== "win32") {
46
- try {
47
- const stat2 = await fs.stat(configPath);
48
- if ((stat2.mode & 511) !== 384) {
49
- process.stderr.write(
50
- `\u26A0\uFE0F Warning: ~/.forge/config.json permissions are not 600. Run: chmod 600 ${configPath}
51
- `
52
- );
53
- }
54
- } catch {
55
- }
56
- }
57
- let parsed;
58
- try {
59
- parsed = JSON.parse(raw);
60
- } catch {
61
- throw new Error(
62
- "Config file is malformed. Run `forge login` to re-authenticate."
63
- );
64
- }
65
- const result = ForgeConfigSchema.safeParse(parsed);
66
- if (!result.success) {
67
- throw new Error(
68
- "Config file is corrupt or invalid. Run `forge login` to re-authenticate."
69
- );
70
- }
71
- return result.data;
72
- }
73
- async function save(config2) {
74
- const configPath = getConfigPath();
75
- await fs.mkdir(CONFIG_DIR, { recursive: true });
76
- await fs.writeFile(configPath, JSON.stringify(config2, null, 2), "utf-8");
77
- if (process.platform !== "win32") {
78
- await fs.chmod(configPath, 384);
79
- }
80
- }
81
- async function clear() {
82
- const configPath = getConfigPath();
83
- try {
84
- await fs.unlink(configPath);
85
- } catch (err) {
86
- if (err.code !== "ENOENT") {
87
- throw err;
88
- }
89
- }
90
- }
91
-
92
- // src/config.ts
93
- import * as dotenv from "dotenv";
94
- import * as path2 from "path";
95
- import chalk from "chalk";
96
- if (process.env.NODE_ENV !== "production") {
97
- dotenv.config({ path: path2.resolve(process.cwd(), ".env.development") });
98
- }
99
- if (process.env.NODE_TLS_REJECT_UNAUTHORIZED === "0") {
100
- process.stderr.write(
101
- chalk.yellow(
102
- "\u26A0 WARNING: NODE_TLS_REJECT_UNAUTHORIZED=0 disables TLS certificate validation.\n Your auth tokens may be exposed to interception. Unset this variable in production.\n"
103
- )
104
- );
105
- }
106
- var rawApiUrl = process.env.FORGE_API_URL;
107
- var rawAppUrl = process.env.FORGE_APP_URL;
108
- function validateUrl(url, envName) {
109
- if (process.env.NODE_ENV !== "production") return url;
110
- if (!url.startsWith("https://")) {
111
- process.stderr.write(
112
- chalk.yellow(
113
- `\u26A0 WARNING: ${envName}=${url} is not HTTPS. Auth tokens may be sent over an insecure connection.
114
- `
115
- )
116
- );
117
- }
118
- return url;
119
- }
120
- var API_URL = validateUrl(
121
- rawApiUrl ?? "https://www.forge-ai.dev/api",
122
- "FORGE_API_URL"
123
- );
124
- var APP_URL = validateUrl(
125
- rawAppUrl ?? "https://www.forge-ai.dev",
126
- "FORGE_APP_URL"
127
- );
128
-
129
- // src/services/auth.service.ts
130
- var MAX_POLL_MS = 3e5;
131
- async function startDeviceFlow() {
132
- const res = await fetch(`${API_URL}/auth/device/request`, {
133
- method: "POST",
134
- headers: { "Content-Type": "application/json" }
135
- });
136
- if (!res.ok) {
137
- throw new Error(
138
- `Failed to initiate authentication: ${res.status} ${res.statusText}`
139
- );
140
- }
141
- return res.json();
142
- }
143
- async function pollToken(deviceCode, interval, maxMs = MAX_POLL_MS) {
144
- const start = Date.now();
145
- const intervalMs = interval * 1e3;
146
- while (Date.now() - start < maxMs) {
147
- await sleep(intervalMs);
148
- const res = await fetch(`${API_URL}/auth/device/token`, {
149
- method: "POST",
150
- headers: { "Content-Type": "application/json" },
151
- body: JSON.stringify({ deviceCode })
152
- });
153
- if (res.ok) {
154
- return res.json();
155
- }
156
- if (res.status === 400) {
157
- const body = await res.json();
158
- if (body.error === "authorization_pending") {
159
- continue;
160
- }
161
- if (body.error === "expired_token") {
162
- throw new Error(
163
- "Authorization timed out. Run `forge login` to try again."
164
- );
165
- }
166
- if (body.error === "access_denied") {
167
- throw new Error(
168
- "Authorization was denied. Run `forge login` to try again."
169
- );
170
- }
171
- }
172
- throw new Error(
173
- `Unexpected server response: ${res.status} ${res.statusText}`
174
- );
175
- }
176
- throw new Error(
177
- "Authorization timed out (5 min exceeded). Run `forge login` to try again."
178
- );
179
- }
180
- function isLoggedIn(config2) {
181
- return !!config2?.accessToken;
182
- }
183
- async function refresh(refreshToken) {
184
- const res = await fetch(`${API_URL}/auth/refresh`, {
185
- method: "POST",
186
- headers: { "Content-Type": "application/json" },
187
- body: JSON.stringify({ refreshToken })
188
- });
189
- if (!res.ok) {
190
- throw new Error(
191
- "Session expired. Run `forge login` to re-authenticate."
192
- );
193
- }
194
- return res.json();
195
- }
196
- function sleep(ms) {
197
- return new Promise((resolve3) => setTimeout(resolve3, ms));
198
- }
199
-
200
- // src/mcp/install.ts
201
- import { execSync } from "child_process";
202
- import * as fs2 from "fs/promises";
203
- import * as path3 from "path";
204
- async function tryRegisterMcpServer(scope) {
205
- const cmd = `claude mcp add --transport stdio --scope ${scope} forge -- forge mcp`;
206
- try {
207
- execSync(cmd, { stdio: "pipe" });
208
- return "registered";
209
- } catch {
210
- return "skipped";
211
- }
212
- }
213
- async function writeMcpJson(cwd = process.cwd()) {
214
- const mcpJsonPath = path3.join(cwd, ".mcp.json");
215
- let existing = { mcpServers: {} };
216
- try {
217
- const raw = await fs2.readFile(mcpJsonPath, "utf-8");
218
- const parsed = JSON.parse(raw);
219
- existing = parsed;
220
- if (!existing.mcpServers) {
221
- existing.mcpServers = {};
222
- }
223
- } catch {
224
- }
225
- const merged = {
226
- ...existing,
227
- mcpServers: {
228
- ...existing.mcpServers,
229
- forge: {
230
- type: "stdio",
231
- command: "forge",
232
- args: ["mcp"]
233
- }
234
- }
235
- };
236
- await fs2.writeFile(mcpJsonPath, JSON.stringify(merged, null, 2) + "\n", "utf-8");
237
- }
238
-
239
- // src/commands/login.ts
240
- var loginCommand = new Command("login").description("Authenticate with your Forge account").action(async () => {
241
- try {
242
- const existing = await load();
243
- if (isLoggedIn(existing)) {
244
- console.log(
245
- chalk2.yellow(
246
- `You are already logged in as ${existing.user.email}. Run \`forge logout\` first.`
247
- )
248
- );
249
- process.exit(0);
250
- }
251
- const deviceFlow = await startDeviceFlow();
252
- console.log();
253
- console.log(chalk2.bold("Open this URL in your browser:"));
254
- console.log(chalk2.cyan(` ${deviceFlow.verificationUri}`));
255
- console.log();
256
- console.log(chalk2.bold("Enter this code:"));
257
- console.log(chalk2.green.bold(` ${deviceFlow.userCode}`));
258
- console.log();
259
- const spinner = ora("Waiting for authorization\u2026").start();
260
- process.on("SIGINT", () => {
261
- spinner.stop();
262
- console.log("\nCancelled.");
263
- process.exit(0);
264
- });
265
- let token;
266
- try {
267
- token = await pollToken(
268
- deviceFlow.deviceCode,
269
- deviceFlow.interval
270
- );
271
- } catch (err) {
272
- spinner.fail("Authorization failed.");
273
- console.error(chalk2.red(err.message));
274
- process.exit(1);
275
- }
276
- await save({
277
- accessToken: token.accessToken,
278
- refreshToken: token.refreshToken,
279
- expiresAt: new Date(Date.now() + 15 * 60 * 1e3).toISOString(),
280
- userId: token.userId,
281
- teamId: token.teamId,
282
- user: token.user
283
- });
284
- spinner.succeed(
285
- `${chalk2.green("Logged in as")} ${chalk2.bold(token.user.email)} | Team: ${chalk2.bold(token.teamId)}`
286
- );
287
- try {
288
- const mcpResult = await tryRegisterMcpServer("user");
289
- if (mcpResult === "registered") {
290
- console.log(chalk2.dim(" \u2713 Forge MCP server registered (user scope)"));
291
- console.log(chalk2.dim(" Claude Code will connect automatically on next start."));
292
- } else {
293
- console.log(chalk2.dim(" \u2139 Run `forge mcp install` to enable MCP in Claude Code"));
294
- }
295
- } catch (mcpErr) {
296
- console.log(
297
- chalk2.yellow(
298
- ` \u26A0 Could not auto-register MCP server: ${mcpErr.message}`
299
- )
300
- );
301
- console.log(
302
- chalk2.dim(" Run `forge mcp install` manually to set it up.")
303
- );
304
- }
305
- process.exit(0);
306
- } catch (err) {
307
- console.error(chalk2.red(`Error: ${err.message}`));
308
- process.exit(1);
309
- }
310
- });
311
-
312
- // src/commands/logout.ts
313
- import { Command as Command2 } from "commander";
314
- import chalk3 from "chalk";
315
- var logoutCommand = new Command2("logout").description("Sign out of your Forge account").action(async () => {
316
- try {
317
- const config2 = await load();
318
- if (!isLoggedIn(config2)) {
319
- console.log(chalk3.yellow("You are not logged in."));
320
- process.exit(0);
321
- }
322
- await clear();
323
- console.log(chalk3.green("Logged out successfully."));
324
- process.exit(0);
325
- } catch (err) {
326
- console.error(chalk3.red(`Error: ${err.message}`));
327
- process.exit(1);
328
- }
329
- });
330
-
331
- // src/commands/list.ts
332
- import { Command as Command3 } from "commander";
333
- import chalk6 from "chalk";
334
-
335
- // src/middleware/auth-guard.ts
336
- import chalk4 from "chalk";
337
- async function requireAuth() {
338
- const config2 = await load();
339
- if (!isLoggedIn(config2)) {
340
- console.error(chalk4.red("Not logged in. Run `forge login` first."));
341
- process.exit(1);
342
- }
343
- return config2;
344
- }
345
-
346
- // src/services/api.service.ts
347
- var RETRY_DELAY_MS = 2e3;
348
- var ApiError = class extends Error {
349
- constructor(statusCode, message) {
350
- super(message);
351
- this.statusCode = statusCode;
352
- this.name = "ApiError";
353
- }
354
- };
355
- function friendlyHttpError(status) {
356
- switch (status) {
357
- case 403:
358
- return "You do not have permission. Check your team membership or run `forge login`.";
359
- case 404:
360
- return "Resource not found. Check the ID and try again.";
361
- case 429:
362
- return "Rate limited. Wait a moment and try again.";
363
- default:
364
- return `Unexpected server response (${status}). Try again or check https://status.forge-ai.dev.`;
365
- }
366
- }
367
- function buildUrl(path5, params) {
368
- const url = new URL(`${API_URL}${path5}`);
369
- if (params) {
370
- for (const [key, value] of Object.entries(params)) {
371
- url.searchParams.set(key, value);
372
- }
373
- }
374
- return url.toString();
375
- }
376
- async function makeRequest(url, accessToken, options) {
377
- const headers = {
378
- Authorization: `Bearer ${accessToken}`,
379
- "Content-Type": "application/json"
380
- };
381
- if (options?.teamId) {
382
- headers["x-team-id"] = options.teamId;
383
- }
384
- return fetch(url, {
385
- method: options?.method ?? "GET",
386
- body: options?.body,
387
- headers
388
- });
389
- }
390
- function sleep2(ms) {
391
- return new Promise((resolve3) => setTimeout(resolve3, ms));
392
- }
393
- async function request(opts) {
394
- const { url, config: config2, method, body } = opts;
395
- const reqOptions = { method, body, teamId: config2.teamId };
396
- let res;
397
- try {
398
- res = await makeRequest(url, config2.accessToken, reqOptions);
399
- } catch {
400
- throw new Error(
401
- "Cannot reach Forge server. Check your connection or try again later."
402
- );
403
- }
404
- if (res.status === 401) {
405
- let refreshed;
406
- try {
407
- refreshed = await refresh(config2.refreshToken);
408
- } catch {
409
- throw new Error(
410
- "Session expired. Run `forge login` to re-authenticate."
411
- );
412
- }
413
- const updatedConfig = {
414
- ...config2,
415
- accessToken: refreshed.accessToken,
416
- expiresAt: refreshed.expiresAt
417
- };
418
- await save(updatedConfig);
419
- try {
420
- res = await makeRequest(url, refreshed.accessToken, reqOptions);
421
- } catch {
422
- throw new Error(
423
- "Cannot reach Forge server. Check your connection or try again later."
424
- );
425
- }
426
- if (res.status === 401) {
427
- throw new Error(
428
- "Session expired. Run `forge login` to re-authenticate."
429
- );
430
- }
431
- }
432
- if (res.status >= 500) {
433
- await sleep2(RETRY_DELAY_MS);
434
- try {
435
- res = await makeRequest(url, config2.accessToken, reqOptions);
436
- } catch {
437
- throw new Error(
438
- "Cannot reach Forge server. Check your connection or try again later."
439
- );
440
- }
441
- if (res.status >= 500) {
442
- throw new Error(
443
- `Forge server error (${res.status}). Try again in a moment, or check https://status.forge.app.`
444
- );
445
- }
446
- }
447
- if (!res.ok) {
448
- throw new ApiError(res.status, friendlyHttpError(res.status));
449
- }
450
- return res.json();
451
- }
452
- async function get(path5, config2, params) {
453
- return request({ url: buildUrl(path5, params), config: config2 });
454
- }
455
- async function post(path5, body, config2) {
456
- return request({
457
- url: buildUrl(path5),
458
- config: config2,
459
- method: "POST",
460
- body: JSON.stringify(body)
461
- });
462
- }
463
- async function patch(path5, body, config2) {
464
- return request({
465
- url: buildUrl(path5),
466
- config: config2,
467
- method: "PATCH",
468
- body: JSON.stringify(body)
469
- });
470
- }
471
-
472
- // src/ui/formatters.ts
473
- import chalk5 from "chalk";
474
-
475
- // src/types/ticket.ts
476
- var AECStatus = /* @__PURE__ */ ((AECStatus2) => {
477
- AECStatus2["DRAFT"] = "draft";
478
- AECStatus2["VALIDATED"] = "validated";
479
- AECStatus2["READY"] = "ready";
480
- AECStatus2["WAITING_FOR_APPROVAL"] = "waiting-for-approval";
481
- AECStatus2["CREATED"] = "created";
482
- AECStatus2["DRIFTED"] = "drifted";
483
- AECStatus2["COMPLETE"] = "complete";
484
- return AECStatus2;
485
- })(AECStatus || {});
486
-
487
- // src/ui/formatters.ts
488
- var STATUS_ICONS = {
489
- ["draft" /* DRAFT */]: "\u2B1C",
490
- ["validated" /* VALIDATED */]: "\u2705",
491
- ["ready" /* READY */]: "\u{1F680}",
492
- ["waiting-for-approval" /* WAITING_FOR_APPROVAL */]: "\u23F3",
493
- ["created" /* CREATED */]: "\u{1F4DD}",
494
- ["drifted" /* DRIFTED */]: "\u26A0\uFE0F ",
495
- ["complete" /* COMPLETE */]: "\u2705"
496
- };
497
- var STATUS_DISPLAY_NAMES = {
498
- ["draft" /* DRAFT */]: "Define",
499
- ["validated" /* VALIDATED */]: "Dev-Refine",
500
- ["ready" /* READY */]: "Execute",
501
- ["waiting-for-approval" /* WAITING_FOR_APPROVAL */]: "Approve",
502
- ["created" /* CREATED */]: "Exported",
503
- ["drifted" /* DRIFTED */]: "Drifted",
504
- ["complete" /* COMPLETE */]: "Done"
505
- };
506
- function statusIcon(status) {
507
- return STATUS_ICONS[status] ?? "\u2753";
508
- }
509
- function formatTicketRow(ticket, selected, memberNames) {
510
- const pointer = selected ? chalk5.cyan("\u25B6") : " ";
511
- const id = chalk5.dim(`[${ticket.id}]`.padEnd(12));
512
- const title = ticket.title.substring(0, 40).padEnd(40);
513
- const displayTitle = selected ? chalk5.bold.cyan(title) : title;
514
- const icon = statusIcon(ticket.status);
515
- const statusText = chalk5.dim(
516
- (STATUS_DISPLAY_NAMES[ticket.status] ?? ticket.status).padEnd(24)
517
- );
518
- const rawAssignee = ticket.assignedTo;
519
- const assigneeName = rawAssignee ? memberNames?.get(rawAssignee) ?? rawAssignee : "";
520
- const assignee = assigneeName ? chalk5.dim(assigneeName) : "";
521
- return `${pointer} ${id} ${displayTitle} ${icon} ${statusText} ${assignee}`;
522
- }
523
-
524
- // src/services/claude.service.ts
525
- import { spawn } from "child_process";
526
- function spawnClaude(action, ticketId) {
527
- const promptName = action === "execute" ? "forge-exec" : "review";
528
- const prompt = `Use the ${promptName} MCP prompt with ticketId "${ticketId}" to ${action} this ticket.`;
529
- const child = process.platform === "win32" ? spawn("cmd", ["/c", "claude", prompt], { stdio: "inherit" }) : spawn("claude", [prompt], { stdio: "inherit" });
530
- return new Promise((resolve3, reject) => {
531
- child.on("error", (err) => {
532
- if (err.code === "ENOENT")
533
- reject(new Error("Claude CLI not found. Install: https://docs.anthropic.com/en/docs/claude-code"));
534
- else reject(err);
535
- });
536
- child.on("close", (code) => resolve3(code ?? 0));
537
- });
538
- }
539
-
540
- // src/services/clipboard.service.ts
541
- import { execSync as execSync2 } from "child_process";
542
- function copyToClipboard(text) {
543
- const platform = process.platform;
544
- if (platform === "darwin") {
545
- execSync2("pbcopy", { input: text });
546
- } else if (platform === "win32") {
547
- execSync2(
548
- 'powershell -NoProfile -Command "$input | Set-Clipboard"',
549
- { input: text }
550
- );
551
- } else {
552
- try {
553
- execSync2("xclip -selection clipboard", { input: text });
554
- } catch {
555
- execSync2("xsel --clipboard --input", { input: text });
556
- }
557
- }
558
- }
559
-
560
- // src/ui/ticket-formatter.ts
561
- function formatTicketPlainText(ticket, memberNames) {
562
- const lines = [];
563
- lines.push(`[${ticket.id}] ${ticket.title}`);
564
- lines.push("-".repeat(72));
565
- const statusName = STATUS_DISPLAY_NAMES[ticket.status] ?? ticket.status;
566
- lines.push(`Status: ${statusName}`);
567
- if (ticket.priority) {
568
- lines.push(`Priority: ${ticket.priority.toUpperCase()}`);
569
- }
570
- if (ticket.assignedTo) {
571
- const name = memberNames?.get(ticket.assignedTo) ?? ticket.assignedTo;
572
- lines.push(`Assignee: ${name}`);
573
- }
574
- if (ticket.description) {
575
- lines.push("", "## Description", ticket.description);
576
- }
577
- if (ticket.problemStatement) {
578
- lines.push("", "## Problem Statement", ticket.problemStatement);
579
- }
580
- if (ticket.solution) {
581
- lines.push("", "## Solution", ticket.solution);
582
- }
583
- if (ticket.acceptanceCriteria.length > 0) {
584
- lines.push("", "## Acceptance Criteria");
585
- ticket.acceptanceCriteria.forEach((ac, i) => {
586
- lines.push(` ${i + 1}. ${ac}`);
587
- });
588
- }
589
- if (ticket.fileChanges && ticket.fileChanges.length > 0) {
590
- lines.push("", "## File Changes");
591
- for (const fc of ticket.fileChanges) {
592
- const notes = fc.notes ? ` - ${fc.notes}` : "";
593
- lines.push(` ${fc.action.toUpperCase()} ${fc.path}${notes}`);
594
- }
595
- }
596
- if (ticket.apiChanges) {
597
- lines.push("", "## API Changes", ticket.apiChanges);
598
- }
599
- if (ticket.testPlan) {
600
- lines.push("", "## Test Plan", ticket.testPlan);
601
- }
602
- if (ticket.designRefs && ticket.designRefs.length > 0) {
603
- lines.push("", "## Design References");
604
- for (const ref of ticket.designRefs) {
605
- lines.push(` - ${ref}`);
606
- }
607
- }
608
- return lines.join("\n");
609
- }
610
-
611
- // src/commands/list.ts
612
- var EXECUTE_VALID = /* @__PURE__ */ new Set(["ready" /* READY */, "validated" /* VALIDATED */]);
613
- var REVIEW_VALID = /* @__PURE__ */ new Set([
614
- "ready" /* READY */,
615
- "validated" /* VALIDATED */,
616
- "created" /* CREATED */,
617
- "drifted" /* DRIFTED */
618
- ]);
619
- var listCommand = new Command3("list").description("List tickets assigned to you").option("--all", "Show all team tickets, not just assigned to me").action(async (options) => {
620
- try {
621
- const config2 = await requireAuth();
622
- const params = {
623
- teamId: config2.teamId
624
- };
625
- if (options.all) {
626
- params.all = "true";
627
- } else {
628
- params.assignedToMe = "true";
629
- }
630
- const [tickets, memberNames] = await Promise.all([
631
- get("/tickets", config2, params),
632
- fetchMemberNames(config2)
633
- ]);
634
- if (!process.stdout.isTTY) {
635
- if (tickets.length === 0) {
636
- const hint = options.all ? "" : " Try `forge list --all` to see all team tickets.";
637
- console.log(`No tickets assigned to you.${hint}`);
638
- } else {
639
- tickets.forEach((t) => {
640
- const assignee = t.assignedTo ? memberNames.get(t.assignedTo) ?? t.assignedTo : "";
641
- console.log(`${t.id} ${t.status} ${t.title} ${assignee}`);
642
- });
643
- }
644
- process.exit(0);
645
- }
646
- await renderInteractiveList(tickets, options.all ?? false, memberNames, config2);
647
- } catch (err) {
648
- console.error(chalk6.red(`Error: ${err.message}`));
649
- process.exit(2);
650
- }
651
- });
652
- async function fetchMemberNames(config2) {
653
- try {
654
- const res = await get(
655
- `/teams/${config2.teamId}/members`,
656
- config2
657
- );
658
- const map = /* @__PURE__ */ new Map();
659
- for (const m of res.members) {
660
- if (m.displayName) map.set(m.userId, m.displayName);
661
- }
662
- return map;
663
- } catch {
664
- return /* @__PURE__ */ new Map();
665
- }
666
- }
667
- var DIVIDER = chalk6.dim("\u2500".repeat(72));
668
- var PRIORITY_COLOR = {
669
- urgent: chalk6.red,
670
- high: chalk6.yellow,
671
- medium: chalk6.white,
672
- low: chalk6.dim
673
- };
674
- async function renderInteractiveList(tickets, showAll, memberNames, config2) {
675
- if (tickets.length === 0) {
676
- const hint = showAll ? "" : " Try `forge list --all` to see all team tickets.";
677
- console.log(chalk6.dim(`No tickets found.${hint}`));
678
- process.exit(0);
679
- }
680
- let selected = 0;
681
- let screen = "list";
682
- let detailTicket = null;
683
- let loading = false;
684
- function renderList() {
685
- process.stdout.write("\x1B[2J\x1B[H");
686
- const label = showAll ? "All Team Tickets" : "My Tickets";
687
- console.log(chalk6.bold(`forge \u2014 ${label} (${tickets.length})`));
688
- console.log(DIVIDER);
689
- tickets.forEach((ticket, i) => {
690
- console.log(formatTicketRow(ticket, i === selected, memberNames));
691
- });
692
- console.log(DIVIDER);
693
- console.log(chalk6.dim("\u2191\u2193 navigate Enter details e execute r review q quit"));
694
- }
695
- function renderDetail() {
696
- if (!detailTicket) return;
697
- process.stdout.write("\x1B[2J\x1B[H");
698
- const icon = statusIcon(detailTicket.status);
699
- const statusName = STATUS_DISPLAY_NAMES[detailTicket.status] ?? detailTicket.status;
700
- console.log(chalk6.bold(`[${detailTicket.id}] ${detailTicket.title}`));
701
- console.log(DIVIDER);
702
- console.log(`${chalk6.dim("Status: ")} ${icon} ${chalk6.bold(statusName)}`);
703
- if (detailTicket.priority) {
704
- const colorFn = PRIORITY_COLOR[detailTicket.priority] ?? chalk6.white;
705
- console.log(`${chalk6.dim("Priority: ")} ${colorFn(detailTicket.priority.toUpperCase())}`);
706
- }
707
- if (detailTicket.assignedTo) {
708
- const name = memberNames.get(detailTicket.assignedTo) ?? detailTicket.assignedTo;
709
- console.log(`${chalk6.dim("Assignee: ")} ${name}`);
710
- }
711
- if (detailTicket.description) {
712
- console.log();
713
- console.log(chalk6.bold.underline("Description"));
714
- console.log(detailTicket.description);
715
- }
716
- if (detailTicket.acceptanceCriteria.length > 0) {
717
- console.log();
718
- console.log(chalk6.bold.underline("Acceptance Criteria"));
719
- detailTicket.acceptanceCriteria.forEach((ac, i) => {
720
- console.log(` ${chalk6.dim(`${i + 1}.`)} ${ac}`);
721
- });
722
- }
723
- console.log();
724
- console.log(DIVIDER);
725
- const actions = [];
726
- if (EXECUTE_VALID.has(detailTicket.status)) actions.push("e execute");
727
- if (REVIEW_VALID.has(detailTicket.status)) actions.push("r review");
728
- actions.push("c copy", "Esc back", "q quit");
729
- console.log(chalk6.dim(actions.join(" ")));
730
- }
731
- function render() {
732
- if (screen === "list") renderList();
733
- else renderDetail();
734
- }
735
- function cleanup() {
736
- process.stdin.setRawMode(false);
737
- process.stdin.pause();
738
- process.stdout.write("\x1B[2J\x1B[H");
739
- }
740
- async function launchAction(action, ticket) {
741
- const validSet = action === "execute" ? EXECUTE_VALID : REVIEW_VALID;
742
- if (!validSet.has(ticket.status)) {
743
- const validNames = [...validSet].map((s) => STATUS_DISPLAY_NAMES[s] ?? s).join(", ");
744
- process.stdout.write(
745
- chalk6.yellow(`
746
- Cannot ${action}: status "${STATUS_DISPLAY_NAMES[ticket.status] ?? ticket.status}" is not valid. Valid: ${validNames}
747
- `)
748
- );
749
- await new Promise((r) => setTimeout(r, 1500));
750
- render();
751
- return;
752
- }
753
- cleanup();
754
- process.stdin.removeListener("data", onData);
755
- try {
756
- await patch(`/tickets/${ticket.id}`, { assignedTo: config2.userId }, config2);
757
- } catch {
758
- process.stderr.write(chalk6.dim(" Warning: Could not auto-assign ticket.\n"));
759
- }
760
- const icon = statusIcon(ticket.status);
761
- process.stderr.write(
762
- `
763
- ${icon} ${action === "execute" ? "Executing" : "Reviewing"} [${ticket.id}] ${ticket.title} \u2014 launching Claude...
764
-
765
- `
766
- );
767
- try {
768
- const exitCode = await spawnClaude(action, ticket.id);
769
- process.exit(exitCode);
770
- } catch (err) {
771
- console.error(chalk6.red(`Error: ${err.message}`));
772
- process.exit(1);
773
- }
774
- }
775
- async function onData(key) {
776
- if (loading) return;
777
- if (screen === "list") {
778
- if (key === "\x1B[A") {
779
- selected = Math.max(0, selected - 1);
780
- render();
781
- } else if (key === "\x1B[B") {
782
- selected = Math.min(tickets.length - 1, selected + 1);
783
- render();
784
- } else if (key === "\r" || key === "\n") {
785
- loading = true;
786
- const ticket = tickets[selected];
787
- process.stdout.write("\x1B[2J\x1B[H");
788
- console.log(chalk6.dim(`Loading ${ticket.id}...`));
789
- try {
790
- detailTicket = await get(
791
- `/tickets/${ticket.id}`,
792
- config2
793
- );
794
- screen = "detail";
795
- } catch (err) {
796
- process.stdout.write("\x1B[2J\x1B[H");
797
- console.log(chalk6.red(`Error loading ticket: ${err.message}`));
798
- await new Promise((r) => setTimeout(r, 1500));
799
- }
800
- loading = false;
801
- render();
802
- } else if (key === "e" || key === "E") {
803
- loading = true;
804
- await launchAction("execute", tickets[selected]);
805
- loading = false;
806
- } else if (key === "r" || key === "R") {
807
- loading = true;
808
- await launchAction("review", tickets[selected]);
809
- loading = false;
810
- } else if (key === "q" || key === "Q" || key === "") {
811
- cleanup();
812
- process.exit(0);
813
- }
814
- } else if (screen === "detail") {
815
- if (key === "\x1B" && key.length === 1) {
816
- screen = "list";
817
- detailTicket = null;
818
- render();
819
- } else if (key === "\x7F" || key === "\b") {
820
- screen = "list";
821
- detailTicket = null;
822
- render();
823
- } else if (key === "e" || key === "E") {
824
- if (detailTicket) {
825
- loading = true;
826
- await launchAction("execute", detailTicket);
827
- loading = false;
828
- }
829
- } else if (key === "r" || key === "R") {
830
- if (detailTicket) {
831
- loading = true;
832
- await launchAction("review", detailTicket);
833
- loading = false;
834
- }
835
- } else if (key === "c" || key === "C") {
836
- if (detailTicket) {
837
- const plain = formatTicketPlainText(detailTicket, memberNames);
838
- try {
839
- copyToClipboard(plain);
840
- process.stdout.write(chalk6.green("\n Copied to clipboard!\n"));
841
- } catch {
842
- process.stdout.write(
843
- chalk6.yellow("\n Could not copy \u2014 install xclip or xsel on Linux.\n")
844
- );
845
- }
846
- await new Promise((r) => setTimeout(r, 800));
847
- render();
848
- }
849
- } else if (key === "q" || key === "Q" || key === "") {
850
- cleanup();
851
- process.exit(0);
852
- }
853
- }
854
- }
855
- process.stdin.setRawMode(true);
856
- process.stdin.resume();
857
- process.stdin.setEncoding("utf-8");
858
- render();
859
- return new Promise(() => {
860
- process.stdin.on("data", onData);
861
- process.once("SIGINT", () => {
862
- cleanup();
863
- process.exit(0);
864
- });
865
- });
866
- }
867
-
868
- // src/commands/show.ts
869
- import { Command as Command4 } from "commander";
870
- import chalk8 from "chalk";
871
-
872
- // src/ui/pager.ts
873
- import chalk7 from "chalk";
874
- var DIVIDER2 = chalk7.dim("\u2500".repeat(72));
875
- var PRIORITY_COLOR2 = {
876
- urgent: chalk7.red,
877
- high: chalk7.yellow,
878
- medium: chalk7.white,
879
- low: chalk7.dim
880
- };
881
- function printTicketDetail(ticket) {
882
- console.log();
883
- console.log(chalk7.bold(`[${ticket.id}] ${ticket.title}`));
884
- console.log(DIVIDER2);
885
- const icon = statusIcon(ticket.status);
886
- const statusText = STATUS_DISPLAY_NAMES[ticket.status] ?? ticket.status;
887
- console.log(`${chalk7.dim("Status: ")} ${icon} ${chalk7.bold(statusText)}`);
888
- if (ticket.priority) {
889
- const colorFn = PRIORITY_COLOR2[ticket.priority] ?? chalk7.white;
890
- console.log(
891
- `${chalk7.dim("Priority: ")} ${colorFn(ticket.priority.toUpperCase())}`
892
- );
893
- }
894
- const assignee = ticket.assignedTo;
895
- if (assignee) {
896
- console.log(`${chalk7.dim("Assignee: ")} ${assignee}`);
897
- }
898
- console.log(
899
- `${chalk7.dim("Created: ")} ${new Date(ticket.createdAt).toLocaleString()}`
900
- );
901
- console.log(
902
- `${chalk7.dim("Updated: ")} ${new Date(ticket.updatedAt).toLocaleString()}`
903
- );
904
- if (ticket.description) {
905
- console.log();
906
- console.log(chalk7.bold.underline("Description"));
907
- console.log(ticket.description);
908
- }
909
- if (ticket.acceptanceCriteria.length > 0) {
910
- console.log();
911
- console.log(chalk7.bold.underline("Acceptance Criteria"));
912
- ticket.acceptanceCriteria.forEach((ac, i) => {
913
- console.log(` ${chalk7.dim(`${i + 1}.`)} ${ac}`);
914
- });
915
- }
916
- console.log();
917
- console.log(DIVIDER2);
918
- console.log(chalk7.dim(`forge review ${ticket.id} # start AI-assisted review`));
919
- console.log(
920
- chalk7.dim(`forge execute ${ticket.id} # start AI-assisted execution`)
921
- );
922
- console.log();
923
- }
924
-
925
- // src/commands/show.ts
926
- var showCommand = new Command4("show").description("Show full details of a ticket").argument("<ticketId>", "The ticket ID to display").action(async (ticketId) => {
927
- try {
928
- const config2 = await requireAuth();
929
- let ticket;
930
- try {
931
- ticket = await get(
932
- `/tickets/${ticketId}`,
933
- config2
934
- );
935
- } catch (err) {
936
- if (err instanceof ApiError && err.statusCode === 404) {
937
- console.error(chalk8.red(`Ticket not found: ${ticketId}`));
938
- process.exit(1);
939
- }
940
- throw err;
941
- }
942
- printTicketDetail(ticket);
943
- process.exit(0);
944
- } catch (err) {
945
- console.error(chalk8.red(`Error: ${err.message}`));
946
- process.exit(2);
947
- }
948
- });
949
-
950
- // src/commands/review.ts
951
- import { Command as Command5 } from "commander";
952
- import chalk9 from "chalk";
953
- var REVIEW_VALID_STATUSES = /* @__PURE__ */ new Set([
954
- "ready" /* READY */,
955
- "validated" /* VALIDATED */,
956
- "created" /* CREATED */,
957
- "drifted" /* DRIFTED */
958
- ]);
959
- var reviewCommand = new Command5("review").description("Start an AI-assisted review session for a ticket").argument("<ticketId>", "The ticket ID to review").action(async (ticketId) => {
960
- try {
961
- const config2 = await requireAuth();
962
- let ticket;
963
- try {
964
- ticket = await get(
965
- `/tickets/${ticketId}`,
966
- config2
967
- );
968
- } catch (err) {
969
- if (err instanceof ApiError && err.statusCode === 404) {
970
- console.error(chalk9.red(`Ticket not found: ${ticketId}`));
971
- process.exit(1);
972
- }
973
- throw err;
974
- }
975
- if (!REVIEW_VALID_STATUSES.has(ticket.status)) {
976
- console.error(
977
- chalk9.yellow(
978
- `Ticket ${ticketId} has status ${ticket.status} which is not ready for review.`
979
- )
980
- );
981
- console.error(
982
- chalk9.dim(
983
- `Valid statuses for review: READY, VALIDATED, CREATED, DRIFTED`
984
- )
985
- );
986
- process.exit(1);
987
- }
988
- const icon = statusIcon(ticket.status);
989
- try {
990
- await patch(`/tickets/${ticketId}`, { assignedTo: config2.userId }, config2);
991
- } catch {
992
- process.stderr.write(chalk9.dim(" Warning: Could not auto-assign ticket.\n"));
993
- }
994
- process.stderr.write(
995
- `
996
- ${icon} Reviewing [${ticket.id}] ${ticket.title} \u2014 launching Claude...
997
-
998
- `
999
- );
1000
- const exitCode = await spawnClaude("review", ticket.id);
1001
- process.exit(exitCode);
1002
- } catch (err) {
1003
- console.error(chalk9.red(`Error: ${err.message}`));
1004
- process.exit(2);
1005
- }
1006
- });
1007
-
1008
- // src/commands/execute.ts
1009
- import { Command as Command6 } from "commander";
1010
- import chalk10 from "chalk";
1011
- var EXECUTE_VALID_STATUSES = /* @__PURE__ */ new Set([
1012
- "ready" /* READY */,
1013
- "validated" /* VALIDATED */
1014
- ]);
1015
- var executeCommand = new Command6("execute").description("Start an AI-assisted execution session for a ticket").argument("<ticketId>", "The ticket ID to execute").action(async (ticketId) => {
1016
- try {
1017
- const config2 = await requireAuth();
1018
- let ticket;
1019
- try {
1020
- ticket = await get(
1021
- `/tickets/${ticketId}`,
1022
- config2
1023
- );
1024
- } catch (err) {
1025
- if (err instanceof ApiError && err.statusCode === 404) {
1026
- console.error(chalk10.red(`Ticket not found: ${ticketId}`));
1027
- process.exit(1);
1028
- }
1029
- throw err;
1030
- }
1031
- if (!EXECUTE_VALID_STATUSES.has(ticket.status)) {
1032
- console.error(
1033
- chalk10.yellow(
1034
- `Ticket ${ticketId} has status ${ticket.status} which is not ready for execution.`
1035
- )
1036
- );
1037
- console.error(
1038
- chalk10.dim(`Valid statuses for execute: READY, VALIDATED`)
1039
- );
1040
- console.error(
1041
- chalk10.dim(
1042
- `Run \`forge review ${ticketId}\` first to prepare the ticket.`
1043
- )
1044
- );
1045
- process.exit(1);
1046
- }
1047
- const icon = statusIcon(ticket.status);
1048
- try {
1049
- await patch(`/tickets/${ticketId}`, { assignedTo: config2.userId }, config2);
1050
- } catch {
1051
- process.stderr.write(chalk10.dim(" Warning: Could not auto-assign ticket.\n"));
1052
- }
1053
- process.stderr.write(
1054
- `
1055
- ${icon} Executing [${ticket.id}] ${ticket.title} \u2014 launching Claude...
1056
-
1057
- `
1058
- );
1059
- const exitCode = await spawnClaude("execute", ticket.id);
1060
- process.exit(exitCode);
1061
- } catch (err) {
1062
- console.error(chalk10.red(`Error: ${err.message}`));
1063
- process.exit(2);
1064
- }
1065
- });
1066
-
1067
- // src/commands/mcp.ts
1068
- import { Command as Command7 } from "commander";
1069
- import chalk11 from "chalk";
1070
-
1071
- // src/mcp/server.ts
1072
- import { Server } from "@modelcontextprotocol/sdk/server/index.js";
1073
- import { StdioServerTransport } from "@modelcontextprotocol/sdk/server/stdio.js";
1074
- import {
1075
- ListToolsRequestSchema,
1076
- ListPromptsRequestSchema,
1077
- GetPromptRequestSchema,
1078
- CallToolRequestSchema
1079
- } from "@modelcontextprotocol/sdk/types.js";
1080
-
1081
- // src/mcp/tools/get-ticket-context.ts
1082
- function formatTicket(t) {
1083
- const lines = [
1084
- `Ticket: ${t.id}`,
1085
- `Title: ${t.title}`,
1086
- `Status: ${t.status}`
1087
- ];
1088
- if (t.priority) lines.push(`Priority: ${t.priority}`);
1089
- if (t.assignedTo) lines.push(`Assigned to: ${t.assignedTo}`);
1090
- lines.push("");
1091
- if (t.description) lines.push(`Description:
1092
- ${t.description}
1093
- `);
1094
- if (t.problemStatement) lines.push(`Problem Statement:
1095
- ${t.problemStatement}
1096
- `);
1097
- if (t.solution) lines.push(`Solution:
1098
- ${t.solution}
1099
- `);
1100
- if (t.acceptanceCriteria?.length) {
1101
- lines.push("Acceptance Criteria:");
1102
- t.acceptanceCriteria.forEach((ac, i) => lines.push(` ${i + 1}. ${ac}`));
1103
- lines.push("");
1104
- }
1105
- if (t.fileChanges?.length) {
1106
- lines.push("File Changes:");
1107
- t.fileChanges.forEach((fc) => {
1108
- const note = fc.notes ? ` \u2014 ${fc.notes}` : "";
1109
- lines.push(` [${fc.action}] ${fc.path}${note}`);
1110
- });
1111
- lines.push("");
1112
- }
1113
- if (t.apiChanges) lines.push(`API Changes:
1114
- ${t.apiChanges}
1115
- `);
1116
- if (t.testPlan) lines.push(`Test Plan:
1117
- ${t.testPlan}
1118
- `);
1119
- if (t.reviewSession?.qaItems?.length) {
1120
- lines.push("Review Q&A:");
1121
- t.reviewSession.qaItems.forEach((qa, i) => {
1122
- lines.push(` Q${i + 1}: ${qa.question}`);
1123
- lines.push(` A${i + 1}: ${qa.answer}`);
1124
- });
1125
- lines.push("");
1126
- }
1127
- return lines.join("\n");
1128
- }
1129
- var getTicketContextToolDefinition = {
1130
- name: "get_ticket_context",
1131
- description: "Fetch the full specification for a Forge ticket including problem statement, solution, acceptance criteria, and file changes. Use this to understand what to implement.",
1132
- inputSchema: {
1133
- type: "object",
1134
- properties: {
1135
- ticketId: {
1136
- type: "string",
1137
- description: "The ticket ID to fetch (e.g., T-001)"
1138
- }
1139
- },
1140
- required: ["ticketId"]
1141
- }
1142
- };
1143
- async function handleGetTicketContext(args, config2) {
1144
- const ticketId = args["ticketId"];
1145
- if (!ticketId || typeof ticketId !== "string" || ticketId.trim() === "") {
1146
- return {
1147
- content: [{ type: "text", text: "Missing required argument: ticketId" }],
1148
- isError: true
1149
- };
1150
- }
1151
- try {
1152
- const ticket = await get(
1153
- `/tickets/${ticketId.trim()}`,
1154
- config2
1155
- );
1156
- return {
1157
- content: [{ type: "text", text: formatTicket(ticket) }]
1158
- };
1159
- } catch (err) {
1160
- const message = err.message ?? String(err);
1161
- if (message.includes("404")) {
1162
- return {
1163
- content: [{ type: "text", text: `Ticket not found: ${ticketId}` }],
1164
- isError: true
1165
- };
1166
- }
1167
- return {
1168
- content: [{ type: "text", text: message }],
1169
- isError: true
1170
- };
1171
- }
1172
- }
1173
-
1174
- // src/mcp/tools/get-file-changes.ts
1175
- var getFileChangesToolDefinition = {
1176
- name: "get_file_changes",
1177
- description: "Fetch the list of files to create, modify, or delete for a Forge ticket. Returns a JSON array of file change objects with path, action, and optional notes. Use this to understand what files need to be touched during implementation.",
1178
- inputSchema: {
1179
- type: "object",
1180
- properties: {
1181
- ticketId: {
1182
- type: "string",
1183
- description: "The ticket ID to fetch file changes for (e.g., T-001)"
1184
- }
1185
- },
1186
- required: ["ticketId"]
1187
- }
1188
- };
1189
- async function handleGetFileChanges(args, config2) {
1190
- const ticketId = args["ticketId"];
1191
- if (!ticketId || typeof ticketId !== "string" || ticketId.trim() === "") {
1192
- return {
1193
- content: [{ type: "text", text: "Missing required argument: ticketId" }],
1194
- isError: true
1195
- };
1196
- }
1197
- try {
1198
- const ticket = await get(
1199
- `/tickets/${ticketId.trim()}`,
1200
- config2
1201
- );
1202
- const changes = ticket.fileChanges ?? [];
1203
- if (changes.length === 0) {
1204
- return {
1205
- content: [{ type: "text", text: "No file changes specified for this ticket." }]
1206
- };
1207
- }
1208
- const lines = changes.map((fc) => {
1209
- const note = fc.notes ? ` \u2014 ${fc.notes}` : "";
1210
- return `[${fc.action}] ${fc.path}${note}`;
1211
- });
1212
- return {
1213
- content: [{ type: "text", text: `File changes for ${ticketId}:
1214
- ${lines.join("\n")}` }]
1215
- };
1216
- } catch (err) {
1217
- const message = err.message ?? String(err);
1218
- if (message.includes("404")) {
1219
- return {
1220
- content: [{ type: "text", text: `Ticket not found: ${ticketId}` }],
1221
- isError: true
1222
- };
1223
- }
1224
- return {
1225
- content: [{ type: "text", text: message }],
1226
- isError: true
1227
- };
1228
- }
1229
- }
1230
-
1231
- // src/mcp/tools/get-repository-context.ts
1232
- import * as path4 from "path";
1233
- import * as fs3 from "fs";
1234
-
1235
- // src/services/git.service.ts
1236
- import simpleGit from "simple-git";
1237
- var GitService = class {
1238
- constructor(repoPath) {
1239
- this.repoPath = repoPath;
1240
- }
1241
- async getBranch() {
1242
- const result = await simpleGit(this.repoPath).branchLocal();
1243
- return result.current;
1244
- }
1245
- async getStatus() {
1246
- const result = await simpleGit(this.repoPath).status();
1247
- return {
1248
- modified: result.modified,
1249
- untracked: result.not_added,
1250
- // simple-git uses 'not_added' for untracked files
1251
- staged: result.staged
1252
- };
1253
- }
1254
- async getFileTree() {
1255
- const raw = await simpleGit(this.repoPath).raw([
1256
- "ls-tree",
1257
- "--name-only",
1258
- "-r",
1259
- "HEAD"
1260
- ]);
1261
- const lines = raw.split("\n").filter(Boolean);
1262
- return lines.slice(0, 200).join("\n");
1263
- }
1264
- };
1265
-
1266
- // src/mcp/tools/get-repository-context.ts
1267
- var getRepositoryContextToolDefinition = {
1268
- name: "get_repository_context",
1269
- description: "Fetch the current git repository context including active branch, working directory status (modified/untracked/staged files), and a file tree snapshot. Use this to understand the repository state before implementing changes.",
1270
- inputSchema: {
1271
- type: "object",
1272
- properties: {
1273
- path: {
1274
- type: "string",
1275
- description: "Absolute path to the repository root. Defaults to the current working directory."
1276
- }
1277
- },
1278
- required: []
1279
- }
1280
- };
1281
- function isNonGitError(message) {
1282
- const lower = message.toLowerCase();
1283
- return lower.includes("not a git") || lower.includes("enoent") || lower.includes("fatal") || lower.includes("not a git repository");
1284
- }
1285
- function validatePath(requested) {
1286
- const cwd = process.cwd();
1287
- let resolved;
1288
- try {
1289
- resolved = fs3.realpathSync(path4.resolve(cwd, requested));
1290
- } catch {
1291
- resolved = path4.resolve(cwd, requested);
1292
- }
1293
- const normalizedCwd = path4.resolve(cwd);
1294
- if (resolved !== normalizedCwd && !resolved.startsWith(normalizedCwd + path4.sep)) {
1295
- return null;
1296
- }
1297
- return resolved;
1298
- }
1299
- async function handleGetRepositoryContext(args, config2) {
1300
- const pathArg = args["path"];
1301
- let resolvedPath;
1302
- if (typeof pathArg === "string" && pathArg.trim() !== "") {
1303
- const validated = validatePath(pathArg.trim());
1304
- if (validated === null) {
1305
- return {
1306
- content: [
1307
- {
1308
- type: "text",
1309
- text: "Path must be within the current working directory"
1310
- }
1311
- ],
1312
- isError: true
1313
- };
1314
- }
1315
- resolvedPath = validated;
1316
- } else {
1317
- resolvedPath = process.cwd();
1318
- }
1319
- const git = new GitService(resolvedPath);
1320
- try {
1321
- const [branch, status, fileTree] = await Promise.all([
1322
- git.getBranch(),
1323
- git.getStatus(),
1324
- git.getFileTree()
1325
- ]);
1326
- const statusLines = [];
1327
- const s = status;
1328
- if (s.modified?.length) statusLines.push(`Modified: ${s.modified.join(", ")}`);
1329
- if (s.not_added?.length) statusLines.push(`Untracked: ${s.not_added.join(", ")}`);
1330
- if (s.staged?.length) statusLines.push(`Staged: ${s.staged.join(", ")}`);
1331
- const statusText = statusLines.length > 0 ? statusLines.join("\n") : "Clean working tree";
1332
- return {
1333
- content: [
1334
- {
1335
- type: "text",
1336
- text: `Repository: ${resolvedPath}
1337
- Branch: ${branch}
1338
-
1339
- Status:
1340
- ${statusText}
1341
-
1342
- Files:
1343
- ${fileTree}`
1344
- }
1345
- ]
1346
- };
1347
- } catch (err) {
1348
- const message = err.message ?? String(err);
1349
- if (isNonGitError(message)) {
1350
- return {
1351
- content: [
1352
- {
1353
- type: "text",
1354
- text: "Not a git repository"
1355
- }
1356
- ],
1357
- isError: true
1358
- };
1359
- }
1360
- return {
1361
- content: [{ type: "text", text: message }],
1362
- isError: true
1363
- };
1364
- }
1365
- }
1366
-
1367
- // src/mcp/tools/update-ticket-status.ts
1368
- var updateTicketStatusToolDefinition = {
1369
- name: "update_ticket_status",
1370
- description: "Update the status of a Forge ticket. Call this after completing implementation to mark the ticket as Exported (created), or to transition it to another valid status (Define=draft, Dev-Refine=validated, Approve=waiting-for-approval, Execute=ready, Done=complete).",
1371
- inputSchema: {
1372
- type: "object",
1373
- properties: {
1374
- ticketId: {
1375
- type: "string",
1376
- description: 'The ticket ID to update (e.g., "T-001")'
1377
- },
1378
- status: {
1379
- type: "string",
1380
- description: `New status value. Must be one of: ${Object.values(AECStatus).join(", ")}`
1381
- }
1382
- },
1383
- required: ["ticketId", "status"]
1384
- }
1385
- };
1386
- async function handleUpdateTicketStatus(args, config2) {
1387
- const ticketId = args["ticketId"];
1388
- const status = args["status"];
1389
- if (!ticketId || typeof ticketId !== "string" || ticketId.trim() === "") {
1390
- return {
1391
- content: [{ type: "text", text: "Missing required argument: ticketId" }],
1392
- isError: true
1393
- };
1394
- }
1395
- const validStatuses = Object.values(AECStatus);
1396
- if (!status || typeof status !== "string" || !validStatuses.includes(status)) {
1397
- return {
1398
- content: [
1399
- {
1400
- type: "text",
1401
- text: `Invalid status: ${status}. Must be one of: ${validStatuses.join(", ")}`
1402
- }
1403
- ],
1404
- isError: true
1405
- };
1406
- }
1407
- try {
1408
- const result = await patch(
1409
- `/tickets/${ticketId.trim()}`,
1410
- { status },
1411
- config2
1412
- );
1413
- const displayStatus = result.status.replace(/-/g, " ");
1414
- return {
1415
- content: [
1416
- {
1417
- type: "text",
1418
- text: `Ticket ${ticketId.trim()} status updated to "${displayStatus}".`
1419
- }
1420
- ]
1421
- };
1422
- } catch (err) {
1423
- const message = err.message ?? String(err);
1424
- if (message.includes("404")) {
1425
- return {
1426
- content: [{ type: "text", text: `Ticket not found: ${ticketId}` }],
1427
- isError: true
1428
- };
1429
- }
1430
- return {
1431
- content: [{ type: "text", text: message }],
1432
- isError: true
1433
- };
1434
- }
1435
- }
1436
-
1437
- // src/mcp/tools/submit-review-session.ts
1438
- var submitReviewSessionToolDefinition = {
1439
- name: "submit_review_session",
1440
- description: "Submit the Q&A pairs collected during a forge review session back to Forge. Call this after the developer has answered all clarifying questions. The ticket status will transition to WAITING_FOR_APPROVAL and the PM will see the answers in the web UI.",
1441
- inputSchema: {
1442
- type: "object",
1443
- properties: {
1444
- ticketId: {
1445
- type: "string",
1446
- description: 'The ticket ID being reviewed (e.g., "aec_abc123")'
1447
- },
1448
- qaItems: {
1449
- type: "array",
1450
- description: "The Q&A pairs collected during the review session",
1451
- items: {
1452
- type: "object",
1453
- properties: {
1454
- question: {
1455
- type: "string",
1456
- description: "The clarifying question that was asked"
1457
- },
1458
- answer: {
1459
- type: "string",
1460
- description: "The developer's answer to the question"
1461
- }
1462
- },
1463
- required: ["question", "answer"]
1464
- },
1465
- minItems: 1
1466
- }
1467
- },
1468
- required: ["ticketId", "qaItems"]
1469
- }
1470
- };
1471
- async function handleSubmitReviewSession(args, config2) {
1472
- const ticketId = args["ticketId"];
1473
- const qaItems = args["qaItems"];
1474
- if (!ticketId || typeof ticketId !== "string" || ticketId.trim() === "") {
1475
- return {
1476
- content: [{ type: "text", text: "Missing required argument: ticketId" }],
1477
- isError: true
1478
- };
1479
- }
1480
- if (!qaItems || !Array.isArray(qaItems) || qaItems.length === 0) {
1481
- return {
1482
- content: [{ type: "text", text: "qaItems must be a non-empty array of {question, answer} objects" }],
1483
- isError: true
1484
- };
1485
- }
1486
- for (const item of qaItems) {
1487
- if (typeof item !== "object" || item === null || typeof item.question !== "string" || typeof item.answer !== "string") {
1488
- return {
1489
- content: [{ type: "text", text: "Each qaItem must have string fields: question and answer" }],
1490
- isError: true
1491
- };
1492
- }
1493
- }
1494
- const validatedItems = qaItems.map((item) => ({
1495
- question: item.question,
1496
- answer: item.answer
1497
- }));
1498
- try {
1499
- const result = await post(
1500
- `/tickets/${ticketId.trim()}/review-session`,
1501
- { qaItems: validatedItems },
1502
- config2
1503
- );
1504
- const displayStatus = result.status.replace(/-/g, " ");
1505
- return {
1506
- content: [
1507
- {
1508
- type: "text",
1509
- text: `Review session submitted for ${result.ticketId}. Status is now "${displayStatus}". The PM will see your answers and can re-bake the ticket.`
1510
- }
1511
- ]
1512
- };
1513
- } catch (err) {
1514
- const message = err.message ?? String(err);
1515
- if (message.includes("404")) {
1516
- return {
1517
- content: [{ type: "text", text: `Ticket not found: ${ticketId}` }],
1518
- isError: true
1519
- };
1520
- }
1521
- return {
1522
- content: [{ type: "text", text: message }],
1523
- isError: true
1524
- };
1525
- }
1526
- }
1527
-
1528
- // src/mcp/tools/list-tickets.ts
1529
- var listTicketsToolDefinition = {
1530
- name: "list_tickets",
1531
- description: "List all Forge tickets for the current team. Returns ticket IDs, titles, statuses, priorities, and assignees. Use this to find ticket IDs before calling get_ticket_context or review prompts.",
1532
- inputSchema: {
1533
- type: "object",
1534
- properties: {
1535
- filter: {
1536
- type: "string",
1537
- description: 'Show "all" team tickets (default) or "mine" for only tickets assigned to me',
1538
- enum: ["all", "mine"]
1539
- }
1540
- },
1541
- required: []
1542
- }
1543
- };
1544
- async function handleListTickets(args, config2) {
1545
- const filter = typeof args.filter === "string" ? args.filter.trim() : "all";
1546
- const params = {
1547
- teamId: config2.teamId
1548
- };
1549
- if (filter === "all") {
1550
- params.all = "true";
1551
- } else {
1552
- params.assignedToMe = "true";
1553
- }
1554
- try {
1555
- const tickets = await get("/tickets", config2, params);
1556
- if (tickets.length === 0) {
1557
- return {
1558
- content: [{ type: "text", text: `No tickets found (filter: ${filter}).` }]
1559
- };
1560
- }
1561
- const lines = tickets.map((t) => {
1562
- const status = t.status.replace(/-/g, " ");
1563
- const priority = t.priority ? ` [${t.priority}]` : "";
1564
- const assignee = t.assignedTo ? ` (${t.assignedTo})` : "";
1565
- return `${t.id} ${status.padEnd(20)} ${t.title}${priority}${assignee}`;
1566
- });
1567
- const header = `${tickets.length} ticket${tickets.length === 1 ? "" : "s"} (filter: ${filter}):
1568
- `;
1569
- const hint = "\n\nTo review a ticket: /forge:review <ticketId>\nTo execute a ticket: /forge:exec <ticketId>";
1570
- return {
1571
- content: [{ type: "text", text: header + lines.join("\n") + hint }]
1572
- };
1573
- } catch (err) {
1574
- const message = err.message ?? String(err);
1575
- return {
1576
- content: [{ type: "text", text: `Failed to list tickets: ${message}` }],
1577
- isError: true
1578
- };
1579
- }
1580
- }
1581
-
1582
- // src/agents/dev-executor.md
1583
- var dev_executor_default = "# Forge Dev Executor Agent\r\n\r\n## Persona\r\n\r\nYou are the **Forge Dev Executor** \u2014 an expert software engineer embedded inside Claude Code with direct access to the Forge ticket management system via MCP tools. Your purpose is to implement Forge tickets with precision, producing code that exactly matches the ticket's acceptance criteria and file change specifications.\r\n\r\nYou operate within a developer's repository, with full access to:\r\n- The complete ticket specification (via `get_ticket_context`)\r\n- Repository structure and git status (via `get_repository_context`)\r\n- File change targets (via `get_file_changes`)\r\n- Ticket status mutation (via `update_ticket_status`)\r\n\r\nYou are not a general-purpose assistant. You are a focused implementation agent. Every decision you make must be traceable to the ticket.\r\n\r\n---\r\n\r\n## Principles\r\n\r\n### 1. Spec-Driven Implementation\r\n- The ticket's acceptance criteria (ACs) are your primary contract. Implement each AC completely.\r\n- If the ticket includes a `<fileChanges>` list, those are your implementation targets. Do not create or modify files outside that list without explicit reasoning.\r\n- If a file change conflicts with an existing pattern, note the conflict but follow the ticket spec unless it would break the build.\r\n\r\n### 2. No Scope Creep\r\n- Do not add features, refactors, or improvements not specified in the ticket.\r\n- If you notice something that could be improved nearby, add a comment `// TODO: <note>` \u2014 do not change it.\r\n- \"While I'm in here\" is not a valid reason to touch additional code.\r\n\r\n### 3. Test-Alongside\r\n- Write or update tests as you implement each file. Do not defer testing to the end.\r\n- Tests should verify ACs directly, not implementation details.\r\n- Follow the project's existing test framework and patterns (check existing `__tests__/` directories).\r\n\r\n### 4. TypeScript Strict\r\n- All new code must pass `tsc --noEmit` without errors.\r\n- Avoid `any` \u2014 use `unknown` with narrowing, or define the type explicitly.\r\n- If a third-party type is missing, add a minimal declaration rather than casting to `any`.\r\n\r\n### 5. Error Handling at Boundaries\r\n- Validate inputs at function entry (especially for MCP tool handlers).\r\n- Return structured errors (never throw unhandled exceptions in tool handlers).\r\n- Network/API errors should surface as readable messages, not raw stack traces.\r\n\r\n### 6. Commit-Ready Output\r\n- Every file you touch should be in a state that could be committed immediately.\r\n- No debug logs left in production code (`console.log` \u2192 use `process.stderr.write` for diagnostic output in CLI).\r\n- No commented-out code blocks.\r\n\r\n---\r\n\r\n## Process\r\n\r\n### Step 1 \u2014 Read the Ticket\r\nYou have received the ticket in `<ticket_context>` XML. Read it fully before writing any code:\r\n1. Note the ticket `id`, `status`, `title`\r\n2. Read every `<item>` in `<acceptanceCriteria>` \u2014 these are your implementation contract\r\n3. Read every `<change>` in `<fileChanges>` \u2014 these are the files you will modify or create\r\n4. Read `<description>`, `<problemStatement>`, and `<solution>` for context and intent\r\n\r\nIf anything is ambiguous, call `get_ticket_context` with the ticketId to retrieve the full structured object for reference.\r\n\r\n### Step 2 \u2014 Explore the Repository\r\nBefore writing code, understand what already exists:\r\n\r\n```\r\nget_repository_context({}) // \u2192 branch, git status, file tree\r\n```\r\n\r\nFrom the file tree, locate:\r\n- The files listed in `<fileChanges>` (verify they exist or need to be created)\r\n- Existing test files for the modules you will touch\r\n- Related files that provide context (types, interfaces, base classes)\r\n\r\nRead each relevant file before modifying it. Never overwrite a file you haven't read.\r\n\r\n### Step 3 \u2014 Implement Each File Change\r\nWork through `<fileChanges>` in order:\r\n\r\nFor each `<change path=\"...\" action=\"...\">`:\r\n- **create**: Write the new file from scratch, following project patterns\r\n- **modify**: Read the current file, then apply the minimum change needed\r\n- **delete**: Confirm the file is safe to delete (no remaining imports), then remove it\r\n\r\nAfter each file change:\r\n- Verify TypeScript compiles (`npm run typecheck` or `tsc --noEmit`)\r\n- Write/update the corresponding test file\r\n\r\n### Step 4 \u2014 Verify All Acceptance Criteria\r\nAfter all file changes are implemented, go through each AC one by one:\r\n\r\n```\r\nAC1: [description] \u2014 \u2705 Implemented in [file:line]\r\nAC2: [description] \u2014 \u2705 Implemented in [file:line]\r\n...\r\n```\r\n\r\nIf any AC is not satisfied, implement it before proceeding.\r\n\r\n### Step 5 \u2014 Run Tests\r\n```bash\r\nnpm test # Run full test suite\r\nnpm run typecheck # Verify TypeScript\r\n```\r\n\r\nAll tests must pass. Fix any regressions before proceeding. If a new test fails, debug and fix it \u2014 do not comment it out or skip it.\r\n\r\n### Step 6 \u2014 Update Ticket Status\r\nWhen all ACs are satisfied and all tests pass:\r\n\r\n```\r\nupdate_ticket_status({ ticketId: '<id>', status: 'CREATED' })\r\n```\r\n\r\nThis signals to the Forge platform that implementation is complete and the ticket is ready for PM review.\r\n\r\n---\r\n\r\n## Code Quality Rules\r\n\r\n### File Organization\r\n- Follow the project's existing folder structure (check `src/` layout before creating files)\r\n- One primary export per file for MCP tool/prompt modules\r\n- Keep files focused \u2014 if a file grows beyond ~200 lines, consider splitting\r\n\r\n### TypeScript Patterns\r\n```typescript\r\n// \u2705 Explicit return types on exported functions\r\nexport async function handleMyTool(\r\n args: Record<string, unknown>,\r\n config: ForgeConfig\r\n): Promise<ToolResult> { ... }\r\n\r\n// \u2705 Type narrowing over casting\r\nconst ticketId = typeof args.ticketId === 'string' ? args.ticketId : undefined;\r\n\r\n// \u274C Avoid\r\nconst ticketId = args.ticketId as string;\r\n```\r\n\r\n### Error Return Pattern (MCP Tools)\r\n```typescript\r\n// \u2705 Structured error \u2014 never throw from a tool handler\r\nif (!ticketId) {\r\n return {\r\n content: [{ type: 'text', text: 'Missing required argument: ticketId' }],\r\n isError: true,\r\n };\r\n}\r\n```\r\n\r\n### Import Order\r\n1. Node built-ins (`fs`, `path`)\r\n2. Third-party (`chalk`, `commander`)\r\n3. Internal \u2014 services (`'../services/api.service.js'`)\r\n4. Internal \u2014 types (`'../types/ticket.js'`)\r\n5. Internal \u2014 local (`'./utils.js'`)\r\n\r\nAll local imports use `.js` extension (ESM requirement).\r\n\r\n### Test Patterns (vitest)\r\n```typescript\r\n// \u2705 Mock factory \u2014 use vi.fn() inline, access via vi.mocked()\r\nvi.mock('../services/api.service', () => ({\r\n get: vi.fn(),\r\n}));\r\nimport { get } from '../services/api.service';\r\n\r\nbeforeEach(() => {\r\n vi.mocked(get).mockResolvedValue(mockData);\r\n});\r\n\r\n// \u274C Avoid \u2014 outer variable reference hits TDZ in vitest v4\r\nconst mockGet = vi.fn();\r\nvi.mock('../services/api.service', () => ({ get: mockGet })); // breaks\r\n```\r\n\r\n### Naming Conventions\r\n- Tool modules: `kebab-case.ts` (e.g., `get-ticket-context.ts`)\r\n- Exported definitions: `camelCase` + suffix (e.g., `getTicketContextToolDefinition`)\r\n- Exported handlers: `handle` + PascalCase (e.g., `handleGetTicketContext`)\r\n- Test files: `__tests__/kebab-case.test.ts`\r\n\r\n### Comments\r\n- Add a comment only when the logic isn't self-evident\r\n- Document workarounds with the reason: `// eslint-disable-next-line @typescript-eslint/no-explicit-any \u2014 Zod v3/v4 compat`\r\n- No TODO comments unless you note the tracking story: `// TODO(6-10): integration test`\r\n";
1584
-
1585
- // src/mcp/prompts/forge-execute.ts
1586
- var forgeExecutePromptDefinition = {
1587
- name: "forge-execute",
1588
- description: "Load the Forge dev-executor persona and full ticket context (XML) to begin implementing a ticket with Claude Code.",
1589
- arguments: [
1590
- {
1591
- name: "ticketId",
1592
- description: "The ticket ID to implement (e.g., T-001)",
1593
- required: true
1594
- }
1595
- ]
1596
- };
1597
- function escapeXml(str) {
1598
- return str.replace(/&/g, "&amp;").replace(/</g, "&lt;").replace(/>/g, "&gt;").replace(/"/g, "&quot;");
1599
- }
1600
- function serializeTicketXml(ticket) {
1601
- const acItems = (ticket.acceptanceCriteria ?? []).map((ac) => ` <item>${escapeXml(ac)}</item>`).join("\n");
1602
- const fcItems = (ticket.fileChanges ?? []).map(
1603
- (fc) => ` <change path="${escapeXml(fc.path)}" action="${escapeXml(fc.action)}">${fc.notes ? escapeXml(fc.notes) : ""}</change>`
1604
- ).join("\n");
1605
- return `<ticket id="${escapeXml(ticket.id)}" status="${ticket.status}">
1606
- <title>${escapeXml(ticket.title)}</title>
1607
- <description>${escapeXml(ticket.description ?? "")}</description>
1608
- <problemStatement>${escapeXml(ticket.problemStatement ?? "")}</problemStatement>
1609
- <solution>${escapeXml(ticket.solution ?? "")}</solution>
1610
- <acceptanceCriteria>
1611
- ${acItems}
1612
- </acceptanceCriteria>
1613
- <fileChanges>
1614
- ${fcItems}
1615
- </fileChanges>
1616
- </ticket>`;
1617
- }
1618
- async function handleForgeExecute(args, config2) {
1619
- const rawId = args.ticketId;
1620
- if (typeof rawId !== "string" || rawId.trim() === "") {
1621
- return {
1622
- messages: [{ role: "user", content: { type: "text", text: "Error: Missing required argument: ticketId" } }]
1623
- };
1624
- }
1625
- const ticketId = rawId.trim();
1626
- let ticket;
1627
- try {
1628
- ticket = await get(`/tickets/${ticketId}`, config2);
1629
- } catch (err) {
1630
- const message = err.message ?? "Unknown error";
1631
- if (message.includes("404")) {
1632
- return {
1633
- messages: [{ role: "user", content: { type: "text", text: `Error: Ticket not found: ${ticketId}` } }]
1634
- };
1635
- }
1636
- return {
1637
- messages: [{ role: "user", content: { type: "text", text: `Error: ${message}` } }]
1638
- };
1639
- }
1640
- const ticketXml = serializeTicketXml(ticket);
1641
- return {
1642
- messages: [
1643
- {
1644
- role: "user",
1645
- content: {
1646
- type: "text",
1647
- text: `<agent_guide>
1648
- ${dev_executor_default}
1649
- </agent_guide>
1650
- <ticket_context>
1651
- ${ticketXml}
1652
- </ticket_context>`
1653
- }
1654
- }
1655
- ]
1656
- };
1657
- }
1658
-
1659
- // src/agents/dev-reviewer.md
1660
- var dev_reviewer_default = '# Forgy \u2014 Interactive Dev Reviewer\r\n\r\n## CRITICAL RULES \u2014 Read These First\r\n\r\n**You are having a CONVERSATION, not writing a report.**\r\n\r\n1. **ONE question at a time via `AskUserQuestion`.** Every question MUST be delivered using the `AskUserQuestion` tool. After calling it, STOP. Wait for the answer. Then call it again for the next question.\r\n2. **You do NOT decide the answers.** You ask. The developer answers. You record what they say.\r\n3. **Never output questions as text.** No numbered lists, no markdown questions. Use `AskUserQuestion` exclusively.\r\n4. **Never offer to submit on behalf of the developer.** Never say "Would you like me to submit these?" before going through questions one by one.\r\n5. **Never pre-fill answers.** You don\'t know the answers. The developer does.\r\n6. **Your first message contains ONLY: greeting + ticket summary.** Then immediately call `AskUserQuestion` for Q1. No text-based questions.\r\n\r\nIf you catch yourself about to write a question as text \u2014 STOP. Use `AskUserQuestion` instead.\r\n\r\n---\r\n\r\n## Persona\r\n\r\nYou are **Forgy** \u2014 a warm, sharp peer reviewer embedded in Claude Code. You question the **developer** (who has technical knowledge of the codebase) to extract their answers about ticket ambiguities. Those answers get submitted back to the **PM/QA** via `submit_review_session`.\r\n\r\nYou don\'t write code or suggest solutions. You read the ticket, spot what\'s unclear, and walk the developer through it one question at a time \u2014 quick, friendly, ultra-concise.\r\n\r\n---\r\n\r\n## How A Session Works (Step by Step)\r\n\r\n### Step 1 \u2014 Read the Ticket (silently)\r\nYou receive the ticket in `<ticket_context>` XML. Read it completely but do NOT output anything yet:\r\n1. Note `id`, `status`, `title`\r\n2. Read every `<item>` in `<acceptanceCriteria>`\r\n3. Read `<description>`, `<problemStatement>`, and `<solution>` for intent\r\n4. Read `<fileChanges>`, `<apiChanges>`, and `<testPlan>` for implementation context\r\n\r\nIf the summary is incomplete, call `get_ticket_context` with the `ticketId`.\r\n\r\n### Step 2 \u2014 Generate All Questions Internally (do NOT output them)\r\nFor each category (Scope, AC Edge Cases, Technical Constraints, UX Intent, Dependencies):\r\n1. Identify specific ambiguities anchored in the ticket text\r\n2. For each question, come up with 2\u20134 plausible answer options the developer can pick from. Even for open-ended questions, provide your best guesses as options \u2014 the developer can always select "Other" and type a custom answer.\r\n3. Mark `[BLOCKING]` if a wrong answer would cause rework\r\n4. Rank: BLOCKING first, then by severity\r\n5. Store the full list internally. You will deliver them ONE AT A TIME via `AskUserQuestion`.\r\n\r\n### Step 3 \u2014 Greet + Ask Q1, Then STOP\r\n\r\nFirst, output a short greeting as text:\r\n\r\n```\r\nHey! I\'m Forgy. Let\'s review **{title}** before you start building.\r\n\r\n**{id}** \xB7 {title} \xB7 {N} acceptance criteria\r\n\r\nI have {M} questions \u2014 I\'ll go one at a time. Your answers go back to the PM.\r\nSay "done" or "submit" anytime to send what we have.\r\n```\r\n\r\nThen **immediately** call `AskUserQuestion` for Q1. Do NOT write Q1 as text.\r\n\r\nThe `AskUserQuestion` call must follow this structure:\r\n\r\n```json\r\n{\r\n "questions": [\r\n {\r\n "question": "[1/{M}] {question text \u2014 1-2 sentences max}",\r\n "header": "Q1 BLOCKING",\r\n "options": [\r\n { "label": "{option 1}", "description": "{brief context if needed}" },\r\n { "label": "{option 2}", "description": "{brief context}" }\r\n ],\r\n "multiSelect": false\r\n }\r\n ]\r\n}\r\n```\r\n\r\n**Rules for the `AskUserQuestion` call:**\r\n- `header`: Use `"Q1"`, `"Q2"`, etc. Append `" BLOCKING"` if the question is blocking (e.g., `"Q1 BLOCKING"`). Max 12 characters.\r\n- `question`: Include the progress indicator `[1/{M}]` at the start. Keep to 1\u20132 sentences.\r\n- `options`: 2\u20134 options. Provide your best guesses for plausible answers. The developer can always select "Other" to type a custom answer \u2014 you do NOT need to include an "Other" option manually.\r\n- `description`: Optional. Use for a brief reference to the ticket section (e.g., "Ref: AC-3" or "Solution section mentions both").\r\n- `multiSelect`: Always `false`.\r\n\r\n**Then STOP. Wait for the developer\'s answer. Do not call AskUserQuestion for Q2 yet.**\r\n\r\n### Step 4 \u2014 Developer Answers \u2192 Acknowledge \u2192 Next Question\r\n\r\nWhen the developer\'s answer comes back:\r\n1. Output a ~5-word acknowledgment as text ("Got it.", "Makes sense.", "Noted.")\r\n2. Immediately call `AskUserQuestion` for the next question\r\n3. Repeat until all questions are answered or skipped\r\n\r\nIf the developer says "skip" or "done" as a text message instead of answering, handle it:\r\n- "skip" \u2192 mark as skipped, call `AskUserQuestion` for next question\r\n- "done"/"submit" \u2192 jump to Step 5\r\n\r\n### Step 5 \u2014 After Last Question \u2192 Recap + Submit Confirmation\r\n\r\nAfter the developer answers the final question, output the recap as text:\r\n\r\n```\r\nHere\'s what goes back to the PM/QA:\r\n\r\n- **Q1**: {their answer or "skipped"}\r\n- **Q2**: {their answer}\r\n- ...\r\n```\r\n\r\nThen immediately call `AskUserQuestion` for the submit decision:\r\n\r\n```json\r\n{\r\n "questions": [\r\n {\r\n "question": "Ready to send these answers to the PM/QA?",\r\n "header": "Submit",\r\n "options": [\r\n { "label": "Submit to PM/QA", "description": "Send all answers to Forge now" },\r\n { "label": "Revisit a question", "description": "Go back and change an answer" },\r\n { "label": "Add more context", "description": "Append additional notes before sending" }\r\n ],\r\n "multiSelect": false\r\n }\r\n ]\r\n}\r\n```\r\n\r\n**Then STOP. Wait for their choice.**\r\n\r\n### Step 6 \u2014 Submit ONLY on Explicit Signal\r\n\r\nWhen the developer picks "Submit to PM/QA":\r\n\r\n1. Compile all Q&A pairs: `[{ question: "...", answer: "..." }, ...]`\r\n2. Call `submit_review_session` with the ticketId and qaItems\r\n3. Confirm as text: "Done \u2014 submitted to Forge. The PM/QA will see your answers."\r\n\r\nIf they pick "Revisit a question" \u2192 ask which one (via `AskUserQuestion` with Q1\u2013QN as options), let them re-answer, then re-show recap.\r\nIf they pick "Add more context" \u2192 let them type it, append to qaItems, then re-show recap.\r\n\r\n**Never call `submit_review_session` before the developer explicitly confirms.**\r\n\r\n---\r\n\r\n## Question Generation Principles\r\n\r\n### Quality Over Quantity\r\nAim for 5\u201310 focused questions. Do not ask about things clearly defined in the ticket. Combine related concerns.\r\n\r\n### Anchor Every Question in the Ticket\r\nFind the specific AC, description, or constraint that is ambiguous. Reference it in the option `description` field. Never ask hypothetical questions.\r\n\r\n### Surface Blockers First\r\nOrder by severity. Put `BLOCKING` in the `header` for questions whose wrong answer would cause rework.\r\n\r\n### One Concern Per Question\r\nDon\'t bundle multiple ambiguities. Each `AskUserQuestion` call = one concern.\r\n\r\n### Developer-Targeted, PM-Useful\r\nAsk questions the developer can answer from their codebase knowledge. Frame so the answer is useful to a PM/QA reading the submission. Technical jargon is fine.\r\n\r\n### Always Offer Plausible Options\r\nEven for questions that seem open-ended, provide 2\u20134 options based on what you can infer from the ticket, the file changes, or common patterns. The developer will pick one or type "Other". Good options save the developer time.\r\n\r\n### Never Implement\r\nDon\'t suggest fixes. Don\'t answer your own questions. Don\'t modify files or call `update_ticket_status`.\r\n\r\n---\r\n\r\n## Question Categories (Internal Use)\r\n\r\n### Category 1: Scope & Boundaries\r\n**Ask when:** AC covers happy paths only; "users" without role specificity; feature touches other unmentioned features.\r\n\r\n### Category 2: Acceptance Criteria Edge Cases\r\n**Ask when:** AC says "X should work" without defining success; numerical limits without boundary behavior; ambiguous conditions.\r\n\r\n### Category 3: Technical Constraints\r\n**Ask when:** No perf requirements; missing error handling for external services; security-sensitive data without handling guidance; file/API changes conflict with codebase patterns.\r\n\r\n### Category 4: UX & PM Intent\r\n**Ask when:** Error states without user-facing messages; incomplete user flow; undefined "sensible defaults".\r\n\r\n### Category 5: Dependencies & Risks\r\n**Ask when:** Referenced endpoints may not exist; feature needs data from another system; untracked parallel dependencies.\r\n\r\n---\r\n\r\n## Full Session Example\r\n\r\n**Forgy outputs greeting text:**\r\n```\r\nHey! I\'m Forgy. Let\'s review **Create Folders** before you start building.\r\n\r\n**T-087** \xB7 Create Folders \xB7 5 acceptance criteria\r\n\r\nI have 4 questions \u2014 I\'ll go one at a time. Your answers go back to the PM.\r\nSay "done" or "submit" anytime to send what we have.\r\n```\r\n\r\n**Forgy immediately calls AskUserQuestion:**\r\n```json\r\n{\r\n "questions": [{\r\n "question": "[1/4] Spec references both React and Angular components. Which framework is this project?",\r\n "header": "Q1 BLOCKING",\r\n "options": [\r\n { "label": "React", "description": "FolderManager.tsx in solution steps" },\r\n { "label": "Angular", "description": "folder-view.component.ts in file changes" }\r\n ],\r\n "multiSelect": false\r\n }]\r\n}\r\n```\r\n\r\n**Developer picks:** `React`\r\n\r\n**Forgy outputs:** `Got it.`\r\n**Forgy calls AskUserQuestion:**\r\n```json\r\n{\r\n "questions": [{\r\n "question": "[2/4] Nested folders listed as both in-scope and out-of-scope. Which is it?",\r\n "header": "Q2 BLOCKING",\r\n "options": [\r\n { "label": "In scope", "description": "Allow folders within folders" },\r\n { "label": "Out of scope", "description": "Flat folders only, no nesting" }\r\n ],\r\n "multiSelect": false\r\n }]\r\n}\r\n```\r\n\r\n**Developer picks:** `Out of scope`\r\n\r\n**Forgy outputs:** `Noted.`\r\n**Forgy calls AskUserQuestion:**\r\n```json\r\n{\r\n "questions": [{\r\n "question": "[3/4] API changes include GitHub repos endpoints. Are those part of this ticket?",\r\n "header": "Q3",\r\n "options": [\r\n { "label": "Yes, needed", "description": "Required for this feature" },\r\n { "label": "No, leftover", "description": "Stale from another spec" }\r\n ],\r\n "multiSelect": false\r\n }]\r\n}\r\n```\r\n\r\n**Developer picks:** `No, leftover`\r\n\r\n**Forgy outputs:** `Makes sense.`\r\n**Forgy calls AskUserQuestion:**\r\n```json\r\n{\r\n "questions": [{\r\n "question": "[4/4] No DB schema or folder CRUD endpoints in the spec. How should folders be persisted?",\r\n "header": "Q4",\r\n "options": [\r\n { "label": "New DB table + REST API", "description": "Full backend support needed" },\r\n { "label": "Client-side only", "description": "localStorage or state, no backend" },\r\n { "label": "Existing table extension", "description": "Add folder column to tickets table" }\r\n ],\r\n "multiSelect": false\r\n }]\r\n}\r\n```\r\n\r\n**Developer picks:** `Other` \u2192 types: `New folders table + REST endpoints, PM missed that entirely`\r\n\r\n**Forgy outputs recap text:**\r\n```\r\nHere\'s what goes back to the PM/QA:\r\n\r\n- **Q1**: React\r\n- **Q2**: Out of scope (flat folders only)\r\n- **Q3**: No, leftover from another spec\r\n- **Q4**: New folders table + REST endpoints, PM missed that entirely\r\n```\r\n\r\n**Forgy calls AskUserQuestion:**\r\n```json\r\n{\r\n "questions": [{\r\n "question": "Ready to send these answers to the PM/QA?",\r\n "header": "Submit",\r\n "options": [\r\n { "label": "Submit to PM/QA", "description": "Send all answers to Forge now" },\r\n { "label": "Revisit a question", "description": "Go back and change an answer" },\r\n { "label": "Add more context", "description": "Append notes before sending" }\r\n ],\r\n "multiSelect": false\r\n }]\r\n}\r\n```\r\n\r\n**Developer picks:** `Submit to PM/QA`\r\n\r\n**Forgy calls `submit_review_session` and outputs:**\r\n```\r\nDone \u2014 submitted to Forge. The PM/QA will see your answers.\r\n```\r\n';
1661
-
1662
- // src/mcp/prompts/forge-review.ts
1663
- var forgeReviewPromptDefinition = {
1664
- name: "review",
1665
- description: "Load the Forge dev-reviewer persona and ticket summary to generate clarifying questions for the PM.",
1666
- arguments: [
1667
- {
1668
- name: "ticketId",
1669
- description: "The ticket ID to review (e.g., T-001)",
1670
- required: true
1671
- }
1672
- ]
1673
- };
1674
- function escapeXml2(str) {
1675
- return str.replace(/&/g, "&amp;").replace(/</g, "&lt;").replace(/>/g, "&gt;").replace(/"/g, "&quot;");
1676
- }
1677
- function serializeTicketReviewXml(ticket) {
1678
- const acItems = (ticket.acceptanceCriteria ?? []).map((ac) => ` <item>${escapeXml2(ac)}</item>`).join("\n");
1679
- const fcItems = (ticket.fileChanges ?? []).map(
1680
- (fc) => ` <change path="${escapeXml2(fc.path)}" action="${escapeXml2(fc.action)}">${fc.notes ? escapeXml2(fc.notes) : ""}</change>`
1681
- ).join("\n");
1682
- return `<ticket id="${escapeXml2(ticket.id)}" status="${ticket.status}">
1683
- <title>${escapeXml2(ticket.title)}</title>
1684
- <description>${escapeXml2(ticket.description ?? "")}</description>
1685
- <problemStatement>${escapeXml2(ticket.problemStatement ?? "")}</problemStatement>
1686
- <solution>${escapeXml2(ticket.solution ?? "")}</solution>
1687
- <acceptanceCriteria>
1688
- ${acItems}
1689
- </acceptanceCriteria>
1690
- <fileChanges>
1691
- ${fcItems}
1692
- </fileChanges>
1693
- <apiChanges>${escapeXml2(ticket.apiChanges ?? "")}</apiChanges>
1694
- <testPlan>${escapeXml2(ticket.testPlan ?? "")}</testPlan>
1695
- </ticket>`;
1696
- }
1697
- async function handleForgeReview(args, config2) {
1698
- const rawId = args.ticketId;
1699
- if (typeof rawId !== "string" || rawId.trim() === "") {
1700
- return {
1701
- messages: [{ role: "user", content: { type: "text", text: "Error: Missing required argument: ticketId" } }]
1702
- };
1703
- }
1704
- const ticketId = rawId.trim();
1705
- let ticket;
1706
- try {
1707
- ticket = await get(`/tickets/${ticketId}`, config2);
1708
- } catch (err) {
1709
- const message = err.message ?? "Unknown error";
1710
- if (message.includes("404")) {
1711
- return {
1712
- messages: [{ role: "user", content: { type: "text", text: `Error: Ticket not found: ${ticketId}` } }]
1713
- };
1714
- }
1715
- return {
1716
- messages: [{ role: "user", content: { type: "text", text: `Error: ${message}` } }]
1717
- };
1718
- }
1719
- const ticketSummaryXml = serializeTicketReviewXml(ticket);
1720
- return {
1721
- messages: [
1722
- {
1723
- role: "user",
1724
- content: {
1725
- type: "text",
1726
- text: `<agent_guide>
1727
- ${dev_reviewer_default}
1728
- </agent_guide>
1729
- <ticket_context>
1730
- ${ticketSummaryXml}
1731
- </ticket_context>`
1732
- }
1733
- }
1734
- ]
1735
- };
1736
- }
1737
-
1738
- // src/mcp/prompts/forge-list.ts
1739
- var forgeListPromptDefinition = {
1740
- name: "list",
1741
- description: "List your Forge tickets with status, priority, and assignee names. Use this to browse tickets before executing or reviewing one.",
1742
- arguments: [
1743
- {
1744
- name: "filter",
1745
- description: 'Show "all" team tickets (default) or "mine" for only assigned to me',
1746
- required: false
1747
- }
1748
- ]
1749
- };
1750
- var STATUS_ICONS2 = {
1751
- ["draft" /* DRAFT */]: "\u2B1C",
1752
- ["validated" /* VALIDATED */]: "\u2705",
1753
- ["ready" /* READY */]: "\u{1F680}",
1754
- ["waiting-for-approval" /* WAITING_FOR_APPROVAL */]: "\u23F3",
1755
- ["created" /* CREATED */]: "\u{1F4DD}",
1756
- ["drifted" /* DRIFTED */]: "\u26A0\uFE0F",
1757
- ["complete" /* COMPLETE */]: "\u2705"
1758
- };
1759
- var STATUS_DISPLAY_NAMES2 = {
1760
- ["draft" /* DRAFT */]: "Define",
1761
- ["validated" /* VALIDATED */]: "Dev-Refine",
1762
- ["ready" /* READY */]: "Execute",
1763
- ["waiting-for-approval" /* WAITING_FOR_APPROVAL */]: "Approve",
1764
- ["created" /* CREATED */]: "Exported",
1765
- ["drifted" /* DRIFTED */]: "Drifted",
1766
- ["complete" /* COMPLETE */]: "Done"
1767
- };
1768
- async function fetchMemberNames2(config2) {
1769
- try {
1770
- const res = await get(
1771
- `/teams/${config2.teamId}/members`,
1772
- config2
1773
- );
1774
- const map = /* @__PURE__ */ new Map();
1775
- for (const m of res.members) {
1776
- if (m.displayName) map.set(m.userId, m.displayName);
1777
- }
1778
- return map;
1779
- } catch {
1780
- return /* @__PURE__ */ new Map();
1781
- }
1782
- }
1783
- async function handleForgeList(args, config2) {
1784
- const filter = typeof args.filter === "string" ? args.filter.trim() : "all";
1785
- const params = {
1786
- teamId: config2.teamId
1787
- };
1788
- if (filter === "all") {
1789
- params.all = "true";
1790
- } else {
1791
- params.assignedToMe = "true";
1792
- }
1793
- let tickets;
1794
- let memberNames;
1795
- try {
1796
- [tickets, memberNames] = await Promise.all([
1797
- get("/tickets", config2, params),
1798
- fetchMemberNames2(config2)
1799
- ]);
1800
- } catch (err) {
1801
- const message = err.message ?? "Unknown error";
1802
- return {
1803
- messages: [{ role: "user", content: { type: "text", text: `Error: Failed to fetch tickets: ${message}` } }]
1804
- };
1805
- }
1806
- if (tickets.length === 0) {
1807
- const hint = filter !== "all" ? "\n\nTry `/forge:list` with filter `all` to see all team tickets." : "";
1808
- return {
1809
- messages: [
1810
- {
1811
- role: "user",
1812
- content: {
1813
- type: "text",
1814
- text: `No tickets found.${hint}`
1815
- }
1816
- }
1817
- ]
1818
- };
1819
- }
1820
- const label = filter === "all" ? "All Team Tickets" : "My Tickets";
1821
- const header = "| ID | Title | Status | Assignee | Priority |";
1822
- const divider = "|-----|-------|--------|----------|----------|";
1823
- const rows = tickets.map((t) => {
1824
- const icon = STATUS_ICONS2[t.status] ?? "\u2753";
1825
- const assignee = t.assignedTo ? memberNames.get(t.assignedTo) ?? t.assignedTo : "\u2014";
1826
- const priority = t.priority ?? "\u2014";
1827
- const title = t.title.length > 50 ? t.title.substring(0, 47) + "..." : t.title;
1828
- const label2 = STATUS_DISPLAY_NAMES2[t.status] ?? t.status;
1829
- return `| \`${t.id}\` | ${title} | ${icon} ${label2} | ${assignee} | ${priority} |`;
1830
- });
1831
- const table = [header, divider, ...rows].join("\n");
1832
- const text = `## ${label} (${tickets.length})
1833
-
1834
- ${table}
1835
-
1836
- > To execute a ticket: \`/forge:exec\` with the ticket ID
1837
- > To review a ticket: \`/forge:review\` with the ticket ID`;
1838
- return {
1839
- messages: [
1840
- {
1841
- role: "user",
1842
- content: { type: "text", text }
1843
- }
1844
- ]
1845
- };
1846
- }
1847
-
1848
- // src/mcp/prompts/forge-exec.ts
1849
- var forgeExecPromptDefinition = {
1850
- name: "forge-exec",
1851
- description: "Execute a Forge ticket \u2014 loads the dev-executor persona and full ticket context to begin implementation.",
1852
- arguments: [
1853
- {
1854
- name: "ticketId",
1855
- description: 'The ticket ID to implement (e.g., "aec_57f97f8c-...")',
1856
- required: true
1857
- }
1858
- ]
1859
- };
1860
- async function handleForgeExec(args, config2) {
1861
- return handleForgeExecute(args, config2);
1862
- }
1863
-
1864
- // src/mcp/server.ts
1865
- var ForgeMCPServer = class {
1866
- constructor(config2) {
1867
- this.config = config2;
1868
- this.server = new Server(
1869
- { name: "forge", version: "1.0.0" },
1870
- { capabilities: { tools: {}, prompts: {} } }
1871
- );
1872
- this.server.setRequestHandler(ListToolsRequestSchema, async () => ({
1873
- tools: [getTicketContextToolDefinition, getFileChangesToolDefinition, getRepositoryContextToolDefinition, updateTicketStatusToolDefinition, submitReviewSessionToolDefinition, listTicketsToolDefinition]
1874
- }));
1875
- this.server.setRequestHandler(ListPromptsRequestSchema, async () => ({
1876
- prompts: [forgeListPromptDefinition, forgeExecPromptDefinition, forgeExecutePromptDefinition, forgeReviewPromptDefinition]
1877
- }));
1878
- this.server.setRequestHandler(GetPromptRequestSchema, async (request2) => {
1879
- const { name, arguments: args = {} } = request2.params;
1880
- switch (name) {
1881
- case "forge-execute":
1882
- return handleForgeExecute(args, this.config);
1883
- case "review":
1884
- return handleForgeReview(args, this.config);
1885
- case "list":
1886
- return handleForgeList(args, this.config);
1887
- case "forge-exec":
1888
- return handleForgeExec(args, this.config);
1889
- default:
1890
- return {
1891
- messages: [{ role: "user", content: { type: "text", text: `Error: Unknown prompt: ${name}` } }]
1892
- };
1893
- }
1894
- });
1895
- this.server.setRequestHandler(CallToolRequestSchema, async (request2) => {
1896
- const { name, arguments: args = {} } = request2.params;
1897
- switch (name) {
1898
- case "get_ticket_context":
1899
- return handleGetTicketContext(
1900
- args,
1901
- this.config
1902
- );
1903
- case "get_file_changes":
1904
- return handleGetFileChanges(
1905
- args,
1906
- this.config
1907
- );
1908
- case "get_repository_context":
1909
- return handleGetRepositoryContext(
1910
- args,
1911
- this.config
1912
- );
1913
- case "update_ticket_status":
1914
- return handleUpdateTicketStatus(
1915
- args,
1916
- this.config
1917
- );
1918
- case "submit_review_session":
1919
- return handleSubmitReviewSession(
1920
- args,
1921
- this.config
1922
- );
1923
- case "list_tickets":
1924
- return handleListTickets(
1925
- args,
1926
- this.config
1927
- );
1928
- default:
1929
- return {
1930
- content: [{ type: "text", text: `Unknown tool: ${name}` }],
1931
- isError: true
1932
- };
1933
- }
1934
- });
1935
- }
1936
- server;
1937
- transport = null;
1938
- async start() {
1939
- this.transport = new StdioServerTransport();
1940
- await this.server.connect(this.transport);
1941
- process.stderr.write("[forge:mcp] server started\n");
1942
- }
1943
- async stop() {
1944
- if (!this.transport) return;
1945
- try {
1946
- await this.server.close();
1947
- } catch {
1948
- }
1949
- this.transport = null;
1950
- process.stderr.write("[forge:mcp] server stopped\n");
1951
- }
1952
- };
1953
-
1954
- // src/commands/mcp.ts
1955
- var DIVIDER3 = "\u2500".repeat(72);
1956
- var mcpCommand = new Command7("mcp").description("Forge MCP server \u2014 run as daemon or install for Claude Code").action(async () => {
1957
- try {
1958
- const config2 = await requireAuth();
1959
- const server = new ForgeMCPServer(config2);
1960
- await server.start();
1961
- process.once("SIGINT", () => {
1962
- server.stop().then(() => process.exit(0));
1963
- });
1964
- await new Promise(() => {
1965
- });
1966
- } catch (err) {
1967
- process.stderr.write(
1968
- chalk11.red(`[forge:mcp] Fatal error: ${err.message}
1969
- `)
1970
- );
1971
- process.exit(1);
1972
- }
1973
- });
1974
- mcpCommand.command("install").description("Write .mcp.json and register forge as a project-scoped MCP server").action(async () => {
1975
- console.log();
1976
- console.log(chalk11.dim(DIVIDER3));
1977
- console.log(" Forge MCP Server \u2014 Project Setup");
1978
- console.log(chalk11.dim(DIVIDER3));
1979
- console.log();
1980
- try {
1981
- await writeMcpJson();
1982
- console.log(
1983
- ` ${chalk11.green("\u2705")} Written ${chalk11.bold(".mcp.json")} ` + chalk11.dim("(project scope \u2014 commit this file for your team)")
1984
- );
1985
- } catch (err) {
1986
- console.error(
1987
- chalk11.red(` \u2717 Failed to write .mcp.json: ${err.message}`)
1988
- );
1989
- process.exit(1);
1990
- }
1991
- const result = await tryRegisterMcpServer("project");
1992
- if (result === "registered") {
1993
- console.log(
1994
- ` ${chalk11.green("\u2705")} Registered via: ` + chalk11.dim("claude mcp add --scope project ...")
1995
- );
1996
- } else {
1997
- console.log(
1998
- ` ${chalk11.dim("\u2139")} claude CLI not found \u2014 .mcp.json is ready but not auto-registered`
1999
- );
2000
- }
2001
- console.log();
2002
- console.log(" Restart Claude Code to apply. Per-ticket usage:");
2003
- console.log(chalk11.dim(" forge execute T-001 \u2192 invoke forge-exec prompt in Claude Code"));
2004
- console.log(chalk11.dim(" forge review T-001 \u2192 invoke review prompt in Claude Code"));
2005
- console.log();
2006
- console.log(chalk11.dim(DIVIDER3));
2007
- console.log();
2008
- });
2009
-
2010
- // src/commands/whoami.ts
2011
- import { Command as Command8 } from "commander";
2012
- import chalk12 from "chalk";
2013
- var whoamiCommand = new Command8("whoami").description("Show the currently authenticated user").action(async () => {
2014
- try {
2015
- const config2 = await load();
2016
- if (!isLoggedIn(config2)) {
2017
- console.log(chalk12.dim("Not logged in. Run `forge login` to authenticate."));
2018
- process.exit(1);
2019
- }
2020
- const { user, teamId, expiresAt } = config2;
2021
- const expired = new Date(expiresAt) < /* @__PURE__ */ new Date();
2022
- console.log(`${chalk12.bold(user.displayName)} (${user.email})`);
2023
- console.log(`${chalk12.dim("Team:")} ${teamId}`);
2024
- console.log(
2025
- `${chalk12.dim("Token:")} ${expired ? chalk12.red("expired") : chalk12.green("valid")}`
2026
- );
2027
- } catch (err) {
2028
- console.error(chalk12.red(`Error: ${err.message}`));
2029
- process.exit(1);
2030
- }
2031
- });
2032
-
2033
- // src/commands/doctor.ts
2034
- import { Command as Command9 } from "commander";
2035
- import chalk13 from "chalk";
2036
- import * as fs4 from "fs/promises";
2037
- import { execFile } from "child_process";
2038
- var PASS = chalk13.green("PASS");
2039
- var FAIL = chalk13.red("FAIL");
2040
- var checks = [
2041
- {
2042
- label: "Config file exists",
2043
- run: async () => {
2044
- try {
2045
- await fs4.access(getConfigPath());
2046
- return { ok: true, detail: getConfigPath() };
2047
- } catch {
2048
- return {
2049
- ok: false,
2050
- detail: `Not found. Run ${chalk13.bold("forge login")} to create it.`
2051
- };
2052
- }
2053
- }
2054
- },
2055
- {
2056
- label: "Authenticated",
2057
- run: async () => {
2058
- const config2 = await load();
2059
- if (isLoggedIn(config2)) {
2060
- return { ok: true, detail: config2.user.email };
2061
- }
2062
- return {
2063
- ok: false,
2064
- detail: `No valid credentials. Run ${chalk13.bold("forge login")}.`
2065
- };
2066
- }
2067
- },
2068
- {
2069
- label: "API reachable",
2070
- run: async () => {
2071
- try {
2072
- const res = await fetch(`${API_URL}/health`, {
2073
- signal: AbortSignal.timeout(5e3)
2074
- });
2075
- if (res.ok) return { ok: true, detail: API_URL };
2076
- return {
2077
- ok: false,
2078
- detail: `Server returned ${res.status}. Check https://status.forge-ai.dev.`
2079
- };
2080
- } catch {
2081
- return {
2082
- ok: false,
2083
- detail: `Cannot reach ${API_URL}. Check your internet connection.`
2084
- };
2085
- }
2086
- }
2087
- },
2088
- {
2089
- label: "Token not expired",
2090
- run: async () => {
2091
- const config2 = await load();
2092
- if (!config2) {
2093
- return { ok: false, detail: "No config file." };
2094
- }
2095
- const expired = new Date(config2.expiresAt) < /* @__PURE__ */ new Date();
2096
- if (!expired) return { ok: true, detail: `Expires ${config2.expiresAt}` };
2097
- return {
2098
- ok: false,
2099
- detail: `Expired at ${config2.expiresAt}. Token will auto-refresh on next API call, or run ${chalk13.bold("forge login")}.`
2100
- };
2101
- }
2102
- },
2103
- {
2104
- label: "Claude CLI installed",
2105
- run: () => new Promise((resolve3) => {
2106
- execFile("claude", ["--version"], (err, stdout) => {
2107
- if (err) {
2108
- resolve3({
2109
- ok: false,
2110
- detail: `Not found. Install from https://docs.anthropic.com/en/docs/claude-code.`
2111
- });
2112
- } else {
2113
- resolve3({ ok: true, detail: stdout.trim() });
2114
- }
2115
- });
2116
- })
2117
- },
2118
- {
2119
- label: "MCP server registered",
2120
- run: async () => {
2121
- try {
2122
- await fs4.access(".mcp.json");
2123
- const raw = await fs4.readFile(".mcp.json", "utf-8");
2124
- const json = JSON.parse(raw);
2125
- if (json?.mcpServers?.forge) {
2126
- return { ok: true, detail: ".mcp.json contains forge entry" };
2127
- }
2128
- return {
2129
- ok: false,
2130
- detail: `.mcp.json exists but missing forge entry. Run ${chalk13.bold("forge mcp install")}.`
2131
- };
2132
- } catch {
2133
- return {
2134
- ok: false,
2135
- detail: `No .mcp.json found. Run ${chalk13.bold("forge mcp install")} in your project root.`
2136
- };
2137
- }
2138
- }
2139
- }
2140
- ];
2141
- var doctorCommand = new Command9("doctor").description("Run diagnostic checks on your Forge CLI setup").action(async () => {
2142
- console.log();
2143
- console.log(chalk13.bold("forge doctor"));
2144
- console.log(chalk13.dim("\u2500".repeat(50)));
2145
- console.log();
2146
- let allPassed = true;
2147
- for (const check of checks) {
2148
- const result = await check.run();
2149
- const badge = result.ok ? PASS : FAIL;
2150
- console.log(` ${badge} ${check.label}`);
2151
- console.log(` ${chalk13.dim(result.detail)}`);
2152
- if (!result.ok) allPassed = false;
2153
- }
2154
- console.log();
2155
- if (allPassed) {
2156
- console.log(chalk13.green("All checks passed. You are good to go."));
2157
- } else {
2158
- console.log(
2159
- chalk13.yellow("Some checks failed. Follow the instructions above to fix them.")
2160
- );
2161
- }
2162
- console.log();
2163
- process.exit(allPassed ? 0 : 1);
2164
- });
2165
-
2166
- // src/index.ts
2167
- var require2 = createRequire(import.meta.url);
2168
- var { version } = require2("../package.json");
2169
- var program = new Command10();
2170
- program.name("forge").version(version).description("CLI for Forge \u2014 authenticate, browse tickets, and execute AI-assisted implementations via MCP");
2171
- program.addCommand(loginCommand);
2172
- program.addCommand(logoutCommand);
2173
- program.addCommand(listCommand, { hidden: true });
2174
- program.addCommand(showCommand);
2175
- program.addCommand(reviewCommand);
2176
- program.addCommand(executeCommand);
2177
- program.addCommand(mcpCommand);
2178
- program.addCommand(whoamiCommand);
2179
- program.addCommand(doctorCommand);
2180
- program.addHelpText("after", `
2181
- Getting started:
2182
- $ forge login Authenticate with your Forge account
2183
- $ forge execute T-001 Start an AI-assisted execution session
2184
- $ forge mcp install Set up MCP server for Claude Code
2185
-
2186
- Troubleshooting:
2187
- $ forge doctor Run diagnostic checks on your setup
2188
- $ forge whoami Show current user and token status
2189
- `);
2190
- program.parse(process.argv);
2191
- //# sourceMappingURL=index.js.map