sanook-cli 0.5.0 → 0.5.1

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
package/CHANGELOG.md CHANGED
@@ -2,6 +2,35 @@
2
2
 
3
3
  ## Unreleased
4
4
 
5
+ ### Fix: couldn't type in the REPL after first-run setup
6
+
7
+ The setup wizard and the REPL were two separate Ink renders (`render(SetupWizard)` → `unmount` → `render(App)`). After the first Ink instance unmounted, stdin raw-mode/keypress handling didn't reattach to the second, so the chat input was dead — you couldn't type anything. Now the wizard, the brain wizard, and the REPL live under **one Ink render** (a `Root` component swaps screens), so stdin stays continuous and input works the moment the REPL appears. (Regression-tested with ink-testing-library: typed characters reach the input box; phase routing verified.)
8
+
9
+ ### Setup/first-run audit — 8 bugs found by adversarial review, all fixed
10
+
11
+ A multi-agent review of the first-run + setup + REPL flow surfaced (and independently verified) eight real, user-facing bugs — now fixed and regression-tested:
12
+
13
+ - **Empty API key silently completed setup.** `@inkjs/ui` PasswordInput fires `onSubmit` on Enter even when empty, so pressing Enter on a blank key advanced the wizard, finished, and saved *no key* — the first message then failed with "no API key" and no way back. The key step now rejects an empty submit (stays put, shows an inline error).
14
+ - **Wizard accepted OAuth/malformed keys it explicitly warned against.** The key step now runs the same `assertDirectApiKey` policy the runtime uses — paste a `sk-ant-oat…` subscription token and it's rejected *at the input* with a clear message, instead of being saved and blowing up later.
15
+ - **No way back from the key/model steps.** Picking the wrong provider dead-ended you into typing a key for the wrong service (Ctrl+C was the only escape). **Esc now returns to provider selection** from any step.
16
+ - **First-run env-detect trusted banned tokens.** An exported OAuth token (`ANTHROPIC_API_KEY=sk-ant-oat…`) made sanook print "✅ ready" and skip the wizard, then error on every message. `detectEnvProvider` now validates the key against policy (new `hasUsableEnvKey`) and falls through to the wizard when it's unusable.
17
+ - **First-run ignored an explicit `-m` flag.** `sanook -m groq` on a machine with only `OPENAI_API_KEY` printed "OpenAI ready" and ran a keyless Groq session. First-run now keys off the `-m` provider when given.
18
+ - **Codex auth step could wedge.** `detectCodex` had no timeout, so a hung `codex` binary left the step with no way forward. Added a 5s timeout (+ Esc always backs out).
19
+ - **Brain wizard name fields were pre-filled with the literal default** (`Owner` / `ผู้ช่วย`), so typing a custom name produced `OwnerPick`. Switched to a placeholder so the field starts empty (Enter still accepts the default).
20
+ - **Banner showed a stale model after `/model`.** It now tracks the live model.
21
+
22
+ ### Fix: duplicate / empty model choices in the setup wizard
23
+
24
+ `mergeModelOptions` only deduped *remote* model ids against the curated list — never the curated list against itself. So aliases pointing at the same id rendered as **two identical-looking choices** (e.g. `haiku — claude-haiku-4-5` and `fast — claude-haiku-4-5`; OpenAI's `smart`/`gpt` both → `gpt-5.5`), which also collided on React keys (`Encountered two children with the same key` → options could duplicate or vanish). It now groups by model id and merges the alias names into one option (`haiku / fast — claude-haiku-4-5`). Separately, the old code dropped the `default` alias entirely, which **emptied the model list for LM Studio** (`{ default: 'local-model' }`) and hid Ollama's `qwen3` — those models are now selectable again. Locked in with tests (every provider yields unique option values; LM Studio/Ollama are non-empty).
25
+
26
+ ### Setup wizard — better provider selection + working OpenAI Codex login
27
+
28
+ - **Provider menu**: each option now shows a one-line hint — `✓ เจอ key ใน env`, `local · ไม่ต้อง key`, `login ChatGPT · ไม่ใช้ API key`, or `ต้องมี API key` — and the list is ordered (popular cloud → others → local → Codex). The API-key step shows the expected key format.
29
+ - **OpenAI Codex (ChatGPT plan)**: picking Codex used to skip straight past auth. It now runs a dedicated step that detects whether the `codex` CLI is installed and logged in (reads `~/.codex/auth.json` — robust inside sandboxes where `codex login status` can panic), and guides you: `npm i -g @openai/codex` → `codex login` → re-check, then continues automatically once you're signed in. No API key required.
30
+ - **Codex runs**: `codex exec` now passes `--ask-for-approval never` (no hang waiting on approvals; safe under the default read-only sandbox) and removes `OPENAI_API_KEY` from the child env so it can't fight the ChatGPT-plan login (codex #2733/#3286). Verified the exact CLI surface against the official OpenAI Codex docs.
31
+
32
+ ## 0.5.0
33
+
5
34
  ### Install UX — `sanook doctor` + post-install guidance (the "`sanook` is not recognized" fix)
6
35
 
7
36
  The #1 first-run snag is `npm i sanook-cli` **without `-g`** → a local install that never lands on PATH, so typing `sanook` fails. Two root-cause fixes:
package/dist/bin.js CHANGED
@@ -1,7 +1,7 @@
1
1
  #!/usr/bin/env node
2
2
  import { runAgent } from './loop.js';
3
3
  import { redactKey } from './providers/keys.js';
4
- import { specKey, parseSpec, PROVIDERS, consoleUrl, detectEnvProvider } from './providers/registry.js';
4
+ import { specKey, parseSpec, PROVIDERS, consoleUrl, detectEnvProvider, hasUsableEnvKey } 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';
@@ -769,19 +769,21 @@ async function main() {
769
769
  return;
770
770
  }
771
771
  await maybePromptForInteractiveUpdate();
772
- // interactive — ครั้งแรก (ยังไม่มี config)
772
+ // interactive — ครั้งแรก (ยังไม่มี config): ถ้าไม่มี key ใช้ได้ใน env → ต้องโชว์ wizard
773
+ let needsSetup = false;
773
774
  if (await isFirstRun()) {
774
- const detected = detectEnvProvider();
775
- if (detected) {
776
- // มี API key ใน env แล้ว → ข้าม wizard, ตั้ง default ให้ตรง provider นั้น, บอกว่าพร้อมใช้
775
+ // provider เป้าหมาย: เคารพ -m ที่ user ใส่ก่อน (กันขึ้น "พร้อมใช้" ผิด provider), ไม่งั้น scan env ตามนิยม
776
+ const flagProvider = model ? parseSpec(model).provider : undefined;
777
+ const target = flagProvider ?? detectEnvProvider()?.provider;
778
+ const tcfg = target ? PROVIDERS[target] : undefined;
779
+ if (target && tcfg && hasUsableEnvKey(target)) {
780
+ // มี key ใช้ได้จริง (ผ่าน policy ไม่ใช่ OAuth) → ข้าม wizard, ตั้ง default, บอกว่าพร้อมใช้
777
781
  const { saveGlobalConfig } = await import('./config.js');
778
- await saveGlobalConfig({ model: `${detected.provider}:${detected.model}`, provider: detected.provider });
779
- console.log(`✅ เจอ ${detected.label} (${detected.envVar}) — พร้อมใช้เลย (ข้าม setup wizard)\n`);
782
+ await saveGlobalConfig({ model: model ?? `${target}:${tcfg.models.default}`, provider: target });
783
+ console.log(`✅ ${tcfg.label} พร้อมใช้เลย (ข้าม setup wizard)\n`);
780
784
  }
781
785
  else {
782
- const { startSetup } = await import('./ui/render.js'); // ไม่มี key → wizard ทีละขั้น
783
- await startSetup();
784
- console.log('✅ ตั้งค่าเสร็จ — พิมพ์งานในช่อง › ได้เลย (/help ดูคำสั่ง)\n');
786
+ needsSetup = true; // ไม่มี provider ที่ key ใช้ได้ (หรือ -m provider ไม่มี key) → wizard (รัน Ink เดียวกับ REPL)
785
787
  }
786
788
  }
787
789
  const config = await loadConfig({ model, budgetUsd });
@@ -789,13 +791,16 @@ async function main() {
789
791
  const initialHistory = argv.includes('--continue') || argv.includes('-c') || argv.includes('--continue-any')
790
792
  ? (await latestSession(argv.includes('--continue-any') ? null : process.cwd()))?.messages
791
793
  : undefined;
792
- const { startRepl } = await import('./ui/render.js');
793
- startRepl({
794
- initialModel: config.model,
795
- fallbackModel: config.fallbackModel,
796
- budgetUsd: config.budgetUsd,
797
- permissionMode: yes ? 'auto' : config.permissionMode,
798
- initialHistory,
794
+ const { startApp } = await import('./ui/render.js');
795
+ startApp({
796
+ needsSetup,
797
+ appProps: {
798
+ initialModel: config.model,
799
+ fallbackModel: config.fallbackModel,
800
+ budgetUsd: config.budgetUsd,
801
+ permissionMode: yes ? 'auto' : config.permissionMode,
802
+ initialHistory,
803
+ },
799
804
  });
800
805
  }
801
806
  main().catch((err) => {
@@ -6,8 +6,19 @@ import { join } from 'node:path';
6
6
  export async function detectCodex() {
7
7
  const hasBinary = await new Promise((resolve) => {
8
8
  const p = spawn('codex', ['--version'], { shell: process.platform === 'win32' });
9
- p.on('error', () => resolve(false));
10
- p.on('close', (code) => resolve(code === 0));
9
+ // timeout: binary ค้าง (shim รอ stdin / Gatekeeper stall ตอนรันครั้งแรกบน macOS) ไม่ให้ wizard ตัน
10
+ const timer = setTimeout(() => {
11
+ p.kill();
12
+ resolve(false);
13
+ }, 5000);
14
+ p.on('error', () => {
15
+ clearTimeout(timer);
16
+ resolve(false);
17
+ });
18
+ p.on('close', (code) => {
19
+ clearTimeout(timer);
20
+ resolve(code === 0);
21
+ });
11
22
  });
12
23
  if (!hasBinary) {
13
24
  return { installed: false, loggedIn: false, reason: 'ไม่พบ codex CLI — ติดตั้ง: npm i -g @openai/codex' };
@@ -26,16 +37,19 @@ export async function detectCodex() {
26
37
  * tolerant ต่อ malformed JSONL (codex bug #15451: --json ถูก ignore เมื่อมี tools active)
27
38
  */
28
39
  export async function runCodex(opts) {
29
- const args = ['exec', '--skip-git-repo-check', '--sandbox', opts.sandbox ?? 'read-only', '--json'];
40
+ // --ask-for-approval never: รัน non-interactive ไม่ค้างรอ approval (ปลอดภัยเพราะ default sandbox = read-only)
41
+ const args = ['exec', '--skip-git-repo-check', '--sandbox', opts.sandbox ?? 'read-only', '--ask-for-approval', 'never', '--json'];
30
42
  if (opts.model)
31
43
  args.push('-m', opts.model);
32
44
  if (opts.resumeThreadId)
33
45
  args.push('resume', opts.resumeThreadId);
34
46
  args.push('-'); // prompt via stdin
35
47
  return new Promise((resolve, reject) => {
36
- // OPENAI_API_KEY='' กัน BYOK key ของ Sanook ไป override ChatGPT login ของ codex
37
- const env = { ...process.env, OPENAI_API_KEY: '' };
38
- const p = spawn('codex', args, { env, shell: process.platform === 'win32' }); // Windows: codex.cmd
48
+ // ลบ OPENAI_API_KEY ออกจาก env ของ child — กัน BYOK key ของ Sanook ไป override/ชนกับ ChatGPT login
49
+ // (codex bug #2733/#3286: ตั้ง OPENAI_API_KEY ค้าง env ทำให้ ChatGPT-plan auth วน loop sign-in)
50
+ const env = { ...process.env };
51
+ delete env.OPENAI_API_KEY;
52
+ const p = spawn('codex', args, { env, shell: process.platform === 'win32' }); // Windows: codex = JS shim ผ่าน .cmd → ต้อง shell
39
53
  let finalText = '';
40
54
  let threadId;
41
55
  let buf = '';
@@ -43,13 +43,29 @@ export async function listRemoteModels(cfg, key, timeoutMs = 6000) {
43
43
  }
44
44
  /**
45
45
  * merge: curated alias (registry — มี label สื่อความหมาย) นำหน้า + remote id ที่เหลือต่อท้าย
46
- * dedup ด้วย model id (ไม่โชว์ id ซ้ำสองครั้ง). ใช้ทั้ง setup wizard และ /model picker
46
+ * dedup ด้วย model id alias หลายตัวที่ชี้ id เดียวกัน (เช่น haiku/fast → claude-haiku-4-5,
47
+ * smart/gpt → gpt-5.5) ต้องรวมเป็น "haiku / fast — id" บรรทัดเดียว ไม่งั้น value ซ้ำ → React key ชน
48
+ * → ตัวเลือกโผล่ซ้ำ/หาย (bug "มีตัวเลือกสองตัวเลือกเป็น model เดียวกัน"). ใช้ทั้ง setup wizard และ /model picker
47
49
  */
48
50
  export function mergeModelOptions(cfg, remote = []) {
49
- const curated = Object.entries(cfg.models)
50
- .filter(([alias]) => alias !== 'default')
51
- .map(([alias, id]) => ({ id, label: `${alias} — ${id}` }));
52
- const seen = new Set(curated.map((c) => c.id));
53
- const extra = remote.filter((id) => !seen.has(id)).map((id) => ({ id, label: id }));
51
+ // group alias ทั้งหมดตาม id (รวม 'default' ด้วย — กัน id ที่มีแต่ alias 'default' เช่น lmstudio:local-model,
52
+ // ollama:qwen3 หายไปจนเลือกไม่ได้/Select ว่าง). ตอนทำ label ค่อยซ่อนคำ "default" ถ้ามีชื่ออื่นอยู่แล้ว
53
+ const aliasesById = new Map();
54
+ const order = []; // คง first-seen order ของ id
55
+ for (const [alias, id] of Object.entries(cfg.models)) {
56
+ if (!aliasesById.has(id)) {
57
+ aliasesById.set(id, []);
58
+ order.push(id);
59
+ }
60
+ aliasesById.get(id)?.push(alias);
61
+ }
62
+ const curated = order.map((id) => {
63
+ const aliases = aliasesById.get(id) ?? [];
64
+ const named = aliases.filter((a) => a !== 'default');
65
+ const shown = named.length ? named : aliases; // มีแต่ 'default' → โชว์ 'default' (ดีกว่าซ่อน id หายไป)
66
+ return { id, label: `${shown.join(' / ')} — ${id}` };
67
+ });
68
+ const seen = new Set(order);
69
+ const extra = [...new Set(remote)].filter((id) => id && !seen.has(id)).map((id) => ({ id, label: id }));
54
70
  return [...curated, ...extra].map((o) => ({ label: o.label, value: o.id }));
55
71
  }
@@ -236,11 +236,33 @@ const CONSOLE_URLS = {
236
236
  export function consoleUrl(provider) {
237
237
  return CONSOLE_URLS[provider];
238
238
  }
239
- /** หา provider ที่ "มี key ใน env แล้ว" (cloud, ตามลำดับนิยม) — ใช้ทำ first-run smart skip + แนะ headless */
239
+ /**
240
+ * provider นี้มี key ใน env ที่ "ใช้ได้จริง" ไหม — มี key + ผ่าน policy (ไม่ใช่ OAuth/subscription token
241
+ * หรือ format ผิด). ใช้ทั้ง first-run smart-skip และ -m flag เพื่อไม่ให้ข้าม wizard ทั้งที่ key ใช้ไม่ได้
242
+ * (เช่น export ANTHROPIC_API_KEY=sk-ant-oat… → ถูกแบน → ต้องเข้า wizard ไม่ใช่ขึ้น "พร้อมใช้")
243
+ */
244
+ export function hasUsableEnvKey(provider) {
245
+ const cfg = PROVIDERS[provider];
246
+ if (!cfg)
247
+ return false;
248
+ if (!cfg.requiresKey)
249
+ return true; // local — ไม่ต้อง key
250
+ const k = resolveKeyFromEnv(cfg.envVar, cfg.envFallbacks);
251
+ if (!k)
252
+ return false;
253
+ try {
254
+ assertDirectApiKey(cfg, k); // reject OAuth prefix / format ผิด
255
+ return true;
256
+ }
257
+ catch {
258
+ return false;
259
+ }
260
+ }
261
+ /** หา provider ที่ "มี key ใช้ได้จริงใน env" (cloud, ตามลำดับนิยม) — ใช้ทำ first-run smart skip + แนะ headless */
240
262
  export function detectEnvProvider() {
241
263
  for (const id of ['anthropic', 'openai', 'google', 'deepseek', 'xai', 'mistral', 'groq', 'glm', 'minimax']) {
242
264
  const cfg = PROVIDERS[id];
243
- if (cfg?.requiresKey && resolveKeyFromEnv(cfg.envVar, cfg.envFallbacks)) {
265
+ if (cfg?.requiresKey && hasUsableEnvKey(id)) {
244
266
  return { provider: id, label: cfg.label, envVar: cfg.envVar, model: cfg.models.default };
245
267
  }
246
268
  }
package/dist/ui/app.js CHANGED
@@ -18,11 +18,16 @@ import { BRAND } from '../brand.js';
18
18
  import { Banner } from './banner.js';
19
19
  const execFileP = promisify(execFile);
20
20
  const PRE_TURN_COMPACT_TOKENS = 100_000; // session ยาวมากเท่านั้นถึง summarize ก่อน turn (mode summarize)
21
- export function App({ initialModel, fallbackModel, budgetUsd, permissionMode = 'ask', initialHistory }) {
21
+ export function App({ initialModel, fallbackModel, budgetUsd, permissionMode = 'ask', initialHistory, initialNote }) {
22
22
  const { exit } = useApp();
23
- const [history, setHistory] = useState(initialHistory?.length
24
- ? [{ id: -1, role: 'system', text: `↻ ต่อจาก session ก่อน (${initialHistory.length} ข้อความ)` }]
25
- : []);
23
+ const [history, setHistory] = useState(() => {
24
+ const seed = [];
25
+ if (initialNote)
26
+ seed.push({ id: -2, role: 'system', text: initialNote });
27
+ if (initialHistory?.length)
28
+ seed.push({ id: -1, role: 'system', text: `↻ ต่อจาก session ก่อน (${initialHistory.length} ข้อความ)` });
29
+ return seed;
30
+ });
26
31
  const [streaming, setStreaming] = useState('');
27
32
  const [busy, setBusy] = useState(false);
28
33
  const [model, setModel] = useState(initialModel);
@@ -299,7 +304,8 @@ export function App({ initialModel, fallbackModel, budgetUsd, permissionMode = '
299
304
  if (next)
300
305
  void submit(next);
301
306
  }
302
- const banner = useMemo(() => _jsx(Banner, { model: initialModel }), [initialModel]);
307
+ // banner ผูกกับ live `model` (ไม่ใช่ initialModel) → /model เปลี่ยนแล้ว banner อัปเดตตาม ไม่ค้าง model เก่า
308
+ const banner = useMemo(() => _jsx(Banner, { model: model }), [model]);
303
309
  const costHint = lastCost.current.includes('cost ') ? lastCost.current.split('cost ')[1] : '';
304
310
  return (_jsxs(Box, { flexDirection: "column", children: [banner, _jsx(Static, { items: history, children: (turn) => _jsx(TurnView, { turn: turn }, turn.id) }), streaming ? (_jsx(Box, { marginTop: 1, children: _jsx(Text, { children: streaming }) })) : null, queued.length ? (_jsx(Box, { flexDirection: "column", marginTop: 1, children: queued.map((q, i) => (_jsxs(Text, { dimColor: true, children: ["\u23F3 \u0E04\u0E34\u0E27 ", i + 1, ": ", q.length > 64 ? `${q.slice(0, 64)}…` : q] }, i))) })) : null, approvalReq ? (_jsxs(Box, { marginTop: 1, borderStyle: "round", borderColor: "yellow", paddingX: 1, flexDirection: "column", children: [_jsxs(Text, { color: "yellow", children: ["\u0E2D\u0E19\u0E38\u0E21\u0E31\u0E15\u0E34\u0E23\u0E31\u0E19 ", approvalReq.tool, "?"] }), _jsx(Text, { dimColor: true, children: approvalReq.summary }), _jsx(Text, { dimColor: true, children: "y = \u0E23\u0E31\u0E19 \u00B7 n = \u0E1B\u0E0F\u0E34\u0E40\u0E2A\u0E18" })] })) : (_jsxs(Box, { marginTop: 1, borderStyle: "round", borderColor: busy ? 'gray' : 'blue', paddingX: 1, children: [_jsx(Text, { color: busy ? 'gray' : 'cyan', children: busy ? '· ' : '› ' }), _jsx(InputView, { value: editor.value, cursor: editor.cursor, busy: busy })] })), _jsxs(Text, { dimColor: true, children: [' ', model, " \u00B7 ", permissionMode === 'ask' ? 'ask-mode' : 'auto', " \u00B7 /help \u00B7 @file \u00B7 \u2191 history", costHint ? ` · ${costHint}` : ''] })] }));
305
311
  }
@@ -15,10 +15,10 @@ export function BrainWizard({ onComplete }) {
15
15
  return (_jsxs(Box, { flexDirection: "column", gap: 1, marginY: 1, children: [_jsx(Text, { bold: true, color: "cyan", children: "\uD83E\uDDE0 \u0E2A\u0E23\u0E49\u0E32\u0E07 Second Brain workspace" }), step === 'path' && (_jsxs(Box, { flexDirection: "column", children: [_jsx(Text, { children: "1. \u0E27\u0E32\u0E07\u0E42\u0E04\u0E23\u0E07\u0E2A\u0E23\u0E49\u0E32\u0E07\u0E44\u0E27\u0E49\u0E17\u0E35\u0E48\u0E44\u0E2B\u0E19? (Enter = default)" }), _jsxs(Text, { color: "gray", children: [" ", DEFAULT_PATH] }), _jsx(TextInput, { defaultValue: DEFAULT_PATH, placeholder: DEFAULT_PATH, onSubmit: (v) => {
16
16
  setPath(v.trim() || DEFAULT_PATH);
17
17
  setStep('owner');
18
- } })] })), step === 'owner' && (_jsxs(Box, { flexDirection: "column", children: [_jsx(Text, { children: "2. \u0E40\u0E23\u0E35\u0E22\u0E01\u0E04\u0E38\u0E13\u0E27\u0E48\u0E32\u0E2D\u0E30\u0E44\u0E23\u0E14\u0E35? (\u0E0A\u0E37\u0E48\u0E2D/\u0E0A\u0E37\u0E48\u0E2D\u0E40\u0E25\u0E48\u0E19 \u2014 Enter = \u0E02\u0E49\u0E32\u0E21)" }), _jsx(TextInput, { defaultValue: BRAIN_DEFAULTS.ownerName, onSubmit: (v) => {
18
+ } })] })), step === 'owner' && (_jsxs(Box, { flexDirection: "column", children: [_jsxs(Text, { children: ["2. \u0E40\u0E23\u0E35\u0E22\u0E01\u0E04\u0E38\u0E13\u0E27\u0E48\u0E32\u0E2D\u0E30\u0E44\u0E23\u0E14\u0E35? (\u0E0A\u0E37\u0E48\u0E2D/\u0E0A\u0E37\u0E48\u0E2D\u0E40\u0E25\u0E48\u0E19 \u2014 Enter \u0E40\u0E1B\u0E25\u0E48\u0E32 = \u0E43\u0E0A\u0E49 \"", BRAIN_DEFAULTS.ownerName, "\")"] }), _jsx(TextInput, { placeholder: BRAIN_DEFAULTS.ownerName, onSubmit: (v) => {
19
19
  setOwnerName(v.trim() || BRAIN_DEFAULTS.ownerName);
20
20
  setStep('ai');
21
- } })] })), step === 'ai' && (_jsxs(Box, { flexDirection: "column", children: [_jsx(Text, { children: "3. \u0E2D\u0E22\u0E32\u0E01\u0E43\u0E2B\u0E49 AI \u0E40\u0E23\u0E35\u0E22\u0E01\u0E15\u0E31\u0E27\u0E40\u0E2D\u0E07\u0E27\u0E48\u0E32\u0E2D\u0E30\u0E44\u0E23?" }), _jsx(TextInput, { defaultValue: BRAIN_DEFAULTS.aiName, onSubmit: (v) => {
21
+ } })] })), step === 'ai' && (_jsxs(Box, { flexDirection: "column", children: [_jsxs(Text, { children: ["3. \u0E2D\u0E22\u0E32\u0E01\u0E43\u0E2B\u0E49 AI \u0E40\u0E23\u0E35\u0E22\u0E01\u0E15\u0E31\u0E27\u0E40\u0E2D\u0E07\u0E27\u0E48\u0E32\u0E2D\u0E30\u0E44\u0E23? ", _jsxs(Text, { color: "gray", children: ["(Enter \u0E40\u0E1B\u0E25\u0E48\u0E32 = \"", BRAIN_DEFAULTS.aiName, "\")"] })] }), _jsx(TextInput, { placeholder: BRAIN_DEFAULTS.aiName, onSubmit: (v) => {
22
22
  setAiName(v.trim() || BRAIN_DEFAULTS.aiName);
23
23
  setStep('autonomy');
24
24
  } })] })), step === 'autonomy' && (_jsxs(Box, { flexDirection: "column", children: [_jsx(Text, { children: "4. \u0E43\u0E2B\u0E49 AI \u0E17\u0E33\u0E07\u0E32\u0E19\u0E41\u0E1A\u0E1A\u0E44\u0E2B\u0E19?" }), _jsx(Select, { options: [
package/dist/ui/render.js CHANGED
@@ -1,32 +1,72 @@
1
1
  import { jsx as _jsx } from "react/jsx-runtime";
2
+ import { useState } from 'react';
2
3
  import { render } from 'ink';
3
4
  import { App } from './app.js';
4
5
  import { SetupWizard } from './setup.js';
5
6
  import { BrainWizard } from './brain-wizard.js';
6
7
  import { saveKey, saveGlobalConfig, saveBrainPath } from '../config.js';
7
- export function startRepl(props) {
8
- render(_jsx(App, { ...props }));
9
- }
10
- /** render first-run wizardsave key+config → (ถ้าเลือก) ต่อ BrainWizard สร้าง second-brain → resolve */
11
- export function startSetup() {
12
- return new Promise((resolve) => {
13
- let unmount = () => { };
8
+ /**
9
+ * Root — โฮสต์ setup wizard → brain wizard → REPL ใน **Ink render เดียว**
10
+ *
11
+ * ก่อนหน้านี้แยกเป็น render(SetupWizard)unmountrender(App) = 2 Ink instances ต่อกัน
12
+ * พอ instance แรก unmount, stdin raw-mode/keypress listener ไม่ reattach กับ instance ที่ 2
13
+ * → พิมพ์ในช่องแชทไม่ได้. รวมเป็น tree เดียว (React สลับ component ภายใน) stdin ต่อเนื่องไม่หลุด.
14
+ */
15
+ export function Root({ needsSetup, appProps }) {
16
+ const [phase, setPhase] = useState(needsSetup ? 'setup' : 'app');
17
+ const [model, setModel] = useState(appProps.initialModel);
18
+ const [brainNote, setBrainNote] = useState(undefined);
19
+ if (phase === 'setup') {
14
20
  const onComplete = (r) => {
15
21
  void (async () => {
16
22
  if (r.key)
17
23
  await saveKey(r.envVar, r.key);
18
24
  await saveGlobalConfig({ model: r.model, provider: r.provider });
19
- unmount();
20
- if (r.createBrain)
21
- await startBrainSetup(); // ถาม identity + path จริง แล้ว scaffold
22
- resolve(r);
25
+ setModel(r.model);
26
+ setPhase(r.createBrain ? 'brain' : 'app');
23
27
  })();
24
28
  };
25
- const instance = render(_jsx(SetupWizard, { onComplete: onComplete }));
26
- unmount = instance.unmount;
27
- });
29
+ return _jsx(SetupWizard, { onComplete: onComplete });
30
+ }
31
+ if (phase === 'brain') {
32
+ const onComplete = (a) => {
33
+ void (async () => {
34
+ const { scaffoldBrain, BRAIN_DEFAULTS, expandHome, wireBrainMcp } = await import('../brain.js');
35
+ const today = new Date().toISOString().slice(0, 10);
36
+ const target = expandHome(a.path);
37
+ try {
38
+ const res = await scaffoldBrain(target, {
39
+ ...BRAIN_DEFAULTS,
40
+ ownerName: a.ownerName,
41
+ aiName: a.aiName,
42
+ autonomy: a.autonomy,
43
+ today,
44
+ });
45
+ await saveBrainPath(target);
46
+ const wired = await wireBrainMcp(target).catch(() => 'skip');
47
+ setBrainNote(`✅ second-brain — ${target} · สร้าง ${res.created.length} ไฟล์ · ` +
48
+ `${wired === 'added' ? 'wire filesystem MCP เข้า vault แล้ว' : 'MCP เดิมอยู่แล้ว (ไม่ทับ)'} · เปิดใน Obsidian: Open folder as vault`);
49
+ }
50
+ catch (e) {
51
+ setBrainNote(`⚠ สร้าง second-brain ไม่สำเร็จ: ${e.message} — ลองใหม่ด้วย ${'`'}sanook brain init${'`'}`);
52
+ }
53
+ setPhase('app');
54
+ })();
55
+ };
56
+ return _jsx(BrainWizard, { onComplete: onComplete });
57
+ }
58
+ // App mount สดตอน phase = 'app' → useState(initialModel) หยิบ model ที่เลือกจาก wizard ถูกต้อง
59
+ return _jsx(App, { ...appProps, initialModel: model, initialNote: brainNote ?? appProps.initialNote });
60
+ }
61
+ /** เปิดแอป: wizard (ถ้า first-run) → REPL — Ink render ครั้งเดียว (fix: พิมพ์ในช่องแชทไม่ได้) */
62
+ export function startApp(props) {
63
+ render(_jsx(Root, { ...props }));
64
+ }
65
+ /** เปิด REPL ตรงๆ (ไม่ผ่าน wizard) — เก็บไว้เผื่อ caller อื่น */
66
+ export function startRepl(appProps) {
67
+ render(_jsx(App, { ...appProps }));
28
68
  }
29
- /** standalone / first-run brain: ถาม path + ตัวตน → scaffold (personalized) + auto-wire filesystem MCP */
69
+ /** standalone `sanook brain init` (interactive): ถาม path + ตัวตน → scaffold + wire MCP — single render, จบแล้ว process ออก */
30
70
  export function startBrainSetup() {
31
71
  return new Promise((resolve) => {
32
72
  let unmount = () => { };
package/dist/ui/setup.js CHANGED
@@ -1,11 +1,29 @@
1
1
  import { jsxs as _jsxs, jsx as _jsx } from "react/jsx-runtime";
2
2
  import { useState, useEffect } from 'react';
3
- import { Box, Text } from 'ink';
3
+ import { Box, Text, useInput } from 'ink';
4
4
  import { Select, PasswordInput } from '@inkjs/ui';
5
5
  import { PROVIDERS, consoleUrl } from '../providers/registry.js';
6
+ import { resolveKeyFromEnv, assertDirectApiKey } from '../providers/keys.js';
6
7
  import { listRemoteModels, mergeModelOptions } from '../providers/models.js';
8
+ import { detectCodex } from '../providers/codex.js';
7
9
  import { BRAND } from '../brand.js';
8
- /** first-run setup wizard: เลือก providerใส่ API key เลือก model เสนอสร้าง second-brain */
10
+ // จัดลำดับ provider ในเมนู: cloud ยอดนิยมcloud อื่นlocalChatGPT-plan (codex) ท้ายสุด
11
+ const PROVIDER_ORDER = ['anthropic', 'openai', 'google', 'deepseek', 'xai', 'mistral', 'groq', 'glm', 'minimax', 'ollama', 'lmstudio', 'codex'];
12
+ /** label + hint ต่อ provider: เจอ key ใน env / local / ChatGPT-login / ต้องมี key — ให้เลือกง่ายขึ้น */
13
+ export function providerOption(id) {
14
+ const p = PROVIDERS[id];
15
+ let hint;
16
+ if (p.kind === 'delegate')
17
+ hint = 'login ChatGPT · ไม่ใช้ API key';
18
+ else if (!p.requiresKey)
19
+ hint = 'local · ไม่ต้อง key';
20
+ else if (resolveKeyFromEnv(p.envVar, p.envFallbacks))
21
+ hint = '✓ เจอ key ใน env';
22
+ else
23
+ hint = 'ต้องมี API key';
24
+ return { label: `${p.label} — ${hint}`, value: p.id };
25
+ }
26
+ /** first-run setup wizard: เลือก provider → (codex login | API key) → เลือก model → เสนอสร้าง second-brain */
9
27
  export function SetupWizard({ onComplete }) {
10
28
  const [step, setStep] = useState('provider');
11
29
  const [provider, setProvider] = useState('');
@@ -13,8 +31,32 @@ export function SetupWizard({ onComplete }) {
13
31
  const [model, setModel] = useState('');
14
32
  const [remote, setRemote] = useState([]);
15
33
  const [loadingModels, setLoadingModels] = useState(false);
34
+ const [codexStatus, setCodexStatus] = useState(null);
35
+ const [recheck, setRecheck] = useState(0);
36
+ const [keyError, setKeyError] = useState('');
16
37
  const cfg = provider ? PROVIDERS[provider] : undefined;
17
- const providerOptions = Object.values(PROVIDERS).map((p) => ({ label: p.label, value: p.id }));
38
+ const providerOptions = PROVIDER_ORDER.filter((id) => PROVIDERS[id]).map(providerOption);
39
+ // codex-auth: เช็ก codex CLI ติดตั้ง + login ChatGPT (re-run เมื่อกด "เช็กใหม่")
40
+ useEffect(() => {
41
+ if (step !== 'codex-auth')
42
+ return;
43
+ let alive = true;
44
+ setCodexStatus(null);
45
+ void detectCodex().then((s) => {
46
+ if (!alive)
47
+ return;
48
+ setCodexStatus(s);
49
+ if (s.installed && s.loggedIn) {
50
+ // login แล้ว → ใช้ default model ของ codex (ChatGPT-plan เลือก model เอง) ข้ามขั้นเลือก key/model
51
+ setModel(`codex:${PROVIDERS.codex.models.default}`);
52
+ setStep('brain-offer');
53
+ }
54
+ });
55
+ return () => {
56
+ alive = false;
57
+ };
58
+ }, [step, recheck]);
59
+ // ดึงรายชื่อ model จริงจาก provider (เฉพาะ provider แบบ SDK ที่ต้อง/ไม่ต้อง key)
18
60
  useEffect(() => {
19
61
  if (step !== 'model' || !cfg)
20
62
  return;
@@ -29,13 +71,55 @@ export function SetupWizard({ onComplete }) {
29
71
  }, [step, cfg, key]);
30
72
  const modelOptions = cfg ? mergeModelOptions(cfg, remote) : [];
31
73
  const finish = (createBrain) => onComplete({ provider, model, envVar: cfg?.envVar ?? '', key, createBrain });
32
- return (_jsxs(Box, { flexDirection: "column", gap: 1, marginY: 1, children: [_jsxs(Text, { bold: true, color: "cyan", children: ["\u2699 \u0E15\u0E31\u0E49\u0E07\u0E04\u0E48\u0E32 ", BRAND.bannerTitle, " (\u0E04\u0E23\u0E31\u0E49\u0E07\u0E41\u0E23\u0E01)"] }), step === 'provider' && (_jsxs(Box, { flexDirection: "column", children: [_jsx(Text, { children: "1. \u0E40\u0E25\u0E37\u0E2D\u0E01 AI provider:" }), _jsx(Select, { options: providerOptions, onChange: (v) => {
74
+ const backToProvider = () => {
75
+ setProvider('');
76
+ setCodexStatus(null);
77
+ setKeyError('');
78
+ setKey('');
79
+ setStep('provider');
80
+ };
81
+ // Esc บนทุก step (ยกเว้น provider) = ย้อนกลับไปเลือก provider — กัน dead-end ตอนเลือกผิด
82
+ // หรือ codex detect ค้าง (step codex-auth ตอน pending ไม่มีปุ่มอื่น แต่ Esc ออกได้เสมอ)
83
+ useInput((_input, key) => {
84
+ if (key.escape && step !== 'provider')
85
+ backToProvider();
86
+ });
87
+ // ตรวจ API key ในขั้นใส่ key — ว่าง = ไม่ผ่าน, OAuth/format ผิด = บอก error (กัน setup จบทั้งที่ key ใช้ไม่ได้)
88
+ const submitKey = (raw) => {
89
+ const k = raw.trim();
90
+ if (!k) {
91
+ setKeyError('วาง API key ก่อนค่ะ (กด Enter ทั้งที่ว่างไม่ได้) · Esc = กลับไปเลือก provider');
92
+ return;
93
+ }
94
+ if (cfg) {
95
+ try {
96
+ assertDirectApiKey(cfg, k); // reject OAuth/subscription token + format ผิด (เหมือน runtime)
97
+ }
98
+ catch (e) {
99
+ setKeyError(e.message.split('\n')[0]);
100
+ return;
101
+ }
102
+ }
103
+ setKeyError('');
104
+ setKey(k);
105
+ setStep('model');
106
+ };
107
+ return (_jsxs(Box, { flexDirection: "column", gap: 1, marginY: 1, children: [_jsxs(Text, { bold: true, color: "cyan", children: ["\u2699 \u0E15\u0E31\u0E49\u0E07\u0E04\u0E48\u0E32 ", BRAND.bannerTitle, " (\u0E04\u0E23\u0E31\u0E49\u0E07\u0E41\u0E23\u0E01)"] }), step === 'provider' && (_jsxs(Box, { flexDirection: "column", children: [_jsx(Text, { children: "1. \u0E40\u0E25\u0E37\u0E2D\u0E01 AI provider (\u2191\u2193 \u0E40\u0E25\u0E37\u0E2D\u0E01 \u00B7 Enter \u0E22\u0E37\u0E19\u0E22\u0E31\u0E19):" }), _jsx(Text, { color: "gray", children: " cloud = \u0E43\u0E2A\u0E48 API key \u00B7 local = \u0E1F\u0E23\u0E35\u0E1A\u0E19\u0E40\u0E04\u0E23\u0E37\u0E48\u0E2D\u0E07 \u00B7 Codex = login \u0E14\u0E49\u0E27\u0E22 ChatGPT" }), _jsx(Select, { options: providerOptions, onChange: (v) => {
33
108
  setProvider(v);
34
- setStep(PROVIDERS[v].requiresKey ? 'key' : 'model');
35
- } })] })), step === 'key' && cfg && (_jsxs(Box, { flexDirection: "column", children: [_jsxs(Text, { children: ["2. \u0E27\u0E32\u0E07 API key \u0E02\u0E2D\u0E07 ", cfg.label, ":"] }), consoleUrl(provider) ? (_jsxs(Text, { color: "cyan", children: [" \u2192 \u0E40\u0E2D\u0E32 key \u0E17\u0E35\u0E48: ", consoleUrl(provider)] })) : null, _jsx(Text, { color: "gray", children: " (API key \u0E15\u0E23\u0E07\u0E08\u0E32\u0E01 console \u2014 \u0E2B\u0E49\u0E32\u0E21 OAuth/subscription token)" }), _jsx(PasswordInput, { placeholder: cfg.envVar, onSubmit: (v) => {
36
- setKey(v.trim());
37
- setStep('model');
38
- } })] })), step === 'model' &&
109
+ const p = PROVIDERS[v];
110
+ if (p.kind === 'delegate')
111
+ setStep('codex-auth');
112
+ else if (p.requiresKey)
113
+ setStep('key');
114
+ else
115
+ setStep('model');
116
+ } })] })), step === 'codex-auth' && (_jsxs(Box, { flexDirection: "column", children: [_jsx(Text, { children: "2. \u0E40\u0E0A\u0E37\u0E48\u0E2D\u0E21 OpenAI Codex (\u0E43\u0E0A\u0E49\u0E42\u0E04\u0E27\u0E15\u0E49\u0E32 ChatGPT plan \u2014 \u0E44\u0E21\u0E48\u0E15\u0E49\u0E2D\u0E07\u0E21\u0E35 API key):" }), codexStatus === null ? (_jsx(Text, { color: "gray", children: " \u0E01\u0E33\u0E25\u0E31\u0E07\u0E40\u0E0A\u0E47\u0E01 codex CLI + \u0E2A\u0E16\u0E32\u0E19\u0E30 login\u2026" })) : !codexStatus.installed ? (_jsxs(Box, { flexDirection: "column", children: [_jsx(Text, { color: "yellow", children: " \u274C \u0E22\u0E31\u0E07\u0E44\u0E21\u0E48\u0E44\u0E14\u0E49\u0E15\u0E34\u0E14\u0E15\u0E31\u0E49\u0E07 codex CLI" }), _jsxs(Text, { children: [' ', "\u0E15\u0E34\u0E14\u0E15\u0E31\u0E49\u0E07\u0E43\u0E19 terminal \u0E2D\u0E35\u0E01\u0E2B\u0E19\u0E49\u0E32\u0E15\u0E48\u0E32\u0E07: ", _jsx(Text, { color: "cyan", children: "npm i -g @openai/codex" })] }), _jsx(Select, { options: [
117
+ { label: 'เช็กใหม่ (ติดตั้งเสร็จแล้ว)', value: 'recheck' },
118
+ { label: '← กลับไปเลือก provider อื่น', value: 'back' },
119
+ ], onChange: (v) => (v === 'recheck' ? setRecheck((n) => n + 1) : backToProvider()) })] })) : !codexStatus.loggedIn ? (_jsxs(Box, { flexDirection: "column", children: [_jsx(Text, { color: "yellow", children: " \u26A0 \u0E15\u0E34\u0E14\u0E15\u0E31\u0E49\u0E07\u0E41\u0E25\u0E49\u0E27 \u0E41\u0E15\u0E48\u0E22\u0E31\u0E07\u0E44\u0E21\u0E48\u0E44\u0E14\u0E49 login ChatGPT" }), _jsxs(Text, { children: [' ', "\u0E23\u0E31\u0E19\u0E43\u0E19 terminal \u0E2D\u0E35\u0E01\u0E2B\u0E19\u0E49\u0E32\u0E15\u0E48\u0E32\u0E07: ", _jsx(Text, { color: "cyan", children: "codex login" }), " ", _jsx(Text, { color: "gray", children: "(\u0E40\u0E1B\u0E34\u0E14 browser \u0E43\u0E2B\u0E49\u0E22\u0E37\u0E19\u0E22\u0E31\u0E19\u0E14\u0E49\u0E27\u0E22\u0E1A\u0E31\u0E0D\u0E0A\u0E35 ChatGPT)" })] }), _jsx(Select, { options: [
120
+ { label: 'เช็กใหม่ (login เสร็จแล้ว)', value: 'recheck' },
121
+ { label: '← กลับไปเลือก provider อื่น', value: 'back' },
122
+ ], onChange: (v) => (v === 'recheck' ? setRecheck((n) => n + 1) : backToProvider()) })] })) : (_jsx(Text, { color: "green", children: " \u2705 login ChatGPT \u0E41\u0E25\u0E49\u0E27 \u2014 \u0E01\u0E33\u0E25\u0E31\u0E07\u0E44\u0E1B\u0E15\u0E48\u0E2D\u2026" }))] })), step === 'key' && cfg && (_jsxs(Box, { flexDirection: "column", children: [_jsxs(Text, { children: ["2. \u0E27\u0E32\u0E07 API key \u0E02\u0E2D\u0E07 ", cfg.label, ": ", _jsx(Text, { color: "gray", children: "(Esc = \u0E01\u0E25\u0E31\u0E1A)" })] }), consoleUrl(provider) ? _jsxs(Text, { color: "cyan", children: [" \u2192 \u0E40\u0E2D\u0E32 key \u0E17\u0E35\u0E48: ", consoleUrl(provider)] }) : null, cfg.keyExample ? _jsxs(Text, { color: "gray", children: [" \u0E23\u0E39\u0E1B\u0E41\u0E1A\u0E1A key: ", cfg.keyExample] }) : null, _jsx(Text, { color: "gray", children: " (API key \u0E15\u0E23\u0E07\u0E08\u0E32\u0E01 console \u2014 \u0E2B\u0E49\u0E32\u0E21 OAuth/subscription token \u00B7 key \u0E08\u0E30\u0E40\u0E01\u0E47\u0E1A\u0E41\u0E1A\u0E1A\u0E40\u0E02\u0E49\u0E32\u0E23\u0E2B\u0E31\u0E2A\u0E43\u0E19\u0E40\u0E04\u0E23\u0E37\u0E48\u0E2D\u0E07)" }), _jsx(PasswordInput, { placeholder: cfg.envVar, onSubmit: submitKey }), keyError ? _jsxs(Text, { color: "red", children: [" \u2717 ", keyError] }) : null] })), step === 'model' &&
39
123
  cfg &&
40
124
  (loadingModels ? (_jsxs(Text, { color: "gray", children: [" \u0E01\u0E33\u0E25\u0E31\u0E07\u0E14\u0E36\u0E07\u0E23\u0E32\u0E22\u0E0A\u0E37\u0E48\u0E2D model \u0E08\u0E32\u0E01 ", cfg.label, "\u2026"] })) : (_jsxs(Box, { flexDirection: "column", children: [_jsxs(Text, { children: ["3. \u0E40\u0E25\u0E37\u0E2D\u0E01 model \u0E40\u0E23\u0E34\u0E48\u0E21\u0E15\u0E49\u0E19", remote.length ? _jsxs(Text, { color: "gray", children: [" (", modelOptions.length, " \u0E15\u0E31\u0E27\u0E08\u0E32\u0E01 provider + alias)"] }) : null, ":"] }), _jsx(Select, { options: modelOptions, onChange: (v) => {
41
125
  setModel(`${provider}:${v}`);
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "sanook-cli",
3
- "version": "0.5.0",
3
+ "version": "0.5.1",
4
4
  "description": "A terminal AI coding agent — BYOK, 12 providers, MCP, cron gateway, skills, and git awareness. Built from scratch in TypeScript.",
5
5
  "type": "module",
6
6
  "bin": {