@skrr-ai/cli 0.1.10 → 0.1.11

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.
Files changed (108) hide show
  1. package/dist/base-command.d.ts +1 -13
  2. package/dist/base-command.js +78 -1
  3. package/dist/commands/browser/skill/show.js +7 -1
  4. package/dist/commands/code/index.d.ts +1 -0
  5. package/dist/commands/code/index.js +9 -1
  6. package/dist/commands/commitments/analytics/index.js +2 -0
  7. package/dist/commands/daemon/byok.d.ts +1 -0
  8. package/dist/commands/daemon/byok.js +2 -1
  9. package/dist/commands/daemon/install.d.ts +1 -0
  10. package/dist/commands/daemon/install.js +2 -1
  11. package/dist/commands/daemon/login.d.ts +31 -0
  12. package/dist/commands/daemon/login.js +56 -0
  13. package/dist/commands/daemon/restart.d.ts +9 -0
  14. package/dist/commands/daemon/restart.js +36 -0
  15. package/dist/commands/daemon/start.d.ts +1 -0
  16. package/dist/commands/daemon/start.js +2 -1
  17. package/dist/commands/daemon/status.d.ts +1 -0
  18. package/dist/commands/daemon/status.js +2 -1
  19. package/dist/commands/daemon/stop.d.ts +1 -0
  20. package/dist/commands/daemon/stop.js +2 -1
  21. package/dist/commands/daemon/uninstall.d.ts +1 -0
  22. package/dist/commands/daemon/uninstall.js +2 -1
  23. package/dist/commands/daemon/unlock.d.ts +9 -0
  24. package/dist/commands/daemon/unlock.js +33 -0
  25. package/dist/commands/goals/key-results/create.js +32 -1
  26. package/dist/commands/goals/key-results/update.d.ts +11 -0
  27. package/dist/commands/goals/key-results/update.js +80 -2
  28. package/dist/commands/goals/plan-now.d.ts +54 -2
  29. package/dist/commands/goals/plan-now.js +175 -18
  30. package/dist/commands/goals/planner-config.d.ts +60 -9
  31. package/dist/commands/goals/planner-config.js +82 -34
  32. package/dist/commands/goals/revisions.js +17 -0
  33. package/dist/commands/goals/show.d.ts +17 -0
  34. package/dist/commands/goals/show.js +90 -3
  35. package/dist/commands/login.js +30 -0
  36. package/dist/commands/spaces/create.js +2 -1
  37. package/dist/commands/spaces/index.js +9 -1
  38. package/dist/commands/spaces/list.d.ts +18 -0
  39. package/dist/commands/spaces/list.js +57 -7
  40. package/dist/commands/spaces/show.js +4 -1
  41. package/dist/commands/spaces/summary.d.ts +4 -0
  42. package/dist/commands/spaces/summary.js +77 -1
  43. package/dist/commands/spaces/update.d.ts +4 -0
  44. package/dist/commands/spaces/update.js +38 -1
  45. package/dist/commands/tasks/actionability.js +40 -1
  46. package/dist/commands/tasks/activity.d.ts +29 -0
  47. package/dist/commands/tasks/activity.js +47 -0
  48. package/dist/commands/tasks/complete.d.ts +47 -0
  49. package/dist/commands/tasks/complete.js +159 -12
  50. package/dist/commands/tasks/create.d.ts +26 -0
  51. package/dist/commands/tasks/create.js +60 -1
  52. package/dist/commands/tasks/events/append.d.ts +2 -0
  53. package/dist/commands/tasks/events/append.js +41 -10
  54. package/dist/commands/tasks/events/list.js +60 -9
  55. package/dist/commands/tasks/output.js +22 -2
  56. package/dist/commands/tasks/ready.d.ts +38 -0
  57. package/dist/commands/tasks/ready.js +37 -0
  58. package/dist/commands/tasks/runs.d.ts +22 -0
  59. package/dist/commands/tasks/runs.js +110 -2
  60. package/dist/commands/tasks/show.d.ts +28 -0
  61. package/dist/commands/tasks/show.js +61 -0
  62. package/dist/commands/tasks/timeline.d.ts +7 -0
  63. package/dist/commands/tasks/timeline.js +34 -3
  64. package/dist/commands/tasks/update.d.ts +20 -0
  65. package/dist/commands/tasks/update.js +38 -1
  66. package/dist/commands/whoami.d.ts +40 -0
  67. package/dist/commands/whoami.js +57 -9
  68. package/dist/commands/wiki/ls.d.ts +23 -0
  69. package/dist/commands/wiki/ls.js +63 -10
  70. package/dist/commands/wiki/mv.d.ts +70 -0
  71. package/dist/commands/wiki/mv.js +198 -4
  72. package/dist/commands/wiki/rm.js +12 -1
  73. package/dist/commands/wiki/write.js +35 -3
  74. package/dist/help.d.ts +27 -0
  75. package/dist/help.js +48 -0
  76. package/dist/hooks/command-not-found.d.ts +31 -0
  77. package/dist/hooks/command-not-found.js +12 -80
  78. package/dist/lib/command-miss.d.ts +60 -0
  79. package/dist/lib/command-miss.js +128 -0
  80. package/dist/lib/commitment-analytics.d.ts +10 -0
  81. package/dist/lib/commitment-analytics.js +10 -0
  82. package/dist/lib/config.js +6 -2
  83. package/dist/lib/daemonBroker.d.ts +7 -1
  84. package/dist/lib/daemonBroker.js +71 -0
  85. package/dist/lib/daemonHandoff.d.ts +38 -0
  86. package/dist/lib/daemonHandoff.js +297 -0
  87. package/dist/lib/exec-oversky.d.ts +30 -0
  88. package/dist/lib/exec-oversky.js +41 -0
  89. package/dist/lib/format.d.ts +7 -0
  90. package/dist/lib/format.js +27 -5
  91. package/dist/lib/login.js +22 -2
  92. package/dist/lib/sky-code.js +1 -1
  93. package/dist/lib/task-transcript.d.ts +9 -0
  94. package/dist/lib/task-transcript.js +13 -2
  95. package/dist/lib/tasks.d.ts +34 -0
  96. package/dist/lib/tasks.js +70 -1
  97. package/dist/node_modules/@skrr-ai/auth-core/dist/cjs/configRoot.d.ts +50 -0
  98. package/dist/node_modules/@skrr-ai/auth-core/dist/cjs/configRoot.js +67 -0
  99. package/dist/node_modules/@skrr-ai/auth-core/dist/cjs/index.d.ts +1 -0
  100. package/dist/node_modules/@skrr-ai/auth-core/dist/cjs/index.js +7 -1
  101. package/dist/node_modules/@skrr-ai/auth-core/dist/esm/configRoot.d.ts +50 -0
  102. package/dist/node_modules/@skrr-ai/auth-core/dist/esm/configRoot.js +59 -0
  103. package/dist/node_modules/@skrr-ai/auth-core/dist/esm/index.d.ts +1 -0
  104. package/dist/node_modules/@skrr-ai/auth-core/dist/esm/index.js +3 -0
  105. package/dist/node_modules/@skrr-ai/auth-core/package.json +1 -1
  106. package/dist/node_modules/@skrr-ai/data-provider/index.js +15 -4
  107. package/oclif.manifest.json +15771 -15538
  108. package/package.json +5 -3
@@ -0,0 +1,128 @@
1
+ "use strict";
2
+ /**
3
+ * What to say when a command id does not resolve — ONE description, shared by
4
+ * every surface that can produce a miss.
5
+ *
6
+ * There are two such surfaces, and they used to disagree. `skrr daemon reload`
7
+ * goes through the `command_not_found` hook and gets a message naming the real
8
+ * command, the mistake, and the next step. `skrr daemon reload --help` goes
9
+ * through oclif's HELP command, which does its own lookup and never reaches
10
+ * that hook — so it answered `Command daemon:reload not found.`: raw
11
+ * boilerplate naming an internal colon-form id the user never typed and cannot
12
+ * type, with no route forward.
13
+ *
14
+ * The audience is what makes that the wrong way round. Someone exploring a
15
+ * service-manager CLI types `--help` on a guessed subcommand FIRST. So the
16
+ * readers most likely to hit a miss were exactly the readers the good message
17
+ * was written for, and they systematically got the bad one.
18
+ *
19
+ * Keeping the text here rather than in either caller is the point: a fix to one
20
+ * phrasing reaches both, and neither surface can quietly drift from the other.
21
+ */
22
+ Object.defineProperty(exports, "__esModule", { value: true });
23
+ exports.editDistance = editDistance;
24
+ exports.nearestCommands = nearestCommands;
25
+ exports.describeCommandMiss = describeCommandMiss;
26
+ exports.lookupFromConfig = lookupFromConfig;
27
+ /** A uuid-ish token, which is what a stray space id looks like. */
28
+ const LOOKS_LIKE_ID = /^[0-9a-f]{8}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{12}$/i;
29
+ /** Cheap edit distance, bounded — this runs only on a miss. */
30
+ function editDistance(a, b) {
31
+ const rows = a.length + 1;
32
+ const cols = b.length + 1;
33
+ let prev = Array.from({ length: cols }, (_, j) => j);
34
+ for (let i = 1; i < rows; i += 1) {
35
+ const cur = [i, ...Array(cols - 1).fill(0)];
36
+ for (let j = 1; j < cols; j += 1) {
37
+ cur[j] = Math.min(prev[j] + 1, cur[j - 1] + 1, prev[j - 1] + (a[i - 1] === b[j - 1] ? 0 : 1));
38
+ }
39
+ prev = cur;
40
+ }
41
+ return prev[cols - 1];
42
+ }
43
+ /**
44
+ * The closest real command ids, so a typo gets a route forward rather than
45
+ * only a verdict.
46
+ */
47
+ function nearestCommands(id, all, limit = 3) {
48
+ // Deduped, because oclif registers a TOPIC for a command path as well as the
49
+ // command itself — `daemon:status` is in `commandIDs` and in `topics`, so the
50
+ // combined candidate list held it twice and a near-miss was answered with
51
+ // "Did you mean: `skrr daemon status`, `skrr daemon status`, …?", spending
52
+ // two of the three suggestion slots saying one thing.
53
+ return ([...new Set(all)]
54
+ .map((candidate) => ({ candidate, d: editDistance(id, candidate) }))
55
+ // A third of the length keeps this to genuine near-misses; without a bound
56
+ // every miss suggests three unrelated commands, which is noise wearing the
57
+ // shape of help.
58
+ .filter(({ d }) => d <= Math.max(2, Math.floor(id.length / 3)))
59
+ .sort((x, y) => x.d - y.d)
60
+ .slice(0, limit)
61
+ .map(({ candidate }) => candidate));
62
+ }
63
+ /**
64
+ * The message for a colon-joined id that did not resolve.
65
+ *
66
+ * Two shapes, in order:
67
+ *
68
+ * 1. A PREFIX of the id is a real command, so the extra tokens are arguments
69
+ * it did not want. Longest prefix first, because `a:b:c` should report
70
+ * `a b`, not `a`.
71
+ * 2. Nothing resolved, so the command genuinely is not there — say so, and
72
+ * suggest the nearest siblings under the same topic before falling back to
73
+ * the whole command list.
74
+ */
75
+ function describeCommandMiss(config, id) {
76
+ const parts = String(id ?? '')
77
+ .split(':')
78
+ .filter(Boolean);
79
+ for (let take = parts.length - 1; take >= 1; take -= 1) {
80
+ const found = config.findCommand(parts.slice(0, take).join(':'));
81
+ if (!found)
82
+ continue;
83
+ const extras = parts.slice(take);
84
+ const spoken = `${config.bin} ${parts.slice(0, take).join(' ')}`;
85
+ // Suggest the flag only when the command actually has it, so the hint can
86
+ // never point at something that does not exist.
87
+ const flags = Object.keys(found.flags ?? {});
88
+ const hint = extras.some((e) => LOOKS_LIKE_ID.test(e)) && flags.includes('space')
89
+ ? ` Pass it as \`--space ${extras[0]}\`.`
90
+ : flags.includes('space')
91
+ ? ' It takes `--space <id>`.'
92
+ : '';
93
+ const plural = extras.length === 1 ? 'argument' : 'arguments';
94
+ return (`\`${spoken}\` exists, but does not take the positional ${plural} ` +
95
+ `${extras.map((e) => `\`${e}\``).join(' ')}.${hint}\n` +
96
+ `See \`${spoken} --help\`.`);
97
+ }
98
+ // Topic names are candidates too, so a near-miss on a bare topic gets routed
99
+ // to the topic rather than nothing (OSK-4869): `skrr harness` (singular) is
100
+ // one edit from the `harnesses` topic, but that topic owns no command whose
101
+ // id is close to `harness`, so a command-id-only search suggests nothing.
102
+ //
103
+ // Siblings first: `commitments:archive:<id>` is a long way from every real
104
+ // command id, so a whole-string comparison finds nothing and the reader is
105
+ // told only that they are wrong — when the useful answer is "that topic
106
+ // exists and here is what it does have".
107
+ const siblings = config.commandIDs.filter((c) => c.startsWith(`${parts[0]}:`));
108
+ const suggestions = siblings.length
109
+ ? nearestCommands(parts.slice(0, 2).join(':'), siblings)
110
+ : nearestCommands(parts.join(':'), [...config.commandIDs, ...config.topicNames]);
111
+ return (`\`${config.bin} ${parts.join(' ')}\` is not a command.` +
112
+ (suggestions.length
113
+ ? `\nDid you mean: ${suggestions
114
+ .map((s) => `\`${config.bin} ${s.split(':').join(' ')}\``)
115
+ .join(', ')}?`
116
+ : `\nRun \`${config.bin} --help\` to see the topics.`));
117
+ }
118
+ /** Adapt an oclif `Config` to the lookup shape above. */
119
+ function lookupFromConfig(config) {
120
+ return {
121
+ bin: config.bin,
122
+ findCommand: (id) => config.findCommand(id),
123
+ commandIDs: config.commandIDs.map((c) => String(c)),
124
+ topicNames: Array.isArray(config.topics)
125
+ ? config.topics.map((t) => String(t?.name ?? '')).filter(Boolean)
126
+ : [],
127
+ };
128
+ }
@@ -22,10 +22,18 @@ export declare const ANALYTICS_REPORTS: {
22
22
  readonly path: "/api/commitment-analytics/distribution";
23
23
  readonly summary: "How commitments are spread across agents, tiers and cadences";
24
24
  };
25
+ readonly 'distribution-tripwire': {
26
+ readonly path: "/api/commitment-analytics/distribution/tripwire";
27
+ readonly summary: "Persistent weekly confirmation of portfolio overload risk";
28
+ };
25
29
  readonly 'attention-budget': {
26
30
  readonly path: "/api/commitment-analytics/attention-budget";
27
31
  readonly summary: "How much user attention the fleet is actually spending";
28
32
  };
33
+ readonly 'decision-inbox': {
34
+ readonly path: "/api/commitment-analytics/decision-inbox";
35
+ readonly summary: "One ranked owner inbox across approvals, blockers, attention and held surfaces";
36
+ };
29
37
  readonly 'action-adapters-audit': {
30
38
  readonly path: "/api/commitment-analytics/action-adapters/audit";
31
39
  readonly summary: "Which action adapters are installed and what they are permitted to do";
@@ -54,7 +62,9 @@ export declare const commitmentAnalyticsApi: {
54
62
  operationsQueue: (query?: Query) => Promise<Record<string, unknown>>;
55
63
  governanceExceptions: (query?: Query) => Promise<Record<string, unknown>>;
56
64
  distribution: (query?: Query) => Promise<Record<string, unknown>>;
65
+ distributionTripwire: (query?: Query) => Promise<Record<string, unknown>>;
57
66
  attentionBudget: (query?: Query) => Promise<Record<string, unknown>>;
67
+ decisionInbox: () => Promise<Record<string, unknown>>;
58
68
  actionAdapterAudit: () => Promise<Record<string, unknown>>;
59
69
  recoveryMetrics: (query?: Query) => Promise<Record<string, unknown>>;
60
70
  recovery: (query?: Query) => Promise<Record<string, unknown>>;
@@ -41,10 +41,18 @@ exports.ANALYTICS_REPORTS = {
41
41
  path: `${MOUNT}/distribution`,
42
42
  summary: 'How commitments are spread across agents, tiers and cadences',
43
43
  },
44
+ 'distribution-tripwire': {
45
+ path: `${MOUNT}/distribution/tripwire`,
46
+ summary: 'Persistent weekly confirmation of portfolio overload risk',
47
+ },
44
48
  'attention-budget': {
45
49
  path: `${MOUNT}/attention-budget`,
46
50
  summary: 'How much user attention the fleet is actually spending',
47
51
  },
52
+ 'decision-inbox': {
53
+ path: `${MOUNT}/decision-inbox`,
54
+ summary: 'One ranked owner inbox across approvals, blockers, attention and held surfaces',
55
+ },
48
56
  'action-adapters-audit': {
49
57
  path: `${MOUNT}/action-adapters/audit`,
50
58
  summary: 'Which action adapters are installed and what they are permitted to do',
@@ -72,7 +80,9 @@ exports.commitmentAnalyticsApi = {
72
80
  operationsQueue: (query = {}) => data_provider_1.request.get((0, triggers_1.withQuery)(`${MOUNT}/operations/queue`, query)),
73
81
  governanceExceptions: (query = {}) => data_provider_1.request.get((0, triggers_1.withQuery)(`${MOUNT}/governance/exceptions`, query)),
74
82
  distribution: (query = {}) => data_provider_1.request.get((0, triggers_1.withQuery)(`${MOUNT}/distribution`, query)),
83
+ distributionTripwire: (query = {}) => data_provider_1.request.get((0, triggers_1.withQuery)(`${MOUNT}/distribution/tripwire`, query)),
75
84
  attentionBudget: (query = {}) => data_provider_1.request.get((0, triggers_1.withQuery)(`${MOUNT}/attention-budget`, query)),
85
+ decisionInbox: () => data_provider_1.request.get(`${MOUNT}/decision-inbox`),
76
86
  actionAdapterAudit: () => data_provider_1.request.get(`${MOUNT}/action-adapters/audit`),
77
87
  recoveryMetrics: (query = {}) => data_provider_1.request.get((0, triggers_1.withQuery)(`${MOUNT}/recovery-metrics`, query)),
78
88
  recovery: (query = {}) => data_provider_1.request.get((0, triggers_1.withQuery)(`${MOUNT}/recovery`, query)),
@@ -51,6 +51,7 @@ const fs = __importStar(require("node:fs"));
51
51
  const os = __importStar(require("node:os"));
52
52
  const path = __importStar(require("node:path"));
53
53
  const keychain_1 = require("./keychain");
54
+ const auth_core_1 = require("@skrr-ai/auth-core");
54
55
  const publicEndpoints_generated_1 = require("./publicEndpoints.generated");
55
56
  // Both from config/public-endpoints.json, the one domain contract, rather than a
56
57
  // fourth hand-copied pair. The CLI previously knew only about dev — there was no
@@ -256,8 +257,11 @@ function isEnvAuthOverride() {
256
257
  * resolver and every existing install read it.
257
258
  */
258
259
  function configRoot(env = process.env) {
259
- const root = (env.SKRR_CONFIG_DIR || env.OVERSKY_CONFIG_DIR)?.trim();
260
- return root || path.join(os.homedir(), '.skrr');
260
+ // Delegates. The variable list lives in ONE place (`@skrr-ai/auth-core`)
261
+ // because every copy of it became a place `skrr` and `skrrd` could disagree —
262
+ // twice in one day, each time as a correct local fix that created the next
263
+ // defect. `scripts/config-root-single-source.spec` fails if this is re-derived.
264
+ return (0, auth_core_1.resolveConfigRoot)(env);
261
265
  }
262
266
  function newConfigDir() {
263
267
  return configRoot();
@@ -80,6 +80,12 @@ export interface DaemonBrokerResult {
80
80
  cliId: string;
81
81
  userId: string;
82
82
  email: string | null;
83
+ /**
84
+ * The refresh family this credential belongs to, named so the caller can
85
+ * retire its predecessors AFTER persisting it. Absent on servers that predate
86
+ * the mint/confirm split, where the mint still revoked eagerly.
87
+ */
88
+ familyId?: string;
83
89
  }
84
90
  /**
85
91
  * Explicit reasons the broker path could fail. These exist to keep the
@@ -87,7 +93,7 @@ export interface DaemonBrokerResult {
87
93
  * fall through to PKCE; `ci_token_refused` is the one we surface
88
94
  * because re-driving it via PKCE would not change the outcome.
89
95
  */
90
- export type DaemonBrokerErrorReason = 'no_bootstrap' | 'stale_bootstrap' | 'bootstrap_parse' | 'base_url_mismatch' | 'daemon_not_authed' | 'ci_token_refused' | 'server_error' | 'network' | 'timeout' | 'unknown';
96
+ export type DaemonBrokerErrorReason = 'no_bootstrap' | 'stale_bootstrap' | 'bootstrap_parse' | 'base_url_mismatch' | 'daemon_not_authed' | 'ci_token_refused' | 'rate_limited' | 'server_error' | 'network' | 'timeout' | 'unknown';
91
97
  export interface DaemonBrokerFailure {
92
98
  ok: false;
93
99
  reason: DaemonBrokerErrorReason;
@@ -351,6 +351,22 @@ async function attemptDaemonBrokerLogin(opts) {
351
351
  if (response.status === 503 && code === 'DAEMON_NOT_AUTHED') {
352
352
  return { ok: false, reason: 'daemon_not_authed', detail: message, status: 503, code };
353
353
  }
354
+ // 429 is TRANSIENT and its remedy is time, which makes it the one failure
355
+ // here that a fall-through cannot substitute for. Left in the `unknown`
356
+ // bucket it fell silently through to PKCE, and in a non-interactive shell
357
+ // that surfaced as "skrr login requires an interactive terminal" — a
358
+ // sentence about the wrong thing entirely. Reachable in the ordinary way: a
359
+ // cold machine running several skrr commands at once has every process
360
+ // brokering, because none of them has a token yet.
361
+ if (response.status === 429) {
362
+ return {
363
+ ok: false,
364
+ reason: 'rate_limited',
365
+ detail: message,
366
+ status: 429,
367
+ ...(code ? { code } : {}),
368
+ };
369
+ }
354
370
  if (response.status === 400 && code === 'BASE_URL_MISMATCH') {
355
371
  // Older daemon binary that lacked the bootstrap.serverUrl field
356
372
  // ended up here despite the CLI sending baseURL. Treat as the
@@ -397,8 +413,52 @@ async function attemptDaemonBrokerLogin(opts) {
397
413
  cliId: parsedBody.cliId,
398
414
  userId: parsedBody.userId,
399
415
  email: parsedBody.email ?? null,
416
+ ...(typeof parsedBody.familyId === 'string' ? { familyId: parsedBody.familyId } : {}),
400
417
  };
401
418
  }
419
+ /** Bound the confirm so a slow server cannot stall a login that already worked. */
420
+ const CONFIRM_TIMEOUT_MS = 10_000;
421
+ /**
422
+ * Retire the cli credentials this one replaces — after it is on disk, never
423
+ * before.
424
+ *
425
+ * The mint deliberately no longer does this. Between the server's response and
426
+ * the credential being usable there are two more hops (the daemon's loopback
427
+ * broker forwarding the body, this process writing it to the keychain), and a
428
+ * failure at either one used to leave the user with no working cli credential at
429
+ * all, recoverable only by a full browser login.
430
+ *
431
+ * Called with the NEW access token explicitly rather than through the shared
432
+ * client. That is the point rather than a detail: the route refuses unless the
433
+ * bearer's own `did` claim is the cliId being swept, so this call is not a claim
434
+ * that the delivery happened — it is a demonstration that it did.
435
+ *
436
+ * Best-effort and silent. The credential is already persisted and working by the
437
+ * time this runs; a failure leaves one extra live family, which the server's
438
+ * session cap bounds and the next confirm sweeps.
439
+ */
440
+ async function confirmCliHandoff(opts) {
441
+ const controller = new AbortController();
442
+ const timer = setTimeout(() => controller.abort(), CONFIRM_TIMEOUT_MS);
443
+ try {
444
+ await fetch(`${opts.serverOrigin}/api/daemons/cli-handoff/confirm`, {
445
+ method: 'POST',
446
+ headers: {
447
+ Authorization: `Bearer ${opts.accessToken}`,
448
+ 'Content-Type': 'application/json',
449
+ Accept: 'application/json',
450
+ },
451
+ body: JSON.stringify({ cliId: opts.cliId, familyId: opts.familyId }),
452
+ signal: controller.signal,
453
+ });
454
+ }
455
+ catch {
456
+ /* bookkeeping — the credential is already delivered */
457
+ }
458
+ finally {
459
+ clearTimeout(timer);
460
+ }
461
+ }
402
462
  /**
403
463
  * Helper: coerce an ISO-string or numeric expiry to epoch ms for
404
464
  * `writeToBackend`. Returns undefined on missing / unparseable input.
@@ -436,6 +496,17 @@ async function attemptDaemonBrokerLoginAndPersist(opts) {
436
496
  ? { refreshExpiresAt: expiryToEpochMs(outcome.refreshExpiresAt) }
437
497
  : {}),
438
498
  }, { serverOrigin, clearReauth: true });
499
+ // Only now: the credential is on disk, so "the predecessor dies iff the
500
+ // replacement is in place" holds. A server that predates the split ships no
501
+ // `familyId` and has already revoked eagerly — nothing to do there.
502
+ if (outcome.familyId) {
503
+ await confirmCliHandoff({
504
+ serverOrigin,
505
+ accessToken: outcome.accessToken,
506
+ cliId: outcome.cliId,
507
+ familyId: outcome.familyId,
508
+ });
509
+ }
439
510
  return outcome;
440
511
  }
441
512
  // ---------------------------------------------------------------------
@@ -0,0 +1,38 @@
1
+ /**
2
+ * Hand the local daemon a credential of its own, right after `skrr login`.
3
+ *
4
+ * The inverse of the daemon-as-broker path that already ships. That one lets an
5
+ * authenticated DAEMON mint a cli-scope credential so the CLI can skip the
6
+ * browser; this lets an authenticated HUMAN mint a daemon-scope one so the
7
+ * machine is logged in by the same act. The direction added here is the safer of
8
+ * the two: daemon scope is deliberately lower-privileged than cli scope
9
+ * (`requireHumanScope` refuses it from billing, account management, password
10
+ * change and deletion), so this is a de-escalation, while the shipping direction
11
+ * is an escalation.
12
+ *
13
+ * Design record: docs/architecture/machine-login-broker-inversion-2026-09-03.md
14
+ *
15
+ * Three properties are deliberate:
16
+ *
17
+ * - **Best-effort, never fatal.** `skrr login` has already SUCCEEDED by the time
18
+ * this runs. A failure here must not fail the login or discard the credential
19
+ * the user just earned — it degrades to exactly the old behaviour, with a hint.
20
+ * - **Skipped when no daemon service is installed.** There is nothing to hand a
21
+ * credential to, and minting one anyway leaves an orphaned daemon-scope family
22
+ * on the account. A binary on PATH is not a service, so this is decided by the
23
+ * daemon's own answer about where its id came from — not by whether `skrrd`
24
+ * exists.
25
+ * - **The bundle goes over STDIN**, never a file. It is a live credential; a temp
26
+ * file would put it at rest with a lifetime nobody owns.
27
+ * - **The predecessor is retired only after the new credential is written.** The
28
+ * mint no longer revokes anything; `confirmDaemonHandoff` does, once the daemon
29
+ * has accepted. So a handoff that fails leaves the machine exactly as it was,
30
+ * rather than signed out.
31
+ */
32
+ export interface DaemonHandoffOutcome {
33
+ status: 'delivered' | 'skipped' | 'failed';
34
+ detail: string;
35
+ }
36
+ export declare function handOffToLocalDaemon(env?: NodeJS.ProcessEnv, opts?: {
37
+ loginFlow?: string;
38
+ }): Promise<DaemonHandoffOutcome>;
@@ -0,0 +1,297 @@
1
+ "use strict";
2
+ Object.defineProperty(exports, "__esModule", { value: true });
3
+ exports.handOffToLocalDaemon = handOffToLocalDaemon;
4
+ const node_child_process_1 = require("node:child_process");
5
+ const data_provider_1 = require("@skrr-ai/data-provider");
6
+ const config_1 = require("./config");
7
+ const exec_oversky_1 = require("./exec-oversky");
8
+ const keychain_1 = require("./keychain");
9
+ /**
10
+ * Ceilings, not expectations. Both calls finish in milliseconds when anything is
11
+ * working; these exist so that when something is NOT working the login ends.
12
+ *
13
+ * The failure this prevents is the worst one available here: `skrr login` has
14
+ * already printed "Login successful", and an unbounded wait on a child process
15
+ * leaves the user staring at a terminal that will never return, with no way to
16
+ * tell a hung handoff from a slow one. A blocked keychain prompt, a daemon
17
+ * binary wedged on a lock, an NFS home directory — none of them are exotic, and
18
+ * all of them used to hang the command.
19
+ *
20
+ * The accept side gets the longer budget because it may have to unlock a
21
+ * keychain. Killing it mid-write is the cost, and it is the lesser one: the
22
+ * credential write is a few milliseconds at the end of that window, so a timeout
23
+ * overwhelmingly lands BEFORE it rather than during it, and `skrr daemon login`
24
+ * recovers either way.
25
+ */
26
+ const PROBE_TIMEOUT_MS = 15_000;
27
+ const ACCEPT_TIMEOUT_MS = 60_000;
28
+ function runOversky(binary, args, opts) {
29
+ const { input, timeoutMs } = opts;
30
+ return new Promise((resolve) => {
31
+ // A Windows npm-global install ships a `.cmd` shim, which `spawn` cannot
32
+ // execute without a shell — Node fires ENOENT instead. `execOversky` has
33
+ // handled this since it was written; this function did not, so the handoff
34
+ // failed on EVERY Windows login and reported it as the daemon refusing the
35
+ // bundle. Restricted to the shim case so real binaries keep the argv-array
36
+ // exec path, which needs no quoting rules to be right.
37
+ const isWinShim = process.platform === 'win32' && /\.(cmd|bat)$/i.test(binary);
38
+ const child = (0, node_child_process_1.spawn)(binary, args, {
39
+ // stderr is CAPTURED, not discarded. Discarding it cost a debugging cycle
40
+ // on the first real run: the daemon exited non-zero with
41
+ // `OVERSKY_KEK_REQUIRED`, and all the user saw was "the daemon refused the
42
+ // handoff" — an outcome with its reason thrown away. A wrapper that hides
43
+ // the wrapped program's error is worse than one that does not wrap.
44
+ stdio: [input === undefined ? 'ignore' : 'pipe', 'pipe', 'pipe'],
45
+ ...(isWinShim ? { shell: true } : {}),
46
+ });
47
+ let stdout = '';
48
+ let stderr = '';
49
+ let settled = false;
50
+ const settle = (value) => {
51
+ if (settled)
52
+ return;
53
+ settled = true;
54
+ clearTimeout(timer);
55
+ resolve(value);
56
+ };
57
+ const timer = setTimeout(() => {
58
+ child.kill('SIGKILL');
59
+ settle({
60
+ ok: false,
61
+ stdout,
62
+ stderr: `timed out after ${Math.round(timeoutMs / 1000)}s`,
63
+ });
64
+ }, timeoutMs);
65
+ // Never hold the event loop open on the timer alone.
66
+ if (typeof timer.unref === 'function')
67
+ timer.unref();
68
+ child.stdout?.on('data', (c) => (stdout += String(c)));
69
+ child.stderr?.on('data', (c) => (stderr += String(c)));
70
+ child.on('error', (err) => settle({ ok: false, stdout, stderr: err.message }));
71
+ child.on('close', (code) => settle({ ok: code === 0, stdout, stderr }));
72
+ if (input !== undefined) {
73
+ // An EPIPE here is the child having already exited — its own exit code and
74
+ // stderr are the real diagnosis, and an unhandled stream error would
75
+ // instead crash the CLI one line after a successful login.
76
+ child.stdin?.on('error', () => {
77
+ /* reported by the close handler */
78
+ });
79
+ child.stdin?.end(input);
80
+ }
81
+ });
82
+ }
83
+ /**
84
+ * The profile flags to pass to `skrrd`, so both spawns land in the same tenancy
85
+ * the CLI is signed in to.
86
+ *
87
+ * A profile is the daemon's tenancy boundary — separate config, state, keychain
88
+ * namespace and service label. The CLI resolves its own; the daemon resolves its
89
+ * own, from `--profile` → `OVERSKY_PROFILE` → its `profile.lock` → `default`.
90
+ * Passing nothing let those two answers differ, and the failure was silent in the
91
+ * damaging direction: `skrr --profile work login` would mint against the id
92
+ * printed by whichever profile the daemon's lock happened to name, then write a
93
+ * work-profile credential over THAT profile's store.
94
+ *
95
+ * `skrr daemon login` already binds the profile this way (`bindToCliSession`);
96
+ * this is the same rule for the path that runs without being asked.
97
+ */
98
+ function profileArgs() {
99
+ const profile = (0, keychain_1.getActiveProfile)();
100
+ return profile && profile !== keychain_1.DEFAULT_PROFILE ? ['--profile', profile] : [];
101
+ }
102
+ /**
103
+ * Parse the machine-readable line `accept-handoff --print-id` writes to stderr.
104
+ *
105
+ * Absent on a daemon that predates it, which is a normal state during a rollout
106
+ * and not an error — the caller degrades to the older, less careful behaviour
107
+ * rather than refusing.
108
+ */
109
+ function parseIdProvenance(stderr) {
110
+ const line = stderr.split('\n').find((l) => l.includes('skrrd:accept-handoff'));
111
+ if (!line)
112
+ return {};
113
+ const idSource = /id-source=([A-Za-z0-9._:-]+)/.exec(line)?.[1];
114
+ const machineUuid = /machine-uuid=([A-Za-z0-9._:-]+)/.exec(line)?.[1];
115
+ // Any non-space run: this one is a URL, and a URL contains characters the
116
+ // other two never do.
117
+ const daemonServer = /server=(\S+)/.exec(line)?.[1];
118
+ return { idSource, machineUuid, daemonServer };
119
+ }
120
+ /**
121
+ * What actually changed on this machine, said out loud.
122
+ *
123
+ * A handoff can re-point the machine to a different ACCOUNT (you signed in as
124
+ * someone else) or a different DEPLOYMENT (your daemon was on dev, your CLI is
125
+ * on prod). Both are the feature working — the machine follows the human, and
126
+ * moving a dev-bound daemon to prod is the exact story this path was written for
127
+ * — and both are state changes an operator should watch happen rather than
128
+ * discover later, when a daemon they thought was on dev answers from prod.
129
+ *
130
+ * Degrades cleanly: an older server sends no account, an older daemon reports no
131
+ * server, and the sentence simply says less rather than guessing.
132
+ */
133
+ function describeDelivery(opts) {
134
+ const parts = [];
135
+ parts.push(opts.accountLabel
136
+ ? `now signed in as ${opts.accountLabel} on ${(0, config_1.describeDeployment)(opts.cliBaseURL)}`
137
+ : 'the local daemon now has its own credential');
138
+ if (opts.daemonServer &&
139
+ canonicalServer(opts.daemonServer) !== canonicalServer(opts.cliBaseURL)) {
140
+ parts.push(`moved from ${(0, config_1.describeDeployment)(opts.daemonServer)}`);
141
+ }
142
+ return parts.join(', ');
143
+ }
144
+ /**
145
+ * Compare deployments, not spellings.
146
+ *
147
+ * `https://oversky.ai` and `https://skrr.ai` are one deployment under two names,
148
+ * and the first is still what every daemon installed before the cutover carries.
149
+ * Trimming and lowercasing alone would report a move on every login from every
150
+ * one of those machines — a warning that is wrong for the largest group of users
151
+ * who would see it, which is how a signal becomes noise. `canonicalizeKnownBaseURL`
152
+ * is the fold the rest of the CLI already compares through.
153
+ */
154
+ function canonicalServer(url) {
155
+ return (0, config_1.canonicalizeKnownBaseURL)(url).trim().replace(/\/+$/, '').toLowerCase();
156
+ }
157
+ /** The most useful line of a failure, so a remedy can name the cause. */
158
+ function firstMeaningfulLine(text) {
159
+ const line = text
160
+ .split('\n')
161
+ .map((l) => l.trim())
162
+ .find((l) => l &&
163
+ !l.startsWith('at ') &&
164
+ !/^[{}]/.test(l) &&
165
+ // The provenance line is a protocol between these two programs, not a
166
+ // diagnosis. Reporting it as one would answer "why did this fail?" with
167
+ // a machine's own bookkeeping.
168
+ !l.includes('skrrd:accept-handoff'));
169
+ return line ? line.slice(0, 160) : 'no output';
170
+ }
171
+ async function handOffToLocalDaemon(env = process.env, opts = {}) {
172
+ // Mirrors `OVERSKY_SKIP_DAEMON_BROKER` on the other direction. An operator
173
+ // deliberately running a daemon against a different deployment than their CLI is
174
+ // doing a legitimate thing, and this must not overrule it.
175
+ if (env.OVERSKY_SKIP_DAEMON_HANDOFF === '1' || env.SKRR_SKIP_DAEMON_HANDOFF === '1') {
176
+ return { status: 'skipped', detail: 'skipped by OVERSKY_SKIP_DAEMON_HANDOFF' };
177
+ }
178
+ // The daemon brokered this login, which means it just used its OWN credential
179
+ // against this server and that credential works. There is nothing to fix.
180
+ //
181
+ // Running anyway is not merely redundant, it is destructive: it mints a
182
+ // replacement, writes it over the working one, revokes the family the daemon is
183
+ // authenticated with, and restarts the process — killing whatever sessions it
184
+ // was running. `skrr login` on a healthy machine is the path where the least
185
+ // should happen, and it was the path where the most did.
186
+ if (opts.loginFlow === 'daemon-broker') {
187
+ return {
188
+ status: 'skipped',
189
+ detail: 'the local daemon brokered this login, so it already has a working credential',
190
+ };
191
+ }
192
+ const binary = (0, exec_oversky_1.findOverskyBinary)();
193
+ if (!binary) {
194
+ return { status: 'skipped', detail: 'no local daemon installed' };
195
+ }
196
+ // An older daemon has no `accept-handoff`, and spawning it would fail with
197
+ // commander's "unknown command" rather than anything a reader could act on. A
198
+ // CLI that outruns the daemon on the machine is the normal case during a
199
+ // staged rollout, not an error.
200
+ const supported = await (0, exec_oversky_1.overskySupportsSubcommand)('accept-handoff');
201
+ if (supported === false) {
202
+ return {
203
+ status: 'skipped',
204
+ detail: 'the installed daemon predates `accept-handoff`; run `skrr daemon login` once',
205
+ };
206
+ }
207
+ // Ask the daemon which id it will bind, rather than deriving it here. The id
208
+ // comes from cwd + profile, and a second copy of that derivation in the CLI
209
+ // would drift — producing a credential minted for a daemon that does not exist,
210
+ // which is indistinguishable from a login that simply did not work.
211
+ const printed = await runOversky(binary, ['accept-handoff', '--print-id', ...profileArgs()], {
212
+ timeoutMs: PROBE_TIMEOUT_MS,
213
+ });
214
+ const daemonId = printed.ok ? printed.stdout.trim() : '';
215
+ if (!daemonId) {
216
+ return {
217
+ status: 'failed',
218
+ detail: `could not read the local daemon id — ${firstMeaningfulLine(printed.stderr)}`,
219
+ };
220
+ }
221
+ const { idSource, machineUuid, daemonServer } = parseIdProvenance(printed.stderr);
222
+ // A well-formed id is not the same as a real daemon. With no service installed
223
+ // the daemon derives one from whatever directory this process is in, so the id
224
+ // above names a daemon that does not exist and will not exist under that id if
225
+ // one is ever installed from a different directory. Minting for it leaves an
226
+ // orphaned daemon-scope family on the account and hands the credential to
227
+ // nothing — which is the case the design record says to skip, and which the
228
+ // binary-presence check alone never detected because a binary on PATH is not a
229
+ // service.
230
+ //
231
+ // Only a daemon that reports its provenance can be skipped this way. An older
232
+ // one says nothing, and is left on the previous behaviour rather than refused.
233
+ if (idSource === 'cwd') {
234
+ return {
235
+ status: 'skipped',
236
+ detail: 'no daemon service is installed on this machine',
237
+ };
238
+ }
239
+ let bundle;
240
+ try {
241
+ bundle = (await data_provider_1.dataService.mintDaemonHandoff({
242
+ daemonId,
243
+ ...(machineUuid ? { machineUuid } : {}),
244
+ }));
245
+ }
246
+ catch (err) {
247
+ return { status: 'failed', detail: err.message };
248
+ }
249
+ const payload = JSON.stringify({
250
+ refreshToken: bundle.refreshToken,
251
+ refreshExpiresAt: bundle.refreshExpiresAt,
252
+ serverUrl: (0, config_1.loadConfig)().baseURL,
253
+ daemonId,
254
+ });
255
+ // No restart from here. `accept-handoff` runs the runtime's own
256
+ // `restartDisconnectedServiceAfterLogin` after it writes the credential — the
257
+ // same reconcile every other credential-write path in the daemon uses — and it
258
+ // is the better of the two: it checks that a service is installed and running,
259
+ // refuses to yank a daemon another profile owns, and gives the in-process
260
+ // watcher a window to converge before reaching for the heavy hammer.
261
+ //
262
+ // A second restart out here does not add a guarantee, it adds an outage. Each
263
+ // one kills in-flight Claude/Codex sessions and browser CDP attachments, and
264
+ // the second would land on a process that is seconds into starting up.
265
+ const accepted = await runOversky(binary, ['accept-handoff', ...profileArgs()], {
266
+ input: payload,
267
+ timeoutMs: ACCEPT_TIMEOUT_MS,
268
+ });
269
+ if (!accepted.ok) {
270
+ // Deliberately NOT confirming. The machine still holds the credential it had
271
+ // before this ran, and it still works — the mint no longer revokes anything,
272
+ // so the cost of a refused handoff is one unused family that the next
273
+ // successful login sweeps, rather than a signed-out machine.
274
+ return { status: 'failed', detail: firstMeaningfulLine(accepted.stderr) };
275
+ }
276
+ // Only now is the predecessor retired: the daemon has written the new
277
+ // credential to its own store, so "the old one dies iff the new one is in
278
+ // place" holds. Best-effort — a confirm that does not land leaves one extra
279
+ // live family, bounded by the server's concurrent-session cap and swept by the
280
+ // next confirm, which is a far cheaper failure than the one it replaces.
281
+ if (bundle.familyId) {
282
+ try {
283
+ await data_provider_1.dataService.confirmDaemonHandoff({ daemonId, familyId: bundle.familyId });
284
+ }
285
+ catch {
286
+ /* the credential is delivered; the sweep is bookkeeping */
287
+ }
288
+ }
289
+ return {
290
+ status: 'delivered',
291
+ detail: describeDelivery({
292
+ accountLabel: bundle.accountLabel,
293
+ daemonServer,
294
+ cliBaseURL: (0, config_1.loadConfig)().baseURL,
295
+ }),
296
+ };
297
+ }