WIP: A simple cli for daily tangled use cases and AI integration. This is for my personal use right now, but happy if others get mileage from it! :)
1import { Command } from 'commander';
2import type { TangledApiClient } from '../lib/api-client.js';
3import { createApiClient } from '../lib/api-client.js';
4import { getCurrentRepoContext } from '../lib/context.js';
5import type { IssueData } from '../lib/issues-api.js';
6import {
7 closeIssue,
8 createIssue,
9 getCompleteIssueData,
10 getIssueState,
11 listIssues,
12 reopenIssue,
13 resolveSequentialNumber,
14 updateIssue,
15} from '../lib/issues-api.js';
16import { buildRepoAtUri } from '../utils/at-uri.js';
17import { ensureAuthenticated, requireAuth } from '../utils/auth-helpers.js';
18import { readBodyInput } from '../utils/body-input.js';
19import { formatDate, formatIssueState, outputJson } from '../utils/formatting.js';
20import { validateIssueBody, validateIssueTitle } from '../utils/validation.js';
21
22/**
23 * Extract rkey from AT-URI
24 */
25function extractRkey(uri: string): string {
26 const parts = uri.split('/');
27 return parts[parts.length - 1] || 'unknown';
28}
29
30/**
31 * Resolve issue number or rkey to full AT-URI
32 * @param input - User input: number ("1"), hash ("#1"), or rkey ("3mef...")
33 * @param client - API client
34 * @param repoAtUri - Repository AT-URI
35 * @returns Object with full issue AT-URI and display identifier
36 */
37async function resolveIssueUri(
38 input: string,
39 client: TangledApiClient,
40 repoAtUri: string
41): Promise<{ uri: string; displayId: string }> {
42 // Strip # prefix if present
43 const normalized = input.startsWith('#') ? input.slice(1) : input;
44
45 // Check if numeric
46 if (/^\d+$/.test(normalized)) {
47 const num = Number.parseInt(normalized, 10);
48
49 if (num < 1) {
50 throw new Error('Issue number must be greater than 0');
51 }
52
53 // Query all issues for this repo
54 const { issues } = await listIssues({
55 client,
56 repoAtUri,
57 limit: 100, // Adjust if needed for large repos
58 });
59
60 // Sort by creation time (oldest first)
61 const sorted = issues.sort(
62 (a, b) => new Date(a.createdAt).getTime() - new Date(b.createdAt).getTime()
63 );
64
65 // Get issue at index (1-based numbering)
66 const issue = sorted[num - 1];
67 if (!issue) {
68 throw new Error(`Issue #${num} not found`);
69 }
70
71 return {
72 uri: issue.uri,
73 displayId: `#${num}`,
74 };
75 }
76
77 // Treat as rkey - validate and build URI
78 if (!/^[a-zA-Z0-9._-]+$/.test(normalized)) {
79 throw new Error(`Invalid issue identifier: ${input}`);
80 }
81
82 const session = await requireAuth(client);
83 return {
84 uri: `at://${session.did}/sh.tangled.repo.issue/${normalized}`,
85 displayId: normalized,
86 };
87}
88
89/**
90 * A custom subclass of Command with support for adding the common issue JSON flag.
91 */
92class IssueCommand extends Command {
93 addIssueJsonOption() {
94 return this.option(
95 '--json [fields]',
96 'Output JSON; optionally specify comma-separated fields (number, title, body, state, author, createdAt, uri, cid)'
97 );
98 }
99}
100
101/**
102 * Issue view subcommand
103 */
104function createViewCommand(): Command {
105 return new IssueCommand('view')
106 .description('View details of a specific issue')
107 .argument('<issue-id>', 'Issue number (e.g., 1, #2) or rkey')
108 .addIssueJsonOption()
109 .action(async (issueId: string, options: { json?: string | true }) => {
110 try {
111 // 1. Validate auth
112 const client = createApiClient();
113 await ensureAuthenticated(client);
114
115 // 2. Get repo context
116 const context = await getCurrentRepoContext();
117 if (!context) {
118 console.error('✗ Not in a Tangled repository');
119 console.error('\nTo use this repository with Tangled, add a remote:');
120 console.error(' git remote add origin git@tangled.org:<did>/<repo>.git');
121 process.exit(1);
122 }
123
124 // 3. Build repo AT-URI
125 const repoAtUri = await buildRepoAtUri(context.owner, context.name, client);
126
127 // 4. Resolve issue ID to URI
128 const { uri: issueUri, displayId } = await resolveIssueUri(issueId, client, repoAtUri);
129
130 // 5. Fetch complete issue data (record, sequential number, state)
131 const issueData = await getCompleteIssueData(client, issueUri, displayId, repoAtUri);
132
133 // 6. Output result
134 if (options.json !== undefined) {
135 outputJson(issueData, typeof options.json === 'string' ? options.json : undefined);
136 return;
137 }
138
139 console.log(`\nIssue ${displayId} ${formatIssueState(issueData.state)}`);
140 console.log(`Title: ${issueData.title}`);
141 console.log(`Author: ${issueData.author}`);
142 console.log(`Created: ${formatDate(issueData.createdAt)}`);
143 console.log(`Repo: ${context.name}`);
144 console.log(`URI: ${issueData.uri}`);
145
146 if (issueData.body) {
147 console.log('\nBody:');
148 console.log(issueData.body);
149 }
150
151 console.log(); // Empty line at end
152 } catch (error) {
153 console.error(
154 `✗ Failed to view issue: ${error instanceof Error ? error.message : 'Unknown error'}`
155 );
156 process.exit(1);
157 }
158 });
159}
160
161/**
162 * Issue edit subcommand
163 */
164function createEditCommand(): Command {
165 return new IssueCommand('edit')
166 .description('Edit an issue title and/or body')
167 .argument('<issue-id>', 'Issue number or rkey')
168 .option('-t, --title <string>', 'New issue title')
169 .option('-b, --body <string>', 'New issue body text')
170 .option('-F, --body-file <path>', 'Read body from file (- for stdin)')
171 .addIssueJsonOption()
172 .action(
173 async (
174 issueId: string,
175 options: { title?: string; body?: string; bodyFile?: string; json?: string | true }
176 ) => {
177 try {
178 // 1. Validate at least one option provided
179 if (!options.title && !options.body && !options.bodyFile) {
180 console.error('✗ At least one of --title, --body, or --body-file must be provided');
181 process.exit(1);
182 }
183
184 // 2. Validate auth
185 const client = createApiClient();
186 await ensureAuthenticated(client);
187
188 // 3. Get repo context
189 const context = await getCurrentRepoContext();
190 if (!context) {
191 console.error('✗ Not in a Tangled repository');
192 console.error('\nTo use this repository with Tangled, add a remote:');
193 console.error(' git remote add origin git@tangled.org:<did>/<repo>.git');
194 process.exit(1);
195 }
196
197 // 4. Build repo AT-URI
198 const repoAtUri = await buildRepoAtUri(context.owner, context.name, client);
199
200 // 5. Resolve issue ID to URI
201 const { uri: issueUri, displayId } = await resolveIssueUri(issueId, client, repoAtUri);
202
203 // 6. Handle body input
204 const body = await readBodyInput(options.body, options.bodyFile);
205
206 // 7. Validate inputs
207 const validTitle = options.title ? validateIssueTitle(options.title) : undefined;
208 const validBody = body !== undefined ? validateIssueBody(body) : undefined;
209
210 // 8. Update issue
211 const updatedIssue = await updateIssue({
212 client,
213 issueUri,
214 title: validTitle,
215 body: validBody,
216 });
217
218 // 9. Output result
219 if (options.json !== undefined) {
220 const [number, state] = await Promise.all([
221 resolveSequentialNumber(displayId, updatedIssue.uri, client, repoAtUri),
222 getIssueState({ client, issueUri: updatedIssue.uri }),
223 ]);
224 const issueData: IssueData = {
225 number,
226 title: updatedIssue.title,
227 body: updatedIssue.body,
228 state,
229 author: updatedIssue.author,
230 createdAt: updatedIssue.createdAt,
231 uri: updatedIssue.uri,
232 cid: updatedIssue.cid,
233 };
234 outputJson(issueData, typeof options.json === 'string' ? options.json : undefined);
235 return;
236 }
237
238 const updated: string[] = [];
239 if (validTitle !== undefined) updated.push('title');
240 if (validBody !== undefined) updated.push('body');
241
242 console.log(`✓ Issue ${displayId} updated`);
243 console.log(` Updated: ${updated.join(', ')}`);
244 } catch (error) {
245 console.error(
246 `✗ Failed to edit issue: ${error instanceof Error ? error.message : 'Unknown error'}`
247 );
248 process.exit(1);
249 }
250 }
251 );
252}
253
254/**
255 * Issue close subcommand
256 */
257function createCloseCommand(): Command {
258 return new IssueCommand('close')
259 .description('Close an issue')
260 .argument('<issue-id>', 'Issue number or rkey')
261 .addIssueJsonOption()
262 .action(async (issueId: string, options: { json?: string | true }) => {
263 try {
264 // 1. Validate auth
265 const client = createApiClient();
266 await ensureAuthenticated(client);
267
268 // 2. Get repo context
269 const context = await getCurrentRepoContext();
270 if (!context) {
271 console.error('✗ Not in a Tangled repository');
272 console.error('\nTo use this repository with Tangled, add a remote:');
273 console.error(' git remote add origin git@tangled.org:<did>/<repo>.git');
274 process.exit(1);
275 }
276
277 // 3. Build repo AT-URI
278 const repoAtUri = await buildRepoAtUri(context.owner, context.name, client);
279
280 // 4. Resolve issue ID to URI
281 const { uri: issueUri, displayId } = await resolveIssueUri(issueId, client, repoAtUri);
282
283 // 5. Fetch complete issue data (state will be 'closed' after operation)
284 const issueData = await getCompleteIssueData(
285 client,
286 issueUri,
287 displayId,
288 repoAtUri,
289 'closed'
290 );
291
292 // 6. Close issue
293 await closeIssue({ client, issueUri });
294
295 // 7. Display success
296 if (options.json !== undefined) {
297 outputJson(issueData, typeof options.json === 'string' ? options.json : undefined);
298 } else {
299 console.log(`✓ Issue ${displayId} closed`);
300 console.log(` Title: ${issueData.title}`);
301 }
302 } catch (error) {
303 console.error(
304 `✗ Failed to close issue: ${error instanceof Error ? error.message : 'Unknown error'}`
305 );
306 process.exit(1);
307 }
308 });
309}
310
311/**
312 * Issue reopen subcommand
313 */
314function createReopenCommand(): Command {
315 return new IssueCommand('reopen')
316 .description('Reopen a closed issue')
317 .argument('<issue-id>', 'Issue number or rkey')
318 .addIssueJsonOption()
319 .action(async (issueId: string, options: { json?: string | true }) => {
320 try {
321 // 1. Validate auth
322 const client = createApiClient();
323 await ensureAuthenticated(client);
324
325 // 2. Get repo context
326 const context = await getCurrentRepoContext();
327 if (!context) {
328 console.error('✗ Not in a Tangled repository');
329 console.error('\nTo use this repository with Tangled, add a remote:');
330 console.error(' git remote add origin git@tangled.org:<did>/<repo>.git');
331 process.exit(1);
332 }
333
334 // 3. Build repo AT-URI
335 const repoAtUri = await buildRepoAtUri(context.owner, context.name, client);
336
337 // 4. Resolve issue ID to URI
338 const { uri: issueUri, displayId } = await resolveIssueUri(issueId, client, repoAtUri);
339
340 // 5. Fetch complete issue data (state will be 'open' after operation)
341 const issueData = await getCompleteIssueData(
342 client,
343 issueUri,
344 displayId,
345 repoAtUri,
346 'open'
347 );
348
349 // 6. Reopen issue
350 await reopenIssue({ client, issueUri });
351
352 // 7. Display success
353 if (options.json !== undefined) {
354 outputJson(issueData, typeof options.json === 'string' ? options.json : undefined);
355 } else {
356 console.log(`✓ Issue ${displayId} reopened`);
357 console.log(` Title: ${issueData.title}`);
358 }
359 } catch (error) {
360 console.error(
361 `✗ Failed to reopen issue: ${error instanceof Error ? error.message : 'Unknown error'}`
362 );
363 process.exit(1);
364 }
365 });
366}
367
368/**
369 * Create the issue command with all subcommands
370 */
371export function createIssueCommand(): Command {
372 const issue = new Command('issue');
373 issue.description('Manage issues in Tangled repositories');
374
375 issue.addCommand(createCreateCommand());
376 issue.addCommand(createListCommand());
377 issue.addCommand(createViewCommand());
378 issue.addCommand(createEditCommand());
379 issue.addCommand(createCloseCommand());
380 issue.addCommand(createReopenCommand());
381
382 return issue;
383}
384
385/**
386 * Issue create subcommand
387 */
388function createCreateCommand(): Command {
389 return new IssueCommand('create')
390 .description('Create a new issue')
391 .argument('<title>', 'Issue title')
392 .option('-b, --body <string>', 'Issue body text')
393 .option('-F, --body-file <path>', 'Read body from file (- for stdin)')
394 .addIssueJsonOption()
395 .action(
396 async (
397 title: string,
398 options: { body?: string; bodyFile?: string; json?: string | true }
399 ) => {
400 try {
401 // 1. Validate auth
402 const client = createApiClient();
403 await ensureAuthenticated(client);
404
405 // 2. Get repo context
406 const context = await getCurrentRepoContext();
407 if (!context) {
408 console.error('✗ Not in a Tangled repository');
409 console.error('\nTo use this repository with Tangled, add a remote:');
410 console.error(' git remote add origin git@tangled.org:<did>/<repo>.git');
411 process.exit(1);
412 }
413
414 // 3. Validate title
415 const validTitle = validateIssueTitle(title);
416
417 // 4. Handle body input
418 const body = await readBodyInput(options.body, options.bodyFile);
419 if (body !== undefined) {
420 validateIssueBody(body);
421 }
422
423 // 5. Build repo AT-URI
424 const repoAtUri = await buildRepoAtUri(context.owner, context.name, client);
425
426 // 6. Create issue (suppress progress message in JSON mode)
427 if (options.json === undefined) {
428 console.log('Creating issue...');
429 }
430 const issue = await createIssue({
431 client,
432 repoAtUri,
433 title: validTitle,
434 body,
435 });
436
437 // 7. Compute sequential number
438 const { issues: allIssues } = await listIssues({ client, repoAtUri, limit: 100 });
439 const sortedAll = allIssues.sort(
440 (a, b) => new Date(a.createdAt).getTime() - new Date(b.createdAt).getTime()
441 );
442 const idx = sortedAll.findIndex((i) => i.uri === issue.uri);
443 const number = idx >= 0 ? idx + 1 : undefined;
444
445 // 8. Output result
446 if (options.json !== undefined) {
447 const issueData: IssueData = {
448 number,
449 title: issue.title,
450 body: issue.body,
451 state: 'open',
452 author: issue.author,
453 createdAt: issue.createdAt,
454 uri: issue.uri,
455 cid: issue.cid,
456 };
457 outputJson(issueData, typeof options.json === 'string' ? options.json : undefined);
458 return;
459 }
460
461 const displayNumber = number !== undefined ? `#${number}` : extractRkey(issue.uri);
462 console.log(`\n✓ Issue ${displayNumber} created`);
463 console.log(` Title: ${issue.title}`);
464 console.log(` URI: ${issue.uri}`);
465 } catch (error) {
466 console.error(
467 `✗ Failed to create issue: ${error instanceof Error ? error.message : 'Unknown error'}`
468 );
469 process.exit(1);
470 }
471 }
472 );
473}
474
475/**
476 * Issue list subcommand
477 */
478function createListCommand(): Command {
479 return new IssueCommand('list')
480 .description('List issues for the current repository')
481 .option('-l, --limit <number>', 'Maximum number of issues to fetch', '50')
482 .addIssueJsonOption()
483 .action(async (options: { limit: string; json?: string | true }) => {
484 try {
485 // 1. Validate auth
486 const client = createApiClient();
487 await ensureAuthenticated(client);
488
489 // 2. Get repo context
490 const context = await getCurrentRepoContext();
491 if (!context) {
492 console.error('✗ Not in a Tangled repository');
493 console.error('\nTo use this repository with Tangled, add a remote:');
494 console.error(' git remote add origin git@tangled.org:<did>/<repo>.git');
495 process.exit(1);
496 }
497
498 // 3. Build repo AT-URI
499 const repoAtUri = await buildRepoAtUri(context.owner, context.name, client);
500
501 // 4. Fetch issues
502 const limit = Number.parseInt(options.limit, 10);
503 if (Number.isNaN(limit) || limit < 1 || limit > 100) {
504 console.error('✗ Invalid limit. Must be between 1 and 100.');
505 process.exit(1);
506 }
507
508 const { issues } = await listIssues({
509 client,
510 repoAtUri,
511 limit,
512 });
513
514 // 5. Handle empty results
515 if (issues.length === 0) {
516 if (options.json !== undefined) {
517 console.log('[]');
518 } else {
519 console.log('No issues found for this repository.');
520 }
521 return;
522 }
523
524 // Sort issues by creation time (oldest first) for consistent numbering
525 const sortedIssues = issues.sort(
526 (a, b) => new Date(a.createdAt).getTime() - new Date(b.createdAt).getTime()
527 );
528
529 // Build issue data with states (in parallel for performance)
530 const issueData = await Promise.all(
531 sortedIssues.map(async (issue, i) => {
532 const state = await getIssueState({ client, issueUri: issue.uri });
533 return {
534 number: i + 1,
535 title: issue.title,
536 body: issue.body,
537 state,
538 author: issue.author,
539 createdAt: issue.createdAt,
540 uri: issue.uri,
541 cid: issue.cid,
542 };
543 })
544 );
545
546 // 6. Output results
547 if (options.json !== undefined) {
548 outputJson(issueData, typeof options.json === 'string' ? options.json : undefined);
549 return;
550 }
551
552 console.log(`\nFound ${issueData.length} issue${issueData.length === 1 ? '' : 's'}:\n`);
553
554 for (const item of issueData) {
555 const stateBadge = formatIssueState(item.state);
556 const date = formatDate(item.createdAt);
557 console.log(` #${item.number} ${stateBadge} ${item.title}`);
558 console.log(` Created ${date}`);
559 console.log();
560 }
561 } catch (error) {
562 console.error(
563 `✗ Failed to list issues: ${error instanceof Error ? error.message : 'Unknown error'}`
564 );
565 process.exit(1);
566 }
567 });
568}