@miphamai/cli 0.55.0 → 0.57.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.
package/bin/mipham.ts CHANGED
@@ -87,7 +87,9 @@ async function runWorkflowCLI(): Promise<boolean> {
87
87
  })
88
88
 
89
89
  // Commander will handle --help, unknown commands, etc.
90
- await program.parseAsync(process.argv)
90
+ // Skip node + script + the leading `workflow` keyword (already matched by the
91
+ // caller); `from: 'user'` stops commander from stripping two more elements.
92
+ await program.parseAsync(process.argv.slice(3), { from: 'user' })
91
93
  return true
92
94
  }
93
95
 
@@ -185,7 +187,9 @@ async function runPluginCLI(): Promise<boolean> {
185
187
  process.exit(result.success ? 0 : 1)
186
188
  })
187
189
 
188
- await program.parseAsync(process.argv)
190
+ // Skip node + script + the leading `plugin` keyword (already matched by the
191
+ // caller); `from: 'user'` stops commander from stripping two more elements.
192
+ await program.parseAsync(process.argv.slice(3), { from: 'user' })
189
193
  return true
190
194
  }
191
195
 
@@ -1054,6 +1058,21 @@ async function runTokenCLI(): Promise<boolean> {
1054
1058
  }
1055
1059
 
1056
1060
  async function main() {
1061
+ // ── Deleted-cwd guard ──────────────────────────────────────────────────
1062
+ // `process.cwd()` throws ENOENT when the directory the process was launched
1063
+ // from no longer exists (e.g. a removed git worktree). Print a clear message
1064
+ // instead of a raw crash dump (matches Claude Code 2.1.239).
1065
+ const { isDeletedCwdError, deletedCwdMessage } = await import('../src/shared/deleted-cwd')
1066
+ try {
1067
+ process.cwd()
1068
+ } catch (err) {
1069
+ if (isDeletedCwdError(err)) {
1070
+ console.error(deletedCwdMessage())
1071
+ process.exit(1)
1072
+ }
1073
+ throw err
1074
+ }
1075
+
1057
1076
  // ── Disable terminal special characters ──────────────────────────────────
1058
1077
  // Terminal intercepts Ctrl+S (XOFF), Ctrl+T (SIGINFO/status), Ctrl+R (rprnt)
1059
1078
  // before the app sees them. Disable these so Agent Dashboard shortcuts work.
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@miphamai/cli",
3
- "version": "0.55.0",
3
+ "version": "0.57.0",
4
4
  "description": "Mipham Code — Multi-model open-core intelligent coding terminal by MiphamAI",
5
5
  "keywords": [
6
6
  "ai",
@@ -115,17 +115,20 @@ Mode: ${ctx.config.permission}
115
115
  Messages: ${msgs.length} in context
116
116
  Tools: ${ctx.engine.getTools().size} available
117
117
 
118
- Permission levels:
119
- auto — run tools automatically without asking
120
- ask — prompt before each tool execution (default)
121
- bypass — skip all permission checks (use with caution)
118
+ Switch mode with Shift+Tab. Modes (least → most permissive):
119
+ default — per-tool defaults (Bash/Write/Edit ask first)
120
+ acceptEdits — reads + edits free; Bash auto-runs read/check commands
121
+ plan — reads only (Read/Grep/Glob); nothing writes or runs
122
+ auto — run everything without asking (hooks are the safety gate)
123
+ dontAsk — only allowlisted tools free; everything else asks
124
+ bypassPermissions — skip ALL permission checks (⚠️ use with caution)
122
125
 
123
- Change with /config permission <level>.
126
+ To let Bash run without asking: press Shift+Tab until the status line shows
127
+ "acceptEdits" (or "auto"), then send your message again.
124
128
 
125
129
  Current directory permissions:
126
130
  CWD: ${process.cwd()}
127
131
 
128
- To add a directory: use /add-dir (coming soon).
129
132
  Tool execution is sandboxed to the project directory by default.`,
130
133
  }
131
134
  }
@@ -38,6 +38,7 @@ import { UsageTracker } from './usage-tracker'
38
38
  import { getMetrics } from './metrics'
39
39
  import { buildRequest, sendInferenceCheck, isInferenceHookEnabled } from './inference-hook'
40
40
  import { getFileInboxTransport } from '../agent/cross-session/file-inbox'
41
+ import { accumulateGraftSavings } from '../shared/graft-savings'
41
42
  import { getMessageBus, type AgentMessage } from '../agent/message-bus'
42
43
  import { createT } from '../i18n-core/t'
43
44
  import enUS from '../i18n-core/locales/en-US.json'
@@ -763,7 +764,9 @@ export class QueryEngine {
763
764
  }
764
765
 
765
766
  private async *continueWithTools(signal?: AbortSignal): AsyncGenerator<StreamChunk> {
766
- const MAX_TURNS = 20
767
+ // Tool-calling round cap. 20 was too low for real multi-step tasks — the model
768
+ // hit "max turns" and dropped pending tools mid-task. 100 stays bounded.
769
+ const MAX_TURNS = 100
767
770
  const toolDefs = this.getToolDefinitions()
768
771
 
769
772
  for (let turn = 0; turn < MAX_TURNS; turn++) {
@@ -1127,6 +1130,9 @@ export class QueryEngine {
1127
1130
  tracker.persist()
1128
1131
  }
1129
1132
 
1133
+ // Accumulate graft token savings from "[graft] tokens saved ≈ N" footers
1134
+ accumulateGraftSavings(result.content)
1135
+
1130
1136
  return result
1131
1137
  } catch (err) {
1132
1138
  // P1-3: Trigger PostToolUseFailure hook on tool execution errors
@@ -804,8 +804,7 @@
804
804
  "assistant": { "role_label": "Mipham Code" },
805
805
  "user": { "role_label": "User" },
806
806
  "input": {
807
- "placeholder": "Type a message (Esc to cancel)...",
808
- "vim_help": "[NORMAL] h/j/k/l w/b 0/$ dd yy p u / (Esc to insert)"
807
+ "placeholder": "Type a message (Esc to clear)..."
809
808
  },
810
809
  "slash_hints": {
811
810
  "label": "Commands:",
@@ -942,7 +941,7 @@
942
941
  "mcp": { "name": "MCP", "description": "Manage MCP server connections" }
943
942
  },
944
943
  "errors": {
945
- "tool_not_allowed": "Tool \"{name}\" requires user approval (permission: ask). The tool was not executed.",
944
+ "tool_not_allowed": "Tool \"{name}\" requires user approval (permission: ask). The tool was not executed. Press Shift+Tab to switch permission mode, or run /permissions.",
946
945
  "tool_blocked": "Tool \"{name}\" blocked by hook",
947
946
  "user_input_blocked": "User input blocked by hook.",
948
947
  "dlp_blocked": "Request blocked by DLP policy.",
@@ -804,8 +804,7 @@
804
804
  "assistant": { "role_label": "Mipham Code" },
805
805
  "user": { "role_label": "用户" },
806
806
  "input": {
807
- "placeholder": "输入消息(Esc 取消)...",
808
- "vim_help": "[NORMAL] h/j/k/l w/b 0/$ dd yy p u /(Esc 插入)"
807
+ "placeholder": "输入消息(Esc 清空)..."
809
808
  },
810
809
  "slash_hints": {
811
810
  "label": "命令:",
@@ -942,7 +941,7 @@
942
941
  "mcp": { "name": "MCP", "description": "管理 MCP 服务器连接" }
943
942
  },
944
943
  "errors": {
945
- "tool_not_allowed": "工具 \"{name}\" 需要用户批准(权限:ask),未执行。",
944
+ "tool_not_allowed": "工具 \"{name}\" 需要用户批准(权限:ask),未执行。按 Shift+Tab 切换权限模式,或运行 /permissions。",
946
945
  "tool_blocked": "工具 \"{name}\" 被钩子拦截",
947
946
  "user_input_blocked": "用户输入被钩子拦截。",
948
947
  "dlp_blocked": "请求被 DLP 策略阻止。",
package/src/index.tsx CHANGED
@@ -396,7 +396,7 @@ export async function runApp(options: RunOptions): Promise<void> {
396
396
  if (context.getMessageCount() === 0) {
397
397
  const basePrompt = instructions.buildSystemPrompt(config.permission as string)
398
398
  const memoryReminder = loadSessionMemories(basePrompt)
399
- const skillsReminder = skillsLoader.buildSystemReminder(basePrompt)
399
+ const skillsReminder = skillsLoader.buildSystemReminder()
400
400
 
401
401
  // Inject previous session summary for AI continuity
402
402
  let prompt = basePrompt
@@ -0,0 +1,174 @@
1
+ import { readFileSync, existsSync, readdirSync, statSync } from 'node:fs'
2
+ import { join } from 'node:path'
3
+ import type { SkillsLoader } from '../skills/loader'
4
+ import type { AgentRegistry } from '../agent/agent-registry'
5
+ import type { McpClient } from '../mcp/client'
6
+ import type { McpServerConfig, ToolDefinition } from '../shared/types'
7
+ import { registerMcpServerTools } from '../mcp/registry'
8
+
9
+ /**
10
+ * Claude marketplace plugin adapter — loads the portable content of a Claude
11
+ * plugin (`.claude-plugin/plugin.json` + skills/agents/MCP) into Mipham Code's
12
+ * own subsystems. Commands (`commands/*.md`) and hooks (`hooks/hooks.json`) are
13
+ * not mapped here: Mipham Code has no file-based slash-command system, and the
14
+ * hook matcher semantics differ.
15
+ */
16
+
17
+ interface ClaudeManifest {
18
+ name: string
19
+ version?: string
20
+ mcpServers?: Record<string, unknown> | string
21
+ [key: string]: unknown
22
+ }
23
+
24
+ /** Read the Claude manifest at `<dir>/.claude-plugin/plugin.json`. */
25
+ export function readClaudeManifest(dir: string): ClaudeManifest | null {
26
+ const path = join(dir, '.claude-plugin', 'plugin.json')
27
+ if (!existsSync(path)) return null
28
+ try {
29
+ return JSON.parse(readFileSync(path, 'utf-8')) as ClaudeManifest
30
+ } catch {
31
+ return null
32
+ }
33
+ }
34
+
35
+ /** Expand `${CLAUDE_PLUGIN_ROOT}` (Claude's plugin-root placeholder). */
36
+ function expandPluginRoot(value: string, pluginRoot: string): string {
37
+ return value.replace(/\$\{CLAUDE_PLUGIN_ROOT\}/g, pluginRoot)
38
+ }
39
+
40
+ /** Load Claude skills — `skills/<name>/SKILL.md` (no leading dot on SKILL.md). */
41
+ function loadClaudeSkills(dir: string, skillsLoader: SkillsLoader): void {
42
+ const skillsDir = join(dir, 'skills')
43
+ if (!existsSync(skillsDir)) return
44
+ let entries: string[] = []
45
+ try {
46
+ entries = readdirSync(skillsDir)
47
+ } catch {
48
+ return
49
+ }
50
+ for (const entry of entries) {
51
+ let skillFile: string | null = null
52
+ try {
53
+ const full = join(skillsDir, entry)
54
+ if (statSync(full).isDirectory()) {
55
+ const candidate = join(full, 'SKILL.md')
56
+ if (existsSync(candidate)) skillFile = candidate
57
+ } else if (entry === 'SKILL.md') {
58
+ skillFile = full
59
+ }
60
+ } catch {
61
+ continue
62
+ }
63
+ if (skillFile) skillsLoader.loadSkillFile(skillFile, 'standard')
64
+ }
65
+ }
66
+
67
+ /** Map a Claude MCP server entry to a Mipham `McpServerConfig`. */
68
+ function toMcpServerConfig(name: string, raw: Record<string, unknown>): McpServerConfig | null {
69
+ const command = typeof raw.command === 'string' ? raw.command : undefined
70
+ const url = typeof raw.url === 'string' ? raw.url : undefined
71
+ if (!command && !url) return null
72
+
73
+ const cfg: McpServerConfig = { name }
74
+ if (command) {
75
+ cfg.command = command
76
+ cfg.args = Array.isArray(raw.args) ? raw.args.map(String) : undefined
77
+ } else {
78
+ cfg.url = url
79
+ }
80
+ if (raw.env && typeof raw.env === 'object') {
81
+ cfg.env = raw.env as Record<string, string>
82
+ }
83
+ if (raw.headers && typeof raw.headers === 'object') {
84
+ cfg.headers = raw.headers as Record<string, string>
85
+ }
86
+ return cfg
87
+ }
88
+
89
+ /** Load Claude MCP servers (`.mcp.json` + inline `mcpServers`) into the client. */
90
+ function loadClaudeMcp(
91
+ dir: string,
92
+ manifest: ClaudeManifest,
93
+ mcpClient: Pick<McpClient, 'connect'>,
94
+ toolsMap: Map<string, ToolDefinition>,
95
+ ): string[] {
96
+ const servers: McpServerConfig[] = []
97
+
98
+ const collect = (obj: unknown): void => {
99
+ if (!obj || typeof obj !== 'object') return
100
+ for (const [name, raw] of Object.entries(obj as Record<string, Record<string, unknown>>)) {
101
+ const cfg = toMcpServerConfig(name, raw)
102
+ if (cfg) servers.push(cfg)
103
+ }
104
+ }
105
+
106
+ // 1. `.mcp.json` at the plugin root
107
+ const mcpJsonPath = join(dir, '.mcp.json')
108
+ if (existsSync(mcpJsonPath)) {
109
+ try {
110
+ collect(JSON.parse(readFileSync(mcpJsonPath, 'utf-8')).mcpServers)
111
+ } catch {
112
+ /* skip unparseable .mcp.json */
113
+ }
114
+ }
115
+ // 2. Inline `mcpServers` field in the manifest
116
+ collect(manifest.mcpServers)
117
+
118
+ for (const cfg of servers) {
119
+ if (cfg.command) cfg.command = expandPluginRoot(cfg.command, dir)
120
+ if (cfg.args) cfg.args = cfg.args.map((a) => expandPluginRoot(a, dir))
121
+ if (cfg.env) {
122
+ for (const key of Object.keys(cfg.env)) cfg.env[key] = expandPluginRoot(cfg.env[key]!, dir)
123
+ }
124
+
125
+ mcpClient
126
+ .connect(cfg)
127
+ .then(() => {
128
+ const count = registerMcpServerTools(cfg.name, toolsMap)
129
+ if (count > 0) {
130
+ process.stderr.write(
131
+ `[plugin] "${manifest.name}": registered ${count} MCP tools from "${cfg.name}"\n`,
132
+ )
133
+ }
134
+ })
135
+ .catch((err: unknown) => {
136
+ process.stderr.write(
137
+ `[plugin] Failed to connect MCP "${cfg.name}" from "${manifest.name}": ${String(err)}\n`,
138
+ )
139
+ })
140
+ }
141
+
142
+ return servers.map((s) => s.name)
143
+ }
144
+
145
+ /** Load a Claude plugin's portable content (skills + agents + MCP) into Mipham Code.
146
+ * Returns the names of the connected MCP servers so the caller can wire cleanup. */
147
+ export function loadClaudePlugin(
148
+ dir: string,
149
+ deps: {
150
+ skillsLoader: SkillsLoader
151
+ agentRegistry: AgentRegistry
152
+ mcpClient: Pick<McpClient, 'connect'>
153
+ toolsMap: Map<string, ToolDefinition>
154
+ },
155
+ ): string[] {
156
+ const manifest = readClaudeManifest(dir)
157
+ if (!manifest) return []
158
+
159
+ loadClaudeSkills(dir, deps.skillsLoader)
160
+
161
+ // Claude agents use `.md` + frontmatter; largely compatible with Mipham's AgentDefinition.
162
+ const agentsDir = join(dir, 'agents')
163
+ if (existsSync(agentsDir)) {
164
+ try {
165
+ deps.agentRegistry.loadDirectory(agentsDir, 'user')
166
+ } catch (err) {
167
+ process.stderr.write(
168
+ `[plugin] Failed to load agents from "${manifest.name}": ${String(err)}\n`,
169
+ )
170
+ }
171
+ }
172
+
173
+ return loadClaudeMcp(dir, manifest, deps.mcpClient, deps.toolsMap)
174
+ }
@@ -7,6 +7,8 @@ import type { HookEngine } from '../core/hooks'
7
7
  import type { McpClient } from '../mcp/client'
8
8
  import { registerMcpServerTools } from '../mcp/registry'
9
9
  import { executeHook } from '../core/hooks-executor'
10
+ import { detectPluginFormat } from './plugin-validator'
11
+ import { loadClaudePlugin } from './claude-plugin'
10
12
  import type { McpServerConfig, ToolDefinition, HookConfig, HookEvent } from '../shared/types'
11
13
 
12
14
  /**
@@ -24,6 +26,29 @@ export function loadPlugins(
24
26
  toolsMap: Map<string, ToolDefinition>,
25
27
  ): void {
26
28
  for (const plugin of pluginManager.getEnabled()) {
29
+ // Claude marketplace plugins: load their portable content via the adapter.
30
+ if (detectPluginFormat(plugin.path) === 'claude') {
31
+ const claudeMcpServers = loadClaudePlugin(plugin.path, {
32
+ skillsLoader,
33
+ agentRegistry,
34
+ mcpClient,
35
+ toolsMap,
36
+ })
37
+ pluginManager.onRemove(plugin.name, () => {
38
+ for (const serverName of claudeMcpServers) {
39
+ try {
40
+ const toolNames = mcpClient.disconnect(serverName)
41
+ for (const toolName of toolNames) {
42
+ toolsMap.delete(`mcp__${serverName}__${toolName}`)
43
+ }
44
+ } catch {
45
+ /* best effort */
46
+ }
47
+ }
48
+ })
49
+ continue
50
+ }
51
+
27
52
  const mcpServers: string[] = []
28
53
  const hookEvents: HookEvent[] = []
29
54
 
@@ -1,4 +1,12 @@
1
- import { mkdirSync, existsSync, writeFileSync, readFileSync, readdirSync, rmSync } from 'node:fs'
1
+ import {
2
+ mkdirSync,
3
+ existsSync,
4
+ writeFileSync,
5
+ readFileSync,
6
+ readdirSync,
7
+ rmSync,
8
+ renameSync,
9
+ } from 'node:fs'
2
10
  import { join } from 'node:path'
3
11
  import { homedir } from 'node:os'
4
12
  import { execSync } from 'node:child_process'
@@ -48,7 +56,7 @@ export class PluginManager {
48
56
  const similarWarning = this.findSimilarWarning(validation.manifest.name)
49
57
  this.plugins.push({
50
58
  name: validation.manifest.name,
51
- version: validation.manifest.version,
59
+ version: validation.manifest.version || '0.0.0',
52
60
  path: destDir,
53
61
  enabled: true,
54
62
  installedAt: new Date().toISOString(),
@@ -78,44 +86,65 @@ export class PluginManager {
78
86
  return { success: false, message: `Invalid package name: "${packageName}"` }
79
87
  }
80
88
 
81
- // Derive plugin name from npm package name (strip scope prefix)
82
- const pluginName = packageName.replace(/^@.+\//, '').replace(/^mipham-plugin-/, '')
89
+ // Staging dir name derived from the npm package name (scope stripped). The
90
+ // final directory is renamed to the manifest `name` once validated — the two
91
+ // can differ (e.g. a scoped package whose manifest declares a shorter name).
92
+ const stagingName = packageName.replace(/^@.+\//, '').replace(/^mipham-plugin-/, '')
83
93
 
84
- const destDir = join(this.pluginDir, pluginName)
85
- if (existsSync(destDir)) {
94
+ const stagingDir = join(this.pluginDir, stagingName)
95
+ if (existsSync(stagingDir)) {
86
96
  return {
87
97
  success: false,
88
- message: `Plugin "${pluginName}" is already installed`,
98
+ message: `Plugin "${stagingName}" is already installed`,
89
99
  }
90
100
  }
91
101
 
92
102
  try {
93
103
  // Install the npm package into the plugins directory
94
- mkdirSync(destDir, { recursive: true })
104
+ mkdirSync(stagingDir, { recursive: true })
95
105
 
96
106
  // Create a minimal package.json so npm install works in a subdirectory
97
107
  writeFileSync(
98
- join(destDir, 'package.json'),
108
+ join(stagingDir, 'package.json'),
99
109
  JSON.stringify({ private: true }, null, 2),
100
110
  'utf-8',
101
111
  )
102
112
 
103
- execSync(`npm install ${packageName} --prefix "${destDir}" --no-save`, {
113
+ execSync(`npm install ${packageName} --prefix "${stagingDir}" --no-save`, {
104
114
  encoding: 'utf-8',
105
115
  stdio: 'pipe',
106
116
  timeout: 60_000,
107
117
  })
108
118
 
109
- // Validate the installed plugin
110
- const validation = validatePlugin(destDir)
119
+ // npm installs the package into <stagingDir>/node_modules/<packageName>/ —
120
+ // validate there, then flatten it to the plugin dir root so its layout
121
+ // matches `install(sourcePath)`.
122
+ const pkgDir = join(stagingDir, 'node_modules', packageName)
123
+ const validation = validatePlugin(pkgDir)
111
124
  if (!validation.valid) {
112
125
  // Clean up on invalid plugin
113
- rmSync(destDir, { recursive: true, force: true })
126
+ rmSync(stagingDir, { recursive: true, force: true })
114
127
  return { success: false, message: validation.errors.join('; ') }
115
128
  }
116
129
 
130
+ this.copyDir(pkgDir, stagingDir)
131
+ rmSync(join(stagingDir, 'node_modules'), { recursive: true, force: true })
132
+
133
+ // Use the manifest `name` for the final directory + record + message so
134
+ // they all agree (npm package name is only the staging key).
135
+ const manifestName = validation.manifest?.name || stagingName
136
+ let destDir = stagingDir
137
+ if (manifestName !== stagingName) {
138
+ destDir = join(this.pluginDir, manifestName)
139
+ if (existsSync(destDir)) {
140
+ rmSync(stagingDir, { recursive: true, force: true })
141
+ return { success: false, message: `Plugin "${manifestName}" is already installed` }
142
+ }
143
+ renameSync(stagingDir, destDir)
144
+ }
145
+
117
146
  this.plugins.push({
118
- name: validation.manifest?.name || pluginName,
147
+ name: manifestName,
119
148
  version: validation.manifest?.version || '0.0.0',
120
149
  path: destDir,
121
150
  enabled: true,
@@ -123,18 +152,18 @@ export class PluginManager {
123
152
  })
124
153
 
125
154
  this.saveState()
126
- const similarWarning = this.findSimilarWarning(pluginName)
155
+ const similarWarning = this.findSimilarWarning(manifestName)
127
156
  return {
128
157
  success: true,
129
158
  message:
130
- `Plugin "${pluginName}" installed from npm` +
159
+ `Plugin "${manifestName}" installed from npm` +
131
160
  (similarWarning ? `\n⚠ ${similarWarning}` : ''),
132
161
  }
133
162
  } catch (err: unknown) {
134
163
  const msg = err instanceof Error ? err.message : String(err)
135
164
  // Clean up on failure
136
165
  try {
137
- rmSync(destDir, { recursive: true, force: true })
166
+ rmSync(stagingDir, { recursive: true, force: true })
138
167
  } catch {
139
168
  /* ok */
140
169
  }
@@ -1,32 +1,67 @@
1
- import { readFileSync } from 'node:fs'
1
+ import { readFileSync, existsSync } from 'node:fs'
2
2
  import { join } from 'node:path'
3
3
 
4
4
  export interface PluginManifest {
5
5
  name: string
6
- version: string
6
+ version?: string
7
7
  miphamVersion?: string
8
8
  hooks?: Record<string, unknown>
9
+ /** Claude manifest fields (commands/agents/skills/mcpServers/…) — read by the Claude loader. */
10
+ [key: string]: unknown
9
11
  }
10
12
 
11
- export function validatePlugin(dir: string): {
13
+ /** Which manifest convention a plugin uses. */
14
+ export type PluginFormat = 'mipham' | 'claude'
15
+
16
+ export interface PluginValidation {
12
17
  valid: boolean
13
18
  errors: string[]
14
19
  manifest?: PluginManifest
15
- } {
16
- const errors: string[] = []
20
+ format: PluginFormat
21
+ /** Resolved manifest path (empty when no manifest found). */
22
+ manifestPath: string
23
+ }
24
+
25
+ /**
26
+ * Resolve a plugin's manifest. Mipham plugins use `plugin.json` at the plugin
27
+ * root; Claude marketplace plugins use `.claude-plugin/plugin.json`.
28
+ */
29
+ function resolveManifestPath(dir: string): { path: string; format: PluginFormat } | null {
30
+ const mipham = join(dir, 'plugin.json')
31
+ if (existsSync(mipham)) return { path: mipham, format: 'mipham' }
32
+ const claude = join(dir, '.claude-plugin', 'plugin.json')
33
+ if (existsSync(claude)) return { path: claude, format: 'claude' }
34
+ return null
35
+ }
36
+
37
+ /** Detect a plugin's manifest convention (defaults to Mipham when absent). */
38
+ export function detectPluginFormat(dir: string): PluginFormat {
39
+ return resolveManifestPath(dir)?.format ?? 'mipham'
40
+ }
17
41
 
42
+ export function validatePlugin(dir: string): PluginValidation {
43
+ const resolved = resolveManifestPath(dir)
44
+ if (!resolved) {
45
+ return {
46
+ valid: false,
47
+ errors: ['No plugin manifest found (expected plugin.json or .claude-plugin/plugin.json)'],
48
+ format: 'mipham',
49
+ manifestPath: '',
50
+ }
51
+ }
52
+
53
+ const errors: string[] = []
18
54
  try {
19
- const raw = readFileSync(join(dir, 'plugin.json'), 'utf-8')
55
+ const raw = readFileSync(resolved.path, 'utf-8')
20
56
  const manifest = JSON.parse(raw) as PluginManifest
21
57
 
22
58
  if (!manifest.name || !/^[a-z0-9-]+$/.test(manifest.name)) {
23
59
  errors.push('Invalid plugin name: must be lowercase alphanumeric with hyphens')
24
60
  }
25
- if (!manifest.version) {
61
+ // Mipham plugins require a version; Claude plugins only require `name`.
62
+ if (resolved.format === 'mipham' && !manifest.version) {
26
63
  errors.push('Missing required field: version')
27
64
  }
28
-
29
- // Check for suspicious hooks
30
65
  if (manifest.hooks) {
31
66
  const hooksStr = JSON.stringify(manifest.hooks)
32
67
  if (hooksStr.includes('rm -rf') || hooksStr.includes('curl') || hooksStr.includes('eval')) {
@@ -34,8 +69,20 @@ export function validatePlugin(dir: string): {
34
69
  }
35
70
  }
36
71
 
37
- return { valid: errors.length === 0, errors, manifest }
72
+ return {
73
+ valid: errors.length === 0,
74
+ errors,
75
+ manifest,
76
+ format: resolved.format,
77
+ manifestPath: resolved.path,
78
+ }
38
79
  } catch (err) {
39
- return { valid: false, errors: [`Failed to read plugin.json: ${String(err)}`] }
80
+ const label = resolved.format === 'claude' ? '.claude-plugin/plugin.json' : 'plugin.json'
81
+ return {
82
+ valid: false,
83
+ errors: [`Failed to read ${label}: ${String(err)}`],
84
+ format: resolved.format,
85
+ manifestPath: resolved.path,
86
+ }
40
87
  }
41
88
  }
@@ -28,8 +28,9 @@ const PROMPT_INJECTION_PATTERNS: Array<{ regex: RegExp; label: string }> = [
28
28
  ]
29
29
 
30
30
  const DANGEROUS_BASH_PATTERNS: Array<{ regex: RegExp; label: string }> = [
31
- { regex: /\$\(.+\)/, label: 'command-substitution' },
32
- { regex: /`[^`]+`/, label: 'backtick-substitution' },
31
+ // NOTE: command substitution ($( … ) / `…`) is legitimate Bash syntax and is NOT
32
+ // blocked here — its dangerous content (a pipe to sh/bash, a chained rm/cat/sh,
33
+ // …) is still caught by the pipe/chain patterns below.
33
34
  { regex: /;\s*(rm|cat|sh|bash)\b/, label: 'command-chain-injection' },
34
35
  { regex: /curl.+\|\s*(sh|bash)\b/, label: 'curl-pipe-shell' },
35
36
  { regex: /\|\s*(sh|bash)\b/, label: 'pipe-to-shell' },
@@ -0,0 +1,26 @@
1
+ /**
2
+ * Detection and messaging for the "current working directory was deleted"
3
+ * startup failure.
4
+ *
5
+ * `process.cwd()` throws `ENOENT` when the directory the process was launched
6
+ * from no longer exists (e.g. a removed git worktree). The CLI entry checks
7
+ * this up front and prints a clear, actionable message instead of letting the
8
+ * error surface as a raw crash dump (matches Claude Code 2.1.239).
9
+ */
10
+
11
+ /** True when `err` is the ENOENT thrown by `process.cwd()` on a deleted cwd. */
12
+ export function isDeletedCwdError(err: unknown): boolean {
13
+ if (!(err instanceof Error)) return false
14
+ // Node sets `code = 'ENOENT'` with a message ending in `, uv_cwd`. Bun and
15
+ // other runtimes may omit the code but still name `getcwd`/`uv_cwd`.
16
+ return (err as NodeJS.ErrnoException).code === 'ENOENT' || /uv_cwd|getcwd/i.test(err.message)
17
+ }
18
+
19
+ /** Human-readable guidance shown when the launch directory was deleted. */
20
+ export function deletedCwdMessage(): string {
21
+ return (
22
+ `Mipham Code can't start: the current working directory no longer exists.\n\n` +
23
+ `The directory you launched from was deleted (for example, a removed git worktree).\n` +
24
+ `Change to a valid directory and run \`mipham\` again.`
25
+ )
26
+ }
@@ -0,0 +1,34 @@
1
+ /**
2
+ * Session-scoped accumulator for graft's token savings. Graft tools emit a
3
+ * "[graft] tokens saved ≈ N" footer on their output; the engine parses that
4
+ * footer after each tool result and adds it here, so the statusline can show
5
+ * "~N tok saved" (mirroring graft's own hooks.js `handleToolSavings`).
6
+ */
7
+
8
+ /** Sum the "[graft] tokens saved ≈ N" footers in a tool output. */
9
+ export function parseGraftSavings(text: string): number {
10
+ let total = 0
11
+ for (const m of text.matchAll(/\[graft\] tokens saved ≈ ([\d,]+)/g)) {
12
+ const digits = m[1]
13
+ if (digits) total += Number(digits.replace(/,/g, '')) || 0
14
+ }
15
+ return total
16
+ }
17
+
18
+ let savings = 0
19
+
20
+ /** Add a tool result's graft savings to the session running total. */
21
+ export function accumulateGraftSavings(text: string): void {
22
+ if (!text) return
23
+ savings += parseGraftSavings(text)
24
+ }
25
+
26
+ /** The session's running graft token savings. */
27
+ export function getGraftSavings(): number {
28
+ return savings
29
+ }
30
+
31
+ /** Reset the running total (e.g. on session reset). */
32
+ export function resetGraftSavings(): void {
33
+ savings = 0
34
+ }