@miphamai/cli 0.4.0 → 0.5.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 +186 -0
- package/package.json +1 -1
- package/src/agent/agent-context.ts +56 -0
- package/src/agent/agent-registry.ts +134 -0
- package/src/agent/sub-agent.ts +73 -88
- package/src/agent/types.ts +39 -0
- package/src/agent-view/agent-view-manager.ts +217 -0
- package/src/agent-view/dashboard.tsx +218 -0
- package/src/agent-view/session-peek.tsx +72 -0
- package/src/agent-view/session-row.tsx +70 -0
- package/src/artifacts/server.ts +31 -0
- package/src/artifacts/versioning.ts +127 -0
- package/src/core/context-compact.ts +50 -0
- package/src/core/context-drain.ts +54 -0
- package/src/core/context-microcompact.ts +132 -0
- package/src/core/context-snip.ts +74 -0
- package/src/core/context-token.ts +108 -0
- package/src/core/context.ts +169 -0
- package/src/core/engine.ts +144 -3
- package/src/core/hooks-config.ts +52 -0
- package/src/core/hooks-executor.ts +113 -0
- package/src/core/hooks.ts +90 -30
- package/src/core/instructions.ts +11 -0
- package/src/core/memory/memory-loader.ts +23 -0
- package/src/core/memory/memory-manager.ts +192 -0
- package/src/core/memory/memory-writer.ts +72 -0
- package/src/core/permission-config.ts +34 -0
- package/src/core/permission-rules.ts +66 -0
- package/src/core/permission.ts +221 -36
- package/src/index.tsx +20 -0
- package/src/plugin/plugin-loader.ts +36 -0
- package/src/plugin/plugin-manager.ts +125 -0
- package/src/plugin/plugin-validator.ts +41 -0
- package/src/shared/types.ts +61 -1
- package/src/skills/fork-executor.ts +40 -0
- package/src/skills/loader.ts +46 -0
- package/src/tools/agent/agent.ts +20 -11
- package/src/tools/agent/skill.ts +32 -2
- package/src/tools/computer/app-launcher.ts +39 -0
- package/src/tools/computer/browser.ts +48 -0
- package/src/tools/computer/computer-use.ts +88 -0
- package/src/tools/computer/playwright.d.ts +7 -0
- package/src/tools/computer/screenshot.ts +57 -0
- package/src/tools/index.ts +3 -0
- package/src/ui/app.tsx +10 -1
- package/src/ui/commands.ts +99 -30
- package/src/ui/input.tsx +123 -8
- package/src/ui/vim-motions.ts +96 -0
- package/src/workflow/budget.ts +33 -0
- package/src/workflow/journal.ts +105 -0
- package/src/workflow/primitives/agent.ts +51 -0
- package/src/workflow/primitives/parallel.ts +8 -0
- package/src/workflow/primitives/phase.ts +19 -0
- package/src/workflow/primitives/pipeline.ts +24 -0
- package/src/workflow/runtime.ts +87 -0
- package/src/workflow/sandbox.ts +75 -0
package/src/core/permission.ts
CHANGED
|
@@ -1,61 +1,127 @@
|
|
|
1
1
|
import type {
|
|
2
2
|
ToolDefinition,
|
|
3
|
-
|
|
3
|
+
PermissionMode,
|
|
4
4
|
PermissionLevel,
|
|
5
5
|
PermissionRule,
|
|
6
6
|
} from '../shared/index.ts'
|
|
7
|
+
import type { PermissionRuleEntry } from '../shared/index.ts'
|
|
8
|
+
import { matchBashRule, compileRule } from './permission-rules'
|
|
9
|
+
import { loadPermissionConfig, nextMode, MODE_CYCLE } from './permission-config'
|
|
10
|
+
|
|
11
|
+
const VALID_MODES: Set<string> = new Set<string>(MODE_CYCLE)
|
|
7
12
|
|
|
8
13
|
export class PermissionSystem {
|
|
9
|
-
private
|
|
10
|
-
private
|
|
14
|
+
private allowRules: PermissionRuleEntry[] = []
|
|
15
|
+
private denyRules: PermissionRuleEntry[] = []
|
|
16
|
+
private askRules: PermissionRuleEntry[] = []
|
|
17
|
+
/** Legacy exact-name rules for backward compat (set via setRule with 'auto' level). */
|
|
18
|
+
private legacyRules = new Map<string, PermissionLevel>()
|
|
19
|
+
/** Legacy default level from constructor when passed non-mode values like 'ask' or 'bypass'. */
|
|
20
|
+
private legacyDefaultFallback: PermissionLevel | null = null
|
|
21
|
+
private mode: PermissionMode = 'default'
|
|
11
22
|
|
|
12
|
-
constructor(
|
|
23
|
+
constructor(modeOrLevel: PermissionLevel = 'default') {
|
|
24
|
+
if (VALID_MODES.has(modeOrLevel)) {
|
|
25
|
+
this.mode = modeOrLevel as PermissionMode
|
|
26
|
+
} else {
|
|
27
|
+
// Legacy values ('ask', 'bypass') → store as fallback, use 'default' mode
|
|
28
|
+
this.mode = 'default'
|
|
29
|
+
this.legacyDefaultFallback = modeOrLevel
|
|
30
|
+
}
|
|
31
|
+
}
|
|
13
32
|
|
|
14
|
-
|
|
15
|
-
|
|
33
|
+
// ── Mode management ──
|
|
34
|
+
|
|
35
|
+
setMode(mode: PermissionMode): void {
|
|
36
|
+
this.mode = mode
|
|
16
37
|
}
|
|
17
38
|
|
|
18
|
-
|
|
19
|
-
return this.
|
|
39
|
+
getMode(): PermissionMode {
|
|
40
|
+
return this.mode
|
|
20
41
|
}
|
|
21
42
|
|
|
22
|
-
|
|
23
|
-
|
|
24
|
-
|
|
25
|
-
} else {
|
|
26
|
-
const rule = toolNameOrRule
|
|
27
|
-
if (rule.pattern) {
|
|
28
|
-
this.patternRules.push({ pattern: new RegExp(rule.pattern), level: rule.level })
|
|
29
|
-
} else {
|
|
30
|
-
this.rules.set(rule.toolName, rule.level)
|
|
31
|
-
}
|
|
32
|
-
}
|
|
43
|
+
cycleMode(): PermissionMode {
|
|
44
|
+
this.mode = nextMode(this.mode)
|
|
45
|
+
return this.mode
|
|
33
46
|
}
|
|
34
47
|
|
|
35
|
-
|
|
36
|
-
|
|
48
|
+
// ── Rule management ──
|
|
49
|
+
|
|
50
|
+
allow(rule: string): void {
|
|
51
|
+
this.allowRules.push(compileRule(rule, 'allow'))
|
|
52
|
+
}
|
|
53
|
+
|
|
54
|
+
deny(rule: string): void {
|
|
55
|
+
this.denyRules.push(compileRule(rule, 'deny'))
|
|
37
56
|
}
|
|
38
57
|
|
|
58
|
+
ask(rule: string): void {
|
|
59
|
+
this.askRules.push(compileRule(rule, 'ask'))
|
|
60
|
+
}
|
|
61
|
+
|
|
62
|
+
loadConfig(raw: { mode?: string; allow?: string[]; deny?: string[] }): void {
|
|
63
|
+
const config = loadPermissionConfig(
|
|
64
|
+
raw as Partial<{ mode: PermissionMode; allow: string[]; deny: string[] }>,
|
|
65
|
+
)
|
|
66
|
+
this.mode = config.mode
|
|
67
|
+
|
|
68
|
+
this.allowRules = []
|
|
69
|
+
this.denyRules = []
|
|
70
|
+
this.askRules = []
|
|
71
|
+
|
|
72
|
+
for (const rule of config.allow) {
|
|
73
|
+
this.allowRules.push(compileRule(rule, 'allow'))
|
|
74
|
+
}
|
|
75
|
+
for (const rule of config.deny) {
|
|
76
|
+
this.denyRules.push(compileRule(rule, 'deny'))
|
|
77
|
+
}
|
|
78
|
+
}
|
|
79
|
+
|
|
80
|
+
// ── Permission check ──
|
|
81
|
+
|
|
39
82
|
/**
|
|
40
|
-
*
|
|
41
|
-
*
|
|
42
|
-
*
|
|
83
|
+
* Resolution chain (first match wins):
|
|
84
|
+
* 1. Deny rules → block
|
|
85
|
+
* 2. Ask rules → require approval
|
|
86
|
+
* 3. Allow rules → permit
|
|
87
|
+
* 4. Legacy exact-name rules (backward compat — e.g. setRule('tool', 'auto'))
|
|
88
|
+
* 5. Mode baseline → mode-specific default (overrides tool.permission for explicit modes)
|
|
89
|
+
* 6. Tool's own permission → tool-specific default (backward compat)
|
|
90
|
+
* 7. Legacy constructor fallback (when constructed with 'ask'/'bypass')
|
|
91
|
+
* 8. System default → 'ask'
|
|
43
92
|
*/
|
|
44
|
-
check(tool: ToolDefinition,
|
|
45
|
-
// 1.
|
|
46
|
-
const
|
|
47
|
-
|
|
48
|
-
|
|
49
|
-
// 2. Pattern-based rules (e.g. "file_*" → bypass)
|
|
50
|
-
for (const { pattern, level } of this.patternRules) {
|
|
51
|
-
if (pattern.test(tool.name)) return level
|
|
93
|
+
check(tool: ToolDefinition, input: Record<string, unknown>): PermissionLevel {
|
|
94
|
+
// 1. Check deny rules (always win)
|
|
95
|
+
for (const rule of this.denyRules) {
|
|
96
|
+
if (this.ruleMatches(rule, tool, input)) return 'ask'
|
|
52
97
|
}
|
|
53
98
|
|
|
54
|
-
//
|
|
99
|
+
// 2. Check ask rules
|
|
100
|
+
for (const rule of this.askRules) {
|
|
101
|
+
if (this.ruleMatches(rule, tool, input)) return 'ask'
|
|
102
|
+
}
|
|
103
|
+
|
|
104
|
+
// 3. Check allow rules
|
|
105
|
+
for (const rule of this.allowRules) {
|
|
106
|
+
if (this.ruleMatches(rule, tool, input)) return 'bypass'
|
|
107
|
+
}
|
|
108
|
+
|
|
109
|
+
// 4. Legacy exact-name rules (backward compat)
|
|
110
|
+
const legacyLevel = this.legacyRules.get(tool.name)
|
|
111
|
+
if (legacyLevel !== undefined) return legacyLevel
|
|
112
|
+
|
|
113
|
+
// 5. Mode baseline
|
|
114
|
+
const baseline = this.modeBaseline(tool)
|
|
115
|
+
if (baseline !== 'mode-baseline') return baseline
|
|
116
|
+
|
|
117
|
+
// 6. Tool's own permission level (backward compat fallback)
|
|
55
118
|
if (tool.permission) return tool.permission
|
|
56
119
|
|
|
57
|
-
//
|
|
58
|
-
return this.
|
|
120
|
+
// 7. Legacy constructor fallback
|
|
121
|
+
if (this.legacyDefaultFallback) return this.legacyDefaultFallback
|
|
122
|
+
|
|
123
|
+
// 8. System default
|
|
124
|
+
return 'ask'
|
|
59
125
|
}
|
|
60
126
|
|
|
61
127
|
needsApproval(tool: ToolDefinition, input: Record<string, unknown>): boolean {
|
|
@@ -66,8 +132,127 @@ export class PermissionSystem {
|
|
|
66
132
|
return this.check(tool, input) === 'bypass'
|
|
67
133
|
}
|
|
68
134
|
|
|
135
|
+
// ── Helpers ──
|
|
136
|
+
|
|
137
|
+
private ruleMatches(
|
|
138
|
+
rule: PermissionRuleEntry,
|
|
139
|
+
tool: ToolDefinition,
|
|
140
|
+
input: Record<string, unknown>,
|
|
141
|
+
): boolean {
|
|
142
|
+
// Try Bash-style matching first
|
|
143
|
+
if (rule.pattern.includes('(')) {
|
|
144
|
+
return matchBashRule(rule.pattern, tool.name, input)
|
|
145
|
+
}
|
|
146
|
+
// Simple tool name match
|
|
147
|
+
return rule.pattern === tool.name || rule.compiled.test(tool.name)
|
|
148
|
+
}
|
|
149
|
+
|
|
150
|
+
private modeBaseline(tool: ToolDefinition): PermissionLevel | 'mode-baseline' {
|
|
151
|
+
switch (this.mode) {
|
|
152
|
+
case 'default':
|
|
153
|
+
// Delegate to tool.permission (backward compat)
|
|
154
|
+
return 'mode-baseline'
|
|
155
|
+
|
|
156
|
+
case 'acceptEdits':
|
|
157
|
+
// Reads + file edits free; Bash requires approval
|
|
158
|
+
return tool.category === 'file'
|
|
159
|
+
? ['Bash'].includes(tool.name)
|
|
160
|
+
? 'ask'
|
|
161
|
+
: 'bypass'
|
|
162
|
+
: tool.name === 'Bash'
|
|
163
|
+
? 'ask'
|
|
164
|
+
: 'ask'
|
|
165
|
+
|
|
166
|
+
case 'plan':
|
|
167
|
+
// Only reads, no writes or executes
|
|
168
|
+
return tool.category === 'file' && ['Read', 'Grep', 'Glob'].includes(tool.name)
|
|
169
|
+
? 'bypass'
|
|
170
|
+
: 'ask'
|
|
171
|
+
|
|
172
|
+
case 'auto':
|
|
173
|
+
// Respect tool-level permissions; safety checks handled by hook layer
|
|
174
|
+
return 'mode-baseline'
|
|
175
|
+
|
|
176
|
+
case 'dontAsk':
|
|
177
|
+
// Only allowlisted tools free (already handled above); everything else requires approval
|
|
178
|
+
return 'ask'
|
|
179
|
+
|
|
180
|
+
case 'bypassPermissions':
|
|
181
|
+
return 'bypass'
|
|
182
|
+
|
|
183
|
+
default:
|
|
184
|
+
return 'mode-baseline'
|
|
185
|
+
}
|
|
186
|
+
}
|
|
187
|
+
|
|
188
|
+
// ── Legacy compatibility ──
|
|
189
|
+
|
|
190
|
+
setDefaultLevel(level: PermissionLevel): void {
|
|
191
|
+
// Map legacy 3-level to new mode
|
|
192
|
+
if (level === 'auto') this.mode = 'auto'
|
|
193
|
+
else if (level === 'bypass') this.mode = 'bypassPermissions'
|
|
194
|
+
else this.mode = 'default'
|
|
195
|
+
}
|
|
196
|
+
|
|
197
|
+
getDefaultLevel(): PermissionLevel {
|
|
198
|
+
// Legacy constructor fallback takes priority
|
|
199
|
+
if (this.legacyDefaultFallback) return this.legacyDefaultFallback
|
|
200
|
+
if (this.mode === 'auto' || this.mode === 'bypassPermissions' || this.mode === 'dontAsk')
|
|
201
|
+
return 'bypass'
|
|
202
|
+
if (this.mode === 'plan') return 'ask'
|
|
203
|
+
return 'auto'
|
|
204
|
+
}
|
|
205
|
+
|
|
206
|
+
setRule(toolNameOrRule: string | PermissionRule, level?: PermissionLevel): void {
|
|
207
|
+
if (typeof toolNameOrRule === 'string') {
|
|
208
|
+
const toolName = toolNameOrRule
|
|
209
|
+
// Remove old entries for this tool
|
|
210
|
+
this.removeRuleFromArrays(toolName)
|
|
211
|
+
if (level !== undefined) {
|
|
212
|
+
this.legacyRules.set(toolName, level)
|
|
213
|
+
// Also sync to new-style arrays for listRules / new API consistency
|
|
214
|
+
if (level === 'bypass') this.allow(toolName)
|
|
215
|
+
else if (level === 'ask') this.ask(toolName)
|
|
216
|
+
// 'auto' is stored only in legacyRules (returns 'auto', not 'bypass')
|
|
217
|
+
}
|
|
218
|
+
} else {
|
|
219
|
+
const rule = toolNameOrRule
|
|
220
|
+
if (rule.pattern) {
|
|
221
|
+
const entry = compileRule(rule.pattern, rule.level === 'bypass' ? 'allow' : 'ask')
|
|
222
|
+
if (rule.level === 'bypass') this.allowRules.push(entry)
|
|
223
|
+
else this.askRules.push(entry)
|
|
224
|
+
} else {
|
|
225
|
+
this.removeRuleFromArrays(rule.toolName)
|
|
226
|
+
this.legacyRules.set(rule.toolName, rule.level)
|
|
227
|
+
if (rule.level === 'bypass') this.allow(rule.toolName)
|
|
228
|
+
else if (rule.level === 'ask') this.ask(rule.toolName)
|
|
229
|
+
}
|
|
230
|
+
}
|
|
231
|
+
}
|
|
232
|
+
|
|
233
|
+
removeRule(toolName: string): void {
|
|
234
|
+
this.legacyRules.delete(toolName)
|
|
235
|
+
this.removeRuleFromArrays(toolName)
|
|
236
|
+
}
|
|
237
|
+
|
|
238
|
+
private removeRuleFromArrays(toolName: string): void {
|
|
239
|
+
this.allowRules = this.allowRules.filter((r) => r.pattern !== toolName)
|
|
240
|
+
this.denyRules = this.denyRules.filter((r) => r.pattern !== toolName)
|
|
241
|
+
this.askRules = this.askRules.filter((r) => r.pattern !== toolName)
|
|
242
|
+
}
|
|
243
|
+
|
|
69
244
|
listRules(): Map<string, PermissionLevel> {
|
|
70
|
-
|
|
245
|
+
const map = new Map<string, PermissionLevel>(this.legacyRules)
|
|
246
|
+
for (const r of this.allowRules) {
|
|
247
|
+
if (!map.has(r.pattern)) map.set(r.pattern, 'bypass')
|
|
248
|
+
}
|
|
249
|
+
for (const r of this.denyRules) {
|
|
250
|
+
if (!map.has(r.pattern)) map.set(r.pattern, 'ask')
|
|
251
|
+
}
|
|
252
|
+
for (const r of this.askRules) {
|
|
253
|
+
if (!map.has(r.pattern)) map.set(r.pattern, 'ask')
|
|
254
|
+
}
|
|
255
|
+
return map
|
|
71
256
|
}
|
|
72
257
|
|
|
73
258
|
getByCategory(
|
package/src/index.tsx
CHANGED
|
@@ -13,6 +13,8 @@ import { McpClient } from './mcp/client'
|
|
|
13
13
|
import { HookEngine } from './core/hooks'
|
|
14
14
|
import { ArtifactServer } from './artifacts/server'
|
|
15
15
|
import { ARTIFACTS_DIR, ARTIFACT_PORT, MIPHAM_DIR } from './shared/constants'
|
|
16
|
+
import { AgentViewManager } from './agent-view/agent-view-manager'
|
|
17
|
+
import { AgentViewDashboard } from './agent-view/dashboard'
|
|
16
18
|
|
|
17
19
|
interface RunOptions {
|
|
18
20
|
model?: string
|
|
@@ -23,6 +25,23 @@ interface RunOptions {
|
|
|
23
25
|
}
|
|
24
26
|
|
|
25
27
|
export async function runApp(options: RunOptions): Promise<void> {
|
|
28
|
+
// Handle `mipham agents` subcommand — launch standalone dashboard
|
|
29
|
+
const args = process.argv.slice(2)
|
|
30
|
+
if (args[0] === 'agents') {
|
|
31
|
+
const agentViewManager = new AgentViewManager()
|
|
32
|
+
const { waitUntilExit } = render(
|
|
33
|
+
<AgentViewDashboard
|
|
34
|
+
manager={agentViewManager}
|
|
35
|
+
onAttach={() => {}}
|
|
36
|
+
onExit={() => process.exit(0)}
|
|
37
|
+
/>,
|
|
38
|
+
)
|
|
39
|
+
await waitUntilExit()
|
|
40
|
+
process.exit(0)
|
|
41
|
+
}
|
|
42
|
+
|
|
43
|
+
// Create AgentViewManager for the full app (shared across the session)
|
|
44
|
+
const agentViewManager = new AgentViewManager()
|
|
26
45
|
// Set terminal window title
|
|
27
46
|
process.stdout.write('\x1b]0;Mipham Code\x07')
|
|
28
47
|
|
|
@@ -94,6 +113,7 @@ export async function runApp(options: RunOptions): Promise<void> {
|
|
|
94
113
|
const engine = new QueryEngine(registry, context, tools)
|
|
95
114
|
engine.setHookEngine(hookEngine)
|
|
96
115
|
engine.setArtifactServer(artifactServer)
|
|
116
|
+
engine.setAgentViewManager(agentViewManager)
|
|
97
117
|
engine.setupContextSummarizer()
|
|
98
118
|
|
|
99
119
|
// Auto-save session on exit
|
|
@@ -0,0 +1,36 @@
|
|
|
1
|
+
import type { PluginManager } from './plugin-manager'
|
|
2
|
+
import { join } from 'node:path'
|
|
3
|
+
import { existsSync } from 'node:fs'
|
|
4
|
+
|
|
5
|
+
export function loadPlugins(
|
|
6
|
+
pluginManager: PluginManager,
|
|
7
|
+
engine: unknown,
|
|
8
|
+
skillsLoader: unknown,
|
|
9
|
+
): void {
|
|
10
|
+
for (const plugin of pluginManager.getEnabled()) {
|
|
11
|
+
// Load custom agents
|
|
12
|
+
const agentsDir = join(plugin.path, 'agents')
|
|
13
|
+
if (existsSync(agentsDir)) {
|
|
14
|
+
// → register with AgentRegistry
|
|
15
|
+
}
|
|
16
|
+
|
|
17
|
+
// Load custom skills
|
|
18
|
+
const skillsDir = join(plugin.path, 'skills')
|
|
19
|
+
if (existsSync(skillsDir)) {
|
|
20
|
+
// → register with SkillsLoader
|
|
21
|
+
}
|
|
22
|
+
|
|
23
|
+
// Load MCP servers
|
|
24
|
+
const mcpDir = join(plugin.path, 'mcp-servers')
|
|
25
|
+
if (existsSync(mcpDir)) {
|
|
26
|
+
// → register MCP configs
|
|
27
|
+
}
|
|
28
|
+
|
|
29
|
+
// Load hooks
|
|
30
|
+
// → parse plugin.json hooks → register with HookEngine
|
|
31
|
+
}
|
|
32
|
+
|
|
33
|
+
// Suppress unused parameter warnings — these are wiring stubs for future phases
|
|
34
|
+
void engine
|
|
35
|
+
void skillsLoader
|
|
36
|
+
}
|
|
@@ -0,0 +1,125 @@
|
|
|
1
|
+
import { mkdirSync, existsSync, writeFileSync, readFileSync, readdirSync, rmSync } from 'node:fs'
|
|
2
|
+
import { join } from 'node:path'
|
|
3
|
+
import { homedir } from 'node:os'
|
|
4
|
+
import { validatePlugin } from './plugin-validator'
|
|
5
|
+
|
|
6
|
+
const PLUGIN_DIR = join(homedir(), '.mipham', 'plugins')
|
|
7
|
+
|
|
8
|
+
export interface InstalledPlugin {
|
|
9
|
+
name: string
|
|
10
|
+
version: string
|
|
11
|
+
path: string
|
|
12
|
+
enabled: boolean
|
|
13
|
+
installedAt: string
|
|
14
|
+
}
|
|
15
|
+
|
|
16
|
+
export class PluginManager {
|
|
17
|
+
private plugins: InstalledPlugin[] = []
|
|
18
|
+
private pluginDir: string
|
|
19
|
+
private statePath: string
|
|
20
|
+
|
|
21
|
+
constructor(pluginDir?: string) {
|
|
22
|
+
this.pluginDir = pluginDir ?? PLUGIN_DIR
|
|
23
|
+
mkdirSync(this.pluginDir, { recursive: true })
|
|
24
|
+
this.statePath = join(this.pluginDir, 'state.json')
|
|
25
|
+
this.loadState()
|
|
26
|
+
}
|
|
27
|
+
|
|
28
|
+
install(sourcePath: string): { success: boolean; message: string } {
|
|
29
|
+
const validation = validatePlugin(sourcePath)
|
|
30
|
+
if (!validation.valid || !validation.manifest) {
|
|
31
|
+
return { success: false, message: validation.errors.join('; ') }
|
|
32
|
+
}
|
|
33
|
+
|
|
34
|
+
const destDir = join(this.pluginDir, validation.manifest.name)
|
|
35
|
+
if (existsSync(destDir)) {
|
|
36
|
+
return {
|
|
37
|
+
success: false,
|
|
38
|
+
message: `Plugin "${validation.manifest.name}" is already installed`,
|
|
39
|
+
}
|
|
40
|
+
}
|
|
41
|
+
|
|
42
|
+
// Copy plugin directory
|
|
43
|
+
mkdirSync(destDir, { recursive: true })
|
|
44
|
+
this.copyDir(sourcePath, destDir)
|
|
45
|
+
|
|
46
|
+
this.plugins.push({
|
|
47
|
+
name: validation.manifest.name,
|
|
48
|
+
version: validation.manifest.version,
|
|
49
|
+
path: destDir,
|
|
50
|
+
enabled: true,
|
|
51
|
+
installedAt: new Date().toISOString(),
|
|
52
|
+
})
|
|
53
|
+
|
|
54
|
+
this.saveState()
|
|
55
|
+
return {
|
|
56
|
+
success: true,
|
|
57
|
+
message: `Plugin "${validation.manifest.name}" v${validation.manifest.version} installed`,
|
|
58
|
+
}
|
|
59
|
+
}
|
|
60
|
+
|
|
61
|
+
list(): InstalledPlugin[] {
|
|
62
|
+
return [...this.plugins]
|
|
63
|
+
}
|
|
64
|
+
|
|
65
|
+
remove(name: string): boolean {
|
|
66
|
+
const plugin = this.plugins.find((p) => p.name === name)
|
|
67
|
+
if (!plugin) return false
|
|
68
|
+
try {
|
|
69
|
+
rmSync(plugin.path, { recursive: true, force: true })
|
|
70
|
+
} catch {
|
|
71
|
+
// Directory may already be gone — that's fine
|
|
72
|
+
}
|
|
73
|
+
this.plugins = this.plugins.filter((p) => p.name !== name)
|
|
74
|
+
this.saveState()
|
|
75
|
+
return true
|
|
76
|
+
}
|
|
77
|
+
|
|
78
|
+
enable(name: string): boolean {
|
|
79
|
+
const p = this.plugins.find((p) => p.name === name)
|
|
80
|
+
if (!p) return false
|
|
81
|
+
p.enabled = true
|
|
82
|
+
this.saveState()
|
|
83
|
+
return true
|
|
84
|
+
}
|
|
85
|
+
|
|
86
|
+
disable(name: string): boolean {
|
|
87
|
+
const p = this.plugins.find((p) => p.name === name)
|
|
88
|
+
if (!p) return false
|
|
89
|
+
p.enabled = false
|
|
90
|
+
this.saveState()
|
|
91
|
+
return true
|
|
92
|
+
}
|
|
93
|
+
|
|
94
|
+
getEnabled(): InstalledPlugin[] {
|
|
95
|
+
return this.plugins.filter((p) => p.enabled)
|
|
96
|
+
}
|
|
97
|
+
|
|
98
|
+
private copyDir(src: string, dest: string): void {
|
|
99
|
+
mkdirSync(dest, { recursive: true })
|
|
100
|
+
for (const entry of readdirSync(src, { withFileTypes: true })) {
|
|
101
|
+
const srcPath = join(src, entry.name)
|
|
102
|
+
const destPath = join(dest, entry.name)
|
|
103
|
+
if (entry.isDirectory()) {
|
|
104
|
+
this.copyDir(srcPath, destPath)
|
|
105
|
+
} else {
|
|
106
|
+
const content = readFileSync(srcPath)
|
|
107
|
+
writeFileSync(destPath, content)
|
|
108
|
+
}
|
|
109
|
+
}
|
|
110
|
+
}
|
|
111
|
+
|
|
112
|
+
private loadState(): void {
|
|
113
|
+
try {
|
|
114
|
+
if (existsSync(this.statePath)) {
|
|
115
|
+
this.plugins = JSON.parse(readFileSync(this.statePath, 'utf-8'))
|
|
116
|
+
}
|
|
117
|
+
} catch {
|
|
118
|
+
this.plugins = []
|
|
119
|
+
}
|
|
120
|
+
}
|
|
121
|
+
|
|
122
|
+
private saveState(): void {
|
|
123
|
+
writeFileSync(this.statePath, JSON.stringify(this.plugins, null, 2), 'utf-8')
|
|
124
|
+
}
|
|
125
|
+
}
|
|
@@ -0,0 +1,41 @@
|
|
|
1
|
+
import { readFileSync } from 'node:fs'
|
|
2
|
+
import { join } from 'node:path'
|
|
3
|
+
|
|
4
|
+
export interface PluginManifest {
|
|
5
|
+
name: string
|
|
6
|
+
version: string
|
|
7
|
+
miphamVersion?: string
|
|
8
|
+
hooks?: Record<string, unknown>
|
|
9
|
+
}
|
|
10
|
+
|
|
11
|
+
export function validatePlugin(dir: string): {
|
|
12
|
+
valid: boolean
|
|
13
|
+
errors: string[]
|
|
14
|
+
manifest?: PluginManifest
|
|
15
|
+
} {
|
|
16
|
+
const errors: string[] = []
|
|
17
|
+
|
|
18
|
+
try {
|
|
19
|
+
const raw = readFileSync(join(dir, 'plugin.json'), 'utf-8')
|
|
20
|
+
const manifest = JSON.parse(raw) as PluginManifest
|
|
21
|
+
|
|
22
|
+
if (!manifest.name || !/^[a-z0-9-]+$/.test(manifest.name)) {
|
|
23
|
+
errors.push('Invalid plugin name: must be lowercase alphanumeric with hyphens')
|
|
24
|
+
}
|
|
25
|
+
if (!manifest.version) {
|
|
26
|
+
errors.push('Missing required field: version')
|
|
27
|
+
}
|
|
28
|
+
|
|
29
|
+
// Check for suspicious hooks
|
|
30
|
+
if (manifest.hooks) {
|
|
31
|
+
const hooksStr = JSON.stringify(manifest.hooks)
|
|
32
|
+
if (hooksStr.includes('rm -rf') || hooksStr.includes('curl') || hooksStr.includes('eval')) {
|
|
33
|
+
errors.push('Suspicious hook commands detected — manual review required')
|
|
34
|
+
}
|
|
35
|
+
}
|
|
36
|
+
|
|
37
|
+
return { valid: errors.length === 0, errors, manifest }
|
|
38
|
+
} catch (err) {
|
|
39
|
+
return { valid: false, errors: [`Failed to read plugin.json: ${String(err)}`] }
|
|
40
|
+
}
|
|
41
|
+
}
|
package/src/shared/types.ts
CHANGED
|
@@ -59,7 +59,9 @@ export interface ToolContext {
|
|
|
59
59
|
model: string
|
|
60
60
|
skillsLoader?: import('../skills/loader').SkillsLoader
|
|
61
61
|
registry?: import('../providers/registry').ProviderRegistry
|
|
62
|
+
toolRegistry?: Map<string, ToolDefinition>
|
|
62
63
|
artifactServer?: import('../artifacts/server').ArtifactServer
|
|
64
|
+
agentRegistry?: import('../agent/agent-registry').AgentRegistry
|
|
63
65
|
}
|
|
64
66
|
|
|
65
67
|
// ── Artifact Types ──
|
|
@@ -131,6 +133,16 @@ export interface SkillDefinition {
|
|
|
131
133
|
tools?: ToolDefinition[]
|
|
132
134
|
hooks?: HookDefinition[]
|
|
133
135
|
prompts?: Record<string, string>
|
|
136
|
+
/** Frontmatter: 'fork' means execute in isolated subagent, undefined means inline */
|
|
137
|
+
context?: string
|
|
138
|
+
/** Model override for fork execution */
|
|
139
|
+
model?: string
|
|
140
|
+
/** Tool whitelist for fork mode */
|
|
141
|
+
allowedTools?: string[]
|
|
142
|
+
/** When true, the skill is NOT shown in system-reminder for AI auto-triggering */
|
|
143
|
+
disableModelInvocation?: boolean
|
|
144
|
+
/** When true, users can invoke this skill directly via /<name> */
|
|
145
|
+
userInvocable?: boolean
|
|
134
146
|
}
|
|
135
147
|
|
|
136
148
|
// ── Hook Types ──
|
|
@@ -140,6 +152,25 @@ export type HookEvent =
|
|
|
140
152
|
| 'SessionStart'
|
|
141
153
|
| 'SessionEnd'
|
|
142
154
|
| 'Notification'
|
|
155
|
+
| 'Stop'
|
|
156
|
+
| 'UserPromptSubmit'
|
|
157
|
+
| 'PreCompact'
|
|
158
|
+
| 'PostCompact'
|
|
159
|
+
| 'ConfigChange'
|
|
160
|
+
|
|
161
|
+
export type HookType = 'command' | 'http' | 'code' | 'mcp_tool'
|
|
162
|
+
|
|
163
|
+
export interface HookConfig {
|
|
164
|
+
type: HookType
|
|
165
|
+
command?: string
|
|
166
|
+
args?: string[]
|
|
167
|
+
url?: string
|
|
168
|
+
method?: 'GET' | 'POST'
|
|
169
|
+
headers?: Record<string, string>
|
|
170
|
+
mcpServer?: string
|
|
171
|
+
mcpTool?: string
|
|
172
|
+
continueOnBlock?: boolean
|
|
173
|
+
}
|
|
143
174
|
|
|
144
175
|
export interface HookDefinition {
|
|
145
176
|
event: HookEvent
|
|
@@ -153,12 +184,19 @@ export interface HookContext {
|
|
|
153
184
|
toolInput?: Record<string, unknown>
|
|
154
185
|
toolResult?: ToolResult
|
|
155
186
|
sessionId: string
|
|
187
|
+
userPrompt?: string
|
|
188
|
+
configKey?: string
|
|
189
|
+
configValue?: unknown
|
|
156
190
|
}
|
|
157
191
|
|
|
158
192
|
export interface HookResult {
|
|
159
193
|
allowed: boolean
|
|
160
194
|
reason?: string
|
|
161
195
|
modifiedInput?: Record<string, unknown>
|
|
196
|
+
decision?: 'allow' | 'block'
|
|
197
|
+
permissionDecision?: 'allow' | 'deny' | 'ask' | 'defer'
|
|
198
|
+
additionalContext?: string
|
|
199
|
+
updatedOutput?: string
|
|
162
200
|
}
|
|
163
201
|
|
|
164
202
|
// ── Instruction Types ──
|
|
@@ -172,7 +210,29 @@ export interface InstructionFile {
|
|
|
172
210
|
}
|
|
173
211
|
|
|
174
212
|
// ── Permission Types ──
|
|
175
|
-
|
|
213
|
+
/** Six explicit permission modes matching Claude Code's permission architecture */
|
|
214
|
+
export type PermissionMode =
|
|
215
|
+
| 'default'
|
|
216
|
+
| 'acceptEdits'
|
|
217
|
+
| 'plan'
|
|
218
|
+
| 'auto'
|
|
219
|
+
| 'dontAsk'
|
|
220
|
+
| 'bypassPermissions'
|
|
221
|
+
|
|
222
|
+
/** Backward-compatible alias: PermissionMode plus legacy 'ask' and 'bypass' */
|
|
223
|
+
export type PermissionLevel = PermissionMode | 'ask' | 'bypass'
|
|
224
|
+
|
|
225
|
+
export interface PermissionConfig {
|
|
226
|
+
mode: PermissionMode
|
|
227
|
+
allow: string[]
|
|
228
|
+
deny: string[]
|
|
229
|
+
}
|
|
230
|
+
|
|
231
|
+
export interface PermissionRuleEntry {
|
|
232
|
+
pattern: string // e.g., "Bash(git:*)"
|
|
233
|
+
level: 'allow' | 'deny' | 'ask'
|
|
234
|
+
compiled: RegExp
|
|
235
|
+
}
|
|
176
236
|
|
|
177
237
|
export interface PermissionRule {
|
|
178
238
|
toolName: string
|
|
@@ -0,0 +1,40 @@
|
|
|
1
|
+
import { SubAgent } from '../agent/sub-agent'
|
|
2
|
+
import type { ProviderRegistry } from '../providers/registry'
|
|
3
|
+
import type { ToolDefinition, SkillDefinition } from '../shared/index.ts'
|
|
4
|
+
import type { AgentDefinition } from '../agent/types'
|
|
5
|
+
|
|
6
|
+
/**
|
|
7
|
+
* Execute a skill in an isolated subagent context (context: fork).
|
|
8
|
+
*
|
|
9
|
+
* The skill's markdown body becomes the subagent's system prompt.
|
|
10
|
+
* The skill's allowed-tools become the subagent's tool whitelist.
|
|
11
|
+
* Results are returned to the AI as internal context (not shown directly to user).
|
|
12
|
+
*/
|
|
13
|
+
export async function executeForkedSkill(
|
|
14
|
+
skill: SkillDefinition,
|
|
15
|
+
args: string,
|
|
16
|
+
registry: ProviderRegistry,
|
|
17
|
+
toolRegistry: Map<string, ToolDefinition>,
|
|
18
|
+
): Promise<string> {
|
|
19
|
+
const agentDef: AgentDefinition = {
|
|
20
|
+
name: `skill:${skill.name}`,
|
|
21
|
+
description: skill.description,
|
|
22
|
+
systemPrompt: buildSkillSystemPrompt(skill),
|
|
23
|
+
tools: skill.allowedTools?.join(', '),
|
|
24
|
+
model: skill.model || 'inherit',
|
|
25
|
+
permissionMode: 'inherit',
|
|
26
|
+
background: false,
|
|
27
|
+
source: 'builtin',
|
|
28
|
+
}
|
|
29
|
+
|
|
30
|
+
const sub = new SubAgent(registry, toolRegistry)
|
|
31
|
+
const prompt = args
|
|
32
|
+
? `Execute the "${skill.name}" skill with arguments: ${args}`
|
|
33
|
+
: `Execute the "${skill.name}" skill.`
|
|
34
|
+
|
|
35
|
+
return sub.execute(prompt, `skill:${skill.name}`, { agentDef })
|
|
36
|
+
}
|
|
37
|
+
|
|
38
|
+
function buildSkillSystemPrompt(skill: SkillDefinition): string {
|
|
39
|
+
return `You are executing the "${skill.name}" skill (v${skill.version}).\n\n${skill.description}\n\nFollow the skill instructions precisely and return results.`
|
|
40
|
+
}
|