@supaku/agentfactory-cli 0.7.5 → 0.7.7

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.
@@ -39,4 +39,14 @@ describe('@supaku/agentfactory-cli subpath exports', () => {
39
39
  expect(mod.runLogAnalyzer).toBeDefined();
40
40
  expect(typeof mod.runLogAnalyzer).toBe('function');
41
41
  });
42
+ it('exports runLinear from ./linear', async () => {
43
+ const mod = await import('../lib/linear-runner.js');
44
+ expect(mod.runLinear).toBeDefined();
45
+ expect(typeof mod.runLinear).toBe('function');
46
+ });
47
+ it('exports parseLinearArgs from ./linear', async () => {
48
+ const mod = await import('../lib/linear-runner.js');
49
+ expect(mod.parseLinearArgs).toBeDefined();
50
+ expect(typeof mod.parseLinearArgs).toBe('function');
51
+ });
42
52
  });
@@ -1 +1 @@
1
- {"version":3,"file":"index.d.ts","sourceRoot":"","sources":["../../src/index.ts"],"names":[],"mappings":";AACA;;;;;GAKG;AAEH,QAAA,MAAM,OAAO,QAAkB,CAAA;AAE/B,iBAAS,SAAS,IAAI,IAAI,CAoBzB"}
1
+ {"version":3,"file":"index.d.ts","sourceRoot":"","sources":["../../src/index.ts"],"names":[],"mappings":";AACA;;;;;GAKG;AAEH,QAAA,MAAM,OAAO,QAAkB,CAAA;AAE/B,iBAAS,SAAS,IAAI,IAAI,CAqBzB"}
package/dist/src/index.js CHANGED
@@ -21,6 +21,7 @@ Commands:
21
21
  cleanup Clean up orphaned git worktrees
22
22
  queue-admin Manage Redis work queue and sessions
23
23
  analyze-logs Analyze agent session logs for errors
24
+ linear Linear issue tracker operations
24
25
  help Show this help message
25
26
 
26
27
  Run 'agentfactory <command> --help' for command-specific options.
@@ -47,6 +48,9 @@ switch (command) {
47
48
  case 'analyze-logs':
48
49
  import('./analyze-logs');
49
50
  break;
51
+ case 'linear':
52
+ import('./linear');
53
+ break;
50
54
  case 'help':
51
55
  case '--help':
52
56
  case '-h':
@@ -0,0 +1,33 @@
1
+ /**
2
+ * Linear CLI Runner — process-agnostic Linear operations.
3
+ *
4
+ * All 15 command implementations extracted from packages/core/src/linear-cli.ts.
5
+ * This module does NOT call process.exit, read process.argv, or load dotenv.
6
+ */
7
+ export interface LinearRunnerConfig {
8
+ command: string;
9
+ args: Record<string, string | string[] | boolean>;
10
+ positionalArgs: string[];
11
+ apiKey?: string;
12
+ }
13
+ export interface LinearRunnerResult {
14
+ output: unknown;
15
+ }
16
+ /**
17
+ * Parse CLI arguments into a structured object.
18
+ *
19
+ * Supports:
20
+ * - `--key value` pairs
21
+ * - JSON array values: `--labels '["Bug", "Feature"]'`
22
+ * - Comma-separated values for array fields: `--labels "Bug,Feature"`
23
+ * - Boolean flags: `--dry-run` (value = "true")
24
+ *
25
+ * Returns the command (first non-flag arg), named args, and positional args.
26
+ */
27
+ export declare function parseLinearArgs(argv: string[]): {
28
+ command: string | undefined;
29
+ args: Record<string, string | string[] | boolean>;
30
+ positionalArgs: string[];
31
+ };
32
+ export declare function runLinear(config: LinearRunnerConfig): Promise<LinearRunnerResult>;
33
+ //# sourceMappingURL=linear-runner.d.ts.map
@@ -0,0 +1 @@
1
+ {"version":3,"file":"linear-runner.d.ts","sourceRoot":"","sources":["../../../src/lib/linear-runner.ts"],"names":[],"mappings":"AAAA;;;;;GAKG;AAUH,MAAM,WAAW,kBAAkB;IACjC,OAAO,EAAE,MAAM,CAAA;IACf,IAAI,EAAE,MAAM,CAAC,MAAM,EAAE,MAAM,GAAG,MAAM,EAAE,GAAG,OAAO,CAAC,CAAA;IACjD,cAAc,EAAE,MAAM,EAAE,CAAA;IACxB,MAAM,CAAC,EAAE,MAAM,CAAA;CAChB;AAED,MAAM,WAAW,kBAAkB;IACjC,MAAM,EAAE,OAAO,CAAA;CAChB;AAOD;;;;;;;;;;GAUG;AACH,wBAAgB,eAAe,CAAC,IAAI,EAAE,MAAM,EAAE,GAAG;IAC/C,OAAO,EAAE,MAAM,GAAG,SAAS,CAAA;IAC3B,IAAI,EAAE,MAAM,CAAC,MAAM,EAAE,MAAM,GAAG,MAAM,EAAE,GAAG,OAAO,CAAC,CAAA;IACjD,cAAc,EAAE,MAAM,EAAE,CAAA;CACzB,CA2CA;AAymBD,wBAAsB,SAAS,CAAC,MAAM,EAAE,kBAAkB,GAAG,OAAO,CAAC,kBAAkB,CAAC,CA2MvF"}
@@ -0,0 +1,681 @@
1
+ /**
2
+ * Linear CLI Runner — process-agnostic Linear operations.
3
+ *
4
+ * All 15 command implementations extracted from packages/core/src/linear-cli.ts.
5
+ * This module does NOT call process.exit, read process.argv, or load dotenv.
6
+ */
7
+ import { createLinearAgentClient } from '@supaku/agentfactory-linear';
8
+ import { checkPRDeploymentStatus, formatDeploymentStatus, } from '@supaku/agentfactory';
9
+ // ── Arg parsing ────────────────────────────────────────────────────
10
+ /** Fields that should be split on commas to create arrays */
11
+ const ARRAY_FIELDS = new Set(['labels']);
12
+ /**
13
+ * Parse CLI arguments into a structured object.
14
+ *
15
+ * Supports:
16
+ * - `--key value` pairs
17
+ * - JSON array values: `--labels '["Bug", "Feature"]'`
18
+ * - Comma-separated values for array fields: `--labels "Bug,Feature"`
19
+ * - Boolean flags: `--dry-run` (value = "true")
20
+ *
21
+ * Returns the command (first non-flag arg), named args, and positional args.
22
+ */
23
+ export function parseLinearArgs(argv) {
24
+ const command = argv[0] && !argv[0].startsWith('--') ? argv[0] : undefined;
25
+ const rest = command ? argv.slice(1) : argv;
26
+ const args = {};
27
+ const positionalArgs = [];
28
+ for (let i = 0; i < rest.length; i++) {
29
+ const arg = rest[i];
30
+ if (arg.startsWith('--')) {
31
+ const key = arg.slice(2);
32
+ const value = rest[i + 1];
33
+ if (value && !value.startsWith('--')) {
34
+ // Support JSON array format: --labels '["Bug", "Feature"]'
35
+ if (value.startsWith('[') && value.endsWith(']')) {
36
+ try {
37
+ const parsed = JSON.parse(value);
38
+ if (Array.isArray(parsed)) {
39
+ args[key] = parsed;
40
+ i++;
41
+ continue;
42
+ }
43
+ }
44
+ catch {
45
+ // Not valid JSON, fall through to normal handling
46
+ }
47
+ }
48
+ // Only split on comma for known array fields
49
+ if (ARRAY_FIELDS.has(key) && value.includes(',')) {
50
+ args[key] = value.split(',').map((v) => v.trim());
51
+ }
52
+ else {
53
+ args[key] = value;
54
+ }
55
+ i++;
56
+ }
57
+ else {
58
+ args[key] = true;
59
+ }
60
+ }
61
+ else {
62
+ positionalArgs.push(arg);
63
+ }
64
+ }
65
+ return { command, args, positionalArgs };
66
+ }
67
+ // ── Command implementations ────────────────────────────────────────
68
+ async function getIssue(client, issueId) {
69
+ const issue = await client.getIssue(issueId);
70
+ const state = await issue.state;
71
+ const team = await issue.team;
72
+ const project = await issue.project;
73
+ const labels = await issue.labels();
74
+ return {
75
+ id: issue.id,
76
+ identifier: issue.identifier,
77
+ title: issue.title,
78
+ description: issue.description,
79
+ url: issue.url,
80
+ status: state?.name,
81
+ team: team?.name,
82
+ project: project?.name,
83
+ labels: labels.nodes.map((l) => l.name),
84
+ createdAt: issue.createdAt,
85
+ updatedAt: issue.updatedAt,
86
+ };
87
+ }
88
+ async function createIssue(client, options) {
89
+ const team = await client.getTeam(options.team);
90
+ const createPayload = {
91
+ teamId: team.id,
92
+ title: options.title,
93
+ };
94
+ if (options.description) {
95
+ createPayload.description = options.description;
96
+ }
97
+ if (options.parentId) {
98
+ createPayload.parentId = options.parentId;
99
+ }
100
+ if (options.project) {
101
+ const projects = await client.linearClient.projects({
102
+ filter: { name: { eq: options.project } },
103
+ });
104
+ if (projects.nodes.length > 0) {
105
+ createPayload.projectId = projects.nodes[0].id;
106
+ }
107
+ }
108
+ if (options.state) {
109
+ const statuses = await client.getTeamStatuses(team.id);
110
+ const stateId = statuses[options.state];
111
+ if (stateId) {
112
+ createPayload.stateId = stateId;
113
+ }
114
+ }
115
+ if (options.labels && options.labels.length > 0) {
116
+ const allLabels = await client.linearClient.issueLabels();
117
+ const labelIds = [];
118
+ for (const labelName of options.labels) {
119
+ const label = allLabels.nodes.find((l) => l.name.toLowerCase() === labelName.toLowerCase());
120
+ if (label) {
121
+ labelIds.push(label.id);
122
+ }
123
+ }
124
+ if (labelIds.length > 0) {
125
+ createPayload.labelIds = labelIds;
126
+ }
127
+ }
128
+ const payload = await client.linearClient.createIssue(createPayload);
129
+ if (!payload.success) {
130
+ throw new Error('Failed to create issue');
131
+ }
132
+ const issue = await payload.issue;
133
+ if (!issue) {
134
+ throw new Error('Issue created but not returned');
135
+ }
136
+ return {
137
+ id: issue.id,
138
+ identifier: issue.identifier,
139
+ title: issue.title,
140
+ url: issue.url,
141
+ };
142
+ }
143
+ async function updateIssue(client, issueId, options) {
144
+ const issue = await client.getIssue(issueId);
145
+ const team = await issue.team;
146
+ const updateData = {};
147
+ if (options.title) {
148
+ updateData.title = options.title;
149
+ }
150
+ if (options.description) {
151
+ updateData.description = options.description;
152
+ }
153
+ if (options.state && team) {
154
+ const statuses = await client.getTeamStatuses(team.id);
155
+ const stateId = statuses[options.state];
156
+ if (stateId) {
157
+ updateData.stateId = stateId;
158
+ }
159
+ }
160
+ if (options.labels && options.labels.length > 0) {
161
+ const allLabels = await client.linearClient.issueLabels();
162
+ const labelIds = [];
163
+ for (const labelName of options.labels) {
164
+ const label = allLabels.nodes.find((l) => l.name.toLowerCase() === labelName.toLowerCase());
165
+ if (label) {
166
+ labelIds.push(label.id);
167
+ }
168
+ }
169
+ updateData.labelIds = labelIds;
170
+ }
171
+ const updatedIssue = await client.updateIssue(issue.id, updateData);
172
+ const state = await updatedIssue.state;
173
+ return {
174
+ id: updatedIssue.id,
175
+ identifier: updatedIssue.identifier,
176
+ title: updatedIssue.title,
177
+ status: state?.name,
178
+ url: updatedIssue.url,
179
+ };
180
+ }
181
+ async function listComments(client, issueId) {
182
+ const comments = await client.getIssueComments(issueId);
183
+ return comments.map((c) => ({
184
+ id: c.id,
185
+ body: c.body,
186
+ createdAt: c.createdAt,
187
+ }));
188
+ }
189
+ async function createComment(client, issueId, body) {
190
+ const comment = await client.createComment(issueId, body);
191
+ return {
192
+ id: comment.id,
193
+ body: comment.body,
194
+ createdAt: comment.createdAt,
195
+ };
196
+ }
197
+ async function addRelation(client, issueId, relatedIssueId, relationType) {
198
+ const result = await client.createIssueRelation({
199
+ issueId,
200
+ relatedIssueId,
201
+ type: relationType,
202
+ });
203
+ return {
204
+ success: result.success,
205
+ relationId: result.relationId,
206
+ issueId,
207
+ relatedIssueId,
208
+ type: relationType,
209
+ };
210
+ }
211
+ async function listRelations(client, issueId) {
212
+ const result = await client.getIssueRelations(issueId);
213
+ return {
214
+ issueId,
215
+ relations: result.relations.map((r) => ({
216
+ id: r.id,
217
+ type: r.type,
218
+ relatedIssue: r.relatedIssueIdentifier ?? r.relatedIssueId,
219
+ createdAt: r.createdAt,
220
+ })),
221
+ inverseRelations: result.inverseRelations.map((r) => ({
222
+ id: r.id,
223
+ type: r.type,
224
+ sourceIssue: r.issueIdentifier ?? r.issueId,
225
+ createdAt: r.createdAt,
226
+ })),
227
+ };
228
+ }
229
+ async function removeRelation(client, relationId) {
230
+ const result = await client.deleteIssueRelation(relationId);
231
+ return {
232
+ success: result.success,
233
+ relationId,
234
+ };
235
+ }
236
+ async function listBacklogIssues(client, projectName) {
237
+ const projects = await client.linearClient.projects({
238
+ filter: { name: { eqIgnoreCase: projectName } },
239
+ });
240
+ if (projects.nodes.length === 0) {
241
+ throw new Error(`Project not found: ${projectName}`);
242
+ }
243
+ const project = projects.nodes[0];
244
+ const issues = await client.linearClient.issues({
245
+ filter: {
246
+ project: { id: { eq: project.id } },
247
+ state: { name: { eqIgnoreCase: 'Backlog' } },
248
+ },
249
+ });
250
+ const results = [];
251
+ for (const issue of issues.nodes) {
252
+ const state = await issue.state;
253
+ const labels = await issue.labels();
254
+ results.push({
255
+ id: issue.id,
256
+ identifier: issue.identifier,
257
+ title: issue.title,
258
+ description: issue.description,
259
+ url: issue.url,
260
+ priority: issue.priority,
261
+ status: state?.name,
262
+ labels: labels.nodes.map((l) => l.name),
263
+ });
264
+ }
265
+ results.sort((a, b) => {
266
+ const aPriority = a.priority || 5;
267
+ const bPriority = b.priority || 5;
268
+ return aPriority - bPriority;
269
+ });
270
+ return results;
271
+ }
272
+ async function getBlockingIssues(client, issueId) {
273
+ const relations = await client.getIssueRelations(issueId);
274
+ const blockingIssues = [];
275
+ for (const relation of relations.inverseRelations) {
276
+ if (relation.type === 'blocks') {
277
+ const blockingIssue = await client.getIssue(relation.issueId);
278
+ const state = await blockingIssue.state;
279
+ const statusName = state?.name ?? 'Unknown';
280
+ if (statusName !== 'Accepted') {
281
+ blockingIssues.push({
282
+ identifier: blockingIssue.identifier,
283
+ title: blockingIssue.title,
284
+ status: statusName,
285
+ });
286
+ }
287
+ }
288
+ }
289
+ return blockingIssues;
290
+ }
291
+ async function listUnblockedBacklogIssues(client, projectName) {
292
+ const projects = await client.linearClient.projects({
293
+ filter: { name: { eqIgnoreCase: projectName } },
294
+ });
295
+ if (projects.nodes.length === 0) {
296
+ throw new Error(`Project not found: ${projectName}`);
297
+ }
298
+ const project = projects.nodes[0];
299
+ const issues = await client.linearClient.issues({
300
+ filter: {
301
+ project: { id: { eq: project.id } },
302
+ state: { name: { eqIgnoreCase: 'Backlog' } },
303
+ },
304
+ });
305
+ const results = [];
306
+ for (const issue of issues.nodes) {
307
+ const blockingIssues = await getBlockingIssues(client, issue.id);
308
+ const state = await issue.state;
309
+ const labels = await issue.labels();
310
+ results.push({
311
+ id: issue.id,
312
+ identifier: issue.identifier,
313
+ title: issue.title,
314
+ description: issue.description,
315
+ url: issue.url,
316
+ priority: issue.priority,
317
+ status: state?.name,
318
+ labels: labels.nodes.map((l) => l.name),
319
+ blocked: blockingIssues.length > 0,
320
+ blockedBy: blockingIssues,
321
+ });
322
+ }
323
+ const unblockedResults = results.filter((r) => !r.blocked);
324
+ unblockedResults.sort((a, b) => {
325
+ const aPriority = a.priority || 5;
326
+ const bPriority = b.priority || 5;
327
+ return aPriority - bPriority;
328
+ });
329
+ return unblockedResults;
330
+ }
331
+ async function checkBlocked(client, issueId) {
332
+ const blockingIssues = await getBlockingIssues(client, issueId);
333
+ return {
334
+ issueId,
335
+ blocked: blockingIssues.length > 0,
336
+ blockedBy: blockingIssues,
337
+ };
338
+ }
339
+ async function listSubIssues(client, issueId) {
340
+ const graph = await client.getSubIssueGraph(issueId);
341
+ return {
342
+ parentId: graph.parentId,
343
+ parentIdentifier: graph.parentIdentifier,
344
+ subIssueCount: graph.subIssues.length,
345
+ subIssues: graph.subIssues.map((node) => ({
346
+ id: node.issue.id,
347
+ identifier: node.issue.identifier,
348
+ title: node.issue.title,
349
+ status: node.issue.status,
350
+ priority: node.issue.priority,
351
+ labels: node.issue.labels,
352
+ url: node.issue.url,
353
+ blockedBy: node.blockedBy,
354
+ blocks: node.blocks,
355
+ })),
356
+ };
357
+ }
358
+ async function listSubIssueStatuses(client, issueId) {
359
+ const statuses = await client.getSubIssueStatuses(issueId);
360
+ return {
361
+ parentIssue: issueId,
362
+ subIssueCount: statuses.length,
363
+ subIssues: statuses,
364
+ allFinishedOrLater: statuses.every((s) => ['Finished', 'Delivered', 'Accepted', 'Canceled'].includes(s.status)),
365
+ incomplete: statuses.filter((s) => !['Finished', 'Delivered', 'Accepted', 'Canceled'].includes(s.status)),
366
+ };
367
+ }
368
+ async function updateSubIssue(client, issueId, options) {
369
+ const issue = await client.getIssue(issueId);
370
+ if (options.state) {
371
+ await client.updateIssueStatus(issue.id, options.state);
372
+ }
373
+ if (options.comment) {
374
+ await client.createComment(issue.id, options.comment);
375
+ }
376
+ const updatedIssue = await client.getIssue(issueId);
377
+ const state = await updatedIssue.state;
378
+ return {
379
+ id: updatedIssue.id,
380
+ identifier: updatedIssue.identifier,
381
+ title: updatedIssue.title,
382
+ status: state?.name,
383
+ url: updatedIssue.url,
384
+ };
385
+ }
386
+ async function checkDeployment(prNumber, format = 'json') {
387
+ const result = await checkPRDeploymentStatus(prNumber);
388
+ if (!result) {
389
+ throw new Error(`Could not get deployment status for PR #${prNumber}. Make sure the PR exists and you have access to it.`);
390
+ }
391
+ if (format === 'markdown') {
392
+ return formatDeploymentStatus(result);
393
+ }
394
+ return result;
395
+ }
396
+ async function createBlocker(client, options) {
397
+ // 1. Fetch source issue to resolve team/project
398
+ const sourceIssue = await client.getIssue(options.sourceIssueId);
399
+ const sourceTeam = await sourceIssue.team;
400
+ const sourceProject = await sourceIssue.project;
401
+ const teamName = options.team ?? sourceTeam?.name;
402
+ if (!teamName) {
403
+ throw new Error('Could not resolve team from source issue. Provide --team explicitly.');
404
+ }
405
+ const team = await client.getTeam(teamName);
406
+ const projectName = options.project ?? sourceProject?.name;
407
+ // 2. Deduplicate: check for existing Icebox issues with same title + "Needs Human" label
408
+ if (projectName) {
409
+ const projects = await client.linearClient.projects({
410
+ filter: { name: { eqIgnoreCase: projectName } },
411
+ });
412
+ if (projects.nodes.length > 0) {
413
+ const existingIssues = await client.linearClient.issues({
414
+ filter: {
415
+ project: { id: { eq: projects.nodes[0].id } },
416
+ state: { name: { eqIgnoreCase: 'Icebox' } },
417
+ labels: { name: { eqIgnoreCase: 'Needs Human' } },
418
+ },
419
+ });
420
+ const duplicate = existingIssues.nodes.find((i) => i.title.toLowerCase() === options.title.toLowerCase());
421
+ if (duplicate) {
422
+ // Add a +1 comment to the existing issue
423
+ await client.createComment(duplicate.id, `+1 — Also needed by ${sourceIssue.identifier}`);
424
+ return {
425
+ id: duplicate.id,
426
+ identifier: duplicate.identifier,
427
+ title: duplicate.title,
428
+ url: duplicate.url,
429
+ sourceIssue: sourceIssue.identifier,
430
+ relation: 'blocks',
431
+ deduplicated: true,
432
+ };
433
+ }
434
+ }
435
+ }
436
+ // 3. Create the blocker issue
437
+ const createPayload = {
438
+ teamId: team.id,
439
+ title: options.title,
440
+ };
441
+ // Description with source reference
442
+ const descParts = [];
443
+ if (options.description) {
444
+ descParts.push(options.description);
445
+ }
446
+ descParts.push(`\n---\n*Source issue: ${sourceIssue.identifier}*`);
447
+ createPayload.description = descParts.join('\n\n');
448
+ // Set state to Icebox
449
+ const statuses = await client.getTeamStatuses(team.id);
450
+ const iceboxStateId = statuses['Icebox'];
451
+ if (iceboxStateId) {
452
+ createPayload.stateId = iceboxStateId;
453
+ }
454
+ // Set "Needs Human" label
455
+ const allLabels = await client.linearClient.issueLabels();
456
+ const needsHumanLabel = allLabels.nodes.find((l) => l.name.toLowerCase() === 'needs human');
457
+ if (needsHumanLabel) {
458
+ createPayload.labelIds = [needsHumanLabel.id];
459
+ }
460
+ // Set project
461
+ if (projectName) {
462
+ const projects = await client.linearClient.projects({
463
+ filter: { name: { eqIgnoreCase: projectName } },
464
+ });
465
+ if (projects.nodes.length > 0) {
466
+ createPayload.projectId = projects.nodes[0].id;
467
+ }
468
+ }
469
+ const payload = await client.linearClient.createIssue(createPayload);
470
+ if (!payload.success) {
471
+ throw new Error('Failed to create blocker issue');
472
+ }
473
+ const blockerIssue = await payload.issue;
474
+ if (!blockerIssue) {
475
+ throw new Error('Blocker issue created but not returned');
476
+ }
477
+ // 4. Create blocking relation: blocker blocks source
478
+ await client.createIssueRelation({
479
+ issueId: blockerIssue.id,
480
+ relatedIssueId: sourceIssue.id,
481
+ type: 'blocks',
482
+ });
483
+ // 5. Post comment on source issue
484
+ await client.createComment(sourceIssue.id, `\u{1F6A7} Human blocker created: [${blockerIssue.identifier}](${blockerIssue.url}) — ${options.title}`);
485
+ // 6. Optionally assign
486
+ if (options.assignee) {
487
+ const users = await client.linearClient.users({
488
+ filter: {
489
+ or: [
490
+ { name: { eqIgnoreCase: options.assignee } },
491
+ { email: { eq: options.assignee } },
492
+ ],
493
+ },
494
+ });
495
+ if (users.nodes.length > 0) {
496
+ await client.linearClient.updateIssue(blockerIssue.id, {
497
+ assigneeId: users.nodes[0].id,
498
+ });
499
+ }
500
+ }
501
+ return {
502
+ id: blockerIssue.id,
503
+ identifier: blockerIssue.identifier,
504
+ title: blockerIssue.title,
505
+ url: blockerIssue.url,
506
+ sourceIssue: sourceIssue.identifier,
507
+ relation: 'blocks',
508
+ deduplicated: false,
509
+ };
510
+ }
511
+ // ── Commands that don't require LINEAR_API_KEY ─────────────────────
512
+ const NO_API_KEY_COMMANDS = new Set(['check-deployment']);
513
+ // ── Main runner ────────────────────────────────────────────────────
514
+ export async function runLinear(config) {
515
+ const { command, args, positionalArgs, apiKey } = config;
516
+ // Lazy client — only created for commands that need it
517
+ let _client = null;
518
+ function client() {
519
+ if (!_client) {
520
+ if (!apiKey) {
521
+ throw new Error('LINEAR_API_KEY environment variable is required');
522
+ }
523
+ _client = createLinearAgentClient({ apiKey });
524
+ }
525
+ return _client;
526
+ }
527
+ // Validate API key for commands that need it
528
+ if (!NO_API_KEY_COMMANDS.has(command) && !apiKey) {
529
+ throw new Error('LINEAR_API_KEY environment variable is required');
530
+ }
531
+ // Helper: get first positional or error
532
+ function requirePositional(name) {
533
+ const val = positionalArgs[0];
534
+ if (!val || val.startsWith('--')) {
535
+ throw new Error(`Missing required argument: <${name}>`);
536
+ }
537
+ return val;
538
+ }
539
+ // Parse sub-options from the original args (for commands that re-parse after positional)
540
+ function subArgs() {
541
+ return args;
542
+ }
543
+ let output;
544
+ switch (command) {
545
+ case 'get-issue': {
546
+ const issueId = requirePositional('issue-id');
547
+ output = await getIssue(client(), issueId);
548
+ break;
549
+ }
550
+ case 'create-issue': {
551
+ if (!args.title || !args.team) {
552
+ throw new Error('Usage: af-linear create-issue --title "Title" --team "Team" [--description "..."] [--project "..."] [--labels "Label1,Label2"] [--state "Backlog"] [--parentId "..."]');
553
+ }
554
+ output = await createIssue(client(), {
555
+ title: args.title,
556
+ team: args.team,
557
+ description: args.description,
558
+ project: args.project,
559
+ labels: args.labels,
560
+ state: args.state,
561
+ parentId: args.parentId,
562
+ });
563
+ break;
564
+ }
565
+ case 'update-issue': {
566
+ const issueId = requirePositional('issue-id');
567
+ const opts = subArgs();
568
+ output = await updateIssue(client(), issueId, {
569
+ title: opts.title,
570
+ description: opts.description,
571
+ state: opts.state,
572
+ labels: opts.labels,
573
+ });
574
+ break;
575
+ }
576
+ case 'list-comments': {
577
+ const issueId = requirePositional('issue-id');
578
+ output = await listComments(client(), issueId);
579
+ break;
580
+ }
581
+ case 'create-comment': {
582
+ const issueId = requirePositional('issue-id');
583
+ if (!args.body) {
584
+ throw new Error('Usage: af-linear create-comment <issue-id> --body "Comment text"');
585
+ }
586
+ output = await createComment(client(), issueId, args.body);
587
+ break;
588
+ }
589
+ case 'list-backlog-issues': {
590
+ if (!args.project) {
591
+ throw new Error('Usage: af-linear list-backlog-issues --project "ProjectName"');
592
+ }
593
+ output = await listBacklogIssues(client(), args.project);
594
+ break;
595
+ }
596
+ case 'list-unblocked-backlog': {
597
+ if (!args.project) {
598
+ throw new Error('Usage: af-linear list-unblocked-backlog --project "ProjectName"');
599
+ }
600
+ output = await listUnblockedBacklogIssues(client(), args.project);
601
+ break;
602
+ }
603
+ case 'check-blocked': {
604
+ const issueId = requirePositional('issue-id');
605
+ output = await checkBlocked(client(), issueId);
606
+ break;
607
+ }
608
+ case 'add-relation': {
609
+ const issueId = positionalArgs[0];
610
+ const relatedIssueId = positionalArgs[1];
611
+ const relationType = args.type;
612
+ if (!issueId ||
613
+ issueId.startsWith('--') ||
614
+ !relatedIssueId ||
615
+ relatedIssueId.startsWith('--') ||
616
+ !relationType ||
617
+ !['related', 'blocks', 'duplicate'].includes(relationType)) {
618
+ throw new Error('Usage: af-linear add-relation <issue-id> <related-issue-id> --type <related|blocks|duplicate>');
619
+ }
620
+ output = await addRelation(client(), issueId, relatedIssueId, relationType);
621
+ break;
622
+ }
623
+ case 'list-relations': {
624
+ const issueId = requirePositional('issue-id');
625
+ output = await listRelations(client(), issueId);
626
+ break;
627
+ }
628
+ case 'remove-relation': {
629
+ const relationId = requirePositional('relation-id');
630
+ output = await removeRelation(client(), relationId);
631
+ break;
632
+ }
633
+ case 'list-sub-issues': {
634
+ const issueId = requirePositional('issue-id');
635
+ output = await listSubIssues(client(), issueId);
636
+ break;
637
+ }
638
+ case 'list-sub-issue-statuses': {
639
+ const issueId = requirePositional('issue-id');
640
+ output = await listSubIssueStatuses(client(), issueId);
641
+ break;
642
+ }
643
+ case 'update-sub-issue': {
644
+ const issueId = requirePositional('issue-id');
645
+ const opts = subArgs();
646
+ output = await updateSubIssue(client(), issueId, {
647
+ state: opts.state,
648
+ comment: opts.comment,
649
+ });
650
+ break;
651
+ }
652
+ case 'check-deployment': {
653
+ const prArg = requirePositional('pr-number');
654
+ const prNumber = parseInt(prArg, 10);
655
+ if (isNaN(prNumber)) {
656
+ throw new Error('PR number must be a valid integer');
657
+ }
658
+ const format = args.format || 'json';
659
+ output = await checkDeployment(prNumber, format);
660
+ break;
661
+ }
662
+ case 'create-blocker': {
663
+ const sourceIssueId = requirePositional('source-issue-id');
664
+ if (!args.title) {
665
+ throw new Error('Usage: af-linear create-blocker <source-issue-id> --title "Title" [--description "..."] [--team "..."] [--project "..."] [--assignee "user@email.com"]');
666
+ }
667
+ output = await createBlocker(client(), {
668
+ title: args.title,
669
+ sourceIssueId,
670
+ description: args.description,
671
+ team: args.team,
672
+ project: args.project,
673
+ assignee: args.assignee,
674
+ });
675
+ break;
676
+ }
677
+ default:
678
+ throw new Error(`Unknown command: ${command}`);
679
+ }
680
+ return { output };
681
+ }
@@ -1 +1 @@
1
- {"version":3,"file":"worker-runner.d.ts","sourceRoot":"","sources":["../../../src/lib/worker-runner.ts"],"names":[],"mappings":"AAAA;;;;;;GAMG;AAmBH,MAAM,WAAW,kBAAkB;IACjC,0BAA0B;IAC1B,MAAM,EAAE,MAAM,CAAA;IACd,iCAAiC;IACjC,MAAM,EAAE,MAAM,CAAA;IACd,+CAA+C;IAC/C,QAAQ,CAAC,EAAE,MAAM,CAAA;IACjB,6CAA6C;IAC7C,QAAQ,CAAC,EAAE,MAAM,CAAA;IACjB,mDAAmD;IACnD,MAAM,CAAC,EAAE,OAAO,CAAA;IAChB,gFAAgF;IAChF,YAAY,CAAC,EAAE,MAAM,CAAA;IACrB,iDAAiD;IACjD,OAAO,CAAC,EAAE,MAAM,CAAA;IAChB,uDAAuD;IACvD,QAAQ,CAAC,EAAE,MAAM,EAAE,CAAA;CACpB;AAwED;;;;;GAKG;AACH,wBAAsB,SAAS,CAC7B,MAAM,EAAE,kBAAkB,EAC1B,MAAM,CAAC,EAAE,WAAW,GACnB,OAAO,CAAC,IAAI,CAAC,CAm4Bf"}
1
+ {"version":3,"file":"worker-runner.d.ts","sourceRoot":"","sources":["../../../src/lib/worker-runner.ts"],"names":[],"mappings":"AAAA;;;;;;GAMG;AAmBH,MAAM,WAAW,kBAAkB;IACjC,0BAA0B;IAC1B,MAAM,EAAE,MAAM,CAAA;IACd,iCAAiC;IACjC,MAAM,EAAE,MAAM,CAAA;IACd,+CAA+C;IAC/C,QAAQ,CAAC,EAAE,MAAM,CAAA;IACjB,6CAA6C;IAC7C,QAAQ,CAAC,EAAE,MAAM,CAAA;IACjB,mDAAmD;IACnD,MAAM,CAAC,EAAE,OAAO,CAAA;IAChB,gFAAgF;IAChF,YAAY,CAAC,EAAE,MAAM,CAAA;IACrB,iDAAiD;IACjD,OAAO,CAAC,EAAE,MAAM,CAAA;IAChB,uDAAuD;IACvD,QAAQ,CAAC,EAAE,MAAM,EAAE,CAAA;CACpB;AAwED;;;;;GAKG;AACH,wBAAsB,SAAS,CAC7B,MAAM,EAAE,kBAAkB,EAC1B,MAAM,CAAC,EAAE,WAAW,GACnB,OAAO,CAAC,IAAI,CAAC,CAw5Bf"}
@@ -54,6 +54,7 @@ export async function runWorker(config, signal) {
54
54
  let shutdownInProgress = false;
55
55
  let consecutiveHeartbeatFailures = 0;
56
56
  let reregistrationInProgress = false;
57
+ let claimFailureCount = 0;
57
58
  const activeOrchestrators = new Map();
58
59
  // Logger — will be re-created after registration with worker context
59
60
  let log = createLogger({}, { showTimestamp: true });
@@ -241,6 +242,10 @@ export async function runWorker(config, signal) {
241
242
  });
242
243
  if (result.data) {
243
244
  consecutiveHeartbeatFailures = 0;
245
+ if (claimFailureCount > 0) {
246
+ log.info('Claim race summary since last heartbeat', { claimFailures: claimFailureCount });
247
+ claimFailureCount = 0;
248
+ }
244
249
  log.debug('Heartbeat acknowledged', {
245
250
  activeCount,
246
251
  pendingWorkCount: result.data.pendingWorkCount,
@@ -608,6 +613,19 @@ export async function runWorker(config, signal) {
608
613
  workerShortId = registration.workerId.substring(0, 8);
609
614
  // Update logger with worker context
610
615
  log = createLogger({ workerId, workerShortId }, { showTimestamp: true });
616
+ // Auto-inherit projects from server if not explicitly configured
617
+ if (!config.projects?.length) {
618
+ try {
619
+ const serverConfig = await apiRequest('/api/config');
620
+ if (serverConfig?.projects?.length) {
621
+ config.projects = serverConfig.projects;
622
+ log.info('Auto-inherited projects from server', { projects: config.projects });
623
+ }
624
+ }
625
+ catch {
626
+ log.debug('Could not fetch server config, using no project filter');
627
+ }
628
+ }
611
629
  // Set up heartbeat
612
630
  heartbeatTimer = setInterval(() => sendHeartbeat(), registration.heartbeatInterval);
613
631
  // Send initial heartbeat
@@ -644,7 +662,8 @@ export async function runWorker(config, signal) {
644
662
  }
645
663
  }
646
664
  else {
647
- log.warn(`Failed to claim work: ${item.issueIdentifier}`);
665
+ claimFailureCount++;
666
+ log.debug(`Failed to claim work: ${item.issueIdentifier}`);
648
667
  }
649
668
  }
650
669
  }
@@ -0,0 +1,37 @@
1
+ #!/usr/bin/env node
2
+ /**
3
+ * AgentFactory Linear CLI
4
+ *
5
+ * Thin wrapper around the linear runner.
6
+ *
7
+ * Usage:
8
+ * af-linear <command> [options]
9
+ *
10
+ * Commands:
11
+ * get-issue <id> Get issue details
12
+ * create-issue Create a new issue
13
+ * update-issue <id> Update an existing issue
14
+ * list-comments <issueId> List comments on an issue
15
+ * create-comment <issueId> Create a comment on an issue
16
+ * list-backlog-issues List backlog issues for a project
17
+ * list-unblocked-backlog List unblocked backlog issues
18
+ * check-blocked <id> Check if an issue is blocked
19
+ * add-relation <id> <id> Create relation between issues
20
+ * list-relations <id> List relations for an issue
21
+ * remove-relation <id> Remove a relation by ID
22
+ * list-sub-issues <id> List sub-issues of a parent issue
23
+ * list-sub-issue-statuses <id> List sub-issue statuses (lightweight)
24
+ * update-sub-issue <id> Update sub-issue status with comment
25
+ * check-deployment <PR> Check Vercel deployment status for a PR
26
+ * create-blocker <source-id> Create a human-needed blocker issue
27
+ *
28
+ * Array Values:
29
+ * --labels accepts comma-separated: --labels "Bug,Feature"
30
+ * For values with commas, use JSON: --labels '["Bug", "UI, UX"]'
31
+ * Text fields (--description, --title, --body) preserve commas.
32
+ *
33
+ * Environment:
34
+ * LINEAR_API_KEY Required API key for authentication
35
+ */
36
+ export {};
37
+ //# sourceMappingURL=linear.d.ts.map
@@ -0,0 +1 @@
1
+ {"version":3,"file":"linear.d.ts","sourceRoot":"","sources":["../../src/linear.ts"],"names":[],"mappings":";AACA;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;GAiCG"}
@@ -0,0 +1,118 @@
1
+ #!/usr/bin/env node
2
+ /**
3
+ * AgentFactory Linear CLI
4
+ *
5
+ * Thin wrapper around the linear runner.
6
+ *
7
+ * Usage:
8
+ * af-linear <command> [options]
9
+ *
10
+ * Commands:
11
+ * get-issue <id> Get issue details
12
+ * create-issue Create a new issue
13
+ * update-issue <id> Update an existing issue
14
+ * list-comments <issueId> List comments on an issue
15
+ * create-comment <issueId> Create a comment on an issue
16
+ * list-backlog-issues List backlog issues for a project
17
+ * list-unblocked-backlog List unblocked backlog issues
18
+ * check-blocked <id> Check if an issue is blocked
19
+ * add-relation <id> <id> Create relation between issues
20
+ * list-relations <id> List relations for an issue
21
+ * remove-relation <id> Remove a relation by ID
22
+ * list-sub-issues <id> List sub-issues of a parent issue
23
+ * list-sub-issue-statuses <id> List sub-issue statuses (lightweight)
24
+ * update-sub-issue <id> Update sub-issue status with comment
25
+ * check-deployment <PR> Check Vercel deployment status for a PR
26
+ * create-blocker <source-id> Create a human-needed blocker issue
27
+ *
28
+ * Array Values:
29
+ * --labels accepts comma-separated: --labels "Bug,Feature"
30
+ * For values with commas, use JSON: --labels '["Bug", "UI, UX"]'
31
+ * Text fields (--description, --title, --body) preserve commas.
32
+ *
33
+ * Environment:
34
+ * LINEAR_API_KEY Required API key for authentication
35
+ */
36
+ import path from 'path';
37
+ import { config } from 'dotenv';
38
+ // Load environment variables from .env.local
39
+ config({ path: path.resolve(process.cwd(), '.env.local') });
40
+ import { runLinear, parseLinearArgs } from './lib/linear-runner.js';
41
+ function printHelp() {
42
+ console.log(`
43
+ AgentFactory Linear CLI — Linear issue tracker operations
44
+
45
+ Usage:
46
+ af-linear <command> [options]
47
+
48
+ Commands:
49
+ get-issue <id> Get issue details
50
+ create-issue Create a new issue
51
+ update-issue <id> Update an existing issue
52
+ list-comments <issueId> List comments on an issue
53
+ create-comment <issueId> Create a comment on an issue
54
+ list-backlog-issues List backlog issues for a project
55
+ list-unblocked-backlog List unblocked backlog issues
56
+ check-blocked <id> Check if an issue is blocked
57
+ add-relation <id> <id> Create relation between issues
58
+ list-relations <id> List relations for an issue
59
+ remove-relation <id> Remove a relation by ID
60
+ list-sub-issues <id> List sub-issues of a parent issue
61
+ list-sub-issue-statuses <id> List sub-issue statuses (lightweight)
62
+ update-sub-issue <id> Update sub-issue status with comment
63
+ check-deployment <PR> Check Vercel deployment status for a PR
64
+ create-blocker <source-id> Create a human-needed blocker issue
65
+ help Show this help message
66
+
67
+ Options:
68
+ --help, -h Show this help message
69
+
70
+ Array Values:
71
+ --labels accepts comma-separated: --labels "Bug,Feature"
72
+ For values with commas, use JSON: --labels '["Bug", "UI, UX"]'
73
+
74
+ Environment:
75
+ LINEAR_API_KEY Required API key for authentication
76
+ LINEAR_ACCESS_TOKEN Alternative to LINEAR_API_KEY
77
+
78
+ Examples:
79
+ af-linear get-issue PROJ-123
80
+ af-linear create-issue --title "Add auth" --team "Engineering" --project "Backend"
81
+ af-linear update-issue PROJ-123 --state "Finished"
82
+ af-linear list-backlog-issues --project "MyProject"
83
+ af-linear check-deployment 42
84
+ `);
85
+ }
86
+ async function main() {
87
+ const { command, args, positionalArgs } = parseLinearArgs(process.argv.slice(2));
88
+ if (!command || command === 'help' || args['help'] || args['h']) {
89
+ printHelp();
90
+ return;
91
+ }
92
+ const apiKey = process.env.LINEAR_API_KEY || process.env.LINEAR_ACCESS_TOKEN;
93
+ const result = await runLinear({
94
+ command,
95
+ args,
96
+ positionalArgs,
97
+ apiKey,
98
+ });
99
+ // check-deployment with markdown format outputs a string, not JSON
100
+ if (typeof result.output === 'string') {
101
+ console.log(result.output);
102
+ }
103
+ else {
104
+ console.log(JSON.stringify(result.output, null, 2));
105
+ }
106
+ // Exit with error code if deployment check failed
107
+ if (command === 'check-deployment' &&
108
+ typeof result.output === 'object' &&
109
+ result.output !== null &&
110
+ 'anyFailed' in result.output &&
111
+ result.output.anyFailed) {
112
+ process.exit(1);
113
+ }
114
+ }
115
+ main().catch((error) => {
116
+ console.error('Error:', error instanceof Error ? error.message : error);
117
+ process.exit(1);
118
+ });
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@supaku/agentfactory-cli",
3
- "version": "0.7.5",
3
+ "version": "0.7.7",
4
4
  "type": "module",
5
5
  "description": "CLI tools for AgentFactory — local orchestrator, remote worker, queue admin",
6
6
  "author": "Supaku (https://supaku.com)",
@@ -33,7 +33,8 @@
33
33
  "af-worker-fleet": "./dist/src/worker-fleet.js",
34
34
  "af-cleanup": "./dist/src/cleanup.js",
35
35
  "af-queue-admin": "./dist/src/queue-admin.js",
36
- "af-analyze-logs": "./dist/src/analyze-logs.js"
36
+ "af-analyze-logs": "./dist/src/analyze-logs.js",
37
+ "af-linear": "./dist/src/linear.js"
37
38
  },
38
39
  "main": "./dist/src/index.js",
39
40
  "module": "./dist/src/index.js",
@@ -73,6 +74,11 @@
73
74
  "types": "./dist/src/lib/analyze-logs-runner.d.ts",
74
75
  "import": "./dist/src/lib/analyze-logs-runner.js",
75
76
  "default": "./dist/src/lib/analyze-logs-runner.js"
77
+ },
78
+ "./linear": {
79
+ "types": "./dist/src/lib/linear-runner.d.ts",
80
+ "import": "./dist/src/lib/linear-runner.js",
81
+ "default": "./dist/src/lib/linear-runner.js"
76
82
  }
77
83
  },
78
84
  "files": [
@@ -82,9 +88,9 @@
82
88
  ],
83
89
  "dependencies": {
84
90
  "dotenv": "^17.2.3",
85
- "@supaku/agentfactory-linear": "0.7.5",
86
- "@supaku/agentfactory-server": "0.7.5",
87
- "@supaku/agentfactory": "0.7.5"
91
+ "@supaku/agentfactory": "0.7.7",
92
+ "@supaku/agentfactory-server": "0.7.7",
93
+ "@supaku/agentfactory-linear": "0.7.7"
88
94
  },
89
95
  "devDependencies": {
90
96
  "@types/node": "^22.5.4",