@skitterbyte/skitterspec-linear 10.2.0 → 10.4.0

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.
@@ -46,6 +46,15 @@ const {
46
46
 
47
47
  const { loadLinearConfig, mergeConfig, defaults: configDefaults, CONFIG_FILE, LIFECYCLE_BUCKETS } = require('./config.js')
48
48
  const { resolveApiKey, makeApiAdapter, stateIdFor, fetchWorkspaceStates } = require('./api.js')
49
+ const { scanDrift, isClean, fileCount, dirtyPaths, repairDrift } = require('./doctor.js')
50
+ const {
51
+ storePath,
52
+ storeMode,
53
+ fingerprint,
54
+ writeKey,
55
+ writeKeyCommand,
56
+ removeKey,
57
+ } = require('./credentials.js')
49
58
 
50
59
  // Resolve a spec argument to its snapshot dir. Accepts a spec name/folder found
51
60
  // under specs/** (preferred) or a literal path to a snapshot directory.
@@ -158,8 +167,15 @@ function warnToErr(snapshotDir, config, err) {
158
167
  if (lines.length) err.write(lines.join('\n') + '\n')
159
168
  }
160
169
 
170
+ // Resolve a spec argument to its folder, or null with a message on stdout.
171
+ // EVERY failure path here must print and EVERY caller must return a non-zero
172
+ // code: /spec-push checks $? before it applies a plan, so a resolve failure that
173
+ // exits 0 reads as "nothing to do" rather than "I could not find the spec".
161
174
  function resolveOrExit(specArg, dir, out) {
162
- if (!specArg) return null
175
+ if (!specArg) {
176
+ out.write('spec-sync: no spec given\n')
177
+ return null
178
+ }
163
179
  const snapshotDir = resolveSnapshotDir(specArg, dir)
164
180
  if (!snapshotDir) {
165
181
  out.write(`spec-sync: spec not found: ${specArg}\n`)
@@ -171,7 +187,7 @@ function resolveOrExit(specArg, dir, out) {
171
187
  // `spec-sync normalize <spec>` — print the local projection as JSON.
172
188
  function specSyncNormalize(dir, config, specArg, out, err) {
173
189
  const snapshotDir = resolveOrExit(specArg, dir, out)
174
- if (!snapshotDir) return
190
+ if (!snapshotDir) return 1
175
191
  // stdout is the projection and nothing else — callers pipe it into jq.
176
192
  warnToErr(snapshotDir, config, err)
177
193
  out.write(JSON.stringify(projectionOf(snapshotDir, config), null, 2) + '\n')
@@ -404,7 +420,7 @@ function specSyncStamp(dir, config, specArg, flags, out) {
404
420
  // files. The skill calls this AFTER applying the plan and stamping new ids.
405
421
  function specSyncRecord(dir, config, specArg, out) {
406
422
  const snapshotDir = resolveOrExit(specArg, dir, out)
407
- if (!snapshotDir) return
423
+ if (!snapshotDir) return 1
408
424
  const identifier = specIdentifier(snapshotDir, config)
409
425
  const file = recordPush({ dir, snapshotDir, identifier, config })
410
426
  out.write(`spec-sync record: snapshot written → ${path.relative(dir, file)}\n`)
@@ -417,7 +433,7 @@ function specSyncRecord(dir, config, specArg, out) {
417
433
  // configured state names and fails loudly on a typo Linear would silently no-op.
418
434
  function specSyncStatus(dir, config, specArg, flags, out) {
419
435
  const snapshotDir = resolveOrExit(specArg, dir, out)
420
- if (!snapshotDir) return
436
+ if (!snapshotDir) return 1
421
437
  const identifier = specIdentifier(snapshotDir, config)
422
438
  const lines = [`spec-sync status: ${identifier}`, ...warningLines(snapshotDir, config)]
423
439
 
@@ -487,6 +503,26 @@ function specSyncVerify(dir, config, specArg, flags, out) {
487
503
  )
488
504
  return 1
489
505
  }
506
+ // A `.base.json` is the LAST-PUSHED SNAPSHOT, not a read-back: it stores
507
+ // content HASHES keyed by identifier, never description text. Passed as
508
+ // `--stored` it parses fine and compares a description against a hash, so the
509
+ // report is confidently, entirely wrong — a real field run read "5045
510
+ // character(s) lost" off an intact mirror. Refused here rather than only
511
+ // documented, because a guard is enforceable and prose is not.
512
+ const snapshotRoot = path.resolve(dir, config.sync.baseDir)
513
+ const isSnapshot =
514
+ flags.stored.endsWith('.base.json') || !path.relative(snapshotRoot, flags.stored).startsWith('..')
515
+ if (isSnapshot) {
516
+ out.write(
517
+ `spec-sync verify: ${path.relative(dir, flags.stored)} is a last-pushed snapshot, not a read-back.\n` +
518
+ ' It holds content hashes keyed by identifier — comparing one against a\n' +
519
+ ' description reports enormous bogus losses on a perfectly intact mirror.\n' +
520
+ ' --stored wants what the tracker CURRENTLY holds:\n' +
521
+ ' {"issue": "…", "subIssues": {"<ref>": "…"}}\n' +
522
+ ' /spec-push reads that back over MCP and writes it for this command.\n',
523
+ )
524
+ return 1
525
+ }
490
526
  let stored
491
527
  try {
492
528
  stored = JSON.parse(fs.readFileSync(flags.stored, 'utf-8'))
@@ -543,6 +579,160 @@ function verifyLines(snapshotDir, config, stored, identifier) {
543
579
  return lines
544
580
  }
545
581
 
582
+ /**
583
+ * `spec-sync doctor [--json]` — identifier drift against the team's CURRENT key.
584
+ *
585
+ * A Linear team rename leaves every stamped identifier, the config `teamKey` and
586
+ * every snapshot filename on the old prefix, and nothing noticed. This reports
587
+ * it. Read-only: exit 0 whether or not it finds drift; non-zero only when it
588
+ * could not look (no key, MCP, unreadable team).
589
+ *
590
+ * Two things it deliberately does NOT do:
591
+ *
592
+ * 1. It does not trust `config.linear.teamKey` for the current key — that value
593
+ * is itself one of the things that goes stale, so trusting it would make the
594
+ * drift invisible. `teamId` survives a rename; the key does not.
595
+ * 2. It does not resolve refs with a bulk `team.issues` query. That connection
596
+ * excludes archived issues AND caps at 250 per page, so the naive version
597
+ * reports most of a healthy repo as missing — measured on a real workspace:
598
+ * 328 issues, 149 archived, and the unpaginated default returns 179. Reading
599
+ * each ref individually has no list to page and no archived flag to forget;
600
+ * `issue(id:)` resolves an archived issue by identifier (also verified).
601
+ */
602
+ async function specSyncDoctor(dir, config, flags, out) {
603
+ const key = resolveApiKey(config, flags.env || process.env)
604
+ const transport = flags.via || (config.apply && config.apply.transport) || (key.ok ? 'api' : 'mcp')
605
+
606
+ // One read PER DRIFTED REF is the whole design; over MCP that is a model
607
+ // round-trip each, which is impractical at the scale this exists for (198 refs
608
+ // in the run that motivated it). Refused rather than quietly slow — the same
609
+ // call `apply --all` makes.
610
+ if (transport === 'mcp') {
611
+ out.write(
612
+ [
613
+ 'spec-sync doctor: needs the api transport (nothing was read)',
614
+ ` ${key.ok ? '--via mcp was requested' : key.error}`,
615
+ ' it reads one issue per drifted ref, which MCP would route through the model.',
616
+ ].join('\n') + '\n',
617
+ )
618
+ return 1
619
+ }
620
+
621
+ const teamId = (config.linear && config.linear.teamId) || ''
622
+ if (!teamId) {
623
+ out.write('spec-sync doctor: no linear.teamId in specs/.core/linear.config.json — nothing to compare against.\n')
624
+ return 1
625
+ }
626
+
627
+ const adapter = flags.adapter || makeApiAdapter({ apiKey: key.key, fetch: flags.fetch })
628
+ let team
629
+ try {
630
+ team = await adapter.readTeam(teamId)
631
+ } catch (error) {
632
+ out.write(`spec-sync doctor: could not read the team: ${error.message}\n`)
633
+ return 1
634
+ }
635
+ if (!team || !team.key) {
636
+ out.write(`spec-sync doctor: Linear returned no team for ${teamId}\n`)
637
+ return 1
638
+ }
639
+
640
+ const drift = scanDrift(dir, config, team.key)
641
+
642
+ // Only the DISTINCT drifted identifiers are checked, so 221 stamps of 198
643
+ // identifiers cost 198 reads.
644
+ const missing = []
645
+ for (const ref of drift.refs) {
646
+ let got
647
+ try {
648
+ got = await adapter.readIssue(ref.to)
649
+ } catch (error) {
650
+ out.write(`spec-sync doctor: could not read ${ref.to}: ${error.message}\n`)
651
+ return 1
652
+ }
653
+ if (!got) missing.push(ref)
654
+ }
655
+
656
+ if (flags.json) {
657
+ out.write(JSON.stringify({ team: { id: team.id, key: team.key, name: team.name }, ...drift, missing }, null, 2) + '\n')
658
+ return 0
659
+ }
660
+
661
+ const was = [...new Set(drift.refs.map((r) => r.from.split('-')[0]))]
662
+ const lines = [`spec-sync doctor: team ${team.key}${was.length ? ` (stamps still on ${was.join(', ')})` : ''}`]
663
+ if (isClean(drift)) {
664
+ lines.push(' drift: none — every stamped identifier is on the current team key')
665
+ out.write(lines.join('\n') + '\n')
666
+ return 0
667
+ }
668
+
669
+ const driftLines = []
670
+ const stamped = drift.stamps.length + drift.urls.length
671
+ if (stamped) driftLines.push(`${stamped} stamp(s) across ${fileCount(drift)} file(s), ${drift.refs.length} distinct ref(s)`)
672
+ if (drift.snapshots.length || drift.snapshotKeys.length) {
673
+ const parts = []
674
+ if (drift.snapshots.length) parts.push(`${drift.snapshots.length} filename(s)`)
675
+ if (drift.snapshotKeys.length) parts.push(`${drift.snapshotKeys.length} sub-issue key(s)`)
676
+ driftLines.push(`snapshots: ${parts.join(' + ')} under ${config.sync.baseDir}`)
677
+ }
678
+ if (drift.config) driftLines.push(`config linear.teamKey = "${drift.config.from}"`)
679
+ lines.push(` drift: ${driftLines[0]}`)
680
+ for (const l of driftLines.slice(1)) lines.push(` ${l}`)
681
+
682
+ // Reported SEPARATELY from drift: a ref that resolves is repairable, one that
683
+ // does not is a different problem and must not be silently rewritten.
684
+ // Prose mentions are NOT repaired, so they are reported outside the drift
685
+ // block — a report that folded them in would imply --write fixes them.
686
+ if (drift.mentions.length) {
687
+ lines.push(
688
+ ` mentions: ${drift.mentions.length} stale ref(s) in spec prose — reported, NOT repaired by --write`,
689
+ )
690
+ }
691
+ lines.push(` missing: ${missing.length} ref(s) that resolve to no issue under ${team.key}`)
692
+ for (const m of missing.slice(0, 10)) lines.push(` ${m.from} → ${m.to} does not exist`)
693
+ if (missing.length > 10) lines.push(` … and ${missing.length - 10} more`)
694
+
695
+ if (!flags.write) {
696
+ lines.push(' run with --write to repair (requires a clean git tree)')
697
+ out.write(lines.join('\n') + '\n')
698
+ return 0
699
+ }
700
+
701
+ // A repair rewrites hundreds of stamps across dozens of files. That is only
702
+ // safe to hand someone if it arrives as ONE reviewable diff they can throw
703
+ // away with `git checkout -- .` — which a dirty tree destroys. Same guard
704
+ // `spec-env integrate` uses, and for the same reason.
705
+ const dirty = dirtyPaths(dir)
706
+ if (dirty === null) {
707
+ lines.push(' --write refused: not a git repository, so the rewrite would not be reviewable')
708
+ out.write(lines.join('\n') + '\n')
709
+ return 1
710
+ }
711
+ if (dirty.length) {
712
+ lines.push(` --write refused: ${dirty.length} uncommitted change(s) — commit or stash first`)
713
+ for (const d of dirty.slice(0, 10)) lines.push(` ${d}`)
714
+ if (dirty.length > 10) lines.push(` … and ${dirty.length - 10} more`)
715
+ lines.push(' the repair is one large diff; it must be reviewable on its own')
716
+ out.write(lines.join('\n') + '\n')
717
+ return 1
718
+ }
719
+
720
+ const skip = new Set(missing.map((m) => m.from))
721
+ const changed = repairDrift(dir, config, drift, { skip })
722
+ lines.push(' repaired:')
723
+ lines.push(` ${changed.files.length} spec file(s)`)
724
+ lines.push(` ${changed.snapshots.length} snapshot file(s) moved`)
725
+ if (changed.config) lines.push(' config linear.teamKey')
726
+ if (changed.skipped) {
727
+ lines.push(` ${changed.skipped} ref(s) LEFT ALONE — they resolve to no issue under ${team.key}`)
728
+ }
729
+ lines.push(' review the diff, then commit it')
730
+ out.write(lines.join('\n') + '\n')
731
+ // Non-zero when anything was left behind, so a caller cannot read a partial
732
+ // repair as a complete one.
733
+ return changed.skipped ? 1 : 0
734
+ }
735
+
546
736
  /**
547
737
  * `spec-sync states [--json]` — which transport this repo will use, and on the
548
738
  * API path the workspace's issue state NAMES.
@@ -739,13 +929,22 @@ async function applyOneSpec({ dir, config, snapshotDir, plan, adapter, teamId, p
739
929
  }
740
930
 
741
931
  // 3. Sub-issue updates — already linked, nothing to stamp.
932
+ //
933
+ // Keyed by REF, never by id: step 4 matches the read-back against the
934
+ // projection, which keys phases by ref. Keying an update by its id made every
935
+ // updated sub-issue report as a stale ref on every push. A plan written before
936
+ // updates carried a ref still resolves — by id, off the projection.
937
+ const refById = new Map()
938
+ for (const s of projectionOf(snapshotDir, config).subIssues || []) {
939
+ if (s.id != null) refById.set(String(s.id), s.ref)
940
+ }
742
941
  for (const sub of (plan.subIssues && plan.subIssues.update) || []) {
743
942
  await adapter.updateIssue(sub.id, withoutNull({
744
943
  title: sub.name,
745
944
  description: sub.goal,
746
945
  stateId: stateId(sub.state),
747
946
  }))
748
- result.subIssues[sub.ref || sub.id] = sub.id
947
+ result.subIssues[sub.ref || refById.get(String(sub.id)) || sub.id] = sub.id
749
948
  lines.push(` sub-issue updated: ${sub.id}`)
750
949
  }
751
950
 
@@ -1166,6 +1365,217 @@ function withoutNull(obj) {
1166
1365
  return out
1167
1366
  }
1168
1367
 
1368
+ // --- credentials -------------------------------------------------------------
1369
+
1370
+ /**
1371
+ * `spec-sync credentials <status|set|unset>` — manage the user-level API key.
1372
+ *
1373
+ * The split here is deliberate and is the whole point of the feature:
1374
+ *
1375
+ * `status` is SAFE FOR A SKILL TO RUN. It reports readiness and never the
1376
+ * value — path, mode, team, and a masked fingerprint.
1377
+ * `set` is for a HUMAN, run outside the model. It reads the key from a TTY
1378
+ * with echo off (or `--stdin`), never from argv.
1379
+ *
1380
+ * A key typed into a chat enters the transcript, is sent to the model and may be
1381
+ * logged; moving where a key is STORED is worthless if it travels through the
1382
+ * conversation to get there. So nothing in this file ever prints a key, and
1383
+ * `--key <value>` is refused rather than supported.
1384
+ */
1385
+ async function specSyncCredentials(dir, config, action, flags, out) {
1386
+ const env = flags.env || process.env
1387
+ const file = storePath(env)
1388
+ const teamId = (config.linear && config.linear.teamId) || ''
1389
+ const teamKey = (config.linear && config.linear.teamKey) || ''
1390
+ const label = teamKey ? `${teamId} (${teamKey})` : teamId
1391
+
1392
+ if (!teamId) {
1393
+ out.write(
1394
+ 'spec-sync credentials: no linear.teamId in specs/.core/linear.config.json.\n' +
1395
+ ' The store is keyed by team — run `spec-sync init-config` first.\n',
1396
+ )
1397
+ return 1
1398
+ }
1399
+
1400
+ if (action === 'status' || !action) return credentialsStatus(dir, config, file, label, flags, out)
1401
+ if (action === 'set') return credentialsSet(file, teamId, label, flags, out)
1402
+ if (action === 'unset') return credentialsUnset(file, teamId, label, out)
1403
+
1404
+ out.write('Usage: skitterspec spec-sync credentials <status|set|unset> [--stdin] [--json]\n')
1405
+ return 1
1406
+ }
1407
+
1408
+ // Readiness only — the command a skill runs. Never prints the key.
1409
+ function credentialsStatus(dir, config, file, label, flags, out) {
1410
+ const resolved = resolveApiKey(config, flags.env || process.env)
1411
+ const mode = storeMode(file)
1412
+ const present = resolved.ok
1413
+ const payload = {
1414
+ store: file,
1415
+ mode,
1416
+ team: label,
1417
+ key: present ? { present: true, source: resolved.source, fingerprint: fingerprint(resolved.key) } : { present: false },
1418
+ }
1419
+ if (flags.json) {
1420
+ out.write(JSON.stringify(payload, null, 2) + '\n')
1421
+ return present ? 0 : 1
1422
+ }
1423
+
1424
+ const lines = ['spec-sync credentials:']
1425
+ const strayed = repoConfigKeyCommand(dir)
1426
+ if (strayed) {
1427
+ lines.push(
1428
+ ' note: a keyCommand in specs/.core/linear.config.json is IGNORED.',
1429
+ ' That file is committed, so a command there would run on the',
1430
+ ' machine of anyone who cloned the repo. Record it here instead:',
1431
+ ' skitterspec spec-sync credentials set --command <cmd>',
1432
+ )
1433
+ }
1434
+ lines.push(` store: ${file}${mode ? ` (${mode})` : ' (not created yet)'}`)
1435
+ lines.push(` team: ${label}`)
1436
+ if (present) {
1437
+ const where =
1438
+ resolved.source === 'env'
1439
+ ? `environment (${resolved.envVar})`
1440
+ : resolved.source === 'command'
1441
+ ? 'keyCommand'
1442
+ : 'store'
1443
+ lines.push(` key: set — ${fingerprint(resolved.key)} from the ${where}`)
1444
+ if (resolved.command) lines.push(` runs: ${resolved.command}`)
1445
+ } else {
1446
+ lines.push(' key: not set')
1447
+ // `resolveApiKey` appends a reason when the store or its keyCommand is
1448
+ // broken rather than merely absent. Dropping it here would report a failing
1449
+ // command as "you never set a key" and send the user to set it again.
1450
+ for (const detail of resolved.error.split('\n').slice(1)) {
1451
+ if (detail.trim()) lines.push(` problem:${detail.replace(/^ +/, ' ')}`)
1452
+ }
1453
+ lines.push('')
1454
+ lines.push(' Run this yourself, in your own terminal — not through an assistant:')
1455
+ lines.push(' skitterspec spec-sync credentials set')
1456
+ }
1457
+ out.write(lines.join('\n') + '\n')
1458
+ return present ? 0 : 1
1459
+ }
1460
+
1461
+ // The human-facing setter. TTY prompt with echo off, or `--stdin` for a pipe.
1462
+ async function credentialsSet(file, teamId, label, flags, out) {
1463
+ if (flags.keyArgGiven) {
1464
+ out.write(
1465
+ 'spec-sync credentials: --key is not supported, on purpose.\n' +
1466
+ ' A secret in the command line is visible in shell history and to `ps`.\n' +
1467
+ ' Run `credentials set` with no arguments and paste at the prompt (input\n' +
1468
+ ' is hidden), or pipe it: `… | credentials set --stdin`.\n',
1469
+ )
1470
+ return 1
1471
+ }
1472
+
1473
+ // A command is not a secret — it names WHERE the key lives, so unlike --key it
1474
+ // is safe on the command line and nothing is prompted for.
1475
+ if (flags.command) {
1476
+ const r = writeKeyCommand(file, teamId, flags.command)
1477
+ if (!r.ok) {
1478
+ out.write(`spec-sync credentials: ${r.reason}\n`)
1479
+ return 1
1480
+ }
1481
+ out.write(
1482
+ `spec-sync credentials: ${label} will resolve its key by running:\n` +
1483
+ ` ${flags.command}\n` +
1484
+ ` recorded in ${r.path} (600)\n`,
1485
+ )
1486
+ return 0
1487
+ }
1488
+
1489
+ let key
1490
+ if (flags.stdin) {
1491
+ key = (await readAllStdin(flags.input || process.stdin)).trim()
1492
+ if (!key) {
1493
+ out.write('spec-sync credentials: nothing on stdin — no key stored.\n')
1494
+ return 1
1495
+ }
1496
+ } else {
1497
+ const input = flags.input || process.stdin
1498
+ if (!input.isTTY) {
1499
+ out.write(
1500
+ 'spec-sync credentials: not a terminal — cannot prompt for a key.\n' +
1501
+ ' Run it in your own terminal, or pipe the key with --stdin.\n',
1502
+ )
1503
+ return 1
1504
+ }
1505
+ key = (await promptHidden(`Linear personal API key for ${label} (hidden): `, input, out)).trim()
1506
+ if (!key) {
1507
+ out.write('spec-sync credentials: empty input — no key stored.\n')
1508
+ return 1
1509
+ }
1510
+ }
1511
+
1512
+ const r = writeKey(file, teamId, key)
1513
+ if (!r.ok) {
1514
+ out.write(`spec-sync credentials: ${r.reason}\n`)
1515
+ return 1
1516
+ }
1517
+ out.write(`spec-sync credentials: key stored for ${label} in ${r.path} (600)\n`)
1518
+ return 0
1519
+ }
1520
+
1521
+ function credentialsUnset(file, teamId, label, out) {
1522
+ const r = removeKey(file, teamId)
1523
+ if (!r.ok) {
1524
+ out.write(`spec-sync credentials: ${r.reason}\n`)
1525
+ return 1
1526
+ }
1527
+ out.write(
1528
+ r.removed
1529
+ ? `spec-sync credentials: removed the key for ${label} from ${r.path}\n`
1530
+ : `spec-sync credentials: no key stored for ${label} — nothing to remove\n`,
1531
+ )
1532
+ return 0
1533
+ }
1534
+
1535
+ // Is a keyCommand set in the REPO's committed config? It is never honoured — the
1536
+ // loader drops unknown keys — but silently ignoring it would leave someone
1537
+ // wondering why their command never runs, so `status` calls it out.
1538
+ function repoConfigKeyCommand(dir) {
1539
+ try {
1540
+ const raw = fs.readFileSync(path.join(dir, CONFIG_FILE), 'utf-8')
1541
+ const parsed = JSON.parse(raw)
1542
+ const auth = parsed && parsed.auth
1543
+ return auth && typeof auth.keyCommand === 'string' && auth.keyCommand.trim()
1544
+ ? auth.keyCommand.trim()
1545
+ : null
1546
+ } catch {
1547
+ return null
1548
+ }
1549
+ }
1550
+
1551
+ // Read stdin to completion (for `--stdin`).
1552
+ function readAllStdin(input) {
1553
+ return new Promise((resolve, reject) => {
1554
+ let data = ''
1555
+ input.setEncoding('utf-8')
1556
+ input.on('data', (chunk) => (data += chunk))
1557
+ input.on('end', () => resolve(data))
1558
+ input.on('error', reject)
1559
+ })
1560
+ }
1561
+
1562
+ // Prompt on a TTY with the input hidden. `_writeToOutput` is readline's own echo
1563
+ // hook — silencing it is what keeps the key off the screen (and out of a
1564
+ // screen-shared terminal or a recorded session).
1565
+ function promptHidden(question, input, out) {
1566
+ const readline = require('node:readline')
1567
+ return new Promise((resolve) => {
1568
+ const rl = readline.createInterface({ input, output: process.stdout, terminal: true })
1569
+ out.write(question)
1570
+ rl.question('', (answer) => {
1571
+ out.write('\n')
1572
+ rl.close()
1573
+ resolve(answer)
1574
+ })
1575
+ rl._writeToOutput = () => {}
1576
+ })
1577
+ }
1578
+
1169
1579
  async function specSync(rest, io = {}) {
1170
1580
  const out = io.out || process.stdout
1171
1581
  const err = io.err || process.stderr
@@ -1173,7 +1583,7 @@ async function specSync(rest, io = {}) {
1173
1583
  let dir = io.cwd || process.cwd()
1174
1584
  const positional = []
1175
1585
  const flags = { json: false, remote: null, workspaceStates: null, skipStateCheck: false, issue: null, url: null, subs: [], stored: null, plan: null, via: null, project: null, all: null,
1176
- force: false, teamId: '', teamKey: '', projectId: '', intakeLabel: '', bugLabels: [], hotfixLabels: [], stateNames: {}, statesFile: null }
1586
+ force: false, write: false, teamId: '', teamKey: '', projectId: '', intakeLabel: '', bugLabels: [], hotfixLabels: [], stateNames: {}, statesFile: null }
1177
1587
  for (let i = 0; i < args.length; i++) {
1178
1588
  if (args[i] === '--dir') dir = path.resolve(args[++i])
1179
1589
  else if (args[i] === '--json') flags.json = true
@@ -1189,6 +1599,17 @@ async function specSync(rest, io = {}) {
1189
1599
  else if (args[i] === '--url') flags.url = args[++i]
1190
1600
  else if (args[i] === '--sub') flags.subs.push(args[++i])
1191
1601
  else if (args[i] === '--force') flags.force = true
1602
+ else if (args[i] === '--write') flags.write = true
1603
+ else if (args[i] === '--stdin') flags.stdin = true
1604
+ else if (args[i] === '--command') flags.command = String(args[++i] || '').trim()
1605
+ else if (args[i] === '--key') {
1606
+ // Consumed and DELIBERATELY DISCARDED. A secret in argv is visible in
1607
+ // shell history and to `ps`, so this is refused rather than supported —
1608
+ // but it must still be swallowed here, or the value would fall through to
1609
+ // `positional` and end up printed back in a usage message.
1610
+ i++
1611
+ flags.keyArgGiven = true
1612
+ }
1192
1613
  else if (args[i] === '--team-id') flags.teamId = String(args[++i] || '').trim()
1193
1614
  else if (args[i] === '--team-key') flags.teamKey = String(args[++i] || '').trim()
1194
1615
  else if (args[i] === '--project-id') flags.projectId = String(args[++i] || '').trim()
@@ -1227,21 +1648,21 @@ async function specSync(rest, io = {}) {
1227
1648
 
1228
1649
  switch (sub) {
1229
1650
  case 'normalize':
1230
- specSyncNormalize(dir, config, positional[0], out, err)
1231
- return 0
1651
+ return specSyncNormalize(dir, config, positional[0], out, err) || 0
1232
1652
  case 'push':
1233
1653
  return specSyncPush(dir, config, positional[0], flags, out, err) || 0
1234
1654
  case 'stamp':
1235
1655
  return specSyncStamp(dir, config, positional[0], flags, out)
1236
1656
  case 'record':
1237
- specSyncRecord(dir, config, positional[0], out)
1238
- return 0
1657
+ return specSyncRecord(dir, config, positional[0], out) || 0
1239
1658
  case 'status':
1240
1659
  return specSyncStatus(dir, config, positional[0], flags, out) || 0
1241
1660
  case 'projects':
1242
1661
  return (await specSyncProjects(dir, config, flags, out)) || 0
1243
1662
  case 'states':
1244
1663
  return (await specSyncStates(dir, config, flags, out)) || 0
1664
+ case 'doctor':
1665
+ return (await specSyncDoctor(dir, config, flags, out)) || 0
1245
1666
  case 'apply':
1246
1667
  return (await specSyncApply(dir, config, positional[0], flags, out)) || 0
1247
1668
  case 'verify':
@@ -1249,8 +1670,11 @@ async function specSync(rest, io = {}) {
1249
1670
  case 'linked':
1250
1671
  specSyncLinked(dir, config, flags, out)
1251
1672
  return 0
1673
+ case 'credentials':
1674
+ return await specSyncCredentials(dir, config, positional[0], flags, out)
1252
1675
  default:
1253
1676
  out.write('Usage: skitterspec spec-sync <normalize|record|status> <spec> [--json] [--remote file] [--workspace-states file]\n' +
1677
+ ' skitterspec spec-sync credentials <status|set|unset> [--stdin] [--json]\n' +
1254
1678
  ' skitterspec spec-sync push <spec> --workspace-states <file> [--json] [--skip-state-check]\n' +
1255
1679
  ' skitterspec spec-sync stamp <spec> --issue KEY-1 [--url URL] [--sub <ref>=KEY-2 …]\n' +
1256
1680
  ' skitterspec spec-sync states [--via api|mcp] [--json]\n' +
@@ -1259,6 +1683,7 @@ async function specSync(rest, io = {}) {
1259
1683
  ' skitterspec spec-sync apply --all <bucket> [--via api|mcp] [--json]\n' +
1260
1684
  ' skitterspec spec-sync verify <spec> --stored <file>\n' +
1261
1685
  ' skitterspec spec-sync linked [--json]\n' +
1686
+ ' skitterspec spec-sync doctor [--write] [--json]\n' +
1262
1687
  ' skitterspec spec-sync init-config --team-id <id> [--team-key K] [--project-id id]\n' +
1263
1688
  ' [--intake-label L] [--bug-labels a,b] [--hotfix-labels a,b]\n' +
1264
1689
  ' [--state <bucket>=<name> …] [--states <file>] [--force] [--json]\n')
@@ -0,0 +1,54 @@
1
+ 'use strict'
2
+
3
+ /**
4
+ * The commands this provider distribution adds on top of the base CLI.
5
+ *
6
+ * ONE table drives both routing and `--help`. That pairing is the point: the bug
7
+ * this fixes was `spec-sync` being routed by the bin while the base's `HELP`
8
+ * const knew nothing about it, so the one distribution that ships the command
9
+ * told users it did not exist. Anything added here is routed and documented in
10
+ * the same edit — the two cannot drift, and a test asserts it.
11
+ *
12
+ * `run(rest)` returns an exit code (the bin propagates it); `summary` is the
13
+ * one-line description `--help` prints.
14
+ */
15
+
16
+ const { specSync } = require('./cli-sync.js')
17
+ const { specSanitise } = require('./cli-sanitise.js')
18
+
19
+ const DIST = '@skitterbyte/skitterspec-linear'
20
+
21
+ const PROVIDER_COMMANDS = {
22
+ 'spec-sync': {
23
+ run: specSync,
24
+ usage: 'skitterspec spec-sync <cmd>',
25
+ summary:
26
+ 'One-way sync to Linear (repo -> tracker; opt-in, needs\n' +
27
+ 'specs/.core/linear.config.json). Run it with no args to\n' +
28
+ 'list its subcommands.',
29
+ },
30
+ 'spec-sanitise': {
31
+ run: specSanitise,
32
+ usage: 'skitterspec spec-sanitise',
33
+ summary:
34
+ 'Rewrite spec markdown so no emphasis or link straddles a\n' +
35
+ 'line break. Dry-run; --write to apply.',
36
+ },
37
+ }
38
+
39
+ // The `--help` section for these commands, in the base HELP's column layout:
40
+ // two-space indent, description starting at column 30, continuations aligned.
41
+ const COL = 30
42
+
43
+ function providerHelpSection() {
44
+ const lines = [`Provider commands (${DIST}):`]
45
+ for (const name of Object.keys(PROVIDER_COMMANDS)) {
46
+ const { usage, summary } = PROVIDER_COMMANDS[name]
47
+ const [first, ...rest] = summary.split('\n')
48
+ lines.push(` ${usage.padEnd(COL - 2)}${first}`)
49
+ for (const line of rest) lines.push(`${' '.repeat(COL)}${line}`)
50
+ }
51
+ return lines.join('\n') + '\n'
52
+ }
53
+
54
+ module.exports = { PROVIDER_COMMANDS, providerHelpSection, DIST }