@ampersend_ai/ampersend-sdk 0.0.25 → 0.0.27

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 (55) hide show
  1. package/dist/ampersend/agent.d.ts +65 -66
  2. package/dist/ampersend/agent.d.ts.map +1 -1
  3. package/dist/ampersend/agent.js +32 -22
  4. package/dist/ampersend/agent.js.map +1 -1
  5. package/dist/ampersend/approval.js.map +1 -1
  6. package/dist/ampersend/client.d.ts +2 -2
  7. package/dist/ampersend/client.d.ts.map +1 -1
  8. package/dist/ampersend/client.js.map +1 -1
  9. package/dist/ampersend/curated-agent.d.ts +94 -94
  10. package/dist/ampersend/curated-agent.d.ts.map +1 -1
  11. package/dist/ampersend/curated-agent.js +41 -41
  12. package/dist/ampersend/curated-agent.js.map +1 -1
  13. package/dist/ampersend/management.d.ts +23 -59
  14. package/dist/ampersend/management.d.ts.map +1 -1
  15. package/dist/ampersend/management.js +6 -8
  16. package/dist/ampersend/management.js.map +1 -1
  17. package/dist/ampersend/types.d.ts +194 -318
  18. package/dist/ampersend/types.d.ts.map +1 -1
  19. package/dist/ampersend/types.js +134 -123
  20. package/dist/ampersend/types.js.map +1 -1
  21. package/dist/ampersend/zod-bridge.d.ts +4 -5
  22. package/dist/ampersend/zod-bridge.d.ts.map +1 -1
  23. package/dist/ampersend/zod-bridge.js +35 -19
  24. package/dist/ampersend/zod-bridge.js.map +1 -1
  25. package/dist/cli/commands/agent.d.ts +3 -1
  26. package/dist/cli/commands/agent.d.ts.map +1 -1
  27. package/dist/cli/commands/agent.js +44 -22
  28. package/dist/cli/commands/agent.js.map +1 -1
  29. package/dist/cli/commands/card.d.ts +19 -16
  30. package/dist/cli/commands/card.d.ts.map +1 -1
  31. package/dist/cli/commands/card.js +21 -16
  32. package/dist/cli/commands/card.js.map +1 -1
  33. package/dist/cli/commands/config.d.ts +1 -1
  34. package/dist/cli/commands/config.d.ts.map +1 -1
  35. package/dist/cli/commands/config.js +31 -42
  36. package/dist/cli/commands/config.js.map +1 -1
  37. package/dist/cli/commands/fetch.d.ts +1 -0
  38. package/dist/cli/commands/fetch.d.ts.map +1 -1
  39. package/dist/cli/commands/fetch.js +3 -2
  40. package/dist/cli/commands/fetch.js.map +1 -1
  41. package/dist/cli/commands/marketplace.d.ts +5 -2
  42. package/dist/cli/commands/marketplace.d.ts.map +1 -1
  43. package/dist/cli/commands/marketplace.js +12 -12
  44. package/dist/cli/commands/marketplace.js.map +1 -1
  45. package/dist/cli/commands/setup.d.ts +4 -1
  46. package/dist/cli/commands/setup.d.ts.map +1 -1
  47. package/dist/cli/commands/setup.js +66 -41
  48. package/dist/cli/commands/setup.js.map +1 -1
  49. package/dist/cli/config.d.ts +190 -68
  50. package/dist/cli/config.d.ts.map +1 -1
  51. package/dist/cli/config.js +357 -150
  52. package/dist/cli/config.js.map +1 -1
  53. package/dist/version.d.ts +1 -1
  54. package/dist/version.js +1 -1
  55. package/package.json +2 -2
@@ -11,7 +11,7 @@ import { err, ok } from "./envelope.js";
11
11
  * Returns an `err` envelope when the local config isn't ready, so the
12
12
  * caller can print it and exit.
13
13
  */
14
- export function loadCredentials() {
14
+ export function loadCredentials(opts = {}) {
15
15
  try {
16
16
  const envConfig = parseEnvConfig();
17
17
  return {
@@ -26,19 +26,20 @@ export function loadCredentials() {
26
26
  catch {
27
27
  // Fall back to config file
28
28
  }
29
- const fileConfig = getRuntimeConfig();
30
- if (fileConfig?.status === "ready" && fileConfig.agentAccount && fileConfig.agentKey) {
31
- const apiUrl = process.env.AMPERSEND_API_URL ?? fileConfig.apiUrl;
29
+ const selected = getSelectedContext(opts);
30
+ if (selected?.context.status === "ready") {
31
+ // AMPERSEND_API_URL is a hard bypass: if set, it always wins.
32
+ const apiUrl = process.env.AMPERSEND_API_URL ?? selected.context.apiUrl;
32
33
  return {
33
34
  ok: true,
34
35
  credentials: {
35
- agentAccount: fileConfig.agentAccount,
36
- agentKey: fileConfig.agentKey,
36
+ agentAccount: selected.context.agentAccount,
37
+ agentKey: selected.context.agentKey,
37
38
  ...(apiUrl ? { apiUrl } : {}),
38
39
  },
39
40
  };
40
41
  }
41
- const status = fileConfig?.status ?? "not_initialized";
42
+ const status = getConfigStatus(opts).status;
42
43
  const code = status === "not_initialized" ? "NOT_CONFIGURED" : "SETUP_INCOMPLETE";
43
44
  return {
44
45
  ok: false,
@@ -49,19 +50,22 @@ export function loadCredentials() {
49
50
  const CONFIG_DIR = join(homedir(), ".ampersend");
50
51
  export const CONFIG_FILE = join(CONFIG_DIR, "config.json");
51
52
  /** Current config version */
52
- const CONFIG_VERSION = 1;
53
+ const CONFIG_VERSION = 2;
53
54
  /** Hard-coded approval expiration: 30 minutes */
54
55
  const APPROVAL_EXPIRY_MS = 30 * 60 * 1000;
55
56
  /** Default API URL (production) */
56
57
  export const DEFAULT_API_URL = "https://api.ampersend.ai";
57
- const HexString = Schema.TemplateLiteral(Schema.Literal("0x"), Schema.String);
58
+ const HexString = Schema.TemplateLiteral([Schema.Literal("0x"), Schema.String]);
58
59
  /**
59
60
  * Cached Laso Bearer token for `card details`/`list`, so a warm read costs
60
61
  * nothing. Stamped with the identity (`agentKey`) and `apiUrl` it was minted
61
62
  * under: `readLasoToken` treats it as absent if either no longer matches the
62
- * active config (covers env-var overrides) or it has expired. Self-correcting,
63
+ * active context (covers env-var overrides) or it has expired. Self-correcting,
63
64
  * so the identity/URL write paths only need to drop it, not re-thread it.
64
65
  *
66
+ * Lives inside the context it was minted under, so switching the active context
67
+ * (`config use`) keeps each context's token intact without any explicit drop.
68
+ *
65
69
  * Schema is the source of truth; the `LasoToken` type is derived from it so the
66
70
  * two can't drift (the same pattern as the schemas in `src/ampersend/`).
67
71
  */
@@ -71,11 +75,41 @@ const LasoTokenSchema = Schema.Struct({
71
75
  agentKey: HexString,
72
76
  apiUrl: Schema.optional(Schema.String),
73
77
  });
74
- /** Schema for validating stored config read from disk.
78
+ // ─── Context model (V2) ──────────────────────────────────────────────────────
79
+ /**
80
+ * A named identity in the config. A context is either:
81
+ * - `ready`: resolved, carrying an active key + on-chain account (+ optional
82
+ * per-context apiUrl and cached lasoToken).
83
+ * - `pending`: an in-flight `setup start` — a generated key plus the approval
84
+ * token and its local expiry, but no account yet. `setup finish`
85
+ * promotes it to `ready`.
75
86
  *
76
- * Effect Schema strips unknown keys, so legacy fields like `network` written
77
- * by older versions are silently dropped on next write. */
78
- const StoredConfigSchema = Schema.Struct({
87
+ * Each context carries its own `apiUrl`, so e.g. a sandbox and a prod context
88
+ * can coexist pointed at different environments.
89
+ */
90
+ const ReadyContextSchema = Schema.Struct({
91
+ status: Schema.Literal("ready"),
92
+ agentKey: HexString,
93
+ agentAccount: HexString,
94
+ createdAt: Schema.String, // ISO timestamp the context was first created
95
+ apiUrl: Schema.optional(Schema.String),
96
+ lasoToken: Schema.optional(LasoTokenSchema),
97
+ });
98
+ const PendingContextSchema = Schema.Struct({
99
+ status: Schema.Literal("pending"),
100
+ agentKey: HexString,
101
+ token: Schema.String,
102
+ expiresAt: Schema.String, // ISO timestamp — informational; `setup finish` lets the API decide
103
+ createdAt: Schema.String, // ISO timestamp the context was first created
104
+ apiUrl: Schema.optional(Schema.String),
105
+ });
106
+ const ContextSchema = Schema.Union([ReadyContextSchema, PendingContextSchema]);
107
+ const StoredConfigV2Schema = Schema.Struct({
108
+ version: Schema.Literal(2),
109
+ activeContext: Schema.optional(Schema.String),
110
+ contexts: Schema.Record(Schema.String, ContextSchema),
111
+ });
112
+ const StoredConfigV1Schema = Schema.Struct({
79
113
  version: Schema.Literal(1),
80
114
  agentKey: Schema.optional(HexString),
81
115
  agentAccount: Schema.optional(HexString),
@@ -87,6 +121,51 @@ const StoredConfigSchema = Schema.Struct({
87
121
  })),
88
122
  lasoToken: Schema.optional(LasoTokenSchema),
89
123
  });
124
+ /** Decodes either version; V1 is normalized to V2 by `readConfig`. */
125
+ const StoredConfigSchema = Schema.Union([StoredConfigV2Schema, StoredConfigV1Schema]);
126
+ /**
127
+ * Migrate a legacy single-account V1 config to the V2 context model. There is
128
+ * no `default` context in V2 — both migrated contexts are auto-named from their
129
+ * key (the same scheme `setup`/`config set` use), and `createdAt` is stamped to
130
+ * the migration time since a V1 file carries no creation timestamp.
131
+ *
132
+ * - A complete identity (key + account) becomes a `ready` context carrying the
133
+ * old top-level apiUrl/lasoToken; it becomes active.
134
+ * - A standalone pending approval becomes a `pending` context. If there was no
135
+ * active identity, the pending context becomes active.
136
+ * - A key-only V1 file (no account, no pending) was already non-functional for
137
+ * reads, so we drop the orphan key and migrate to an empty (not_initialized)
138
+ * config.
139
+ */
140
+ function migrateV1toV2(v1) {
141
+ const config = { version: 2, contexts: {} };
142
+ const createdAt = new Date().toISOString();
143
+ if (v1.agentKey && v1.agentAccount) {
144
+ const name = uniqueContextName(config, v1.apiUrl, privateKeyToAddress(v1.agentKey));
145
+ config.contexts[name] = {
146
+ status: "ready",
147
+ agentKey: v1.agentKey,
148
+ agentAccount: v1.agentAccount,
149
+ createdAt,
150
+ ...(v1.apiUrl ? { apiUrl: v1.apiUrl } : {}),
151
+ ...(v1.lasoToken ? { lasoToken: v1.lasoToken } : {}),
152
+ };
153
+ config.activeContext = name;
154
+ }
155
+ if (v1.pendingApproval) {
156
+ const name = uniqueContextName(config, v1.apiUrl, privateKeyToAddress(v1.pendingApproval.agentKey));
157
+ config.contexts[name] = {
158
+ status: "pending",
159
+ agentKey: v1.pendingApproval.agentKey,
160
+ token: v1.pendingApproval.token,
161
+ expiresAt: v1.pendingApproval.expiresAt,
162
+ createdAt,
163
+ ...(v1.apiUrl ? { apiUrl: v1.apiUrl } : {}),
164
+ };
165
+ config.activeContext ??= name;
166
+ }
167
+ return config;
168
+ }
90
169
  /**
91
170
  * Ensure config directory exists with secure permissions
92
171
  */
@@ -96,7 +175,7 @@ function ensureConfigDir() {
96
175
  }
97
176
  }
98
177
  /**
99
- * Read config file if it exists.
178
+ * Read config file if it exists, normalized to the V2 context model.
100
179
  * Returns null if the file is missing or corrupt.
101
180
  */
102
181
  export function readConfig() {
@@ -106,7 +185,8 @@ export function readConfig() {
106
185
  const content = readFileSync(CONFIG_FILE, "utf-8");
107
186
  try {
108
187
  const parsed = JSON.parse(content);
109
- return Schema.decodeUnknownSync(StoredConfigSchema)(parsed);
188
+ const decoded = Schema.decodeUnknownSync(StoredConfigSchema)(parsed);
189
+ return decoded.version === 1 ? migrateV1toV2(decoded) : decoded;
110
190
  }
111
191
  catch {
112
192
  // Corrupt or unrecognised config — treat as absent so commands can re-initialise
@@ -114,34 +194,65 @@ export function readConfig() {
114
194
  }
115
195
  }
116
196
  /**
117
- * Write config file with secure permissions
197
+ * Drop expired *pending* contexts so the map can't grow without bound. Ready
198
+ * contexts are never auto-pruned (only `config rm` removes them). If the active
199
+ * context is pruned, `activeContext` is cleared.
200
+ */
201
+ function prunePendingExpired(config) {
202
+ const contexts = {};
203
+ for (const [name, ctx] of Object.entries(config.contexts)) {
204
+ if (ctx.status === "pending" && isPendingExpired(ctx))
205
+ continue;
206
+ contexts[name] = ctx;
207
+ }
208
+ const activeContext = config.activeContext && contexts[config.activeContext] ? config.activeContext : undefined;
209
+ return { version: 2, ...(activeContext ? { activeContext } : {}), contexts };
210
+ }
211
+ /**
212
+ * Write config file with secure permissions. Always writes V2 and prunes
213
+ * expired pending contexts on the way out.
118
214
  */
119
215
  export function writeConfig(config) {
120
216
  ensureConfigDir();
121
- const withVersion = { version: CONFIG_VERSION, ...config };
122
- writeFileSync(CONFIG_FILE, JSON.stringify(withVersion, null, 2), { mode: 0o600 });
217
+ const pruned = prunePendingExpired({ version: CONFIG_VERSION, ...config });
218
+ writeFileSync(CONFIG_FILE, JSON.stringify(pruned, null, 2), { mode: 0o600 });
219
+ }
220
+ /**
221
+ * The context name to use this invocation: `--context` flag > `AMPERSEND_CONTEXT`
222
+ * env > persisted `activeContext`. Returns undefined if none resolves.
223
+ */
224
+ export function resolveContextName(opts = {}) {
225
+ return opts.context ?? process.env.AMPERSEND_CONTEXT ?? readConfig()?.activeContext ?? undefined;
123
226
  }
124
227
  /**
125
- * Get runtime config with status
228
+ * The selected context (name + value) after applying flag/env/active precedence,
229
+ * or null if no config file exists or the resolved name has no context.
126
230
  */
127
- export function getRuntimeConfig() {
128
- const stored = readConfig();
129
- if (!stored) {
231
+ export function getSelectedContext(opts = {}) {
232
+ const config = readConfig();
233
+ const name = resolveContextName(opts);
234
+ if (!config || !name)
130
235
  return null;
131
- }
132
- const { version: _, ...rest } = stored;
133
- const status = rest.agentKey && rest.agentAccount ? "ready" : "pending_agent";
134
- return { ...rest, status };
236
+ const context = config.contexts[name];
237
+ if (!context)
238
+ return null;
239
+ return { name, context };
135
240
  }
136
241
  /**
137
- * Get configuration status for error messages
242
+ * Effective API URL for unauthenticated calls and setup flows.
243
+ * Precedence: AMPERSEND_API_URL (hard bypass) > selected context's apiUrl > default.
138
244
  */
139
- export function getConfigStatus() {
140
- const config = getRuntimeConfig();
141
- if (!config) {
245
+ export function getActiveApiUrl(opts = {}) {
246
+ return process.env.AMPERSEND_API_URL ?? getSelectedContext(opts)?.context.apiUrl ?? DEFAULT_API_URL;
247
+ }
248
+ /**
249
+ * Per-context status for the selected context, for error messages and `status`.
250
+ */
251
+ export function getConfigStatus(opts = {}) {
252
+ const selected = getSelectedContext(opts);
253
+ if (!selected)
142
254
  return { status: "not_initialized" };
143
- }
144
- return { status: config.status };
255
+ return { status: selected.context.status === "ready" ? "ready" : "pending_agent" };
145
256
  }
146
257
  /**
147
258
  * Check if a pending approval has expired locally.
@@ -155,11 +266,62 @@ export function isPendingExpired(pending) {
155
266
  export function computeApprovalExpiry() {
156
267
  return new Date(Date.now() + APPROVAL_EXPIRY_MS).toISOString();
157
268
  }
269
+ /** Empty V2 config used when starting from scratch. */
270
+ function emptyConfig() {
271
+ return { version: 2, contexts: {} };
272
+ }
273
+ /** Return a copy of `record` without `key` (avoids dynamic `delete`). */
274
+ function omitKey(record, key) {
275
+ const { [key]: _omitted, ...rest } = record;
276
+ return rest;
277
+ }
278
+ /**
279
+ * Production = the default URL, or no URL at all. Auto-derived context names get
280
+ * the URL host prepended only for non-production environments.
281
+ */
282
+ export function isProductionUrl(apiUrl) {
283
+ return !apiUrl || apiUrl === DEFAULT_API_URL;
284
+ }
285
+ /**
286
+ * Auto-derive a context name from the agent key when `--context` is omitted.
287
+ * The base is `ctx-<4 hex of key address>`; non-production URLs prepend the host
288
+ * so contexts targeting different environments stay distinct (e.g.
289
+ * `api.sandbox.ampersend.ai-ctx-1a2b`). Not guaranteed unique on its own —
290
+ * callers go through `uniqueContextName` to disambiguate.
291
+ */
292
+ export function autoContextName(apiUrl, keyAddress) {
293
+ const base = `ctx-${keyAddress.slice(2, 6).toLowerCase()}`;
294
+ if (isProductionUrl(apiUrl))
295
+ return base;
296
+ try {
297
+ return `${new URL(apiUrl).host}-${base}`;
298
+ }
299
+ catch {
300
+ return base;
301
+ }
302
+ }
303
+ /**
304
+ * An auto-derived context name guaranteed free in `config`. Appends `-2`, `-3`,
305
+ * … if the base name (or a prior counter) is already taken.
306
+ */
307
+ export function uniqueContextName(config, apiUrl, keyAddress) {
308
+ const base = autoContextName(apiUrl, keyAddress);
309
+ if (!config.contexts[base])
310
+ return base;
311
+ for (let n = 2;; n++) {
312
+ const candidate = `${base}-${n}`;
313
+ if (!config.contexts[candidate])
314
+ return candidate;
315
+ }
316
+ }
158
317
  /**
159
- * Set active config directly using "agentKey:::agentAccount" format.
160
- * Replaces the old `config init` + `config set-agent` flow.
318
+ * Set a context's identity directly using "agentKey:::agentAccount" format and
319
+ * make it active. With `--context <name>` the identity is written to that named
320
+ * context (creating or overwriting it); without one, a fresh auto-named context
321
+ * is minted. A context's `apiUrl` is fixed at creation — `setConfig` only sets
322
+ * it on a brand-new context, never edits an existing one's URL.
161
323
  */
162
- export function setConfig(secret) {
324
+ export function setConfig(secret, opts = {}) {
163
325
  const parts = secret.split(":::");
164
326
  if (parts.length !== 2) {
165
327
  return err("INVALID_FORMAT", 'Expected format: "agentKey:::agentAccount"');
@@ -171,123 +333,148 @@ export function setConfig(secret) {
171
333
  if (!isAddress(agentAccount)) {
172
334
  return err("INVALID_ADDRESS", "Invalid Ethereum address format for agent account");
173
335
  }
174
- const existing = readConfig();
175
- // lasoToken intentionally not carried over: changing the active identity
176
- // invalidates any cached Laso token (it's stamped with the old agentKey).
177
- writeConfig({
178
- agentKey: agentKey,
179
- agentAccount: agentAccount,
180
- ...(existing?.apiUrl ? { apiUrl: existing.apiUrl } : {}),
181
- // Preserve pending approval if any
182
- ...(existing?.pendingApproval ? { pendingApproval: existing.pendingApproval } : {}),
183
- });
184
- const agentKeyAddress = privateKeyToAddress(agentKey);
185
- return ok({
186
- agentKeyAddress,
187
- agentAccount,
188
- status: "ready",
189
- });
190
- }
191
- /**
192
- * Set API URL in config. Pass undefined to clear (revert to production default).
193
- */
194
- export function setApiUrl(apiUrl) {
195
- if (apiUrl != null) {
336
+ if (opts.apiUrl != null) {
196
337
  try {
197
- new URL(apiUrl);
338
+ new URL(opts.apiUrl);
198
339
  }
199
340
  catch {
200
341
  return err("INVALID_URL", "Invalid URL format.");
201
342
  }
202
343
  }
203
- const existing = readConfig();
204
- // lasoToken intentionally not carried over: changing the API URL points reads
205
- // at a different Laso/facilitator context, so a token minted under the old
206
- // URL no longer applies.
207
- writeConfig({
208
- ...(existing?.agentKey ? { agentKey: existing.agentKey } : {}),
209
- ...(existing?.agentAccount ? { agentAccount: existing.agentAccount } : {}),
210
- ...(existing?.pendingApproval ? { pendingApproval: existing.pendingApproval } : {}),
211
- ...(apiUrl != null ? { apiUrl } : {}),
212
- });
213
- return ok({ apiUrl: apiUrl ?? DEFAULT_API_URL });
344
+ const agentKeyAddress = privateKeyToAddress(agentKey);
345
+ const config = readConfig() ?? emptyConfig();
346
+ // Explicit --context names the target (create or overwrite); otherwise mint a
347
+ // fresh auto-named context.
348
+ const name = opts.name ?? uniqueContextName(config, opts.apiUrl, agentKeyAddress);
349
+ const existing = config.contexts[name];
350
+ // The URL is set only on a new context; an explicit opt on creation wins,
351
+ // otherwise inherit the URL an overwritten context already had. lasoToken is
352
+ // intentionally dropped: changing the identity invalidates a token stamped
353
+ // with the old key. createdAt is preserved when overwriting a named context.
354
+ const apiUrl = opts.apiUrl ?? existing?.apiUrl;
355
+ config.contexts[name] = {
356
+ status: "ready",
357
+ agentKey: agentKey,
358
+ agentAccount: agentAccount,
359
+ createdAt: existing?.createdAt ?? new Date().toISOString(),
360
+ ...(apiUrl ? { apiUrl } : {}),
361
+ };
362
+ config.activeContext = name;
363
+ writeConfig(config);
364
+ return ok({ agentKeyAddress, agentAccount, context: name, status: "ready" });
214
365
  }
215
366
  /**
216
- * Store a pending approval in config.
217
- * Called by `setup start`.
367
+ * Switch the active context without re-running setup. Errors if the name is
368
+ * unknown.
218
369
  */
219
- export function storePendingApproval(pending) {
220
- const existing = readConfig();
221
- writeConfig({
222
- ...(existing?.agentKey ? { agentKey: existing.agentKey } : {}),
223
- ...(existing?.agentAccount ? { agentAccount: existing.agentAccount } : {}),
224
- ...(existing?.apiUrl ? { apiUrl: existing.apiUrl } : {}),
225
- // Active identity is unchanged by a pending approval, so a cached Laso
226
- // token stays valid — preserve it.
227
- ...(existing?.lasoToken ? { lasoToken: existing.lasoToken } : {}),
228
- pendingApproval: pending,
229
- });
370
+ export function useContext(name) {
371
+ const config = readConfig();
372
+ const context = config?.contexts[name];
373
+ if (!config || !context) {
374
+ return err("UNKNOWN_CONTEXT", `No context named "${name}". Run "ampersend config status" to list contexts.`);
375
+ }
376
+ config.activeContext = name;
377
+ writeConfig(config);
378
+ return ok({ context: name, status: context.status === "ready" ? "ready" : "pending_agent" });
230
379
  }
231
380
  /**
232
- * Clear pending approval from config.
381
+ * Delete a context. If it was the active one, the active selection is cleared.
382
+ * Errors if the name is unknown.
233
383
  */
234
- export function clearPendingApproval() {
235
- const existing = readConfig();
236
- if (!existing)
237
- return;
238
- const { pendingApproval: _, version: __, ...rest } = existing;
239
- writeConfig(rest);
384
+ export function removeContext(name) {
385
+ const config = readConfig();
386
+ if (!config || !config.contexts[name]) {
387
+ return err("UNKNOWN_CONTEXT", `No context named "${name}". Run "ampersend config status" to list contexts.`);
388
+ }
389
+ const wasActive = config.activeContext === name;
390
+ config.contexts = omitKey(config.contexts, name);
391
+ if (wasActive)
392
+ delete config.activeContext;
393
+ writeConfig(config);
394
+ return ok({ context: name, wasActive });
395
+ }
396
+ /**
397
+ * Create a `pending` context from a freshly-requested approval.
398
+ * Called by `setup start`. Makes it active unless `detach` is set.
399
+ */
400
+ export function startContext(name, pending, opts = {}) {
401
+ const config = readConfig() ?? emptyConfig();
402
+ config.contexts[name] = {
403
+ status: "pending",
404
+ agentKey: pending.agentKey,
405
+ token: pending.token,
406
+ expiresAt: pending.expiresAt,
407
+ createdAt: config.contexts[name]?.createdAt ?? new Date().toISOString(),
408
+ ...(opts.apiUrl && !isProductionUrl(opts.apiUrl) ? { apiUrl: opts.apiUrl } : {}),
409
+ };
410
+ if (!opts.detach)
411
+ config.activeContext = name;
412
+ writeConfig(config);
240
413
  }
241
414
  /**
242
- * Promote a pending approval to active config.
243
- * Called by `setup finish` when the approval is resolved.
415
+ * Promote a `pending` context to `ready` (key stays, account filled in).
416
+ * Called by `setup finish`. Makes the context active.
244
417
  */
245
- export function promotePending(agentAccount) {
246
- const existing = readConfig();
247
- if (!existing?.pendingApproval) {
248
- return err("NO_PENDING", "No pending approval to promote");
418
+ export function finishContext(name, agentAccount) {
419
+ const config = readConfig();
420
+ const context = config?.contexts[name];
421
+ if (!config || !context) {
422
+ return err("UNKNOWN_CONTEXT", `No context named "${name}".`);
249
423
  }
250
- // Drop lasoToken alongside the old key: the active identity changes here, so
251
- // a token minted under the previous key no longer matches. readLasoToken
252
- // would reject it anyway; dropping it keeps the file honest.
253
- const { agentKey: _oldKey, lasoToken: _lasoToken, pendingApproval, version: _version, ...rest } = existing;
254
- const agentKeyAddress = privateKeyToAddress(pendingApproval.agentKey);
255
- // Promote: pending key becomes active, pending cleared
256
- writeConfig({
257
- ...rest,
258
- agentKey: pendingApproval.agentKey,
259
- agentAccount,
260
- // pendingApproval intentionally omitted — cleared on promote
261
- });
262
- return ok({
263
- agentKeyAddress,
264
- agentAccount,
424
+ if (context.status !== "pending") {
425
+ return err("NOT_PENDING", `Context "${name}" is already ready. Use "ampersend config use ${name}" to select it.`);
426
+ }
427
+ const agentKeyAddress = privateKeyToAddress(context.agentKey);
428
+ config.contexts[name] = {
265
429
  status: "ready",
266
- });
430
+ agentKey: context.agentKey,
431
+ agentAccount,
432
+ createdAt: context.createdAt,
433
+ ...(context.apiUrl ? { apiUrl: context.apiUrl } : {}),
434
+ };
435
+ config.activeContext = name;
436
+ writeConfig(config);
437
+ return ok({ agentKeyAddress, agentAccount, context: name, status: "ready" });
438
+ }
439
+ /**
440
+ * Remove a pending context (e.g. when an approval is rejected). No-op if the
441
+ * context is missing or no longer pending.
442
+ */
443
+ export function clearPendingContext(name) {
444
+ const config = readConfig();
445
+ const context = config?.contexts[name];
446
+ if (!config || !context || context.status !== "pending")
447
+ return;
448
+ config.contexts = omitKey(config.contexts, name);
449
+ if (config.activeContext === name)
450
+ delete config.activeContext;
451
+ writeConfig(config);
267
452
  }
268
453
  /**
269
- * Cache a Laso Bearer token, preserving the rest of the config file.
270
- * The token is stamped with the identity/URL it was minted under so
271
- * `readLasoToken` can reject it after an env-var or config change.
454
+ * Cache a Laso Bearer token on the selected context, preserving the rest of the
455
+ * config. The token is stamped with the identity/URL it was minted under so
456
+ * `readLasoToken` can reject it after an env-var or context change.
272
457
  *
273
- * No-op when credentials come purely from env vars (no file to write): the
274
- * config file is the only cache, and we don't create one just to hold a token.
458
+ * No-op when the selected context isn't a ready one (e.g. env-only credentials):
459
+ * the config file is the only cache, and we don't create one just to hold a token.
275
460
  */
276
- export function storeLasoToken(token) {
277
- const existing = readConfig();
278
- if (!existing)
461
+ export function storeLasoToken(token, opts = {}) {
462
+ const config = readConfig();
463
+ const selected = getSelectedContext(opts);
464
+ if (!config || selected?.context.status !== "ready")
279
465
  return;
280
- const { version: _version, ...rest } = existing;
281
- writeConfig({ ...rest, lasoToken: token });
466
+ config.contexts[selected.name] = { ...selected.context, lasoToken: token };
467
+ writeConfig(config);
282
468
  }
283
469
  /**
284
- * Read the cached Laso token, or null if there's no usable one. Treated as
285
- * absent when: no token cached, expired, or its stamped `agentKey`/`apiUrl`
286
- * no longer match the active credentials (covers env-var overrides and any
287
- * write path that forgot to drop it). Self-correcting by construction.
470
+ * Read the selected context's cached Laso token, or null if there's no usable
471
+ * one. Treated as absent when: no token cached, expired, or its stamped
472
+ * `agentKey`/`apiUrl` no longer match the active credentials (covers env-var
473
+ * overrides). Self-correcting by construction.
288
474
  */
289
- export function readLasoToken(active) {
290
- const stored = readConfig()?.lasoToken;
475
+ export function readLasoToken(active, opts = {}) {
476
+ const ctx = getSelectedContext(opts)?.context;
477
+ const stored = ctx?.status === "ready" ? ctx.lasoToken : undefined;
291
478
  if (!stored)
292
479
  return null;
293
480
  if (new Date(stored.expiresAt).getTime() <= Date.now())
@@ -302,6 +489,26 @@ export function readLasoToken(active) {
302
489
  return null;
303
490
  return stored;
304
491
  }
492
+ /** Build a status summary for one context. */
493
+ function summarizeContext(name, context, activeName) {
494
+ const summary = {
495
+ name,
496
+ status: context.status === "ready" ? "ready" : "pending_agent",
497
+ active: name === activeName,
498
+ createdAt: context.createdAt,
499
+ agentKeyAddress: privateKeyToAddress(context.agentKey),
500
+ };
501
+ if (context.status === "ready") {
502
+ summary.agentAccount = context.agentAccount;
503
+ }
504
+ else {
505
+ summary.pendingExpired = isPendingExpired(context);
506
+ }
507
+ if (context.apiUrl && context.apiUrl !== DEFAULT_API_URL) {
508
+ summary.apiUrl = context.apiUrl;
509
+ }
510
+ return summary;
511
+ }
305
512
  /**
306
513
  * Get current configuration status.
307
514
  * Checks env vars first (takes precedence), then config file.
@@ -331,34 +538,34 @@ export function getStatus() {
331
538
  // No env vars, check file
332
539
  }
333
540
  // Check config file
334
- const config = getRuntimeConfig();
335
- if (!config) {
541
+ const config = readConfig();
542
+ if (!config || Object.keys(config.contexts).length === 0) {
336
543
  return ok({ status: "not_initialized", credentialSource: "none" });
337
544
  }
338
- // Determine effective API URL (env var takes precedence over file)
339
- const envApiUrl = process.env.AMPERSEND_API_URL;
340
- const effectiveApiUrl = envApiUrl ?? config.apiUrl;
545
+ // Oldest-first so `config status` lists contexts in a stable creation order.
546
+ const contexts = Object.entries(config.contexts)
547
+ .map(([name, ctx]) => summarizeContext(name, ctx, config.activeContext))
548
+ .sort((a, b) => a.createdAt.localeCompare(b.createdAt));
549
+ // Hoist the active context's fields to the top level, reusing its summary
550
+ // (built above) rather than re-deriving the key address and account.
551
+ const activeSummary = contexts.find((c) => c.active);
341
552
  const result = {
342
- status: config.status,
553
+ status: activeSummary?.status ?? "not_initialized",
343
554
  credentialSource: "file",
344
555
  configPath: CONFIG_FILE,
556
+ ...(config.activeContext ? { activeContext: config.activeContext } : {}),
557
+ contexts,
345
558
  };
346
- if (config.agentKey) {
347
- result.agentKeyAddress = privateKeyToAddress(config.agentKey);
348
- }
349
- if (config.agentAccount) {
350
- result.agentAccount = config.agentAccount;
351
- }
352
- if (effectiveApiUrl && effectiveApiUrl !== DEFAULT_API_URL) {
353
- result.apiUrl = effectiveApiUrl;
354
- }
355
- // Always show pending approval info if present
356
- if (config.pendingApproval) {
357
- const pendingKeyAddress = privateKeyToAddress(config.pendingApproval.agentKey);
358
- result.pendingApproval = {
359
- agentKeyAddress: pendingKeyAddress,
360
- expired: isPendingExpired(config.pendingApproval),
361
- };
559
+ if (activeSummary) {
560
+ if (activeSummary.agentKeyAddress)
561
+ result.agentKeyAddress = activeSummary.agentKeyAddress;
562
+ if (activeSummary.agentAccount)
563
+ result.agentAccount = activeSummary.agentAccount;
564
+ // Effective API URL for the active context (env var takes precedence).
565
+ const effectiveApiUrl = process.env.AMPERSEND_API_URL ?? activeSummary.apiUrl;
566
+ if (effectiveApiUrl && effectiveApiUrl !== DEFAULT_API_URL) {
567
+ result.apiUrl = effectiveApiUrl;
568
+ }
362
569
  }
363
570
  return ok(result);
364
571
  }