sanook-cli 0.4.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.
Files changed (238) hide show
  1. package/.env.example +19 -0
  2. package/CHANGELOG.md +173 -0
  3. package/README.md +153 -20
  4. package/README.th.md +136 -0
  5. package/dist/agentContext.js +4 -0
  6. package/dist/approval.js +6 -0
  7. package/dist/bin.js +405 -57
  8. package/dist/brain.js +92 -59
  9. package/dist/brand.js +47 -0
  10. package/dist/checkpoint.js +37 -0
  11. package/dist/commands.js +86 -6
  12. package/dist/compaction.js +76 -5
  13. package/dist/config.js +100 -12
  14. package/dist/cost.js +60 -3
  15. package/dist/doctor.js +92 -0
  16. package/dist/gateway/auth.js +2 -2
  17. package/dist/gateway/ledger.js +2 -2
  18. package/dist/gateway/scheduler.js +1 -0
  19. package/dist/gateway/serve.js +6 -4
  20. package/dist/gateway/server.js +10 -2
  21. package/dist/git.js +11 -2
  22. package/dist/hooks.js +43 -17
  23. package/dist/knowledge.js +48 -49
  24. package/dist/loop.js +182 -66
  25. package/dist/lsp/client.js +173 -0
  26. package/dist/lsp/framing.js +56 -0
  27. package/dist/lsp/index.js +138 -0
  28. package/dist/lsp/servers.js +82 -0
  29. package/dist/mcp-server.js +244 -0
  30. package/dist/mcp.js +184 -29
  31. package/dist/memory-store.js +559 -0
  32. package/dist/memory.js +143 -29
  33. package/dist/orchestrate.js +150 -0
  34. package/dist/providers/codex.js +21 -7
  35. package/dist/providers/keys.js +3 -2
  36. package/dist/providers/models.js +22 -6
  37. package/dist/providers/registry.js +155 -1
  38. package/dist/repomap.js +93 -0
  39. package/dist/search/chunk.js +158 -0
  40. package/dist/search/embed-store.js +187 -0
  41. package/dist/search/engine.js +203 -0
  42. package/dist/search/fuse.js +35 -0
  43. package/dist/search/index-core.js +187 -0
  44. package/dist/search/indexer.js +241 -0
  45. package/dist/search/store.js +77 -0
  46. package/dist/session.js +42 -8
  47. package/dist/skill-install.js +10 -10
  48. package/dist/skills.js +12 -9
  49. package/dist/summarize.js +31 -0
  50. package/dist/tools/bash.js +21 -2
  51. package/dist/tools/diagnostics.js +41 -0
  52. package/dist/tools/edit.js +29 -7
  53. package/dist/tools/index.js +8 -1
  54. package/dist/tools/list.js +7 -2
  55. package/dist/tools/permission.js +90 -9
  56. package/dist/tools/read.js +23 -4
  57. package/dist/tools/remember.js +1 -1
  58. package/dist/tools/sandbox.js +61 -0
  59. package/dist/tools/search.js +105 -4
  60. package/dist/tools/task.js +195 -29
  61. package/dist/tools/timeout.js +35 -0
  62. package/dist/tools/util.js +10 -0
  63. package/dist/tools/write.js +6 -4
  64. package/dist/trust.js +89 -0
  65. package/dist/ui/app.js +228 -31
  66. package/dist/ui/banner.js +4 -9
  67. package/dist/ui/brain-wizard.js +2 -2
  68. package/dist/ui/history.js +30 -0
  69. package/dist/ui/mentions.js +44 -0
  70. package/dist/ui/render.js +55 -15
  71. package/dist/ui/setup.js +97 -12
  72. package/dist/ui/useEditor.js +83 -0
  73. package/dist/update.js +114 -0
  74. package/dist/worktree.js +173 -0
  75. package/package.json +11 -5
  76. package/scripts/postinstall.mjs +33 -0
  77. package/second-brain/.agents/_Index.md +30 -0
  78. package/second-brain/.agents/skills/_Index.md +30 -0
  79. package/second-brain/.agents/workflows/_Index.md +30 -0
  80. package/second-brain/AGENTS.md +4 -4
  81. package/second-brain/Acceptance/_Index.md +30 -0
  82. package/second-brain/Acceptance/golden-case-template.md +39 -0
  83. package/second-brain/Areas/_Index.md +30 -0
  84. package/second-brain/Bugs/System-OS/_Index.md +30 -0
  85. package/second-brain/Bugs/_Index.md +30 -0
  86. package/second-brain/CLAUDE.md +4 -1
  87. package/second-brain/Checklists/_Index.md +30 -0
  88. package/second-brain/Checklists/preflight-postflight-template.md +29 -0
  89. package/second-brain/Distillations/_Index.md +30 -0
  90. package/second-brain/Entities/_Index.md +30 -0
  91. package/second-brain/Entities/entity-template.md +33 -0
  92. package/second-brain/Evals/_Index.md +30 -0
  93. package/second-brain/Evals/correction-pairs.md +24 -0
  94. package/second-brain/Evals/failure-taxonomy.md +24 -0
  95. package/second-brain/Evals/golden-set.md +25 -0
  96. package/second-brain/Evals/quality-ledger.md +23 -0
  97. package/second-brain/Evals/self-eval-rubric.md +23 -0
  98. package/second-brain/GEMINI.md +4 -4
  99. package/second-brain/Goals/_Index.md +30 -0
  100. package/second-brain/Handoffs/_Index.md +30 -0
  101. package/second-brain/Home.md +7 -0
  102. package/second-brain/Intake/Raw Sources/_Index.md +30 -0
  103. package/second-brain/Intake/_Index.md +30 -0
  104. package/second-brain/Intake/_Quarantine/_Index.md +30 -0
  105. package/second-brain/Learning/_Index.md +30 -0
  106. package/second-brain/Playbooks/_Index.md +30 -0
  107. package/second-brain/Playbooks/playbook-template.md +23 -0
  108. package/second-brain/Projects/_Index.md +30 -0
  109. package/second-brain/Prompts/_Index.md +30 -0
  110. package/second-brain/README.md +2 -1
  111. package/second-brain/Research/_Index.md +30 -0
  112. package/second-brain/Retrospectives/_Index.md +30 -0
  113. package/second-brain/Reviews/_Index.md +30 -0
  114. package/second-brain/Runbooks/_Index.md +30 -0
  115. package/second-brain/Runbooks/eval-loop.md +24 -0
  116. package/second-brain/Sessions/_Index.md +30 -0
  117. package/second-brain/Shared/AI-Context-Index.md +20 -0
  118. package/second-brain/Shared/AI-Threads/_Index.md +30 -0
  119. package/second-brain/Shared/Archive/_Index.md +30 -0
  120. package/second-brain/Shared/Assets/_Index.md +30 -0
  121. package/second-brain/Shared/Context-Packs/_Index.md +30 -0
  122. package/second-brain/Shared/Context7-Docs/_Index.md +30 -0
  123. package/second-brain/Shared/Coordination/NOW.md +28 -0
  124. package/second-brain/Shared/Coordination/_Index.md +30 -0
  125. package/second-brain/Shared/Coordination/agent-registry.md +24 -0
  126. package/second-brain/Shared/Coordination/task-board/_Index.md +30 -0
  127. package/second-brain/Shared/Coordination/task-board/task-template.md +43 -0
  128. package/second-brain/Shared/Coordination/task-board.md +32 -0
  129. package/second-brain/Shared/Core-Facts/_Index.md +30 -0
  130. package/second-brain/Shared/Decision-Memory/_Index.md +30 -0
  131. package/second-brain/Shared/Glossary/_Index.md +30 -0
  132. package/second-brain/Shared/Memory-Inbox/_Index.md +30 -0
  133. package/second-brain/Shared/Operating-State/_Index.md +30 -0
  134. package/second-brain/Shared/Prompting/_Index.md +30 -0
  135. package/second-brain/Shared/Provenance/_Index.md +30 -0
  136. package/second-brain/Shared/Rules/_Index.md +30 -0
  137. package/second-brain/Shared/Rules/contextual-note-rule.md +30 -0
  138. package/second-brain/Shared/Rules/frontmatter-standard.md +10 -0
  139. package/second-brain/Shared/Rules/memory-write-protocol.md +28 -0
  140. package/second-brain/Shared/Rules/procedural-runbook-header.md +40 -0
  141. package/second-brain/Shared/Rules/review-and-staleness-policy.md +22 -0
  142. package/second-brain/Shared/Rules/rules-formatting.md +34 -0
  143. package/second-brain/Shared/Scripts/_Index.md +30 -0
  144. package/second-brain/Shared/Scripts-Archive/_Index.md +30 -0
  145. package/second-brain/Shared/Tech-Standards/_Index.md +30 -0
  146. package/second-brain/Shared/Tech-Standards/verification-standard.md +40 -0
  147. package/second-brain/Shared/User-Memory/_Index.md +30 -0
  148. package/second-brain/Shared/User-Persona/_Index.md +30 -0
  149. package/second-brain/Shared/User-Persona/owner-profile.md +25 -0
  150. package/second-brain/Shared/Working-Memory/_Index.md +30 -0
  151. package/second-brain/Shared/_Index.md +30 -0
  152. package/second-brain/Shared/mcp-servers/_Index.md +30 -0
  153. package/second-brain/Skills/_Index.md +30 -0
  154. package/second-brain/Templates/_Index.md +30 -0
  155. package/second-brain/Templates/bug.md +2 -0
  156. package/second-brain/Templates/handoff.md +2 -0
  157. package/second-brain/Templates/session.md +2 -0
  158. package/second-brain/Tools/_Index.md +30 -0
  159. package/second-brain/Traces/_Index.md +30 -0
  160. package/second-brain/Vault Structure Map.md +33 -1
  161. package/second-brain/copilot/_Index.md +30 -0
  162. package/skills/audit-license-compliance/SKILL.md +117 -0
  163. package/skills/author-codemod/SKILL.md +110 -0
  164. package/skills/build-audit-logging/SKILL.md +112 -0
  165. package/skills/build-cdc-streaming-pipeline/SKILL.md +123 -0
  166. package/skills/build-cli-tool/SKILL.md +108 -0
  167. package/skills/build-data-table/SKILL.md +141 -0
  168. package/skills/build-native-mobile-ui/SKILL.md +154 -0
  169. package/skills/build-offline-first-sync/SKILL.md +118 -0
  170. package/skills/build-realtime-channel/SKILL.md +122 -0
  171. package/skills/build-vector-search/SKILL.md +131 -0
  172. package/skills/compose-local-dev-stack/SKILL.md +149 -0
  173. package/skills/configure-bundler-build/SKILL.md +166 -0
  174. package/skills/configure-dns-tls/SKILL.md +142 -0
  175. package/skills/configure-reverse-proxy-lb/SKILL.md +129 -0
  176. package/skills/configure-security-headers-csp/SKILL.md +122 -0
  177. package/skills/contract-testing/SKILL.md +140 -0
  178. package/skills/datetime-timezone-correctness/SKILL.md +125 -0
  179. package/skills/debug-ci-pipeline-failure/SKILL.md +134 -0
  180. package/skills/debug-flaky-tests/SKILL.md +128 -0
  181. package/skills/defend-llm-prompt-injection/SKILL.md +110 -0
  182. package/skills/deliver-webhooks/SKILL.md +116 -0
  183. package/skills/design-api-pagination/SKILL.md +144 -0
  184. package/skills/design-authorization-model/SKILL.md +119 -0
  185. package/skills/design-backup-dr-recovery/SKILL.md +113 -0
  186. package/skills/design-event-sourcing-cqrs/SKILL.md +143 -0
  187. package/skills/design-multi-tenancy/SKILL.md +100 -0
  188. package/skills/design-protobuf-grpc-service/SKILL.md +146 -0
  189. package/skills/design-relational-schema/SKILL.md +129 -0
  190. package/skills/design-search-index-infra/SKILL.md +151 -0
  191. package/skills/design-state-machine/SKILL.md +108 -0
  192. package/skills/design-token-system/SKILL.md +109 -0
  193. package/skills/distributed-locks-leases/SKILL.md +120 -0
  194. package/skills/encrypt-sensitive-data/SKILL.md +148 -0
  195. package/skills/feature-flags-rollout/SKILL.md +130 -0
  196. package/skills/file-upload-object-storage/SKILL.md +107 -0
  197. package/skills/fuzz-dynamic-security-test/SKILL.md +111 -0
  198. package/skills/harden-llm-app-reliability/SKILL.md +126 -0
  199. package/skills/i18n-localization-setup/SKILL.md +113 -0
  200. package/skills/idempotency-keys/SKILL.md +107 -0
  201. package/skills/implement-push-notifications/SKILL.md +142 -0
  202. package/skills/ingest-webhook-secure/SKILL.md +120 -0
  203. package/skills/integrate-oauth-oidc/SKILL.md +126 -0
  204. package/skills/load-stress-test/SKILL.md +129 -0
  205. package/skills/map-privacy-data-gdpr/SKILL.md +146 -0
  206. package/skills/model-nosql-data/SKILL.md +118 -0
  207. package/skills/money-decimal-arithmetic/SKILL.md +123 -0
  208. package/skills/monitor-ml-drift/SKILL.md +109 -0
  209. package/skills/numeric-precision-units/SKILL.md +144 -0
  210. package/skills/optimize-llm-cost-latency/SKILL.md +103 -0
  211. package/skills/optimize-react-rerenders/SKILL.md +124 -0
  212. package/skills/orchestrate-agent-workflow/SKILL.md +100 -0
  213. package/skills/payments-billing-integration/SKILL.md +114 -0
  214. package/skills/pin-toolchain-versions/SKILL.md +116 -0
  215. package/skills/plan-strangler-migration/SKILL.md +95 -0
  216. package/skills/property-based-testing/SKILL.md +108 -0
  217. package/skills/publish-package-registry/SKILL.md +130 -0
  218. package/skills/recover-git-state/SKILL.md +119 -0
  219. package/skills/remediate-web-vulnerabilities/SKILL.md +125 -0
  220. package/skills/resilience-timeouts-retries/SKILL.md +104 -0
  221. package/skills/resolve-merge-rebase-conflict/SKILL.md +97 -0
  222. package/skills/rewrite-git-history/SKILL.md +109 -0
  223. package/skills/scaffold-cross-platform-app/SKILL.md +137 -0
  224. package/skills/schema-evolution-compatibility/SKILL.md +121 -0
  225. package/skills/send-transactional-email/SKILL.md +126 -0
  226. package/skills/serve-deploy-ml-model/SKILL.md +107 -0
  227. package/skills/setup-cdn-edge-waf/SKILL.md +107 -0
  228. package/skills/setup-devcontainer-env/SKILL.md +131 -0
  229. package/skills/setup-lint-format-precommit/SKILL.md +140 -0
  230. package/skills/setup-monorepo-tooling/SKILL.md +125 -0
  231. package/skills/ship-mobile-app-store-release/SKILL.md +137 -0
  232. package/skills/structured-output-llm/SKILL.md +86 -0
  233. package/skills/supply-chain-sbom-provenance/SKILL.md +120 -0
  234. package/skills/test-data-factories/SKILL.md +158 -0
  235. package/skills/threat-model-stride/SKILL.md +123 -0
  236. package/skills/train-evaluate-ml-model/SKILL.md +109 -0
  237. package/skills/unicode-text-correctness/SKILL.md +109 -0
  238. package/skills/visual-regression-testing/SKILL.md +120 -0
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,10 +1,29 @@
1
- import { jsx as _jsx, jsxs as _jsxs } from "react/jsx-runtime";
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
- import { PROVIDERS } from '../providers/registry.js';
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';
7
- /** first-run setup wizard: เลือก provider → ใส่ API key → เลือก model → เสนอสร้าง second-brain */
8
+ import { detectCodex } from '../providers/codex.js';
9
+ import { BRAND } from '../brand.js';
10
+ // จัดลำดับ provider ในเมนู: cloud ยอดนิยม → cloud อื่น → local → ChatGPT-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 */
8
27
  export function SetupWizard({ onComplete }) {
9
28
  const [step, setStep] = useState('provider');
10
29
  const [provider, setProvider] = useState('');
@@ -12,8 +31,32 @@ export function SetupWizard({ onComplete }) {
12
31
  const [model, setModel] = useState('');
13
32
  const [remote, setRemote] = useState([]);
14
33
  const [loadingModels, setLoadingModels] = useState(false);
34
+ const [codexStatus, setCodexStatus] = useState(null);
35
+ const [recheck, setRecheck] = useState(0);
36
+ const [keyError, setKeyError] = useState('');
15
37
  const cfg = provider ? PROVIDERS[provider] : undefined;
16
- 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)
17
60
  useEffect(() => {
18
61
  if (step !== 'model' || !cfg)
19
62
  return;
@@ -28,19 +71,61 @@ export function SetupWizard({ onComplete }) {
28
71
  }, [step, cfg, key]);
29
72
  const modelOptions = cfg ? mergeModelOptions(cfg, remote) : [];
30
73
  const finish = (createBrain) => onComplete({ provider, model, envVar: cfg?.envVar ?? '', key, createBrain });
31
- return (_jsxs(Box, { flexDirection: "column", gap: 1, marginY: 1, children: [_jsx(Text, { bold: true, color: "cyan", children: "\u2699 \u0E15\u0E31\u0E49\u0E07\u0E04\u0E48\u0E32 Sanook AI CLI (\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) => {
32
108
  setProvider(v);
33
- setStep(PROVIDERS[v].requiresKey ? 'key' : 'model');
34
- } })] })), 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: " (key \u0E15\u0E23\u0E07\u0E08\u0E32\u0E01 console \u0E02\u0E2D\u0E07\u0E04\u0E48\u0E32\u0E22 \u2014 \u0E2B\u0E49\u0E32\u0E21 OAuth/subscription token)" }), _jsx(PasswordInput, { placeholder: cfg.envVar, onSubmit: (v) => {
35
- setKey(v.trim());
36
- setStep('model');
37
- } })] })), 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' &&
38
123
  cfg &&
39
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) => {
40
125
  setModel(`${provider}:${v}`);
41
126
  setStep('brain-offer');
42
127
  } })] }))), step === 'brain-offer' && (_jsxs(Box, { flexDirection: "column", children: [_jsx(Text, { children: "4. \u0E2A\u0E23\u0E49\u0E32\u0E07 \"second brain\" workspace (Obsidian) \u0E2A\u0E33\u0E2B\u0E23\u0E31\u0E1A\u0E08\u0E31\u0E14\u0E40\u0E01\u0E47\u0E1A\u0E07\u0E32\u0E19 + \u0E04\u0E27\u0E32\u0E21\u0E08\u0E33 AI?" }), _jsx(Select, { options: [
43
128
  { label: 'สร้างเลย — ตอบไม่กี่ข้อ (ชื่อ + ที่เก็บ)', value: 'yes' },
44
- { label: 'ข้ามไปก่อน (สั่ง sanook brain init ทีหลังได้)', value: 'no' },
129
+ { label: `ข้ามไปก่อน (สั่ง ${BRAND.cliName} brain init ทีหลังได้)`, value: 'no' },
45
130
  ], onChange: (v) => finish(v === 'yes') })] }))] }));
46
131
  }
@@ -0,0 +1,83 @@
1
+ import { useState, useRef } from 'react';
2
+ export function useEditor(history) {
3
+ const [value, setValue] = useState('');
4
+ const [cursor, setCursor] = useState(0);
5
+ const histIndex = useRef(null); // null = กำลังแก้ draft (ไม่ได้อยู่ในประวัติ)
6
+ const draft = useRef('');
7
+ const set = (v, c = v.length) => {
8
+ setValue(v);
9
+ setCursor(Math.max(0, Math.min(c, v.length)));
10
+ };
11
+ const reset = () => {
12
+ histIndex.current = null;
13
+ set('');
14
+ };
15
+ const insert = (s) => set(value.slice(0, cursor) + s + value.slice(cursor), cursor + s.length);
16
+ const historyPrev = () => {
17
+ if (!history.length)
18
+ return;
19
+ if (histIndex.current === null) {
20
+ draft.current = value;
21
+ histIndex.current = history.length - 1;
22
+ }
23
+ else {
24
+ histIndex.current = Math.max(0, histIndex.current - 1);
25
+ }
26
+ set(history[histIndex.current]);
27
+ };
28
+ const historyNext = () => {
29
+ if (histIndex.current === null)
30
+ return;
31
+ if (histIndex.current >= history.length - 1) {
32
+ histIndex.current = null;
33
+ set(draft.current);
34
+ }
35
+ else {
36
+ histIndex.current += 1;
37
+ set(history[histIndex.current]);
38
+ }
39
+ };
40
+ const handleKey = (input, key) => {
41
+ if (key.return) {
42
+ // Alt/Option+Enter หรือบรรทัดลงท้าย "\" → ขึ้นบรรทัดใหม่ (multiline) ไม่ submit
43
+ if (key.meta)
44
+ return insert('\n'), 'handled';
45
+ if (value.slice(0, cursor).endsWith('\\'))
46
+ return set(value.slice(0, cursor - 1) + '\n' + value.slice(cursor), cursor), 'handled';
47
+ return 'submit';
48
+ }
49
+ if (key.upArrow)
50
+ return historyPrev(), 'handled';
51
+ if (key.downArrow)
52
+ return historyNext(), 'handled';
53
+ if (key.leftArrow)
54
+ return setCursor(Math.max(0, cursor - 1)), 'handled';
55
+ if (key.rightArrow)
56
+ return setCursor(Math.min(value.length, cursor + 1)), 'handled';
57
+ if (key.ctrl) {
58
+ switch (input) {
59
+ case 'a': return setCursor(0), 'handled';
60
+ case 'e': return setCursor(value.length), 'handled';
61
+ case 'u': return set(value.slice(cursor), 0), 'handled'; // ลบจากต้นบรรทัดถึง cursor
62
+ case 'k': return set(value.slice(0, cursor), cursor), 'handled'; // ลบจาก cursor ถึงท้าย
63
+ case 'w': { // ลบ word ก่อน cursor (รวมกรณีเหลือแต่ whitespace)
64
+ const left = value.slice(0, cursor).replace(/\s+$|\s*\S+\s*$/, '');
65
+ return set(left + value.slice(cursor), left.length), 'handled';
66
+ }
67
+ case 'c': return 'interrupt';
68
+ default: return 'handled';
69
+ }
70
+ }
71
+ if (key.backspace || key.delete) {
72
+ if (cursor === 0)
73
+ return 'handled';
74
+ return set(value.slice(0, cursor - 1) + value.slice(cursor), cursor - 1), 'handled';
75
+ }
76
+ if (input && !key.meta) {
77
+ histIndex.current = null; // เริ่มพิมพ์ = ออกจากโหมดดูประวัติ
78
+ return insert(input), 'handled';
79
+ }
80
+ return 'none';
81
+ };
82
+ return { value, cursor, setValue: (v) => set(v), reset, handleKey };
83
+ }
package/dist/update.js ADDED
@@ -0,0 +1,114 @@
1
+ import { spawn } from 'node:child_process';
2
+ const DEFAULT_REGISTRY = 'https://registry.npmjs.org';
3
+ export const UPDATE_CHECK_INTERVAL_MS = 24 * 60 * 60 * 1000;
4
+ function packageUrl(registry, packageName) {
5
+ const base = registry.replace(/\/+$/, '') || DEFAULT_REGISTRY;
6
+ const encoded = encodeURIComponent(packageName).replace(/^%40/, '@');
7
+ return `${base}/${encoded}`;
8
+ }
9
+ function splitVersion(version) {
10
+ const [withoutBuild] = version.trim().replace(/^v/, '').split('+');
11
+ const [corePart, prereleasePart = ''] = withoutBuild.split('-', 2);
12
+ return {
13
+ core: corePart.split('.').map((part) => Number.parseInt(part, 10)).map((n) => (Number.isFinite(n) ? n : 0)),
14
+ prerelease: prereleasePart ? prereleasePart.split('.') : [],
15
+ };
16
+ }
17
+ function comparePrerelease(a, b) {
18
+ if (!a.length && !b.length)
19
+ return 0;
20
+ if (!a.length)
21
+ return 1;
22
+ if (!b.length)
23
+ return -1;
24
+ const len = Math.max(a.length, b.length);
25
+ for (let i = 0; i < len; i++) {
26
+ const pa = a[i];
27
+ const pb = b[i];
28
+ if (pa === undefined)
29
+ return -1;
30
+ if (pb === undefined)
31
+ return 1;
32
+ const na = /^\d+$/.test(pa) ? Number(pa) : Number.NaN;
33
+ const nb = /^\d+$/.test(pb) ? Number(pb) : Number.NaN;
34
+ if (Number.isFinite(na) && Number.isFinite(nb) && na !== nb)
35
+ return na > nb ? 1 : -1;
36
+ if (Number.isFinite(na) !== Number.isFinite(nb))
37
+ return Number.isFinite(na) ? -1 : 1;
38
+ if (pa !== pb)
39
+ return pa > pb ? 1 : -1;
40
+ }
41
+ return 0;
42
+ }
43
+ export function compareVersions(a, b) {
44
+ const va = splitVersion(a);
45
+ const vb = splitVersion(b);
46
+ const len = Math.max(va.core.length, vb.core.length, 3);
47
+ for (let i = 0; i < len; i++) {
48
+ const na = va.core[i] ?? 0;
49
+ const nb = vb.core[i] ?? 0;
50
+ if (na !== nb)
51
+ return na > nb ? 1 : -1;
52
+ }
53
+ return comparePrerelease(va.prerelease, vb.prerelease);
54
+ }
55
+ export function isNewerVersion(latest, current) {
56
+ return compareVersions(latest, current) > 0;
57
+ }
58
+ export function installCommand(packageName) {
59
+ return `npm install -g ${packageName}@latest`;
60
+ }
61
+ export function shouldCheckForUpdate(cache, nowMs = Date.now(), intervalMs = UPDATE_CHECK_INTERVAL_MS) {
62
+ if (!cache?.checkedAt)
63
+ return true;
64
+ const checkedAt = Date.parse(cache.checkedAt);
65
+ if (!Number.isFinite(checkedAt))
66
+ return true;
67
+ if (checkedAt > nowMs)
68
+ return true;
69
+ return nowMs - checkedAt >= intervalMs;
70
+ }
71
+ export async function fetchLatestVersion(meta, opts = {}) {
72
+ const fetchImpl = opts.fetchImpl ?? fetch;
73
+ const ctrl = new AbortController();
74
+ const timer = setTimeout(() => ctrl.abort(), opts.timeoutMs ?? 8000);
75
+ try {
76
+ const res = await fetchImpl(packageUrl(opts.registry ?? process.env.npm_config_registry ?? DEFAULT_REGISTRY, meta.name), {
77
+ headers: { accept: 'application/vnd.npm.install-v1+json' },
78
+ signal: ctrl.signal,
79
+ });
80
+ if (!res.ok) {
81
+ throw new Error(`npm registry ตอบ ${res.status}${res.statusText ? ` ${res.statusText}` : ''}`);
82
+ }
83
+ const body = (await res.json());
84
+ const latest = body['dist-tags']?.latest;
85
+ if (!latest)
86
+ throw new Error('npm registry ไม่มี dist-tag "latest"');
87
+ return latest;
88
+ }
89
+ finally {
90
+ clearTimeout(timer);
91
+ }
92
+ }
93
+ export async function checkForUpdate(meta, opts = {}) {
94
+ const latestVersion = await fetchLatestVersion(meta, opts);
95
+ return {
96
+ packageName: meta.name,
97
+ currentVersion: meta.version,
98
+ latestVersion,
99
+ isOutdated: isNewerVersion(latestVersion, meta.version),
100
+ installCommand: installCommand(meta.name),
101
+ };
102
+ }
103
+ export function installLatest(meta, opts = {}) {
104
+ const spawnImpl = opts.spawnImpl ?? spawn;
105
+ return new Promise((resolve, reject) => {
106
+ const child = spawnImpl('npm', ['install', '-g', `${meta.name}@latest`], {
107
+ stdio: 'inherit',
108
+ env: process.env,
109
+ shell: process.platform === 'win32', // Windows: npm = npm.cmd → spawn ตรงๆ ENOENT
110
+ });
111
+ child.once('error', reject);
112
+ child.once('close', (code) => resolve(code ?? 1));
113
+ });
114
+ }
@@ -0,0 +1,173 @@
1
+ // ============================================================================
2
+ // src/worktree.ts — throwaway git worktrees for ISOLATED parallel write agents.
3
+ //
4
+ // When several sub-agents edit files at once, they would clobber each other in
5
+ // one working tree. This gives each one its own `git worktree` (detached at the
6
+ // current HEAD), so their writes are physically isolated; afterwards each
7
+ // worktree's diff is captured and applied back to the main tree sequentially.
8
+ //
9
+ // Reuses runGit()/isGitRepo() from src/git.ts (execFile, no shell). Everything is
10
+ // best-effort + defensive: not a git repo → returns null (caller falls back to a
11
+ // shared tree); a failed apply is reported, never thrown past the orchestrator.
12
+ // ============================================================================
13
+ import { mkdtemp, rm, writeFile, readFile, realpath } from 'node:fs/promises';
14
+ import { tmpdir } from 'node:os';
15
+ import { join } from 'node:path';
16
+ import { randomUUID } from 'node:crypto';
17
+ import { runGit, isGitRepo } from './git.js';
18
+ /** repo root of `cwd` (the top-level working dir), or null if not a git repo. */
19
+ export async function getRepoRoot(cwd = process.cwd()) {
20
+ if (!(await isGitRepo(cwd)))
21
+ return null;
22
+ try {
23
+ return (await runGit(['rev-parse', '--show-toplevel'], cwd)).trim();
24
+ }
25
+ catch {
26
+ return null;
27
+ }
28
+ }
29
+ /**
30
+ * Create a detached worktree at the current HEAD of the repo containing `cwd`.
31
+ * Returns null if `cwd` is not in a git repo (caller should then run un-isolated).
32
+ */
33
+ export async function createWorktree(cwd = process.cwd()) {
34
+ const repoRoot = await getRepoRoot(cwd);
35
+ if (!repoRoot)
36
+ return null;
37
+ try {
38
+ const baseRef = (await runGit(['rev-parse', 'HEAD'], repoRoot)).trim();
39
+ const tmpParent = await mkdtemp(join(tmpdir(), 'sanook-wt-'));
40
+ const path = join(tmpParent, `t-${randomUUID().slice(0, 8)}`); // must not pre-exist; git creates it
41
+ await runGit(['worktree', 'add', '--detach', path, baseRef], repoRoot);
42
+ const real = await realpath(path).catch(() => path);
43
+ return { path: real, baseRef, repoRoot, tmpParent };
44
+ }
45
+ catch {
46
+ return null;
47
+ }
48
+ }
49
+ /**
50
+ * Capture everything the sub-agent changed in its worktree as a unified diff
51
+ * (vs the base HEAD), including new/untracked files. Empty string = no changes.
52
+ */
53
+ export async function captureDiff(wt) {
54
+ try {
55
+ await runGit(['add', '-A'], wt.path); // stage incl. untracked so they appear in the diff
56
+ return await runGit(['diff', '--cached', '--binary', wt.baseRef], wt.path);
57
+ }
58
+ catch {
59
+ return '';
60
+ }
61
+ }
62
+ /**
63
+ * Apply a captured diff back into the main repo (at its root). Uses --3way so a
64
+ * clean patch lands and a conflicting one is reported rather than silently lost.
65
+ * Empty diff is a no-op success.
66
+ */
67
+ export async function applyDiff(diff, repoRoot) {
68
+ if (!diff.trim())
69
+ return { ok: true };
70
+ const files = diffFiles(diff);
71
+ if (files.length) {
72
+ try {
73
+ await runGit(['diff', '--cached', '--quiet', '--', ...files], repoRoot);
74
+ }
75
+ catch {
76
+ return { ok: false, reason: 'touched files have staged changes; refusing to disturb the index' };
77
+ }
78
+ }
79
+ // Snapshot every touched file's exact pre-apply content (or absence). `git apply --3way`
80
+ // can leave conflict markers + unmerged index entries on failure, and across git versions
81
+ // `--check` doesn't always foresee a 3-way conflict — so on ANY failure we roll the working
82
+ // tree back to precisely this snapshot, preserving uncommitted changes that were already there.
83
+ const before = new Map();
84
+ await Promise.all(files.map(async (f) => {
85
+ before.set(f, await readFile(join(repoRoot, f)).catch(() => null));
86
+ }));
87
+ const patchFile = join(tmpdir(), `sanook-patch-${randomUUID().slice(0, 8)}.diff`);
88
+ try {
89
+ await writeFile(patchFile, diff, 'utf8');
90
+ await runGit(['apply', '--check', '--3way', '--whitespace=nowarn', patchFile], repoRoot); // fast reject
91
+ await runGit(['apply', '--3way', '--whitespace=nowarn', patchFile], repoRoot);
92
+ return { ok: true };
93
+ }
94
+ catch (e) {
95
+ // restore exact pre-apply content + clear any index/unmerged entries --3way may have created
96
+ await Promise.all([...before].map(async ([f, content]) => {
97
+ const abs = join(repoRoot, f);
98
+ if (content == null)
99
+ await rm(abs, { force: true }).catch(() => { });
100
+ else
101
+ await writeFile(abs, content).catch(() => { });
102
+ }));
103
+ if (files.length)
104
+ await runGit(['reset', '-q', '--', ...files], repoRoot).catch(() => { });
105
+ return { ok: false, reason: e.message.split('\n')[0] };
106
+ }
107
+ finally {
108
+ await rm(patchFile, { force: true }).catch(() => { });
109
+ }
110
+ }
111
+ /** Remove the worktree and its temp parent (best-effort; prunes git's bookkeeping). */
112
+ export async function removeWorktree(wt) {
113
+ await runGit(['worktree', 'remove', '--force', wt.path], wt.repoRoot).catch(() => { });
114
+ await rm(wt.tmpParent, { recursive: true, force: true }).catch(() => { });
115
+ await runGit(['worktree', 'prune'], wt.repoRoot).catch(() => { });
116
+ }
117
+ /** changed file paths in a captured diff (for a human-readable summary). */
118
+ export function diffFiles(diff) {
119
+ const files = new Set();
120
+ for (const m of diff.matchAll(/^diff --git a\/(.+?) b\/(.+)$/gm))
121
+ files.add(m[2]);
122
+ return [...files];
123
+ }
124
+ /**
125
+ * Run `work(task, cwd, i)` for each task in ITS OWN throwaway worktree (concurrently,
126
+ * via the injected `runConcurrently`), then capture+apply each worktree's diff back
127
+ * into the main tree sequentially. The work callback is injected so this whole
128
+ * lifecycle (create → isolate → merge → cleanup) unit-tests with no agent/network.
129
+ * Returns null if `root` is not a git repo or worktrees can't be created.
130
+ */
131
+ export async function runInWorktrees(tasks, root, work, runConcurrently) {
132
+ if (!(await getRepoRoot(root)))
133
+ return null;
134
+ const wts = [];
135
+ for (let i = 0; i < tasks.length; i++) {
136
+ const wt = await createWorktree(root);
137
+ if (!wt) {
138
+ for (const w of wts)
139
+ await removeWorktree(w);
140
+ return null;
141
+ }
142
+ wts.push(wt);
143
+ }
144
+ let results;
145
+ try {
146
+ results = await runConcurrently(tasks.map((t, i) => () => work(t, wts[i].path, i)));
147
+ }
148
+ catch (e) {
149
+ for (const w of wts)
150
+ await removeWorktree(w);
151
+ throw e;
152
+ }
153
+ const out = [];
154
+ for (let i = 0; i < wts.length; i++) {
155
+ let merge;
156
+ try {
157
+ const diff = await captureDiff(wts[i]);
158
+ if (!diff.trim()) {
159
+ merge = { description: tasks[i].description, changed: [], applied: true };
160
+ }
161
+ else {
162
+ const changed = diffFiles(diff);
163
+ const res = await applyDiff(diff, root); // sequential → deterministic conflict handling
164
+ merge = { description: tasks[i].description, changed, applied: res.ok, reason: res.reason };
165
+ }
166
+ }
167
+ finally {
168
+ await removeWorktree(wts[i]);
169
+ }
170
+ out.push({ result: results[i], merge });
171
+ }
172
+ return out;
173
+ }
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "sanook-cli",
3
- "version": "0.4.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": {
@@ -10,6 +10,7 @@
10
10
  "dist",
11
11
  "skills",
12
12
  "second-brain",
13
+ "scripts/postinstall.mjs",
13
14
  "README.md",
14
15
  "CHANGELOG.md",
15
16
  "LICENSE",
@@ -21,6 +22,7 @@
21
22
  "typecheck": "tsc --noEmit",
22
23
  "test": "vitest run",
23
24
  "eval": "tsx src/eval/run.ts",
25
+ "postinstall": "node scripts/postinstall.mjs",
24
26
  "prepublishOnly": "npm run build"
25
27
  },
26
28
  "engines": {
@@ -28,15 +30,19 @@
28
30
  },
29
31
  "keywords": [
30
32
  "ai",
31
- "cli",
32
33
  "coding-agent",
33
- "agent",
34
+ "ai-agent",
35
+ "cli",
34
36
  "llm",
35
37
  "terminal",
36
38
  "byok",
37
39
  "mcp",
38
- "gateway",
39
- "cron",
40
+ "second-brain",
41
+ "obsidian",
42
+ "cross-session-memory",
43
+ "claude-code-alternative",
44
+ "ai-coding",
45
+ "agent",
40
46
  "claude",
41
47
  "gemini"
42
48
  ],