minimal-agent 0.1.9 → 0.3.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 (97) hide show
  1. package/README.md +383 -122
  2. package/package.json +19 -12
  3. package/plugins/HOW-TO-WRITE-A-PLUGIN.md +186 -0
  4. package/plugins/ralph-wiggum/commands/ralph-loop.md +6 -16
  5. package/plugins/ralph-wiggum/plugin.js +205 -0
  6. package/plugins/ralph-wiggum/src/goalState.js +260 -0
  7. package/plugins/ralph-wiggum/src/sentinels.js +21 -0
  8. package/plugins/ralph-wiggum/src/stopHookRunner.js +104 -0
  9. package/plugins/ralph-wiggum/src/verificationGate.js +202 -0
  10. package/plugins/workflow-runner/.claude-plugin/plugin.json +5 -0
  11. package/plugins/workflow-runner/commands/workflow.md +15 -0
  12. package/plugins/workflow-runner/commands/workflows.md +8 -0
  13. package/plugins/workflow-runner/plugin.js +36 -0
  14. package/plugins/workflow-runner/src/expressions.js +369 -0
  15. package/plugins/workflow-runner/src/index.js +174 -0
  16. package/plugins/workflow-runner/src/loader.js +183 -0
  17. package/plugins/workflow-runner/src/runner.js +290 -0
  18. package/plugins/workflow-runner/src/stepExecutors/assert.js +28 -0
  19. package/plugins/workflow-runner/src/stepExecutors/llm.js +44 -0
  20. package/plugins/workflow-runner/src/stepExecutors/skill.js +103 -0
  21. package/plugins/workflow-runner/src/stepExecutors/tool.js +35 -0
  22. package/plugins/workflow-runner/src/types.js +59 -0
  23. package/plugins/workflow-runner/src/workflowState.js +46 -0
  24. package/skills/image-gen-openrouter/SKILL.md +121 -0
  25. package/skills/subtitle-srt/SKILL.md +134 -0
  26. package/skills/tts-zh/SKILL.md +137 -0
  27. package/skills/video-compose/SKILL.md +139 -0
  28. package/src/bootstrap/cwdArg.js +22 -0
  29. package/src/bootstrap/workingDir.js +31 -0
  30. package/src/cli/configWizard.js +272 -0
  31. package/src/cli/print.js +192 -0
  32. package/src/config/configFile.js +78 -0
  33. package/src/config.js +118 -0
  34. package/src/context/compact.js +357 -0
  35. package/src/context/microCompactLite.js +151 -0
  36. package/src/context/persistContext.js +109 -0
  37. package/src/context/reactiveCompact.js +121 -0
  38. package/src/context/sessionPath.js +58 -0
  39. package/src/context/snipCompact.js +112 -0
  40. package/src/context/tokenCounter.js +66 -0
  41. package/src/llm/client.js +182 -0
  42. package/src/loop.js +230 -0
  43. package/src/main.js +116 -0
  44. package/src/plugin-sdk.js +24 -0
  45. package/src/plugins/commandRouter.js +169 -0
  46. package/src/plugins/hookEngine.js +258 -0
  47. package/src/plugins/pluginApi.js +23 -0
  48. package/src/plugins/pluginLoader.js +71 -0
  49. package/src/plugins/pluginRunner.js +65 -0
  50. package/src/plugins/transcript.js +171 -0
  51. package/src/prompts/projectInstructions.js +48 -0
  52. package/src/prompts/skillList.js +126 -0
  53. package/src/prompts/system.js +155 -0
  54. package/src/session/runTurn.js +41 -0
  55. package/src/session/sessionState.js +19 -0
  56. package/src/tools/bash/bash.js +352 -0
  57. package/src/tools/bash/semantics.js +85 -0
  58. package/src/tools/bash/warnings.js +98 -0
  59. package/src/tools/edit/edit.js +253 -0
  60. package/src/tools/edit/multi-edit.js +155 -0
  61. package/src/tools/glob/glob.js +97 -0
  62. package/src/tools/grep/grep.js +185 -0
  63. package/src/tools/grep/rgPath.js +173 -0
  64. package/src/tools/index.js +94 -0
  65. package/src/tools/read/read.js +209 -0
  66. package/src/tools/shared/fileState.js +61 -0
  67. package/src/tools/shared/fileUtils.js +281 -0
  68. package/src/tools/shared/schemas.js +16 -0
  69. package/src/tools/types.js +21 -0
  70. package/src/tools/webbrowser/browser.js +55 -0
  71. package/src/tools/webbrowser/webbrowser.js +194 -0
  72. package/src/tools/webfetch/preapproved.js +267 -0
  73. package/src/tools/webfetch/webfetch.js +317 -0
  74. package/src/tools/websearch/websearch.js +161 -0
  75. package/src/tools/write/write.js +125 -0
  76. package/src/types/turndown.d.ts +23 -0
  77. package/src/types.js +16 -0
  78. package/src/ui/App.js +37 -0
  79. package/src/ui/InputBox.js +240 -0
  80. package/src/ui/MessageList.js +28 -0
  81. package/src/ui/Root.js +70 -0
  82. package/src/ui/StatusLine.js +41 -0
  83. package/src/ui/ToolStatus.js +11 -0
  84. package/src/ui/hooks/useChat.js +234 -0
  85. package/src/ui/hooks/usePasteHandler.js +137 -0
  86. package/src/ui/hooks/useTextBuffer.js +55 -0
  87. package/src/ui/hooks/useTokenUsage.js +30 -0
  88. package/src/ui/textBuffer.js +217 -0
  89. package/src/utils/packageRoot.js +37 -0
  90. package/src/utils/resourcePaths.js +49 -0
  91. package/src/utils/zodToJson.js +29 -0
  92. package/workflows/book-review-short.yaml +99 -0
  93. package/workflows/e2e-write-greet.yaml +27 -0
  94. package/workflows/schema.json +74 -0
  95. package/workflows/youtube-shorts.yaml +171 -0
  96. package/dist/main.js +0 -5936
  97. package/plugins/ralph-wiggum/scripts/setup-ralph-loop.sh +0 -203
@@ -0,0 +1,31 @@
1
+ /**
2
+ * ============================================================
3
+ * src/bootstrap/workingDir.ts —— 工作目录管理
4
+ * ------------------------------------------------------------
5
+ * 启动时一次性捕获 process.cwd(),后续所有"操作目录"统一通过
6
+ * getWorkingDir() 读取,杜绝多处 process.cwd() 不一致的隐患。
7
+ *
8
+ * 设计选择:
9
+ * - 模块级 state:单进程单 cwd,不需要 AsyncLocalStorage
10
+ * - 锁定时机:main() 最开头,在加载 config / provider / history 之前
11
+ * - 环境变量覆盖 MINIMAL_AGENT_CWD:测试 / docker / 显式 cd 场景
12
+ * ============================================================
13
+ */
14
+ import { resolve } from 'node:path';
15
+ let _workingDir = null;
16
+ /** 首次调用锁定 cwd(解析 MINIMAL_AGENT_CWD),重复调用幂等。 */
17
+ export function initWorkingDir() {
18
+ if (_workingDir !== null)
19
+ return _workingDir;
20
+ const override = process.env.MINIMAL_AGENT_CWD;
21
+ _workingDir = resolve(override ?? process.cwd());
22
+ return _workingDir;
23
+ }
24
+ /** 读取已锁定的工作目录;尚未 init 时退到 lazy init(主要给测试)。 */
25
+ export function getWorkingDir() {
26
+ return _workingDir ?? initWorkingDir();
27
+ }
28
+ /** 仅供测试:重置 module-level state */
29
+ export function _resetWorkingDir() {
30
+ _workingDir = null;
31
+ }
@@ -0,0 +1,272 @@
1
+ import { jsx as _jsx, jsxs as _jsxs } from "react/jsx-runtime";
2
+ /**
3
+ * ============================================================
4
+ * src/cli/configWizard.tsx —— 首次启动配置向导(Ink TUI)
5
+ * ------------------------------------------------------------
6
+ * 当 loadProviderLayered() 返回 null(env 和 ~/.minimal-agent/config.json 都没有
7
+ * 完整 provider 配置)时,由 Root.tsx 渲染本组件。
8
+ *
9
+ * 流程:
10
+ * Step 1: 选 provider 预设(minimax / openai / deepseek / kimi / custom)
11
+ * Step 2: 输入 baseURL(预设有默认值,用户可改)
12
+ * Step 3: 输入 apiKey(不回显,用 * 替代)
13
+ * Step 4: 输入 model(预设有候选)
14
+ * Step 5: 连接测试(POST /chat/completions max_tokens=5)
15
+ * - 通过 → saveConfig + onSuccess(provider) → Root 切到 App
16
+ * - 失败 → 显示错误 + 回到 Step 1(保留已输入的值方便修改)
17
+ *
18
+ * 设计取舍:
19
+ * - 不引第三方 prompt 库(@inquirer/prompts 等),用 Ink + useInput 手写,
20
+ * 保持"零新增依赖"承诺。
21
+ * - 不做 spinner 动画,loading 时显示一行文字够用。
22
+ * - 不做"返回上一步",要修改请按 ESC 退出向导重跑(简化键位逻辑)。
23
+ * —— 用户中途退出 = 不写文件 = 下次启动继续向导,符合需求 "直到成功为止"。
24
+ * ============================================================
25
+ */
26
+ import { useCallback, useMemo, useState } from 'react';
27
+ import { Box, Text, useApp, useInput } from 'ink';
28
+ import { saveConfig } from '../config/configFile.js';
29
+ // 兜底值:custom 或表外 model 都退到这里。
30
+ // 与 src/config.ts 的同名常量含义对齐("既没 env、saved 也没填 contextWindow" 的最终兜底)。
31
+ const FALLBACK_CONTEXT_WINDOW = 128_000;
32
+ const PRESETS = [
33
+ {
34
+ name: 'minimax',
35
+ label: 'MiniMax (海螺)',
36
+ baseURL: 'https://api.minimax.chat/v1',
37
+ models: ['MiniMax-M2.7', 'MiniMax-M1', 'abab6.5s-chat'],
38
+ contextWindow: 204_800,
39
+ },
40
+ {
41
+ name: 'deepseek',
42
+ label: 'DeepSeek',
43
+ baseURL: 'https://api.deepseek.com/v1',
44
+ models: ['deepseek-chat', 'deepseek-reasoner'],
45
+ contextWindow: 128_000,
46
+ },
47
+ {
48
+ name: 'openai',
49
+ label: 'OpenAI',
50
+ baseURL: 'https://api.openai.com/v1',
51
+ models: ['gpt-4o', 'gpt-4o-mini'],
52
+ contextWindow: 128_000,
53
+ },
54
+ {
55
+ name: 'moonshot',
56
+ label: 'Moonshot (Kimi)',
57
+ baseURL: 'https://api.moonshot.cn/v1',
58
+ models: ['moonshot-v1-8k', 'moonshot-v1-32k', 'moonshot-v1-128k'],
59
+ contextWindow: 128_000,
60
+ modelContextWindows: {
61
+ 'moonshot-v1-8k': 8_000,
62
+ 'moonshot-v1-32k': 32_000,
63
+ 'moonshot-v1-128k': 128_000,
64
+ },
65
+ },
66
+ {
67
+ name: 'custom',
68
+ label: '自定义(手动填 baseURL)',
69
+ baseURL: '',
70
+ models: [],
71
+ contextWindow: FALLBACK_CONTEXT_WINDOW,
72
+ },
73
+ ];
74
+ function resolveContextWindow(preset, model) {
75
+ return (preset.modelContextWindows?.[model] ??
76
+ preset.contextWindow ??
77
+ FALLBACK_CONTEXT_WINDOW);
78
+ }
79
+ export function ConfigWizard({ onSuccess }) {
80
+ const { exit } = useApp();
81
+ const [step, setStep] = useState('preset');
82
+ const [presetIndex, setPresetIndex] = useState(0);
83
+ const [baseURL, setBaseURL] = useState('');
84
+ const [apiKey, setApiKey] = useState('');
85
+ const [model, setModel] = useState('');
86
+ const [modelIndex, setModelIndex] = useState(0);
87
+ const [draft, setDraft] = useState('');
88
+ const [errorMsg, setErrorMsg] = useState(null);
89
+ const preset = PRESETS[presetIndex];
90
+ const enterStep = useCallback((next) => {
91
+ setErrorMsg(null);
92
+ if (next === 'baseURL')
93
+ setDraft(baseURL || preset.baseURL);
94
+ if (next === 'apiKey')
95
+ setDraft('');
96
+ if (next === 'model') {
97
+ if (preset.models.length > 0) {
98
+ setDraft(model || preset.models[0]);
99
+ setModelIndex(0);
100
+ }
101
+ else {
102
+ setDraft(model);
103
+ }
104
+ }
105
+ setStep(next);
106
+ }, [baseURL, model, preset]);
107
+ // handleTest 接受 finalModel 作为参数:
108
+ // Step 4 里调用方 `setModel(...) + handleTest()` 时,setModel 是异步的,handleTest
109
+ // 的 closure 还引用着旧的 model state;直接传 finalModel 才能避免"首次必跑两轮"
110
+ // —— 第一次发请求时 model 还是空串,API 报错,回到 Step 1 再走一遍才能成功。
111
+ //
112
+ // 测试通过后 **不立即** saveConfig + onSuccess,而是转去 Step 5(tavily)
113
+ // 让用户决定是否一并填 Tavily key,最后在 finalize() 一次性落盘。
114
+ const handleTest = useCallback(async (finalModel) => {
115
+ setStep('testing');
116
+ setErrorMsg(null);
117
+ try {
118
+ const url = `${baseURL.replace(/\/$/, '')}/chat/completions`;
119
+ const resp = await fetch(url, {
120
+ method: 'POST',
121
+ headers: {
122
+ 'Content-Type': 'application/json',
123
+ Authorization: `Bearer ${apiKey}`,
124
+ },
125
+ body: JSON.stringify({
126
+ model: finalModel,
127
+ messages: [{ role: 'user', content: 'ping' }],
128
+ max_tokens: 5,
129
+ stream: false,
130
+ }),
131
+ });
132
+ if (!resp.ok) {
133
+ const body = await resp.text().catch(() => '');
134
+ throw new Error(`HTTP ${resp.status}: ${body.slice(0, 200) || resp.statusText}`);
135
+ }
136
+ const data = (await resp.json());
137
+ if (!Array.isArray(data.choices)) {
138
+ throw new Error('响应中没有 choices 字段,可能不是 OpenAI 兼容协议');
139
+ }
140
+ setDraft('');
141
+ setStep('tavily');
142
+ }
143
+ catch (e) {
144
+ setErrorMsg(e.message || '连接失败');
145
+ setStep('error');
146
+ }
147
+ }, [apiKey, baseURL]);
148
+ // 落盘 + 切到 App。tavily key 为空表示用户跳过 WebSearch 配置。
149
+ const finalize = useCallback(async (finalModel, tavilyApiKey) => {
150
+ const contextWindow = resolveContextWindow(preset, finalModel);
151
+ await saveConfig({
152
+ baseURL,
153
+ apiKey,
154
+ model: finalModel,
155
+ provider: preset.name,
156
+ contextWindow,
157
+ tavilyApiKey,
158
+ });
159
+ // 立刻注入当前进程 env,让本会话内的 WebSearch 不必重启就能用
160
+ if (tavilyApiKey) {
161
+ process.env.TAVILY_API_KEY = tavilyApiKey;
162
+ }
163
+ onSuccess({
164
+ name: preset.name,
165
+ baseURL,
166
+ apiKey,
167
+ model: finalModel,
168
+ contextWindow,
169
+ });
170
+ }, [apiKey, baseURL, onSuccess, preset]);
171
+ useInput((input, key) => {
172
+ if (key.escape || (key.ctrl && input === 'c')) {
173
+ exit();
174
+ return;
175
+ }
176
+ if (step === 'preset') {
177
+ if (key.upArrow) {
178
+ setPresetIndex((i) => (i - 1 + PRESETS.length) % PRESETS.length);
179
+ }
180
+ else if (key.downArrow) {
181
+ setPresetIndex((i) => (i + 1) % PRESETS.length);
182
+ }
183
+ else if (key.return) {
184
+ enterStep('baseURL');
185
+ }
186
+ return;
187
+ }
188
+ if (step === 'baseURL') {
189
+ if (key.return) {
190
+ if (draft.trim().length === 0)
191
+ return;
192
+ setBaseURL(draft.trim());
193
+ enterStep('apiKey');
194
+ }
195
+ else if (key.backspace || key.delete) {
196
+ setDraft((s) => s.slice(0, -1));
197
+ }
198
+ else if (input && !key.meta && !key.ctrl) {
199
+ setDraft((s) => s + input);
200
+ }
201
+ return;
202
+ }
203
+ if (step === 'apiKey') {
204
+ if (key.return) {
205
+ if (draft.length === 0)
206
+ return;
207
+ setApiKey(draft);
208
+ enterStep('model');
209
+ }
210
+ else if (key.backspace || key.delete) {
211
+ setDraft((s) => s.slice(0, -1));
212
+ }
213
+ else if (input && !key.meta && !key.ctrl) {
214
+ setDraft((s) => s + input);
215
+ }
216
+ return;
217
+ }
218
+ if (step === 'model') {
219
+ if (preset.models.length > 0) {
220
+ if (key.upArrow) {
221
+ const ni = (modelIndex - 1 + preset.models.length) % preset.models.length;
222
+ setModelIndex(ni);
223
+ setDraft(preset.models[ni]);
224
+ }
225
+ else if (key.downArrow) {
226
+ const ni = (modelIndex + 1) % preset.models.length;
227
+ setModelIndex(ni);
228
+ setDraft(preset.models[ni]);
229
+ }
230
+ else if (key.tab) {
231
+ setDraft((s) => s + '');
232
+ }
233
+ }
234
+ if (key.return) {
235
+ const finalModel = draft.trim();
236
+ if (finalModel.length === 0)
237
+ return;
238
+ setModel(finalModel);
239
+ void handleTest(finalModel);
240
+ }
241
+ else if (key.backspace || key.delete) {
242
+ setDraft((s) => s.slice(0, -1));
243
+ }
244
+ else if (input && !key.meta && !key.ctrl && !key.upArrow && !key.downArrow) {
245
+ setDraft((s) => s + input);
246
+ }
247
+ return;
248
+ }
249
+ if (step === 'tavily') {
250
+ if (key.return) {
251
+ // 空 draft 表示用户跳过 WebSearch 配置(合法路径,不报错)
252
+ const tavilyApiKey = draft.trim().length > 0 ? draft.trim() : undefined;
253
+ void finalize(model, tavilyApiKey);
254
+ }
255
+ else if (key.backspace || key.delete) {
256
+ setDraft((s) => s.slice(0, -1));
257
+ }
258
+ else if (input && !key.meta && !key.ctrl) {
259
+ setDraft((s) => s + input);
260
+ }
261
+ return;
262
+ }
263
+ if (step === 'error') {
264
+ if (key.return) {
265
+ enterStep('preset');
266
+ }
267
+ return;
268
+ }
269
+ });
270
+ const maskedApiKey = useMemo(() => '*'.repeat(Math.min(draft.length, 32)), [draft]);
271
+ return (_jsxs(Box, { flexDirection: "column", paddingX: 1, children: [_jsx(Box, { marginBottom: 1, children: _jsx(Text, { color: "cyan", bold: true, children: "minimal-agent \u00B7 \u9996\u6B21\u914D\u7F6E\u5411\u5BFC" }) }), _jsx(Box, { marginBottom: 1, children: _jsx(Text, { color: "gray", children: "\u6309 ESC \u6216 Ctrl+C \u9000\u51FA\uFF08\u4E0D\u5199\u914D\u7F6E\uFF0C\u4E0B\u6B21\u542F\u52A8\u7EE7\u7EED\u5411\u5BFC\uFF09" }) }), step === 'preset' && (_jsxs(Box, { flexDirection: "column", children: [_jsx(Text, { children: "Step 1/5 \u00B7 \u9009\u62E9 provider \u9884\u8BBE\uFF08\u2191\u2193 \u79FB\u52A8\uFF0CEnter \u786E\u8BA4\uFF09" }), _jsx(Box, { flexDirection: "column", marginTop: 1, children: PRESETS.map((p, i) => (_jsxs(Text, { color: i === presetIndex ? 'cyan' : undefined, children: [i === presetIndex ? '> ' : ' ', p.label, p.baseURL ? ` (${p.baseURL})` : ''] }, p.name))) })] })), step === 'baseURL' && (_jsxs(Box, { flexDirection: "column", children: [_jsx(Text, { children: "Step 2/5 \u00B7 \u8F93\u5165 API base URL\uFF08\u9ED8\u8BA4\u4E3A\u9884\u8BBE\u503C\uFF0C\u53EF\u6539\uFF09" }), _jsx(Box, { marginTop: 1, borderStyle: "round", borderColor: "cyan", paddingX: 1, children: _jsx(Text, { children: draft || ' ' }) }), _jsx(Text, { color: "gray", children: "Enter \u786E\u8BA4 \u00B7 Backspace \u5220\u9664" })] })), step === 'apiKey' && (_jsxs(Box, { flexDirection: "column", children: [_jsx(Text, { children: "Step 3/5 \u00B7 \u8F93\u5165 API key\uFF08\u8F93\u5165\u5185\u5BB9\u4E0D\u4F1A\u663E\u793A\uFF09" }), _jsx(Box, { marginTop: 1, borderStyle: "round", borderColor: "cyan", paddingX: 1, children: _jsx(Text, { children: maskedApiKey || ' ' }) }), _jsxs(Text, { color: "gray", children: ["Enter \u786E\u8BA4 \u00B7 Backspace \u5220\u9664\uFF08\u5DF2\u8F93\u5165 ", draft.length, " \u5B57\u7B26\uFF09"] })] })), step === 'model' && (_jsxs(Box, { flexDirection: "column", children: [_jsxs(Text, { children: ["Step 4/5 \u00B7 \u8F93\u5165\u6216\u9009\u62E9 model", preset.models.length > 0 ? '(↑↓ 切换预设,或直接编辑)' : ''] }), preset.models.length > 0 && (_jsx(Box, { flexDirection: "column", marginTop: 1, children: preset.models.map((m, i) => (_jsxs(Text, { color: i === modelIndex ? 'cyan' : 'gray', children: [i === modelIndex ? '> ' : ' ', m] }, m))) })), _jsx(Box, { marginTop: 1, borderStyle: "round", borderColor: "cyan", paddingX: 1, children: _jsx(Text, { children: draft || ' ' }) }), _jsx(Text, { color: "gray", children: "Enter \u63D0\u4EA4\u5E76\u6D4B\u8BD5\u8FDE\u63A5" })] })), step === 'testing' && (_jsxs(Box, { flexDirection: "column", children: [_jsxs(Text, { color: "yellow", children: ["\u23F3 \u6B63\u5728\u6D4B\u8BD5\u8FDE\u63A5 ", baseURL, " ..."] }), _jsxs(Text, { color: "gray", children: ["model = ", model] })] })), step === 'tavily' && (_jsxs(Box, { flexDirection: "column", children: [_jsx(Text, { color: "green", children: "\u2713 LLM \u8FDE\u63A5\u6D4B\u8BD5\u901A\u8FC7" }), _jsx(Box, { marginTop: 1, children: _jsx(Text, { children: "Step 5/5 \u00B7 \u53EF\u9009 - Tavily API key\uFF08\u7528\u4E8E WebSearch \u5DE5\u5177\uFF09" }) }), _jsx(Box, { marginTop: 1, borderStyle: "round", borderColor: "cyan", paddingX: 1, children: _jsx(Text, { children: maskedApiKey || ' ' }) }), _jsx(Text, { color: "gray", children: "\u76F4\u63A5 Enter \u8DF3\u8FC7\uFF08\u4E4B\u540E\u53EF\u5728\u5BF9\u8BDD\u91CC\u8F93\u5165 /config \u6DFB\u52A0\uFF0C\u6216\u8BBE env TAVILY_API_KEY\uFF09" }), _jsxs(Text, { color: "gray", children: ["\u514D\u8D39 key \u7533\u8BF7\uFF1Ahttps://tavily.com/ \u00B7 \u5DF2\u8F93\u5165 ", draft.length, " \u5B57\u7B26"] })] })), step === 'error' && (_jsxs(Box, { flexDirection: "column", children: [_jsx(Text, { color: "red", children: "\u2717 \u8FDE\u63A5\u6D4B\u8BD5\u5931\u8D25" }), _jsx(Box, { marginTop: 1, children: _jsx(Text, { color: "red", children: errorMsg ?? '未知错误' }) }), _jsx(Box, { marginTop: 1, children: _jsx(Text, { color: "gray", children: "\u5DF2\u8F93\u5165\uFF1A" }) }), _jsxs(Text, { color: "gray", children: [" provider = ", preset.name] }), _jsxs(Text, { color: "gray", children: [" baseURL = ", baseURL] }), _jsxs(Text, { color: "gray", children: [" model = ", model] }), _jsx(Box, { marginTop: 1, children: _jsx(Text, { color: "yellow", children: "\u6309 Enter \u56DE\u5230 Step 1 \u91CD\u8BD5 \u00B7 ESC \u9000\u51FA" }) })] }))] }));
272
+ }
@@ -0,0 +1,192 @@
1
+ /**
2
+ * ============================================================
3
+ * src/cli/print.ts —— CLI 非交互模式(-p / --print)
4
+ * ------------------------------------------------------------
5
+ * 不启动 Ink TUI,直接执行单次问答,流式输出到 stdout。
6
+ * 适合脚本集成、CI/CD、快速单次查询。
7
+ *
8
+ * v2 改进(对齐 kakadeai CLI 工程实践):
9
+ * 1. SIGINT 先保存 context 再退出(防丢失)
10
+ * 2. handleEvent 返回退出码,不直接 process.exit(单一职责)
11
+ * 3. EPIPE 保护:管道断开时静默退出不崩溃
12
+ * 4. stdin peek 超时:防止非 TTY 无管道时永久挂起
13
+ *
14
+ * 使用方式:
15
+ * minimal-agent -p "提示"
16
+ * echo "提示" | minimal-agent -p
17
+ * minimal-agent -p --verbose "提示"
18
+ * ============================================================
19
+ */
20
+ import { saveContext } from '../context/persistContext.js';
21
+ import { runWithPlugins } from '../plugins/pluginRunner.js';
22
+ /** stderr 显示工具结果时的截断阈值;超过即末尾加 "..." */
23
+ const TOOL_OUTPUT_PREVIEW_MAX = 200;
24
+ /** stdin 读取超时(ms),防止非 TTY + 无管道时永久挂起 */
25
+ const STDIN_TIMEOUT_MS = 3000;
26
+ function handleEPIPE(stream) {
27
+ return (err) => {
28
+ if (err.code === 'EPIPE')
29
+ stream.destroy();
30
+ };
31
+ }
32
+ export function truncateForDisplay(content, max = TOOL_OUTPUT_PREVIEW_MAX) {
33
+ if (content.length <= max)
34
+ return content;
35
+ return content.slice(0, max) + '...';
36
+ }
37
+ export function extractPromptArgs(args) {
38
+ // 布尔标志:直接过滤
39
+ const FLAG_BOOLEAN = new Set([
40
+ '-p',
41
+ '--print',
42
+ '--verbose',
43
+ '-v',
44
+ '-h',
45
+ '--help',
46
+ '-V',
47
+ '--version',
48
+ ]);
49
+ // 带值标志:本身 + 后面紧跟的值都要跳过
50
+ const FLAG_WITH_VALUE = new Set(['-d', '--cwd']);
51
+ const result = [];
52
+ for (let i = 0; i < args.length; i++) {
53
+ const a = args[i];
54
+ if (FLAG_BOOLEAN.has(a))
55
+ continue;
56
+ if (FLAG_WITH_VALUE.has(a)) {
57
+ i++; // 跳过 flag 后面的值
58
+ continue;
59
+ }
60
+ result.push(a);
61
+ }
62
+ return result;
63
+ }
64
+ /**
65
+ * CLI 非交互模式:读取 prompt,执行 runQuery,流式输出到 stdout,最后落盘 history。
66
+ */
67
+ export async function runPrintMode(provider, args, initialHistory, options) {
68
+ // EPIPE 保护:管道断开时不崩溃(如 `bun -p "..." | head -1`)
69
+ process.stdout.on('error', handleEPIPE(process.stdout));
70
+ process.stderr.on('error', handleEPIPE(process.stderr));
71
+ const promptArgs = extractPromptArgs(args);
72
+ let prompt;
73
+ if (promptArgs.length > 0) {
74
+ prompt = promptArgs.join(' ');
75
+ }
76
+ else if (!process.stdin.isTTY) {
77
+ prompt = await readFromStdin();
78
+ }
79
+ else {
80
+ console.error('错误: 请提供提示文本或使用管道输入');
81
+ process.exit(1);
82
+ }
83
+ if (!prompt.trim()) {
84
+ console.error('错误: 提示不能为空');
85
+ process.exit(1);
86
+ }
87
+ const abortController = new AbortController();
88
+ let interrupted = false;
89
+ process.on('SIGINT', () => {
90
+ if (interrupted)
91
+ process.exit(130);
92
+ interrupted = true;
93
+ abortController.abort();
94
+ console.error('\n已中断');
95
+ void saveContext(initialHistory).finally(() => process.exit(130));
96
+ });
97
+ const history = initialHistory;
98
+ const output = { buffer: '' };
99
+ try {
100
+ for await (const event of runWithPlugins(prompt, {
101
+ provider,
102
+ history,
103
+ signal: abortController.signal,
104
+ })) {
105
+ // event 是 LoopEvent | PluginEvent;插件私有事件 handleEvent 走 default 静默忽略
106
+ const result = handleEvent(event, output, options.verbose);
107
+ if (result.exitCode !== undefined) {
108
+ await saveContext(history);
109
+ process.exit(result.exitCode);
110
+ }
111
+ }
112
+ }
113
+ catch (e) {
114
+ if (e.name === 'AbortError') {
115
+ await saveContext(history);
116
+ process.exit(130);
117
+ }
118
+ console.error(`\n未捕获异常: ${e.message}`);
119
+ await saveContext(history);
120
+ process.exit(1);
121
+ }
122
+ await saveContext(history);
123
+ }
124
+ /**
125
+ * 处理 runQuery yield 出来的 LoopEvent。
126
+ * 纯事件分发函数,返回 { exitCode? } 由调用方决定是否退出。
127
+ */
128
+ export function handleEvent(event, output, verbose) {
129
+ switch (event.type) {
130
+ case 'text':
131
+ if (verbose)
132
+ process.stdout.write(event.delta);
133
+ output.buffer += event.delta;
134
+ return {};
135
+ case 'assistant_message':
136
+ return {};
137
+ case 'tool_start':
138
+ if (verbose) {
139
+ process.stderr.write(`\n🔧 工具: ${event.toolName}(${event.argsPreview})\n`);
140
+ }
141
+ return {};
142
+ case 'tool_end':
143
+ if (verbose) {
144
+ process.stderr.write(`${event.ok ? '✅' : '❌'} ${event.toolName}: ${truncateForDisplay(event.content)}\n`);
145
+ }
146
+ return {};
147
+ case 'compact_start':
148
+ if (verbose)
149
+ process.stderr.write('\n📝 正在压缩上下文...\n');
150
+ return {};
151
+ case 'compact_done':
152
+ if (verbose) {
153
+ process.stderr.write(`📝 压缩完成: ${event.before} → ${event.after} tokens\n`);
154
+ }
155
+ return {};
156
+ case 'turn_done':
157
+ if (!verbose)
158
+ console.log(output.buffer);
159
+ return {};
160
+ case 'error':
161
+ console.error(`\n错误: ${event.error}`);
162
+ return { exitCode: 1 };
163
+ default:
164
+ return {};
165
+ }
166
+ }
167
+ function readFromStdin() {
168
+ return new Promise((resolve) => {
169
+ let data = '';
170
+ let settled = false;
171
+ const timer = setTimeout(() => {
172
+ if (!settled) {
173
+ settled = true;
174
+ resolve('');
175
+ }
176
+ }, STDIN_TIMEOUT_MS);
177
+ process.stdin.setEncoding('utf8');
178
+ function onData(chunk) {
179
+ data += chunk;
180
+ }
181
+ function onEnd() {
182
+ if (!settled) {
183
+ clearTimeout(timer);
184
+ settled = true;
185
+ resolve(data.trim());
186
+ }
187
+ }
188
+ process.stdin.on('data', onData);
189
+ process.stdin.on('end', onEnd);
190
+ });
191
+ }
192
+ export { readFromStdin };
@@ -0,0 +1,78 @@
1
+ /**
2
+ * ============================================================
3
+ * src/config/configFile.ts —— 模式 B 向导写的 provider 配置
4
+ * ------------------------------------------------------------
5
+ * 与 src/config.ts 的关系:
6
+ * - config.ts 负责"读取 process.env 拼装 Provider"(模式 A)
7
+ * - 本文件负责"读写 ~/.minimal-agent/config.json"(模式 B)
8
+ * - 上层(loadProviderLayered)先看 env、再看本文件
9
+ *
10
+ * 与 src/context/persistContext.ts 的关系:
11
+ * - 复用同一个目录 ~/.minimal-agent/
12
+ * - 同样"解析失败返回 null、写失败静默"模式
13
+ *
14
+ * 为什么用 JSON 而非 .env 格式:
15
+ * - 解析无歧义(dotenv 各家行为略有差异)
16
+ * - 显式区分"向导写的" vs "用户写的 .env",避免互相覆盖
17
+ * - JSON 还可以放结构化扩展(如 savedAt 用于排查)
18
+ * ============================================================
19
+ */
20
+ import { chmod, mkdir, readFile, writeFile } from 'node:fs/promises';
21
+ import { homedir } from 'node:os';
22
+ import { dirname, join } from 'node:path';
23
+ /** 配置文件路径(带环境变量覆盖,主要给测试用) */
24
+ export function getConfigFilePath() {
25
+ return (process.env.MINIMAL_AGENT_CONFIG_FILE ??
26
+ join(homedir(), '.minimal-agent', 'config.json'));
27
+ }
28
+ /**
29
+ * 读取向导保存的配置。
30
+ * - 文件不存在 / JSON 损坏 / 必填字段缺失 → 返回 null
31
+ * - 成功 → 返回 SavedConfig
32
+ */
33
+ export async function readSavedConfig() {
34
+ const file = getConfigFilePath();
35
+ try {
36
+ const raw = await readFile(file, 'utf8');
37
+ const data = JSON.parse(raw);
38
+ if (typeof data.baseURL !== 'string' ||
39
+ typeof data.apiKey !== 'string' ||
40
+ typeof data.model !== 'string' ||
41
+ data.baseURL.length === 0 ||
42
+ data.apiKey.length === 0 ||
43
+ data.model.length === 0) {
44
+ return null;
45
+ }
46
+ return {
47
+ baseURL: data.baseURL,
48
+ apiKey: data.apiKey,
49
+ model: data.model,
50
+ provider: typeof data.provider === 'string' ? data.provider : undefined,
51
+ contextWindow: typeof data.contextWindow === 'number' && data.contextWindow > 0
52
+ ? data.contextWindow
53
+ : undefined,
54
+ tavilyApiKey: typeof data.tavilyApiKey === 'string' && data.tavilyApiKey.length > 0
55
+ ? data.tavilyApiKey
56
+ : undefined,
57
+ savedAt: typeof data.savedAt === 'number' ? data.savedAt : 0,
58
+ };
59
+ }
60
+ catch {
61
+ return null;
62
+ }
63
+ }
64
+ /**
65
+ * 写入向导收集到的配置。
66
+ * unix 上 chmod 600(仅本人可读写),Windows 上 chmod 是 no-op,靠 NTFS ACL。
67
+ */
68
+ export async function saveConfig(cfg) {
69
+ const file = getConfigFilePath();
70
+ await mkdir(dirname(file), { recursive: true });
71
+ const data = { ...cfg, savedAt: Date.now() };
72
+ await writeFile(file, JSON.stringify(data, null, 2), 'utf8');
73
+ try {
74
+ await chmod(file, 0o600);
75
+ }
76
+ catch {
77
+ }
78
+ }