@skitterbyte/skitterspec-linear 10.3.0 → 10.5.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.
@@ -42,10 +42,24 @@ const {
42
42
  stampSubIssueId,
43
43
  listPhaseFiles,
44
44
  compareStored,
45
+ planRetarget,
46
+ applyRetarget,
47
+ deriveRecordedKey,
48
+ isEmptyRetarget,
49
+ dirtyPaths,
45
50
  } = require('../sync-core')
46
51
 
47
52
  const { loadLinearConfig, mergeConfig, defaults: configDefaults, CONFIG_FILE, LIFECYCLE_BUCKETS } = require('./config.js')
48
53
  const { resolveApiKey, makeApiAdapter, stateIdFor, fetchWorkspaceStates } = require('./api.js')
54
+ const { runChecks } = require('./doctor.js')
55
+ const {
56
+ storePath,
57
+ storeMode,
58
+ fingerprint,
59
+ writeKey,
60
+ writeKeyCommand,
61
+ removeKey,
62
+ } = require('./credentials.js')
49
63
 
50
64
  // Resolve a spec argument to its snapshot dir. Accepts a spec name/folder found
51
65
  // under specs/** (preferred) or a literal path to a snapshot directory.
@@ -494,6 +508,26 @@ function specSyncVerify(dir, config, specArg, flags, out) {
494
508
  )
495
509
  return 1
496
510
  }
511
+ // A `.base.json` is the LAST-PUSHED SNAPSHOT, not a read-back: it stores
512
+ // content HASHES keyed by identifier, never description text. Passed as
513
+ // `--stored` it parses fine and compares a description against a hash, so the
514
+ // report is confidently, entirely wrong — a real field run read "5045
515
+ // character(s) lost" off an intact mirror. Refused here rather than only
516
+ // documented, because a guard is enforceable and prose is not.
517
+ const snapshotRoot = path.resolve(dir, config.sync.baseDir)
518
+ const isSnapshot =
519
+ flags.stored.endsWith('.base.json') || !path.relative(snapshotRoot, flags.stored).startsWith('..')
520
+ if (isSnapshot) {
521
+ out.write(
522
+ `spec-sync verify: ${path.relative(dir, flags.stored)} is a last-pushed snapshot, not a read-back.\n` +
523
+ ' It holds content hashes keyed by identifier — comparing one against a\n' +
524
+ ' description reports enormous bogus losses on a perfectly intact mirror.\n' +
525
+ ' --stored wants what the tracker CURRENTLY holds:\n' +
526
+ ' {"issue": "…", "subIssues": {"<ref>": "…"}}\n' +
527
+ ' /spec-push reads that back over MCP and writes it for this command.\n',
528
+ )
529
+ return 1
530
+ }
497
531
  let stored
498
532
  try {
499
533
  stored = JSON.parse(fs.readFileSync(flags.stored, 'utf-8'))
@@ -550,6 +584,373 @@ function verifyLines(snapshotDir, config, stored, identifier) {
550
584
  return lines
551
585
  }
552
586
 
587
+ /**
588
+ * `spec-sync doctor [--json]` — is this project set up, across every layer?
589
+ *
590
+ * The scaffold, isolation, the tracker config and the key are each checked by a
591
+ * different command or by none, so nothing answered the whole question. This
592
+ * does, and every failing row names the command that fixes it.
593
+ *
594
+ * The report itself is `doctor.js`, which is pure — this half only GATHERS. It
595
+ * is the one place allowed to touch the filesystem for that, and it must not
596
+ * throw doing it: a doctor that dies on a malformed config is useless on exactly
597
+ * the repo that needs it. So every read is wrapped, and a parse failure becomes
598
+ * a `broken` row rather than a stack trace.
599
+ *
600
+ * Dispatched BEFORE the config load for the same reason (like `init-config`):
601
+ * the loader throws on malformed JSON and the dispatcher short-circuits when no
602
+ * config exists, so a doctor sitting after it could never report the two states
603
+ * it most needs to.
604
+ */
605
+ async function specSyncDoctor(dir, flags, out) {
606
+ const state = gatherState(dir, flags)
607
+ if (flags.remoteCheck) state.remote = await checkRemote(state, flags)
608
+
609
+ const report = runChecks(state)
610
+
611
+ if (flags.json) {
612
+ out.write(JSON.stringify(report, null, 2) + '\n')
613
+ return report.ok ? 0 : 1
614
+ }
615
+
616
+ const width = Math.max(...report.checks.map((c) => c.label.length))
617
+ const lines = ['skitterspec doctor:']
618
+ for (const c of report.checks) {
619
+ lines.push(` ${c.label.padEnd(width)} ${c.state.padEnd(8)} ${c.detail}`)
620
+ // The fix sits under the row it belongs to, indented past the state column,
621
+ // so a wall of rows still reads as "this one, and here is the command".
622
+ if (c.fix) lines.push(` ${' '.repeat(width)} ${' '.repeat(8)} → ${c.fix}`)
623
+ }
624
+ // Everything not `ok`/`skipped` needs a human's attention — including a
625
+ // `missing` one, which is why the count and the EXIT CODE differ: a declined
626
+ // opt-in is worth reporting but must not fail the run (see `runChecks`).
627
+ const attention = report.checks.filter((c) => c.state === 'broken' || c.state === 'missing').length
628
+ lines.push('')
629
+ lines.push(attention ? ` ${attention} check(s) need attention.` : ' ready.')
630
+
631
+ out.write(lines.join('\n') + '\n')
632
+ return report.ok ? 0 : 1
633
+ }
634
+
635
+ /**
636
+ * The live half of the report: one `team(id:)` call proving the id resolves and
637
+ * the key is accepted. Well-formed config is not working config.
638
+ *
639
+ * Opt-in because it is the only part that needs the network — the offline checks
640
+ * have to stay usable with no connectivity, which is exactly when a setup
641
+ * problem is most annoying to diagnose.
642
+ *
643
+ * **No API message is ever relayed.** A GraphQL error body can echo the request
644
+ * back, and this is a command a skill prints; so a failure is CLASSIFIED into a
645
+ * short reason of our own words.
646
+ */
647
+ async function checkRemote(state, flags) {
648
+ if (!state.tracker.parsed || !state.tracker.teamId) {
649
+ return { checked: true, skipped: true, reason: 'no usable tracker config to check against' }
650
+ }
651
+ if (!state.key.ok) {
652
+ return { checked: true, skipped: true, reason: 'no key, so there is nothing to check with' }
653
+ }
654
+
655
+ const key = resolveApiKey(state._config, flags.env || process.env)
656
+ const adapter = flags.adapter || makeApiAdapter({ apiKey: key.key, fetch: flags.fetch })
657
+ let team
658
+ try {
659
+ team = await adapter.readTeam(state.tracker.teamId)
660
+ } catch (error) {
661
+ const failure = classifyRemoteFailure(error)
662
+ // NEVER ANSWERED is not the same as ANSWERED NO. An unreachable API or a
663
+ // rate-limit means the check did not run — the setup is unexamined, not
664
+ // wrong — so it reports `skipped`, the same state as "you didn't ask for
665
+ // it". Calling it `broken` exited 1 on a healthy project that merely had no
666
+ // network, and every skill branching on that code failed with it.
667
+ if (failure.reached === false) return { checked: true, skipped: true, reason: failure.reason }
668
+ return { checked: true, ok: false, ...failure }
669
+ }
670
+ if (!team || !team.key) {
671
+ return {
672
+ checked: true,
673
+ ok: false,
674
+ reason: 'the key was accepted, but no team has that id',
675
+ fix: '/spec-linear-setup',
676
+ }
677
+ }
678
+ return { checked: true, ok: true, teamKey: team.key, recordedKey: state.tracker.teamKey }
679
+ }
680
+
681
+ // Map a thrown API error onto our own short reason. Matched on the shapes
682
+ // `api.js` raises; anything unrecognised degrades to a generic line rather than
683
+ // leaking the message.
684
+ //
685
+ // BLIND SPOT: matching on message text, so an api.js rewording lands here as the
686
+ // unrecognised case. That is why the fallback stays `broken` — Linear ANSWERED
687
+ // and refused, which is evidence of a problem even when we cannot name it. Only
688
+ // a request that got no answer at all (`reached: false`) is un-evidence.
689
+ function classifyRemoteFailure(error) {
690
+ const m = String((error && error.message) || '')
691
+ if (/rejected the API key|HTTP 401|HTTP 403/.test(m)) {
692
+ return { reason: 'Linear rejected the key — it may be revoked or for another workspace', fix: 'skitterspec spec-sync credentials set' }
693
+ }
694
+ // `reached: false` — the request never got an answer, so it says nothing about
695
+ // whether this project is set up correctly. See the caller.
696
+ if (/unreachable/.test(m)) {
697
+ return { reason: 'Linear could not be reached — check your connection', fix: null, reached: false }
698
+ }
699
+ if (/rate-limited/.test(m)) {
700
+ return { reason: 'Linear rate-limited the request and did not recover', fix: null, reached: false }
701
+ }
702
+ if (/Entity not found|not found/i.test(m)) {
703
+ return { reason: 'no team with that id in this workspace', fix: '/spec-linear-setup' }
704
+ }
705
+ return { reason: 'Linear did not accept the request', fix: null }
706
+ }
707
+
708
+ // Read the project's real state for `runChecks`. Never throws: every probe that
709
+ // can fail reports the failure as data.
710
+ function gatherState(dir, flags) {
711
+ const state = { scaffold: {}, isolation: {}, tracker: {}, key: {}, remote: { checked: false } }
712
+
713
+ const specs = path.join(dir, 'specs')
714
+ state.scaffold.specsDir = fs.existsSync(specs)
715
+ if (state.scaffold.specsDir) {
716
+ state.scaffold.core = fs.existsSync(path.join(specs, '.core'))
717
+ // Reported for context only — a missing bucket is normal (see scaffoldCheck).
718
+ state.scaffold.buckets = BUCKETS.filter((b) => fs.existsSync(path.join(specs, b)))
719
+ state.scaffold.skills = countSkills(dir)
720
+ }
721
+
722
+ const envFile = path.join(dir, 'specs', '.core', 'env.config.json')
723
+ state.isolation.present = fs.existsSync(envFile)
724
+ if (state.isolation.present) {
725
+ try {
726
+ JSON.parse(fs.readFileSync(envFile, 'utf-8'))
727
+ state.isolation.parsed = true
728
+ } catch (error) {
729
+ state.isolation.parsed = false
730
+ state.isolation.error = error.message
731
+ }
732
+ }
733
+
734
+ state.tracker.present = fs.existsSync(path.join(dir, CONFIG_FILE))
735
+ let config = null
736
+ if (state.tracker.present) {
737
+ try {
738
+ config = loadLinearConfig(dir).config
739
+ state.tracker.parsed = true
740
+ state.tracker.teamId = (config.linear && config.linear.teamId) || ''
741
+ state.tracker.teamKey = (config.linear && config.linear.teamKey) || ''
742
+ } catch (error) {
743
+ state.tracker.parsed = false
744
+ state.tracker.error = error.message
745
+ }
746
+ }
747
+
748
+ // Without a parsed config there is no `auth.keyEnv` and no team to key on, so
749
+ // there is nothing to look up — the key row reports as skipped instead.
750
+ if (config) {
751
+ const resolved = resolveApiKey(config, flags.env || process.env)
752
+ // A key resolves from the environment, the store, or a `keyCommand` the
753
+ // store runs — `resolveApiKey` covers all three, so the row must not be read
754
+ // as "env var unset".
755
+ //
756
+ // When it does NOT resolve, `resolveApiKey` appends the reason on later
757
+ // lines: a store that is world-readable, or a keyCommand that failed.
758
+ // Dropping it reported a broken command as `no key for SKS` and sent the
759
+ // user to set a key they had already set (`credentials status` keeps it for
760
+ // the same reason).
761
+ const why = resolved.ok ? '' : resolved.error.split('\n').slice(1).map((l) => l.trim()).filter(Boolean).join('; ')
762
+ state.key = resolved.ok
763
+ ? { ok: true, source: resolved.source === 'env' ? `the environment (${resolved.envVar})` : resolved.source, fingerprint: fingerprint(resolved.key) }
764
+ : { ok: false, error: `no key for ${state.tracker.teamKey || state.tracker.teamId}${why ? ` — ${why}` : ''}` }
765
+ }
766
+
767
+ state._config = config
768
+ return state
769
+ }
770
+
771
+ // How many skills are installed in the target project.
772
+ function countSkills(dir) {
773
+ const skills = path.join(dir, '.claude', 'skills')
774
+ try {
775
+ return fs
776
+ .readdirSync(skills, { withFileTypes: true })
777
+ .filter((e) => (e.isDirectory() || e.isSymbolicLink()) && fs.existsSync(path.join(skills, e.name, 'SKILL.md')))
778
+ .length
779
+ } catch {
780
+ return 0
781
+ }
782
+ }
783
+
784
+ /**
785
+ * `spec-sync retarget [--yes]` — repoint a mirror after a team-key rename.
786
+ *
787
+ * Renaming a Linear team rewrites the key in every issue identifier, and the
788
+ * repo stamps those in three places. Nothing moved them, so afterwards
789
+ * `/spec-push` fails with `no Linear issue found for SKI-7` — the right failure,
790
+ * with no way out but a hand rewrite (done twice by hand on 2026-09-02).
791
+ *
792
+ * The rewrite itself is provider-neutral and lives in `sync-core/retarget.js`.
793
+ * Only two things here touch Linear:
794
+ *
795
+ * 1. **Detection.** `teamId` survives a rename, so ask Linear for that team's
796
+ * CURRENT key and compare it with the recorded one. A difference IS the
797
+ * rename. It is never taken as an argument: nothing stops a typo rewriting
798
+ * every stamp to a key that does not exist.
799
+ * 2. **One spot-check.** Resolve the first remapped identifier and compare its
800
+ * TITLE to the spec's. That is what makes the number-preservation assumption
801
+ * safe, and it tests identity rather than mere existence — `SKS-7` existing
802
+ * does not make it the issue that was `SKI-7`. One read, which is also what
803
+ * keeps the MCP path viable.
804
+ */
805
+ async function specSyncRetarget(dir, config, flags, out) {
806
+ const teamId = (config.linear && config.linear.teamId) || ''
807
+ if (!teamId) {
808
+ out.write('spec-sync retarget: no linear.teamId in specs/.core/linear.config.json — nothing to compare against.\n')
809
+ return 1
810
+ }
811
+
812
+ const recorded = deriveRecordedKey(dir, config)
813
+ if (!recorded.key) {
814
+ out.write(`spec-sync retarget: cannot tell which key this repo is stamped with.\n ${recorded.reason}\n`)
815
+ return 1
816
+ }
817
+
818
+ const key = resolveApiKey(config, flags.env || process.env)
819
+ const transport = flags.via || (config.apply && config.apply.transport) || (key.ok ? 'api' : 'mcp')
820
+
821
+ // Linear's MCP `get_team` does not return the team key (observed 2026-09-02),
822
+ // so the one fact detection needs is unavailable there. Report what IS known
823
+ // and let the operator supply it, rather than guessing or silently skipping.
824
+ if (transport === 'mcp') {
825
+ out.write(
826
+ [
827
+ 'spec-sync retarget: transport = mcp — cannot read the team key (nothing was changed)',
828
+ ` ${key.ok ? '--via mcp was requested' : key.error}`,
829
+ ` recorded key: ${recorded.key} (from ${recorded.source})`,
830
+ " Linear's MCP get_team does not return a team key, so a rename cannot be detected here.",
831
+ ' Confirm the current key in Linear, then set an API key and re-run for the plan.',
832
+ ].join('\n') + '\n',
833
+ )
834
+ return 1
835
+ }
836
+
837
+ const adapter = flags.adapter || makeApiAdapter({ apiKey: key.key, fetch: flags.fetch })
838
+ let team
839
+ try {
840
+ team = await adapter.readTeam(teamId)
841
+ } catch (error) {
842
+ out.write(`spec-sync retarget: could not read the team: ${error.message}\n`)
843
+ return 1
844
+ }
845
+ if (!team || !team.key) {
846
+ out.write(`spec-sync retarget: Linear returned no team for ${teamId}\n`)
847
+ return 1
848
+ }
849
+
850
+ const header = [
851
+ `spec-sync retarget: team ${teamId} (${team.name || team.key})`,
852
+ ` recorded key: ${recorded.key} (from ${recorded.source})`,
853
+ ` linear key: ${team.key}${team.key === recorded.key ? '' : ' <- renamed'}`,
854
+ ]
855
+
856
+ if (team.key === recorded.key) {
857
+ out.write([...header, '', ' already current — nothing to retarget'].join('\n') + '\n')
858
+ return 0
859
+ }
860
+
861
+ const plan = planRetarget({ dir, oldKey: recorded.key, newKey: team.key, config })
862
+ if (isEmptyRetarget(plan)) {
863
+ out.write([...header, '', ' the key moved, but nothing in the repo is stamped with it'].join('\n') + '\n')
864
+ return 0
865
+ }
866
+
867
+ // Spot-check BEFORE reporting the plan as safe: a wrong mapping is caught
868
+ // here, not after the files have moved.
869
+ const check = await spotCheck({ dir, config, plan, adapter, oldKey: recorded.key, newKey: team.key })
870
+ const lines = [
871
+ ...header,
872
+ '',
873
+ ' would rewrite:',
874
+ ` ${plan.stamps.length} frontmatter stamp(s) (linear_identifier, linear_url, linear_issue_id)`,
875
+ ` ${plan.snapshots.length} base snapshot(s) (rename + re-key subIssues)`,
876
+ ` ${plan.configKey ? 1 : 0} config key (linear.teamKey)`,
877
+ '',
878
+ ` spot-check: ${check.line}`,
879
+ ]
880
+
881
+ if (!check.ok) {
882
+ lines.push(' refusing — the mapping is not safe to apply')
883
+ out.write(lines.join('\n') + '\n')
884
+ return 1
885
+ }
886
+
887
+ if (!flags.yes) {
888
+ lines.push(' dry-run — re-run with --yes to apply.')
889
+ out.write(lines.join('\n') + '\n')
890
+ return 0
891
+ }
892
+
893
+ const dirty = dirtyPaths(dir)
894
+ if (dirty === null) {
895
+ lines.push(' --yes refused: not a git repository, so the rewrite would not be reviewable')
896
+ out.write(lines.join('\n') + '\n')
897
+ return 1
898
+ }
899
+ if (dirty.length) {
900
+ lines.push(` --yes refused: ${dirty.length} uncommitted change(s) — commit or stash first`)
901
+ for (const d of dirty.slice(0, 10)) lines.push(` ${d}`)
902
+ if (dirty.length > 10) lines.push(` … and ${dirty.length - 10} more`)
903
+ lines.push(' the rewrite must land as one reviewable, revertable change')
904
+ out.write(lines.join('\n') + '\n')
905
+ return 1
906
+ }
907
+
908
+ const changed = applyRetarget(plan, { dir, config })
909
+ lines.push(
910
+ ' applied:',
911
+ ` ${changed.files.length} spec file(s)`,
912
+ ` ${changed.snapshots.length} snapshot(s) moved`,
913
+ ...(changed.configKey ? [' config linear.teamKey'] : []),
914
+ " nothing was pushed — only the repo's stamps moved; the mirror is untouched.",
915
+ ' review the diff, then commit it.',
916
+ )
917
+ out.write(lines.join('\n') + '\n')
918
+ return 0
919
+ }
920
+
921
+ /**
922
+ * Resolve the first remapped SPEC issue and compare its title to the spec's.
923
+ *
924
+ * Deliberately ONE read. Checking every ref instead would cost a request each
925
+ * (~198 on a real repo), force an MCP refusal, and still only prove the issues
926
+ * exist — not that they are the same issues.
927
+ */
928
+ async function spotCheck({ dir, config, plan, adapter, oldKey, newKey }) {
929
+ const overviewFile = (config.snapshot && config.snapshot.overviewFile) || '00-overview.md'
930
+ const stamp = plan.stamps.find((s) => path.basename(s.file) === overviewFile)
931
+ if (!stamp) return { ok: true, line: 'skipped — no spec issue in the plan to check' }
932
+
933
+ const before = parseFrontmatter(stamp.from).data.linear_identifier
934
+ if (!before) return { ok: true, line: 'skipped — no linear_identifier in the plan to check' }
935
+ const from = String(before).trim()
936
+ const to = `${newKey}-${from.slice(oldKey.length + 1)}`
937
+
938
+ let issue
939
+ try {
940
+ issue = await adapter.readIssue(to)
941
+ } catch (error) {
942
+ return { ok: false, line: `${to} could not be read — ${error.message}` }
943
+ }
944
+ if (!issue) return { ok: false, line: `${from} → ${to}, but ${to} does not exist in Linear` }
945
+
946
+ const expected = readSnapshot(path.dirname(path.join(dir, stamp.file)), config).title
947
+ const got = issue.title || ''
948
+ if (expected && got && expected.trim() !== got.trim()) {
949
+ return { ok: false, line: `${to} resolves, but its title is "${got}" — the spec's is "${expected}"` }
950
+ }
951
+ return { ok: true, line: `${to} resolves, title matches the spec` }
952
+ }
953
+
553
954
  /**
554
955
  * `spec-sync states [--json]` — which transport this repo will use, and on the
555
956
  * API path the workspace's issue state NAMES.
@@ -746,13 +1147,22 @@ async function applyOneSpec({ dir, config, snapshotDir, plan, adapter, teamId, p
746
1147
  }
747
1148
 
748
1149
  // 3. Sub-issue updates — already linked, nothing to stamp.
1150
+ //
1151
+ // Keyed by REF, never by id: step 4 matches the read-back against the
1152
+ // projection, which keys phases by ref. Keying an update by its id made every
1153
+ // updated sub-issue report as a stale ref on every push. A plan written before
1154
+ // updates carried a ref still resolves — by id, off the projection.
1155
+ const refById = new Map()
1156
+ for (const s of projectionOf(snapshotDir, config).subIssues || []) {
1157
+ if (s.id != null) refById.set(String(s.id), s.ref)
1158
+ }
749
1159
  for (const sub of (plan.subIssues && plan.subIssues.update) || []) {
750
1160
  await adapter.updateIssue(sub.id, withoutNull({
751
1161
  title: sub.name,
752
1162
  description: sub.goal,
753
1163
  stateId: stateId(sub.state),
754
1164
  }))
755
- result.subIssues[sub.ref || sub.id] = sub.id
1165
+ result.subIssues[sub.ref || refById.get(String(sub.id)) || sub.id] = sub.id
756
1166
  lines.push(` sub-issue updated: ${sub.id}`)
757
1167
  }
758
1168
 
@@ -1173,14 +1583,229 @@ function withoutNull(obj) {
1173
1583
  return out
1174
1584
  }
1175
1585
 
1586
+ // --- credentials -------------------------------------------------------------
1587
+
1588
+ /**
1589
+ * `spec-sync credentials <status|set|unset>` — manage the user-level API key.
1590
+ *
1591
+ * The split here is deliberate and is the whole point of the feature:
1592
+ *
1593
+ * `status` is SAFE FOR A SKILL TO RUN. It reports readiness and never the
1594
+ * value — path, mode, team, and a masked fingerprint.
1595
+ * `set` is for a HUMAN, run outside the model. It reads the key from a TTY
1596
+ * with echo off (or `--stdin`), never from argv.
1597
+ *
1598
+ * A key typed into a chat enters the transcript, is sent to the model and may be
1599
+ * logged; moving where a key is STORED is worthless if it travels through the
1600
+ * conversation to get there. So nothing in this file ever prints a key, and
1601
+ * `--key <value>` is refused rather than supported.
1602
+ */
1603
+ async function specSyncCredentials(dir, config, action, flags, out) {
1604
+ const env = flags.env || process.env
1605
+ const file = storePath(env)
1606
+ const teamId = (config.linear && config.linear.teamId) || ''
1607
+ const teamKey = (config.linear && config.linear.teamKey) || ''
1608
+ const label = teamKey ? `${teamId} (${teamKey})` : teamId
1609
+
1610
+ if (!teamId) {
1611
+ out.write(
1612
+ 'spec-sync credentials: no linear.teamId in specs/.core/linear.config.json.\n' +
1613
+ ' The store is keyed by team — run `spec-sync init-config` first.\n',
1614
+ )
1615
+ return 1
1616
+ }
1617
+
1618
+ if (action === 'status' || !action) return credentialsStatus(dir, config, file, label, flags, out)
1619
+ if (action === 'set') return credentialsSet(file, teamId, label, flags, out)
1620
+ if (action === 'unset') return credentialsUnset(file, teamId, label, out)
1621
+
1622
+ out.write('Usage: skitterspec spec-sync credentials <status|set|unset> [--stdin] [--json]\n')
1623
+ return 1
1624
+ }
1625
+
1626
+ // Readiness only — the command a skill runs. Never prints the key.
1627
+ function credentialsStatus(dir, config, file, label, flags, out) {
1628
+ const resolved = resolveApiKey(config, flags.env || process.env)
1629
+ const mode = storeMode(file)
1630
+ const present = resolved.ok
1631
+ const payload = {
1632
+ store: file,
1633
+ mode,
1634
+ team: label,
1635
+ key: present ? { present: true, source: resolved.source, fingerprint: fingerprint(resolved.key) } : { present: false },
1636
+ }
1637
+ if (flags.json) {
1638
+ out.write(JSON.stringify(payload, null, 2) + '\n')
1639
+ return present ? 0 : 1
1640
+ }
1641
+
1642
+ const lines = ['spec-sync credentials:']
1643
+ const strayed = repoConfigKeyCommand(dir)
1644
+ if (strayed) {
1645
+ lines.push(
1646
+ ' note: a keyCommand in specs/.core/linear.config.json is IGNORED.',
1647
+ ' That file is committed, so a command there would run on the',
1648
+ ' machine of anyone who cloned the repo. Record it here instead:',
1649
+ ' skitterspec spec-sync credentials set --command <cmd>',
1650
+ )
1651
+ }
1652
+ lines.push(` store: ${file}${mode ? ` (${mode})` : ' (not created yet)'}`)
1653
+ lines.push(` team: ${label}`)
1654
+ if (present) {
1655
+ const where =
1656
+ resolved.source === 'env'
1657
+ ? `environment (${resolved.envVar})`
1658
+ : resolved.source === 'command'
1659
+ ? 'keyCommand'
1660
+ : 'store'
1661
+ lines.push(` key: set — ${fingerprint(resolved.key)} from the ${where}`)
1662
+ if (resolved.command) lines.push(` runs: ${resolved.command}`)
1663
+ } else {
1664
+ lines.push(' key: not set')
1665
+ // `resolveApiKey` appends a reason when the store or its keyCommand is
1666
+ // broken rather than merely absent. Dropping it here would report a failing
1667
+ // command as "you never set a key" and send the user to set it again.
1668
+ for (const detail of resolved.error.split('\n').slice(1)) {
1669
+ if (detail.trim()) lines.push(` problem:${detail.replace(/^ +/, ' ')}`)
1670
+ }
1671
+ lines.push('')
1672
+ lines.push(' Run this yourself, in your own terminal — not through an assistant:')
1673
+ lines.push(' skitterspec spec-sync credentials set')
1674
+ }
1675
+ out.write(lines.join('\n') + '\n')
1676
+ return present ? 0 : 1
1677
+ }
1678
+
1679
+ // The human-facing setter. TTY prompt with echo off, or `--stdin` for a pipe.
1680
+ async function credentialsSet(file, teamId, label, flags, out) {
1681
+ if (flags.keyArgGiven) {
1682
+ out.write(
1683
+ 'spec-sync credentials: --key is not supported, on purpose.\n' +
1684
+ ' A secret in the command line is visible in shell history and to `ps`.\n' +
1685
+ ' Run `credentials set` with no arguments and paste at the prompt (input\n' +
1686
+ ' is hidden), or pipe it: `… | credentials set --stdin`.\n',
1687
+ )
1688
+ return 1
1689
+ }
1690
+
1691
+ // A command is not a secret — it names WHERE the key lives, so unlike --key it
1692
+ // is safe on the command line and nothing is prompted for.
1693
+ if (flags.command) {
1694
+ const r = writeKeyCommand(file, teamId, flags.command)
1695
+ if (!r.ok) {
1696
+ out.write(`spec-sync credentials: ${r.reason}\n`)
1697
+ return 1
1698
+ }
1699
+ out.write(
1700
+ `spec-sync credentials: ${label} will resolve its key by running:\n` +
1701
+ ` ${flags.command}\n` +
1702
+ ` recorded in ${r.path} (600)\n`,
1703
+ )
1704
+ return 0
1705
+ }
1706
+
1707
+ let key
1708
+ if (flags.stdin) {
1709
+ key = (await readAllStdin(flags.input || process.stdin)).trim()
1710
+ if (!key) {
1711
+ out.write('spec-sync credentials: nothing on stdin — no key stored.\n')
1712
+ return 1
1713
+ }
1714
+ } else {
1715
+ const input = flags.input || process.stdin
1716
+ if (!input.isTTY) {
1717
+ out.write(
1718
+ 'spec-sync credentials: not a terminal — cannot prompt for a key.\n' +
1719
+ ' Run it in your own terminal, or pipe the key with --stdin.\n',
1720
+ )
1721
+ return 1
1722
+ }
1723
+ key = (await promptHidden(`Linear personal API key for ${label} (hidden): `, input, out)).trim()
1724
+ if (!key) {
1725
+ out.write('spec-sync credentials: empty input — no key stored.\n')
1726
+ return 1
1727
+ }
1728
+ }
1729
+
1730
+ const r = writeKey(file, teamId, key)
1731
+ if (!r.ok) {
1732
+ out.write(`spec-sync credentials: ${r.reason}\n`)
1733
+ return 1
1734
+ }
1735
+ out.write(`spec-sync credentials: key stored for ${label} in ${r.path} (600)\n`)
1736
+ return 0
1737
+ }
1738
+
1739
+ function credentialsUnset(file, teamId, label, out) {
1740
+ const r = removeKey(file, teamId)
1741
+ if (!r.ok) {
1742
+ out.write(`spec-sync credentials: ${r.reason}\n`)
1743
+ return 1
1744
+ }
1745
+ out.write(
1746
+ r.removed
1747
+ ? `spec-sync credentials: removed the key for ${label} from ${r.path}\n`
1748
+ : `spec-sync credentials: no key stored for ${label} — nothing to remove\n`,
1749
+ )
1750
+ return 0
1751
+ }
1752
+
1753
+ // Is a keyCommand set in the REPO's committed config? It is never honoured — the
1754
+ // loader drops unknown keys — but silently ignoring it would leave someone
1755
+ // wondering why their command never runs, so `status` calls it out.
1756
+ function repoConfigKeyCommand(dir) {
1757
+ try {
1758
+ const raw = fs.readFileSync(path.join(dir, CONFIG_FILE), 'utf-8')
1759
+ const parsed = JSON.parse(raw)
1760
+ const auth = parsed && parsed.auth
1761
+ return auth && typeof auth.keyCommand === 'string' && auth.keyCommand.trim()
1762
+ ? auth.keyCommand.trim()
1763
+ : null
1764
+ } catch {
1765
+ return null
1766
+ }
1767
+ }
1768
+
1769
+ // Read stdin to completion (for `--stdin`).
1770
+ function readAllStdin(input) {
1771
+ return new Promise((resolve, reject) => {
1772
+ let data = ''
1773
+ input.setEncoding('utf-8')
1774
+ input.on('data', (chunk) => (data += chunk))
1775
+ input.on('end', () => resolve(data))
1776
+ input.on('error', reject)
1777
+ })
1778
+ }
1779
+
1780
+ // Prompt on a TTY with the input hidden. `_writeToOutput` is readline's own echo
1781
+ // hook — silencing it is what keeps the key off the screen (and out of a
1782
+ // screen-shared terminal or a recorded session).
1783
+ function promptHidden(question, input, out) {
1784
+ const readline = require('node:readline')
1785
+ return new Promise((resolve) => {
1786
+ const rl = readline.createInterface({ input, output: process.stdout, terminal: true })
1787
+ out.write(question)
1788
+ rl.question('', (answer) => {
1789
+ out.write('\n')
1790
+ rl.close()
1791
+ resolve(answer)
1792
+ })
1793
+ rl._writeToOutput = () => {}
1794
+ })
1795
+ }
1796
+
1176
1797
  async function specSync(rest, io = {}) {
1177
1798
  const out = io.out || process.stdout
1178
1799
  const err = io.err || process.stderr
1179
1800
  const [sub, ...args] = rest
1180
1801
  let dir = io.cwd || process.cwd()
1181
1802
  const positional = []
1803
+ // Anything `--`-prefixed that no branch below consumed. Collected rather than
1804
+ // pushed onto `positional`, where it was silently discarded — see the refusal
1805
+ // after the loop.
1806
+ const unknownFlags = []
1182
1807
  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,
1183
- force: false, teamId: '', teamKey: '', projectId: '', intakeLabel: '', bugLabels: [], hotfixLabels: [], stateNames: {}, statesFile: null }
1808
+ force: false, yes: false, remoteCheck: false, teamId: '', teamKey: '', projectId: '', intakeLabel: '', bugLabels: [], hotfixLabels: [], stateNames: {}, statesFile: null }
1184
1809
  for (let i = 0; i < args.length; i++) {
1185
1810
  if (args[i] === '--dir') dir = path.resolve(args[++i])
1186
1811
  else if (args[i] === '--json') flags.json = true
@@ -1196,6 +1821,18 @@ async function specSync(rest, io = {}) {
1196
1821
  else if (args[i] === '--url') flags.url = args[++i]
1197
1822
  else if (args[i] === '--sub') flags.subs.push(args[++i])
1198
1823
  else if (args[i] === '--force') flags.force = true
1824
+ else if (args[i] === '--yes') flags.yes = true
1825
+ else if (args[i] === '--check-remote') flags.remoteCheck = true
1826
+ else if (args[i] === '--stdin') flags.stdin = true
1827
+ else if (args[i] === '--command') flags.command = String(args[++i] || '').trim()
1828
+ else if (args[i] === '--key') {
1829
+ // Consumed and DELIBERATELY DISCARDED. A secret in argv is visible in
1830
+ // shell history and to `ps`, so this is refused rather than supported —
1831
+ // but it must still be swallowed here, or the value would fall through to
1832
+ // `positional` and end up printed back in a usage message.
1833
+ i++
1834
+ flags.keyArgGiven = true
1835
+ }
1199
1836
  else if (args[i] === '--team-id') flags.teamId = String(args[++i] || '').trim()
1200
1837
  else if (args[i] === '--team-key') flags.teamKey = String(args[++i] || '').trim()
1201
1838
  else if (args[i] === '--project-id') flags.projectId = String(args[++i] || '').trim()
@@ -1208,8 +1845,34 @@ async function specSync(rest, io = {}) {
1208
1845
  const [bucket, ...rest] = String(args[++i] || '').split('=')
1209
1846
  flags.stateNames[String(bucket).trim()] = rest.join('=').trim()
1210
1847
  } else if (args[i] === '--states') flags.statesFile = path.resolve(args[++i])
1848
+ else if (args[i].startsWith('--')) unknownFlags.push(args[i])
1211
1849
  else positional.push(args[i])
1212
1850
  }
1851
+ // REFUSE AN UNKNOWN FLAG, before anything runs.
1852
+ //
1853
+ // These used to land in `positional` and vanish. That is merely untidy for a
1854
+ // typo, but it turned a RENAMED flag into a silent no-op: `--write` moved to
1855
+ // `--yes` when `doctor` became `retarget`, so `spec-sync doctor --write` — the
1856
+ // exact 10.4.0 invocation for repairing a renamed team — parsed, ran the
1857
+ // readiness report instead, ignored the flag and exited 0. A script would read
1858
+ // that as "repaired".
1859
+ //
1860
+ // A spec name never starts with `--`, so this cannot swallow a real argument.
1861
+ if (unknownFlags.length) {
1862
+ const lines = [`spec-sync: unknown flag ${unknownFlags.join(', ')}`]
1863
+ // Renamed flags get a specific hand-off; a bare "unknown flag" would leave
1864
+ // the caller to guess what replaced it.
1865
+ if (unknownFlags.includes('--write')) {
1866
+ lines.push(
1867
+ ' --write was replaced by --yes, and the command that repairs a renamed',
1868
+ ' team is now `spec-sync retarget --yes` (it was `doctor --write`).',
1869
+ )
1870
+ }
1871
+ lines.push(' run `skitterspec spec-sync` for the full usage.')
1872
+ out.write(lines.join('\n') + '\n')
1873
+ return 1
1874
+ }
1875
+
1213
1876
  dir = path.resolve(dir)
1214
1877
  // Injection seam, alongside cwd/out/err: `env` supplies the key lookup and
1215
1878
  // `adapter`/`fetch` stand in for the network, so `apply` is exercised end to
@@ -1223,6 +1886,11 @@ async function specSync(rest, io = {}) {
1223
1886
  // malformed enough for the loader to throw on.
1224
1887
  if (sub === 'init-config') return specSyncInitConfig(dir, flags, out)
1225
1888
 
1889
+ // Same reason as init-config: this is the command you run when the config is
1890
+ // missing or malformed, so it must not sit behind a loader that throws on the
1891
+ // one and short-circuits on the other.
1892
+ if (sub === 'doctor') return await specSyncDoctor(dir, flags, out)
1893
+
1226
1894
  const { config, present } = loadLinearConfig(dir)
1227
1895
  if (!present) {
1228
1896
  out.write(
@@ -1247,6 +1915,8 @@ async function specSync(rest, io = {}) {
1247
1915
  return (await specSyncProjects(dir, config, flags, out)) || 0
1248
1916
  case 'states':
1249
1917
  return (await specSyncStates(dir, config, flags, out)) || 0
1918
+ case 'retarget':
1919
+ return (await specSyncRetarget(dir, config, flags, out)) || 0
1250
1920
  case 'apply':
1251
1921
  return (await specSyncApply(dir, config, positional[0], flags, out)) || 0
1252
1922
  case 'verify':
@@ -1254,8 +1924,11 @@ async function specSync(rest, io = {}) {
1254
1924
  case 'linked':
1255
1925
  specSyncLinked(dir, config, flags, out)
1256
1926
  return 0
1927
+ case 'credentials':
1928
+ return await specSyncCredentials(dir, config, positional[0], flags, out)
1257
1929
  default:
1258
1930
  out.write('Usage: skitterspec spec-sync <normalize|record|status> <spec> [--json] [--remote file] [--workspace-states file]\n' +
1931
+ ' skitterspec spec-sync credentials <status|set|unset> [--stdin] [--json]\n' +
1259
1932
  ' skitterspec spec-sync push <spec> --workspace-states <file> [--json] [--skip-state-check]\n' +
1260
1933
  ' skitterspec spec-sync stamp <spec> --issue KEY-1 [--url URL] [--sub <ref>=KEY-2 …]\n' +
1261
1934
  ' skitterspec spec-sync states [--via api|mcp] [--json]\n' +
@@ -1264,6 +1937,8 @@ async function specSync(rest, io = {}) {
1264
1937
  ' skitterspec spec-sync apply --all <bucket> [--via api|mcp] [--json]\n' +
1265
1938
  ' skitterspec spec-sync verify <spec> --stored <file>\n' +
1266
1939
  ' skitterspec spec-sync linked [--json]\n' +
1940
+ ' skitterspec spec-sync retarget [--yes]\n' +
1941
+ ' skitterspec spec-sync doctor [--check-remote] [--json]\n' +
1267
1942
  ' skitterspec spec-sync init-config --team-id <id> [--team-key K] [--project-id id]\n' +
1268
1943
  ' [--intake-label L] [--bug-labels a,b] [--hotfix-labels a,b]\n' +
1269
1944
  ' [--state <bucket>=<name> …] [--states <file>] [--force] [--json]\n')