sanook-cli 0.5.1 → 0.5.5

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (217) hide show
  1. package/.env.example +161 -3
  2. package/CHANGELOG.md +148 -10
  3. package/README.md +255 -26
  4. package/README.th.md +95 -7
  5. package/dist/approval.js +13 -0
  6. package/dist/bin.js +3552 -155
  7. package/dist/brain-consolidate.js +335 -0
  8. package/dist/brain-context.js +262 -0
  9. package/dist/brain-doctor.js +318 -0
  10. package/dist/brain-eval.js +186 -0
  11. package/dist/brain-final.js +377 -0
  12. package/dist/brain-metrics.js +277 -0
  13. package/dist/brain-new.js +402 -0
  14. package/dist/brain-pack.js +210 -0
  15. package/dist/brain-repair.js +280 -0
  16. package/dist/brain-review.js +382 -0
  17. package/dist/brain.js +15 -1
  18. package/dist/brand.js +1 -1
  19. package/dist/cli-args.js +190 -0
  20. package/dist/cli-option-values.js +16 -0
  21. package/dist/clipboard.js +65 -0
  22. package/dist/commands.js +266 -27
  23. package/dist/compaction.js +96 -11
  24. package/dist/config.js +149 -33
  25. package/dist/context-compression.js +191 -0
  26. package/dist/context-pack.js +145 -0
  27. package/dist/cost.js +49 -15
  28. package/dist/dashboard/api-helpers.js +87 -0
  29. package/dist/dashboard/server.js +179 -0
  30. package/dist/dashboard/static/app.js +277 -0
  31. package/dist/dashboard/static/index.html +39 -0
  32. package/dist/dashboard/static/styles.css +85 -0
  33. package/dist/diff.js +10 -2
  34. package/dist/first-run.js +21 -0
  35. package/dist/gateway/auth.js +49 -9
  36. package/dist/gateway/bluebubbles.js +205 -0
  37. package/dist/gateway/config.js +929 -0
  38. package/dist/gateway/deliver.js +399 -0
  39. package/dist/gateway/discord.js +124 -0
  40. package/dist/gateway/doctor.js +456 -0
  41. package/dist/gateway/email.js +501 -0
  42. package/dist/gateway/googlechat.js +207 -0
  43. package/dist/gateway/homeassistant.js +256 -0
  44. package/dist/gateway/ledger.js +38 -1
  45. package/dist/gateway/line.js +171 -0
  46. package/dist/gateway/lock.js +3 -1
  47. package/dist/gateway/matrix.js +366 -0
  48. package/dist/gateway/mattermost.js +322 -0
  49. package/dist/gateway/ntfy.js +218 -0
  50. package/dist/gateway/schedule.js +31 -4
  51. package/dist/gateway/serve.js +267 -7
  52. package/dist/gateway/server.js +253 -19
  53. package/dist/gateway/service.js +224 -0
  54. package/dist/gateway/session.js +362 -0
  55. package/dist/gateway/signal.js +351 -0
  56. package/dist/gateway/slack.js +124 -0
  57. package/dist/gateway/sms.js +169 -0
  58. package/dist/gateway/targets.js +576 -0
  59. package/dist/gateway/teams.js +106 -0
  60. package/dist/gateway/telegram.js +38 -15
  61. package/dist/gateway/webhooks.js +220 -0
  62. package/dist/gateway/whatsapp.js +230 -0
  63. package/dist/hooks.js +13 -2
  64. package/dist/hotkeys.js +21 -0
  65. package/dist/i18n/en.js +98 -0
  66. package/dist/i18n/index.js +19 -0
  67. package/dist/i18n/th.js +98 -0
  68. package/dist/i18n/types.js +1 -0
  69. package/dist/insights-args.js +55 -0
  70. package/dist/insights.js +86 -0
  71. package/dist/knowledge.js +55 -29
  72. package/dist/loop.js +157 -29
  73. package/dist/lsp/index.js +23 -5
  74. package/dist/mcp-hub.js +33 -0
  75. package/dist/mcp-registry.js +494 -0
  76. package/dist/mcp-risk.js +71 -0
  77. package/dist/mcp-server.js +1 -1
  78. package/dist/mcp.js +120 -10
  79. package/dist/memory-log.js +90 -0
  80. package/dist/memory-store.js +37 -1
  81. package/dist/memory.js +148 -37
  82. package/dist/model-picker.js +58 -0
  83. package/dist/orchestrate.js +51 -19
  84. package/dist/personality.js +58 -0
  85. package/dist/plan-handoff.js +17 -0
  86. package/dist/polyglot.js +162 -0
  87. package/dist/process-runner.js +96 -0
  88. package/dist/project-init.js +91 -0
  89. package/dist/project-registry.js +143 -0
  90. package/dist/project-scaffold.js +124 -0
  91. package/dist/prompt-size.js +155 -0
  92. package/dist/providers/codex-login.js +138 -0
  93. package/dist/providers/codex.js +89 -43
  94. package/dist/providers/keys.js +22 -1
  95. package/dist/providers/models.js +2 -2
  96. package/dist/providers/registry.js +14 -47
  97. package/dist/search/chunk.js +7 -8
  98. package/dist/search/cli.js +83 -0
  99. package/dist/search/embed-store.js +3 -0
  100. package/dist/search/embedding-config.js +22 -0
  101. package/dist/search/engine.js +2 -13
  102. package/dist/search/indexer.js +44 -1
  103. package/dist/search/store.js +23 -1
  104. package/dist/session-distill.js +84 -0
  105. package/dist/session.js +92 -16
  106. package/dist/skill-install.js +53 -13
  107. package/dist/skills.js +33 -0
  108. package/dist/slash-completion.js +155 -0
  109. package/dist/support-dump.js +206 -0
  110. package/dist/tool-catalog.js +59 -0
  111. package/dist/tools/edit.js +45 -15
  112. package/dist/tools/git.js +10 -5
  113. package/dist/tools/homeassistant.js +106 -0
  114. package/dist/tools/index.js +10 -0
  115. package/dist/tools/list.js +19 -6
  116. package/dist/tools/permission.js +992 -12
  117. package/dist/tools/polyglot.js +126 -0
  118. package/dist/tools/read.js +16 -4
  119. package/dist/tools/sandbox.js +38 -13
  120. package/dist/tools/schedule.js +19 -3
  121. package/dist/tools/search.js +226 -15
  122. package/dist/tools/task.js +40 -9
  123. package/dist/tools/timeout.js +23 -3
  124. package/dist/tools/web-fetch-tool.js +33 -0
  125. package/dist/trust.js +11 -1
  126. package/dist/turn-retrieval.js +83 -0
  127. package/dist/ui/app.js +878 -32
  128. package/dist/ui/banner.js +78 -4
  129. package/dist/ui/history.js +37 -5
  130. package/dist/ui/markdown.js +122 -0
  131. package/dist/ui/mentions.js +3 -2
  132. package/dist/ui/overlay.js +496 -0
  133. package/dist/ui/queue.js +23 -0
  134. package/dist/ui/render.js +20 -1
  135. package/dist/ui/session-panel.js +115 -0
  136. package/dist/ui/setup-providers.js +40 -0
  137. package/dist/ui/setup.js +172 -46
  138. package/dist/ui/status.js +142 -0
  139. package/dist/ui/thinking-panel.js +36 -0
  140. package/dist/ui/tool-trail.js +97 -0
  141. package/dist/ui/transcript.js +26 -0
  142. package/dist/ui/useBusyElapsed.js +19 -0
  143. package/dist/ui/useEditor.js +144 -5
  144. package/dist/ui/useGitBranch.js +57 -0
  145. package/dist/update.js +56 -17
  146. package/dist/web-fetch.js +637 -0
  147. package/dist/web-surface.js +190 -0
  148. package/dist/worktree.js +175 -4
  149. package/package.json +5 -5
  150. package/second-brain/AGENTS.md +6 -4
  151. package/second-brain/CLAUDE.md +7 -1
  152. package/second-brain/Evals/_Index.md +10 -2
  153. package/second-brain/Evals/quality-ledger.md +9 -1
  154. package/second-brain/Evals/second-brain-benchmarks.md +62 -0
  155. package/second-brain/GEMINI.md +5 -4
  156. package/second-brain/Home.md +1 -1
  157. package/second-brain/Projects/_Index.md +19 -4
  158. package/second-brain/Projects/sanook-cli/_Index.md +30 -0
  159. package/second-brain/Projects/sanook-cli/context.md +35 -0
  160. package/second-brain/Projects/sanook-cli/current-state.md +32 -0
  161. package/second-brain/Projects/sanook-cli/overview.md +41 -0
  162. package/second-brain/Projects/sanook-cli/repo.md +34 -0
  163. package/second-brain/Projects/sanook-cli/second-brain-feature-roadmap.md +197 -0
  164. package/second-brain/README.md +1 -1
  165. package/second-brain/Research/2026-06-17-ai-second-brain-method-experiment.md +108 -0
  166. package/second-brain/Research/2026-06-18-ai-token-reduction-frameworks.md +55 -0
  167. package/second-brain/Research/2026-06-18-hermes-cli-second-brain-expansion-research.md +160 -0
  168. package/second-brain/Research/2026-06-18-hermes-tui-parity-map.md +129 -0
  169. package/second-brain/Research/2026-06-18-sanook-mcp-ecosystem-and-ux-roadmap.md +181 -0
  170. package/second-brain/Research/2026-06-19-hermes-python-architecture-for-sanook.md +49 -0
  171. package/second-brain/Research/2026-06-19-terminal-ui-brand-research.md +52 -0
  172. package/second-brain/Research/_Index.md +8 -1
  173. package/second-brain/Reviews/2026-06-18-auto-improve-maintenance.md +54 -0
  174. package/second-brain/Reviews/_Index.md +1 -1
  175. package/second-brain/Runbooks/_Index.md +6 -1
  176. package/second-brain/Runbooks/ai-second-brain-operating-sequence.md +108 -0
  177. package/second-brain/SANOOK.md +45 -0
  178. package/second-brain/Sessions/2026-06-17-ai-framework-additional-zones.md +68 -0
  179. package/second-brain/Sessions/2026-06-17-ai-second-brain-sequence-experiment.md +63 -0
  180. package/second-brain/Sessions/2026-06-18-cli-args-release-readiness.md +59 -0
  181. package/second-brain/Sessions/2026-06-18-final-gate-template-final.md +192 -0
  182. package/second-brain/Sessions/2026-06-18-final-gate-template.md +71 -0
  183. package/second-brain/Sessions/2026-06-18-framework-dogfood-permission-and-memory.md +58 -0
  184. package/second-brain/Sessions/2026-06-18-hermes-second-brain-expansion-research.md +52 -0
  185. package/second-brain/Sessions/2026-06-18-mcp-ecosystem-and-sanook-ux-scan.md +81 -0
  186. package/second-brain/Sessions/2026-06-18-sanook-brain-cli-p0-implementation.md +86 -0
  187. package/second-brain/Sessions/2026-06-18-sanook-brain-final-cli-final.md +246 -0
  188. package/second-brain/Sessions/2026-06-18-sanook-brain-final-cli.md +78 -0
  189. package/second-brain/Sessions/2026-06-18-sanook-cli-second-brain-roadmap-correction.md +54 -0
  190. package/second-brain/Sessions/2026-06-18-token-reduction-framework-integration.md +69 -0
  191. package/second-brain/Sessions/_Index.md +15 -1
  192. package/second-brain/Shared/AI-Context-Index.md +22 -0
  193. package/second-brain/Shared/Context-Packs/_Index.md +9 -1
  194. package/second-brain/Shared/Context-Packs/coding-release.md +51 -0
  195. package/second-brain/Shared/Context-Packs/research-to-framework.md +51 -0
  196. package/second-brain/Shared/Context-Packs/second-brain-maintenance.md +41 -0
  197. package/second-brain/Shared/Operating-State/current-state.md +14 -4
  198. package/second-brain/Shared/Scripts/_Index.md +3 -1
  199. package/second-brain/Shared/Scripts/ai-second-brain-method-eval.mjs +198 -0
  200. package/second-brain/Shared/Tech-Standards/_Index.md +6 -1
  201. package/second-brain/Shared/Tech-Standards/mcp-integration-roadmap.md +86 -0
  202. package/second-brain/Shared/Tech-Standards/polyglot-runtime-strategy.md +46 -0
  203. package/second-brain/Shared/Tech-Standards/verification-standard.md +24 -0
  204. package/second-brain/Shared/Tech-Standards/web-search-grounding-policy.md +70 -0
  205. package/second-brain/Shared/User-Memory/_Index.md +4 -1
  206. package/second-brain/Shared/User-Memory/response-examples.md +98 -0
  207. package/second-brain/Shared/User-Memory/user-preferences.md +1 -0
  208. package/second-brain/Templates/_Index.md +9 -0
  209. package/second-brain/Templates/final-lite.md +111 -0
  210. package/second-brain/Templates/final.md +231 -0
  211. package/second-brain/Templates/project-workspace/_Index.md +31 -0
  212. package/second-brain/Templates/project-workspace/context.md +28 -0
  213. package/second-brain/Templates/project-workspace/current-state.md +29 -0
  214. package/second-brain/Templates/project-workspace/overview.md +39 -0
  215. package/second-brain/Templates/project-workspace/repo.md +33 -0
  216. package/second-brain/Vault Structure Map.md +2 -1
  217. package/skills/structured-output-llm/SKILL.md +1 -1
package/dist/session.js CHANGED
@@ -1,30 +1,60 @@
1
- import { chmod, readFile, writeFile, mkdir, readdir, realpath } from 'node:fs/promises';
1
+ import { chmod, readFile, writeFile, mkdir, readdir, realpath, rm } from 'node:fs/promises';
2
2
  import { join, resolve } from 'node:path';
3
3
  import { appHomePath, persistenceEnabled } from './brand.js';
4
- import { redactKey } from './providers/keys.js';
4
+ import { redactKey, redactUnknown } from './providers/keys.js';
5
5
  // session store — จำ conversation + ความคืบหน้า เพื่อ "ทำงานต่อได้" ไม่ลืมว่าทำถึงไหน
6
6
  const SESSION_DIR = appHomePath('sessions');
7
+ function isRecord(value) {
8
+ return value !== null && typeof value === 'object' && !Array.isArray(value);
9
+ }
10
+ function isModelMessage(value) {
11
+ if (!isRecord(value))
12
+ return false;
13
+ if (value.role === 'system')
14
+ return typeof value.content === 'string';
15
+ if (value.role === 'tool')
16
+ return Array.isArray(value.content);
17
+ if (value.role === 'user' || value.role === 'assistant') {
18
+ return typeof value.content === 'string' || Array.isArray(value.content);
19
+ }
20
+ return false;
21
+ }
22
+ function isSession(value) {
23
+ if (!isRecord(value))
24
+ return false;
25
+ return (typeof value.id === 'string' &&
26
+ (value.title === undefined || typeof value.title === 'string') &&
27
+ typeof value.created === 'string' &&
28
+ typeof value.updated === 'string' &&
29
+ typeof value.model === 'string' &&
30
+ typeof value.cwd === 'string' &&
31
+ Array.isArray(value.messages) &&
32
+ value.messages.every(isModelMessage));
33
+ }
7
34
  export function newSessionId() {
8
35
  // CLI runtime — ใช้ Date/random ได้ (ไม่ใช่ workflow context)
9
36
  const ts = new Date().toISOString().replace(/[:.]/g, '-').slice(0, 19);
10
37
  return `${ts}-${Math.random().toString(36).slice(2, 8)}`;
11
38
  }
12
- function redactUnknown(value) {
13
- if (typeof value === 'string')
14
- return redactKey(value);
15
- if (Array.isArray(value))
16
- return value.map(redactUnknown);
17
- if (value && typeof value === 'object') {
18
- return Object.fromEntries(Object.entries(value).map(([k, v]) => [k, redactUnknown(v)]));
39
+ export function sessionStorePath() {
40
+ return SESSION_DIR;
41
+ }
42
+ function sessionFilePath(id) {
43
+ if (!/^[A-Za-z0-9_.-]+$/.test(id) || id.includes('..')) {
44
+ throw new Error(`session id ไม่ถูกต้อง: ${id}`);
19
45
  }
20
- return value;
46
+ return join(SESSION_DIR, `${id}.json`);
21
47
  }
22
48
  function sanitizeSession(s) {
23
49
  return {
24
50
  ...s,
51
+ title: typeof s.title === 'string' ? redactKey(s.title) : s.title,
25
52
  messages: redactUnknown(s.messages),
26
53
  };
27
54
  }
55
+ export function sanitizeSessionForExport(s) {
56
+ return sanitizeSession(s);
57
+ }
28
58
  async function canonicalPath(path) {
29
59
  try {
30
60
  return await realpath(path);
@@ -37,24 +67,25 @@ export async function saveSession(s) {
37
67
  if (!persistenceEnabled())
38
68
  return;
39
69
  await mkdir(SESSION_DIR, { recursive: true });
40
- const path = join(SESSION_DIR, `${s.id}.json`);
70
+ const path = sessionFilePath(s.id);
41
71
  await writeFile(path, `${JSON.stringify(sanitizeSession(s), null, 2)}\n`, { mode: 0o600 });
42
72
  await chmod(path, 0o600).catch(() => { });
43
73
  }
44
74
  export async function loadSession(id) {
45
75
  try {
46
- return JSON.parse(await readFile(join(SESSION_DIR, `${id}.json`), 'utf8'));
76
+ const parsed = JSON.parse(await readFile(sessionFilePath(id), 'utf8'));
77
+ return isSession(parsed) && parsed.id === id ? parsed : null;
47
78
  }
48
79
  catch {
49
80
  return null;
50
81
  }
51
82
  }
52
- /** session ล่าสุด (สำหรับ --continue). ค่า default จำกัดเฉพาะ cwd ปัจจุบัน กัน context ข้าม project */
53
- export async function latestSession(cwd = process.cwd()) {
83
+ export async function listSessions(options = {}) {
54
84
  try {
85
+ const cwd = options.cwd === undefined ? process.cwd() : options.cwd;
55
86
  const ids = (await readdir(SESSION_DIR)).filter((f) => f.endsWith('.json')).map((f) => f.slice(0, -5));
56
87
  if (!ids.length)
57
- return null;
88
+ return [];
58
89
  let sessions = (await Promise.all(ids.map(loadSession))).filter((s) => s !== null);
59
90
  if (cwd) {
60
91
  const current = await canonicalPath(cwd);
@@ -62,9 +93,54 @@ export async function latestSession(cwd = process.cwd()) {
62
93
  sessions = pairs.filter((p) => p.cwd === current).map((p) => p.session);
63
94
  }
64
95
  sessions.sort((a, b) => b.updated.localeCompare(a.updated));
65
- return sessions[0] ?? null;
96
+ const limit = options.limit;
97
+ return typeof limit === 'number' && Number.isInteger(limit) && limit > 0 ? sessions.slice(0, limit) : sessions;
66
98
  }
67
99
  catch {
100
+ return [];
101
+ }
102
+ }
103
+ export async function removeSession(id) {
104
+ try {
105
+ await rm(sessionFilePath(id), { force: false });
106
+ return true;
107
+ }
108
+ catch (e) {
109
+ const code = e.code;
110
+ if (code === 'ENOENT')
111
+ return false;
112
+ throw e;
113
+ }
114
+ }
115
+ export async function renameSession(id, title) {
116
+ const session = await loadSession(id);
117
+ if (!session)
68
118
  return null;
119
+ const next = { ...session, title: title.trim(), updated: new Date().toISOString() };
120
+ await saveSession(next);
121
+ return next;
122
+ }
123
+ export async function pruneSessions(options = {}) {
124
+ const sessions = await listSessions({ cwd: options.cwd });
125
+ const removeIds = new Set();
126
+ if (Number.isInteger(options.keep) && options.keep >= 0) {
127
+ for (const s of sessions.slice(options.keep))
128
+ removeIds.add(s.id);
69
129
  }
130
+ if (options.before) {
131
+ const beforeMs = options.before.getTime();
132
+ for (const s of sessions) {
133
+ const updatedMs = Date.parse(s.updated);
134
+ if (Number.isFinite(updatedMs) && updatedMs < beforeMs)
135
+ removeIds.add(s.id);
136
+ }
137
+ }
138
+ const removed = sessions.filter((s) => removeIds.has(s.id));
139
+ for (const s of removed)
140
+ await removeSession(s.id);
141
+ return removed;
142
+ }
143
+ /** session ล่าสุด (สำหรับ --continue). ค่า default จำกัดเฉพาะ cwd ปัจจุบัน กัน context ข้าม project */
144
+ export async function latestSession(cwd = process.cwd()) {
145
+ return (await listSessions({ cwd, limit: 1 }))[0] ?? null;
70
146
  }
@@ -1,4 +1,4 @@
1
- import { readFile, writeFile, mkdir, readdir, rm, stat, lstat, copyFile } from 'node:fs/promises';
1
+ import { readFile, writeFile, mkdir, readdir, rm, stat, lstat, copyFile, rename } from 'node:fs/promises';
2
2
  import { tmpdir } from 'node:os';
3
3
  import { join, basename, resolve, sep, dirname } from 'node:path';
4
4
  import { execFile } from 'node:child_process';
@@ -6,13 +6,14 @@ import { promisify } from 'node:util';
6
6
  import { randomUUID } from 'node:crypto';
7
7
  import { lookup } from 'node:dns/promises';
8
8
  import { isIP } from 'node:net';
9
- import { parseFrontmatter, isValidSkillName } from './skills.js';
9
+ import { parseFrontmatter, isValidSkillName, bundledSkillsDir, listBundledSkills } from './skills.js';
10
10
  import { appHomePath, BRAND } from './brand.js';
11
11
  const execFileAsync = promisify(execFile);
12
12
  const USER_SKILLS = appHomePath('skills');
13
13
  const MAX_FILES = 300;
14
14
  const MAX_BYTES = 20 * 1024 * 1024; // 20MB ต่อ skill
15
15
  const MAX_MD = 2 * 1024 * 1024; // 2MB ต่อ SKILL.md จาก URL
16
+ const SUPPORT_DIRS = new Set(['references', 'scripts', 'assets', 'templates']);
16
17
  const exists = async (p) => {
17
18
  try {
18
19
  await stat(p);
@@ -22,7 +23,7 @@ const exists = async (p) => {
22
23
  return false;
23
24
  }
24
25
  };
25
- async function copyTreeSafe(srcDir, destDir, budget, depth = 2) {
26
+ async function copyTreeSafe(srcDir, destDir, budget, depth = 2, atRoot = true) {
26
27
  if (depth < 0)
27
28
  return;
28
29
  let entries;
@@ -35,12 +36,14 @@ async function copyTreeSafe(srcDir, destDir, budget, depth = 2) {
35
36
  for (const e of entries) {
36
37
  if (e.name.startsWith('.'))
37
38
  continue; // skip .git/dotfiles
39
+ if (atRoot && e.name !== 'SKILL.md' && !SUPPORT_DIRS.has(e.name))
40
+ continue;
38
41
  const s = join(srcDir, e.name);
39
42
  const st = await lstat(s);
40
43
  if (st.isSymbolicLink())
41
44
  continue; // ห้าม copy symlink (กัน planted symlink หลุด ~/.sanook)
42
45
  if (st.isDirectory()) {
43
- await copyTreeSafe(s, join(destDir, e.name), budget, depth - 1);
46
+ await copyTreeSafe(s, join(destDir, e.name), budget, depth - 1, false);
44
47
  }
45
48
  else if (st.isFile()) {
46
49
  if (--budget.files < 0)
@@ -53,7 +56,22 @@ async function copyTreeSafe(srcDir, destDir, budget, depth = 2) {
53
56
  }
54
57
  }
55
58
  }
56
- /** copy skill dir (SKILL.md + references/scripts ที่เป็น regular file) → ~/.sanook/skills/<name> */
59
+ async function replaceSkillDir(name, populate) {
60
+ const dest = join(USER_SKILLS, name);
61
+ const tmp = join(USER_SKILLS, `.${name}.${randomUUID()}.tmp`);
62
+ await mkdir(USER_SKILLS, { recursive: true });
63
+ try {
64
+ await populate(tmp);
65
+ await rm(dest, { recursive: true, force: true });
66
+ await rename(tmp, dest);
67
+ return dest;
68
+ }
69
+ catch (e) {
70
+ await rm(tmp, { recursive: true, force: true }).catch(() => { });
71
+ throw e;
72
+ }
73
+ }
74
+ /** copy skill dir (SKILL.md + allowed support dirs as regular files) → ~/.sanook/skills/<name> */
57
75
  async function installFromDir(srcDir) {
58
76
  const md = join(srcDir, 'SKILL.md');
59
77
  const stMd = await lstat(md);
@@ -63,10 +81,7 @@ async function installFromDir(srcDir) {
63
81
  const name = meta.name || basename(srcDir);
64
82
  if (!isValidSkillName(name))
65
83
  throw new Error(`ชื่อ skill ไม่ถูกต้อง: "${name}"`);
66
- const dest = join(USER_SKILLS, name);
67
- await rm(dest, { recursive: true, force: true });
68
- await mkdir(dest, { recursive: true });
69
- await copyTreeSafe(srcDir, dest, { files: MAX_FILES, bytes: MAX_BYTES });
84
+ const dest = await replaceSkillDir(name, (tmp) => copyTreeSafe(srcDir, tmp, { files: MAX_FILES, bytes: MAX_BYTES }));
70
85
  return { name, path: dest };
71
86
  }
72
87
  /** เขียน SKILL.md เดียว (จาก URL) → ~/.sanook/skills/<name> */
@@ -75,10 +90,10 @@ async function installFromContent(content, fallbackName) {
75
90
  const name = meta.name || fallbackName;
76
91
  if (!isValidSkillName(name))
77
92
  throw new Error(`ชื่อ skill ไม่ถูกต้อง: "${name}"`);
78
- const dest = join(USER_SKILLS, name);
79
- await rm(dest, { recursive: true, force: true });
80
- await mkdir(dest, { recursive: true });
81
- await writeFile(join(dest, 'SKILL.md'), content);
93
+ const dest = await replaceSkillDir(name, async (tmp) => {
94
+ await mkdir(tmp, { recursive: true });
95
+ await writeFile(join(tmp, 'SKILL.md'), content);
96
+ });
82
97
  return { name, path: dest };
83
98
  }
84
99
  /** หา SKILL.md ใน dir (ตรงๆ, ทุก subdir, หรือ skills/ subdir) → ติดตั้งทั้งหมด */
@@ -95,6 +110,8 @@ async function installFromLocal(path, onLog) {
95
110
  continue;
96
111
  }
97
112
  for (const e of entries) {
113
+ if (e.name.startsWith('.'))
114
+ continue;
98
115
  if (e.isDirectory() && (await exists(join(root, e.name, 'SKILL.md')))) {
99
116
  try {
100
117
  out.push(await installFromDir(join(root, e.name)));
@@ -155,6 +172,29 @@ async function fetchSkillMd(url) {
155
172
  throw new Error('SKILL.md ใหญ่เกิน 2MB');
156
173
  return text;
157
174
  }
175
+ function bundledCatalogHint(name) {
176
+ const sample = ['git-commit-pr', 'write-tests', 'debug-root-cause'];
177
+ return `ไม่เจอ bundled skill "${name}" — ลอง ${sample.join(', ')} หรือ ${BRAND.cliName} skill list`;
178
+ }
179
+ /**
180
+ * ติดตั้ง skill จาก bundled catalog (ชื่อ slug) · local path · URL · GitHub
181
+ * ⚠️ skill = instruction ที่ agent จะ trust (ไม่ใช่ data) — ติดตั้งจาก source ที่เชื่อถือเท่านั้น
182
+ */
183
+ export async function installNamedSkill(nameOrSource, onLog) {
184
+ if (await exists(nameOrSource))
185
+ return installFromLocal(nameOrSource, onLog);
186
+ if (isValidSkillName(nameOrSource)) {
187
+ const bundled = join(bundledSkillsDir(), nameOrSource);
188
+ if (await exists(join(bundled, 'SKILL.md')))
189
+ return [await installFromDir(bundled)];
190
+ const catalog = await listBundledSkills();
191
+ const match = catalog.find((skill) => skill.name === nameOrSource);
192
+ if (match)
193
+ return [await installFromDir(dirname(dirname(match.path)))];
194
+ throw new Error(bundledCatalogHint(nameOrSource));
195
+ }
196
+ return installSkill(nameOrSource, onLog);
197
+ }
158
198
  /**
159
199
  * ติดตั้ง skill จาก source — local path · URL ของ SKILL.md (https) · GitHub ("user/repo" หรือ "user/repo/sub/path")
160
200
  * ⚠️ skill = instruction ที่ agent จะ trust (ไม่ใช่ data) — ติดตั้งจาก source ที่เชื่อถือเท่านั้น
package/dist/skills.js CHANGED
@@ -9,6 +9,9 @@ import { projectConfigPathIfTrusted } from './trust.js';
9
9
  // 3 ชั้น: bundled (ship กับ CLI) → global (~/.sanook) → project (.sanook) — ชั้นหลัง override ชื่อซ้ำ
10
10
  const BUNDLED_SKILLS = join(dirname(fileURLToPath(import.meta.url)), '..', 'skills');
11
11
  const GLOBAL_SKILLS = appHomePath('skills');
12
+ export function bundledSkillsDir() {
13
+ return BUNDLED_SKILLS;
14
+ }
12
15
  /** minimal frontmatter parser (key: value ใน --- block) — ไม่พึ่ง YAML dep */
13
16
  export function parseFrontmatter(content) {
14
17
  const m = content.match(/^---\r?\n([\s\S]*?)\r?\n---\r?\n?([\s\S]*)$/);
@@ -33,6 +36,36 @@ export function parseFrontmatter(content) {
33
36
  export function isValidSkillName(name) {
34
37
  return /^[a-z0-9][a-z0-9-]{0,63}$/.test(name);
35
38
  }
39
+ /** list bundled skills only (sanook skill install <name> catalog) */
40
+ export async function listBundledSkills() {
41
+ const out = [];
42
+ let entries;
43
+ try {
44
+ entries = await readdir(BUNDLED_SKILLS, { withFileTypes: true });
45
+ }
46
+ catch {
47
+ return out;
48
+ }
49
+ for (const e of entries) {
50
+ if (!e.isDirectory() || !isValidSkillName(e.name))
51
+ continue;
52
+ const p = join(BUNDLED_SKILLS, e.name, 'SKILL.md');
53
+ try {
54
+ const { meta } = parseFrontmatter(await readFile(p, 'utf8'));
55
+ const name = meta.name && isValidSkillName(meta.name) ? meta.name : e.name;
56
+ out.push({
57
+ name,
58
+ description: meta.description ?? '',
59
+ whenToUse: meta.when_to_use,
60
+ path: p,
61
+ });
62
+ }
63
+ catch {
64
+ /* skip invalid entries */
65
+ }
66
+ }
67
+ return out.sort((a, b) => a.name.localeCompare(b.name));
68
+ }
36
69
  /** scan project + global skills → list (name+description เท่านั้น สำหรับ inject). project ทับ global ชื่อซ้ำ */
37
70
  export async function loadSkills(cwd = process.cwd()) {
38
71
  const out = new Map();
@@ -0,0 +1,155 @@
1
+ import { existsSync, readdirSync } from 'node:fs';
2
+ import { homedir } from 'node:os';
3
+ import { basename, dirname, isAbsolute, resolve } from 'node:path';
4
+ const PATH_TOKEN_RE = /((?:\.{1,2}\/|~\/?|\/|@|[^"'`\s]+\/)[^"'`\s]*)$/;
5
+ const MAX_PATH_COMPLETIONS = 40;
6
+ const DETAIL_SECTIONS = [
7
+ { text: 'thinking ', display: 'thinking', meta: 'details section' },
8
+ { text: 'tools ', display: 'tools', meta: 'details section' },
9
+ ];
10
+ const DETAIL_MODES = [
11
+ { text: 'hidden', display: 'hidden', meta: 'details mode' },
12
+ { text: 'collapsed', display: 'collapsed', meta: 'details mode' },
13
+ { text: 'expanded', display: 'expanded', meta: 'details mode' },
14
+ ];
15
+ const TRAIL_MODES = [
16
+ { text: 'compact', display: 'compact', meta: 'trail mode' },
17
+ { text: 'expanded', display: 'expanded', meta: 'trail mode' },
18
+ ];
19
+ const COPY_TARGETS = [{ text: 'last', display: 'last', meta: 'copy target' }];
20
+ const BUILTIN_SLASH_COMPLETIONS = [
21
+ { text: '/help', display: '/help', meta: 'command list + pager' },
22
+ { text: '/hotkeys', display: '/hotkeys', meta: 'keyboard shortcuts' },
23
+ { text: '/details', display: '/details', meta: 'thinking/tool trail visibility' },
24
+ { text: '/model', display: '/model', meta: 'pick provider then model' },
25
+ { text: '/setup', display: '/setup', meta: 'setup wizard sections' },
26
+ { text: '/dashboard', display: '/dashboard', meta: 'open web dashboard' },
27
+ { text: '/mcp', display: '/mcp', meta: 'browse MCP servers' },
28
+ { text: '/skills', display: '/skills', meta: 'browse loaded skills' },
29
+ { text: '/sessions', display: '/sessions', meta: 'resume saved sessions' },
30
+ { text: '/tasks', display: '/tasks', meta: 'background task_spawn jobs' },
31
+ { text: '/status', display: '/status', meta: 'session/model status' },
32
+ { text: '/platforms', display: '/platforms', meta: 'providers + gateways' },
33
+ { text: '/trail', display: '/trail', meta: 'toggle tool trail detail' },
34
+ { text: '/tools', display: '/tools', meta: 'agent tools' },
35
+ { text: '/diff', display: '/diff', meta: 'git diff stat' },
36
+ { text: '/copy', display: '/copy', meta: 'copy latest assistant response' },
37
+ { text: '/retry', display: '/retry', meta: 'rerun last prompt' },
38
+ { text: '/stop', display: '/stop', meta: 'stop current turn' },
39
+ { text: '/undo', display: '/undo', meta: 'stash recent file edits' },
40
+ { text: '/rewind', display: '/rewind', meta: 'restore previous turn' },
41
+ { text: '/cost', display: '/cost', meta: 'last usage/cost' },
42
+ { text: '/usage', display: '/usage', meta: 'last usage/cost' },
43
+ { text: '/insights', display: '/insights', meta: 'local usage insights' },
44
+ { text: '/personality', display: '/personality', meta: 'set response style' },
45
+ { text: '/compact', display: '/compact', meta: 'compress context' },
46
+ { text: '/compress', display: '/compress', meta: 'compress context' },
47
+ { text: '/new', display: '/new', meta: 'new conversation' },
48
+ { text: '/reset', display: '/reset', meta: 'new conversation' },
49
+ { text: '/clear', display: '/clear', meta: 'clear conversation' },
50
+ { text: '/quit', display: '/quit', meta: 'exit REPL' },
51
+ ];
52
+ export function slashCompletionItems(input) {
53
+ if (!/^\/[a-z0-9-?]*$/i.test(input))
54
+ return [];
55
+ const query = input.slice(1).toLowerCase();
56
+ return BUILTIN_SLASH_COMPLETIONS.filter((item) => item.text.slice(1).startsWith(query));
57
+ }
58
+ export function completionForInput(input, cwd = process.cwd()) {
59
+ const slash = slashCompletionItems(input);
60
+ if (slash.length)
61
+ return { items: slash, replaceFrom: 0 };
62
+ const slashArgs = slashArgumentCompletion(input);
63
+ if (slashArgs.items.length)
64
+ return slashArgs;
65
+ const path = pathCompletion(input, cwd);
66
+ if (path.items.length)
67
+ return path;
68
+ return { items: [], replaceFrom: 0 };
69
+ }
70
+ function slashArgumentCompletion(input) {
71
+ const commandMatch = /^\/([a-z0-9-?]+)\s+/i.exec(input);
72
+ if (!commandMatch)
73
+ return { items: [], replaceFrom: 0 };
74
+ const command = commandMatch[1].toLowerCase();
75
+ const rawArgs = input.slice(commandMatch[0].length);
76
+ const hasTrailingSpace = /\s$/.test(input);
77
+ const args = rawArgs.trim() ? rawArgs.trim().split(/\s+/) : [];
78
+ const activeIndex = hasTrailingSpace ? args.length : Math.max(0, args.length - 1);
79
+ const prefix = hasTrailingSpace ? '' : (args.at(-1) ?? '');
80
+ const replaceFrom = input.length - prefix.length;
81
+ if (command === 'trail' && activeIndex === 0) {
82
+ return { items: filterArgumentItems(TRAIL_MODES, prefix), replaceFrom };
83
+ }
84
+ if (command === 'copy' && activeIndex === 0) {
85
+ return { items: filterArgumentItems(COPY_TARGETS, prefix), replaceFrom };
86
+ }
87
+ if (command === 'details') {
88
+ if (activeIndex === 0)
89
+ return { items: filterArgumentItems(DETAIL_SECTIONS, prefix), replaceFrom };
90
+ const section = args[0]?.toLowerCase();
91
+ if (activeIndex === 1 && (section === 'thinking' || section === 'tools')) {
92
+ return { items: filterArgumentItems(DETAIL_MODES, prefix), replaceFrom };
93
+ }
94
+ }
95
+ return { items: [], replaceFrom: 0 };
96
+ }
97
+ function filterArgumentItems(items, prefix) {
98
+ const query = prefix.toLowerCase();
99
+ return items.filter((item) => item.text.toLowerCase().startsWith(query));
100
+ }
101
+ function pathCompletion(input, cwd) {
102
+ const match = PATH_TOKEN_RE.exec(input);
103
+ if (!match)
104
+ return { items: [], replaceFrom: 0 };
105
+ const token = match[1];
106
+ const replaceFrom = input.length - token.length;
107
+ const mention = token.startsWith('@');
108
+ const rawToken = mention ? token.slice(1) : token;
109
+ const raw = rawToken === '~' ? '~/' : rawToken;
110
+ const hasTrailingSlash = raw.endsWith('/');
111
+ const rawDir = hasTrailingSlash ? raw : dirname(raw);
112
+ const prefix = hasTrailingSlash ? '' : basename(raw);
113
+ const dirPart = rawDir === '.' ? '' : rawDir;
114
+ const absoluteDir = resolveInputPath(dirPart || '.', cwd);
115
+ if (!existsSync(absoluteDir))
116
+ return { items: [], replaceFrom };
117
+ let entries;
118
+ try {
119
+ entries = readdirSync(absoluteDir, { withFileTypes: true });
120
+ }
121
+ catch {
122
+ return { items: [], replaceFrom };
123
+ }
124
+ const head = `${mention ? '@' : ''}${dirPart ? `${dirPart.replace(/\/?$/, '/')}` : ''}`;
125
+ const items = entries
126
+ .filter((entry) => !entry.name.startsWith('.') && entry.name.startsWith(prefix))
127
+ .sort((a, b) => Number(b.isDirectory()) - Number(a.isDirectory()) || a.name.localeCompare(b.name))
128
+ .slice(0, MAX_PATH_COMPLETIONS)
129
+ .map((entry) => {
130
+ const suffix = entry.isDirectory() ? '/' : '';
131
+ const text = `${head}${entry.name}${suffix}`;
132
+ return { display: text, meta: entry.isDirectory() ? 'dir' : 'file', text };
133
+ });
134
+ return { items, replaceFrom };
135
+ }
136
+ function resolveInputPath(input, cwd) {
137
+ if (input === '~')
138
+ return homedir();
139
+ if (input.startsWith('~/'))
140
+ return resolve(homedir(), input.slice(2));
141
+ if (isAbsolute(input))
142
+ return input;
143
+ return resolve(cwd, input);
144
+ }
145
+ export function clampCompletionIndex(index, count) {
146
+ if (count <= 0)
147
+ return 0;
148
+ return ((index % count) + count) % count;
149
+ }
150
+ export function completionReplaceValue(input, item, replaceFrom = 0) {
151
+ if (!item)
152
+ return null;
153
+ const next = `${input.slice(0, replaceFrom)}${item.text}`;
154
+ return next === input ? null : next;
155
+ }