@miphamai/cli 0.81.6 → 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.
- package/README.md +9 -9
- package/bin/daemon.ts +7 -32
- package/bin/mipham.ts +43 -29
- package/package.json +5 -2
- package/skills/standard/mipham-code-setup.SKILL.md +3 -3
- package/src/agent/sub-agent.ts +12 -1
- package/src/commands/project.ts +92 -12
- package/src/config/keys-manager.ts +3 -3
- package/src/config/loader.ts +82 -1
- package/src/core/context.ts +10 -2
- package/src/core/engine.ts +19 -4
- package/src/core/metrics.ts +8 -0
- package/src/core/paths.ts +79 -0
- package/src/core/permission-rules.ts +121 -13
- package/src/core/permission.ts +3 -0
- package/src/core/session-log.ts +11 -2
- package/src/daemon/engine-capabilities.ts +131 -0
- package/src/daemon/index.ts +4 -1
- package/src/daemon/launch.ts +287 -0
- package/src/daemon/remote-engine.ts +2 -0
- package/src/daemon/server.ts +9 -0
- package/src/daemon/session-worker.ts +7 -4
- package/src/i18n-core/locales/en-US.json +6 -7
- package/src/i18n-core/locales/zh-CN.json +6 -7
- package/src/index.tsx +79 -0
- package/src/mcp/client.ts +4 -2
- package/src/providers/anthropic.ts +2 -0
- package/src/shared/package-info.ts +1 -1
- package/src/shared/types.ts +15 -0
- package/src/skills/bundled-skills.ts +1 -1
- package/src/telemetry/consent.ts +209 -0
- package/src/telemetry/crash.ts +197 -0
- package/src/telemetry/endpoint.ts +82 -0
- package/src/telemetry/index.ts +153 -0
- package/src/telemetry/payload.ts +141 -0
- package/src/telemetry/queue.ts +95 -0
- package/src/telemetry/redact.ts +127 -0
- package/src/telemetry/transport.ts +81 -0
- package/src/tools/agent/workflow.ts +11 -4
- package/src/tools/exec/bash.ts +6 -4
- package/src/tools/exec/enter-worktree.ts +6 -5
- package/src/tools/exec/exit-worktree.ts +10 -5
- package/src/tools/exec/git.ts +18 -8
- package/src/tools/system/config.ts +3 -3
- package/src/ui/app.tsx +40 -11
- package/src/ui/commands.ts +159 -34
- package/src/workflow/primitives/agent.ts +4 -2
- package/src/core/task-runner-tasks.json +0 -14
- package/src/core/task-runner.ts +0 -163
- package/src/skills/mipham/runtime.ts +0 -66
- package/src/skills/standard/runtime.ts +0 -62
package/README.md
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
|
-
# Mipham Code v0.
|
|
1
|
+
# Mipham Code v0.81.6
|
|
2
2
|
|
|
3
|
-
**Multi-model open-core intelligent coding terminal** —
|
|
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
|
-
- **
|
|
19
|
-
- **
|
|
20
|
-
- **
|
|
21
|
-
- **
|
|
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
|
|
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
|
|
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
|
|
5
|
-
*
|
|
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 {
|
|
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
|
-
|
|
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
|
|
339
|
-
const
|
|
340
|
-
|
|
341
|
-
|
|
342
|
-
|
|
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
|
-
|
|
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
|
|
401
|
-
|
|
402
|
-
|
|
403
|
-
|
|
404
|
-
|
|
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.
|
|
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
|
|
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
|
|
289
|
+
Mipham Code ships with 28 built-in skills loaded automatically:
|
|
290
290
|
|
|
291
|
-
- **Standard (
|
|
292
|
-
- **Mipham (
|
|
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
|
|
package/src/agent/sub-agent.ts
CHANGED
|
@@ -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
|
})
|
package/src/commands/project.ts
CHANGED
|
@@ -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
|
-
|
|
37
|
-
|
|
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
|
|
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 (${
|
|
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 (${
|
|
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
|
-
${
|
|
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
|
-
|
|
388
|
-
const
|
|
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
|
|
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 (
|
|
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
|
|
7
|
-
const KEYS_FILE = join(
|
|
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(
|
|
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 })
|
package/src/config/loader.ts
CHANGED
|
@@ -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')
|
package/src/core/context.ts
CHANGED
|
@@ -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: [
|
|
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
|
})
|