@skrr-ai/cli 0.1.38 → 0.1.40

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 (34) hide show
  1. package/README.md +1 -1
  2. package/bin/dev-fallback.js +8 -0
  3. package/dist/base-command.js +8 -0
  4. package/dist/commands/agents/create.d.ts +7 -0
  5. package/dist/commands/agents/create.js +13 -1
  6. package/dist/commands/agents/triggers/policy.d.ts +1 -0
  7. package/dist/commands/agents/triggers/policy.js +32 -0
  8. package/dist/commands/agents/update.js +4 -3
  9. package/dist/commands/followups/observability.js +6 -2
  10. package/dist/commands/followups/remind.js +5 -2
  11. package/dist/commands/followups/reschedule.js +2 -1
  12. package/dist/commands/followups/show.js +4 -0
  13. package/dist/commands/followups/watch.d.ts +1 -0
  14. package/dist/commands/followups/watch.js +9 -3
  15. package/dist/commands/machines/dedicated/create.js +6 -2
  16. package/dist/commands/machines/dedicated/destroy.js +10 -10
  17. package/dist/commands/machines/dedicated/exec.js +3 -1
  18. package/dist/commands/machines/dedicated/price-book.d.ts +17 -0
  19. package/dist/commands/machines/dedicated/price-book.js +35 -0
  20. package/dist/commands/tasks/lifecycle/detach.js +26 -5
  21. package/dist/lib/dedicated-lease-command.d.ts +3 -1
  22. package/dist/lib/dedicated-lease-command.js +8 -3
  23. package/dist/lib/dedicated-machines.d.ts +48 -1
  24. package/dist/lib/dedicated-machines.js +93 -7
  25. package/dist/lib/dedicated-wait.js +13 -0
  26. package/dist/lib/followups.d.ts +8 -0
  27. package/dist/lib/label-ref.d.ts +8 -1
  28. package/dist/lib/label-ref.js +12 -2
  29. package/dist/lib/option-hint.d.ts +15 -0
  30. package/dist/lib/option-hint.js +75 -0
  31. package/dist/lib/workspaces.d.ts +3 -0
  32. package/dist/lib/workspaces.js +38 -0
  33. package/oclif.manifest.json +4952 -4885
  34. package/package.json +5 -3
@@ -5,6 +5,8 @@ exports.describeDedicatedImageStanding = describeDedicatedImageStanding;
5
5
  exports.dedicatedRuntimeCreateInput = dedicatedRuntimeCreateInput;
6
6
  exports.isDedicatedRepositoryRef = isDedicatedRepositoryRef;
7
7
  exports.createDedicatedRuntime = createDedicatedRuntime;
8
+ exports.getDedicatedOperatorPriceBook = getDedicatedOperatorPriceBook;
9
+ exports.dedicatedOperatorPriceBookLines = dedicatedOperatorPriceBookLines;
8
10
  exports.listDedicatedRuntimes = listDedicatedRuntimes;
9
11
  exports.getDedicatedRuntime = getDedicatedRuntime;
10
12
  exports.performDedicatedLeaseAction = performDedicatedLeaseAction;
@@ -157,6 +159,59 @@ async function createDedicatedRuntime(input) {
157
159
  const { requestId, workspaceId, ...body } = input;
158
160
  return (0, api_fetch_1.apiFetch)(withDedicatedScope('/api/machines/dedicated', { workspaceId }), { method: 'POST', body, headers: { 'Idempotency-Key': requestId } });
159
161
  }
162
+ /**
163
+ * The Dedicated Runtime price book as an operator reads it: which version and
164
+ * which pricing policy, and each tier's rate, cost and margin. The customer
165
+ * catalog carries none of the policy or cost facts, and the admin plane that
166
+ * also serves this is not deployed in production (OSK-9562).
167
+ */
168
+ async function getDedicatedOperatorPriceBook() {
169
+ return (0, api_fetch_1.apiFetch)('/api/machines/dedicated/price-book');
170
+ }
171
+ function formatBps(bps) {
172
+ return bps === null ? 'unknown' : `${(bps / 100).toFixed(1)}%`;
173
+ }
174
+ function formatRateCents(cents) {
175
+ return cents === null ? 'none' : `$${(cents / 100).toFixed(2)}/hr`;
176
+ }
177
+ /** Human lines for `machines dedicated price-book`. Pure, so it can be pinned. */
178
+ function dedicatedOperatorPriceBookLines(read) {
179
+ const book = read.priceBook;
180
+ const lines = [
181
+ `Dedicated Runtime price book — ${book.approved ? 'approved' : 'NOT approved'}${book.required ? '' : ' (not required here)'}`,
182
+ ` version ${book.version ?? 'not recorded'}`,
183
+ ` policy ${book.pricingPolicyRef ?? 'not recorded'}`,
184
+ ` target margin ${formatBps(book.targetGrossMarginBps)}`,
185
+ ];
186
+ if (!book.approved && book.missingReason)
187
+ lines.push(` why ${book.missingReason}`);
188
+ if (read.availability === 'disabled' && read.disabledReason) {
189
+ lines.push(` product disabled: ${read.disabledReason}`);
190
+ }
191
+ if (read.configError)
192
+ lines.push(` config unreadable: ${read.configError}`);
193
+ lines.push('', 'Offered tiers');
194
+ if (book.presetChecks.length === 0)
195
+ lines.push(' none');
196
+ for (const check of book.presetChecks) {
197
+ const verdict = !check.priced
198
+ ? 'UNPRICED'
199
+ : !check.providerCostRecorded
200
+ ? 'UNCOSTED'
201
+ : check.marginTargetMet
202
+ ? 'meets target'
203
+ : 'BELOW TARGET';
204
+ lines.push(` ${check.sizePreset.padEnd(12)} rate ${formatRateCents(check.customerRateCentsPerHour)} · cost ${formatRateCents(check.providerCostCentsPerHour)} · margin ${formatBps(check.grossMarginBps)} · ${verdict}`);
205
+ }
206
+ const withheld = read.sizeAvailability.filter((entry) => !entry.offered);
207
+ if (withheld.length > 0) {
208
+ lines.push('', 'Withheld tiers');
209
+ for (const entry of withheld) {
210
+ lines.push(` ${entry.id.padEnd(12)} ${entry.withheldReason ?? 'no reason recorded'}`);
211
+ }
212
+ }
213
+ return lines;
214
+ }
160
215
  async function listDedicatedRuntimes(options = {}) {
161
216
  const query = new URLSearchParams();
162
217
  if (options.limit)
@@ -411,10 +466,25 @@ function dedicatedSpendSummary(lease) {
411
466
  parts.push(`projected ${formatUsdCents(projected)}`);
412
467
  }
413
468
  if (billing.spendingLimitExceeded) {
414
- parts.push('OVER CAP — compute is stopped, files are kept');
469
+ parts.push(`OVER CAP — ${overCapComputeClause(lease.state)}, files are kept`);
415
470
  }
416
471
  return parts.join(' · ');
417
472
  }
473
+ /**
474
+ * What the cap has done to compute, in the tense the lease is actually in.
475
+ *
476
+ * This always said "compute is stopped", including for a machine that was still
477
+ * `requested` — which, over its cap, never provisions and cannot have its cap
478
+ * raised until it settles (OSK-9836). The reconciler stops compute for exactly
479
+ * `stopping`, `stopped` and `archived`; anything else is only on its way there.
480
+ */
481
+ function overCapComputeClause(state) {
482
+ if (state === 'stopped' || state === 'archived')
483
+ return 'compute is stopped';
484
+ if (state === 'stopping')
485
+ return 'compute is stopping';
486
+ return 'compute will be stopped';
487
+ }
418
488
  /**
419
489
  * The human rendering of `machines dedicated show`, as lines.
420
490
  *
@@ -1064,9 +1134,9 @@ function dedicatedRuntimeApiErrorDetails(err) {
1064
1134
  * say nothing to the person running it (OSK-8768); `--json` keeps them in
1065
1135
  * `error.details.reasons`.
1066
1136
  */
1067
- function describeRefusalReasons(err) {
1137
+ function describeRefusalReasons(err, context = {}) {
1068
1138
  const details = dedicatedRuntimeApiErrorDetails(err);
1069
- const spend = describeSpendingCapRefusal(err.body, details);
1139
+ const spend = describeSpendingCapRefusal(err.body, details, context);
1070
1140
  if (spend)
1071
1141
  return spend;
1072
1142
  const sentences = (0, harnesses_1.describeHealthReasonSentences)(details?.reasons, details?.state);
@@ -1078,7 +1148,7 @@ function describeRefusalReasons(err) {
1078
1148
  * the cap. Without them "would exceed the monthly cap" left a user guessing by
1079
1149
  * how much, and which cap (OSK-9552).
1080
1150
  */
1081
- function describeSpendingCapRefusal(body, details) {
1151
+ function describeSpendingCapRefusal(body, details, context = {}) {
1082
1152
  let code;
1083
1153
  try {
1084
1154
  code = JSON.parse(body || '{}').code;
@@ -1102,9 +1172,25 @@ function describeSpendingCapRefusal(body, details) {
1102
1172
  ].filter(Boolean);
1103
1173
  if (parts.length === 0)
1104
1174
  return '';
1105
- return ` ${parts.join('; ')}. Raise the cap with \`machines dedicated spending-limit <lease-id> --cents <n>\`.`;
1175
+ return ` ${parts.join('; ')}. ${raiseCapHint(context)}`;
1176
+ }
1177
+ /**
1178
+ * How to get past a cap refusal, for the command that was refused. A create or
1179
+ * a restore makes a NEW machine and takes its cap as a flag; only an existing
1180
+ * machine has a lease id to raise. Every refusal used to say
1181
+ * `machines dedicated spending-limit <lease-id>`, which a user refused on create
1182
+ * had no lease id to run (OSK-9552).
1183
+ */
1184
+ function raiseCapHint(context) {
1185
+ const bin = context.bin || 'skrr';
1186
+ const creates = context.command === 'machines:dedicated:create' ||
1187
+ context.command === 'machines:dedicated:restore';
1188
+ if (creates) {
1189
+ return 'Pass a higher --spending-limit-cents: the cap is compared with the whole month, not with this machine alone.';
1190
+ }
1191
+ return `Raise the cap with \`${bin} machines dedicated spending-limit ${context.leaseId || '<lease-id>'} --cents <n>\`.`;
1106
1192
  }
1107
- function formatDedicatedRuntimeApiError(err) {
1193
+ function formatDedicatedRuntimeApiError(err, context = {}) {
1108
1194
  if (!(err instanceof api_fetch_1.ApiFetchError) || !err.body) {
1109
1195
  return null;
1110
1196
  }
@@ -1132,7 +1218,7 @@ function formatDedicatedRuntimeApiError(err) {
1132
1218
  // Machine noun even though the caller named a Dedicated Runtime. Do not
1133
1219
  // repeat that contradictory product name while a mixed deployment rolls.
1134
1220
  const sentence = (parsed.message || parsed.error || parsed.code)?.replace(/(?:skrr )?Hosted Machine/g, 'Dedicated Runtime');
1135
- return sentence ? `${sentence}${describeRefusalReasons(err)}` : null;
1221
+ return sentence ? `${sentence}${describeRefusalReasons(err, context)}` : null;
1136
1222
  }
1137
1223
  catch {
1138
1224
  return err.body.trim() || null;
@@ -17,8 +17,21 @@ function backupIsNewer(current, previous) {
17
17
  const b = Date.parse(previous);
18
18
  return Number.isFinite(a) && Number.isFinite(b) ? a > b : current !== previous;
19
19
  }
20
+ /**
21
+ * `recovering` is also the state an image move replaces the machine through, on
22
+ * purpose. Reading every `recovering` as a failed action being retried told an
23
+ * owner watching an ordinary `update-image --wait` that destroy was available
24
+ * "if it never settles".
25
+ */
26
+ const PLANNED_REPLACEMENT_PROGRESS = {
27
+ dedicated_runtime_image_update: 'replacing the machine',
28
+ dedicated_runtime_image_update_rolling_back: 'replacing the machine again',
29
+ };
20
30
  function progressOf(lease) {
21
31
  const state = (0, dedicated_machines_1.dedicatedLeaseState)(lease);
32
+ const planned = lease.stateReason ? PLANNED_REPLACEMENT_PROGRESS[lease.stateReason] : undefined;
33
+ if (state === 'recovering' && planned)
34
+ return planned;
22
35
  if (state === 'recovering') {
23
36
  // `recovering` is where a failed provision or action lands, retried with
24
37
  // backoff. It can settle — or never, which is why destroy is allowed from it.
@@ -34,6 +34,8 @@ export interface FollowUpRow {
34
34
  terminal?: string | null;
35
35
  terminalAt?: string | null;
36
36
  terminalReason?: string | null;
37
+ /** Who settled it: `trigger_fire` for the runtime, otherwise the actor. */
38
+ terminalBy?: string | null;
37
39
  escalatedInitiativeId?: string | null;
38
40
  capsule?: unknown;
39
41
  nextRunAt?: string | null;
@@ -62,6 +64,12 @@ export interface FollowUpObservability {
62
64
  terminal: Record<string, number>;
63
65
  escalationRate: number | null;
64
66
  fires: number;
67
+ /** USD from the fires' UsageLog rows; null when the ledger could not be read. */
68
+ spend?: {
69
+ today: number;
70
+ total: number;
71
+ currency: string;
72
+ } | null;
65
73
  budget: {
66
74
  pool: string | null;
67
75
  used: number;
@@ -59,12 +59,19 @@ export declare function closestNames(typed: string, names: string[], limit?: num
59
59
  export interface LabelVocabularyContext {
60
60
  /** Narrow to labels attachable to this entity type. Omit for all of them. */
61
61
  applicableTo?: LabelApplicable;
62
- /** Narrows to the workspace scope; `--workspace` / OVERSKY_WORKSPACE_ID. */
62
+ /**
63
+ * Narrows to this workspace scope. When omitted the AMBIENT workspace is
64
+ * resolved (`--workspace` / OVERSKY_WORKSPACE_ID / the server's default
65
+ * workspace): a session that never selected a workspace still writes to
66
+ * one, and a vocabulary that cannot see it refuses names that exist.
67
+ */
63
68
  workspaceId?: string;
64
69
  /** Archived labels are excluded unless asked for. */
65
70
  includeArchived?: boolean;
66
71
  /** Injected in tests. */
67
72
  getLabels?: typeof dataService.getLabels;
73
+ /** Injected in tests; defaults to `workspaces.ambientWorkspaceId`. */
74
+ resolveWorkspaceId?: () => Promise<string | undefined>;
68
75
  }
69
76
  export interface LabelRefContext extends LabelVocabularyContext {
70
77
  /** Which entity the resolved labels will be attached to. */
@@ -39,6 +39,7 @@ exports.closestNames = closestNames;
39
39
  exports.collectLabelVocabulary = collectLabelVocabulary;
40
40
  exports.resolveLabelRefs = resolveLabelRefs;
41
41
  const data_provider_1 = require("@skrr-ai/data-provider");
42
+ const workspaces_1 = require("./workspaces");
42
43
  /** A canonical v4 uuid, which is what every label id is. */
43
44
  const UUID_RE = /^[0-9a-f]{8}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{12}$/i;
44
45
  function looksLikeLabelId(ref) {
@@ -89,9 +90,18 @@ function closestNames(typed, names, limit = 3) {
89
90
  */
90
91
  async function collectLabelVocabulary(context) {
91
92
  const getLabels = context.getLabels ?? data_provider_1.dataService.getLabels;
93
+ let workspaceId = context.workspaceId;
94
+ if (!workspaceId) {
95
+ try {
96
+ workspaceId = await (context.resolveWorkspaceId ?? workspaces_1.ambientWorkspaceId)();
97
+ }
98
+ catch {
99
+ workspaceId = undefined;
100
+ }
101
+ }
92
102
  const requests = [{ scope: 'user' }];
93
- if (context.workspaceId)
94
- requests.push({ scope: 'workspace', scopeId: context.workspaceId });
103
+ if (workspaceId)
104
+ requests.push({ scope: 'workspace', scopeId: workspaceId });
95
105
  const settled = await Promise.allSettled(requests.map((request) => getLabels({
96
106
  ...request,
97
107
  ...(context.applicableTo ? { applicableTo: context.applicableTo } : {}),
@@ -0,0 +1,15 @@
1
+ /**
2
+ * A "did you mean" for oclif's enum refusal (OSK-9735).
3
+ *
4
+ * Tasks, spaces, goals and key results each spell one lifecycle their own way —
5
+ * "under way" is `in_progress` for a space and `active` for a goal; "finished"
6
+ * is `done`, `completed` or `achieved` — and oclif's
7
+ * `Expected --status=in_progress to be one of: …` was the only place a user
8
+ * learned that. This names the value the command uses for the word that was
9
+ * typed, and otherwise the nearest spelling.
10
+ *
11
+ * It suggests and never substitutes. Some of the differences are deliberate (a
12
+ * key result's status is a health reading, not a lifecycle), so writing a mapped
13
+ * value would record a status nobody chose.
14
+ */
15
+ export declare function invalidOptionHint(message: string): string | null;
@@ -0,0 +1,75 @@
1
+ "use strict";
2
+ /**
3
+ * A "did you mean" for oclif's enum refusal (OSK-9735).
4
+ *
5
+ * Tasks, spaces, goals and key results each spell one lifecycle their own way —
6
+ * "under way" is `in_progress` for a space and `active` for a goal; "finished"
7
+ * is `done`, `completed` or `achieved` — and oclif's
8
+ * `Expected --status=in_progress to be one of: …` was the only place a user
9
+ * learned that. This names the value the command uses for the word that was
10
+ * typed, and otherwise the nearest spelling.
11
+ *
12
+ * It suggests and never substitutes. Some of the differences are deliberate (a
13
+ * key result's status is a health reading, not a lifecycle), so writing a mapped
14
+ * value would record a status nobody chose.
15
+ */
16
+ Object.defineProperty(exports, "__esModule", { value: true });
17
+ exports.invalidOptionHint = invalidOptionHint;
18
+ /**
19
+ * Spellings of one concept across the CLI's vocabularies. A value is suggested
20
+ * only when it shares a group with what was typed.
21
+ */
22
+ const CONCEPT_GROUPS = [
23
+ new Set(['in_progress', 'inprogress', 'active', 'started', 'ongoing', 'running', 'doing']),
24
+ new Set(['done', 'completed', 'complete', 'achieved', 'finished', 'closed', 'resolved']),
25
+ new Set(['cancelled', 'canceled', 'abandoned']),
26
+ new Set(['in_review', 'inreview', 'review', 'reviewing']),
27
+ new Set(['planned', 'todo', 'to_do', 'not_started']),
28
+ ];
29
+ const REFUSAL = /Expected (--[\w-]+)=(.+?) to be one of: ([^\n]+)/;
30
+ const normalize = (value) => value
31
+ .trim()
32
+ .toLowerCase()
33
+ .replace(/[\s-]+/g, '_');
34
+ function editDistance(a, b) {
35
+ const row = Array.from({ length: b.length + 1 }, (_, i) => i);
36
+ for (let i = 1; i <= a.length; i += 1) {
37
+ let diagonal = row[0];
38
+ row[0] = i;
39
+ for (let j = 1; j <= b.length; j += 1) {
40
+ const above = row[j];
41
+ row[j] = Math.min(row[j] + 1, row[j - 1] + 1, diagonal + (a[i - 1] === b[j - 1] ? 0 : 1));
42
+ diagonal = above;
43
+ }
44
+ }
45
+ return row[b.length];
46
+ }
47
+ function invalidOptionHint(message) {
48
+ const match = REFUSAL.exec(message || '');
49
+ if (!match)
50
+ return null;
51
+ const [, flag, input, list] = match;
52
+ const options = list
53
+ .split(',')
54
+ .map((option) => option.trim())
55
+ .filter(Boolean);
56
+ const typed = normalize(input);
57
+ const exact = options.find((option) => normalize(option) === typed);
58
+ if (exact)
59
+ return ` Did you mean ${flag} ${exact}?`;
60
+ const group = CONCEPT_GROUPS.find((concept) => concept.has(typed));
61
+ const synonym = group && options.find((option) => group.has(normalize(option)));
62
+ if (synonym) {
63
+ return ` Did you mean ${flag} ${synonym}? Here "${input}" is spelled "${synonym}".`;
64
+ }
65
+ let nearest = null;
66
+ for (const option of options) {
67
+ const distance = editDistance(typed, normalize(option));
68
+ if (!nearest || distance < nearest.distance)
69
+ nearest = { option, distance };
70
+ }
71
+ if (nearest && nearest.distance <= Math.max(2, Math.floor(typed.length / 4))) {
72
+ return ` Did you mean ${flag} ${nearest.option}?`;
73
+ }
74
+ return null;
75
+ }
@@ -56,6 +56,9 @@ export interface WorkspaceListing {
56
56
  */
57
57
  export declare function describeUnselectedWorkspace(listing: WorkspaceListing): string;
58
58
  export declare function listWorkspaces(): Promise<WorkspaceRow[]>;
59
+ export declare function ambientWorkspaceId(env?: NodeJS.ProcessEnv): Promise<string | undefined>;
60
+ /** Test seam — a memoized network result must not leak between specs. */
61
+ export declare function clearAmbientWorkspaceCache(): void;
59
62
  /** `listWorkspaces`, keeping the envelope's caller-level fields. */
60
63
  export declare function listWorkspaceListing(): Promise<WorkspaceListing>;
61
64
  export declare function getWorkspace(workspaceId: string): Promise<WorkspaceRow>;
@@ -2,6 +2,8 @@
2
2
  Object.defineProperty(exports, "__esModule", { value: true });
3
3
  exports.describeUnselectedWorkspace = describeUnselectedWorkspace;
4
4
  exports.listWorkspaces = listWorkspaces;
5
+ exports.ambientWorkspaceId = ambientWorkspaceId;
6
+ exports.clearAmbientWorkspaceCache = clearAmbientWorkspaceCache;
5
7
  exports.listWorkspaceListing = listWorkspaceListing;
6
8
  exports.getWorkspace = getWorkspace;
7
9
  exports.matchWorkspace = matchWorkspace;
@@ -60,6 +62,42 @@ function describeUnselectedWorkspace(listing) {
60
62
  async function listWorkspaces() {
61
63
  return (await listWorkspaceListing()).rows;
62
64
  }
65
+ /**
66
+ * The workspace a bare session actually writes to, for callers merging the
67
+ * caller's vocabulary or defaults across scopes.
68
+ *
69
+ * `OVERSKY_WORKSPACE_ID` is only populated when a workspace was SELECTED —
70
+ * `workspaces use`, `--workspace`, or a daemon session handoff. An account
71
+ * that never selected one still has a server-side `defaultWorkspaceId`, and
72
+ * every unscoped write lands there; a caller that only read the variable
73
+ * would reason about a smaller world than the one the server writes into
74
+ * (observed: `labels list` reported a user-only vocabulary while three
75
+ * workspace-scoped labels existed and were attachable).
76
+ *
77
+ * The listing fetch is memoized and fail-open: this runs to give a better
78
+ * answer, and must never become the error — an unreachable server returns
79
+ * `undefined`, which is the same world the caller saw before this existed.
80
+ */
81
+ let memoizedDefaultWorkspaceId;
82
+ async function ambientWorkspaceId(env = process.env) {
83
+ const explicit = env.OVERSKY_WORKSPACE_ID?.trim();
84
+ if (explicit)
85
+ return explicit;
86
+ if (memoizedDefaultWorkspaceId !== undefined) {
87
+ return memoizedDefaultWorkspaceId ?? undefined;
88
+ }
89
+ try {
90
+ memoizedDefaultWorkspaceId = (await listWorkspaceListing()).defaultWorkspaceId ?? null;
91
+ }
92
+ catch {
93
+ memoizedDefaultWorkspaceId = null;
94
+ }
95
+ return memoizedDefaultWorkspaceId ?? undefined;
96
+ }
97
+ /** Test seam — a memoized network result must not leak between specs. */
98
+ function clearAmbientWorkspaceCache() {
99
+ memoizedDefaultWorkspaceId = undefined;
100
+ }
63
101
  /** `listWorkspaces`, keeping the envelope's caller-level fields. */
64
102
  async function listWorkspaceListing() {
65
103
  const rows = [];