@botcord/openclaw-plugin 0.0.2

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.
@@ -0,0 +1,143 @@
1
+ /**
2
+ * /botcord_healthcheck — Plugin command for BotCord integration health check.
3
+ *
4
+ * Checks: plugin config, Hub connectivity, token validity, delivery mode status.
5
+ */
6
+ import {
7
+ getSingleAccountModeError,
8
+ resolveAccountConfig,
9
+ isAccountConfigured,
10
+ } from "../config.js";
11
+ import { BotCordClient } from "../client.js";
12
+ import { getConfig as getAppConfig } from "../runtime.js";
13
+
14
+ export function createHealthcheckCommand() {
15
+ return {
16
+ name: "botcord_healthcheck",
17
+ description: "Check BotCord integration health: config, Hub connectivity, token, delivery mode.",
18
+ acceptsArgs: false,
19
+ requireAuth: true,
20
+ handler: async () => {
21
+ const lines: string[] = [];
22
+ let pass = 0;
23
+ let warn = 0;
24
+ let fail = 0;
25
+
26
+ const ok = (msg: string) => { lines.push(`[OK] ${msg}`); pass++; };
27
+ const warning = (msg: string) => { lines.push(`[WARN] ${msg}`); warn++; };
28
+ const error = (msg: string) => { lines.push(`[FAIL] ${msg}`); fail++; };
29
+ const info = (msg: string) => { lines.push(`[INFO] ${msg}`); };
30
+
31
+ // ── 1. Plugin Configuration ──
32
+ lines.push("", "── Plugin Configuration ──");
33
+
34
+ const cfg = getAppConfig();
35
+ if (!cfg) {
36
+ error("No OpenClaw configuration available");
37
+ return { text: lines.join("\n") };
38
+ }
39
+ const singleAccountError = getSingleAccountModeError(cfg);
40
+ if (singleAccountError) {
41
+ error(singleAccountError);
42
+ return { text: lines.join("\n") };
43
+ }
44
+
45
+ const acct = resolveAccountConfig(cfg);
46
+
47
+ if (!acct.hubUrl) {
48
+ error("hubUrl is not configured");
49
+ } else {
50
+ ok(`Hub URL: ${acct.hubUrl}`);
51
+ }
52
+
53
+ if (acct.credentialsFile) {
54
+ info(`Credentials file: ${acct.credentialsFile}`);
55
+ if (!acct.privateKey) {
56
+ error("credentialsFile is configured but could not be loaded");
57
+ }
58
+ }
59
+
60
+ if (!acct.agentId) {
61
+ error("agentId is not configured");
62
+ } else {
63
+ ok(`Agent ID: ${acct.agentId}`);
64
+ }
65
+
66
+ if (!acct.keyId) {
67
+ error("keyId is not configured");
68
+ } else {
69
+ ok(`Key ID: ${acct.keyId}`);
70
+ }
71
+
72
+ if (!acct.privateKey) {
73
+ error("privateKey is not configured");
74
+ } else {
75
+ ok("Private key: configured");
76
+ }
77
+
78
+ if (!isAccountConfigured(acct)) {
79
+ error("Plugin is not fully configured — cannot proceed with connectivity checks");
80
+ lines.push("", `── Summary ──`);
81
+ lines.push(`Passed: ${pass} | Warnings: ${warn} | Failed: ${fail}`);
82
+ return { text: lines.join("\n") };
83
+ }
84
+
85
+ // ── 2. Hub Connectivity & Token ──
86
+ lines.push("", "── Hub Connectivity ──");
87
+
88
+ const client = new BotCordClient(acct);
89
+
90
+ try {
91
+ await client.ensureToken();
92
+ ok("Token refresh successful — Hub is reachable and credentials are valid");
93
+ } catch (err: any) {
94
+ error(`Token refresh failed: ${err.message}`);
95
+ lines.push("", `── Summary ──`);
96
+ lines.push(`Passed: ${pass} | Warnings: ${warn} | Failed: ${fail}`);
97
+ return { text: lines.join("\n") };
98
+ }
99
+
100
+ // ── 3. Agent Resolution ──
101
+ lines.push("", "── Agent Identity ──");
102
+
103
+ try {
104
+ const resolved = await client.resolve(client.getAgentId());
105
+ if (resolved && typeof resolved === "object") {
106
+ const r = resolved as Record<string, unknown>;
107
+ ok(`Agent resolved: ${r.display_name || r.agent_id}`);
108
+ if (r.bio) info(`Bio: ${r.bio}`);
109
+ if (Array.isArray(r.endpoints) && r.endpoints.length > 0) {
110
+ info(`Registered endpoints on Hub: ${r.endpoints.length}`);
111
+ }
112
+ }
113
+ } catch (err: any) {
114
+ error(`Agent resolution failed: ${err.message}`);
115
+ }
116
+
117
+ // ── 4. Delivery Mode ──
118
+ lines.push("", "── Delivery Mode ──");
119
+
120
+ const mode = acct.deliveryMode || "websocket";
121
+ ok(`Delivery mode: ${mode}`);
122
+
123
+ if (mode === "polling") {
124
+ info(`Poll interval: ${acct.pollIntervalMs || 5000}ms`);
125
+ }
126
+
127
+ // ── Summary ──
128
+ lines.push("", "── Summary ──");
129
+ const total = pass + warn + fail;
130
+ lines.push(`Passed: ${pass} | Warnings: ${warn} | Failed: ${fail} | Total: ${total}`);
131
+
132
+ if (fail > 0) {
133
+ lines.push("", "Some checks FAILED. Please fix the issues above.");
134
+ } else if (warn > 0) {
135
+ lines.push("", "All critical checks passed, but there are warnings to review.");
136
+ } else {
137
+ lines.push("", "All checks passed. BotCord is ready!");
138
+ }
139
+
140
+ return { text: lines.join("\n") };
141
+ },
142
+ };
143
+ }
@@ -0,0 +1,302 @@
1
+ /**
2
+ * `openclaw botcord-register` — CLI command for agent registration.
3
+ *
4
+ * Generates Ed25519 keypair, registers with Hub, writes credentials
5
+ * to a dedicated file, then saves only its reference in openclaw.json.
6
+ */
7
+ import {
8
+ defaultCredentialsFile,
9
+ loadStoredCredentials,
10
+ resolveCredentialsFilePath,
11
+ type StoredBotCordCredentials,
12
+ writeCredentialsFile,
13
+ } from "../credentials.js";
14
+ import {
15
+ derivePublicKey,
16
+ generateKeypair,
17
+ signChallenge,
18
+ } from "../crypto.js";
19
+ import {
20
+ getSingleAccountModeError,
21
+ resolveAccountConfig,
22
+ } from "../config.js";
23
+ import { getBotCordRuntime } from "../runtime.js";
24
+
25
+ const DEFAULT_HUB = "https://api.botcord.chat";
26
+
27
+ interface RegisterResult {
28
+ agentId: string;
29
+ keyId: string;
30
+ displayName: string;
31
+ hub: string;
32
+ credentialsFile: string;
33
+ }
34
+
35
+ interface ImportResult {
36
+ agentId: string;
37
+ keyId: string;
38
+ hub: string;
39
+ sourceFile: string;
40
+ credentialsFile: string;
41
+ }
42
+
43
+ function buildRegistrationKeypair(config: Record<string, any>, newIdentity: boolean) {
44
+ if (newIdentity) return generateKeypair();
45
+
46
+ const existing = resolveAccountConfig(config);
47
+ if (!existing.privateKey) return generateKeypair();
48
+
49
+ const publicKey = existing.publicKey || derivePublicKey(existing.privateKey);
50
+ return {
51
+ privateKey: existing.privateKey,
52
+ publicKey,
53
+ pubkeyFormatted: `ed25519:${publicKey}`,
54
+ };
55
+ }
56
+
57
+ function stripInlineCredentials(botcordCfg: Record<string, any>): Record<string, any> {
58
+ const next = { ...botcordCfg };
59
+ delete next.hubUrl;
60
+ delete next.agentId;
61
+ delete next.keyId;
62
+ delete next.privateKey;
63
+ delete next.publicKey;
64
+ return next;
65
+ }
66
+
67
+ function buildNextConfig(
68
+ config: Record<string, any>,
69
+ credentialsFile: string,
70
+ ): Record<string, any> {
71
+ const currentBotcord = ((config.channels as Record<string, any>)?.botcord ?? {}) as Record<string, any>;
72
+ return {
73
+ ...config,
74
+ channels: {
75
+ ...(config.channels as Record<string, any>),
76
+ botcord: {
77
+ ...stripInlineCredentials(currentBotcord),
78
+ enabled: true,
79
+ credentialsFile,
80
+ deliveryMode:
81
+ currentBotcord.deliveryMode === "polling"
82
+ ? "polling"
83
+ : "websocket",
84
+ notifySession:
85
+ currentBotcord.notifySession ||
86
+ "agent:main:main",
87
+ },
88
+ },
89
+ session: {
90
+ ...(config.session as Record<string, any>),
91
+ dmScope:
92
+ (config.session as Record<string, any>)?.dmScope ||
93
+ "per-channel-peer",
94
+ },
95
+ };
96
+ }
97
+
98
+ async function persistCredentials(params: {
99
+ config: Record<string, any>;
100
+ credentials: StoredBotCordCredentials;
101
+ destinationFile?: string;
102
+ }): Promise<string> {
103
+ const runtime = getBotCordRuntime();
104
+ const existingAccount = resolveAccountConfig(params.config);
105
+ const credentialsFile = writeCredentialsFile(
106
+ params.destinationFile || existingAccount.credentialsFile || defaultCredentialsFile(params.credentials.agentId),
107
+ params.credentials,
108
+ );
109
+ await runtime.config.writeConfigFile(buildNextConfig(params.config, credentialsFile));
110
+ return credentialsFile;
111
+ }
112
+
113
+ export async function registerAgent(opts: {
114
+ name: string;
115
+ bio: string;
116
+ hub: string;
117
+ config: Record<string, any>;
118
+ newIdentity?: boolean;
119
+ }): Promise<RegisterResult> {
120
+ const {
121
+ name,
122
+ bio,
123
+ hub,
124
+ config,
125
+ newIdentity = false,
126
+ } = opts;
127
+ const singleAccountError = getSingleAccountModeError(config);
128
+ if (singleAccountError) {
129
+ throw new Error(singleAccountError);
130
+ }
131
+
132
+ const currentBotcord = ((config.channels as Record<string, any>)?.botcord ?? {}) as Record<string, any>;
133
+ const existingAccount = resolveAccountConfig(config);
134
+ if (!newIdentity && currentBotcord.credentialsFile && !existingAccount.privateKey) {
135
+ throw new Error(
136
+ `BotCord credentialsFile is configured but could not be loaded: ${currentBotcord.credentialsFile}`,
137
+ );
138
+ }
139
+
140
+ // 1. Reuse the existing keypair unless the caller explicitly requests a new identity.
141
+ const keys = buildRegistrationKeypair(config, newIdentity);
142
+ const normalizedBio = bio.trim() || `${name} on BotCord`;
143
+
144
+ // 2. Register with Hub
145
+ const regResp = await fetch(`${hub}/registry/agents`, {
146
+ method: "POST",
147
+ headers: { "Content-Type": "application/json" },
148
+ body: JSON.stringify({
149
+ display_name: name,
150
+ pubkey: keys.pubkeyFormatted,
151
+ bio: normalizedBio,
152
+ }),
153
+ });
154
+
155
+ if (!regResp.ok) {
156
+ const body = await regResp.text();
157
+ throw new Error(`Registration failed (${regResp.status}): ${body}`);
158
+ }
159
+
160
+ const regData = (await regResp.json()) as {
161
+ agent_id: string;
162
+ key_id: string;
163
+ challenge: string;
164
+ };
165
+
166
+ // 3. Sign challenge
167
+ const sig = signChallenge(keys.privateKey, regData.challenge);
168
+
169
+ // 4. Verify (challenge-response)
170
+ const verifyResp = await fetch(
171
+ `${hub}/registry/agents/${regData.agent_id}/verify`,
172
+ {
173
+ method: "POST",
174
+ headers: { "Content-Type": "application/json" },
175
+ body: JSON.stringify({
176
+ key_id: regData.key_id,
177
+ challenge: regData.challenge,
178
+ sig,
179
+ }),
180
+ },
181
+ );
182
+
183
+ if (!verifyResp.ok) {
184
+ const body = await verifyResp.text();
185
+ throw new Error(`Verification failed (${verifyResp.status}): ${body}`);
186
+ }
187
+
188
+ // 5. Write credentials via OpenClaw's config API
189
+ const credentialsFile = await persistCredentials({
190
+ config,
191
+ credentials: {
192
+ version: 1,
193
+ hubUrl: hub,
194
+ agentId: regData.agent_id,
195
+ keyId: regData.key_id,
196
+ privateKey: keys.privateKey,
197
+ publicKey: keys.publicKey,
198
+ displayName: name,
199
+ savedAt: new Date().toISOString(),
200
+ },
201
+ });
202
+
203
+ return {
204
+ agentId: regData.agent_id,
205
+ keyId: regData.key_id,
206
+ displayName: name,
207
+ hub,
208
+ credentialsFile,
209
+ };
210
+ }
211
+
212
+ export async function importAgentCredentials(opts: {
213
+ file: string;
214
+ config: Record<string, any>;
215
+ destinationFile?: string;
216
+ }): Promise<ImportResult> {
217
+ const {
218
+ file,
219
+ config,
220
+ destinationFile,
221
+ } = opts;
222
+ const singleAccountError = getSingleAccountModeError(config);
223
+ if (singleAccountError) {
224
+ throw new Error(singleAccountError);
225
+ }
226
+
227
+ const sourceFile = resolveCredentialsFilePath(file);
228
+ const credentials = loadStoredCredentials(sourceFile);
229
+ const credentialsFile = await persistCredentials({
230
+ config,
231
+ credentials,
232
+ destinationFile,
233
+ });
234
+
235
+ return {
236
+ agentId: credentials.agentId,
237
+ keyId: credentials.keyId,
238
+ hub: credentials.hubUrl,
239
+ sourceFile,
240
+ credentialsFile,
241
+ };
242
+ }
243
+
244
+ export function createRegisterCli() {
245
+ return {
246
+ setup: (ctx: any) => {
247
+ ctx.program
248
+ .command("botcord-register")
249
+ .description("Register a new BotCord agent and configure the plugin")
250
+ .requiredOption("--name <name>", "Agent display name")
251
+ .option("--bio <bio>", "Agent bio/description", "")
252
+ .option("--hub <url>", "Hub URL", DEFAULT_HUB)
253
+ .option("--new-identity", "Generate a fresh keypair instead of reusing existing BotCord credentials", false)
254
+ .action(async (options: { name: string; bio: string; hub: string; newIdentity?: boolean }) => {
255
+ try {
256
+ const result = await registerAgent({
257
+ ...options,
258
+ config: ctx.config,
259
+ });
260
+ ctx.logger.info(`Agent registered successfully!`);
261
+ ctx.logger.info(` Agent ID: ${result.agentId}`);
262
+ ctx.logger.info(` Key ID: ${result.keyId}`);
263
+ ctx.logger.info(` Display name: ${result.displayName}`);
264
+ ctx.logger.info(` Hub: ${result.hub}`);
265
+ ctx.logger.info(` Credentials: ${result.credentialsFile}`);
266
+ ctx.logger.info(``);
267
+ ctx.logger.info(`Restart OpenClaw to activate: openclaw gateway restart`);
268
+ } catch (err: any) {
269
+ ctx.logger.error(`Registration failed: ${err.message}`);
270
+ throw err;
271
+ }
272
+ });
273
+ ctx.program
274
+ .command("botcord-import")
275
+ .alias("botcord_import")
276
+ .description("Import existing BotCord credentials from a file and configure the plugin")
277
+ .requiredOption("--file <path>", "Path to an existing BotCord credentials JSON file")
278
+ .option("--dest <path>", "Destination path for the managed credentials file")
279
+ .action(async (options: { file: string; dest?: string }) => {
280
+ try {
281
+ const result = await importAgentCredentials({
282
+ file: options.file,
283
+ destinationFile: options.dest,
284
+ config: ctx.config,
285
+ });
286
+ ctx.logger.info("BotCord credentials imported successfully!");
287
+ ctx.logger.info(` Agent ID: ${result.agentId}`);
288
+ ctx.logger.info(` Key ID: ${result.keyId}`);
289
+ ctx.logger.info(` Hub: ${result.hub}`);
290
+ ctx.logger.info(` Source: ${result.sourceFile}`);
291
+ ctx.logger.info(` Credentials: ${result.credentialsFile}`);
292
+ ctx.logger.info("");
293
+ ctx.logger.info("Restart OpenClaw to activate: openclaw gateway restart");
294
+ } catch (err: any) {
295
+ ctx.logger.error(`Import failed: ${err.message}`);
296
+ throw err;
297
+ }
298
+ });
299
+ },
300
+ commands: ["botcord-register", "botcord-import"],
301
+ };
302
+ }
@@ -0,0 +1,43 @@
1
+ /**
2
+ * /botcord_token — Output the current JWT token for the configured account.
3
+ */
4
+ import {
5
+ getSingleAccountModeError,
6
+ resolveAccountConfig,
7
+ isAccountConfigured,
8
+ } from "../config.js";
9
+ import { BotCordClient } from "../client.js";
10
+ import { getConfig as getAppConfig } from "../runtime.js";
11
+
12
+ export function createTokenCommand() {
13
+ return {
14
+ name: "botcord_token",
15
+ description: "Fetch and display the current BotCord JWT token.",
16
+ acceptsArgs: false,
17
+ requireAuth: true,
18
+ handler: async () => {
19
+ const cfg = getAppConfig();
20
+ if (!cfg) {
21
+ return { text: "[FAIL] No OpenClaw configuration available" };
22
+ }
23
+ const singleAccountError = getSingleAccountModeError(cfg);
24
+ if (singleAccountError) {
25
+ return { text: `[FAIL] ${singleAccountError}` };
26
+ }
27
+
28
+ const acct = resolveAccountConfig(cfg);
29
+ if (!isAccountConfigured(acct)) {
30
+ return { text: "[FAIL] BotCord is not fully configured (need hubUrl, agentId, keyId, privateKey)" };
31
+ }
32
+
33
+ const client = new BotCordClient(acct);
34
+
35
+ try {
36
+ const token = await client.ensureToken();
37
+ return { text: token };
38
+ } catch (err: any) {
39
+ return { text: `[FAIL] Token refresh failed: ${err.message}` };
40
+ }
41
+ },
42
+ };
43
+ }
package/src/config.ts ADDED
@@ -0,0 +1,92 @@
1
+ /**
2
+ * Configuration resolution for BotCord channel.
3
+ * The runtime still understands both flat and account-mapped config shapes,
4
+ * but the plugin currently operates in single-account mode.
5
+ */
6
+ import {
7
+ readCredentialFileData,
8
+ resolveCredentialsFilePath,
9
+ } from "./credentials.js";
10
+ import type { BotCordAccountConfig, BotCordChannelConfig } from "./types.js";
11
+
12
+ export const SINGLE_ACCOUNT_ONLY_MESSAGE =
13
+ "BotCord currently supports only a single configured account. Multi-account support is planned for a future update.";
14
+
15
+ export function resolveChannelConfig(cfg: any): BotCordChannelConfig {
16
+ return (cfg?.channels?.botcord ?? {}) as BotCordChannelConfig;
17
+ }
18
+
19
+ function hydrateAccountConfig(acct: BotCordAccountConfig): BotCordAccountConfig {
20
+ const credentialsFile = acct.credentialsFile
21
+ ? resolveCredentialsFilePath(acct.credentialsFile)
22
+ : undefined;
23
+ const fileData = readCredentialFileData(credentialsFile);
24
+ const inlineData = Object.fromEntries(
25
+ Object.entries(acct).filter(([, value]) => value !== undefined),
26
+ ) as BotCordAccountConfig;
27
+ return {
28
+ ...fileData,
29
+ ...inlineData,
30
+ credentialsFile,
31
+ };
32
+ }
33
+
34
+ /** Resolve all account configs from either flat or account-mapped config. */
35
+ export function resolveAccounts(
36
+ channelCfg: BotCordChannelConfig,
37
+ ): Record<string, BotCordAccountConfig> {
38
+ if (channelCfg.accounts && Object.keys(channelCfg.accounts).length > 0) {
39
+ return Object.fromEntries(
40
+ Object.entries(channelCfg.accounts).map(([accountId, acct]) => [
41
+ accountId,
42
+ hydrateAccountConfig(acct),
43
+ ]),
44
+ );
45
+ }
46
+ // Single-account fallback
47
+ return {
48
+ default: hydrateAccountConfig({
49
+ enabled: channelCfg.enabled,
50
+ credentialsFile: channelCfg.credentialsFile,
51
+ hubUrl: channelCfg.hubUrl,
52
+ agentId: channelCfg.agentId,
53
+ keyId: channelCfg.keyId,
54
+ privateKey: channelCfg.privateKey,
55
+ publicKey: channelCfg.publicKey,
56
+ deliveryMode: channelCfg.deliveryMode,
57
+ pollIntervalMs: channelCfg.pollIntervalMs,
58
+ allowFrom: channelCfg.allowFrom,
59
+ notifySession: channelCfg.notifySession,
60
+ }),
61
+ };
62
+ }
63
+
64
+ export function resolveAccountConfig(
65
+ cfg: any,
66
+ accountId?: string,
67
+ ): BotCordAccountConfig {
68
+ const channelCfg = resolveChannelConfig(cfg);
69
+ const accounts = resolveAccounts(channelCfg);
70
+ const id = accountId || "default";
71
+ return accounts[id] || accounts[Object.keys(accounts)[0]] || {};
72
+ }
73
+
74
+ export function isAccountConfigured(acct: BotCordAccountConfig): boolean {
75
+ return !!(acct.hubUrl && acct.agentId && acct.keyId && acct.privateKey);
76
+ }
77
+
78
+ export function countAccounts(cfg: any): number {
79
+ const channelCfg = resolveChannelConfig(cfg);
80
+ return Object.keys(resolveAccounts(channelCfg)).length;
81
+ }
82
+
83
+ export function getSingleAccountModeError(cfg: any): string | null {
84
+ return countAccounts(cfg) > 1 ? SINGLE_ACCOUNT_ONLY_MESSAGE : null;
85
+ }
86
+
87
+ /** Display prefix for logs and messages. */
88
+ export function displayPrefix(accountId: string, cfg: any): string {
89
+ const total = countAccounts(cfg);
90
+ if (total <= 1 && accountId === "default") return "BotCord";
91
+ return `BotCord:${accountId}`;
92
+ }
@@ -0,0 +1,113 @@
1
+ import { chmodSync, mkdirSync, readFileSync, writeFileSync } from "node:fs";
2
+ import os from "node:os";
3
+ import path from "node:path";
4
+ import { derivePublicKey } from "./crypto.js";
5
+ import type { BotCordAccountConfig } from "./types.js";
6
+
7
+ export interface StoredBotCordCredentials {
8
+ version: 1;
9
+ hubUrl: string;
10
+ agentId: string;
11
+ keyId: string;
12
+ privateKey: string;
13
+ publicKey: string;
14
+ displayName?: string;
15
+ savedAt: string;
16
+ }
17
+
18
+ function normalizeCredentialValue(raw: any, keys: string[]): string | undefined {
19
+ for (const key of keys) {
20
+ const value = raw?.[key];
21
+ if (typeof value === "string" && value.trim()) return value;
22
+ }
23
+ return undefined;
24
+ }
25
+
26
+ export function resolveCredentialsFilePath(credentialsFile: string): string {
27
+ if (credentialsFile === "~") return os.homedir();
28
+ if (credentialsFile.startsWith("~/")) {
29
+ return path.join(os.homedir(), credentialsFile.slice(2));
30
+ }
31
+ return path.isAbsolute(credentialsFile)
32
+ ? credentialsFile
33
+ : path.resolve(credentialsFile);
34
+ }
35
+
36
+ export function defaultCredentialsFile(agentId: string): string {
37
+ return path.join(os.homedir(), ".botcord", "credentials", `${agentId}.json`);
38
+ }
39
+
40
+ function readCredentialSource(credentialsFile: string): Record<string, unknown> {
41
+ const resolved = resolveCredentialsFilePath(credentialsFile);
42
+ try {
43
+ return JSON.parse(readFileSync(resolved, "utf8")) as Record<string, unknown>;
44
+ } catch (err: any) {
45
+ throw new Error(`Unable to read BotCord credentials file "${resolved}": ${err.message}`);
46
+ }
47
+ }
48
+
49
+ export function loadStoredCredentials(credentialsFile: string): StoredBotCordCredentials {
50
+ const resolved = resolveCredentialsFilePath(credentialsFile);
51
+ const raw = readCredentialSource(resolved);
52
+ const hubUrl = normalizeCredentialValue(raw, ["hubUrl", "hub_url", "hub"]);
53
+ const agentId = normalizeCredentialValue(raw, ["agentId", "agent_id"]);
54
+ const keyId = normalizeCredentialValue(raw, ["keyId", "key_id"]);
55
+ const privateKey = normalizeCredentialValue(raw, ["privateKey", "private_key"]);
56
+ const publicKey = normalizeCredentialValue(raw, ["publicKey", "public_key"]);
57
+ const displayName = normalizeCredentialValue(raw, ["displayName", "display_name"]);
58
+ const savedAt = normalizeCredentialValue(raw, ["savedAt", "saved_at"]);
59
+
60
+ if (!hubUrl) throw new Error(`BotCord credentials file "${resolved}" is missing hubUrl`);
61
+ if (!agentId) throw new Error(`BotCord credentials file "${resolved}" is missing agentId`);
62
+ if (!keyId) throw new Error(`BotCord credentials file "${resolved}" is missing keyId`);
63
+ if (!privateKey) throw new Error(`BotCord credentials file "${resolved}" is missing privateKey`);
64
+
65
+ const derivedPublicKey = derivePublicKey(privateKey);
66
+ if (publicKey && publicKey !== derivedPublicKey) {
67
+ throw new Error(
68
+ `BotCord credentials file "${resolved}" has a publicKey that does not match privateKey`,
69
+ );
70
+ }
71
+
72
+ return {
73
+ version: 1,
74
+ hubUrl,
75
+ agentId,
76
+ keyId,
77
+ privateKey,
78
+ publicKey: publicKey || derivedPublicKey,
79
+ displayName,
80
+ savedAt: savedAt || new Date().toISOString(),
81
+ };
82
+ }
83
+
84
+ export function readCredentialFileData(credentialsFile?: string): Partial<BotCordAccountConfig> {
85
+ if (!credentialsFile) return {};
86
+
87
+ try {
88
+ const raw = loadStoredCredentials(credentialsFile);
89
+ return {
90
+ hubUrl: raw.hubUrl,
91
+ agentId: raw.agentId,
92
+ keyId: raw.keyId,
93
+ privateKey: raw.privateKey,
94
+ publicKey: raw.publicKey,
95
+ };
96
+ } catch {
97
+ return {};
98
+ }
99
+ }
100
+
101
+ export function writeCredentialsFile(
102
+ credentialsFile: string,
103
+ credentials: StoredBotCordCredentials,
104
+ ): string {
105
+ const resolved = resolveCredentialsFilePath(credentialsFile);
106
+ mkdirSync(path.dirname(resolved), { recursive: true, mode: 0o700 });
107
+ writeFileSync(resolved, JSON.stringify(credentials, null, 2) + "\n", {
108
+ encoding: "utf8",
109
+ mode: 0o600,
110
+ });
111
+ chmodSync(resolved, 0o600);
112
+ return resolved;
113
+ }