sanook-cli 0.4.0 → 0.5.0

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 (235) hide show
  1. package/.env.example +19 -0
  2. package/CHANGELOG.md +144 -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 +394 -51
  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 +2 -2
  35. package/dist/providers/keys.js +3 -2
  36. package/dist/providers/registry.js +133 -1
  37. package/dist/repomap.js +93 -0
  38. package/dist/search/chunk.js +158 -0
  39. package/dist/search/embed-store.js +187 -0
  40. package/dist/search/engine.js +203 -0
  41. package/dist/search/fuse.js +35 -0
  42. package/dist/search/index-core.js +187 -0
  43. package/dist/search/indexer.js +241 -0
  44. package/dist/search/store.js +77 -0
  45. package/dist/session.js +42 -8
  46. package/dist/skill-install.js +10 -10
  47. package/dist/skills.js +12 -9
  48. package/dist/summarize.js +31 -0
  49. package/dist/tools/bash.js +21 -2
  50. package/dist/tools/diagnostics.js +41 -0
  51. package/dist/tools/edit.js +29 -7
  52. package/dist/tools/index.js +8 -1
  53. package/dist/tools/list.js +7 -2
  54. package/dist/tools/permission.js +90 -9
  55. package/dist/tools/read.js +23 -4
  56. package/dist/tools/remember.js +1 -1
  57. package/dist/tools/sandbox.js +61 -0
  58. package/dist/tools/search.js +105 -4
  59. package/dist/tools/task.js +195 -29
  60. package/dist/tools/timeout.js +35 -0
  61. package/dist/tools/util.js +10 -0
  62. package/dist/tools/write.js +6 -4
  63. package/dist/trust.js +89 -0
  64. package/dist/ui/app.js +218 -27
  65. package/dist/ui/banner.js +4 -9
  66. package/dist/ui/history.js +30 -0
  67. package/dist/ui/mentions.js +44 -0
  68. package/dist/ui/setup.js +6 -5
  69. package/dist/ui/useEditor.js +83 -0
  70. package/dist/update.js +114 -0
  71. package/dist/worktree.js +173 -0
  72. package/package.json +11 -5
  73. package/scripts/postinstall.mjs +33 -0
  74. package/second-brain/.agents/_Index.md +30 -0
  75. package/second-brain/.agents/skills/_Index.md +30 -0
  76. package/second-brain/.agents/workflows/_Index.md +30 -0
  77. package/second-brain/AGENTS.md +4 -4
  78. package/second-brain/Acceptance/_Index.md +30 -0
  79. package/second-brain/Acceptance/golden-case-template.md +39 -0
  80. package/second-brain/Areas/_Index.md +30 -0
  81. package/second-brain/Bugs/System-OS/_Index.md +30 -0
  82. package/second-brain/Bugs/_Index.md +30 -0
  83. package/second-brain/CLAUDE.md +4 -1
  84. package/second-brain/Checklists/_Index.md +30 -0
  85. package/second-brain/Checklists/preflight-postflight-template.md +29 -0
  86. package/second-brain/Distillations/_Index.md +30 -0
  87. package/second-brain/Entities/_Index.md +30 -0
  88. package/second-brain/Entities/entity-template.md +33 -0
  89. package/second-brain/Evals/_Index.md +30 -0
  90. package/second-brain/Evals/correction-pairs.md +24 -0
  91. package/second-brain/Evals/failure-taxonomy.md +24 -0
  92. package/second-brain/Evals/golden-set.md +25 -0
  93. package/second-brain/Evals/quality-ledger.md +23 -0
  94. package/second-brain/Evals/self-eval-rubric.md +23 -0
  95. package/second-brain/GEMINI.md +4 -4
  96. package/second-brain/Goals/_Index.md +30 -0
  97. package/second-brain/Handoffs/_Index.md +30 -0
  98. package/second-brain/Home.md +7 -0
  99. package/second-brain/Intake/Raw Sources/_Index.md +30 -0
  100. package/second-brain/Intake/_Index.md +30 -0
  101. package/second-brain/Intake/_Quarantine/_Index.md +30 -0
  102. package/second-brain/Learning/_Index.md +30 -0
  103. package/second-brain/Playbooks/_Index.md +30 -0
  104. package/second-brain/Playbooks/playbook-template.md +23 -0
  105. package/second-brain/Projects/_Index.md +30 -0
  106. package/second-brain/Prompts/_Index.md +30 -0
  107. package/second-brain/README.md +2 -1
  108. package/second-brain/Research/_Index.md +30 -0
  109. package/second-brain/Retrospectives/_Index.md +30 -0
  110. package/second-brain/Reviews/_Index.md +30 -0
  111. package/second-brain/Runbooks/_Index.md +30 -0
  112. package/second-brain/Runbooks/eval-loop.md +24 -0
  113. package/second-brain/Sessions/_Index.md +30 -0
  114. package/second-brain/Shared/AI-Context-Index.md +20 -0
  115. package/second-brain/Shared/AI-Threads/_Index.md +30 -0
  116. package/second-brain/Shared/Archive/_Index.md +30 -0
  117. package/second-brain/Shared/Assets/_Index.md +30 -0
  118. package/second-brain/Shared/Context-Packs/_Index.md +30 -0
  119. package/second-brain/Shared/Context7-Docs/_Index.md +30 -0
  120. package/second-brain/Shared/Coordination/NOW.md +28 -0
  121. package/second-brain/Shared/Coordination/_Index.md +30 -0
  122. package/second-brain/Shared/Coordination/agent-registry.md +24 -0
  123. package/second-brain/Shared/Coordination/task-board/_Index.md +30 -0
  124. package/second-brain/Shared/Coordination/task-board/task-template.md +43 -0
  125. package/second-brain/Shared/Coordination/task-board.md +32 -0
  126. package/second-brain/Shared/Core-Facts/_Index.md +30 -0
  127. package/second-brain/Shared/Decision-Memory/_Index.md +30 -0
  128. package/second-brain/Shared/Glossary/_Index.md +30 -0
  129. package/second-brain/Shared/Memory-Inbox/_Index.md +30 -0
  130. package/second-brain/Shared/Operating-State/_Index.md +30 -0
  131. package/second-brain/Shared/Prompting/_Index.md +30 -0
  132. package/second-brain/Shared/Provenance/_Index.md +30 -0
  133. package/second-brain/Shared/Rules/_Index.md +30 -0
  134. package/second-brain/Shared/Rules/contextual-note-rule.md +30 -0
  135. package/second-brain/Shared/Rules/frontmatter-standard.md +10 -0
  136. package/second-brain/Shared/Rules/memory-write-protocol.md +28 -0
  137. package/second-brain/Shared/Rules/procedural-runbook-header.md +40 -0
  138. package/second-brain/Shared/Rules/review-and-staleness-policy.md +22 -0
  139. package/second-brain/Shared/Rules/rules-formatting.md +34 -0
  140. package/second-brain/Shared/Scripts/_Index.md +30 -0
  141. package/second-brain/Shared/Scripts-Archive/_Index.md +30 -0
  142. package/second-brain/Shared/Tech-Standards/_Index.md +30 -0
  143. package/second-brain/Shared/Tech-Standards/verification-standard.md +40 -0
  144. package/second-brain/Shared/User-Memory/_Index.md +30 -0
  145. package/second-brain/Shared/User-Persona/_Index.md +30 -0
  146. package/second-brain/Shared/User-Persona/owner-profile.md +25 -0
  147. package/second-brain/Shared/Working-Memory/_Index.md +30 -0
  148. package/second-brain/Shared/_Index.md +30 -0
  149. package/second-brain/Shared/mcp-servers/_Index.md +30 -0
  150. package/second-brain/Skills/_Index.md +30 -0
  151. package/second-brain/Templates/_Index.md +30 -0
  152. package/second-brain/Templates/bug.md +2 -0
  153. package/second-brain/Templates/handoff.md +2 -0
  154. package/second-brain/Templates/session.md +2 -0
  155. package/second-brain/Tools/_Index.md +30 -0
  156. package/second-brain/Traces/_Index.md +30 -0
  157. package/second-brain/Vault Structure Map.md +33 -1
  158. package/second-brain/copilot/_Index.md +30 -0
  159. package/skills/audit-license-compliance/SKILL.md +117 -0
  160. package/skills/author-codemod/SKILL.md +110 -0
  161. package/skills/build-audit-logging/SKILL.md +112 -0
  162. package/skills/build-cdc-streaming-pipeline/SKILL.md +123 -0
  163. package/skills/build-cli-tool/SKILL.md +108 -0
  164. package/skills/build-data-table/SKILL.md +141 -0
  165. package/skills/build-native-mobile-ui/SKILL.md +154 -0
  166. package/skills/build-offline-first-sync/SKILL.md +118 -0
  167. package/skills/build-realtime-channel/SKILL.md +122 -0
  168. package/skills/build-vector-search/SKILL.md +131 -0
  169. package/skills/compose-local-dev-stack/SKILL.md +149 -0
  170. package/skills/configure-bundler-build/SKILL.md +166 -0
  171. package/skills/configure-dns-tls/SKILL.md +142 -0
  172. package/skills/configure-reverse-proxy-lb/SKILL.md +129 -0
  173. package/skills/configure-security-headers-csp/SKILL.md +122 -0
  174. package/skills/contract-testing/SKILL.md +140 -0
  175. package/skills/datetime-timezone-correctness/SKILL.md +125 -0
  176. package/skills/debug-ci-pipeline-failure/SKILL.md +134 -0
  177. package/skills/debug-flaky-tests/SKILL.md +128 -0
  178. package/skills/defend-llm-prompt-injection/SKILL.md +110 -0
  179. package/skills/deliver-webhooks/SKILL.md +116 -0
  180. package/skills/design-api-pagination/SKILL.md +144 -0
  181. package/skills/design-authorization-model/SKILL.md +119 -0
  182. package/skills/design-backup-dr-recovery/SKILL.md +113 -0
  183. package/skills/design-event-sourcing-cqrs/SKILL.md +143 -0
  184. package/skills/design-multi-tenancy/SKILL.md +100 -0
  185. package/skills/design-protobuf-grpc-service/SKILL.md +146 -0
  186. package/skills/design-relational-schema/SKILL.md +129 -0
  187. package/skills/design-search-index-infra/SKILL.md +151 -0
  188. package/skills/design-state-machine/SKILL.md +108 -0
  189. package/skills/design-token-system/SKILL.md +109 -0
  190. package/skills/distributed-locks-leases/SKILL.md +120 -0
  191. package/skills/encrypt-sensitive-data/SKILL.md +148 -0
  192. package/skills/feature-flags-rollout/SKILL.md +130 -0
  193. package/skills/file-upload-object-storage/SKILL.md +107 -0
  194. package/skills/fuzz-dynamic-security-test/SKILL.md +111 -0
  195. package/skills/harden-llm-app-reliability/SKILL.md +126 -0
  196. package/skills/i18n-localization-setup/SKILL.md +113 -0
  197. package/skills/idempotency-keys/SKILL.md +107 -0
  198. package/skills/implement-push-notifications/SKILL.md +142 -0
  199. package/skills/ingest-webhook-secure/SKILL.md +120 -0
  200. package/skills/integrate-oauth-oidc/SKILL.md +126 -0
  201. package/skills/load-stress-test/SKILL.md +129 -0
  202. package/skills/map-privacy-data-gdpr/SKILL.md +146 -0
  203. package/skills/model-nosql-data/SKILL.md +118 -0
  204. package/skills/money-decimal-arithmetic/SKILL.md +123 -0
  205. package/skills/monitor-ml-drift/SKILL.md +109 -0
  206. package/skills/numeric-precision-units/SKILL.md +144 -0
  207. package/skills/optimize-llm-cost-latency/SKILL.md +103 -0
  208. package/skills/optimize-react-rerenders/SKILL.md +124 -0
  209. package/skills/orchestrate-agent-workflow/SKILL.md +100 -0
  210. package/skills/payments-billing-integration/SKILL.md +114 -0
  211. package/skills/pin-toolchain-versions/SKILL.md +116 -0
  212. package/skills/plan-strangler-migration/SKILL.md +95 -0
  213. package/skills/property-based-testing/SKILL.md +108 -0
  214. package/skills/publish-package-registry/SKILL.md +130 -0
  215. package/skills/recover-git-state/SKILL.md +119 -0
  216. package/skills/remediate-web-vulnerabilities/SKILL.md +125 -0
  217. package/skills/resilience-timeouts-retries/SKILL.md +104 -0
  218. package/skills/resolve-merge-rebase-conflict/SKILL.md +97 -0
  219. package/skills/rewrite-git-history/SKILL.md +109 -0
  220. package/skills/scaffold-cross-platform-app/SKILL.md +137 -0
  221. package/skills/schema-evolution-compatibility/SKILL.md +121 -0
  222. package/skills/send-transactional-email/SKILL.md +126 -0
  223. package/skills/serve-deploy-ml-model/SKILL.md +107 -0
  224. package/skills/setup-cdn-edge-waf/SKILL.md +107 -0
  225. package/skills/setup-devcontainer-env/SKILL.md +131 -0
  226. package/skills/setup-lint-format-precommit/SKILL.md +140 -0
  227. package/skills/setup-monorepo-tooling/SKILL.md +125 -0
  228. package/skills/ship-mobile-app-store-release/SKILL.md +137 -0
  229. package/skills/structured-output-llm/SKILL.md +86 -0
  230. package/skills/supply-chain-sbom-provenance/SKILL.md +120 -0
  231. package/skills/test-data-factories/SKILL.md +158 -0
  232. package/skills/threat-model-stride/SKILL.md +123 -0
  233. package/skills/train-evaluate-ml-model/SKILL.md +109 -0
  234. package/skills/unicode-text-correctness/SKILL.md +109 -0
  235. package/skills/visual-regression-testing/SKILL.md +120 -0
package/dist/config.js CHANGED
@@ -1,19 +1,72 @@
1
1
  import { z } from 'zod';
2
2
  import { readFile, writeFile, mkdir, chmod } from 'node:fs/promises';
3
- import { homedir } from 'node:os';
4
3
  import { join } from 'node:path';
5
- export const CONFIG_DIR = join(homedir(), '.sanook');
4
+ import { appHomePath, appProjectPath, BRAND } from './brand.js';
5
+ import { projectRoot, projectTrustStatus } from './trust.js';
6
+ import { registerPricing } from './cost.js';
7
+ export const CONFIG_DIR = appHomePath();
6
8
  const CONFIG_PATH = join(CONFIG_DIR, 'config.json');
7
9
  const AUTH_PATH = join(CONFIG_DIR, 'auth.json'); // API keys (chmod 0600)
10
+ export const PricingOverrideSchema = z.record(z.string(), z
11
+ .object({
12
+ input: z.number().finite().nonnegative().optional(),
13
+ output: z.number().finite().nonnegative().optional(),
14
+ cacheWrite: z.number().finite().nonnegative().optional(),
15
+ cacheRead: z.number().finite().nonnegative().optional(),
16
+ })
17
+ .strict()
18
+ .refine((v) => Object.keys(v).length > 0, 'ต้องใส่ราคาอย่างน้อยหนึ่ง field'));
8
19
  export const ConfigSchema = z.object({
9
20
  model: z.string().default('sonnet'),
21
+ /** model สำรองเมื่อ model หลักล้ม (rate-limit/billing) — ตั้งด้วย sanook config set fallbackModel <spec> */
22
+ fallbackModel: z.string().optional(),
10
23
  budgetUsd: z.number().positive().optional(),
11
24
  maxSteps: z.number().int().positive().default(20),
12
25
  // auto = รัน tool เลย (act-first) · ask = ขออนุมัติก่อน write/bash/commit
13
- permissionMode: z.enum(['auto', 'ask']).default('auto'),
26
+ permissionMode: z.enum(['auto', 'ask']).default('ask'),
14
27
  // path ของ second-brain workspace ที่ scaffold ไว้ (sanook brain) — optional
15
28
  brainPath: z.string().optional(),
29
+ // pricing override/extension per "provider:model" → ทำให้ budget cap ใช้ได้กับ model ที่ยังไม่มีในตาราง
30
+ pricing: PricingOverrideSchema.optional(),
31
+ // ── token/cost tuning (ดู agentTuning) — .catch กันค่า config.json ผิดทำ boot พัง (agentTuning อ่าน raw + coerce เองด้วย) ──
32
+ // prompt-cache TTL: '5m' (default, ephemeral) · '1h' (จ่าย write 2x แต่ cache อยู่ยาว — คุ้มเมื่อ session หยุดๆทำๆ)
33
+ cacheTtl: z.enum(['5m', '1h']).catch('5m').default('5m'),
34
+ // วิธีบีบ context ตอนยาว: 'truncate' (default, zero-LLM) · 'summarize' (ใช้ model ถูกย่อ — จำ context ได้ดีกว่า)
35
+ compaction: z.enum(['truncate', 'summarize']).catch('truncate').default('truncate'),
36
+ // extended thinking (Anthropic): false/ไม่ตั้ง = ปิด · true = budget default · number = budget tokens
37
+ thinking: z.union([z.boolean(), z.number().int().positive()]).optional().catch(undefined),
38
+ // model สำหรับย่อ (compaction=summarize) — ไม่ตั้ง = ใช้ fast-sibling ของ model หลัก (ค่ายเดียวกัน ถูกกว่า)
39
+ summaryModel: z.string().optional().catch(undefined),
16
40
  });
41
+ const DEFAULT_THINKING_BUDGET = 4096;
42
+ /** parse thinking config (config field หรือ env) → budget tokens (undefined = ปิด) */
43
+ function parseThinking(v) {
44
+ if (typeof v === 'number' && v > 0)
45
+ return Math.floor(v);
46
+ if (v === true)
47
+ return DEFAULT_THINKING_BUDGET;
48
+ if (typeof v === 'string') {
49
+ if (/^\d+$/.test(v))
50
+ return Number.parseInt(v, 10);
51
+ if (['on', 'true', '1', 'yes'].includes(v.toLowerCase()))
52
+ return DEFAULT_THINKING_BUDGET;
53
+ }
54
+ return undefined;
55
+ }
56
+ /**
57
+ * อ่าน tuning knobs (cache TTL / thinking / compaction / summary model) จาก global config.json
58
+ * + env override (SANOOK_CACHE_TTL / SANOOK_THINKING / SANOOK_COMPACTION / SANOOK_SUMMARY_MODEL).
59
+ * อ่านตรงจาก config.json (เลี่ยง thread ผ่าน call stack ลึก) — เบา, เรียกครั้งเดียวต่อ turn.
60
+ */
61
+ export async function agentTuning() {
62
+ const raw = await readGlobalConfigRaw();
63
+ const envTtl = process.env.SANOOK_CACHE_TTL;
64
+ const cacheTtl = envTtl === '1h' || (envTtl !== '5m' && raw.cacheTtl === '1h') ? '1h' : '5m';
65
+ const thinkingBudget = parseThinking(process.env.SANOOK_THINKING ?? raw.thinking);
66
+ const compaction = (process.env.SANOOK_COMPACTION ?? raw.compaction) === 'summarize' ? 'summarize' : 'truncate';
67
+ const summaryModel = process.env.SANOOK_SUMMARY_MODEL ?? (typeof raw.summaryModel === 'string' ? raw.summaryModel : undefined);
68
+ return { cacheTtl, thinkingBudget, compaction, summaryModel };
69
+ }
17
70
  async function readJson(path) {
18
71
  try {
19
72
  const parsed = JSON.parse(await readFile(path, 'utf8'));
@@ -23,21 +76,53 @@ async function readJson(path) {
23
76
  return {}; // ไม่มีไฟล์ / parse ไม่ได้ = ใช้ default
24
77
  }
25
78
  }
79
+ function sanitizeUntrustedProjectConfig(cfg) {
80
+ const out = { ...cfg };
81
+ delete out.permissionMode;
82
+ return out;
83
+ }
26
84
  /**
27
- * โหลด config แบบ layered: global (~/.sanook) < project (.sanook) < CLI overrides
85
+ * โหลด config แบบ layered: global (~/.sanook) < project (.sanook) < env < CLI overrides
28
86
  * merge raw ทุกชั้นก่อน แล้ว validate zod ทีเดียวที่ merged สุดท้าย
29
87
  * (config flat — shallow merge พอ; strip undefined ใน overrides กัน override ทับ default)
30
88
  */
31
89
  export async function loadConfig(overrides = {}, cwd = process.cwd()) {
32
- const global = await readJson(join(homedir(), '.sanook', 'config.json'));
33
- const project = await readJson(join(cwd, '.sanook', 'config.json'));
90
+ const global = await readJson(CONFIG_PATH);
91
+ const root = await projectRoot(cwd);
92
+ const projectRaw = await readJson(appProjectPath(root, 'config.json'));
93
+ const trust = await projectTrustStatus(root);
94
+ const project = trust.trusted ? projectRaw : sanitizeUntrustedProjectConfig(projectRaw);
95
+ const envConfig = {};
96
+ if (process.env[BRAND.modelEnvVar])
97
+ envConfig.model = process.env[BRAND.modelEnvVar];
34
98
  const cleanOverrides = {};
35
99
  for (const [k, v] of Object.entries(overrides)) {
36
100
  if (v !== undefined)
37
101
  cleanOverrides[k] = v;
38
102
  }
39
- const merged = { ...global, ...project, ...cleanOverrides };
40
- return ConfigSchema.parse(merged);
103
+ const merged = { ...global, ...project, ...envConfig, ...cleanOverrides };
104
+ const config = ConfigSchema.parse(merged);
105
+ // pricing override: config.pricing + env SANOOK_PRICING (JSON) → ลงทะเบียนเข้า cost table
106
+ registerPricing(config.pricing);
107
+ registerPricing(parseEnvPricing());
108
+ return config;
109
+ }
110
+ /** env SANOOK_PRICING = JSON ของ { "provider:model": { input, output, ... } } */
111
+ function parseEnvPricing() {
112
+ const raw = process.env.SANOOK_PRICING;
113
+ if (!raw)
114
+ return undefined;
115
+ try {
116
+ const parsed = JSON.parse(raw);
117
+ const res = PricingOverrideSchema.safeParse(parsed);
118
+ return res.success ? res.data : undefined;
119
+ }
120
+ catch {
121
+ return undefined; // JSON ไม่ถูก = ข้าม (ไม่ทำให้ boot ล้ม)
122
+ }
123
+ }
124
+ export function parsePricingOverride(raw) {
125
+ return PricingOverrideSchema.parse(JSON.parse(raw));
41
126
  }
42
127
  /** ครั้งแรกที่รัน (ยังไม่มี global config) → ต้องทำ setup wizard */
43
128
  export async function isFirstRun() {
@@ -53,13 +138,15 @@ export async function isFirstRun() {
53
138
  export async function saveGlobalConfig(cfg) {
54
139
  await mkdir(CONFIG_DIR, { recursive: true });
55
140
  const existing = await readJson(CONFIG_PATH);
56
- await writeFile(CONFIG_PATH, `${JSON.stringify({ ...existing, ...cfg }, null, 2)}\n`);
141
+ await writeFile(CONFIG_PATH, `${JSON.stringify({ ...existing, ...cfg }, null, 2)}\n`, { mode: 0o600 });
142
+ await chmod(CONFIG_PATH, 0o600).catch(() => { });
57
143
  }
58
144
  /** บันทึก path ของ second-brain workspace ลง global config (merge — ไม่ทับ field อื่น) */
59
145
  export async function saveBrainPath(path) {
60
146
  await mkdir(CONFIG_DIR, { recursive: true });
61
147
  const existing = await readJson(CONFIG_PATH);
62
- await writeFile(CONFIG_PATH, `${JSON.stringify({ ...existing, brainPath: path }, null, 2)}\n`);
148
+ await writeFile(CONFIG_PATH, `${JSON.stringify({ ...existing, brainPath: path }, null, 2)}\n`, { mode: 0o600 });
149
+ await chmod(CONFIG_PATH, 0o600).catch(() => { });
63
150
  }
64
151
  /** อ่าน config.json ดิบ (ไม่ apply default/schema) — สำหรับ `sanook config` */
65
152
  export async function readGlobalConfigRaw() {
@@ -69,7 +156,8 @@ export async function readGlobalConfigRaw() {
69
156
  export async function patchGlobalConfig(patch) {
70
157
  await mkdir(CONFIG_DIR, { recursive: true });
71
158
  const existing = await readJson(CONFIG_PATH);
72
- await writeFile(CONFIG_PATH, `${JSON.stringify({ ...existing, ...patch }, null, 2)}\n`);
159
+ await writeFile(CONFIG_PATH, `${JSON.stringify({ ...existing, ...patch }, null, 2)}\n`, { mode: 0o600 });
160
+ await chmod(CONFIG_PATH, 0o600).catch(() => { });
73
161
  }
74
162
  /** บันทึก API key ลง ~/.sanook/auth.json (chmod 0600) + set env ทันทีสำหรับ session นี้ */
75
163
  export async function saveKey(envVar, key) {
@@ -82,7 +170,7 @@ export async function saveKey(envVar, key) {
82
170
  /* ยังไม่มีไฟล์ */
83
171
  }
84
172
  auth[envVar] = key;
85
- await writeFile(AUTH_PATH, `${JSON.stringify(auth, null, 2)}\n`);
173
+ await writeFile(AUTH_PATH, `${JSON.stringify(auth, null, 2)}\n`, { mode: 0o600 });
86
174
  await chmod(AUTH_PATH, 0o600); // เจ้าของอ่าน/เขียนเท่านั้น
87
175
  process.env[envVar] = key;
88
176
  }
package/dist/cost.js CHANGED
@@ -1,10 +1,59 @@
1
- // key = specKey() = "anthropic:<curated model id>" — ต้องตรงกับ id ใน registry (มี test กัน drift)
1
+ // key = specKey() = "<provider>:<model id>" — ต้องตรงกับ id ใน registry (มี test กัน drift)
2
+ // Anthropic = ราคา verified (มิ.ย. 2026). ที่เหลือ = published list price โดยประมาณ — override ได้
2
3
  export const PRICING = {
4
+ // ── Anthropic (verified) ────────────────────────────────────────────────
3
5
  'anthropic:claude-opus-4-8': { input: 5, output: 25, cacheWrite: 6.25, cacheRead: 0.5 },
4
6
  'anthropic:claude-sonnet-4-6': { input: 3, output: 15, cacheWrite: 3.75, cacheRead: 0.3 },
5
7
  'anthropic:claude-haiku-4-5': { input: 1, output: 5, cacheWrite: 1.25, cacheRead: 0.1 },
6
8
  'anthropic:claude-fable-5': { input: 10, output: 50, cacheWrite: 12.5, cacheRead: 1 },
9
+ // ── ราคาประมาณ (published list price ต่อ 1M tokens) — อาจคลาดเคลื่อน, override ได้ด้วย
10
+ // `sanook config set pricing '{"openai:gpt-5.5":{"input":1.25,...}}'` หรือ env SANOOK_PRICING
11
+ // OpenAI
12
+ 'openai:gpt-5.5': { input: 1.25, output: 10, cacheWrite: 1.25, cacheRead: 0.125 },
13
+ 'openai:gpt-5.4-mini': { input: 0.25, output: 2, cacheWrite: 0.25, cacheRead: 0.025 },
14
+ 'openai:gpt-5.3-codex': { input: 1.25, output: 10, cacheWrite: 1.25, cacheRead: 0.125 },
15
+ // Google Gemini (≤200k context tier)
16
+ 'google:gemini-2.5-pro': { input: 1.25, output: 10, cacheWrite: 1.25, cacheRead: 0.31 },
17
+ 'google:gemini-2.5-flash': { input: 0.3, output: 2.5, cacheWrite: 0.3, cacheRead: 0.075 },
18
+ // DeepSeek V4
19
+ 'deepseek:deepseek-v4-flash': { input: 0.28, output: 0.42, cacheWrite: 0.28, cacheRead: 0.028 },
20
+ 'deepseek:deepseek-v4-pro': { input: 0.55, output: 2.19, cacheWrite: 0.55, cacheRead: 0.055 },
21
+ // xAI Grok
22
+ 'xai:grok-4.3': { input: 3, output: 15, cacheWrite: 3, cacheRead: 0.75 },
23
+ // Mistral
24
+ 'mistral:mistral-large-latest': { input: 2, output: 6, cacheWrite: 2, cacheRead: 0.2 },
25
+ 'mistral:mistral-small-latest': { input: 0.2, output: 0.6, cacheWrite: 0.2, cacheRead: 0.02 },
26
+ // Groq
27
+ 'groq:llama-3.3-70b-versatile': { input: 0.59, output: 0.79, cacheWrite: 0.59, cacheRead: 0.059 },
7
28
  };
29
+ /** true ถ้ามี pricing สำหรับ specKey นี้ (ใช้เตือนตอน budget cap ตั้งไว้แต่คิดเงินไม่ได้) */
30
+ export function hasPricingForKey(specKey) {
31
+ return specKey in PRICING;
32
+ }
33
+ /**
34
+ * merge pricing เพิ่ม/override (จาก config `pricing` หรือ env SANOOK_PRICING)
35
+ * — ให้ budget cap ใช้ได้กับ provider ที่ยังไม่มีในตาราง โดยไม่ต้องแก้โค้ด
36
+ */
37
+ export function registerPricing(extra) {
38
+ if (!extra)
39
+ return;
40
+ for (const [key, p] of Object.entries(extra)) {
41
+ if (p == null || typeof p !== 'object')
42
+ continue;
43
+ const base = PRICING[key] ?? { input: 0, output: 0, cacheWrite: 0, cacheRead: 0 };
44
+ const inputRate = Number(p.input ?? base.input);
45
+ const next = {
46
+ input: inputRate,
47
+ output: Number(p.output ?? base.output),
48
+ // override ที่ใส่แค่ input/output (ตามที่ hint แนะนำ) → cache rate อนุมานจาก input แทน 0 (กัน undercount)
49
+ cacheWrite: Number(p.cacheWrite ?? p.input ?? base.cacheWrite),
50
+ cacheRead: Number(p.cacheRead ?? (base.cacheRead || inputRate * 0.1)),
51
+ };
52
+ if (Object.values(next).some((n) => !Number.isFinite(n) || n < 0))
53
+ continue;
54
+ PRICING[key] = next;
55
+ }
56
+ }
8
57
  export class CostMeter {
9
58
  specKey;
10
59
  budgetUsd;
@@ -40,15 +89,23 @@ export class CostMeter {
40
89
  (cacheWriteTokens / 1e6) * p.cacheWrite;
41
90
  }
42
91
  }
92
+ /** รวม token + cost จาก meter อีกตัว (เช่น primary model ก่อน fallback) — กัน usage หาย/budget reset */
93
+ merge(other) {
94
+ this.inTok += other.inTok;
95
+ this.outTok += other.outTok;
96
+ this.cacheReadTok += other.cacheReadTok;
97
+ this.cacheWriteTok += other.cacheWriteTok;
98
+ this.spent += other.spent;
99
+ }
43
100
  get totalUsd() {
44
101
  return this.spent;
45
102
  }
46
103
  get hasPricing() {
47
104
  return this.specKey in PRICING;
48
105
  }
49
- /** true เมื่อใช้เกิน budget (เช็คก่อนยิง request ถัดไป) */
106
+ /** true เมื่อใช้เกิน budget (เช็คก่อนยิง request ถัดไป) — no-op ถ้าไม่มี pricing (เตือนที่ entry point) */
50
107
  get overBudget() {
51
- return this.budgetUsd != null && this.spent >= this.budgetUsd;
108
+ return this.budgetUsd != null && this.hasPricing && this.spent >= this.budgetUsd;
52
109
  }
53
110
  summary() {
54
111
  const total = this.inTok + this.outTok + this.cacheReadTok + this.cacheWriteTok;
package/dist/doctor.js ADDED
@@ -0,0 +1,92 @@
1
+ import { execFile } from 'node:child_process';
2
+ import { promisify } from 'node:util';
3
+ import { existsSync } from 'node:fs';
4
+ import { join, resolve, delimiter } from 'node:path';
5
+ import { BRAND } from './brand.js';
6
+ const execFileP = promisify(execFile);
7
+ function normDir(d) {
8
+ try {
9
+ const r = resolve(d).replace(/[\\/]+$/, '');
10
+ return process.platform === 'win32' ? r.toLowerCase() : r; // Windows PATH is case-insensitive
11
+ }
12
+ catch {
13
+ return process.platform === 'win32' ? d.toLowerCase() : d;
14
+ }
15
+ }
16
+ /** binDir อยู่ใน PATH ไหม — normalize (ตัด trailing slash, case-insensitive บน Windows) ก่อนเทียบ */
17
+ export function isOnPath(binDir, pathEnv) {
18
+ if (!binDir)
19
+ return false;
20
+ const target = normDir(binDir);
21
+ return (pathEnv ?? '')
22
+ .split(delimiter)
23
+ .filter(Boolean)
24
+ .map(normDir)
25
+ .includes(target);
26
+ }
27
+ /** เก็บข้อมูลการติดตั้งจริงจากเครื่อง (Node version, npm global bin, shim, PATH, local install) */
28
+ export async function diagnose() {
29
+ const isWin = process.platform === 'win32';
30
+ const major = Number(process.versions.node.split('.')[0]);
31
+ let prefix = '';
32
+ try {
33
+ // Windows: npm = npm.cmd → ต้อง shell:true ไม่งั้น ENOENT
34
+ prefix = (await execFileP('npm', ['config', 'get', 'prefix'], { shell: isWin })).stdout.trim();
35
+ }
36
+ catch {
37
+ /* npm หาไม่เจอใน PATH */
38
+ }
39
+ // global bin: บน Windows = prefix เอง (มี npx.cmd อยู่ตรงนั้น), บน Unix = prefix/bin
40
+ const binDir = prefix ? (isWin ? prefix : join(prefix, 'bin')) : '';
41
+ const shimNames = isWin ? [`${BRAND.cliName}.cmd`, `${BRAND.cliName}.ps1`, BRAND.cliName] : [BRAND.cliName];
42
+ const globalInstalled = !!binDir && shimNames.some((s) => existsSync(join(binDir, s)));
43
+ const localInstall = shimNames.some((s) => existsSync(join(process.cwd(), 'node_modules', '.bin', s)));
44
+ return {
45
+ node: process.version,
46
+ nodeOk: Number.isFinite(major) && major >= 22,
47
+ binDir,
48
+ globalInstalled,
49
+ onPath: isOnPath(binDir, process.env.PATH),
50
+ localInstall,
51
+ isWin,
52
+ };
53
+ }
54
+ /** รายงาน + วิธีแก้ที่ปลอดภัยต่อ OS (ไม่ใช้ setx %PATH% ซึ่งเป็น footgun ตัด PATH 1024 ตัว) */
55
+ export function formatReport(r, pkgName) {
56
+ const ok = (b) => (b ? '✓' : '✗');
57
+ const lines = [
58
+ `${BRAND.productName} doctor — ตรวจการติดตั้ง`,
59
+ '',
60
+ ` ${ok(r.nodeOk)} Node ${r.node}${r.nodeOk ? '' : ' ← ต้อง ≥ 22 (อัปเดตที่ https://nodejs.org)'}`,
61
+ ` ${ok(!!r.binDir)} npm global bin: ${r.binDir || '(หาไม่เจอ — npm อยู่ใน PATH ไหม?)'}`,
62
+ ` ${ok(r.globalInstalled)} ติดตั้ง global "${BRAND.cliName}": ${r.globalInstalled ? 'ใช่' : 'ยัง'}`,
63
+ ` ${ok(r.onPath)} bin อยู่ใน PATH: ${r.onPath ? 'ใช่' : 'ไม่'}`,
64
+ ];
65
+ if (r.localInstall)
66
+ lines.push(` ℹ เจอ local install ที่โฟลเดอร์นี้ → ใช้ได้เลยด้วย: npx ${BRAND.cliName}`);
67
+ lines.push('', 'สรุป:');
68
+ if (r.globalInstalled && r.onPath) {
69
+ lines.push(` ✅ พร้อมใช้ — พิมพ์ "${BRAND.cliName}" ได้เลย (ยังไม่เจอ? ปิด-เปิด terminal ใหม่)`);
70
+ return lines.join('\n');
71
+ }
72
+ if (!r.globalInstalled) {
73
+ lines.push(` • ลงแบบ global: npm install -g ${pkgName} → แล้วพิมพ์ "${BRAND.cliName}" ได้`);
74
+ }
75
+ if (r.globalInstalled && !r.onPath && r.binDir) {
76
+ if (r.isWin) {
77
+ lines.push(' • bin ไม่อยู่ใน PATH — เพิ่มเข้า user PATH (วางใน PowerShell · ปลอดภัย แก้เฉพาะ user):');
78
+ lines.push(` [Environment]::SetEnvironmentVariable('Path',[Environment]::GetEnvironmentVariable('Path','User')+';${r.binDir}','User')`);
79
+ lines.push(' แล้วปิด-เปิด terminal ใหม่');
80
+ }
81
+ else {
82
+ const rc = process.env.SHELL?.includes('zsh') ? '~/.zshrc' : '~/.bashrc';
83
+ lines.push(` • bin ไม่อยู่ใน PATH — เพิ่มใน ${rc}: export PATH="$PATH:${r.binDir}" (แล้วเปิด shell ใหม่)`);
84
+ }
85
+ }
86
+ if (r.localInstall)
87
+ lines.push(` • หรือใช้ทันทีโดยไม่แก้ PATH: npx ${BRAND.cliName}`);
88
+ return lines.join('\n');
89
+ }
90
+ export async function runDoctor(pkgName) {
91
+ return formatReport(await diagnose(), pkgName);
92
+ }
@@ -1,8 +1,8 @@
1
1
  import { readFile, writeFile, mkdir, chmod } from 'node:fs/promises';
2
- import { homedir } from 'node:os';
3
2
  import { join } from 'node:path';
4
3
  import { randomBytes, timingSafeEqual } from 'node:crypto';
5
- const GATEWAY_DIR = join(homedir(), '.sanook', 'gateway');
4
+ import { appHomePath } from '../brand.js';
5
+ const GATEWAY_DIR = appHomePath('gateway');
6
6
  const TOKEN_FILE = join(GATEWAY_DIR, 'token');
7
7
  /** โหลด bearer token ของ gateway; ไม่มี → สร้าง 256-bit ใหม่ เก็บ chmod 600 */
8
8
  export async function loadOrCreateToken() {
@@ -1,12 +1,12 @@
1
1
  import { readFile, writeFile, rename, mkdir, chmod } from 'node:fs/promises';
2
- import { homedir } from 'node:os';
3
2
  import { join } from 'node:path';
4
3
  import { randomUUID } from 'node:crypto';
5
4
  import { withFileLock } from './lock.js';
5
+ import { appHomePath } from '../brand.js';
6
6
  // task-ledger = งานที่ gateway ต้องทำ (cron / message / one-shot) — Hermes "Kanban" / OpenClaw "Task Brain"
7
7
  // เก็บเป็น JSON (zero native dep) แทน SQLite; ทุก mutation = locked read-modify-write (atomic ต่อ op)
8
8
  // → กัน lost-write จากหลาย writer (server enqueue / scheduler update / cron CLI) ที่ยิงไฟล์เดียวกัน
9
- const GATEWAY_DIR = join(homedir(), '.sanook', 'gateway');
9
+ const GATEWAY_DIR = appHomePath('gateway');
10
10
  const TASKS_FILE = join(GATEWAY_DIR, 'tasks.json');
11
11
  const LOCK_FILE = join(GATEWAY_DIR, 'tasks.lock');
12
12
  // ── low-level: read ตรงจากไฟล์ทุกครั้ง (ไม่ cache snapshot → ไม่มี stale-overwrite) ──
@@ -9,6 +9,7 @@ async function runTask(task, opts) {
9
9
  prompt: task.spec,
10
10
  maxSteps: 20,
11
11
  budgetUsd: opts.budgetUsd,
12
+ permissionMode: opts.permissionMode ?? 'ask',
12
13
  });
13
14
  return text;
14
15
  }
@@ -1,11 +1,11 @@
1
1
  import { mkdir } from 'node:fs/promises';
2
- import { homedir } from 'node:os';
3
2
  import { join } from 'node:path';
4
3
  import { acquireSingleton } from './lock.js';
5
4
  import { loadOrCreateToken } from './auth.js';
6
5
  import { startServer } from './server.js';
7
6
  import { startScheduler } from './scheduler.js';
8
- const GATEWAY_DIR = join(homedir(), '.sanook', 'gateway');
7
+ import { appHomePath, BRAND, BRAND_ENV, envFlag } from '../brand.js';
8
+ const GATEWAY_DIR = appHomePath('gateway');
9
9
  const SERVE_LOCK = join(GATEWAY_DIR, 'serve.lock');
10
10
  /**
11
11
  * จุดเดียวที่ start ทั้ง gateway: HTTP server (รับ request 24/7) + scheduler (cron tick)
@@ -18,7 +18,7 @@ export async function startGateway(opts) {
18
18
  await mkdir(GATEWAY_DIR, { recursive: true });
19
19
  const release = await acquireSingleton(SERVE_LOCK);
20
20
  if (!release) {
21
- throw new Error('มี sanook gateway รันอยู่แล้ว (เจอ serve.lock) — ปิดตัวเดิมก่อน หรือถ้าค้างให้ลบ ~/.sanook/gateway/serve.lock');
21
+ throw new Error(`มี ${BRAND.cliName} gateway รันอยู่แล้ว (เจอ serve.lock) — ปิดตัวเดิมก่อน หรือถ้าค้างให้ลบ ${appHomePath('gateway', 'serve.lock')}`);
22
22
  }
23
23
  const token = await loadOrCreateToken();
24
24
  const stopServer = startServer({
@@ -26,11 +26,13 @@ export async function startGateway(opts) {
26
26
  token,
27
27
  defaultModel: opts.model,
28
28
  budgetUsd: opts.budgetUsd,
29
+ permissionMode: opts.permissionMode ?? (envFlag(BRAND_ENV.gatewayAllowWrite) ? 'auto' : 'ask'),
29
30
  onLog: log,
30
31
  });
31
32
  const stopScheduler = startScheduler({
32
33
  defaultModel: opts.model,
33
34
  budgetUsd: opts.budgetUsd,
35
+ permissionMode: opts.permissionMode ?? (envFlag(BRAND_ENV.gatewayAllowWrite) ? 'auto' : 'ask'),
34
36
  tickMs: opts.tickMs,
35
37
  onLog: log,
36
38
  });
@@ -47,7 +49,7 @@ export async function startGateway(opts) {
47
49
  });
48
50
  // หมายเหตุ: log "เริ่มแล้ว" อยู่ใน startTelegram (success path) — ถ้า fail-closed จะ log "ไม่เริ่ม" แทน
49
51
  }
50
- log(`scheduler tick ทุก ${(opts.tickMs ?? 60_000) / 1000}s · token: ~/.sanook/gateway/token (chmod 600)`);
52
+ log(`scheduler tick ทุก ${(opts.tickMs ?? 60_000) / 1000}s · token: ${appHomePath('gateway', 'token')} (chmod 600)`);
51
53
  return () => {
52
54
  stopServer();
53
55
  stopScheduler();
@@ -4,6 +4,7 @@ import { parseSchedule } from './schedule.js';
4
4
  import { tokenMatches } from './auth.js';
5
5
  import { runAgent } from '../loop.js';
6
6
  import { redactKey } from '../providers/keys.js';
7
+ import { BRAND } from '../brand.js';
7
8
  function send(res, status, body) {
8
9
  res.writeHead(status, { 'content-type': 'application/json' });
9
10
  res.end(JSON.stringify(body));
@@ -42,7 +43,7 @@ async function handle(req, res, opts) {
42
43
  const url = new URL(req.url ?? '/', 'http://127.0.0.1');
43
44
  // /health = public (เช็คว่า process alive โดยไม่ต้องมี token)
44
45
  if (req.method === 'GET' && url.pathname === '/health') {
45
- return send(res, 200, { ok: true, service: 'sanook-gateway' });
46
+ return send(res, 200, { ok: true, service: BRAND.gatewayServiceName });
46
47
  }
47
48
  // ทุก endpoint อื่น → bearer token
48
49
  const auth = req.headers.authorization ?? '';
@@ -63,7 +64,14 @@ async function handle(req, res, opts) {
63
64
  .slice(0, lastUserIdx)
64
65
  .map((m) => ({ role: m.role, content: m.content }));
65
66
  const model = typeof body.model === 'string' && body.model ? body.model : opts.defaultModel;
66
- const { text } = await runAgent({ model, prompt, history, maxSteps: 20, budgetUsd: opts.budgetUsd });
67
+ const { text } = await runAgent({
68
+ model,
69
+ prompt,
70
+ history,
71
+ maxSteps: 20,
72
+ budgetUsd: opts.budgetUsd,
73
+ permissionMode: opts.permissionMode ?? 'ask',
74
+ });
67
75
  return send(res, 200, {
68
76
  object: 'chat.completion',
69
77
  model,
package/dist/git.js CHANGED
@@ -3,8 +3,17 @@ import { promisify } from 'node:util';
3
3
  const execFileAsync = promisify(execFile);
4
4
  // git helper — execFile('git', args[]) ไม่ผ่าน shell (บทเรียนจาก grep RCE: ไม่ interpolate เข้า shell string)
5
5
  export async function runGit(args, cwd = process.cwd()) {
6
- const { stdout } = await execFileAsync('git', args, { cwd, maxBuffer: 10 * 1024 * 1024 });
7
- return stdout;
6
+ try {
7
+ const { stdout } = await execFileAsync('git', args, { cwd, maxBuffer: 10 * 1024 * 1024 });
8
+ return stdout;
9
+ }
10
+ catch (e) {
11
+ // git ไม่ได้ติดตั้ง/ไม่อยู่ใน PATH → ข้อความชัดแทน "spawn git ENOENT" งงๆ (ทุกแพลตฟอร์ม)
12
+ if (e.code === 'ENOENT') {
13
+ throw new Error('ไม่พบ git ใน PATH — ติดตั้งจาก https://git-scm.com แล้วเปิด terminal ใหม่');
14
+ }
15
+ throw e;
16
+ }
8
17
  }
9
18
  export async function isGitRepo(cwd = process.cwd()) {
10
19
  try {
package/dist/hooks.js CHANGED
@@ -1,20 +1,43 @@
1
1
  import { readFile } from 'node:fs/promises';
2
- import { homedir } from 'node:os';
3
- import { join } from 'node:path';
4
2
  import { spawn } from 'node:child_process';
5
- export async function loadHooksConfig() {
3
+ import { appHomePath, BRAND_ENV, envFlag } from './brand.js';
4
+ import { hasUntrustedProjectConfig, projectConfigPathIfTrusted, projectRoot } from './trust.js';
5
+ const SAFE_ENV_KEYS = ['PATH', 'HOME', 'TMPDIR', 'TEMP', 'LANG', 'LC_ALL', 'USER', 'SHELL', 'TERM', 'NODE_PATH', 'NVM_DIR', 'APPDATA'];
6
+ function safeEnv() {
7
+ const out = {};
8
+ for (const k of SAFE_ENV_KEYS) {
9
+ const v = process.env[k];
10
+ if (v != null)
11
+ out[k] = v;
12
+ }
13
+ return out;
14
+ }
15
+ async function readHooksFile(path, merged) {
16
+ const cfg = JSON.parse(await readFile(path, 'utf8'));
17
+ const valid = (h) => Boolean(h &&
18
+ typeof h === 'object' &&
19
+ typeof h.matcher === 'string' &&
20
+ typeof h.command === 'string');
21
+ if (Array.isArray(cfg.PreToolUse))
22
+ merged.PreToolUse.push(...cfg.PreToolUse.filter(valid));
23
+ if (Array.isArray(cfg.PostToolUse))
24
+ merged.PostToolUse.push(...cfg.PostToolUse.filter(valid));
25
+ }
26
+ export async function loadHooksConfig(cwd = process.cwd()) {
6
27
  const merged = { PreToolUse: [], PostToolUse: [] };
7
- for (const p of [join(homedir(), '.sanook', 'hooks.json'), join(process.cwd(), '.sanook', 'hooks.json')]) {
8
- try {
9
- const cfg = JSON.parse(await readFile(p, 'utf8'));
10
- if (Array.isArray(cfg.PreToolUse))
11
- merged.PreToolUse.push(...cfg.PreToolUse);
12
- if (Array.isArray(cfg.PostToolUse))
13
- merged.PostToolUse.push(...cfg.PostToolUse);
14
- }
15
- catch {
16
- /* ไม่มี config = ข้าม */
17
- }
28
+ try {
29
+ await readHooksFile(appHomePath('hooks.json'), merged);
30
+ }
31
+ catch {
32
+ /* ไม่มี global config = ข้าม */
33
+ }
34
+ const root = await projectRoot(cwd);
35
+ const projectPath = await projectConfigPathIfTrusted('hooks.json', root);
36
+ if (projectPath) {
37
+ await readHooksFile(projectPath, merged);
38
+ }
39
+ else if (await hasUntrustedProjectConfig('hooks.json', root)) {
40
+ /* project hook config มีอยู่แต่ยังไม่ trusted = ข้ามแบบ fail-closed */
18
41
  }
19
42
  return merged;
20
43
  }
@@ -31,7 +54,10 @@ export function matches(matcher, tool) {
31
54
  /** รัน command — payload เข้า stdin (เป็น DATA ไม่ใช่ shell arg → กัน injection); command = config ของ user (trusted) */
32
55
  function runCommand(command, payload, timeoutMs = 10_000) {
33
56
  return new Promise((resolve) => {
34
- const child = spawn(command, { shell: true });
57
+ const child = spawn(command, {
58
+ shell: true,
59
+ env: envFlag(BRAND_ENV.hooksInheritEnv) ? process.env : safeEnv(),
60
+ });
35
61
  let stdout = '';
36
62
  let stderr = '';
37
63
  const timer = setTimeout(() => {
@@ -96,8 +122,8 @@ function wrapToolsWithHooks(tools, cfg) {
96
122
  return out;
97
123
  }
98
124
  /** wrap tools ด้วย hooks ถ้ามี config (ไม่มี → คืน tools เดิม zero overhead) */
99
- export async function maybeWrapHooks(tools) {
100
- const cfg = await loadHooksConfig();
125
+ export async function maybeWrapHooks(tools, cwd = process.cwd()) {
126
+ const cfg = await loadHooksConfig(cwd);
101
127
  if (!(cfg.PreToolUse?.length || cfg.PostToolUse?.length))
102
128
  return tools;
103
129
  return wrapToolsWithHooks(tools, cfg);