sanook-cli 0.5.1 → 0.5.5

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 (217) hide show
  1. package/.env.example +161 -3
  2. package/CHANGELOG.md +148 -10
  3. package/README.md +255 -26
  4. package/README.th.md +95 -7
  5. package/dist/approval.js +13 -0
  6. package/dist/bin.js +3552 -155
  7. package/dist/brain-consolidate.js +335 -0
  8. package/dist/brain-context.js +262 -0
  9. package/dist/brain-doctor.js +318 -0
  10. package/dist/brain-eval.js +186 -0
  11. package/dist/brain-final.js +377 -0
  12. package/dist/brain-metrics.js +277 -0
  13. package/dist/brain-new.js +402 -0
  14. package/dist/brain-pack.js +210 -0
  15. package/dist/brain-repair.js +280 -0
  16. package/dist/brain-review.js +382 -0
  17. package/dist/brain.js +15 -1
  18. package/dist/brand.js +1 -1
  19. package/dist/cli-args.js +190 -0
  20. package/dist/cli-option-values.js +16 -0
  21. package/dist/clipboard.js +65 -0
  22. package/dist/commands.js +266 -27
  23. package/dist/compaction.js +96 -11
  24. package/dist/config.js +149 -33
  25. package/dist/context-compression.js +191 -0
  26. package/dist/context-pack.js +145 -0
  27. package/dist/cost.js +49 -15
  28. package/dist/dashboard/api-helpers.js +87 -0
  29. package/dist/dashboard/server.js +179 -0
  30. package/dist/dashboard/static/app.js +277 -0
  31. package/dist/dashboard/static/index.html +39 -0
  32. package/dist/dashboard/static/styles.css +85 -0
  33. package/dist/diff.js +10 -2
  34. package/dist/first-run.js +21 -0
  35. package/dist/gateway/auth.js +49 -9
  36. package/dist/gateway/bluebubbles.js +205 -0
  37. package/dist/gateway/config.js +929 -0
  38. package/dist/gateway/deliver.js +399 -0
  39. package/dist/gateway/discord.js +124 -0
  40. package/dist/gateway/doctor.js +456 -0
  41. package/dist/gateway/email.js +501 -0
  42. package/dist/gateway/googlechat.js +207 -0
  43. package/dist/gateway/homeassistant.js +256 -0
  44. package/dist/gateway/ledger.js +38 -1
  45. package/dist/gateway/line.js +171 -0
  46. package/dist/gateway/lock.js +3 -1
  47. package/dist/gateway/matrix.js +366 -0
  48. package/dist/gateway/mattermost.js +322 -0
  49. package/dist/gateway/ntfy.js +218 -0
  50. package/dist/gateway/schedule.js +31 -4
  51. package/dist/gateway/serve.js +267 -7
  52. package/dist/gateway/server.js +253 -19
  53. package/dist/gateway/service.js +224 -0
  54. package/dist/gateway/session.js +362 -0
  55. package/dist/gateway/signal.js +351 -0
  56. package/dist/gateway/slack.js +124 -0
  57. package/dist/gateway/sms.js +169 -0
  58. package/dist/gateway/targets.js +576 -0
  59. package/dist/gateway/teams.js +106 -0
  60. package/dist/gateway/telegram.js +38 -15
  61. package/dist/gateway/webhooks.js +220 -0
  62. package/dist/gateway/whatsapp.js +230 -0
  63. package/dist/hooks.js +13 -2
  64. package/dist/hotkeys.js +21 -0
  65. package/dist/i18n/en.js +98 -0
  66. package/dist/i18n/index.js +19 -0
  67. package/dist/i18n/th.js +98 -0
  68. package/dist/i18n/types.js +1 -0
  69. package/dist/insights-args.js +55 -0
  70. package/dist/insights.js +86 -0
  71. package/dist/knowledge.js +55 -29
  72. package/dist/loop.js +157 -29
  73. package/dist/lsp/index.js +23 -5
  74. package/dist/mcp-hub.js +33 -0
  75. package/dist/mcp-registry.js +494 -0
  76. package/dist/mcp-risk.js +71 -0
  77. package/dist/mcp-server.js +1 -1
  78. package/dist/mcp.js +120 -10
  79. package/dist/memory-log.js +90 -0
  80. package/dist/memory-store.js +37 -1
  81. package/dist/memory.js +148 -37
  82. package/dist/model-picker.js +58 -0
  83. package/dist/orchestrate.js +51 -19
  84. package/dist/personality.js +58 -0
  85. package/dist/plan-handoff.js +17 -0
  86. package/dist/polyglot.js +162 -0
  87. package/dist/process-runner.js +96 -0
  88. package/dist/project-init.js +91 -0
  89. package/dist/project-registry.js +143 -0
  90. package/dist/project-scaffold.js +124 -0
  91. package/dist/prompt-size.js +155 -0
  92. package/dist/providers/codex-login.js +138 -0
  93. package/dist/providers/codex.js +89 -43
  94. package/dist/providers/keys.js +22 -1
  95. package/dist/providers/models.js +2 -2
  96. package/dist/providers/registry.js +14 -47
  97. package/dist/search/chunk.js +7 -8
  98. package/dist/search/cli.js +83 -0
  99. package/dist/search/embed-store.js +3 -0
  100. package/dist/search/embedding-config.js +22 -0
  101. package/dist/search/engine.js +2 -13
  102. package/dist/search/indexer.js +44 -1
  103. package/dist/search/store.js +23 -1
  104. package/dist/session-distill.js +84 -0
  105. package/dist/session.js +92 -16
  106. package/dist/skill-install.js +53 -13
  107. package/dist/skills.js +33 -0
  108. package/dist/slash-completion.js +155 -0
  109. package/dist/support-dump.js +206 -0
  110. package/dist/tool-catalog.js +59 -0
  111. package/dist/tools/edit.js +45 -15
  112. package/dist/tools/git.js +10 -5
  113. package/dist/tools/homeassistant.js +106 -0
  114. package/dist/tools/index.js +10 -0
  115. package/dist/tools/list.js +19 -6
  116. package/dist/tools/permission.js +992 -12
  117. package/dist/tools/polyglot.js +126 -0
  118. package/dist/tools/read.js +16 -4
  119. package/dist/tools/sandbox.js +38 -13
  120. package/dist/tools/schedule.js +19 -3
  121. package/dist/tools/search.js +226 -15
  122. package/dist/tools/task.js +40 -9
  123. package/dist/tools/timeout.js +23 -3
  124. package/dist/tools/web-fetch-tool.js +33 -0
  125. package/dist/trust.js +11 -1
  126. package/dist/turn-retrieval.js +83 -0
  127. package/dist/ui/app.js +878 -32
  128. package/dist/ui/banner.js +78 -4
  129. package/dist/ui/history.js +37 -5
  130. package/dist/ui/markdown.js +122 -0
  131. package/dist/ui/mentions.js +3 -2
  132. package/dist/ui/overlay.js +496 -0
  133. package/dist/ui/queue.js +23 -0
  134. package/dist/ui/render.js +20 -1
  135. package/dist/ui/session-panel.js +115 -0
  136. package/dist/ui/setup-providers.js +40 -0
  137. package/dist/ui/setup.js +172 -46
  138. package/dist/ui/status.js +142 -0
  139. package/dist/ui/thinking-panel.js +36 -0
  140. package/dist/ui/tool-trail.js +97 -0
  141. package/dist/ui/transcript.js +26 -0
  142. package/dist/ui/useBusyElapsed.js +19 -0
  143. package/dist/ui/useEditor.js +144 -5
  144. package/dist/ui/useGitBranch.js +57 -0
  145. package/dist/update.js +56 -17
  146. package/dist/web-fetch.js +637 -0
  147. package/dist/web-surface.js +190 -0
  148. package/dist/worktree.js +175 -4
  149. package/package.json +5 -5
  150. package/second-brain/AGENTS.md +6 -4
  151. package/second-brain/CLAUDE.md +7 -1
  152. package/second-brain/Evals/_Index.md +10 -2
  153. package/second-brain/Evals/quality-ledger.md +9 -1
  154. package/second-brain/Evals/second-brain-benchmarks.md +62 -0
  155. package/second-brain/GEMINI.md +5 -4
  156. package/second-brain/Home.md +1 -1
  157. package/second-brain/Projects/_Index.md +19 -4
  158. package/second-brain/Projects/sanook-cli/_Index.md +30 -0
  159. package/second-brain/Projects/sanook-cli/context.md +35 -0
  160. package/second-brain/Projects/sanook-cli/current-state.md +32 -0
  161. package/second-brain/Projects/sanook-cli/overview.md +41 -0
  162. package/second-brain/Projects/sanook-cli/repo.md +34 -0
  163. package/second-brain/Projects/sanook-cli/second-brain-feature-roadmap.md +197 -0
  164. package/second-brain/README.md +1 -1
  165. package/second-brain/Research/2026-06-17-ai-second-brain-method-experiment.md +108 -0
  166. package/second-brain/Research/2026-06-18-ai-token-reduction-frameworks.md +55 -0
  167. package/second-brain/Research/2026-06-18-hermes-cli-second-brain-expansion-research.md +160 -0
  168. package/second-brain/Research/2026-06-18-hermes-tui-parity-map.md +129 -0
  169. package/second-brain/Research/2026-06-18-sanook-mcp-ecosystem-and-ux-roadmap.md +181 -0
  170. package/second-brain/Research/2026-06-19-hermes-python-architecture-for-sanook.md +49 -0
  171. package/second-brain/Research/2026-06-19-terminal-ui-brand-research.md +52 -0
  172. package/second-brain/Research/_Index.md +8 -1
  173. package/second-brain/Reviews/2026-06-18-auto-improve-maintenance.md +54 -0
  174. package/second-brain/Reviews/_Index.md +1 -1
  175. package/second-brain/Runbooks/_Index.md +6 -1
  176. package/second-brain/Runbooks/ai-second-brain-operating-sequence.md +108 -0
  177. package/second-brain/SANOOK.md +45 -0
  178. package/second-brain/Sessions/2026-06-17-ai-framework-additional-zones.md +68 -0
  179. package/second-brain/Sessions/2026-06-17-ai-second-brain-sequence-experiment.md +63 -0
  180. package/second-brain/Sessions/2026-06-18-cli-args-release-readiness.md +59 -0
  181. package/second-brain/Sessions/2026-06-18-final-gate-template-final.md +192 -0
  182. package/second-brain/Sessions/2026-06-18-final-gate-template.md +71 -0
  183. package/second-brain/Sessions/2026-06-18-framework-dogfood-permission-and-memory.md +58 -0
  184. package/second-brain/Sessions/2026-06-18-hermes-second-brain-expansion-research.md +52 -0
  185. package/second-brain/Sessions/2026-06-18-mcp-ecosystem-and-sanook-ux-scan.md +81 -0
  186. package/second-brain/Sessions/2026-06-18-sanook-brain-cli-p0-implementation.md +86 -0
  187. package/second-brain/Sessions/2026-06-18-sanook-brain-final-cli-final.md +246 -0
  188. package/second-brain/Sessions/2026-06-18-sanook-brain-final-cli.md +78 -0
  189. package/second-brain/Sessions/2026-06-18-sanook-cli-second-brain-roadmap-correction.md +54 -0
  190. package/second-brain/Sessions/2026-06-18-token-reduction-framework-integration.md +69 -0
  191. package/second-brain/Sessions/_Index.md +15 -1
  192. package/second-brain/Shared/AI-Context-Index.md +22 -0
  193. package/second-brain/Shared/Context-Packs/_Index.md +9 -1
  194. package/second-brain/Shared/Context-Packs/coding-release.md +51 -0
  195. package/second-brain/Shared/Context-Packs/research-to-framework.md +51 -0
  196. package/second-brain/Shared/Context-Packs/second-brain-maintenance.md +41 -0
  197. package/second-brain/Shared/Operating-State/current-state.md +14 -4
  198. package/second-brain/Shared/Scripts/_Index.md +3 -1
  199. package/second-brain/Shared/Scripts/ai-second-brain-method-eval.mjs +198 -0
  200. package/second-brain/Shared/Tech-Standards/_Index.md +6 -1
  201. package/second-brain/Shared/Tech-Standards/mcp-integration-roadmap.md +86 -0
  202. package/second-brain/Shared/Tech-Standards/polyglot-runtime-strategy.md +46 -0
  203. package/second-brain/Shared/Tech-Standards/verification-standard.md +24 -0
  204. package/second-brain/Shared/Tech-Standards/web-search-grounding-policy.md +70 -0
  205. package/second-brain/Shared/User-Memory/_Index.md +4 -1
  206. package/second-brain/Shared/User-Memory/response-examples.md +98 -0
  207. package/second-brain/Shared/User-Memory/user-preferences.md +1 -0
  208. package/second-brain/Templates/_Index.md +9 -0
  209. package/second-brain/Templates/final-lite.md +111 -0
  210. package/second-brain/Templates/final.md +231 -0
  211. package/second-brain/Templates/project-workspace/_Index.md +31 -0
  212. package/second-brain/Templates/project-workspace/context.md +28 -0
  213. package/second-brain/Templates/project-workspace/current-state.md +29 -0
  214. package/second-brain/Templates/project-workspace/overview.md +39 -0
  215. package/second-brain/Templates/project-workspace/repo.md +33 -0
  216. package/second-brain/Vault Structure Map.md +2 -1
  217. package/skills/structured-output-llm/SKILL.md +1 -1
package/dist/bin.js CHANGED
@@ -1,61 +1,24 @@
1
1
  #!/usr/bin/env node
2
2
  import { runAgent } from './loop.js';
3
- import { redactKey } from './providers/keys.js';
4
- import { specKey, parseSpec, PROVIDERS, consoleUrl, detectEnvProvider, hasUsableEnvKey } from './providers/registry.js';
3
+ import { assertDirectApiKey, redactKey } from './providers/keys.js';
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
- async function runHeadless(model, prompt, budgetUsd, maxSteps, json, history, planMode = false, permissionMode = 'ask', quiet = false, fallbackModel) {
21
+ async function runHeadless(model, prompt, budgetUsd, maxSteps, json, history, planMode = false, permissionMode = 'ask', quiet = false, fallbackModel, planHandoffTask) {
59
22
  const controller = new AbortController();
60
23
  process.on('SIGINT', () => {
61
24
  controller.abort();
@@ -96,6 +59,10 @@ async function runHeadless(model, prompt, budgetUsd, maxSteps, json, history, pl
96
59
  process.stdout.write(`\n${DIM}${cost.summary()}${RESET}\n`);
97
60
  else if (quiet)
98
61
  process.stdout.write('\n');
62
+ if (planMode && planHandoffTask && !json && !quiet) {
63
+ const { formatPlanExecuteHandoff } = await import('./plan-handoff.js');
64
+ process.stderr.write(`\n${DIM}${formatPlanExecuteHandoff(planHandoffTask)}${RESET}\n`);
65
+ }
99
66
  // จำ session ไว้ทำงานต่อได้ (sanook --continue "...") — แก้ concern AI ลืมว่าทำถึงไหน
100
67
  const now = new Date().toISOString();
101
68
  await saveSession({ id: newSessionId(), created: now, updated: now, model, cwd: process.cwd(), messages });
@@ -105,6 +72,16 @@ async function runHeadless(model, prompt, budgetUsd, maxSteps, json, history, pl
105
72
  if (brain) {
106
73
  await appendBrainWorklog(brain, { prompt, summary: cost.summary(), model, today: now.slice(0, 10) }).catch(() => { });
107
74
  }
75
+ // opt-in (experimental, default OFF): auto-distill durable decisions/gotchas/preferences from this
76
+ // session into the compounding memory store so the self-retrieving brain surfaces them next time.
77
+ // Off by default per experiment H5 — extraction is high-precision (~0.88) but end-to-end recall is
78
+ // gated by retrieval quality (semantic helps). Enable with SANOOK_AUTO_DISTILL=1.
79
+ if (envFlag('SANOOK_AUTO_DISTILL')) {
80
+ const { distilledFactsFromMessages } = await import('./session-distill.js');
81
+ const { appendMemory } = await import('./memory.js');
82
+ for (const fact of distilledFactsFromMessages(messages))
83
+ await appendMemory(fact).catch(() => { });
84
+ }
108
85
  }
109
86
  catch (err) {
110
87
  const msg = redactKey(err.message);
@@ -115,6 +92,28 @@ async function runHeadless(model, prompt, budgetUsd, maxSteps, json, history, pl
115
92
  process.exit(1);
116
93
  }
117
94
  }
95
+ /** sanook plan "<task>" — read-only plan mode; stderr prints execute handoff after success */
96
+ async function runPlan(args) {
97
+ const parsed = parseArgs(args);
98
+ if (parsed.budgetInvalid)
99
+ process.stderr.write(`${BRAND.cliName}: ⚠ --budget ไม่ถูกต้อง (ต้องเป็นจำนวนบวก) — รันต่อโดยไม่มี spend cap\n`);
100
+ const resumeSession = await requestedResumeSession(args, parsed.resume);
101
+ const budgetUsd = Number.isFinite(parsed.budget) ? parsed.budget : undefined;
102
+ const piped = process.stdin.isTTY ? '' : (await readStdin()).trim();
103
+ const prompt = piped ? `${parsed.prompt}\n\n<stdin>\n${piped}\n</stdin>`.trim() : parsed.prompt;
104
+ if (!prompt) {
105
+ console.error(`ใช้: ${BRAND.cliName} plan "<task>" [--json] [-m model]`);
106
+ process.exit(1);
107
+ }
108
+ const config = await loadConfig({ model: parsed.model ?? resumeSession?.model, budgetUsd });
109
+ const noKey = headlessKeyHint(config.model);
110
+ if (noKey) {
111
+ process.stderr.write(`${noKey}\n`);
112
+ process.exit(1);
113
+ }
114
+ const history = resumeSession?.messages ?? (await requestedContinuationHistory(args));
115
+ await runHeadless(config.model, prompt, config.budgetUsd, config.maxSteps, parsed.json, history, true, 'ask', parsed.quiet, config.fallbackModel, prompt);
116
+ }
118
117
  // อ่านจาก package.json (single source of truth) — กัน version constant drift
119
118
  const PACKAGE = JSON.parse(readFileSync(new URL('../package.json', import.meta.url), 'utf8'));
120
119
  const VERSION = PACKAGE.version;
@@ -123,44 +122,113 @@ const HELP = `${BRAND.productName} — a terminal AI coding agent (BYOK)
123
122
 
124
123
  usage:
125
124
  ${BRAND.cliName} "<task>" run one task (headless)
125
+ ${BRAND.cliName} plan "<task>" plan-only (read-only) + execute handoff hint on stderr
126
+ ${BRAND.cliName} -z "<task>" one-shot final output (script-friendly)
127
+ ${BRAND.cliName} chat -q "<query>" direct one-shot query
126
128
  ${BRAND.cliName} interactive REPL
129
+ ${BRAND.cliName} setup [section] setup wizard (model | gateway | tools | agent | brain)
130
+ ${BRAND.cliName} dashboard [--port] Sanook Dashboard (local web admin UI)
131
+ ${BRAND.cliName} model choose provider + model
127
132
  ${BRAND.cliName} --json "<task>" headless, JSONL output (for CI/scripts)
133
+ ${BRAND.cliName} sessions list/resume-audit saved conversation sessions
134
+ ${BRAND.cliName} insights local usage/session insights
135
+ ${BRAND.cliName} dump [--show-keys] support snapshot (secrets redacted)
136
+ ${BRAND.cliName} prompt-size [--json] inspect prompt/context budget without calling a model
137
+ ${BRAND.cliName} runtimes [--json] inspect optional Python/Rust runtime surface
128
138
  ${BRAND.cliName} update update ${BRAND.cliName} to the latest npm release
129
139
  ${BRAND.cliName} doctor ตรวจการติดตั้ง + วิธีแก้ PATH (เมื่อพิมพ์ "${BRAND.cliName}" แล้วไม่เจอ)
130
140
 
131
141
  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)
142
+ ${BRAND.cliName} gateway setup telegram ตั้งค่า Telegram token + allowlist
143
+ ${BRAND.cliName} gateway setup discord ตั้งค่า Discord bot token + channel allowlist
144
+ ${BRAND.cliName} gateway setup slack ตั้งค่า Slack bot/app token + channel allowlist
145
+ ${BRAND.cliName} gateway setup mattermost ตั้งค่า Mattermost token + user/channel allowlist
146
+ ${BRAND.cliName} gateway setup homeassistant ตั้งค่า Home Assistant token + state-change filters
147
+ ${BRAND.cliName} gateway setup email ตั้งค่า Email IMAP/SMTP + allowed senders
148
+ ${BRAND.cliName} gateway setup line ตั้งค่า LINE Messaging API push target
149
+ ${BRAND.cliName} gateway setup sms ตั้งค่า Twilio SMS webhook + allowlist
150
+ ${BRAND.cliName} gateway setup ntfy ตั้งค่า ntfy topic push + subscribe
151
+ ${BRAND.cliName} gateway setup signal ตั้งค่า Signal ผ่าน signal-cli HTTP daemon
152
+ ${BRAND.cliName} gateway setup whatsapp ตั้งค่า WhatsApp Cloud API webhook + send
153
+ ${BRAND.cliName} gateway setup matrix ตั้งค่า Matrix homeserver sync + send
154
+ ${BRAND.cliName} gateway setup googlechat ตั้งค่า Google Chat bot send
155
+ ${BRAND.cliName} gateway setup bluebubbles ตั้งค่า BlueBubbles/iMessage send
156
+ ${BRAND.cliName} gateway setup teams ตั้งค่า Microsoft Teams delivery
157
+ ${BRAND.cliName} gateway setup webhooks เปิด generic webhook routes + HMAC
158
+ ${BRAND.cliName} gateway run [--port 8787] [--model spec] เปิด gateway (เหมือน serve)
159
+ ${BRAND.cliName} gateway start [--port 8787] เปิด gateway เป็น background process
160
+ ${BRAND.cliName} gateway stop|restart|install จัดการ gateway service
161
+ ${BRAND.cliName} gateway status ดู config/status gateway
162
+ ${BRAND.cliName} gateway doctor ตรวจ token/webhook/allowlist ของ channels ที่ตั้งไว้
163
+ ${BRAND.cliName} send --to telegram|discord|slack|mattermost|homeassistant|email|line|sms|ntfy|signal|whatsapp|matrix|googlechat|bluebubbles|teams[:target] "msg" ส่งข้อความออก platform โดยไม่เรียก LLM
164
+ ${BRAND.cliName} webhook subscribe <route> [--prompt "..."] [--to telegram|slack|mattermost|homeassistant|sms|ntfy|signal|whatsapp|matrix|googlechat|bluebubbles|teams]
165
+ รับ event จาก GitHub/GitLab/Jira/Stripe แล้ว trigger agent/delivery
166
+ ${BRAND.cliName} send --list [platform] ดู messaging targets ที่ตั้งค่าไว้
167
+ ${BRAND.cliName} serve [--port 8787] [--model spec] เปิด gateway (OpenAI-compat /v1/chat/completions + scheduler)
168
+ ${BRAND.cliName} cron add "<when>" "<task>" [--to <target>] [--model <model>]
169
+ ตั้งงานล่วงหน้า + ส่งผลลัพธ์กลับ messaging target ได้
134
170
  ${BRAND.cliName} cron list ดู task ทั้งหมด
135
171
  ${BRAND.cliName} cron rm <id> ลบ task
136
172
 
137
173
  skills (built-in + ติดตั้งเพิ่มได้):
138
174
  ${BRAND.cliName} skill list ดู skill ทั้งหมด
175
+ ${BRAND.cliName} skill install <name|path> ติดตั้ง skill จาก bundled catalog / local path
139
176
  ${BRAND.cliName} skill add <user/repo|url|path> ติดตั้ง skill จาก GitHub / URL / local
140
177
  ${BRAND.cliName} skill remove <name> ลบ skill ที่ติดตั้ง
141
178
  ${BRAND.cliName} models [provider] ดู/verify model id (เทียบ provider จริงถ้ามี key)
142
179
 
180
+ project setup:
181
+ ${BRAND.cliName} init [--trust] scaffold .sanook/commands + onboarding hints
182
+
143
183
  second brain (Obsidian workspace สำหรับจัดเก็บงาน + ความจำ AI):
144
184
  ${BRAND.cliName} brain init [path] สร้างโครงสร้าง second-brain ที่ path (ไม่ใส่ = ถาม)
185
+ ${BRAND.cliName} brain doctor ตรวจ health ของ second-brain ที่ config.brainPath
186
+ ${BRAND.cliName} brain context [--task "..."] [--project <slug>] แสดง context ที่ Sanook จะ inject (+ project auto-detect จาก cwd)
187
+ ${BRAND.cliName} brain projects list แสดง Projects/<slug>/ + repo_path mapping
188
+ ${BRAND.cliName} brain eval รัน second-brain benchmark sanity checks
189
+ ${BRAND.cliName} brain review curator review: inbox, packs, sessions, evals, note hygiene
190
+ ${BRAND.cliName} brain pack list|show <name> ดู context packs ใน Shared/Context-Packs/
191
+ ${BRAND.cliName} brain new project [--title "..."] [--repo /path] [--verify "..."] scaffold Projects/<slug>/ workspace
192
+ ${BRAND.cliName} brain repair [--dry-run] แก้ one-line fixes หลัง doctor/review
193
+ ${BRAND.cliName} brain consolidate [--apply] sleep-time consolidation (inbox, stale, retrieval; dry-run default)
194
+ ${BRAND.cliName} brain metrics [--no-retrieval] vault counts, stale notes, index freshness, retrieval coverage
195
+ ${BRAND.cliName} brain final --task "..." สร้าง final gate note ใน Sessions พร้อม evidence scaffold
145
196
 
146
197
  search (BM25 + optional BYOK semantic เหนือ vault + memory + sessions + skills):
147
198
  ${BRAND.cliName} index (re)index vault+memory แบบ incremental (O(delta))
148
199
  ${BRAND.cliName} search "<query>" [--mode auto|fts|semantic|hybrid] [--limit N] [--source vault,memory]
200
+ ${BRAND.cliName} web status [--json] [--probe] ตรวจ true web/search readiness ผ่าน MCP (local search ไม่ใช่ internet)
201
+ ${BRAND.cliName} web doctor [--json] probe web/search/fetch MCP candidates
202
+ ${BRAND.cliName} web fetch <url> [--json] ดึงหน้าเว็บสาธารณะ + สรุปโครงสร้าง (fallback ladder ที่ถูกกติกา)
203
+ ${BRAND.cliName} web search "<q>" [--limit N] ค้นเว็บผ่าน Tavily (ต้องมี TAVILY_API_KEY)
204
+ ${BRAND.cliName} web setup tavily [--api-key K] ตั้งค่า Tavily (เขียน MCP + เก็บ key 0600)
149
205
  ${BRAND.cliName} mcp serve expose brain เป็น MCP server (stdio) ให้ Claude Desktop/Cursor
150
206
 
151
207
  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
208
+ ${BRAND.cliName} status ดู provider/key/brain/gateway status แบบ redacted
209
+ ${BRAND.cliName} auth [list|status|add|remove] จัดการ API keys ของ providers (BYOK, redacted)
210
+ ${BRAND.cliName} sessions [list|latest|show|rm] จัดการ saved sessions
211
+ ${BRAND.cliName} insights [--days N] [--all] ดู usage/session insights ในเครื่อง
212
+ ${BRAND.cliName} memory [stats|log "<q>"] ดู memory: สถิติ + วิวัฒนาการของ belief (เคยเชื่ออะไร ถูก supersede ตอนไหน)
213
+ ${BRAND.cliName} dump [--show-keys] diagnostic/support dump แบบไม่โชว์ raw secret
214
+ ${BRAND.cliName} prompt-size [--json] ดู system prompt / skills / brain / tools token budget แบบ offline
215
+ ${BRAND.cliName} runtimes [--json] ดู Python/Rust optional runtime + บทบาทใน Sanook
216
+ ${BRAND.cliName} web status [--json] ดู web-search/fetch readiness และ grounding policy
217
+ ${BRAND.cliName} tools ดู tool surface ที่ agent ใช้ได้
218
+ ${BRAND.cliName} config [get|set <k> <v>] ดู/แก้ ${appHomePath('config.json')} (model/budgetUsd/permissionMode/cacheTtl/compaction/contextCompression/thinking/embeddingModel)
219
+ ${BRAND.cliName} mcp [search|info|install|test|doctor|enable|disable|preset|list|add|remove] จัดการ MCP servers
154
220
  ${BRAND.cliName} trust [status|add|remove] อนุญาต/ยกเลิก project .sanook mcp/hooks/skills/commands
155
221
 
156
222
  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)
223
+ -m, --model <spec> sonnet/opus/haiku/fable · gpt/codex · gemini · grok · mistral · groq · ollama/lmstudio
224
+ or "provider:model-id" (e.g. openai:gpt-5.3-codex, groq:fast, google:gemini-2.5-flash)
159
225
  -b, --budget <usd> stop when estimated cost exceeds this
160
226
  -c, --continue resume the latest session ของ project นี้
227
+ -r, --resume <id> resume a specific saved session
161
228
  --continue-any resume latest session ข้าม project (explicit)
162
229
  --plan plan mode — สำรวจ+วางแผนเท่านั้น ไม่แก้ไฟล์ (read-only)
163
230
  -y, --yes อนุมัติ tool อัตโนมัติ (ข้าม ask-mode permission)
231
+ --yolo alias ของ --yes (compat)
164
232
  --json machine-readable JSONL output
165
233
  -v, --version
166
234
  -h, --help
@@ -170,41 +238,2719 @@ env (BYOK — direct API key only):
170
238
  ${BRAND_ENV.disableUpdateCheck}=1 disable interactive update prompts`;
171
239
  /** sanook serve [--port N] [--model spec] — เปิด gateway (HTTP loopback + cron scheduler) อยู่ยาว */
172
240
  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]}`);
241
+ const parsed = parseServeArgs(args);
242
+ if (parsed.portError) {
243
+ console.error(`port ไม่ถูกต้อง: ${parsed.portError}`);
244
+ process.exit(1);
245
+ }
246
+ if (parsed.modelError) {
247
+ console.error(`model ไม่ถูกต้อง: ${parsed.modelError}`);
248
+ process.exit(1);
249
+ }
250
+ const config = await loadConfig({ model: parsed.model });
251
+ const { startGateway } = await import('./gateway/serve.js');
252
+ process.stdout.write(`${DIM}${BRAND.productName} gateway — model: ${config.model}${RESET}\n`);
253
+ const stop = await startGateway({
254
+ port: parsed.port,
255
+ model: config.model,
256
+ budgetUsd: config.budgetUsd,
257
+ permissionMode: envFlag(BRAND_ENV.gatewayAllowWrite) ? 'auto' : config.permissionMode,
258
+ onLog: (m) => process.stdout.write(`${DIM}[gateway] ${m}${RESET}\n`),
259
+ });
260
+ const shutdown = () => {
261
+ stop();
262
+ process.stdout.write('\n[gateway] หยุดแล้ว\n');
263
+ process.exit(0);
264
+ };
265
+ process.on('SIGINT', shutdown);
266
+ process.on('SIGTERM', shutdown);
267
+ // server + scheduler interval ถือ event loop ไว้ → process อยู่ยาวจนกด Ctrl-C
268
+ }
269
+ async function startModelSetup() {
270
+ const config = await loadConfig({});
271
+ const { startApp } = await import('./ui/render.js');
272
+ startApp({
273
+ needsSetup: true,
274
+ appProps: {
275
+ initialModel: config.model,
276
+ fallbackModel: config.fallbackModel,
277
+ budgetUsd: config.budgetUsd,
278
+ permissionMode: config.permissionMode,
279
+ },
280
+ });
281
+ }
282
+ async function runDashboard(args = []) {
283
+ let port = 9119;
284
+ for (let i = 0; i < args.length; i++) {
285
+ if (args[i] === '--port' && args[i + 1]) {
286
+ port = Number(args[++i]);
287
+ if (!Number.isFinite(port) || port <= 0) {
288
+ console.error(`${BRAND.cliName}: --port ต้องเป็นจำนวนบวก`);
289
+ process.exit(1);
290
+ }
291
+ }
292
+ else if (args[i] === '-h' || args[i] === '--help') {
293
+ console.log(`ใช้: ${BRAND.cliName} dashboard [--port 9119]`);
294
+ console.log('เปิด Sanook Dashboard (local web UI — Hermes-style admin panel)');
295
+ return;
296
+ }
297
+ }
298
+ const { startDashboardServer } = await import('./dashboard/server.js');
299
+ const stop = await startDashboardServer({
300
+ port,
301
+ onLog: (m) => console.log(m),
302
+ });
303
+ const shutdown = () => {
304
+ stop();
305
+ process.stdout.write('\n[dashboard] stopped\n');
306
+ process.exit(0);
307
+ };
308
+ process.on('SIGINT', shutdown);
309
+ process.on('SIGTERM', shutdown);
310
+ }
311
+ async function runTools(_args = []) {
312
+ const { tools } = await import('./tools/index.js');
313
+ const names = Object.keys(tools).sort();
314
+ console.log(`${BRAND.productName} tools (${names.length})`);
315
+ console.log(names.map((n) => ` ${n}`).join('\n'));
316
+ console.log(`\nจัดการ MCP เพิ่มเติม: ${BRAND.cliName} mcp add <name> <command> [args...]`);
317
+ }
318
+ async function runPromptSize(args = []) {
319
+ const allowed = new Set(['--json']);
320
+ const unknown = args.find((arg) => !allowed.has(arg));
321
+ if (unknown) {
322
+ console.error(`ไม่รู้จัก option: ${unknown}`);
323
+ console.error(`ใช้: ${BRAND.cliName} prompt-size [--json]`);
324
+ process.exit(1);
325
+ }
326
+ const { buildPromptSizeBreakdown, renderPromptSizeBreakdown } = await import('./prompt-size.js');
327
+ const report = await buildPromptSizeBreakdown();
328
+ if (args.includes('--json'))
329
+ console.log(JSON.stringify(report, null, 2));
330
+ else
331
+ process.stdout.write(renderPromptSizeBreakdown(report));
332
+ }
333
+ async function runRuntimes(args = []) {
334
+ const allowed = new Set(['--json']);
335
+ const unknown = args.find((arg) => !allowed.has(arg));
336
+ if (unknown) {
337
+ console.error(`ไม่รู้จัก option: ${unknown}`);
338
+ console.error(`ใช้: ${BRAND.cliName} runtimes [--json]`);
339
+ process.exit(1);
340
+ }
341
+ const { inspectPolyglotRuntimes, renderPolyglotReport } = await import('./polyglot.js');
342
+ const report = await inspectPolyglotRuntimes();
343
+ if (args.includes('--json'))
344
+ console.log(JSON.stringify(report, null, 2));
345
+ else
346
+ process.stdout.write(renderPolyglotReport(report));
347
+ }
348
+ async function runWeb(args = []) {
349
+ const action = args[0] && !args[0].startsWith('--') ? args[0] : 'status';
350
+ const rest = action === args[0] ? args.slice(1) : args;
351
+ if (action === 'fetch')
352
+ return runWebFetch(rest);
353
+ if (action === 'search')
354
+ return runWebSearch(rest);
355
+ if (action === 'setup')
356
+ return runWebSetup(rest);
357
+ const flags = rest;
358
+ const allowed = new Set(['--json', '--probe']);
359
+ const unknown = flags.find((arg) => !allowed.has(arg));
360
+ if (!['status', 'doctor'].includes(action) || unknown) {
361
+ if (unknown)
362
+ console.error(`ไม่รู้จัก option: ${unknown}`);
363
+ console.error(`ใช้: ${BRAND.cliName} web status [--json] [--probe]`);
364
+ console.error(` ${BRAND.cliName} web doctor [--json]`);
365
+ console.error(` ${BRAND.cliName} web fetch <url> [--json] [--no-reader] [--no-archive] [--no-robots] [--allow-private]`);
366
+ console.error(` ${BRAND.cliName} web search "<query>" [--json] [--limit N]`);
367
+ console.error(` ${BRAND.cliName} web setup tavily [--api-key <key>]`);
368
+ process.exit(1);
369
+ }
370
+ const { inspectWebSurface, renderWebSurfaceReport } = await import('./web-surface.js');
371
+ const report = await inspectWebSurface({ cwd: process.cwd(), probe: action === 'doctor' || flags.includes('--probe') });
372
+ if (flags.includes('--json'))
373
+ console.log(JSON.stringify(report, null, 2));
374
+ else
375
+ process.stdout.write(renderWebSurfaceReport(report));
376
+ if (action === 'doctor' && report.webCandidates.some((candidate) => candidate.probe && !candidate.probe.ok))
377
+ process.exit(1);
378
+ }
379
+ /** TAVILY_API_KEY from env first, then any MCP server env in ~/.sanook/mcp.json. */
380
+ async function resolveTavilyKey() {
381
+ const fromEnv = process.env.TAVILY_API_KEY?.trim();
382
+ if (fromEnv)
383
+ return fromEnv;
384
+ try {
385
+ const { loadMcpConfig } = await import('./mcp.js');
386
+ const cfg = await loadMcpConfig();
387
+ for (const server of Object.values(cfg)) {
388
+ const key = server.env?.TAVILY_API_KEY?.trim();
389
+ if (key)
390
+ return key;
391
+ }
392
+ }
393
+ catch {
394
+ /* no mcp config */
395
+ }
396
+ return undefined;
397
+ }
398
+ async function runWebFetch(args) {
399
+ const url = args.find((a) => !a.startsWith('--'));
400
+ if (!url) {
401
+ console.error(`ใช้: ${BRAND.cliName} web fetch <url> [--json] [--no-reader] [--no-archive] [--no-robots] [--allow-private]`);
402
+ process.exit(1);
403
+ }
404
+ const { fetchWeb, renderWebFetchResult } = await import('./web-fetch.js');
405
+ const result = await fetchWeb(url, {
406
+ allowReader: !args.includes('--no-reader'),
407
+ allowArchive: !args.includes('--no-archive'),
408
+ respectRobots: !args.includes('--no-robots'),
409
+ allowPrivateHosts: args.includes('--allow-private'),
410
+ tavilyApiKey: await resolveTavilyKey(),
411
+ });
412
+ if (args.includes('--json'))
413
+ console.log(JSON.stringify(result, null, 2));
414
+ else
415
+ process.stdout.write(`${renderWebFetchResult(result)}\n`);
416
+ if (!result.ok)
417
+ process.exit(1);
418
+ }
419
+ async function runWebSearch(args) {
420
+ let limit = 5;
421
+ const terms = [];
422
+ for (let i = 0; i < args.length; i++) {
423
+ const a = args[i];
424
+ if (a === '--json')
425
+ continue;
426
+ if (a === '--limit') {
427
+ limit = Number(args[++i]) || limit;
428
+ continue;
429
+ }
430
+ if (a.startsWith('--limit=')) {
431
+ limit = Number(a.slice('--limit='.length)) || limit;
432
+ continue;
433
+ }
434
+ if (a.startsWith('--'))
435
+ continue;
436
+ terms.push(a);
437
+ }
438
+ const query = terms.join(' ').trim();
439
+ if (!query) {
440
+ console.error(`ใช้: ${BRAND.cliName} web search "<query>" [--json] [--limit N]`);
441
+ process.exit(1);
442
+ }
443
+ const apiKey = await resolveTavilyKey();
444
+ if (!apiKey) {
445
+ console.error(`web search ต้องมี Tavily API key — ตั้งค่า: ${BRAND.cliName} web setup tavily`);
446
+ process.exit(1);
447
+ }
448
+ const { tavilySearch } = await import('./web-fetch.js');
449
+ try {
450
+ const hits = await tavilySearch(query, { apiKey, maxResults: Math.min(Math.max(limit, 1), 20) });
451
+ if (args.includes('--json')) {
452
+ console.log(JSON.stringify(hits, null, 2));
453
+ return;
454
+ }
455
+ if (!hits.length) {
456
+ console.log('(no results)');
457
+ return;
458
+ }
459
+ for (const h of hits)
460
+ console.log(`- ${h.title}\n ${h.url}\n ${h.content.slice(0, 200)}`);
461
+ }
462
+ catch (e) {
463
+ console.error(`web search ล้มเหลว: ${e.message}`);
464
+ process.exit(1);
465
+ }
466
+ }
467
+ async function runWebSetup(args) {
468
+ const provider = args.find((a) => !a.startsWith('--'));
469
+ if (provider !== 'tavily') {
470
+ console.error(`ใช้: ${BRAND.cliName} web setup tavily [--api-key <key>]`);
471
+ process.exit(1);
472
+ }
473
+ let apiKey = (argValue(args, '--api-key') ?? process.env.TAVILY_API_KEY ?? '').trim();
474
+ if (!apiKey)
475
+ apiKey = (await askText('Tavily API key (tvly-...): ')).trim();
476
+ if (!apiKey) {
477
+ console.error('ต้องระบุ Tavily API key');
478
+ process.exit(1);
479
+ }
480
+ const mcpPath = appHomePath('mcp.json');
481
+ let cfg;
482
+ try {
483
+ const parsed = JSON.parse(await readFile(mcpPath, 'utf8'));
484
+ cfg = { mcpServers: parsed.mcpServers ?? {} };
485
+ }
486
+ catch {
487
+ cfg = { mcpServers: {} };
488
+ }
489
+ cfg.mcpServers.tavily = { command: 'npx', args: ['-y', 'tavily-mcp'], env: { TAVILY_API_KEY: apiKey } };
490
+ await mkdir(dirname(mcpPath), { recursive: true });
491
+ await writeFile(mcpPath, `${JSON.stringify(cfg, null, 2)}\n`, { mode: 0o600 });
492
+ await chmod(mcpPath, 0o600).catch(() => { });
493
+ console.log(`ตั้งค่า Tavily แล้ว → ${mcpPath} (chmod 600; key เก็บแบบ env ไม่ echo ออกจอ)`);
494
+ console.log(` • agent runtime ใช้ผ่าน MCP "tavily" (search/extract)`);
495
+ console.log(` • ${BRAND.cliName} web fetch <url> และ ${BRAND.cliName} web search "<q>" จะหยิบ key นี้อัตโนมัติ`);
496
+ console.log(`ทดสอบ: ${BRAND.cliName} mcp test tavily`);
497
+ }
498
+ /** sanook memory [log "<query>" | stats] — read-only view over the bi-temporal memory store */
499
+ async function runMemory(args = []) {
500
+ const action = args[0] && !args[0].startsWith('--') ? args[0] : undefined;
501
+ const rest = action ? args.slice(1) : args;
502
+ const json = args.includes('--json');
503
+ const { loadStore } = await import('./memory-store.js');
504
+ const { memoryLog, renderMemoryLog, memoryStats, renderMemoryStats } = await import('./memory-log.js');
505
+ const store = await loadStore();
506
+ if (action === 'log') {
507
+ const query = rest.filter((a) => !a.startsWith('--')).join(' ').trim();
508
+ const entries = memoryLog(store, query);
509
+ if (json)
510
+ console.log(JSON.stringify(entries, null, 2));
511
+ else
512
+ process.stdout.write(`${renderMemoryLog(entries, query)}\n`);
513
+ return;
514
+ }
515
+ // default + `stats`: overview of what's remembered (active / superseded / archived)
516
+ const stats = memoryStats(store);
517
+ console.log(json ? JSON.stringify(stats, null, 2) : renderMemoryStats(stats));
518
+ }
519
+ async function runAgentSetupSummary() {
520
+ const cfg = await loadConfig({});
521
+ console.log(`${BRAND.productName} agent settings`);
522
+ console.log(` model: ${cfg.model}`);
523
+ console.log(` fallbackModel: ${cfg.fallbackModel ?? '(not set)'}`);
524
+ console.log(` personality: ${cfg.personality ?? '(none)'}`);
525
+ console.log(` permissionMode: ${cfg.permissionMode}`);
526
+ console.log(` maxSteps: ${cfg.maxSteps}`);
527
+ console.log(` budgetUsd: ${cfg.budgetUsd ?? '(not set)'}`);
528
+ console.log(` brainPath: ${cfg.brainPath ?? '(not set)'}`);
529
+ console.log(` insights: ${BRAND.cliName} insights [--days N]`);
530
+ console.log('\nแก้ค่าได้ด้วย:');
531
+ console.log(` ${BRAND.cliName} config set personality concise`);
532
+ console.log(` ${BRAND.cliName} config set permissionMode ask`);
533
+ console.log(` ${BRAND.cliName} config set budgetUsd 0.25`);
534
+ console.log(` ${BRAND.cliName} config set fallbackModel haiku`);
535
+ }
536
+ async function runGatewayDoctor() {
537
+ const { checkGateway, formatGatewayDoctorReport } = await import('./gateway/doctor.js');
538
+ const report = await checkGateway();
539
+ console.log(formatGatewayDoctorReport(report));
540
+ if (!report.ok)
541
+ process.exit(1);
542
+ }
543
+ async function runGatewayStatus() {
544
+ 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');
545
+ const cfg = await readGatewayConfig();
546
+ const telegram = resolveTelegramConfig(cfg);
547
+ const discord = resolveDiscordConfig(cfg);
548
+ const slack = resolveSlackConfig(cfg);
549
+ const email = resolveEmailConfig(cfg);
550
+ const homeassistant = resolveHomeAssistantConfig(cfg);
551
+ const line = resolveLineConfig(cfg);
552
+ const mattermost = resolveMattermostConfig(cfg);
553
+ const sms = resolveSmsConfig(cfg);
554
+ const ntfy = resolveNtfyConfig(cfg);
555
+ const signal = resolveSignalConfig(cfg);
556
+ const whatsapp = resolveWhatsAppConfig(cfg);
557
+ const matrix = resolveMatrixConfig(cfg);
558
+ const googleChat = resolveGoogleChatConfig(cfg);
559
+ const bluebubbles = resolveBlueBubblesConfig(cfg);
560
+ const teams = resolveTeamsConfig(cfg);
561
+ const webhooks = resolveWebhookConfig(cfg);
562
+ const { redactSignalId } = await import('./gateway/signal.js');
563
+ const { redactWhatsAppId } = await import('./gateway/whatsapp.js');
564
+ console.log(`${BRAND.productName} gateway`);
565
+ console.log(` config: ${gatewayConfigPath()}`);
566
+ console.log(` token: ${appHomePath('gateway', 'token')} (HTTP bearer, auto-created on run)`);
567
+ const { gatewayServiceStatus } = await import('./gateway/service.js');
568
+ const service = await gatewayServiceStatus();
569
+ console.log(` service: ${service.running ? `running (pid ${service.state?.pid})` : service.state ? `stopped (last pid ${service.state.pid})` : 'not started'}`);
570
+ console.log(` log: ${service.logPath}`);
571
+ console.log(` telegram: ${telegram.token ? `configured via ${telegram.source}` : 'not configured'}`);
572
+ if (telegram.token) {
573
+ console.log(` enabled: ${telegram.enabled}`);
574
+ console.log(` allowed chats: ${telegram.allowedChatIds.length ? telegram.allowedChatIds.join(', ') : '(none — fail closed)'}`);
575
+ console.log(` allow write: ${telegram.allowWrite ? 'yes' : 'no'}`);
576
+ }
577
+ console.log(` discord: ${discord.token ? `configured via ${discord.source}` : 'not configured'}`);
578
+ if (discord.token) {
579
+ console.log(` default channel: ${discord.defaultChannelId ?? '(not set)'}`);
580
+ console.log(` allowed channels: ${discord.allowedChannelIds.length ? discord.allowedChannelIds.join(', ') : '(none)'}`);
581
+ }
582
+ console.log(` slack: ${slack.botToken ? `configured via ${slack.source}` : 'not configured'}`);
583
+ if (slack.botToken) {
584
+ console.log(` app token: ${slack.appToken ? 'set' : '(not set — needed for future Socket Mode gateway)'}`);
585
+ console.log(` default channel: ${slack.defaultChannelId ?? '(not set)'}`);
586
+ console.log(` allowed channels: ${slack.allowedChannelIds.length ? slack.allowedChannelIds.join(', ') : '(none)'}`);
587
+ }
588
+ console.log(` mattermost: ${mattermost.serverUrl || mattermost.token ? `configured via ${mattermost.source}` : 'not configured'}`);
589
+ if (mattermost.serverUrl || mattermost.token) {
590
+ console.log(` server url: ${mattermost.serverUrl ?? '(not set)'}`);
591
+ console.log(` token: ${mattermost.token ? 'set' : '(not set)'}`);
592
+ console.log(` home channel: ${mattermost.homeChannel ?? '(not set)'}`);
593
+ console.log(` allowed users: ${mattermost.allowedUsers.length ? mattermost.allowedUsers.join(', ') : mattermost.allowAllUsers ? '(all users)' : '(none)'}`);
594
+ console.log(` allowed channels: ${mattermost.allowedChannels.length ? mattermost.allowedChannels.join(', ') : '(none)'}`);
595
+ console.log(` free channels: ${mattermost.freeResponseChannels.length ? mattermost.freeResponseChannels.join(', ') : '(none)'}`);
596
+ console.log(` require mention: ${mattermost.requireMention ? 'yes' : 'no'}`);
597
+ console.log(` reply mode: ${mattermost.replyMode}`);
598
+ }
599
+ console.log(` homeassistant: ${homeassistant.token || homeassistant.url !== 'http://homeassistant.local:8123' ? `configured via ${homeassistant.source}` : 'not configured'}`);
600
+ if (homeassistant.token || homeassistant.url !== 'http://homeassistant.local:8123') {
601
+ console.log(` url: ${homeassistant.url}`);
602
+ console.log(` token: ${homeassistant.token ? 'set' : '(not set)'}`);
603
+ console.log(` home channel: ${homeassistant.homeChannel ?? '(not set)'}`);
604
+ console.log(` watch domains: ${homeassistant.watchDomains.length ? homeassistant.watchDomains.join(', ') : '(none)'}`);
605
+ console.log(` watch entities: ${homeassistant.watchEntities.length ? homeassistant.watchEntities.join(', ') : '(none)'}`);
606
+ console.log(` ignore entities: ${homeassistant.ignoreEntities.length ? homeassistant.ignoreEntities.join(', ') : '(none)'}`);
607
+ console.log(` watch all: ${homeassistant.watchAll ? 'yes' : 'no'}`);
608
+ console.log(` cooldown: ${homeassistant.cooldownSeconds}s`);
609
+ }
610
+ console.log(` email: ${email.address ? `configured via ${email.source}` : 'not configured'}`);
611
+ if (email.address) {
612
+ console.log(` address: ${email.address}`);
613
+ console.log(` smtp: ${email.smtpHost ?? '(not set)'}:${email.smtpPort}`);
614
+ console.log(` imap: ${email.imapHost ?? '(not set)'}:${email.imapPort}`);
615
+ console.log(` home address: ${email.homeAddress ?? '(not set)'}`);
616
+ console.log(` allowed senders: ${email.allowedUsers.length ? email.allowedUsers.join(', ') : email.allowAllUsers ? '(all users)' : '(none)'}`);
617
+ }
618
+ console.log(` line: ${line.channelAccessToken ? `configured via ${line.source}` : 'not configured'}`);
619
+ if (line.channelAccessToken) {
620
+ console.log(` channel secret: ${line.channelSecret ? 'set' : '(not set — needed for webhook replies)'}`);
621
+ console.log(` home channel: ${line.homeChannel ?? '(not set)'}`);
622
+ console.log(` allowed users: ${line.allowedUsers.length ? line.allowedUsers.join(', ') : line.allowAllUsers ? '(all users)' : '(none)'}`);
623
+ console.log(` allowed groups: ${line.allowedGroups.length ? line.allowedGroups.join(', ') : '(none)'}`);
624
+ console.log(` allowed rooms: ${line.allowedRooms.length ? line.allowedRooms.join(', ') : '(none)'}`);
625
+ console.log(` public url: ${line.publicUrl ?? '(not set)'}`);
626
+ }
627
+ console.log(` sms: ${sms.accountSid || sms.authToken || sms.phoneNumber ? `configured via ${sms.source}` : 'not configured'}`);
628
+ if (sms.accountSid || sms.authToken || sms.phoneNumber) {
629
+ console.log(` account sid: ${sms.accountSid ? 'set' : '(not set)'}`);
630
+ console.log(` auth token: ${sms.authToken ? 'set' : '(not set)'}`);
631
+ console.log(` phone number: ${sms.phoneNumber ?? '(not set)'}`);
632
+ console.log(` home channel: ${sms.homeChannel ?? '(not set)'}`);
633
+ console.log(` allowed users: ${sms.allowedUsers.length ? sms.allowedUsers.join(', ') : sms.allowAllUsers ? '(all users)' : '(none)'}`);
634
+ console.log(` webhook url: ${sms.webhookUrl ?? (sms.insecureNoSignature ? '(signature disabled)' : '(not set)')}`);
635
+ }
636
+ console.log(` ntfy: ${ntfy.topic || ntfy.token ? `configured via ${ntfy.source}` : 'not configured'}`);
637
+ if (ntfy.topic || ntfy.token) {
638
+ console.log(` server url: ${ntfy.serverUrl}`);
639
+ console.log(` topic: ${ntfy.topic ?? '(not set)'}`);
640
+ console.log(` publish topic: ${ntfy.publishTopic ?? '(same as topic)'}`);
641
+ console.log(` home channel: ${ntfy.homeChannel ?? '(not set)'}`);
642
+ console.log(` allowed topics: ${ntfy.allowedUsers.length ? ntfy.allowedUsers.join(', ') : ntfy.allowAllUsers ? '(all topics)' : '(none)'}`);
643
+ console.log(` token: ${ntfy.token ? 'set' : '(not set)'}`);
644
+ console.log(` markdown: ${ntfy.markdown ? 'yes' : 'no'}`);
645
+ }
646
+ console.log(` signal: ${signal.account ? `configured via ${signal.source}` : 'not configured'}`);
647
+ if (signal.account) {
648
+ console.log(` http url: ${signal.httpUrl}`);
649
+ console.log(` account: ${redactSignalId(signal.account)}`);
650
+ console.log(` home channel: ${redactSignalId(signal.homeChannel)}`);
651
+ console.log(` allowed users: ${signal.allowedUsers.length ? signal.allowedUsers.map(redactSignalId).join(', ') : signal.allowAllUsers ? '(all users)' : '(none)'}`);
652
+ console.log(` allowed groups: ${signal.groupAllowedUsers.length ? signal.groupAllowedUsers.map(redactSignalId).join(', ') : '(none)'}`);
653
+ console.log(` require mention: ${signal.requireMention ? 'yes' : 'no'}`);
654
+ }
655
+ console.log(` whatsapp: ${whatsapp.phoneNumberId || whatsapp.accessToken ? `configured via ${whatsapp.source}` : 'not configured'}`);
656
+ if (whatsapp.phoneNumberId || whatsapp.accessToken) {
657
+ console.log(` phone number id: ${whatsapp.phoneNumberId ? 'set' : '(not set)'}`);
658
+ console.log(` access token: ${whatsapp.accessToken ? 'set' : '(not set)'}`);
659
+ console.log(` app secret: ${whatsapp.appSecret ? 'set' : '(not set — needed for webhook)'}`);
660
+ console.log(` verify token: ${whatsapp.verifyToken ? 'set' : '(not set — needed for webhook verify)'}`);
661
+ console.log(` home channel: ${redactWhatsAppId(whatsapp.homeChannel)}`);
662
+ console.log(` allowed users: ${whatsapp.allowedUsers.length ? whatsapp.allowedUsers.map(redactWhatsAppId).join(', ') : whatsapp.allowAllUsers ? '(all users)' : '(none)'}`);
663
+ console.log(` public url: ${whatsapp.publicUrl ?? '(not set)'}`);
664
+ console.log(` api version: ${whatsapp.apiVersion}`);
665
+ }
666
+ console.log(` matrix: ${matrix.homeserver || matrix.accessToken || matrix.userId ? `configured via ${matrix.source}` : 'not configured'}`);
667
+ if (matrix.homeserver || matrix.accessToken || matrix.userId) {
668
+ console.log(` homeserver: ${matrix.homeserver ?? '(not set)'}`);
669
+ console.log(` access token: ${matrix.accessToken ? 'set' : '(not set)'}`);
670
+ console.log(` user id: ${matrix.userId ?? '(not set)'}`);
671
+ console.log(` password: ${matrix.password ? 'set' : '(not set)'}`);
672
+ console.log(` home room: ${matrix.homeRoom ?? '(not set)'}`);
673
+ console.log(` allowed users: ${matrix.allowedUsers.length ? matrix.allowedUsers.join(', ') : matrix.allowAllUsers ? '(all users)' : '(none)'}`);
674
+ console.log(` allowed rooms: ${matrix.allowedRooms.length ? matrix.allowedRooms.join(', ') : '(none)'}`);
675
+ console.log(` free rooms: ${matrix.freeResponseRooms.length ? matrix.freeResponseRooms.join(', ') : '(none)'}`);
676
+ console.log(` require mention: ${matrix.requireMention ? 'yes' : 'no'}`);
677
+ console.log(` auto join: ${matrix.autoJoin ? 'yes' : 'no'}`);
678
+ }
679
+ console.log(` googlechat: ${googleChat.serviceAccountJson || googleChat.incomingWebhookUrl ? `configured via ${googleChat.source}` : 'not configured'}`);
680
+ if (googleChat.serviceAccountJson || googleChat.incomingWebhookUrl) {
681
+ console.log(` project id: ${googleChat.projectId ?? '(not set)'}`);
682
+ console.log(` subscription: ${googleChat.subscriptionName ? 'set' : '(not set — needed for Pub/Sub inbound)'}`);
683
+ console.log(` service account: ${googleChat.serviceAccountJson ? 'set' : '(not set)'}`);
684
+ console.log(` api base url: ${googleChat.apiBaseUrl}`);
685
+ console.log(` webhook url: ${googleChat.incomingWebhookUrl ? 'set' : '(not set)'}`);
686
+ console.log(` home channel: ${googleChat.homeChannel ?? '(not set)'}`);
687
+ console.log(` allowed spaces: ${googleChat.allowedSpaces.length ? googleChat.allowedSpaces.join(', ') : googleChat.allowAllSpaces ? '(all spaces)' : '(none)'}`);
688
+ console.log(` allowed users: ${googleChat.allowedUsers.length ? googleChat.allowedUsers.join(', ') : googleChat.allowAllUsers ? '(all users)' : '(none)'}`);
689
+ console.log(` free spaces: ${googleChat.freeResponseSpaces.length ? googleChat.freeResponseSpaces.join(', ') : '(none)'}`);
690
+ console.log(` flow control: messages=${googleChat.maxMessages}, bytes=${googleChat.maxBytes}`);
691
+ }
692
+ console.log(` bluebubbles: ${bluebubbles.serverUrl || bluebubbles.password ? `configured via ${bluebubbles.source}` : 'not configured'}`);
693
+ if (bluebubbles.serverUrl || bluebubbles.password) {
694
+ console.log(` server url: ${bluebubbles.serverUrl ?? '(not set)'}`);
695
+ console.log(` password: ${bluebubbles.password ? 'set' : '(not set)'}`);
696
+ console.log(` webhook: ${bluebubbles.webhookHost}:${bluebubbles.webhookPort}${bluebubbles.webhookPath}`);
697
+ console.log(` home channel: ${bluebubbles.homeChannel ?? '(not set)'}`);
698
+ console.log(` allowed targets: ${bluebubbles.allowedUsers.length ? bluebubbles.allowedUsers.join(', ') : bluebubbles.allowAllUsers ? '(all targets)' : '(none)'}`);
699
+ console.log(` require mention: ${bluebubbles.requireMention ? 'yes' : 'no'}`);
700
+ }
701
+ console.log(` teams: ${teams.incomingWebhookUrl || teams.graphAccessToken || teams.clientId ? `configured via ${teams.source}` : 'not configured'}`);
702
+ if (teams.incomingWebhookUrl || teams.graphAccessToken || teams.clientId) {
703
+ console.log(` delivery mode: ${teams.deliveryMode}`);
704
+ console.log(` webhook url: ${teams.incomingWebhookUrl ? 'set' : '(not set)'}`);
705
+ console.log(` graph token: ${teams.graphAccessToken ? 'set' : '(not set)'}`);
706
+ console.log(` chat id: ${teams.chatId ?? '(not set)'}`);
707
+ console.log(` team/channel: ${teams.teamId && teams.channelId ? `${teams.teamId}/${teams.channelId}` : '(not set)'}`);
708
+ console.log(` home channel: ${teams.homeChannel ?? '(not set)'}`);
709
+ console.log(` bot app: ${teams.clientId && teams.tenantId ? 'set' : '(not set)'}`);
710
+ console.log(` allowed users: ${teams.allowedUsers.length ? teams.allowedUsers.join(', ') : teams.allowAllUsers ? '(all users)' : '(none)'}`);
711
+ console.log(` webhook port: ${teams.port}`);
712
+ }
713
+ console.log(` webhooks: ${webhooks.enabled ? `enabled via ${webhooks.source}` : 'not enabled'} (${Object.keys(webhooks.routes).length} route${Object.keys(webhooks.routes).length === 1 ? '' : 's'})`);
714
+ if (webhooks.enabled) {
715
+ console.log(` global secret: ${webhooks.secret ? 'set' : '(not set)'}`);
716
+ console.log(` public url: ${webhooks.publicUrl ?? '(not set)'}`);
717
+ console.log(` rate limit: ${webhooks.rateLimitPerMinute}/minute`);
718
+ }
719
+ const { checkGateway, formatGatewayDoctorStatus, listPendingCronJobs, listRecentDeliveryFailures, summarizeChannelHealth, } = await import('./gateway/doctor.js');
720
+ const pendingCron = await listPendingCronJobs();
721
+ console.log(`\ncron (pending): ${pendingCron.length}`);
722
+ if (pendingCron.length) {
723
+ for (const task of pendingCron.slice(0, 10)) {
724
+ const when = new Date(task.runAt).toISOString();
725
+ const deliver = task.deliver ? ` → ${task.deliver}` : '';
726
+ console.log(` ${task.id} ${when} ${task.schedule ?? 'once'} ${task.spec.slice(0, 60)}${deliver}`);
727
+ }
728
+ if (pendingCron.length > 10)
729
+ console.log(` … และอีก ${pendingCron.length - 10} งาน`);
730
+ }
731
+ const deliveryFailures = await listRecentDeliveryFailures(5);
732
+ console.log(`\ndelivery failures (recent): ${deliveryFailures.length ? deliveryFailures.length : 'none'}`);
733
+ for (const failure of deliveryFailures) {
734
+ const when = failure.lastRun ? new Date(failure.lastRun).toISOString() : '(unknown)';
735
+ console.log(` ${failure.taskId} ${when} ${failure.deliver} ${failure.error.slice(0, 120)}`);
736
+ }
737
+ const healthReport = await checkGateway({ config: cfg, skipNetwork: true });
738
+ const health = summarizeChannelHealth(healthReport.checks).filter((item) => item.status !== 'skip');
739
+ console.log(`\nchannel health (config): ${health.length ? '' : 'no configured channels'}`);
740
+ for (const item of health) {
741
+ console.log(` ${item.channel.padEnd(12)} ${formatGatewayDoctorStatus(item.status)}`);
742
+ }
743
+ console.log(` (live token/webhook probes: ${BRAND.cliName} gateway doctor)`);
744
+ console.log(`\nredacted config:\n${JSON.stringify(redactGatewayConfig(cfg), null, 2)}`);
745
+ }
746
+ async function runGatewaySetup(args) {
747
+ const platformArgProvided = Boolean(args[0] && !args[0].startsWith('--'));
748
+ let platform = platformArgProvided ? args[0] : undefined;
749
+ const rest = platformArgProvided ? args.slice(1) : args;
750
+ if (!platform) {
751
+ if (!process.stdin.isTTY) {
752
+ console.error(`ใช้: ${BRAND.cliName} gateway setup <telegram|discord|slack|mattermost|homeassistant|email|line|sms|ntfy|signal|whatsapp|matrix|googlechat|bluebubbles|teams|webhooks> [options]`);
753
+ process.exit(1);
754
+ }
755
+ const { readGatewayConfig, resolveBlueBubblesConfig, resolveDiscordConfig, resolveEmailConfig, resolveGoogleChatConfig, resolveHomeAssistantConfig, resolveLineConfig, resolveMattermostConfig, resolveMatrixConfig, resolveNtfyConfig, resolveSignalConfig, resolveSlackConfig, resolveSmsConfig, resolveTelegramConfig, resolveTeamsConfig, resolveWhatsAppConfig, resolveWebhookConfig, } = await import('./gateway/config.js');
756
+ const cfg = await readGatewayConfig();
757
+ const options = [
758
+ { id: 'telegram', label: `Telegram ${resolveTelegramConfig(cfg).token ? '(configured)' : ''}` },
759
+ { id: 'discord', label: `Discord ${resolveDiscordConfig(cfg).token ? '(configured)' : ''}` },
760
+ { id: 'slack', label: `Slack ${resolveSlackConfig(cfg).botToken ? '(configured)' : ''}` },
761
+ { id: 'mattermost', label: `Mattermost ${resolveMattermostConfig(cfg).serverUrl ? '(configured)' : ''}` },
762
+ { id: 'homeassistant', label: `Home Assistant ${resolveHomeAssistantConfig(cfg).token ? '(configured)' : ''}` },
763
+ { id: 'email', label: `Email ${resolveEmailConfig(cfg).address ? '(configured)' : ''}` },
764
+ { id: 'line', label: `LINE ${resolveLineConfig(cfg).channelAccessToken ? '(configured)' : ''}` },
765
+ { id: 'sms', label: `SMS/Twilio ${resolveSmsConfig(cfg).accountSid ? '(configured)' : ''}` },
766
+ { id: 'ntfy', label: `ntfy ${resolveNtfyConfig(cfg).topic ? '(configured)' : ''}` },
767
+ { id: 'signal', label: `Signal ${resolveSignalConfig(cfg).account ? '(configured)' : ''}` },
768
+ { id: 'whatsapp', label: `WhatsApp Cloud ${resolveWhatsAppConfig(cfg).phoneNumberId ? '(configured)' : ''}` },
769
+ { id: 'matrix', label: `Matrix ${resolveMatrixConfig(cfg).homeserver ? '(configured)' : ''}` },
770
+ { id: 'googlechat', label: `Google Chat ${resolveGoogleChatConfig(cfg).serviceAccountJson || resolveGoogleChatConfig(cfg).incomingWebhookUrl ? '(configured)' : ''}` },
771
+ { id: 'bluebubbles', label: `BlueBubbles/iMessage ${resolveBlueBubblesConfig(cfg).serverUrl ? '(configured)' : ''}` },
772
+ { id: 'teams', label: `Microsoft Teams ${resolveTeamsConfig(cfg).incomingWebhookUrl || resolveTeamsConfig(cfg).graphAccessToken ? '(configured)' : ''}` },
773
+ { id: 'webhooks', label: `Webhooks ${resolveWebhookConfig(cfg).enabled ? '(configured)' : ''}` },
774
+ ];
775
+ console.log(`${BRAND.productName} gateway setup`);
776
+ for (const [i, option] of options.entries())
777
+ console.log(` ${i + 1}. ${option.label}`);
778
+ const answer = await askText('เลือก platform [1-16]: ');
779
+ const index = Number(answer || '1') - 1;
780
+ platform = options[index]?.id;
781
+ }
782
+ if (platform === 'whatsapp-cloud')
783
+ platform = 'whatsapp';
784
+ if (platform === 'msteams' || platform === 'ms-teams' || platform === 'microsoft-teams')
785
+ platform = 'teams';
786
+ if (platform === 'google-chat' || platform === 'google_chat' || platform === 'gchat')
787
+ platform = 'googlechat';
788
+ if (platform === 'blue-bubbles' || platform === 'blue_bubbles' || platform === 'imessage')
789
+ platform = 'bluebubbles';
790
+ if (!platform ||
791
+ ![
792
+ 'telegram',
793
+ 'discord',
794
+ 'slack',
795
+ 'mattermost',
796
+ 'homeassistant',
797
+ 'hass',
798
+ 'email',
799
+ 'line',
800
+ 'sms',
801
+ 'ntfy',
802
+ 'signal',
803
+ 'whatsapp',
804
+ 'matrix',
805
+ 'googlechat',
806
+ 'bluebubbles',
807
+ 'teams',
808
+ 'webhooks',
809
+ ].includes(platform)) {
810
+ console.error(`ตอนนี้ setup อัตโนมัติรองรับ telegram / discord / slack / mattermost / homeassistant / email / line / sms / ntfy / signal / whatsapp / matrix / googlechat / bluebubbles / teams / webhooks — ได้ "${platform ?? ''}"`);
811
+ process.exit(1);
812
+ }
813
+ if (platform === 'discord')
814
+ return runDiscordGatewaySetup(rest);
815
+ if (platform === 'slack')
816
+ return runSlackGatewaySetup(rest);
817
+ if (platform === 'mattermost')
818
+ return runMattermostGatewaySetup(rest);
819
+ if (platform === 'homeassistant' || platform === 'hass')
820
+ return runHomeAssistantGatewaySetup(rest);
821
+ if (platform === 'email')
822
+ return runEmailGatewaySetup(rest);
823
+ if (platform === 'line')
824
+ return runLineGatewaySetup(rest);
825
+ if (platform === 'sms')
826
+ return runSmsGatewaySetup(rest);
827
+ if (platform === 'ntfy')
828
+ return runNtfyGatewaySetup(rest);
829
+ if (platform === 'signal')
830
+ return runSignalGatewaySetup(rest);
831
+ if (platform === 'whatsapp')
832
+ return runWhatsAppGatewaySetup(rest);
833
+ if (platform === 'matrix')
834
+ return runMatrixGatewaySetup(rest);
835
+ if (platform === 'googlechat')
836
+ return runGoogleChatGatewaySetup(rest);
837
+ if (platform === 'bluebubbles')
838
+ return runBlueBubblesGatewaySetup(rest);
839
+ if (platform === 'teams')
840
+ return runTeamsGatewaySetup(rest);
841
+ if (platform === 'webhooks')
842
+ return runWebhookGatewaySetup(rest);
843
+ let token = argValue(rest, '--bot-token', '--token');
844
+ let allowedRaw = argValue(rest, '--allowed-chats', '--chat-ids');
845
+ const allowWrite = rest.includes('--allow-write');
846
+ if (!token) {
847
+ if (!process.stdin.isTTY) {
848
+ console.error(`ใช้: ${BRAND.cliName} gateway setup telegram --bot-token <token> --allowed-chats <chat_id[,chat_id]>`);
849
+ process.exit(1);
850
+ }
851
+ console.log(`${BRAND.productName} Telegram setup`);
852
+ console.log(`สร้าง bot ผ่าน @BotFather แล้ววาง token ที่นี่ (จะเก็บใน ${appHomePath('gateway', 'config.json')} chmod 600)`);
853
+ token = await askText('Telegram bot token: ');
854
+ }
855
+ if (!allowedRaw) {
856
+ if (!process.stdin.isTTY) {
857
+ console.error('ต้องระบุ --allowed-chats <chat_id[,chat_id]> เพื่อ fail-closed');
858
+ process.exit(1);
859
+ }
860
+ allowedRaw = await askText('Allowed private chat IDs (comma-separated): ');
861
+ }
862
+ const { parseAllowedChats } = await import('./gateway/telegram.js');
863
+ const allowedChatIds = parseAllowedChats(allowedRaw);
864
+ if (!token.trim() || !allowedChatIds.length) {
865
+ console.error('Telegram setup ต้องมี bot token และ allowed chat id อย่างน้อย 1 ค่า');
866
+ process.exit(1);
867
+ }
868
+ const { patchGatewayConfig, gatewayConfigPath } = await import('./gateway/config.js');
869
+ await patchGatewayConfig({
870
+ telegram: {
871
+ enabled: true,
872
+ botToken: token.trim(),
873
+ allowedChatIds,
874
+ allowWrite,
875
+ },
876
+ });
877
+ console.log(`บันทึก Telegram gateway config แล้ว: ${gatewayConfigPath()}`);
878
+ console.log(`รัน: ${BRAND.cliName} gateway run`);
879
+ }
880
+ function parseStringCsv(raw) {
881
+ if (!raw)
882
+ return [];
883
+ return raw
884
+ .split(',')
885
+ .map((s) => s.trim())
886
+ .filter(Boolean);
887
+ }
888
+ async function runDiscordGatewaySetup(args) {
889
+ let token = argValue(args, '--bot-token', '--token');
890
+ let defaultChannel = argValue(args, '--channel', '--default-channel');
891
+ let allowedRaw = argValue(args, '--allowed-channels', '--channel-ids');
892
+ const allowWrite = args.includes('--allow-write');
893
+ if (!token) {
894
+ if (!process.stdin.isTTY) {
895
+ console.error(`ใช้: ${BRAND.cliName} gateway setup discord --bot-token <token> --channel <channel_id>`);
896
+ process.exit(1);
897
+ }
898
+ console.log(`${BRAND.productName} Discord setup`);
899
+ console.log('สร้าง bot ใน Discord Developer Portal, เปิด Message Content Intent, แล้ววาง Bot Token ที่นี่');
900
+ token = await askText('Discord bot token: ');
901
+ }
902
+ if (!defaultChannel && !allowedRaw) {
903
+ if (!process.stdin.isTTY) {
904
+ console.error('ต้องระบุ --channel <channel_id> หรือ --allowed-channels <id[,id]>');
905
+ process.exit(1);
906
+ }
907
+ defaultChannel = await askText('Default Discord channel ID: ');
908
+ }
909
+ const allowedChannelIds = parseStringCsv(allowedRaw ?? defaultChannel);
910
+ if (!token.trim() || !allowedChannelIds.length) {
911
+ console.error('Discord setup ต้องมี bot token และ channel id อย่างน้อย 1 ค่า');
912
+ process.exit(1);
913
+ }
914
+ const { patchGatewayConfig, gatewayConfigPath } = await import('./gateway/config.js');
915
+ await patchGatewayConfig({
916
+ discord: {
917
+ enabled: true,
918
+ botToken: token.trim(),
919
+ defaultChannelId: (defaultChannel ?? allowedChannelIds[0]).trim(),
920
+ allowedChannelIds,
921
+ allowWrite,
922
+ },
923
+ });
924
+ console.log(`บันทึก Discord gateway config แล้ว: ${gatewayConfigPath()}`);
925
+ console.log(`ส่งทดสอบได้ด้วย: ${BRAND.cliName} send --to discord "hello"`);
926
+ }
927
+ async function runSlackGatewaySetup(args) {
928
+ let botToken = argValue(args, '--bot-token', '--token');
929
+ let appToken = argValue(args, '--app-token');
930
+ let defaultChannel = argValue(args, '--channel', '--default-channel');
931
+ let allowedRaw = argValue(args, '--allowed-channels', '--channel-ids');
932
+ const allowWrite = args.includes('--allow-write');
933
+ if (!botToken) {
934
+ if (!process.stdin.isTTY) {
935
+ console.error(`ใช้: ${BRAND.cliName} gateway setup slack --bot-token <xoxb-token> --app-token <xapp-token> --channel <channel_id>`);
936
+ process.exit(1);
937
+ }
938
+ console.log(`${BRAND.productName} Slack setup`);
939
+ console.log('สร้าง Slack app, เปิด Socket Mode, เพิ่ม scopes แล้ววาง Bot Token (xoxb-) ที่นี่');
940
+ botToken = await askText('Slack bot token (xoxb-): ');
941
+ }
942
+ if (!appToken && process.stdin.isTTY) {
943
+ appToken = await askText('Slack app token (xapp-, optional for outbound send but needed for gateway Socket Mode): ');
944
+ }
945
+ if (!defaultChannel && !allowedRaw) {
946
+ if (!process.stdin.isTTY) {
947
+ console.error('ต้องระบุ --channel <channel_id> หรือ --allowed-channels <id[,id]>');
948
+ process.exit(1);
949
+ }
950
+ defaultChannel = await askText('Default Slack channel ID: ');
951
+ }
952
+ const allowedChannelIds = parseStringCsv(allowedRaw ?? defaultChannel);
953
+ if (!botToken.trim() || !allowedChannelIds.length) {
954
+ console.error('Slack setup ต้องมี bot token และ channel id อย่างน้อย 1 ค่า');
955
+ process.exit(1);
956
+ }
957
+ const { patchGatewayConfig, gatewayConfigPath } = await import('./gateway/config.js');
958
+ await patchGatewayConfig({
959
+ slack: {
960
+ enabled: true,
961
+ botToken: botToken.trim(),
962
+ appToken: appToken?.trim() || undefined,
963
+ defaultChannelId: (defaultChannel ?? allowedChannelIds[0]).trim(),
964
+ allowedChannelIds,
965
+ allowWrite,
966
+ },
967
+ });
968
+ console.log(`บันทึก Slack gateway config แล้ว: ${gatewayConfigPath()}`);
969
+ console.log(`ส่งทดสอบได้ด้วย: ${BRAND.cliName} send --to slack "hello"`);
970
+ }
971
+ async function runMattermostGatewaySetup(args) {
972
+ let serverUrl = argValue(args, '--url', '--server-url');
973
+ let token = argValue(args, '--token');
974
+ let homeChannel = argValue(args, '--home-channel', '--channel', '--to');
975
+ let allowedUsersRaw = argValue(args, '--allowed-users');
976
+ let allowedChannelsRaw = argValue(args, '--allowed-channels', '--channel-ids');
977
+ let freeResponseChannelsRaw = argValue(args, '--free-response-channels');
978
+ const homeChannelName = argValue(args, '--home-channel-name');
979
+ const allowAllUsers = args.includes('--allow-all-users');
980
+ const requireMention = !args.includes('--no-require-mention');
981
+ const groupSessionsPerUser = !args.includes('--shared-channel-session');
982
+ const replyMode = args.includes('--thread-replies') || argValue(args, '--reply-mode') === 'thread' ? 'thread' : 'off';
983
+ if (!serverUrl) {
984
+ if (!process.stdin.isTTY) {
985
+ console.error(`ใช้: ${BRAND.cliName} gateway setup mattermost --url <https://mm.example.com> --token <token> --allowed-users <user_id[,user_id]> --home-channel <channel_id>`);
986
+ process.exit(1);
987
+ }
988
+ console.log(`${BRAND.productName} Mattermost setup`);
989
+ console.log('ใช้ Mattermost REST API v4 + WebSocket; แนะนำ token ของ dedicated bot account');
990
+ serverUrl = await askText('Mattermost server URL (เช่น https://mm.example.com): ');
991
+ }
992
+ if (!token) {
993
+ if (!process.stdin.isTTY) {
994
+ console.error('ต้องระบุ --token <Mattermost personal/bot access token>');
995
+ process.exit(1);
996
+ }
997
+ token = await askText('Mattermost token: ');
998
+ }
999
+ if (!allowedUsersRaw && !allowAllUsers) {
1000
+ if (!process.stdin.isTTY) {
1001
+ console.error('ต้องระบุ --allowed-users <user_id[,user_id]> เพื่อ fail-closed หรือ --allow-all-users');
1002
+ process.exit(1);
1003
+ }
1004
+ allowedUsersRaw = await askText('Allowed Mattermost user IDs (comma-separated): ');
1005
+ }
1006
+ if (!homeChannel && !allowedChannelsRaw) {
1007
+ if (!process.stdin.isTTY) {
1008
+ console.error('ต้องระบุ --home-channel <channel_id> หรือ --allowed-channels <channel_id[,id]> เพื่อส่งข้อความออกแบบ fail-closed');
1009
+ process.exit(1);
1010
+ }
1011
+ homeChannel = await askText('Mattermost home channel ID (blank = skip outbound home): ');
1012
+ }
1013
+ const { normalizeMattermostUrl } = await import('./gateway/mattermost.js');
1014
+ const cleanServerUrl = normalizeMattermostUrl(serverUrl);
1015
+ const cleanHome = homeChannel?.trim();
1016
+ const allowedUsers = parseStringCsv(allowedUsersRaw);
1017
+ const allowedChannels = parseStringCsv(allowedChannelsRaw ?? cleanHome);
1018
+ const freeResponseChannels = parseStringCsv(freeResponseChannelsRaw);
1019
+ if (!cleanServerUrl) {
1020
+ console.error('Mattermost setup ต้องมี server URL ที่ขึ้นต้นด้วย http:// หรือ https://');
1021
+ process.exit(1);
1022
+ }
1023
+ if (!token.trim()) {
1024
+ console.error('Mattermost setup ต้องมี token');
1025
+ process.exit(1);
1026
+ }
1027
+ if (!allowAllUsers && !allowedUsers.length) {
1028
+ console.error('Mattermost setup ต้องมี allowed users อย่างน้อย 1 ค่า หรือระบุ --allow-all-users');
1029
+ process.exit(1);
1030
+ }
1031
+ if (!cleanHome && !allowedChannels.length) {
1032
+ console.error('Mattermost setup ต้องมี home channel/allowed channels อย่างน้อย 1 ค่า');
1033
+ process.exit(1);
1034
+ }
1035
+ const { patchGatewayConfig, gatewayConfigPath } = await import('./gateway/config.js');
1036
+ await patchGatewayConfig({
1037
+ mattermost: {
1038
+ enabled: true,
1039
+ serverUrl: cleanServerUrl,
1040
+ token: token.trim(),
1041
+ homeChannel: cleanHome || allowedChannels[0],
1042
+ homeChannelName: homeChannelName?.trim() || undefined,
1043
+ allowedUsers,
1044
+ allowedChannels,
1045
+ freeResponseChannels,
1046
+ allowAllUsers,
1047
+ requireMention,
1048
+ groupSessionsPerUser,
1049
+ replyMode,
1050
+ },
1051
+ });
1052
+ console.log(`บันทึก Mattermost gateway config แล้ว: ${gatewayConfigPath()}`);
1053
+ console.log(`Mattermost websocket: ${cleanServerUrl}/api/v4/websocket`);
1054
+ console.log(`ส่งทดสอบได้ด้วย: ${BRAND.cliName} send --to mattermost "hello"`);
1055
+ }
1056
+ async function runHomeAssistantGatewaySetup(args) {
1057
+ const url = argValue(args, '--url')?.trim() || 'http://homeassistant.local:8123';
1058
+ let token = argValue(args, '--token');
1059
+ let homeChannel = argValue(args, '--home-channel', '--notification-id', '--to');
1060
+ let watchDomainsRaw = argValue(args, '--watch-domains', '--domains');
1061
+ let watchEntitiesRaw = argValue(args, '--watch-entities', '--entities');
1062
+ let ignoreEntitiesRaw = argValue(args, '--ignore-entities');
1063
+ const homeChannelName = argValue(args, '--home-channel-name');
1064
+ const watchAll = args.includes('--watch-all');
1065
+ const cooldownSecondsRaw = argValue(args, '--cooldown-seconds', '--cooldown');
1066
+ if (!token) {
1067
+ if (!process.stdin.isTTY) {
1068
+ console.error(`ใช้: ${BRAND.cliName} gateway setup homeassistant --token <long-lived-token> [--url http://homeassistant.local:8123] --watch-domains climate,binary_sensor`);
1069
+ process.exit(1);
1070
+ }
1071
+ console.log(`${BRAND.productName} Home Assistant setup`);
1072
+ console.log('สร้าง Long-Lived Access Token จาก Home Assistant Profile แล้ววาง token ที่นี่');
1073
+ token = await askText('Home Assistant long-lived access token: ');
1074
+ }
1075
+ if (!homeChannel && process.stdin.isTTY) {
1076
+ homeChannel = (await askText('Persistent notification id (blank = sanook_agent): ')) || 'sanook_agent';
1077
+ }
1078
+ if (!watchDomainsRaw && !watchEntitiesRaw && !watchAll) {
1079
+ if (!process.stdin.isTTY) {
1080
+ console.error('ต้องระบุ --watch-domains, --watch-entities หรือ --watch-all เพื่อรับ state_changed events');
1081
+ process.exit(1);
1082
+ }
1083
+ watchDomainsRaw = await askText('Watch domains (comma-separated; เช่น climate,binary_sensor,alarm_control_panel): ');
1084
+ }
1085
+ const { homeAssistantWebSocketUrl, normalizeHomeAssistantUrl } = await import('./gateway/homeassistant.js');
1086
+ const cleanUrl = normalizeHomeAssistantUrl(url);
1087
+ const watchDomains = parseStringCsv(watchDomainsRaw);
1088
+ const watchEntities = parseStringCsv(watchEntitiesRaw);
1089
+ const ignoreEntities = parseStringCsv(ignoreEntitiesRaw);
1090
+ const cooldownSeconds = cooldownSecondsRaw ? Number(cooldownSecondsRaw) : undefined;
1091
+ if (!cleanUrl) {
1092
+ console.error('Home Assistant setup ต้องมี URL ที่ขึ้นต้นด้วย http:// หรือ https://');
1093
+ process.exit(1);
1094
+ }
1095
+ if (!token.trim()) {
1096
+ console.error('Home Assistant setup ต้องมี token');
1097
+ process.exit(1);
1098
+ }
1099
+ if (!watchAll && !watchDomains.length && !watchEntities.length) {
1100
+ console.error('Home Assistant setup ต้องมี watch domains/entities อย่างน้อย 1 ค่า หรือระบุ --watch-all');
1101
+ process.exit(1);
1102
+ }
1103
+ if (cooldownSecondsRaw && (!Number.isInteger(cooldownSeconds) || Number(cooldownSeconds) <= 0)) {
1104
+ console.error('--cooldown-seconds ต้องเป็น integer มากกว่า 0');
1105
+ process.exit(1);
1106
+ }
1107
+ const { patchGatewayConfig, gatewayConfigPath } = await import('./gateway/config.js');
1108
+ await patchGatewayConfig({
1109
+ homeassistant: {
1110
+ enabled: true,
1111
+ url: cleanUrl,
1112
+ token: token.trim(),
1113
+ homeChannel: homeChannel?.trim() || 'sanook_agent',
1114
+ homeChannelName: homeChannelName?.trim() || undefined,
1115
+ watchDomains,
1116
+ watchEntities,
1117
+ ignoreEntities,
1118
+ watchAll,
1119
+ cooldownSeconds,
1120
+ },
1121
+ });
1122
+ console.log(`บันทึก Home Assistant gateway config แล้ว: ${gatewayConfigPath()}`);
1123
+ console.log(`Home Assistant websocket: ${homeAssistantWebSocketUrl(cleanUrl)}`);
1124
+ console.log(`ส่งทดสอบได้ด้วย: ${BRAND.cliName} send --to homeassistant "hello"`);
1125
+ }
1126
+ async function runLineGatewaySetup(args) {
1127
+ let channelAccessToken = argValue(args, '--channel-access-token', '--access-token', '--token');
1128
+ let channelSecret = argValue(args, '--channel-secret', '--secret');
1129
+ let homeChannel = argValue(args, '--home-channel', '--to');
1130
+ let allowedUsersRaw = argValue(args, '--allowed-users');
1131
+ let allowedGroupsRaw = argValue(args, '--allowed-groups');
1132
+ let allowedRoomsRaw = argValue(args, '--allowed-rooms');
1133
+ const publicUrl = argValue(args, '--public-url');
1134
+ const allowAllUsers = args.includes('--allow-all-users');
1135
+ if (!channelAccessToken) {
1136
+ if (!process.stdin.isTTY) {
1137
+ console.error(`ใช้: ${BRAND.cliName} gateway setup line --channel-access-token <token> --home-channel <U/C/R-id>`);
1138
+ process.exit(1);
1139
+ }
1140
+ console.log(`${BRAND.productName} LINE setup`);
1141
+ console.log('สร้าง LINE Messaging API channel แล้ววาง long-lived Channel access token ที่นี่');
1142
+ channelAccessToken = await askText('LINE channel access token: ');
1143
+ }
1144
+ if (!channelSecret && process.stdin.isTTY) {
1145
+ channelSecret = await askText('LINE channel secret (needed for webhook replies): ');
1146
+ }
1147
+ if (!homeChannel && !allowedUsersRaw && !allowedGroupsRaw && !allowedRoomsRaw && !allowAllUsers) {
1148
+ if (!process.stdin.isTTY) {
1149
+ console.error('ต้องระบุ --home-channel <U/C/R-id> หรือ allowed list อย่างน้อยหนึ่งชุด เพื่อ fail-closed');
1150
+ process.exit(1);
1151
+ }
1152
+ homeChannel = await askText('LINE home channel ID (U user / C group / R room): ');
1153
+ }
1154
+ const home = homeChannel?.trim();
1155
+ const allowedUsers = parseStringCsv(allowedUsersRaw);
1156
+ const allowedGroups = parseStringCsv(allowedGroupsRaw);
1157
+ const allowedRooms = parseStringCsv(allowedRoomsRaw);
1158
+ if (!allowAllUsers && !home && !allowedUsers.length && !allowedGroups.length && !allowedRooms.length) {
1159
+ console.error('LINE setup ต้องมี home channel/allowlist อย่างน้อย 1 ค่า หรือระบุ --allow-all-users');
1160
+ process.exit(1);
1161
+ }
1162
+ if (!channelAccessToken.trim()) {
1163
+ console.error('LINE setup ต้องมี channel access token');
1164
+ process.exit(1);
1165
+ }
1166
+ const { patchGatewayConfig, gatewayConfigPath } = await import('./gateway/config.js');
1167
+ await patchGatewayConfig({
1168
+ line: {
1169
+ enabled: true,
1170
+ channelAccessToken: channelAccessToken.trim(),
1171
+ channelSecret: channelSecret?.trim() || undefined,
1172
+ homeChannel: home || allowedUsers[0] || allowedGroups[0] || allowedRooms[0],
1173
+ allowedUsers,
1174
+ allowedGroups,
1175
+ allowedRooms,
1176
+ allowAllUsers,
1177
+ publicUrl: publicUrl?.trim() || undefined,
1178
+ },
1179
+ });
1180
+ console.log(`บันทึก LINE gateway config แล้ว: ${gatewayConfigPath()}`);
1181
+ console.log(`ส่งทดสอบได้ด้วย: ${BRAND.cliName} send --to line "hello"`);
1182
+ }
1183
+ async function runSmsGatewaySetup(args) {
1184
+ let accountSid = argValue(args, '--account-sid', '--sid');
1185
+ let authToken = argValue(args, '--auth-token', '--token');
1186
+ let phoneNumber = argValue(args, '--phone-number', '--from');
1187
+ let homeChannel = argValue(args, '--home-channel', '--to');
1188
+ let allowedRaw = argValue(args, '--allowed-users', '--allowed-numbers');
1189
+ const homeChannelName = argValue(args, '--home-channel-name');
1190
+ const webhookUrl = argValue(args, '--webhook-url');
1191
+ const allowAllUsers = args.includes('--allow-all-users');
1192
+ const insecureNoSignature = args.includes('--insecure-no-signature');
1193
+ if (!accountSid) {
1194
+ if (!process.stdin.isTTY) {
1195
+ console.error(`ใช้: ${BRAND.cliName} gateway setup sms --account-sid <AC...> --auth-token <token> --phone-number <+1555...> --home-channel <+1555...> --webhook-url <https://.../sms/webhook>`);
1196
+ process.exit(1);
1197
+ }
1198
+ console.log(`${BRAND.productName} SMS/Twilio setup`);
1199
+ console.log('ใช้ Twilio Programmable Messaging; inbound webhook ต้องตั้ง URL เดียวกันใน Twilio Console');
1200
+ accountSid = await askText('Twilio Account SID: ');
1201
+ }
1202
+ if (!authToken) {
1203
+ if (!process.stdin.isTTY) {
1204
+ console.error('ต้องระบุ --auth-token <token>');
1205
+ process.exit(1);
1206
+ }
1207
+ authToken = await askText('Twilio Auth Token: ');
1208
+ }
1209
+ if (!phoneNumber) {
1210
+ if (!process.stdin.isTTY) {
1211
+ console.error('ต้องระบุ --phone-number <E.164 Twilio number>');
1212
+ process.exit(1);
1213
+ }
1214
+ phoneNumber = await askText('Twilio phone number (+1555...): ');
1215
+ }
1216
+ if (!homeChannel && !allowedRaw && !allowAllUsers) {
1217
+ if (!process.stdin.isTTY) {
1218
+ console.error('ต้องระบุ --home-channel <phone> หรือ --allowed-users <phone[,phone]> เพื่อ fail-closed');
1219
+ process.exit(1);
1220
+ }
1221
+ homeChannel = await askText('Home/allowed phone number (+1555...): ');
1222
+ }
1223
+ const { normalizeSmsPhone } = await import('./gateway/sms.js');
1224
+ const from = normalizeSmsPhone(phoneNumber);
1225
+ const home = normalizeSmsPhone(homeChannel);
1226
+ const allowedUsers = parseStringCsv(allowedRaw ?? home).map((phone) => normalizeSmsPhone(phone)).filter((phone) => Boolean(phone));
1227
+ if (!accountSid.trim() || !authToken.trim() || !from) {
1228
+ console.error('SMS setup ต้องมี account sid, auth token และ Twilio phone number');
1229
+ process.exit(1);
1230
+ }
1231
+ if (!allowAllUsers && !home && !allowedUsers.length) {
1232
+ console.error('SMS setup ต้องมี home channel/allowlist อย่างน้อย 1 ค่า หรือระบุ --allow-all-users');
1233
+ process.exit(1);
1234
+ }
1235
+ if (!webhookUrl && !insecureNoSignature) {
1236
+ if (!process.stdin.isTTY) {
1237
+ console.error('ต้องระบุ --webhook-url <https://.../sms/webhook> เพื่อ verify Twilio signature หรือ --insecure-no-signature สำหรับ local dev');
1238
+ process.exit(1);
1239
+ }
1240
+ console.log('ยังไม่ได้ตั้ง webhook URL; inbound SMS จะไม่เริ่มจนกว่าจะตั้ง SMS_WEBHOOK_URL หรือรัน setup ใหม่พร้อม --webhook-url');
1241
+ }
1242
+ const { patchGatewayConfig, gatewayConfigPath } = await import('./gateway/config.js');
1243
+ await patchGatewayConfig({
1244
+ sms: {
1245
+ enabled: true,
1246
+ accountSid: accountSid.trim(),
1247
+ authToken: authToken.trim(),
1248
+ phoneNumber: from,
1249
+ homeChannel: home || allowedUsers[0],
1250
+ homeChannelName: homeChannelName?.trim() || undefined,
1251
+ allowedUsers,
1252
+ allowAllUsers,
1253
+ webhookUrl: webhookUrl?.trim() || undefined,
1254
+ insecureNoSignature,
1255
+ },
1256
+ });
1257
+ console.log(`บันทึก SMS/Twilio gateway config แล้ว: ${gatewayConfigPath()}`);
1258
+ console.log(`ตั้ง Twilio webhook เป็น: ${webhookUrl?.trim() || `http://127.0.0.1:<port>/sms/webhook`}`);
1259
+ console.log(`ส่งทดสอบได้ด้วย: ${BRAND.cliName} send --to sms "hello"`);
1260
+ }
1261
+ async function runSignalGatewaySetup(args) {
1262
+ const httpUrl = argValue(args, '--http-url', '--url')?.trim() || 'http://127.0.0.1:8080';
1263
+ let account = argValue(args, '--account', '--phone-number');
1264
+ let homeChannel = argValue(args, '--home-channel', '--to');
1265
+ let allowedRaw = argValue(args, '--allowed-users', '--allowed-numbers');
1266
+ let groupAllowedRaw = argValue(args, '--group-allowed-users', '--allowed-groups');
1267
+ const homeChannelName = argValue(args, '--home-channel-name');
1268
+ const allowAllUsers = args.includes('--allow-all-users');
1269
+ const requireMention = args.includes('--require-mention');
1270
+ if (!account) {
1271
+ if (!process.stdin.isTTY) {
1272
+ console.error(`ใช้: ${BRAND.cliName} gateway setup signal --account <+1555...> --home-channel <+1555...> [--http-url http://127.0.0.1:8080]`);
1273
+ process.exit(1);
1274
+ }
1275
+ console.log(`${BRAND.productName} Signal setup`);
1276
+ console.log('ต้องมี signal-cli daemon --http รันอยู่; Sanook ใช้ JSON-RPC /api/v1/rpc และ SSE /api/v1/events');
1277
+ account = await askText('Signal account (+E.164): ');
1278
+ }
1279
+ if (!homeChannel && !allowedRaw && !groupAllowedRaw && !allowAllUsers) {
1280
+ if (process.stdin.isTTY) {
1281
+ homeChannel = await askText('Signal home/allowed user (+E.164 หรือ UUID; blank = account/Note to Self): ');
1282
+ }
1283
+ if (!homeChannel)
1284
+ homeChannel = account;
1285
+ }
1286
+ if (!allowedRaw && !allowAllUsers && homeChannel && !homeChannel.trim().toLowerCase().startsWith('group:')) {
1287
+ allowedRaw = homeChannel;
1288
+ }
1289
+ if (!groupAllowedRaw && homeChannel?.trim().toLowerCase().startsWith('group:')) {
1290
+ groupAllowedRaw = homeChannel;
1291
+ }
1292
+ const { normalizeSignalId } = await import('./gateway/signal.js');
1293
+ const cleanAccount = normalizeSignalId(account);
1294
+ const cleanHome = normalizeSignalId(homeChannel);
1295
+ const allowedUsers = parseStringCsv(allowedRaw).map(normalizeSignalId).filter((id) => Boolean(id));
1296
+ const groupAllowedUsers = parseStringCsv(groupAllowedRaw)
1297
+ .map((id) => {
1298
+ if (id.trim() === '*')
1299
+ return '*';
1300
+ const normalized = normalizeSignalId(id);
1301
+ return normalized?.startsWith('group:') ? normalized : normalized ? `group:${normalized}` : undefined;
1302
+ })
1303
+ .filter((id) => Boolean(id));
1304
+ if (!cleanAccount) {
1305
+ console.error('Signal setup ต้องมี account (+E.164 หรือ account id)');
1306
+ process.exit(1);
1307
+ }
1308
+ if (!allowAllUsers && !cleanHome && !allowedUsers.length && !groupAllowedUsers.length) {
1309
+ console.error('Signal setup ต้องมี home channel/allowlist อย่างน้อย 1 ค่า หรือระบุ --allow-all-users');
1310
+ process.exit(1);
1311
+ }
1312
+ const { patchGatewayConfig, gatewayConfigPath } = await import('./gateway/config.js');
1313
+ await patchGatewayConfig({
1314
+ signal: {
1315
+ enabled: true,
1316
+ httpUrl,
1317
+ account: cleanAccount,
1318
+ homeChannel: cleanHome || allowedUsers[0] || groupAllowedUsers[0] || cleanAccount,
1319
+ homeChannelName: homeChannelName?.trim() || undefined,
1320
+ allowedUsers,
1321
+ groupAllowedUsers,
1322
+ allowAllUsers,
1323
+ requireMention,
1324
+ },
1325
+ });
1326
+ console.log(`บันทึก Signal gateway config แล้ว: ${gatewayConfigPath()}`);
1327
+ console.log(`ตรวจ signal-cli daemon: ${httpUrl}/api/v1/check`);
1328
+ console.log(`ส่งทดสอบได้ด้วย: ${BRAND.cliName} send --to signal "hello"`);
1329
+ }
1330
+ async function runWhatsAppGatewaySetup(args) {
1331
+ let phoneNumberId = argValue(args, '--phone-number-id', '--phone-id');
1332
+ let accessToken = argValue(args, '--access-token', '--token');
1333
+ let appSecret = argValue(args, '--app-secret', '--secret');
1334
+ let verifyToken = argValue(args, '--verify-token');
1335
+ let homeChannel = argValue(args, '--home-channel', '--to');
1336
+ let allowedRaw = argValue(args, '--allowed-users', '--allowed-numbers');
1337
+ const homeChannelName = argValue(args, '--home-channel-name');
1338
+ const publicUrl = argValue(args, '--public-url');
1339
+ const apiVersion = argValue(args, '--api-version');
1340
+ const allowAllUsers = args.includes('--allow-all-users');
1341
+ if (!phoneNumberId) {
1342
+ if (!process.stdin.isTTY) {
1343
+ console.error(`ใช้: ${BRAND.cliName} gateway setup whatsapp --phone-number-id <id> --access-token <EAA...> --app-secret <secret> --home-channel <wa_id>`);
1344
+ process.exit(1);
1345
+ }
1346
+ console.log(`${BRAND.productName} WhatsApp Cloud setup`);
1347
+ console.log('ใช้ Meta WhatsApp Business Cloud API: ต้องมี Phone Number ID, Access Token, App Secret และ public HTTPS webhook URL');
1348
+ phoneNumberId = await askText('WhatsApp Phone Number ID (ตัวเลขจาก Meta API Setup ไม่ใช่เบอร์โทร): ');
1349
+ }
1350
+ if (!accessToken) {
1351
+ if (!process.stdin.isTTY) {
1352
+ console.error('ต้องระบุ --access-token <Meta WhatsApp Cloud token>');
1353
+ process.exit(1);
1354
+ }
1355
+ accessToken = await askText('WhatsApp Cloud access token: ');
1356
+ }
1357
+ if (!appSecret) {
1358
+ if (!process.stdin.isTTY) {
1359
+ console.error('ต้องระบุ --app-secret <Meta app secret> เพื่อ verify X-Hub-Signature-256');
1360
+ process.exit(1);
1361
+ }
1362
+ appSecret = await askText('Meta app secret (Settings > Basic): ');
1363
+ }
1364
+ if (!homeChannel && !allowedRaw && !allowAllUsers) {
1365
+ if (!process.stdin.isTTY) {
1366
+ console.error('ต้องระบุ --home-channel <wa_id> หรือ --allowed-users <wa_id[,wa_id]> เพื่อ fail-closed');
1367
+ process.exit(1);
1368
+ }
1369
+ homeChannel = await askText('WhatsApp home/allowed wa_id (country code, no +): ');
1370
+ }
1371
+ const { randomBytes } = await import('node:crypto');
1372
+ const { normalizeWhatsAppId } = await import('./gateway/whatsapp.js');
1373
+ const cleanPhoneNumberId = phoneNumberId.trim();
1374
+ const cleanHome = normalizeWhatsAppId(homeChannel);
1375
+ const allowedUsers = parseStringCsv(allowedRaw ?? cleanHome).map(normalizeWhatsAppId).filter((id) => Boolean(id));
1376
+ if (!verifyToken)
1377
+ verifyToken = randomBytes(24).toString('base64url');
1378
+ if (!cleanPhoneNumberId || !accessToken.trim() || !appSecret.trim()) {
1379
+ console.error('WhatsApp setup ต้องมี phone number id, access token และ app secret');
1380
+ process.exit(1);
1381
+ }
1382
+ if (!allowAllUsers && !cleanHome && !allowedUsers.length) {
1383
+ console.error('WhatsApp setup ต้องมี home channel/allowlist อย่างน้อย 1 ค่า หรือระบุ --allow-all-users');
1384
+ process.exit(1);
1385
+ }
1386
+ const { patchGatewayConfig, gatewayConfigPath } = await import('./gateway/config.js');
1387
+ await patchGatewayConfig({
1388
+ whatsapp: {
1389
+ enabled: true,
1390
+ phoneNumberId: cleanPhoneNumberId,
1391
+ accessToken: accessToken.trim(),
1392
+ appSecret: appSecret.trim(),
1393
+ verifyToken: verifyToken.trim(),
1394
+ homeChannel: cleanHome || allowedUsers[0],
1395
+ homeChannelName: homeChannelName?.trim() || undefined,
1396
+ allowedUsers,
1397
+ allowAllUsers,
1398
+ publicUrl: publicUrl?.trim() || undefined,
1399
+ apiVersion: apiVersion?.trim() || undefined,
1400
+ },
1401
+ });
1402
+ const callback = publicUrl?.trim() ? `${publicUrl.trim().replace(/\/+$/, '')}/whatsapp/webhook` : `https://<your-tunnel>/whatsapp/webhook`;
1403
+ console.log(`บันทึก WhatsApp Cloud gateway config แล้ว: ${gatewayConfigPath()}`);
1404
+ console.log(`Meta webhook callback URL: ${callback}`);
1405
+ console.log(`Meta verify token: ${verifyToken.trim()}`);
1406
+ console.log(`ส่งทดสอบได้ด้วย: ${BRAND.cliName} send --to whatsapp "hello"`);
1407
+ }
1408
+ async function runMatrixGatewaySetup(args) {
1409
+ let homeserver = argValue(args, '--homeserver', '--server', '--url');
1410
+ let accessToken = argValue(args, '--access-token', '--token');
1411
+ let userId = argValue(args, '--user-id', '--user');
1412
+ let password = argValue(args, '--password');
1413
+ let homeRoom = argValue(args, '--home-room', '--room', '--to');
1414
+ let allowedUsersRaw = argValue(args, '--allowed-users');
1415
+ let allowedRoomsRaw = argValue(args, '--allowed-rooms');
1416
+ let freeResponseRoomsRaw = argValue(args, '--free-response-rooms');
1417
+ const homeRoomName = argValue(args, '--home-room-name');
1418
+ const allowAllUsers = args.includes('--allow-all-users');
1419
+ const requireMention = !args.includes('--no-require-mention');
1420
+ const groupSessionsPerUser = !args.includes('--shared-room-session');
1421
+ const autoJoin = !args.includes('--no-auto-join');
1422
+ const pollTimeoutMs = argValue(args, '--poll-timeout-ms');
1423
+ if (!homeserver) {
1424
+ if (!process.stdin.isTTY) {
1425
+ console.error(`ใช้: ${BRAND.cliName} gateway setup matrix --homeserver <https://matrix.org> --access-token <token> --allowed-users <@you:server> [--home-room '!room:server']`);
1426
+ process.exit(1);
1427
+ }
1428
+ console.log(`${BRAND.productName} Matrix setup`);
1429
+ console.log('ใช้ Matrix Client-Server API: ต้องมี homeserver URL และ access token หรือ user/password ของ bot account');
1430
+ homeserver = await askText('Matrix homeserver URL (เช่น https://matrix.org): ');
1431
+ }
1432
+ if (!accessToken && (!userId || !password)) {
1433
+ if (!process.stdin.isTTY) {
1434
+ console.error('ต้องระบุ --access-token <token> หรือ --user-id <@bot:server> --password <password>');
1435
+ process.exit(1);
1436
+ }
1437
+ accessToken = await askText('Matrix access token (แนะนำ; blank = ใช้ user/password): ');
1438
+ if (!accessToken) {
1439
+ userId = await askText('Matrix bot user id (@bot:server): ');
1440
+ password = await askText('Matrix bot password: ');
1441
+ }
1442
+ }
1443
+ if (!allowedUsersRaw && !allowAllUsers) {
1444
+ if (!process.stdin.isTTY) {
1445
+ console.error('ต้องระบุ --allowed-users <@user:server[,user]> เพื่อ fail-closed หรือ --allow-all-users');
1446
+ process.exit(1);
1447
+ }
1448
+ allowedUsersRaw = await askText('Allowed Matrix user IDs (comma-separated): ');
1449
+ }
1450
+ if (!homeRoom && process.stdin.isTTY) {
1451
+ homeRoom = await askText('Matrix home room id/alias (!room:server หรือ #room:server; blank = skip): ');
1452
+ }
1453
+ const { normalizeMatrixHomeserver, normalizeMatrixRoomId, normalizeMatrixUserId } = await import('./gateway/matrix.js');
1454
+ const cleanHomeserver = normalizeMatrixHomeserver(homeserver);
1455
+ const cleanUserId = normalizeMatrixUserId(userId);
1456
+ const cleanHomeRoom = normalizeMatrixRoomId(homeRoom);
1457
+ const allowedUsers = parseStringCsv(allowedUsersRaw).map(normalizeMatrixUserId).filter((id) => Boolean(id));
1458
+ const allowedRooms = parseStringCsv(allowedRoomsRaw).map(normalizeMatrixRoomId).filter((id) => Boolean(id));
1459
+ const freeResponseRooms = parseStringCsv(freeResponseRoomsRaw).map(normalizeMatrixRoomId).filter((id) => Boolean(id));
1460
+ const timeout = pollTimeoutMs ? Number(pollTimeoutMs) : undefined;
1461
+ if (!cleanHomeserver) {
1462
+ console.error('Matrix setup ต้องมี homeserver URL ที่ขึ้นต้นด้วย http:// หรือ https://');
1463
+ process.exit(1);
1464
+ }
1465
+ if (!accessToken?.trim() && (!cleanUserId || !password?.trim())) {
1466
+ console.error('Matrix setup ต้องมี access token หรือ user id/password');
1467
+ process.exit(1);
1468
+ }
1469
+ if (!allowAllUsers && !allowedUsers.length) {
1470
+ console.error('Matrix setup ต้องมี allowed users อย่างน้อย 1 ค่า หรือระบุ --allow-all-users');
1471
+ process.exit(1);
1472
+ }
1473
+ if (homeRoom?.trim() && !cleanHomeRoom) {
1474
+ console.error('Matrix home room ต้องเป็น room id/alias เช่น !abc123:matrix.org หรือ #room:matrix.org');
1475
+ process.exit(1);
1476
+ }
1477
+ if (pollTimeoutMs && (!Number.isInteger(timeout) || Number(timeout) <= 0)) {
1478
+ console.error('--poll-timeout-ms ต้องเป็น integer มากกว่า 0');
1479
+ process.exit(1);
1480
+ }
1481
+ const { patchGatewayConfig, gatewayConfigPath } = await import('./gateway/config.js');
1482
+ await patchGatewayConfig({
1483
+ matrix: {
1484
+ enabled: true,
1485
+ homeserver: cleanHomeserver,
1486
+ accessToken: accessToken?.trim() || undefined,
1487
+ userId: cleanUserId,
1488
+ password: password?.trim() || undefined,
1489
+ homeRoom: cleanHomeRoom || allowedRooms[0],
1490
+ homeRoomName: homeRoomName?.trim() || undefined,
1491
+ allowedUsers,
1492
+ allowedRooms,
1493
+ freeResponseRooms,
1494
+ allowAllUsers,
1495
+ requireMention,
1496
+ groupSessionsPerUser,
1497
+ autoJoin,
1498
+ pollTimeoutMs: timeout,
1499
+ },
1500
+ });
1501
+ console.log(`บันทึก Matrix gateway config แล้ว: ${gatewayConfigPath()}`);
1502
+ console.log(`Matrix sync: ${cleanHomeserver}/_matrix/client/v3/sync`);
1503
+ console.log(`ส่งทดสอบได้ด้วย: ${BRAND.cliName} send --to matrix "hello"${cleanHomeRoom ? '' : ` หรือ ${BRAND.cliName} send --to matrix:!room:server "hello"`}`);
1504
+ }
1505
+ async function runGoogleChatGatewaySetup(args) {
1506
+ const projectId = argValue(args, '--project-id');
1507
+ const subscriptionName = argValue(args, '--subscription-name', '--subscription');
1508
+ let serviceAccountJson = argValue(args, '--service-account-json', '--service-account', '--credentials');
1509
+ const apiBaseUrl = argValue(args, '--api-base-url', '--base-url');
1510
+ let incomingWebhookUrl = argValue(args, '--incoming-webhook-url', '--webhook-url', '--url');
1511
+ let homeChannel = argValue(args, '--home-channel', '--space', '--to');
1512
+ const homeChannelName = argValue(args, '--home-channel-name');
1513
+ const allowedUsersRaw = argValue(args, '--allowed-users');
1514
+ const allowedSpacesRaw = argValue(args, '--allowed-spaces', '--spaces');
1515
+ const freeResponseSpacesRaw = argValue(args, '--free-response-spaces');
1516
+ const maxMessagesRaw = argValue(args, '--max-messages');
1517
+ const maxBytesRaw = argValue(args, '--max-bytes');
1518
+ const allowAllUsers = args.includes('--allow-all-users');
1519
+ const allowAllSpaces = args.includes('--allow-all-spaces');
1520
+ if ((!incomingWebhookUrl && !serviceAccountJson) || (!incomingWebhookUrl && !homeChannel && !allowedSpacesRaw && !allowAllSpaces)) {
1521
+ if (!process.stdin.isTTY) {
1522
+ 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?...>`);
1523
+ process.exit(1);
1524
+ }
1525
+ console.log(`${BRAND.productName} Google Chat setup`);
1526
+ console.log('ใช้ Service Account JSON + Chat REST API สำหรับ bot app หรือ incoming webhook URL สำหรับส่งง่าย ๆ');
1527
+ serviceAccountJson ||= await askText('Service Account JSON path (blank = webhook mode): ');
1528
+ if (serviceAccountJson) {
1529
+ homeChannel ||= await askText('Home space (spaces/AAA...; blank = skip): ');
1530
+ }
1531
+ else {
1532
+ incomingWebhookUrl ||= await askText('Google Chat incoming webhook URL: ');
1533
+ }
1534
+ }
1535
+ const { normalizeGoogleChatApiBaseUrl, normalizeGoogleChatWebhookUrl, parseGoogleChatTarget } = await import('./gateway/googlechat.js');
1536
+ const cleanApiBaseUrl = normalizeGoogleChatApiBaseUrl(apiBaseUrl);
1537
+ const cleanWebhookUrl = normalizeGoogleChatWebhookUrl(incomingWebhookUrl);
1538
+ const cleanServiceAccountJson = serviceAccountJson?.trim();
1539
+ const cleanHomeChannel = homeChannel?.trim();
1540
+ const maxMessages = maxMessagesRaw ? Number(maxMessagesRaw) : undefined;
1541
+ const maxBytes = maxBytesRaw ? Number(maxBytesRaw) : undefined;
1542
+ if (!cleanApiBaseUrl) {
1543
+ console.error('Google Chat API base URL ต้องเป็น https:// URL');
1544
+ process.exit(1);
1545
+ }
1546
+ if (incomingWebhookUrl?.trim() && !cleanWebhookUrl) {
1547
+ console.error('Google Chat incoming webhook URL ต้องเป็น https:// URL');
1548
+ process.exit(1);
1549
+ }
1550
+ if (!cleanWebhookUrl && !cleanServiceAccountJson) {
1551
+ console.error('Google Chat setup ต้องมี service account JSON หรือ incoming webhook URL');
1552
+ process.exit(1);
1553
+ }
1554
+ if (cleanHomeChannel) {
1555
+ try {
1556
+ parseGoogleChatTarget({
1557
+ apiBaseUrl: cleanApiBaseUrl,
1558
+ homeChannel: cleanHomeChannel,
1559
+ allowedUsers: [],
1560
+ allowedSpaces: [],
1561
+ freeResponseSpaces: [],
1562
+ allowAllUsers: false,
1563
+ allowAllSpaces: false,
1564
+ maxMessages: 1,
1565
+ maxBytes: 16_777_216,
1566
+ enabled: true,
1567
+ source: 'config',
1568
+ serviceAccountJson: cleanServiceAccountJson,
1569
+ incomingWebhookUrl: cleanWebhookUrl,
1570
+ }, cleanHomeChannel);
1571
+ }
1572
+ catch (e) {
1573
+ console.error(e instanceof Error ? e.message : 'Google Chat home channel ไม่ถูกต้อง');
1574
+ process.exit(1);
1575
+ }
1576
+ }
1577
+ if (!cleanWebhookUrl && !cleanHomeChannel && !allowedSpacesRaw?.trim() && !allowAllSpaces) {
1578
+ console.error('Google Chat service-account setup ต้องมี home channel, allowed spaces หรือ --allow-all-spaces');
1579
+ process.exit(1);
1580
+ }
1581
+ if (maxMessagesRaw && (!Number.isInteger(maxMessages) || Number(maxMessages) <= 0)) {
1582
+ console.error('--max-messages ต้องเป็น integer มากกว่า 0');
1583
+ process.exit(1);
1584
+ }
1585
+ if (maxBytesRaw && (!Number.isInteger(maxBytes) || Number(maxBytes) <= 0)) {
1586
+ console.error('--max-bytes ต้องเป็น integer มากกว่า 0');
1587
+ process.exit(1);
1588
+ }
1589
+ const { patchGatewayConfig, gatewayConfigPath } = await import('./gateway/config.js');
1590
+ await patchGatewayConfig({
1591
+ googleChat: {
1592
+ enabled: true,
1593
+ projectId: projectId?.trim() || undefined,
1594
+ subscriptionName: subscriptionName?.trim() || undefined,
1595
+ serviceAccountJson: cleanServiceAccountJson || undefined,
1596
+ apiBaseUrl: cleanApiBaseUrl,
1597
+ incomingWebhookUrl: cleanWebhookUrl,
1598
+ homeChannel: cleanHomeChannel || (cleanWebhookUrl ? 'webhook' : undefined),
1599
+ homeChannelName: homeChannelName?.trim() || undefined,
1600
+ allowedUsers: parseStringCsv(allowedUsersRaw),
1601
+ allowedSpaces: parseStringCsv(allowedSpacesRaw),
1602
+ freeResponseSpaces: parseStringCsv(freeResponseSpacesRaw),
1603
+ allowAllUsers,
1604
+ allowAllSpaces,
1605
+ maxMessages,
1606
+ maxBytes,
1607
+ },
1608
+ });
1609
+ console.log(`บันทึก Google Chat gateway config แล้ว: ${gatewayConfigPath()}`);
1610
+ console.log(cleanWebhookUrl ? 'Google Chat delivery mode: incoming webhook' : 'Google Chat delivery mode: Chat REST API');
1611
+ console.log(`ส่งทดสอบได้ด้วย: ${BRAND.cliName} send --to googlechat "hello"${cleanHomeChannel ? '' : ` หรือ ${BRAND.cliName} send --to googlechat:spaces/<space> "hello"`}`);
1612
+ }
1613
+ async function runBlueBubblesGatewaySetup(args) {
1614
+ let serverUrl = argValue(args, '--server-url', '--url');
1615
+ let password = argValue(args, '--password', '--token', '--guid');
1616
+ const webhookHost = argValue(args, '--webhook-host');
1617
+ const webhookPortRaw = argValue(args, '--webhook-port');
1618
+ const webhookPath = argValue(args, '--webhook-path');
1619
+ let homeChannel = argValue(args, '--home-channel', '--chat-guid', '--to');
1620
+ const homeChannelName = argValue(args, '--home-channel-name');
1621
+ let allowedUsersRaw = argValue(args, '--allowed-users', '--allowed-targets');
1622
+ const allowAllUsers = args.includes('--allow-all-users');
1623
+ const requireMention = args.includes('--require-mention');
1624
+ const mentionPatternsRaw = argValue(args, '--mention-patterns');
1625
+ const sendReadReceipts = !args.includes('--no-read-receipts');
1626
+ if (!serverUrl || !password || (!homeChannel && !allowedUsersRaw && !allowAllUsers)) {
1627
+ if (!process.stdin.isTTY) {
1628
+ console.error(`ใช้: ${BRAND.cliName} gateway setup bluebubbles --server-url <http://mac:1234> --password <server-password> --home-channel <chat-guid|email|phone>`);
1629
+ process.exit(1);
1630
+ }
1631
+ console.log(`${BRAND.productName} BlueBubbles/iMessage setup`);
1632
+ console.log('ต้องมี BlueBubbles Server URL + server password; outbound ใช้ REST API /api/v1/message/text');
1633
+ serverUrl ||= await askText('BlueBubbles server URL (เช่น http://localhost:1234): ');
1634
+ password ||= await askText('BlueBubbles server password: ');
1635
+ homeChannel ||= await askText('Home chat GUID/email/phone (blank = explicit targets only): ');
1636
+ }
1637
+ if (!allowedUsersRaw && homeChannel && !allowAllUsers)
1638
+ allowedUsersRaw = homeChannel;
1639
+ const { normalizeBlueBubblesServerUrl, normalizeBlueBubblesWebhookPath } = await import('./gateway/bluebubbles.js');
1640
+ const cleanServerUrl = normalizeBlueBubblesServerUrl(serverUrl);
1641
+ const cleanPassword = password?.trim();
1642
+ const cleanHomeChannel = homeChannel?.trim();
1643
+ const webhookPort = webhookPortRaw ? Number(webhookPortRaw) : undefined;
1644
+ if (!cleanServerUrl) {
1645
+ console.error('BlueBubbles server URL ต้องเป็น http:// หรือ https:// URL');
1646
+ process.exit(1);
1647
+ }
1648
+ if (!cleanPassword) {
1649
+ console.error('BlueBubbles setup ต้องมี server password');
1650
+ process.exit(1);
1651
+ }
1652
+ if (webhookPortRaw && (!Number.isInteger(webhookPort) || Number(webhookPort) <= 0 || Number(webhookPort) > 65535)) {
1653
+ console.error('--webhook-port ต้องเป็น port 1-65535');
1654
+ process.exit(1);
1655
+ }
1656
+ const allowedUsers = parseStringCsv(allowedUsersRaw);
1657
+ if (!allowAllUsers && !cleanHomeChannel && !allowedUsers.length) {
1658
+ console.error('BlueBubbles setup ต้องมี home channel/allowlist อย่างน้อย 1 ค่า หรือระบุ --allow-all-users');
1659
+ process.exit(1);
1660
+ }
1661
+ const { patchGatewayConfig, gatewayConfigPath } = await import('./gateway/config.js');
1662
+ await patchGatewayConfig({
1663
+ bluebubbles: {
1664
+ enabled: true,
1665
+ serverUrl: cleanServerUrl,
1666
+ password: cleanPassword,
1667
+ webhookHost: webhookHost?.trim() || undefined,
1668
+ webhookPort,
1669
+ webhookPath: normalizeBlueBubblesWebhookPath(webhookPath),
1670
+ homeChannel: cleanHomeChannel || allowedUsers[0],
1671
+ homeChannelName: homeChannelName?.trim() || undefined,
1672
+ allowedUsers,
1673
+ allowAllUsers,
1674
+ requireMention,
1675
+ mentionPatterns: parseStringCsv(mentionPatternsRaw),
1676
+ sendReadReceipts,
1677
+ },
1678
+ });
1679
+ console.log(`บันทึก BlueBubbles gateway config แล้ว: ${gatewayConfigPath()}`);
1680
+ console.log(`BlueBubbles REST: ${cleanServerUrl}/api/v1/message/text`);
1681
+ console.log(`ส่งทดสอบได้ด้วย: ${BRAND.cliName} send --to bluebubbles "hello"${cleanHomeChannel ? '' : ` หรือ ${BRAND.cliName} send --to bluebubbles:<chat-guid|email|phone> "hello"`}`);
1682
+ }
1683
+ async function runTeamsGatewaySetup(args) {
1684
+ let incomingWebhookUrl = argValue(args, '--incoming-webhook-url', '--webhook-url', '--url');
1685
+ const graphAccessToken = argValue(args, '--graph-access-token', '--access-token', '--token');
1686
+ const teamId = argValue(args, '--team-id');
1687
+ const channelId = argValue(args, '--channel-id');
1688
+ const chatId = argValue(args, '--chat-id');
1689
+ let homeChannel = argValue(args, '--home-channel', '--to');
1690
+ const homeChannelName = argValue(args, '--home-channel-name');
1691
+ const clientId = argValue(args, '--client-id');
1692
+ const clientSecret = argValue(args, '--client-secret');
1693
+ const tenantId = argValue(args, '--tenant-id');
1694
+ const allowedUsersRaw = argValue(args, '--allowed-users');
1695
+ const allowAllUsers = args.includes('--allow-all-users');
1696
+ const portRaw = argValue(args, '--port');
1697
+ const rawMode = argValue(args, '--delivery-mode', '--mode');
1698
+ const deliveryMode = rawMode === 'graph' || (!rawMode && graphAccessToken) ? 'graph' : 'incoming_webhook';
1699
+ if (deliveryMode === 'incoming_webhook' && !incomingWebhookUrl) {
1700
+ if (!process.stdin.isTTY) {
1701
+ console.error(`ใช้: ${BRAND.cliName} gateway setup teams --incoming-webhook-url <https://...>`);
1702
+ process.exit(1);
1703
+ }
1704
+ console.log(`${BRAND.productName} Microsoft Teams setup`);
1705
+ console.log('โหมดง่าย: สร้าง Incoming Webhook ใน Teams channel แล้ววาง URL ที่นี่');
1706
+ incomingWebhookUrl = await askText('Teams incoming webhook URL: ');
1707
+ homeChannel ||= (await askText('Teams home target label (blank = webhook): ')) || 'webhook';
1708
+ }
1709
+ if (deliveryMode === 'graph' && (!graphAccessToken || (!chatId && !homeChannel && (!teamId || !channelId)))) {
1710
+ if (!process.stdin.isTTY) {
1711
+ console.error(`ใช้: ${BRAND.cliName} gateway setup teams --delivery-mode graph --graph-access-token <token> (--chat-id <id> หรือ --team-id <id> --channel-id <id>)`);
1712
+ process.exit(1);
1713
+ }
1714
+ console.log(`${BRAND.productName} Microsoft Teams Graph setup`);
1715
+ console.log('ต้องมี Microsoft Graph token และ chat id หรือ team/channel id สำหรับ proactive delivery');
1716
+ }
1717
+ const { normalizeTeamsWebhookUrl } = await import('./gateway/teams.js');
1718
+ const cleanWebhookUrl = normalizeTeamsWebhookUrl(incomingWebhookUrl);
1719
+ const cleanPort = portRaw ? Number(portRaw) : undefined;
1720
+ if (deliveryMode === 'incoming_webhook' && !cleanWebhookUrl) {
1721
+ console.error('Microsoft Teams incoming webhook URL ต้องเป็น https:// URL');
1722
+ process.exit(1);
1723
+ }
1724
+ if (deliveryMode === 'graph' && !graphAccessToken?.trim()) {
1725
+ console.error('Microsoft Teams Graph mode ต้องมี --graph-access-token');
1726
+ process.exit(1);
1727
+ }
1728
+ if (deliveryMode === 'graph' && !chatId?.trim() && !homeChannel?.trim() && (!teamId?.trim() || !channelId?.trim())) {
1729
+ console.error('Microsoft Teams Graph mode ต้องมี --chat-id หรือ --team-id + --channel-id');
1730
+ process.exit(1);
1731
+ }
1732
+ if (portRaw && (!Number.isInteger(cleanPort) || Number(cleanPort) <= 0)) {
1733
+ console.error('--port ต้องเป็น integer มากกว่า 0');
1734
+ process.exit(1);
1735
+ }
1736
+ const { patchGatewayConfig, gatewayConfigPath } = await import('./gateway/config.js');
1737
+ const graphHome = homeChannel?.trim() || chatId?.trim() || (teamId?.trim() && channelId?.trim() ? `team/${teamId.trim()}/channel/${channelId.trim()}` : undefined);
1738
+ await patchGatewayConfig({
1739
+ teams: {
1740
+ enabled: true,
1741
+ deliveryMode,
1742
+ incomingWebhookUrl: cleanWebhookUrl,
1743
+ graphAccessToken: graphAccessToken?.trim() || undefined,
1744
+ teamId: teamId?.trim() || undefined,
1745
+ channelId: channelId?.trim() || undefined,
1746
+ chatId: chatId?.trim() || undefined,
1747
+ homeChannel: graphHome || (cleanWebhookUrl ? 'webhook' : undefined),
1748
+ homeChannelName: homeChannelName?.trim() || undefined,
1749
+ clientId: clientId?.trim() || undefined,
1750
+ clientSecret: clientSecret?.trim() || undefined,
1751
+ tenantId: tenantId?.trim() || undefined,
1752
+ allowedUsers: parseStringCsv(allowedUsersRaw),
1753
+ allowAllUsers,
1754
+ port: cleanPort,
1755
+ },
1756
+ });
1757
+ console.log(`บันทึก Microsoft Teams gateway config แล้ว: ${gatewayConfigPath()}`);
1758
+ console.log(`Teams delivery mode: ${deliveryMode}`);
1759
+ if (deliveryMode === 'incoming_webhook')
1760
+ console.log('ส่งผ่าน Incoming Webhook ที่ตั้งไว้');
1761
+ else
1762
+ console.log(`Graph target: ${graphHome}`);
1763
+ console.log(`ส่งทดสอบได้ด้วย: ${BRAND.cliName} send --to teams "hello"`);
1764
+ }
1765
+ async function runNtfyGatewaySetup(args) {
1766
+ let topic = argValue(args, '--topic');
1767
+ const serverUrl = argValue(args, '--server-url') ?? argValue(args, '--url');
1768
+ const token = argValue(args, '--token');
1769
+ const publishTopic = argValue(args, '--publish-topic');
1770
+ let homeChannel = argValue(args, '--home-channel', '--to');
1771
+ let allowedRaw = argValue(args, '--allowed-users', '--allowed-topics');
1772
+ const homeChannelName = argValue(args, '--home-channel-name');
1773
+ const allowAllUsers = args.includes('--allow-all-users');
1774
+ const markdown = args.includes('--markdown');
1775
+ if (!topic) {
1776
+ if (!process.stdin.isTTY) {
1777
+ console.error(`ใช้: ${BRAND.cliName} gateway setup ntfy --topic <topic> [--token <tk_...|user:pass>]`);
1778
+ process.exit(1);
1779
+ }
1780
+ console.log(`${BRAND.productName} ntfy setup`);
1781
+ console.log('เลือก topic ยาว/เดายาก แล้ว subscribe topic นี้ใน ntfy mobile app หรือ self-hosted ntfy');
1782
+ topic = await askText('ntfy topic: ');
1783
+ }
1784
+ const cleanTopic = topic.trim();
1785
+ if (!cleanTopic) {
1786
+ console.error('ntfy setup ต้องมี topic');
1787
+ process.exit(1);
1788
+ }
1789
+ if (!homeChannel)
1790
+ homeChannel = cleanTopic;
1791
+ if (!allowedRaw && !allowAllUsers)
1792
+ allowedRaw = cleanTopic;
1793
+ const allowedUsers = parseStringCsv(allowedRaw);
1794
+ if (!allowAllUsers && !allowedUsers.includes(cleanTopic) && homeChannel !== cleanTopic) {
1795
+ console.error('ntfy setup ต้องมี topic ใน --allowed-users หรือใช้ --allow-all-users เพื่อรับ inbound');
1796
+ process.exit(1);
1797
+ }
1798
+ const { patchGatewayConfig, gatewayConfigPath } = await import('./gateway/config.js');
1799
+ await patchGatewayConfig({
1800
+ ntfy: {
1801
+ enabled: true,
1802
+ serverUrl: serverUrl?.trim() || undefined,
1803
+ topic: cleanTopic,
1804
+ publishTopic: publishTopic?.trim() || undefined,
1805
+ token: token?.trim() || undefined,
1806
+ homeChannel: homeChannel?.trim() || cleanTopic,
1807
+ homeChannelName: homeChannelName?.trim() || undefined,
1808
+ allowedUsers,
1809
+ allowAllUsers,
1810
+ markdown,
1811
+ },
1812
+ });
1813
+ console.log(`บันทึก ntfy gateway config แล้ว: ${gatewayConfigPath()}`);
1814
+ console.log(`subscribe topic ในแอป ntfy: ${cleanTopic}`);
1815
+ console.log(`ส่งทดสอบได้ด้วย: ${BRAND.cliName} send --to ntfy "hello"`);
1816
+ }
1817
+ async function runWebhookGatewaySetup(args) {
1818
+ let secret = argValue(args, '--secret', '--webhook-secret');
1819
+ const publicUrl = argValue(args, '--public-url');
1820
+ const rateLimitRaw = argValue(args, '--rate-limit', '--rate-limit-per-minute');
1821
+ const insecureNoAuth = args.includes('--insecure-no-auth');
1822
+ if (!secret && !insecureNoAuth) {
1823
+ if (process.stdin.isTTY) {
1824
+ const { generateWebhookSecret } = await import('./gateway/webhooks.js');
1825
+ console.log(`${BRAND.productName} Webhooks setup`);
1826
+ console.log('ตั้ง global HMAC secret สำหรับ route ที่ไม่ได้ระบุ secret เอง');
1827
+ secret = (await askText('Webhook global secret (blank = auto-generate): ')) || generateWebhookSecret();
1828
+ }
1829
+ else {
1830
+ const { generateWebhookSecret } = await import('./gateway/webhooks.js');
1831
+ secret = generateWebhookSecret();
1832
+ }
1833
+ }
1834
+ const rateLimitPerMinute = rateLimitRaw ? parsePort(rateLimitRaw, 30, 'webhook rate limit') : undefined;
1835
+ const { patchGatewayConfig, gatewayConfigPath } = await import('./gateway/config.js');
1836
+ await patchGatewayConfig({
1837
+ webhooks: {
1838
+ enabled: true,
1839
+ secret: insecureNoAuth ? 'INSECURE_NO_AUTH' : secret?.trim(),
1840
+ publicUrl: publicUrl?.trim() || undefined,
1841
+ rateLimitPerMinute,
1842
+ },
1843
+ });
1844
+ console.log(`บันทึก Webhooks gateway config แล้ว: ${gatewayConfigPath()}`);
1845
+ console.log(`เพิ่ม route ได้ด้วย: ${BRAND.cliName} webhook subscribe github-issues --events issues --prompt "New issue: {issue.title}" --to telegram`);
1846
+ }
1847
+ function parsePort(raw, fallback, label) {
1848
+ if (!raw)
1849
+ return fallback;
1850
+ const n = Number(raw);
1851
+ if (!Number.isInteger(n) || n < 1 || n > 65535) {
1852
+ console.error(`${label} ต้องเป็น port 1-65535`);
1853
+ process.exit(1);
1854
+ }
1855
+ return n;
1856
+ }
1857
+ async function runEmailGatewaySetup(args) {
1858
+ let address = argValue(args, '--address', '--email');
1859
+ let password = argValue(args, '--password', '--app-password');
1860
+ let imapHost = argValue(args, '--imap-host');
1861
+ let smtpHost = argValue(args, '--smtp-host');
1862
+ let homeAddress = argValue(args, '--home-address', '--to');
1863
+ let allowedRaw = argValue(args, '--allowed-users', '--allowed-senders');
1864
+ const imapPort = parsePort(argValue(args, '--imap-port'), 993, 'imap port');
1865
+ const smtpPort = parsePort(argValue(args, '--smtp-port'), 587, 'smtp port');
1866
+ const pollIntervalSeconds = parsePort(argValue(args, '--poll-interval'), 15, 'poll interval');
1867
+ const allowAllUsers = args.includes('--allow-all-users');
1868
+ if (!address) {
1869
+ if (!process.stdin.isTTY) {
1870
+ 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`);
1871
+ process.exit(1);
1872
+ }
1873
+ console.log(`${BRAND.productName} Email setup`);
1874
+ console.log('แนะนำให้ใช้ dedicated mailbox + app password ไม่ใช่บัญชีส่วนตัวหลัก');
1875
+ address = await askText('Email address ของ bot: ');
1876
+ }
1877
+ if (!password) {
1878
+ if (!process.stdin.isTTY) {
1879
+ console.error('ต้องระบุ --password <app-password>');
1880
+ process.exit(1);
1881
+ }
1882
+ password = await askText('Email app password: ');
1883
+ }
1884
+ if (!imapHost) {
1885
+ if (!process.stdin.isTTY) {
1886
+ console.error('ต้องระบุ --imap-host <host>');
1887
+ process.exit(1);
1888
+ }
1889
+ imapHost = await askText('IMAP host (เช่น imap.gmail.com): ');
1890
+ }
1891
+ if (!smtpHost) {
1892
+ if (!process.stdin.isTTY) {
1893
+ console.error('ต้องระบุ --smtp-host <host>');
1894
+ process.exit(1);
1895
+ }
1896
+ smtpHost = await askText('SMTP host (เช่น smtp.gmail.com): ');
1897
+ }
1898
+ if (!homeAddress && !allowedRaw && !allowAllUsers) {
1899
+ if (!process.stdin.isTTY) {
1900
+ console.error('ต้องระบุ --home-address <email> หรือ --allowed-users <email[,email]> เพื่อ fail-closed');
1901
+ process.exit(1);
1902
+ }
1903
+ homeAddress = await askText('Home/allowed email address: ');
1904
+ }
1905
+ const allowedUsers = parseStringCsv(allowedRaw ?? homeAddress).map((s) => s.toLowerCase());
1906
+ if (!allowAllUsers && !allowedUsers.length) {
1907
+ console.error('Email setup ต้องมี allowed sender/home address อย่างน้อย 1 ค่า หรือระบุ --allow-all-users');
1908
+ process.exit(1);
1909
+ }
1910
+ const { patchGatewayConfig, gatewayConfigPath } = await import('./gateway/config.js');
1911
+ await patchGatewayConfig({
1912
+ email: {
1913
+ enabled: true,
1914
+ address: address.trim(),
1915
+ password: password.trim(),
1916
+ imapHost: imapHost.trim(),
1917
+ imapPort,
1918
+ smtpHost: smtpHost.trim(),
1919
+ smtpPort,
1920
+ homeAddress: homeAddress?.trim() || allowedUsers[0],
1921
+ allowedUsers,
1922
+ allowAllUsers,
1923
+ pollIntervalSeconds,
1924
+ },
1925
+ });
1926
+ console.log(`บันทึก Email gateway config แล้ว: ${gatewayConfigPath()}`);
1927
+ console.log(`ส่งทดสอบได้ด้วย: ${BRAND.cliName} send --to email:${homeAddress?.trim() || allowedUsers[0]} "hello"`);
1928
+ }
1929
+ async function runGateway(args) {
1930
+ const [action, ...rest] = args;
1931
+ if (!action || action === 'status' || action === 'list')
1932
+ return runGatewayStatus();
1933
+ if (action === 'doctor')
1934
+ return runGatewayDoctor();
1935
+ if (action === 'setup')
1936
+ return runGatewaySetup(rest);
1937
+ if (action === 'run') {
1938
+ if (!hasServeCommandRequest(['serve', ...rest])) {
1939
+ console.error(`ไม่รู้จัก: gateway run ${rest.join(' ')} — ใช้ gateway run [--port N] [--model spec]`);
1940
+ process.exit(1);
1941
+ }
1942
+ return runServe(rest);
1943
+ }
1944
+ if (action === 'start') {
1945
+ const { startGatewayService } = await import('./gateway/service.js');
1946
+ const res = await startGatewayService({ entrypoint: resolve(process.argv[1]), gatewayArgs: rest });
1947
+ console.log(res.started
1948
+ ? `เริ่ม ${BRAND.cliName} gateway background แล้ว (pid ${res.state.pid})`
1949
+ : `${BRAND.cliName} gateway รันอยู่แล้ว (pid ${res.state.pid})`);
1950
+ console.log(`log: ${res.state.logPath}`);
1951
+ return;
1952
+ }
1953
+ if (action === 'stop') {
1954
+ const { stopGatewayService } = await import('./gateway/service.js');
1955
+ const res = await stopGatewayService();
1956
+ console.log(res.state ? (res.stopped ? `หยุด gateway pid ${res.state.pid} แล้ว` : `gateway ไม่ได้รันอยู่ (last pid ${res.state.pid})`) : 'ยังไม่มี gateway service state');
1957
+ return;
1958
+ }
1959
+ if (action === 'restart') {
1960
+ const { startGatewayService, stopGatewayService } = await import('./gateway/service.js');
1961
+ await stopGatewayService();
1962
+ const res = await startGatewayService({ entrypoint: resolve(process.argv[1]), gatewayArgs: rest });
1963
+ console.log(`restart gateway แล้ว (pid ${res.state.pid})`);
1964
+ console.log(`log: ${res.state.logPath}`);
1965
+ return;
1966
+ }
1967
+ if (action === 'install') {
1968
+ const { installGatewayService } = await import('./gateway/service.js');
1969
+ const res = await installGatewayService(resolve(process.argv[1]));
1970
+ console.log(`ติดตั้ง service file แล้ว (${res.kind}): ${res.path}`);
1971
+ console.log('เริ่ม service ด้วย:');
1972
+ for (const line of res.instructions)
1973
+ console.log(` ${line}`);
1974
+ return;
1975
+ }
1976
+ if (action === 'uninstall' || action === 'remove-service') {
1977
+ const { uninstallGatewayService } = await import('./gateway/service.js');
1978
+ const removed = await uninstallGatewayService();
1979
+ console.log(removed.length ? `ลบ service files:\n${removed.map((p) => ` ${p}`).join('\n')}` : 'ไม่พบ service file ที่ต้องลบ');
1980
+ return;
1981
+ }
1982
+ console.error(`ไม่รู้จัก: gateway ${action} — ใช้ setup / run / start / stop / restart / install / doctor / status`);
1983
+ process.exit(1);
1984
+ }
1985
+ async function runStatus() {
1986
+ const cfg = await loadConfig({});
1987
+ const parsed = parseSpec(cfg.model);
1988
+ const provider = PROVIDERS[parsed.provider];
1989
+ const keyReady = provider ? (!provider.requiresKey || Boolean(resolveKeyFromEnv(provider.envVar, provider.envFallbacks))) : false;
1990
+ const { readGatewayConfig, resolveBlueBubblesConfig, resolveDiscordConfig, resolveEmailConfig, resolveGoogleChatConfig, resolveHomeAssistantConfig, resolveLineConfig, resolveMattermostConfig, resolveMatrixConfig, resolveNtfyConfig, resolveSignalConfig, resolveSlackConfig, resolveSmsConfig, resolveTelegramConfig, resolveTeamsConfig, resolveWhatsAppConfig, resolveWebhookConfig, } = await import('./gateway/config.js');
1991
+ const gatewayConfig = await readGatewayConfig();
1992
+ const telegram = resolveTelegramConfig(gatewayConfig);
1993
+ const discord = resolveDiscordConfig(gatewayConfig);
1994
+ const slack = resolveSlackConfig(gatewayConfig);
1995
+ const email = resolveEmailConfig(gatewayConfig);
1996
+ const homeassistant = resolveHomeAssistantConfig(gatewayConfig);
1997
+ const line = resolveLineConfig(gatewayConfig);
1998
+ const mattermost = resolveMattermostConfig(gatewayConfig);
1999
+ const sms = resolveSmsConfig(gatewayConfig);
2000
+ const ntfy = resolveNtfyConfig(gatewayConfig);
2001
+ const signal = resolveSignalConfig(gatewayConfig);
2002
+ const whatsapp = resolveWhatsAppConfig(gatewayConfig);
2003
+ const matrix = resolveMatrixConfig(gatewayConfig);
2004
+ const googleChat = resolveGoogleChatConfig(gatewayConfig);
2005
+ const bluebubbles = resolveBlueBubblesConfig(gatewayConfig);
2006
+ const teams = resolveTeamsConfig(gatewayConfig);
2007
+ const webhooks = resolveWebhookConfig(gatewayConfig);
2008
+ console.log(`${BRAND.productName} status`);
2009
+ console.log(` version: ${VERSION}`);
2010
+ console.log(` model: ${cfg.model}`);
2011
+ console.log(` provider: ${provider?.label ?? parsed.provider}`);
2012
+ console.log(` personality:${cfg.personality ? ` ${cfg.personality}` : ' none'}`);
2013
+ console.log(` key: ${keyReady ? 'ready' : provider?.requiresKey ? `missing (${provider.envVar})` : 'not required'}`);
2014
+ console.log(` brain: ${cfg.brainPath ?? '(not configured)'}`);
2015
+ console.log(' gateway: HTTP loopback + cron available');
2016
+ console.log(` telegram: ${telegram.token ? `configured (${telegram.allowedChatIds.length} allowed chat${telegram.allowedChatIds.length === 1 ? '' : 's'})` : 'not configured'}`);
2017
+ console.log(` discord: ${discord.token ? `configured (${discord.allowedChannelIds.length} allowed channel${discord.allowedChannelIds.length === 1 ? '' : 's'})` : 'not configured'}`);
2018
+ console.log(` slack: ${slack.botToken ? `configured (${slack.allowedChannelIds.length} allowed channel${slack.allowedChannelIds.length === 1 ? '' : 's'})` : 'not configured'}`);
2019
+ 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'}`);
2020
+ 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'}`);
2021
+ console.log(` email: ${email.address ? `configured (${email.allowedUsers.length} allowed sender${email.allowedUsers.length === 1 ? '' : 's'})` : 'not configured'}`);
2022
+ 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'}`);
2023
+ console.log(` sms: ${sms.accountSid && sms.authToken && sms.phoneNumber ? `configured (${sms.allowedUsers.length} allowed sender${sms.allowedUsers.length === 1 ? '' : 's'})` : 'not configured'}`);
2024
+ console.log(` ntfy: ${ntfy.topic ? `configured (${ntfy.allowedUsers.length} allowed topic${ntfy.allowedUsers.length === 1 ? '' : 's'})` : 'not configured'}`);
2025
+ 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'}`);
2026
+ console.log(` whatsapp: ${whatsapp.phoneNumberId && whatsapp.accessToken ? `configured (${whatsapp.allowedUsers.length} allowed user${whatsapp.allowedUsers.length === 1 ? '' : 's'})` : 'not configured'}`);
2027
+ 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'}`);
2028
+ console.log(` googlechat:${googleChat.serviceAccountJson || googleChat.incomingWebhookUrl ? ` configured (${googleChat.serviceAccountJson ? 'chat api' : 'webhook'})` : ' not configured'}`);
2029
+ console.log(` bluebubbles:${bluebubbles.serverUrl && bluebubbles.password ? ` configured (${bluebubbles.allowedUsers.length} allowed target${bluebubbles.allowedUsers.length === 1 ? '' : 's'})` : ' not configured'}`);
2030
+ console.log(` teams: ${teams.incomingWebhookUrl || teams.graphAccessToken ? `configured (${teams.deliveryMode})` : 'not configured'}`);
2031
+ console.log(` webhooks: ${webhooks.enabled ? `enabled (${Object.keys(webhooks.routes).length} route${Object.keys(webhooks.routes).length === 1 ? '' : 's'})` : 'not enabled'}`);
2032
+ console.log(` config: ${appHomePath('config.json')}`);
2033
+ }
2034
+ function compactText(raw, max = 120) {
2035
+ const text = redactKey(raw).replace(/\s+/g, ' ').trim();
2036
+ return text.length > max ? `${text.slice(0, max - 1).trimEnd()}…` : text;
2037
+ }
2038
+ function messageContentText(content) {
2039
+ if (typeof content === 'string')
2040
+ return content;
2041
+ if (!Array.isArray(content))
2042
+ return '';
2043
+ return content
2044
+ .map((part) => {
2045
+ if (typeof part === 'string')
2046
+ return part;
2047
+ if (part && typeof part === 'object') {
2048
+ const record = part;
2049
+ if (typeof record.text === 'string')
2050
+ return record.text;
2051
+ if (typeof record.type === 'string')
2052
+ return `[${record.type}]`;
2053
+ }
2054
+ return '';
2055
+ })
2056
+ .filter(Boolean)
2057
+ .join(' ');
2058
+ }
2059
+ function sessionPreview(session) {
2060
+ if (session.title)
2061
+ return compactText(session.title);
2062
+ const firstUser = session.messages.find((m) => m.role === 'user') ?? session.messages[0];
2063
+ return firstUser ? compactText(messageContentText(firstUser.content)) : '';
2064
+ }
2065
+ function sessionUsage() {
2066
+ return `ใช้:
2067
+ ${BRAND.cliName} sessions [list] [--all] [--limit N]
2068
+ ${BRAND.cliName} sessions latest [--all]
2069
+ ${BRAND.cliName} sessions show <id>
2070
+ ${BRAND.cliName} sessions export <id> [--format json|markdown] [--output path]
2071
+ ${BRAND.cliName} sessions rename <id> <title>
2072
+ ${BRAND.cliName} sessions stats [--all]
2073
+ ${BRAND.cliName} sessions prune --keep N [--all] [--yes]
2074
+ ${BRAND.cliName} sessions rm <id>
2075
+
2076
+ resume:
2077
+ ${BRAND.cliName} --resume <id> "<task>"
2078
+ ${BRAND.cliName} -r <id> "<task>"`;
2079
+ }
2080
+ function parseLimit(args, fallback) {
2081
+ const provided = args.some((a) => a === '--limit' || a === '-n' || a.startsWith('--limit=') || a.startsWith('-n='));
2082
+ const raw = argValue(args, '--limit', '-n');
2083
+ if (!provided)
2084
+ return fallback;
2085
+ const n = Number(raw);
2086
+ if (!raw || !Number.isInteger(n) || n <= 0) {
2087
+ console.error('--limit ต้องเป็น integer บวก');
2088
+ process.exit(2);
2089
+ }
2090
+ return n;
2091
+ }
2092
+ function printSessionDetails(session) {
2093
+ const users = session.messages.filter((m) => m.role === 'user');
2094
+ const assistants = session.messages.filter((m) => m.role === 'assistant');
2095
+ const lastUser = users[users.length - 1];
2096
+ const lastAssistant = assistants[assistants.length - 1];
2097
+ console.log(`${BRAND.productName} session ${session.id}`);
2098
+ if (session.title)
2099
+ console.log(` title: ${redactKey(session.title)}`);
2100
+ console.log(` model: ${session.model}`);
2101
+ console.log(` cwd: ${session.cwd}`);
2102
+ console.log(` created: ${session.created}`);
2103
+ console.log(` updated: ${session.updated}`);
2104
+ console.log(` messages: ${session.messages.length} (${users.length} user, ${assistants.length} assistant)`);
2105
+ console.log(` preview: ${sessionPreview(session) || '(empty)'}`);
2106
+ if (lastUser)
2107
+ console.log(` last user: ${compactText(messageContentText(lastUser.content)) || '(empty)'}`);
2108
+ if (lastAssistant)
2109
+ console.log(` last ai: ${compactText(messageContentText(lastAssistant.content)) || '(empty)'}`);
2110
+ }
2111
+ function sessionToMarkdown(session) {
2112
+ const safe = sanitizeSessionForExport(session);
2113
+ const lines = [
2114
+ `# ${safe.title ? redactKey(safe.title) : `Session ${safe.id}`}`,
2115
+ '',
2116
+ `- id: ${safe.id}`,
2117
+ `- model: ${safe.model}`,
2118
+ `- cwd: ${safe.cwd}`,
2119
+ `- created: ${safe.created}`,
2120
+ `- updated: ${safe.updated}`,
2121
+ '',
2122
+ ];
2123
+ for (const [i, msg] of safe.messages.entries()) {
2124
+ const role = msg.role ?? 'message';
2125
+ const text = compactText(messageContentText(msg.content), 20_000);
2126
+ lines.push(`## ${i + 1}. ${role}`, '', text || '(empty)', '');
2127
+ }
2128
+ return `${lines.join('\n').trimEnd()}\n`;
2129
+ }
2130
+ function parseDateFlag(args, ...names) {
2131
+ const raw = argValue(args, ...names);
2132
+ if (!raw)
2133
+ return undefined;
2134
+ const d = new Date(raw);
2135
+ if (!Number.isFinite(d.getTime())) {
2136
+ console.error(`วันที่ไม่ถูกต้อง: ${raw}`);
2137
+ process.exit(2);
2138
+ }
2139
+ return d;
2140
+ }
2141
+ async function loadSessionOrExit(id) {
2142
+ const session = await loadSession(id);
2143
+ if (!session) {
2144
+ console.error(`ไม่เจอ session ${id}`);
2145
+ process.exit(1);
2146
+ }
2147
+ return session;
2148
+ }
2149
+ async function requestedResumeSession(rawArgs, resumeId) {
2150
+ const requested = hasResumeRequest(rawArgs);
2151
+ if (!requested)
2152
+ return null;
2153
+ if (!resumeId) {
2154
+ console.error(`ใช้: ${BRAND.cliName} --resume <session_id> "<task>"`);
2155
+ process.exit(2);
2156
+ }
2157
+ return loadSessionOrExit(resumeId);
2158
+ }
2159
+ async function requestedContinuationHistory(rawArgs) {
2160
+ if (!hasContinueRequest(rawArgs))
2161
+ return undefined;
2162
+ return (await latestSession(hasContinueAnyRequest(rawArgs) ? null : process.cwd()))?.messages;
2163
+ }
2164
+ function printSessionStats(sessions, scope) {
2165
+ const byModel = new Map();
2166
+ let messages = 0;
2167
+ for (const s of sessions) {
2168
+ byModel.set(s.model, (byModel.get(s.model) ?? 0) + 1);
2169
+ messages += s.messages.length;
2170
+ }
2171
+ console.log(`${BRAND.productName} session stats (${scope})`);
2172
+ console.log(` sessions: ${sessions.length}`);
2173
+ console.log(` messages: ${messages}`);
2174
+ if (sessions[0])
2175
+ console.log(` latest: ${sessions[0].id} (${sessions[0].updated})`);
2176
+ if (sessions[sessions.length - 1])
2177
+ console.log(` oldest: ${sessions[sessions.length - 1].id} (${sessions[sessions.length - 1].updated})`);
2178
+ console.log(' models:');
2179
+ for (const [model, count] of [...byModel.entries()].sort((a, b) => b[1] - a[1])) {
2180
+ console.log(` ${model}: ${count}`);
2181
+ }
2182
+ }
2183
+ async function runSessions(args) {
2184
+ if (args.includes('-h') || args.includes('--help') || args[0] === 'help') {
2185
+ console.log(sessionUsage());
2186
+ return;
2187
+ }
2188
+ const action = args[0] && !args[0].startsWith('-') ? args[0] : 'list';
2189
+ const rest = action === 'list' && (args[0]?.startsWith('-') || args[0] === undefined) ? args : args.slice(1);
2190
+ const all = rest.includes('--all') || rest.includes('-a');
2191
+ const cwd = all ? null : process.cwd();
2192
+ if (action === 'list' || action === 'ls') {
2193
+ const sessions = await listSessions({ cwd, limit: parseLimit(rest, 20) });
2194
+ if (!sessions.length) {
2195
+ console.log(`ยังไม่มี saved sessions${all ? '' : ' สำหรับ project นี้'} — store: ${sessionStorePath()}`);
2196
+ return;
2197
+ }
2198
+ console.log(`${BRAND.productName} sessions (${all ? 'all projects' : 'current project'})`);
2199
+ for (const s of sessions) {
2200
+ const cwdSuffix = all ? ` ${s.cwd}` : '';
2201
+ console.log(`${s.id} ${s.updated} ${s.model} ${s.messages.length} msg ${sessionPreview(s)}${cwdSuffix}`);
2202
+ }
2203
+ console.log(`\nstore: ${sessionStorePath()}`);
2204
+ return;
2205
+ }
2206
+ if (action === 'latest') {
2207
+ const session = (await listSessions({ cwd, limit: 1 }))[0];
2208
+ if (!session) {
2209
+ console.log(`ไม่เจอ session${all ? '' : ' สำหรับ project นี้'}`);
2210
+ return;
2211
+ }
2212
+ printSessionDetails(session);
2213
+ return;
2214
+ }
2215
+ if (action === 'show' || action === 'cat') {
2216
+ const id = positionalArgs(rest, ['--limit', '-n'])[0];
2217
+ if (!id) {
2218
+ console.error(`ใช้: ${BRAND.cliName} sessions show <id>`);
2219
+ process.exit(2);
2220
+ }
2221
+ printSessionDetails(await loadSessionOrExit(id));
2222
+ return;
2223
+ }
2224
+ if (action === 'export') {
2225
+ const id = positionalArgs(rest, ['--format', '--output', '-o'])[0];
2226
+ if (!id) {
2227
+ console.error(`ใช้: ${BRAND.cliName} sessions export <id> [--format json|markdown] [--output path]`);
2228
+ process.exit(2);
2229
+ }
2230
+ const format = argValue(rest, '--format') ?? 'markdown';
2231
+ if (format !== 'json' && format !== 'markdown' && format !== 'md') {
2232
+ console.error('--format ต้องเป็น json หรือ markdown');
2233
+ process.exit(2);
2234
+ }
2235
+ const session = await loadSessionOrExit(id);
2236
+ const out = format === 'json' ? `${JSON.stringify(sanitizeSessionForExport(session), null, 2)}\n` : sessionToMarkdown(session);
2237
+ const outputPath = argValue(rest, '--output', '-o');
2238
+ if (!outputPath || outputPath === '-') {
2239
+ process.stdout.write(out);
2240
+ return;
2241
+ }
2242
+ await mkdir(dirname(outputPath), { recursive: true });
2243
+ await writeFile(outputPath, out, { mode: 0o600 });
2244
+ await chmod(outputPath, 0o600).catch(() => { });
2245
+ console.log(`exported session ${id} → ${outputPath}`);
2246
+ return;
2247
+ }
2248
+ if (action === 'rename' || action === 'title') {
2249
+ const [id, ...titleParts] = positionalArgs(rest, ['--limit', '-n']);
2250
+ const title = titleParts.join(' ').trim();
2251
+ if (!id || !title) {
2252
+ console.error(`ใช้: ${BRAND.cliName} sessions rename <id> <title>`);
2253
+ process.exit(2);
2254
+ }
2255
+ const next = await renameSession(id, title);
2256
+ if (!next) {
2257
+ console.error(`ไม่เจอ session ${id}`);
2258
+ process.exit(1);
2259
+ }
2260
+ console.log(`ตั้งชื่อ session ${id}: ${redactKey(next.title ?? '')}`);
2261
+ return;
2262
+ }
2263
+ if (action === 'stats') {
2264
+ printSessionStats(await listSessions({ cwd }), all ? 'all projects' : 'current project');
2265
+ return;
2266
+ }
2267
+ if (action === 'prune') {
2268
+ const keepRaw = argValue(rest, '--keep');
2269
+ const before = parseDateFlag(rest, '--before');
2270
+ if (!keepRaw && !before) {
2271
+ console.error(`ใช้: ${BRAND.cliName} sessions prune --keep N [--before YYYY-MM-DD] [--all] [--yes]`);
2272
+ process.exit(2);
2273
+ }
2274
+ const keep = keepRaw == null ? undefined : Number(keepRaw);
2275
+ if (keep != null && (!Number.isInteger(keep) || keep < 0)) {
2276
+ console.error('--keep ต้องเป็น integer >= 0');
2277
+ process.exit(2);
2278
+ }
2279
+ const candidates = await listSessions({ cwd });
2280
+ const candidateIds = new Set();
2281
+ if (keep != null)
2282
+ for (const s of candidates.slice(keep))
2283
+ candidateIds.add(s.id);
2284
+ if (before) {
2285
+ const beforeMs = before.getTime();
2286
+ for (const s of candidates) {
2287
+ const updatedMs = Date.parse(s.updated);
2288
+ if (Number.isFinite(updatedMs) && updatedMs < beforeMs)
2289
+ candidateIds.add(s.id);
2290
+ }
2291
+ }
2292
+ if (!candidateIds.size) {
2293
+ console.log('ไม่มี session ที่ต้อง prune');
2294
+ return;
2295
+ }
2296
+ if (!rest.includes('--yes') && !rest.includes('-y')) {
2297
+ console.log(`จะลบ ${candidateIds.size} sessions (dry-run):`);
2298
+ for (const s of candidates.filter((x) => candidateIds.has(x.id)))
2299
+ console.log(` ${s.id} ${s.updated} ${sessionPreview(s)}`);
2300
+ console.log(`\nรันซ้ำพร้อม --yes เพื่อยืนยัน`);
2301
+ return;
2302
+ }
2303
+ const removed = await pruneSessions({ cwd, keep, before });
2304
+ console.log(`ลบ ${removed.length} sessions แล้ว`);
2305
+ return;
2306
+ }
2307
+ if (action === 'rm' || action === 'remove' || action === 'delete') {
2308
+ const id = positionalArgs(rest, ['--limit', '-n'])[0];
2309
+ if (!id) {
2310
+ console.error(`ใช้: ${BRAND.cliName} sessions rm <id>`);
2311
+ process.exit(2);
2312
+ }
2313
+ const ok = await removeSession(id);
2314
+ console.log(ok ? `ลบ session ${id} แล้ว` : `ไม่เจอ session ${id}`);
2315
+ return;
2316
+ }
2317
+ console.error(`ไม่รู้จัก: sessions ${action}\n${sessionUsage()}`);
2318
+ process.exit(1);
2319
+ }
2320
+ async function runInsights(args) {
2321
+ const { parseInsightsArgs } = await import('./insights-args.js');
2322
+ const parsed = parseInsightsArgs(args);
2323
+ if (parsed === null) {
2324
+ console.error(`ใช้: ${BRAND.cliName} insights [--days N] [--all]`);
2325
+ process.exit(2);
2326
+ }
2327
+ const { renderInsights } = await import('./insights.js');
2328
+ console.log(await renderInsights({ days: parsed.days, cwd: parsed.all ? null : process.cwd(), includeGateway: true }));
2329
+ }
2330
+ async function runDump(args) {
2331
+ if (args.includes('-h') || args.includes('--help')) {
2332
+ console.log(`ใช้: ${BRAND.cliName} dump [--show-keys]\n\nสร้าง diagnostic/support dump โดย redact secret เสมอ`);
2333
+ return;
2334
+ }
2335
+ const { buildSupportDump } = await import('./support-dump.js');
2336
+ process.stdout.write(await buildSupportDump({
2337
+ showKeys: args.includes('--show-keys'),
2338
+ version: VERSION,
2339
+ packageName: PACKAGE_NAME,
2340
+ cwd: process.cwd(),
2341
+ }));
2342
+ }
2343
+ function providerIds() {
2344
+ return Object.keys(PROVIDERS).join(', ');
2345
+ }
2346
+ function findProviderId(raw) {
2347
+ if (!raw)
2348
+ return undefined;
2349
+ const lower = raw.toLowerCase();
2350
+ if (PROVIDERS[lower])
2351
+ return lower;
2352
+ for (const [id, cfg] of Object.entries(PROVIDERS)) {
2353
+ if (raw === cfg.envVar || cfg.envFallbacks?.includes(raw))
2354
+ return id;
2355
+ }
2356
+ return undefined;
2357
+ }
2358
+ function authEnvSource(providerId) {
2359
+ const cfg = PROVIDERS[providerId];
2360
+ if (!cfg)
2361
+ return undefined;
2362
+ for (const name of [cfg.envVar, ...(cfg.envFallbacks ?? [])]) {
2363
+ if (process.env[name]?.trim())
2364
+ return name;
2365
+ }
2366
+ return undefined;
2367
+ }
2368
+ function authUsage() {
2369
+ return `ใช้:
2370
+ ${BRAND.cliName} auth list
2371
+ ${BRAND.cliName} auth status <provider>
2372
+ ${BRAND.cliName} auth add <provider> --api-key <key> [--use]
2373
+ ${BRAND.cliName} auth remove <provider|ENV_VAR>
2374
+ ${BRAND.cliName} auth reset [provider|ENV_VAR]
2375
+
2376
+ providers: ${providerIds()}`;
2377
+ }
2378
+ async function runAuth(args) {
2379
+ const action = args[0] ?? 'list';
2380
+ const rest = args.slice(action === 'list' && args[0] !== 'list' ? 0 : 1);
2381
+ const { authConfigPath, clearStoredAuth, readStoredAuthRaw, removeStoredKey, saveGlobalConfig, saveKey } = await import('./config.js');
2382
+ if (action === '-h' || action === '--help' || action === 'help') {
2383
+ console.log(authUsage());
2384
+ return;
2385
+ }
2386
+ if (action === 'list' || action === 'ls' || action === 'status-all') {
2387
+ const stored = await readStoredAuthRaw();
2388
+ console.log(`${BRAND.productName} auth`);
2389
+ console.log(` store: ${authConfigPath()}`);
2390
+ for (const [id, cfg] of Object.entries(PROVIDERS)) {
2391
+ if (!cfg.requiresKey) {
2392
+ console.log(` ${id.padEnd(10)} ${cfg.label} — no API key required`);
2393
+ continue;
2394
+ }
2395
+ const key = resolveKeyFromEnv(cfg.envVar, cfg.envFallbacks);
2396
+ const source = authEnvSource(id);
2397
+ const saved = stored[cfg.envVar];
2398
+ const state = key ? `ready via ${source ?? cfg.envVar}` : `missing ${cfg.envVar}`;
2399
+ const savedText = saved ? ` · stored ${redactKey(saved)}` : '';
2400
+ console.log(` ${id.padEnd(10)} ${cfg.label} — ${state}${savedText}`);
2401
+ }
2402
+ return;
2403
+ }
2404
+ if (action === 'status') {
2405
+ const providerId = findProviderId(positionalArgs(rest, ['--api-key', '--key', '--token', '--model'])[0]);
2406
+ if (!providerId) {
2407
+ console.error(`ใช้: ${BRAND.cliName} auth status <provider>\nproviders: ${providerIds()}`);
2408
+ process.exit(1);
2409
+ }
2410
+ const cfg = PROVIDERS[providerId];
2411
+ const stored = await readStoredAuthRaw();
2412
+ const key = resolveKeyFromEnv(cfg.envVar, cfg.envFallbacks);
2413
+ const source = authEnvSource(providerId);
2414
+ console.log(`${cfg.label} (${providerId})`);
2415
+ console.log(` key required: ${cfg.requiresKey ? 'yes' : 'no'}`);
2416
+ console.log(` env var: ${cfg.envVar}${cfg.envFallbacks?.length ? ` (fallback: ${cfg.envFallbacks.join(', ')})` : ''}`);
2417
+ console.log(` stored: ${stored[cfg.envVar] ? redactKey(stored[cfg.envVar]) : '(not stored)'}`);
2418
+ console.log(` runtime: ${key ? `${redactKey(key)} via ${source ?? cfg.envVar}` : '(missing)'}`);
2419
+ const url = consoleUrl(providerId);
2420
+ if (url)
2421
+ console.log(` console: ${url}`);
2422
+ if (cfg.note)
2423
+ console.log(` note: ${cfg.note}`);
2424
+ return;
2425
+ }
2426
+ if (action === 'add' || action === 'login') {
2427
+ const providerId = findProviderId(positionalArgs(rest, ['--api-key', '--key', '--token', '--model'])[0]);
2428
+ if (!providerId) {
2429
+ console.error(`ใช้: ${BRAND.cliName} auth add <provider> --api-key <key>\nproviders: ${providerIds()}`);
2430
+ process.exit(1);
2431
+ }
2432
+ const cfg = PROVIDERS[providerId];
2433
+ if (!cfg.requiresKey) {
2434
+ console.log(`${cfg.label} ไม่ต้องเก็บ API key ใน Sanook`);
2435
+ if (cfg.note)
2436
+ console.log(cfg.note);
2437
+ return;
2438
+ }
2439
+ let key = argValue(rest, '--api-key', '--key', '--token');
2440
+ if (!key) {
2441
+ if (!process.stdin.isTTY) {
2442
+ console.error(`ใช้: ${BRAND.cliName} auth add ${providerId} --api-key <key>`);
2443
+ process.exit(1);
2444
+ }
2445
+ key = await askText(`${cfg.label} API key (${cfg.keyExample ?? cfg.envVar}): `);
2446
+ }
2447
+ try {
2448
+ assertDirectApiKey(cfg, key);
2449
+ }
2450
+ catch (e) {
2451
+ console.error(redactKey(e.message));
2452
+ process.exit(1);
2453
+ }
2454
+ await saveKey(cfg.envVar, key.trim());
2455
+ console.log(`บันทึก ${cfg.label} key แล้ว: ${cfg.envVar}=${redactKey(key.trim())}`);
2456
+ if (rest.includes('--use') || rest.includes('--default')) {
2457
+ const modelArg = argValue(rest, '--model', '-m') ?? 'default';
2458
+ const model = modelArg.includes(':') ? modelArg : `${providerId}:${cfg.models[modelArg] ?? modelArg}`;
2459
+ await saveGlobalConfig({ model, provider: providerId });
2460
+ console.log(`ตั้ง default model เป็น ${model}`);
2461
+ }
2462
+ else {
2463
+ console.log(`ใช้เป็น default ได้ด้วย: ${BRAND.cliName} auth add ${providerId} --api-key <key> --use`);
2464
+ }
2465
+ return;
2466
+ }
2467
+ if (action === 'remove' || action === 'rm' || action === 'logout') {
2468
+ const target = positionalArgs(rest, ['--api-key', '--key', '--token', '--model'])[0];
2469
+ if (!target && action !== 'logout') {
2470
+ console.error(`ใช้: ${BRAND.cliName} auth remove <provider|ENV_VAR>`);
2471
+ process.exit(1);
2472
+ }
2473
+ if (!target && action === 'logout') {
2474
+ await clearStoredAuth();
2475
+ console.log('ล้าง key ที่ Sanook เก็บไว้ทั้งหมดแล้ว');
2476
+ return;
2477
+ }
2478
+ const providerId = findProviderId(target);
2479
+ const envVar = providerId ? PROVIDERS[providerId].envVar : target;
2480
+ const ok = await removeStoredKey(envVar);
2481
+ console.log(ok ? `ลบ ${envVar} ออกจาก Sanook auth store แล้ว` : `ไม่เจอ ${envVar} ใน Sanook auth store`);
2482
+ return;
2483
+ }
2484
+ if (action === 'reset' || action === 'clear') {
2485
+ const target = positionalArgs(rest, ['--api-key', '--key', '--token', '--model'])[0];
2486
+ if (!target) {
2487
+ await clearStoredAuth();
2488
+ console.log('ล้าง key ที่ Sanook เก็บไว้ทั้งหมดแล้ว');
2489
+ return;
2490
+ }
2491
+ const providerId = findProviderId(target);
2492
+ const envVar = providerId ? PROVIDERS[providerId].envVar : target;
2493
+ const ok = await removeStoredKey(envVar);
2494
+ console.log(ok ? `ลบ ${envVar} ออกจาก Sanook auth store แล้ว` : `ไม่เจอ ${envVar} ใน Sanook auth store`);
2495
+ return;
2496
+ }
2497
+ console.error(`ไม่รู้จัก: auth ${action}\n${authUsage()}`);
2498
+ process.exit(1);
2499
+ }
2500
+ async function runSetup(args) {
2501
+ if (args.includes('-h') || args.includes('--help') || args[0] === 'help' || args[0] === 'list' || args[0] === 'status') {
2502
+ console.log(await setupOverview());
2503
+ return;
2504
+ }
2505
+ const section = args.find((a) => !a.startsWith('-')) ?? 'model';
2506
+ const start = args.indexOf(section);
2507
+ const rest = start === -1 ? [] : args.slice(start + 1);
2508
+ if (section === 'model')
2509
+ return startModelSetup();
2510
+ if (section === 'gateway')
2511
+ return runGateway(['setup', ...rest]);
2512
+ if (section === 'tools')
2513
+ return runTools(rest);
2514
+ if (section === 'agent')
2515
+ return runAgentSetupSummary();
2516
+ if (section === 'brain')
2517
+ return runBrain(['init', ...rest]);
2518
+ console.error(`ไม่รู้จัก setup section "${section}" — ใช้ model / gateway / tools / agent / brain`);
2519
+ process.exit(1);
2520
+ }
2521
+ async function setupOverview() {
2522
+ const cfg = await loadConfig({});
2523
+ const { readGatewayConfig, resolveBlueBubblesConfig, resolveDiscordConfig, resolveEmailConfig, resolveGoogleChatConfig, resolveHomeAssistantConfig, resolveLineConfig, resolveMattermostConfig, resolveMatrixConfig, resolveNtfyConfig, resolveSignalConfig, resolveSlackConfig, resolveSmsConfig, resolveTelegramConfig, resolveTeamsConfig, resolveWhatsAppConfig, resolveWebhookConfig, } = await import('./gateway/config.js');
2524
+ const gateway = await readGatewayConfig();
2525
+ const telegram = resolveTelegramConfig(gateway);
2526
+ const discord = resolveDiscordConfig(gateway);
2527
+ const slack = resolveSlackConfig(gateway);
2528
+ const mattermost = resolveMattermostConfig(gateway);
2529
+ const homeassistant = resolveHomeAssistantConfig(gateway);
2530
+ const email = resolveEmailConfig(gateway);
2531
+ const line = resolveLineConfig(gateway);
2532
+ const sms = resolveSmsConfig(gateway);
2533
+ const ntfy = resolveNtfyConfig(gateway);
2534
+ const signal = resolveSignalConfig(gateway);
2535
+ const whatsapp = resolveWhatsAppConfig(gateway);
2536
+ const matrix = resolveMatrixConfig(gateway);
2537
+ const googleChat = resolveGoogleChatConfig(gateway);
2538
+ const bluebubbles = resolveBlueBubblesConfig(gateway);
2539
+ const teams = resolveTeamsConfig(gateway);
2540
+ const webhooks = resolveWebhookConfig(gateway);
2541
+ const configuredPlatforms = [
2542
+ telegram.token ? 'telegram' : '',
2543
+ discord.token ? 'discord' : '',
2544
+ slack.botToken ? 'slack' : '',
2545
+ mattermost.serverUrl && mattermost.token ? 'mattermost' : '',
2546
+ homeassistant.token ? 'homeassistant' : '',
2547
+ email.address ? 'email' : '',
2548
+ line.channelAccessToken ? 'line' : '',
2549
+ sms.accountSid ? 'sms' : '',
2550
+ ntfy.topic ? 'ntfy' : '',
2551
+ signal.account ? 'signal' : '',
2552
+ whatsapp.phoneNumberId && whatsapp.accessToken ? 'whatsapp' : '',
2553
+ matrix.homeserver ? 'matrix' : '',
2554
+ googleChat.serviceAccountJson || googleChat.incomingWebhookUrl ? 'googlechat' : '',
2555
+ bluebubbles.serverUrl && bluebubbles.password ? 'bluebubbles' : '',
2556
+ teams.incomingWebhookUrl || teams.graphAccessToken ? 'teams' : '',
2557
+ webhooks.enabled ? 'webhooks' : '',
2558
+ ].filter(Boolean);
2559
+ return [
2560
+ `${BRAND.productName} setup`,
2561
+ '',
2562
+ ` model ${BRAND.cliName} setup model เลือก provider + model (current: ${cfg.model})`,
2563
+ ` gateway ${BRAND.cliName} setup gateway เชื่อม messaging platforms (${configuredPlatforms.length ? configuredPlatforms.join(', ') : 'not configured'})`,
2564
+ ` tools ${BRAND.cliName} setup tools ดู tool surface + MCP entry points`,
2565
+ ` agent ${BRAND.cliName} setup agent ตั้ง permission/budget/personality/insights`,
2566
+ ` brain ${BRAND.cliName} setup brain สร้าง Second Brain vault + AGENTS/GEMINI/SANOOK rules`,
2567
+ '',
2568
+ `เริ่มเร็ว: ${BRAND.cliName} setup model`,
2569
+ `ดูสถานะ: ${BRAND.cliName} status`,
2570
+ ].join('\n');
2571
+ }
2572
+ function modelOverrideForProvider(providerArg, modelArg) {
2573
+ const providerId = findProviderId(providerArg);
2574
+ if (!providerArg)
2575
+ return modelArg;
2576
+ if (!providerId) {
2577
+ console.error(`ไม่รู้จัก provider "${providerArg}" — มี: ${providerIds()}`);
2578
+ process.exit(1);
2579
+ }
2580
+ if (!modelArg)
2581
+ return `${providerId}:${PROVIDERS[providerId].models.default}`;
2582
+ if (modelArg.includes(':'))
2583
+ return modelArg;
2584
+ return `${providerId}:${PROVIDERS[providerId].models[modelArg] ?? modelArg}`;
2585
+ }
2586
+ function appendPipedInput(prompt, piped) {
2587
+ return piped ? `${prompt}\n\n<stdin>\n${piped}\n</stdin>`.trim() : prompt;
2588
+ }
2589
+ async function runChat(args) {
2590
+ if (args.includes('-h') || args.includes('--help')) {
2591
+ console.log(`ใช้:
2592
+ ${BRAND.cliName} chat -q "<query>" [--provider <provider>] [--model <alias|id>]
2593
+ ${BRAND.cliName} chat "<query>" [--provider <provider>]
2594
+ ${BRAND.cliName} chat เปิด interactive REPL
2595
+
2596
+ providers: ${providerIds()}`);
2597
+ return;
2598
+ }
2599
+ let split = extractValue(args, '-q', '--query');
2600
+ const query = split.value;
2601
+ split = extractValue(split.rest, '--provider');
2602
+ const provider = split.value;
2603
+ split = extractValue(split.rest, '--toolsets', '--tools');
2604
+ const toolsets = split.value;
2605
+ const safeMode = split.rest.includes('--safe-mode');
2606
+ const yolo = split.rest.includes('--yolo') || split.rest.includes('--dangerously-skip-permissions');
2607
+ const cleaned = stripBooleanFlags(split.rest, '--safe-mode', '--yolo', '--dangerously-skip-permissions');
2608
+ const parsed = parseArgs(yolo ? [...cleaned, '--yes'] : cleaned);
2609
+ const resumeSession = await requestedResumeSession(cleaned, parsed.resume);
2610
+ const budgetUsd = Number.isFinite(parsed.budget) ? parsed.budget : undefined;
2611
+ const model = modelOverrideForProvider(provider, parsed.model ?? (provider ? undefined : resumeSession?.model));
2612
+ const piped = process.stdin.isTTY ? '' : (await readStdin()).trim();
2613
+ const prompt = appendPipedInput(query ?? parsed.prompt, piped);
2614
+ if (toolsets && !parsed.quiet && !parsed.json) {
2615
+ process.stderr.write(`${DIM}(toolsets="${toolsets}" accepted; Sanook currently exposes the configured tool surface)${RESET}\n`);
2616
+ }
2617
+ if (!prompt) {
2618
+ const config = await loadConfig({ model, budgetUsd });
2619
+ const { startApp } = await import('./ui/render.js');
2620
+ startApp({
2621
+ needsSetup: false,
2622
+ appProps: {
2623
+ initialModel: config.model,
2624
+ fallbackModel: config.fallbackModel,
2625
+ budgetUsd: config.budgetUsd,
2626
+ permissionMode: parsed.yes || yolo ? 'auto' : safeMode ? 'ask' : config.permissionMode,
2627
+ initialHistory: resumeSession?.messages ?? (await requestedContinuationHistory(cleaned)),
2628
+ },
2629
+ });
2630
+ return;
2631
+ }
2632
+ const config = await loadConfig({ model, budgetUsd });
2633
+ const noKey = headlessKeyHint(config.model);
2634
+ if (noKey) {
2635
+ process.stderr.write(`${noKey}\n`);
2636
+ process.exit(1);
2637
+ }
2638
+ const history = resumeSession?.messages ?? (await requestedContinuationHistory(args));
2639
+ 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, parsed.planMode ? prompt : undefined);
2640
+ }
2641
+ async function runPureOneShot(args) {
2642
+ const rest = args;
2643
+ const parsed = parseArgs(rest);
2644
+ const resumeSession = await requestedResumeSession(rest, parsed.resume);
2645
+ const budgetUsd = Number.isFinite(parsed.budget) ? parsed.budget : undefined;
2646
+ const piped = process.stdin.isTTY ? '' : (await readStdin()).trim();
2647
+ const prompt = appendPipedInput(parsed.prompt, piped);
2648
+ if (!prompt) {
2649
+ console.error(`ใช้: ${BRAND.cliName} -z "<task>"`);
2650
+ process.exit(1);
2651
+ }
2652
+ const config = await loadConfig({ model: parsed.model ?? resumeSession?.model, budgetUsd });
2653
+ const noKey = headlessKeyHint(config.model);
2654
+ if (noKey) {
2655
+ process.stderr.write(`${noKey}\n`);
2656
+ process.exit(1);
2657
+ }
2658
+ const history = resumeSession?.messages ?? (await requestedContinuationHistory(rest));
2659
+ await runHeadless(config.model, prompt, config.budgetUsd, config.maxSteps, parsed.json, history, parsed.planMode, parsed.yes ? 'auto' : config.permissionMode, true, config.fallbackModel, parsed.planMode ? prompt : undefined);
2660
+ }
2661
+ async function runSend(args) {
2662
+ const json = args.includes('--json');
2663
+ const quiet = args.includes('--quiet') || args.includes('-q');
2664
+ const wantsList = args.includes('--list') || args.includes('-l');
2665
+ const valueFlags = ['--to', '-t', '--file', '-f', '--subject', '-s'];
2666
+ if (args.includes('-h') || args.includes('--help')) {
2667
+ console.log(`ใช้:
2668
+ ${BRAND.cliName} send --to telegram[:chat_id[:thread_id]] "message"
2669
+ ${BRAND.cliName} send --to discord[:channel_id[:thread_id]] "message"
2670
+ ${BRAND.cliName} send --to slack[:channel_id[:thread_ts]] "message"
2671
+ ${BRAND.cliName} send --to mattermost[:channel_id[:root_post_id]] "message"
2672
+ ${BRAND.cliName} send --to homeassistant[:notification_id] "message"
2673
+ ${BRAND.cliName} send --to email[:recipient@example.com] --subject "[CI]" "message"
2674
+ ${BRAND.cliName} send --to line[:U/C/R-id] "message"
2675
+ ${BRAND.cliName} send --to sms[:+15558675310] "message"
2676
+ ${BRAND.cliName} send --to ntfy[:topic] "message"
2677
+ ${BRAND.cliName} send --to signal[:+15558675310|group:<id>] "message"
2678
+ ${BRAND.cliName} send --to whatsapp[:15558675310] "message"
2679
+ ${BRAND.cliName} send --to matrix[:!roomid:matrix.org] "message"
2680
+ ${BRAND.cliName} send --to googlechat[:spaces/AAA|spaces/AAA/threads/BBB] "message"
2681
+ ${BRAND.cliName} send --to bluebubbles[:chat-guid|email|phone] "message"
2682
+ ${BRAND.cliName} send --to teams[:chat_id|team/<team-id>/channel/<channel-id>] "message"
2683
+ ${BRAND.cliName} send --to slack --subject "[CI]" --file build.log
2684
+ echo "done" | ${BRAND.cliName} send --to telegram --quiet
2685
+ ${BRAND.cliName} send --list [platform] [--json]`);
2686
+ return;
2687
+ }
2688
+ if (wantsList) {
2689
+ const { listConfiguredTargets } = await import('./gateway/targets.js');
2690
+ const { readGatewayConfig } = await import('./gateway/config.js');
2691
+ const filter = positionalArgs(args, valueFlags)[0];
2692
+ const targets = listConfiguredTargets(await readGatewayConfig()).filter((t) => !filter || t.platform === filter);
2693
+ if (json) {
2694
+ console.log(JSON.stringify({ targets }));
2695
+ return;
2696
+ }
2697
+ if (!targets.length) {
2698
+ console.log(filter ? `ยังไม่มี target สำหรับ ${filter}` : `ยังไม่มี messaging target — เริ่มด้วย: ${BRAND.cliName} gateway setup`);
2699
+ return;
2700
+ }
2701
+ for (const t of targets) {
2702
+ console.log(`${t.target.padEnd(24)} ${t.configured ? 'ready' : 'not-ready'} ${t.label}`);
2703
+ }
2704
+ return;
2705
+ }
2706
+ const to = argValue(args, '--to', '-t');
2707
+ if (!to) {
2708
+ console.error(`ใช้: ${BRAND.cliName} send --to <telegram|discord|slack|mattermost|homeassistant|email|line|sms|ntfy|signal|whatsapp|matrix|googlechat|bluebubbles|teams>[:target] "message"`);
2709
+ process.exit(2);
2710
+ }
2711
+ const file = argValue(args, '--file', '-f');
2712
+ const subject = argValue(args, '--subject', '-s');
2713
+ let message = positionalArgs(args, valueFlags).join(' ').trim();
2714
+ if (!message && file)
2715
+ message = file === '-' ? await readStdin() : await readFile(file, 'utf8');
2716
+ if (!message && !process.stdin.isTTY)
2717
+ message = (await readStdin()).trim();
2718
+ if (subject && message && !to.startsWith('email'))
2719
+ message = `${subject.trim()}\n\n${message.trim()}`;
2720
+ if (!message) {
2721
+ console.error('message ว่าง — ใส่ข้อความ, --file <path>, หรือ pipe stdin เข้ามา');
2722
+ process.exit(2);
2723
+ }
2724
+ const { parseSendTarget } = await import('./gateway/targets.js');
2725
+ try {
2726
+ parseSendTarget(to);
2727
+ }
2728
+ catch (e) {
2729
+ console.error(e.message);
2730
+ process.exit(2);
2731
+ }
2732
+ const { deliverToTarget } = await import('./gateway/deliver.js');
2733
+ try {
2734
+ const result = await deliverToTarget(to, message, { subject });
2735
+ if (json)
2736
+ console.log(JSON.stringify({ ok: true, ...result }));
2737
+ else if (!quiet)
2738
+ console.log(`sent ${result.target}`);
2739
+ }
2740
+ catch (e) {
2741
+ const msg = redactKey(e.message);
2742
+ if (json)
2743
+ console.log(JSON.stringify({ ok: false, error: msg }));
2744
+ else
2745
+ console.error(`ส่งไม่สำเร็จ: ${msg}`);
177
2746
  process.exit(1);
178
2747
  }
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
2748
+ }
2749
+ async function runWebhook(args) {
2750
+ const action = args[0] ?? 'list';
2751
+ const rest = action === 'list' && args[0] !== 'list' ? args : args.slice(1);
2752
+ const valueFlags = [
2753
+ '--events',
2754
+ '--prompt',
2755
+ '--to',
2756
+ '-t',
2757
+ '--deliver',
2758
+ '--deliver-chat-id',
2759
+ '--chat-id',
2760
+ '--secret',
2761
+ '--description',
2762
+ '--payload',
2763
+ '--public-url',
2764
+ '--rate-limit',
2765
+ '--rate-limit-per-minute',
2766
+ ];
2767
+ if (args.includes('-h') || args.includes('--help') || action === 'help') {
2768
+ console.log(`ใช้:
2769
+ ${BRAND.cliName} webhook subscribe <route> [--events issues,push] [--prompt "..."] [--to telegram|slack:C01|mattermost:chan|homeassistant|sms|ntfy|signal|whatsapp|matrix|googlechat|bluebubbles|teams]
2770
+ ${BRAND.cliName} webhook subscribe <route> --deliver telegram --deliver-chat-id 123 --deliver-only --prompt "New event: {__raw__}"
2771
+ ${BRAND.cliName} webhook list
2772
+ ${BRAND.cliName} webhook remove <route>
2773
+ ${BRAND.cliName} webhook test <route> --payload '{"event_type":"ping"}'
2774
+
2775
+ signature headers:
2776
+ GitHub: X-Hub-Signature-256: sha256=<hmac>
2777
+ GitLab: X-Gitlab-Token: <secret>
2778
+ Generic: X-Webhook-Signature: <hmac-hex>`);
2779
+ return;
2780
+ }
2781
+ if (action === 'subscribe' || action === 'add') {
2782
+ const name = positionalArgs(rest, valueFlags)[0];
2783
+ if (!name) {
2784
+ console.error(`ใช้: ${BRAND.cliName} webhook subscribe <route> [--prompt "..."] [--to <target>]`);
2785
+ process.exit(2);
2786
+ }
2787
+ const { isValidWebhookRouteName, generateWebhookSecret } = await import('./gateway/webhooks.js');
2788
+ if (!isValidWebhookRouteName(name)) {
2789
+ console.error('route ต้องเป็น a-z/A-Z/0-9/_/- ความยาวไม่เกิน 64 และต้องขึ้นต้นด้วยตัวอักษรหรือตัวเลข');
2790
+ process.exit(2);
2791
+ }
2792
+ const prompt = argValue(rest, '--prompt');
2793
+ const description = argValue(rest, '--description');
2794
+ const events = parseStringCsv(argValue(rest, '--events')).map((event) => event.trim()).filter(Boolean);
2795
+ const deliver = webhookDeliverTarget(rest);
2796
+ const deliverOnly = rest.includes('--deliver-only');
2797
+ const insecureNoAuth = rest.includes('--insecure-no-auth');
2798
+ const routeSecret = insecureNoAuth ? 'INSECURE_NO_AUTH' : (argValue(rest, '--secret')?.trim() || generateWebhookSecret());
2799
+ const publicUrl = argValue(rest, '--public-url');
2800
+ const rateLimitRaw = argValue(rest, '--rate-limit', '--rate-limit-per-minute');
2801
+ const rateLimitPerMinute = rateLimitRaw ? parsePort(rateLimitRaw, 30, 'webhook route rate limit') : undefined;
2802
+ if (deliverOnly && deliver === 'log') {
2803
+ console.error('--deliver-only ต้องมี --to หรือ --deliver เป็น messaging target จริง');
2804
+ process.exit(2);
2805
+ }
2806
+ if (deliver !== 'log') {
2807
+ const { parseSendTarget } = await import('./gateway/targets.js');
2808
+ try {
2809
+ parseSendTarget(deliver);
2810
+ }
2811
+ catch (e) {
2812
+ console.error(e.message);
2813
+ process.exit(2);
2814
+ }
2815
+ }
2816
+ const { patchGatewayConfig, readGatewayConfig } = await import('./gateway/config.js');
2817
+ const current = await readGatewayConfig();
2818
+ await patchGatewayConfig({
2819
+ webhooks: {
2820
+ enabled: true,
2821
+ publicUrl: publicUrl?.trim() || current.webhooks?.publicUrl,
2822
+ routes: {
2823
+ [name]: {
2824
+ events,
2825
+ secret: routeSecret,
2826
+ prompt: prompt?.trim() || undefined,
2827
+ deliver,
2828
+ deliverOnly,
2829
+ description: description?.trim() || undefined,
2830
+ rateLimitPerMinute,
2831
+ },
2832
+ },
2833
+ },
2834
+ });
2835
+ const base = (publicUrl?.trim() || current.webhooks?.publicUrl || 'http://127.0.0.1:8787').replace(/\/+$/, '');
2836
+ console.log(`เพิ่ม webhook route "${name}" แล้ว`);
2837
+ console.log(`URL: ${base}/webhooks/${name}`);
2838
+ console.log(`secret: ${routeSecret}`);
2839
+ console.log(`test: ${BRAND.cliName} webhook test ${name} --payload '{"event_type":"ping"}'`);
2840
+ return;
2841
+ }
2842
+ if (action === 'list' || action === undefined) {
2843
+ const { readGatewayConfig, resolveWebhookConfig } = await import('./gateway/config.js');
2844
+ const cfg = await readGatewayConfig();
2845
+ const webhooks = resolveWebhookConfig(cfg);
2846
+ const routes = Object.values(webhooks.routes);
2847
+ if (!routes.length) {
2848
+ console.log(`ยังไม่มี webhook route — เพิ่มด้วย: ${BRAND.cliName} webhook subscribe <route> --prompt "Event: {__raw__}"`);
2849
+ return;
2850
+ }
2851
+ const base = (webhooks.publicUrl || 'http://127.0.0.1:8787').replace(/\/+$/, '');
2852
+ for (const route of routes) {
2853
+ const events = route.events.length ? route.events.join(',') : '*';
2854
+ const mode = route.deliverOnly ? 'direct' : 'agent';
2855
+ console.log(`${route.name.padEnd(20)} ${mode.padEnd(6)} events:${events.padEnd(12)} deliver:${route.deliver} ${base}/webhooks/${route.name}`);
2856
+ }
2857
+ return;
2858
+ }
2859
+ if (action === 'remove' || action === 'rm') {
2860
+ const name = positionalArgs(rest, valueFlags)[0];
2861
+ if (!name) {
2862
+ console.error(`ใช้: ${BRAND.cliName} webhook remove <route>`);
2863
+ process.exit(2);
2864
+ }
2865
+ const { readGatewayConfig, writeGatewayConfig } = await import('./gateway/config.js');
2866
+ const cfg = await readGatewayConfig();
2867
+ if (!cfg.webhooks?.routes?.[name]) {
2868
+ console.log(`ไม่พบ webhook route "${name}"`);
2869
+ return;
2870
+ }
2871
+ const routes = { ...cfg.webhooks.routes };
2872
+ delete routes[name];
2873
+ await writeGatewayConfig({ ...cfg, webhooks: { ...cfg.webhooks, routes } });
2874
+ console.log(`ลบ webhook route "${name}" แล้ว`);
2875
+ return;
2876
+ }
2877
+ if (action === 'test') {
2878
+ const name = positionalArgs(rest, valueFlags)[0];
2879
+ if (!name) {
2880
+ console.error(`ใช้: ${BRAND.cliName} webhook test <route> [--payload <json>]`);
2881
+ process.exit(2);
2882
+ }
2883
+ const payload = argValue(rest, '--payload') ?? '{"event_type":"ping"}';
2884
+ let rawBody;
2885
+ let parsedPayload;
2886
+ try {
2887
+ parsedPayload = JSON.parse(payload);
2888
+ rawBody = JSON.stringify(parsedPayload);
2889
+ }
2890
+ catch {
2891
+ console.error('--payload ต้องเป็น JSON object/string ที่ parse ได้');
2892
+ process.exit(2);
2893
+ }
2894
+ const { readGatewayConfig, resolveWebhookConfig } = await import('./gateway/config.js');
2895
+ const { handleWebhookRequest } = await import('./gateway/webhooks.js');
2896
+ const cfg = resolveWebhookConfig(await readGatewayConfig());
2897
+ const route = cfg.routes[name];
2898
+ if (!route) {
2899
+ console.error(`ไม่พบ webhook route "${name}"`);
2900
+ process.exit(2);
2901
+ }
2902
+ const secret = route.secret || cfg.secret;
2903
+ const eventType = parsedPayload && typeof parsedPayload === 'object' && typeof parsedPayload.event_type === 'string'
2904
+ ? parsedPayload.event_type
2905
+ : 'ping';
2906
+ const headers = { 'x-event-type': eventType };
2907
+ if (secret && secret !== 'INSECURE_NO_AUTH') {
2908
+ const { createHmac } = await import('node:crypto');
2909
+ headers['x-webhook-signature'] = createHmac('sha256', secret).update(rawBody).digest('hex');
2910
+ headers['x-request-id'] = `sanook-test-${Date.now()}`;
2911
+ }
2912
+ const appCfg = await loadConfig({});
2913
+ const result = await handleWebhookRequest({
2914
+ routeName: name,
2915
+ rawBody,
2916
+ headers,
2917
+ config: cfg,
2918
+ model: appCfg.model,
2919
+ budgetUsd: appCfg.budgetUsd,
2920
+ permissionMode: appCfg.permissionMode,
2921
+ onLog: (m) => process.stderr.write(`${DIM}${m}${RESET}\n`),
2922
+ });
2923
+ console.log(JSON.stringify(result.body, null, 2));
2924
+ if (result.status >= 400)
2925
+ process.exit(1);
2926
+ return;
2927
+ }
2928
+ console.error(`ไม่รู้จัก: webhook ${action} — ใช้ subscribe / list / remove / test`);
2929
+ process.exit(2);
2930
+ }
2931
+ function webhookDeliverTarget(args) {
2932
+ const direct = argValue(args, '--to', '-t')?.trim();
2933
+ if (direct)
2934
+ return direct;
2935
+ const deliver = argValue(args, '--deliver')?.trim();
2936
+ if (!deliver || deliver === 'log')
2937
+ return 'log';
2938
+ const chat = argValue(args, '--deliver-chat-id', '--chat-id')?.trim();
2939
+ return chat ? `${deliver}:${chat}` : deliver;
198
2940
  }
199
2941
  /** sanook cron add "<when>" "<task>" | cron list | cron rm <id> */
200
2942
  async function runCron(args) {
201
2943
  const [action, ...rest] = args;
202
2944
  const { listTasks, enqueueTask, removeTask } = await import('./gateway/ledger.js');
2945
+ const valueFlags = ['--to', '-t', '--model', '-m'];
203
2946
  if (action === 'add') {
204
- const schedule = rest[0];
205
- const spec = rest.slice(1).join(' ').trim();
2947
+ const deliverRaw = argValue(rest, '--to', '-t')?.trim();
2948
+ const model = argValue(rest, '--model', '-m');
2949
+ const positionals = positionalArgs(rest, valueFlags);
2950
+ const schedule = positionals[0];
2951
+ const spec = positionals.slice(1).join(' ').trim();
206
2952
  if (!schedule || !spec) {
207
- console.error('ใช้: sanook cron add "<when>" "<task>" (when: "every 30m" | "09:00" | ISO | now)');
2953
+ console.error(`ใช้: ${BRAND.cliName} cron add "<when>" "<task>" [--to <target>] [--model <provider:model>]`);
208
2954
  console.error('หมายเหตุ: when ที่มีช่องว่างต้องครอบ quote เช่น "every 30m"');
209
2955
  process.exit(1);
210
2956
  }
@@ -217,14 +2963,30 @@ async function runCron(args) {
217
2963
  }
218
2964
  process.exit(1);
219
2965
  }
2966
+ let deliver;
2967
+ if (deliverRaw) {
2968
+ const { parseSendTarget, formatTarget } = await import('./gateway/targets.js');
2969
+ try {
2970
+ deliver = formatTarget(parseSendTarget(deliverRaw));
2971
+ }
2972
+ catch (e) {
2973
+ console.error(e.message);
2974
+ process.exit(2);
2975
+ }
2976
+ }
220
2977
  const task = await enqueueTask({
221
2978
  kind: sched.recurring ? 'cron' : 'once',
222
2979
  spec,
223
2980
  schedule: sched.recurring ? sched.normalized : undefined,
2981
+ model,
2982
+ deliver,
224
2983
  runAt: sched.runAt,
225
2984
  });
226
2985
  const when = new Date(task.runAt).toLocaleString();
227
- console.log(`เพิ่ม task ${task.id} รัน ${when}${sched.recurring ? ` แล้วทุก ${sched.normalized}` : ''}`);
2986
+ const extras = [task.deliver ? `ส่งไป ${task.deliver}` : undefined, task.model ? `model ${task.model}` : undefined]
2987
+ .filter(Boolean)
2988
+ .join(' · ');
2989
+ console.log(`เพิ่ม task ${task.id} — รัน ${when}${sched.recurring ? ` แล้วทุก ${sched.normalized}` : ''}${extras ? ` · ${extras}` : ''}`);
228
2990
  return;
229
2991
  }
230
2992
  if (action === 'rm' || action === 'remove') {
@@ -244,26 +3006,39 @@ async function runCron(args) {
244
3006
  }
245
3007
  for (const t of tasks) {
246
3008
  const next = new Date(t.runAt).toLocaleString();
247
- console.log(`${t.id} [${t.status}] ${t.schedule ?? 'once'} next:${next} ${t.spec.slice(0, 50)}`);
3009
+ const extras = [t.deliver ? `to:${t.deliver}` : undefined, t.model ? `model:${t.model}` : undefined].filter(Boolean).join(' ');
3010
+ console.log(`${t.id} [${t.status}] ${t.schedule ?? 'once'} next:${next}${extras ? ` ${extras}` : ''} → ${t.spec.slice(0, 50)}`);
248
3011
  }
249
3012
  return;
250
3013
  }
251
3014
  console.error(`ไม่รู้จัก: cron ${action} — ใช้ add / list / rm`);
252
3015
  process.exit(1);
253
3016
  }
254
- /** sanook skill list | add <source> | remove <name> */
3017
+ /** sanook init [--trust] scaffold project .sanook/commands + onboarding hints */
3018
+ async function runInit(args) {
3019
+ const trust = args.includes('--trust');
3020
+ const unknown = args.filter((a) => a !== '--trust');
3021
+ if (unknown.length) {
3022
+ console.error(`ใช้: ${BRAND.cliName} init [--trust]`);
3023
+ process.exit(1);
3024
+ }
3025
+ const { initProject, formatInitResult } = await import('./project-init.js');
3026
+ const result = await initProject({ trust });
3027
+ console.log(formatInitResult(result));
3028
+ }
3029
+ /** sanook skill list | install <name|path> | add <source> | remove <name> */
255
3030
  async function runSkill(args) {
256
3031
  const [action, ...rest] = args;
257
- if (action === 'add') {
3032
+ if (action === 'add' || action === 'install') {
258
3033
  const source = rest[0];
259
3034
  if (!source) {
260
- console.error('ใช้: sanook skill add <github "user/repo" | URL ของ SKILL.md | local path>');
3035
+ console.error(`ใช้: ${BRAND.cliName} skill install <bundled-name|path> หรือ ${BRAND.cliName} skill add <github "user/repo" | URL ของ SKILL.md | local path>`);
261
3036
  process.exit(1);
262
3037
  }
263
3038
  console.error(`${DIM}⚠ skill = instruction ที่ AI จะทำตาม — ติดตั้งจาก source ที่เชื่อถือเท่านั้น${RESET}`);
264
- const { installSkill } = await import('./skill-install.js');
3039
+ const { installNamedSkill } = await import('./skill-install.js');
265
3040
  try {
266
- const installed = await installSkill(source, (m) => process.stderr.write(`${DIM}${m}${RESET}\n`));
3041
+ const installed = await installNamedSkill(source, (m) => process.stderr.write(`${DIM}${m}${RESET}\n`));
267
3042
  console.log(`ติดตั้ง ${installed.length} skill: ${installed.map((s) => s.name).join(', ')}`);
268
3043
  }
269
3044
  catch (e) {
@@ -333,10 +3108,271 @@ async function runModels(args) {
333
3108
  else
334
3109
  console.log('\n✓ ทุก curated id มีใน provider');
335
3110
  }
3111
+ function brainDoctorStatusLabel(status) {
3112
+ return status.toUpperCase().padEnd(4);
3113
+ }
3114
+ /** sanook brain doctor — check configured second-brain health without modifying it */
3115
+ async function runBrainDoctor() {
3116
+ const cfg = await loadConfig({});
3117
+ const { checkBrain } = await import('./brain-doctor.js');
3118
+ const report = await checkBrain({ brainPath: cfg.brainPath });
3119
+ console.log(`${BRAND.productName} brain doctor`);
3120
+ for (const check of report.checks) {
3121
+ console.log(`[${brainDoctorStatusLabel(check.status)}] ${check.id} — ${check.message}`);
3122
+ if (check.path)
3123
+ console.log(` ${check.path}`);
3124
+ for (const detail of check.details ?? [])
3125
+ console.log(` - ${detail}`);
3126
+ }
3127
+ if (!report.ok)
3128
+ process.exit(1);
3129
+ }
3130
+ /** sanook brain context [--task "..."] — show the prompt context Sanook loads from the vault */
3131
+ async function runBrainContext(args) {
3132
+ const { parseBrainContextArgs, inspectBrainContext, formatBrainContextReport } = await import('./brain-context.js');
3133
+ const parsed = parseBrainContextArgs(args);
3134
+ if (!parsed.ok) {
3135
+ console.error(parsed.message);
3136
+ console.error(`ใช้: ${BRAND.cliName} brain context [--task "..."] [--project <slug>] [--mode auto|fts|semantic|hybrid] [--limit N] [--source vault,session,skill] [--no-content]`);
3137
+ process.exit(1);
3138
+ }
3139
+ const cfg = await loadConfig({});
3140
+ const report = await inspectBrainContext({
3141
+ brainPath: cfg.brainPath,
3142
+ task: parsed.value.task,
3143
+ cwd: process.cwd(),
3144
+ projectSlug: parsed.value.project,
3145
+ mode: parsed.value.mode,
3146
+ limit: parsed.value.limit,
3147
+ sources: parsed.value.sources,
3148
+ });
3149
+ console.log(formatBrainContextReport(report, parsed.value.showContent));
3150
+ if (!report.ok)
3151
+ process.exit(1);
3152
+ }
3153
+ /** sanook brain eval — run the lightweight second-brain benchmark sanity checks */
3154
+ async function runBrainEval(args) {
3155
+ const allowed = new Set(['--no-retrieval']);
3156
+ const unknown = args.filter((arg) => !allowed.has(arg));
3157
+ if (unknown.length) {
3158
+ console.error(`ไม่รู้จัก option: ${unknown.join(' ')}`);
3159
+ console.error(`ใช้: ${BRAND.cliName} brain eval [--no-retrieval]`);
3160
+ process.exit(1);
3161
+ }
3162
+ const cfg = await loadConfig({});
3163
+ const { runBrainEval: evaluate, formatBrainEvalReport } = await import('./brain-eval.js');
3164
+ const report = await evaluate({ brainPath: cfg.brainPath, runRetrieval: !args.includes('--no-retrieval') });
3165
+ console.log(formatBrainEvalReport(report));
3166
+ if (!report.ok)
3167
+ process.exit(1);
3168
+ }
3169
+ /** sanook brain review — curator health review over the configured second-brain vault */
3170
+ async function runBrainReview(args) {
3171
+ const { parseBrainReviewArgs, reviewBrain, formatBrainReviewReport } = await import('./brain-review.js');
3172
+ const parsed = parseBrainReviewArgs(args);
3173
+ if (!parsed.ok) {
3174
+ console.error(parsed.message);
3175
+ console.error(`ใช้: ${BRAND.cliName} brain review [--no-hygiene]`);
3176
+ process.exit(1);
3177
+ }
3178
+ const cfg = await loadConfig({});
3179
+ const report = await reviewBrain({
3180
+ brainPath: cfg.brainPath,
3181
+ scanMarkdownHygiene: parsed.value.scanMarkdownHygiene,
3182
+ });
3183
+ console.log(formatBrainReviewReport(report));
3184
+ if (!report.ok)
3185
+ process.exit(1);
3186
+ }
3187
+ /** sanook brain consolidate [--apply] [--archive] [--memory] — sleep-time consolidation runner */
3188
+ async function runBrainConsolidate(args) {
3189
+ const { parseBrainConsolidateArgs, runBrainConsolidate: consolidate, formatBrainConsolidateReport } = await import('./brain-consolidate.js');
3190
+ const parsed = parseBrainConsolidateArgs(args);
3191
+ if (!parsed.ok) {
3192
+ console.error(parsed.message);
3193
+ console.error(`ใช้: ${BRAND.cliName} brain consolidate [--apply] [--apply --archive] [--memory] [--no-retrieval]`);
3194
+ process.exit(1);
3195
+ }
3196
+ const cfg = await loadConfig({});
3197
+ const report = await consolidate({
3198
+ brainPath: cfg.brainPath,
3199
+ apply: parsed.value.apply,
3200
+ archive: parsed.value.archive,
3201
+ memory: parsed.value.memory,
3202
+ runRetrieval: parsed.value.runRetrieval,
3203
+ });
3204
+ console.log(formatBrainConsolidateReport(report));
3205
+ if (!report.ok)
3206
+ process.exit(1);
3207
+ }
3208
+ /** sanook brain metrics [--no-retrieval] — vault counts, stale notes, index + retrieval coverage */
3209
+ async function runBrainMetrics(args) {
3210
+ const allowed = new Set(['--no-retrieval']);
3211
+ const unknown = args.filter((arg) => !allowed.has(arg));
3212
+ if (unknown.length) {
3213
+ console.error(`ไม่รู้จัก option: ${unknown.join(' ')}`);
3214
+ console.error(`ใช้: ${BRAND.cliName} brain metrics [--no-retrieval]`);
3215
+ process.exit(1);
3216
+ }
3217
+ const cfg = await loadConfig({});
3218
+ const { collectBrainMetrics, formatBrainMetricsReport } = await import('./brain-metrics.js');
3219
+ const report = await collectBrainMetrics({ brainPath: cfg.brainPath, runRetrievalEval: !args.includes('--no-retrieval') });
3220
+ console.log(formatBrainMetricsReport(report));
3221
+ if (!report.ok)
3222
+ process.exit(1);
3223
+ }
3224
+ /** sanook brain projects list — show vault project workspaces and repo_path mappings */
3225
+ async function runBrainProjects(args) {
3226
+ if (args[0] && args[0] !== 'list') {
3227
+ console.error(`ใช้: ${BRAND.cliName} brain projects list`);
3228
+ process.exit(1);
3229
+ }
3230
+ const cfg = await loadConfig({});
3231
+ if (!cfg.brainPath) {
3232
+ console.error('ยังไม่ได้ตั้ง brainPath — รัน `sanook brain init [path]` ก่อน');
3233
+ process.exit(1);
3234
+ }
3235
+ const { listVaultProjects, formatVaultProjectLine, resolveVaultProject } = await import('./project-registry.js');
3236
+ const projects = await listVaultProjects(cfg.brainPath);
3237
+ const active = await resolveVaultProject({ brainPath: cfg.brainPath, cwd: process.cwd() });
3238
+ console.log(`${BRAND.productName} brain projects`);
3239
+ console.log(`vault: ${cfg.brainPath}`);
3240
+ console.log(`cwd: ${process.cwd()}${active ? ` → active: ${active.slug}` : ''}`);
3241
+ if (!projects.length) {
3242
+ console.log('(no project workspaces — run `sanook brain new project --title "..." --repo /path`)');
3243
+ return;
3244
+ }
3245
+ console.log('\nslug repo_path');
3246
+ for (const project of projects) {
3247
+ const marker = active?.slug === project.slug ? ' *' : '';
3248
+ console.log(`${formatVaultProjectLine(project)}${marker}`);
3249
+ }
3250
+ }
3251
+ /** sanook brain pack list|show <name> — inspect Shared/Context-Packs/ bundles */
3252
+ async function runBrainPack(args) {
3253
+ const { parseBrainPackArgs, listContextPacks, showContextPack, formatBrainPackListReport, formatBrainPackShowReport } = await import('./brain-pack.js');
3254
+ const parsed = parseBrainPackArgs(args);
3255
+ if (!parsed.ok) {
3256
+ console.error(parsed.message);
3257
+ console.error(`ใช้: ${BRAND.cliName} brain pack list | ${BRAND.cliName} brain pack show <name>`);
3258
+ process.exit(1);
3259
+ }
3260
+ const cfg = await loadConfig({});
3261
+ if (!cfg.brainPath) {
3262
+ console.error('ยังไม่ได้ตั้ง brainPath — รัน `sanook brain init [path]` ก่อน');
3263
+ process.exit(1);
3264
+ }
3265
+ if (parsed.action === 'list') {
3266
+ const report = await listContextPacks(cfg.brainPath);
3267
+ console.log(formatBrainPackListReport(report));
3268
+ if (!report.ok)
3269
+ process.exit(1);
3270
+ return;
3271
+ }
3272
+ const report = await showContextPack(cfg.brainPath, parsed.name);
3273
+ console.log(formatBrainPackShowReport(report));
3274
+ if (!report.ok)
3275
+ process.exit(1);
3276
+ }
3277
+ /** sanook brain new <type> [--title "..."] — template-backed note creation in the correct vault folder */
3278
+ async function runBrainNew(args) {
3279
+ const { parseBrainNewArgs, createBrainNote, formatBrainNewReport } = await import('./brain-new.js');
3280
+ const parsed = parseBrainNewArgs(args);
3281
+ if (!parsed.ok) {
3282
+ console.error(parsed.message);
3283
+ console.error(`ใช้: ${BRAND.cliName} brain new <session|bug|handoff|project|golden-case|checklist> [--title "..."] [--repo /path] [--verify "..."] [--output path] [--force]`);
3284
+ process.exit(1);
3285
+ }
3286
+ const cfg = await loadConfig({});
3287
+ const report = await createBrainNote({
3288
+ brainPath: cfg.brainPath,
3289
+ today: new Date().toISOString().slice(0, 10),
3290
+ ...parsed.value,
3291
+ });
3292
+ console.log(formatBrainNewReport(report));
3293
+ if (!report.ok)
3294
+ process.exit(1);
3295
+ }
3296
+ /** sanook brain repair [--dry-run] — apply safe one-line fixes after doctor/review findings */
3297
+ async function runBrainRepair(args) {
3298
+ const { parseBrainRepairArgs, repairBrain, formatBrainRepairReport } = await import('./brain-repair.js');
3299
+ const parsed = parseBrainRepairArgs(args);
3300
+ if (!parsed.ok) {
3301
+ console.error(parsed.message);
3302
+ console.error(`ใช้: ${BRAND.cliName} brain repair [--dry-run]`);
3303
+ process.exit(1);
3304
+ }
3305
+ const cfg = await loadConfig({});
3306
+ const report = await repairBrain({ brainPath: cfg.brainPath, dryRun: parsed.dryRun });
3307
+ console.log(formatBrainRepairReport(report));
3308
+ if (!report.ok)
3309
+ process.exit(1);
3310
+ }
3311
+ /** sanook brain final [--task "..."] [--from-diff] [--lite] — create an evidence-backed closeout note */
3312
+ async function runBrainFinal(args) {
3313
+ const { parseBrainFinalArgs, createBrainFinal, formatBrainFinalReport } = await import('./brain-final.js');
3314
+ const parsed = parseBrainFinalArgs(args);
3315
+ if (!parsed.ok) {
3316
+ console.error(parsed.message);
3317
+ console.error(`ใช้: ${BRAND.cliName} brain final [--task "..."] [--from-diff] [--lite] [--output Sessions/name.md] [--force]`);
3318
+ process.exit(1);
3319
+ }
3320
+ const cfg = await loadConfig({});
3321
+ const report = await createBrainFinal({
3322
+ brainPath: cfg.brainPath,
3323
+ today: new Date().toISOString().slice(0, 10),
3324
+ ...parsed.value,
3325
+ });
3326
+ console.log(formatBrainFinalReport(report));
3327
+ if (!report.ok)
3328
+ process.exit(1);
3329
+ }
336
3330
  /** sanook brain init [path] — scaffold second-brain workspace (interactive ถ้าไม่ใส่ path) */
337
3331
  async function runBrain(args) {
3332
+ if (args[0] === 'doctor')
3333
+ return runBrainDoctor();
3334
+ if (args[0] === 'context')
3335
+ return runBrainContext(args.slice(1));
3336
+ if (args[0] === 'eval')
3337
+ return runBrainEval(args.slice(1));
3338
+ if (args[0] === 'review')
3339
+ return runBrainReview(args.slice(1));
3340
+ if (args[0] === 'projects')
3341
+ return runBrainProjects(args.slice(1));
3342
+ if (args[0] === 'pack')
3343
+ return runBrainPack(args.slice(1));
3344
+ if (args[0] === 'new')
3345
+ return runBrainNew(args.slice(1));
3346
+ if (args[0] === 'repair')
3347
+ return runBrainRepair(args.slice(1));
3348
+ if (args[0] === 'consolidate')
3349
+ return runBrainConsolidate(args.slice(1));
3350
+ if (args[0] === 'metrics')
3351
+ return runBrainMetrics(args.slice(1));
3352
+ if (args[0] === 'final')
3353
+ return runBrainFinal(args.slice(1));
338
3354
  if (args[0] !== 'init') {
339
- console.log(`ใช้: sanook brain init [path] สร้างโครงสร้าง second-brain (Obsidian vault)
3355
+ console.log(`ใช้:
3356
+ sanook brain init [path] สร้างโครงสร้าง second-brain (Obsidian vault)
3357
+ sanook brain doctor ตรวจ health ของ second-brain ที่ config.brainPath
3358
+ sanook brain context แสดง context ที่ Sanook จะ inject (project auto-detect จาก cwd)
3359
+ sanook brain context --task "..." ดู retrieval hits ต่อ task
3360
+ sanook brain context --project <slug> บังคับ project workspace
3361
+ sanook brain projects list แสดง Projects/<slug>/ + repo_path
3362
+ sanook brain eval รัน second-brain benchmark sanity checks
3363
+ sanook brain review curator review: inbox, packs, sessions, evals, note hygiene
3364
+ sanook brain pack list แสดง context packs ใน Shared/Context-Packs/
3365
+ sanook brain pack show <name> แสดง load order / done criteria ของ pack
3366
+ sanook brain new project --title "..." --repo /path [--verify "..."]
3367
+ sanook brain new <type> [--title "..."] สร้างโน้ตจาก template
3368
+ sanook brain repair [--dry-run] แก้ one-line fixes หลัง doctor/review
3369
+ sanook brain consolidate [--apply] [--apply --archive] [--memory]
3370
+ sleep-time consolidation (dry-run default)
3371
+ sanook brain metrics [--no-retrieval]
3372
+ vault counts, stale notes, index freshness, retrieval coverage
3373
+ sanook brain final --task "..." [--from-diff] [--lite]
3374
+ สร้าง final gate note ใน Sessions
3375
+
340
3376
  ไม่ใส่ path → wizard ถาม path + ตัวตน
341
3377
  -y, --yes ใช้ค่า default ทั้งหมด (ต้องระบุ path)`);
342
3378
  return;
@@ -351,7 +3387,9 @@ async function runBrain(args) {
351
3387
  return;
352
3388
  }
353
3389
  const { scaffoldBrain, BRAIN_DEFAULTS, expandHome, wireBrainMcp } = await import('./brain.js');
354
- const target = expandHome(pathArg ?? join(homedir(), 'Documents', BRAIN_DEFAULTS.vaultName));
3390
+ // resolve to absolute before persisting — getBrainPath() is later read from an arbitrary cwd,
3391
+ // so a relative path (e.g. "./vault") would resolve differently per run
3392
+ const target = resolve(expandHome(pathArg ?? join(homedir(), 'Documents', BRAIN_DEFAULTS.vaultName)));
355
3393
  const today = new Date().toISOString().slice(0, 10);
356
3394
  try {
357
3395
  const res = await scaffoldBrain(target, { ...BRAIN_DEFAULTS, today });
@@ -379,7 +3417,22 @@ async function readStdin() {
379
3417
  async function runConfig(args) {
380
3418
  const { readGlobalConfigRaw, patchGlobalConfig } = await import('./config.js');
381
3419
  const [action, key, ...rest] = args;
382
- const ALLOWED = ['model', 'fallbackModel', 'budgetUsd', 'maxSteps', 'permissionMode', 'brainPath', 'pricing', 'cacheTtl', 'compaction', 'thinking', 'summaryModel'];
3420
+ const ALLOWED = [
3421
+ 'model',
3422
+ 'fallbackModel',
3423
+ 'budgetUsd',
3424
+ 'maxSteps',
3425
+ 'permissionMode',
3426
+ 'brainPath',
3427
+ 'pricing',
3428
+ 'cacheTtl',
3429
+ 'compaction',
3430
+ 'contextCompression',
3431
+ 'thinking',
3432
+ 'summaryModel',
3433
+ 'embeddingModel',
3434
+ 'personality',
3435
+ ];
383
3436
  if (action === 'set') {
384
3437
  if (!key || rest.length === 0) {
385
3438
  console.error(`ใช้: ${BRAND.cliName} config set <key> <value> (key: ${ALLOWED.join(' | ')})`);
@@ -392,8 +3445,8 @@ async function runConfig(args) {
392
3445
  const raw = rest.join(' ');
393
3446
  let value = raw;
394
3447
  if (key === 'budgetUsd') {
395
- const n = Number(raw);
396
- if (!Number.isFinite(n) || n <= 0) {
3448
+ const n = parseBudgetUsd(raw);
3449
+ if (n === undefined) {
397
3450
  console.error('budgetUsd ต้องเป็นตัวเลขบวก เช่น 0.25');
398
3451
  process.exit(1);
399
3452
  }
@@ -419,20 +3472,31 @@ async function runConfig(args) {
419
3472
  console.error('compaction ต้องเป็น truncate หรือ summarize');
420
3473
  process.exit(1);
421
3474
  }
3475
+ else if (key === 'contextCompression' && raw !== 'off' && raw !== 'selective' && raw !== 'headroom') {
3476
+ console.error('contextCompression ต้องเป็น off, selective หรือ headroom');
3477
+ process.exit(1);
3478
+ }
3479
+ else if (key === 'brainPath') {
3480
+ // store absolute — getBrainPath() is read from arbitrary cwd, so a relative path drifts
3481
+ const { expandHome } = await import('./brain.js');
3482
+ value = resolve(expandHome(raw.trim()));
3483
+ }
422
3484
  else if (key === 'thinking') {
423
3485
  // เก็บเป็น 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;
3486
+ value = parseThinkingConfigValue(raw);
3487
+ if (value === undefined) {
3488
+ console.error('thinking ต้องเป็น on/off, true/false, yes/no หรือ budget tokens (integer บวก เช่น 4000)');
3489
+ process.exit(1);
3490
+ }
3491
+ }
3492
+ else if (key === 'personality') {
3493
+ const { normalizePersonalityName, personalityListText } = await import('./personality.js');
3494
+ const name = normalizePersonalityName(raw);
3495
+ if (!name) {
3496
+ console.error(`personality ไม่รู้จัก: ${raw}\n${personalityListText()}`);
3497
+ process.exit(1);
435
3498
  }
3499
+ value = name === 'none' ? undefined : name;
436
3500
  }
437
3501
  else if (key === 'pricing') {
438
3502
  try {
@@ -459,33 +3523,24 @@ async function runIndex(_args) {
459
3523
  const { reindex } = await import('./search/indexer.js');
460
3524
  console.log('indexing…');
461
3525
  const r = await reindex();
3526
+ const { resetSearchCaches } = await import('./search/engine.js');
3527
+ resetSearchCaches();
462
3528
  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)'}`);
3529
+ `memory=${r.memory} sessions=${r.sessions} skills=${r.skills} vectors=${r.vectors}\n` +
3530
+ `vault: ${r.vaultPath ?? '(not set — `' + BRAND.cliName + ' brain init` or set config.brainPath)'}`);
464
3531
  }
465
3532
  /** sanook search "<query>" [--mode ..] [--limit N] [--source a,b] — one-shot ranked search */
466
3533
  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) {
3534
+ const { parseSearchArgs } = await import('./search/cli.js');
3535
+ const parsed = parseSearchArgs(args);
3536
+ if (!parsed.ok) {
3537
+ console.error(parsed.message);
484
3538
  console.error(`ใช้: ${BRAND.cliName} search "<query>" [--mode auto|fts|semantic|hybrid] [--limit N] [--source vault,memory]`);
485
3539
  process.exit(1);
486
3540
  }
3541
+ const { query, mode, limit, sources } = parsed.value;
487
3542
  const { search } = await import('./search/engine.js');
488
- const res = await search(query, { mode: mode, limit, sources: sources });
3543
+ const res = await search(query, { mode, limit, sources });
489
3544
  if (res.degraded)
490
3545
  console.log(`${DIM}(mode=${res.mode}, degraded: ${res.degraded})${RESET}`);
491
3546
  else
@@ -506,24 +3561,213 @@ async function runMcpServe() {
506
3561
  const { runMcpServer } = await import('./mcp-server.js');
507
3562
  await runMcpServer();
508
3563
  }
509
- /** sanook mcp [list | add <name> <command> [args...] | remove <name>] — จัดการ ~/.sanook/mcp.json */
3564
+ /** sanook mcp [search|info|install|test|doctor|enable|disable|list|add|remove] — จัดการ ~/.sanook/mcp.json */
510
3565
  async function runMcp(args) {
3566
+ const readConfig = async (path) => {
3567
+ try {
3568
+ const parsed = JSON.parse(await readFile(path, 'utf8'));
3569
+ return { mcpServers: parsed.mcpServers ?? {} };
3570
+ }
3571
+ catch {
3572
+ return { mcpServers: {} };
3573
+ }
3574
+ };
3575
+ const writeConfig = async (path, cfg) => {
3576
+ await mkdir(dirname(path), { recursive: true });
3577
+ await writeFile(path, `${JSON.stringify(cfg, null, 2)}\n`, { mode: 0o600 });
3578
+ await chmod(path, 0o600).catch(() => { });
3579
+ };
511
3580
  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 ?? {} };
3581
+ let cfg = await readConfig(mcpPath);
3582
+ const [action = 'list', ...rest] = args;
3583
+ const positionals = (items, valueFlags = new Set()) => {
3584
+ const out = [];
3585
+ for (let i = 0; i < items.length; i++) {
3586
+ const item = items[i];
3587
+ if (!item.startsWith('--')) {
3588
+ out.push(item);
3589
+ continue;
3590
+ }
3591
+ const flag = item.includes('=') ? item.slice(0, item.indexOf('=')) : item;
3592
+ if (!item.includes('=') && valueFlags.has(flag) && items[i + 1])
3593
+ i++;
3594
+ }
3595
+ return out;
3596
+ };
3597
+ if (action === 'search') {
3598
+ const { searchMcpRegistry, formatRegistrySearch, parseMcpRegistrySearchArgs } = await import('./mcp-registry.js');
3599
+ const parsed = parseMcpRegistrySearchArgs(rest);
3600
+ if (!parsed.ok) {
3601
+ console.error(parsed.message);
3602
+ process.exit(1);
3603
+ }
3604
+ const result = await searchMcpRegistry(parsed.value.query, { limit: parsed.value.limit, cursor: parsed.value.cursor });
3605
+ console.log(formatRegistrySearch(result));
3606
+ return;
516
3607
  }
517
- catch {
518
- /* ยังไม่มีไฟล์ */
3608
+ if (action === 'info') {
3609
+ const { getMcpRegistryServer, formatRegistryInfo } = await import('./mcp-registry.js');
3610
+ const name = positionals(rest, new Set(['--version']))[0];
3611
+ const versionArg = rest.find((item) => item.startsWith('--version='));
3612
+ const version = versionArg?.slice('--version='.length) ?? (rest.includes('--version') ? rest[rest.indexOf('--version') + 1] : undefined);
3613
+ if (!name) {
3614
+ console.error(`ใช้: ${BRAND.cliName} mcp info <registry-server-name> [--version=x.y.z]`);
3615
+ process.exit(1);
3616
+ }
3617
+ const server = await getMcpRegistryServer(name, { version });
3618
+ if (!server) {
3619
+ console.error(`ไม่เจอ MCP registry server: ${name}`);
3620
+ process.exit(1);
3621
+ }
3622
+ console.log(formatRegistryInfo(server));
3623
+ return;
3624
+ }
3625
+ if (action === 'preset') {
3626
+ const { formatPreset } = await import('./mcp-registry.js');
3627
+ console.log(formatPreset(rest[0]));
3628
+ return;
3629
+ }
3630
+ if (action === 'install') {
3631
+ const { buildMcpInstallPlan, getMcpRegistryServer, parseKeyValueList, formatRegistryInfo, parseMcpRegistryInstallArgs, } = await import('./mcp-registry.js');
3632
+ const parsedInstall = parseMcpRegistryInstallArgs(rest);
3633
+ if (!parsedInstall.ok) {
3634
+ console.error(parsedInstall.message);
3635
+ process.exit(1);
3636
+ }
3637
+ const { name, alias, transport, version, env, headers, project } = parsedInstall.value;
3638
+ if (alias && !isValidMcpServerName(alias)) {
3639
+ console.error('ชื่อ MCP server ต้องเป็น a-z/A-Z/0-9/_/- ความยาวไม่เกิน 64 และห้ามใช้ชื่อพิเศษ');
3640
+ process.exit(1);
3641
+ }
3642
+ if (transport && !['auto', 'remote', 'stdio'].includes(transport)) {
3643
+ console.error('--transport ต้องเป็น auto, remote, หรือ stdio');
3644
+ process.exit(1);
3645
+ }
3646
+ const server = await getMcpRegistryServer(name, { version });
3647
+ if (!server) {
3648
+ console.error(`ไม่เจอ MCP registry server: ${name}`);
3649
+ process.exit(1);
3650
+ }
3651
+ const plan = buildMcpInstallPlan(server, {
3652
+ alias,
3653
+ transport,
3654
+ env: parseKeyValueList(env),
3655
+ headers: parseKeyValueList(headers),
3656
+ });
3657
+ if (!plan.ok) {
3658
+ console.log(formatRegistryInfo(server));
3659
+ console.error(`\nยัง install ไม่ได้: ต้องระบุ ${plan.missing.join(', ') || 'transport/package ที่รองรับ'}`);
3660
+ if (plan.missing.some((item) => item.startsWith('env:')))
3661
+ console.error(`ตัวอย่าง: ${BRAND.cliName} mcp install ${name} --env KEY=value`);
3662
+ if (plan.missing.some((item) => item.startsWith('header:')))
3663
+ console.error(`ตัวอย่าง: ${BRAND.cliName} mcp install ${name} --header Authorization='Bearer ...'`);
3664
+ for (const warning of plan.warnings)
3665
+ console.error(`warning: ${warning}`);
3666
+ process.exit(1);
3667
+ }
3668
+ let targetPath = mcpPath;
3669
+ if (project) {
3670
+ // Use trust status (not projectConfigPathIfTrusted, which requires a PRE-EXISTING file) so the
3671
+ // FIRST project-scoped install into a trusted project can create .sanook/mcp.json.
3672
+ const { projectTrustStatus, projectRoot } = await import('./trust.js');
3673
+ const { appProjectPath } = await import('./brand.js');
3674
+ const root = await projectRoot(process.cwd());
3675
+ const trust = await projectTrustStatus(root);
3676
+ if (!trust.trusted) {
3677
+ console.error(`project MCP ต้อง trust ก่อน: ${BRAND.cliName} trust add`);
3678
+ process.exit(1);
3679
+ }
3680
+ targetPath = appProjectPath(root, 'mcp.json'); // may not exist yet — writeConfig creates it
3681
+ }
3682
+ cfg = await readConfig(targetPath);
3683
+ cfg.mcpServers[plan.alias] = plan.config;
3684
+ await writeConfig(targetPath, cfg);
3685
+ console.log(`ติดตั้ง MCP "${plan.alias}" จาก ${server.name} (${plan.source}) → ${targetPath}`);
3686
+ const { inferRegistryServerRisk, formatMcpRiskLabel } = await import('./mcp-risk.js');
3687
+ console.log(`risk: ${formatMcpRiskLabel(inferRegistryServerRisk(server))}`);
3688
+ if (plan.requirements.length)
3689
+ console.log(`requirements: ${plan.requirements.join(', ')}`);
3690
+ for (const warning of plan.warnings)
3691
+ console.log(`warning: ${warning}`);
3692
+ console.log(`ทดสอบ: ${BRAND.cliName} mcp test ${plan.alias}`);
3693
+ return;
3694
+ }
3695
+ if (action === 'test' || action === 'doctor') {
3696
+ const { loadMcpConfig, probeMcpServer, isMcpServerEnabled } = await import('./mcp.js');
3697
+ const { inferConfiguredServerRisk, formatMcpRiskLabel } = await import('./mcp-risk.js');
3698
+ const logs = [];
3699
+ const merged = await loadMcpConfig((m) => logs.push(m));
3700
+ const explicitName = action === 'test' && rest[0] ? rest[0] : undefined;
3701
+ const names = explicitName
3702
+ ? [explicitName]
3703
+ : action === 'test'
3704
+ ? Object.keys(merged).filter((n) => isMcpServerEnabled(merged[n]))
3705
+ : Object.keys(merged);
3706
+ if (!names.length) {
3707
+ console.log(`ยังไม่มี MCP server — เพิ่ม: ${BRAND.cliName} mcp search github`);
3708
+ return;
3709
+ }
3710
+ let failed = false;
3711
+ if (logs.length)
3712
+ for (const log of logs)
3713
+ console.log(`note: ${log}`);
3714
+ for (const n of names) {
3715
+ const server = merged[n];
3716
+ if (!server) {
3717
+ failed = true;
3718
+ console.log(`[FAIL] ${n} — ไม่เจอใน config`);
3719
+ continue;
3720
+ }
3721
+ if (!explicitName && action === 'doctor' && !isMcpServerEnabled(server)) {
3722
+ console.log(`[SKIP] ${n} (disabled)`);
3723
+ continue;
3724
+ }
3725
+ const probe = await probeMcpServer(server);
3726
+ const risk = formatMcpRiskLabel(inferConfiguredServerRisk(n, server, probe.tools));
3727
+ if (probe.ok) {
3728
+ console.log(`[PASS] ${n} (${probe.transport}) — ${probe.tools.length} tool(s) · risk: ${risk}`);
3729
+ for (const tool of probe.tools.slice(0, action === 'doctor' ? 8 : 30))
3730
+ console.log(` - ${tool.name}${tool.description ? ` — ${tool.description}` : ''}`);
3731
+ if (action === 'doctor' && probe.tools.length > 8)
3732
+ console.log(` ... ${probe.tools.length - 8} more`);
3733
+ }
3734
+ else {
3735
+ failed = true;
3736
+ console.log(`[FAIL] ${n} (${probe.transport}) — ${probe.error} · risk: ${risk}`);
3737
+ if (probe.authHints?.length)
3738
+ for (const hint of probe.authHints)
3739
+ console.log(` hint: ${hint}`);
3740
+ }
3741
+ }
3742
+ if (failed)
3743
+ process.exit(1);
3744
+ return;
3745
+ }
3746
+ if (action === 'enable' || action === 'disable') {
3747
+ const { findMcpServerConfigPath } = await import('./mcp.js');
3748
+ const [name] = rest;
3749
+ if (!name) {
3750
+ console.error(`ใช้: ${BRAND.cliName} mcp ${action} <name>`);
3751
+ process.exit(1);
3752
+ }
3753
+ const targetPath = await findMcpServerConfigPath(name);
3754
+ if (!targetPath) {
3755
+ console.error(`ไม่เจอ MCP server "${name}" ใน global หรือ trusted project config`);
3756
+ process.exit(1);
3757
+ }
3758
+ const targetCfg = await readConfig(targetPath);
3759
+ if (!targetCfg.mcpServers[name]) {
3760
+ console.error(`ไม่เจอ MCP server "${name}" ใน ${targetPath}`);
3761
+ process.exit(1);
3762
+ }
3763
+ targetCfg.mcpServers[name] = { ...targetCfg.mcpServers[name], enabled: action === 'enable' };
3764
+ await writeConfig(targetPath, targetCfg);
3765
+ console.log(`${action === 'enable' ? 'เปิด' : 'ปิด'} MCP server "${name}" แล้ว → ${targetPath}`);
3766
+ console.log(`ทดสอบ: ${BRAND.cliName} mcp test ${name}`);
3767
+ return;
519
3768
  }
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
3769
  if (action === 'add') {
3770
+ const [name, command, ...cmdArgs] = rest;
527
3771
  if (!name || !command) {
528
3772
  console.error(`ใช้: ${BRAND.cliName} mcp add <name> <command> [args...] (เช่น: mcp add fs npx -y @modelcontextprotocol/server-filesystem /path)`);
529
3773
  console.error(` remote: ${BRAND.cliName} mcp add <name> https://host/mcp (Streamable-HTTP)`);
@@ -535,29 +3779,60 @@ async function runMcp(args) {
535
3779
  }
536
3780
  // command เป็น http(s):// → remote MCP (Streamable-HTTP), ไม่งั้น stdio
537
3781
  cfg.mcpServers[name] = /^https?:\/\//.test(command) ? { url: command } : { command, args: cmdArgs };
538
- await write();
3782
+ await writeConfig(mcpPath, cfg);
539
3783
  console.log(`เพิ่ม MCP server "${name}"${/^https?:\/\//.test(command) ? ' (remote http)' : ''}`);
540
3784
  return;
541
3785
  }
542
3786
  if (action === 'remove' || action === 'rm') {
3787
+ const [name] = rest;
543
3788
  if (name && cfg.mcpServers[name]) {
544
3789
  delete cfg.mcpServers[name];
545
- await write();
546
- console.log(`ลบ MCP server "${name}" แล้ว`);
3790
+ await writeConfig(mcpPath, cfg);
3791
+ console.log(`ลบ MCP server "${name}" แล้ว (${mcpPath})`);
547
3792
  }
548
3793
  else
549
3794
  console.log(`ไม่เจอ MCP server "${name ?? ''}"`);
550
3795
  return;
551
3796
  }
552
- const names = Object.keys(cfg.mcpServers);
3797
+ if (action !== 'list') {
3798
+ console.log(`ใช้: ${BRAND.cliName} mcp [search|info|install|test|doctor|enable|disable|preset|list|add|remove|serve]`);
3799
+ return;
3800
+ }
3801
+ const { loadMcpConfig, isMcpServerEnabled } = await import('./mcp.js');
3802
+ const { inferConfiguredServerRisk, formatMcpRiskLabel } = await import('./mcp-risk.js');
3803
+ const logs = [];
3804
+ const merged = await loadMcpConfig((m) => logs.push(m));
3805
+ const names = Object.keys(merged);
553
3806
  if (!names.length) {
554
- console.log(`ยังไม่มี MCP server — เพิ่ม: ${BRAND.cliName} mcp add <name> <command> [args...]`);
3807
+ console.log(`ยังไม่มี MCP server — เพิ่ม: ${BRAND.cliName} mcp search github`);
555
3808
  return;
556
3809
  }
3810
+ if (logs.length)
3811
+ for (const log of logs)
3812
+ console.log(`note: ${log}`);
557
3813
  console.log(`${names.length} MCP servers:`);
558
3814
  for (const n of names) {
559
- const s = cfg.mcpServers[n];
560
- console.log(` ${n} — ${s.url ? `${s.url} (http)` : `${s.command} ${(s.args ?? []).join(' ')}`}`);
3815
+ const s = merged[n];
3816
+ const enabled = isMcpServerEnabled(s);
3817
+ const risk = formatMcpRiskLabel(inferConfiguredServerRisk(n, s));
3818
+ console.log(` ${n}${enabled ? '' : ' (disabled)'} — risk: ${risk} — ${s.url ? `${s.url} (http)` : `${s.command} ${(s.args ?? []).join(' ')}`}`);
3819
+ }
3820
+ if (rest.includes('--tools')) {
3821
+ const { probeMcpServer } = await import('./mcp.js');
3822
+ for (const n of names) {
3823
+ if (!isMcpServerEnabled(merged[n])) {
3824
+ console.log(`\n[SKIP] ${n} tools (disabled)`);
3825
+ continue;
3826
+ }
3827
+ const probe = await probeMcpServer(merged[n]);
3828
+ const risk = formatMcpRiskLabel(inferConfiguredServerRisk(n, merged[n], probe.tools));
3829
+ console.log(`\n${probe.ok ? '[PASS]' : '[FAIL]'} ${n} tools${probe.ok ? ` (${probe.tools.length})` : ` — ${probe.error}`} · risk: ${risk}`);
3830
+ if (probe.authHints?.length)
3831
+ for (const hint of probe.authHints)
3832
+ console.log(` hint: ${hint}`);
3833
+ for (const tool of probe.tools.slice(0, 30))
3834
+ console.log(` - ${tool.name}`);
3835
+ }
561
3836
  }
562
3837
  }
563
3838
  /** sanook trust [status|add|remove] — trust project .sanook content that can steer/execute code */
@@ -642,6 +3917,66 @@ async function askYesNo(question) {
642
3917
  rl.close();
643
3918
  }
644
3919
  }
3920
+ async function askText(question) {
3921
+ const rl = createInterface({ input: process.stdin, output: process.stdout });
3922
+ try {
3923
+ return (await rl.question(question)).trim();
3924
+ }
3925
+ finally {
3926
+ rl.close();
3927
+ }
3928
+ }
3929
+ function extractValue(args, ...names) {
3930
+ const rest = [];
3931
+ let value;
3932
+ for (let i = 0; i < args.length; i++) {
3933
+ const a = args[i];
3934
+ const eq = names.find((name) => a.startsWith(`${name}=`));
3935
+ if (eq) {
3936
+ value ??= a.slice(eq.length + 1);
3937
+ continue;
3938
+ }
3939
+ if (names.includes(a)) {
3940
+ if (args[i + 1] && !args[i + 1].startsWith('-')) {
3941
+ value ??= args[i + 1];
3942
+ i++;
3943
+ }
3944
+ continue;
3945
+ }
3946
+ rest.push(a);
3947
+ }
3948
+ return { value, rest };
3949
+ }
3950
+ function stripBooleanFlags(args, ...names) {
3951
+ return args.filter((a) => !names.includes(a));
3952
+ }
3953
+ function positionalArgs(args, valueFlags = []) {
3954
+ const out = [];
3955
+ for (let i = 0; i < args.length; i++) {
3956
+ const a = args[i];
3957
+ if (valueFlags.some((name) => a.startsWith(`${name}=`)))
3958
+ continue;
3959
+ if (valueFlags.includes(a)) {
3960
+ i++;
3961
+ continue;
3962
+ }
3963
+ if (a.startsWith('-'))
3964
+ continue;
3965
+ out.push(a);
3966
+ }
3967
+ return out;
3968
+ }
3969
+ function argValue(args, ...names) {
3970
+ for (const name of names) {
3971
+ const eq = args.find((a) => a.startsWith(`${name}=`));
3972
+ if (eq)
3973
+ return eq.slice(name.length + 1);
3974
+ const idx = args.indexOf(name);
3975
+ if (idx !== -1 && args[idx + 1] && !args[idx + 1].startsWith('--'))
3976
+ return args[idx + 1];
3977
+ }
3978
+ return undefined;
3979
+ }
645
3980
  async function maybePromptForInteractiveUpdate() {
646
3981
  if (envFlag(BRAND_ENV.disableUpdateCheck) || process.env.CI)
647
3982
  return;
@@ -686,8 +4021,12 @@ function headlessKeyHint(modelSpec) {
686
4021
  `⚠ ยังไม่มี API key สำหรับ ${cfg.label} (${cfg.envVar})`,
687
4022
  `เริ่มใช้งาน:`,
688
4023
  ` • รัน "${BRAND.cliName}" (ไม่ใส่ task) → setup wizard ทีละขั้น (แนะนำ)`,
4024
+ ` • หรือ: ${BRAND.cliName} auth add ${provider} --api-key "..." --use${url ? ` · เอา key ที่: ${url}` : ''}`,
689
4025
  ` • หรือ: export ${cfg.envVar}="..."${url ? ` · เอา key ที่: ${url}` : ''}`,
690
4026
  ];
4027
+ if (provider === 'openai') {
4028
+ lines.push(` • ถ้าต้องการใช้ ChatGPT plan ไม่ใช้ API key: ใช้ ${BRAND.cliName} -m codex แล้วรัน codex login`);
4029
+ }
691
4030
  const other = detectEnvProvider();
692
4031
  if (other && other.provider !== provider) {
693
4032
  lines.push(` • เจอ key ของ ${other.label} อยู่แล้ว → ใช้เลย: ${BRAND.cliName} -m ${other.provider} "<task>"`);
@@ -703,11 +4042,15 @@ async function main() {
703
4042
  process.exit(1);
704
4043
  }
705
4044
  const argv = process.argv.slice(2);
706
- if (argv.length === 1 && (argv[0] === '-v' || argv[0] === '--version')) {
4045
+ // --version/--help win in ANY position before `--` (so `sanook --help foo`, `-m x -h` work),
4046
+ // not only as the sole argument. (Quoted prompts like `sanook "explain --help"` are one token → unaffected.)
4047
+ const optArgsEnd = argv.indexOf('--');
4048
+ const topOpts = optArgsEnd === -1 ? argv : argv.slice(0, optArgsEnd);
4049
+ if (topOpts.includes('-v') || topOpts.includes('--version')) {
707
4050
  console.log(VERSION);
708
4051
  return;
709
4052
  }
710
- if (argv.length === 1 && (argv[0] === '-h' || argv[0] === '--help')) {
4053
+ if (topOpts.includes('-h') || topOpts.includes('--help')) {
711
4054
  console.log(HELP);
712
4055
  return;
713
4056
  }
@@ -722,18 +4065,61 @@ async function main() {
722
4065
  // โหลด API key จาก ~/.sanook/auth.json เข้า env (ไม่ override env ที่ตั้งไว้แล้ว)
723
4066
  await loadKeysIntoEnv();
724
4067
  process.on('exit', closeMcp); // ปิด MCP server (kill child) ตอนจบ
4068
+ // management surfaces (Sanook-branded) — setup/model/gateway/status/tools/send
4069
+ if (argv[0] === '-z')
4070
+ return runPureOneShot(argv.slice(1));
4071
+ if (argv[0] === 'chat')
4072
+ return runChat(argv.slice(1));
4073
+ if (argv[0] === 'plan')
4074
+ return runPlan(argv.slice(1));
4075
+ if (argv[0] === 'setup')
4076
+ return runSetup(argv.slice(1));
4077
+ if (argv[0] === 'model' && (argv.length === 1 || argv[1].startsWith('--')))
4078
+ return startModelSetup();
4079
+ if (argv[0] === 'gateway')
4080
+ return runGateway(argv.slice(1));
4081
+ if (argv[0] === 'status' && (argv.length === 1 || argv[1].startsWith('--')))
4082
+ return runStatus();
4083
+ if (argv[0] === 'auth')
4084
+ return runAuth(argv.slice(1));
4085
+ if (argv[0] === 'sessions' || argv[0] === 'session')
4086
+ return runSessions(argv.slice(1));
4087
+ if (argv[0] === 'insights')
4088
+ return runInsights(argv.slice(1));
4089
+ if (argv[0] === 'memory' && ['log', 'stats', undefined].includes(argv[1]))
4090
+ return runMemory(argv.slice(1));
4091
+ if (argv[0] === 'dump')
4092
+ return runDump(argv.slice(1));
4093
+ if (argv[0] === 'prompt-size' && (argv.length === 1 || argv[1].startsWith('--')))
4094
+ return runPromptSize(argv.slice(1));
4095
+ if (argv[0] === 'runtimes' && (argv.length === 1 || argv[1].startsWith('--')))
4096
+ return runRuntimes(argv.slice(1));
4097
+ if (argv[0] === 'dashboard' && (argv.length === 1 || !argv[1].startsWith('-') || argv[1] === '--port')) {
4098
+ return runDashboard(argv.slice(1));
4099
+ }
4100
+ if (argv[0] === 'web' && ['status', 'doctor', 'fetch', 'search', 'setup', undefined].includes(argv[1]))
4101
+ return runWeb(argv.slice(1));
4102
+ if (argv[0] === 'tools' && (argv.length === 1 || argv[1].startsWith('--')))
4103
+ return runTools(argv.slice(1));
4104
+ if (argv[0] === 'send')
4105
+ return runSend(argv.slice(1));
4106
+ if (argv[0] === 'webhook' || argv[0] === 'webhooks')
4107
+ return runWebhook(argv.slice(1));
725
4108
  // subcommands: serve · cron — match เฉพาะรูปแบบที่ถูกต้อง กัน prompt unquoted ("serve coffee") misfire
726
- if (argv[0] === 'serve' && (argv.length === 1 || argv[1].startsWith('--')))
4109
+ if (hasServeCommandRequest(argv))
727
4110
  return runServe(argv.slice(1));
728
4111
  if (argv[0] === 'cron' && ['add', 'list', 'rm', 'remove', undefined].includes(argv[1])) {
729
4112
  return runCron(argv.slice(1));
730
4113
  }
731
- if (argv[0] === 'skill' && ['list', 'add', 'remove', 'rm', undefined].includes(argv[1])) {
4114
+ if (argv[0] === 'skill' && ['list', 'add', 'install', 'remove', 'rm', undefined].includes(argv[1])) {
732
4115
  return runSkill(argv.slice(1));
733
4116
  }
4117
+ if (argv[0] === 'init' && (argv.length === 1 || argv[1].startsWith('-')))
4118
+ return runInit(argv.slice(1));
734
4119
  if (argv[0] === 'models')
735
4120
  return runModels(argv.slice(1));
736
- if (argv[0] === 'brain' && ['init', undefined].includes(argv[1]))
4121
+ if (argv[0] === 'brain' &&
4122
+ ['init', 'doctor', 'context', 'eval', 'review', 'projects', 'pack', 'new', 'repair', 'consolidate', 'metrics', 'final', undefined].includes(argv[1]))
737
4123
  return runBrain(argv.slice(1));
738
4124
  if (argv[0] === 'config' && ['get', 'set', 'list', undefined].includes(argv[1]))
739
4125
  return runConfig(argv.slice(1));
@@ -743,17 +4129,28 @@ async function main() {
743
4129
  return runSearch(argv.slice(1));
744
4130
  if (argv[0] === 'mcp' && argv[1] === 'serve')
745
4131
  return runMcpServe();
746
- if (argv[0] === 'mcp' && ['add', 'list', 'remove', 'rm', undefined].includes(argv[1]))
4132
+ if (argv[0] === 'mcp' && ['add', 'list', 'remove', 'rm', 'search', 'info', 'install', 'test', 'doctor', 'enable', 'disable', 'preset', undefined].includes(argv[1]))
747
4133
  return runMcp(argv.slice(1));
748
4134
  if (argv[0] === 'trust' && ['status', 'add', 'remove', 'rm', undefined].includes(argv[1]))
749
4135
  return runTrust(argv.slice(1));
750
- const { model, budget, json, quiet, prompt: argPrompt, planMode, yes } = parseArgs(argv);
4136
+ // A management command word whose subcommand didn't match any route above → don't silently
4137
+ // fall through and run it as an LLM task (costly + confusing). These words intentionally
4138
+ // require a valid subcommand; an NL prompt starting with one can still be quoted as a single arg.
4139
+ const MANAGEMENT_WORDS = new Set(['config', 'mcp', 'brain', 'web', 'trust', 'cron', 'skill', 'init', 'dashboard']);
4140
+ if (argv[0] && MANAGEMENT_WORDS.has(argv[0]) && argv[1] && !argv[1].startsWith('-')) {
4141
+ console.error(`${BRAND.cliName}: ไม่รู้จัก subcommand "${argv[0]} ${argv[1]}" — ดูวิธีใช้: ${BRAND.cliName} --help`);
4142
+ process.exit(1);
4143
+ }
4144
+ const { model, budget, budgetInvalid, json, quiet, prompt: argPrompt, planMode, yes, resume } = parseArgs(argv);
4145
+ if (budgetInvalid)
4146
+ process.stderr.write(`${BRAND.cliName}: ⚠ --budget ไม่ถูกต้อง (ต้องเป็นจำนวนบวก) — รันต่อโดยไม่มี spend cap\n`);
4147
+ const resumeSession = await requestedResumeSession(argv, resume);
751
4148
  const budgetUsd = Number.isFinite(budget) ? budget : undefined;
752
4149
  // stdin piping: `git diff | sanook "review this"` → ผนวก stdin เข้า prompt (headless/CI)
753
4150
  const piped = process.stdin.isTTY ? '' : (await readStdin()).trim();
754
4151
  const prompt = piped ? `${argPrompt}\n\n<stdin>\n${piped}\n</stdin>`.trim() : argPrompt;
755
4152
  if (prompt) {
756
- const config = await loadConfig({ model, budgetUsd });
4153
+ const config = await loadConfig({ model: model ?? resumeSession?.model, budgetUsd });
757
4154
  // headless + ยังไม่มี key → บอกวิธีเริ่มแบบ actionable แทนปล่อยให้ throw error ดิบ (กัน dead-end ของ flow ที่ README แนะนำ)
758
4155
  const noKey = headlessKeyHint(config.model);
759
4156
  if (noKey) {
@@ -761,11 +4158,8 @@ async function main() {
761
4158
  process.exit(1);
762
4159
  }
763
4160
  // --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;
768
- await runHeadless(config.model, prompt, config.budgetUsd, config.maxSteps, json, history, planMode, yes ? 'auto' : config.permissionMode, quiet, config.fallbackModel);
4161
+ const history = resumeSession?.messages ?? (await requestedContinuationHistory(argv));
4162
+ await runHeadless(config.model, prompt, config.budgetUsd, config.maxSteps, json, history, planMode, yes ? 'auto' : config.permissionMode, quiet, config.fallbackModel, planMode ? prompt : undefined);
769
4163
  return;
770
4164
  }
771
4165
  await maybePromptForInteractiveUpdate();
@@ -776,7 +4170,8 @@ async function main() {
776
4170
  const flagProvider = model ? parseSpec(model).provider : undefined;
777
4171
  const target = flagProvider ?? detectEnvProvider()?.provider;
778
4172
  const tcfg = target ? PROVIDERS[target] : undefined;
779
- if (target && tcfg && hasUsableEnvKey(target)) {
4173
+ const { providerCanSkipSetup } = await import('./first-run.js');
4174
+ if (target && tcfg && (await providerCanSkipSetup(target))) {
780
4175
  // มี key ใช้ได้จริง (ผ่าน policy ไม่ใช่ OAuth) → ข้าม wizard, ตั้ง default, บอกว่าพร้อมใช้
781
4176
  const { saveGlobalConfig } = await import('./config.js');
782
4177
  await saveGlobalConfig({ model: model ?? `${target}:${tcfg.models.default}`, provider: target });
@@ -786,11 +4181,13 @@ async function main() {
786
4181
  needsSetup = true; // ไม่มี provider ที่ key ใช้ได้ (หรือ -m provider ไม่มี key) → wizard (รัน Ink เดียวกับ REPL)
787
4182
  }
788
4183
  }
789
- const config = await loadConfig({ model, budgetUsd });
4184
+ const config = await loadConfig({ model: model ?? resumeSession?.model, budgetUsd });
4185
+ if (!needsSetup) {
4186
+ const { modelNeedsSetup } = await import('./first-run.js');
4187
+ needsSetup = await modelNeedsSetup(config.model);
4188
+ }
790
4189
  // --continue / -c → โหลด conversation ล่าสุดเข้า REPL (เดิม resume ได้แค่ headless)
791
- const initialHistory = argv.includes('--continue') || argv.includes('-c') || argv.includes('--continue-any')
792
- ? (await latestSession(argv.includes('--continue-any') ? null : process.cwd()))?.messages
793
- : undefined;
4190
+ const initialHistory = resumeSession?.messages ?? (await requestedContinuationHistory(argv));
794
4191
  const { startApp } = await import('./ui/render.js');
795
4192
  startApp({
796
4193
  needsSetup,