@miphamai/cli 0.81.5 → 0.81.7

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 (51) hide show
  1. package/README.md +9 -9
  2. package/bin/daemon.ts +7 -32
  3. package/bin/mipham.ts +43 -29
  4. package/package.json +5 -2
  5. package/skills/standard/mipham-code-setup.SKILL.md +3 -3
  6. package/src/agent/sub-agent.ts +12 -1
  7. package/src/commands/project.ts +92 -12
  8. package/src/config/keys-manager.ts +3 -3
  9. package/src/config/loader.ts +82 -1
  10. package/src/core/context.ts +10 -2
  11. package/src/core/engine.ts +32 -4
  12. package/src/core/metrics.ts +8 -0
  13. package/src/core/paths.ts +79 -0
  14. package/src/core/permission-rules.ts +145 -13
  15. package/src/core/permission.ts +3 -0
  16. package/src/core/session-log.ts +11 -2
  17. package/src/daemon/engine-capabilities.ts +131 -0
  18. package/src/daemon/index.ts +4 -1
  19. package/src/daemon/launch.ts +287 -0
  20. package/src/daemon/remote-engine.ts +5 -0
  21. package/src/daemon/server.ts +9 -0
  22. package/src/daemon/session-worker.ts +7 -4
  23. package/src/i18n-core/locales/en-US.json +6 -7
  24. package/src/i18n-core/locales/zh-CN.json +6 -7
  25. package/src/index.tsx +79 -0
  26. package/src/mcp/client.ts +109 -8
  27. package/src/providers/anthropic.ts +2 -0
  28. package/src/shared/package-info.ts +1 -1
  29. package/src/shared/types.ts +15 -0
  30. package/src/skills/bundled-skills.ts +1 -1
  31. package/src/telemetry/consent.ts +209 -0
  32. package/src/telemetry/crash.ts +197 -0
  33. package/src/telemetry/endpoint.ts +82 -0
  34. package/src/telemetry/index.ts +153 -0
  35. package/src/telemetry/payload.ts +141 -0
  36. package/src/telemetry/queue.ts +95 -0
  37. package/src/telemetry/redact.ts +127 -0
  38. package/src/telemetry/transport.ts +81 -0
  39. package/src/tools/agent/workflow.ts +11 -4
  40. package/src/tools/exec/bash.ts +6 -4
  41. package/src/tools/exec/enter-worktree.ts +6 -5
  42. package/src/tools/exec/exit-worktree.ts +10 -5
  43. package/src/tools/exec/git.ts +18 -8
  44. package/src/tools/system/config.ts +3 -3
  45. package/src/ui/app.tsx +47 -11
  46. package/src/ui/commands.ts +159 -34
  47. package/src/workflow/primitives/agent.ts +4 -2
  48. package/src/core/task-runner-tasks.json +0 -14
  49. package/src/core/task-runner.ts +0 -163
  50. package/src/skills/mipham/runtime.ts +0 -66
  51. package/src/skills/standard/runtime.ts +0 -62
package/README.md CHANGED
@@ -1,6 +1,6 @@
1
- # Mipham Code v0.5.9
1
+ # Mipham Code v0.81.6
2
2
 
3
- **Multi-model open-core intelligent coding terminal** — 9 AI providers, 66 commands, 16 tools, 14 skills + marketplace, self-update. AI-assisted code generation, security auditing, MCP protocol, and extensible skills — in a single CLI.
3
+ **Multi-model open-core intelligent coding terminal** — 12 AI providers, 137 commands, 31 tools, 28 skills + marketplace, self-update. AI-assisted code generation, security auditing, MCP protocol, and extensible skills — in a single CLI.
4
4
 
5
5
  Built by [One Mipham Corporation](https://onemipham.com) (北京华安麦逄科技有限公司) | Brand: MiphamAI
6
6
 
@@ -15,13 +15,13 @@ Requirements: **Bun 1.2+** (recommended) or **Node.js 22+**
15
15
 
16
16
  ## Features
17
17
 
18
- - **9 AI Providers** — Anthropic Claude · OpenAI GPT · DeepSeek · Kimi (Moonshot) · Google Gemini · Doubao 豆包 · Tencent Hunyuan 混元 · Qwen 通义千问 · MiphamAI
19
- - **66 Slash Commands** — Interactive command picker (↑↓ Enter Esc), intuitive command interface, zero re-learning
20
- - **16 Built-in Tools** — File ops, shell execution, agent dispatch, MCP integration, web search
21
- - **14 Skills + Marketplace** — 11 standard + 3 Mipham-exclusive skills. `/browse-skills` `/install-skill` community marketplace
18
+ - **12 AI Providers** — Anthropic Claude · OpenAI GPT · DeepSeek · Kimi (Moonshot) · Google Gemini · Doubao 豆包 · Tencent Hunyuan 混元 · Qwen 通义千问 · MiniMax(国内 / 国际)· Ollama(本地)· MiphamAI
19
+ - **137 Slash Commands** — Interactive command picker (↑↓ Enter Esc), intuitive command interface, zero re-learning
20
+ - **31 Built-in Tools** — File ops, shell execution, agent dispatch, MCP integration, web search
21
+ - **28 Skills + Marketplace** — 22 standard + 6 Mipham-exclusive skills. `/browse-skills` `/install-skill` community marketplace
22
22
  - **Self-Update** — `mipham update` / `/upgrade` — one command to check, backup config, upgrade, restore. API keys preserved
23
23
  - **Smart Recommendations** — `/recommend` analyzes your project, suggests skills, providers, and config
24
- - **One-Click Config** — `/init` generates config.yml with all 9 providers pre-populated (just replace API keys)
24
+ - **One-Click Config** — `/init` generates config.yml with the providers pre-populated (just replace API keys)
25
25
  - **MCP Protocol** — Full JSON-RPC 2.0 stdio transport for external server integration
26
26
  - **Security Hardened** — Path sandbox · SSRF protection · Bash blacklist · Permission gating · Parameter validation · command injection prevention · API key redaction
27
27
  - **Config Resilience** — YAML error recovery, deep merge providers, auto-backup (5 copies), corruption auto-restore
@@ -34,7 +34,7 @@ Requirements: **Bun 1.2+** (recommended) or **Node.js 22+**
34
34
  mipham
35
35
 
36
36
  # First run auto-initializes config. Or:
37
- /init # Generate ~/.mipham/config.yml with 9 providers pre-configured
37
+ /init # Generate ~/.mipham/config.yml with providers pre-configured
38
38
 
39
39
  # Set API keys (env vars or config.yml)
40
40
  export DEEPSEEK_API_KEY="sk-..."
@@ -73,7 +73,7 @@ Press **Ctrl+P** for model picker · **Shift+Tab** to cycle permission mode · *
73
73
  - [Website (International)](https://mipham.ai/mipham-code)
74
74
  - [国内站](https://onemipham.com/mipham-code)
75
75
  - [GitHub](https://github.com/One-Mipham/mipham-code)
76
- - [Product Specification](https://github.com/One-Mipham/mipham-code/blob/main/PRODUCT.md)
76
+ - [Product Specification (v1.0.0, 2026-06-10 snapshot)](https://github.com/One-Mipham/mipham-code/blob/main/PRODUCT.md) — frozen, not current
77
77
  - [npm](https://www.npmjs.com/package/@miphamai/cli)
78
78
 
79
79
  ## License
package/bin/daemon.ts CHANGED
@@ -1,40 +1,15 @@
1
1
  #!/usr/bin/env bun
2
2
 
3
3
  /**
4
- * Mipham Code Daemon — standalone background process.
5
- * Spawned by `mipham daemon start`.
4
+ * Mipham Code Daemon — standalone source-mode entry.
5
+ *
6
+ * The compiled binary reaches the same code through the hidden `__daemon`
7
+ * branch in bin/mipham.ts. Both call runDaemonProcess() so there is exactly
8
+ * one implementation of the daemon process body.
6
9
  *
7
10
  * Usage: bun run bin/daemon.ts [--port PORT] [--bind HOST]
8
11
  */
9
12
 
10
- import { startDaemon } from '../src/daemon/index'
11
-
12
- const args = process.argv.slice(2)
13
-
14
- // Parse --port and --bind from CLI args (passed by mipham CLI)
15
- for (let i = 0; i < args.length; i++) {
16
- if (args[i] === '--port' && args[i + 1]) {
17
- process.env.MIPHAM_PORT = args[i + 1]!
18
- }
19
- if (args[i] === '--bind' && args[i + 1]) {
20
- process.env.MIPHAM_BIND = args[i + 1]!
21
- }
22
- }
23
-
24
- const { port } = await startDaemon()
25
-
26
- console.log(`Daemon running on http://127.0.0.1:${port}`)
27
- console.log(`PID: ${process.pid}`)
28
-
29
- // Keep process alive
30
- process.on('SIGTERM', async () => {
31
- const { stopDaemon } = await import('../src/daemon/index')
32
- await stopDaemon(true)
33
- process.exit(0)
34
- })
13
+ import { runDaemonProcess } from '../src/daemon/launch'
35
14
 
36
- process.on('SIGINT', async () => {
37
- const { stopDaemon } = await import('../src/daemon/index')
38
- await stopDaemon(true)
39
- process.exit(0)
40
- })
15
+ await runDaemonProcess()
package/bin/mipham.ts CHANGED
@@ -6,6 +6,8 @@
6
6
 
7
7
  export {} // ensure module scope (prevents global name collisions)
8
8
 
9
+ import { DAEMON_ENTRY, userArgs } from '../src/daemon/launch'
10
+
9
11
  async function runWorkflowCLI(): Promise<boolean> {
10
12
  const args = process.argv.slice(2)
11
13
  if (args[0] !== 'workflow') return false
@@ -324,8 +326,6 @@ async function runDaemonCLI(): Promise<boolean> {
324
326
 
325
327
  const subcmd = args[1]
326
328
 
327
- const { spawn } = await import('node:child_process')
328
-
329
329
  if (subcmd === 'start') {
330
330
  const { getDaemonStatus } = await import('../src/daemon/index')
331
331
  const status = getDaemonStatus()
@@ -335,23 +335,13 @@ async function runDaemonCLI(): Promise<boolean> {
335
335
  }
336
336
 
337
337
  console.log('Starting daemon...')
338
- const daemonScript = new URL('./daemon.ts', import.meta.url).pathname
339
- const child = spawn('bun', ['run', daemonScript], {
340
- detached: true,
341
- stdio: 'ignore',
342
- env: { ...process.env },
343
- })
344
- child.unref()
345
-
346
- // Wait briefly for daemon to write PID/port files
347
- await new Promise((resolve) => setTimeout(resolve, 1000))
348
-
349
- const newStatus = getDaemonStatus()
350
- if (newStatus) {
351
- console.log(`Daemon started (PID: ${newStatus.pid}, Port: ${newStatus.port})`)
352
- } else {
353
- console.log('Daemon started (PID unknown — check `mipham daemon status`)')
338
+ const { startDetachedDaemon } = await import('../src/daemon/launch')
339
+ const launch = await startDetachedDaemon()
340
+ if (!launch.ok) {
341
+ console.error(`Failed to start daemon: ${launch.reason}`)
342
+ process.exit(1)
354
343
  }
344
+ console.log(`Daemon started (PID: ${launch.pid}, Port: ${launch.port})`)
355
345
  process.exit(0)
356
346
  }
357
347
 
@@ -387,6 +377,7 @@ async function runDaemonCLI(): Promise<boolean> {
387
377
 
388
378
  if (subcmd === 'restart') {
389
379
  const { getDaemonStatus } = await import('../src/daemon/index')
380
+ const { startDetachedDaemon, waitForDaemonExit } = await import('../src/daemon/launch')
390
381
  const status = getDaemonStatus()
391
382
  if (status) {
392
383
  try {
@@ -394,18 +385,28 @@ async function runDaemonCLI(): Promise<boolean> {
394
385
  } catch {
395
386
  // Process may have already exited
396
387
  }
397
- await new Promise((resolve) => setTimeout(resolve, 500))
388
+ // A fixed sleep here is a guess, and the guess is load-bearing:
389
+ // startDetachedDaemon() opens by probing getStatus(), so a pid file the old
390
+ // daemon has not unlinked yet reads as "already running" — restart would
391
+ // then report success for a start it never performed. Wait for the old
392
+ // daemon to be *gone*; if it never goes, refuse instead of starting blind.
393
+ const waitStarted = Date.now()
394
+ const gone = await waitForDaemonExit()
395
+ if (!gone) {
396
+ const waitedMs = Date.now() - waitStarted
397
+ console.error(
398
+ `Refusing to restart: old daemon (PID: ${status.pid}) is still running after ${waitedMs}ms`,
399
+ )
400
+ process.exit(1)
401
+ }
398
402
  }
399
403
 
400
- const daemonScript = new URL('./daemon.ts', import.meta.url).pathname
401
- const child = spawn('bun', ['run', daemonScript], {
402
- detached: true,
403
- stdio: 'ignore',
404
- env: { ...process.env },
405
- })
406
- child.unref()
407
- await new Promise((resolve) => setTimeout(resolve, 1000))
408
- console.log('Daemon restarted.')
404
+ const launch = await startDetachedDaemon()
405
+ if (!launch.ok) {
406
+ console.error(`Failed to restart daemon: ${launch.reason}`)
407
+ process.exit(1)
408
+ }
409
+ console.log(`Daemon restarted (PID: ${launch.pid}, Port: ${launch.port})`)
409
410
  process.exit(0)
410
411
  }
411
412
 
@@ -1095,6 +1096,19 @@ async function runInitCLI(): Promise<boolean> {
1095
1096
  }
1096
1097
 
1097
1098
  async function main() {
1099
+ // ── Hidden daemon entry ────────────────────────────────────────────────
1100
+ // `daemon start` re-execs this same program with `__daemon` (see
1101
+ // src/daemon/launch.ts). This must come first: everything below assumes an
1102
+ // interactive TTY — the stty block would run against a detached child whose
1103
+ // stdio is the daemon log. The sentinel is stripped here because the two
1104
+ // entry points have different argv shapes (see runDaemonProcess).
1105
+ const argv = userArgs(process.argv)
1106
+ if (argv[0] === DAEMON_ENTRY) {
1107
+ const { runDaemonProcess } = await import('../src/daemon/launch')
1108
+ await runDaemonProcess(argv.slice(1))
1109
+ return
1110
+ }
1111
+
1098
1112
  // ── Deleted-cwd guard ──────────────────────────────────────────────────
1099
1113
  // `process.cwd()` throws ENOENT when the directory the process was launched
1100
1114
  // from no longer exists (e.g. a removed git worktree). Print a clear message
@@ -1309,4 +1323,4 @@ Docs: https://onemipham.com/mipham-code
1309
1323
  }
1310
1324
  }
1311
1325
 
1312
- main()
1326
+ void main()
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@miphamai/cli",
3
- "version": "0.81.5",
3
+ "version": "0.81.7",
4
4
  "description": "Mipham Code — Multi-model open-core intelligent coding terminal by MiphamAI",
5
5
  "keywords": [
6
6
  "ai",
@@ -40,7 +40,8 @@
40
40
  "typecheck": "tsc --noEmit",
41
41
  "test": "vitest run",
42
42
  "knip": "knip --production --no-progress --no-exit-code",
43
- "coverage": "vitest run --coverage --coverage.provider=v8 --coverage.include='src/**/*.{ts,tsx}' --coverage.reporter=text-summary --coverage.reporter=json-summary"
43
+ "coverage": "vitest run --coverage",
44
+ "mutate": "stryker run"
44
45
  },
45
46
  "dependencies": {
46
47
  "@larksuiteoapi/node-sdk": "^1.73.0",
@@ -53,6 +54,8 @@
53
54
  },
54
55
  "devDependencies": {
55
56
  "@mipham/shared": "workspace:*",
57
+ "@stryker-mutator/core": "10.0.0",
58
+ "@stryker-mutator/vitest-runner": "10.0.0",
56
59
  "@types/bun": "^1.4.2",
57
60
  "@types/node": "^22.19.19",
58
61
  "@types/react": "^19.3.0",
@@ -286,10 +286,10 @@ providers:
286
286
 
287
287
  ### 5.1 — Built-in Skills
288
288
 
289
- Mipham Code ships with 17 built-in skills loaded automatically:
289
+ Mipham Code ships with 28 built-in skills loaded automatically:
290
290
 
291
- - **Standard (14)**: code-review, compassionate-communication, doc-generator, github-ops, memory, mipham-code-setup, security-review, self-review, superpower, systematic-debugging, tdd, test-driven-development, web-access, web-search
292
- - **Mipham (3)**: om-artifact, om-model-optimize, om-security
291
+ - **Standard (22)**: code-review, codebase-design, compassionate-communication, debug-loop, doc-generator, domain-modeling, github-ops, grill-with-docs, implement, memory, mipham-code-setup, research, safe-coding, security-review, self-review, superpower, tdd, to-spec, triage, trim-process-prose, web-access, web-search
292
+ - **Mipham (6)**: doc-sync, om-artifact, om-model-optimize, om-security, save-to-wiki, self-audit
293
293
 
294
294
  ### 5.2 — Community Skills
295
295
 
@@ -10,6 +10,7 @@ import type { PermissionSystem } from '../core/permission'
10
10
  import { AgentExperience } from './agent-experience'
11
11
  import { PatternAnalyzer } from './pattern-analyzer.js'
12
12
  import { getWorkspaceTrust } from '../core/workspace-trust'
13
+ import { recordToolCall } from '../telemetry/index'
13
14
  import type { ExperienceRuleEngine } from '../core/rule-engine.js'
14
15
 
15
16
  // Singleton instances (created lazily)
@@ -85,7 +86,7 @@ export class SubAgent {
85
86
  // Register completion callback for hook firing
86
87
  bgRegistry.onComplete(taskId, (task) => {
87
88
  if (this.hookEngine) {
88
- this.hookEngine.executeSubagentStop(
89
+ void this.hookEngine.executeSubagentStop(
89
90
  agentType,
90
91
  description,
91
92
  taskId,
@@ -397,6 +398,15 @@ export class SubAgent {
397
398
  throw new DOMException('Aborted', 'AbortError')
398
399
  }
399
400
 
401
+ // Counted here, at dispatch, mirroring `Engine.executeTool`'s entry
402
+ // increment — this loop bypasses `executeTool` entirely and
403
+ // reimplements the hook/permission steps itself, so without this line
404
+ // every tool call made inside a sub-agent (and therefore inside a
405
+ // workflow, which derives sub-agents) is invisible to the metrics
406
+ // registry. Same failure shape as the two "one of two paths wired"
407
+ // bugs this repo has already recorded.
408
+ recordToolCall(tu.name)
409
+
400
410
  const tool = this.toolRegistry.get(tu.name)
401
411
  if (!tool) {
402
412
  currentMessages.push({
@@ -490,6 +500,7 @@ export class SubAgent {
490
500
  content: displayResult.success
491
501
  ? displayResult.content
492
502
  : displayResult.error || displayResult.content,
503
+ ...(displayResult.success ? {} : { is_error: true }),
493
504
  },
494
505
  ],
495
506
  })
@@ -8,6 +8,7 @@
8
8
  import type { CommandHandler, CommandContext, CommandResult } from '../ui/commands.js'
9
9
  import { getWorkspaceTrust } from '../core/workspace-trust'
10
10
  import { atomicWriteFileSync } from '../shared/atomic-write'
11
+ import { CLOUD_PROVIDERS } from '../config/wizard-config'
11
12
  import { homedir } from 'node:os'
12
13
 
13
14
  export {
@@ -21,6 +22,18 @@ export {
21
22
  trustCmd,
22
23
  }
23
24
 
25
+ /**
26
+ * provider id → 环境变量名,例如 `minimax-global` → `MINIMAX_GLOBAL_API_KEY`。
27
+ *
28
+ * 非字母数字字符必须归一为 `_`:生成的模板里写着 `export <名字>=...`,
29
+ * 而带连字符的 `export MINIMAX-GLOBAL_API_KEY=…` 是 shell 语法错误
30
+ * (`openai-compat.ts` 的 `${VAR}` 解析器另一条 `$VAR` 分支也只认
31
+ * `[A-Z_][A-Z0-9_]*`)。`/init` 过去只输出无连字符的 mipham,所以这条一直不可达。
32
+ */
33
+ function envVarFor(providerId: string): string {
34
+ return `${providerId.toUpperCase().replace(/[^A-Z0-9]+/g, '_')}_API_KEY`
35
+ }
36
+
24
37
  const initCmd: CommandHandler = async (ctx) => {
25
38
  const { existsSync, mkdirSync } = await import('node:fs')
26
39
  const { join } = await import('node:path')
@@ -33,8 +46,11 @@ const initCmd: CommandHandler = async (ctx) => {
33
46
  if (!existsSync(userConfigPath)) {
34
47
  mkdirSync(join(home, '.mipham'), { recursive: true })
35
48
 
36
- const activeProviders = ctx.config.providers.filter((p) => p.status === 'active')
37
- const providerYaml = activeProviders
49
+ // 生成物必须来自随包发布的清单,不能来自 ctx.config —— 后者的 status 表达
50
+ // 的是「用户停用了哪家」这个**运行时**状态,而 12 家里只有 mipham 声明了它
51
+ // (其余 status 全在 model 层),拿它筛会恒得 1 家。
52
+ const presetProviders = CLOUD_PROVIDERS
53
+ const providerYaml = presetProviders
38
54
  .map((p) => {
39
55
  const tips: Record<string, string> = {
40
56
  anthropic: '# Get key: https://console.anthropic.com/',
@@ -51,7 +67,7 @@ const initCmd: CommandHandler = async (ctx) => {
51
67
  return ` ${comment}
52
68
  - id: ${p.id}
53
69
  name: "${p.name}"${baseUrlLine}
54
- apiKey: "\${${p.id.toUpperCase()}_API_KEY}"`
70
+ apiKey: "\${${envVarFor(p.id)}}"`
55
71
  })
56
72
  .join('\n\n')
57
73
 
@@ -75,7 +91,7 @@ defaultProvider: ${ctx.providerId}
75
91
  defaultModel: ${ctx.modelId}
76
92
  permission: ask
77
93
 
78
- # ── Providers (${activeProviders.length} pre-configured — just add your API keys) ──
94
+ # ── Providers (${presetProviders.length} pre-configured — just add your API keys) ──
79
95
  providers:
80
96
  ${providerYaml}
81
97
  `
@@ -84,14 +100,14 @@ ${providerYaml}
84
100
  return {
85
101
  content: `✅ Mipham Code initialized!
86
102
 
87
- Created: ~/.mipham/config.yml (${activeProviders.length} providers pre-configured)
103
+ Created: ~/.mipham/config.yml (${presetProviders.length} providers pre-configured)
88
104
 
89
105
  Next steps:
90
106
  1. Edit ~/.mipham/config.yml — replace API key placeholders with your real keys
91
107
  2. Run mipham to start
92
108
 
93
109
  Providers configured:
94
- ${activeProviders.map((p) => ` • ${p.name} — ${p.id.toUpperCase()}_API_KEY`).join('\n')}
110
+ ${presetProviders.map((p) => ` • ${p.name} — ${envVarFor(p.id)}`).join('\n')}
95
111
 
96
112
  Tip: /setup for the full 6-step wizard.`,
97
113
  }
@@ -105,10 +121,64 @@ Run /setup for the full wizard, or /config to view current settings.`,
105
121
  }
106
122
  }
107
123
 
108
- const permissionsCmd: CommandHandler = (ctx) => {
124
+ const permissionsCmd: CommandHandler = async (ctx, args) => {
109
125
  const c = ctx.engine.getContext()
110
126
  const msgs = c.getMessages()
111
127
 
128
+ // ── Rule persistence: allow/deny/remove <rule> [--user] ──
129
+ const positional = args.filter((a) => !a.startsWith('--'))
130
+ const scope: 'project' | 'user' = args.includes('--user') ? 'user' : 'project'
131
+ const verb = positional[0]
132
+ const rule = positional[1]
133
+
134
+ if (verb === 'allow' || verb === 'deny' || verb === 'remove') {
135
+ const { validateRulePattern } = await import('../core/permission-rules')
136
+ const { addSettingsRule, removeSettingsRule, settingsPathFor } =
137
+ await import('../config/loader')
138
+
139
+ // A rule that can't match is worse than no rule: it reads as protection
140
+ // that isn't there. Validate before writing.
141
+ const invalid = validateRulePattern(rule ?? '')
142
+ const usage =
143
+ `Usage: /permissions <allow|deny|remove> <rule> [--user]\n\n` +
144
+ ` rule Tool pattern — "Bash" or "Bash(npm test)".\n` +
145
+ ` --user Write to ~/.mipham/settings.json instead of .mipham/settings.json.`
146
+
147
+ if (verb !== 'remove' && !rule) {
148
+ return { content: `Missing rule.\n\n${usage}` }
149
+ }
150
+ if (invalid && verb !== 'remove') {
151
+ return { content: `Invalid rule "${rule}": ${invalid}.\n\n${usage}` }
152
+ }
153
+
154
+ const perm = ctx.engine.getPermission()
155
+
156
+ if (verb === 'remove') {
157
+ const removed = removeSettingsRule(rule!, scope)
158
+ if (!removed) {
159
+ return { content: `No rule "${rule}" in ${settingsPathFor(scope)}.` }
160
+ }
161
+ perm.removeRule(rule!)
162
+ return {
163
+ content: `Removed from ${removed.path}\n\npermissions.${removed.key}:\n ${rule}`,
164
+ }
165
+ }
166
+
167
+ const path = addSettingsRule(verb, rule!, scope)
168
+ if (verb === 'allow') perm.allow(rule!)
169
+ else perm.deny(rule!)
170
+ return {
171
+ content:
172
+ `Added to ${path}\n\n` +
173
+ `permissions.${verb}:\n ${rule}\n\n` +
174
+ `This rule persists across sessions and applies from now on.`,
175
+ }
176
+ }
177
+
178
+ const settings = await import('../config/loader').then((m) => m.loadSettingsJson())
179
+ const ruleLines = (label: string, rules: string[]) =>
180
+ rules.length > 0 ? ` ${label}\n${rules.map((r) => ` ${r}`).join('\n')}` : ` no ${label}`
181
+
112
182
  return {
113
183
  content: `─ Permission Settings ─
114
184
 
@@ -125,6 +195,15 @@ Switch mode with Shift+Tab. Modes (least → most permissive):
125
195
  To let Bash run without asking: press Shift+Tab until the status line shows
126
196
  "acceptEdits", then send your message again.
127
197
 
198
+ Persisted rules (settings.json):
199
+ ${ruleLines('allow', settings.permissions.allow)}
200
+ ${ruleLines('deny', settings.permissions.deny)}
201
+
202
+ Persist a rule with:
203
+ /permissions allow "Bash(npm test)" → .mipham/settings.json
204
+ /permissions deny "Bash(rm *)" --user → ~/.mipham/settings.json
205
+ /permissions remove "Bash(npm test)"
206
+
128
207
  Current directory permissions:
129
208
  CWD: ${process.cwd()}
130
209
 
@@ -384,8 +463,9 @@ async function setupStep1(ctx: CommandContext): Promise<CommandResult> {
384
463
  if (!existsSync(configPath)) {
385
464
  // Generate a user-friendly config with all providers pre-populated.
386
465
  // Users just need to replace the API key placeholders with their real keys.
387
- const activeProviders = ctx.config.providers.filter((p) => p.status === 'active')
388
- const providerYaml = activeProviders
466
+ // 同 /init:清单来自 CLOUD_PROVIDERS,与运行时 status 无关(见上方注释)。
467
+ const presetProviders = CLOUD_PROVIDERS
468
+ const providerYaml = presetProviders
389
469
  .map((p) => {
390
470
  const comment =
391
471
  p.id === 'anthropic'
@@ -409,12 +489,12 @@ async function setupStep1(ctx: CommandContext): Promise<CommandResult> {
409
489
  return ` ${comment}
410
490
  - id: ${p.id}
411
491
  name: "${p.name}"${baseUrlLine}
412
- apiKey: "\${${p.id.toUpperCase()}_API_KEY}"`
492
+ apiKey: "\${${envVarFor(p.id)}}"`
413
493
  })
414
494
  .join('\n\n')
415
495
 
416
496
  const defaultConfig = `# Mipham Code — User Configuration
417
- # Location: ~/.mipham/config.yml
497
+ # Location: <project>/.mipham/config.yml (project-level; ~/.mipham/config.yml is the user-level file — both are read)
418
498
  # Docs: https://mipham.ai/code/docs/config
419
499
  #
420
500
  # ═══ Quick Start ═══
@@ -433,7 +513,7 @@ defaultProvider: ${ctx.providerId}
433
513
  defaultModel: ${ctx.modelId}
434
514
  permission: ask
435
515
 
436
- # ── Providers (8 configured, just add your API keys) ──
516
+ # ── Providers (${presetProviders.length} pre-configured — just add your API keys) ──
437
517
  providers:
438
518
  ${providerYaml}
439
519
  `
@@ -3,8 +3,8 @@ import { join, dirname } from 'node:path'
3
3
  import { homedir } from 'node:os'
4
4
  import { saveProviderApiKey } from './loader'
5
5
 
6
- const MIPHAM_DIR = join(homedir(), '.mipham')
7
- const KEYS_FILE = join(MIPHAM_DIR, 'keys.json')
6
+ const MIPHAM_HOME = join(homedir(), '.mipham')
7
+ const KEYS_FILE = join(MIPHAM_HOME, 'keys.json')
8
8
  const EXPIRY_DAYS = 90
9
9
 
10
10
  export interface KeyEntry {
@@ -84,7 +84,7 @@ export class KeyManager {
84
84
 
85
85
  // Backup old entry if it exists
86
86
  if (existing) {
87
- const backupDir = join(MIPHAM_DIR, 'keys')
87
+ const backupDir = join(MIPHAM_HOME, 'keys')
88
88
  mkdirSync(backupDir, { recursive: true })
89
89
  const backupPath = join(backupDir, `${provider}.backup`)
90
90
  writeFileSync(backupPath, JSON.stringify(existing, null, 2) + '\n', { mode: 0o600 })
@@ -7,7 +7,7 @@ import {
7
7
  unlinkSync,
8
8
  chmodSync,
9
9
  } from 'node:fs'
10
- import { join } from 'node:path'
10
+ import { join, dirname } from 'node:path'
11
11
  import { homedir } from 'node:os'
12
12
  import { parse as parseYaml, stringify as stringifyYaml } from 'yaml'
13
13
  import { atomicWriteFileSync } from '../shared/atomic-write'
@@ -271,6 +271,87 @@ export function loadSettingsJson(cwd: string = process.cwd()): SettingsJson {
271
271
  return { hooks, permissions }
272
272
  }
273
273
 
274
+ /** Which settings.json a permission rule is persisted to. */
275
+ export type SettingsScope = 'project' | 'user'
276
+
277
+ export function settingsPathFor(scope: SettingsScope, cwd: string = process.cwd()): string {
278
+ return scope === 'user'
279
+ ? join(MIPHAM_HOME, 'settings.json')
280
+ : join(cwd, '.mipham', 'settings.json')
281
+ }
282
+
283
+ /**
284
+ * Read a settings.json as a plain object, preserving any key we don't model
285
+ * (hooks, and anything a future version adds). A malformed file is an error,
286
+ * not something to clobber — the user's other settings live in the same file.
287
+ */
288
+ export function readSettingsDoc(path: string): Record<string, unknown> {
289
+ if (!existsSync(path)) return {}
290
+ let parsed: unknown
291
+ try {
292
+ parsed = JSON.parse(readFileSync(path, 'utf-8'))
293
+ } catch {
294
+ throw new Error(`${path} is not valid JSON. Fix or remove it, then retry.`)
295
+ }
296
+ if (!parsed || typeof parsed !== 'object' || Array.isArray(parsed)) {
297
+ throw new Error(`${path} does not contain a JSON object. Fix or remove it, then retry.`)
298
+ }
299
+ return parsed as Record<string, unknown>
300
+ }
301
+
302
+ export function writeSettingsDoc(path: string, doc: Record<string, unknown>): void {
303
+ mkdirSync(dirname(path), { recursive: true })
304
+ atomicWriteFileSync(path, JSON.stringify(doc, null, 2) + '\n')
305
+ }
306
+
307
+ /**
308
+ * Persist one rule into `permissions.<key>` of a scope's settings.json.
309
+ * Idempotent: re-adding an existing rule leaves the file unchanged.
310
+ * Returns the path written.
311
+ */
312
+ export function addSettingsRule(
313
+ key: 'allow' | 'deny',
314
+ rule: string,
315
+ scope: SettingsScope = 'project',
316
+ cwd: string = process.cwd(),
317
+ ): string {
318
+ const path = settingsPathFor(scope, cwd)
319
+ const doc = readSettingsDoc(path)
320
+ const perms = (doc.permissions ?? {}) as Record<string, unknown>
321
+ const list = Array.isArray(perms[key]) ? perms[key] : []
322
+ const strings = (list as unknown[]).filter((r): r is string => typeof r === 'string')
323
+ if (!strings.includes(rule)) strings.push(rule)
324
+ perms[key] = strings
325
+ doc.permissions = perms
326
+ writeSettingsDoc(path, doc)
327
+ return path
328
+ }
329
+
330
+ /**
331
+ * Remove a rule from whichever `permissions` list holds it. Returns the path
332
+ * and key it was removed from, or null when the rule was not present.
333
+ */
334
+ export function removeSettingsRule(
335
+ rule: string,
336
+ scope: SettingsScope = 'project',
337
+ cwd: string = process.cwd(),
338
+ ): { path: string; key: 'allow' | 'deny' } | null {
339
+ const path = settingsPathFor(scope, cwd)
340
+ const doc = readSettingsDoc(path)
341
+ const perms = (doc.permissions ?? {}) as Record<string, unknown>
342
+
343
+ for (const key of ['allow', 'deny'] as const) {
344
+ if (!Array.isArray(perms[key])) continue
345
+ const list = (perms[key] as unknown[]).filter((r): r is string => typeof r === 'string')
346
+ if (!list.includes(rule)) continue
347
+ perms[key] = list.filter((r) => r !== rule)
348
+ doc.permissions = perms
349
+ writeSettingsDoc(path, doc)
350
+ return { path, key }
351
+ }
352
+ return null
353
+ }
354
+
274
355
  export function loadConfig(cwd: string = process.cwd()): MiphamConfig {
275
356
  const configPath = join(cwd, '.mipham', 'config.yml')
276
357
  const userConfigPath = join(MIPHAM_HOME, 'config.yml')
@@ -146,7 +146,15 @@ export class ContextManager {
146
146
  const content = result.success ? result.content : result.error || result.content
147
147
  const msg: Message = {
148
148
  role: 'user',
149
- content: [{ type: 'tool_result', tool_use_id: toolUseId, content }],
149
+ content: [
150
+ {
151
+ type: 'tool_result',
152
+ tool_use_id: toolUseId,
153
+ content,
154
+ // 成功不写该键 —— 与 session-log.deriveMessages 的展平式对称(保字节级互逆)
155
+ ...(result.success ? {} : { is_error: true }),
156
+ },
157
+ ],
150
158
  }
151
159
  this.messages.push(msg)
152
160
  this.estimatedTokens += this.estimateTokens(JSON.stringify(msg.content))
@@ -369,7 +377,7 @@ export class ContextManager {
369
377
  if (usage > microThreshold) {
370
378
  this.compressionPending = true
371
379
  // Schedule microcompact asynchronously (fire-and-forget)
372
- Promise.resolve().then(() => {
380
+ void Promise.resolve().then(() => {
373
381
  this.runMicrocompact()
374
382
  this.compressionPending = false
375
383
  })