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 { confirm } from '@inquirer/prompts';
2import { Command } from 'commander';
3import { createApiClient } from '../lib/api-client.js';
4import type { TangledApiClient } from '../lib/api-client.js';
5import { getCurrentRepoContext } from '../lib/context.js';
6import {
7 closeIssue,
8 createIssue,
9 deleteIssue,
10 getIssue,
11 getIssueState,
12 listIssues,
13 reopenIssue,
14 updateIssue,
15} from '../lib/issues-api.js';
16import { buildRepoAtUri } from '../utils/at-uri.js';
17import { 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 * Issue view subcommand
91 */
92function createViewCommand(): Command {
93 return new Command('view')
94 .description('View details of a specific issue')
95 .argument('<issue-id>', 'Issue number (e.g., 1, #2) or rkey')
96 .option(
97 '--json [fields]',
98 'Output JSON; optionally specify comma-separated fields (title, body, state, author, createdAt, uri, cid)'
99 )
100 .action(async (issueId: string, options: { json?: string | true }) => {
101 try {
102 // 1. Validate auth
103 const client = createApiClient();
104 if (!(await client.resumeSession())) {
105 console.error('✗ Not authenticated. Run "tangled auth login" first.');
106 process.exit(1);
107 }
108
109 // 2. Get repo context
110 const context = await getCurrentRepoContext();
111 if (!context) {
112 console.error('✗ Not in a Tangled repository');
113 console.error('\nTo use this repository with Tangled, add a remote:');
114 console.error(' git remote add origin git@tangled.org:<did>/<repo>.git');
115 process.exit(1);
116 }
117
118 // 3. Build repo AT-URI
119 const repoAtUri = await buildRepoAtUri(context.owner, context.name, client);
120
121 // 4. Resolve issue ID to URI
122 const { uri: issueUri, displayId } = await resolveIssueUri(issueId, client, repoAtUri);
123
124 // 5. Fetch issue details
125 const issue = await getIssue({ client, issueUri });
126
127 // 6. Fetch issue state
128 const state = await getIssueState({ client, issueUri: issue.uri });
129
130 // 7. Output result
131 if (options.json !== undefined) {
132 const issueData = {
133 title: issue.title,
134 body: issue.body,
135 state,
136 author: issue.author,
137 createdAt: issue.createdAt,
138 uri: issue.uri,
139 cid: issue.cid,
140 };
141 outputJson(issueData, typeof options.json === 'string' ? options.json : undefined);
142 return;
143 }
144
145 console.log(`\nIssue ${displayId} ${formatIssueState(state)}`);
146 console.log(`Title: ${issue.title}`);
147 console.log(`Author: ${issue.author}`);
148 console.log(`Created: ${formatDate(issue.createdAt)}`);
149 console.log(`Repo: ${context.name}`);
150 console.log(`URI: ${issue.uri}`);
151
152 if (issue.body) {
153 console.log('\nBody:');
154 console.log(issue.body);
155 }
156
157 console.log(); // Empty line at end
158 } catch (error) {
159 console.error(
160 `✗ Failed to view issue: ${error instanceof Error ? error.message : 'Unknown error'}`
161 );
162 process.exit(1);
163 }
164 });
165}
166
167/**
168 * Issue edit subcommand
169 */
170function createEditCommand(): Command {
171 return new Command('edit')
172 .description('Edit an issue title and/or body')
173 .argument('<issue-id>', 'Issue number or rkey')
174 .option('-t, --title <string>', 'New issue title')
175 .option('-b, --body <string>', 'New issue body text')
176 .option('-F, --body-file <path>', 'Read body from file (- for stdin)')
177 .option(
178 '--json [fields]',
179 'Output JSON of the updated issue; optionally specify comma-separated fields (title, body, author, createdAt, uri, cid)'
180 )
181 .action(
182 async (
183 issueId: string,
184 options: { title?: string; body?: string; bodyFile?: string; json?: string | true }
185 ) => {
186 try {
187 // 1. Validate at least one option provided
188 if (!options.title && !options.body && !options.bodyFile) {
189 console.error('✗ At least one of --title, --body, or --body-file must be provided');
190 process.exit(1);
191 }
192
193 // 2. Validate auth
194 const client = createApiClient();
195 if (!(await client.resumeSession())) {
196 console.error('✗ Not authenticated. Run "tangled auth login" first.');
197 process.exit(1);
198 }
199
200 // 3. Get repo context
201 const context = await getCurrentRepoContext();
202 if (!context) {
203 console.error('✗ Not in a Tangled repository');
204 console.error('\nTo use this repository with Tangled, add a remote:');
205 console.error(' git remote add origin git@tangled.org:<did>/<repo>.git');
206 process.exit(1);
207 }
208
209 // 4. Build repo AT-URI
210 const repoAtUri = await buildRepoAtUri(context.owner, context.name, client);
211
212 // 5. Resolve issue ID to URI
213 const { uri: issueUri, displayId } = await resolveIssueUri(issueId, client, repoAtUri);
214
215 // 6. Handle body input
216 const body = await readBodyInput(options.body, options.bodyFile);
217
218 // 7. Validate inputs
219 const validTitle = options.title ? validateIssueTitle(options.title) : undefined;
220 const validBody = body !== undefined ? validateIssueBody(body) : undefined;
221
222 // 8. Update issue
223 const updatedIssue = await updateIssue({
224 client,
225 issueUri,
226 title: validTitle,
227 body: validBody,
228 });
229
230 // 9. Output result
231 if (options.json !== undefined) {
232 const issueData = {
233 title: updatedIssue.title,
234 body: updatedIssue.body,
235 author: updatedIssue.author,
236 createdAt: updatedIssue.createdAt,
237 uri: updatedIssue.uri,
238 cid: updatedIssue.cid,
239 };
240 outputJson(issueData, typeof options.json === 'string' ? options.json : undefined);
241 return;
242 }
243
244 const updated: string[] = [];
245 if (validTitle !== undefined) updated.push('title');
246 if (validBody !== undefined) updated.push('body');
247
248 console.log(`✓ Issue ${displayId} updated`);
249 console.log(` Updated: ${updated.join(', ')}`);
250 } catch (error) {
251 console.error(
252 `✗ Failed to edit issue: ${error instanceof Error ? error.message : 'Unknown error'}`
253 );
254 process.exit(1);
255 }
256 }
257 );
258}
259
260/**
261 * Issue close subcommand
262 */
263function createCloseCommand(): Command {
264 return new Command('close')
265 .description('Close an issue')
266 .argument('<issue-id>', 'Issue number or rkey')
267 .action(async (issueId: string) => {
268 try {
269 // 1. Validate auth
270 const client = createApiClient();
271 if (!(await client.resumeSession())) {
272 console.error('✗ Not authenticated. Run "tangled auth login" first.');
273 process.exit(1);
274 }
275
276 // 2. Get repo context
277 const context = await getCurrentRepoContext();
278 if (!context) {
279 console.error('✗ Not in a Tangled repository');
280 console.error('\nTo use this repository with Tangled, add a remote:');
281 console.error(' git remote add origin git@tangled.org:<did>/<repo>.git');
282 process.exit(1);
283 }
284
285 // 3. Build repo AT-URI
286 const repoAtUri = await buildRepoAtUri(context.owner, context.name, client);
287
288 // 4. Resolve issue ID to URI
289 const { uri: issueUri, displayId } = await resolveIssueUri(issueId, client, repoAtUri);
290
291 // 5. Close issue
292 await closeIssue({ client, issueUri });
293
294 // 6. Display success
295 console.log(`✓ Issue ${displayId} closed`);
296 } catch (error) {
297 console.error(
298 `✗ Failed to close issue: ${error instanceof Error ? error.message : 'Unknown error'}`
299 );
300 process.exit(1);
301 }
302 });
303}
304
305/**
306 * Issue reopen subcommand
307 */
308function createReopenCommand(): Command {
309 return new Command('reopen')
310 .description('Reopen a closed issue')
311 .argument('<issue-id>', 'Issue number or rkey')
312 .action(async (issueId: string) => {
313 try {
314 // 1. Validate auth
315 const client = createApiClient();
316 if (!(await client.resumeSession())) {
317 console.error('✗ Not authenticated. Run "tangled auth login" first.');
318 process.exit(1);
319 }
320
321 // 2. Get repo context
322 const context = await getCurrentRepoContext();
323 if (!context) {
324 console.error('✗ Not in a Tangled repository');
325 console.error('\nTo use this repository with Tangled, add a remote:');
326 console.error(' git remote add origin git@tangled.org:<did>/<repo>.git');
327 process.exit(1);
328 }
329
330 // 3. Build repo AT-URI
331 const repoAtUri = await buildRepoAtUri(context.owner, context.name, client);
332
333 // 4. Resolve issue ID to URI
334 const { uri: issueUri, displayId } = await resolveIssueUri(issueId, client, repoAtUri);
335
336 // 5. Reopen issue
337 await reopenIssue({ client, issueUri });
338
339 // 6. Display success
340 console.log(`✓ Issue ${displayId} reopened`);
341 } catch (error) {
342 console.error(
343 `✗ Failed to reopen issue: ${error instanceof Error ? error.message : 'Unknown error'}`
344 );
345 process.exit(1);
346 }
347 });
348}
349
350/**
351 * Issue delete subcommand
352 */
353function createDeleteCommand(): Command {
354 return new Command('delete')
355 .description('Delete an issue permanently')
356 .argument('<issue-id>', 'Issue number or rkey')
357 .option('-f, --force', 'Skip confirmation prompt')
358 .action(async (issueId: string, options: { force?: boolean }) => {
359 // 1. Validate auth
360 const client = createApiClient();
361 if (!(await client.resumeSession())) {
362 console.error('✗ Not authenticated. Run "tangled auth login" first.');
363 process.exit(1);
364 }
365
366 // 2. Get repo context
367 const context = await getCurrentRepoContext();
368 if (!context) {
369 console.error('✗ Not in a Tangled repository');
370 console.error('\nTo use this repository with Tangled, add a remote:');
371 console.error(' git remote add origin git@tangled.org:<did>/<repo>.git');
372 process.exit(1);
373 }
374
375 // 3. Build repo AT-URI and resolve issue ID
376 let issueUri: string;
377 let displayId: string;
378 try {
379 const repoAtUri = await buildRepoAtUri(context.owner, context.name, client);
380 ({ uri: issueUri, displayId } = await resolveIssueUri(issueId, client, repoAtUri));
381 } catch (error) {
382 console.error(
383 `✗ Failed to delete issue: ${error instanceof Error ? error.message : 'Unknown error'}`
384 );
385 process.exit(1);
386 }
387
388 // 4. Confirm deletion if not --force (outside try so process.exit(0) propagates cleanly)
389 if (!options.force) {
390 const confirmed = await confirm({
391 message: `Are you sure you want to delete issue ${displayId}? This cannot be undone.`,
392 default: false,
393 });
394
395 if (!confirmed) {
396 console.log('Deletion cancelled.');
397 process.exit(0);
398 }
399 }
400
401 // 5. Delete issue
402 try {
403 await deleteIssue({ client, issueUri });
404 console.log(`✓ Issue ${displayId} deleted`);
405 } catch (error) {
406 console.error(
407 `✗ Failed to delete issue: ${error instanceof Error ? error.message : 'Unknown error'}`
408 );
409 process.exit(1);
410 }
411 });
412}
413
414/**
415 * Create the issue command with all subcommands
416 */
417export function createIssueCommand(): Command {
418 const issue = new Command('issue');
419 issue.description('Manage issues in Tangled repositories');
420
421 issue.addCommand(createCreateCommand());
422 issue.addCommand(createListCommand());
423 issue.addCommand(createViewCommand());
424 issue.addCommand(createEditCommand());
425 issue.addCommand(createCloseCommand());
426 issue.addCommand(createReopenCommand());
427 issue.addCommand(createDeleteCommand());
428
429 return issue;
430}
431
432/**
433 * Issue create subcommand
434 */
435function createCreateCommand(): Command {
436 return new Command('create')
437 .description('Create a new issue')
438 .argument('<title>', 'Issue title')
439 .option('-b, --body <string>', 'Issue body text')
440 .option('-F, --body-file <path>', 'Read body from file (- for stdin)')
441 .option(
442 '--json [fields]',
443 'Output JSON; optionally specify comma-separated fields (title, body, author, createdAt, uri, cid)'
444 )
445 .action(
446 async (
447 title: string,
448 options: { body?: string; bodyFile?: string; json?: string | true }
449 ) => {
450 try {
451 // 1. Validate auth
452 const client = createApiClient();
453 if (!(await client.resumeSession())) {
454 console.error('✗ Not authenticated. Run "tangled auth login" first.');
455 process.exit(1);
456 }
457
458 // 2. Get repo context
459 const context = await getCurrentRepoContext();
460 if (!context) {
461 console.error('✗ Not in a Tangled repository');
462 console.error('\nTo use this repository with Tangled, add a remote:');
463 console.error(' git remote add origin git@tangled.org:<did>/<repo>.git');
464 process.exit(1);
465 }
466
467 // 3. Validate title
468 const validTitle = validateIssueTitle(title);
469
470 // 4. Handle body input
471 const body = await readBodyInput(options.body, options.bodyFile);
472 if (body !== undefined) {
473 validateIssueBody(body);
474 }
475
476 // 5. Build repo AT-URI
477 const repoAtUri = await buildRepoAtUri(context.owner, context.name, client);
478
479 // 6. Create issue (suppress progress message in JSON mode)
480 if (options.json === undefined) {
481 console.log('Creating issue...');
482 }
483 const issue = await createIssue({
484 client,
485 repoAtUri,
486 title: validTitle,
487 body,
488 });
489
490 // 7. Output result
491 if (options.json !== undefined) {
492 const issueData = {
493 title: issue.title,
494 body: issue.body,
495 author: issue.author,
496 createdAt: issue.createdAt,
497 uri: issue.uri,
498 cid: issue.cid,
499 };
500 outputJson(issueData, typeof options.json === 'string' ? options.json : undefined);
501 return;
502 }
503
504 const rkey = extractRkey(issue.uri);
505 console.log(`\n✓ Issue created: #${rkey}`);
506 console.log(` Title: ${issue.title}`);
507 console.log(` URI: ${issue.uri}`);
508 } catch (error) {
509 console.error(
510 `✗ Failed to create issue: ${error instanceof Error ? error.message : 'Unknown error'}`
511 );
512 process.exit(1);
513 }
514 }
515 );
516}
517
518/**
519 * Issue list subcommand
520 */
521function createListCommand(): Command {
522 return new Command('list')
523 .description('List issues for the current repository')
524 .option('-l, --limit <number>', 'Maximum number of issues to fetch', '50')
525 .option(
526 '--json [fields]',
527 'Output JSON; optionally specify comma-separated fields (number, title, body, state, author, createdAt, uri, cid)'
528 )
529 .action(async (options: { limit: string; json?: string | true }) => {
530 try {
531 // 1. Validate auth
532 const client = createApiClient();
533 if (!(await client.resumeSession())) {
534 console.error('✗ Not authenticated. Run "tangled auth login" first.');
535 process.exit(1);
536 }
537
538 // 2. Get repo context
539 const context = await getCurrentRepoContext();
540 if (!context) {
541 console.error('✗ Not in a Tangled repository');
542 console.error('\nTo use this repository with Tangled, add a remote:');
543 console.error(' git remote add origin git@tangled.org:<did>/<repo>.git');
544 process.exit(1);
545 }
546
547 // 3. Build repo AT-URI
548 const repoAtUri = await buildRepoAtUri(context.owner, context.name, client);
549
550 // 4. Fetch issues
551 const limit = Number.parseInt(options.limit, 10);
552 if (Number.isNaN(limit) || limit < 1 || limit > 100) {
553 console.error('✗ Invalid limit. Must be between 1 and 100.');
554 process.exit(1);
555 }
556
557 const { issues } = await listIssues({
558 client,
559 repoAtUri,
560 limit,
561 });
562
563 // 5. Handle empty results
564 if (issues.length === 0) {
565 if (options.json !== undefined) {
566 console.log('[]');
567 } else {
568 console.log('No issues found for this repository.');
569 }
570 return;
571 }
572
573 // Sort issues by creation time (oldest first) for consistent numbering
574 const sortedIssues = issues.sort(
575 (a, b) => new Date(a.createdAt).getTime() - new Date(b.createdAt).getTime()
576 );
577
578 // Build issue data with states (in parallel for performance)
579 const issueData = await Promise.all(
580 sortedIssues.map(async (issue, i) => {
581 const state = await getIssueState({ client, issueUri: issue.uri });
582 return {
583 number: i + 1,
584 title: issue.title,
585 body: issue.body,
586 state,
587 author: issue.author,
588 createdAt: issue.createdAt,
589 uri: issue.uri,
590 cid: issue.cid,
591 };
592 })
593 );
594
595 // 6. Output results
596 if (options.json !== undefined) {
597 outputJson(issueData, typeof options.json === 'string' ? options.json : undefined);
598 return;
599 }
600
601 console.log(`\nFound ${issueData.length} issue${issueData.length === 1 ? '' : 's'}:\n`);
602
603 for (const item of issueData) {
604 const stateBadge = formatIssueState(item.state);
605 const date = formatDate(item.createdAt);
606 console.log(` #${item.number} ${stateBadge} ${item.title}`);
607 console.log(` Created ${date}`);
608 console.log();
609 }
610 } catch (error) {
611 console.error(
612 `✗ Failed to list issues: ${error instanceof Error ? error.message : 'Unknown error'}`
613 );
614 process.exit(1);
615 }
616 });
617}