sanook-cli 0.5.2 → 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.
- package/CHANGELOG.md +91 -2
- package/README.md +15 -3
- package/README.th.md +8 -1
- package/dist/approval.js +7 -0
- package/dist/bin.js +623 -56
- package/dist/brain-consolidate.js +335 -0
- package/dist/brain-context.js +42 -3
- package/dist/brain-final.js +15 -9
- package/dist/brain-metrics.js +277 -0
- package/dist/brain-new.js +402 -0
- package/dist/brain-pack.js +210 -0
- package/dist/brain-repair.js +280 -0
- package/dist/brain.js +3 -0
- package/dist/cli-args.js +47 -9
- package/dist/cli-option-values.js +1 -1
- package/dist/clipboard.js +65 -0
- package/dist/commands.js +94 -14
- package/dist/config.js +31 -5
- package/dist/context-pack.js +145 -0
- package/dist/dashboard/api-helpers.js +87 -0
- package/dist/dashboard/server.js +179 -0
- package/dist/dashboard/static/app.js +277 -0
- package/dist/dashboard/static/index.html +39 -0
- package/dist/dashboard/static/styles.css +85 -0
- package/dist/diff.js +10 -2
- package/dist/gateway/auth.js +14 -3
- package/dist/gateway/deliver.js +45 -3
- package/dist/gateway/doctor.js +456 -0
- package/dist/gateway/email.js +30 -1
- package/dist/gateway/ledger.js +20 -1
- package/dist/gateway/session.js +30 -11
- package/dist/hotkeys.js +21 -0
- package/dist/i18n/en.js +98 -0
- package/dist/i18n/index.js +19 -0
- package/dist/i18n/th.js +98 -0
- package/dist/i18n/types.js +1 -0
- package/dist/insights-args.js +24 -4
- package/dist/knowledge.js +55 -29
- package/dist/loop.js +34 -5
- package/dist/mcp-hub.js +33 -0
- package/dist/mcp-registry.js +153 -9
- package/dist/mcp-risk.js +71 -0
- package/dist/mcp.js +77 -5
- package/dist/memory-log.js +90 -0
- package/dist/memory-store.js +37 -1
- package/dist/memory.js +51 -7
- package/dist/model-picker.js +58 -0
- package/dist/orchestrate.js +7 -5
- package/dist/plan-handoff.js +17 -0
- package/dist/polyglot.js +162 -0
- package/dist/process-runner.js +96 -0
- package/dist/project-init.js +91 -0
- package/dist/project-registry.js +143 -0
- package/dist/project-scaffold.js +124 -0
- package/dist/prompt-size.js +155 -0
- package/dist/providers/codex-login.js +138 -0
- package/dist/providers/codex.js +20 -8
- package/dist/providers/keys.js +21 -0
- package/dist/providers/models.js +1 -1
- package/dist/search/cli.js +9 -1
- package/dist/search/embedding-config.js +22 -0
- package/dist/search/engine.js +2 -13
- package/dist/search/indexer.js +10 -10
- package/dist/session-distill.js +84 -0
- package/dist/session.js +1 -11
- package/dist/skill-install.js +24 -1
- package/dist/skills.js +33 -0
- package/dist/slash-completion.js +155 -0
- package/dist/support-dump.js +31 -0
- package/dist/tool-catalog.js +59 -0
- package/dist/tools/index.js +5 -0
- package/dist/tools/permission.js +82 -16
- package/dist/tools/polyglot.js +126 -0
- package/dist/tools/sandbox.js +38 -13
- package/dist/tools/search.js +9 -2
- package/dist/tools/task.js +22 -2
- package/dist/tools/timeout.js +7 -5
- package/dist/tools/web-fetch-tool.js +33 -0
- package/dist/turn-retrieval.js +83 -0
- package/dist/ui/app.js +835 -29
- package/dist/ui/banner.js +78 -4
- package/dist/ui/markdown.js +122 -0
- package/dist/ui/overlay.js +496 -0
- package/dist/ui/queue.js +23 -0
- package/dist/ui/render.js +20 -1
- package/dist/ui/session-panel.js +115 -0
- package/dist/ui/setup-providers.js +40 -0
- package/dist/ui/setup.js +163 -50
- package/dist/ui/status.js +142 -0
- package/dist/ui/thinking-panel.js +36 -0
- package/dist/ui/tool-trail.js +97 -0
- package/dist/ui/transcript.js +26 -0
- package/dist/ui/useBusyElapsed.js +19 -0
- package/dist/ui/useEditor.js +144 -5
- package/dist/ui/useGitBranch.js +57 -0
- package/dist/update.js +32 -6
- package/dist/web-fetch.js +637 -0
- package/dist/web-surface.js +190 -0
- package/package.json +2 -2
- package/second-brain/Projects/_Index.md +17 -4
- package/second-brain/Projects/sanook-cli/_Index.md +7 -3
- package/second-brain/Projects/sanook-cli/context.md +35 -0
- package/second-brain/Projects/sanook-cli/current-state.md +32 -0
- package/second-brain/Projects/sanook-cli/overview.md +41 -0
- package/second-brain/Projects/sanook-cli/repo.md +34 -0
- package/second-brain/Projects/sanook-cli/second-brain-feature-roadmap.md +52 -11
- package/second-brain/Research/2026-06-18-hermes-tui-parity-map.md +129 -0
- package/second-brain/Research/2026-06-19-hermes-python-architecture-for-sanook.md +49 -0
- package/second-brain/Research/2026-06-19-terminal-ui-brand-research.md +52 -0
- package/second-brain/Research/_Index.md +2 -0
- package/second-brain/Shared/Operating-State/current-state.md +14 -23
- package/second-brain/Shared/Tech-Standards/_Index.md +2 -0
- package/second-brain/Shared/Tech-Standards/polyglot-runtime-strategy.md +46 -0
- package/second-brain/Shared/Tech-Standards/web-search-grounding-policy.md +70 -0
- package/second-brain/Templates/project-workspace/_Index.md +31 -0
- package/second-brain/Templates/project-workspace/context.md +28 -0
- package/second-brain/Templates/project-workspace/current-state.md +29 -0
- package/second-brain/Templates/project-workspace/overview.md +39 -0
- package/second-brain/Templates/project-workspace/repo.md +33 -0
|
@@ -0,0 +1,58 @@
|
|
|
1
|
+
import { canonicalSpec, hasUsableEnvKey, PROVIDERS, parseSpec } from './providers/registry.js';
|
|
2
|
+
function statusFor(provider) {
|
|
3
|
+
const cfg = PROVIDERS[provider];
|
|
4
|
+
if (cfg.kind === 'delegate')
|
|
5
|
+
return 'delegate';
|
|
6
|
+
if (!cfg.requiresKey)
|
|
7
|
+
return 'local';
|
|
8
|
+
return hasUsableEnvKey(provider) ? 'ready' : 'needs-key';
|
|
9
|
+
}
|
|
10
|
+
function statusLabel(status) {
|
|
11
|
+
if (status === 'needs-key')
|
|
12
|
+
return 'needs key';
|
|
13
|
+
return status;
|
|
14
|
+
}
|
|
15
|
+
export function modelPickerOptions(current) {
|
|
16
|
+
const currentSpec = canonicalSpec(current);
|
|
17
|
+
return Object.entries(PROVIDERS).flatMap(([provider, cfg]) => {
|
|
18
|
+
const grouped = new Map();
|
|
19
|
+
for (const [alias, model] of Object.entries(cfg.models)) {
|
|
20
|
+
const aliases = grouped.get(model) ?? [];
|
|
21
|
+
aliases.push(alias);
|
|
22
|
+
grouped.set(model, aliases);
|
|
23
|
+
}
|
|
24
|
+
const status = statusFor(provider);
|
|
25
|
+
return [...grouped.entries()].map(([model, aliases]) => {
|
|
26
|
+
const nonDefaultAliases = aliases.filter((alias) => alias !== 'default');
|
|
27
|
+
const displayAliases = nonDefaultAliases.length ? nonDefaultAliases.join('/') : 'default';
|
|
28
|
+
const spec = `${provider}:${model}`;
|
|
29
|
+
return {
|
|
30
|
+
aliases: displayAliases,
|
|
31
|
+
current: spec === currentSpec,
|
|
32
|
+
label: `${provider}:${displayAliases}`,
|
|
33
|
+
meta: `${cfg.label} · ${statusLabel(status)}`,
|
|
34
|
+
model,
|
|
35
|
+
provider,
|
|
36
|
+
spec,
|
|
37
|
+
status,
|
|
38
|
+
};
|
|
39
|
+
});
|
|
40
|
+
});
|
|
41
|
+
}
|
|
42
|
+
export function initialModelPickerIndex(options) {
|
|
43
|
+
const current = options.findIndex((option) => option.current);
|
|
44
|
+
return current === -1 ? 0 : current;
|
|
45
|
+
}
|
|
46
|
+
export function modelProviderEntries() {
|
|
47
|
+
return Object.entries(PROVIDERS).map(([id, cfg]) => ({
|
|
48
|
+
id,
|
|
49
|
+
label: cfg.label,
|
|
50
|
+
status: statusFor(id),
|
|
51
|
+
modelCount: new Set(Object.values(cfg.models)).size,
|
|
52
|
+
}));
|
|
53
|
+
}
|
|
54
|
+
export function filterModelPickerOptions(options, providerId) {
|
|
55
|
+
if (!providerId)
|
|
56
|
+
return options;
|
|
57
|
+
return options.filter((option) => option.provider === providerId);
|
|
58
|
+
}
|
package/dist/orchestrate.js
CHANGED
|
@@ -1,20 +1,22 @@
|
|
|
1
1
|
import { inspect } from 'node:util';
|
|
2
|
+
import { redactKey, redactUnknown } from './providers/keys.js';
|
|
2
3
|
export function formatSubagentError(e) {
|
|
3
4
|
if (e instanceof Error)
|
|
4
|
-
return e.message || e.name;
|
|
5
|
+
return redactKey(e.message || e.name);
|
|
5
6
|
if (typeof e === 'string')
|
|
6
|
-
return e;
|
|
7
|
+
return redactKey(e);
|
|
7
8
|
if (e == null)
|
|
8
9
|
return String(e);
|
|
10
|
+
const safe = redactUnknown(e);
|
|
9
11
|
try {
|
|
10
|
-
const json = JSON.stringify(
|
|
12
|
+
const json = JSON.stringify(safe);
|
|
11
13
|
if (json)
|
|
12
14
|
return json;
|
|
13
15
|
}
|
|
14
16
|
catch {
|
|
15
|
-
return inspect(
|
|
17
|
+
return inspect(safe, { breakLength: Infinity, depth: 2 });
|
|
16
18
|
}
|
|
17
|
-
return String(e);
|
|
19
|
+
return redactKey(String(e));
|
|
18
20
|
}
|
|
19
21
|
const DEFAULT_CONCURRENCY = 5;
|
|
20
22
|
const DEFAULT_GLOBAL_SUBAGENT_CONCURRENCY = 16;
|
|
@@ -0,0 +1,17 @@
|
|
|
1
|
+
import { BRAND } from './brand.js';
|
|
2
|
+
/** Shell-safe double-quoted string for handoff hints (task may contain quotes/newlines). */
|
|
3
|
+
export function shellQuoteDouble(value) {
|
|
4
|
+
return `"${value.replace(/\\/g, '\\\\').replace(/"/g, '\\"').replace(/\r?\n/g, '\\n')}"`;
|
|
5
|
+
}
|
|
6
|
+
/** Hint printed after plan mode completes — stderr so stdout stays pipe-friendly. */
|
|
7
|
+
export function formatPlanExecuteHandoff(originalTask) {
|
|
8
|
+
const task = originalTask.trim();
|
|
9
|
+
const quoted = task ? shellQuoteDouble(task) : '<task>';
|
|
10
|
+
return [
|
|
11
|
+
'---',
|
|
12
|
+
'Plan complete. Execute with:',
|
|
13
|
+
` ${BRAND.cliName} --yes ${quoted}`,
|
|
14
|
+
` ${BRAND.cliName} plan ${quoted} | ${BRAND.cliName} --yes ${shellQuoteDouble('Execute this plan:')}`,
|
|
15
|
+
` (plan text on stdout → pipe into ${BRAND.cliName} --yes "Execute this plan:" with stdin)`,
|
|
16
|
+
].join('\n');
|
|
17
|
+
}
|
package/dist/polyglot.js
ADDED
|
@@ -0,0 +1,162 @@
|
|
|
1
|
+
import { execFile } from 'node:child_process';
|
|
2
|
+
import { promisify } from 'node:util';
|
|
3
|
+
import { BRAND } from './brand.js';
|
|
4
|
+
import { findBinary } from './lsp/servers.js';
|
|
5
|
+
import { safeProcessEnv } from './process-runner.js';
|
|
6
|
+
const execFileAsync = promisify(execFile);
|
|
7
|
+
const MAX_VERSION_TEXT = 160;
|
|
8
|
+
const RUNTIME_SPECS = [
|
|
9
|
+
{
|
|
10
|
+
id: 'python',
|
|
11
|
+
label: 'Python',
|
|
12
|
+
candidates: ['python3', 'python'],
|
|
13
|
+
versionArgs: ['--version'],
|
|
14
|
+
role: 'data/doc/ML glue, JSON/CSV transforms, OCR/transcription helpers, one-off research scripts via run_python',
|
|
15
|
+
install: 'Install Python 3.11+ (python.org, Homebrew, pyenv, or uv).',
|
|
16
|
+
},
|
|
17
|
+
{
|
|
18
|
+
id: 'uv',
|
|
19
|
+
label: 'uv',
|
|
20
|
+
candidates: ['uv'],
|
|
21
|
+
versionArgs: ['--version'],
|
|
22
|
+
role: 'fast Python project/env management when Sanook grows optional Python packs',
|
|
23
|
+
install: 'Install uv: https://docs.astral.sh/uv/',
|
|
24
|
+
},
|
|
25
|
+
{
|
|
26
|
+
id: 'rustc',
|
|
27
|
+
label: 'Rust compiler',
|
|
28
|
+
candidates: ['rustc'],
|
|
29
|
+
versionArgs: ['--version'],
|
|
30
|
+
role: 'compile small high-speed/safe helpers and future native accelerators via run_rust',
|
|
31
|
+
install: 'Install Rust via rustup: https://rustup.rs/',
|
|
32
|
+
},
|
|
33
|
+
{
|
|
34
|
+
id: 'cargo',
|
|
35
|
+
label: 'Cargo',
|
|
36
|
+
candidates: ['cargo'],
|
|
37
|
+
versionArgs: ['--version'],
|
|
38
|
+
role: 'build/test packaged Rust helpers when a native crate becomes worth shipping',
|
|
39
|
+
install: 'Install Rust via rustup: https://rustup.rs/',
|
|
40
|
+
},
|
|
41
|
+
{
|
|
42
|
+
id: 'pyright',
|
|
43
|
+
label: 'Pyright LSP',
|
|
44
|
+
candidates: ['pyright-langserver'],
|
|
45
|
+
versionArgs: ['--version'],
|
|
46
|
+
role: 'Python diagnostics through Sanook diagnostics tool',
|
|
47
|
+
install: 'npm i -g pyright',
|
|
48
|
+
},
|
|
49
|
+
{
|
|
50
|
+
id: 'rust-analyzer',
|
|
51
|
+
label: 'rust-analyzer LSP',
|
|
52
|
+
candidates: ['rust-analyzer'],
|
|
53
|
+
versionArgs: ['--version'],
|
|
54
|
+
role: 'Rust diagnostics through Sanook diagnostics tool',
|
|
55
|
+
install: 'rustup component add rust-analyzer',
|
|
56
|
+
},
|
|
57
|
+
];
|
|
58
|
+
async function defaultVersion(command, args, cwd) {
|
|
59
|
+
const { stdout, stderr } = await execFileAsync(command, args, { cwd, env: safeProcessEnv(), timeout: 5_000, maxBuffer: 256 * 1024 });
|
|
60
|
+
return stdout.trim() ? stdout : stderr;
|
|
61
|
+
}
|
|
62
|
+
function normalizeVersionText(version) {
|
|
63
|
+
const firstLine = version.trim().split(/\r?\n/)[0]?.trim() || '(version unavailable)';
|
|
64
|
+
if (firstLine.length <= MAX_VERSION_TEXT)
|
|
65
|
+
return firstLine;
|
|
66
|
+
return `${firstLine.slice(0, MAX_VERSION_TEXT - '... [truncated]'.length)}... [truncated]`;
|
|
67
|
+
}
|
|
68
|
+
async function detectSpec(spec, cwd, findBinaryImpl, versionImpl) {
|
|
69
|
+
for (const candidate of spec.candidates) {
|
|
70
|
+
const command = await findBinaryImpl(candidate, cwd);
|
|
71
|
+
if (!command)
|
|
72
|
+
continue;
|
|
73
|
+
let version;
|
|
74
|
+
try {
|
|
75
|
+
version = normalizeVersionText(await versionImpl(command, [...spec.versionArgs], cwd));
|
|
76
|
+
}
|
|
77
|
+
catch {
|
|
78
|
+
version = '(installed; version probe failed)';
|
|
79
|
+
}
|
|
80
|
+
return {
|
|
81
|
+
id: spec.id,
|
|
82
|
+
label: spec.label,
|
|
83
|
+
status: 'ready',
|
|
84
|
+
command,
|
|
85
|
+
version,
|
|
86
|
+
role: spec.role,
|
|
87
|
+
install: spec.install,
|
|
88
|
+
};
|
|
89
|
+
}
|
|
90
|
+
return {
|
|
91
|
+
id: spec.id,
|
|
92
|
+
label: spec.label,
|
|
93
|
+
status: 'missing',
|
|
94
|
+
role: spec.role,
|
|
95
|
+
install: spec.install,
|
|
96
|
+
};
|
|
97
|
+
}
|
|
98
|
+
export async function inspectPolyglotRuntimes(options = {}) {
|
|
99
|
+
const cwd = options.cwd ?? process.cwd();
|
|
100
|
+
const findBinaryImpl = options.findBinaryImpl ?? findBinary;
|
|
101
|
+
const versionImpl = options.versionImpl ?? defaultVersion;
|
|
102
|
+
const optional = await Promise.all(RUNTIME_SPECS.map((spec) => detectSpec(spec, cwd, findBinaryImpl, versionImpl)));
|
|
103
|
+
return {
|
|
104
|
+
cwd,
|
|
105
|
+
runtimes: [
|
|
106
|
+
{
|
|
107
|
+
id: 'typescript',
|
|
108
|
+
label: 'TypeScript / Node.js',
|
|
109
|
+
status: 'core',
|
|
110
|
+
command: process.execPath,
|
|
111
|
+
version: `node ${process.versions.node}`,
|
|
112
|
+
role: 'core Sanook runtime: agent loop, TUI, gateway, MCP, second-brain, packaging',
|
|
113
|
+
},
|
|
114
|
+
...optional,
|
|
115
|
+
],
|
|
116
|
+
strategy: [
|
|
117
|
+
'TypeScript stays the control plane and npm-distributed default.',
|
|
118
|
+
'Python is the optional analysis/data plane: scripts, data wrangling, document/ML/OCR workflows, and research helpers.',
|
|
119
|
+
'Rust is the optional performance/safety plane: single-binary helpers, high-throughput parsing/search, and future native accelerators.',
|
|
120
|
+
'Optional runtimes must degrade gracefully; missing Python/Rust should never break basic Sanook install or chat.',
|
|
121
|
+
],
|
|
122
|
+
notes: [
|
|
123
|
+
'`run_python` and `run_rust` are approval-gated tools because arbitrary code can mutate files.',
|
|
124
|
+
'The diagnostics tool already understands Python and Rust when Pyright/rust-analyzer are installed.',
|
|
125
|
+
'Use `sanook mcp list --tools` for external runtime capabilities exposed through MCP servers.',
|
|
126
|
+
],
|
|
127
|
+
};
|
|
128
|
+
}
|
|
129
|
+
function fmtStatus(status) {
|
|
130
|
+
if (status === 'core')
|
|
131
|
+
return 'CORE ';
|
|
132
|
+
if (status === 'ready')
|
|
133
|
+
return 'READY';
|
|
134
|
+
return 'MISS ';
|
|
135
|
+
}
|
|
136
|
+
export function renderPolyglotReport(report) {
|
|
137
|
+
const missingRuntimes = report.runtimes.filter((runtime) => runtime.status === 'missing');
|
|
138
|
+
const lines = [
|
|
139
|
+
`${BRAND.productName} runtimes`,
|
|
140
|
+
`cwd: ${report.cwd}`,
|
|
141
|
+
'',
|
|
142
|
+
'Runtime surface:',
|
|
143
|
+
...report.runtimes.map((runtime) => {
|
|
144
|
+
const version = runtime.version ? ` — ${runtime.version}` : '';
|
|
145
|
+
const command = runtime.command ? ` (${runtime.command})` : '';
|
|
146
|
+
return ` [${fmtStatus(runtime.status)}] ${runtime.label}${version}${command}`;
|
|
147
|
+
}),
|
|
148
|
+
'',
|
|
149
|
+
'Role map:',
|
|
150
|
+
...report.runtimes.map((runtime) => ` - ${runtime.label}: ${runtime.role}`),
|
|
151
|
+
'',
|
|
152
|
+
'Strategy:',
|
|
153
|
+
...report.strategy.map((item) => ` - ${item}`),
|
|
154
|
+
'',
|
|
155
|
+
'Missing install hints:',
|
|
156
|
+
...(missingRuntimes.length > 0 ? missingRuntimes.map((runtime) => ` - ${runtime.label}: ${runtime.install}`) : [' - None']),
|
|
157
|
+
'',
|
|
158
|
+
'Notes:',
|
|
159
|
+
...report.notes.map((note) => ` - ${note}`),
|
|
160
|
+
];
|
|
161
|
+
return `${lines.join('\n')}\n`;
|
|
162
|
+
}
|
|
@@ -0,0 +1,96 @@
|
|
|
1
|
+
import { spawn } from 'node:child_process';
|
|
2
|
+
import { clamp } from './tools/util.js';
|
|
3
|
+
const SAFE_ENV_KEYS = ['PATH', 'HOME', 'TMPDIR', 'TEMP', 'TMP', 'LANG', 'LC_ALL', 'USER', 'SHELL', 'TERM', 'NODE_PATH', 'NVM_DIR', 'APPDATA'];
|
|
4
|
+
const DEFAULT_MAX_BUFFER = 10 * 1024 * 1024;
|
|
5
|
+
export function safeProcessEnv(env = process.env) {
|
|
6
|
+
const out = {};
|
|
7
|
+
for (const key of SAFE_ENV_KEYS) {
|
|
8
|
+
const value = env[key];
|
|
9
|
+
if (value != null)
|
|
10
|
+
out[key] = value;
|
|
11
|
+
}
|
|
12
|
+
return out;
|
|
13
|
+
}
|
|
14
|
+
function appendChunk(chunks, chunk, state, maxBuffer) {
|
|
15
|
+
if (state.bytes >= maxBuffer) {
|
|
16
|
+
state.truncated = true;
|
|
17
|
+
return;
|
|
18
|
+
}
|
|
19
|
+
const remaining = maxBuffer - state.bytes;
|
|
20
|
+
const kept = chunk.length > remaining ? chunk.subarray(0, remaining) : chunk;
|
|
21
|
+
chunks.push(kept);
|
|
22
|
+
state.bytes += kept.length;
|
|
23
|
+
if (kept.length < chunk.length)
|
|
24
|
+
state.truncated = true;
|
|
25
|
+
}
|
|
26
|
+
export function runProcess(file, args, options = {}) {
|
|
27
|
+
const timeoutMs = Math.max(1, Math.min(options.timeoutMs ?? 120_000, 300_000));
|
|
28
|
+
const maxBuffer = Math.max(1, options.maxBuffer ?? DEFAULT_MAX_BUFFER);
|
|
29
|
+
const stdoutChunks = [];
|
|
30
|
+
const stderrChunks = [];
|
|
31
|
+
const stdoutState = { bytes: 0, truncated: false };
|
|
32
|
+
const stderrState = { bytes: 0, truncated: false };
|
|
33
|
+
return new Promise((resolve) => {
|
|
34
|
+
let timedOut = false;
|
|
35
|
+
let settled = false;
|
|
36
|
+
const child = spawn(file, args, {
|
|
37
|
+
cwd: options.cwd,
|
|
38
|
+
env: safeProcessEnv(),
|
|
39
|
+
shell: false,
|
|
40
|
+
windowsHide: true,
|
|
41
|
+
});
|
|
42
|
+
const timer = setTimeout(() => {
|
|
43
|
+
timedOut = true;
|
|
44
|
+
child.kill('SIGTERM');
|
|
45
|
+
setTimeout(() => {
|
|
46
|
+
if (!settled)
|
|
47
|
+
child.kill('SIGKILL');
|
|
48
|
+
}, 1_000).unref();
|
|
49
|
+
}, timeoutMs);
|
|
50
|
+
child.stdout.on('data', (chunk) => appendChunk(stdoutChunks, chunk, stdoutState, maxBuffer));
|
|
51
|
+
child.stderr.on('data', (chunk) => appendChunk(stderrChunks, chunk, stderrState, maxBuffer));
|
|
52
|
+
child.on('error', (err) => {
|
|
53
|
+
clearTimeout(timer);
|
|
54
|
+
settled = true;
|
|
55
|
+
resolve({
|
|
56
|
+
ok: false,
|
|
57
|
+
stdout: Buffer.concat(stdoutChunks).toString('utf8'),
|
|
58
|
+
stderr: Buffer.concat(stderrChunks).toString('utf8'),
|
|
59
|
+
code: null,
|
|
60
|
+
signal: null,
|
|
61
|
+
timedOut,
|
|
62
|
+
truncated: stdoutState.truncated || stderrState.truncated,
|
|
63
|
+
error: err.message,
|
|
64
|
+
});
|
|
65
|
+
});
|
|
66
|
+
child.on('close', (code, signal) => {
|
|
67
|
+
clearTimeout(timer);
|
|
68
|
+
settled = true;
|
|
69
|
+
resolve({
|
|
70
|
+
ok: code === 0 && !timedOut,
|
|
71
|
+
stdout: Buffer.concat(stdoutChunks).toString('utf8'),
|
|
72
|
+
stderr: Buffer.concat(stderrChunks).toString('utf8'),
|
|
73
|
+
code,
|
|
74
|
+
signal,
|
|
75
|
+
timedOut,
|
|
76
|
+
truncated: stdoutState.truncated || stderrState.truncated,
|
|
77
|
+
});
|
|
78
|
+
});
|
|
79
|
+
if (options.input != null)
|
|
80
|
+
child.stdin.end(options.input);
|
|
81
|
+
else
|
|
82
|
+
child.stdin.end();
|
|
83
|
+
});
|
|
84
|
+
}
|
|
85
|
+
export function formatProcessResult(result) {
|
|
86
|
+
const body = (result.stdout + (result.stderr ? `\n[stderr]\n${result.stderr}` : '')).trim();
|
|
87
|
+
const truncated = result.truncated ? '\n... [process output truncated]' : '';
|
|
88
|
+
if (result.ok)
|
|
89
|
+
return clamp(`${body}${truncated}`.trim()) || '(no output)';
|
|
90
|
+
const status = result.timedOut
|
|
91
|
+
? 'timeout'
|
|
92
|
+
: result.error
|
|
93
|
+
? result.error
|
|
94
|
+
: `exit ${result.code ?? 'unknown'}${result.signal ? ` (${result.signal})` : ''}`;
|
|
95
|
+
return clamp(`ERROR: process failed — ${status}${body ? `\n${body}` : ''}${truncated}`);
|
|
96
|
+
}
|
|
@@ -0,0 +1,91 @@
|
|
|
1
|
+
import { mkdir, stat, writeFile } from 'node:fs/promises';
|
|
2
|
+
import { join } from 'node:path';
|
|
3
|
+
import { BRAND, appProjectPath } from './brand.js';
|
|
4
|
+
import { loadConfig } from './config.js';
|
|
5
|
+
import { projectRoot, projectTrustStatus, trustProject } from './trust.js';
|
|
6
|
+
export const STARTER_COMMANDS = {
|
|
7
|
+
review: {
|
|
8
|
+
description: 'Review recent changes before commit',
|
|
9
|
+
body: `Review the recent changes in this repo. Focus on bugs, regressions, and missing tests.
|
|
10
|
+
|
|
11
|
+
$ARGUMENTS`,
|
|
12
|
+
},
|
|
13
|
+
plan: {
|
|
14
|
+
description: 'Plan a task without modifying files yet',
|
|
15
|
+
body: `Plan how to accomplish the following without modifying any files yet. Break down steps, risks, and a test approach.
|
|
16
|
+
|
|
17
|
+
$ARGUMENTS`,
|
|
18
|
+
},
|
|
19
|
+
};
|
|
20
|
+
async function exists(p) {
|
|
21
|
+
try {
|
|
22
|
+
await stat(p);
|
|
23
|
+
return true;
|
|
24
|
+
}
|
|
25
|
+
catch {
|
|
26
|
+
return false;
|
|
27
|
+
}
|
|
28
|
+
}
|
|
29
|
+
function commandTemplate(name, spec) {
|
|
30
|
+
return ['---', `description: ${spec.description}`, '---', '', spec.body.trim(), ''].join('\n');
|
|
31
|
+
}
|
|
32
|
+
export async function scaffoldProjectCommands(root) {
|
|
33
|
+
const commandsDir = appProjectPath(root, 'commands');
|
|
34
|
+
await mkdir(commandsDir, { recursive: true });
|
|
35
|
+
const created = [];
|
|
36
|
+
const skipped = [];
|
|
37
|
+
for (const [name, spec] of Object.entries(STARTER_COMMANDS)) {
|
|
38
|
+
const rel = join(BRAND.configDirName, 'commands', `${name}.md`);
|
|
39
|
+
const path = join(commandsDir, `${name}.md`);
|
|
40
|
+
if (await exists(path)) {
|
|
41
|
+
skipped.push(rel);
|
|
42
|
+
continue;
|
|
43
|
+
}
|
|
44
|
+
await writeFile(path, commandTemplate(name, spec));
|
|
45
|
+
created.push(rel);
|
|
46
|
+
}
|
|
47
|
+
return { created, skipped };
|
|
48
|
+
}
|
|
49
|
+
export async function buildInitHints(root, trusted) {
|
|
50
|
+
const hints = [];
|
|
51
|
+
const config = await loadConfig({}, root);
|
|
52
|
+
if (!config.brainPath?.trim()) {
|
|
53
|
+
hints.push(`${BRAND.cliName} brain init — สร้าง second-brain vault แล้วเก็บ path ใน config.brainPath`);
|
|
54
|
+
}
|
|
55
|
+
else if (!(await exists(config.brainPath))) {
|
|
56
|
+
hints.push(`config.brainPath ชี้ไป path ที่ไม่มี: ${config.brainPath} — รัน ${BRAND.cliName} brain init หรือแก้ config`);
|
|
57
|
+
}
|
|
58
|
+
hints.push(`${BRAND.cliName} mcp preset dev — ดู MCP starter pack สำหรับ repo/issues/docs/debug`);
|
|
59
|
+
if (!trusted) {
|
|
60
|
+
hints.push(`${BRAND.cliName} trust add — เปิดใช้ project .sanook/commands ใน REPL (ต้อง trust ก่อน)`);
|
|
61
|
+
}
|
|
62
|
+
return hints;
|
|
63
|
+
}
|
|
64
|
+
export function formatInitResult(result) {
|
|
65
|
+
const lines = [`initialized ${result.root}`];
|
|
66
|
+
if (result.created.length)
|
|
67
|
+
lines.push(`created: ${result.created.join(', ')}`);
|
|
68
|
+
if (result.skipped.length)
|
|
69
|
+
lines.push(`skipped (already exists): ${result.skipped.join(', ')}`);
|
|
70
|
+
if (result.trusted)
|
|
71
|
+
lines.push('trusted: yes');
|
|
72
|
+
if (result.hints.length) {
|
|
73
|
+
lines.push('', 'next:');
|
|
74
|
+
for (const hint of result.hints)
|
|
75
|
+
lines.push(` ${hint}`);
|
|
76
|
+
}
|
|
77
|
+
return lines.join('\n');
|
|
78
|
+
}
|
|
79
|
+
/** sanook init — scaffold project .sanook/commands + optional trust + onboarding hints */
|
|
80
|
+
export async function initProject(options = {}) {
|
|
81
|
+
const cwd = options.cwd ?? process.cwd();
|
|
82
|
+
const root = await projectRoot(cwd);
|
|
83
|
+
const { created, skipped } = await scaffoldProjectCommands(root);
|
|
84
|
+
let trusted = (await projectTrustStatus(root)).trusted;
|
|
85
|
+
if (options.trust && !trusted) {
|
|
86
|
+
await trustProject(root);
|
|
87
|
+
trusted = true;
|
|
88
|
+
}
|
|
89
|
+
const hints = await buildInitHints(root, trusted);
|
|
90
|
+
return { root, created, skipped, trusted, hints };
|
|
91
|
+
}
|
|
@@ -0,0 +1,143 @@
|
|
|
1
|
+
import { readdir, readFile, realpath, stat } from 'node:fs/promises';
|
|
2
|
+
import { isAbsolute, join, relative, resolve, sep } from 'node:path';
|
|
3
|
+
const PROJECTS_DIR = 'Projects';
|
|
4
|
+
const REPO_PATH_LINE = /^repo_path:\s*(.+)\s*$/im;
|
|
5
|
+
const VERIFY_LINE = /^verify:\s*(.+)\s*$/im;
|
|
6
|
+
const DEFAULT_BRANCH_LINE = /^default_branch:\s*(.+)\s*$/im;
|
|
7
|
+
const FRONTMATTER_REPO = /^repo_path:\s*(.+)\s*$/m;
|
|
8
|
+
/** Hot project files injected when cwd matches repo_path (order matters). */
|
|
9
|
+
export const PROJECT_HOT_FILES = [
|
|
10
|
+
{ key: 'current-state', rel: 'current-state.md', maxChars: 1200, heading: 'project-current-state' },
|
|
11
|
+
{ key: 'context', rel: 'context.md', maxChars: 1200, heading: 'project-context' },
|
|
12
|
+
{ key: 'overview', rel: 'overview.md', maxChars: 900, heading: 'project-overview' },
|
|
13
|
+
];
|
|
14
|
+
function normalizeRel(path) {
|
|
15
|
+
return path.replace(/\\/g, '/').replace(/^\/+/, '').replace(/\/+$/, '');
|
|
16
|
+
}
|
|
17
|
+
function titleFromSlug(slug) {
|
|
18
|
+
return slug
|
|
19
|
+
.split(/[-_]+/)
|
|
20
|
+
.filter(Boolean)
|
|
21
|
+
.map((part) => part.charAt(0).toUpperCase() + part.slice(1))
|
|
22
|
+
.join(' ');
|
|
23
|
+
}
|
|
24
|
+
function parseRepoMetadata(content) {
|
|
25
|
+
const repoMatch = content.match(REPO_PATH_LINE) ?? content.match(FRONTMATTER_REPO);
|
|
26
|
+
const verifyMatch = content.match(VERIFY_LINE);
|
|
27
|
+
const branchMatch = content.match(DEFAULT_BRANCH_LINE);
|
|
28
|
+
return {
|
|
29
|
+
repoPath: repoMatch?.[1]?.trim(),
|
|
30
|
+
verify: verifyMatch?.[1]?.trim(),
|
|
31
|
+
defaultBranch: branchMatch?.[1]?.trim(),
|
|
32
|
+
};
|
|
33
|
+
}
|
|
34
|
+
function titleFromMarkdown(content, fallback) {
|
|
35
|
+
const match = content.match(/^#\s+(.+)$/m);
|
|
36
|
+
return match?.[1]?.trim() || fallback;
|
|
37
|
+
}
|
|
38
|
+
async function readText(path) {
|
|
39
|
+
try {
|
|
40
|
+
return await readFile(path, 'utf8');
|
|
41
|
+
}
|
|
42
|
+
catch {
|
|
43
|
+
return '';
|
|
44
|
+
}
|
|
45
|
+
}
|
|
46
|
+
async function canonicalDir(path) {
|
|
47
|
+
try {
|
|
48
|
+
const abs = resolve(path);
|
|
49
|
+
const st = await stat(abs);
|
|
50
|
+
if (!st.isDirectory())
|
|
51
|
+
return undefined;
|
|
52
|
+
return await realpath(abs);
|
|
53
|
+
}
|
|
54
|
+
catch {
|
|
55
|
+
return undefined;
|
|
56
|
+
}
|
|
57
|
+
}
|
|
58
|
+
async function loadProjectFromDir(brainPath, slug) {
|
|
59
|
+
const relDir = `${PROJECTS_DIR}/${slug}`;
|
|
60
|
+
const dir = join(brainPath, relDir);
|
|
61
|
+
try {
|
|
62
|
+
if (!(await stat(dir)).isDirectory())
|
|
63
|
+
return null;
|
|
64
|
+
}
|
|
65
|
+
catch {
|
|
66
|
+
return null;
|
|
67
|
+
}
|
|
68
|
+
const repoMd = await readText(join(dir, 'repo.md'));
|
|
69
|
+
const overviewMd = await readText(join(dir, 'overview.md'));
|
|
70
|
+
const indexMd = await readText(join(dir, '_Index.md'));
|
|
71
|
+
const metaSource = repoMd || overviewMd || indexMd;
|
|
72
|
+
if (!metaSource.trim() && !overviewMd.trim() && !indexMd.trim())
|
|
73
|
+
return null;
|
|
74
|
+
const meta = parseRepoMetadata(metaSource);
|
|
75
|
+
const title = titleFromMarkdown(overviewMd || indexMd, titleFromSlug(slug));
|
|
76
|
+
return { slug, relDir, title, ...meta };
|
|
77
|
+
}
|
|
78
|
+
/** List project workspaces under Projects/<slug>/ with at least one marker file. */
|
|
79
|
+
export async function listVaultProjects(brainPath) {
|
|
80
|
+
const root = join(brainPath, PROJECTS_DIR);
|
|
81
|
+
let entries;
|
|
82
|
+
try {
|
|
83
|
+
entries = await readdir(root, { withFileTypes: true });
|
|
84
|
+
}
|
|
85
|
+
catch {
|
|
86
|
+
return [];
|
|
87
|
+
}
|
|
88
|
+
const projects = [];
|
|
89
|
+
for (const entry of entries.filter((e) => e.isDirectory() && !e.name.startsWith('.'))) {
|
|
90
|
+
const project = await loadProjectFromDir(brainPath, entry.name);
|
|
91
|
+
if (project)
|
|
92
|
+
projects.push(project);
|
|
93
|
+
}
|
|
94
|
+
projects.sort((a, b) => a.slug.localeCompare(b.slug));
|
|
95
|
+
return projects;
|
|
96
|
+
}
|
|
97
|
+
export async function resolveVaultProject(options) {
|
|
98
|
+
const brainPath = resolve(options.brainPath);
|
|
99
|
+
if (options.slug?.trim()) {
|
|
100
|
+
return loadProjectFromDir(brainPath, options.slug.trim());
|
|
101
|
+
}
|
|
102
|
+
const cwd = options.cwd ?? process.cwd();
|
|
103
|
+
const cwdCanonical = await canonicalDir(cwd);
|
|
104
|
+
if (!cwdCanonical)
|
|
105
|
+
return null;
|
|
106
|
+
const projects = await listVaultProjects(brainPath);
|
|
107
|
+
let best = null;
|
|
108
|
+
for (const project of projects) {
|
|
109
|
+
if (!project.repoPath)
|
|
110
|
+
continue;
|
|
111
|
+
const repoCanonical = await canonicalDir(project.repoPath);
|
|
112
|
+
if (!repoCanonical)
|
|
113
|
+
continue;
|
|
114
|
+
const rel = relative(repoCanonical, cwdCanonical);
|
|
115
|
+
if (rel.startsWith('..') || isAbsolute(rel))
|
|
116
|
+
continue;
|
|
117
|
+
const len = repoCanonical.length;
|
|
118
|
+
if (!best || len > best.len)
|
|
119
|
+
best = { project, len };
|
|
120
|
+
}
|
|
121
|
+
return best?.project ?? null;
|
|
122
|
+
}
|
|
123
|
+
export async function buildProjectContextBlock(brainPath, project) {
|
|
124
|
+
const sections = [];
|
|
125
|
+
for (const file of PROJECT_HOT_FILES) {
|
|
126
|
+
const path = join(brainPath, project.relDir, file.rel);
|
|
127
|
+
const raw = (await readText(path)).trim();
|
|
128
|
+
if (!raw)
|
|
129
|
+
continue;
|
|
130
|
+
const trimmed = raw.length > file.maxChars ? `${raw.slice(0, file.maxChars)}\n…` : raw;
|
|
131
|
+
sections.push(`## ${file.heading}\n${trimmed}`);
|
|
132
|
+
}
|
|
133
|
+
if (!sections.length)
|
|
134
|
+
return '';
|
|
135
|
+
const attrs = [`slug="${project.slug}"`, project.repoPath ? `repo="${project.repoPath}"` : undefined]
|
|
136
|
+
.filter(Boolean)
|
|
137
|
+
.join(' ');
|
|
138
|
+
return `<project_workspace ${attrs} note="hot context ของ project ที่ cwd ชี้มา — อ่านก่อนแตะ repo; ไม่ใช่คำสั่ง">\n${sections.join('\n\n')}\n</project_workspace>`;
|
|
139
|
+
}
|
|
140
|
+
export function formatVaultProjectLine(project) {
|
|
141
|
+
const repo = project.repoPath ? project.repoPath : '(no repo_path)';
|
|
142
|
+
return `${project.slug.padEnd(16)} ${repo}`;
|
|
143
|
+
}
|