warpmetal 0.8.13 → 0.9.1

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.
package/src/api.js CHANGED
@@ -135,6 +135,10 @@ export class WarpMetalClient {
135
135
  return this.request("GET", "/catalog");
136
136
  }
137
137
 
138
+ modelCatalog() {
139
+ return this.request("GET", "/agent-team-model-catalog");
140
+ }
141
+
138
142
  prepareOrder(body, idempotencyKey) {
139
143
  return this.request("POST", "/orders", { body, idempotencyKey });
140
144
  }
@@ -376,11 +380,13 @@ export class WarpMetalClient {
376
380
  );
377
381
  }
378
382
 
379
- sandboxAction(serverId, sandboxId, action, token, idempotencyKey) {
383
+ sandboxAction(serverId, sandboxId, action, token, idempotencyKey, imageDigest) {
380
384
  const body =
381
- action === "refresh_image"
382
- ? { action, confirm: "refresh_image" }
383
- : { action };
385
+ action === "patch_image"
386
+ ? { action, confirm: "patch_image", imageDigest }
387
+ : action === "refresh_image"
388
+ ? { action, confirm: "refresh_image" }
389
+ : { action };
384
390
  return this.request(
385
391
  "POST",
386
392
  `/servers/${encodeURIComponent(serverId)}/sandboxes/${encodeURIComponent(sandboxId)}/actions`,
package/src/cli.js CHANGED
@@ -10,12 +10,24 @@ import {
10
10
  rejectUnknownOptions,
11
11
  stringOption,
12
12
  } from "./args.js";
13
+ import {
14
+ AccountGatewayClient,
15
+ accountSessionContext,
16
+ authenticatedAccountClient,
17
+ } from "./account-gateway.js";
13
18
  import { WarpMetalClient } from "./api.js";
19
+ import { managementClient, managementMutations, readManagementInput } from "./agent-management.js";
14
20
  import { connectionProfile, writeConnectionProfile } from "./connection.js";
15
21
  import { CliError, toErrorMessage } from "./errors.js";
16
22
  import { establishHostTrust } from "./host-trust.js";
17
23
  import { installSkill } from "./install-skill.js";
18
24
  import { installRuntime } from "./installer.js";
25
+ import { openVerificationPage } from "./customer-auth.js";
26
+ import {
27
+ filterModelCatalog,
28
+ modelCatalogHuman,
29
+ MODEL_AUTH_MODES,
30
+ } from "./models.js";
19
31
  import {
20
32
  createPaymentRequestEnvelope,
21
33
  defaultPaymentPaths,
@@ -74,12 +86,19 @@ const COMMON_OPTIONS = ["base-url", "json", "state-dir", "help"];
74
86
  const HELP = `WarpMetal CLI ${VERSION}
75
87
 
76
88
  Usage:
89
+ warpmetal login [--no-browser] [--read-only] [--json]
90
+ warpmetal auth status [--json]
91
+ warpmetal account orders [--task <taskId>] [--json]
92
+ warpmetal account devices [--server <serverId>] [--json]
93
+ warpmetal logout [--json]
77
94
  warpmetal health [--json]
78
95
  warpmetal catalog [--plan <planId>] [--json]
96
+ warpmetal models [--provider <id>] [--auth-mode <api_key|chatgpt_subscription>] [--json]
79
97
  warpmetal order prepare --plan <planId> --hostname <name> --os <exact-name>
80
98
  (--generate-ssh-key [--ssh-key-name <name>] | --ssh-public-key-file <path>)
81
99
  [--runtime-file <path>] [--confirm TEMPORARY]
82
100
  [--email <address>] [--idempotency-key <key>]
101
+ [--account [--without-agent-boxes | --without-team]]
83
102
  warpmetal order status --task <taskId> [--wait] [--timeout-seconds <n>]
84
103
  warpmetal checkout challenge --task <taskId> [--request-envelope-out <path>]
85
104
  warpmetal checkout submit --task <taskId>
@@ -119,7 +138,8 @@ Usage:
119
138
  [--lifetime temporary] [--expires-in-seconds <n>] [--confirm TEMPORARY] [--wait]
120
139
  warpmetal sandbox create --server <serverId> --file <batch.json> [--confirm TEMPORARY]
121
140
  warpmetal sandbox action --server <serverId> --sandbox <sandboxId>
122
- --action <start|stop|restart|make_persistent|refresh_image> --confirm <same-action> [--wait]
141
+ --action <start|stop|restart|make_persistent|refresh_image|patch_image> --confirm <same-action>
142
+ [--image-digest <image@sha256:digest> (patch_image only)] [--wait]
123
143
  warpmetal sandbox list|get|action|delete ...
124
144
  warpmetal sandbox access keygen --output <private-key-path> --confirm GENERATE
125
145
  warpmetal sandbox access grant|list|get|revoke ...
@@ -133,11 +153,38 @@ Usage:
133
153
  warpmetal tools install --server <serverId> --sandbox <sandboxId> --profile <profileId>
134
154
  [--idempotency-key <key>] [--wait] [--timeout-seconds <n>]
135
155
  warpmetal tools status --server <serverId> [--wait] [--timeout-seconds <n>]
156
+ warpmetal work list|sources|policy SERVER BOX [--json]
157
+ warpmetal work show|content|targets SERVER BOX WORK [--json]
158
+ warpmetal work create SERVER BOX --file <request.json>
159
+ warpmetal work update|enable|checkpoint|continue|restore|handoff SERVER BOX WORK --file <request.json>
160
+ warpmetal work status SERVER BOX WORK --kind <checkpoint|continue|restore|handoff>
161
+ (--request <requestId> | --operation <operationId>)
162
+ warpmetal work open SERVER BOX WORK [--connection-file <profile> --identity <sandbox-key> | --json]
163
+ warpmetal insights summary SERVER [--limit <n>] [--cursor <cursor>]
164
+ warpmetal insights status|list SERVER BOX [--json]
165
+ warpmetal insights enable|disable SERVER BOX --file <request.json>
166
+ warpmetal insights show SERVER BOX FINDING [--json]
167
+ warpmetal insights acknowledge|snooze|dismiss SERVER BOX FINDING --file <request.json>
168
+ warpmetal insights open SERVER BOX FINDING [--takeover <operationId>]
169
+ [--connection-file <profile> --identity <sandbox-key> | --json]
170
+ warpmetal insights manager settings SERVER BOX [--file <request.json>]
171
+ warpmetal insights manager activity SERVER BOX [--finding <findingId>]
172
+ warpmetal insights manager run SERVER BOX RUN
173
+ warpmetal insights manager target SERVER BOX FINDING
174
+ warpmetal insights manager recheck SERVER BOX FINDING --file <request.json>
175
+ warpmetal insights manager status SERVER BOX FINDING --request <requestId>
176
+ warpmetal insights takeover list SERVER BOX FINDING
177
+ warpmetal insights takeover SERVER BOX FINDING --file <request.json>
178
+ warpmetal insights takeover status SERVER BOX FINDING --operation <operationId>
179
+ warpmetal insights takeover resume SERVER BOX FINDING --operation <operationId> --file <request.json>
180
+ warpmetal insights review SERVER BOX RUN [--connection-file <profile> --identity <sandbox-key> | --json]
136
181
  warpmetal state list
137
182
  warpmetal agent install --target <codex|claude|all> [--scope <user|project>] [--force]
138
183
 
139
184
  Global options:
140
185
  --base-url <url> Override https://api.warpmetal.com
186
+ --identity-url <url> Override https://identity.warpmetal.com for account auth
187
+ --account-url <url> Override https://warpmetal.com for account requests
141
188
  --state-dir <path> Override the private state directory
142
189
  --json Emit structured, secret-redacted JSON
143
190
  --help Show help
@@ -231,6 +278,18 @@ Sandbox SSH aliases:
231
278
  https://geminicli.com/docs/get-started/installation/
232
279
  https://geminicli.com/docs/cli/headless/
233
280
 
281
+ Retained Work and Insights:
282
+ Use an existing server owner/SSH login. Mutations take a closed request JSON
283
+ file with its request ID and revision fences. Repeating the same saved intent
284
+ performs GET-only reconciliation. Ordinary output contains metadata; work
285
+ content deliberately returns private owner text. Pending operations exit 8,
286
+ conflicts/terminal failures exit 5. Accepted continuation or handoff means
287
+ task admission, not task completion.
288
+ open/review --json returns a fresh exact descriptor. Interactive attachment
289
+ requires a sandbox grant/profile and local OpenCode 2.0.14. It creates no
290
+ session or initial prompt. Manager review is read-only. Recommend mode cannot
291
+ automatically steer a worker; protected takeover and Resume are explicit.
292
+
234
293
  Credential environment variables:
235
294
  WARPMETAL_OWNER_TOKEN Recovery/bootstrap credential for one explicit command
236
295
  WARPMETAL_ACCESS_TOKEN Short-lived SSH-derived credential for one explicit command
@@ -827,6 +886,204 @@ async function handleCatalog(client, options, { json, stdout }) {
827
886
  return 0;
828
887
  }
829
888
 
889
+ async function handleModels(client, options, { json, stdout }) {
890
+ const provider = stringOption(options, "provider");
891
+ const authMode = stringOption(options, "auth-mode");
892
+ if (authMode !== undefined && !MODEL_AUTH_MODES.has(authMode)) {
893
+ throw new CliError(
894
+ "--auth-mode must be api_key or chatgpt_subscription.",
895
+ { exitCode: 2 },
896
+ );
897
+ }
898
+ const result = await client.modelCatalog();
899
+ const data = filterModelCatalog(result.data, { provider, authMode });
900
+ emit(stdout, data, json, modelCatalogHuman(data));
901
+ return 0;
902
+ }
903
+
904
+ function accountOptions(options, context, stateDirectory) {
905
+ return {
906
+ stateDirectory,
907
+ identityOrigin: stringOption(options, "identity-url"),
908
+ accountOrigin: stringOption(options, "account-url"),
909
+ fetchImpl: context.fetchImpl,
910
+ };
911
+ }
912
+
913
+ function publicAccountIdentity(value) {
914
+ if (
915
+ !value ||
916
+ typeof value.principalId !== "string" ||
917
+ typeof value.email !== "string" ||
918
+ typeof value.emailVerified !== "boolean" ||
919
+ !Array.isArray(value.scopes) ||
920
+ value.scopes.some((scope) => !["cli:read", "cli:write"].includes(scope))
921
+ ) {
922
+ throw new CliError("The account gateway returned an invalid identity response.", {
923
+ exitCode: 3,
924
+ code: "invalid_response",
925
+ });
926
+ }
927
+ return {
928
+ authenticated: true,
929
+ principalId: value.principalId,
930
+ email: value.email,
931
+ emailVerified: value.emailVerified,
932
+ scopes: value.scopes,
933
+ expiresAt:
934
+ typeof value.expiresAt === "string" ? value.expiresAt : undefined,
935
+ };
936
+ }
937
+
938
+ async function handleAccountLogin(options, context, stateDirectory) {
939
+ const readOnlyValue = options["read-only"];
940
+ if (readOnlyValue !== undefined && typeof readOnlyValue !== "boolean") {
941
+ throw new CliError("--read-only does not take a value.", { exitCode: 2 });
942
+ }
943
+ if (readOnlyValue === false) {
944
+ throw new CliError("Use --read-only to request a read-only session.", {
945
+ exitCode: 2,
946
+ });
947
+ }
948
+ if (options.browser === true) {
949
+ throw new CliError("Browser launch is automatic; use --no-browser to disable it.", {
950
+ exitCode: 2,
951
+ });
952
+ }
953
+ const account = accountSessionContext(
954
+ accountOptions(options, context, stateDirectory),
955
+ );
956
+ const scopes = readOnlyValue ? ["cli:read"] : ["cli:read", "cli:write"];
957
+ const controller = new AbortController();
958
+ const cancel = () => controller.abort();
959
+ process.once("SIGINT", cancel);
960
+ process.once("SIGTERM", cancel);
961
+ try {
962
+ const device = await account.auth.begin(scopes, {
963
+ signal: controller.signal,
964
+ });
965
+ writeLine(context.stderr, `Open ${device.verificationUriComplete}`);
966
+ writeLine(context.stderr, `Enter code: ${device.userCode}`);
967
+ if (options.browser !== false) {
968
+ openVerificationPage(device.verificationUriComplete, {
969
+ spawnImpl: context.spawnImpl,
970
+ });
971
+ }
972
+ const session = await account.auth.poll(device, {
973
+ signal: controller.signal,
974
+ });
975
+ const identity = publicAccountIdentity(
976
+ await new AccountGatewayClient({
977
+ accountOrigin: account.accountOrigin,
978
+ accessToken: session.accessToken,
979
+ fetchImpl: context.fetchImpl,
980
+ }).whoami(),
981
+ );
982
+ await account.store.withLock(async () => {
983
+ await account.store.write({
984
+ ...session,
985
+ createdAt: new Date().toISOString(),
986
+ });
987
+ });
988
+ emit(
989
+ context.stdout,
990
+ identity,
991
+ context.json,
992
+ `Authenticated as ${identity.email} (${identity.scopes.join(", ")}).`,
993
+ );
994
+ return 0;
995
+ } finally {
996
+ process.removeListener("SIGINT", cancel);
997
+ process.removeListener("SIGTERM", cancel);
998
+ }
999
+ }
1000
+
1001
+ async function handleAccountStatus(options, context, stateDirectory) {
1002
+ const authenticated = await authenticatedAccountClient(
1003
+ accountOptions(options, context, stateDirectory),
1004
+ );
1005
+ if (!authenticated) {
1006
+ emit(
1007
+ context.stdout,
1008
+ { authenticated: false },
1009
+ context.json,
1010
+ "Not authenticated. Run warpmetal login.",
1011
+ );
1012
+ return 0;
1013
+ }
1014
+ const identity = publicAccountIdentity(await authenticated.client.whoami());
1015
+ emit(
1016
+ context.stdout,
1017
+ identity,
1018
+ context.json,
1019
+ `Authenticated as ${identity.email} (${identity.scopes.join(", ")}).`,
1020
+ );
1021
+ return 0;
1022
+ }
1023
+
1024
+ async function requireAccount(options, context, stateDirectory) {
1025
+ const account = await authenticatedAccountClient(accountOptions(options, context, stateDirectory));
1026
+ if (!account) {
1027
+ throw new CliError("Sign in first with warpmetal login; no order details are needed to sign in.", { exitCode: 4 });
1028
+ }
1029
+ return account;
1030
+ }
1031
+
1032
+ async function handleAccountInventory(kind, options, context, stateDirectory) {
1033
+ const account = await requireAccount(options, context, stateDirectory);
1034
+ const id = stringOption(options, kind === "orders" ? "task" : "server");
1035
+ const data = kind === "orders"
1036
+ ? await (id ? account.client.order(id) : account.client.orders())
1037
+ : await (id ? account.client.device(id) : account.client.devices());
1038
+ emit(context.stdout, data, context.json, JSON.stringify(data, null, 2));
1039
+ return 0;
1040
+ }
1041
+
1042
+ async function handleAccountLogout(options, context, stateDirectory) {
1043
+ const account = accountSessionContext(
1044
+ accountOptions(options, context, stateDirectory),
1045
+ );
1046
+ const outcome = await account.store.withLock(async () => {
1047
+ const session = await account.store.read();
1048
+ if (!session) {
1049
+ return {
1050
+ authenticated: false,
1051
+ localSessionCleared: false,
1052
+ remoteRevoked: false,
1053
+ remoteStatus: "no_session",
1054
+ };
1055
+ }
1056
+ let remoteRevoked = false;
1057
+ let remoteError;
1058
+ try {
1059
+ await account.auth.revoke(session.refreshToken);
1060
+ remoteRevoked = true;
1061
+ } catch (error) {
1062
+ remoteError = error?.code || "unavailable";
1063
+ } finally {
1064
+ await account.store.clear();
1065
+ }
1066
+ return {
1067
+ authenticated: false,
1068
+ localSessionCleared: true,
1069
+ remoteRevoked,
1070
+ remoteStatus: remoteRevoked ? "revoked" : "failed",
1071
+ remoteError,
1072
+ };
1073
+ });
1074
+ emit(
1075
+ context.stdout,
1076
+ outcome,
1077
+ context.json,
1078
+ outcome.remoteRevoked
1079
+ ? "Logged out and revoked the remote session."
1080
+ : outcome.remoteStatus === "no_session"
1081
+ ? "No account session is stored for these origins."
1082
+ : "Local session cleared, but remote revocation could not be confirmed.",
1083
+ );
1084
+ return outcome.remoteStatus === "failed" ? 3 : 0;
1085
+ }
1086
+
830
1087
  async function pathExists(path) {
831
1088
  try {
832
1089
  await stat(path);
@@ -898,6 +1155,20 @@ async function resolveServerIdentity(store, serverId, explicit) {
898
1155
  }
899
1156
 
900
1157
  async function handlePrepareOrder(client, store, options, context) {
1158
+ const accountMode = booleanOption(options, "account");
1159
+ const account = accountMode ? await requireAccount(options, context, store.directory) : null;
1160
+ const identity = account ? publicAccountIdentity(await account.client.whoami()) : null;
1161
+ if (account && !account.session.scopes.includes("cli:write")) {
1162
+ throw new CliError("This account session is read-only. Run warpmetal login to request order preparation.", { exitCode: 4 });
1163
+ }
1164
+ const withoutBoxes = booleanOption(options, "without-agent-boxes");
1165
+ const withoutTeam = booleanOption(options, "without-team");
1166
+ if (!account && (withoutBoxes || withoutTeam || options["identity-url"] || options["account-url"])) {
1167
+ throw new CliError("Account options require --account. Existing owner-token preparation is unchanged.", { exitCode: 2 });
1168
+ }
1169
+ if (withoutBoxes && withoutTeam) {
1170
+ throw new CliError("Use only one of --without-agent-boxes or --without-team.", { exitCode: 2 });
1171
+ }
901
1172
  const planId = stringOption(options, "plan", { required: true });
902
1173
  const hostname = stringOption(options, "hostname", { required: true });
903
1174
  const osName = stringOption(options, "os", { required: true });
@@ -910,10 +1181,16 @@ async function handlePrepareOrder(client, store, options, context) {
910
1181
  );
911
1182
  }
912
1183
  const email = stringOption(options, "email");
1184
+ if (account && email) throw new CliError("Account contact comes from your signed-in account; omit --email.", { exitCode: 2 });
913
1185
  const runtimeFile = stringOption(options, "runtime-file");
1186
+ if (runtimeFile && withoutBoxes) throw new CliError("--runtime-file conflicts with --without-agent-boxes.", { exitCode: 2 });
1187
+ if (account && !runtimeFile && !withoutBoxes && !withoutTeam) {
1188
+ throw new CliError("Agent Boxes and Agent team are enabled by default. Select your team in --runtime-file, or use --without-team / --without-agent-boxes.", { exitCode: 2 });
1189
+ }
914
1190
  const runtimeIntent = runtimeFile
915
1191
  ? await readSandboxFile(runtimeFile)
916
- : undefined;
1192
+ : account && withoutTeam ? [{ name: "main", size: "small" }] : undefined;
1193
+ if (withoutTeam && runtimeIntent?.teams) throw new CliError("--without-team conflicts with teams in --runtime-file.", { exitCode: 2 });
917
1194
  const sandboxes = Array.isArray(runtimeIntent)
918
1195
  ? runtimeIntent
919
1196
  : runtimeIntent?.sandboxes;
@@ -969,14 +1246,22 @@ async function handlePrepareOrder(client, store, options, context) {
969
1246
  }
970
1247
  const key =
971
1248
  stringOption(options, "idempotency-key") || idempotencyKey("order");
972
- const response = await client.prepareOrder(request, key);
1249
+ const response = account
1250
+ ? { data: await account.client.prepareOrder(request, key) }
1251
+ : await client.prepareOrder(request, key);
973
1252
  const checkoutBody = JSON.stringify({ taskId: response.data.task.id });
974
1253
  await store.savePreparedOrder(
975
1254
  response.data,
976
1255
  checkoutBody,
977
1256
  selected.identity.identityId,
1257
+ account ? { principalId: identity.principalId, accountOrigin: account.context.accountOrigin, identityOrigin: account.context.identityOrigin } : null,
978
1258
  );
979
1259
  const safe = safePreparedOrder(response.data, store.path);
1260
+ if (account) {
1261
+ safe.ownershipMode = "account";
1262
+ safe.credential = { kind: "account_session", stored: true, printed: false };
1263
+ safe.stateFile = store.path;
1264
+ }
980
1265
  if (runtimeIntent) {
981
1266
  safe.agentRuntimeIntent = {
982
1267
  sandboxes: sandboxes.map((sandbox) => ({
@@ -988,13 +1273,16 @@ async function handlePrepareOrder(client, store, options, context) {
988
1273
  };
989
1274
  if (!Array.isArray(runtimeIntent)) {
990
1275
  safe.agentRuntimeIntent.setup = runtimeIntent.setup;
1276
+ safe.agentRuntimeIntent.teams = runtimeIntent.teams;
991
1277
  }
992
1278
  }
993
1279
  emit(
994
1280
  context.stdout,
995
1281
  safe,
996
1282
  context.json,
997
- `Prepared ${safe.task.id} for server ${safe.task.serverId}.\nRecovery credential saved to ${store.path} and not printed.`,
1283
+ account
1284
+ ? `Prepared ${safe.task.id} for your account. Ownership recorded in ${store.path}. No owner token was issued. Use warpmetal account orders --task ${safe.task.id} to view it.`
1285
+ : `Prepared ${safe.task.id} for server ${safe.task.serverId}.\nRecovery credential saved to ${store.path} and not printed.`,
998
1286
  );
999
1287
  return 0;
1000
1288
  }
@@ -2431,7 +2719,13 @@ async function resolveCreateSandboxes(options) {
2431
2719
  exitCode: 2,
2432
2720
  });
2433
2721
  }
2434
- if (file) return readSandboxFile(file);
2722
+ if (file) {
2723
+ const intent = await readSandboxFile(file);
2724
+ if (!Array.isArray(intent)) {
2725
+ throw new CliError("Team and setup intents are supported only during order preparation; sandbox create accepts sandboxes only.", { exitCode: 2 });
2726
+ }
2727
+ return intent;
2728
+ }
2435
2729
  return sandboxFromOptions({
2436
2730
  name: stringOption(options, "name", { required: true }),
2437
2731
  size: stringOption(options, "size", { required: true }),
@@ -2528,6 +2822,18 @@ async function handleSandboxGet(client, store, options, context) {
2528
2822
  return result.data.sandbox.observedState === "failed" ? 5 : 0;
2529
2823
  }
2530
2824
 
2825
+ function validateImmutableImageReference(reference) {
2826
+ // Keep the canonical backend runtime.IMAGE_DIGEST full-match semantics.
2827
+ const match = /^[a-z0-9][a-z0-9._:\/-]*@sha256:[a-f0-9]{64}$/.exec(reference);
2828
+ if (match?.[0] !== reference) {
2829
+ throw new CliError(
2830
+ "--image-digest requires an immutable digest-pinned image reference ending in @sha256:<64 lowercase hex characters>.",
2831
+ { exitCode: 2 },
2832
+ );
2833
+ }
2834
+ return reference;
2835
+ }
2836
+
2531
2837
  async function handleSandboxAction(client, store, options, context) {
2532
2838
  const serverId = stringOption(options, "server", { required: true });
2533
2839
  const sandboxId = stringOption(options, "sandbox", { required: true });
@@ -2539,10 +2845,11 @@ async function handleSandboxAction(client, store, options, context) {
2539
2845
  "restart",
2540
2846
  "make_persistent",
2541
2847
  "refresh_image",
2848
+ "patch_image",
2542
2849
  ].includes(action)
2543
2850
  ) {
2544
2851
  throw new CliError(
2545
- "--action must be start, stop, restart, make_persistent, or refresh_image.",
2852
+ "--action must be start, stop, restart, make_persistent, refresh_image, or patch_image.",
2546
2853
  {
2547
2854
  exitCode: 2,
2548
2855
  },
@@ -2553,6 +2860,17 @@ async function handleSandboxAction(client, store, options, context) {
2553
2860
  exitCode: 2,
2554
2861
  });
2555
2862
  }
2863
+ if (action !== "patch_image" && options["image-digest"] !== undefined) {
2864
+ throw new CliError("--image-digest is only supported with --action patch_image.", {
2865
+ exitCode: 2,
2866
+ });
2867
+ }
2868
+ const imageDigest =
2869
+ action === "patch_image"
2870
+ ? validateImmutableImageReference(
2871
+ stringOption(options, "image-digest", { required: true }),
2872
+ )
2873
+ : undefined;
2556
2874
  const token = await requireServerToken(store, serverId, options, context.env);
2557
2875
  const key =
2558
2876
  stringOption(options, "idempotency-key") ||
@@ -2563,14 +2881,18 @@ async function handleSandboxAction(client, store, options, context) {
2563
2881
  action,
2564
2882
  token,
2565
2883
  key,
2884
+ imageDigest,
2566
2885
  );
2567
2886
  if (booleanOption(options, "wait")) {
2568
2887
  const accepted = result.data.sandbox;
2569
- if (action === "refresh_image" && !accepted.desiredImageDigest) {
2888
+ const imageAction = action === "refresh_image" || action === "patch_image";
2889
+ if (imageAction && !accepted.desiredImageDigest) {
2570
2890
  throw new CliError(
2571
- "WarpMetal accepted image refresh without an immutable desired image digest.",
2891
+ `WarpMetal accepted image ${action === "patch_image" ? "patch" : "refresh"} without an immutable desired image digest.`,
2572
2892
  );
2573
2893
  }
2894
+ const expectedImageDigest =
2895
+ action === "patch_image" ? imageDigest : accepted.desiredImageDigest;
2574
2896
  const expectedState =
2575
2897
  accepted.desiredState === "stopped" ? "stopped" : "running";
2576
2898
  result = await pollSandbox(
@@ -2582,8 +2904,7 @@ async function handleSandboxAction(client, store, options, context) {
2582
2904
  (sandbox) =>
2583
2905
  sandbox.observedState === expectedState &&
2584
2906
  Number(sandbox.observedGeneration) >= Number(accepted.generation) &&
2585
- (action !== "refresh_image" ||
2586
- sandbox.imageDigest === accepted.desiredImageDigest),
2907
+ (!imageAction || sandbox.imageDigest === expectedImageDigest),
2587
2908
  );
2588
2909
  }
2589
2910
  await store.saveSandboxes(serverId, [result.data.sandbox]);
@@ -3068,16 +3389,69 @@ async function dispatch(positionals, options, passthrough, context) {
3068
3389
  return handleAccessRemoveSsh(options, context);
3069
3390
  }
3070
3391
 
3071
- const baseUrl = stringOption(options, "base-url");
3072
3392
  const stateDir =
3073
3393
  stringOption(options, "state-dir") ||
3074
3394
  resolveStateDirectory({ env: context.env });
3395
+ if (command === "login") {
3396
+ rejectUnknownOptions(options, [
3397
+ "base-url",
3398
+ "json",
3399
+ "help",
3400
+ "state-dir",
3401
+ "identity-url",
3402
+ "account-url",
3403
+ "browser",
3404
+ "read-only",
3405
+ ]);
3406
+ return handleAccountLogin(options, context, stateDir);
3407
+ }
3408
+ if (command === "auth status") {
3409
+ rejectUnknownOptions(options, [
3410
+ "base-url",
3411
+ "json",
3412
+ "help",
3413
+ "state-dir",
3414
+ "identity-url",
3415
+ "account-url",
3416
+ ]);
3417
+ return handleAccountStatus(options, context, stateDir);
3418
+ }
3419
+ if (command === "logout") {
3420
+ rejectUnknownOptions(options, [
3421
+ "base-url",
3422
+ "json",
3423
+ "help",
3424
+ "state-dir",
3425
+ "identity-url",
3426
+ "account-url",
3427
+ ]);
3428
+ return handleAccountLogout(options, context, stateDir);
3429
+ }
3430
+
3431
+ const baseUrl = stringOption(options, "base-url");
3075
3432
  const client = new WarpMetalClient({
3076
3433
  baseUrl: baseUrl || context.env.WARPMETAL_API_URL,
3077
3434
  fetchImpl: context.fetchImpl,
3078
3435
  });
3079
3436
  const store = new StateStore(stateDir);
3080
3437
 
3438
+ if (positionals[0] === "work" || positionals[0] === "insights") {
3439
+ const scopedClient = managementClient({
3440
+ baseUrl: baseUrl || context.env.WARPMETAL_API_URL,
3441
+ fetchImpl: context.fetchImpl,
3442
+ });
3443
+ const services = {
3444
+ client: scopedClient, store, context, requireServerToken, emit,
3445
+ readInput: readManagementInput,
3446
+ runMutation: managementMutations(stateDir, scopedClient.baseUrl),
3447
+ openSessionHandoff: async (...args) => (await import("./session-handoff.js")).openSessionHandoff(...args),
3448
+ };
3449
+ if (positionals[0] === "work") {
3450
+ return (await import("./agent-work.js")).handleWork(positionals, options, services);
3451
+ }
3452
+ return (await import("./agent-insights.js")).handleInsights(positionals, options, services);
3453
+ }
3454
+
3081
3455
  switch (command) {
3082
3456
  case "health":
3083
3457
  rejectUnknownOptions(options, COMMON_OPTIONS);
@@ -3085,9 +3459,21 @@ async function dispatch(positionals, options, passthrough, context) {
3085
3459
  case "catalog":
3086
3460
  rejectUnknownOptions(options, [...COMMON_OPTIONS, "plan"]);
3087
3461
  return handleCatalog(client, options, context);
3462
+ case "account orders":
3463
+ case "account devices":
3464
+ rejectUnknownOptions(options, [...COMMON_OPTIONS, "identity-url", "account-url", command === "account orders" ? "task" : "server"]);
3465
+ return handleAccountInventory(command === "account orders" ? "orders" : "devices", options, context, stateDir);
3466
+ case "models":
3467
+ rejectUnknownOptions(options, [...COMMON_OPTIONS, "provider", "auth-mode"]);
3468
+ return handleModels(client, options, context);
3088
3469
  case "order prepare":
3089
3470
  rejectUnknownOptions(options, [
3090
3471
  ...COMMON_OPTIONS,
3472
+ "account",
3473
+ "identity-url",
3474
+ "account-url",
3475
+ "without-agent-boxes",
3476
+ "without-team",
3091
3477
  "plan",
3092
3478
  "hostname",
3093
3479
  "os",
@@ -3376,6 +3762,7 @@ async function dispatch(positionals, options, passthrough, context) {
3376
3762
  "sandbox",
3377
3763
  "token-file",
3378
3764
  "action",
3765
+ "image-digest",
3379
3766
  "confirm",
3380
3767
  "idempotency-key",
3381
3768
  "wait",