@kweaver-ai/kweaver-sdk 0.6.7 → 0.6.8
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 +3 -1
- package/README.zh.md +3 -1
- package/dist/auth/oauth.d.ts +58 -3
- package/dist/auth/oauth.js +102 -18
- package/dist/commands/agent-members.d.ts +68 -0
- package/dist/commands/agent-members.js +383 -0
- package/dist/commands/agent.js +4 -0
- package/dist/commands/auth.js +67 -30
- package/dist/commands/config.js +80 -28
- package/package.json +1 -1
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
|
|
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
|
|
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
|
|
package/dist/auth/oauth.d.ts
CHANGED
|
@@ -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
|
-
/**
|
|
31
|
-
export
|
|
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.
|
package/dist/auth/oauth.js
CHANGED
|
@@ -246,42 +246,94 @@ function extractRsaPublicKeyMaterialFromPageProps(pageProps) {
|
|
|
246
246
|
}
|
|
247
247
|
return deepFindSigninRsaMaterial(pageProps, 5, new Set());
|
|
248
248
|
}
|
|
249
|
-
/**
|
|
250
|
-
|
|
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
|
|
258
|
-
|
|
259
|
-
|
|
260
|
-
|
|
261
|
-
|
|
262
|
-
|
|
263
|
-
|
|
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
|
-
|
|
281
|
+
return null;
|
|
267
282
|
}
|
|
268
|
-
return null;
|
|
269
283
|
}
|
|
270
|
-
/**
|
|
271
|
-
|
|
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
|
|
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",
|
|
333
|
+
parts.push("--client-secret", q(clientSecret));
|
|
282
334
|
}
|
|
283
335
|
if (refreshToken) {
|
|
284
|
-
parts.push("--refresh-token",
|
|
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
|
-
|
|
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
|
+
}
|
package/dist/commands/agent.js
CHANGED
|
@@ -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)
|
package/dist/commands/auth.js
CHANGED
|
@@ -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
|
|
257
|
-
if (!
|
|
258
|
-
|
|
259
|
-
|
|
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 (!
|
|
262
|
-
console.error(
|
|
263
|
-
"
|
|
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: ${
|
|
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
|
|
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
|
|
507
|
-
if (!
|
|
508
|
-
|
|
509
|
-
|
|
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 (!
|
|
512
|
-
console.error(
|
|
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
|
|
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
|
-
|
|
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 (
|
|
524
|
-
|
|
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: ${
|
|
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}.`);
|
package/dist/commands/config.js
CHANGED
|
@@ -1,15 +1,7 @@
|
|
|
1
1
|
import { listBusinessDomains } from "../api/business-domains.js";
|
|
2
|
-
import {
|
|
3
|
-
|
|
4
|
-
|
|
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
|
|
33
|
-
if (!
|
|
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
|
|
31
|
+
const bdSource = process.env.KWEAVER_BUSINESS_DOMAIN
|
|
39
32
|
? "env"
|
|
40
33
|
: loadPlatformBusinessDomain(platform)
|
|
41
34
|
? "config"
|
|
42
35
|
: "default";
|
|
43
|
-
const
|
|
44
|
-
console.log(`Platform: ${platform}${
|
|
45
|
-
console.log(`Business Domain: ${bd} (${
|
|
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
|
|
55
|
-
if (!
|
|
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
|
-
|
|
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
|
|
65
|
-
if (!
|
|
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) =>
|
|
71
|
-
|
|
72
|
-
|
|
73
|
-
|
|
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
|
-
|
|
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
|
+
}
|
package/package.json
CHANGED