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! :)
at main 568 lines 19 kB view raw
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}