sanook-cli 0.5.0 → 0.5.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.
Files changed (146) hide show
  1. package/.env.example +161 -3
  2. package/CHANGELOG.md +83 -5
  3. package/README.md +240 -23
  4. package/README.th.md +87 -6
  5. package/dist/approval.js +6 -0
  6. package/dist/bin.js +3045 -210
  7. package/dist/brain-context.js +223 -0
  8. package/dist/brain-doctor.js +318 -0
  9. package/dist/brain-eval.js +186 -0
  10. package/dist/brain-final.js +371 -0
  11. package/dist/brain-review.js +382 -0
  12. package/dist/brain.js +12 -1
  13. package/dist/brand.js +1 -1
  14. package/dist/cli-args.js +152 -0
  15. package/dist/cli-option-values.js +16 -0
  16. package/dist/commands.js +172 -13
  17. package/dist/compaction.js +96 -11
  18. package/dist/config.js +118 -28
  19. package/dist/context-compression.js +191 -0
  20. package/dist/cost.js +49 -15
  21. package/dist/first-run.js +21 -0
  22. package/dist/gateway/auth.js +37 -8
  23. package/dist/gateway/bluebubbles.js +205 -0
  24. package/dist/gateway/config.js +929 -0
  25. package/dist/gateway/deliver.js +357 -0
  26. package/dist/gateway/discord.js +124 -0
  27. package/dist/gateway/email.js +472 -0
  28. package/dist/gateway/googlechat.js +207 -0
  29. package/dist/gateway/homeassistant.js +256 -0
  30. package/dist/gateway/ledger.js +18 -0
  31. package/dist/gateway/line.js +171 -0
  32. package/dist/gateway/lock.js +3 -1
  33. package/dist/gateway/matrix.js +366 -0
  34. package/dist/gateway/mattermost.js +322 -0
  35. package/dist/gateway/ntfy.js +218 -0
  36. package/dist/gateway/schedule.js +31 -4
  37. package/dist/gateway/serve.js +267 -7
  38. package/dist/gateway/server.js +253 -19
  39. package/dist/gateway/service.js +224 -0
  40. package/dist/gateway/session.js +343 -0
  41. package/dist/gateway/signal.js +351 -0
  42. package/dist/gateway/slack.js +124 -0
  43. package/dist/gateway/sms.js +169 -0
  44. package/dist/gateway/targets.js +576 -0
  45. package/dist/gateway/teams.js +106 -0
  46. package/dist/gateway/telegram.js +38 -15
  47. package/dist/gateway/webhooks.js +220 -0
  48. package/dist/gateway/whatsapp.js +230 -0
  49. package/dist/hooks.js +13 -2
  50. package/dist/insights-args.js +35 -0
  51. package/dist/insights.js +86 -0
  52. package/dist/loop.js +123 -24
  53. package/dist/lsp/index.js +23 -5
  54. package/dist/mcp-registry.js +350 -0
  55. package/dist/mcp-server.js +1 -1
  56. package/dist/mcp.js +44 -6
  57. package/dist/memory.js +100 -33
  58. package/dist/orchestrate.js +49 -19
  59. package/dist/personality.js +58 -0
  60. package/dist/providers/codex.js +86 -38
  61. package/dist/providers/keys.js +1 -1
  62. package/dist/providers/models.js +22 -6
  63. package/dist/providers/registry.js +38 -49
  64. package/dist/search/chunk.js +7 -8
  65. package/dist/search/cli.js +75 -0
  66. package/dist/search/embed-store.js +3 -0
  67. package/dist/search/indexer.js +44 -1
  68. package/dist/search/store.js +23 -1
  69. package/dist/session.js +93 -7
  70. package/dist/skill-install.js +29 -12
  71. package/dist/support-dump.js +175 -0
  72. package/dist/tools/edit.js +45 -15
  73. package/dist/tools/git.js +10 -5
  74. package/dist/tools/homeassistant.js +106 -0
  75. package/dist/tools/index.js +5 -0
  76. package/dist/tools/list.js +19 -6
  77. package/dist/tools/permission.js +923 -9
  78. package/dist/tools/read.js +16 -4
  79. package/dist/tools/schedule.js +19 -3
  80. package/dist/tools/search.js +217 -13
  81. package/dist/tools/task.js +18 -7
  82. package/dist/tools/timeout.js +21 -3
  83. package/dist/trust.js +11 -1
  84. package/dist/ui/app.js +57 -11
  85. package/dist/ui/brain-wizard.js +2 -2
  86. package/dist/ui/history.js +37 -5
  87. package/dist/ui/mentions.js +3 -2
  88. package/dist/ui/render.js +55 -15
  89. package/dist/ui/setup.js +107 -10
  90. package/dist/update.js +24 -11
  91. package/dist/worktree.js +175 -4
  92. package/package.json +4 -4
  93. package/second-brain/AGENTS.md +6 -4
  94. package/second-brain/CLAUDE.md +7 -1
  95. package/second-brain/Evals/_Index.md +10 -2
  96. package/second-brain/Evals/quality-ledger.md +9 -1
  97. package/second-brain/Evals/second-brain-benchmarks.md +62 -0
  98. package/second-brain/GEMINI.md +5 -4
  99. package/second-brain/Home.md +1 -1
  100. package/second-brain/Projects/_Index.md +3 -1
  101. package/second-brain/Projects/sanook-cli/_Index.md +26 -0
  102. package/second-brain/Projects/sanook-cli/second-brain-feature-roadmap.md +156 -0
  103. package/second-brain/README.md +1 -1
  104. package/second-brain/Research/2026-06-17-ai-second-brain-method-experiment.md +108 -0
  105. package/second-brain/Research/2026-06-18-ai-token-reduction-frameworks.md +55 -0
  106. package/second-brain/Research/2026-06-18-hermes-cli-second-brain-expansion-research.md +160 -0
  107. package/second-brain/Research/2026-06-18-sanook-mcp-ecosystem-and-ux-roadmap.md +181 -0
  108. package/second-brain/Research/_Index.md +6 -1
  109. package/second-brain/Reviews/2026-06-18-auto-improve-maintenance.md +54 -0
  110. package/second-brain/Reviews/_Index.md +1 -1
  111. package/second-brain/Runbooks/_Index.md +6 -1
  112. package/second-brain/Runbooks/ai-second-brain-operating-sequence.md +108 -0
  113. package/second-brain/SANOOK.md +45 -0
  114. package/second-brain/Sessions/2026-06-17-ai-framework-additional-zones.md +68 -0
  115. package/second-brain/Sessions/2026-06-17-ai-second-brain-sequence-experiment.md +63 -0
  116. package/second-brain/Sessions/2026-06-18-cli-args-release-readiness.md +59 -0
  117. package/second-brain/Sessions/2026-06-18-final-gate-template-final.md +192 -0
  118. package/second-brain/Sessions/2026-06-18-final-gate-template.md +71 -0
  119. package/second-brain/Sessions/2026-06-18-framework-dogfood-permission-and-memory.md +58 -0
  120. package/second-brain/Sessions/2026-06-18-hermes-second-brain-expansion-research.md +52 -0
  121. package/second-brain/Sessions/2026-06-18-mcp-ecosystem-and-sanook-ux-scan.md +81 -0
  122. package/second-brain/Sessions/2026-06-18-sanook-brain-cli-p0-implementation.md +86 -0
  123. package/second-brain/Sessions/2026-06-18-sanook-brain-final-cli-final.md +246 -0
  124. package/second-brain/Sessions/2026-06-18-sanook-brain-final-cli.md +78 -0
  125. package/second-brain/Sessions/2026-06-18-sanook-cli-second-brain-roadmap-correction.md +54 -0
  126. package/second-brain/Sessions/2026-06-18-token-reduction-framework-integration.md +69 -0
  127. package/second-brain/Sessions/_Index.md +15 -1
  128. package/second-brain/Shared/AI-Context-Index.md +22 -0
  129. package/second-brain/Shared/Context-Packs/_Index.md +9 -1
  130. package/second-brain/Shared/Context-Packs/coding-release.md +51 -0
  131. package/second-brain/Shared/Context-Packs/research-to-framework.md +51 -0
  132. package/second-brain/Shared/Context-Packs/second-brain-maintenance.md +41 -0
  133. package/second-brain/Shared/Operating-State/current-state.md +22 -3
  134. package/second-brain/Shared/Scripts/_Index.md +3 -1
  135. package/second-brain/Shared/Scripts/ai-second-brain-method-eval.mjs +198 -0
  136. package/second-brain/Shared/Tech-Standards/_Index.md +4 -1
  137. package/second-brain/Shared/Tech-Standards/mcp-integration-roadmap.md +86 -0
  138. package/second-brain/Shared/Tech-Standards/verification-standard.md +24 -0
  139. package/second-brain/Shared/User-Memory/_Index.md +4 -1
  140. package/second-brain/Shared/User-Memory/response-examples.md +98 -0
  141. package/second-brain/Shared/User-Memory/user-preferences.md +1 -0
  142. package/second-brain/Templates/_Index.md +9 -0
  143. package/second-brain/Templates/final-lite.md +111 -0
  144. package/second-brain/Templates/final.md +231 -0
  145. package/second-brain/Vault Structure Map.md +2 -1
  146. package/skills/structured-output-llm/SKILL.md +1 -1
package/dist/bin.js CHANGED
@@ -1,60 +1,23 @@
1
1
  #!/usr/bin/env node
2
2
  import { runAgent } from './loop.js';
3
- import { redactKey } from './providers/keys.js';
3
+ import { assertDirectApiKey, redactKey } from './providers/keys.js';
4
4
  import { specKey, parseSpec, PROVIDERS, consoleUrl, detectEnvProvider } from './providers/registry.js';
5
5
  import { resolveKeyFromEnv } from './providers/keys.js';
6
6
  import { hasPricingForKey } from './cost.js';
7
7
  import { loadConfig, isFirstRun, loadKeysIntoEnv, parsePricingOverride } from './config.js';
8
- import { saveSession, latestSession, newSessionId } from './session.js';
8
+ import { saveSession, latestSession, newSessionId, listSessions, loadSession, removeSession, pruneSessions, renameSession, sanitizeSessionForExport, sessionStorePath, } from './session.js';
9
9
  import { closeMcp, isValidMcpServerName } from './mcp.js';
10
10
  import { readFileSync } from 'node:fs';
11
11
  import { homedir } from 'node:os';
12
- import { join, dirname } from 'node:path';
12
+ import { join, dirname, resolve } from 'node:path';
13
13
  import { chmod, readFile, writeFile, mkdir } from 'node:fs/promises';
14
14
  import { createInterface } from 'node:readline/promises';
15
15
  import { appHomePath, BRAND, BRAND_ENV, envFlag } from './brand.js';
16
+ import { hasContinueAnyRequest, hasContinueRequest, hasResumeRequest, hasServeCommandRequest, parseArgs, parseBudgetUsd, parseServeArgs, parseThinkingConfigValue, } from './cli-args.js';
16
17
  // สี: เคารพ NO_COLOR + auto-plain เมื่อ pipe/redirect (legacy Windows cmd ก็ไม่เห็น garbage ANSI); FORCE_COLOR บังคับได้
17
18
  const useColor = !process.env.NO_COLOR && (Boolean(process.env.FORCE_COLOR) || process.stdout.isTTY === true);
18
19
  const DIM = useColor ? '\x1b[2m' : '';
19
20
  const RESET = useColor ? '\x1b[0m' : '';
20
- function parseArgs(argv) {
21
- let model;
22
- let budget;
23
- let json = false;
24
- let quiet = false;
25
- let planMode = false;
26
- let yes = false;
27
- const rest = [];
28
- for (let i = 0; i < argv.length; i++) {
29
- const a = argv[i];
30
- if (a === '--model' || a === '-m')
31
- model = argv[++i];
32
- else if (a === '--budget' || a === '-b')
33
- budget = Number.parseFloat(argv[++i] ?? '');
34
- else if (a === '--json')
35
- json = true;
36
- else if (a === '-q' || a === '--quiet')
37
- quiet = true;
38
- else if (a === '--output-format') {
39
- const v = argv[++i];
40
- if (v === 'json')
41
- json = true;
42
- else if (v === 'final' || v === 'quiet')
43
- quiet = true;
44
- /* 'text' = default */
45
- }
46
- else if (a === '--plan')
47
- planMode = true;
48
- else if (a === '--yes' || a === '-y')
49
- yes = true;
50
- else if (a === '-p' || a === '--print' || a === '-c' || a === '--continue') {
51
- /* -p headless flag · -c/--continue resume (handled in main) */
52
- }
53
- else
54
- rest.push(a);
55
- }
56
- return { model, budget, json, quiet, prompt: rest.join(' ').trim(), planMode, yes };
57
- }
58
21
  async function runHeadless(model, prompt, budgetUsd, maxSteps, json, history, planMode = false, permissionMode = 'ask', quiet = false, fallbackModel) {
59
22
  const controller = new AbortController();
60
23
  process.on('SIGINT', () => {
@@ -106,105 +69,2560 @@ async function runHeadless(model, prompt, budgetUsd, maxSteps, json, history, pl
106
69
  await appendBrainWorklog(brain, { prompt, summary: cost.summary(), model, today: now.slice(0, 10) }).catch(() => { });
107
70
  }
108
71
  }
109
- catch (err) {
110
- const msg = redactKey(err.message);
72
+ catch (err) {
73
+ const msg = redactKey(err.message);
74
+ if (json)
75
+ process.stdout.write(`${JSON.stringify({ type: 'error', message: msg })}\n`);
76
+ else
77
+ console.error(`\nERROR: ${msg}`);
78
+ process.exit(1);
79
+ }
80
+ }
81
+ // อ่านจาก package.json (single source of truth) — กัน version constant drift
82
+ const PACKAGE = JSON.parse(readFileSync(new URL('../package.json', import.meta.url), 'utf8'));
83
+ const VERSION = PACKAGE.version;
84
+ const PACKAGE_NAME = PACKAGE.name;
85
+ const HELP = `${BRAND.productName} — a terminal AI coding agent (BYOK)
86
+
87
+ usage:
88
+ ${BRAND.cliName} "<task>" run one task (headless)
89
+ ${BRAND.cliName} -z "<task>" one-shot final output (script-friendly)
90
+ ${BRAND.cliName} chat -q "<query>" direct one-shot query
91
+ ${BRAND.cliName} interactive REPL
92
+ ${BRAND.cliName} setup [section] setup wizard (model | gateway | tools | agent | brain)
93
+ ${BRAND.cliName} model choose provider + model
94
+ ${BRAND.cliName} --json "<task>" headless, JSONL output (for CI/scripts)
95
+ ${BRAND.cliName} sessions list/resume-audit saved conversation sessions
96
+ ${BRAND.cliName} insights local usage/session insights
97
+ ${BRAND.cliName} dump [--show-keys] support snapshot (secrets redacted)
98
+ ${BRAND.cliName} update update ${BRAND.cliName} to the latest npm release
99
+ ${BRAND.cliName} doctor ตรวจการติดตั้ง + วิธีแก้ PATH (เมื่อพิมพ์ "${BRAND.cliName}" แล้วไม่เจอ)
100
+
101
+ gateway (อยู่ยาว 24/7 — HTTP loopback + cron):
102
+ ${BRAND.cliName} gateway setup telegram ตั้งค่า Telegram token + allowlist
103
+ ${BRAND.cliName} gateway setup discord ตั้งค่า Discord bot token + channel allowlist
104
+ ${BRAND.cliName} gateway setup slack ตั้งค่า Slack bot/app token + channel allowlist
105
+ ${BRAND.cliName} gateway setup mattermost ตั้งค่า Mattermost token + user/channel allowlist
106
+ ${BRAND.cliName} gateway setup homeassistant ตั้งค่า Home Assistant token + state-change filters
107
+ ${BRAND.cliName} gateway setup email ตั้งค่า Email IMAP/SMTP + allowed senders
108
+ ${BRAND.cliName} gateway setup line ตั้งค่า LINE Messaging API push target
109
+ ${BRAND.cliName} gateway setup sms ตั้งค่า Twilio SMS webhook + allowlist
110
+ ${BRAND.cliName} gateway setup ntfy ตั้งค่า ntfy topic push + subscribe
111
+ ${BRAND.cliName} gateway setup signal ตั้งค่า Signal ผ่าน signal-cli HTTP daemon
112
+ ${BRAND.cliName} gateway setup whatsapp ตั้งค่า WhatsApp Cloud API webhook + send
113
+ ${BRAND.cliName} gateway setup matrix ตั้งค่า Matrix homeserver sync + send
114
+ ${BRAND.cliName} gateway setup googlechat ตั้งค่า Google Chat bot send
115
+ ${BRAND.cliName} gateway setup bluebubbles ตั้งค่า BlueBubbles/iMessage send
116
+ ${BRAND.cliName} gateway setup teams ตั้งค่า Microsoft Teams delivery
117
+ ${BRAND.cliName} gateway setup webhooks เปิด generic webhook routes + HMAC
118
+ ${BRAND.cliName} gateway run [--port 8787] [--model spec] เปิด gateway (เหมือน serve)
119
+ ${BRAND.cliName} gateway start [--port 8787] เปิด gateway เป็น background process
120
+ ${BRAND.cliName} gateway stop|restart|install จัดการ gateway service
121
+ ${BRAND.cliName} gateway status ดู config/status gateway
122
+ ${BRAND.cliName} send --to telegram|discord|slack|mattermost|homeassistant|email|line|sms|ntfy|signal|whatsapp|matrix|googlechat|bluebubbles|teams[:target] "msg" ส่งข้อความออก platform โดยไม่เรียก LLM
123
+ ${BRAND.cliName} webhook subscribe <route> [--prompt "..."] [--to telegram|slack|mattermost|homeassistant|sms|ntfy|signal|whatsapp|matrix|googlechat|bluebubbles|teams]
124
+ รับ event จาก GitHub/GitLab/Jira/Stripe แล้ว trigger agent/delivery
125
+ ${BRAND.cliName} send --list [platform] ดู messaging targets ที่ตั้งค่าไว้
126
+ ${BRAND.cliName} serve [--port 8787] [--model spec] เปิด gateway (OpenAI-compat /v1/chat/completions + scheduler)
127
+ ${BRAND.cliName} cron add "<when>" "<task>" [--to <target>] [--model <model>]
128
+ ตั้งงานล่วงหน้า + ส่งผลลัพธ์กลับ messaging target ได้
129
+ ${BRAND.cliName} cron list ดู task ทั้งหมด
130
+ ${BRAND.cliName} cron rm <id> ลบ task
131
+
132
+ skills (built-in + ติดตั้งเพิ่มได้):
133
+ ${BRAND.cliName} skill list ดู skill ทั้งหมด
134
+ ${BRAND.cliName} skill add <user/repo|url|path> ติดตั้ง skill จาก GitHub / URL / local
135
+ ${BRAND.cliName} skill remove <name> ลบ skill ที่ติดตั้ง
136
+ ${BRAND.cliName} models [provider] ดู/verify model id (เทียบ provider จริงถ้ามี key)
137
+
138
+ second brain (Obsidian workspace สำหรับจัดเก็บงาน + ความจำ AI):
139
+ ${BRAND.cliName} brain init [path] สร้างโครงสร้าง second-brain ที่ path (ไม่ใส่ = ถาม)
140
+ ${BRAND.cliName} brain doctor ตรวจ health ของ second-brain ที่ config.brainPath
141
+ ${BRAND.cliName} brain context [--task "..."] แสดง context ที่ Sanook จะ inject + retrieval hits ต่อ task
142
+ ${BRAND.cliName} brain eval รัน second-brain benchmark sanity checks
143
+ ${BRAND.cliName} brain review curator review: inbox, packs, sessions, evals, note hygiene
144
+ ${BRAND.cliName} brain final --task "..." สร้าง final gate note ใน Sessions พร้อม evidence scaffold
145
+
146
+ search (BM25 + optional BYOK semantic เหนือ vault + memory + sessions + skills):
147
+ ${BRAND.cliName} index (re)index vault+memory แบบ incremental (O(delta))
148
+ ${BRAND.cliName} search "<query>" [--mode auto|fts|semantic|hybrid] [--limit N] [--source vault,memory]
149
+ ${BRAND.cliName} mcp serve expose brain เป็น MCP server (stdio) ให้ Claude Desktop/Cursor
150
+
151
+ config & mcp:
152
+ ${BRAND.cliName} status ดู provider/key/brain/gateway status แบบ redacted
153
+ ${BRAND.cliName} auth [list|status|add|remove] จัดการ API keys ของ providers (BYOK, redacted)
154
+ ${BRAND.cliName} sessions [list|latest|show|rm] จัดการ saved sessions
155
+ ${BRAND.cliName} insights [--days N] [--all] ดู usage/session insights ในเครื่อง
156
+ ${BRAND.cliName} dump [--show-keys] diagnostic/support dump แบบไม่โชว์ raw secret
157
+ ${BRAND.cliName} tools ดู tool surface ที่ agent ใช้ได้
158
+ ${BRAND.cliName} config [get|set <k> <v>] ดู/แก้ ${appHomePath('config.json')} (model/budgetUsd/permissionMode/cacheTtl/compaction/contextCompression/thinking/embeddingModel)
159
+ ${BRAND.cliName} mcp [search|info|install|test|doctor|preset|list|add|remove] จัดการ MCP servers
160
+ ${BRAND.cliName} trust [status|add|remove] อนุญาต/ยกเลิก project .sanook mcp/hooks/skills/commands
161
+
162
+ flags:
163
+ -m, --model <spec> sonnet/opus/haiku/fable · gpt/codex · gemini · grok · mistral · groq · ollama/lmstudio
164
+ or "provider:model-id" (e.g. openai:gpt-5.3-codex, groq:fast, google:gemini-2.5-flash)
165
+ -b, --budget <usd> stop when estimated cost exceeds this
166
+ -c, --continue resume the latest session ของ project นี้
167
+ -r, --resume <id> resume a specific saved session
168
+ --continue-any resume latest session ข้าม project (explicit)
169
+ --plan plan mode — สำรวจ+วางแผนเท่านั้น ไม่แก้ไฟล์ (read-only)
170
+ -y, --yes อนุมัติ tool อัตโนมัติ (ข้าม ask-mode permission)
171
+ --yolo alias ของ --yes (compat)
172
+ --json machine-readable JSONL output
173
+ -v, --version
174
+ -h, --help
175
+
176
+ env (BYOK — direct API key only):
177
+ ANTHROPIC_API_KEY / GOOGLE_GENERATIVE_AI_API_KEY / OPENAI_API_KEY
178
+ ${BRAND_ENV.disableUpdateCheck}=1 disable interactive update prompts`;
179
+ /** sanook serve [--port N] [--model spec] — เปิด gateway (HTTP loopback + cron scheduler) อยู่ยาว */
180
+ async function runServe(args) {
181
+ const parsed = parseServeArgs(args);
182
+ if (parsed.portError) {
183
+ console.error(`port ไม่ถูกต้อง: ${parsed.portError}`);
184
+ process.exit(1);
185
+ }
186
+ const config = await loadConfig({ model: parsed.model });
187
+ const { startGateway } = await import('./gateway/serve.js');
188
+ process.stdout.write(`${DIM}${BRAND.productName} gateway — model: ${config.model}${RESET}\n`);
189
+ const stop = await startGateway({
190
+ port: parsed.port,
191
+ model: config.model,
192
+ budgetUsd: config.budgetUsd,
193
+ permissionMode: envFlag(BRAND_ENV.gatewayAllowWrite) ? 'auto' : config.permissionMode,
194
+ onLog: (m) => process.stdout.write(`${DIM}[gateway] ${m}${RESET}\n`),
195
+ });
196
+ const shutdown = () => {
197
+ stop();
198
+ process.stdout.write('\n[gateway] หยุดแล้ว\n');
199
+ process.exit(0);
200
+ };
201
+ process.on('SIGINT', shutdown);
202
+ process.on('SIGTERM', shutdown);
203
+ // server + scheduler interval ถือ event loop ไว้ → process อยู่ยาวจนกด Ctrl-C
204
+ }
205
+ async function startModelSetup() {
206
+ const config = await loadConfig({});
207
+ const { startApp } = await import('./ui/render.js');
208
+ startApp({
209
+ needsSetup: true,
210
+ appProps: {
211
+ initialModel: config.model,
212
+ fallbackModel: config.fallbackModel,
213
+ budgetUsd: config.budgetUsd,
214
+ permissionMode: config.permissionMode,
215
+ },
216
+ });
217
+ }
218
+ async function runTools(_args = []) {
219
+ const { tools } = await import('./tools/index.js');
220
+ const names = Object.keys(tools).sort();
221
+ console.log(`${BRAND.productName} tools (${names.length})`);
222
+ console.log(names.map((n) => ` ${n}`).join('\n'));
223
+ console.log(`\nจัดการ MCP เพิ่มเติม: ${BRAND.cliName} mcp add <name> <command> [args...]`);
224
+ }
225
+ async function runAgentSetupSummary() {
226
+ const cfg = await loadConfig({});
227
+ console.log(`${BRAND.productName} agent settings`);
228
+ console.log(` model: ${cfg.model}`);
229
+ console.log(` fallbackModel: ${cfg.fallbackModel ?? '(not set)'}`);
230
+ console.log(` personality: ${cfg.personality ?? '(none)'}`);
231
+ console.log(` permissionMode: ${cfg.permissionMode}`);
232
+ console.log(` maxSteps: ${cfg.maxSteps}`);
233
+ console.log(` budgetUsd: ${cfg.budgetUsd ?? '(not set)'}`);
234
+ console.log(` brainPath: ${cfg.brainPath ?? '(not set)'}`);
235
+ console.log(` insights: ${BRAND.cliName} insights [--days N]`);
236
+ console.log('\nแก้ค่าได้ด้วย:');
237
+ console.log(` ${BRAND.cliName} config set personality concise`);
238
+ console.log(` ${BRAND.cliName} config set permissionMode ask`);
239
+ console.log(` ${BRAND.cliName} config set budgetUsd 0.25`);
240
+ console.log(` ${BRAND.cliName} config set fallbackModel haiku`);
241
+ }
242
+ async function runGatewayStatus() {
243
+ const { readGatewayConfig, redactGatewayConfig, resolveBlueBubblesConfig, resolveDiscordConfig, resolveEmailConfig, resolveGoogleChatConfig, resolveHomeAssistantConfig, resolveLineConfig, resolveMattermostConfig, resolveMatrixConfig, resolveNtfyConfig, resolveSignalConfig, resolveSlackConfig, resolveSmsConfig, resolveTelegramConfig, resolveTeamsConfig, resolveWhatsAppConfig, resolveWebhookConfig, gatewayConfigPath, } = await import('./gateway/config.js');
244
+ const cfg = await readGatewayConfig();
245
+ const telegram = resolveTelegramConfig(cfg);
246
+ const discord = resolveDiscordConfig(cfg);
247
+ const slack = resolveSlackConfig(cfg);
248
+ const email = resolveEmailConfig(cfg);
249
+ const homeassistant = resolveHomeAssistantConfig(cfg);
250
+ const line = resolveLineConfig(cfg);
251
+ const mattermost = resolveMattermostConfig(cfg);
252
+ const sms = resolveSmsConfig(cfg);
253
+ const ntfy = resolveNtfyConfig(cfg);
254
+ const signal = resolveSignalConfig(cfg);
255
+ const whatsapp = resolveWhatsAppConfig(cfg);
256
+ const matrix = resolveMatrixConfig(cfg);
257
+ const googleChat = resolveGoogleChatConfig(cfg);
258
+ const bluebubbles = resolveBlueBubblesConfig(cfg);
259
+ const teams = resolveTeamsConfig(cfg);
260
+ const webhooks = resolveWebhookConfig(cfg);
261
+ const { redactSignalId } = await import('./gateway/signal.js');
262
+ const { redactWhatsAppId } = await import('./gateway/whatsapp.js');
263
+ console.log(`${BRAND.productName} gateway`);
264
+ console.log(` config: ${gatewayConfigPath()}`);
265
+ console.log(` token: ${appHomePath('gateway', 'token')} (HTTP bearer, auto-created on run)`);
266
+ const { gatewayServiceStatus } = await import('./gateway/service.js');
267
+ const service = await gatewayServiceStatus();
268
+ console.log(` service: ${service.running ? `running (pid ${service.state?.pid})` : service.state ? `stopped (last pid ${service.state.pid})` : 'not started'}`);
269
+ console.log(` log: ${service.logPath}`);
270
+ console.log(` telegram: ${telegram.token ? `configured via ${telegram.source}` : 'not configured'}`);
271
+ if (telegram.token) {
272
+ console.log(` enabled: ${telegram.enabled}`);
273
+ console.log(` allowed chats: ${telegram.allowedChatIds.length ? telegram.allowedChatIds.join(', ') : '(none — fail closed)'}`);
274
+ console.log(` allow write: ${telegram.allowWrite ? 'yes' : 'no'}`);
275
+ }
276
+ console.log(` discord: ${discord.token ? `configured via ${discord.source}` : 'not configured'}`);
277
+ if (discord.token) {
278
+ console.log(` default channel: ${discord.defaultChannelId ?? '(not set)'}`);
279
+ console.log(` allowed channels: ${discord.allowedChannelIds.length ? discord.allowedChannelIds.join(', ') : '(none)'}`);
280
+ }
281
+ console.log(` slack: ${slack.botToken ? `configured via ${slack.source}` : 'not configured'}`);
282
+ if (slack.botToken) {
283
+ console.log(` app token: ${slack.appToken ? 'set' : '(not set — needed for future Socket Mode gateway)'}`);
284
+ console.log(` default channel: ${slack.defaultChannelId ?? '(not set)'}`);
285
+ console.log(` allowed channels: ${slack.allowedChannelIds.length ? slack.allowedChannelIds.join(', ') : '(none)'}`);
286
+ }
287
+ console.log(` mattermost: ${mattermost.serverUrl || mattermost.token ? `configured via ${mattermost.source}` : 'not configured'}`);
288
+ if (mattermost.serverUrl || mattermost.token) {
289
+ console.log(` server url: ${mattermost.serverUrl ?? '(not set)'}`);
290
+ console.log(` token: ${mattermost.token ? 'set' : '(not set)'}`);
291
+ console.log(` home channel: ${mattermost.homeChannel ?? '(not set)'}`);
292
+ console.log(` allowed users: ${mattermost.allowedUsers.length ? mattermost.allowedUsers.join(', ') : mattermost.allowAllUsers ? '(all users)' : '(none)'}`);
293
+ console.log(` allowed channels: ${mattermost.allowedChannels.length ? mattermost.allowedChannels.join(', ') : '(none)'}`);
294
+ console.log(` free channels: ${mattermost.freeResponseChannels.length ? mattermost.freeResponseChannels.join(', ') : '(none)'}`);
295
+ console.log(` require mention: ${mattermost.requireMention ? 'yes' : 'no'}`);
296
+ console.log(` reply mode: ${mattermost.replyMode}`);
297
+ }
298
+ console.log(` homeassistant: ${homeassistant.token || homeassistant.url !== 'http://homeassistant.local:8123' ? `configured via ${homeassistant.source}` : 'not configured'}`);
299
+ if (homeassistant.token || homeassistant.url !== 'http://homeassistant.local:8123') {
300
+ console.log(` url: ${homeassistant.url}`);
301
+ console.log(` token: ${homeassistant.token ? 'set' : '(not set)'}`);
302
+ console.log(` home channel: ${homeassistant.homeChannel ?? '(not set)'}`);
303
+ console.log(` watch domains: ${homeassistant.watchDomains.length ? homeassistant.watchDomains.join(', ') : '(none)'}`);
304
+ console.log(` watch entities: ${homeassistant.watchEntities.length ? homeassistant.watchEntities.join(', ') : '(none)'}`);
305
+ console.log(` ignore entities: ${homeassistant.ignoreEntities.length ? homeassistant.ignoreEntities.join(', ') : '(none)'}`);
306
+ console.log(` watch all: ${homeassistant.watchAll ? 'yes' : 'no'}`);
307
+ console.log(` cooldown: ${homeassistant.cooldownSeconds}s`);
308
+ }
309
+ console.log(` email: ${email.address ? `configured via ${email.source}` : 'not configured'}`);
310
+ if (email.address) {
311
+ console.log(` address: ${email.address}`);
312
+ console.log(` smtp: ${email.smtpHost ?? '(not set)'}:${email.smtpPort}`);
313
+ console.log(` imap: ${email.imapHost ?? '(not set)'}:${email.imapPort}`);
314
+ console.log(` home address: ${email.homeAddress ?? '(not set)'}`);
315
+ console.log(` allowed senders: ${email.allowedUsers.length ? email.allowedUsers.join(', ') : email.allowAllUsers ? '(all users)' : '(none)'}`);
316
+ }
317
+ console.log(` line: ${line.channelAccessToken ? `configured via ${line.source}` : 'not configured'}`);
318
+ if (line.channelAccessToken) {
319
+ console.log(` channel secret: ${line.channelSecret ? 'set' : '(not set — needed for webhook replies)'}`);
320
+ console.log(` home channel: ${line.homeChannel ?? '(not set)'}`);
321
+ console.log(` allowed users: ${line.allowedUsers.length ? line.allowedUsers.join(', ') : line.allowAllUsers ? '(all users)' : '(none)'}`);
322
+ console.log(` allowed groups: ${line.allowedGroups.length ? line.allowedGroups.join(', ') : '(none)'}`);
323
+ console.log(` allowed rooms: ${line.allowedRooms.length ? line.allowedRooms.join(', ') : '(none)'}`);
324
+ console.log(` public url: ${line.publicUrl ?? '(not set)'}`);
325
+ }
326
+ console.log(` sms: ${sms.accountSid || sms.authToken || sms.phoneNumber ? `configured via ${sms.source}` : 'not configured'}`);
327
+ if (sms.accountSid || sms.authToken || sms.phoneNumber) {
328
+ console.log(` account sid: ${sms.accountSid ? 'set' : '(not set)'}`);
329
+ console.log(` auth token: ${sms.authToken ? 'set' : '(not set)'}`);
330
+ console.log(` phone number: ${sms.phoneNumber ?? '(not set)'}`);
331
+ console.log(` home channel: ${sms.homeChannel ?? '(not set)'}`);
332
+ console.log(` allowed users: ${sms.allowedUsers.length ? sms.allowedUsers.join(', ') : sms.allowAllUsers ? '(all users)' : '(none)'}`);
333
+ console.log(` webhook url: ${sms.webhookUrl ?? (sms.insecureNoSignature ? '(signature disabled)' : '(not set)')}`);
334
+ }
335
+ console.log(` ntfy: ${ntfy.topic || ntfy.token ? `configured via ${ntfy.source}` : 'not configured'}`);
336
+ if (ntfy.topic || ntfy.token) {
337
+ console.log(` server url: ${ntfy.serverUrl}`);
338
+ console.log(` topic: ${ntfy.topic ?? '(not set)'}`);
339
+ console.log(` publish topic: ${ntfy.publishTopic ?? '(same as topic)'}`);
340
+ console.log(` home channel: ${ntfy.homeChannel ?? '(not set)'}`);
341
+ console.log(` allowed topics: ${ntfy.allowedUsers.length ? ntfy.allowedUsers.join(', ') : ntfy.allowAllUsers ? '(all topics)' : '(none)'}`);
342
+ console.log(` token: ${ntfy.token ? 'set' : '(not set)'}`);
343
+ console.log(` markdown: ${ntfy.markdown ? 'yes' : 'no'}`);
344
+ }
345
+ console.log(` signal: ${signal.account ? `configured via ${signal.source}` : 'not configured'}`);
346
+ if (signal.account) {
347
+ console.log(` http url: ${signal.httpUrl}`);
348
+ console.log(` account: ${redactSignalId(signal.account)}`);
349
+ console.log(` home channel: ${redactSignalId(signal.homeChannel)}`);
350
+ console.log(` allowed users: ${signal.allowedUsers.length ? signal.allowedUsers.map(redactSignalId).join(', ') : signal.allowAllUsers ? '(all users)' : '(none)'}`);
351
+ console.log(` allowed groups: ${signal.groupAllowedUsers.length ? signal.groupAllowedUsers.map(redactSignalId).join(', ') : '(none)'}`);
352
+ console.log(` require mention: ${signal.requireMention ? 'yes' : 'no'}`);
353
+ }
354
+ console.log(` whatsapp: ${whatsapp.phoneNumberId || whatsapp.accessToken ? `configured via ${whatsapp.source}` : 'not configured'}`);
355
+ if (whatsapp.phoneNumberId || whatsapp.accessToken) {
356
+ console.log(` phone number id: ${whatsapp.phoneNumberId ? 'set' : '(not set)'}`);
357
+ console.log(` access token: ${whatsapp.accessToken ? 'set' : '(not set)'}`);
358
+ console.log(` app secret: ${whatsapp.appSecret ? 'set' : '(not set — needed for webhook)'}`);
359
+ console.log(` verify token: ${whatsapp.verifyToken ? 'set' : '(not set — needed for webhook verify)'}`);
360
+ console.log(` home channel: ${redactWhatsAppId(whatsapp.homeChannel)}`);
361
+ console.log(` allowed users: ${whatsapp.allowedUsers.length ? whatsapp.allowedUsers.map(redactWhatsAppId).join(', ') : whatsapp.allowAllUsers ? '(all users)' : '(none)'}`);
362
+ console.log(` public url: ${whatsapp.publicUrl ?? '(not set)'}`);
363
+ console.log(` api version: ${whatsapp.apiVersion}`);
364
+ }
365
+ console.log(` matrix: ${matrix.homeserver || matrix.accessToken || matrix.userId ? `configured via ${matrix.source}` : 'not configured'}`);
366
+ if (matrix.homeserver || matrix.accessToken || matrix.userId) {
367
+ console.log(` homeserver: ${matrix.homeserver ?? '(not set)'}`);
368
+ console.log(` access token: ${matrix.accessToken ? 'set' : '(not set)'}`);
369
+ console.log(` user id: ${matrix.userId ?? '(not set)'}`);
370
+ console.log(` password: ${matrix.password ? 'set' : '(not set)'}`);
371
+ console.log(` home room: ${matrix.homeRoom ?? '(not set)'}`);
372
+ console.log(` allowed users: ${matrix.allowedUsers.length ? matrix.allowedUsers.join(', ') : matrix.allowAllUsers ? '(all users)' : '(none)'}`);
373
+ console.log(` allowed rooms: ${matrix.allowedRooms.length ? matrix.allowedRooms.join(', ') : '(none)'}`);
374
+ console.log(` free rooms: ${matrix.freeResponseRooms.length ? matrix.freeResponseRooms.join(', ') : '(none)'}`);
375
+ console.log(` require mention: ${matrix.requireMention ? 'yes' : 'no'}`);
376
+ console.log(` auto join: ${matrix.autoJoin ? 'yes' : 'no'}`);
377
+ }
378
+ console.log(` googlechat: ${googleChat.serviceAccountJson || googleChat.incomingWebhookUrl ? `configured via ${googleChat.source}` : 'not configured'}`);
379
+ if (googleChat.serviceAccountJson || googleChat.incomingWebhookUrl) {
380
+ console.log(` project id: ${googleChat.projectId ?? '(not set)'}`);
381
+ console.log(` subscription: ${googleChat.subscriptionName ? 'set' : '(not set — needed for Pub/Sub inbound)'}`);
382
+ console.log(` service account: ${googleChat.serviceAccountJson ? 'set' : '(not set)'}`);
383
+ console.log(` api base url: ${googleChat.apiBaseUrl}`);
384
+ console.log(` webhook url: ${googleChat.incomingWebhookUrl ? 'set' : '(not set)'}`);
385
+ console.log(` home channel: ${googleChat.homeChannel ?? '(not set)'}`);
386
+ console.log(` allowed spaces: ${googleChat.allowedSpaces.length ? googleChat.allowedSpaces.join(', ') : googleChat.allowAllSpaces ? '(all spaces)' : '(none)'}`);
387
+ console.log(` allowed users: ${googleChat.allowedUsers.length ? googleChat.allowedUsers.join(', ') : googleChat.allowAllUsers ? '(all users)' : '(none)'}`);
388
+ console.log(` free spaces: ${googleChat.freeResponseSpaces.length ? googleChat.freeResponseSpaces.join(', ') : '(none)'}`);
389
+ console.log(` flow control: messages=${googleChat.maxMessages}, bytes=${googleChat.maxBytes}`);
390
+ }
391
+ console.log(` bluebubbles: ${bluebubbles.serverUrl || bluebubbles.password ? `configured via ${bluebubbles.source}` : 'not configured'}`);
392
+ if (bluebubbles.serverUrl || bluebubbles.password) {
393
+ console.log(` server url: ${bluebubbles.serverUrl ?? '(not set)'}`);
394
+ console.log(` password: ${bluebubbles.password ? 'set' : '(not set)'}`);
395
+ console.log(` webhook: ${bluebubbles.webhookHost}:${bluebubbles.webhookPort}${bluebubbles.webhookPath}`);
396
+ console.log(` home channel: ${bluebubbles.homeChannel ?? '(not set)'}`);
397
+ console.log(` allowed targets: ${bluebubbles.allowedUsers.length ? bluebubbles.allowedUsers.join(', ') : bluebubbles.allowAllUsers ? '(all targets)' : '(none)'}`);
398
+ console.log(` require mention: ${bluebubbles.requireMention ? 'yes' : 'no'}`);
399
+ }
400
+ console.log(` teams: ${teams.incomingWebhookUrl || teams.graphAccessToken || teams.clientId ? `configured via ${teams.source}` : 'not configured'}`);
401
+ if (teams.incomingWebhookUrl || teams.graphAccessToken || teams.clientId) {
402
+ console.log(` delivery mode: ${teams.deliveryMode}`);
403
+ console.log(` webhook url: ${teams.incomingWebhookUrl ? 'set' : '(not set)'}`);
404
+ console.log(` graph token: ${teams.graphAccessToken ? 'set' : '(not set)'}`);
405
+ console.log(` chat id: ${teams.chatId ?? '(not set)'}`);
406
+ console.log(` team/channel: ${teams.teamId && teams.channelId ? `${teams.teamId}/${teams.channelId}` : '(not set)'}`);
407
+ console.log(` home channel: ${teams.homeChannel ?? '(not set)'}`);
408
+ console.log(` bot app: ${teams.clientId && teams.tenantId ? 'set' : '(not set)'}`);
409
+ console.log(` allowed users: ${teams.allowedUsers.length ? teams.allowedUsers.join(', ') : teams.allowAllUsers ? '(all users)' : '(none)'}`);
410
+ console.log(` webhook port: ${teams.port}`);
411
+ }
412
+ console.log(` webhooks: ${webhooks.enabled ? `enabled via ${webhooks.source}` : 'not enabled'} (${Object.keys(webhooks.routes).length} route${Object.keys(webhooks.routes).length === 1 ? '' : 's'})`);
413
+ if (webhooks.enabled) {
414
+ console.log(` global secret: ${webhooks.secret ? 'set' : '(not set)'}`);
415
+ console.log(` public url: ${webhooks.publicUrl ?? '(not set)'}`);
416
+ console.log(` rate limit: ${webhooks.rateLimitPerMinute}/minute`);
417
+ }
418
+ console.log(`\nredacted config:\n${JSON.stringify(redactGatewayConfig(cfg), null, 2)}`);
419
+ }
420
+ async function runGatewaySetup(args) {
421
+ const platformArgProvided = Boolean(args[0] && !args[0].startsWith('--'));
422
+ let platform = platformArgProvided ? args[0] : undefined;
423
+ const rest = platformArgProvided ? args.slice(1) : args;
424
+ if (!platform) {
425
+ if (!process.stdin.isTTY) {
426
+ console.error(`ใช้: ${BRAND.cliName} gateway setup <telegram|discord|slack|mattermost|homeassistant|email|line|sms|ntfy|signal|whatsapp|matrix|googlechat|bluebubbles|teams|webhooks> [options]`);
427
+ process.exit(1);
428
+ }
429
+ const { readGatewayConfig, resolveBlueBubblesConfig, resolveDiscordConfig, resolveEmailConfig, resolveGoogleChatConfig, resolveHomeAssistantConfig, resolveLineConfig, resolveMattermostConfig, resolveMatrixConfig, resolveNtfyConfig, resolveSignalConfig, resolveSlackConfig, resolveSmsConfig, resolveTelegramConfig, resolveTeamsConfig, resolveWhatsAppConfig, resolveWebhookConfig, } = await import('./gateway/config.js');
430
+ const cfg = await readGatewayConfig();
431
+ const options = [
432
+ { id: 'telegram', label: `Telegram ${resolveTelegramConfig(cfg).token ? '(configured)' : ''}` },
433
+ { id: 'discord', label: `Discord ${resolveDiscordConfig(cfg).token ? '(configured)' : ''}` },
434
+ { id: 'slack', label: `Slack ${resolveSlackConfig(cfg).botToken ? '(configured)' : ''}` },
435
+ { id: 'mattermost', label: `Mattermost ${resolveMattermostConfig(cfg).serverUrl ? '(configured)' : ''}` },
436
+ { id: 'homeassistant', label: `Home Assistant ${resolveHomeAssistantConfig(cfg).token ? '(configured)' : ''}` },
437
+ { id: 'email', label: `Email ${resolveEmailConfig(cfg).address ? '(configured)' : ''}` },
438
+ { id: 'line', label: `LINE ${resolveLineConfig(cfg).channelAccessToken ? '(configured)' : ''}` },
439
+ { id: 'sms', label: `SMS/Twilio ${resolveSmsConfig(cfg).accountSid ? '(configured)' : ''}` },
440
+ { id: 'ntfy', label: `ntfy ${resolveNtfyConfig(cfg).topic ? '(configured)' : ''}` },
441
+ { id: 'signal', label: `Signal ${resolveSignalConfig(cfg).account ? '(configured)' : ''}` },
442
+ { id: 'whatsapp', label: `WhatsApp Cloud ${resolveWhatsAppConfig(cfg).phoneNumberId ? '(configured)' : ''}` },
443
+ { id: 'matrix', label: `Matrix ${resolveMatrixConfig(cfg).homeserver ? '(configured)' : ''}` },
444
+ { id: 'googlechat', label: `Google Chat ${resolveGoogleChatConfig(cfg).serviceAccountJson || resolveGoogleChatConfig(cfg).incomingWebhookUrl ? '(configured)' : ''}` },
445
+ { id: 'bluebubbles', label: `BlueBubbles/iMessage ${resolveBlueBubblesConfig(cfg).serverUrl ? '(configured)' : ''}` },
446
+ { id: 'teams', label: `Microsoft Teams ${resolveTeamsConfig(cfg).incomingWebhookUrl || resolveTeamsConfig(cfg).graphAccessToken ? '(configured)' : ''}` },
447
+ { id: 'webhooks', label: `Webhooks ${resolveWebhookConfig(cfg).enabled ? '(configured)' : ''}` },
448
+ ];
449
+ console.log(`${BRAND.productName} gateway setup`);
450
+ for (const [i, option] of options.entries())
451
+ console.log(` ${i + 1}. ${option.label}`);
452
+ const answer = await askText('เลือก platform [1-16]: ');
453
+ const index = Number(answer || '1') - 1;
454
+ platform = options[index]?.id;
455
+ }
456
+ if (platform === 'whatsapp-cloud')
457
+ platform = 'whatsapp';
458
+ if (platform === 'msteams' || platform === 'ms-teams' || platform === 'microsoft-teams')
459
+ platform = 'teams';
460
+ if (platform === 'google-chat' || platform === 'google_chat' || platform === 'gchat')
461
+ platform = 'googlechat';
462
+ if (platform === 'blue-bubbles' || platform === 'blue_bubbles' || platform === 'imessage')
463
+ platform = 'bluebubbles';
464
+ if (!platform ||
465
+ ![
466
+ 'telegram',
467
+ 'discord',
468
+ 'slack',
469
+ 'mattermost',
470
+ 'homeassistant',
471
+ 'hass',
472
+ 'email',
473
+ 'line',
474
+ 'sms',
475
+ 'ntfy',
476
+ 'signal',
477
+ 'whatsapp',
478
+ 'matrix',
479
+ 'googlechat',
480
+ 'bluebubbles',
481
+ 'teams',
482
+ 'webhooks',
483
+ ].includes(platform)) {
484
+ console.error(`ตอนนี้ setup อัตโนมัติรองรับ telegram / discord / slack / mattermost / homeassistant / email / line / sms / ntfy / signal / whatsapp / matrix / googlechat / bluebubbles / teams / webhooks — ได้ "${platform ?? ''}"`);
485
+ process.exit(1);
486
+ }
487
+ if (platform === 'discord')
488
+ return runDiscordGatewaySetup(rest);
489
+ if (platform === 'slack')
490
+ return runSlackGatewaySetup(rest);
491
+ if (platform === 'mattermost')
492
+ return runMattermostGatewaySetup(rest);
493
+ if (platform === 'homeassistant' || platform === 'hass')
494
+ return runHomeAssistantGatewaySetup(rest);
495
+ if (platform === 'email')
496
+ return runEmailGatewaySetup(rest);
497
+ if (platform === 'line')
498
+ return runLineGatewaySetup(rest);
499
+ if (platform === 'sms')
500
+ return runSmsGatewaySetup(rest);
501
+ if (platform === 'ntfy')
502
+ return runNtfyGatewaySetup(rest);
503
+ if (platform === 'signal')
504
+ return runSignalGatewaySetup(rest);
505
+ if (platform === 'whatsapp')
506
+ return runWhatsAppGatewaySetup(rest);
507
+ if (platform === 'matrix')
508
+ return runMatrixGatewaySetup(rest);
509
+ if (platform === 'googlechat')
510
+ return runGoogleChatGatewaySetup(rest);
511
+ if (platform === 'bluebubbles')
512
+ return runBlueBubblesGatewaySetup(rest);
513
+ if (platform === 'teams')
514
+ return runTeamsGatewaySetup(rest);
515
+ if (platform === 'webhooks')
516
+ return runWebhookGatewaySetup(rest);
517
+ let token = argValue(rest, '--bot-token', '--token');
518
+ let allowedRaw = argValue(rest, '--allowed-chats', '--chat-ids');
519
+ const allowWrite = rest.includes('--allow-write');
520
+ if (!token) {
521
+ if (!process.stdin.isTTY) {
522
+ console.error(`ใช้: ${BRAND.cliName} gateway setup telegram --bot-token <token> --allowed-chats <chat_id[,chat_id]>`);
523
+ process.exit(1);
524
+ }
525
+ console.log(`${BRAND.productName} Telegram setup`);
526
+ console.log(`สร้าง bot ผ่าน @BotFather แล้ววาง token ที่นี่ (จะเก็บใน ${appHomePath('gateway', 'config.json')} chmod 600)`);
527
+ token = await askText('Telegram bot token: ');
528
+ }
529
+ if (!allowedRaw) {
530
+ if (!process.stdin.isTTY) {
531
+ console.error('ต้องระบุ --allowed-chats <chat_id[,chat_id]> เพื่อ fail-closed');
532
+ process.exit(1);
533
+ }
534
+ allowedRaw = await askText('Allowed private chat IDs (comma-separated): ');
535
+ }
536
+ const { parseAllowedChats } = await import('./gateway/telegram.js');
537
+ const allowedChatIds = parseAllowedChats(allowedRaw);
538
+ if (!token.trim() || !allowedChatIds.length) {
539
+ console.error('Telegram setup ต้องมี bot token และ allowed chat id อย่างน้อย 1 ค่า');
540
+ process.exit(1);
541
+ }
542
+ const { patchGatewayConfig, gatewayConfigPath } = await import('./gateway/config.js');
543
+ await patchGatewayConfig({
544
+ telegram: {
545
+ enabled: true,
546
+ botToken: token.trim(),
547
+ allowedChatIds,
548
+ allowWrite,
549
+ },
550
+ });
551
+ console.log(`บันทึก Telegram gateway config แล้ว: ${gatewayConfigPath()}`);
552
+ console.log(`รัน: ${BRAND.cliName} gateway run`);
553
+ }
554
+ function parseStringCsv(raw) {
555
+ if (!raw)
556
+ return [];
557
+ return raw
558
+ .split(',')
559
+ .map((s) => s.trim())
560
+ .filter(Boolean);
561
+ }
562
+ async function runDiscordGatewaySetup(args) {
563
+ let token = argValue(args, '--bot-token', '--token');
564
+ let defaultChannel = argValue(args, '--channel', '--default-channel');
565
+ let allowedRaw = argValue(args, '--allowed-channels', '--channel-ids');
566
+ const allowWrite = args.includes('--allow-write');
567
+ if (!token) {
568
+ if (!process.stdin.isTTY) {
569
+ console.error(`ใช้: ${BRAND.cliName} gateway setup discord --bot-token <token> --channel <channel_id>`);
570
+ process.exit(1);
571
+ }
572
+ console.log(`${BRAND.productName} Discord setup`);
573
+ console.log('สร้าง bot ใน Discord Developer Portal, เปิด Message Content Intent, แล้ววาง Bot Token ที่นี่');
574
+ token = await askText('Discord bot token: ');
575
+ }
576
+ if (!defaultChannel && !allowedRaw) {
577
+ if (!process.stdin.isTTY) {
578
+ console.error('ต้องระบุ --channel <channel_id> หรือ --allowed-channels <id[,id]>');
579
+ process.exit(1);
580
+ }
581
+ defaultChannel = await askText('Default Discord channel ID: ');
582
+ }
583
+ const allowedChannelIds = parseStringCsv(allowedRaw ?? defaultChannel);
584
+ if (!token.trim() || !allowedChannelIds.length) {
585
+ console.error('Discord setup ต้องมี bot token และ channel id อย่างน้อย 1 ค่า');
586
+ process.exit(1);
587
+ }
588
+ const { patchGatewayConfig, gatewayConfigPath } = await import('./gateway/config.js');
589
+ await patchGatewayConfig({
590
+ discord: {
591
+ enabled: true,
592
+ botToken: token.trim(),
593
+ defaultChannelId: (defaultChannel ?? allowedChannelIds[0]).trim(),
594
+ allowedChannelIds,
595
+ allowWrite,
596
+ },
597
+ });
598
+ console.log(`บันทึก Discord gateway config แล้ว: ${gatewayConfigPath()}`);
599
+ console.log(`ส่งทดสอบได้ด้วย: ${BRAND.cliName} send --to discord "hello"`);
600
+ }
601
+ async function runSlackGatewaySetup(args) {
602
+ let botToken = argValue(args, '--bot-token', '--token');
603
+ let appToken = argValue(args, '--app-token');
604
+ let defaultChannel = argValue(args, '--channel', '--default-channel');
605
+ let allowedRaw = argValue(args, '--allowed-channels', '--channel-ids');
606
+ const allowWrite = args.includes('--allow-write');
607
+ if (!botToken) {
608
+ if (!process.stdin.isTTY) {
609
+ console.error(`ใช้: ${BRAND.cliName} gateway setup slack --bot-token <xoxb-token> --app-token <xapp-token> --channel <channel_id>`);
610
+ process.exit(1);
611
+ }
612
+ console.log(`${BRAND.productName} Slack setup`);
613
+ console.log('สร้าง Slack app, เปิด Socket Mode, เพิ่ม scopes แล้ววาง Bot Token (xoxb-) ที่นี่');
614
+ botToken = await askText('Slack bot token (xoxb-): ');
615
+ }
616
+ if (!appToken && process.stdin.isTTY) {
617
+ appToken = await askText('Slack app token (xapp-, optional for outbound send but needed for gateway Socket Mode): ');
618
+ }
619
+ if (!defaultChannel && !allowedRaw) {
620
+ if (!process.stdin.isTTY) {
621
+ console.error('ต้องระบุ --channel <channel_id> หรือ --allowed-channels <id[,id]>');
622
+ process.exit(1);
623
+ }
624
+ defaultChannel = await askText('Default Slack channel ID: ');
625
+ }
626
+ const allowedChannelIds = parseStringCsv(allowedRaw ?? defaultChannel);
627
+ if (!botToken.trim() || !allowedChannelIds.length) {
628
+ console.error('Slack setup ต้องมี bot token และ channel id อย่างน้อย 1 ค่า');
629
+ process.exit(1);
630
+ }
631
+ const { patchGatewayConfig, gatewayConfigPath } = await import('./gateway/config.js');
632
+ await patchGatewayConfig({
633
+ slack: {
634
+ enabled: true,
635
+ botToken: botToken.trim(),
636
+ appToken: appToken?.trim() || undefined,
637
+ defaultChannelId: (defaultChannel ?? allowedChannelIds[0]).trim(),
638
+ allowedChannelIds,
639
+ allowWrite,
640
+ },
641
+ });
642
+ console.log(`บันทึก Slack gateway config แล้ว: ${gatewayConfigPath()}`);
643
+ console.log(`ส่งทดสอบได้ด้วย: ${BRAND.cliName} send --to slack "hello"`);
644
+ }
645
+ async function runMattermostGatewaySetup(args) {
646
+ let serverUrl = argValue(args, '--url', '--server-url');
647
+ let token = argValue(args, '--token');
648
+ let homeChannel = argValue(args, '--home-channel', '--channel', '--to');
649
+ let allowedUsersRaw = argValue(args, '--allowed-users');
650
+ let allowedChannelsRaw = argValue(args, '--allowed-channels', '--channel-ids');
651
+ let freeResponseChannelsRaw = argValue(args, '--free-response-channels');
652
+ const homeChannelName = argValue(args, '--home-channel-name');
653
+ const allowAllUsers = args.includes('--allow-all-users');
654
+ const requireMention = !args.includes('--no-require-mention');
655
+ const groupSessionsPerUser = !args.includes('--shared-channel-session');
656
+ const replyMode = args.includes('--thread-replies') || argValue(args, '--reply-mode') === 'thread' ? 'thread' : 'off';
657
+ if (!serverUrl) {
658
+ if (!process.stdin.isTTY) {
659
+ console.error(`ใช้: ${BRAND.cliName} gateway setup mattermost --url <https://mm.example.com> --token <token> --allowed-users <user_id[,user_id]> --home-channel <channel_id>`);
660
+ process.exit(1);
661
+ }
662
+ console.log(`${BRAND.productName} Mattermost setup`);
663
+ console.log('ใช้ Mattermost REST API v4 + WebSocket; แนะนำ token ของ dedicated bot account');
664
+ serverUrl = await askText('Mattermost server URL (เช่น https://mm.example.com): ');
665
+ }
666
+ if (!token) {
667
+ if (!process.stdin.isTTY) {
668
+ console.error('ต้องระบุ --token <Mattermost personal/bot access token>');
669
+ process.exit(1);
670
+ }
671
+ token = await askText('Mattermost token: ');
672
+ }
673
+ if (!allowedUsersRaw && !allowAllUsers) {
674
+ if (!process.stdin.isTTY) {
675
+ console.error('ต้องระบุ --allowed-users <user_id[,user_id]> เพื่อ fail-closed หรือ --allow-all-users');
676
+ process.exit(1);
677
+ }
678
+ allowedUsersRaw = await askText('Allowed Mattermost user IDs (comma-separated): ');
679
+ }
680
+ if (!homeChannel && !allowedChannelsRaw) {
681
+ if (!process.stdin.isTTY) {
682
+ console.error('ต้องระบุ --home-channel <channel_id> หรือ --allowed-channels <channel_id[,id]> เพื่อส่งข้อความออกแบบ fail-closed');
683
+ process.exit(1);
684
+ }
685
+ homeChannel = await askText('Mattermost home channel ID (blank = skip outbound home): ');
686
+ }
687
+ const { normalizeMattermostUrl } = await import('./gateway/mattermost.js');
688
+ const cleanServerUrl = normalizeMattermostUrl(serverUrl);
689
+ const cleanHome = homeChannel?.trim();
690
+ const allowedUsers = parseStringCsv(allowedUsersRaw);
691
+ const allowedChannels = parseStringCsv(allowedChannelsRaw ?? cleanHome);
692
+ const freeResponseChannels = parseStringCsv(freeResponseChannelsRaw);
693
+ if (!cleanServerUrl) {
694
+ console.error('Mattermost setup ต้องมี server URL ที่ขึ้นต้นด้วย http:// หรือ https://');
695
+ process.exit(1);
696
+ }
697
+ if (!token.trim()) {
698
+ console.error('Mattermost setup ต้องมี token');
699
+ process.exit(1);
700
+ }
701
+ if (!allowAllUsers && !allowedUsers.length) {
702
+ console.error('Mattermost setup ต้องมี allowed users อย่างน้อย 1 ค่า หรือระบุ --allow-all-users');
703
+ process.exit(1);
704
+ }
705
+ if (!cleanHome && !allowedChannels.length) {
706
+ console.error('Mattermost setup ต้องมี home channel/allowed channels อย่างน้อย 1 ค่า');
707
+ process.exit(1);
708
+ }
709
+ const { patchGatewayConfig, gatewayConfigPath } = await import('./gateway/config.js');
710
+ await patchGatewayConfig({
711
+ mattermost: {
712
+ enabled: true,
713
+ serverUrl: cleanServerUrl,
714
+ token: token.trim(),
715
+ homeChannel: cleanHome || allowedChannels[0],
716
+ homeChannelName: homeChannelName?.trim() || undefined,
717
+ allowedUsers,
718
+ allowedChannels,
719
+ freeResponseChannels,
720
+ allowAllUsers,
721
+ requireMention,
722
+ groupSessionsPerUser,
723
+ replyMode,
724
+ },
725
+ });
726
+ console.log(`บันทึก Mattermost gateway config แล้ว: ${gatewayConfigPath()}`);
727
+ console.log(`Mattermost websocket: ${cleanServerUrl}/api/v4/websocket`);
728
+ console.log(`ส่งทดสอบได้ด้วย: ${BRAND.cliName} send --to mattermost "hello"`);
729
+ }
730
+ async function runHomeAssistantGatewaySetup(args) {
731
+ const url = argValue(args, '--url')?.trim() || 'http://homeassistant.local:8123';
732
+ let token = argValue(args, '--token');
733
+ let homeChannel = argValue(args, '--home-channel', '--notification-id', '--to');
734
+ let watchDomainsRaw = argValue(args, '--watch-domains', '--domains');
735
+ let watchEntitiesRaw = argValue(args, '--watch-entities', '--entities');
736
+ let ignoreEntitiesRaw = argValue(args, '--ignore-entities');
737
+ const homeChannelName = argValue(args, '--home-channel-name');
738
+ const watchAll = args.includes('--watch-all');
739
+ const cooldownSecondsRaw = argValue(args, '--cooldown-seconds', '--cooldown');
740
+ if (!token) {
741
+ if (!process.stdin.isTTY) {
742
+ console.error(`ใช้: ${BRAND.cliName} gateway setup homeassistant --token <long-lived-token> [--url http://homeassistant.local:8123] --watch-domains climate,binary_sensor`);
743
+ process.exit(1);
744
+ }
745
+ console.log(`${BRAND.productName} Home Assistant setup`);
746
+ console.log('สร้าง Long-Lived Access Token จาก Home Assistant Profile แล้ววาง token ที่นี่');
747
+ token = await askText('Home Assistant long-lived access token: ');
748
+ }
749
+ if (!homeChannel && process.stdin.isTTY) {
750
+ homeChannel = (await askText('Persistent notification id (blank = sanook_agent): ')) || 'sanook_agent';
751
+ }
752
+ if (!watchDomainsRaw && !watchEntitiesRaw && !watchAll) {
753
+ if (!process.stdin.isTTY) {
754
+ console.error('ต้องระบุ --watch-domains, --watch-entities หรือ --watch-all เพื่อรับ state_changed events');
755
+ process.exit(1);
756
+ }
757
+ watchDomainsRaw = await askText('Watch domains (comma-separated; เช่น climate,binary_sensor,alarm_control_panel): ');
758
+ }
759
+ const { homeAssistantWebSocketUrl, normalizeHomeAssistantUrl } = await import('./gateway/homeassistant.js');
760
+ const cleanUrl = normalizeHomeAssistantUrl(url);
761
+ const watchDomains = parseStringCsv(watchDomainsRaw);
762
+ const watchEntities = parseStringCsv(watchEntitiesRaw);
763
+ const ignoreEntities = parseStringCsv(ignoreEntitiesRaw);
764
+ const cooldownSeconds = cooldownSecondsRaw ? Number(cooldownSecondsRaw) : undefined;
765
+ if (!cleanUrl) {
766
+ console.error('Home Assistant setup ต้องมี URL ที่ขึ้นต้นด้วย http:// หรือ https://');
767
+ process.exit(1);
768
+ }
769
+ if (!token.trim()) {
770
+ console.error('Home Assistant setup ต้องมี token');
771
+ process.exit(1);
772
+ }
773
+ if (!watchAll && !watchDomains.length && !watchEntities.length) {
774
+ console.error('Home Assistant setup ต้องมี watch domains/entities อย่างน้อย 1 ค่า หรือระบุ --watch-all');
775
+ process.exit(1);
776
+ }
777
+ if (cooldownSecondsRaw && (!Number.isInteger(cooldownSeconds) || Number(cooldownSeconds) <= 0)) {
778
+ console.error('--cooldown-seconds ต้องเป็น integer มากกว่า 0');
779
+ process.exit(1);
780
+ }
781
+ const { patchGatewayConfig, gatewayConfigPath } = await import('./gateway/config.js');
782
+ await patchGatewayConfig({
783
+ homeassistant: {
784
+ enabled: true,
785
+ url: cleanUrl,
786
+ token: token.trim(),
787
+ homeChannel: homeChannel?.trim() || 'sanook_agent',
788
+ homeChannelName: homeChannelName?.trim() || undefined,
789
+ watchDomains,
790
+ watchEntities,
791
+ ignoreEntities,
792
+ watchAll,
793
+ cooldownSeconds,
794
+ },
795
+ });
796
+ console.log(`บันทึก Home Assistant gateway config แล้ว: ${gatewayConfigPath()}`);
797
+ console.log(`Home Assistant websocket: ${homeAssistantWebSocketUrl(cleanUrl)}`);
798
+ console.log(`ส่งทดสอบได้ด้วย: ${BRAND.cliName} send --to homeassistant "hello"`);
799
+ }
800
+ async function runLineGatewaySetup(args) {
801
+ let channelAccessToken = argValue(args, '--channel-access-token', '--access-token', '--token');
802
+ let channelSecret = argValue(args, '--channel-secret', '--secret');
803
+ let homeChannel = argValue(args, '--home-channel', '--to');
804
+ let allowedUsersRaw = argValue(args, '--allowed-users');
805
+ let allowedGroupsRaw = argValue(args, '--allowed-groups');
806
+ let allowedRoomsRaw = argValue(args, '--allowed-rooms');
807
+ const publicUrl = argValue(args, '--public-url');
808
+ const allowAllUsers = args.includes('--allow-all-users');
809
+ if (!channelAccessToken) {
810
+ if (!process.stdin.isTTY) {
811
+ console.error(`ใช้: ${BRAND.cliName} gateway setup line --channel-access-token <token> --home-channel <U/C/R-id>`);
812
+ process.exit(1);
813
+ }
814
+ console.log(`${BRAND.productName} LINE setup`);
815
+ console.log('สร้าง LINE Messaging API channel แล้ววาง long-lived Channel access token ที่นี่');
816
+ channelAccessToken = await askText('LINE channel access token: ');
817
+ }
818
+ if (!channelSecret && process.stdin.isTTY) {
819
+ channelSecret = await askText('LINE channel secret (needed for webhook replies): ');
820
+ }
821
+ if (!homeChannel && !allowedUsersRaw && !allowedGroupsRaw && !allowedRoomsRaw && !allowAllUsers) {
822
+ if (!process.stdin.isTTY) {
823
+ console.error('ต้องระบุ --home-channel <U/C/R-id> หรือ allowed list อย่างน้อยหนึ่งชุด เพื่อ fail-closed');
824
+ process.exit(1);
825
+ }
826
+ homeChannel = await askText('LINE home channel ID (U user / C group / R room): ');
827
+ }
828
+ const home = homeChannel?.trim();
829
+ const allowedUsers = parseStringCsv(allowedUsersRaw);
830
+ const allowedGroups = parseStringCsv(allowedGroupsRaw);
831
+ const allowedRooms = parseStringCsv(allowedRoomsRaw);
832
+ if (!allowAllUsers && !home && !allowedUsers.length && !allowedGroups.length && !allowedRooms.length) {
833
+ console.error('LINE setup ต้องมี home channel/allowlist อย่างน้อย 1 ค่า หรือระบุ --allow-all-users');
834
+ process.exit(1);
835
+ }
836
+ if (!channelAccessToken.trim()) {
837
+ console.error('LINE setup ต้องมี channel access token');
838
+ process.exit(1);
839
+ }
840
+ const { patchGatewayConfig, gatewayConfigPath } = await import('./gateway/config.js');
841
+ await patchGatewayConfig({
842
+ line: {
843
+ enabled: true,
844
+ channelAccessToken: channelAccessToken.trim(),
845
+ channelSecret: channelSecret?.trim() || undefined,
846
+ homeChannel: home || allowedUsers[0] || allowedGroups[0] || allowedRooms[0],
847
+ allowedUsers,
848
+ allowedGroups,
849
+ allowedRooms,
850
+ allowAllUsers,
851
+ publicUrl: publicUrl?.trim() || undefined,
852
+ },
853
+ });
854
+ console.log(`บันทึก LINE gateway config แล้ว: ${gatewayConfigPath()}`);
855
+ console.log(`ส่งทดสอบได้ด้วย: ${BRAND.cliName} send --to line "hello"`);
856
+ }
857
+ async function runSmsGatewaySetup(args) {
858
+ let accountSid = argValue(args, '--account-sid', '--sid');
859
+ let authToken = argValue(args, '--auth-token', '--token');
860
+ let phoneNumber = argValue(args, '--phone-number', '--from');
861
+ let homeChannel = argValue(args, '--home-channel', '--to');
862
+ let allowedRaw = argValue(args, '--allowed-users', '--allowed-numbers');
863
+ const homeChannelName = argValue(args, '--home-channel-name');
864
+ const webhookUrl = argValue(args, '--webhook-url');
865
+ const allowAllUsers = args.includes('--allow-all-users');
866
+ const insecureNoSignature = args.includes('--insecure-no-signature');
867
+ if (!accountSid) {
868
+ if (!process.stdin.isTTY) {
869
+ console.error(`ใช้: ${BRAND.cliName} gateway setup sms --account-sid <AC...> --auth-token <token> --phone-number <+1555...> --home-channel <+1555...> --webhook-url <https://.../sms/webhook>`);
870
+ process.exit(1);
871
+ }
872
+ console.log(`${BRAND.productName} SMS/Twilio setup`);
873
+ console.log('ใช้ Twilio Programmable Messaging; inbound webhook ต้องตั้ง URL เดียวกันใน Twilio Console');
874
+ accountSid = await askText('Twilio Account SID: ');
875
+ }
876
+ if (!authToken) {
877
+ if (!process.stdin.isTTY) {
878
+ console.error('ต้องระบุ --auth-token <token>');
879
+ process.exit(1);
880
+ }
881
+ authToken = await askText('Twilio Auth Token: ');
882
+ }
883
+ if (!phoneNumber) {
884
+ if (!process.stdin.isTTY) {
885
+ console.error('ต้องระบุ --phone-number <E.164 Twilio number>');
886
+ process.exit(1);
887
+ }
888
+ phoneNumber = await askText('Twilio phone number (+1555...): ');
889
+ }
890
+ if (!homeChannel && !allowedRaw && !allowAllUsers) {
891
+ if (!process.stdin.isTTY) {
892
+ console.error('ต้องระบุ --home-channel <phone> หรือ --allowed-users <phone[,phone]> เพื่อ fail-closed');
893
+ process.exit(1);
894
+ }
895
+ homeChannel = await askText('Home/allowed phone number (+1555...): ');
896
+ }
897
+ const { normalizeSmsPhone } = await import('./gateway/sms.js');
898
+ const from = normalizeSmsPhone(phoneNumber);
899
+ const home = normalizeSmsPhone(homeChannel);
900
+ const allowedUsers = parseStringCsv(allowedRaw ?? home).map((phone) => normalizeSmsPhone(phone)).filter((phone) => Boolean(phone));
901
+ if (!accountSid.trim() || !authToken.trim() || !from) {
902
+ console.error('SMS setup ต้องมี account sid, auth token และ Twilio phone number');
903
+ process.exit(1);
904
+ }
905
+ if (!allowAllUsers && !home && !allowedUsers.length) {
906
+ console.error('SMS setup ต้องมี home channel/allowlist อย่างน้อย 1 ค่า หรือระบุ --allow-all-users');
907
+ process.exit(1);
908
+ }
909
+ if (!webhookUrl && !insecureNoSignature) {
910
+ if (!process.stdin.isTTY) {
911
+ console.error('ต้องระบุ --webhook-url <https://.../sms/webhook> เพื่อ verify Twilio signature หรือ --insecure-no-signature สำหรับ local dev');
912
+ process.exit(1);
913
+ }
914
+ console.log('ยังไม่ได้ตั้ง webhook URL; inbound SMS จะไม่เริ่มจนกว่าจะตั้ง SMS_WEBHOOK_URL หรือรัน setup ใหม่พร้อม --webhook-url');
915
+ }
916
+ const { patchGatewayConfig, gatewayConfigPath } = await import('./gateway/config.js');
917
+ await patchGatewayConfig({
918
+ sms: {
919
+ enabled: true,
920
+ accountSid: accountSid.trim(),
921
+ authToken: authToken.trim(),
922
+ phoneNumber: from,
923
+ homeChannel: home || allowedUsers[0],
924
+ homeChannelName: homeChannelName?.trim() || undefined,
925
+ allowedUsers,
926
+ allowAllUsers,
927
+ webhookUrl: webhookUrl?.trim() || undefined,
928
+ insecureNoSignature,
929
+ },
930
+ });
931
+ console.log(`บันทึก SMS/Twilio gateway config แล้ว: ${gatewayConfigPath()}`);
932
+ console.log(`ตั้ง Twilio webhook เป็น: ${webhookUrl?.trim() || `http://127.0.0.1:<port>/sms/webhook`}`);
933
+ console.log(`ส่งทดสอบได้ด้วย: ${BRAND.cliName} send --to sms "hello"`);
934
+ }
935
+ async function runSignalGatewaySetup(args) {
936
+ const httpUrl = argValue(args, '--http-url', '--url')?.trim() || 'http://127.0.0.1:8080';
937
+ let account = argValue(args, '--account', '--phone-number');
938
+ let homeChannel = argValue(args, '--home-channel', '--to');
939
+ let allowedRaw = argValue(args, '--allowed-users', '--allowed-numbers');
940
+ let groupAllowedRaw = argValue(args, '--group-allowed-users', '--allowed-groups');
941
+ const homeChannelName = argValue(args, '--home-channel-name');
942
+ const allowAllUsers = args.includes('--allow-all-users');
943
+ const requireMention = args.includes('--require-mention');
944
+ if (!account) {
945
+ if (!process.stdin.isTTY) {
946
+ console.error(`ใช้: ${BRAND.cliName} gateway setup signal --account <+1555...> --home-channel <+1555...> [--http-url http://127.0.0.1:8080]`);
947
+ process.exit(1);
948
+ }
949
+ console.log(`${BRAND.productName} Signal setup`);
950
+ console.log('ต้องมี signal-cli daemon --http รันอยู่; Sanook ใช้ JSON-RPC /api/v1/rpc และ SSE /api/v1/events');
951
+ account = await askText('Signal account (+E.164): ');
952
+ }
953
+ if (!homeChannel && !allowedRaw && !groupAllowedRaw && !allowAllUsers) {
954
+ if (process.stdin.isTTY) {
955
+ homeChannel = await askText('Signal home/allowed user (+E.164 หรือ UUID; blank = account/Note to Self): ');
956
+ }
957
+ if (!homeChannel)
958
+ homeChannel = account;
959
+ }
960
+ if (!allowedRaw && !allowAllUsers && homeChannel && !homeChannel.trim().toLowerCase().startsWith('group:')) {
961
+ allowedRaw = homeChannel;
962
+ }
963
+ if (!groupAllowedRaw && homeChannel?.trim().toLowerCase().startsWith('group:')) {
964
+ groupAllowedRaw = homeChannel;
965
+ }
966
+ const { normalizeSignalId } = await import('./gateway/signal.js');
967
+ const cleanAccount = normalizeSignalId(account);
968
+ const cleanHome = normalizeSignalId(homeChannel);
969
+ const allowedUsers = parseStringCsv(allowedRaw).map(normalizeSignalId).filter((id) => Boolean(id));
970
+ const groupAllowedUsers = parseStringCsv(groupAllowedRaw)
971
+ .map((id) => {
972
+ if (id.trim() === '*')
973
+ return '*';
974
+ const normalized = normalizeSignalId(id);
975
+ return normalized?.startsWith('group:') ? normalized : normalized ? `group:${normalized}` : undefined;
976
+ })
977
+ .filter((id) => Boolean(id));
978
+ if (!cleanAccount) {
979
+ console.error('Signal setup ต้องมี account (+E.164 หรือ account id)');
980
+ process.exit(1);
981
+ }
982
+ if (!allowAllUsers && !cleanHome && !allowedUsers.length && !groupAllowedUsers.length) {
983
+ console.error('Signal setup ต้องมี home channel/allowlist อย่างน้อย 1 ค่า หรือระบุ --allow-all-users');
984
+ process.exit(1);
985
+ }
986
+ const { patchGatewayConfig, gatewayConfigPath } = await import('./gateway/config.js');
987
+ await patchGatewayConfig({
988
+ signal: {
989
+ enabled: true,
990
+ httpUrl,
991
+ account: cleanAccount,
992
+ homeChannel: cleanHome || allowedUsers[0] || groupAllowedUsers[0] || cleanAccount,
993
+ homeChannelName: homeChannelName?.trim() || undefined,
994
+ allowedUsers,
995
+ groupAllowedUsers,
996
+ allowAllUsers,
997
+ requireMention,
998
+ },
999
+ });
1000
+ console.log(`บันทึก Signal gateway config แล้ว: ${gatewayConfigPath()}`);
1001
+ console.log(`ตรวจ signal-cli daemon: ${httpUrl}/api/v1/check`);
1002
+ console.log(`ส่งทดสอบได้ด้วย: ${BRAND.cliName} send --to signal "hello"`);
1003
+ }
1004
+ async function runWhatsAppGatewaySetup(args) {
1005
+ let phoneNumberId = argValue(args, '--phone-number-id', '--phone-id');
1006
+ let accessToken = argValue(args, '--access-token', '--token');
1007
+ let appSecret = argValue(args, '--app-secret', '--secret');
1008
+ let verifyToken = argValue(args, '--verify-token');
1009
+ let homeChannel = argValue(args, '--home-channel', '--to');
1010
+ let allowedRaw = argValue(args, '--allowed-users', '--allowed-numbers');
1011
+ const homeChannelName = argValue(args, '--home-channel-name');
1012
+ const publicUrl = argValue(args, '--public-url');
1013
+ const apiVersion = argValue(args, '--api-version');
1014
+ const allowAllUsers = args.includes('--allow-all-users');
1015
+ if (!phoneNumberId) {
1016
+ if (!process.stdin.isTTY) {
1017
+ console.error(`ใช้: ${BRAND.cliName} gateway setup whatsapp --phone-number-id <id> --access-token <EAA...> --app-secret <secret> --home-channel <wa_id>`);
1018
+ process.exit(1);
1019
+ }
1020
+ console.log(`${BRAND.productName} WhatsApp Cloud setup`);
1021
+ console.log('ใช้ Meta WhatsApp Business Cloud API: ต้องมี Phone Number ID, Access Token, App Secret และ public HTTPS webhook URL');
1022
+ phoneNumberId = await askText('WhatsApp Phone Number ID (ตัวเลขจาก Meta API Setup ไม่ใช่เบอร์โทร): ');
1023
+ }
1024
+ if (!accessToken) {
1025
+ if (!process.stdin.isTTY) {
1026
+ console.error('ต้องระบุ --access-token <Meta WhatsApp Cloud token>');
1027
+ process.exit(1);
1028
+ }
1029
+ accessToken = await askText('WhatsApp Cloud access token: ');
1030
+ }
1031
+ if (!appSecret) {
1032
+ if (!process.stdin.isTTY) {
1033
+ console.error('ต้องระบุ --app-secret <Meta app secret> เพื่อ verify X-Hub-Signature-256');
1034
+ process.exit(1);
1035
+ }
1036
+ appSecret = await askText('Meta app secret (Settings > Basic): ');
1037
+ }
1038
+ if (!homeChannel && !allowedRaw && !allowAllUsers) {
1039
+ if (!process.stdin.isTTY) {
1040
+ console.error('ต้องระบุ --home-channel <wa_id> หรือ --allowed-users <wa_id[,wa_id]> เพื่อ fail-closed');
1041
+ process.exit(1);
1042
+ }
1043
+ homeChannel = await askText('WhatsApp home/allowed wa_id (country code, no +): ');
1044
+ }
1045
+ const { randomBytes } = await import('node:crypto');
1046
+ const { normalizeWhatsAppId } = await import('./gateway/whatsapp.js');
1047
+ const cleanPhoneNumberId = phoneNumberId.trim();
1048
+ const cleanHome = normalizeWhatsAppId(homeChannel);
1049
+ const allowedUsers = parseStringCsv(allowedRaw ?? cleanHome).map(normalizeWhatsAppId).filter((id) => Boolean(id));
1050
+ if (!verifyToken)
1051
+ verifyToken = randomBytes(24).toString('base64url');
1052
+ if (!cleanPhoneNumberId || !accessToken.trim() || !appSecret.trim()) {
1053
+ console.error('WhatsApp setup ต้องมี phone number id, access token และ app secret');
1054
+ process.exit(1);
1055
+ }
1056
+ if (!allowAllUsers && !cleanHome && !allowedUsers.length) {
1057
+ console.error('WhatsApp setup ต้องมี home channel/allowlist อย่างน้อย 1 ค่า หรือระบุ --allow-all-users');
1058
+ process.exit(1);
1059
+ }
1060
+ const { patchGatewayConfig, gatewayConfigPath } = await import('./gateway/config.js');
1061
+ await patchGatewayConfig({
1062
+ whatsapp: {
1063
+ enabled: true,
1064
+ phoneNumberId: cleanPhoneNumberId,
1065
+ accessToken: accessToken.trim(),
1066
+ appSecret: appSecret.trim(),
1067
+ verifyToken: verifyToken.trim(),
1068
+ homeChannel: cleanHome || allowedUsers[0],
1069
+ homeChannelName: homeChannelName?.trim() || undefined,
1070
+ allowedUsers,
1071
+ allowAllUsers,
1072
+ publicUrl: publicUrl?.trim() || undefined,
1073
+ apiVersion: apiVersion?.trim() || undefined,
1074
+ },
1075
+ });
1076
+ const callback = publicUrl?.trim() ? `${publicUrl.trim().replace(/\/+$/, '')}/whatsapp/webhook` : `https://<your-tunnel>/whatsapp/webhook`;
1077
+ console.log(`บันทึก WhatsApp Cloud gateway config แล้ว: ${gatewayConfigPath()}`);
1078
+ console.log(`Meta webhook callback URL: ${callback}`);
1079
+ console.log(`Meta verify token: ${verifyToken.trim()}`);
1080
+ console.log(`ส่งทดสอบได้ด้วย: ${BRAND.cliName} send --to whatsapp "hello"`);
1081
+ }
1082
+ async function runMatrixGatewaySetup(args) {
1083
+ let homeserver = argValue(args, '--homeserver', '--server', '--url');
1084
+ let accessToken = argValue(args, '--access-token', '--token');
1085
+ let userId = argValue(args, '--user-id', '--user');
1086
+ let password = argValue(args, '--password');
1087
+ let homeRoom = argValue(args, '--home-room', '--room', '--to');
1088
+ let allowedUsersRaw = argValue(args, '--allowed-users');
1089
+ let allowedRoomsRaw = argValue(args, '--allowed-rooms');
1090
+ let freeResponseRoomsRaw = argValue(args, '--free-response-rooms');
1091
+ const homeRoomName = argValue(args, '--home-room-name');
1092
+ const allowAllUsers = args.includes('--allow-all-users');
1093
+ const requireMention = !args.includes('--no-require-mention');
1094
+ const groupSessionsPerUser = !args.includes('--shared-room-session');
1095
+ const autoJoin = !args.includes('--no-auto-join');
1096
+ const pollTimeoutMs = argValue(args, '--poll-timeout-ms');
1097
+ if (!homeserver) {
1098
+ if (!process.stdin.isTTY) {
1099
+ console.error(`ใช้: ${BRAND.cliName} gateway setup matrix --homeserver <https://matrix.org> --access-token <token> --allowed-users <@you:server> [--home-room '!room:server']`);
1100
+ process.exit(1);
1101
+ }
1102
+ console.log(`${BRAND.productName} Matrix setup`);
1103
+ console.log('ใช้ Matrix Client-Server API: ต้องมี homeserver URL และ access token หรือ user/password ของ bot account');
1104
+ homeserver = await askText('Matrix homeserver URL (เช่น https://matrix.org): ');
1105
+ }
1106
+ if (!accessToken && (!userId || !password)) {
1107
+ if (!process.stdin.isTTY) {
1108
+ console.error('ต้องระบุ --access-token <token> หรือ --user-id <@bot:server> --password <password>');
1109
+ process.exit(1);
1110
+ }
1111
+ accessToken = await askText('Matrix access token (แนะนำ; blank = ใช้ user/password): ');
1112
+ if (!accessToken) {
1113
+ userId = await askText('Matrix bot user id (@bot:server): ');
1114
+ password = await askText('Matrix bot password: ');
1115
+ }
1116
+ }
1117
+ if (!allowedUsersRaw && !allowAllUsers) {
1118
+ if (!process.stdin.isTTY) {
1119
+ console.error('ต้องระบุ --allowed-users <@user:server[,user]> เพื่อ fail-closed หรือ --allow-all-users');
1120
+ process.exit(1);
1121
+ }
1122
+ allowedUsersRaw = await askText('Allowed Matrix user IDs (comma-separated): ');
1123
+ }
1124
+ if (!homeRoom && process.stdin.isTTY) {
1125
+ homeRoom = await askText('Matrix home room id/alias (!room:server หรือ #room:server; blank = skip): ');
1126
+ }
1127
+ const { normalizeMatrixHomeserver, normalizeMatrixRoomId, normalizeMatrixUserId } = await import('./gateway/matrix.js');
1128
+ const cleanHomeserver = normalizeMatrixHomeserver(homeserver);
1129
+ const cleanUserId = normalizeMatrixUserId(userId);
1130
+ const cleanHomeRoom = normalizeMatrixRoomId(homeRoom);
1131
+ const allowedUsers = parseStringCsv(allowedUsersRaw).map(normalizeMatrixUserId).filter((id) => Boolean(id));
1132
+ const allowedRooms = parseStringCsv(allowedRoomsRaw).map(normalizeMatrixRoomId).filter((id) => Boolean(id));
1133
+ const freeResponseRooms = parseStringCsv(freeResponseRoomsRaw).map(normalizeMatrixRoomId).filter((id) => Boolean(id));
1134
+ const timeout = pollTimeoutMs ? Number(pollTimeoutMs) : undefined;
1135
+ if (!cleanHomeserver) {
1136
+ console.error('Matrix setup ต้องมี homeserver URL ที่ขึ้นต้นด้วย http:// หรือ https://');
1137
+ process.exit(1);
1138
+ }
1139
+ if (!accessToken?.trim() && (!cleanUserId || !password?.trim())) {
1140
+ console.error('Matrix setup ต้องมี access token หรือ user id/password');
1141
+ process.exit(1);
1142
+ }
1143
+ if (!allowAllUsers && !allowedUsers.length) {
1144
+ console.error('Matrix setup ต้องมี allowed users อย่างน้อย 1 ค่า หรือระบุ --allow-all-users');
1145
+ process.exit(1);
1146
+ }
1147
+ if (homeRoom?.trim() && !cleanHomeRoom) {
1148
+ console.error('Matrix home room ต้องเป็น room id/alias เช่น !abc123:matrix.org หรือ #room:matrix.org');
1149
+ process.exit(1);
1150
+ }
1151
+ if (pollTimeoutMs && (!Number.isInteger(timeout) || Number(timeout) <= 0)) {
1152
+ console.error('--poll-timeout-ms ต้องเป็น integer มากกว่า 0');
1153
+ process.exit(1);
1154
+ }
1155
+ const { patchGatewayConfig, gatewayConfigPath } = await import('./gateway/config.js');
1156
+ await patchGatewayConfig({
1157
+ matrix: {
1158
+ enabled: true,
1159
+ homeserver: cleanHomeserver,
1160
+ accessToken: accessToken?.trim() || undefined,
1161
+ userId: cleanUserId,
1162
+ password: password?.trim() || undefined,
1163
+ homeRoom: cleanHomeRoom || allowedRooms[0],
1164
+ homeRoomName: homeRoomName?.trim() || undefined,
1165
+ allowedUsers,
1166
+ allowedRooms,
1167
+ freeResponseRooms,
1168
+ allowAllUsers,
1169
+ requireMention,
1170
+ groupSessionsPerUser,
1171
+ autoJoin,
1172
+ pollTimeoutMs: timeout,
1173
+ },
1174
+ });
1175
+ console.log(`บันทึก Matrix gateway config แล้ว: ${gatewayConfigPath()}`);
1176
+ console.log(`Matrix sync: ${cleanHomeserver}/_matrix/client/v3/sync`);
1177
+ console.log(`ส่งทดสอบได้ด้วย: ${BRAND.cliName} send --to matrix "hello"${cleanHomeRoom ? '' : ` หรือ ${BRAND.cliName} send --to matrix:!room:server "hello"`}`);
1178
+ }
1179
+ async function runGoogleChatGatewaySetup(args) {
1180
+ const projectId = argValue(args, '--project-id');
1181
+ const subscriptionName = argValue(args, '--subscription-name', '--subscription');
1182
+ let serviceAccountJson = argValue(args, '--service-account-json', '--service-account', '--credentials');
1183
+ const apiBaseUrl = argValue(args, '--api-base-url', '--base-url');
1184
+ let incomingWebhookUrl = argValue(args, '--incoming-webhook-url', '--webhook-url', '--url');
1185
+ let homeChannel = argValue(args, '--home-channel', '--space', '--to');
1186
+ const homeChannelName = argValue(args, '--home-channel-name');
1187
+ const allowedUsersRaw = argValue(args, '--allowed-users');
1188
+ const allowedSpacesRaw = argValue(args, '--allowed-spaces', '--spaces');
1189
+ const freeResponseSpacesRaw = argValue(args, '--free-response-spaces');
1190
+ const maxMessagesRaw = argValue(args, '--max-messages');
1191
+ const maxBytesRaw = argValue(args, '--max-bytes');
1192
+ const allowAllUsers = args.includes('--allow-all-users');
1193
+ const allowAllSpaces = args.includes('--allow-all-spaces');
1194
+ if ((!incomingWebhookUrl && !serviceAccountJson) || (!incomingWebhookUrl && !homeChannel && !allowedSpacesRaw && !allowAllSpaces)) {
1195
+ if (!process.stdin.isTTY) {
1196
+ console.error(`ใช้: ${BRAND.cliName} gateway setup googlechat --service-account-json <path> --home-channel <spaces/AAA> หรือ --incoming-webhook-url <https://chat.googleapis.com/v1/spaces/.../messages?...>`);
1197
+ process.exit(1);
1198
+ }
1199
+ console.log(`${BRAND.productName} Google Chat setup`);
1200
+ console.log('ใช้ Service Account JSON + Chat REST API สำหรับ bot app หรือ incoming webhook URL สำหรับส่งง่าย ๆ');
1201
+ serviceAccountJson ||= await askText('Service Account JSON path (blank = webhook mode): ');
1202
+ if (serviceAccountJson) {
1203
+ homeChannel ||= await askText('Home space (spaces/AAA...; blank = skip): ');
1204
+ }
1205
+ else {
1206
+ incomingWebhookUrl ||= await askText('Google Chat incoming webhook URL: ');
1207
+ }
1208
+ }
1209
+ const { normalizeGoogleChatApiBaseUrl, normalizeGoogleChatWebhookUrl, parseGoogleChatTarget } = await import('./gateway/googlechat.js');
1210
+ const cleanApiBaseUrl = normalizeGoogleChatApiBaseUrl(apiBaseUrl);
1211
+ const cleanWebhookUrl = normalizeGoogleChatWebhookUrl(incomingWebhookUrl);
1212
+ const cleanServiceAccountJson = serviceAccountJson?.trim();
1213
+ const cleanHomeChannel = homeChannel?.trim();
1214
+ const maxMessages = maxMessagesRaw ? Number(maxMessagesRaw) : undefined;
1215
+ const maxBytes = maxBytesRaw ? Number(maxBytesRaw) : undefined;
1216
+ if (!cleanApiBaseUrl) {
1217
+ console.error('Google Chat API base URL ต้องเป็น https:// URL');
1218
+ process.exit(1);
1219
+ }
1220
+ if (incomingWebhookUrl?.trim() && !cleanWebhookUrl) {
1221
+ console.error('Google Chat incoming webhook URL ต้องเป็น https:// URL');
1222
+ process.exit(1);
1223
+ }
1224
+ if (!cleanWebhookUrl && !cleanServiceAccountJson) {
1225
+ console.error('Google Chat setup ต้องมี service account JSON หรือ incoming webhook URL');
1226
+ process.exit(1);
1227
+ }
1228
+ if (cleanHomeChannel) {
1229
+ try {
1230
+ parseGoogleChatTarget({
1231
+ apiBaseUrl: cleanApiBaseUrl,
1232
+ homeChannel: cleanHomeChannel,
1233
+ allowedUsers: [],
1234
+ allowedSpaces: [],
1235
+ freeResponseSpaces: [],
1236
+ allowAllUsers: false,
1237
+ allowAllSpaces: false,
1238
+ maxMessages: 1,
1239
+ maxBytes: 16_777_216,
1240
+ enabled: true,
1241
+ source: 'config',
1242
+ serviceAccountJson: cleanServiceAccountJson,
1243
+ incomingWebhookUrl: cleanWebhookUrl,
1244
+ }, cleanHomeChannel);
1245
+ }
1246
+ catch (e) {
1247
+ console.error(e instanceof Error ? e.message : 'Google Chat home channel ไม่ถูกต้อง');
1248
+ process.exit(1);
1249
+ }
1250
+ }
1251
+ if (!cleanWebhookUrl && !cleanHomeChannel && !allowedSpacesRaw?.trim() && !allowAllSpaces) {
1252
+ console.error('Google Chat service-account setup ต้องมี home channel, allowed spaces หรือ --allow-all-spaces');
1253
+ process.exit(1);
1254
+ }
1255
+ if (maxMessagesRaw && (!Number.isInteger(maxMessages) || Number(maxMessages) <= 0)) {
1256
+ console.error('--max-messages ต้องเป็น integer มากกว่า 0');
1257
+ process.exit(1);
1258
+ }
1259
+ if (maxBytesRaw && (!Number.isInteger(maxBytes) || Number(maxBytes) <= 0)) {
1260
+ console.error('--max-bytes ต้องเป็น integer มากกว่า 0');
1261
+ process.exit(1);
1262
+ }
1263
+ const { patchGatewayConfig, gatewayConfigPath } = await import('./gateway/config.js');
1264
+ await patchGatewayConfig({
1265
+ googleChat: {
1266
+ enabled: true,
1267
+ projectId: projectId?.trim() || undefined,
1268
+ subscriptionName: subscriptionName?.trim() || undefined,
1269
+ serviceAccountJson: cleanServiceAccountJson || undefined,
1270
+ apiBaseUrl: cleanApiBaseUrl,
1271
+ incomingWebhookUrl: cleanWebhookUrl,
1272
+ homeChannel: cleanHomeChannel || (cleanWebhookUrl ? 'webhook' : undefined),
1273
+ homeChannelName: homeChannelName?.trim() || undefined,
1274
+ allowedUsers: parseStringCsv(allowedUsersRaw),
1275
+ allowedSpaces: parseStringCsv(allowedSpacesRaw),
1276
+ freeResponseSpaces: parseStringCsv(freeResponseSpacesRaw),
1277
+ allowAllUsers,
1278
+ allowAllSpaces,
1279
+ maxMessages,
1280
+ maxBytes,
1281
+ },
1282
+ });
1283
+ console.log(`บันทึก Google Chat gateway config แล้ว: ${gatewayConfigPath()}`);
1284
+ console.log(cleanWebhookUrl ? 'Google Chat delivery mode: incoming webhook' : 'Google Chat delivery mode: Chat REST API');
1285
+ console.log(`ส่งทดสอบได้ด้วย: ${BRAND.cliName} send --to googlechat "hello"${cleanHomeChannel ? '' : ` หรือ ${BRAND.cliName} send --to googlechat:spaces/<space> "hello"`}`);
1286
+ }
1287
+ async function runBlueBubblesGatewaySetup(args) {
1288
+ let serverUrl = argValue(args, '--server-url', '--url');
1289
+ let password = argValue(args, '--password', '--token', '--guid');
1290
+ const webhookHost = argValue(args, '--webhook-host');
1291
+ const webhookPortRaw = argValue(args, '--webhook-port');
1292
+ const webhookPath = argValue(args, '--webhook-path');
1293
+ let homeChannel = argValue(args, '--home-channel', '--chat-guid', '--to');
1294
+ const homeChannelName = argValue(args, '--home-channel-name');
1295
+ let allowedUsersRaw = argValue(args, '--allowed-users', '--allowed-targets');
1296
+ const allowAllUsers = args.includes('--allow-all-users');
1297
+ const requireMention = args.includes('--require-mention');
1298
+ const mentionPatternsRaw = argValue(args, '--mention-patterns');
1299
+ const sendReadReceipts = !args.includes('--no-read-receipts');
1300
+ if (!serverUrl || !password || (!homeChannel && !allowedUsersRaw && !allowAllUsers)) {
1301
+ if (!process.stdin.isTTY) {
1302
+ console.error(`ใช้: ${BRAND.cliName} gateway setup bluebubbles --server-url <http://mac:1234> --password <server-password> --home-channel <chat-guid|email|phone>`);
1303
+ process.exit(1);
1304
+ }
1305
+ console.log(`${BRAND.productName} BlueBubbles/iMessage setup`);
1306
+ console.log('ต้องมี BlueBubbles Server URL + server password; outbound ใช้ REST API /api/v1/message/text');
1307
+ serverUrl ||= await askText('BlueBubbles server URL (เช่น http://localhost:1234): ');
1308
+ password ||= await askText('BlueBubbles server password: ');
1309
+ homeChannel ||= await askText('Home chat GUID/email/phone (blank = explicit targets only): ');
1310
+ }
1311
+ if (!allowedUsersRaw && homeChannel && !allowAllUsers)
1312
+ allowedUsersRaw = homeChannel;
1313
+ const { normalizeBlueBubblesServerUrl, normalizeBlueBubblesWebhookPath } = await import('./gateway/bluebubbles.js');
1314
+ const cleanServerUrl = normalizeBlueBubblesServerUrl(serverUrl);
1315
+ const cleanPassword = password?.trim();
1316
+ const cleanHomeChannel = homeChannel?.trim();
1317
+ const webhookPort = webhookPortRaw ? Number(webhookPortRaw) : undefined;
1318
+ if (!cleanServerUrl) {
1319
+ console.error('BlueBubbles server URL ต้องเป็น http:// หรือ https:// URL');
1320
+ process.exit(1);
1321
+ }
1322
+ if (!cleanPassword) {
1323
+ console.error('BlueBubbles setup ต้องมี server password');
1324
+ process.exit(1);
1325
+ }
1326
+ if (webhookPortRaw && (!Number.isInteger(webhookPort) || Number(webhookPort) <= 0 || Number(webhookPort) > 65535)) {
1327
+ console.error('--webhook-port ต้องเป็น port 1-65535');
1328
+ process.exit(1);
1329
+ }
1330
+ const allowedUsers = parseStringCsv(allowedUsersRaw);
1331
+ if (!allowAllUsers && !cleanHomeChannel && !allowedUsers.length) {
1332
+ console.error('BlueBubbles setup ต้องมี home channel/allowlist อย่างน้อย 1 ค่า หรือระบุ --allow-all-users');
1333
+ process.exit(1);
1334
+ }
1335
+ const { patchGatewayConfig, gatewayConfigPath } = await import('./gateway/config.js');
1336
+ await patchGatewayConfig({
1337
+ bluebubbles: {
1338
+ enabled: true,
1339
+ serverUrl: cleanServerUrl,
1340
+ password: cleanPassword,
1341
+ webhookHost: webhookHost?.trim() || undefined,
1342
+ webhookPort,
1343
+ webhookPath: normalizeBlueBubblesWebhookPath(webhookPath),
1344
+ homeChannel: cleanHomeChannel || allowedUsers[0],
1345
+ homeChannelName: homeChannelName?.trim() || undefined,
1346
+ allowedUsers,
1347
+ allowAllUsers,
1348
+ requireMention,
1349
+ mentionPatterns: parseStringCsv(mentionPatternsRaw),
1350
+ sendReadReceipts,
1351
+ },
1352
+ });
1353
+ console.log(`บันทึก BlueBubbles gateway config แล้ว: ${gatewayConfigPath()}`);
1354
+ console.log(`BlueBubbles REST: ${cleanServerUrl}/api/v1/message/text`);
1355
+ console.log(`ส่งทดสอบได้ด้วย: ${BRAND.cliName} send --to bluebubbles "hello"${cleanHomeChannel ? '' : ` หรือ ${BRAND.cliName} send --to bluebubbles:<chat-guid|email|phone> "hello"`}`);
1356
+ }
1357
+ async function runTeamsGatewaySetup(args) {
1358
+ let incomingWebhookUrl = argValue(args, '--incoming-webhook-url', '--webhook-url', '--url');
1359
+ const graphAccessToken = argValue(args, '--graph-access-token', '--access-token', '--token');
1360
+ const teamId = argValue(args, '--team-id');
1361
+ const channelId = argValue(args, '--channel-id');
1362
+ const chatId = argValue(args, '--chat-id');
1363
+ let homeChannel = argValue(args, '--home-channel', '--to');
1364
+ const homeChannelName = argValue(args, '--home-channel-name');
1365
+ const clientId = argValue(args, '--client-id');
1366
+ const clientSecret = argValue(args, '--client-secret');
1367
+ const tenantId = argValue(args, '--tenant-id');
1368
+ const allowedUsersRaw = argValue(args, '--allowed-users');
1369
+ const allowAllUsers = args.includes('--allow-all-users');
1370
+ const portRaw = argValue(args, '--port');
1371
+ const rawMode = argValue(args, '--delivery-mode', '--mode');
1372
+ const deliveryMode = rawMode === 'graph' || (!rawMode && graphAccessToken) ? 'graph' : 'incoming_webhook';
1373
+ if (deliveryMode === 'incoming_webhook' && !incomingWebhookUrl) {
1374
+ if (!process.stdin.isTTY) {
1375
+ console.error(`ใช้: ${BRAND.cliName} gateway setup teams --incoming-webhook-url <https://...>`);
1376
+ process.exit(1);
1377
+ }
1378
+ console.log(`${BRAND.productName} Microsoft Teams setup`);
1379
+ console.log('โหมดง่าย: สร้าง Incoming Webhook ใน Teams channel แล้ววาง URL ที่นี่');
1380
+ incomingWebhookUrl = await askText('Teams incoming webhook URL: ');
1381
+ homeChannel ||= (await askText('Teams home target label (blank = webhook): ')) || 'webhook';
1382
+ }
1383
+ if (deliveryMode === 'graph' && (!graphAccessToken || (!chatId && !homeChannel && (!teamId || !channelId)))) {
1384
+ if (!process.stdin.isTTY) {
1385
+ console.error(`ใช้: ${BRAND.cliName} gateway setup teams --delivery-mode graph --graph-access-token <token> (--chat-id <id> หรือ --team-id <id> --channel-id <id>)`);
1386
+ process.exit(1);
1387
+ }
1388
+ console.log(`${BRAND.productName} Microsoft Teams Graph setup`);
1389
+ console.log('ต้องมี Microsoft Graph token และ chat id หรือ team/channel id สำหรับ proactive delivery');
1390
+ }
1391
+ const { normalizeTeamsWebhookUrl } = await import('./gateway/teams.js');
1392
+ const cleanWebhookUrl = normalizeTeamsWebhookUrl(incomingWebhookUrl);
1393
+ const cleanPort = portRaw ? Number(portRaw) : undefined;
1394
+ if (deliveryMode === 'incoming_webhook' && !cleanWebhookUrl) {
1395
+ console.error('Microsoft Teams incoming webhook URL ต้องเป็น https:// URL');
1396
+ process.exit(1);
1397
+ }
1398
+ if (deliveryMode === 'graph' && !graphAccessToken?.trim()) {
1399
+ console.error('Microsoft Teams Graph mode ต้องมี --graph-access-token');
1400
+ process.exit(1);
1401
+ }
1402
+ if (deliveryMode === 'graph' && !chatId?.trim() && !homeChannel?.trim() && (!teamId?.trim() || !channelId?.trim())) {
1403
+ console.error('Microsoft Teams Graph mode ต้องมี --chat-id หรือ --team-id + --channel-id');
1404
+ process.exit(1);
1405
+ }
1406
+ if (portRaw && (!Number.isInteger(cleanPort) || Number(cleanPort) <= 0)) {
1407
+ console.error('--port ต้องเป็น integer มากกว่า 0');
1408
+ process.exit(1);
1409
+ }
1410
+ const { patchGatewayConfig, gatewayConfigPath } = await import('./gateway/config.js');
1411
+ const graphHome = homeChannel?.trim() || chatId?.trim() || (teamId?.trim() && channelId?.trim() ? `team/${teamId.trim()}/channel/${channelId.trim()}` : undefined);
1412
+ await patchGatewayConfig({
1413
+ teams: {
1414
+ enabled: true,
1415
+ deliveryMode,
1416
+ incomingWebhookUrl: cleanWebhookUrl,
1417
+ graphAccessToken: graphAccessToken?.trim() || undefined,
1418
+ teamId: teamId?.trim() || undefined,
1419
+ channelId: channelId?.trim() || undefined,
1420
+ chatId: chatId?.trim() || undefined,
1421
+ homeChannel: graphHome || (cleanWebhookUrl ? 'webhook' : undefined),
1422
+ homeChannelName: homeChannelName?.trim() || undefined,
1423
+ clientId: clientId?.trim() || undefined,
1424
+ clientSecret: clientSecret?.trim() || undefined,
1425
+ tenantId: tenantId?.trim() || undefined,
1426
+ allowedUsers: parseStringCsv(allowedUsersRaw),
1427
+ allowAllUsers,
1428
+ port: cleanPort,
1429
+ },
1430
+ });
1431
+ console.log(`บันทึก Microsoft Teams gateway config แล้ว: ${gatewayConfigPath()}`);
1432
+ console.log(`Teams delivery mode: ${deliveryMode}`);
1433
+ if (deliveryMode === 'incoming_webhook')
1434
+ console.log('ส่งผ่าน Incoming Webhook ที่ตั้งไว้');
1435
+ else
1436
+ console.log(`Graph target: ${graphHome}`);
1437
+ console.log(`ส่งทดสอบได้ด้วย: ${BRAND.cliName} send --to teams "hello"`);
1438
+ }
1439
+ async function runNtfyGatewaySetup(args) {
1440
+ let topic = argValue(args, '--topic');
1441
+ const serverUrl = argValue(args, '--server-url') ?? argValue(args, '--url');
1442
+ const token = argValue(args, '--token');
1443
+ const publishTopic = argValue(args, '--publish-topic');
1444
+ let homeChannel = argValue(args, '--home-channel', '--to');
1445
+ let allowedRaw = argValue(args, '--allowed-users', '--allowed-topics');
1446
+ const homeChannelName = argValue(args, '--home-channel-name');
1447
+ const allowAllUsers = args.includes('--allow-all-users');
1448
+ const markdown = args.includes('--markdown');
1449
+ if (!topic) {
1450
+ if (!process.stdin.isTTY) {
1451
+ console.error(`ใช้: ${BRAND.cliName} gateway setup ntfy --topic <topic> [--token <tk_...|user:pass>]`);
1452
+ process.exit(1);
1453
+ }
1454
+ console.log(`${BRAND.productName} ntfy setup`);
1455
+ console.log('เลือก topic ยาว/เดายาก แล้ว subscribe topic นี้ใน ntfy mobile app หรือ self-hosted ntfy');
1456
+ topic = await askText('ntfy topic: ');
1457
+ }
1458
+ const cleanTopic = topic.trim();
1459
+ if (!cleanTopic) {
1460
+ console.error('ntfy setup ต้องมี topic');
1461
+ process.exit(1);
1462
+ }
1463
+ if (!homeChannel)
1464
+ homeChannel = cleanTopic;
1465
+ if (!allowedRaw && !allowAllUsers)
1466
+ allowedRaw = cleanTopic;
1467
+ const allowedUsers = parseStringCsv(allowedRaw);
1468
+ if (!allowAllUsers && !allowedUsers.includes(cleanTopic) && homeChannel !== cleanTopic) {
1469
+ console.error('ntfy setup ต้องมี topic ใน --allowed-users หรือใช้ --allow-all-users เพื่อรับ inbound');
1470
+ process.exit(1);
1471
+ }
1472
+ const { patchGatewayConfig, gatewayConfigPath } = await import('./gateway/config.js');
1473
+ await patchGatewayConfig({
1474
+ ntfy: {
1475
+ enabled: true,
1476
+ serverUrl: serverUrl?.trim() || undefined,
1477
+ topic: cleanTopic,
1478
+ publishTopic: publishTopic?.trim() || undefined,
1479
+ token: token?.trim() || undefined,
1480
+ homeChannel: homeChannel?.trim() || cleanTopic,
1481
+ homeChannelName: homeChannelName?.trim() || undefined,
1482
+ allowedUsers,
1483
+ allowAllUsers,
1484
+ markdown,
1485
+ },
1486
+ });
1487
+ console.log(`บันทึก ntfy gateway config แล้ว: ${gatewayConfigPath()}`);
1488
+ console.log(`subscribe topic ในแอป ntfy: ${cleanTopic}`);
1489
+ console.log(`ส่งทดสอบได้ด้วย: ${BRAND.cliName} send --to ntfy "hello"`);
1490
+ }
1491
+ async function runWebhookGatewaySetup(args) {
1492
+ let secret = argValue(args, '--secret', '--webhook-secret');
1493
+ const publicUrl = argValue(args, '--public-url');
1494
+ const rateLimitRaw = argValue(args, '--rate-limit', '--rate-limit-per-minute');
1495
+ const insecureNoAuth = args.includes('--insecure-no-auth');
1496
+ if (!secret && !insecureNoAuth) {
1497
+ if (process.stdin.isTTY) {
1498
+ const { generateWebhookSecret } = await import('./gateway/webhooks.js');
1499
+ console.log(`${BRAND.productName} Webhooks setup`);
1500
+ console.log('ตั้ง global HMAC secret สำหรับ route ที่ไม่ได้ระบุ secret เอง');
1501
+ secret = (await askText('Webhook global secret (blank = auto-generate): ')) || generateWebhookSecret();
1502
+ }
1503
+ else {
1504
+ const { generateWebhookSecret } = await import('./gateway/webhooks.js');
1505
+ secret = generateWebhookSecret();
1506
+ }
1507
+ }
1508
+ const rateLimitPerMinute = rateLimitRaw ? parsePort(rateLimitRaw, 30, 'webhook rate limit') : undefined;
1509
+ const { patchGatewayConfig, gatewayConfigPath } = await import('./gateway/config.js');
1510
+ await patchGatewayConfig({
1511
+ webhooks: {
1512
+ enabled: true,
1513
+ secret: insecureNoAuth ? 'INSECURE_NO_AUTH' : secret?.trim(),
1514
+ publicUrl: publicUrl?.trim() || undefined,
1515
+ rateLimitPerMinute,
1516
+ },
1517
+ });
1518
+ console.log(`บันทึก Webhooks gateway config แล้ว: ${gatewayConfigPath()}`);
1519
+ console.log(`เพิ่ม route ได้ด้วย: ${BRAND.cliName} webhook subscribe github-issues --events issues --prompt "New issue: {issue.title}" --to telegram`);
1520
+ }
1521
+ function parsePort(raw, fallback, label) {
1522
+ if (!raw)
1523
+ return fallback;
1524
+ const n = Number(raw);
1525
+ if (!Number.isInteger(n) || n < 1 || n > 65535) {
1526
+ console.error(`${label} ต้องเป็น port 1-65535`);
1527
+ process.exit(1);
1528
+ }
1529
+ return n;
1530
+ }
1531
+ async function runEmailGatewaySetup(args) {
1532
+ let address = argValue(args, '--address', '--email');
1533
+ let password = argValue(args, '--password', '--app-password');
1534
+ let imapHost = argValue(args, '--imap-host');
1535
+ let smtpHost = argValue(args, '--smtp-host');
1536
+ let homeAddress = argValue(args, '--home-address', '--to');
1537
+ let allowedRaw = argValue(args, '--allowed-users', '--allowed-senders');
1538
+ const imapPort = parsePort(argValue(args, '--imap-port'), 993, 'imap port');
1539
+ const smtpPort = parsePort(argValue(args, '--smtp-port'), 587, 'smtp port');
1540
+ const pollIntervalSeconds = parsePort(argValue(args, '--poll-interval'), 15, 'poll interval');
1541
+ const allowAllUsers = args.includes('--allow-all-users');
1542
+ if (!address) {
1543
+ if (!process.stdin.isTTY) {
1544
+ console.error(`ใช้: ${BRAND.cliName} gateway setup email --address bot@example.com --password <app-password> --imap-host imap.example.com --smtp-host smtp.example.com --home-address you@example.com`);
1545
+ process.exit(1);
1546
+ }
1547
+ console.log(`${BRAND.productName} Email setup`);
1548
+ console.log('แนะนำให้ใช้ dedicated mailbox + app password ไม่ใช่บัญชีส่วนตัวหลัก');
1549
+ address = await askText('Email address ของ bot: ');
1550
+ }
1551
+ if (!password) {
1552
+ if (!process.stdin.isTTY) {
1553
+ console.error('ต้องระบุ --password <app-password>');
1554
+ process.exit(1);
1555
+ }
1556
+ password = await askText('Email app password: ');
1557
+ }
1558
+ if (!imapHost) {
1559
+ if (!process.stdin.isTTY) {
1560
+ console.error('ต้องระบุ --imap-host <host>');
1561
+ process.exit(1);
1562
+ }
1563
+ imapHost = await askText('IMAP host (เช่น imap.gmail.com): ');
1564
+ }
1565
+ if (!smtpHost) {
1566
+ if (!process.stdin.isTTY) {
1567
+ console.error('ต้องระบุ --smtp-host <host>');
1568
+ process.exit(1);
1569
+ }
1570
+ smtpHost = await askText('SMTP host (เช่น smtp.gmail.com): ');
1571
+ }
1572
+ if (!homeAddress && !allowedRaw && !allowAllUsers) {
1573
+ if (!process.stdin.isTTY) {
1574
+ console.error('ต้องระบุ --home-address <email> หรือ --allowed-users <email[,email]> เพื่อ fail-closed');
1575
+ process.exit(1);
1576
+ }
1577
+ homeAddress = await askText('Home/allowed email address: ');
1578
+ }
1579
+ const allowedUsers = parseStringCsv(allowedRaw ?? homeAddress).map((s) => s.toLowerCase());
1580
+ if (!allowAllUsers && !allowedUsers.length) {
1581
+ console.error('Email setup ต้องมี allowed sender/home address อย่างน้อย 1 ค่า หรือระบุ --allow-all-users');
1582
+ process.exit(1);
1583
+ }
1584
+ const { patchGatewayConfig, gatewayConfigPath } = await import('./gateway/config.js');
1585
+ await patchGatewayConfig({
1586
+ email: {
1587
+ enabled: true,
1588
+ address: address.trim(),
1589
+ password: password.trim(),
1590
+ imapHost: imapHost.trim(),
1591
+ imapPort,
1592
+ smtpHost: smtpHost.trim(),
1593
+ smtpPort,
1594
+ homeAddress: homeAddress?.trim() || allowedUsers[0],
1595
+ allowedUsers,
1596
+ allowAllUsers,
1597
+ pollIntervalSeconds,
1598
+ },
1599
+ });
1600
+ console.log(`บันทึก Email gateway config แล้ว: ${gatewayConfigPath()}`);
1601
+ console.log(`ส่งทดสอบได้ด้วย: ${BRAND.cliName} send --to email:${homeAddress?.trim() || allowedUsers[0]} "hello"`);
1602
+ }
1603
+ async function runGateway(args) {
1604
+ const [action, ...rest] = args;
1605
+ if (!action || action === 'status' || action === 'list')
1606
+ return runGatewayStatus();
1607
+ if (action === 'setup')
1608
+ return runGatewaySetup(rest);
1609
+ if (action === 'run') {
1610
+ if (!hasServeCommandRequest(['serve', ...rest])) {
1611
+ console.error(`ไม่รู้จัก: gateway run ${rest.join(' ')} — ใช้ gateway run [--port N] [--model spec]`);
1612
+ process.exit(1);
1613
+ }
1614
+ return runServe(rest);
1615
+ }
1616
+ if (action === 'start') {
1617
+ const { startGatewayService } = await import('./gateway/service.js');
1618
+ const res = await startGatewayService({ entrypoint: resolve(process.argv[1]), gatewayArgs: rest });
1619
+ console.log(res.started
1620
+ ? `เริ่ม ${BRAND.cliName} gateway background แล้ว (pid ${res.state.pid})`
1621
+ : `${BRAND.cliName} gateway รันอยู่แล้ว (pid ${res.state.pid})`);
1622
+ console.log(`log: ${res.state.logPath}`);
1623
+ return;
1624
+ }
1625
+ if (action === 'stop') {
1626
+ const { stopGatewayService } = await import('./gateway/service.js');
1627
+ const res = await stopGatewayService();
1628
+ console.log(res.state ? (res.stopped ? `หยุด gateway pid ${res.state.pid} แล้ว` : `gateway ไม่ได้รันอยู่ (last pid ${res.state.pid})`) : 'ยังไม่มี gateway service state');
1629
+ return;
1630
+ }
1631
+ if (action === 'restart') {
1632
+ const { startGatewayService, stopGatewayService } = await import('./gateway/service.js');
1633
+ await stopGatewayService();
1634
+ const res = await startGatewayService({ entrypoint: resolve(process.argv[1]), gatewayArgs: rest });
1635
+ console.log(`restart gateway แล้ว (pid ${res.state.pid})`);
1636
+ console.log(`log: ${res.state.logPath}`);
1637
+ return;
1638
+ }
1639
+ if (action === 'install') {
1640
+ const { installGatewayService } = await import('./gateway/service.js');
1641
+ const res = await installGatewayService(resolve(process.argv[1]));
1642
+ console.log(`ติดตั้ง service file แล้ว (${res.kind}): ${res.path}`);
1643
+ console.log('เริ่ม service ด้วย:');
1644
+ for (const line of res.instructions)
1645
+ console.log(` ${line}`);
1646
+ return;
1647
+ }
1648
+ if (action === 'uninstall' || action === 'remove-service') {
1649
+ const { uninstallGatewayService } = await import('./gateway/service.js');
1650
+ const removed = await uninstallGatewayService();
1651
+ console.log(removed.length ? `ลบ service files:\n${removed.map((p) => ` ${p}`).join('\n')}` : 'ไม่พบ service file ที่ต้องลบ');
1652
+ return;
1653
+ }
1654
+ console.error(`ไม่รู้จัก: gateway ${action} — ใช้ setup / run / start / stop / restart / install / status`);
1655
+ process.exit(1);
1656
+ }
1657
+ async function runStatus() {
1658
+ const cfg = await loadConfig({});
1659
+ const parsed = parseSpec(cfg.model);
1660
+ const provider = PROVIDERS[parsed.provider];
1661
+ const keyReady = provider ? (!provider.requiresKey || Boolean(resolveKeyFromEnv(provider.envVar, provider.envFallbacks))) : false;
1662
+ const { readGatewayConfig, resolveBlueBubblesConfig, resolveDiscordConfig, resolveEmailConfig, resolveGoogleChatConfig, resolveHomeAssistantConfig, resolveLineConfig, resolveMattermostConfig, resolveMatrixConfig, resolveNtfyConfig, resolveSignalConfig, resolveSlackConfig, resolveSmsConfig, resolveTelegramConfig, resolveTeamsConfig, resolveWhatsAppConfig, resolveWebhookConfig, } = await import('./gateway/config.js');
1663
+ const gatewayConfig = await readGatewayConfig();
1664
+ const telegram = resolveTelegramConfig(gatewayConfig);
1665
+ const discord = resolveDiscordConfig(gatewayConfig);
1666
+ const slack = resolveSlackConfig(gatewayConfig);
1667
+ const email = resolveEmailConfig(gatewayConfig);
1668
+ const homeassistant = resolveHomeAssistantConfig(gatewayConfig);
1669
+ const line = resolveLineConfig(gatewayConfig);
1670
+ const mattermost = resolveMattermostConfig(gatewayConfig);
1671
+ const sms = resolveSmsConfig(gatewayConfig);
1672
+ const ntfy = resolveNtfyConfig(gatewayConfig);
1673
+ const signal = resolveSignalConfig(gatewayConfig);
1674
+ const whatsapp = resolveWhatsAppConfig(gatewayConfig);
1675
+ const matrix = resolveMatrixConfig(gatewayConfig);
1676
+ const googleChat = resolveGoogleChatConfig(gatewayConfig);
1677
+ const bluebubbles = resolveBlueBubblesConfig(gatewayConfig);
1678
+ const teams = resolveTeamsConfig(gatewayConfig);
1679
+ const webhooks = resolveWebhookConfig(gatewayConfig);
1680
+ console.log(`${BRAND.productName} status`);
1681
+ console.log(` version: ${VERSION}`);
1682
+ console.log(` model: ${cfg.model}`);
1683
+ console.log(` provider: ${provider?.label ?? parsed.provider}`);
1684
+ console.log(` personality:${cfg.personality ? ` ${cfg.personality}` : ' none'}`);
1685
+ console.log(` key: ${keyReady ? 'ready' : provider?.requiresKey ? `missing (${provider.envVar})` : 'not required'}`);
1686
+ console.log(` brain: ${cfg.brainPath ?? '(not configured)'}`);
1687
+ console.log(' gateway: HTTP loopback + cron available');
1688
+ console.log(` telegram: ${telegram.token ? `configured (${telegram.allowedChatIds.length} allowed chat${telegram.allowedChatIds.length === 1 ? '' : 's'})` : 'not configured'}`);
1689
+ console.log(` discord: ${discord.token ? `configured (${discord.allowedChannelIds.length} allowed channel${discord.allowedChannelIds.length === 1 ? '' : 's'})` : 'not configured'}`);
1690
+ console.log(` slack: ${slack.botToken ? `configured (${slack.allowedChannelIds.length} allowed channel${slack.allowedChannelIds.length === 1 ? '' : 's'})` : 'not configured'}`);
1691
+ console.log(` mattermost:${mattermost.serverUrl && mattermost.token ? ` configured (${mattermost.allowedUsers.length} allowed user${mattermost.allowedUsers.length === 1 ? '' : 's'}, ${mattermost.allowedChannels.length} channel${mattermost.allowedChannels.length === 1 ? '' : 's'})` : ' not configured'}`);
1692
+ console.log(` homeassist:${homeassistant.token ? ` configured (${homeassistant.watchDomains.length} domain${homeassistant.watchDomains.length === 1 ? '' : 's'}, ${homeassistant.watchEntities.length} entit${homeassistant.watchEntities.length === 1 ? 'y' : 'ies'}, watchAll=${homeassistant.watchAll ? 'yes' : 'no'})` : ' not configured'}`);
1693
+ console.log(` email: ${email.address ? `configured (${email.allowedUsers.length} allowed sender${email.allowedUsers.length === 1 ? '' : 's'})` : 'not configured'}`);
1694
+ console.log(` line: ${line.channelAccessToken ? `configured (${line.allowedUsers.length + line.allowedGroups.length + line.allowedRooms.length} allowed target${line.allowedUsers.length + line.allowedGroups.length + line.allowedRooms.length === 1 ? '' : 's'})` : 'not configured'}`);
1695
+ console.log(` sms: ${sms.accountSid && sms.authToken && sms.phoneNumber ? `configured (${sms.allowedUsers.length} allowed sender${sms.allowedUsers.length === 1 ? '' : 's'})` : 'not configured'}`);
1696
+ console.log(` ntfy: ${ntfy.topic ? `configured (${ntfy.allowedUsers.length} allowed topic${ntfy.allowedUsers.length === 1 ? '' : 's'})` : 'not configured'}`);
1697
+ console.log(` signal: ${signal.account ? `configured (${signal.allowedUsers.length} allowed user${signal.allowedUsers.length === 1 ? '' : 's'}, ${signal.groupAllowedUsers.length} group${signal.groupAllowedUsers.length === 1 ? '' : 's'})` : 'not configured'}`);
1698
+ console.log(` whatsapp: ${whatsapp.phoneNumberId && whatsapp.accessToken ? `configured (${whatsapp.allowedUsers.length} allowed user${whatsapp.allowedUsers.length === 1 ? '' : 's'})` : 'not configured'}`);
1699
+ console.log(` matrix: ${matrix.homeserver && (matrix.accessToken || (matrix.userId && matrix.password)) ? `configured (${matrix.allowedUsers.length} allowed user${matrix.allowedUsers.length === 1 ? '' : 's'}, ${matrix.allowedRooms.length} room${matrix.allowedRooms.length === 1 ? '' : 's'})` : 'not configured'}`);
1700
+ console.log(` googlechat:${googleChat.serviceAccountJson || googleChat.incomingWebhookUrl ? ` configured (${googleChat.serviceAccountJson ? 'chat api' : 'webhook'})` : ' not configured'}`);
1701
+ console.log(` bluebubbles:${bluebubbles.serverUrl && bluebubbles.password ? ` configured (${bluebubbles.allowedUsers.length} allowed target${bluebubbles.allowedUsers.length === 1 ? '' : 's'})` : ' not configured'}`);
1702
+ console.log(` teams: ${teams.incomingWebhookUrl || teams.graphAccessToken ? `configured (${teams.deliveryMode})` : 'not configured'}`);
1703
+ console.log(` webhooks: ${webhooks.enabled ? `enabled (${Object.keys(webhooks.routes).length} route${Object.keys(webhooks.routes).length === 1 ? '' : 's'})` : 'not enabled'}`);
1704
+ console.log(` config: ${appHomePath('config.json')}`);
1705
+ }
1706
+ function compactText(raw, max = 120) {
1707
+ const text = redactKey(raw).replace(/\s+/g, ' ').trim();
1708
+ return text.length > max ? `${text.slice(0, max - 1).trimEnd()}…` : text;
1709
+ }
1710
+ function messageContentText(content) {
1711
+ if (typeof content === 'string')
1712
+ return content;
1713
+ if (!Array.isArray(content))
1714
+ return '';
1715
+ return content
1716
+ .map((part) => {
1717
+ if (typeof part === 'string')
1718
+ return part;
1719
+ if (part && typeof part === 'object') {
1720
+ const record = part;
1721
+ if (typeof record.text === 'string')
1722
+ return record.text;
1723
+ if (typeof record.type === 'string')
1724
+ return `[${record.type}]`;
1725
+ }
1726
+ return '';
1727
+ })
1728
+ .filter(Boolean)
1729
+ .join(' ');
1730
+ }
1731
+ function sessionPreview(session) {
1732
+ if (session.title)
1733
+ return compactText(session.title);
1734
+ const firstUser = session.messages.find((m) => m.role === 'user') ?? session.messages[0];
1735
+ return firstUser ? compactText(messageContentText(firstUser.content)) : '';
1736
+ }
1737
+ function sessionUsage() {
1738
+ return `ใช้:
1739
+ ${BRAND.cliName} sessions [list] [--all] [--limit N]
1740
+ ${BRAND.cliName} sessions latest [--all]
1741
+ ${BRAND.cliName} sessions show <id>
1742
+ ${BRAND.cliName} sessions export <id> [--format json|markdown] [--output path]
1743
+ ${BRAND.cliName} sessions rename <id> <title>
1744
+ ${BRAND.cliName} sessions stats [--all]
1745
+ ${BRAND.cliName} sessions prune --keep N [--all] [--yes]
1746
+ ${BRAND.cliName} sessions rm <id>
1747
+
1748
+ resume:
1749
+ ${BRAND.cliName} --resume <id> "<task>"
1750
+ ${BRAND.cliName} -r <id> "<task>"`;
1751
+ }
1752
+ function parseLimit(args, fallback) {
1753
+ const provided = args.some((a) => a === '--limit' || a === '-n' || a.startsWith('--limit=') || a.startsWith('-n='));
1754
+ const raw = argValue(args, '--limit', '-n');
1755
+ if (!provided)
1756
+ return fallback;
1757
+ const n = Number(raw);
1758
+ if (!raw || !Number.isInteger(n) || n <= 0) {
1759
+ console.error('--limit ต้องเป็น integer บวก');
1760
+ process.exit(2);
1761
+ }
1762
+ return n;
1763
+ }
1764
+ function printSessionDetails(session) {
1765
+ const users = session.messages.filter((m) => m.role === 'user');
1766
+ const assistants = session.messages.filter((m) => m.role === 'assistant');
1767
+ const lastUser = users[users.length - 1];
1768
+ const lastAssistant = assistants[assistants.length - 1];
1769
+ console.log(`${BRAND.productName} session ${session.id}`);
1770
+ if (session.title)
1771
+ console.log(` title: ${redactKey(session.title)}`);
1772
+ console.log(` model: ${session.model}`);
1773
+ console.log(` cwd: ${session.cwd}`);
1774
+ console.log(` created: ${session.created}`);
1775
+ console.log(` updated: ${session.updated}`);
1776
+ console.log(` messages: ${session.messages.length} (${users.length} user, ${assistants.length} assistant)`);
1777
+ console.log(` preview: ${sessionPreview(session) || '(empty)'}`);
1778
+ if (lastUser)
1779
+ console.log(` last user: ${compactText(messageContentText(lastUser.content)) || '(empty)'}`);
1780
+ if (lastAssistant)
1781
+ console.log(` last ai: ${compactText(messageContentText(lastAssistant.content)) || '(empty)'}`);
1782
+ }
1783
+ function sessionToMarkdown(session) {
1784
+ const safe = sanitizeSessionForExport(session);
1785
+ const lines = [
1786
+ `# ${safe.title ? redactKey(safe.title) : `Session ${safe.id}`}`,
1787
+ '',
1788
+ `- id: ${safe.id}`,
1789
+ `- model: ${safe.model}`,
1790
+ `- cwd: ${safe.cwd}`,
1791
+ `- created: ${safe.created}`,
1792
+ `- updated: ${safe.updated}`,
1793
+ '',
1794
+ ];
1795
+ for (const [i, msg] of safe.messages.entries()) {
1796
+ const role = msg.role ?? 'message';
1797
+ const text = compactText(messageContentText(msg.content), 20_000);
1798
+ lines.push(`## ${i + 1}. ${role}`, '', text || '(empty)', '');
1799
+ }
1800
+ return `${lines.join('\n').trimEnd()}\n`;
1801
+ }
1802
+ function parseDateFlag(args, ...names) {
1803
+ const raw = argValue(args, ...names);
1804
+ if (!raw)
1805
+ return undefined;
1806
+ const d = new Date(raw);
1807
+ if (!Number.isFinite(d.getTime())) {
1808
+ console.error(`วันที่ไม่ถูกต้อง: ${raw}`);
1809
+ process.exit(2);
1810
+ }
1811
+ return d;
1812
+ }
1813
+ async function loadSessionOrExit(id) {
1814
+ const session = await loadSession(id);
1815
+ if (!session) {
1816
+ console.error(`ไม่เจอ session ${id}`);
1817
+ process.exit(1);
1818
+ }
1819
+ return session;
1820
+ }
1821
+ async function requestedResumeSession(rawArgs, resumeId) {
1822
+ const requested = hasResumeRequest(rawArgs);
1823
+ if (!requested)
1824
+ return null;
1825
+ if (!resumeId) {
1826
+ console.error(`ใช้: ${BRAND.cliName} --resume <session_id> "<task>"`);
1827
+ process.exit(2);
1828
+ }
1829
+ return loadSessionOrExit(resumeId);
1830
+ }
1831
+ async function requestedContinuationHistory(rawArgs) {
1832
+ if (!hasContinueRequest(rawArgs))
1833
+ return undefined;
1834
+ return (await latestSession(hasContinueAnyRequest(rawArgs) ? null : process.cwd()))?.messages;
1835
+ }
1836
+ function printSessionStats(sessions, scope) {
1837
+ const byModel = new Map();
1838
+ let messages = 0;
1839
+ for (const s of sessions) {
1840
+ byModel.set(s.model, (byModel.get(s.model) ?? 0) + 1);
1841
+ messages += s.messages.length;
1842
+ }
1843
+ console.log(`${BRAND.productName} session stats (${scope})`);
1844
+ console.log(` sessions: ${sessions.length}`);
1845
+ console.log(` messages: ${messages}`);
1846
+ if (sessions[0])
1847
+ console.log(` latest: ${sessions[0].id} (${sessions[0].updated})`);
1848
+ if (sessions[sessions.length - 1])
1849
+ console.log(` oldest: ${sessions[sessions.length - 1].id} (${sessions[sessions.length - 1].updated})`);
1850
+ console.log(' models:');
1851
+ for (const [model, count] of [...byModel.entries()].sort((a, b) => b[1] - a[1])) {
1852
+ console.log(` ${model}: ${count}`);
1853
+ }
1854
+ }
1855
+ async function runSessions(args) {
1856
+ if (args.includes('-h') || args.includes('--help') || args[0] === 'help') {
1857
+ console.log(sessionUsage());
1858
+ return;
1859
+ }
1860
+ const action = args[0] && !args[0].startsWith('-') ? args[0] : 'list';
1861
+ const rest = action === 'list' && (args[0]?.startsWith('-') || args[0] === undefined) ? args : args.slice(1);
1862
+ const all = rest.includes('--all') || rest.includes('-a');
1863
+ const cwd = all ? null : process.cwd();
1864
+ if (action === 'list' || action === 'ls') {
1865
+ const sessions = await listSessions({ cwd, limit: parseLimit(rest, 20) });
1866
+ if (!sessions.length) {
1867
+ console.log(`ยังไม่มี saved sessions${all ? '' : ' สำหรับ project นี้'} — store: ${sessionStorePath()}`);
1868
+ return;
1869
+ }
1870
+ console.log(`${BRAND.productName} sessions (${all ? 'all projects' : 'current project'})`);
1871
+ for (const s of sessions) {
1872
+ const cwdSuffix = all ? ` ${s.cwd}` : '';
1873
+ console.log(`${s.id} ${s.updated} ${s.model} ${s.messages.length} msg ${sessionPreview(s)}${cwdSuffix}`);
1874
+ }
1875
+ console.log(`\nstore: ${sessionStorePath()}`);
1876
+ return;
1877
+ }
1878
+ if (action === 'latest') {
1879
+ const session = (await listSessions({ cwd, limit: 1 }))[0];
1880
+ if (!session) {
1881
+ console.log(`ไม่เจอ session${all ? '' : ' สำหรับ project นี้'}`);
1882
+ return;
1883
+ }
1884
+ printSessionDetails(session);
1885
+ return;
1886
+ }
1887
+ if (action === 'show' || action === 'cat') {
1888
+ const id = positionalArgs(rest, ['--limit', '-n'])[0];
1889
+ if (!id) {
1890
+ console.error(`ใช้: ${BRAND.cliName} sessions show <id>`);
1891
+ process.exit(2);
1892
+ }
1893
+ printSessionDetails(await loadSessionOrExit(id));
1894
+ return;
1895
+ }
1896
+ if (action === 'export') {
1897
+ const id = positionalArgs(rest, ['--format', '--output', '-o'])[0];
1898
+ if (!id) {
1899
+ console.error(`ใช้: ${BRAND.cliName} sessions export <id> [--format json|markdown] [--output path]`);
1900
+ process.exit(2);
1901
+ }
1902
+ const format = argValue(rest, '--format') ?? 'markdown';
1903
+ if (format !== 'json' && format !== 'markdown' && format !== 'md') {
1904
+ console.error('--format ต้องเป็น json หรือ markdown');
1905
+ process.exit(2);
1906
+ }
1907
+ const session = await loadSessionOrExit(id);
1908
+ const out = format === 'json' ? `${JSON.stringify(sanitizeSessionForExport(session), null, 2)}\n` : sessionToMarkdown(session);
1909
+ const outputPath = argValue(rest, '--output', '-o');
1910
+ if (!outputPath || outputPath === '-') {
1911
+ process.stdout.write(out);
1912
+ return;
1913
+ }
1914
+ await mkdir(dirname(outputPath), { recursive: true });
1915
+ await writeFile(outputPath, out, { mode: 0o600 });
1916
+ await chmod(outputPath, 0o600).catch(() => { });
1917
+ console.log(`exported session ${id} → ${outputPath}`);
1918
+ return;
1919
+ }
1920
+ if (action === 'rename' || action === 'title') {
1921
+ const [id, ...titleParts] = positionalArgs(rest, ['--limit', '-n']);
1922
+ const title = titleParts.join(' ').trim();
1923
+ if (!id || !title) {
1924
+ console.error(`ใช้: ${BRAND.cliName} sessions rename <id> <title>`);
1925
+ process.exit(2);
1926
+ }
1927
+ const next = await renameSession(id, title);
1928
+ if (!next) {
1929
+ console.error(`ไม่เจอ session ${id}`);
1930
+ process.exit(1);
1931
+ }
1932
+ console.log(`ตั้งชื่อ session ${id}: ${redactKey(next.title ?? '')}`);
1933
+ return;
1934
+ }
1935
+ if (action === 'stats') {
1936
+ printSessionStats(await listSessions({ cwd }), all ? 'all projects' : 'current project');
1937
+ return;
1938
+ }
1939
+ if (action === 'prune') {
1940
+ const keepRaw = argValue(rest, '--keep');
1941
+ const before = parseDateFlag(rest, '--before');
1942
+ if (!keepRaw && !before) {
1943
+ console.error(`ใช้: ${BRAND.cliName} sessions prune --keep N [--before YYYY-MM-DD] [--all] [--yes]`);
1944
+ process.exit(2);
1945
+ }
1946
+ const keep = keepRaw == null ? undefined : Number(keepRaw);
1947
+ if (keep != null && (!Number.isInteger(keep) || keep < 0)) {
1948
+ console.error('--keep ต้องเป็น integer >= 0');
1949
+ process.exit(2);
1950
+ }
1951
+ const candidates = await listSessions({ cwd });
1952
+ const candidateIds = new Set();
1953
+ if (keep != null)
1954
+ for (const s of candidates.slice(keep))
1955
+ candidateIds.add(s.id);
1956
+ if (before) {
1957
+ const beforeMs = before.getTime();
1958
+ for (const s of candidates) {
1959
+ const updatedMs = Date.parse(s.updated);
1960
+ if (Number.isFinite(updatedMs) && updatedMs < beforeMs)
1961
+ candidateIds.add(s.id);
1962
+ }
1963
+ }
1964
+ if (!candidateIds.size) {
1965
+ console.log('ไม่มี session ที่ต้อง prune');
1966
+ return;
1967
+ }
1968
+ if (!rest.includes('--yes') && !rest.includes('-y')) {
1969
+ console.log(`จะลบ ${candidateIds.size} sessions (dry-run):`);
1970
+ for (const s of candidates.filter((x) => candidateIds.has(x.id)))
1971
+ console.log(` ${s.id} ${s.updated} ${sessionPreview(s)}`);
1972
+ console.log(`\nรันซ้ำพร้อม --yes เพื่อยืนยัน`);
1973
+ return;
1974
+ }
1975
+ const removed = await pruneSessions({ cwd, keep, before });
1976
+ console.log(`ลบ ${removed.length} sessions แล้ว`);
1977
+ return;
1978
+ }
1979
+ if (action === 'rm' || action === 'remove' || action === 'delete') {
1980
+ const id = positionalArgs(rest, ['--limit', '-n'])[0];
1981
+ if (!id) {
1982
+ console.error(`ใช้: ${BRAND.cliName} sessions rm <id>`);
1983
+ process.exit(2);
1984
+ }
1985
+ const ok = await removeSession(id);
1986
+ console.log(ok ? `ลบ session ${id} แล้ว` : `ไม่เจอ session ${id}`);
1987
+ return;
1988
+ }
1989
+ console.error(`ไม่รู้จัก: sessions ${action}\n${sessionUsage()}`);
1990
+ process.exit(1);
1991
+ }
1992
+ async function runInsights(args) {
1993
+ const { parseInsightsArgs } = await import('./insights-args.js');
1994
+ const parsed = parseInsightsArgs(args);
1995
+ if (parsed === null) {
1996
+ console.error(`ใช้: ${BRAND.cliName} insights [--days N] [--all]`);
1997
+ process.exit(2);
1998
+ }
1999
+ const { renderInsights } = await import('./insights.js');
2000
+ console.log(await renderInsights({ days: parsed.days, cwd: parsed.all ? null : process.cwd(), includeGateway: true }));
2001
+ }
2002
+ async function runDump(args) {
2003
+ if (args.includes('-h') || args.includes('--help')) {
2004
+ console.log(`ใช้: ${BRAND.cliName} dump [--show-keys]\n\nสร้าง diagnostic/support dump โดย redact secret เสมอ`);
2005
+ return;
2006
+ }
2007
+ const { buildSupportDump } = await import('./support-dump.js');
2008
+ process.stdout.write(await buildSupportDump({
2009
+ showKeys: args.includes('--show-keys'),
2010
+ version: VERSION,
2011
+ packageName: PACKAGE_NAME,
2012
+ cwd: process.cwd(),
2013
+ }));
2014
+ }
2015
+ function providerIds() {
2016
+ return Object.keys(PROVIDERS).join(', ');
2017
+ }
2018
+ function findProviderId(raw) {
2019
+ if (!raw)
2020
+ return undefined;
2021
+ const lower = raw.toLowerCase();
2022
+ if (PROVIDERS[lower])
2023
+ return lower;
2024
+ for (const [id, cfg] of Object.entries(PROVIDERS)) {
2025
+ if (raw === cfg.envVar || cfg.envFallbacks?.includes(raw))
2026
+ return id;
2027
+ }
2028
+ return undefined;
2029
+ }
2030
+ function authEnvSource(providerId) {
2031
+ const cfg = PROVIDERS[providerId];
2032
+ if (!cfg)
2033
+ return undefined;
2034
+ for (const name of [cfg.envVar, ...(cfg.envFallbacks ?? [])]) {
2035
+ if (process.env[name]?.trim())
2036
+ return name;
2037
+ }
2038
+ return undefined;
2039
+ }
2040
+ function authUsage() {
2041
+ return `ใช้:
2042
+ ${BRAND.cliName} auth list
2043
+ ${BRAND.cliName} auth status <provider>
2044
+ ${BRAND.cliName} auth add <provider> --api-key <key> [--use]
2045
+ ${BRAND.cliName} auth remove <provider|ENV_VAR>
2046
+ ${BRAND.cliName} auth reset [provider|ENV_VAR]
2047
+
2048
+ providers: ${providerIds()}`;
2049
+ }
2050
+ async function runAuth(args) {
2051
+ const action = args[0] ?? 'list';
2052
+ const rest = args.slice(action === 'list' && args[0] !== 'list' ? 0 : 1);
2053
+ const { authConfigPath, clearStoredAuth, readStoredAuthRaw, removeStoredKey, saveGlobalConfig, saveKey } = await import('./config.js');
2054
+ if (action === '-h' || action === '--help' || action === 'help') {
2055
+ console.log(authUsage());
2056
+ return;
2057
+ }
2058
+ if (action === 'list' || action === 'ls' || action === 'status-all') {
2059
+ const stored = await readStoredAuthRaw();
2060
+ console.log(`${BRAND.productName} auth`);
2061
+ console.log(` store: ${authConfigPath()}`);
2062
+ for (const [id, cfg] of Object.entries(PROVIDERS)) {
2063
+ if (!cfg.requiresKey) {
2064
+ console.log(` ${id.padEnd(10)} ${cfg.label} — no API key required`);
2065
+ continue;
2066
+ }
2067
+ const key = resolveKeyFromEnv(cfg.envVar, cfg.envFallbacks);
2068
+ const source = authEnvSource(id);
2069
+ const saved = stored[cfg.envVar];
2070
+ const state = key ? `ready via ${source ?? cfg.envVar}` : `missing ${cfg.envVar}`;
2071
+ const savedText = saved ? ` · stored ${redactKey(saved)}` : '';
2072
+ console.log(` ${id.padEnd(10)} ${cfg.label} — ${state}${savedText}`);
2073
+ }
2074
+ return;
2075
+ }
2076
+ if (action === 'status') {
2077
+ const providerId = findProviderId(positionalArgs(rest, ['--api-key', '--key', '--token', '--model'])[0]);
2078
+ if (!providerId) {
2079
+ console.error(`ใช้: ${BRAND.cliName} auth status <provider>\nproviders: ${providerIds()}`);
2080
+ process.exit(1);
2081
+ }
2082
+ const cfg = PROVIDERS[providerId];
2083
+ const stored = await readStoredAuthRaw();
2084
+ const key = resolveKeyFromEnv(cfg.envVar, cfg.envFallbacks);
2085
+ const source = authEnvSource(providerId);
2086
+ console.log(`${cfg.label} (${providerId})`);
2087
+ console.log(` key required: ${cfg.requiresKey ? 'yes' : 'no'}`);
2088
+ console.log(` env var: ${cfg.envVar}${cfg.envFallbacks?.length ? ` (fallback: ${cfg.envFallbacks.join(', ')})` : ''}`);
2089
+ console.log(` stored: ${stored[cfg.envVar] ? redactKey(stored[cfg.envVar]) : '(not stored)'}`);
2090
+ console.log(` runtime: ${key ? `${redactKey(key)} via ${source ?? cfg.envVar}` : '(missing)'}`);
2091
+ const url = consoleUrl(providerId);
2092
+ if (url)
2093
+ console.log(` console: ${url}`);
2094
+ if (cfg.note)
2095
+ console.log(` note: ${cfg.note}`);
2096
+ return;
2097
+ }
2098
+ if (action === 'add' || action === 'login') {
2099
+ const providerId = findProviderId(positionalArgs(rest, ['--api-key', '--key', '--token', '--model'])[0]);
2100
+ if (!providerId) {
2101
+ console.error(`ใช้: ${BRAND.cliName} auth add <provider> --api-key <key>\nproviders: ${providerIds()}`);
2102
+ process.exit(1);
2103
+ }
2104
+ const cfg = PROVIDERS[providerId];
2105
+ if (!cfg.requiresKey) {
2106
+ console.log(`${cfg.label} ไม่ต้องเก็บ API key ใน Sanook`);
2107
+ if (cfg.note)
2108
+ console.log(cfg.note);
2109
+ return;
2110
+ }
2111
+ let key = argValue(rest, '--api-key', '--key', '--token');
2112
+ if (!key) {
2113
+ if (!process.stdin.isTTY) {
2114
+ console.error(`ใช้: ${BRAND.cliName} auth add ${providerId} --api-key <key>`);
2115
+ process.exit(1);
2116
+ }
2117
+ key = await askText(`${cfg.label} API key (${cfg.keyExample ?? cfg.envVar}): `);
2118
+ }
2119
+ try {
2120
+ assertDirectApiKey(cfg, key);
2121
+ }
2122
+ catch (e) {
2123
+ console.error(redactKey(e.message));
2124
+ process.exit(1);
2125
+ }
2126
+ await saveKey(cfg.envVar, key.trim());
2127
+ console.log(`บันทึก ${cfg.label} key แล้ว: ${cfg.envVar}=${redactKey(key.trim())}`);
2128
+ if (rest.includes('--use') || rest.includes('--default')) {
2129
+ const modelArg = argValue(rest, '--model', '-m') ?? 'default';
2130
+ const model = modelArg.includes(':') ? modelArg : `${providerId}:${cfg.models[modelArg] ?? modelArg}`;
2131
+ await saveGlobalConfig({ model, provider: providerId });
2132
+ console.log(`ตั้ง default model เป็น ${model}`);
2133
+ }
2134
+ else {
2135
+ console.log(`ใช้เป็น default ได้ด้วย: ${BRAND.cliName} auth add ${providerId} --api-key <key> --use`);
2136
+ }
2137
+ return;
2138
+ }
2139
+ if (action === 'remove' || action === 'rm' || action === 'logout') {
2140
+ const target = positionalArgs(rest, ['--api-key', '--key', '--token', '--model'])[0];
2141
+ if (!target && action !== 'logout') {
2142
+ console.error(`ใช้: ${BRAND.cliName} auth remove <provider|ENV_VAR>`);
2143
+ process.exit(1);
2144
+ }
2145
+ if (!target && action === 'logout') {
2146
+ await clearStoredAuth();
2147
+ console.log('ล้าง key ที่ Sanook เก็บไว้ทั้งหมดแล้ว');
2148
+ return;
2149
+ }
2150
+ const providerId = findProviderId(target);
2151
+ const envVar = providerId ? PROVIDERS[providerId].envVar : target;
2152
+ const ok = await removeStoredKey(envVar);
2153
+ console.log(ok ? `ลบ ${envVar} ออกจาก Sanook auth store แล้ว` : `ไม่เจอ ${envVar} ใน Sanook auth store`);
2154
+ return;
2155
+ }
2156
+ if (action === 'reset' || action === 'clear') {
2157
+ const target = positionalArgs(rest, ['--api-key', '--key', '--token', '--model'])[0];
2158
+ if (!target) {
2159
+ await clearStoredAuth();
2160
+ console.log('ล้าง key ที่ Sanook เก็บไว้ทั้งหมดแล้ว');
2161
+ return;
2162
+ }
2163
+ const providerId = findProviderId(target);
2164
+ const envVar = providerId ? PROVIDERS[providerId].envVar : target;
2165
+ const ok = await removeStoredKey(envVar);
2166
+ console.log(ok ? `ลบ ${envVar} ออกจาก Sanook auth store แล้ว` : `ไม่เจอ ${envVar} ใน Sanook auth store`);
2167
+ return;
2168
+ }
2169
+ console.error(`ไม่รู้จัก: auth ${action}\n${authUsage()}`);
2170
+ process.exit(1);
2171
+ }
2172
+ async function runSetup(args) {
2173
+ if (args.includes('-h') || args.includes('--help') || args[0] === 'help' || args[0] === 'list' || args[0] === 'status') {
2174
+ console.log(await setupOverview());
2175
+ return;
2176
+ }
2177
+ const section = args.find((a) => !a.startsWith('-')) ?? 'model';
2178
+ const start = args.indexOf(section);
2179
+ const rest = start === -1 ? [] : args.slice(start + 1);
2180
+ if (section === 'model')
2181
+ return startModelSetup();
2182
+ if (section === 'gateway')
2183
+ return runGateway(['setup', ...rest]);
2184
+ if (section === 'tools')
2185
+ return runTools(rest);
2186
+ if (section === 'agent')
2187
+ return runAgentSetupSummary();
2188
+ if (section === 'brain')
2189
+ return runBrain(['init', ...rest]);
2190
+ console.error(`ไม่รู้จัก setup section "${section}" — ใช้ model / gateway / tools / agent / brain`);
2191
+ process.exit(1);
2192
+ }
2193
+ async function setupOverview() {
2194
+ const cfg = await loadConfig({});
2195
+ const { readGatewayConfig, resolveBlueBubblesConfig, resolveDiscordConfig, resolveEmailConfig, resolveGoogleChatConfig, resolveHomeAssistantConfig, resolveLineConfig, resolveMattermostConfig, resolveMatrixConfig, resolveNtfyConfig, resolveSignalConfig, resolveSlackConfig, resolveSmsConfig, resolveTelegramConfig, resolveTeamsConfig, resolveWhatsAppConfig, resolveWebhookConfig, } = await import('./gateway/config.js');
2196
+ const gateway = await readGatewayConfig();
2197
+ const telegram = resolveTelegramConfig(gateway);
2198
+ const discord = resolveDiscordConfig(gateway);
2199
+ const slack = resolveSlackConfig(gateway);
2200
+ const mattermost = resolveMattermostConfig(gateway);
2201
+ const homeassistant = resolveHomeAssistantConfig(gateway);
2202
+ const email = resolveEmailConfig(gateway);
2203
+ const line = resolveLineConfig(gateway);
2204
+ const sms = resolveSmsConfig(gateway);
2205
+ const ntfy = resolveNtfyConfig(gateway);
2206
+ const signal = resolveSignalConfig(gateway);
2207
+ const whatsapp = resolveWhatsAppConfig(gateway);
2208
+ const matrix = resolveMatrixConfig(gateway);
2209
+ const googleChat = resolveGoogleChatConfig(gateway);
2210
+ const bluebubbles = resolveBlueBubblesConfig(gateway);
2211
+ const teams = resolveTeamsConfig(gateway);
2212
+ const webhooks = resolveWebhookConfig(gateway);
2213
+ const configuredPlatforms = [
2214
+ telegram.token ? 'telegram' : '',
2215
+ discord.token ? 'discord' : '',
2216
+ slack.botToken ? 'slack' : '',
2217
+ mattermost.serverUrl && mattermost.token ? 'mattermost' : '',
2218
+ homeassistant.token ? 'homeassistant' : '',
2219
+ email.address ? 'email' : '',
2220
+ line.channelAccessToken ? 'line' : '',
2221
+ sms.accountSid ? 'sms' : '',
2222
+ ntfy.topic ? 'ntfy' : '',
2223
+ signal.account ? 'signal' : '',
2224
+ whatsapp.phoneNumberId && whatsapp.accessToken ? 'whatsapp' : '',
2225
+ matrix.homeserver ? 'matrix' : '',
2226
+ googleChat.serviceAccountJson || googleChat.incomingWebhookUrl ? 'googlechat' : '',
2227
+ bluebubbles.serverUrl && bluebubbles.password ? 'bluebubbles' : '',
2228
+ teams.incomingWebhookUrl || teams.graphAccessToken ? 'teams' : '',
2229
+ webhooks.enabled ? 'webhooks' : '',
2230
+ ].filter(Boolean);
2231
+ return [
2232
+ `${BRAND.productName} setup`,
2233
+ '',
2234
+ ` model ${BRAND.cliName} setup model เลือก provider + model (current: ${cfg.model})`,
2235
+ ` gateway ${BRAND.cliName} setup gateway เชื่อม messaging platforms (${configuredPlatforms.length ? configuredPlatforms.join(', ') : 'not configured'})`,
2236
+ ` tools ${BRAND.cliName} setup tools ดู tool surface + MCP entry points`,
2237
+ ` agent ${BRAND.cliName} setup agent ตั้ง permission/budget/personality/insights`,
2238
+ ` brain ${BRAND.cliName} setup brain สร้าง Second Brain vault + AGENTS/GEMINI/SANOOK rules`,
2239
+ '',
2240
+ `เริ่มเร็ว: ${BRAND.cliName} setup model`,
2241
+ `ดูสถานะ: ${BRAND.cliName} status`,
2242
+ ].join('\n');
2243
+ }
2244
+ function modelOverrideForProvider(providerArg, modelArg) {
2245
+ const providerId = findProviderId(providerArg);
2246
+ if (!providerArg)
2247
+ return modelArg;
2248
+ if (!providerId) {
2249
+ console.error(`ไม่รู้จัก provider "${providerArg}" — มี: ${providerIds()}`);
2250
+ process.exit(1);
2251
+ }
2252
+ if (!modelArg)
2253
+ return `${providerId}:${PROVIDERS[providerId].models.default}`;
2254
+ if (modelArg.includes(':'))
2255
+ return modelArg;
2256
+ return `${providerId}:${PROVIDERS[providerId].models[modelArg] ?? modelArg}`;
2257
+ }
2258
+ function appendPipedInput(prompt, piped) {
2259
+ return piped ? `${prompt}\n\n<stdin>\n${piped}\n</stdin>`.trim() : prompt;
2260
+ }
2261
+ async function runChat(args) {
2262
+ if (args.includes('-h') || args.includes('--help')) {
2263
+ console.log(`ใช้:
2264
+ ${BRAND.cliName} chat -q "<query>" [--provider <provider>] [--model <alias|id>]
2265
+ ${BRAND.cliName} chat "<query>" [--provider <provider>]
2266
+ ${BRAND.cliName} chat เปิด interactive REPL
2267
+
2268
+ providers: ${providerIds()}`);
2269
+ return;
2270
+ }
2271
+ let split = extractValue(args, '-q', '--query');
2272
+ const query = split.value;
2273
+ split = extractValue(split.rest, '--provider');
2274
+ const provider = split.value;
2275
+ split = extractValue(split.rest, '--toolsets', '--tools');
2276
+ const toolsets = split.value;
2277
+ const safeMode = split.rest.includes('--safe-mode');
2278
+ const yolo = split.rest.includes('--yolo') || split.rest.includes('--dangerously-skip-permissions');
2279
+ const cleaned = stripBooleanFlags(split.rest, '--safe-mode', '--yolo', '--dangerously-skip-permissions');
2280
+ const parsed = parseArgs(yolo ? [...cleaned, '--yes'] : cleaned);
2281
+ const resumeSession = await requestedResumeSession(cleaned, parsed.resume);
2282
+ const budgetUsd = Number.isFinite(parsed.budget) ? parsed.budget : undefined;
2283
+ const model = modelOverrideForProvider(provider, parsed.model ?? (provider ? undefined : resumeSession?.model));
2284
+ const piped = process.stdin.isTTY ? '' : (await readStdin()).trim();
2285
+ const prompt = appendPipedInput(query ?? parsed.prompt, piped);
2286
+ if (toolsets && !parsed.quiet && !parsed.json) {
2287
+ process.stderr.write(`${DIM}(toolsets="${toolsets}" accepted; Sanook currently exposes the configured tool surface)${RESET}\n`);
2288
+ }
2289
+ if (!prompt) {
2290
+ const config = await loadConfig({ model, budgetUsd });
2291
+ const { startApp } = await import('./ui/render.js');
2292
+ startApp({
2293
+ needsSetup: false,
2294
+ appProps: {
2295
+ initialModel: config.model,
2296
+ fallbackModel: config.fallbackModel,
2297
+ budgetUsd: config.budgetUsd,
2298
+ permissionMode: parsed.yes || yolo ? 'auto' : safeMode ? 'ask' : config.permissionMode,
2299
+ initialHistory: resumeSession?.messages ?? (await requestedContinuationHistory(cleaned)),
2300
+ },
2301
+ });
2302
+ return;
2303
+ }
2304
+ const config = await loadConfig({ model, budgetUsd });
2305
+ const noKey = headlessKeyHint(config.model);
2306
+ if (noKey) {
2307
+ process.stderr.write(`${noKey}\n`);
2308
+ process.exit(1);
2309
+ }
2310
+ const history = resumeSession?.messages ?? (await requestedContinuationHistory(args));
2311
+ await runHeadless(config.model, prompt, config.budgetUsd, config.maxSteps, parsed.json, history, parsed.planMode, parsed.yes || yolo ? 'auto' : safeMode ? 'ask' : config.permissionMode, parsed.quiet, config.fallbackModel);
2312
+ }
2313
+ async function runPureOneShot(args) {
2314
+ const rest = args;
2315
+ const parsed = parseArgs(rest);
2316
+ const resumeSession = await requestedResumeSession(rest, parsed.resume);
2317
+ const budgetUsd = Number.isFinite(parsed.budget) ? parsed.budget : undefined;
2318
+ const piped = process.stdin.isTTY ? '' : (await readStdin()).trim();
2319
+ const prompt = appendPipedInput(parsed.prompt, piped);
2320
+ if (!prompt) {
2321
+ console.error(`ใช้: ${BRAND.cliName} -z "<task>"`);
2322
+ process.exit(1);
2323
+ }
2324
+ const config = await loadConfig({ model: parsed.model ?? resumeSession?.model, budgetUsd });
2325
+ const noKey = headlessKeyHint(config.model);
2326
+ if (noKey) {
2327
+ process.stderr.write(`${noKey}\n`);
2328
+ process.exit(1);
2329
+ }
2330
+ const history = resumeSession?.messages ?? (await requestedContinuationHistory(rest));
2331
+ await runHeadless(config.model, prompt, config.budgetUsd, config.maxSteps, parsed.json, history, parsed.planMode, parsed.yes ? 'auto' : config.permissionMode, true, config.fallbackModel);
2332
+ }
2333
+ async function runSend(args) {
2334
+ const json = args.includes('--json');
2335
+ const quiet = args.includes('--quiet') || args.includes('-q');
2336
+ const wantsList = args.includes('--list') || args.includes('-l');
2337
+ const valueFlags = ['--to', '-t', '--file', '-f', '--subject', '-s'];
2338
+ if (args.includes('-h') || args.includes('--help')) {
2339
+ console.log(`ใช้:
2340
+ ${BRAND.cliName} send --to telegram[:chat_id[:thread_id]] "message"
2341
+ ${BRAND.cliName} send --to discord[:channel_id[:thread_id]] "message"
2342
+ ${BRAND.cliName} send --to slack[:channel_id[:thread_ts]] "message"
2343
+ ${BRAND.cliName} send --to mattermost[:channel_id[:root_post_id]] "message"
2344
+ ${BRAND.cliName} send --to homeassistant[:notification_id] "message"
2345
+ ${BRAND.cliName} send --to email[:recipient@example.com] --subject "[CI]" "message"
2346
+ ${BRAND.cliName} send --to line[:U/C/R-id] "message"
2347
+ ${BRAND.cliName} send --to sms[:+15558675310] "message"
2348
+ ${BRAND.cliName} send --to ntfy[:topic] "message"
2349
+ ${BRAND.cliName} send --to signal[:+15558675310|group:<id>] "message"
2350
+ ${BRAND.cliName} send --to whatsapp[:15558675310] "message"
2351
+ ${BRAND.cliName} send --to matrix[:!roomid:matrix.org] "message"
2352
+ ${BRAND.cliName} send --to googlechat[:spaces/AAA|spaces/AAA/threads/BBB] "message"
2353
+ ${BRAND.cliName} send --to bluebubbles[:chat-guid|email|phone] "message"
2354
+ ${BRAND.cliName} send --to teams[:chat_id|team/<team-id>/channel/<channel-id>] "message"
2355
+ ${BRAND.cliName} send --to slack --subject "[CI]" --file build.log
2356
+ echo "done" | ${BRAND.cliName} send --to telegram --quiet
2357
+ ${BRAND.cliName} send --list [platform] [--json]`);
2358
+ return;
2359
+ }
2360
+ if (wantsList) {
2361
+ const { listConfiguredTargets } = await import('./gateway/targets.js');
2362
+ const { readGatewayConfig } = await import('./gateway/config.js');
2363
+ const filter = positionalArgs(args, valueFlags)[0];
2364
+ const targets = listConfiguredTargets(await readGatewayConfig()).filter((t) => !filter || t.platform === filter);
2365
+ if (json) {
2366
+ console.log(JSON.stringify({ targets }));
2367
+ return;
2368
+ }
2369
+ if (!targets.length) {
2370
+ console.log(filter ? `ยังไม่มี target สำหรับ ${filter}` : `ยังไม่มี messaging target — เริ่มด้วย: ${BRAND.cliName} gateway setup`);
2371
+ return;
2372
+ }
2373
+ for (const t of targets) {
2374
+ console.log(`${t.target.padEnd(24)} ${t.configured ? 'ready' : 'not-ready'} ${t.label}`);
2375
+ }
2376
+ return;
2377
+ }
2378
+ const to = argValue(args, '--to', '-t');
2379
+ if (!to) {
2380
+ console.error(`ใช้: ${BRAND.cliName} send --to <telegram|discord|slack|mattermost|homeassistant|email|line|sms|ntfy|signal|whatsapp|matrix|googlechat|bluebubbles|teams>[:target] "message"`);
2381
+ process.exit(2);
2382
+ }
2383
+ const file = argValue(args, '--file', '-f');
2384
+ const subject = argValue(args, '--subject', '-s');
2385
+ let message = positionalArgs(args, valueFlags).join(' ').trim();
2386
+ if (!message && file)
2387
+ message = file === '-' ? await readStdin() : await readFile(file, 'utf8');
2388
+ if (!message && !process.stdin.isTTY)
2389
+ message = (await readStdin()).trim();
2390
+ if (subject && message && !to.startsWith('email'))
2391
+ message = `${subject.trim()}\n\n${message.trim()}`;
2392
+ if (!message) {
2393
+ console.error('message ว่าง — ใส่ข้อความ, --file <path>, หรือ pipe stdin เข้ามา');
2394
+ process.exit(2);
2395
+ }
2396
+ const { parseSendTarget } = await import('./gateway/targets.js');
2397
+ try {
2398
+ parseSendTarget(to);
2399
+ }
2400
+ catch (e) {
2401
+ console.error(e.message);
2402
+ process.exit(2);
2403
+ }
2404
+ const { deliverToTarget } = await import('./gateway/deliver.js');
2405
+ try {
2406
+ const result = await deliverToTarget(to, message, { subject });
111
2407
  if (json)
112
- process.stdout.write(`${JSON.stringify({ type: 'error', message: msg })}\n`);
2408
+ console.log(JSON.stringify({ ok: true, ...result }));
2409
+ else if (!quiet)
2410
+ console.log(`sent ${result.target}`);
2411
+ }
2412
+ catch (e) {
2413
+ const msg = redactKey(e.message);
2414
+ if (json)
2415
+ console.log(JSON.stringify({ ok: false, error: msg }));
113
2416
  else
114
- console.error(`\nERROR: ${msg}`);
2417
+ console.error(`ส่งไม่สำเร็จ: ${msg}`);
115
2418
  process.exit(1);
116
2419
  }
117
2420
  }
118
- // อ่านจาก package.json (single source of truth) — กัน version constant drift
119
- const PACKAGE = JSON.parse(readFileSync(new URL('../package.json', import.meta.url), 'utf8'));
120
- const VERSION = PACKAGE.version;
121
- const PACKAGE_NAME = PACKAGE.name;
122
- const HELP = `${BRAND.productName} — a terminal AI coding agent (BYOK)
123
-
124
- usage:
125
- ${BRAND.cliName} "<task>" run one task (headless)
126
- ${BRAND.cliName} interactive REPL
127
- ${BRAND.cliName} --json "<task>" headless, JSONL output (for CI/scripts)
128
- ${BRAND.cliName} update update ${BRAND.cliName} to the latest npm release
129
- ${BRAND.cliName} doctor ตรวจการติดตั้ง + วิธีแก้ PATH (เมื่อพิมพ์ "${BRAND.cliName}" แล้วไม่เจอ)
130
-
131
- gateway (อยู่ยาว 24/7 — HTTP loopback + cron):
132
- ${BRAND.cliName} serve [--port 8787] เปิด gateway (OpenAI-compat /v1/chat/completions + scheduler)
133
- ${BRAND.cliName} cron add "<when>" "<task>" ตั้งงานล่วงหน้า (when: "every 30m" | "09:00" | ISO | now)
134
- ${BRAND.cliName} cron list ดู task ทั้งหมด
135
- ${BRAND.cliName} cron rm <id> ลบ task
136
-
137
- skills (built-in + ติดตั้งเพิ่มได้):
138
- ${BRAND.cliName} skill list ดู skill ทั้งหมด
139
- ${BRAND.cliName} skill add <user/repo|url|path> ติดตั้ง skill จาก GitHub / URL / local
140
- ${BRAND.cliName} skill remove <name> ลบ skill ที่ติดตั้ง
141
- ${BRAND.cliName} models [provider] ดู/verify model id (เทียบ provider จริงถ้ามี key)
142
-
143
- second brain (Obsidian workspace สำหรับจัดเก็บงาน + ความจำ AI):
144
- ${BRAND.cliName} brain init [path] สร้างโครงสร้าง second-brain ที่ path (ไม่ใส่ = ถาม)
145
-
146
- search (BM25 + optional BYOK semantic เหนือ vault + memory + sessions + skills):
147
- ${BRAND.cliName} index (re)index vault+memory แบบ incremental (O(delta))
148
- ${BRAND.cliName} search "<query>" [--mode auto|fts|semantic|hybrid] [--limit N] [--source vault,memory]
149
- ${BRAND.cliName} mcp serve expose brain เป็น MCP server (stdio) ให้ Claude Desktop/Cursor
150
-
151
- config & mcp:
152
- ${BRAND.cliName} config [get|set <k> <v>] ดู/แก้ ${appHomePath('config.json')} (model/budgetUsd/permissionMode/cacheTtl/compaction/thinking)
153
- ${BRAND.cliName} mcp [list|add <name> <cmd> …|remove <name>] จัดการ MCP servers
154
- ${BRAND.cliName} trust [status|add|remove] อนุญาต/ยกเลิก project .sanook mcp/hooks/skills/commands
155
-
156
- flags:
157
- -m, --model <spec> sonnet/opus/haiku/fable · gpt/codex · gemini · grok · deepseek · mistral · groq · ollama/lmstudio
158
- or "provider:model-id" (e.g. openai:gpt-5-codex, groq:fast, google:gemini-2.5-flash)
159
- -b, --budget <usd> stop when estimated cost exceeds this
160
- -c, --continue resume the latest session ของ project นี้
161
- --continue-any resume latest session ข้าม project (explicit)
162
- --plan plan mode — สำรวจ+วางแผนเท่านั้น ไม่แก้ไฟล์ (read-only)
163
- -y, --yes อนุมัติ tool อัตโนมัติ (ข้าม ask-mode permission)
164
- --json machine-readable JSONL output
165
- -v, --version
166
- -h, --help
2421
+ async function runWebhook(args) {
2422
+ const action = args[0] ?? 'list';
2423
+ const rest = action === 'list' && args[0] !== 'list' ? args : args.slice(1);
2424
+ const valueFlags = [
2425
+ '--events',
2426
+ '--prompt',
2427
+ '--to',
2428
+ '-t',
2429
+ '--deliver',
2430
+ '--deliver-chat-id',
2431
+ '--chat-id',
2432
+ '--secret',
2433
+ '--description',
2434
+ '--payload',
2435
+ '--public-url',
2436
+ '--rate-limit',
2437
+ '--rate-limit-per-minute',
2438
+ ];
2439
+ if (args.includes('-h') || args.includes('--help') || action === 'help') {
2440
+ console.log(`ใช้:
2441
+ ${BRAND.cliName} webhook subscribe <route> [--events issues,push] [--prompt "..."] [--to telegram|slack:C01|mattermost:chan|homeassistant|sms|ntfy|signal|whatsapp|matrix|googlechat|bluebubbles|teams]
2442
+ ${BRAND.cliName} webhook subscribe <route> --deliver telegram --deliver-chat-id 123 --deliver-only --prompt "New event: {__raw__}"
2443
+ ${BRAND.cliName} webhook list
2444
+ ${BRAND.cliName} webhook remove <route>
2445
+ ${BRAND.cliName} webhook test <route> --payload '{"event_type":"ping"}'
167
2446
 
168
- env (BYOK — direct API key only):
169
- ANTHROPIC_API_KEY / GOOGLE_GENERATIVE_AI_API_KEY / OPENAI_API_KEY
170
- ${BRAND_ENV.disableUpdateCheck}=1 disable interactive update prompts`;
171
- /** sanook serve [--port N] [--model spec] — เปิด gateway (HTTP loopback + cron scheduler) อยู่ยาว */
172
- async function runServe(args) {
173
- const portIdx = args.indexOf('--port');
174
- const port = portIdx !== -1 ? Number(args[portIdx + 1]) : 8787;
175
- if (!Number.isInteger(port) || port < 1 || port > 65535) {
176
- console.error(`port ไม่ถูกต้อง: ${args[portIdx + 1]}`);
177
- process.exit(1);
2447
+ signature headers:
2448
+ GitHub: X-Hub-Signature-256: sha256=<hmac>
2449
+ GitLab: X-Gitlab-Token: <secret>
2450
+ Generic: X-Webhook-Signature: <hmac-hex>`);
2451
+ return;
178
2452
  }
179
- const mIdx = args.findIndex((a) => a === '--model' || a === '-m');
180
- const config = await loadConfig({ model: mIdx !== -1 ? args[mIdx + 1] : undefined });
181
- const { startGateway } = await import('./gateway/serve.js');
182
- process.stdout.write(`${DIM}${BRAND.productName} gateway model: ${config.model}${RESET}\n`);
183
- const stop = await startGateway({
184
- port,
185
- model: config.model,
186
- budgetUsd: config.budgetUsd,
187
- permissionMode: envFlag(BRAND_ENV.gatewayAllowWrite) ? 'auto' : config.permissionMode,
188
- onLog: (m) => process.stdout.write(`${DIM}[gateway] ${m}${RESET}\n`),
189
- });
190
- const shutdown = () => {
191
- stop();
192
- process.stdout.write('\n[gateway] หยุดแล้ว\n');
193
- process.exit(0);
194
- };
195
- process.on('SIGINT', shutdown);
196
- process.on('SIGTERM', shutdown);
197
- // server + scheduler interval ถือ event loop ไว้ → process อยู่ยาวจนกด Ctrl-C
2453
+ if (action === 'subscribe' || action === 'add') {
2454
+ const name = positionalArgs(rest, valueFlags)[0];
2455
+ if (!name) {
2456
+ console.error(`ใช้: ${BRAND.cliName} webhook subscribe <route> [--prompt "..."] [--to <target>]`);
2457
+ process.exit(2);
2458
+ }
2459
+ const { isValidWebhookRouteName, generateWebhookSecret } = await import('./gateway/webhooks.js');
2460
+ if (!isValidWebhookRouteName(name)) {
2461
+ console.error('route ต้องเป็น a-z/A-Z/0-9/_/- ความยาวไม่เกิน 64 และต้องขึ้นต้นด้วยตัวอักษรหรือตัวเลข');
2462
+ process.exit(2);
2463
+ }
2464
+ const prompt = argValue(rest, '--prompt');
2465
+ const description = argValue(rest, '--description');
2466
+ const events = parseStringCsv(argValue(rest, '--events')).map((event) => event.trim()).filter(Boolean);
2467
+ const deliver = webhookDeliverTarget(rest);
2468
+ const deliverOnly = rest.includes('--deliver-only');
2469
+ const insecureNoAuth = rest.includes('--insecure-no-auth');
2470
+ const routeSecret = insecureNoAuth ? 'INSECURE_NO_AUTH' : (argValue(rest, '--secret')?.trim() || generateWebhookSecret());
2471
+ const publicUrl = argValue(rest, '--public-url');
2472
+ const rateLimitRaw = argValue(rest, '--rate-limit', '--rate-limit-per-minute');
2473
+ const rateLimitPerMinute = rateLimitRaw ? parsePort(rateLimitRaw, 30, 'webhook route rate limit') : undefined;
2474
+ if (deliverOnly && deliver === 'log') {
2475
+ console.error('--deliver-only ต้องมี --to หรือ --deliver เป็น messaging target จริง');
2476
+ process.exit(2);
2477
+ }
2478
+ if (deliver !== 'log') {
2479
+ const { parseSendTarget } = await import('./gateway/targets.js');
2480
+ try {
2481
+ parseSendTarget(deliver);
2482
+ }
2483
+ catch (e) {
2484
+ console.error(e.message);
2485
+ process.exit(2);
2486
+ }
2487
+ }
2488
+ const { patchGatewayConfig, readGatewayConfig } = await import('./gateway/config.js');
2489
+ const current = await readGatewayConfig();
2490
+ await patchGatewayConfig({
2491
+ webhooks: {
2492
+ enabled: true,
2493
+ publicUrl: publicUrl?.trim() || current.webhooks?.publicUrl,
2494
+ routes: {
2495
+ [name]: {
2496
+ events,
2497
+ secret: routeSecret,
2498
+ prompt: prompt?.trim() || undefined,
2499
+ deliver,
2500
+ deliverOnly,
2501
+ description: description?.trim() || undefined,
2502
+ rateLimitPerMinute,
2503
+ },
2504
+ },
2505
+ },
2506
+ });
2507
+ const base = (publicUrl?.trim() || current.webhooks?.publicUrl || 'http://127.0.0.1:8787').replace(/\/+$/, '');
2508
+ console.log(`เพิ่ม webhook route "${name}" แล้ว`);
2509
+ console.log(`URL: ${base}/webhooks/${name}`);
2510
+ console.log(`secret: ${routeSecret}`);
2511
+ console.log(`test: ${BRAND.cliName} webhook test ${name} --payload '{"event_type":"ping"}'`);
2512
+ return;
2513
+ }
2514
+ if (action === 'list' || action === undefined) {
2515
+ const { readGatewayConfig, resolveWebhookConfig } = await import('./gateway/config.js');
2516
+ const cfg = await readGatewayConfig();
2517
+ const webhooks = resolveWebhookConfig(cfg);
2518
+ const routes = Object.values(webhooks.routes);
2519
+ if (!routes.length) {
2520
+ console.log(`ยังไม่มี webhook route — เพิ่มด้วย: ${BRAND.cliName} webhook subscribe <route> --prompt "Event: {__raw__}"`);
2521
+ return;
2522
+ }
2523
+ const base = (webhooks.publicUrl || 'http://127.0.0.1:8787').replace(/\/+$/, '');
2524
+ for (const route of routes) {
2525
+ const events = route.events.length ? route.events.join(',') : '*';
2526
+ const mode = route.deliverOnly ? 'direct' : 'agent';
2527
+ console.log(`${route.name.padEnd(20)} ${mode.padEnd(6)} events:${events.padEnd(12)} deliver:${route.deliver} ${base}/webhooks/${route.name}`);
2528
+ }
2529
+ return;
2530
+ }
2531
+ if (action === 'remove' || action === 'rm') {
2532
+ const name = positionalArgs(rest, valueFlags)[0];
2533
+ if (!name) {
2534
+ console.error(`ใช้: ${BRAND.cliName} webhook remove <route>`);
2535
+ process.exit(2);
2536
+ }
2537
+ const { readGatewayConfig, writeGatewayConfig } = await import('./gateway/config.js');
2538
+ const cfg = await readGatewayConfig();
2539
+ if (!cfg.webhooks?.routes?.[name]) {
2540
+ console.log(`ไม่พบ webhook route "${name}"`);
2541
+ return;
2542
+ }
2543
+ const routes = { ...cfg.webhooks.routes };
2544
+ delete routes[name];
2545
+ await writeGatewayConfig({ ...cfg, webhooks: { ...cfg.webhooks, routes } });
2546
+ console.log(`ลบ webhook route "${name}" แล้ว`);
2547
+ return;
2548
+ }
2549
+ if (action === 'test') {
2550
+ const name = positionalArgs(rest, valueFlags)[0];
2551
+ if (!name) {
2552
+ console.error(`ใช้: ${BRAND.cliName} webhook test <route> [--payload <json>]`);
2553
+ process.exit(2);
2554
+ }
2555
+ const payload = argValue(rest, '--payload') ?? '{"event_type":"ping"}';
2556
+ let rawBody;
2557
+ let parsedPayload;
2558
+ try {
2559
+ parsedPayload = JSON.parse(payload);
2560
+ rawBody = JSON.stringify(parsedPayload);
2561
+ }
2562
+ catch {
2563
+ console.error('--payload ต้องเป็น JSON object/string ที่ parse ได้');
2564
+ process.exit(2);
2565
+ }
2566
+ const { readGatewayConfig, resolveWebhookConfig } = await import('./gateway/config.js');
2567
+ const { handleWebhookRequest } = await import('./gateway/webhooks.js');
2568
+ const cfg = resolveWebhookConfig(await readGatewayConfig());
2569
+ const route = cfg.routes[name];
2570
+ if (!route) {
2571
+ console.error(`ไม่พบ webhook route "${name}"`);
2572
+ process.exit(2);
2573
+ }
2574
+ const secret = route.secret || cfg.secret;
2575
+ const eventType = parsedPayload && typeof parsedPayload === 'object' && typeof parsedPayload.event_type === 'string'
2576
+ ? parsedPayload.event_type
2577
+ : 'ping';
2578
+ const headers = { 'x-event-type': eventType };
2579
+ if (secret && secret !== 'INSECURE_NO_AUTH') {
2580
+ const { createHmac } = await import('node:crypto');
2581
+ headers['x-webhook-signature'] = createHmac('sha256', secret).update(rawBody).digest('hex');
2582
+ headers['x-request-id'] = `sanook-test-${Date.now()}`;
2583
+ }
2584
+ const appCfg = await loadConfig({});
2585
+ const result = await handleWebhookRequest({
2586
+ routeName: name,
2587
+ rawBody,
2588
+ headers,
2589
+ config: cfg,
2590
+ model: appCfg.model,
2591
+ budgetUsd: appCfg.budgetUsd,
2592
+ permissionMode: appCfg.permissionMode,
2593
+ onLog: (m) => process.stderr.write(`${DIM}${m}${RESET}\n`),
2594
+ });
2595
+ console.log(JSON.stringify(result.body, null, 2));
2596
+ if (result.status >= 400)
2597
+ process.exit(1);
2598
+ return;
2599
+ }
2600
+ console.error(`ไม่รู้จัก: webhook ${action} — ใช้ subscribe / list / remove / test`);
2601
+ process.exit(2);
2602
+ }
2603
+ function webhookDeliverTarget(args) {
2604
+ const direct = argValue(args, '--to', '-t')?.trim();
2605
+ if (direct)
2606
+ return direct;
2607
+ const deliver = argValue(args, '--deliver')?.trim();
2608
+ if (!deliver || deliver === 'log')
2609
+ return 'log';
2610
+ const chat = argValue(args, '--deliver-chat-id', '--chat-id')?.trim();
2611
+ return chat ? `${deliver}:${chat}` : deliver;
198
2612
  }
199
2613
  /** sanook cron add "<when>" "<task>" | cron list | cron rm <id> */
200
2614
  async function runCron(args) {
201
2615
  const [action, ...rest] = args;
202
2616
  const { listTasks, enqueueTask, removeTask } = await import('./gateway/ledger.js');
2617
+ const valueFlags = ['--to', '-t', '--model', '-m'];
203
2618
  if (action === 'add') {
204
- const schedule = rest[0];
205
- const spec = rest.slice(1).join(' ').trim();
2619
+ const deliverRaw = argValue(rest, '--to', '-t')?.trim();
2620
+ const model = argValue(rest, '--model', '-m');
2621
+ const positionals = positionalArgs(rest, valueFlags);
2622
+ const schedule = positionals[0];
2623
+ const spec = positionals.slice(1).join(' ').trim();
206
2624
  if (!schedule || !spec) {
207
- console.error('ใช้: sanook cron add "<when>" "<task>" (when: "every 30m" | "09:00" | ISO | now)');
2625
+ console.error(`ใช้: ${BRAND.cliName} cron add "<when>" "<task>" [--to <target>] [--model <provider:model>]`);
208
2626
  console.error('หมายเหตุ: when ที่มีช่องว่างต้องครอบ quote เช่น "every 30m"');
209
2627
  process.exit(1);
210
2628
  }
@@ -217,14 +2635,30 @@ async function runCron(args) {
217
2635
  }
218
2636
  process.exit(1);
219
2637
  }
2638
+ let deliver;
2639
+ if (deliverRaw) {
2640
+ const { parseSendTarget, formatTarget } = await import('./gateway/targets.js');
2641
+ try {
2642
+ deliver = formatTarget(parseSendTarget(deliverRaw));
2643
+ }
2644
+ catch (e) {
2645
+ console.error(e.message);
2646
+ process.exit(2);
2647
+ }
2648
+ }
220
2649
  const task = await enqueueTask({
221
2650
  kind: sched.recurring ? 'cron' : 'once',
222
2651
  spec,
223
2652
  schedule: sched.recurring ? sched.normalized : undefined,
2653
+ model,
2654
+ deliver,
224
2655
  runAt: sched.runAt,
225
2656
  });
226
2657
  const when = new Date(task.runAt).toLocaleString();
227
- console.log(`เพิ่ม task ${task.id} รัน ${when}${sched.recurring ? ` แล้วทุก ${sched.normalized}` : ''}`);
2658
+ const extras = [task.deliver ? `ส่งไป ${task.deliver}` : undefined, task.model ? `model ${task.model}` : undefined]
2659
+ .filter(Boolean)
2660
+ .join(' · ');
2661
+ console.log(`เพิ่ม task ${task.id} — รัน ${when}${sched.recurring ? ` แล้วทุก ${sched.normalized}` : ''}${extras ? ` · ${extras}` : ''}`);
228
2662
  return;
229
2663
  }
230
2664
  if (action === 'rm' || action === 'remove') {
@@ -244,7 +2678,8 @@ async function runCron(args) {
244
2678
  }
245
2679
  for (const t of tasks) {
246
2680
  const next = new Date(t.runAt).toLocaleString();
247
- console.log(`${t.id} [${t.status}] ${t.schedule ?? 'once'} next:${next} ${t.spec.slice(0, 50)}`);
2681
+ const extras = [t.deliver ? `to:${t.deliver}` : undefined, t.model ? `model:${t.model}` : undefined].filter(Boolean).join(' ');
2682
+ console.log(`${t.id} [${t.status}] ${t.schedule ?? 'once'} next:${next}${extras ? ` ${extras}` : ''} → ${t.spec.slice(0, 50)}`);
248
2683
  }
249
2684
  return;
250
2685
  }
@@ -333,10 +2768,122 @@ async function runModels(args) {
333
2768
  else
334
2769
  console.log('\n✓ ทุก curated id มีใน provider');
335
2770
  }
2771
+ function brainDoctorStatusLabel(status) {
2772
+ return status.toUpperCase().padEnd(4);
2773
+ }
2774
+ /** sanook brain doctor — check configured second-brain health without modifying it */
2775
+ async function runBrainDoctor() {
2776
+ const cfg = await loadConfig({});
2777
+ const { checkBrain } = await import('./brain-doctor.js');
2778
+ const report = await checkBrain({ brainPath: cfg.brainPath });
2779
+ console.log(`${BRAND.productName} brain doctor`);
2780
+ for (const check of report.checks) {
2781
+ console.log(`[${brainDoctorStatusLabel(check.status)}] ${check.id} — ${check.message}`);
2782
+ if (check.path)
2783
+ console.log(` ${check.path}`);
2784
+ for (const detail of check.details ?? [])
2785
+ console.log(` - ${detail}`);
2786
+ }
2787
+ if (!report.ok)
2788
+ process.exit(1);
2789
+ }
2790
+ /** sanook brain context [--task "..."] — show the prompt context Sanook loads from the vault */
2791
+ async function runBrainContext(args) {
2792
+ const { parseBrainContextArgs, inspectBrainContext, formatBrainContextReport } = await import('./brain-context.js');
2793
+ const parsed = parseBrainContextArgs(args);
2794
+ if (!parsed.ok) {
2795
+ console.error(parsed.message);
2796
+ console.error(`ใช้: ${BRAND.cliName} brain context [--task "..."] [--mode auto|fts|semantic|hybrid] [--limit N] [--source vault,session,skill] [--no-content]`);
2797
+ process.exit(1);
2798
+ }
2799
+ const cfg = await loadConfig({});
2800
+ const report = await inspectBrainContext({
2801
+ brainPath: cfg.brainPath,
2802
+ task: parsed.value.task,
2803
+ mode: parsed.value.mode,
2804
+ limit: parsed.value.limit,
2805
+ sources: parsed.value.sources,
2806
+ });
2807
+ console.log(formatBrainContextReport(report, parsed.value.showContent));
2808
+ if (!report.ok)
2809
+ process.exit(1);
2810
+ }
2811
+ /** sanook brain eval — run the lightweight second-brain benchmark sanity checks */
2812
+ async function runBrainEval(args) {
2813
+ const allowed = new Set(['--no-retrieval']);
2814
+ const unknown = args.filter((arg) => !allowed.has(arg));
2815
+ if (unknown.length) {
2816
+ console.error(`ไม่รู้จัก option: ${unknown.join(' ')}`);
2817
+ console.error(`ใช้: ${BRAND.cliName} brain eval [--no-retrieval]`);
2818
+ process.exit(1);
2819
+ }
2820
+ const cfg = await loadConfig({});
2821
+ const { runBrainEval: evaluate, formatBrainEvalReport } = await import('./brain-eval.js');
2822
+ const report = await evaluate({ brainPath: cfg.brainPath, runRetrieval: !args.includes('--no-retrieval') });
2823
+ console.log(formatBrainEvalReport(report));
2824
+ if (!report.ok)
2825
+ process.exit(1);
2826
+ }
2827
+ /** sanook brain review — curator health review over the configured second-brain vault */
2828
+ async function runBrainReview(args) {
2829
+ const { parseBrainReviewArgs, reviewBrain, formatBrainReviewReport } = await import('./brain-review.js');
2830
+ const parsed = parseBrainReviewArgs(args);
2831
+ if (!parsed.ok) {
2832
+ console.error(parsed.message);
2833
+ console.error(`ใช้: ${BRAND.cliName} brain review [--no-hygiene]`);
2834
+ process.exit(1);
2835
+ }
2836
+ const cfg = await loadConfig({});
2837
+ const report = await reviewBrain({
2838
+ brainPath: cfg.brainPath,
2839
+ scanMarkdownHygiene: parsed.value.scanMarkdownHygiene,
2840
+ });
2841
+ console.log(formatBrainReviewReport(report));
2842
+ if (!report.ok)
2843
+ process.exit(1);
2844
+ }
2845
+ /** sanook brain final [--task "..."] [--from-diff] [--lite] — create an evidence-backed closeout note */
2846
+ async function runBrainFinal(args) {
2847
+ const { parseBrainFinalArgs, createBrainFinal, formatBrainFinalReport } = await import('./brain-final.js');
2848
+ const parsed = parseBrainFinalArgs(args);
2849
+ if (!parsed.ok) {
2850
+ console.error(parsed.message);
2851
+ console.error(`ใช้: ${BRAND.cliName} brain final [--task "..."] [--from-diff] [--lite] [--output Sessions/name.md] [--force]`);
2852
+ process.exit(1);
2853
+ }
2854
+ const cfg = await loadConfig({});
2855
+ const report = await createBrainFinal({
2856
+ brainPath: cfg.brainPath,
2857
+ today: new Date().toISOString().slice(0, 10),
2858
+ ...parsed.value,
2859
+ });
2860
+ console.log(formatBrainFinalReport(report));
2861
+ if (!report.ok)
2862
+ process.exit(1);
2863
+ }
336
2864
  /** sanook brain init [path] — scaffold second-brain workspace (interactive ถ้าไม่ใส่ path) */
337
2865
  async function runBrain(args) {
2866
+ if (args[0] === 'doctor')
2867
+ return runBrainDoctor();
2868
+ if (args[0] === 'context')
2869
+ return runBrainContext(args.slice(1));
2870
+ if (args[0] === 'eval')
2871
+ return runBrainEval(args.slice(1));
2872
+ if (args[0] === 'review')
2873
+ return runBrainReview(args.slice(1));
2874
+ if (args[0] === 'final')
2875
+ return runBrainFinal(args.slice(1));
338
2876
  if (args[0] !== 'init') {
339
- console.log(`ใช้: sanook brain init [path] สร้างโครงสร้าง second-brain (Obsidian vault)
2877
+ console.log(`ใช้:
2878
+ sanook brain init [path] สร้างโครงสร้าง second-brain (Obsidian vault)
2879
+ sanook brain doctor ตรวจ health ของ second-brain ที่ config.brainPath
2880
+ sanook brain context แสดง context ที่ Sanook จะ inject
2881
+ sanook brain context --task "..." ดู retrieval hits ต่อ task
2882
+ sanook brain eval รัน second-brain benchmark sanity checks
2883
+ sanook brain review curator review: inbox, packs, sessions, evals, note hygiene
2884
+ sanook brain final --task "..." [--from-diff] [--lite]
2885
+ สร้าง final gate note ใน Sessions
2886
+
340
2887
  ไม่ใส่ path → wizard ถาม path + ตัวตน
341
2888
  -y, --yes ใช้ค่า default ทั้งหมด (ต้องระบุ path)`);
342
2889
  return;
@@ -379,7 +2926,22 @@ async function readStdin() {
379
2926
  async function runConfig(args) {
380
2927
  const { readGlobalConfigRaw, patchGlobalConfig } = await import('./config.js');
381
2928
  const [action, key, ...rest] = args;
382
- const ALLOWED = ['model', 'fallbackModel', 'budgetUsd', 'maxSteps', 'permissionMode', 'brainPath', 'pricing', 'cacheTtl', 'compaction', 'thinking', 'summaryModel'];
2929
+ const ALLOWED = [
2930
+ 'model',
2931
+ 'fallbackModel',
2932
+ 'budgetUsd',
2933
+ 'maxSteps',
2934
+ 'permissionMode',
2935
+ 'brainPath',
2936
+ 'pricing',
2937
+ 'cacheTtl',
2938
+ 'compaction',
2939
+ 'contextCompression',
2940
+ 'thinking',
2941
+ 'summaryModel',
2942
+ 'embeddingModel',
2943
+ 'personality',
2944
+ ];
383
2945
  if (action === 'set') {
384
2946
  if (!key || rest.length === 0) {
385
2947
  console.error(`ใช้: ${BRAND.cliName} config set <key> <value> (key: ${ALLOWED.join(' | ')})`);
@@ -392,8 +2954,8 @@ async function runConfig(args) {
392
2954
  const raw = rest.join(' ');
393
2955
  let value = raw;
394
2956
  if (key === 'budgetUsd') {
395
- const n = Number(raw);
396
- if (!Number.isFinite(n) || n <= 0) {
2957
+ const n = parseBudgetUsd(raw);
2958
+ if (n === undefined) {
397
2959
  console.error('budgetUsd ต้องเป็นตัวเลขบวก เช่น 0.25');
398
2960
  process.exit(1);
399
2961
  }
@@ -419,21 +2981,27 @@ async function runConfig(args) {
419
2981
  console.error('compaction ต้องเป็น truncate หรือ summarize');
420
2982
  process.exit(1);
421
2983
  }
2984
+ else if (key === 'contextCompression' && raw !== 'off' && raw !== 'selective' && raw !== 'headroom') {
2985
+ console.error('contextCompression ต้องเป็น off, selective หรือ headroom');
2986
+ process.exit(1);
2987
+ }
422
2988
  else if (key === 'thinking') {
423
2989
  // เก็บเป็น number (budget) หรือ boolean ให้ตรง ConfigSchema (ไม่เก็บ string)
424
- if (raw === 'on' || raw === 'true')
425
- value = true;
426
- else if (raw === 'off' || raw === 'false')
427
- value = false;
428
- else {
429
- const n = Number(raw);
430
- if (!Number.isInteger(n) || n <= 0) {
431
- console.error('thinking ต้องเป็น on/off หรือ budget tokens (integer บวก เช่น 4000)');
432
- process.exit(1);
433
- }
434
- value = n;
2990
+ value = parseThinkingConfigValue(raw);
2991
+ if (value === undefined) {
2992
+ console.error('thinking ต้องเป็น on/off หรือ budget tokens (integer บวก เช่น 4000)');
2993
+ process.exit(1);
435
2994
  }
436
2995
  }
2996
+ else if (key === 'personality') {
2997
+ const { normalizePersonalityName, personalityListText } = await import('./personality.js');
2998
+ const name = normalizePersonalityName(raw);
2999
+ if (!name) {
3000
+ console.error(`personality ไม่รู้จัก: ${raw}\n${personalityListText()}`);
3001
+ process.exit(1);
3002
+ }
3003
+ value = name === 'none' ? undefined : name;
3004
+ }
437
3005
  else if (key === 'pricing') {
438
3006
  try {
439
3007
  value = parsePricingOverride(raw); // { "provider:model": { input, output, cacheRead?, cacheWrite? } }
@@ -459,33 +3027,24 @@ async function runIndex(_args) {
459
3027
  const { reindex } = await import('./search/indexer.js');
460
3028
  console.log('indexing…');
461
3029
  const r = await reindex();
3030
+ const { resetSearchCaches } = await import('./search/engine.js');
3031
+ resetSearchCaches();
462
3032
  console.log(`done: +${r.added} ~${r.updated} -${r.removed} (skipped ${r.skipped}) · ` +
463
- `memory=${r.memory} sessions=${r.sessions} skills=${r.skills}\nvault: ${r.vaultPath ?? '(not set — `' + BRAND.cliName + ' brain init` or set config.brainPath)'}`);
3033
+ `memory=${r.memory} sessions=${r.sessions} skills=${r.skills} vectors=${r.vectors}\n` +
3034
+ `vault: ${r.vaultPath ?? '(not set — `' + BRAND.cliName + ' brain init` or set config.brainPath)'}`);
464
3035
  }
465
3036
  /** sanook search "<query>" [--mode ..] [--limit N] [--source a,b] — one-shot ranked search */
466
3037
  async function runSearch(args) {
467
- const queryParts = [];
468
- let mode = 'auto';
469
- let limit = 8;
470
- let sources;
471
- for (let i = 0; i < args.length; i++) {
472
- const a = args[i];
473
- if (a === '--mode')
474
- mode = args[++i] ?? 'auto';
475
- else if (a === '--limit')
476
- limit = Number.parseInt(args[++i] ?? '8', 10) || 8;
477
- else if (a === '--source' || a === '--sources')
478
- sources = (args[++i] ?? '').split(',').map((s) => s.trim()).filter(Boolean);
479
- else
480
- queryParts.push(a);
481
- }
482
- const query = queryParts.join(' ').trim();
483
- if (!query) {
3038
+ const { parseSearchArgs } = await import('./search/cli.js');
3039
+ const parsed = parseSearchArgs(args);
3040
+ if (!parsed.ok) {
3041
+ console.error(parsed.message);
484
3042
  console.error(`ใช้: ${BRAND.cliName} search "<query>" [--mode auto|fts|semantic|hybrid] [--limit N] [--source vault,memory]`);
485
3043
  process.exit(1);
486
3044
  }
3045
+ const { query, mode, limit, sources } = parsed.value;
487
3046
  const { search } = await import('./search/engine.js');
488
- const res = await search(query, { mode: mode, limit, sources: sources });
3047
+ const res = await search(query, { mode, limit, sources });
489
3048
  if (res.degraded)
490
3049
  console.log(`${DIM}(mode=${res.mode}, degraded: ${res.degraded})${RESET}`);
491
3050
  else
@@ -506,24 +3065,183 @@ async function runMcpServe() {
506
3065
  const { runMcpServer } = await import('./mcp-server.js');
507
3066
  await runMcpServer();
508
3067
  }
509
- /** sanook mcp [list | add <name> <command> [args...] | remove <name>] — จัดการ ~/.sanook/mcp.json */
3068
+ /** sanook mcp [search|info|install|test|doctor|list|add|remove] — จัดการ ~/.sanook/mcp.json */
510
3069
  async function runMcp(args) {
3070
+ const readConfig = async (path) => {
3071
+ try {
3072
+ const parsed = JSON.parse(await readFile(path, 'utf8'));
3073
+ return { mcpServers: parsed.mcpServers ?? {} };
3074
+ }
3075
+ catch {
3076
+ return { mcpServers: {} };
3077
+ }
3078
+ };
3079
+ const writeConfig = async (path, cfg) => {
3080
+ await mkdir(dirname(path), { recursive: true });
3081
+ await writeFile(path, `${JSON.stringify(cfg, null, 2)}\n`, { mode: 0o600 });
3082
+ await chmod(path, 0o600).catch(() => { });
3083
+ };
511
3084
  const mcpPath = appHomePath('mcp.json');
512
- let cfg = { mcpServers: {} };
513
- try {
514
- const parsed = JSON.parse(await readFile(mcpPath, 'utf8'));
515
- cfg = { mcpServers: parsed.mcpServers ?? {} };
3085
+ let cfg = await readConfig(mcpPath);
3086
+ const [action = 'list', ...rest] = args;
3087
+ const positionals = (items, valueFlags = new Set()) => {
3088
+ const out = [];
3089
+ for (let i = 0; i < items.length; i++) {
3090
+ const item = items[i];
3091
+ if (!item.startsWith('--')) {
3092
+ out.push(item);
3093
+ continue;
3094
+ }
3095
+ const flag = item.includes('=') ? item.slice(0, item.indexOf('=')) : item;
3096
+ if (!item.includes('=') && valueFlags.has(flag) && items[i + 1])
3097
+ i++;
3098
+ }
3099
+ return out;
3100
+ };
3101
+ if (action === 'search') {
3102
+ const { searchMcpRegistry, formatRegistrySearch, parseMcpRegistrySearchArgs } = await import('./mcp-registry.js');
3103
+ const parsed = parseMcpRegistrySearchArgs(rest);
3104
+ if (!parsed.ok) {
3105
+ console.error(parsed.message);
3106
+ process.exit(1);
3107
+ }
3108
+ const result = await searchMcpRegistry(parsed.value.query, { limit: parsed.value.limit, cursor: parsed.value.cursor });
3109
+ console.log(formatRegistrySearch(result));
3110
+ return;
516
3111
  }
517
- catch {
518
- /* ยังไม่มีไฟล์ */
3112
+ if (action === 'info') {
3113
+ const { getMcpRegistryServer, formatRegistryInfo } = await import('./mcp-registry.js');
3114
+ const name = positionals(rest, new Set(['--version']))[0];
3115
+ const versionArg = rest.find((item) => item.startsWith('--version='));
3116
+ const version = versionArg?.slice('--version='.length) ?? (rest.includes('--version') ? rest[rest.indexOf('--version') + 1] : undefined);
3117
+ if (!name) {
3118
+ console.error(`ใช้: ${BRAND.cliName} mcp info <registry-server-name> [--version=x.y.z]`);
3119
+ process.exit(1);
3120
+ }
3121
+ const server = await getMcpRegistryServer(name, { version });
3122
+ if (!server) {
3123
+ console.error(`ไม่เจอ MCP registry server: ${name}`);
3124
+ process.exit(1);
3125
+ }
3126
+ console.log(formatRegistryInfo(server));
3127
+ return;
3128
+ }
3129
+ if (action === 'preset') {
3130
+ const { formatPreset } = await import('./mcp-registry.js');
3131
+ console.log(formatPreset(rest[0]));
3132
+ return;
3133
+ }
3134
+ if (action === 'install') {
3135
+ const { buildMcpInstallPlan, getMcpRegistryServer, parseKeyValueList, formatRegistryInfo, } = await import('./mcp-registry.js');
3136
+ const name = positionals(rest, new Set(['--name', '--transport', '--env', '--header', '--version']))[0];
3137
+ if (!name) {
3138
+ console.error(`ใช้: ${BRAND.cliName} mcp install <registry-server-name> [--name alias] [--transport auto|remote|stdio] [--env KEY=value] [--header KEY=value] [--project]`);
3139
+ process.exit(1);
3140
+ }
3141
+ const optionValues = (flag) => {
3142
+ const out = [];
3143
+ for (let i = 0; i < rest.length; i++) {
3144
+ if (rest[i] === flag && rest[i + 1])
3145
+ out.push(rest[++i]);
3146
+ else if (rest[i].startsWith(`${flag}=`))
3147
+ out.push(rest[i].slice(flag.length + 1));
3148
+ }
3149
+ return out;
3150
+ };
3151
+ const valueOf = (flag) => optionValues(flag)[0];
3152
+ const alias = valueOf('--name');
3153
+ if (alias && !isValidMcpServerName(alias)) {
3154
+ console.error('ชื่อ MCP server ต้องเป็น a-z/A-Z/0-9/_/- ความยาวไม่เกิน 64 และห้ามใช้ชื่อพิเศษ');
3155
+ process.exit(1);
3156
+ }
3157
+ const transport = valueOf('--transport');
3158
+ if (transport && !['auto', 'remote', 'stdio'].includes(transport)) {
3159
+ console.error('--transport ต้องเป็น auto, remote, หรือ stdio');
3160
+ process.exit(1);
3161
+ }
3162
+ const server = await getMcpRegistryServer(name, { version: valueOf('--version') });
3163
+ if (!server) {
3164
+ console.error(`ไม่เจอ MCP registry server: ${name}`);
3165
+ process.exit(1);
3166
+ }
3167
+ const plan = buildMcpInstallPlan(server, {
3168
+ alias,
3169
+ transport,
3170
+ env: parseKeyValueList(optionValues('--env')),
3171
+ headers: parseKeyValueList(optionValues('--header')),
3172
+ });
3173
+ if (!plan.ok) {
3174
+ console.log(formatRegistryInfo(server));
3175
+ console.error(`\nยัง install ไม่ได้: ต้องระบุ ${plan.missing.join(', ') || 'transport/package ที่รองรับ'}`);
3176
+ if (plan.missing.some((item) => item.startsWith('env:')))
3177
+ console.error(`ตัวอย่าง: ${BRAND.cliName} mcp install ${name} --env KEY=value`);
3178
+ if (plan.missing.some((item) => item.startsWith('header:')))
3179
+ console.error(`ตัวอย่าง: ${BRAND.cliName} mcp install ${name} --header Authorization='Bearer ...'`);
3180
+ for (const warning of plan.warnings)
3181
+ console.error(`warning: ${warning}`);
3182
+ process.exit(1);
3183
+ }
3184
+ let targetPath = mcpPath;
3185
+ if (rest.includes('--project')) {
3186
+ const { projectConfigPathIfTrusted, projectRoot } = await import('./trust.js');
3187
+ const root = await projectRoot(process.cwd());
3188
+ const projectPath = await projectConfigPathIfTrusted('mcp.json', root);
3189
+ if (!projectPath) {
3190
+ console.error(`project MCP ต้อง trust ก่อน: ${BRAND.cliName} trust add`);
3191
+ process.exit(1);
3192
+ }
3193
+ targetPath = projectPath;
3194
+ }
3195
+ cfg = await readConfig(targetPath);
3196
+ cfg.mcpServers[plan.alias] = plan.config;
3197
+ await writeConfig(targetPath, cfg);
3198
+ console.log(`ติดตั้ง MCP "${plan.alias}" จาก ${server.name} (${plan.source}) → ${targetPath}`);
3199
+ if (plan.requirements.length)
3200
+ console.log(`requirements: ${plan.requirements.join(', ')}`);
3201
+ for (const warning of plan.warnings)
3202
+ console.log(`warning: ${warning}`);
3203
+ console.log(`ทดสอบ: ${BRAND.cliName} mcp test ${plan.alias}`);
3204
+ return;
3205
+ }
3206
+ if (action === 'test' || action === 'doctor') {
3207
+ const { loadMcpConfig, probeMcpServer } = await import('./mcp.js');
3208
+ const logs = [];
3209
+ const merged = await loadMcpConfig((m) => logs.push(m));
3210
+ const names = action === 'test' && rest[0] ? [rest[0]] : Object.keys(merged);
3211
+ if (!names.length) {
3212
+ console.log(`ยังไม่มี MCP server — เพิ่ม: ${BRAND.cliName} mcp search github`);
3213
+ return;
3214
+ }
3215
+ let failed = false;
3216
+ if (logs.length)
3217
+ for (const log of logs)
3218
+ console.log(`note: ${log}`);
3219
+ for (const n of names) {
3220
+ const server = merged[n];
3221
+ if (!server) {
3222
+ failed = true;
3223
+ console.log(`[FAIL] ${n} — ไม่เจอใน config`);
3224
+ continue;
3225
+ }
3226
+ const probe = await probeMcpServer(server);
3227
+ if (probe.ok) {
3228
+ console.log(`[PASS] ${n} (${probe.transport}) — ${probe.tools.length} tool(s)`);
3229
+ for (const tool of probe.tools.slice(0, action === 'doctor' ? 8 : 30))
3230
+ console.log(` - ${tool.name}${tool.description ? ` — ${tool.description}` : ''}`);
3231
+ if (action === 'doctor' && probe.tools.length > 8)
3232
+ console.log(` ... ${probe.tools.length - 8} more`);
3233
+ }
3234
+ else {
3235
+ failed = true;
3236
+ console.log(`[FAIL] ${n} (${probe.transport}) — ${probe.error}`);
3237
+ }
3238
+ }
3239
+ if (failed)
3240
+ process.exit(1);
3241
+ return;
519
3242
  }
520
- const write = async () => {
521
- await mkdir(dirname(mcpPath), { recursive: true });
522
- await writeFile(mcpPath, `${JSON.stringify(cfg, null, 2)}\n`, { mode: 0o600 });
523
- await chmod(mcpPath, 0o600).catch(() => { });
524
- };
525
- const [action, name, command, ...cmdArgs] = args;
526
3243
  if (action === 'add') {
3244
+ const [name, command, ...cmdArgs] = rest;
527
3245
  if (!name || !command) {
528
3246
  console.error(`ใช้: ${BRAND.cliName} mcp add <name> <command> [args...] (เช่น: mcp add fs npx -y @modelcontextprotocol/server-filesystem /path)`);
529
3247
  console.error(` remote: ${BRAND.cliName} mcp add <name> https://host/mcp (Streamable-HTTP)`);
@@ -535,30 +3253,50 @@ async function runMcp(args) {
535
3253
  }
536
3254
  // command เป็น http(s):// → remote MCP (Streamable-HTTP), ไม่งั้น stdio
537
3255
  cfg.mcpServers[name] = /^https?:\/\//.test(command) ? { url: command } : { command, args: cmdArgs };
538
- await write();
3256
+ await writeConfig(mcpPath, cfg);
539
3257
  console.log(`เพิ่ม MCP server "${name}"${/^https?:\/\//.test(command) ? ' (remote http)' : ''}`);
540
3258
  return;
541
3259
  }
542
3260
  if (action === 'remove' || action === 'rm') {
3261
+ const [name] = rest;
543
3262
  if (name && cfg.mcpServers[name]) {
544
3263
  delete cfg.mcpServers[name];
545
- await write();
546
- console.log(`ลบ MCP server "${name}" แล้ว`);
3264
+ await writeConfig(mcpPath, cfg);
3265
+ console.log(`ลบ MCP server "${name}" แล้ว (${mcpPath})`);
547
3266
  }
548
3267
  else
549
3268
  console.log(`ไม่เจอ MCP server "${name ?? ''}"`);
550
3269
  return;
551
3270
  }
552
- const names = Object.keys(cfg.mcpServers);
3271
+ if (action !== 'list') {
3272
+ console.log(`ใช้: ${BRAND.cliName} mcp [search|info|install|test|doctor|preset|list|add|remove|serve]`);
3273
+ return;
3274
+ }
3275
+ const { loadMcpConfig } = await import('./mcp.js');
3276
+ const logs = [];
3277
+ const merged = await loadMcpConfig((m) => logs.push(m));
3278
+ const names = Object.keys(merged);
553
3279
  if (!names.length) {
554
- console.log(`ยังไม่มี MCP server — เพิ่ม: ${BRAND.cliName} mcp add <name> <command> [args...]`);
3280
+ console.log(`ยังไม่มี MCP server — เพิ่ม: ${BRAND.cliName} mcp search github`);
555
3281
  return;
556
3282
  }
3283
+ if (logs.length)
3284
+ for (const log of logs)
3285
+ console.log(`note: ${log}`);
557
3286
  console.log(`${names.length} MCP servers:`);
558
3287
  for (const n of names) {
559
- const s = cfg.mcpServers[n];
3288
+ const s = merged[n];
560
3289
  console.log(` ${n} — ${s.url ? `${s.url} (http)` : `${s.command} ${(s.args ?? []).join(' ')}`}`);
561
3290
  }
3291
+ if (rest.includes('--tools')) {
3292
+ const { probeMcpServer } = await import('./mcp.js');
3293
+ for (const n of names) {
3294
+ const probe = await probeMcpServer(merged[n]);
3295
+ console.log(`\n${probe.ok ? '[PASS]' : '[FAIL]'} ${n} tools${probe.ok ? ` (${probe.tools.length})` : ` — ${probe.error}`}`);
3296
+ for (const tool of probe.tools.slice(0, 30))
3297
+ console.log(` - ${tool.name}`);
3298
+ }
3299
+ }
562
3300
  }
563
3301
  /** sanook trust [status|add|remove] — trust project .sanook content that can steer/execute code */
564
3302
  async function runTrust(args) {
@@ -642,6 +3380,66 @@ async function askYesNo(question) {
642
3380
  rl.close();
643
3381
  }
644
3382
  }
3383
+ async function askText(question) {
3384
+ const rl = createInterface({ input: process.stdin, output: process.stdout });
3385
+ try {
3386
+ return (await rl.question(question)).trim();
3387
+ }
3388
+ finally {
3389
+ rl.close();
3390
+ }
3391
+ }
3392
+ function extractValue(args, ...names) {
3393
+ const rest = [];
3394
+ let value;
3395
+ for (let i = 0; i < args.length; i++) {
3396
+ const a = args[i];
3397
+ const eq = names.find((name) => a.startsWith(`${name}=`));
3398
+ if (eq) {
3399
+ value ??= a.slice(eq.length + 1);
3400
+ continue;
3401
+ }
3402
+ if (names.includes(a)) {
3403
+ if (args[i + 1] && !args[i + 1].startsWith('-')) {
3404
+ value ??= args[i + 1];
3405
+ i++;
3406
+ }
3407
+ continue;
3408
+ }
3409
+ rest.push(a);
3410
+ }
3411
+ return { value, rest };
3412
+ }
3413
+ function stripBooleanFlags(args, ...names) {
3414
+ return args.filter((a) => !names.includes(a));
3415
+ }
3416
+ function positionalArgs(args, valueFlags = []) {
3417
+ const out = [];
3418
+ for (let i = 0; i < args.length; i++) {
3419
+ const a = args[i];
3420
+ if (valueFlags.some((name) => a.startsWith(`${name}=`)))
3421
+ continue;
3422
+ if (valueFlags.includes(a)) {
3423
+ i++;
3424
+ continue;
3425
+ }
3426
+ if (a.startsWith('-'))
3427
+ continue;
3428
+ out.push(a);
3429
+ }
3430
+ return out;
3431
+ }
3432
+ function argValue(args, ...names) {
3433
+ for (const name of names) {
3434
+ const eq = args.find((a) => a.startsWith(`${name}=`));
3435
+ if (eq)
3436
+ return eq.slice(name.length + 1);
3437
+ const idx = args.indexOf(name);
3438
+ if (idx !== -1 && args[idx + 1] && !args[idx + 1].startsWith('--'))
3439
+ return args[idx + 1];
3440
+ }
3441
+ return undefined;
3442
+ }
645
3443
  async function maybePromptForInteractiveUpdate() {
646
3444
  if (envFlag(BRAND_ENV.disableUpdateCheck) || process.env.CI)
647
3445
  return;
@@ -686,8 +3484,12 @@ function headlessKeyHint(modelSpec) {
686
3484
  `⚠ ยังไม่มี API key สำหรับ ${cfg.label} (${cfg.envVar})`,
687
3485
  `เริ่มใช้งาน:`,
688
3486
  ` • รัน "${BRAND.cliName}" (ไม่ใส่ task) → setup wizard ทีละขั้น (แนะนำ)`,
3487
+ ` • หรือ: ${BRAND.cliName} auth add ${provider} --api-key "..." --use${url ? ` · เอา key ที่: ${url}` : ''}`,
689
3488
  ` • หรือ: export ${cfg.envVar}="..."${url ? ` · เอา key ที่: ${url}` : ''}`,
690
3489
  ];
3490
+ if (provider === 'openai') {
3491
+ lines.push(` • ถ้าต้องการใช้ ChatGPT plan ไม่ใช้ API key: ใช้ ${BRAND.cliName} -m codex แล้วรัน codex login`);
3492
+ }
691
3493
  const other = detectEnvProvider();
692
3494
  if (other && other.provider !== provider) {
693
3495
  lines.push(` • เจอ key ของ ${other.label} อยู่แล้ว → ใช้เลย: ${BRAND.cliName} -m ${other.provider} "<task>"`);
@@ -722,8 +3524,35 @@ async function main() {
722
3524
  // โหลด API key จาก ~/.sanook/auth.json เข้า env (ไม่ override env ที่ตั้งไว้แล้ว)
723
3525
  await loadKeysIntoEnv();
724
3526
  process.on('exit', closeMcp); // ปิด MCP server (kill child) ตอนจบ
3527
+ // management surfaces (Sanook-branded) — setup/model/gateway/status/tools/send
3528
+ if (argv[0] === '-z')
3529
+ return runPureOneShot(argv.slice(1));
3530
+ if (argv[0] === 'chat')
3531
+ return runChat(argv.slice(1));
3532
+ if (argv[0] === 'setup')
3533
+ return runSetup(argv.slice(1));
3534
+ if (argv[0] === 'model' && (argv.length === 1 || argv[1].startsWith('--')))
3535
+ return startModelSetup();
3536
+ if (argv[0] === 'gateway')
3537
+ return runGateway(argv.slice(1));
3538
+ if (argv[0] === 'status' && (argv.length === 1 || argv[1].startsWith('--')))
3539
+ return runStatus();
3540
+ if (argv[0] === 'auth')
3541
+ return runAuth(argv.slice(1));
3542
+ if (argv[0] === 'sessions' || argv[0] === 'session')
3543
+ return runSessions(argv.slice(1));
3544
+ if (argv[0] === 'insights')
3545
+ return runInsights(argv.slice(1));
3546
+ if (argv[0] === 'dump')
3547
+ return runDump(argv.slice(1));
3548
+ if (argv[0] === 'tools' && (argv.length === 1 || argv[1].startsWith('--')))
3549
+ return runTools(argv.slice(1));
3550
+ if (argv[0] === 'send')
3551
+ return runSend(argv.slice(1));
3552
+ if (argv[0] === 'webhook' || argv[0] === 'webhooks')
3553
+ return runWebhook(argv.slice(1));
725
3554
  // subcommands: serve · cron — match เฉพาะรูปแบบที่ถูกต้อง กัน prompt unquoted ("serve coffee") misfire
726
- if (argv[0] === 'serve' && (argv.length === 1 || argv[1].startsWith('--')))
3555
+ if (hasServeCommandRequest(argv))
727
3556
  return runServe(argv.slice(1));
728
3557
  if (argv[0] === 'cron' && ['add', 'list', 'rm', 'remove', undefined].includes(argv[1])) {
729
3558
  return runCron(argv.slice(1));
@@ -733,7 +3562,7 @@ async function main() {
733
3562
  }
734
3563
  if (argv[0] === 'models')
735
3564
  return runModels(argv.slice(1));
736
- if (argv[0] === 'brain' && ['init', undefined].includes(argv[1]))
3565
+ if (argv[0] === 'brain' && ['init', 'doctor', 'context', 'eval', 'review', 'final', undefined].includes(argv[1]))
737
3566
  return runBrain(argv.slice(1));
738
3567
  if (argv[0] === 'config' && ['get', 'set', 'list', undefined].includes(argv[1]))
739
3568
  return runConfig(argv.slice(1));
@@ -743,17 +3572,18 @@ async function main() {
743
3572
  return runSearch(argv.slice(1));
744
3573
  if (argv[0] === 'mcp' && argv[1] === 'serve')
745
3574
  return runMcpServe();
746
- if (argv[0] === 'mcp' && ['add', 'list', 'remove', 'rm', undefined].includes(argv[1]))
3575
+ if (argv[0] === 'mcp' && ['add', 'list', 'remove', 'rm', 'search', 'info', 'install', 'test', 'doctor', 'preset', undefined].includes(argv[1]))
747
3576
  return runMcp(argv.slice(1));
748
3577
  if (argv[0] === 'trust' && ['status', 'add', 'remove', 'rm', undefined].includes(argv[1]))
749
3578
  return runTrust(argv.slice(1));
750
- const { model, budget, json, quiet, prompt: argPrompt, planMode, yes } = parseArgs(argv);
3579
+ const { model, budget, json, quiet, prompt: argPrompt, planMode, yes, resume } = parseArgs(argv);
3580
+ const resumeSession = await requestedResumeSession(argv, resume);
751
3581
  const budgetUsd = Number.isFinite(budget) ? budget : undefined;
752
3582
  // stdin piping: `git diff | sanook "review this"` → ผนวก stdin เข้า prompt (headless/CI)
753
3583
  const piped = process.stdin.isTTY ? '' : (await readStdin()).trim();
754
3584
  const prompt = piped ? `${argPrompt}\n\n<stdin>\n${piped}\n</stdin>`.trim() : argPrompt;
755
3585
  if (prompt) {
756
- const config = await loadConfig({ model, budgetUsd });
3586
+ const config = await loadConfig({ model: model ?? resumeSession?.model, budgetUsd });
757
3587
  // headless + ยังไม่มี key → บอกวิธีเริ่มแบบ actionable แทนปล่อยให้ throw error ดิบ (กัน dead-end ของ flow ที่ README แนะนำ)
758
3588
  const noKey = headlessKeyHint(config.model);
759
3589
  if (noKey) {
@@ -761,41 +3591,46 @@ async function main() {
761
3591
  process.exit(1);
762
3592
  }
763
3593
  // --continue / -c → โหลด session ล่าสุดมาต่อ (จำว่าทำถึงไหน)
764
- const wantsContinue = argv.includes('--continue') || argv.includes('-c') || argv.includes('--continue-any');
765
- const history = wantsContinue
766
- ? (await latestSession(argv.includes('--continue-any') ? null : process.cwd()))?.messages
767
- : undefined;
3594
+ const history = resumeSession?.messages ?? (await requestedContinuationHistory(argv));
768
3595
  await runHeadless(config.model, prompt, config.budgetUsd, config.maxSteps, json, history, planMode, yes ? 'auto' : config.permissionMode, quiet, config.fallbackModel);
769
3596
  return;
770
3597
  }
771
3598
  await maybePromptForInteractiveUpdate();
772
- // interactive — ครั้งแรก (ยังไม่มี config)
3599
+ // interactive — ครั้งแรก (ยังไม่มี config): ถ้าไม่มี key ใช้ได้ใน env → ต้องโชว์ wizard
3600
+ let needsSetup = false;
773
3601
  if (await isFirstRun()) {
774
- const detected = detectEnvProvider();
775
- if (detected) {
776
- // มี API key ใน env แล้ว → ข้าม wizard, ตั้ง default ให้ตรง provider นั้น, บอกว่าพร้อมใช้
3602
+ // provider เป้าหมาย: เคารพ -m ที่ user ใส่ก่อน (กันขึ้น "พร้อมใช้" ผิด provider), ไม่งั้น scan env ตามนิยม
3603
+ const flagProvider = model ? parseSpec(model).provider : undefined;
3604
+ const target = flagProvider ?? detectEnvProvider()?.provider;
3605
+ const tcfg = target ? PROVIDERS[target] : undefined;
3606
+ const { providerCanSkipSetup } = await import('./first-run.js');
3607
+ if (target && tcfg && (await providerCanSkipSetup(target))) {
3608
+ // มี key ใช้ได้จริง (ผ่าน policy ไม่ใช่ OAuth) → ข้าม wizard, ตั้ง default, บอกว่าพร้อมใช้
777
3609
  const { saveGlobalConfig } = await import('./config.js');
778
- await saveGlobalConfig({ model: `${detected.provider}:${detected.model}`, provider: detected.provider });
779
- console.log(`✅ เจอ ${detected.label} (${detected.envVar}) — พร้อมใช้เลย (ข้าม setup wizard)\n`);
3610
+ await saveGlobalConfig({ model: model ?? `${target}:${tcfg.models.default}`, provider: target });
3611
+ console.log(`✅ ${tcfg.label} พร้อมใช้เลย (ข้าม setup wizard)\n`);
780
3612
  }
781
3613
  else {
782
- const { startSetup } = await import('./ui/render.js'); // ไม่มี key → wizard ทีละขั้น
783
- await startSetup();
784
- console.log('✅ ตั้งค่าเสร็จ — พิมพ์งานในช่อง › ได้เลย (/help ดูคำสั่ง)\n');
3614
+ needsSetup = true; // ไม่มี provider ที่ key ใช้ได้ (หรือ -m provider ไม่มี key) → wizard (รัน Ink เดียวกับ REPL)
785
3615
  }
786
3616
  }
787
- const config = await loadConfig({ model, budgetUsd });
3617
+ const config = await loadConfig({ model: model ?? resumeSession?.model, budgetUsd });
3618
+ if (!needsSetup) {
3619
+ const { modelNeedsSetup } = await import('./first-run.js');
3620
+ needsSetup = await modelNeedsSetup(config.model);
3621
+ }
788
3622
  // --continue / -c → โหลด conversation ล่าสุดเข้า REPL (เดิม resume ได้แค่ headless)
789
- const initialHistory = argv.includes('--continue') || argv.includes('-c') || argv.includes('--continue-any')
790
- ? (await latestSession(argv.includes('--continue-any') ? null : process.cwd()))?.messages
791
- : undefined;
792
- const { startRepl } = await import('./ui/render.js');
793
- startRepl({
794
- initialModel: config.model,
795
- fallbackModel: config.fallbackModel,
796
- budgetUsd: config.budgetUsd,
797
- permissionMode: yes ? 'auto' : config.permissionMode,
798
- initialHistory,
3623
+ const initialHistory = resumeSession?.messages ?? (await requestedContinuationHistory(argv));
3624
+ const { startApp } = await import('./ui/render.js');
3625
+ startApp({
3626
+ needsSetup,
3627
+ appProps: {
3628
+ initialModel: config.model,
3629
+ fallbackModel: config.fallbackModel,
3630
+ budgetUsd: config.budgetUsd,
3631
+ permissionMode: yes ? 'auto' : config.permissionMode,
3632
+ initialHistory,
3633
+ },
799
3634
  });
800
3635
  }
801
3636
  main().catch((err) => {