sanook-cli 0.5.7 → 0.5.8

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.
@@ -1,5 +1,5 @@
1
1
  import { readFile, readdir, stat } from 'node:fs/promises';
2
- import { join, resolve, relative } from 'node:path';
2
+ import { join, resolve, relative, isAbsolute } from 'node:path';
3
3
  import { homedir } from 'node:os';
4
4
  import { appHomePath, BRAND } from '../brand.js';
5
5
  import { loadConfig } from '../config.js';
@@ -54,12 +54,21 @@ export async function dashboardLogsTail(maxLines = 200) {
54
54
  function safeRoot(root) {
55
55
  return resolve(root);
56
56
  }
57
+ /**
58
+ * True only if `target` is the root itself or strictly inside it. Uses path.relative (not startsWith)
59
+ * so a sibling dir sharing the root's name-prefix (e.g. .sanook-secrets vs .sanook) and absolute-path
60
+ * escapes are both rejected — prevents directory traversal in the dashboard file API.
61
+ */
62
+ function isWithin(target, root) {
63
+ const rel = relative(safeRoot(root), target);
64
+ return !rel.startsWith('..') && !isAbsolute(rel);
65
+ }
57
66
  export async function dashboardListFiles(subpath = '') {
58
67
  const config = await loadConfig({});
59
68
  const roots = [appHomePath(), config.brainPath ? resolve(config.brainPath) : null].filter(Boolean);
60
69
  const root = safeRoot(roots[0] ?? appHomePath());
61
70
  const target = safeRoot(join(root, subpath.replace(/^\/+/, '')));
62
- if (!target.startsWith(root) && !roots.some((r) => target.startsWith(safeRoot(r)))) {
71
+ if (!roots.some((r) => isWithin(target, r))) {
63
72
  throw new Error('path not allowed');
64
73
  }
65
74
  const entries = await readdir(target, { withFileTypes: true });
@@ -75,7 +84,7 @@ export async function dashboardReadFile(subpath) {
75
84
  const config = await loadConfig({});
76
85
  const allowedRoots = [appHomePath(), config.brainPath ? resolve(config.brainPath) : null].filter(Boolean);
77
86
  const target = safeRoot(subpath.startsWith('/') ? subpath : join(appHomePath(), subpath));
78
- if (!allowedRoots.some((root) => target.startsWith(safeRoot(root))))
87
+ if (!allowedRoots.some((root) => isWithin(target, root)))
79
88
  throw new Error('path not allowed');
80
89
  const info = await stat(target);
81
90
  if (!info.isFile())
@@ -85,3 +94,103 @@ export async function dashboardReadFile(subpath) {
85
94
  const content = await readFile(target, 'utf8');
86
95
  return { path: relative(homedir(), target) || target, content };
87
96
  }
97
+ export async function dashboardSkills() {
98
+ const { loadSkills } = await import('../skills.js');
99
+ const { loadLedger } = await import('../self-improve.js');
100
+ const [skills, ledger] = await Promise.all([loadSkills(), loadLedger().catch(() => ({ families: [] }))]);
101
+ const autoNames = new Set((ledger.families ?? []).map((f) => f.skillName).filter((n) => Boolean(n)));
102
+ return {
103
+ skills: skills.map((s) => ({
104
+ name: s.name,
105
+ description: s.description,
106
+ whenToUse: s.whenToUse ?? null,
107
+ auto: autoNames.has(s.name),
108
+ })),
109
+ };
110
+ }
111
+ export async function dashboardMemory() {
112
+ const { loadStore, activeFacts } = await import('../memory-store.js');
113
+ const config = await loadConfig({});
114
+ const store = await loadStore();
115
+ const facts = activeFacts(store)
116
+ .slice()
117
+ .sort((a, b) => b.lastAccessed - a.lastAccessed)
118
+ .map((f) => ({
119
+ id: f.id,
120
+ text: f.text,
121
+ noteType: f.noteType,
122
+ trust: f.trust,
123
+ tier: f.tier,
124
+ importance: Math.round(f.importance * 100) / 100,
125
+ created: f.created,
126
+ lastAccessed: f.lastAccessed,
127
+ accessCount: f.accessCount,
128
+ }));
129
+ return { facts, brainPath: config.brainPath ?? null };
130
+ }
131
+ // ---- Usage / cost ledger ---------------------------------------------------
132
+ export async function dashboardUsage() {
133
+ const { loadUsageEvents, aggregateUsageEvents } = await import('../usage-ledger.js');
134
+ const events = await loadUsageEvents();
135
+ const daily = aggregateUsageEvents(events, 'daily').slice(-30);
136
+ const totals = events.reduce((acc, e) => {
137
+ acc.turns += 1;
138
+ acc.inputTokens += e.inputTokens;
139
+ acc.outputTokens += e.outputTokens;
140
+ acc.totalTokens += e.inputTokens + e.outputTokens + e.cacheReadTokens + e.cacheWriteTokens;
141
+ acc.costUsd += e.costUsd ?? 0;
142
+ return acc;
143
+ }, { turns: 0, inputTokens: 0, outputTokens: 0, totalTokens: 0, costUsd: 0 });
144
+ return { totals, daily };
145
+ }
146
+ // ---- Install commands (multi-platform) -------------------------------------
147
+ export {} from '../install-info.js';
148
+ import { dashboardInstallPayload } from '../install-info.js';
149
+ export function dashboardInstall() {
150
+ return dashboardInstallPayload();
151
+ }
152
+ export async function dashboardPersona() {
153
+ const { loadPersonaAnswers } = await import('../memory.js');
154
+ const { PERSONA_QUESTIONS } = await import('../persona.js');
155
+ const { BRAND } = await import('../brand.js');
156
+ const config = await loadConfig({});
157
+ const brainPath = config.brainPath ?? null;
158
+ const answers = await loadPersonaAnswers();
159
+ const rows = PERSONA_QUESTIONS.map((q) => {
160
+ const v = (answers[q.id] ?? '').trim();
161
+ return {
162
+ id: q.id,
163
+ label: q.label,
164
+ value: v,
165
+ display: v ? (q.type === 'select' ? (q.options?.find((o) => o.value === v)?.label ?? v) : v) : '—',
166
+ };
167
+ });
168
+ const hasProfile = rows.some((r) => r.value);
169
+ const profilePath = brainPath ? `${brainPath}/Shared/User-Persona/persona.md` : null;
170
+ return {
171
+ brainPath,
172
+ profilePath,
173
+ rows,
174
+ hasProfile,
175
+ cliCommand: `${BRAND.cliName} persona`,
176
+ };
177
+ }
178
+ export async function dashboardSelfImprove() {
179
+ const { loadLedger } = await import('../self-improve.js');
180
+ const { selfImproveEnabled, selfImproveThreshold } = await import('../brand.js');
181
+ const ledger = await loadLedger();
182
+ const families = (ledger.families ?? [])
183
+ .slice()
184
+ .sort((a, b) => b.lastSeen - a.lastSeen)
185
+ .map((f) => ({
186
+ sig: f.sig,
187
+ terms: f.terms,
188
+ sample: f.samples[f.samples.length - 1] ?? '',
189
+ count: f.count,
190
+ skillCreated: f.skillCreated,
191
+ skillName: f.skillName,
192
+ firstSeen: f.firstSeen,
193
+ lastSeen: f.lastSeen,
194
+ }));
195
+ return { enabled: selfImproveEnabled(), threshold: selfImproveThreshold(), families };
196
+ }
@@ -29,6 +29,17 @@ function json(res, status, body) {
29
29
  res.writeHead(status, { 'Content-Type': 'application/json; charset=utf-8' });
30
30
  res.end(`${JSON.stringify(body)}\n`);
31
31
  }
32
+ async function packageVersion() {
33
+ if (process.env.npm_package_version)
34
+ return process.env.npm_package_version;
35
+ try {
36
+ const pkg = JSON.parse(await readFile(new URL('../../package.json', import.meta.url), 'utf8'));
37
+ return typeof pkg.version === 'string' && pkg.version ? pkg.version : 'dev';
38
+ }
39
+ catch {
40
+ return 'dev';
41
+ }
42
+ }
32
43
  async function handleApi(req, res, pathname) {
33
44
  if (req.method === 'GET' && pathname === '/api/status') {
34
45
  const config = await loadConfig({});
@@ -36,7 +47,7 @@ async function handleApi(req, res, pathname) {
36
47
  json(res, 200, {
37
48
  product: 'Sanook Dashboard',
38
49
  cli: BRAND.cliName,
39
- version: process.env.npm_package_version ?? 'dev',
50
+ version: await packageVersion(),
40
51
  model: config.model,
41
52
  locale: config.locale,
42
53
  brainPath: config.brainPath ?? null,
@@ -91,6 +102,53 @@ async function handleApi(req, res, pathname) {
91
102
  json(res, 200, await dashboardListFiles(sub));
92
103
  return true;
93
104
  }
105
+ if (req.method === 'GET' && pathname === '/api/skills') {
106
+ const { dashboardSkills } = await import('./api-helpers.js');
107
+ json(res, 200, await dashboardSkills());
108
+ return true;
109
+ }
110
+ if (req.method === 'GET' && pathname === '/api/memory') {
111
+ const { dashboardMemory } = await import('./api-helpers.js');
112
+ json(res, 200, await dashboardMemory());
113
+ return true;
114
+ }
115
+ if (req.method === 'GET' && pathname === '/api/usage') {
116
+ const { dashboardUsage } = await import('./api-helpers.js');
117
+ json(res, 200, await dashboardUsage());
118
+ return true;
119
+ }
120
+ if (req.method === 'GET' && pathname === '/api/self-improve') {
121
+ const { dashboardSelfImprove } = await import('./api-helpers.js');
122
+ json(res, 200, await dashboardSelfImprove());
123
+ return true;
124
+ }
125
+ if (req.method === 'GET' && pathname === '/api/install') {
126
+ const { dashboardInstall } = await import('./api-helpers.js');
127
+ json(res, 200, dashboardInstall());
128
+ return true;
129
+ }
130
+ if (req.method === 'GET' && pathname === '/api/persona') {
131
+ const { dashboardPersona } = await import('./api-helpers.js');
132
+ json(res, 200, await dashboardPersona());
133
+ return true;
134
+ }
135
+ if (req.method === 'POST' && pathname === '/api/terminal/run') {
136
+ const { handleTerminalRun } = await import('./terminal.js');
137
+ await handleTerminalRun(req, res);
138
+ return true;
139
+ }
140
+ if (req.method === 'POST' && pathname === '/api/terminal/reset') {
141
+ const url = new URL(req.url ?? '/', 'http://local');
142
+ const { resetTerminalSession } = await import('./terminal.js');
143
+ resetTerminalSession(url.searchParams.get('session') ?? 'web');
144
+ json(res, 200, { ok: true });
145
+ return true;
146
+ }
147
+ if (req.method === 'GET' && pathname === '/api/terminal/shell-status') {
148
+ const { shellStatus } = await import('./terminal.js');
149
+ json(res, 200, await shellStatus());
150
+ return true;
151
+ }
94
152
  if (req.method === 'GET' && pathname === '/api/chat/status') {
95
153
  json(res, 200, {
96
154
  hint: `Use ${BRAND.cliName} in terminal, or start ${BRAND.cliName} serve for HTTP chat`,
@@ -146,6 +204,22 @@ async function serveStatic(res, staticDir, pathname) {
146
204
  }
147
205
  }
148
206
  }
207
+ async function serveInstallScript(res, pathname) {
208
+ if (pathname !== '/install.sh' && pathname !== '/install.ps1')
209
+ return false;
210
+ const root = join(fileURLToPath(new URL('.', import.meta.url)), '..', '..');
211
+ const name = pathname === '/install.sh' ? 'install.sh' : 'install.ps1';
212
+ try {
213
+ const body = await readFile(join(root, 'scripts', name), 'utf8');
214
+ res.writeHead(200, { 'Content-Type': 'text/plain; charset=utf-8', 'Cache-Control': 'public, max-age=300' });
215
+ res.end(body);
216
+ }
217
+ catch {
218
+ res.writeHead(404);
219
+ res.end('install script not found');
220
+ }
221
+ return true;
222
+ }
149
223
  export async function startDashboardServer(opts = {}) {
150
224
  const port = opts.port ?? 9119;
151
225
  const host = opts.host ?? '127.0.0.1';
@@ -161,6 +235,8 @@ export async function startDashboardServer(opts = {}) {
161
235
  json(res, 404, { error: 'not found' });
162
236
  return;
163
237
  }
238
+ if (req.method === 'GET' && (await serveInstallScript(res, url.pathname)))
239
+ return;
164
240
  await serveStatic(res, staticDir, url.pathname);
165
241
  }
166
242
  catch (e) {
@@ -171,6 +247,14 @@ export async function startDashboardServer(opts = {}) {
171
247
  server.once('error', reject);
172
248
  server.listen(port, host, () => resolve());
173
249
  });
250
+ // raw shell over ws (no-op if node-pty/ws not installed)
251
+ try {
252
+ const { attachShell } = await import('./terminal.js');
253
+ await attachShell(server);
254
+ }
255
+ catch {
256
+ /* optional */
257
+ }
174
258
  log(`Sanook Dashboard — http://${host}:${port}`);
175
259
  return () => server.close();
176
260
  }