talon-agent 3.25.1 → 3.25.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.
- package/package.json +1 -1
- package/src/cli/config.ts +24 -0
- package/src/cli/menu.ts +6 -11
- package/src/cli/setup.ts +208 -36
package/package.json
CHANGED
package/src/cli/config.ts
CHANGED
|
@@ -18,6 +18,13 @@ export function printBanner(): void {
|
|
|
18
18
|
console.log();
|
|
19
19
|
}
|
|
20
20
|
|
|
21
|
+
/**
|
|
22
|
+
* The subset of ~/.talon/config.json the CLI reads or prompts for — a
|
|
23
|
+
* partial view, not the whole schema (that lives in util/config.ts).
|
|
24
|
+
* Anything absent here is still present at runtime on a loaded config and
|
|
25
|
+
* MUST be carried through on save: `talon setup` used to rebuild the file
|
|
26
|
+
* from its own named fields alone and silently dropped every other key.
|
|
27
|
+
*/
|
|
21
28
|
export type Config = {
|
|
22
29
|
frontend: string | string[];
|
|
23
30
|
/** Active backend (`claude` / `kilo` / `opencode` / `codex` / `openai-agents`). */
|
|
@@ -56,6 +63,17 @@ export type Config = {
|
|
|
56
63
|
adminUserIds?: string[];
|
|
57
64
|
[key: string]: unknown;
|
|
58
65
|
};
|
|
66
|
+
// WhatsApp — pairing + allowlists. Session credentials are not here;
|
|
67
|
+
// they live in ~/.talon/whatsapp-auth/.
|
|
68
|
+
whatsapp?: {
|
|
69
|
+
allowedJids?: string[];
|
|
70
|
+
allowedGroups?: string[];
|
|
71
|
+
groupPolicy?: "listed" | "with-allowed-user" | "all";
|
|
72
|
+
respondMode?: "mention" | "all";
|
|
73
|
+
pairingNumber?: string;
|
|
74
|
+
sendReadReceipts?: boolean;
|
|
75
|
+
[key: string]: unknown;
|
|
76
|
+
};
|
|
59
77
|
};
|
|
60
78
|
|
|
61
79
|
export const DEFAULTS: Config = {
|
|
@@ -99,6 +117,12 @@ export function isConfigured(config: Config): boolean {
|
|
|
99
117
|
if (fe === "telegram") return !!config.botToken;
|
|
100
118
|
if (fe === "teams") return !!config.teamsWebhookUrl;
|
|
101
119
|
if (fe === "discord") return !!config.discord?.botToken;
|
|
120
|
+
// WhatsApp's credentials live in ~/.talon/whatsapp-auth/, not the
|
|
121
|
+
// config — but the loader hard-requires the block, so its presence is
|
|
122
|
+
// what "configured" means here. Omitting this case sent every
|
|
123
|
+
// WhatsApp user through the fail-closed branch below and straight
|
|
124
|
+
// into the setup wizard on a plain `talon`.
|
|
125
|
+
if (fe === "whatsapp") return !!config.whatsapp;
|
|
102
126
|
// terminal (stdio) and native (the client bridge) carry no
|
|
103
127
|
// credentials; unknown names fail closed so a typo'd frontend sends
|
|
104
128
|
// the user to setup instead of a daemon that can't start.
|
package/src/cli/menu.ts
CHANGED
|
@@ -8,6 +8,7 @@ import pc from "picocolors";
|
|
|
8
8
|
import { existsSync } from "node:fs";
|
|
9
9
|
import { findRunningInstance } from "../core/daemon/discovery.js";
|
|
10
10
|
import { printBanner, loadConfig, isConfigured } from "./config.js";
|
|
11
|
+
import { getFrontendDescriptor } from "../core/frontend-runtime/routing.js";
|
|
11
12
|
import { CONFIG_FILE, PKG_ROOT } from "./context.js";
|
|
12
13
|
import { runSetup } from "./setup.js";
|
|
13
14
|
import { showStatus } from "./status.js";
|
|
@@ -36,18 +37,12 @@ export async function mainMenu(): Promise<void> {
|
|
|
36
37
|
const fes = Array.isArray(config.frontend)
|
|
37
38
|
? config.frontend
|
|
38
39
|
: [config.frontend];
|
|
40
|
+
// Labels come from the frontend registry, not a chain here. The old
|
|
41
|
+
// if/else fell through to "Terminal" for anything it didn't name, so a
|
|
42
|
+
// WhatsApp install read as "Terminal" in this header. An unknown id now
|
|
43
|
+
// shows itself rather than impersonating another frontend.
|
|
39
44
|
const frontendLabel = fes
|
|
40
|
-
.map((f) =>
|
|
41
|
-
f === "telegram"
|
|
42
|
-
? "Telegram"
|
|
43
|
-
: f === "teams"
|
|
44
|
-
? "Teams"
|
|
45
|
-
: f === "discord"
|
|
46
|
-
? "Discord"
|
|
47
|
-
: f === "native"
|
|
48
|
-
? "Native"
|
|
49
|
-
: "Terminal",
|
|
50
|
-
)
|
|
45
|
+
.map((f) => getFrontendDescriptor(f)?.label ?? f)
|
|
51
46
|
.join(" + ");
|
|
52
47
|
|
|
53
48
|
const action = await p.select({
|
package/src/cli/setup.ts
CHANGED
|
@@ -7,6 +7,14 @@ import * as p from "@clack/prompts";
|
|
|
7
7
|
import pc from "picocolors";
|
|
8
8
|
import { printBanner, loadConfig, saveConfig, type Config } from "./config.js";
|
|
9
9
|
|
|
10
|
+
/** "353871234567, +44 7700 900000" → ["353871234567", "447700900000"]. */
|
|
11
|
+
function parseNumberList(raw: string): string[] {
|
|
12
|
+
return raw
|
|
13
|
+
.split(",")
|
|
14
|
+
.map((entry) => entry.replace(/[\s+()-]/g, ""))
|
|
15
|
+
.filter((entry) => entry.length > 0);
|
|
16
|
+
}
|
|
17
|
+
|
|
10
18
|
export async function runSetup(): Promise<void> {
|
|
11
19
|
printBanner();
|
|
12
20
|
p.intro(pc.inverse(" Setup Wizard "));
|
|
@@ -24,6 +32,10 @@ export async function runSetup(): Promise<void> {
|
|
|
24
32
|
value: "telegram",
|
|
25
33
|
label: `Telegram ${pc.dim("— bot via @BotFather")}`,
|
|
26
34
|
},
|
|
35
|
+
{
|
|
36
|
+
value: "whatsapp",
|
|
37
|
+
label: `WhatsApp ${pc.dim("— a real account via Baileys multi-device")}`,
|
|
38
|
+
},
|
|
27
39
|
{
|
|
28
40
|
value: "discord",
|
|
29
41
|
label: `Discord ${pc.dim("— bot via Developer Portal (discord.js v14)")}`,
|
|
@@ -36,6 +48,10 @@ export async function runSetup(): Promise<void> {
|
|
|
36
48
|
value: "terminal",
|
|
37
49
|
label: `Terminal ${pc.dim("— local CLI chat")}`,
|
|
38
50
|
},
|
|
51
|
+
{
|
|
52
|
+
value: "native",
|
|
53
|
+
label: `Native ${pc.dim("— HTTP+SSE bridge for the companion app")}`,
|
|
54
|
+
},
|
|
39
55
|
],
|
|
40
56
|
required: true,
|
|
41
57
|
});
|
|
@@ -227,6 +243,75 @@ export async function runSetup(): Promise<void> {
|
|
|
227
243
|
discordApplicationId = appId as string;
|
|
228
244
|
}
|
|
229
245
|
|
|
246
|
+
let whatsapp: Config["whatsapp"] | undefined;
|
|
247
|
+
|
|
248
|
+
if (selectedFrontends.includes("whatsapp")) {
|
|
249
|
+
p.note(
|
|
250
|
+
"Talon drives a real WhatsApp account over Baileys multi-device —\n" +
|
|
251
|
+
"the same mechanism as WhatsApp Web. On first start you'll pair it\n" +
|
|
252
|
+
"under WhatsApp → Linked devices.",
|
|
253
|
+
"WhatsApp Setup",
|
|
254
|
+
);
|
|
255
|
+
|
|
256
|
+
const pairing = (await p.text({
|
|
257
|
+
message: "The bot account's own number (E.164 digits, no +)",
|
|
258
|
+
placeholder: "leave blank to pair by QR code instead",
|
|
259
|
+
initialValue: config.whatsapp?.pairingNumber || "",
|
|
260
|
+
validate: (v) => {
|
|
261
|
+
if (v && !/^\d{7,15}$/.test(v.replace(/[\s+()-]/g, "")))
|
|
262
|
+
return "Digits only, country code included — e.g. 353871234567";
|
|
263
|
+
},
|
|
264
|
+
})) as string;
|
|
265
|
+
if (p.isCancel(pairing)) {
|
|
266
|
+
p.cancel("Cancelled.");
|
|
267
|
+
process.exit(0);
|
|
268
|
+
}
|
|
269
|
+
|
|
270
|
+
const allowed = (await p.text({
|
|
271
|
+
message: "Numbers allowed to DM the bot (comma-separated)",
|
|
272
|
+
placeholder: "353871234567, 447700900000 — blank disables DMs",
|
|
273
|
+
initialValue: (config.whatsapp?.allowedJids ?? []).join(", "),
|
|
274
|
+
})) as string;
|
|
275
|
+
if (p.isCancel(allowed)) {
|
|
276
|
+
p.cancel("Cancelled.");
|
|
277
|
+
process.exit(0);
|
|
278
|
+
}
|
|
279
|
+
|
|
280
|
+
const groupPolicy = await p.select({
|
|
281
|
+
message: "Which groups should the bot serve?",
|
|
282
|
+
initialValue: config.whatsapp?.groupPolicy ?? "listed",
|
|
283
|
+
options: [
|
|
284
|
+
{
|
|
285
|
+
value: "listed",
|
|
286
|
+
label: `Listed only ${pc.dim("— groups you name in allowedGroups")}`,
|
|
287
|
+
},
|
|
288
|
+
{
|
|
289
|
+
value: "with-allowed-user",
|
|
290
|
+
label: `Groups I'm in ${pc.dim("— any group containing an allowed number")}`,
|
|
291
|
+
},
|
|
292
|
+
{
|
|
293
|
+
value: "all",
|
|
294
|
+
label: `All ${pc.dim("— every group the account belongs to")}`,
|
|
295
|
+
},
|
|
296
|
+
],
|
|
297
|
+
});
|
|
298
|
+
if (p.isCancel(groupPolicy)) {
|
|
299
|
+
p.cancel("Cancelled.");
|
|
300
|
+
process.exit(0);
|
|
301
|
+
}
|
|
302
|
+
|
|
303
|
+
whatsapp = {
|
|
304
|
+
// Preserve allowedGroups / respondMode / sendReadReceipts — the
|
|
305
|
+
// wizard doesn't prompt for them but must not drop them.
|
|
306
|
+
...config.whatsapp,
|
|
307
|
+
allowedJids: parseNumberList(allowed),
|
|
308
|
+
groupPolicy: groupPolicy as "listed" | "with-allowed-user" | "all",
|
|
309
|
+
...(pairing.replace(/[\s+()-]/g, "")
|
|
310
|
+
? { pairingNumber: pairing.replace(/[\s+()-]/g, "") }
|
|
311
|
+
: {}),
|
|
312
|
+
};
|
|
313
|
+
}
|
|
314
|
+
|
|
230
315
|
// Discover models from SDK; fall back to static list if SDK isn't available
|
|
231
316
|
const {
|
|
232
317
|
registerClaudeModels,
|
|
@@ -393,52 +478,28 @@ export async function runSetup(): Promise<void> {
|
|
|
393
478
|
// creds configured separately (kilo via `kilo login`, opencode via
|
|
394
479
|
// its own auth flow).
|
|
395
480
|
|
|
396
|
-
const newConfig
|
|
397
|
-
|
|
398
|
-
selectedFrontends.length === 1 ? selectedFrontends[0] : selectedFrontends,
|
|
481
|
+
const newConfig = buildSetupConfig(config, {
|
|
482
|
+
selectedFrontends,
|
|
399
483
|
backend,
|
|
400
|
-
botToken
|
|
484
|
+
botToken,
|
|
401
485
|
claudeBinary,
|
|
402
486
|
codexApiKey,
|
|
403
487
|
openaiApiKey,
|
|
404
488
|
openaiBaseUrl,
|
|
405
489
|
openaiApiMode,
|
|
406
490
|
model: model as string,
|
|
407
|
-
concurrency: config.concurrency,
|
|
408
491
|
pulse: pulse as boolean,
|
|
409
|
-
|
|
410
|
-
adminUserId: adminId ? parseInt(adminId, 10) || undefined : undefined,
|
|
492
|
+
adminId,
|
|
411
493
|
apiId,
|
|
412
494
|
apiHash,
|
|
413
|
-
|
|
414
|
-
|
|
415
|
-
|
|
416
|
-
|
|
417
|
-
|
|
418
|
-
|
|
419
|
-
|
|
420
|
-
|
|
421
|
-
: undefined,
|
|
422
|
-
teamsWebhookPort: selectedFrontends.includes("teams")
|
|
423
|
-
? teamsWebhookPort
|
|
424
|
-
: undefined,
|
|
425
|
-
teamsBotDisplayName: selectedFrontends.includes("teams")
|
|
426
|
-
? teamsBotDisplayName
|
|
427
|
-
: undefined,
|
|
428
|
-
// Discord — bot token + applicationId. Allowlists / admin IDs /
|
|
429
|
-
// mention vs channel-wide reply behaviour are left as defaults in
|
|
430
|
-
// the wizard; advanced users hand-edit talon.json.
|
|
431
|
-
discord:
|
|
432
|
-
selectedFrontends.includes("discord") &&
|
|
433
|
-
discordBotToken &&
|
|
434
|
-
discordApplicationId
|
|
435
|
-
? {
|
|
436
|
-
...config.discord,
|
|
437
|
-
botToken: discordBotToken,
|
|
438
|
-
applicationId: discordApplicationId,
|
|
439
|
-
}
|
|
440
|
-
: config.discord,
|
|
441
|
-
};
|
|
495
|
+
teamsWebhookUrl,
|
|
496
|
+
teamsWebhookSecret,
|
|
497
|
+
teamsWebhookPort,
|
|
498
|
+
teamsBotDisplayName,
|
|
499
|
+
discordBotToken,
|
|
500
|
+
discordApplicationId,
|
|
501
|
+
whatsapp,
|
|
502
|
+
});
|
|
442
503
|
|
|
443
504
|
const s = p.spinner();
|
|
444
505
|
s.start("Saving configuration");
|
|
@@ -453,3 +514,114 @@ export async function runSetup(): Promise<void> {
|
|
|
453
514
|
);
|
|
454
515
|
}
|
|
455
516
|
}
|
|
517
|
+
|
|
518
|
+
/** Everything the wizard's prompts collected, in one bag. */
|
|
519
|
+
export type SetupAnswers = {
|
|
520
|
+
selectedFrontends: string[];
|
|
521
|
+
backend: Config["backend"];
|
|
522
|
+
botToken?: string;
|
|
523
|
+
claudeBinary?: string;
|
|
524
|
+
codexApiKey?: string;
|
|
525
|
+
openaiApiKey?: string;
|
|
526
|
+
openaiBaseUrl?: string;
|
|
527
|
+
openaiApiMode?: Config["openaiApiMode"];
|
|
528
|
+
model: string;
|
|
529
|
+
pulse: boolean;
|
|
530
|
+
adminId?: string;
|
|
531
|
+
apiId?: number;
|
|
532
|
+
apiHash?: string;
|
|
533
|
+
teamsWebhookUrl?: string;
|
|
534
|
+
teamsWebhookSecret?: string;
|
|
535
|
+
teamsWebhookPort?: number;
|
|
536
|
+
teamsBotDisplayName?: string;
|
|
537
|
+
discordBotToken?: string;
|
|
538
|
+
discordApplicationId?: string;
|
|
539
|
+
whatsapp?: Config["whatsapp"];
|
|
540
|
+
};
|
|
541
|
+
|
|
542
|
+
/**
|
|
543
|
+
* Fold the wizard's answers onto the existing config.
|
|
544
|
+
*
|
|
545
|
+
* Extracted from `runSetup` so the merge is testable without driving the
|
|
546
|
+
* prompts — the behaviour that matters here is what it *doesn't* touch.
|
|
547
|
+
* The wizard models roughly half of ~/.talon/config.json; it used to
|
|
548
|
+
* rebuild the file from its own named fields alone, which silently
|
|
549
|
+
* deleted every other key (whatsapp, native, soul, memory, github,
|
|
550
|
+
* heartbeat/dream, allowlists, plugin blocks…). Spreading `existing`
|
|
551
|
+
* first keeps them. Fields below still override, and an explicit
|
|
552
|
+
* `undefined` still deletes, because `saveConfig` strips undefined.
|
|
553
|
+
*/
|
|
554
|
+
export function buildSetupConfig(
|
|
555
|
+
existing: Config,
|
|
556
|
+
answers: SetupAnswers,
|
|
557
|
+
): Config {
|
|
558
|
+
const on = (id: string) => answers.selectedFrontends.includes(id);
|
|
559
|
+
/** Did the chosen backend's credential prompt actually run? */
|
|
560
|
+
const ownsCredential = (backend: Config["backend"]) =>
|
|
561
|
+
answers.backend === backend;
|
|
562
|
+
return {
|
|
563
|
+
...existing,
|
|
564
|
+
frontend:
|
|
565
|
+
answers.selectedFrontends.length === 1
|
|
566
|
+
? answers.selectedFrontends[0]
|
|
567
|
+
: answers.selectedFrontends,
|
|
568
|
+
backend: answers.backend,
|
|
569
|
+
botToken: on("telegram") ? answers.botToken : undefined,
|
|
570
|
+
// Backend credentials: the wizard prompts for exactly one backend's
|
|
571
|
+
// (an if/else-if chain on the chosen backend), so taking the answer
|
|
572
|
+
// unconditionally deleted every other backend's — picking `claude`
|
|
573
|
+
// wiped a stored openaiApiKey/openaiBaseUrl even though the account
|
|
574
|
+
// stays reachable via enabledBackends and per-chat overrides. Each
|
|
575
|
+
// credential is now overwritten only by the prompt that owns it, and
|
|
576
|
+
// left alone otherwise. Blanking a shown prompt still clears it.
|
|
577
|
+
claudeBinary: ownsCredential("claude")
|
|
578
|
+
? answers.claudeBinary
|
|
579
|
+
: existing.claudeBinary,
|
|
580
|
+
codexApiKey: ownsCredential("codex")
|
|
581
|
+
? answers.codexApiKey
|
|
582
|
+
: existing.codexApiKey,
|
|
583
|
+
openaiApiKey: ownsCredential("openai-agents")
|
|
584
|
+
? answers.openaiApiKey
|
|
585
|
+
: existing.openaiApiKey,
|
|
586
|
+
openaiBaseUrl: ownsCredential("openai-agents")
|
|
587
|
+
? answers.openaiBaseUrl
|
|
588
|
+
: existing.openaiBaseUrl,
|
|
589
|
+
openaiApiMode: ownsCredential("openai-agents")
|
|
590
|
+
? answers.openaiApiMode
|
|
591
|
+
: existing.openaiApiMode,
|
|
592
|
+
model: answers.model,
|
|
593
|
+
concurrency: existing.concurrency,
|
|
594
|
+
pulse: answers.pulse,
|
|
595
|
+
pulseIntervalMs: existing.pulseIntervalMs,
|
|
596
|
+
adminUserId: answers.adminId
|
|
597
|
+
? parseInt(answers.adminId, 10) || undefined
|
|
598
|
+
: undefined,
|
|
599
|
+
apiId: answers.apiId,
|
|
600
|
+
apiHash: answers.apiHash,
|
|
601
|
+
maxMessageLength: existing.maxMessageLength,
|
|
602
|
+
plugins: existing.plugins,
|
|
603
|
+
// Teams
|
|
604
|
+
teamsWebhookUrl: on("teams") ? answers.teamsWebhookUrl : undefined,
|
|
605
|
+
teamsWebhookSecret: on("teams") ? answers.teamsWebhookSecret : undefined,
|
|
606
|
+
teamsWebhookPort: on("teams") ? answers.teamsWebhookPort : undefined,
|
|
607
|
+
teamsBotDisplayName: on("teams") ? answers.teamsBotDisplayName : undefined,
|
|
608
|
+
// Discord — bot token + applicationId. Allowlists / admin IDs /
|
|
609
|
+
// mention vs channel-wide reply behaviour are left as defaults in
|
|
610
|
+
// the wizard; advanced users hand-edit talon.json.
|
|
611
|
+
discord:
|
|
612
|
+
on("discord") && answers.discordBotToken && answers.discordApplicationId
|
|
613
|
+
? {
|
|
614
|
+
...existing.discord,
|
|
615
|
+
botToken: answers.discordBotToken,
|
|
616
|
+
applicationId: answers.discordApplicationId,
|
|
617
|
+
}
|
|
618
|
+
: existing.discord,
|
|
619
|
+
// Never dropped. Deselecting WhatsApp shouldn't throw away its
|
|
620
|
+
// pairing setup, and dropping it while `frontend` still lists
|
|
621
|
+
// whatsapp produces a config util/config.ts refuses to load at all
|
|
622
|
+
// ("WhatsApp frontend requires a whatsapp config block") — a daemon
|
|
623
|
+
// that won't start. The `??` keeps that impossible even if the
|
|
624
|
+
// branch that collects the answer is ever skipped.
|
|
625
|
+
whatsapp: answers.whatsapp ?? existing.whatsapp,
|
|
626
|
+
};
|
|
627
|
+
}
|