@ampersend_ai/ampersend-sdk 0.0.22 → 0.0.26

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 (119) hide show
  1. package/dist/ampersend/agent-client.d.ts +81 -0
  2. package/dist/ampersend/agent-client.d.ts.map +1 -0
  3. package/dist/ampersend/agent-client.js +104 -0
  4. package/dist/ampersend/agent-client.js.map +1 -0
  5. package/dist/ampersend/agent.d.ts +118 -0
  6. package/dist/ampersend/agent.d.ts.map +1 -0
  7. package/dist/ampersend/agent.js +109 -0
  8. package/dist/ampersend/agent.js.map +1 -0
  9. package/dist/ampersend/approval.js.map +1 -1
  10. package/dist/ampersend/client.d.ts +32 -8
  11. package/dist/ampersend/client.d.ts.map +1 -1
  12. package/dist/ampersend/client.js +81 -3
  13. package/dist/ampersend/client.js.map +1 -1
  14. package/dist/ampersend/curated-agent.d.ts +94 -94
  15. package/dist/ampersend/curated-agent.d.ts.map +1 -1
  16. package/dist/ampersend/curated-agent.js +41 -41
  17. package/dist/ampersend/curated-agent.js.map +1 -1
  18. package/dist/ampersend/index.d.ts +2 -0
  19. package/dist/ampersend/index.d.ts.map +1 -1
  20. package/dist/ampersend/index.js +4 -0
  21. package/dist/ampersend/index.js.map +1 -1
  22. package/dist/ampersend/management.d.ts +23 -59
  23. package/dist/ampersend/management.d.ts.map +1 -1
  24. package/dist/ampersend/management.js +6 -8
  25. package/dist/ampersend/management.js.map +1 -1
  26. package/dist/ampersend/marketplace.d.ts +20 -14
  27. package/dist/ampersend/marketplace.d.ts.map +1 -1
  28. package/dist/ampersend/marketplace.js +23 -49
  29. package/dist/ampersend/marketplace.js.map +1 -1
  30. package/dist/ampersend/treasurer.d.ts +4 -0
  31. package/dist/ampersend/treasurer.d.ts.map +1 -1
  32. package/dist/ampersend/treasurer.js +2 -2
  33. package/dist/ampersend/treasurer.js.map +1 -1
  34. package/dist/ampersend/types.d.ts +215 -310
  35. package/dist/ampersend/types.d.ts.map +1 -1
  36. package/dist/ampersend/types.js +146 -119
  37. package/dist/ampersend/types.js.map +1 -1
  38. package/dist/ampersend/zod-bridge.d.ts +4 -5
  39. package/dist/ampersend/zod-bridge.d.ts.map +1 -1
  40. package/dist/ampersend/zod-bridge.js +35 -19
  41. package/dist/ampersend/zod-bridge.js.map +1 -1
  42. package/dist/cli/ampersend.js +45 -2
  43. package/dist/cli/ampersend.js.map +1 -1
  44. package/dist/cli/commands/agent.d.ts +24 -0
  45. package/dist/cli/commands/agent.d.ts.map +1 -0
  46. package/dist/cli/commands/agent.js +166 -0
  47. package/dist/cli/commands/agent.js.map +1 -0
  48. package/dist/cli/commands/card.d.ts +128 -0
  49. package/dist/cli/commands/card.d.ts.map +1 -0
  50. package/dist/cli/commands/card.js +404 -0
  51. package/dist/cli/commands/card.js.map +1 -0
  52. package/dist/cli/commands/config.d.ts +1 -1
  53. package/dist/cli/commands/config.d.ts.map +1 -1
  54. package/dist/cli/commands/config.js +31 -42
  55. package/dist/cli/commands/config.js.map +1 -1
  56. package/dist/cli/commands/fetch.d.ts +77 -2
  57. package/dist/cli/commands/fetch.d.ts.map +1 -1
  58. package/dist/cli/commands/fetch.js +210 -129
  59. package/dist/cli/commands/fetch.js.map +1 -1
  60. package/dist/cli/commands/fund.d.ts +3 -0
  61. package/dist/cli/commands/fund.d.ts.map +1 -0
  62. package/dist/cli/commands/fund.js +22 -0
  63. package/dist/cli/commands/fund.js.map +1 -0
  64. package/dist/cli/commands/marketplace.d.ts +6 -0
  65. package/dist/cli/commands/marketplace.d.ts.map +1 -1
  66. package/dist/cli/commands/marketplace.js +27 -8
  67. package/dist/cli/commands/marketplace.js.map +1 -1
  68. package/dist/cli/commands/setup.d.ts +4 -1
  69. package/dist/cli/commands/setup.d.ts.map +1 -1
  70. package/dist/cli/commands/setup.js +66 -41
  71. package/dist/cli/commands/setup.js.map +1 -1
  72. package/dist/cli/commands/version.d.ts +3 -0
  73. package/dist/cli/commands/version.d.ts.map +1 -0
  74. package/dist/cli/commands/version.js +12 -0
  75. package/dist/cli/commands/version.js.map +1 -0
  76. package/dist/cli/config.d.ts +225 -45
  77. package/dist/cli/config.d.ts.map +1 -1
  78. package/dist/cli/config.js +426 -115
  79. package/dist/cli/config.js.map +1 -1
  80. package/dist/index.d.ts +2 -0
  81. package/dist/index.d.ts.map +1 -1
  82. package/dist/index.js +1 -0
  83. package/dist/index.js.map +1 -1
  84. package/dist/mcp/proxy/cli.js +1 -0
  85. package/dist/mcp/proxy/cli.js.map +1 -1
  86. package/dist/mcp/proxy/factory.d.ts +2 -0
  87. package/dist/mcp/proxy/factory.d.ts.map +1 -1
  88. package/dist/mcp/proxy/factory.js +10 -1
  89. package/dist/mcp/proxy/factory.js.map +1 -1
  90. package/dist/mcp/proxy/server/init.js +1 -1
  91. package/dist/mcp/proxy/server/init.js.map +1 -1
  92. package/dist/mcp/proxy/server/server.d.ts +3 -1
  93. package/dist/mcp/proxy/server/server.d.ts.map +1 -1
  94. package/dist/mcp/proxy/server/server.js +5 -2
  95. package/dist/mcp/proxy/server/server.js.map +1 -1
  96. package/dist/mcp/proxy/types.d.ts +7 -0
  97. package/dist/mcp/proxy/types.d.ts.map +1 -1
  98. package/dist/mcp/proxy/types.js.map +1 -1
  99. package/dist/version.d.ts +3 -1
  100. package/dist/version.d.ts.map +1 -1
  101. package/dist/version.js +3 -1
  102. package/dist/version.js.map +1 -1
  103. package/dist/x402/http/factory.d.ts +2 -0
  104. package/dist/x402/http/factory.d.ts.map +1 -1
  105. package/dist/x402/http/factory.js +1 -0
  106. package/dist/x402/http/factory.js.map +1 -1
  107. package/dist/x402/index.d.ts +2 -0
  108. package/dist/x402/index.d.ts.map +1 -1
  109. package/dist/x402/index.js +2 -0
  110. package/dist/x402/index.js.map +1 -1
  111. package/dist/x402/siwx.d.ts +55 -0
  112. package/dist/x402/siwx.d.ts.map +1 -0
  113. package/dist/x402/siwx.js +122 -0
  114. package/dist/x402/siwx.js.map +1 -0
  115. package/dist/x402/wallets/smart-account/cosigned.d.ts +9 -2
  116. package/dist/x402/wallets/smart-account/cosigned.d.ts.map +1 -1
  117. package/dist/x402/wallets/smart-account/cosigned.js +13 -4
  118. package/dist/x402/wallets/smart-account/cosigned.js.map +1 -1
  119. package/package.json +3 -2
@@ -6,21 +6,110 @@ import { isAddress } from "viem";
6
6
  import { privateKeyToAddress } from "viem/accounts";
7
7
  import { parseEnvConfig } from "../ampersend/env.js";
8
8
  import { err, ok } from "./envelope.js";
9
+ /**
10
+ * Shared by every CLI command that needs to talk to the API as an agent.
11
+ * Returns an `err` envelope when the local config isn't ready, so the
12
+ * caller can print it and exit.
13
+ */
14
+ export function loadCredentials(opts = {}) {
15
+ try {
16
+ const envConfig = parseEnvConfig();
17
+ return {
18
+ ok: true,
19
+ credentials: {
20
+ agentAccount: envConfig.AGENT_ACCOUNT,
21
+ agentKey: envConfig.AGENT_KEY,
22
+ ...(envConfig.API_URL ? { apiUrl: envConfig.API_URL } : {}),
23
+ },
24
+ };
25
+ }
26
+ catch {
27
+ // Fall back to config file
28
+ }
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;
33
+ return {
34
+ ok: true,
35
+ credentials: {
36
+ agentAccount: selected.context.agentAccount,
37
+ agentKey: selected.context.agentKey,
38
+ ...(apiUrl ? { apiUrl } : {}),
39
+ },
40
+ };
41
+ }
42
+ const status = getConfigStatus(opts).status;
43
+ const code = status === "not_initialized" ? "NOT_CONFIGURED" : "SETUP_INCOMPLETE";
44
+ return {
45
+ ok: false,
46
+ error: err(code, 'Run "ampersend setup start" or "ampersend config set" to configure', { status }),
47
+ };
48
+ }
9
49
  /** Config directory and file paths */
10
50
  const CONFIG_DIR = join(homedir(), ".ampersend");
11
51
  export const CONFIG_FILE = join(CONFIG_DIR, "config.json");
12
52
  /** Current config version */
13
- const CONFIG_VERSION = 1;
53
+ const CONFIG_VERSION = 2;
14
54
  /** Hard-coded approval expiration: 30 minutes */
15
55
  const APPROVAL_EXPIRY_MS = 30 * 60 * 1000;
16
56
  /** Default API URL (production) */
17
57
  export const DEFAULT_API_URL = "https://api.ampersend.ai";
18
- const HexString = Schema.TemplateLiteral(Schema.Literal("0x"), Schema.String);
19
- /** Schema for validating stored config read from disk.
58
+ const HexString = Schema.TemplateLiteral([Schema.Literal("0x"), Schema.String]);
59
+ /**
60
+ * Cached Laso Bearer token for `card details`/`list`, so a warm read costs
61
+ * nothing. Stamped with the identity (`agentKey`) and `apiUrl` it was minted
62
+ * under: `readLasoToken` treats it as absent if either no longer matches the
63
+ * active context (covers env-var overrides) or it has expired. Self-correcting,
64
+ * so the identity/URL write paths only need to drop it, not re-thread it.
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.
20
68
  *
21
- * Effect Schema strips unknown keys, so legacy fields like `network` written
22
- * by older versions are silently dropped on next write. */
23
- const StoredConfigSchema = Schema.Struct({
69
+ * Schema is the source of truth; the `LasoToken` type is derived from it so the
70
+ * two can't drift (the same pattern as the schemas in `src/ampersend/`).
71
+ */
72
+ const LasoTokenSchema = Schema.Struct({
73
+ idToken: Schema.String,
74
+ expiresAt: Schema.String, // ISO timestamp
75
+ agentKey: HexString,
76
+ apiUrl: Schema.optional(Schema.String),
77
+ });
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`.
86
+ *
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({
24
113
  version: Schema.Literal(1),
25
114
  agentKey: Schema.optional(HexString),
26
115
  agentAccount: Schema.optional(HexString),
@@ -30,7 +119,53 @@ const StoredConfigSchema = Schema.Struct({
30
119
  agentKey: HexString,
31
120
  expiresAt: Schema.String,
32
121
  })),
122
+ lasoToken: Schema.optional(LasoTokenSchema),
33
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
+ }
34
169
  /**
35
170
  * Ensure config directory exists with secure permissions
36
171
  */
@@ -40,7 +175,7 @@ function ensureConfigDir() {
40
175
  }
41
176
  }
42
177
  /**
43
- * Read config file if it exists.
178
+ * Read config file if it exists, normalized to the V2 context model.
44
179
  * Returns null if the file is missing or corrupt.
45
180
  */
46
181
  export function readConfig() {
@@ -50,7 +185,8 @@ export function readConfig() {
50
185
  const content = readFileSync(CONFIG_FILE, "utf-8");
51
186
  try {
52
187
  const parsed = JSON.parse(content);
53
- return Schema.decodeUnknownSync(StoredConfigSchema)(parsed);
188
+ const decoded = Schema.decodeUnknownSync(StoredConfigSchema)(parsed);
189
+ return decoded.version === 1 ? migrateV1toV2(decoded) : decoded;
54
190
  }
55
191
  catch {
56
192
  // Corrupt or unrecognised config — treat as absent so commands can re-initialise
@@ -58,34 +194,65 @@ export function readConfig() {
58
194
  }
59
195
  }
60
196
  /**
61
- * 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.
62
214
  */
63
215
  export function writeConfig(config) {
64
216
  ensureConfigDir();
65
- const withVersion = { version: CONFIG_VERSION, ...config };
66
- 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 });
67
219
  }
68
220
  /**
69
- * Get runtime config with status
221
+ * The context name to use this invocation: `--context` flag > `AMPERSEND_CONTEXT`
222
+ * env > persisted `activeContext`. Returns undefined if none resolves.
70
223
  */
71
- export function getRuntimeConfig() {
72
- const stored = readConfig();
73
- if (!stored) {
224
+ export function resolveContextName(opts = {}) {
225
+ return opts.context ?? process.env.AMPERSEND_CONTEXT ?? readConfig()?.activeContext ?? undefined;
226
+ }
227
+ /**
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.
230
+ */
231
+ export function getSelectedContext(opts = {}) {
232
+ const config = readConfig();
233
+ const name = resolveContextName(opts);
234
+ if (!config || !name)
74
235
  return null;
75
- }
76
- const { version: _, ...rest } = stored;
77
- const status = rest.agentKey && rest.agentAccount ? "ready" : "pending_agent";
78
- return { ...rest, status };
236
+ const context = config.contexts[name];
237
+ if (!context)
238
+ return null;
239
+ return { name, context };
79
240
  }
80
241
  /**
81
- * 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.
82
244
  */
83
- export function getConfigStatus() {
84
- const config = getRuntimeConfig();
85
- 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)
86
254
  return { status: "not_initialized" };
87
- }
88
- return { status: config.status };
255
+ return { status: selected.context.status === "ready" ? "ready" : "pending_agent" };
89
256
  }
90
257
  /**
91
258
  * Check if a pending approval has expired locally.
@@ -99,11 +266,62 @@ export function isPendingExpired(pending) {
99
266
  export function computeApprovalExpiry() {
100
267
  return new Date(Date.now() + APPROVAL_EXPIRY_MS).toISOString();
101
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
+ }
102
317
  /**
103
- * Set active config directly using "agentKey:::agentAccount" format.
104
- * 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.
105
323
  */
106
- export function setConfig(secret) {
324
+ export function setConfig(secret, opts = {}) {
107
325
  const parts = secret.split(":::");
108
326
  if (parts.length !== 2) {
109
327
  return err("INVALID_FORMAT", 'Expected format: "agentKey:::agentAccount"');
@@ -115,88 +333,181 @@ export function setConfig(secret) {
115
333
  if (!isAddress(agentAccount)) {
116
334
  return err("INVALID_ADDRESS", "Invalid Ethereum address format for agent account");
117
335
  }
118
- const existing = readConfig();
119
- writeConfig({
120
- agentKey: agentKey,
121
- agentAccount: agentAccount,
122
- ...(existing?.apiUrl ? { apiUrl: existing.apiUrl } : {}),
123
- // Preserve pending approval if any
124
- ...(existing?.pendingApproval ? { pendingApproval: existing.pendingApproval } : {}),
125
- });
126
- const agentKeyAddress = privateKeyToAddress(agentKey);
127
- return ok({
128
- agentKeyAddress,
129
- agentAccount,
130
- status: "ready",
131
- });
132
- }
133
- /**
134
- * Set API URL in config. Pass undefined to clear (revert to production default).
135
- */
136
- export function setApiUrl(apiUrl) {
137
- if (apiUrl != null) {
336
+ if (opts.apiUrl != null) {
138
337
  try {
139
- new URL(apiUrl);
338
+ new URL(opts.apiUrl);
140
339
  }
141
340
  catch {
142
341
  return err("INVALID_URL", "Invalid URL format.");
143
342
  }
144
343
  }
145
- const existing = readConfig();
146
- writeConfig({
147
- ...(existing?.agentKey ? { agentKey: existing.agentKey } : {}),
148
- ...(existing?.agentAccount ? { agentAccount: existing.agentAccount } : {}),
149
- ...(existing?.pendingApproval ? { pendingApproval: existing.pendingApproval } : {}),
150
- ...(apiUrl != null ? { apiUrl } : {}),
151
- });
152
- 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" });
153
365
  }
154
366
  /**
155
- * Store a pending approval in config.
156
- * Called by `setup start`.
367
+ * Switch the active context without re-running setup. Errors if the name is
368
+ * unknown.
157
369
  */
158
- export function storePendingApproval(pending) {
159
- const existing = readConfig();
160
- writeConfig({
161
- ...(existing?.agentKey ? { agentKey: existing.agentKey } : {}),
162
- ...(existing?.agentAccount ? { agentAccount: existing.agentAccount } : {}),
163
- ...(existing?.apiUrl ? { apiUrl: existing.apiUrl } : {}),
164
- pendingApproval: pending,
165
- });
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" });
166
379
  }
167
380
  /**
168
- * 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.
169
383
  */
170
- export function clearPendingApproval() {
171
- const existing = readConfig();
172
- if (!existing)
173
- return;
174
- const { pendingApproval: _, version: __, ...rest } = existing;
175
- 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 });
176
395
  }
177
396
  /**
178
- * Promote a pending approval to active config.
179
- * Called by `setup finish` when the approval is resolved.
397
+ * Create a `pending` context from a freshly-requested approval.
398
+ * Called by `setup start`. Makes it active unless `detach` is set.
180
399
  */
181
- export function promotePending(agentAccount) {
182
- const existing = readConfig();
183
- if (!existing?.pendingApproval) {
184
- return err("NO_PENDING", "No pending approval to promote");
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);
413
+ }
414
+ /**
415
+ * Promote a `pending` context to `ready` (key stays, account filled in).
416
+ * Called by `setup finish`. Makes the context active.
417
+ */
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}".`);
185
423
  }
186
- const { agentKey: _oldKey, pendingApproval, version: _version, ...rest } = existing;
187
- const agentKeyAddress = privateKeyToAddress(pendingApproval.agentKey);
188
- // Promote: pending key becomes active, pending cleared
189
- writeConfig({
190
- ...rest,
191
- agentKey: pendingApproval.agentKey,
192
- agentAccount,
193
- // pendingApproval intentionally omitted — cleared on promote
194
- });
195
- return ok({
196
- agentKeyAddress,
197
- 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] = {
198
429
  status: "ready",
199
- });
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);
452
+ }
453
+ /**
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.
457
+ *
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.
460
+ */
461
+ export function storeLasoToken(token, opts = {}) {
462
+ const config = readConfig();
463
+ const selected = getSelectedContext(opts);
464
+ if (!config || selected?.context.status !== "ready")
465
+ return;
466
+ config.contexts[selected.name] = { ...selected.context, lasoToken: token };
467
+ writeConfig(config);
468
+ }
469
+ /**
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.
474
+ */
475
+ export function readLasoToken(active, opts = {}) {
476
+ const ctx = getSelectedContext(opts)?.context;
477
+ const stored = ctx?.status === "ready" ? ctx.lasoToken : undefined;
478
+ if (!stored)
479
+ return null;
480
+ if (new Date(stored.expiresAt).getTime() <= Date.now())
481
+ return null;
482
+ if (stored.agentKey !== active.agentKey)
483
+ return null;
484
+ // apiUrl absent on either side means production default; normalize so an
485
+ // explicit prod URL and an implicit one are treated as the same context.
486
+ const storedUrl = stored.apiUrl ?? DEFAULT_API_URL;
487
+ const activeUrl = active.apiUrl ?? DEFAULT_API_URL;
488
+ if (storedUrl !== activeUrl)
489
+ return null;
490
+ return stored;
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;
200
511
  }
201
512
  /**
202
513
  * Get current configuration status.
@@ -227,34 +538,34 @@ export function getStatus() {
227
538
  // No env vars, check file
228
539
  }
229
540
  // Check config file
230
- const config = getRuntimeConfig();
231
- if (!config) {
541
+ const config = readConfig();
542
+ if (!config || Object.keys(config.contexts).length === 0) {
232
543
  return ok({ status: "not_initialized", credentialSource: "none" });
233
544
  }
234
- // Determine effective API URL (env var takes precedence over file)
235
- const envApiUrl = process.env.AMPERSEND_API_URL;
236
- 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);
237
552
  const result = {
238
- status: config.status,
553
+ status: activeSummary?.status ?? "not_initialized",
239
554
  credentialSource: "file",
240
555
  configPath: CONFIG_FILE,
556
+ ...(config.activeContext ? { activeContext: config.activeContext } : {}),
557
+ contexts,
241
558
  };
242
- if (config.agentKey) {
243
- result.agentKeyAddress = privateKeyToAddress(config.agentKey);
244
- }
245
- if (config.agentAccount) {
246
- result.agentAccount = config.agentAccount;
247
- }
248
- if (effectiveApiUrl && effectiveApiUrl !== DEFAULT_API_URL) {
249
- result.apiUrl = effectiveApiUrl;
250
- }
251
- // Always show pending approval info if present
252
- if (config.pendingApproval) {
253
- const pendingKeyAddress = privateKeyToAddress(config.pendingApproval.agentKey);
254
- result.pendingApproval = {
255
- agentKeyAddress: pendingKeyAddress,
256
- expired: isPendingExpired(config.pendingApproval),
257
- };
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
+ }
258
569
  }
259
570
  return ok(result);
260
571
  }