@kweaver-ai/kweaver-sdk 0.6.7 → 0.6.9

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
@@ -31,7 +31,9 @@ export KWEAVER_BASE_URL=https://your-kweaver-instance.com
31
31
  export KWEAVER_TOKEN=your-token
32
32
  ```
33
33
 
34
- With both set, API commands use that token even if you never ran `auth login`. You can also run **`kweaver auth status`**, **`kweaver auth whoami`** (supports `--json`), and **`kweaver config show`** when there is **no** current platform in `~/.kweaver/` the CLI decodes the token locally (JWT only). If the token is opaque, identity fields are omitted and a short hint is printed.
34
+ With both set, API commands use that token even if you never ran `auth login`. You can also run **`kweaver auth status`**, **`kweaver auth whoami`** (supports `--json`), and **`kweaver config show`** when there is **no** current platform in `~/.kweaver/`. In env-token mode, `whoami` resolves the bound identity from EACP `/api/eacp/v1/user/get` and prints `Type` (user/app), `User ID`, `Account` and `Name`; this works for both opaque and JWT tokens. If EACP is unreachable, the CLI falls back to local JWT decode and prints a short hint when the token is opaque.
35
+
36
+ `kweaver config list-bd` lists business domains for the current user. App (service) tokens are not bound to an end-user — when the backend rejects the call with `401 invalid user_id`, the CLI re-checks the token type via EACP and, if confirmed `type:"app"`, replaces the cryptic backend body with `This command does not support app accounts.`. Use a user token (interactive `auth login`) for user-bound endpoints.
35
37
 
36
38
  ### Business domain (platform)
37
39
 
package/README.zh.md CHANGED
@@ -31,7 +31,9 @@ export KWEAVER_BASE_URL=https://your-kweaver-instance.com
31
31
  export KWEAVER_TOKEN=your-token
32
32
  ```
33
33
 
34
- 两者同时设置时,即使未执行 `auth login`,业务命令也会使用该 token。若 **`~/.kweaver/` 无当前平台**,仍可使用 **`kweaver auth status`**、**`kweaver auth whoami`**(支持 `--json`)、**`kweaver config show`**:CLI 会在本地解 JWT 展示身份;若 token opaque,则省略身份字段并给出简短提示。
34
+ 两者同时设置时,即使未执行 `auth login`,业务命令也会使用该 token。若 **`~/.kweaver/` 无当前平台**,仍可使用 **`kweaver auth status`**、**`kweaver auth whoami`**(支持 `--json`)、**`kweaver config show`**。环境变量模式下,`whoami` 会通过 EACP `/api/eacp/v1/user/get` 在线获取身份并展示 `Type`(user/app)、`User ID`、`Account`、`Name`,对 opaque 与 JWT token 都生效;若 EACP 不可达,则回退到本地 JWT 解码,opaque token 会给出简短提示。
35
+
36
+ `kweaver config list-bd` 用于列出当前用户可访问的业务域。**应用(service)token 没有绑定终端用户**——当后端返回 `401 invalid user_id` 时,CLI 会再向 EACP 复核 token 类型,确认为 `type:"app"` 后将晦涩的后端原文替换为 `This command does not support app accounts.`。需要这个能力时请改用交互式 `auth login` 获得的用户 token。
35
37
 
36
38
  ### 业务域(平台配置)
37
39
 
@@ -27,18 +27,73 @@ export declare const DEFAULT_SIGNIN_RSA_MODULUS_HEX = "C1D9F84B95AF6B331FBA2D64D
27
27
  * Build an SPKI PEM from an RSA modulus (hex) and public exponent (default 65537 / 0x10001).
28
28
  */
29
29
  export declare function rsaModulusHexToSpkiPem(modulusHex: string, exponent?: number): string;
30
- /** POSIX shell single-quote escaping for copy-paste commands. */
31
- export declare function shellQuoteForShell(value: string): string;
30
+ /** Identity resolved from EACP `/api/eacp/v1/user/get` for the bound access token. */
31
+ export interface EacpUserInfo {
32
+ /** "user" — interactive end-user; "app" — service / application identity. */
33
+ type: "user" | "app";
34
+ /** EACP id (`userid` for users, `id` for apps). */
35
+ id: string;
36
+ /** Login name (e.g. "alice@example.com"). User tokens only. */
37
+ account?: string;
38
+ /** Display name — `visionName` for users, app name for apps. */
39
+ name?: string;
40
+ }
41
+ /**
42
+ * Probe EACP for the bound identity. Returns `null` on any failure (network,
43
+ * non-2xx, malformed JSON, unknown `type`). Callers must treat absence as a
44
+ * soft signal — this never throws and never blocks the user's actual command.
45
+ *
46
+ * The shape differs per token type, mirroring the EACP backend:
47
+ * - `type: "user"` → `{ userid, account, name, ... }`
48
+ * - `type: "app"` → `{ id, name }`
49
+ */
50
+ export declare function fetchEacpUserInfo(baseUrl: string, accessToken: string, tlsInsecure?: boolean): Promise<EacpUserInfo | null>;
51
+ /**
52
+ * Quote a value for safe copy-paste into a shell command.
53
+ *
54
+ * Strategy:
55
+ * - If the value only contains "shell-safe" characters, return it bare. This
56
+ * keeps the printed command portable across shells (issue #74: POSIX single
57
+ * quotes are literal in cmd.exe, so any quoting locks the line to one OS).
58
+ * - Otherwise the value contains characters the shell would interpret
59
+ * (space, `&`, `|`, `$`, `*`, ...), so we must quote per host shell:
60
+ * - win32 (cmd.exe / PowerShell): wrap in `"..."`; embedded `"` -> `""`
61
+ * - POSIX (bash/zsh/sh): wrap in `'...'`; embedded `'` -> `'\''`
62
+ *
63
+ * `platform` defaults to `process.platform`; passable for tests and for
64
+ * generating commands targeted at a specific shell.
65
+ */
66
+ export declare function shellQuoteForShell(value: string, platform?: NodeJS.Platform): string;
32
67
  /**
33
68
  * Build a one-line `kweaver auth login ...` command for headless / other machines.
34
69
  * Omits `--client-secret` when empty (PKCE-only client); headless refresh may still require a confidential client.
70
+ *
71
+ * `platform` defaults to `process.platform`; pass explicitly in tests or when
72
+ * generating a command meant for a different OS.
35
73
  */
36
- export declare function buildCopyCommand(baseUrl: string, clientId: string, clientSecret: string, refreshToken: string | undefined, tlsInsecure?: boolean): string;
74
+ export declare function buildCopyCommand(baseUrl: string, clientId: string, clientSecret: string, refreshToken: string | undefined, tlsInsecure?: boolean, platform?: NodeJS.Platform): string;
37
75
  /**
38
76
  * HTML shown after successful OAuth callback with a copyable headless login command.
39
77
  */
40
78
  export declare function buildCallbackHtml(copyCommand: string): string;
41
79
  export declare function normalizeBaseUrl(value: string): string;
80
+ /**
81
+ * Resolve the platform URL the CLI should act on, with explicit precedence:
82
+ *
83
+ * 1. **positional** — caller passed a URL/alias (always wins)
84
+ * 2. **env** — `KWEAVER_BASE_URL` is set (explicit user intent;
85
+ * wins over a stale `~/.kweaver/` saved session)
86
+ * 3. **saved** — `getCurrentPlatform()` from local config
87
+ *
88
+ * `source` lets callers print provenance without re-deriving it. This mirrors
89
+ * `ensureValidToken()` (which is already env-first), so introspection
90
+ * commands (`auth status`, `auth whoami`, `config show|list-bd|set-bd`) and
91
+ * data-path commands agree on which platform "current" means.
92
+ */
93
+ export declare function resolveActivePlatform(positional?: string | null): {
94
+ url: string;
95
+ source: "positional" | "env" | "saved";
96
+ } | null;
42
97
  /**
43
98
  * Temporarily disable TLS certificate verification for Node `fetch` (sets
44
99
  * NODE_TLS_REJECT_UNAUTHORIZED). Used for `--insecure` login and token refresh.
@@ -246,42 +246,94 @@ function extractRsaPublicKeyMaterialFromPageProps(pageProps) {
246
246
  }
247
247
  return deepFindSigninRsaMaterial(pageProps, 5, new Set());
248
248
  }
249
- /** Best-effort fetch of display name via EACP userinfo (ShareServer). */
250
- async function fetchDisplayName(baseUrl, accessToken, tlsInsecure) {
249
+ /**
250
+ * Probe EACP for the bound identity. Returns `null` on any failure (network,
251
+ * non-2xx, malformed JSON, unknown `type`). Callers must treat absence as a
252
+ * soft signal — this never throws and never blocks the user's actual command.
253
+ *
254
+ * The shape differs per token type, mirroring the EACP backend:
255
+ * - `type: "user"` → `{ userid, account, name, ... }`
256
+ * - `type: "app"` → `{ id, name }`
257
+ */
258
+ export async function fetchEacpUserInfo(baseUrl, accessToken, tlsInsecure) {
251
259
  try {
252
260
  const res = await runWithTlsInsecure(tlsInsecure, () => fetch(`${baseUrl}/api/eacp/v1/user/get`, {
253
261
  headers: { Authorization: `Bearer ${accessToken}`, Accept: "application/json" },
254
262
  }));
255
263
  if (!res.ok)
256
264
  return null;
257
- const info = (await res.json());
258
- if (typeof info.account === "string")
259
- return info.account;
260
- if (typeof info.name === "string")
261
- return info.name;
262
- if (typeof info.mail === "string")
263
- return info.mail;
265
+ const raw = (await res.json());
266
+ const type = raw.type;
267
+ if (type !== "user" && type !== "app")
268
+ return null;
269
+ const idCandidate = type === "user" ? raw.userid : raw.id;
270
+ const id = typeof idCandidate === "string" ? idCandidate : "";
271
+ if (!id)
272
+ return null;
273
+ return {
274
+ type,
275
+ id,
276
+ account: typeof raw.account === "string" ? raw.account : undefined,
277
+ name: typeof raw.name === "string" ? raw.name : undefined,
278
+ };
264
279
  }
265
280
  catch {
266
- /* Non-critical — displayName will be absent. */
281
+ return null;
267
282
  }
268
- return null;
269
283
  }
270
- /** POSIX shell single-quote escaping for copy-paste commands. */
271
- export function shellQuoteForShell(value) {
284
+ /** Best-effort fetch of display name via EACP userinfo (ShareServer). */
285
+ async function fetchDisplayName(baseUrl, accessToken, tlsInsecure) {
286
+ const info = await fetchEacpUserInfo(baseUrl, accessToken, tlsInsecure);
287
+ return info?.account ?? info?.name ?? null;
288
+ }
289
+ /**
290
+ * Characters that bash/zsh/sh AND cmd.exe/PowerShell all leave untouched when
291
+ * a value is written without surrounding quotes. Real-world OAuth values
292
+ * (URLs, UUID-like client IDs, base64url refresh tokens) live in this set, so
293
+ * we can emit them bare and the resulting command is portable across macOS,
294
+ * Linux, Windows cmd, and PowerShell — including the copy-mac-paste-to-windows
295
+ * (and the reverse) workflow.
296
+ */
297
+ const SHELL_SAFE_VALUE = /^[A-Za-z0-9._:/+=@-]+$/;
298
+ /**
299
+ * Quote a value for safe copy-paste into a shell command.
300
+ *
301
+ * Strategy:
302
+ * - If the value only contains "shell-safe" characters, return it bare. This
303
+ * keeps the printed command portable across shells (issue #74: POSIX single
304
+ * quotes are literal in cmd.exe, so any quoting locks the line to one OS).
305
+ * - Otherwise the value contains characters the shell would interpret
306
+ * (space, `&`, `|`, `$`, `*`, ...), so we must quote per host shell:
307
+ * - win32 (cmd.exe / PowerShell): wrap in `"..."`; embedded `"` -> `""`
308
+ * - POSIX (bash/zsh/sh): wrap in `'...'`; embedded `'` -> `'\''`
309
+ *
310
+ * `platform` defaults to `process.platform`; passable for tests and for
311
+ * generating commands targeted at a specific shell.
312
+ */
313
+ export function shellQuoteForShell(value, platform = process.platform) {
314
+ if (value !== "" && SHELL_SAFE_VALUE.test(value)) {
315
+ return value;
316
+ }
317
+ if (platform === "win32") {
318
+ return `"${value.replace(/"/g, `""`)}"`;
319
+ }
272
320
  return `'${value.replace(/'/g, `'\\''`)}'`;
273
321
  }
274
322
  /**
275
323
  * Build a one-line `kweaver auth login ...` command for headless / other machines.
276
324
  * Omits `--client-secret` when empty (PKCE-only client); headless refresh may still require a confidential client.
325
+ *
326
+ * `platform` defaults to `process.platform`; pass explicitly in tests or when
327
+ * generating a command meant for a different OS.
277
328
  */
278
- export function buildCopyCommand(baseUrl, clientId, clientSecret, refreshToken, tlsInsecure) {
279
- const parts = ["kweaver", "auth", "login", shellQuoteForShell(normalizeBaseUrl(baseUrl)), "--client-id", shellQuoteForShell(clientId)];
329
+ export function buildCopyCommand(baseUrl, clientId, clientSecret, refreshToken, tlsInsecure, platform = process.platform) {
330
+ const q = (v) => shellQuoteForShell(v, platform);
331
+ const parts = ["kweaver", "auth", "login", q(normalizeBaseUrl(baseUrl)), "--client-id", q(clientId)];
280
332
  if (clientSecret) {
281
- parts.push("--client-secret", shellQuoteForShell(clientSecret));
333
+ parts.push("--client-secret", q(clientSecret));
282
334
  }
283
335
  if (refreshToken) {
284
- parts.push("--refresh-token", shellQuoteForShell(refreshToken));
336
+ parts.push("--refresh-token", q(refreshToken));
285
337
  }
286
338
  if (tlsInsecure) {
287
339
  parts.push("--insecure");
@@ -351,6 +403,33 @@ function buildCallbackExchangeErrorHtml(message) {
351
403
  export function normalizeBaseUrl(value) {
352
404
  return value.replace(/\/+$/, "");
353
405
  }
406
+ /**
407
+ * Resolve the platform URL the CLI should act on, with explicit precedence:
408
+ *
409
+ * 1. **positional** — caller passed a URL/alias (always wins)
410
+ * 2. **env** — `KWEAVER_BASE_URL` is set (explicit user intent;
411
+ * wins over a stale `~/.kweaver/` saved session)
412
+ * 3. **saved** — `getCurrentPlatform()` from local config
413
+ *
414
+ * `source` lets callers print provenance without re-deriving it. This mirrors
415
+ * `ensureValidToken()` (which is already env-first), so introspection
416
+ * commands (`auth status`, `auth whoami`, `config show|list-bd|set-bd`) and
417
+ * data-path commands agree on which platform "current" means.
418
+ */
419
+ export function resolveActivePlatform(positional) {
420
+ if (positional) {
421
+ const url = /^https?:\/\//.test(positional) ? normalizeBaseUrl(positional) : positional;
422
+ return { url, source: "positional" };
423
+ }
424
+ const envRaw = process.env.KWEAVER_BASE_URL?.trim();
425
+ if (envRaw) {
426
+ return { url: normalizeBaseUrl(envRaw), source: "env" };
427
+ }
428
+ const saved = getCurrentPlatform();
429
+ if (saved)
430
+ return { url: saved, source: "saved" };
431
+ return null;
432
+ }
354
433
  /**
355
434
  * Temporarily disable TLS certificate verification for Node `fetch` (sets
356
435
  * NODE_TLS_REJECT_UNAUTHORIZED). Used for `--insecure` login and token refresh.
@@ -1653,7 +1732,12 @@ export function formatHttpError(error) {
1653
1732
  if (oauthMessage) {
1654
1733
  return `HTTP ${error.status} ${error.statusText}\n\n${oauthMessage}`;
1655
1734
  }
1656
- return `${error.message}\n${error.body}`.trim();
1735
+ const base = `${error.message}\n${error.body}`.trim();
1736
+ if ((error.status === 403 || error.status === 404) &&
1737
+ /BknBackend\.KnowledgeNetwork\.NotFound/.test(error.body)) {
1738
+ return `Hint: this is a "knowledge network not found" error (the kn-id does not exist), not a permission/auth issue. Verify the kn-id you passed.\n${base}`;
1739
+ }
1740
+ return base;
1657
1741
  }
1658
1742
  if (error instanceof NetworkRequestError) {
1659
1743
  return [
@@ -0,0 +1,68 @@
1
+ /**
2
+ * Pure helpers and orchestrators for managing agent member associations
3
+ * (skills, tools, mcps) via get → mutate(config) → update.
4
+ */
5
+ export interface MutationReport {
6
+ finalIds: string[];
7
+ added: string[];
8
+ alreadyAttached: string[];
9
+ removed: string[];
10
+ notAttached: string[];
11
+ }
12
+ export interface MutateConfigMembersInput {
13
+ config: Record<string, unknown>;
14
+ path: string[];
15
+ idField: string;
16
+ addIds: string[];
17
+ removeIds: string[];
18
+ }
19
+ export interface MutateConfigMembersResult {
20
+ newConfig: Record<string, unknown>;
21
+ report: MutationReport;
22
+ finalIds: string[];
23
+ }
24
+ export declare function mutateConfigMembers(input: MutateConfigMembersInput): MutateConfigMembersResult;
25
+ export interface MemberFetchResult {
26
+ exists: boolean;
27
+ published: boolean;
28
+ name?: string;
29
+ /** Optional free-form status label for `list` output; e.g. "published" | "draft" | "offline". */
30
+ status?: string;
31
+ }
32
+ export interface MemberSpec {
33
+ /** Human-readable noun used in error/warning messages. */
34
+ memberKind: string;
35
+ /** Path inside the agent `config` object where the member array lives. */
36
+ configPath: string[];
37
+ /** Key inside each array element that identifies the member. */
38
+ idField: string;
39
+ }
40
+ export interface AgentMembersDeps {
41
+ getAgent: (agentId: string) => Promise<string>;
42
+ updateAgent: (agentId: string, body: Record<string, unknown>) => Promise<string>;
43
+ fetchById: (id: string) => Promise<MemberFetchResult>;
44
+ }
45
+ export interface PatchAgentMembersInput {
46
+ agentId: string;
47
+ spec: MemberSpec;
48
+ addIds: string[];
49
+ removeIds: string[];
50
+ strict: boolean;
51
+ deps: AgentMembersDeps;
52
+ }
53
+ export interface PatchAgentMembersReport extends MutationReport {
54
+ warnings: string[];
55
+ }
56
+ export declare function patchAgentMembers(input: PatchAgentMembersInput): Promise<PatchAgentMembersReport>;
57
+ export interface ListAgentMembersInput {
58
+ agentId: string;
59
+ spec: MemberSpec;
60
+ deps: Pick<AgentMembersDeps, "getAgent" | "fetchById">;
61
+ }
62
+ export interface ListedMember {
63
+ id: string;
64
+ name: string | null;
65
+ status: string;
66
+ }
67
+ export declare function listAgentMembers(input: ListAgentMembersInput): Promise<ListedMember[]>;
68
+ export declare function runAgentSkillCommand(args: string[]): Promise<number>;
@@ -0,0 +1,383 @@
1
+ /**
2
+ * Pure helpers and orchestrators for managing agent member associations
3
+ * (skills, tools, mcps) via get → mutate(config) → update.
4
+ */
5
+ import { getAgent, updateAgent } from "../api/agent-list.js";
6
+ import { getSkill } from "../api/skills.js";
7
+ import { ensureValidToken, formatHttpError } from "../auth/oauth.js";
8
+ import { resolveBusinessDomain } from "../config/store.js";
9
+ /** Deep-clone a JSON-serializable object so mutations don't leak to callers. */
10
+ function clone(value) {
11
+ return JSON.parse(JSON.stringify(value));
12
+ }
13
+ /**
14
+ * Descend into `config` along `path`, creating empty objects and a terminal
15
+ * empty array along the way if any node is missing. Returns the terminal array.
16
+ */
17
+ function ensureArrayAtPath(root, path) {
18
+ let cursor = root;
19
+ for (let i = 0; i < path.length - 1; i += 1) {
20
+ const key = path[i];
21
+ const next = cursor[key];
22
+ if (next === undefined || next === null) {
23
+ cursor[key] = {};
24
+ }
25
+ else if (typeof next !== "object" || Array.isArray(next)) {
26
+ throw new Error(`Config path conflict at ${path.slice(0, i + 1).join(".")}: expected object, got ${Array.isArray(next) ? "array" : typeof next}`);
27
+ }
28
+ cursor = cursor[key];
29
+ }
30
+ const terminalKey = path[path.length - 1];
31
+ const terminal = cursor[terminalKey];
32
+ if (terminal === undefined || terminal === null) {
33
+ cursor[terminalKey] = [];
34
+ }
35
+ else if (!Array.isArray(terminal)) {
36
+ throw new Error(`Config path conflict at ${path.join(".")}: expected array, got ${typeof terminal}`);
37
+ }
38
+ return cursor[terminalKey];
39
+ }
40
+ export function mutateConfigMembers(input) {
41
+ if (input.path.length === 0) {
42
+ throw new Error("mutateConfigMembers: path must have at least one segment");
43
+ }
44
+ const newConfig = clone(input.config);
45
+ const arr = ensureArrayAtPath(newConfig, input.path);
46
+ const existingIds = arr.map((el) => String(el[input.idField] ?? ""));
47
+ const currentSet = new Set(existingIds);
48
+ const added = [];
49
+ const alreadyAttached = [];
50
+ for (const id of input.addIds) {
51
+ if (currentSet.has(id)) {
52
+ alreadyAttached.push(id);
53
+ }
54
+ else {
55
+ arr.push({ [input.idField]: id });
56
+ currentSet.add(id);
57
+ added.push(id);
58
+ }
59
+ }
60
+ const removeSet = new Set(input.removeIds);
61
+ const removed = [];
62
+ const notAttached = [];
63
+ if (removeSet.size > 0) {
64
+ const survivors = [];
65
+ const survivingIdSet = new Set();
66
+ for (const el of arr) {
67
+ const id = String(el[input.idField] ?? "");
68
+ if (removeSet.has(id)) {
69
+ if (!removed.includes(id))
70
+ removed.push(id);
71
+ continue;
72
+ }
73
+ survivors.push(el);
74
+ survivingIdSet.add(id);
75
+ }
76
+ for (const id of input.removeIds) {
77
+ if (!removed.includes(id) && !survivingIdSet.has(id)) {
78
+ notAttached.push(id);
79
+ }
80
+ }
81
+ arr.length = 0;
82
+ arr.push(...survivors);
83
+ }
84
+ const finalIds = arr.map((el) => String(el[input.idField] ?? ""));
85
+ return {
86
+ newConfig,
87
+ finalIds,
88
+ report: {
89
+ finalIds,
90
+ added,
91
+ alreadyAttached,
92
+ removed,
93
+ notAttached,
94
+ },
95
+ };
96
+ }
97
+ function mergeAgentBody(current, newConfig) {
98
+ return {
99
+ name: current.name,
100
+ profile: current.profile,
101
+ avatar_type: current.avatar_type,
102
+ avatar: current.avatar,
103
+ product_key: current.product_key,
104
+ config: newConfig,
105
+ };
106
+ }
107
+ export async function patchAgentMembers(input) {
108
+ const { agentId, spec, addIds, removeIds, strict, deps } = input;
109
+ const warnings = [];
110
+ // 1. validate (add only)
111
+ if (addIds.length > 0) {
112
+ const results = await Promise.all(addIds.map(async (id) => ({ id, info: await deps.fetchById(id) })));
113
+ const missing = results.filter((r) => !r.info.exists).map((r) => r.id);
114
+ if (missing.length > 0) {
115
+ throw new Error(`${spec.memberKind}(s) ${missing.join(", ")} not found (aborting, agent not modified)`);
116
+ }
117
+ const drafts = results.filter((r) => r.info.exists && !r.info.published).map((r) => r.id);
118
+ if (drafts.length > 0) {
119
+ if (strict) {
120
+ throw new Error(`${spec.memberKind}(s) ${drafts.join(", ")} are in draft status (aborted by --strict)`);
121
+ }
122
+ for (const id of drafts) {
123
+ warnings.push(`${spec.memberKind} ${id} is in draft status (use --strict to reject, or publish it first)`);
124
+ }
125
+ }
126
+ }
127
+ // 2. fetch current agent
128
+ const currentRaw = await deps.getAgent(agentId);
129
+ const current = JSON.parse(currentRaw);
130
+ const config = (current.config ?? {});
131
+ // 3. mutate
132
+ const { newConfig, report } = mutateConfigMembers({
133
+ config,
134
+ path: spec.configPath,
135
+ idField: spec.idField,
136
+ addIds,
137
+ removeIds,
138
+ });
139
+ // Short-circuit: no-op (skip the write if neither add nor remove changed anything)
140
+ const nothingChanged = report.added.length === 0 && report.removed.length === 0;
141
+ if (nothingChanged) {
142
+ return { ...report, warnings };
143
+ }
144
+ // 4. write
145
+ await deps.updateAgent(agentId, mergeAgentBody(current, newConfig));
146
+ // 5. report
147
+ return { ...report, warnings };
148
+ }
149
+ export async function listAgentMembers(input) {
150
+ const { agentId, spec, deps } = input;
151
+ const currentRaw = await deps.getAgent(agentId);
152
+ const current = JSON.parse(currentRaw);
153
+ const config = (current.config ?? {});
154
+ // Read (don't create) the path. If any segment is missing, result is empty.
155
+ let cursor = config;
156
+ for (const key of spec.configPath) {
157
+ if (cursor && typeof cursor === "object" && !Array.isArray(cursor) && key in cursor) {
158
+ cursor = cursor[key];
159
+ }
160
+ else {
161
+ return [];
162
+ }
163
+ }
164
+ if (!Array.isArray(cursor))
165
+ return [];
166
+ const ids = cursor.map((el) => String(el[spec.idField] ?? ""));
167
+ const results = await Promise.all(ids.map(async (id) => {
168
+ try {
169
+ const info = await deps.fetchById(id);
170
+ return {
171
+ id,
172
+ name: info.name ?? null,
173
+ status: info.status ?? (info.exists ? (info.published ? "published" : "unpublish") : "unknown"),
174
+ };
175
+ }
176
+ catch {
177
+ return { id, name: null, status: "unknown" };
178
+ }
179
+ }));
180
+ return results;
181
+ }
182
+ // ── Skill command handler ────────────────────────────────────────────────────
183
+ const SKILL_SPEC = {
184
+ memberKind: "skill",
185
+ configPath: ["skills", "skills"],
186
+ idField: "skill_id",
187
+ };
188
+ function parseWriteArgs(args, verb) {
189
+ const agentId = args[0];
190
+ if (!agentId || agentId.startsWith("-")) {
191
+ throw new Error(`Missing <agent-id> for ${verb}`);
192
+ }
193
+ const ids = [];
194
+ let strict = false;
195
+ let businessDomain = "";
196
+ for (let i = 1; i < args.length; i += 1) {
197
+ const arg = args[i];
198
+ if (arg === "--strict") {
199
+ strict = true;
200
+ continue;
201
+ }
202
+ if (arg === "-bd" || arg === "--biz-domain") {
203
+ businessDomain = args[i + 1] ?? "";
204
+ if (!businessDomain || businessDomain.startsWith("-")) {
205
+ throw new Error("Missing value for biz-domain flag");
206
+ }
207
+ i += 1;
208
+ continue;
209
+ }
210
+ if (arg.startsWith("-")) {
211
+ throw new Error(`Unsupported flag: ${arg}`);
212
+ }
213
+ ids.push(arg);
214
+ }
215
+ if (ids.length === 0) {
216
+ throw new Error(`Missing <member-id> for ${verb}`);
217
+ }
218
+ return { agentId, ids, strict, businessDomain };
219
+ }
220
+ function printReport(kind, agentId, report) {
221
+ for (const w of report.warnings)
222
+ process.stderr.write(`! ${w}\n`);
223
+ for (const id of report.added)
224
+ console.log(`✓ ${id} added`);
225
+ for (const id of report.alreadyAttached)
226
+ console.log(`• ${id} already attached (skipped)`);
227
+ for (const id of report.removed)
228
+ console.log(`✓ ${id} removed`);
229
+ for (const id of report.notAttached)
230
+ console.log(`• ${id} not attached (skipped)`);
231
+ console.log(`Agent ${agentId} now has ${report.finalIds.length} ${kind}(s) attached.`);
232
+ }
233
+ async function runSkillAdd(args) {
234
+ try {
235
+ const parsed = parseWriteArgs(args, "add");
236
+ const token = await ensureValidToken();
237
+ const businessDomain = parsed.businessDomain || resolveBusinessDomain();
238
+ const deps = {
239
+ getAgent: (id) => getAgent({ baseUrl: token.baseUrl, accessToken: token.accessToken, agentId: id, businessDomain }),
240
+ updateAgent: (id, body) => updateAgent({ baseUrl: token.baseUrl, accessToken: token.accessToken, agentId: id, body: JSON.stringify(body), businessDomain }),
241
+ fetchById: async (id) => {
242
+ try {
243
+ const info = await getSkill({ baseUrl: token.baseUrl, accessToken: token.accessToken, skillId: id, businessDomain });
244
+ return {
245
+ exists: true,
246
+ published: info.status === "published",
247
+ name: info.name,
248
+ status: info.status,
249
+ };
250
+ }
251
+ catch {
252
+ return { exists: false, published: false };
253
+ }
254
+ },
255
+ };
256
+ const report = await patchAgentMembers({
257
+ agentId: parsed.agentId,
258
+ spec: SKILL_SPEC,
259
+ addIds: parsed.ids,
260
+ removeIds: [],
261
+ strict: parsed.strict,
262
+ deps,
263
+ });
264
+ printReport("skill", parsed.agentId, report);
265
+ return 0;
266
+ }
267
+ catch (error) {
268
+ process.stderr.write(`✗ ${error instanceof Error ? error.message : String(error)}\n`);
269
+ return 1;
270
+ }
271
+ }
272
+ async function runSkillRemove(args) {
273
+ try {
274
+ const parsed = parseWriteArgs(args, "remove");
275
+ const token = await ensureValidToken();
276
+ const businessDomain = parsed.businessDomain || resolveBusinessDomain();
277
+ const deps = {
278
+ getAgent: (id) => getAgent({ baseUrl: token.baseUrl, accessToken: token.accessToken, agentId: id, businessDomain }),
279
+ updateAgent: (id, body) => updateAgent({ baseUrl: token.baseUrl, accessToken: token.accessToken, agentId: id, body: JSON.stringify(body), businessDomain }),
280
+ fetchById: async () => ({ exists: true, published: true }),
281
+ };
282
+ const report = await patchAgentMembers({
283
+ agentId: parsed.agentId,
284
+ spec: SKILL_SPEC,
285
+ addIds: [],
286
+ removeIds: parsed.ids,
287
+ strict: false,
288
+ deps,
289
+ });
290
+ printReport("skill", parsed.agentId, report);
291
+ return 0;
292
+ }
293
+ catch (error) {
294
+ process.stderr.write(`✗ ${error instanceof Error ? error.message : String(error)}\n`);
295
+ return 1;
296
+ }
297
+ }
298
+ async function runSkillList(args) {
299
+ const agentId = args[0];
300
+ if (!agentId || agentId.startsWith("-")) {
301
+ process.stderr.write("Missing <agent-id> for list\n");
302
+ return 1;
303
+ }
304
+ let pretty = true;
305
+ let businessDomain = "";
306
+ for (let i = 1; i < args.length; i += 1) {
307
+ const arg = args[i];
308
+ if (arg === "--pretty") {
309
+ pretty = true;
310
+ continue;
311
+ }
312
+ if (arg === "--compact") {
313
+ pretty = false;
314
+ continue;
315
+ }
316
+ if (arg === "-bd" || arg === "--biz-domain") {
317
+ businessDomain = args[i + 1] ?? "";
318
+ if (!businessDomain || businessDomain.startsWith("-")) {
319
+ process.stderr.write("Missing value for biz-domain flag\n");
320
+ return 1;
321
+ }
322
+ i += 1;
323
+ continue;
324
+ }
325
+ process.stderr.write(`Unsupported flag: ${arg}\n`);
326
+ return 1;
327
+ }
328
+ const token = await ensureValidToken();
329
+ businessDomain = businessDomain || resolveBusinessDomain();
330
+ const deps = {
331
+ getAgent: (id) => getAgent({ baseUrl: token.baseUrl, accessToken: token.accessToken, agentId: id, businessDomain }),
332
+ fetchById: async (id) => {
333
+ try {
334
+ const info = await getSkill({ baseUrl: token.baseUrl, accessToken: token.accessToken, skillId: id, businessDomain });
335
+ return { exists: true, published: info.status === "published", name: info.name, status: info.status };
336
+ }
337
+ catch {
338
+ return { exists: false, published: false };
339
+ }
340
+ },
341
+ };
342
+ try {
343
+ const rows = await listAgentMembers({ agentId, spec: SKILL_SPEC, deps });
344
+ const output = rows.map((r) => ({ skill_id: r.id, name: r.name, status: r.status }));
345
+ console.log(JSON.stringify(output, null, pretty ? 2 : 0));
346
+ return 0;
347
+ }
348
+ catch (error) {
349
+ process.stderr.write(`✗ ${error instanceof Error ? error.message : String(error)}\n`);
350
+ return 1;
351
+ }
352
+ }
353
+ export async function runAgentSkillCommand(args) {
354
+ const [verb, ...rest] = args;
355
+ if (!verb || verb === "--help" || verb === "-h") {
356
+ console.log(`kweaver agent skill
357
+
358
+ Subcommands:
359
+ add <agent-id> <skill-id>... [--strict] [-bd <bd>] Attach skills to an agent
360
+ remove <agent-id> <skill-id>... [-bd <bd>] Detach skills from an agent
361
+ list <agent-id> [--pretty|--compact] [-bd <bd>] List skills attached to an agent
362
+
363
+ Notes:
364
+ --strict On add, reject skills that exist but are not in 'published' status.
365
+ Default behaviour: warn and continue.
366
+ Dedupe is automatic for add; remove silently skips not-attached ids.`);
367
+ return 0;
368
+ }
369
+ try {
370
+ if (verb === "add")
371
+ return await runSkillAdd(rest);
372
+ if (verb === "remove")
373
+ return await runSkillRemove(rest);
374
+ if (verb === "list")
375
+ return await runSkillList(rest);
376
+ process.stderr.write(`Unknown agent skill subcommand: ${verb}\n`);
377
+ return 1;
378
+ }
379
+ catch (error) {
380
+ process.stderr.write(`${formatHttpError(error)}\n`);
381
+ return 1;
382
+ }
383
+ }
@@ -1,5 +1,6 @@
1
1
  import { ensureValidToken, formatHttpError, with401RefreshRetry } from "../auth/oauth.js";
2
2
  import { runAgentChatCommand } from "./agent-chat.js";
3
+ import { runAgentSkillCommand } from "./agent-members.js";
3
4
  import { listAgents, getAgent, getAgentByKey, createAgent, updateAgent, deleteAgent, publishAgent, unpublishAgent, listPersonalAgents, listPublishedAgentTemplates, getPublishedAgentTemplate, listAgentCategories, } from "../api/agent-list.js";
4
5
  import { listConversations, listMessages, getTracesByConversation } from "../api/conversations.js";
5
6
  import { fetchAgentInfo } from "../api/agent-chat.js";
@@ -516,6 +517,7 @@ Subcommands:
516
517
  unpublish <agent_id> Unpublish an agent
517
518
  chat <agent_id> Start interactive chat with an agent
518
519
  chat <agent_id> -m "message" Send a single message (non-interactive)
520
+ skill <verb> ... Manage skills attached to an agent (add/remove/list)
519
521
  sessions <agent_id> List all conversations for an agent
520
522
  history <agent_id> <conversation_id> Show message history for a conversation
521
523
  trace <agent_id> <conversation_id> Get trace data for a conversation`);
@@ -554,6 +556,8 @@ Subcommands:
554
556
  return runAgentPublishCommand(rest);
555
557
  if (subcommand === "unpublish")
556
558
  return runAgentUnpublishCommand(rest);
559
+ if (subcommand === "skill")
560
+ return runAgentSkillCommand(rest);
557
561
  return -1;
558
562
  };
559
563
  // Show subcommand-specific help inline (no retry needed)
@@ -2,7 +2,7 @@ import { isNoAuth } from "../config/no-auth.js";
2
2
  import { autoSelectBusinessDomain, clearPlatformSession, deletePlatform, deleteUser, getActiveUser, getConfigDir, getCurrentPlatform, getPlatformAlias, hasPlatform, listPlatforms, listUserProfiles, loadClientConfig, loadTokenConfig, loadUserTokenConfig, resolveBusinessDomain, resolvePlatformIdentifier, resolveUserId, saveNoAuthPlatform, setActiveUser, setCurrentPlatform, setPlatformAlias, } from "../config/store.js";
3
3
  import { decodeJwtPayload } from "../config/jwt.js";
4
4
  import { eacpModifyPassword } from "../auth/eacp-modify-password.js";
5
- import { buildCopyCommand, formatHttpError, InitialPasswordChangeRequiredError, normalizeBaseUrl, oauth2Login, oauth2PasswordSigninLogin, promptForUsername, promptForPassword, refreshTokenLogin, } from "../auth/oauth.js";
5
+ import { buildCopyCommand, fetchEacpUserInfo, formatHttpError, InitialPasswordChangeRequiredError, normalizeBaseUrl, oauth2Login, oauth2PasswordSigninLogin, promptForUsername, promptForPassword, refreshTokenLogin, resolveActivePlatform, } from "../auth/oauth.js";
6
6
  export async function runAuthCommand(args) {
7
7
  const target = args[0];
8
8
  const rest = args.slice(1);
@@ -54,7 +54,7 @@ Login options:
54
54
  return runAuthCommand([url, ...rest.slice(1)]);
55
55
  }
56
56
  if (target === "whoami") {
57
- return runAuthWhoamiCommand(rest);
57
+ return await runAuthWhoamiCommand(rest);
58
58
  }
59
59
  if (target === "export") {
60
60
  return runAuthExportCommand(rest);
@@ -253,22 +253,26 @@ Login options:
253
253
  if (target === "status") {
254
254
  const resolvedTarget = args[1] ? resolvePlatformIdentifier(args[1]) : undefined;
255
255
  const statusTarget = resolvedTarget && /^https?:\/\//.test(resolvedTarget) ? normalizeBaseUrl(resolvedTarget) : resolvedTarget ?? undefined;
256
- const platform = statusTarget ?? getCurrentPlatform();
257
- if (!platform) {
258
- const envRaw = process.env.KWEAVER_BASE_URL?.trim();
259
- const envUrl = envRaw ? normalizeBaseUrl(envRaw) : undefined;
256
+ const active = resolveActivePlatform(statusTarget);
257
+ if (!active) {
258
+ console.error("No active platform. Run `kweaver auth login <platform-url>` first.\n" +
259
+ " Tip: set KWEAVER_BASE_URL and KWEAVER_TOKEN to use this command without a saved login.");
260
+ return 1;
261
+ }
262
+ if (active.source === "env") {
260
263
  const envToken = process.env.KWEAVER_TOKEN?.trim();
261
- if (!envUrl || !envToken) {
262
- console.error("No active platform. Run `kweaver auth login <platform-url>` first.\n" +
263
- " Tip: set KWEAVER_BASE_URL and KWEAVER_TOKEN to use this command without a saved login.");
264
+ if (!envToken) {
265
+ console.error(`KWEAVER_BASE_URL is set to ${active.url} but KWEAVER_TOKEN is missing. ` +
266
+ "Set KWEAVER_TOKEN, or unset KWEAVER_BASE_URL to fall back to the saved session.");
264
267
  return 1;
265
268
  }
266
269
  console.log(`Config directory: ${getConfigDir()}`);
267
- console.log(`Platform: ${envUrl} (KWEAVER_BASE_URL)`);
270
+ console.log(`Platform: ${active.url} (KWEAVER_BASE_URL)`);
268
271
  console.log(`Token present: yes (KWEAVER_TOKEN)`);
269
272
  console.log(`Refresh token: n/a (env)`);
270
273
  return 0;
271
274
  }
275
+ const platform = active.url;
272
276
  const token = loadTokenConfig(platform);
273
277
  if (!token) {
274
278
  console.error(statusTarget ? `No saved token for ${statusTarget}.` : "No saved token found.");
@@ -490,11 +494,13 @@ You can specify either the userId (sub claim) or the username (preferred_usernam
490
494
  console.log(`Switched to user ${resolvedId}${displayName} on ${platform}`);
491
495
  return 0;
492
496
  }
493
- function runAuthWhoamiCommand(args) {
497
+ async function runAuthWhoamiCommand(args) {
494
498
  if (args[0] === "--help" || args[0] === "-h") {
495
499
  console.log(`kweaver auth whoami [platform-url|alias] [--json]
496
500
 
497
- Show current user identity decoded from the saved id_token.
501
+ Show current user identity. For env-token mode (KWEAVER_TOKEN), the bound
502
+ identity is resolved live from EACP /api/eacp/v1/user/get; for saved sessions
503
+ it is decoded from the local id_token.
498
504
 
499
505
  Options:
500
506
  --json Output as JSON (machine-readable)`);
@@ -503,40 +509,71 @@ Options:
503
509
  const jsonOutput = args.includes("--json");
504
510
  const positional = args.find((a) => !a.startsWith("-"));
505
511
  const resolved = positional ? resolvePlatformIdentifier(positional) : null;
506
- const platform = resolved && /^https?:\/\//.test(resolved) ? normalizeBaseUrl(resolved) : resolved ?? getCurrentPlatform();
507
- if (!platform) {
508
- const envRaw = process.env.KWEAVER_BASE_URL?.trim();
509
- const envUrl = envRaw ? normalizeBaseUrl(envRaw) : undefined;
512
+ const active = resolveActivePlatform(resolved);
513
+ if (!active) {
514
+ console.error("No active platform. Run `kweaver auth login <platform-url>` first.");
515
+ return 1;
516
+ }
517
+ // Env mode requires both URL and token. resolveActivePlatform() returns
518
+ // source="env" as soon as KWEAVER_BASE_URL is set; if KWEAVER_TOKEN is
519
+ // missing we cannot inspect identity at all, so guide the user explicitly.
520
+ if (active.source === "env") {
510
521
  const envToken = process.env.KWEAVER_TOKEN?.trim();
511
- if (!envUrl || !envToken) {
512
- console.error("No active platform. Run `kweaver auth login <platform-url>` first.");
522
+ if (!envToken) {
523
+ console.error(`KWEAVER_BASE_URL is set to ${active.url} but KWEAVER_TOKEN is missing. ` +
524
+ "Set KWEAVER_TOKEN, or unset KWEAVER_BASE_URL to fall back to the saved session.");
513
525
  return 1;
514
526
  }
515
527
  const accessToken = envToken.replace(/^Bearer\s+/i, "");
516
- const payload = decodeJwtPayload(accessToken);
528
+ const envUrl = active.url;
529
+ const userInfo = await fetchEacpUserInfo(envUrl, accessToken);
530
+ // Always decode the JWT in env mode so we can render Issuer/Issued/Expires
531
+ // alongside EACP's Type/Account/Name. The two carry different facts: EACP
532
+ // tells us *who* the token belongs to (works for opaque tokens too); the
533
+ // JWT claims tell us *when* it was issued and when it expires (only
534
+ // available when the token is a JWT). Showing both gives the user the
535
+ // complete picture without forcing them to pick a mode.
536
+ const jwtPayload = decodeJwtPayload(accessToken);
517
537
  if (jsonOutput) {
518
- console.log(JSON.stringify({ platform: envUrl, source: "env", ...(payload ?? {}) }, null, 2));
538
+ const out = { platform: envUrl, source: "env" };
539
+ if (userInfo)
540
+ out.userInfo = userInfo;
541
+ if (jwtPayload)
542
+ Object.assign(out, jwtPayload);
543
+ console.log(JSON.stringify(out, null, 2));
519
544
  return 0;
520
545
  }
521
546
  console.log(`Platform: ${envUrl}`);
522
547
  console.log(`Source: env (KWEAVER_TOKEN)`);
523
- if (payload) {
524
- const uname = payload.preferred_username ?? payload.name;
548
+ if (userInfo) {
549
+ console.log(`Type: ${userInfo.type}`);
550
+ console.log(`User ID: ${userInfo.id}`);
551
+ if (userInfo.account)
552
+ console.log(`Account: ${userInfo.account}`);
553
+ if (userInfo.name)
554
+ console.log(`Name: ${userInfo.name}`);
555
+ }
556
+ else if (jwtPayload) {
557
+ const uname = jwtPayload.preferred_username ?? jwtPayload.name;
525
558
  if (uname)
526
559
  console.log(`Username: ${uname}`);
527
- console.log(`User ID: ${payload.sub ?? "(unknown)"}`);
528
- console.log(`Issuer: ${payload.iss ?? "(unknown)"}`);
529
- if (payload.iat)
530
- console.log(`Issued: ${new Date(payload.iat * 1000).toISOString()}`);
531
- if (payload.exp)
532
- console.log(`Expires: ${new Date(payload.exp * 1000).toISOString()}`);
560
+ console.log(`User ID: ${jwtPayload.sub ?? "(unknown)"}`);
533
561
  }
534
562
  else {
535
- console.log(`User info unavailable: opaque access token.`);
536
- console.log(`Hint: run \`kweaver auth login ${envUrl}\` to obtain a full session.`);
563
+ console.log(`User info unavailable: opaque access token and EACP did not respond.`);
564
+ console.log(`Hint: run \`kweaver auth login ${envUrl}\` to obtain a full session, or check connectivity to ${envUrl}.`);
565
+ }
566
+ if (jwtPayload) {
567
+ if (jwtPayload.iss)
568
+ console.log(`Issuer: ${jwtPayload.iss}`);
569
+ if (jwtPayload.iat)
570
+ console.log(`Issued: ${new Date(jwtPayload.iat * 1000).toISOString()}`);
571
+ if (jwtPayload.exp)
572
+ console.log(`Expires: ${new Date(jwtPayload.exp * 1000).toISOString()}`);
537
573
  }
538
574
  return 0;
539
575
  }
576
+ const platform = active.url;
540
577
  const token = loadTokenConfig(platform);
541
578
  if (!token) {
542
579
  console.error(`No saved token for ${platform}.`);
@@ -1,15 +1,7 @@
1
1
  import { listBusinessDomains } from "../api/business-domains.js";
2
- import { normalizeBaseUrl, withTokenRetry } from "../auth/oauth.js";
3
- // Resolve platform URL: saved current platform > KWEAVER_BASE_URL (normalized to
4
- // match what `auth login` writes, so env users share the same platforms/<key>/ dir).
5
- function resolvePlatformUrl() {
6
- const saved = getCurrentPlatform();
7
- if (saved)
8
- return saved;
9
- const env = process.env.KWEAVER_BASE_URL?.trim();
10
- return env ? normalizeBaseUrl(env) : undefined;
11
- }
12
- import { getCurrentPlatform, loadPlatformBusinessDomain, resolveBusinessDomain, savePlatformBusinessDomain, } from "../config/store.js";
2
+ import { fetchEacpUserInfo, resolveActivePlatform, withTokenRetry } from "../auth/oauth.js";
3
+ import { HttpError } from "../utils/http.js";
4
+ import { loadPlatformBusinessDomain, resolveBusinessDomain, savePlatformBusinessDomain, } from "../config/store.js";
13
5
  const HELP = `kweaver config
14
6
 
15
7
  Subcommands:
@@ -29,20 +21,21 @@ export async function runConfigCommand(args) {
29
21
  return 0;
30
22
  }
31
23
  if (sub === "show") {
32
- const platform = resolvePlatformUrl();
33
- if (!platform) {
24
+ const active = resolveActivePlatform();
25
+ if (!active) {
34
26
  console.error("No active platform. Run `kweaver auth login <url>` first.\n Tip: set KWEAVER_BASE_URL to use this command without a saved login.");
35
27
  return 1;
36
28
  }
29
+ const platform = active.url;
37
30
  const bd = resolveBusinessDomain(platform);
38
- const source = process.env.KWEAVER_BUSINESS_DOMAIN
31
+ const bdSource = process.env.KWEAVER_BUSINESS_DOMAIN
39
32
  ? "env"
40
33
  : loadPlatformBusinessDomain(platform)
41
34
  ? "config"
42
35
  : "default";
43
- const platformSource = getCurrentPlatform() ? "" : " (KWEAVER_BASE_URL)";
44
- console.log(`Platform: ${platform}${platformSource}`);
45
- console.log(`Business Domain: ${bd} (${source})`);
36
+ const platformSuffix = active.source === "env" ? " (KWEAVER_BASE_URL)" : "";
37
+ console.log(`Platform: ${platform}${platformSuffix}`);
38
+ console.log(`Business Domain: ${bd} (${bdSource})`);
46
39
  return 0;
47
40
  }
48
41
  if (sub === "set-bd") {
@@ -51,27 +44,36 @@ export async function runConfigCommand(args) {
51
44
  console.error("Usage: kweaver config set-bd <value>");
52
45
  return 1;
53
46
  }
54
- const platform = resolvePlatformUrl();
55
- if (!platform) {
47
+ const active = resolveActivePlatform();
48
+ if (!active) {
56
49
  console.error("No active platform. Run `kweaver auth login <url>` first.\n Tip: set KWEAVER_BASE_URL to write the business domain for that platform.");
57
50
  return 1;
58
51
  }
52
+ const platform = active.url;
59
53
  savePlatformBusinessDomain(platform, value);
60
- console.log(`Business domain set to: ${value} (${getCurrentPlatform() ? platform : `${platform} via KWEAVER_BASE_URL`})`);
54
+ const provenance = active.source === "env" ? `${platform} via KWEAVER_BASE_URL` : platform;
55
+ console.log(`Business domain set to: ${value} (${provenance})`);
61
56
  return 0;
62
57
  }
63
58
  if (sub === "list-bd") {
64
- const platform = resolvePlatformUrl();
65
- if (!platform) {
59
+ const active = resolveActivePlatform();
60
+ if (!active) {
66
61
  console.error("No active platform. Run `kweaver auth login <url>` first.\n Tip: set KWEAVER_BASE_URL and KWEAVER_TOKEN to use this command without a saved login.");
67
62
  return 1;
68
63
  }
64
+ const platform = active.url;
65
+ let lastAccessToken = "";
66
+ let lastTlsInsecure;
69
67
  try {
70
- const rows = await withTokenRetry((token) => listBusinessDomains({
71
- baseUrl: platform,
72
- accessToken: token.accessToken,
73
- tlsInsecure: token.tlsInsecure,
74
- }));
68
+ const rows = await withTokenRetry((token) => {
69
+ lastAccessToken = token.accessToken;
70
+ lastTlsInsecure = token.tlsInsecure;
71
+ return listBusinessDomains({
72
+ baseUrl: platform,
73
+ accessToken: token.accessToken,
74
+ tlsInsecure: token.tlsInsecure,
75
+ });
76
+ });
75
77
  const currentId = resolveBusinessDomain(platform);
76
78
  const payload = {
77
79
  currentId,
@@ -84,7 +86,12 @@ export async function runConfigCommand(args) {
84
86
  return 0;
85
87
  }
86
88
  catch (error) {
87
- const message = error instanceof Error ? error.message : String(error);
89
+ // Backend returns 401 + `invalid user_id` when the caller is an app
90
+ // (service) token with no bound user. Probe EACP to confirm — only swap
91
+ // the cryptic backend body for a one-liner when we can prove `type:"app"`.
92
+ // See kweaver-core#263.
93
+ const friendly = await maybeAppAccountMessage(error, platform, lastAccessToken, lastTlsInsecure);
94
+ const message = friendly ?? (error instanceof Error ? error.message : String(error));
88
95
  console.error(`Failed to list business domains: ${message}`);
89
96
  return 1;
90
97
  }
@@ -93,3 +100,48 @@ export async function runConfigCommand(args) {
93
100
  console.log(HELP);
94
101
  return 1;
95
102
  }
103
+ /**
104
+ * Detect "app account hit a user-scoped endpoint" by signature, then confirm
105
+ * with EACP. Returns a short user-facing message if the call really came from
106
+ * an app token, otherwise `null` (caller falls back to the original error).
107
+ *
108
+ * Two layers of evidence (signature + identity) keep us from probing EACP on
109
+ * every random failure and from mislabeling real auth problems.
110
+ */
111
+ async function maybeAppAccountMessage(error, baseUrl, accessToken, tlsInsecure) {
112
+ // Detect 401 either as a direct HttpError or via the wrapping Error's
113
+ // message ("Authentication failed (401)..." / "...status 401..."). We don't
114
+ // rely solely on the `cause` chain because withTokenRetry may attempt a
115
+ // token refresh and then wrap with the *refresh* error as cause, dropping
116
+ // the original list-bd HttpError.
117
+ if (!is401Error(error))
118
+ return null;
119
+ if (!accessToken)
120
+ return null;
121
+ const info = await fetchEacpUserInfo(baseUrl, accessToken, tlsInsecure);
122
+ if (info?.type !== "app")
123
+ return null;
124
+ return "This command does not support app accounts.";
125
+ }
126
+ /** True if the error or any cause looks like a 401 from the platform. */
127
+ function is401Error(error) {
128
+ const seen = new Set();
129
+ const queue = [error];
130
+ while (queue.length) {
131
+ const cur = queue.shift();
132
+ if (!cur || seen.has(cur))
133
+ continue;
134
+ seen.add(cur);
135
+ if (cur instanceof HttpError && cur.status === 401)
136
+ return true;
137
+ if (cur instanceof Error) {
138
+ if (/\b401\b/.test(cur.message))
139
+ return true;
140
+ if (cur.cause)
141
+ queue.push(cur.cause);
142
+ }
143
+ if (cur instanceof AggregateError)
144
+ queue.push(...cur.errors);
145
+ }
146
+ return false;
147
+ }
@@ -1,11 +1,11 @@
1
- import { ensureValidToken, formatHttpError, with401RefreshRetry } from "../auth/oauth.js";
1
+ import { ensureValidToken, formatHttpError, resolveActivePlatform, with401RefreshRetry } from "../auth/oauth.js";
2
2
  import { knSearch, knSchemaSearch, queryObjectInstance, queryInstanceSubgraph, getLogicPropertiesValues, getActionInfo, listTools, listResources, readResource, listResourceTemplates, listPrompts, getPrompt, } from "../api/context-loader.js";
3
- import { addContextLoaderEntry, getCurrentContextLoaderKn, getCurrentPlatform, loadContextLoaderConfig, removeContextLoaderEntry, setCurrentContextLoader, } from "../config/store.js";
3
+ import { addContextLoaderEntry, getCurrentContextLoaderKn, loadContextLoaderConfig, removeContextLoaderEntry, setCurrentContextLoader, } from "../config/store.js";
4
4
  const MCP_NOT_CONFIGURED = "Context-loader MCP is not configured. Run: kweaver context-loader config set --kn-id <kn-id>";
5
5
  function ensureContextLoaderConfig() {
6
- const platform = getCurrentPlatform();
7
- if (!platform) {
8
- throw new Error("No platform selected. Run: kweaver auth <platform-url>");
6
+ const active = resolveActivePlatform();
7
+ if (!active) {
8
+ throw new Error("No platform selected. Set KWEAVER_BASE_URL or run: kweaver auth <platform-url>");
9
9
  }
10
10
  const kn = getCurrentContextLoaderKn();
11
11
  if (!kn) {
@@ -118,11 +118,12 @@ Subcommands:
118
118
  show Show current config (knId + mcpUrl)`);
119
119
  return 0;
120
120
  }
121
- const platform = getCurrentPlatform();
122
- if (!platform) {
123
- console.error("No platform selected. Run: kweaver auth <platform-url>");
121
+ const active = resolveActivePlatform();
122
+ if (!active) {
123
+ console.error("No platform selected. Set KWEAVER_BASE_URL or run: kweaver auth <platform-url>");
124
124
  return 1;
125
125
  }
126
+ const platform = active.url;
126
127
  if (action === "show") {
127
128
  const kn = getCurrentContextLoaderKn();
128
129
  if (!kn) {
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@kweaver-ai/kweaver-sdk",
3
- "version": "0.6.7",
3
+ "version": "0.6.9",
4
4
  "description": "KWeaver TypeScript SDK — CLI tool and programmatic API for knowledge networks and Decision Agents.",
5
5
  "type": "module",
6
6
  "main": "./dist/index.js",