@2kw/ai 7.1.0-dev.1 → 7.1.0-dev.16

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/README.md CHANGED
@@ -85,7 +85,8 @@ kubectl-style contexts switch between organizations and environments:
85
85
  ```bash
86
86
  2kw context list # List all contexts
87
87
  2kw context use acme-corp # Switch active context
88
- 2kw context create staging # Create a new context
88
+ 2kw context create staging --base-url https://api.staging.example # Create a context, sign in through the browser
89
+ 2kw context create ci --api-key sk_... # Or store an API key instead
89
90
  ```
90
91
 
91
92
  `2kw config set memory true` makes an API-key context send `X-Backbone-Memory: enabled` on `ai respond` and `agents run` calls, which opts those runs into agent memory; `AI_2KW_MEMORY=true|false` overrides it per shell. Runs from a browser-login (session) context are always eligible for agent memory regardless of this setting, so `memory false` only affects API-key contexts.
@@ -11,8 +11,8 @@ export declare function redactSecrets(tools: Record<string, unknown>[] | undefin
11
11
  envNames: string[];
12
12
  };
13
13
  /**
14
- * Tool entries the backend stores but never runs (planner/runtime-injected or executor-less,
15
- * e.g. a console-stored `web_search`). agent.yaml cannot author them, so export drops them.
14
+ * Tool entries the backend stores but never runs as authored (planner- or runtime-injected,
15
+ * e.g. `backbone.tool_search`). agent.yaml cannot author them, so export drops them.
16
16
  */
17
17
  export declare function dropInertTools(tools: Record<string, unknown>[] | undefined): {
18
18
  tools: Record<string, unknown>[] | undefined;
@@ -43,8 +43,8 @@ export function redactSecrets(tools) {
43
43
  return { tools: redacted, envNames: [...owners.keys()] };
44
44
  }
45
45
  /**
46
- * Tool entries the backend stores but never runs (planner/runtime-injected or executor-less,
47
- * e.g. a console-stored `web_search`). agent.yaml cannot author them, so export drops them.
46
+ * Tool entries the backend stores but never runs as authored (planner- or runtime-injected,
47
+ * e.g. `backbone.tool_search`). agent.yaml cannot author them, so export drops them.
48
48
  */
49
49
  export function dropInertTools(tools) {
50
50
  if (!tools)
@@ -138,6 +138,77 @@ export declare const agentSchema: {
138
138
  };
139
139
  };
140
140
  };
141
+ readonly webSearchTool: {
142
+ readonly type: "object";
143
+ readonly additionalProperties: false;
144
+ readonly required: readonly ["type"];
145
+ readonly properties: {
146
+ readonly type: {
147
+ readonly const: "web_search";
148
+ };
149
+ readonly config: {
150
+ readonly type: "object";
151
+ readonly properties: {
152
+ readonly source: {
153
+ readonly enum: readonly ["builtin", "external", "native"];
154
+ };
155
+ readonly searchProviderId: {
156
+ readonly type: readonly ["string", "null"];
157
+ };
158
+ readonly fallback: {
159
+ readonly enum: readonly ["builtin", "external"];
160
+ };
161
+ readonly allowedDomains: {
162
+ readonly type: "array";
163
+ readonly items: {
164
+ readonly type: "string";
165
+ readonly pattern: "^(?=.{1,253}$)([\\p{L}\\p{N}](?:[\\p{L}\\p{N}-]{0,61}[\\p{L}\\p{N}])?\\.)+\\p{L}(?:[\\p{L}\\p{N}-]{0,61}[\\p{L}\\p{N}])?$";
166
+ };
167
+ readonly maxItems: 10;
168
+ };
169
+ };
170
+ readonly if: {
171
+ readonly anyOf: readonly [{
172
+ readonly required: readonly ["source"];
173
+ readonly properties: {
174
+ readonly source: {
175
+ readonly const: "external";
176
+ };
177
+ };
178
+ }, {
179
+ readonly required: readonly ["source", "fallback"];
180
+ readonly properties: {
181
+ readonly source: {
182
+ readonly const: "native";
183
+ };
184
+ readonly fallback: {
185
+ readonly const: "external";
186
+ };
187
+ };
188
+ }];
189
+ };
190
+ readonly then: {
191
+ readonly required: readonly ["searchProviderId"];
192
+ readonly properties: {
193
+ readonly searchProviderId: {
194
+ readonly type: "string";
195
+ readonly pattern: "\\S";
196
+ };
197
+ };
198
+ };
199
+ readonly else: {
200
+ readonly properties: {
201
+ readonly searchProviderId: {
202
+ readonly type: "null";
203
+ };
204
+ };
205
+ };
206
+ };
207
+ readonly requiresConfirmation: {
208
+ readonly type: "boolean";
209
+ };
210
+ };
211
+ };
141
212
  readonly extractionTool: {
142
213
  readonly type: "object";
143
214
  readonly additionalProperties: false;
@@ -306,6 +377,18 @@ export declare const agentSchema: {
306
377
  readonly then: {
307
378
  readonly $ref: "#/$defs/fileSearchTool";
308
379
  };
380
+ }, {
381
+ readonly if: {
382
+ required: string[];
383
+ properties: {
384
+ type: {
385
+ const: string;
386
+ };
387
+ };
388
+ };
389
+ readonly then: {
390
+ readonly $ref: "#/$defs/webSearchTool";
391
+ };
309
392
  }, {
310
393
  readonly if: {
311
394
  required: string[];
@@ -16,6 +16,9 @@ const MODEL_REF = {
16
16
  maxLength: 255,
17
17
  pattern: "^(?:[^/#]+|[A-Za-z0-9-]+/[^#]+)$",
18
18
  };
19
+ // A plain hostname (no scheme, path, port or wildcard): dot-separated labels of letters, digits and
20
+ // inner hyphens, Unicode or ASCII. Mirrors WebToolEntryValidator.HOST; needs a Unicode-aware regex engine.
21
+ const HOSTNAME = "^(?=.{1,253}$)([\\p{L}\\p{N}](?:[\\p{L}\\p{N}-]{0,61}[\\p{L}\\p{N}])?\\.)+\\p{L}(?:[\\p{L}\\p{N}-]{0,61}[\\p{L}\\p{N}])?$";
19
22
  const oneOfLowercase = (...values) => ({ type: "string", enum: values });
20
23
  /** Types the backend injects or cannot execute; authoring them has no effect or breaks the run. */
21
24
  export const NON_AUTHORABLE_TOOL_TYPES = {
@@ -27,10 +30,10 @@ export const NON_AUTHORABLE_TOOL_TYPES = {
27
30
  "backbone.deliver": "it is added at run time while sandbox outputs are held",
28
31
  // #1244: injected wherever backbone.bash is advertised; an authored copy is dropped.
29
32
  "backbone.view_image": "it is added at run time wherever the sandboxed shell is",
30
- web_search: "it has no executor and is skipped",
31
33
  };
32
34
  const BUILTIN_TYPES = [
33
35
  "file_search",
36
+ "web_search",
34
37
  "backbone.extraction",
35
38
  "backbone.document_convert",
36
39
  "backbone.todo",
@@ -118,6 +121,35 @@ export const agentSchema = {
118
121
  },
119
122
  },
120
123
  },
124
+ // A missing config, `config: {}` or a missing source runs the built-in search. Mirrors
125
+ // WebToolEntryValidator; `config` stays open so later sources' fields do not break older CLIs.
126
+ webSearchTool: {
127
+ type: "object",
128
+ additionalProperties: false,
129
+ required: ["type"],
130
+ properties: {
131
+ type: { const: "web_search" },
132
+ config: {
133
+ type: "object",
134
+ properties: {
135
+ source: { enum: ["builtin", "external", "native"] },
136
+ searchProviderId: { type: ["string", "null"] },
137
+ fallback: { enum: ["builtin", "external"] },
138
+ allowedDomains: { type: "array", items: { type: "string", pattern: HOSTNAME }, maxItems: 10 },
139
+ },
140
+ // A provider id is required exactly when an external provider serves the search.
141
+ if: {
142
+ anyOf: [
143
+ { required: ["source"], properties: { source: { const: "external" } } },
144
+ { required: ["source", "fallback"], properties: { source: { const: "native" }, fallback: { const: "external" } } },
145
+ ],
146
+ },
147
+ then: { required: ["searchProviderId"], properties: { searchProviderId: { type: "string", pattern: "\\S" } } },
148
+ else: { properties: { searchProviderId: { type: "null" } } },
149
+ },
150
+ requiresConfirmation: { type: "boolean" },
151
+ },
152
+ },
121
153
  extractionTool: {
122
154
  type: "object",
123
155
  additionalProperties: false,
@@ -204,6 +236,7 @@ export const agentSchema = {
204
236
  type: "object",
205
237
  allOf: [
206
238
  { if: typeIs("file_search"), then: { $ref: "#/$defs/fileSearchTool" } },
239
+ { if: typeIs("web_search"), then: { $ref: "#/$defs/webSearchTool" } },
207
240
  { if: typeIs("backbone.extraction"), then: { $ref: "#/$defs/extractionTool" } },
208
241
  { if: typeIs("backbone.document_convert"), then: { $ref: "#/$defs/documentConvertTool" } },
209
242
  { if: typeIs("backbone.todo"), then: { $ref: "#/$defs/todoTool" } },
@@ -26,6 +26,9 @@ tools: []
26
26
  # - type: file_search
27
27
  # config:
28
28
  # knowledgeBaseIds: [kb_id]
29
+ # - type: web_search
30
+ # config:
31
+ # allowedDomains: [example.com]
29
32
  # - type: backbone.extraction
30
33
  # config:
31
34
  # schemaIds: [schema_id]
@@ -6,6 +6,8 @@ const validate = ajv.compile(agentSchema);
6
6
  const NULLABLE_KEYS = ["description", "models", "instructions", "tools", "hitlPolicy", "options", "skills"];
7
7
  /** Tool-rule values the backend matches case-insensitively; the actions (approve, auto, deny) are exact. */
8
8
  const TOOL_RULE_CLASSES = new Set(["read", "write", "destructive"]);
9
+ /** web_search config keys whose explicit `null` the backend reads as absent. */
10
+ const WEB_SEARCH_NULLABLE_KEYS = ["source", "fallback", "searchProviderId", "allowedDomains"];
9
11
  const isPlainObject = (v) => typeof v === "object" && v !== null && !Array.isArray(v);
10
12
  /**
11
13
  * A deep copy of `doc` in the form the backend actually checks: explicit nulls it treats as absent
@@ -31,6 +33,8 @@ export function normalizeForValidation(doc) {
31
33
  for (const tool of copy.tools) {
32
34
  if (isPlainObject(tool) && tool.annotations === null)
33
35
  delete tool.annotations;
36
+ if (isPlainObject(tool) && tool.type === "web_search")
37
+ normalizeWebSearch(tool);
34
38
  }
35
39
  }
36
40
  const policy = copy.hitlPolicy;
@@ -50,6 +54,21 @@ export function normalizeForValidation(doc) {
50
54
  }
51
55
  return copy;
52
56
  }
57
+ /** WebToolEntryValidator reads a null config and null config fields as absent, and trims each domain. */
58
+ function normalizeWebSearch(tool) {
59
+ if (tool.config === null)
60
+ delete tool.config;
61
+ const config = tool.config;
62
+ if (!isPlainObject(config))
63
+ return;
64
+ for (const key of WEB_SEARCH_NULLABLE_KEYS) {
65
+ if (config[key] === null)
66
+ delete config[key];
67
+ }
68
+ if (Array.isArray(config.allowedDomains)) {
69
+ config.allowedDomains = config.allowedDomains.map((d) => (typeof d === "string" ? d.trim() : d));
70
+ }
71
+ }
53
72
  function describe(e) {
54
73
  // if/then wrappers restate the real error one level up; drop them. `not` only marks
55
74
  // non-authorable tool types, which validateAgentConfig reports with the reason.
@@ -75,6 +94,13 @@ function describe(e) {
75
94
  if (e.keyword === "const") {
76
95
  return { pointer, message: `must be ${JSON.stringify(params.allowedValue)}` };
77
96
  }
97
+ // The `else` branch of webSearchTool's provider rule: an id where no external provider serves the search.
98
+ if (e.keyword === "type" && params.type === "null" && pointer.endsWith("/config/searchProviderId")) {
99
+ return {
100
+ pointer,
101
+ message: "only allowed when an external provider serves the search (source external, or native with fallback external)",
102
+ };
103
+ }
78
104
  return { pointer, message: e.message ?? e.keyword };
79
105
  }
80
106
  export function validateAgentConfig(raw) {
@@ -98,11 +124,18 @@ export function validateAgentConfig(raw) {
98
124
  }
99
125
  }
100
126
  if (Array.isArray(record.tools)) {
127
+ let webSearchSeen = false;
101
128
  record.tools.forEach((tool, i) => {
102
129
  const type = tool?.type;
103
130
  if (typeof type === "string" && Object.hasOwn(NON_AUTHORABLE_TOOL_TYPES, type)) {
104
131
  issues.push({ pointer: `/tools/${i}/type`, message: `type "${type}" cannot be authored: ${NON_AUTHORABLE_TOOL_TYPES[type]}` });
105
132
  }
133
+ // The backend refuses a second envelope (WebToolEntryValidator).
134
+ if (type === "web_search") {
135
+ if (webSearchSeen)
136
+ issues.push({ pointer: `/tools/${i}/type`, message: "an agent may carry only one web_search tool" });
137
+ webSearchSeen = true;
138
+ }
106
139
  });
107
140
  }
108
141
  if (Array.isArray(record.skills)) {
@@ -3,6 +3,7 @@ import chalk from "chalk";
3
3
  import { getClient, runAction } from "../lib/client.js";
4
4
  import { isJsonOutput } from "../lib/config.js";
5
5
  import { BackboneApiError, CliUsageError } from "../lib/errors.js";
6
+ import { ensurePublished } from "../lib/agent-publish.js";
6
7
  import { findAgentByName } from "../lib/agent-lookup.js";
7
8
  import { loadAgentConfig, maskSecrets } from "../agent-config/file.js";
8
9
  import { CONFIG_FIELDS, planApply } from "../agent-config/plan.js";
@@ -110,30 +111,31 @@ export function makeAgentApplyCommand() {
110
111
  let versionId = latest?.id;
111
112
  version = latest?.versionNumber ?? null;
112
113
  if (plan.action === "created") {
113
- let created;
114
- try {
115
- ({ data: created } = await client.POST("/v1/agents", {
116
- // eslint-disable-next-line @typescript-eslint/no-explicit-any
117
- body: agentBody(config),
118
- }));
119
- }
120
- catch (err) {
121
- if (err instanceof BackboneApiError && err.status === 500) {
122
- throw new BackboneApiError({
123
- status: err.status,
124
- error: err.errorType,
125
- message: `${err.message} — an agent named "${config.name}" may already exist`,
126
- timestamp: err.timestamp,
127
- code: err.code,
128
- });
129
- }
130
- throw err;
131
- }
114
+ // A duplicate name answers 409 with the backend's own message (#668).
115
+ const { data: created } = await client.POST("/v1/agents", {
116
+ // eslint-disable-next-line @typescript-eslint/no-explicit-any
117
+ body: agentBody(config),
118
+ });
132
119
  if (!created?.id)
133
120
  throw new Error("Agent was created but the response has no id");
134
121
  agentId = String(created.id);
122
+ // Create publishes version 1 (#668); an older backend does not, so publish as a fallback.
123
+ const head = await ensurePublished(client, created, versionBody(config, opts.message));
124
+ versionId = head.versionId;
125
+ version = head.version;
126
+ // A message given on the command line is kept in the history as a second version (D4);
127
+ // the default message is not worth a version of its own.
128
+ if (!head.fallback && command.getOptionValueSource("message") === "cli") {
129
+ const { data: v } = await client.POST("/v1/agents/{agentId}/versions", {
130
+ params: { path: { agentId } },
131
+ // eslint-disable-next-line @typescript-eslint/no-explicit-any
132
+ body: versionBody(config, opts.message),
133
+ });
134
+ versionId = v?.id;
135
+ version = v?.versionNumber ?? null;
136
+ }
135
137
  }
136
- if (plan.action === "created" || plan.action === "versioned") {
138
+ if (plan.action === "versioned") {
137
139
  const { data: v } = await client.POST("/v1/agents/{agentId}/versions", {
138
140
  params: { path: { agentId: agentId } },
139
141
  // eslint-disable-next-line @typescript-eslint/no-explicit-any
@@ -10,6 +10,7 @@ import { makeAgentExportCommand } from "./agent-export.js";
10
10
  import { makeAgentPolicyCommand } from "./agent-policy.js";
11
11
  import { makeAgentInitCommand, makeAgentSchemaCommand } from "./agent-init.js";
12
12
  import { maskSecrets } from "../agent-config/file.js";
13
+ import { ensurePublished } from "../lib/agent-publish.js";
13
14
  import { collectModel, MODEL_HELP, MODELS_HELP, modelFieldsFromOptions } from "../lib/agent-models.js";
14
15
  import { assertNameNotBlank } from "../lib/overlay.js";
15
16
  export function makeAgentsCommand() {
@@ -58,7 +59,7 @@ export function makeAgentsCommand() {
58
59
  });
59
60
  cmd
60
61
  .command("create")
61
- .description("Create a new agent")
62
+ .description("Create a new agent; it is published as version 1 and runnable at once")
62
63
  .requiredOption("-n, --name <name>", "Agent name")
63
64
  .option("-m, --model <model>", MODEL_HELP, collectModel)
64
65
  .option("--models <list>", MODELS_HELP)
@@ -72,18 +73,38 @@ export function makeAgentsCommand() {
72
73
  await runAction(command, async () => {
73
74
  const models = modelFieldsFromOptions(opts, true);
74
75
  const client = getClient(command);
76
+ const tools = opts.tools ? JSON.parse(opts.tools) : undefined;
77
+ const skills = opts.skills ? JSON.parse(opts.skills) : undefined;
78
+ const options = opts.options ? JSON.parse(opts.options) : undefined;
79
+ const hitlPolicy = opts.hitlPolicy ? JSON.parse(opts.hitlPolicy) : undefined;
75
80
  const { data } = await client.POST("/v1/agents", {
76
81
  body: {
77
82
  name: opts.name,
78
83
  ...models,
79
84
  description: opts.description,
80
85
  instructions: opts.instructions,
81
- options: opts.options ? JSON.parse(opts.options) : undefined,
82
- tools: opts.tools ? JSON.parse(opts.tools) : undefined,
83
- skills: opts.skills ? JSON.parse(opts.skills) : undefined,
84
- hitlPolicy: opts.hitlPolicy ? JSON.parse(opts.hitlPolicy) : undefined,
86
+ options,
87
+ tools,
88
+ skills,
89
+ hitlPolicy,
85
90
  },
86
91
  });
92
+ if (data?.id) {
93
+ // Create publishes version 1; an older backend leaves it unpublished, so publish then.
94
+ const head = await ensurePublished(client, data, {
95
+ ...models,
96
+ instructions: opts.instructions,
97
+ options,
98
+ tools,
99
+ skills,
100
+ hitlPolicy,
101
+ changeDescription: "Initial version",
102
+ });
103
+ // Report the head the fallback just published, not the pre-publish create response.
104
+ if (head.fallback && head.versionId) {
105
+ Object.assign(data, { latestVersionId: head.versionId, latestVersionNumber: head.version ?? undefined });
106
+ }
107
+ }
87
108
  formatDetail(maskSecrets(data, []), command);
88
109
  });
89
110
  });
@@ -14,6 +14,8 @@ type SessionConfig = Extract<ResolvedConfig, {
14
14
  * or a code that already carries its own separator) is shown untouched.
15
15
  */
16
16
  export declare function formatUserCode(code: string): string;
17
+ /** Trailing slashes make `${authUrl}/api/auth/...` double up — drop them once, here. */
18
+ export declare function stripTrailingSlash(url: string): string;
17
19
  /** Injection points for {@link deviceFlowLogin}; all default to the real thing. */
18
20
  export interface DeviceLoginDeps {
19
21
  requestCode?: typeof requestDeviceCode;
@@ -33,6 +35,9 @@ export interface DeviceLoginTarget {
33
35
  contextName: string;
34
36
  baseUrl: string;
35
37
  authUrl: string;
38
+ /** Make the context active once stored. Defaults to true; `context create`
39
+ * turns it off for every context but the first. */
40
+ activate?: boolean;
36
41
  }
37
42
  /**
38
43
  * The browser login, end to end: ask for a device code, wait for the user to
@@ -27,7 +27,7 @@ export function formatUserCode(code) {
27
27
  return code.length === 8 ? `${code.slice(0, 4)}-${code.slice(4)}` : code;
28
28
  }
29
29
  /** Trailing slashes make `${authUrl}/api/auth/...` double up — drop them once, here. */
30
- function stripTrailingSlash(url) {
30
+ export function stripTrailingSlash(url) {
31
31
  return url.replace(/\/+$/, "");
32
32
  }
33
33
  /**
@@ -65,7 +65,7 @@ async function tryOpenBrowser(url) {
65
65
  */
66
66
  export async function deviceFlowLogin(target, deps = {}) {
67
67
  const { requestCode = requestDeviceCode, poll = pollDeviceToken, runPolling = runDevicePolling, sessionInfo = getSessionInfo, listOrgs = listOrganizations, chooseOrg = selectOrganization, setActiveOrg = setActiveOrganization, revoke = revokeSession, openBrowser = tryOpenBrowser, saveContext = setContext, activateContext = setActiveContext, } = deps;
68
- const { contextName, baseUrl, authUrl } = target;
68
+ const { contextName, baseUrl, authUrl, activate = true } = target;
69
69
  const device = await requestCode(authUrl, CLI_CLIENT_ID);
70
70
  console.log("");
71
71
  console.log(`Open ${chalk.cyan(device.verification_uri_complete)}`);
@@ -121,10 +121,14 @@ export async function deviceFlowLogin(target, deps = {}) {
121
121
  }
122
122
  // Outside the guard on purpose: the session is on disk now, so a failure to
123
123
  // mark the context active still leaves a credential the user can reach.
124
- activateContext(contextName);
124
+ if (activate)
125
+ activateContext(contextName);
125
126
  console.log(chalk.green(`Logged in as ${email}`));
126
127
  console.log(chalk.dim(` — organization: ${org.name}`));
127
128
  console.log(chalk.dim(`Config stored at: ${store.path}`));
129
+ if (!activate) {
130
+ console.log(chalk.dim(`Switch to it with "2kw context use ${contextName}".`));
131
+ }
128
132
  }
129
133
  /**
130
134
  * The pre-device-flow login: take an API key from the flag or the terminal and
@@ -140,6 +140,99 @@ export function makeBillingCommand() {
140
140
  });
141
141
  cmd.addCommand(makeOverageCommand());
142
142
  cmd.addCommand(makeTopUpsCommand());
143
+ cmd.addCommand(makeUsageCommand());
144
+ return cmd;
145
+ }
146
+ /**
147
+ * `bb billing usage periods|period|external` — the console usage page's reads (#1076): platform usage
148
+ * as a share of the period's included usage, and usage on the organisation's own provider keys. All
149
+ * three need a member login.
150
+ */
151
+ function makeUsageCommand() {
152
+ const cmd = new Command("usage").description("Show platform usage and usage on your own provider keys");
153
+ cmd
154
+ .command("periods")
155
+ .description("List billing periods, newest first (needs `bb auth login`)")
156
+ .action(async (_opts, command) => {
157
+ await runAction(command, async () => {
158
+ const client = getClient(command);
159
+ const { data } = await client.GET("/v1/usage/periods");
160
+ formatList((data ?? []), command, ["periodStart", "periodEnd", "kind"]);
161
+ });
162
+ });
163
+ cmd
164
+ .command("period")
165
+ .description("Show platform usage of a period, the current one by default (needs `bb auth login`)")
166
+ .option("--start <instant>", "A period start from `bb billing usage periods`")
167
+ .action(async (opts, command) => {
168
+ await runAction(command, async () => {
169
+ const client = getClient(command);
170
+ const { data } = await client.GET("/v1/usage/period", {
171
+ params: { query: opts.start ? { periodStart: String(opts.start) } : {} },
172
+ });
173
+ if (isJsonOutput(command)) {
174
+ console.log(JSON.stringify(data, null, 2));
175
+ return;
176
+ }
177
+ if (!data || data.accounting === "UNAVAILABLE") {
178
+ console.log(chalk.dim("Platform usage is not available for this organisation yet."));
179
+ return;
180
+ }
181
+ if (data.accounting === "SHADOW") {
182
+ console.log(chalk.yellow("Preview: usage is counted but not yet enforced."));
183
+ }
184
+ const plan = data.plan ? `${data.plan} ${data.usageLevel ?? ""}`.trim() : data.kind;
185
+ console.log(`Period: ${data.periodStart?.slice(0, 10)} → ${data.periodEnd?.slice(0, 10)} (${plan})`);
186
+ console.log(`Used: ${data.usedPercent == null ? "no included usage" : `${data.usedPercent} %`}`);
187
+ for (const meter of data.meters ?? []) {
188
+ console.log(` ${(meter.meter ?? "").padEnd(18)} ${meter.percent ?? "-"} % ${meter.quantity} ${meter.unit}`);
189
+ }
190
+ if (data.extraUsage) {
191
+ const state = data.extraUsage.enabled ? "" : chalk.dim(" (off)");
192
+ console.log(`Extra usage: €${data.extraUsage.spentEur} of €${data.extraUsage.capEur}${state}`);
193
+ }
194
+ if (data.prepaid) {
195
+ const expiry = data.prepaid.nextExpiry ? chalk.dim(` (next expiry ${data.prepaid.nextExpiry})`) : "";
196
+ console.log(`Prepaid: €${data.prepaid.remainingEur}${expiry}`);
197
+ }
198
+ });
199
+ });
200
+ cmd
201
+ .command("external")
202
+ .description("Show usage on your own provider keys, estimated at list price (needs `bb auth login`)")
203
+ .option("--from <date>", "First day, YYYY-MM-DD (UTC); default 30 days ago")
204
+ .option("--to <date>", "Last day, YYYY-MM-DD (UTC), included; at most 90 days")
205
+ .action(async (opts, command) => {
206
+ await runAction(command, async () => {
207
+ const client = getClient(command);
208
+ const query = {};
209
+ if (opts.from)
210
+ query.startDate = String(opts.from);
211
+ if (opts.to)
212
+ query.endDate = String(opts.to);
213
+ const { data } = await client.GET("/v1/usage/external", { params: { query } });
214
+ if (isJsonOutput(command)) {
215
+ console.log(JSON.stringify(data, null, 2));
216
+ return;
217
+ }
218
+ if (!data?.available) {
219
+ console.log(chalk.dim("Usage capture is off."));
220
+ return;
221
+ }
222
+ const estimate = data.totals?.estimatedCostEur;
223
+ const total = estimate == null ? "not priced" : `~€${estimate}`;
224
+ console.log(`Range: ${data.startDate} → ${data.endDate}`);
225
+ console.log(`Requests: ${data.totals?.requests ?? 0}`);
226
+ console.log(`Estimated: ${total} ${chalk.dim("(provider list price, billed by the provider)")}`);
227
+ const rows = (data.rows ?? []).map((row) => ({
228
+ provider: row.provider ?? "-",
229
+ model: row.model,
230
+ requests: row.requests,
231
+ estimate: row.estimatedCostEur == null ? "-" : `~€${row.estimatedCostEur}`,
232
+ }));
233
+ formatList(rows, command, ["provider", "model", "requests", "estimate"]);
234
+ });
235
+ });
143
236
  return cmd;
144
237
  }
145
238
  /** Whole euros from cents, e.g. 1234 → "€12.34". Amounts are net of VAT (#973 D-O). */
@@ -1,5 +1,6 @@
1
1
  import { Command } from "commander";
2
2
  import { updateContext } from "../lib/config.js";
3
+ import { type DeviceLoginDeps } from "./auth.js";
3
4
  import { listOrganizations, setActiveOrganization } from "../lib/auth-service.js";
4
5
  import { selectOrganization, type Asker } from "../lib/auth-session.js";
5
6
  /** Injection points for {@link performSetOrg}; all default to the real thing. */
@@ -24,5 +25,9 @@ export interface SetOrgDeps {
24
25
  * @param orgArg an organization id or slug; omitted means "prompt".
25
26
  */
26
27
  export declare function performSetOrg(orgArg: string | undefined, deps?: SetOrgDeps): Promise<void>;
27
- export declare function makeContextCommand(): Command;
28
+ /**
29
+ * @param deviceDeps forwarded to {@link deviceFlowLogin} by `context create` —
30
+ * a seam for tests, as on `makeAuthCommand`.
31
+ */
32
+ export declare function makeContextCommand(deviceDeps?: DeviceLoginDeps): Command;
28
33
  //# sourceMappingURL=context.d.ts.map
@@ -2,7 +2,8 @@ import { Command } from "commander";
2
2
  import chalk from "chalk";
3
3
  import Table from "cli-table3";
4
4
  import { createInterface } from "node:readline/promises";
5
- import { store, isJsonOutput, getAllContexts, getActiveContext, getActiveContextName, getContextCount, setContext, deleteContext, renameContext, setActiveContext, updateContext, validateContextName, DEFAULT_BASE_URL, } from "../lib/config.js";
5
+ import { store, isJsonOutput, getAllContexts, getActiveContext, getActiveContextName, getContextCount, setContext, deleteContext, renameContext, setActiveContext, updateContext, validateContextName, defaultAuthUrlFor, DEFAULT_BASE_URL, } from "../lib/config.js";
6
+ import { deviceFlowLogin, stripTrailingSlash } from "./auth.js";
6
7
  import { listOrganizations, setActiveOrganization, } from "../lib/auth-service.js";
7
8
  import { selectOrganization } from "../lib/auth-session.js";
8
9
  import { runAction } from "../lib/client.js";
@@ -76,7 +77,11 @@ export async function performSetOrg(orgArg, deps = {}) {
76
77
  }
77
78
  console.log(chalk.green(`Active organization: ${org.name} (${org.slug})`));
78
79
  }
79
- export function makeContextCommand() {
80
+ /**
81
+ * @param deviceDeps forwarded to {@link deviceFlowLogin} by `context create` —
82
+ * a seam for tests, as on `makeAuthCommand`.
83
+ */
84
+ export function makeContextCommand(deviceDeps = {}) {
80
85
  const cmd = new Command("context").description("Manage CLI contexts for multiple environments");
81
86
  cmd
82
87
  .command("list")
@@ -125,10 +130,12 @@ export function makeContextCommand() {
125
130
  });
126
131
  cmd
127
132
  .command("create")
128
- .description("Create a new context")
133
+ .description("Create a new context and sign in through the browser (or with an API key)")
129
134
  .argument("<name>", "Context name")
130
135
  .option("--base-url <url>", "Base URL")
131
- .option("--api-key <key>", "API key")
136
+ .option("--auth-url <url>", "Auth service URL (defaults to the one matching the base URL)")
137
+ .option("--api-key <key>", "Save an API key instead of signing in through the browser")
138
+ .option("--manual", "Prompt for an API key instead of signing in through the browser")
132
139
  .action(async (name, _opts, command) => {
133
140
  validateContextName(name);
134
141
  // Merged, not own — the root program declares --base-url and --api-key
@@ -140,6 +147,17 @@ export function makeContextCommand() {
140
147
  if (contexts[name]) {
141
148
  throw new Error(`Context "${name}" already exists. Delete it first or choose a different name.`);
142
149
  }
150
+ // The same browser login as `auth login`, written into the new context
151
+ // instead of the active one. Only the first context becomes active, as
152
+ // with an API key below: creating one is not switching to it.
153
+ if (!opts.apiKey && !opts.manual) {
154
+ const baseUrl = opts.baseUrl ?? DEFAULT_BASE_URL;
155
+ const authUrl = stripTrailingSlash(opts.authUrl ?? defaultAuthUrlFor(baseUrl));
156
+ const activate = getContextCount() === 0;
157
+ console.log(chalk.dim(`Auth service: ${authUrl} (override with --auth-url)`));
158
+ await runAction(command, () => deviceFlowLogin({ contextName: name, baseUrl, authUrl, activate }, deviceDeps));
159
+ return;
160
+ }
143
161
  let baseUrl = opts.baseUrl;
144
162
  let apiKey = opts.apiKey;
145
163
  if (!baseUrl || !apiKey) {
@@ -1,8 +1,15 @@
1
1
  import { Command } from "commander";
2
2
  import { getClient, runAction } from "../lib/client.js";
3
+ import { resolveConfig } from "../lib/config.js";
3
4
  import { CliUsageError } from "../lib/errors.js";
4
5
  import { formatPage, formatDetail, formatList, formatSuccess } from "../lib/output.js";
5
6
  import { addPaginationOptions, paginationParams } from "../lib/pagination.js";
7
+ /**
8
+ * Switching member sign-in on or off is a decision a signed-in person makes: the backend refuses an
9
+ * API key, however admin it is, so the command says so before it sends anything (see `SESSION_ONLY`
10
+ * in connectors.ts for the same wording).
11
+ */
12
+ const SESSION_ONLY = "Signed-in admins only: run 2kw auth login and sign in through the browser; an API key is refused";
6
13
  const MIN_MONTHLY_PERCENT = 0;
7
14
  const MAX_MONTHLY_PERCENT = 1000;
8
15
  /**
@@ -217,6 +224,28 @@ export function makeInstallationsCommand() {
217
224
  });
218
225
  });
219
226
  cmd.addCommand(budget);
227
+ cmd
228
+ .command("member-login")
229
+ .description(`Allow or stop members signing in to this installation's app as themselves. Stopping signs everyone out of it. ${SESSION_ONLY}`)
230
+ .argument("<id>", "Installation ID")
231
+ .option("--enable", "Allow members to sign in")
232
+ .option("--disable", "Stop member sign-in and sign everyone out of the app")
233
+ .action(async (id, opts, command) => {
234
+ await runAction(command, async () => {
235
+ if (Boolean(opts.enable) === Boolean(opts.disable)) {
236
+ throw new CliUsageError("pass exactly one of --enable and --disable");
237
+ }
238
+ if (resolveConfig(command).kind === "apiKey") {
239
+ throw new CliUsageError(SESSION_ONLY);
240
+ }
241
+ const client = getClient(command);
242
+ const { data } = await client.PUT("/v1/installations/{id}/member-login", {
243
+ params: { path: { id } },
244
+ body: { enabled: Boolean(opts.enable) },
245
+ });
246
+ formatDetail(data, command);
247
+ });
248
+ });
220
249
  return cmd;
221
250
  }
222
251
  //# sourceMappingURL=installations.js.map
@@ -0,0 +1,15 @@
1
+ import type { AgentDTO, ApiClient } from "./agent-lookup.js";
2
+ export interface PublishedHead {
3
+ versionId: string | undefined;
4
+ version: number | null;
5
+ }
6
+ /**
7
+ * The head of a just-created agent. A backend that publishes version 1 on create (#668) answers with
8
+ * `latestVersionId`: use it. An older backend leaves the agent unpublished, so publish with
9
+ * `POST /v1/agents/{agentId}/versions` as before. One path, correct against either backend, so neither
10
+ * deploy order nor a backend rollback leaves a new agent unrunnable.
11
+ */
12
+ export declare function ensurePublished(client: ApiClient, created: AgentDTO, versionBody: Record<string, unknown>): Promise<PublishedHead & {
13
+ fallback: boolean;
14
+ }>;
15
+ //# sourceMappingURL=agent-publish.d.ts.map
@@ -0,0 +1,18 @@
1
+ /**
2
+ * The head of a just-created agent. A backend that publishes version 1 on create (#668) answers with
3
+ * `latestVersionId`: use it. An older backend leaves the agent unpublished, so publish with
4
+ * `POST /v1/agents/{agentId}/versions` as before. One path, correct against either backend, so neither
5
+ * deploy order nor a backend rollback leaves a new agent unrunnable.
6
+ */
7
+ export async function ensurePublished(client, created, versionBody) {
8
+ if (created.latestVersionId) {
9
+ return { versionId: created.latestVersionId, version: created.latestVersionNumber ?? 1, fallback: false };
10
+ }
11
+ const { data: v } = await client.POST("/v1/agents/{agentId}/versions", {
12
+ params: { path: { agentId: String(created.id) } },
13
+ // eslint-disable-next-line @typescript-eslint/no-explicit-any
14
+ body: versionBody,
15
+ });
16
+ return { versionId: v?.id, version: v?.versionNumber ?? null, fallback: true };
17
+ }
18
+ //# sourceMappingURL=agent-publish.js.map
@@ -156,7 +156,8 @@ function relayDecideCommand(ref, responseId, calls, withApprovals) {
156
156
  /**
157
157
  * A connector tool's open approval, decoded from the standard `mcp_approval_request` item (#949). The item
158
158
  * is the MCP twin of `backbone:approval_request` (#805): it names the server and the tool, carries no call
159
- * id, policy class or status, and exists only while the decision is open, so its id doubles as the call id
159
+ * id or status (its policy class is an optional extension, #1084; "unknown" without it), and exists only
160
+ * while the decision is open, so its id doubles as the call id
160
161
  * and the tool is named as the approval row stores it, `mcp__<server_label>__<tool>`.
161
162
  */
162
163
  function connectorApproval(i) {
@@ -165,7 +166,7 @@ function connectorApproval(i) {
165
166
  callId: String(i.id),
166
167
  tool: `mcp__${String(i.server_label)}__${String(i.name)}`,
167
168
  arguments: parseArguments(i.arguments),
168
- policyClass: "unknown",
169
+ policyClass: typeof i.policy_class === "string" ? i.policy_class : "unknown",
169
170
  reason: null,
170
171
  preview: null,
171
172
  };
@@ -94,6 +94,7 @@ const HINTS = {
94
94
  429: "Rate limit exceeded. Wait a moment and try again.",
95
95
  };
96
96
  const PENDING_APPROVALS_HINT = "The approval was already decided or its response was superseded. See what is still pending: 2kw agents approvals --agent <agent-id> --status pending";
97
+ const NOT_PUBLISHED_HINT = "The agent has no published version. Run: 2kw agents apply -f agent.yaml (or 2kw agents versions create).";
97
98
  const CODE_HINTS = {
98
99
  approval_hmac_mismatch: PENDING_APPROVALS_HINT,
99
100
  unknown_approval_id: PENDING_APPROVALS_HINT,
@@ -115,6 +116,10 @@ export function hintFor(err) {
115
116
  }
116
117
  if (err.code && CODE_HINTS[err.code])
117
118
  return CODE_HINTS[err.code];
119
+ // The gateway answers `agent_not_published` (code), the REST layer AGENT_NOT_PUBLISHED (errorCode).
120
+ if (err.code === "agent_not_published" || err.errorCode === "AGENT_NOT_PUBLISHED") {
121
+ return NOT_PUBLISHED_HINT;
122
+ }
118
123
  // The 403 whose cause is the credential, not the role (#805). Without this the
119
124
  // generic 403 hint tells an organization admin to check a role that is already
120
125
  // right, and no role would ever have fixed it. Matched on the backend's error code,
@@ -125,7 +130,7 @@ export function hintFor(err) {
125
130
  // Anchored on the agent service's wording: prompts and schemas send the same
126
131
  // "Label '<x>' not found" prefix, and must keep the generic 404 hint.
127
132
  if (err.status === 404 && /^Label 'latest' not found (on agent|for this agent)/.test(err.message)) {
128
- return "The agent has no published version. Run: 2kw agents apply -f agent.yaml (or 2kw agents versions create).";
133
+ return NOT_PUBLISHED_HINT;
129
134
  }
130
135
  if (err.status === 404 && /^Label '.+' not found (on agent|for this agent)/.test(err.message)) {
131
136
  return "That label does not exist on the agent. List labels: 2kw agents labels list --agent <agent-id>";
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@2kw/ai",
3
- "version": "7.1.0-dev.1",
3
+ "version": "7.1.0-dev.16",
4
4
  "description": "CLI for 2kw.ai — schema-driven document extraction, an OpenAI-compatible EU LLM gateway, transcription, prompts, datasets, and experiments from your terminal or agentic workflows. Ships as 2kw, backbone, and bb.",
5
5
  "keywords": [
6
6
  "cli",