@miphamai/cli 0.80.1 → 0.81.1

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/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@miphamai/cli",
3
- "version": "0.80.1",
3
+ "version": "0.81.1",
4
4
  "description": "Mipham Code — Multi-model open-core intelligent coding terminal by MiphamAI",
5
5
  "keywords": [
6
6
  "ai",
@@ -51,10 +51,10 @@
51
51
  },
52
52
  "devDependencies": {
53
53
  "@mipham/shared": "workspace:*",
54
- "@types/bun": "^1.3.14",
54
+ "@types/bun": "^1.4.2",
55
55
  "@types/node": "^22.19.19",
56
56
  "@types/react": "^19.3.0",
57
57
  "ink-testing-library": "^4.0.0",
58
- "vitest": "^4.1.7"
58
+ "vitest": "^5.0.0"
59
59
  }
60
60
  }
@@ -1,5 +1,6 @@
1
1
  import { mkdirSync, readFileSync, writeFileSync, existsSync, unlinkSync } from 'node:fs'
2
2
  import { join } from 'node:path'
3
+ import { homedir } from 'node:os'
3
4
  import { ExperienceRuleExtractor, type ExperienceRule } from './experience-rules.js'
4
5
 
5
6
  const MAX_EXPERIENCES = 20
@@ -10,7 +11,7 @@ export class AgentExperience {
10
11
 
11
12
  constructor(
12
13
  private readonly agentName: string,
13
- baseDir: string = join(process.env.HOME || '~', '.mipham', 'agent-memory'),
14
+ baseDir: string = join(homedir(), '.mipham', 'agent-memory'),
14
15
  ) {
15
16
  this.expDir = join(baseDir, agentName)
16
17
  this.expFile = join(this.expDir, 'experience.md')
@@ -1,5 +1,6 @@
1
1
  import { mkdirSync, readFileSync, writeFileSync, existsSync } from 'node:fs'
2
2
  import { join, dirname } from 'node:path'
3
+ import { homedir } from 'node:os'
3
4
  import type { CrsiProvenanceBridge, CrsiVerdict } from './crsi-provenance-bridge.js'
4
5
  import { isRecoverableToolFailure } from './recoverable-failure.js'
5
6
 
@@ -31,7 +32,7 @@ export class EffectivenessTracker {
31
32
  private storePath: string
32
33
  private provenanceBridge?: CrsiProvenanceBridge
33
34
 
34
- constructor(storeDir: string = join(process.env.HOME || '~', '.mipham', 'rule-engine')) {
35
+ constructor(storeDir: string = join(homedir(), '.mipham', 'rule-engine')) {
35
36
  this.storePath = join(storeDir, STORE_FILE)
36
37
  this.data = new Map()
37
38
  }
@@ -11,6 +11,7 @@
11
11
 
12
12
  import { mkdirSync, writeFileSync, existsSync, chmodSync } from 'node:fs'
13
13
  import { join, resolve } from 'node:path'
14
+ import { homedir } from 'node:os'
14
15
 
15
16
  export interface ScaffoldResult {
16
17
  created: string[]
@@ -87,7 +88,7 @@ export function scaffoldLoopKit(basePath: string): ScaffoldResult {
87
88
  const created: string[] = []
88
89
  const skipped: string[] = []
89
90
 
90
- const resolved = resolve(basePath.replace(/^~/, process.env.HOME || '~'))
91
+ const resolved = resolve(basePath.replace(/^~/, homedir()))
91
92
  const miphamDir = join(resolved, '.mipham')
92
93
 
93
94
  // ── .mipham/ root ──
@@ -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 { homedir } from 'node:os'
11
12
 
12
13
  export {
13
14
  initCmd,
@@ -311,7 +312,7 @@ const setupCmd: CommandHandler = async (ctx, args) => {
311
312
  const { existsSync } = await import('node:fs')
312
313
  const { join } = await import('node:path')
313
314
  const cwd = process.cwd()
314
- const home = process.env.HOME || '~'
315
+ const home = homedir()
315
316
 
316
317
  const hasProjectMipham = existsSync(join(cwd, 'MIPHAM.md'))
317
318
  const hasProjectConfig = existsSync(join(cwd, '.mipham', 'config.yml'))
@@ -688,7 +689,7 @@ permission boundaries. Adding directories here extends read/write access.`,
688
689
 
689
690
  const { existsSync } = await import('node:fs')
690
691
  const { resolve } = await import('node:path')
691
- const resolved = resolve(dir.replace(/^~/, process.env.HOME || '~'))
692
+ const resolved = resolve(dir.replace(/^~/, homedir()))
692
693
 
693
694
  if (!existsSync(resolved)) {
694
695
  return { content: `✗ Directory not found: ${resolved}\n\nCheck the path and try again.` }
@@ -1,5 +1,6 @@
1
1
  import { readFileSync, existsSync } from 'node:fs'
2
2
  import { join, resolve, relative, sep, isAbsolute } from 'node:path'
3
+ import { homedir } from 'node:os'
3
4
  import { execSync } from 'node:child_process'
4
5
  import { parse as parseYaml } from 'yaml'
5
6
  import type { InstructionFile } from '../shared/index.ts'
@@ -126,7 +127,7 @@ export class InstructionsLoader {
126
127
  })
127
128
 
128
129
  // Tier 3: 用户层 ~/.mipham/USER.md
129
- const home = process.env.HOME || '~'
130
+ const home = homedir()
130
131
  this.tryLoad(join(home, '.mipham', 'USER.md'), 'user')
131
132
 
132
133
  // CRSI 教训召回:读 crsi-lessons.md 提取精华,注入系统提示(只写不读 → 写后召回)
@@ -63,6 +63,56 @@ const WRITER_COMMANDS = new Set([
63
63
  'truncate',
64
64
  ])
65
65
 
66
+ /**
67
+ * Commands that wrap another command as their payload. A Read/Edit deny rule
68
+ * must still apply when the reader/writer is wrapped (`sudo cat X`, `env -C / X`,
69
+ * `timeout 5 X`) — otherwise the wrapper silently bypasses the rule.
70
+ */
71
+ const PREFIX_COMMANDS = new Set([
72
+ 'sudo',
73
+ 'doas',
74
+ 'nohup',
75
+ 'command',
76
+ 'exec',
77
+ 'nice',
78
+ 'timeout',
79
+ 'env',
80
+ 'xargs',
81
+ 'eval',
82
+ 'stdbuf',
83
+ ])
84
+
85
+ /** Value-taking options of wrapper commands (consume the following token). */
86
+ const PREFIX_VALUE_OPTIONS = new Set([
87
+ '-u',
88
+ '--user',
89
+ '-g',
90
+ '--group',
91
+ '-h',
92
+ '--host',
93
+ '-p',
94
+ '--prompt', // sudo/doas
95
+ '-n',
96
+ '--adjustment', // nice (and xargs --max-args)
97
+ '-k',
98
+ '--kill-after',
99
+ '-s',
100
+ '--signal', // timeout
101
+ '-C',
102
+ '--chdir',
103
+ '--unset',
104
+ '-S',
105
+ '--split-string', // env
106
+ '-a',
107
+ '--arg-file',
108
+ '-E',
109
+ '--eof',
110
+ '-I',
111
+ '--replace',
112
+ '-P',
113
+ '--max-procs', // xargs
114
+ ])
115
+
66
116
  /**
67
117
  * Split a (possibly compound) shell command into simple-command segments, so a
68
118
  * Bash(pattern) rule matches any segment rather than only the whole string
@@ -156,6 +206,8 @@ function flattenCommand(command: string): string[] {
156
206
  const out: string[] = []
157
207
  for (const seg of splitShellSegments(command)) {
158
208
  out.push(seg)
209
+ const stripped = stripPrefixCommand(seg)
210
+ if (stripped !== seg) out.push(stripped)
159
211
  for (const inner of extractSubstitutions(seg)) {
160
212
  out.push(...flattenCommand(inner))
161
213
  }
@@ -163,6 +215,46 @@ function flattenCommand(command: string): string[] {
163
215
  return out
164
216
  }
165
217
 
218
+ /**
219
+ * Resolve the effective command and its arguments after any wrapper prefix
220
+ * commands (`sudo`, `env`, `timeout`, …). Skips the wrapper and its own options
221
+ * (flags, the values of value-taking flags, `env`'s `VAR=value` assignments, and
222
+ * `timeout`'s positional duration) to reach the real command. Conservative, not a
223
+ * full parser: only recognized wrappers are stripped, so an unrecognized token is
224
+ * always treated as the command (never skipped) — which over-matches, the safe
225
+ * direction for a deny rule.
226
+ */
227
+ function effectiveCommand(tokens: string[]): { base: string; args: string[] } {
228
+ let i = 0
229
+ while (i < tokens.length) {
230
+ const name = (tokens[i] || '').split('/').pop() || ''
231
+ if (!PREFIX_COMMANDS.has(name)) break
232
+ i++ // skip the wrapper
233
+ while (i < tokens.length && tokens[i]!.startsWith('-')) {
234
+ const opt = tokens[i]!
235
+ i++
236
+ if (PREFIX_VALUE_OPTIONS.has(opt) && i < tokens.length) i++ // skip the flag's value
237
+ }
238
+ if (name === 'env') {
239
+ while (i < tokens.length && tokens[i]!.includes('=')) i++ // `VAR=value` assignments
240
+ }
241
+ if (name === 'timeout') i++ // positional duration
242
+ }
243
+ if (i >= tokens.length) return { base: '', args: [] }
244
+ return { base: (tokens[i] || '').split('/').pop() || '', args: tokens.slice(i + 1) }
245
+ }
246
+
247
+ /** A command with any wrapper prefix commands stripped, so `sudo rm -rf /`
248
+ * matches a `Bash(rm *)` rule. Returns the input unchanged when there is no
249
+ * wrapper (or nothing but wrappers). */
250
+ function stripPrefixCommand(command: string): string {
251
+ const tokens = command.split(/\s+/).filter(Boolean)
252
+ if (tokens.length === 0) return command
253
+ const { base, args } = effectiveCommand(tokens)
254
+ if (!base) return command
255
+ return [base, ...args].join(' ')
256
+ }
257
+
166
258
  /**
167
259
  * Detect reader/writer commands at the front of each shell segment and recurse
168
260
  * into command substitutions, so `echo $(cat .git-credentials)` is caught.
@@ -171,8 +263,7 @@ function scanReaderWriterCommands(command: string, read: string[], write: string
171
263
  for (const seg of splitShellSegments(command)) {
172
264
  const tokens = seg.split(/\s+/).filter(Boolean)
173
265
  if (tokens.length > 0) {
174
- const base = (tokens[0] || '').split('/').pop() || ''
175
- const args = tokens.slice(1)
266
+ const { base, args } = effectiveCommand(tokens)
176
267
  if (READER_COMMANDS.has(base)) {
177
268
  // `sed -i` / `perl -i` read AND write their file args.
178
269
  const inPlace = args.some((a) => a === '-i' || a.startsWith('--in-place'))
@@ -1,6 +1,7 @@
1
1
  import type { ExperienceRule } from '../agent/experience-rules.js'
2
2
  import { mkdirSync, writeFileSync, readFileSync, existsSync } from 'node:fs'
3
3
  import { join, dirname } from 'node:path'
4
+ import { homedir } from 'node:os'
4
5
  import { MANAGED_RULES } from './crsi-managed-rules'
5
6
 
6
7
  export interface ToolRule {
@@ -61,7 +62,7 @@ export class ExperienceRuleEngine {
61
62
  private rules: ToolRule[]
62
63
  private storePath: string
63
64
 
64
- constructor(storeDir: string = join(process.env.HOME || '~', '.mipham', 'rule-engine')) {
65
+ constructor(storeDir: string = join(homedir(), '.mipham', 'rule-engine')) {
65
66
  this.rules = [...BUILTIN_RULES, ...MANAGED_RULES].map((r) => ({ ...r }))
66
67
  this.storePath = join(storeDir, 'rules.json')
67
68
  this.load()
@@ -1,4 +1,5 @@
1
1
  import { appendFileSync, readFileSync, mkdirSync, existsSync } from 'node:fs'
2
+ import { homedir } from 'node:os'
2
3
  import { join } from 'node:path'
3
4
  import { createHash } from 'node:crypto'
4
5
  import type { Message, ToolUseContent, ToolResultContent, ToolResult } from '../shared/types'
@@ -101,7 +102,7 @@ export function deriveMessages(events: SessionEvent[]): Message[] {
101
102
  return out
102
103
  }
103
104
 
104
- const HOME = process.env.HOME || '~'
105
+ const HOME = homedir()
105
106
  const LOG_DIR = join(HOME, '.mipham', 'sessions')
106
107
 
107
108
  /** 将会话名消毒为安全文件名(与 SessionStore 共用;防路径穿越)。 */
@@ -8,6 +8,7 @@ import {
8
8
  existsSync,
9
9
  statSync,
10
10
  } from 'node:fs'
11
+ import { homedir } from 'node:os'
11
12
  import { join } from 'node:path'
12
13
  import { createHash } from 'node:crypto'
13
14
  import type { Message } from '../shared/types'
@@ -28,7 +29,7 @@ export interface StoredSession {
28
29
  messages: Message[]
29
30
  }
30
31
 
31
- const HOME = process.env.HOME || '~'
32
+ const HOME = homedir()
32
33
  const SESSIONS_DIR = join(HOME, '.mipham', 'sessions')
33
34
  const INDEX_FILE = join(SESSIONS_DIR, '.index.json')
34
35
  const SUMMARIES_DIR = join(SESSIONS_DIR, '.summaries')
@@ -1,6 +1,7 @@
1
1
  // apps/cli/src/daemon/database.ts
2
2
  import { Database } from 'bun:sqlite'
3
3
  import { readdirSync, readFileSync, existsSync } from 'node:fs'
4
+ import { homedir } from 'node:os'
4
5
  import { join } from 'node:path'
5
6
  import type {
6
7
  DaemonSession,
@@ -339,7 +340,7 @@ export class DaemonDatabase {
339
340
  // ── Migration ──────────────────────────────────────────────
340
341
 
341
342
  migrateFromJsonl(): number {
342
- const sessionsDir = join(process.env.HOME || '/tmp', '.mipham', 'sessions')
343
+ const sessionsDir = join(homedir(), '.mipham', 'sessions')
343
344
 
344
345
  if (!existsSync(sessionsDir)) return 0
345
346
 
@@ -9,7 +9,7 @@
9
9
  export const PACKAGE_NAME = '@miphamai/cli' as const
10
10
 
11
11
  /** 当前发布版本 */
12
- export const PACKAGE_VERSION = '0.80.1' as const
12
+ export const PACKAGE_VERSION = '0.81.1' as const
13
13
 
14
14
  /** npm install 全局安装命令 */
15
15
  export const NPM_INSTALL_COMMAND = `npm install -g ${PACKAGE_NAME}` as const
@@ -1,9 +1,10 @@
1
1
  import { readFileSync, writeFileSync, existsSync, mkdirSync } from 'node:fs'
2
2
  import { join } from 'node:path'
3
+ import { homedir } from 'node:os'
3
4
  import { parse as parseYaml, stringify } from 'yaml'
4
5
  import type { ToolDefinition } from '../../shared/index.ts'
5
6
 
6
- const MIPHAM_DIR = join(process.env.HOME || '~', '.mipham')
7
+ const MIPHAM_DIR = join(homedir(), '.mipham')
7
8
  const USER_CONFIG = join(MIPHAM_DIR, 'config.yml')
8
9
 
9
10
  export const configTool: ToolDefinition = {
package/src/ui/chat.tsx CHANGED
@@ -2,6 +2,7 @@ import React, { useMemo } from 'react'
2
2
  import { Box, Text } from 'ink'
3
3
  import { useI18n } from '../i18n-context'
4
4
  import type { ChatMessage } from './app'
5
+ import { homedir } from 'node:os'
5
6
 
6
7
  interface ChatPanelProps {
7
8
  messages: ChatMessage[]
@@ -11,7 +12,7 @@ interface ChatPanelProps {
11
12
  /** Format cwd for display: replace HOME with ~, truncate if too long */
12
13
  function displayCwd(): string {
13
14
  const cwd = process.cwd()
14
- const home = process.env.HOME || ''
15
+ const home = homedir()
15
16
  if (home && cwd.startsWith(home)) {
16
17
  return '~' + cwd.slice(home.length)
17
18
  }
@@ -18,6 +18,7 @@ import { InstructionsLoader } from '../core/instructions'
18
18
  import { findDerivableSections, DERIVABLE_HINTS } from '../core/claude-md-audit'
19
19
  import { fixDoctor, fixConfig, fixCache, selectRepoClaudeFiles } from '../core/fix'
20
20
  import { fixCodeTarget } from '../core/fix-code'
21
+ import { homedir } from 'node:os'
21
22
  import { runCrsiModification, approvePending, rejectPending, hasPending } from '../core/crsi-modify'
22
23
  import {
23
24
  produceCrsiProposal,
@@ -2573,7 +2574,7 @@ const installPluginCmd: CommandHandler = async (ctx, args) => {
2573
2574
 
2574
2575
  if (isLocalPath) {
2575
2576
  const { resolve } = await import('node:path')
2576
- const resolved = resolve(target.replace(/^~/, process.env.HOME || '~'))
2577
+ const resolved = resolve(target.replace(/^~/, homedir()))
2577
2578
  result = manager.install(resolved)
2578
2579
  } else {
2579
2580
  // Treat as npm package name
@@ -2882,7 +2883,7 @@ const loopCmd: CommandHandler = async (ctx, args) => {
2882
2883
  const { scaffoldLoopKit } = await import('../commands/loop-scaffold')
2883
2884
  const { created, skipped } = scaffoldLoopKit(targetPath)
2884
2885
  const { resolve } = await import('node:path')
2885
- const resolved = resolve(targetPath.replace(/^~/, process.env.HOME || '~'))
2886
+ const resolved = resolve(targetPath.replace(/^~/, homedir()))
2886
2887
 
2887
2888
  const lines: string[] = ['── LoopKit Vault Created ──', '', `Location: ${resolved}`, '']
2888
2889
 
@@ -3721,7 +3722,7 @@ const cdCmd: CommandHandler = async (ctx, args) => {
3721
3722
 
3722
3723
  const { existsSync } = await import('node:fs')
3723
3724
  const { resolve } = await import('node:path')
3724
- const resolved = resolve(target.replace(/^~/, process.env.HOME || '~'))
3725
+ const resolved = resolve(target.replace(/^~/, homedir()))
3725
3726
 
3726
3727
  if (!existsSync(resolved)) {
3727
3728
  const suggestions = suggestDirectories(resolved)
@@ -4118,7 +4119,7 @@ const memoryCmd: CommandHandler = async (ctx, args) => {
4118
4119
  const { existsSync, readdirSync, readFileSync, statSync } = await import('node:fs')
4119
4120
  const { join } = await import('node:path')
4120
4121
 
4121
- const home = process.env.HOME || '~'
4122
+ const home = homedir()
4122
4123
  const memoryDir = join(home, '.mipham', 'memory')
4123
4124
 
4124
4125
  // /memory gc — 记忆卫生:归档「0 召回 + 过期」的 auto-* 记忆(手写只报告)
@@ -4320,7 +4321,7 @@ const workflowsCmd: CommandHandler = async () => {
4320
4321
 
4321
4322
  const locations = [
4322
4323
  join(process.cwd(), '.claude', 'workflows'),
4323
- join(process.env.HOME || '~', '.claude', 'workflows'),
4324
+ join(homedir(), '.claude', 'workflows'),
4324
4325
  ]
4325
4326
 
4326
4327
  const lines: string[] = ['─ Workflows ─', '']
@@ -4418,7 +4419,7 @@ const workflowRunCmd = async (name: string): Promise<CommandResult> => {
4418
4419
 
4419
4420
  const locations = [
4420
4421
  join(process.cwd(), '.claude', 'workflows'),
4421
- join(process.env.HOME || '~', '.claude', 'workflows'),
4422
+ join(homedir(), '.claude', 'workflows'),
4422
4423
  ]
4423
4424
 
4424
4425
  for (const loc of locations) {
@@ -4739,7 +4740,7 @@ const logoutCmd: CommandHandler = async () => {
4739
4740
  const { existsSync } = await import('node:fs')
4740
4741
  const { join } = await import('node:path')
4741
4742
 
4742
- const home = process.env.HOME || '~'
4743
+ const home = homedir()
4743
4744
  const userConfig = join(home, '.mipham', 'config.yml')
4744
4745
  const hasUserConfig = existsSync(userConfig)
4745
4746
 
@@ -23,7 +23,21 @@ function detectParallelism(): number {
23
23
  return os.cpus().length || 1
24
24
  }
25
25
 
26
- const MAX_CONCURRENT = Math.max(1, Math.min(16, detectParallelism()))
26
+ /**
27
+ * Resolve the concurrency cap for parallel() fan-out. Defaults to CPU-derived
28
+ * parallelism capped at 16. An explicit `MIPHAM_WORKFLOW_MAX_CONCURRENT_AGENTS`
29
+ * (1–256) overrides it, for inference-bound fan-outs whose bottleneck is LLM
30
+ * latency rather than CPU. Invalid / out-of-range values fall back to the default.
31
+ */
32
+ export function resolveMaxConcurrent(envValue: string | undefined): number {
33
+ if (envValue !== undefined && envValue !== '') {
34
+ const n = Number(envValue)
35
+ if (Number.isInteger(n) && n >= 1) return Math.min(n, 256)
36
+ }
37
+ return Math.max(1, Math.min(16, detectParallelism()))
38
+ }
39
+
40
+ const MAX_CONCURRENT = resolveMaxConcurrent(process.env.MIPHAM_WORKFLOW_MAX_CONCURRENT_AGENTS)
27
41
 
28
42
  /**
29
43
  * Simple async semaphore for concurrency limiting.