@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 +21 -2
- package/package.json +1 -1
- package/src/commands/project.ts +9 -6
- package/src/core/engine.ts +7 -1
- package/src/i18n-core/locales/en-US.json +2 -3
- package/src/i18n-core/locales/zh-CN.json +2 -3
- package/src/index.tsx +1 -1
- package/src/plugin/claude-plugin.ts +174 -0
- package/src/plugin/plugin-loader.ts +25 -0
- package/src/plugin/plugin-manager.ts +46 -17
- package/src/plugin/plugin-validator.ts +58 -11
- package/src/security/gate.ts +3 -2
- package/src/shared/deleted-cwd.ts +26 -0
- package/src/shared/graft-savings.ts +34 -0
- package/src/shared/graft-stats.ts +53 -0
- package/src/shared/package-info.ts +1 -1
- package/src/skills/loader.ts +11 -33
- package/src/skills/seam.ts +1 -1
- package/src/tools/agent/memory.ts +20 -2
- package/src/tools/exec/bash.ts +4 -4
- package/src/ui/app.tsx +16 -3
- package/src/ui/graft-status.tsx +63 -0
- package/src/ui/input.tsx +45 -156
- package/src/ui/vim-motions.ts +0 -113
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
|
-
|
|
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
|
-
|
|
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
package/src/commands/project.ts
CHANGED
|
@@ -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
|
-
|
|
119
|
-
|
|
120
|
-
|
|
121
|
-
|
|
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
|
-
|
|
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
|
}
|
package/src/core/engine.ts
CHANGED
|
@@ -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
|
-
|
|
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
|
|
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(
|
|
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 {
|
|
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
|
-
//
|
|
82
|
-
|
|
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
|
|
85
|
-
if (existsSync(
|
|
94
|
+
const stagingDir = join(this.pluginDir, stagingName)
|
|
95
|
+
if (existsSync(stagingDir)) {
|
|
86
96
|
return {
|
|
87
97
|
success: false,
|
|
88
|
-
message: `Plugin "${
|
|
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(
|
|
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(
|
|
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 "${
|
|
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
|
-
//
|
|
110
|
-
|
|
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(
|
|
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:
|
|
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(
|
|
155
|
+
const similarWarning = this.findSimilarWarning(manifestName)
|
|
127
156
|
return {
|
|
128
157
|
success: true,
|
|
129
158
|
message:
|
|
130
|
-
`Plugin "${
|
|
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(
|
|
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
|
|
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
|
-
|
|
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
|
-
|
|
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(
|
|
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
|
-
|
|
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 {
|
|
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
|
-
|
|
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
|
}
|
package/src/security/gate.ts
CHANGED
|
@@ -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
|
-
|
|
32
|
-
|
|
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
|
+
}
|