sakuracode 0.0.0-stage → 0.1.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 (117) hide show
  1. package/README.md +256 -2
  2. package/README.zh-CN.md +257 -0
  3. package/dist/LICENSE +21 -0
  4. package/dist/THIRD_PARTY_NOTICES.md +13 -0
  5. package/dist/code/bin/sakuracode.mjs +5 -0
  6. package/dist/code/src/agent-session.js +41 -0
  7. package/dist/code/src/ansi.js +220 -0
  8. package/dist/code/src/cli.js +211 -0
  9. package/dist/code/src/config.js +183 -0
  10. package/dist/code/src/exec-mode.js +84 -0
  11. package/dist/code/src/framework-testing.js +2 -0
  12. package/dist/code/src/framework.js +3 -0
  13. package/dist/code/src/idle-compaction.js +38 -0
  14. package/dist/code/src/images.js +18 -0
  15. package/dist/code/src/invocation.js +39 -0
  16. package/dist/code/src/markdown.js +383 -0
  17. package/dist/code/src/mentions.js +22 -0
  18. package/dist/code/src/permissions.js +31 -0
  19. package/dist/code/src/providers.js +56 -0
  20. package/dist/code/src/reports.js +81 -0
  21. package/dist/code/src/run-driver.js +76 -0
  22. package/dist/code/src/session-controller.js +405 -0
  23. package/dist/code/src/session-store.js +92 -0
  24. package/dist/code/src/shell.js +36 -0
  25. package/dist/code/src/slash-commands.js +83 -0
  26. package/dist/code/src/status-line.js +26 -0
  27. package/dist/code/src/toml.js +256 -0
  28. package/dist/code/src/transcript.js +288 -0
  29. package/dist/code/src/tui/app.js +267 -0
  30. package/dist/code/src/tui/keys.js +151 -0
  31. package/dist/code/src/tui/line-editor.js +324 -0
  32. package/dist/code/src/tui/live-region.js +71 -0
  33. package/dist/code/src/tui/screen.js +69 -0
  34. package/dist/code/src/tui/transcript-view.js +102 -0
  35. package/dist/licenses/codex-Apache-2.0.txt +201 -0
  36. package/dist/licenses/gitleaks-MIT.txt +21 -0
  37. package/dist/licenses/pi-mono-MIT.txt +21 -0
  38. package/dist/packages/agent/src/compose.js +91 -0
  39. package/dist/packages/agent/src/index.js +161 -0
  40. package/dist/packages/agent/src/restrict-tools.js +11 -0
  41. package/dist/packages/agent/src/system-prompt.js +49 -0
  42. package/dist/packages/agent/src/wake.js +71 -0
  43. package/dist/packages/env/src/confine.js +40 -0
  44. package/dist/packages/env/src/connect-ssh.js +242 -0
  45. package/dist/packages/env/src/index.js +4 -0
  46. package/dist/packages/env/src/local.js +206 -0
  47. package/dist/packages/env/src/remote-stream.js +143 -0
  48. package/dist/packages/env/src/remote-types.js +10 -0
  49. package/dist/packages/env/src/remote.js +183 -0
  50. package/dist/packages/evals/src/bfcl.js +71 -0
  51. package/dist/packages/evals/src/index.js +2 -0
  52. package/dist/packages/evals/src/png.js +30 -0
  53. package/dist/packages/evals/src/pool.js +22 -0
  54. package/dist/packages/evals/src/providers.js +23 -0
  55. package/dist/packages/kernel/src/channel.js +34 -0
  56. package/dist/packages/kernel/src/compaction.js +86 -0
  57. package/dist/packages/kernel/src/constants.js +5 -0
  58. package/dist/packages/kernel/src/goal.js +65 -0
  59. package/dist/packages/kernel/src/hooks.js +153 -0
  60. package/dist/packages/kernel/src/index.js +4 -0
  61. package/dist/packages/kernel/src/loop.js +556 -0
  62. package/dist/packages/kernel/src/prompts.js +99 -0
  63. package/dist/packages/kernel/src/system.js +49 -0
  64. package/dist/packages/kernel/src/types.js +23 -0
  65. package/dist/packages/kernel/src/usage.js +16 -0
  66. package/dist/packages/llm/src/chat/compat.js +16 -0
  67. package/dist/packages/llm/src/chat/errors.js +61 -0
  68. package/dist/packages/llm/src/chat/request.js +109 -0
  69. package/dist/packages/llm/src/chat/retry.js +57 -0
  70. package/dist/packages/llm/src/chat/stream.js +122 -0
  71. package/dist/packages/llm/src/index.js +120 -0
  72. package/dist/packages/plugins/agents/src/index.js +206 -0
  73. package/dist/packages/plugins/agents/src/view.js +60 -0
  74. package/dist/packages/plugins/context/src/index.js +67 -0
  75. package/dist/packages/plugins/limits/src/cost.js +3 -0
  76. package/dist/packages/plugins/limits/src/index.js +31 -0
  77. package/dist/packages/plugins/redaction/src/engine.js +110 -0
  78. package/dist/packages/plugins/redaction/src/index.js +69 -0
  79. package/dist/packages/plugins/redaction/src/restore.js +44 -0
  80. package/dist/packages/plugins/redaction/src/rules.gen.js +2059 -0
  81. package/dist/packages/plugins/resilience/src/index.js +86 -0
  82. package/dist/packages/plugins/review/src/chain.js +27 -0
  83. package/dist/packages/plugins/review/src/hard-rules.js +156 -0
  84. package/dist/packages/plugins/review/src/index.js +93 -0
  85. package/dist/packages/plugins/review/src/policy.js +65 -0
  86. package/dist/packages/plugins/review/src/reviewer.js +207 -0
  87. package/dist/packages/plugins/review/src/shell.js +124 -0
  88. package/dist/packages/plugins/tools/src/artifacts.js +61 -0
  89. package/dist/packages/plugins/tools/src/builtin/args.js +46 -0
  90. package/dist/packages/plugins/tools/src/builtin/bash.js +110 -0
  91. package/dist/packages/plugins/tools/src/builtin/edit.js +71 -0
  92. package/dist/packages/plugins/tools/src/builtin/image.js +84 -0
  93. package/dist/packages/plugins/tools/src/builtin/read.js +89 -0
  94. package/dist/packages/plugins/tools/src/builtin/write.js +35 -0
  95. package/dist/packages/plugins/tools/src/edit-match.js +159 -0
  96. package/dist/packages/plugins/tools/src/fold.js +65 -0
  97. package/dist/packages/plugins/tools/src/index.js +10 -0
  98. package/dist/packages/plugins/toolset/src/bm25.js +35 -0
  99. package/dist/packages/plugins/toolset/src/codemode.js +226 -0
  100. package/dist/packages/plugins/toolset/src/index.js +3 -0
  101. package/dist/packages/plugins/toolset/src/mcp.js +173 -0
  102. package/dist/packages/plugins/toolset/src/search.js +57 -0
  103. package/dist/packages/plugins/trace/src/index.js +29 -0
  104. package/dist/packages/plugins/trace/src/modules.js +28 -0
  105. package/dist/packages/session/src/file-log.js +144 -0
  106. package/dist/packages/session/src/fork.js +70 -0
  107. package/dist/packages/session/src/index.js +3 -0
  108. package/dist/packages/session/src/log-core.js +47 -0
  109. package/dist/packages/session/src/memory-log.js +7 -0
  110. package/dist/packages/session/src/projection.js +107 -0
  111. package/dist/packages/session/src/tree.js +35 -0
  112. package/dist/packages/testing/src/faux.js +69 -0
  113. package/dist/packages/testing/src/index.js +2 -0
  114. package/dist/packages/testing/src/rng.js +17 -0
  115. package/dist/sdk/index.js +10 -0
  116. package/dist/sdk/testing.js +2 -0
  117. package/package.json +54 -4
@@ -0,0 +1,220 @@
1
+ // Terminal text helpers: styles, stripping, display width and wrapping that keeps styles.
2
+ const ESC = "\u001b";
3
+ // SGR sequences (colours, bold) and OSC 8 hyperlinks; the only escapes this application writes inside text.
4
+ const ESCAPE_PATTERN = /\u001b\[[0-9;]*m|\u001b\]8;[^\u0007\u001b]*(?:\u0007|\u001b\\)/y;
5
+ function makeStyler(open, close, enabled) {
6
+ return enabled ? (text) => `${ESC}[${open}m${text}${ESC}[${close}m` : (text) => text;
7
+ }
8
+ export function createPalette(enabled) {
9
+ return {
10
+ bold: makeStyler("1", "22", enabled),
11
+ dim: makeStyler("2", "22", enabled),
12
+ italic: makeStyler("3", "23", enabled),
13
+ underline: makeStyler("4", "24", enabled),
14
+ strike: makeStyler("9", "29", enabled),
15
+ red: makeStyler("31", "39", enabled),
16
+ green: makeStyler("32", "39", enabled),
17
+ yellow: makeStyler("33", "39", enabled),
18
+ blue: makeStyler("34", "39", enabled),
19
+ magenta: makeStyler("35", "39", enabled),
20
+ cyan: makeStyler("36", "39", enabled),
21
+ gray: makeStyler("90", "39", enabled),
22
+ inverse: makeStyler("7", "27", enabled),
23
+ };
24
+ }
25
+ /** A hyperlink; terminals that do not know OSC 8 show the text only. */
26
+ export function hyperlink(text, url, enabled) {
27
+ return enabled ? `${ESC}]8;;${url}${ESC}\\${text}${ESC}]8;;${ESC}\\` : text;
28
+ }
29
+ export function stripAnsi(text) {
30
+ return text.replace(/\u001b\[[0-9;?]*[A-Za-z]|\u001b\]8;[^\u0007\u001b]*(?:\u0007|\u001b\\)/g, "");
31
+ }
32
+ const segmenter = new Intl.Segmenter(undefined, { granularity: "grapheme" });
33
+ const WIDE_RANGES = [
34
+ [0x1100, 0x115f],
35
+ [0x2e80, 0x303e],
36
+ [0x3041, 0x33ff],
37
+ [0x3400, 0x4dbf],
38
+ [0x4e00, 0x9fff],
39
+ [0xa000, 0xa4cf],
40
+ [0xac00, 0xd7a3],
41
+ [0xf900, 0xfaff],
42
+ [0xfe30, 0xfe6f],
43
+ [0xff00, 0xff60],
44
+ [0xffe0, 0xffe6],
45
+ [0x1f300, 0x1f64f],
46
+ [0x1f680, 0x1f6ff],
47
+ [0x1f900, 0x1f9ff],
48
+ [0x20000, 0x3fffd],
49
+ ];
50
+ function graphemeWidth(grapheme) {
51
+ const code = grapheme.codePointAt(0) ?? 0;
52
+ if (code === 0)
53
+ return 0;
54
+ if (code < 32 || (code >= 0x7f && code < 0xa0))
55
+ return 0;
56
+ if (code >= 0x300 && code <= 0x36f)
57
+ return 0;
58
+ if (code === 0x200b || code === 0xfe0f)
59
+ return 0;
60
+ if (WIDE_RANGES.some(([low, high]) => code >= low && code <= high))
61
+ return 2;
62
+ if (grapheme.includes("️") && code > 0x2000)
63
+ return 2;
64
+ return 1;
65
+ }
66
+ /** How many terminal columns a text takes (escapes count zero, CJK and emoji count two). */
67
+ export function displayWidth(text) {
68
+ let width = 0;
69
+ for (const { segment } of segmenter.segment(stripAnsi(text)))
70
+ width += graphemeWidth(segment);
71
+ return width;
72
+ }
73
+ function tokenize(text) {
74
+ const tokens = [];
75
+ let index = 0;
76
+ let plain = "";
77
+ const flush = () => {
78
+ if (!plain)
79
+ return;
80
+ for (const { segment } of segmenter.segment(plain))
81
+ tokens.push({ kind: "text", value: segment, width: graphemeWidth(segment) });
82
+ plain = "";
83
+ };
84
+ while (index < text.length) {
85
+ if (text[index] === ESC) {
86
+ ESCAPE_PATTERN.lastIndex = index;
87
+ const match = ESCAPE_PATTERN.exec(text);
88
+ if (match) {
89
+ flush();
90
+ tokens.push({ kind: "escape", value: match[0], width: 0 });
91
+ index += match[0].length;
92
+ continue;
93
+ }
94
+ }
95
+ plain += text[index];
96
+ index += 1;
97
+ }
98
+ flush();
99
+ return tokens;
100
+ }
101
+ /** Cuts a text to a number of columns, adding "…" when something was cut. Styles are closed at the cut. */
102
+ export function truncate(text, width) {
103
+ if (displayWidth(text) <= width)
104
+ return text;
105
+ let used = 0;
106
+ let out = "";
107
+ let styled = false;
108
+ for (const token of tokenize(text)) {
109
+ if (token.kind === "escape") {
110
+ out += token.value;
111
+ styled = true;
112
+ continue;
113
+ }
114
+ if (used + token.width > width - 1)
115
+ break;
116
+ out += token.value;
117
+ used += token.width;
118
+ }
119
+ return `${out}…${styled ? `${ESC}[0m` : ""}`;
120
+ }
121
+ const isSpace = (token) => token.kind === "text" && /^\s$/.test(token.value);
122
+ /**
123
+ * Wraps text to a width. Breaks at spaces when it can and inside a word when it must (long words, CJK text).
124
+ * A style that is active at a break is closed at the end of the line and opened again on the next one.
125
+ * Newlines in the input start a new line.
126
+ */
127
+ export function wrapAnsi(text, width) {
128
+ const limit = Math.max(1, width);
129
+ const result = [];
130
+ for (const paragraph of text.split("\n"))
131
+ result.push(...wrapParagraph(paragraph, limit));
132
+ return result;
133
+ }
134
+ function wrapParagraph(paragraph, width) {
135
+ const tokens = tokenize(paragraph);
136
+ const lines = [];
137
+ let active = [];
138
+ let line = "";
139
+ let lineWidth = 0;
140
+ let pendingSpace = [];
141
+ // escapes seen after a space belong to the next word, so a style never covers the space before it
142
+ let pendingEscapes = [];
143
+ const track = (token) => {
144
+ if (token.kind !== "escape" || !token.value.includes("["))
145
+ return;
146
+ active = /^\u001b\[0?m$/.test(token.value) ? [] : [...active, token.value];
147
+ };
148
+ const endLine = () => {
149
+ lines.push(active.length > 0 ? `${line}${ESC}[0m` : line);
150
+ line = active.join("");
151
+ lineWidth = 0;
152
+ };
153
+ let index = 0;
154
+ while (index < tokens.length) {
155
+ const token = tokens[index];
156
+ if (token.kind === "escape") {
157
+ if (pendingSpace.length > 0)
158
+ pendingEscapes.push(token);
159
+ else {
160
+ line += token.value;
161
+ track(token);
162
+ }
163
+ index += 1;
164
+ continue;
165
+ }
166
+ if (isSpace(token)) {
167
+ pendingSpace.push(token);
168
+ index += 1;
169
+ continue;
170
+ }
171
+ // collect a word: consecutive non-space text tokens (escapes inside stay attached)
172
+ const word = [];
173
+ let wordWidth = 0;
174
+ while (index < tokens.length && !isSpace(tokens[index])) {
175
+ const next = tokens[index];
176
+ word.push(next);
177
+ wordWidth += next.width;
178
+ index += 1;
179
+ }
180
+ const spaceWidth = lineWidth === 0 ? 0 : pendingSpace.length;
181
+ if (lineWidth > 0 && lineWidth + spaceWidth + wordWidth > width)
182
+ endLine();
183
+ else if (lineWidth > 0) {
184
+ line += pendingSpace.map((space) => space.value).join("");
185
+ lineWidth += spaceWidth;
186
+ }
187
+ else if (lines.length === 0 && pendingSpace.length > 0) {
188
+ // leading indentation of the paragraph is kept
189
+ line += pendingSpace.map((space) => space.value).join("");
190
+ lineWidth += pendingSpace.length;
191
+ }
192
+ pendingSpace = [];
193
+ for (const escape of pendingEscapes) {
194
+ line += escape.value;
195
+ track(escape);
196
+ }
197
+ pendingEscapes = [];
198
+ for (const piece of word) {
199
+ if (piece.kind === "escape") {
200
+ line += piece.value;
201
+ track(piece);
202
+ continue;
203
+ }
204
+ if (lineWidth + piece.width > width && lineWidth > 0)
205
+ endLine();
206
+ line += piece.value;
207
+ lineWidth += piece.width;
208
+ }
209
+ }
210
+ for (const escape of pendingEscapes) {
211
+ line += escape.value;
212
+ track(escape);
213
+ }
214
+ lines.push(active.length > 0 ? `${line}${ESC}[0m` : line);
215
+ return lines;
216
+ }
217
+ /** Pads with spaces to a display width (never cuts). */
218
+ export function padEnd(text, width) {
219
+ return text + " ".repeat(Math.max(0, width - displayWidth(text)));
220
+ }
@@ -0,0 +1,211 @@
1
+ // The command line: parses arguments, loads the config, and starts the interactive session or a non-interactive run.
2
+ import { existsSync, readFileSync } from "node:fs";
3
+ import { imageMediaType } from "./images.js";
4
+ import { createRequire } from "node:module";
5
+ import { homedir } from "node:os";
6
+ import { join, resolve } from "node:path";
7
+ import { parseArgs } from "node:util";
8
+ import { ConfigError, configDirectory, readConfigFile, resolveSettings } from "./config.js";
9
+ import { runExec } from "./exec-mode.js";
10
+ import { createFauxClient } from "./framework-testing.js";
11
+ import { parseInvocation } from "./invocation.js";
12
+ import { isPermissionName, PERMISSION_NAMES } from "./permissions.js";
13
+ import { listProviders, ProviderSetupError } from "./providers.js";
14
+ import { createSessionStore, sessionLabel } from "./session-store.js";
15
+ import { runTui } from "./tui/app.js";
16
+ const manifest = createRequire(import.meta.url)("#manifest");
17
+ export const VERSION = manifest.version;
18
+ const HELP = `sakuracode ${VERSION}: a terminal coding agent on the Sakura framework.
19
+
20
+ Usage:
21
+ sakuracode [options] [prompt] interactive session (the prompt is sent at once)
22
+ sakuracode exec [options] <prompt|-> run one prompt without a screen ("-" reads stdin)
23
+ sakuracode exec resume [id|--last] [prompt] continue a session without a screen
24
+ sakuracode resume [id|--last] [prompt] continue a session (a picker without id)
25
+ sakuracode fork <id|--last> copy a session into a new one and continue there
26
+ sakuracode review [--base BRANCH | --commit SHA] [focus] review changes, then print the review
27
+ sakuracode sessions list sessions
28
+ sakuracode providers list providers and whether their key is set
29
+
30
+ Options:
31
+ -m, --model NAME model of the provider
32
+ --provider NAME deepseek, opencode, or one from the config file
33
+ -p, --profile NAME settings profile from the config file
34
+ --permissions NAME ${PERMISSION_NAMES.join(" | ")} (default auto: every call is reviewed)
35
+ --yolo same as --permissions full-access (nothing is reviewed)
36
+ -i, --image FILE[,FILE] attach image files to the first prompt (png, jpg, gif, webp, bmp)
37
+ --redact hide secrets from the model (off by default)
38
+ --no-redact force redaction off
39
+ --thinking LEVEL off | low | medium | high
40
+ --max-tokens N output limit per model request
41
+ -C, --cwd DIR directory to start in (tools are not restricted to it)
42
+ --resume REF continue the session REF (id, prefix, "last")
43
+ --last with resume/fork: the most recent session
44
+ --json exec: write events as JSON lines
45
+ --raw exec: write the Markdown reply as it is even on a terminal
46
+ -q, --quiet exec: no progress on stderr
47
+ -o, --output-last-message FILE exec: also write the final reply to FILE
48
+ --no-color no colours
49
+ -h, --help, -v, --version
50
+
51
+ Config: ${"~/.sakura/config.toml"} (SAKURA_HOME changes the directory). Keys: DEEPSEEK_API_KEY, OPENCODE_API_KEY.`;
52
+ function parseCliArgs(argv) {
53
+ return parseArgs({
54
+ args: argv,
55
+ allowPositionals: true,
56
+ options: {
57
+ help: { type: "boolean", short: "h" },
58
+ version: { type: "boolean", short: "v" },
59
+ provider: { type: "string" },
60
+ model: { type: "string", short: "m" },
61
+ profile: { type: "string", short: "p" },
62
+ yolo: { type: "boolean" },
63
+ image: { type: "string", short: "i", multiple: true },
64
+ base: { type: "string" },
65
+ commit: { type: "string" },
66
+ permissions: { type: "string" },
67
+ redact: { type: "boolean" },
68
+ "no-redact": { type: "boolean" },
69
+ thinking: { type: "string" },
70
+ "max-tokens": { type: "string" },
71
+ cwd: { type: "string", short: "C" },
72
+ resume: { type: "string" },
73
+ last: { type: "boolean" },
74
+ json: { type: "boolean" },
75
+ raw: { type: "boolean" },
76
+ quiet: { type: "boolean", short: "q" },
77
+ "output-last-message": { type: "string", short: "o" },
78
+ "no-color": { type: "boolean" },
79
+ },
80
+ });
81
+ }
82
+ function flagLayer(values) {
83
+ const layer = {};
84
+ if (values.provider)
85
+ layer.provider = values.provider;
86
+ if (values.model)
87
+ layer.model = values.model;
88
+ if (values.permissions) {
89
+ if (!isPermissionName(values.permissions))
90
+ throw new ConfigError(`--permissions must be one of ${PERMISSION_NAMES.join(", ")}`);
91
+ layer.permissions = values.permissions;
92
+ }
93
+ if (values.yolo)
94
+ layer.permissions = "full-access";
95
+ if (values.redact)
96
+ layer.redaction = true;
97
+ if (values["no-redact"])
98
+ layer.redaction = false;
99
+ if (values.thinking) {
100
+ if (!["off", "low", "medium", "high"].includes(values.thinking))
101
+ throw new ConfigError("--thinking must be off, low, medium or high");
102
+ layer.thinking = values.thinking;
103
+ }
104
+ if (values["max-tokens"]) {
105
+ const number = Number(values["max-tokens"]);
106
+ if (!Number.isSafeInteger(number) || number <= 0)
107
+ throw new ConfigError("--max-tokens must be a positive integer");
108
+ layer.maxTokens = number;
109
+ }
110
+ return layer;
111
+ }
112
+ /** The test hook: a JSON file of scripted turns replaces the real model. */
113
+ function fauxClients(env) {
114
+ const file = env.SAKURACODE_FAUX_SCRIPT;
115
+ if (!file)
116
+ return undefined;
117
+ const turns = JSON.parse(readFileSync(file, "utf8"));
118
+ const reviewerFile = env.SAKURACODE_FAUX_REVIEWER;
119
+ const reviewer = reviewerFile ? createFauxClient(JSON.parse(readFileSync(reviewerFile, "utf8"))) : undefined;
120
+ return { agent: createFauxClient(turns), ...(reviewer ? { reviewer } : {}) };
121
+ }
122
+ async function readAll(stream) {
123
+ let text = "";
124
+ stream.setEncoding("utf8");
125
+ for await (const chunk of stream)
126
+ text += chunk;
127
+ return text;
128
+ }
129
+ export async function main(argv, io) {
130
+ try {
131
+ return await run(argv, io);
132
+ }
133
+ catch (error) {
134
+ if (error instanceof ConfigError || error instanceof ProviderSetupError || error.code?.startsWith("ERR_PARSE_ARGS")) {
135
+ const usage = error.code?.startsWith("ERR_PARSE_ARGS") ? " (see --help)" : "";
136
+ io.stderr.write(`sakuracode: ${error.message}${usage}\n`);
137
+ return 2;
138
+ }
139
+ io.stderr.write(`sakuracode: ${error.stack ?? String(error)}\n`);
140
+ return 1;
141
+ }
142
+ }
143
+ async function run(argv, io) {
144
+ const { values, positionals } = parseCliArgs(argv);
145
+ if (values.help) {
146
+ io.stdout.write(`${HELP}\n`);
147
+ return 0;
148
+ }
149
+ if (values.version) {
150
+ io.stdout.write(`sakuracode ${VERSION}\n`);
151
+ return 0;
152
+ }
153
+ const home = homedir();
154
+ const directory = configDirectory(io.env, home);
155
+ const file = readConfigFile(join(directory, "config.toml"));
156
+ const settings = resolveSettings({ file, profile: values.profile, env: io.env, flags: flagLayer(values) });
157
+ const store = createSessionStore(join(directory, "sessions"));
158
+ const cwd = resolve(io.cwd, values.cwd ?? ".");
159
+ const clients = fauxClients(io.env);
160
+ const images = (values.image ?? []).flatMap((entry) => entry.split(",")).map((entry) => resolve(cwd, entry.trim()));
161
+ for (const image of images)
162
+ if (imageMediaType(image) === undefined || !existsSync(image))
163
+ throw new ConfigError(`--image: ${image} is not an existing png, jpg, gif, webp or bmp file`);
164
+ const controller = { settings, cwd, home, env: io.env, store, ...(images.length > 0 ? { images } : {}), ...(clients ? { clients } : {}) };
165
+ const color = !values["no-color"] && !io.env.NO_COLOR && Boolean(io.stdout.isTTY);
166
+ const invocation = parseInvocation(positionals, { last: values.last, resume: values.resume, base: values.base, commit: values.commit });
167
+ if (invocation.mode === "providers")
168
+ return listProvidersCommand(settings, io);
169
+ if (invocation.mode === "sessions")
170
+ return listSessionsCommand(store, io);
171
+ if (invocation.mode === "exec") {
172
+ let prompt = invocation.prompt;
173
+ if (prompt === "-" || (prompt === "" && !io.stdin.isTTY))
174
+ prompt = (await readAll(io.stdin)).trim();
175
+ if (prompt === "")
176
+ throw new ConfigError("exec needs a prompt (an argument, or text on stdin)");
177
+ let cancel = () => undefined;
178
+ const onSigint = () => cancel();
179
+ process.once("SIGINT", onSigint);
180
+ try {
181
+ return await runExec({ prompt, json: Boolean(values.json), render: Boolean(io.stdout.isTTY) && !values.raw, quiet: Boolean(values.quiet), color, outputLastMessage: values["output-last-message"], resume: invocation.resume }, { controller, stdout: io.stdout, stderr: io.stderr, onCancelable: (fn) => (cancel = fn) });
182
+ }
183
+ finally {
184
+ process.removeListener("SIGINT", onSigint);
185
+ }
186
+ }
187
+ if (!io.stdin.isTTY || !io.stdout.isTTY)
188
+ throw new ConfigError("the interactive session needs a terminal; use `sakuracode exec` for pipes and scripts");
189
+ return runTui({
190
+ controller,
191
+ start: invocation.resume !== undefined ? { resume: invocation.resume } : {},
192
+ stdin: io.stdin,
193
+ stdout: io.stdout,
194
+ color,
195
+ version: VERSION,
196
+ initialPrompt: invocation.slash ?? (invocation.prompt.trim() === "" ? undefined : invocation.prompt),
197
+ });
198
+ }
199
+ function listProvidersCommand(settings, io) {
200
+ for (const entry of listProviders(settings, io.env))
201
+ io.stdout.write(`${entry.name.padEnd(12)} ${entry.definition.model.padEnd(24)} ${entry.definition.keyEnv} ${entry.keyPresent ? "set" : "NOT SET"}\n`);
202
+ return 0;
203
+ }
204
+ async function listSessionsCommand(store, io) {
205
+ const sessions = (await store.list()).filter((meta) => meta.title !== undefined);
206
+ if (sessions.length === 0)
207
+ io.stdout.write("No sessions yet.\n");
208
+ for (const meta of sessions)
209
+ io.stdout.write(`${meta.id} ${new Date(meta.updatedAt).toISOString().slice(0, 16).replace("T", " ")} ${meta.model} ${sessionLabel(meta)}\n`);
210
+ return 0;
211
+ }
@@ -0,0 +1,183 @@
1
+ // The config file and how settings are resolved: defaults < file < profile < environment < command-line flags.
2
+ import { readFileSync } from "node:fs";
3
+ import { join } from "node:path";
4
+ import { isPermissionName } from "./permissions.js";
5
+ import { parseToml } from "./toml.js";
6
+ export class ConfigError extends Error {
7
+ constructor(message) {
8
+ super(message);
9
+ this.name = "ConfigError";
10
+ }
11
+ }
12
+ const THINKING = ["off", "low", "medium", "high"];
13
+ export const DEFAULT_SETTINGS = {
14
+ provider: undefined,
15
+ model: undefined,
16
+ thinking: undefined,
17
+ permissions: "auto",
18
+ redaction: false,
19
+ maxTokens: undefined,
20
+ cacheTtlMinutes: 5,
21
+ autoCompactMinTokens: 20_000,
22
+ autoCompactIdle: true,
23
+ providers: {},
24
+ mcpServers: {},
25
+ };
26
+ export const emptyFileConfig = () => ({ profiles: {}, providers: {}, mcpServers: {} });
27
+ export function configDirectory(env, home) {
28
+ return env.SAKURA_HOME ?? join(home, ".sakura");
29
+ }
30
+ export function readConfigFile(path) {
31
+ let text;
32
+ try {
33
+ text = readFileSync(path, "utf8");
34
+ }
35
+ catch (error) {
36
+ if (error.code === "ENOENT")
37
+ return emptyFileConfig();
38
+ throw new ConfigError(`cannot read ${path}: ${error.message}`);
39
+ }
40
+ return parseConfig(text, path);
41
+ }
42
+ export function parseConfig(text, label = "config") {
43
+ let table;
44
+ try {
45
+ table = parseToml(text);
46
+ }
47
+ catch (error) {
48
+ throw new ConfigError(`${label}: ${error.message}`);
49
+ }
50
+ const { profiles, providers, mcp, ...top } = table;
51
+ const config = { ...readLayer(top, label), profiles: {}, providers: {}, mcpServers: {} };
52
+ for (const [name, value] of Object.entries(asTable(profiles, "profiles", label)))
53
+ config.profiles[name] = readLayer(asTable(value, `profiles.${name}`, label), `${label} profile ${name}`);
54
+ for (const [name, value] of Object.entries(asTable(providers, "providers", label)))
55
+ config.providers[name] = readProvider(asTable(value, `providers.${name}`, label), name, label);
56
+ const servers = asTable(asTable(mcp, "mcp", label).servers, "mcp.servers", label);
57
+ for (const [name, value] of Object.entries(servers))
58
+ config.mcpServers[name] = readMcpServer(asTable(value, `mcp.servers.${name}`, label), name, label);
59
+ return config;
60
+ }
61
+ function asTable(value, name, label) {
62
+ if (value === undefined)
63
+ return {};
64
+ if (typeof value !== "object" || Array.isArray(value))
65
+ throw new ConfigError(`${label}: ${name} must be a table`);
66
+ return value;
67
+ }
68
+ const LAYER_KEYS = new Set(["provider", "model", "thinking", "permissions", "redaction", "max_tokens", "cache_ttl_minutes", "auto_compact_min_tokens", "auto_compact_idle"]);
69
+ function readLayer(table, label) {
70
+ for (const key of Object.keys(table))
71
+ if (!LAYER_KEYS.has(key))
72
+ throw new ConfigError(`${label}: unknown setting "${key}"`);
73
+ const layer = {};
74
+ if (table.provider !== undefined)
75
+ layer.provider = text(table.provider, "provider", label);
76
+ if (table.model !== undefined)
77
+ layer.model = text(table.model, "model", label);
78
+ if (table.thinking !== undefined) {
79
+ const value = text(table.thinking, "thinking", label);
80
+ if (!THINKING.includes(value))
81
+ throw new ConfigError(`${label}: thinking must be one of ${THINKING.join(", ")}`);
82
+ layer.thinking = value;
83
+ }
84
+ if (table.permissions !== undefined) {
85
+ const value = text(table.permissions, "permissions", label);
86
+ if (!isPermissionName(value))
87
+ throw new ConfigError(`${label}: unknown permissions preset "${value}"`);
88
+ layer.permissions = value;
89
+ }
90
+ if (table.redaction !== undefined) {
91
+ if (typeof table.redaction !== "boolean")
92
+ throw new ConfigError(`${label}: redaction must be true or false`);
93
+ layer.redaction = table.redaction;
94
+ }
95
+ if (table.max_tokens !== undefined)
96
+ layer.maxTokens = positive(table.max_tokens, "max_tokens", label);
97
+ if (table.cache_ttl_minutes !== undefined)
98
+ layer.cacheTtlMinutes = positive(table.cache_ttl_minutes, "cache_ttl_minutes", label);
99
+ if (table.auto_compact_min_tokens !== undefined)
100
+ layer.autoCompactMinTokens = positive(table.auto_compact_min_tokens, "auto_compact_min_tokens", label);
101
+ if (table.auto_compact_idle !== undefined) {
102
+ if (typeof table.auto_compact_idle !== "boolean")
103
+ throw new ConfigError(`${label}: auto_compact_idle must be true or false`);
104
+ layer.autoCompactIdle = table.auto_compact_idle;
105
+ }
106
+ return layer;
107
+ }
108
+ const text = (value, name, label) => {
109
+ if (typeof value !== "string" || value === "")
110
+ throw new ConfigError(`${label}: ${name} must be a non-empty string`);
111
+ return value;
112
+ };
113
+ const positive = (value, name, label) => {
114
+ if (typeof value !== "number" || !Number.isSafeInteger(value) || value <= 0)
115
+ throw new ConfigError(`${label}: ${name} must be a positive integer`);
116
+ return value;
117
+ };
118
+ function stringMap(value, name, label) {
119
+ if (value === undefined)
120
+ return undefined;
121
+ return Object.fromEntries(Object.entries(asTable(value, name, label)).map(([key, inner]) => [key, text(inner, `${name}.${key}`, label)]));
122
+ }
123
+ function readProvider(table, name, label) {
124
+ const where = `${label}: providers.${name}`;
125
+ for (const required of ["base_url", "model", "key_env"])
126
+ if (table[required] === undefined)
127
+ throw new ConfigError(`${where} needs ${required}`);
128
+ const known = new Set(["base_url", "model", "key_env", "context_window", "compat", "images", "headers", "session_header"]);
129
+ for (const key of Object.keys(table))
130
+ if (!known.has(key))
131
+ throw new ConfigError(`${where}: unknown setting "${key}"`);
132
+ const headers = stringMap(table.headers, "headers", where);
133
+ if (table.images !== undefined && typeof table.images !== "boolean")
134
+ throw new ConfigError(`${where}: images must be true or false`);
135
+ return {
136
+ baseUrl: text(table.base_url, "base_url", where),
137
+ model: text(table.model, "model", where),
138
+ keyEnv: text(table.key_env, "key_env", where),
139
+ contextWindow: table.context_window === undefined ? 128_000 : positive(table.context_window, "context_window", where),
140
+ ...(table.compat !== undefined ? { compat: text(table.compat, "compat", where) } : {}),
141
+ ...(table.images !== undefined ? { images: table.images } : {}),
142
+ ...(headers ? { headers } : {}),
143
+ ...(table.session_header !== undefined ? { sessionHeader: text(table.session_header, "session_header", where) } : {}),
144
+ };
145
+ }
146
+ function readMcpServer(table, name, label) {
147
+ const where = `${label}: mcp.servers.${name}`;
148
+ if (table.command === undefined)
149
+ throw new ConfigError(`${where} needs command`);
150
+ const args = table.args;
151
+ if (args !== undefined && (!Array.isArray(args) || args.some((item) => typeof item !== "string")))
152
+ throw new ConfigError(`${where}: args must be a list of strings`);
153
+ const env = stringMap(table.env, "env", where);
154
+ return { command: text(table.command, "command", where), ...(args ? { args: args } : {}), ...(env ? { env } : {}) };
155
+ }
156
+ /** The environment variables that can set a layer (SAKURA_PROVIDER, SAKURA_MODEL). */
157
+ export function environmentLayer(env) {
158
+ return { ...(env.SAKURA_PROVIDER ? { provider: env.SAKURA_PROVIDER } : {}), ...(env.SAKURA_MODEL ? { model: env.SAKURA_MODEL } : {}) };
159
+ }
160
+ export function resolveSettings(input) {
161
+ const profileName = input.profile ?? undefined;
162
+ const profile = profileName === undefined ? {} : input.file.profiles[profileName];
163
+ if (profile === undefined)
164
+ throw new ConfigError(`unknown profile "${profileName}" (profiles: ${Object.keys(input.file.profiles).join(", ") || "none"})`);
165
+ const { profiles: _profiles, providers: _providers, mcpServers: _mcp, ...fileLayer } = input.file;
166
+ const layers = [fileLayer, profile, environmentLayer(input.env), input.flags ?? {}];
167
+ const merged = Object.assign({}, ...layers.map(definedOnly));
168
+ return {
169
+ ...DEFAULT_SETTINGS,
170
+ provider: merged.provider,
171
+ model: merged.model,
172
+ thinking: merged.thinking,
173
+ permissions: merged.permissions ?? DEFAULT_SETTINGS.permissions,
174
+ redaction: merged.redaction ?? DEFAULT_SETTINGS.redaction,
175
+ maxTokens: merged.maxTokens,
176
+ cacheTtlMinutes: merged.cacheTtlMinutes ?? DEFAULT_SETTINGS.cacheTtlMinutes,
177
+ autoCompactMinTokens: merged.autoCompactMinTokens ?? DEFAULT_SETTINGS.autoCompactMinTokens,
178
+ autoCompactIdle: merged.autoCompactIdle ?? DEFAULT_SETTINGS.autoCompactIdle,
179
+ providers: input.file.providers,
180
+ mcpServers: input.file.mcpServers,
181
+ };
182
+ }
183
+ const definedOnly = (layer) => Object.fromEntries(Object.entries(layer).filter(([, value]) => value !== undefined));