min-agent 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.
@@ -0,0 +1,128 @@
1
+ /**
2
+ * Split "thinking" fragments (inline XML-style blocks some models leak into text)
3
+ * from displayable assistant text. Thinking is emitted to stderr; display goes to Markdown.
4
+ */
5
+
6
+ const BT = String.fromCharCode(96) // backtick — some models wrap thinking in `think` / `redacted_thinking`
7
+
8
+ const THINKING_BLOCKS: ReadonlyArray<{ open: string; close: string }> = [
9
+ { open: "<think>", close: "</think>" },
10
+ { open: "<thinking>", close: "</thinking>" },
11
+ { open: `<${BT}think${BT}>`, close: `<${BT}/think${BT}>` },
12
+ { open: `<${BT}redacted_thinking${BT}>`, close: `<${BT}/redacted_thinking${BT}>` },
13
+ ]
14
+
15
+ /** Max tail to keep when looking for a partial opening tag. */
16
+ const PARTIAL_TAG_HOLD = 72
17
+
18
+ function lower(s: string): string {
19
+ return s.toLowerCase()
20
+ }
21
+
22
+ function findFirstOpen(buf: string): { index: number; tag: (typeof THINKING_BLOCKS)[number] } | null {
23
+ const l = lower(buf)
24
+ let best: { index: number; tag: (typeof THINKING_BLOCKS)[number] } | null = null
25
+ for (const tag of THINKING_BLOCKS) {
26
+ const i = l.indexOf(tag.open)
27
+ if (i >= 0 && (!best || i < best.index)) best = { index: i, tag }
28
+ }
29
+ return best
30
+ }
31
+
32
+ /**
33
+ * Incremental filter: feed raw model text deltas; get display text (for stdout / history)
34
+ * and thinking text (for stderr). Handles tags split across chunks.
35
+ */
36
+ export class ThinkingBodySplitter {
37
+ private buf = ""
38
+
39
+ feed(chunk: string): { display: string; thinking: string } {
40
+ this.buf += chunk
41
+ return this.drain(false)
42
+ }
43
+
44
+ /** End of stream: flush remainder; incomplete thinking block → stderr only. */
45
+ flush(): { display: string; thinking: string } {
46
+ return this.drain(true)
47
+ }
48
+
49
+ private drain(isFinal: boolean): { display: string; thinking: string } {
50
+ let display = ""
51
+ let thinking = ""
52
+
53
+ while (this.buf.length > 0) {
54
+ const open = findFirstOpen(this.buf)
55
+ if (!open) {
56
+ if (isFinal) {
57
+ display += this.buf
58
+ this.buf = ""
59
+ } else {
60
+ const holdFrom = lastPotentialPartialOpen(this.buf)
61
+ if (holdFrom >= 0) {
62
+ display += this.buf.slice(0, holdFrom)
63
+ this.buf = this.buf.slice(holdFrom)
64
+ } else {
65
+ display += this.buf
66
+ this.buf = ""
67
+ }
68
+ }
69
+ break
70
+ }
71
+
72
+ if (open.index > 0) {
73
+ display += this.buf.slice(0, open.index)
74
+ this.buf = this.buf.slice(open.index)
75
+ }
76
+
77
+ const low = lower(this.buf)
78
+ if (!low.startsWith(open.tag.open)) {
79
+ this.buf = this.buf.slice(1)
80
+ continue
81
+ }
82
+
83
+ const afterOpen = open.tag.open.length
84
+ const closeRel = low.indexOf(open.tag.close, afterOpen)
85
+ if (closeRel < 0) {
86
+ if (isFinal) {
87
+ thinking += this.buf.slice(afterOpen)
88
+ this.buf = ""
89
+ }
90
+ break
91
+ }
92
+
93
+ const inner = this.buf.slice(afterOpen, closeRel)
94
+ thinking += inner
95
+ this.buf = this.buf.slice(closeRel + open.tag.close.length)
96
+ }
97
+
98
+ return { display, thinking }
99
+ }
100
+ }
101
+
102
+ /** If `buf` ends with `<...` that could still become a thinking open tag, return index of `<` to hold from; else -1. */
103
+ function lastPotentialPartialOpen(buf: string): number {
104
+ const start = Math.max(0, buf.length - PARTIAL_TAG_HOLD)
105
+ const tail = buf.slice(start)
106
+ const lt = tail.lastIndexOf("<")
107
+ if (lt < 0) return -1
108
+ const globalLt = start + lt
109
+ const cand = buf.slice(globalLt).toLowerCase()
110
+ if (cand.length > 64) return -1
111
+ for (const tag of THINKING_BLOCKS) {
112
+ if (tag.open.startsWith(cand)) return globalLt
113
+ }
114
+ return -1
115
+ }
116
+
117
+ /** Final strip for assistant message (complete tags only). */
118
+ export function stripThinkingFromAssistantText(text: string): string {
119
+ let s = text
120
+ for (const { open, close } of THINKING_BLOCKS) {
121
+ const re = new RegExp(
122
+ open.replace(/[.*+?^${}()|[\]\\]/g, "\\$&") + "[\\s\\S]*?" + close.replace(/[.*+?^${}()|[\]\\]/g, "\\$&"),
123
+ "gi",
124
+ )
125
+ s = s.replace(re, "")
126
+ }
127
+ return s
128
+ }
package/src/cli.ts ADDED
@@ -0,0 +1,494 @@
1
+ import { runAgent, runChat } from "./agent.js"
2
+ import {
3
+ loadMcpConfig,
4
+ saveMcpConfig,
5
+ checkMcpServer,
6
+ checkAllMcpServers,
7
+ formatMcpServerBinding,
8
+ type McpServerConfig,
9
+ } from "./mcp.js"
10
+ import { discoverSkills, getSkills } from "./skills.js"
11
+ import { loadMemories, addMemory, deleteMemory, searchMemories } from "./memory.js"
12
+ import { runSetup, isConfigured, loadConfig, saveConfig, fetchModels, getConfigDir, getRulesFile } from "./config.js"
13
+ import { setAutoApprove } from "./confirm.js"
14
+ import { existsSync, readFileSync, writeFileSync, mkdirSync } from "fs"
15
+ import path from "path"
16
+
17
+ const rawArgs = process.argv.slice(2)
18
+
19
+ // Extract leading global flags only, so subcommands can still use "-y"
20
+ const args = [...rawArgs]
21
+ let hasYes = false
22
+ while (args[0] === "--yes" || args[0] === "-y") {
23
+ hasYes = true
24
+ args.shift()
25
+ }
26
+ if (hasYes) setAutoApprove(true)
27
+
28
+ function printUsage() {
29
+ console.log(`
30
+ min-agent - Minimal AI coding agent
31
+
32
+ Usage:
33
+ min-agent chat <message> Send a message to the agent
34
+ min-agent chat Start interactive multi-turn chat
35
+ min-agent chat --resume <id> Resume a previous session
36
+ min-agent setup Configure API provider (interactive)
37
+ min-agent models List available models
38
+ min-agent history List saved sessions
39
+ min-agent rules Show loaded instruction rules
40
+ min-agent rules edit Edit global rules file
41
+ min-agent memory List all memories
42
+ min-agent memory add <text> Add a memory manually
43
+ min-agent memory search <query> Search memories
44
+ min-agent memory delete <index> Delete a memory by number
45
+ min-agent mcp add <name> <cmd> Add a local MCP server (stdio)
46
+ min-agent mcp add <name> --url <url> [--sse] [--token <t>] Add remote MCP (HTTP)
47
+ min-agent mcp remove <name> Remove an MCP server
48
+ min-agent mcp list List configured MCP servers
49
+ min-agent mcp check Check MCP server availability
50
+ min-agent skills list List available skills
51
+ min-agent serve [--host H] [--port P] HTTP API (see docs/API.md)
52
+
53
+ Options:
54
+ --model, -m <model> Override model for this request
55
+ --image, -i <path> Attach an image (can be used multiple times)
56
+ --yes, -y Auto-approve all confirmations (dangerous commands, file overwrites)
57
+
58
+ Rules (loaded as system instructions):
59
+ Global: ~/.min-agent/rules.md
60
+ Project: ./AGENTS.md or ./RULES.md or ./.min-agent/AGENTS.md
61
+ Config: "instructions" array in ~/.min-agent/config.json
62
+
63
+ Examples:
64
+ min-agent setup
65
+ min-agent chat "hello"
66
+ min-agent chat
67
+ min-agent serve --port 8787
68
+ min-agent rules edit
69
+ min-agent mcp add filesystem npx -y @modelcontextprotocol/server-filesystem /tmp
70
+ min-agent mcp add remote --url https://example.com/mcp --token "$TOKEN"
71
+ `)
72
+ }
73
+
74
+ function parseMcpAddArgs(argv: string[]): {
75
+ skipCheck: boolean
76
+ url?: string
77
+ token?: string
78
+ sse: boolean
79
+ cmd: string[]
80
+ } {
81
+ let skipCheck = false
82
+ let url: string | undefined
83
+ let token: string | undefined
84
+ let sse = false
85
+ const cmd: string[] = []
86
+ for (let i = 0; i < argv.length; i++) {
87
+ const a = argv[i]
88
+ if (a === "--skip-check") {
89
+ skipCheck = true
90
+ continue
91
+ }
92
+ if (a === "--sse") {
93
+ sse = true
94
+ continue
95
+ }
96
+ if (a === "--url" && argv[i + 1]) {
97
+ url = argv[++i]
98
+ continue
99
+ }
100
+ if (a === "--token" && argv[i + 1]) {
101
+ token = argv[++i]
102
+ continue
103
+ }
104
+ cmd.push(a)
105
+ }
106
+ return { skipCheck, url, token, sse, cmd }
107
+ }
108
+
109
+ function ensureProjectDefaults(): void {
110
+ const projectConfigDir = path.join(process.cwd(), ".min-agent")
111
+ const projectSkillsDir = path.join(projectConfigDir, "skills")
112
+ const projectMcpFile = path.join(projectConfigDir, "mcp.json")
113
+
114
+ if (!existsSync(projectSkillsDir)) {
115
+ mkdirSync(projectSkillsDir, { recursive: true })
116
+ }
117
+
118
+ if (!existsSync(projectMcpFile)) {
119
+ mkdirSync(projectConfigDir, { recursive: true })
120
+ writeFileSync(projectMcpFile, JSON.stringify({ mcpServers: {} }, null, 2), "utf-8")
121
+ }
122
+ }
123
+
124
+ async function main() {
125
+ ensureProjectDefaults()
126
+
127
+ if (args.length === 0 || args[0] === "--help" || args[0] === "-h") {
128
+ printUsage()
129
+ process.exit(0)
130
+ }
131
+
132
+ const command = args[0]
133
+
134
+ switch (command) {
135
+ case "setup": {
136
+ await runSetup()
137
+ break
138
+ }
139
+
140
+ case "models": {
141
+ const config = loadConfig()
142
+ if (!config.provider?.baseURL || !config.provider?.apiKey) {
143
+ console.error("Not configured. Run: min-agent setup")
144
+ process.exit(1)
145
+ }
146
+ console.log("Fetching models...")
147
+ const models = await fetchModels(config.provider.baseURL, config.provider.apiKey)
148
+ if (models.length === 0) {
149
+ console.log("No models found or unable to fetch model list.")
150
+ } else {
151
+ console.log(`\nAvailable models (${models.length}):`)
152
+ for (const m of models) {
153
+ const marker = m === config.provider.defaultModel ? " ← default" : ""
154
+ console.log(` ${m}${marker}`)
155
+ }
156
+ }
157
+ break
158
+ }
159
+
160
+ case "chat": {
161
+ if (!isConfigured()) {
162
+ console.error("Not configured. Run: min-agent setup")
163
+ process.exit(1)
164
+ }
165
+
166
+ // Parse --model / -m, --resume, and --image / -i flags
167
+ let modelOverride: string | undefined
168
+ let resumeId: string | undefined
169
+ const images: string[] = []
170
+ const chatArgs: string[] = []
171
+ for (let i = 1; i < args.length; i++) {
172
+ if ((args[i] === "--model" || args[i] === "-m") && args[i + 1]) {
173
+ modelOverride = args[++i]
174
+ } else if (args[i] === "--resume" && args[i + 1]) {
175
+ resumeId = args[++i]
176
+ } else if ((args[i] === "--image" || args[i] === "-i") && args[i + 1]) {
177
+ images.push(args[++i])
178
+ } else {
179
+ chatArgs.push(args[i])
180
+ }
181
+ }
182
+
183
+ const message = chatArgs.join(" ")
184
+ if (!message) {
185
+ // No message provided — enter interactive multi-turn mode
186
+ await runChat(modelOverride, resumeId)
187
+ } else {
188
+ await runAgent(message, modelOverride, images.length > 0 ? images : undefined)
189
+ }
190
+ break
191
+ }
192
+
193
+ case "serve": {
194
+ if (!isConfigured()) {
195
+ console.error("Not configured. Run: min-agent setup")
196
+ process.exit(1)
197
+ }
198
+ let servePort: number | undefined
199
+ let serveHost: string | undefined
200
+ for (let i = 1; i < args.length; i++) {
201
+ if ((args[i] === "--port" || args[i] === "-p") && args[i + 1]) {
202
+ servePort = parseInt(args[++i], 10)
203
+ } else if (args[i] === "--host" && args[i + 1]) {
204
+ serveHost = args[++i]
205
+ }
206
+ }
207
+ const { runServe } = await import("./serve.js")
208
+ await runServe({ port: servePort, host: serveHost })
209
+ break
210
+ }
211
+
212
+ case "mcp": {
213
+ const subcommand = args[1]
214
+ switch (subcommand) {
215
+ case "add": {
216
+ const name = args[2]
217
+ if (!name) {
218
+ console.error("Usage: min-agent mcp add <name> [--skip-check] <command...>")
219
+ console.error(" or: min-agent mcp add <name> --url <https://host/mcp> [--sse] [--token <bearer>] [--skip-check]")
220
+ process.exit(1)
221
+ }
222
+ const { skipCheck, url, token, sse, cmd } = parseMcpAddArgs(args.slice(3))
223
+
224
+ let entry: McpServerConfig
225
+ if (url?.trim()) {
226
+ entry = {
227
+ url: url.trim(),
228
+ enabled: true,
229
+ remoteTransport: sse ? "sse" : "auto",
230
+ }
231
+ if (token?.trim()) entry.token = token.trim()
232
+ } else if (cmd.length > 0) {
233
+ entry = { command: cmd, enabled: true }
234
+ } else {
235
+ console.error("Usage: min-agent mcp add <name> [--skip-check] <command...>")
236
+ console.error(" or: min-agent mcp add <name> --url <https://host/mcp> [--sse] [--token <bearer>] [--skip-check]")
237
+ process.exit(1)
238
+ }
239
+
240
+ let detectedTools = 0
241
+ if (!skipCheck) {
242
+ console.log(`Validating MCP server "${name}"...`)
243
+ const checkResult = await checkMcpServer(name, entry)
244
+ if (!checkResult.ok) {
245
+ console.error(`MCP server "${name}" validation failed: ${checkResult.error ?? "unknown error"}`)
246
+ process.exit(1)
247
+ }
248
+ detectedTools = checkResult.toolCount
249
+ } else {
250
+ console.log(`Skipping MCP validation for "${name}" (--skip-check).`)
251
+ }
252
+ const config = loadMcpConfig()
253
+ config.mcpServers[name] = entry
254
+ saveMcpConfig(config)
255
+ const toolsSuffix = skipCheck ? "" : ` (${detectedTools} tools detected)`
256
+ console.log(`✓ MCP server "${name}" added: ${formatMcpServerBinding(entry)}${toolsSuffix}`)
257
+ break
258
+ }
259
+ case "remove": {
260
+ const name = args[2]
261
+ if (!name) {
262
+ console.error("Usage: min-agent mcp remove <name>")
263
+ process.exit(1)
264
+ }
265
+ const config = loadMcpConfig()
266
+ if (!config.mcpServers[name]) {
267
+ console.error(`MCP server "${name}" not found`)
268
+ process.exit(1)
269
+ }
270
+ delete config.mcpServers[name]
271
+ saveMcpConfig(config)
272
+ console.log(`✓ MCP server "${name}" removed`)
273
+ break
274
+ }
275
+ case "list": {
276
+ const config = loadMcpConfig()
277
+ const servers = Object.entries(config.mcpServers)
278
+ if (servers.length === 0) {
279
+ console.log("No MCP servers configured.")
280
+ console.log("Add one with: min-agent mcp add <name> <command...> or --url <https://...>")
281
+ } else {
282
+ console.log("MCP Servers:")
283
+ for (const [name, cfg] of servers) {
284
+ const status = cfg.enabled === false ? " (disabled)" : ""
285
+ console.log(` ${name}: ${formatMcpServerBinding(cfg)}${status}`)
286
+ }
287
+ }
288
+ break
289
+ }
290
+ case "check": {
291
+ const results = await checkAllMcpServers()
292
+ if (results.length === 0) {
293
+ console.log("No MCP servers configured.")
294
+ console.log("Add one with: min-agent mcp add <name> <command...> or --url <https://...>")
295
+ break
296
+ }
297
+
298
+ let failed = 0
299
+ console.log("MCP Check Results:")
300
+ for (const result of results) {
301
+ if (!result.enabled) {
302
+ console.log(` ${result.name}: skipped (disabled)`)
303
+ continue
304
+ }
305
+ if (result.ok) {
306
+ console.log(` ${result.name}: ok (${result.toolCount} tools)`)
307
+ } else {
308
+ failed++
309
+ console.log(` ${result.name}: failed${result.error ? ` - ${result.error}` : ""}`)
310
+ }
311
+ }
312
+
313
+ if (failed > 0) {
314
+ console.error(`\n${failed} MCP server(s) failed.`)
315
+ process.exit(1)
316
+ } else {
317
+ console.log("\nAll enabled MCP servers are available.")
318
+ }
319
+ break
320
+ }
321
+ default:
322
+ console.error("Usage: min-agent mcp [add|remove|list|check]")
323
+ process.exit(1)
324
+ }
325
+ break
326
+ }
327
+
328
+ case "history": {
329
+ const { listSessions } = await import("./sessions.js")
330
+ const sessions = listSessions()
331
+ if (sessions.length === 0) {
332
+ console.log("No saved sessions.")
333
+ console.log("Sessions are auto-saved when you exit interactive chat.")
334
+ } else {
335
+ console.log(`Sessions (${sessions.length}):`)
336
+ for (const s of sessions.slice(0, 20)) {
337
+ const date = s.updated.split("T")[0]
338
+ console.log(` ${s.id} ${date} ${s.title} (${s.messageCount} msgs)`)
339
+ }
340
+ console.log("\nResume with: min-agent chat --resume <id>")
341
+ }
342
+ break
343
+ }
344
+
345
+ case "memory": {
346
+ const subcommand = args[1]
347
+ switch (subcommand) {
348
+ case "add": {
349
+ const text = args.slice(2).join(" ")
350
+ if (!text) {
351
+ console.error("Usage: min-agent memory add <text>")
352
+ process.exit(1)
353
+ }
354
+ addMemory(text)
355
+ console.log(`✓ Memory saved: "${text}"`)
356
+ break
357
+ }
358
+ case "search": {
359
+ const query = args.slice(2).join(" ")
360
+ if (!query) {
361
+ console.error("Usage: min-agent memory search <query>")
362
+ process.exit(1)
363
+ }
364
+ const results = searchMemories(query)
365
+ if (results.length === 0) {
366
+ console.log(`No memories matching "${query}"`)
367
+ } else {
368
+ console.log(`Found ${results.length} memory(s):`)
369
+ for (const m of results) {
370
+ const tags = m.tags.length > 0 ? ` [${m.tags.join(", ")}]` : ""
371
+ console.log(` #${m.index + 1}: ${m.content}${tags}`)
372
+ }
373
+ }
374
+ break
375
+ }
376
+ case "delete": {
377
+ const idx = parseInt(args[2])
378
+ if (isNaN(idx)) {
379
+ console.error("Usage: min-agent memory delete <number>")
380
+ process.exit(1)
381
+ }
382
+ if (deleteMemory(idx - 1)) {
383
+ console.log(`✓ Memory #${idx} deleted`)
384
+ } else {
385
+ console.error(`Memory #${idx} not found`)
386
+ }
387
+ break
388
+ }
389
+ default: {
390
+ // List all memories
391
+ const memories = loadMemories()
392
+ if (memories.length === 0) {
393
+ console.log("No memories stored.")
394
+ console.log("The agent will automatically save memories during conversations.")
395
+ console.log("Or add manually: min-agent memory add \"prefer TypeScript over JavaScript\"")
396
+ } else {
397
+ console.log(`Memories (${memories.length}):`)
398
+ for (let i = 0; i < memories.length; i++) {
399
+ const m = memories[i]
400
+ const tags = m.tags.length > 0 ? ` [${m.tags.join(", ")}]` : ""
401
+ const date = m.created.split("T")[0]
402
+ console.log(` #${i + 1}: ${m.content}${tags} (${date})`)
403
+ }
404
+ }
405
+ break
406
+ }
407
+ }
408
+ break
409
+ }
410
+
411
+ case "skills": {
412
+ const subcommand = args[1]
413
+ switch (subcommand) {
414
+ case "list": {
415
+ discoverSkills()
416
+ const skills = getSkills()
417
+ if (skills.length === 0) {
418
+ console.log("No skills found.")
419
+ console.log("Add skills by creating SKILL.md files in:")
420
+ console.log(" ~/.agents/skills/<name>/SKILL.md (global, all projects)")
421
+ console.log(" .min-agent/skills/<name>/SKILL.md")
422
+ console.log(" .opencode/skills/<name>/SKILL.md")
423
+ console.log("")
424
+ console.log("SKILL.md format:")
425
+ console.log(" ---")
426
+ console.log(" name: my-skill")
427
+ console.log(" description: What this skill does")
428
+ console.log(" ---")
429
+ console.log(" # Instructions content...")
430
+ } else {
431
+ console.log("Available Skills:")
432
+ for (const skill of skills) {
433
+ console.log(` ${skill.name}: ${skill.description}`)
434
+ console.log(` ${skill.location}`)
435
+ }
436
+ }
437
+ break
438
+ }
439
+ default:
440
+ console.error("Usage: min-agent skills [list]")
441
+ process.exit(1)
442
+ }
443
+ break
444
+ }
445
+
446
+ case "rules": {
447
+ const subcommand = args[1]
448
+ if (subcommand === "edit") {
449
+ const rulesFile = getRulesFile()
450
+ if (!existsSync(rulesFile)) {
451
+ mkdirSync(getConfigDir(), { recursive: true })
452
+ writeFileSync(
453
+ rulesFile,
454
+ `# Global Agent Rules\n\n<!-- Add your custom instructions here. They will be included in every conversation. -->\n`,
455
+ "utf-8",
456
+ )
457
+ }
458
+ const editor = process.env.EDITOR || "vi"
459
+ const { execSync } = await import("child_process")
460
+ execSync(`${editor} "${rulesFile}"`, { stdio: "inherit" })
461
+ } else {
462
+ const { loadInstructions } = await import("./instructions.js")
463
+ const instructions = await loadInstructions()
464
+ if (instructions.length === 0) {
465
+ console.log("No instruction rules loaded.")
466
+ console.log("")
467
+ console.log("Add rules by creating:")
468
+ console.log(` Global: ${getRulesFile()}`)
469
+ console.log(" Project: ./AGENTS.md or ./RULES.md")
470
+ console.log("")
471
+ console.log("Or add paths/URLs in ~/.min-agent/config.json:")
472
+ console.log(' { "instructions": ["./docs/rules.md", "https://..."] }')
473
+ } else {
474
+ console.log(`Loaded ${instructions.length} instruction source(s):\n`)
475
+ for (const inst of instructions) {
476
+ const firstLine = inst.split("\n")[0]
477
+ console.log(` ${firstLine}`)
478
+ }
479
+ }
480
+ }
481
+ break
482
+ }
483
+
484
+ default:
485
+ console.error(`Unknown command: ${command}`)
486
+ printUsage()
487
+ process.exit(1)
488
+ }
489
+ }
490
+
491
+ main().catch((err) => {
492
+ console.error(`\x1b[31mFatal error: ${err.message}\x1b[0m`)
493
+ process.exit(1)
494
+ })