@miphamai/cli 0.3.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.
Files changed (74) hide show
  1. package/bin/mipham.ts +188 -0
  2. package/dist/mipham +0 -0
  3. package/package.json +9 -9
  4. package/skills/mipham/om-artifact.mipham-skill.md +69 -0
  5. package/skills/mipham/om-model-optimize.mipham-skill.md +9 -6
  6. package/skills/mipham/om-security.mipham-skill.md +11 -8
  7. package/skills/standard/doc-generator.SKILL.md +6 -1
  8. package/skills/standard/github-ops.SKILL.md +12 -7
  9. package/skills/standard/memory.SKILL.md +1 -0
  10. package/skills/standard/self-review.SKILL.md +1 -0
  11. package/skills/standard/superpower.SKILL.md +7 -7
  12. package/skills/standard/web-search.SKILL.md +3 -0
  13. package/src/agent/agent-context.ts +56 -0
  14. package/src/agent/agent-registry.ts +134 -0
  15. package/src/agent/sub-agent.ts +73 -89
  16. package/src/agent/types.ts +39 -0
  17. package/src/agent-view/agent-view-manager.ts +217 -0
  18. package/src/agent-view/dashboard.tsx +218 -0
  19. package/src/agent-view/session-peek.tsx +72 -0
  20. package/src/agent-view/session-row.tsx +70 -0
  21. package/src/artifacts/manifest.ts +101 -0
  22. package/src/artifacts/server.ts +374 -0
  23. package/src/artifacts/versioning.ts +127 -0
  24. package/src/core/context-compact.ts +50 -0
  25. package/src/core/context-drain.ts +54 -0
  26. package/src/core/context-microcompact.ts +132 -0
  27. package/src/core/context-snip.ts +74 -0
  28. package/src/core/context-token.ts +108 -0
  29. package/src/core/context.ts +169 -0
  30. package/src/core/engine.ts +207 -58
  31. package/src/core/hooks-config.ts +52 -0
  32. package/src/core/hooks-executor.ts +113 -0
  33. package/src/core/hooks.ts +90 -30
  34. package/src/core/instructions.ts +12 -1
  35. package/src/core/memory/memory-loader.ts +23 -0
  36. package/src/core/memory/memory-manager.ts +192 -0
  37. package/src/core/memory/memory-writer.ts +72 -0
  38. package/src/core/permission-config.ts +34 -0
  39. package/src/core/permission-rules.ts +66 -0
  40. package/src/core/permission.ts +224 -34
  41. package/src/index.tsx +30 -2
  42. package/src/mcp/transport.ts +42 -5
  43. package/src/plugin/plugin-loader.ts +36 -0
  44. package/src/plugin/plugin-manager.ts +125 -0
  45. package/src/plugin/plugin-validator.ts +41 -0
  46. package/src/providers/fetch-utils.ts +1 -3
  47. package/src/providers/openai-compat.ts +2 -11
  48. package/src/shared/constants.ts +8 -1
  49. package/src/shared/types.ts +82 -2
  50. package/src/skills/fork-executor.ts +40 -0
  51. package/src/skills/loader.ts +48 -2
  52. package/src/tools/agent/agent.ts +20 -11
  53. package/src/tools/agent/skill.ts +32 -2
  54. package/src/tools/artifact/artifact.ts +144 -0
  55. package/src/tools/computer/app-launcher.ts +39 -0
  56. package/src/tools/computer/browser.ts +48 -0
  57. package/src/tools/computer/computer-use.ts +88 -0
  58. package/src/tools/computer/playwright.d.ts +7 -0
  59. package/src/tools/computer/screenshot.ts +57 -0
  60. package/src/tools/index.ts +6 -0
  61. package/src/ui/app.tsx +20 -7
  62. package/src/ui/chat.tsx +9 -5
  63. package/src/ui/commands.ts +185 -40
  64. package/src/ui/input.tsx +203 -27
  65. package/src/ui/picker.tsx +2 -5
  66. package/src/ui/vim-motions.ts +96 -0
  67. package/src/workflow/budget.ts +33 -0
  68. package/src/workflow/journal.ts +105 -0
  69. package/src/workflow/primitives/agent.ts +51 -0
  70. package/src/workflow/primitives/parallel.ts +8 -0
  71. package/src/workflow/primitives/phase.ts +19 -0
  72. package/src/workflow/primitives/pipeline.ts +24 -0
  73. package/src/workflow/runtime.ts +87 -0
  74. package/src/workflow/sandbox.ts +75 -0
@@ -50,7 +50,7 @@ export interface Message {
50
50
 
51
51
  // ── Tool Types ──
52
52
  export type ToolPermission = 'auto' | 'ask' | 'bypass'
53
- export type ToolCategory = 'file' | 'exec' | 'agent' | 'network' | 'system'
53
+ export type ToolCategory = 'file' | 'exec' | 'agent' | 'network' | 'system' | 'artifact'
54
54
 
55
55
  export interface ToolContext {
56
56
  cwd: string
@@ -59,6 +59,28 @@ 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>
63
+ artifactServer?: import('../artifacts/server').ArtifactServer
64
+ agentRegistry?: import('../agent/agent-registry').AgentRegistry
65
+ }
66
+
67
+ // ── Artifact Types ──
68
+ export interface ArtifactEntry {
69
+ name: string
70
+ path: string
71
+ url: string
72
+ size: number
73
+ type: 'html' | 'svg'
74
+ createdAt: string
75
+ sessionId: string
76
+ versions?: string[] // version tags e.g. ['v1', 'v2']
77
+ versionCount?: number
78
+ }
79
+
80
+ export interface ArtifactManifest {
81
+ version: 1
82
+ artifacts: ArtifactEntry[]
83
+ port?: number
62
84
  }
63
85
 
64
86
  export interface ToolResult {
@@ -111,6 +133,16 @@ export interface SkillDefinition {
111
133
  tools?: ToolDefinition[]
112
134
  hooks?: HookDefinition[]
113
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
114
146
  }
115
147
 
116
148
  // ── Hook Types ──
@@ -120,6 +152,25 @@ export type HookEvent =
120
152
  | 'SessionStart'
121
153
  | 'SessionEnd'
122
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
+ }
123
174
 
124
175
  export interface HookDefinition {
125
176
  event: HookEvent
@@ -133,12 +184,19 @@ export interface HookContext {
133
184
  toolInput?: Record<string, unknown>
134
185
  toolResult?: ToolResult
135
186
  sessionId: string
187
+ userPrompt?: string
188
+ configKey?: string
189
+ configValue?: unknown
136
190
  }
137
191
 
138
192
  export interface HookResult {
139
193
  allowed: boolean
140
194
  reason?: string
141
195
  modifiedInput?: Record<string, unknown>
196
+ decision?: 'allow' | 'block'
197
+ permissionDecision?: 'allow' | 'deny' | 'ask' | 'defer'
198
+ additionalContext?: string
199
+ updatedOutput?: string
142
200
  }
143
201
 
144
202
  // ── Instruction Types ──
@@ -152,7 +210,29 @@ export interface InstructionFile {
152
210
  }
153
211
 
154
212
  // ── Permission Types ──
155
- export type PermissionLevel = 'auto' | 'ask' | 'bypass'
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
+ }
156
236
 
157
237
  export interface PermissionRule {
158
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
+ }
@@ -1,5 +1,6 @@
1
1
  import { readFileSync, existsSync, readdirSync, statSync } from 'node:fs'
2
- import { join, extname } from 'node:path'
2
+ import { join } from 'node:path'
3
+ import { homedir } from 'node:os'
3
4
  import { parse as parseYaml } from 'yaml'
4
5
  import type { SkillDefinition } from '../shared/index.ts'
5
6
 
@@ -84,7 +85,7 @@ export class SkillsLoader {
84
85
  private tryLoad(path: string, type: 'standard' | 'mipham'): void {
85
86
  try {
86
87
  const raw = readFileSync(path, 'utf-8')
87
- const { data, content } = parseFrontmatter(raw)
88
+ const { data } = parseFrontmatter(raw)
88
89
 
89
90
  const skill: SkillDefinition = {
90
91
  name: (data.name as string) || this.nameFromPath(path),
@@ -94,6 +95,12 @@ export class SkillsLoader {
94
95
  tools: data.tools as SkillDefinition['tools'],
95
96
  hooks: data.hooks as SkillDefinition['hooks'],
96
97
  prompts: data.prompts as SkillDefinition['prompts'],
98
+ // NEW: frontmatter fields for fork/auto-trigger support
99
+ context: data.context as string | undefined,
100
+ model: data.model as string | undefined,
101
+ allowedTools: data['allowed-tools'] as string[] | undefined,
102
+ disableModelInvocation: data['disable-model-invocation'] as boolean | undefined,
103
+ userInvocable: data['user-invocable'] as boolean | undefined,
97
104
  }
98
105
 
99
106
  this.skills.set(skill.name, skill)
@@ -102,6 +109,45 @@ export class SkillsLoader {
102
109
  }
103
110
  }
104
111
 
112
+ /**
113
+ * Build the system-reminder block for AI auto-triggering.
114
+ * Injects available skill names + descriptions so the AI can match
115
+ * user requests to relevant skills.
116
+ */
117
+ buildSystemReminder(maxTokens: number = 5000): string {
118
+ const skills = this.list().filter((s) => {
119
+ // Skip skills that disable model invocation
120
+ return !s.disableModelInvocation
121
+ })
122
+
123
+ if (skills.length === 0) return ''
124
+
125
+ const lines: string[] = [
126
+ '<system-reminder>',
127
+ 'The following skills are available. Invoke via the Skill tool when relevant:',
128
+ ]
129
+
130
+ let tokenBudget = 0
131
+ for (const skill of skills) {
132
+ const entry = `- ${skill.name}: ${skill.description}`
133
+ const entryTokens = Math.ceil(entry.length / 4) + 1 // rough estimate
134
+ if (tokenBudget + entryTokens > maxTokens) break
135
+
136
+ lines.push(entry)
137
+ tokenBudget += entryTokens
138
+ }
139
+
140
+ lines.push('</system-reminder>')
141
+ return lines.join('\n')
142
+ }
143
+
144
+ /** Load skills from ~/.mipham/skills/ (user home directory). */
145
+ loadUserSkills(): void {
146
+ const home = homedir()
147
+ const userSkillsPath = join(home, '.mipham', 'skills')
148
+ this.loadExternal([userSkillsPath])
149
+ }
150
+
105
151
  private nameFromPath(path: string): string {
106
152
  const base = path.split('/').pop() || ''
107
153
  return base.replace(/\.(SKILL|mipham-skill)\.md$/i, '')
@@ -1,5 +1,6 @@
1
1
  import type { ToolDefinition } from '../../shared/index.ts'
2
- import { SubAgent, type SubAgentType } from '../../agent/sub-agent'
2
+ import { SubAgent } from '../../agent/sub-agent'
3
+ import type { SubAgentType } from '../../agent/types'
3
4
 
4
5
  const VALID_TYPES: SubAgentType[] = ['general', 'explore', 'plan', 'code-review']
5
6
 
@@ -35,17 +36,25 @@ export const agentTool: ToolDefinition = {
35
36
  }
36
37
 
37
38
  const registry = ctx.registry
38
- if (!registry) {
39
- // No registry in context — return structured simulation
40
- const sub = new SubAgent(
41
- null as unknown as import('../../providers/registry').ProviderRegistry,
42
- )
43
- const result = await sub.execute(prompt, description, { type: agentType })
44
- return { success: true, content: result }
39
+ const toolRegistry = ctx.toolRegistry
40
+ if (!registry || !toolRegistry) {
41
+ return {
42
+ success: false,
43
+ content: '',
44
+ error:
45
+ 'Sub-agent execution requires an active provider and tool registry. Connect a provider API key first.',
46
+ }
45
47
  }
46
48
 
47
- const sub = new SubAgent(registry)
48
- const result = await sub.execute(prompt, description, { type: agentType })
49
- return { success: true, content: result }
49
+ // Resolve agent definition from registry (custom > builtin)
50
+ const agentDef = ctx.agentRegistry?.resolve(agentType)
51
+
52
+ try {
53
+ const sub = new SubAgent(registry, toolRegistry)
54
+ const result = await sub.execute(prompt, description, { type: agentType, agentDef })
55
+ return { success: true, content: result }
56
+ } catch (err) {
57
+ return { success: false, content: '', error: String(err) }
58
+ }
50
59
  },
51
60
  }
@@ -1,4 +1,5 @@
1
- import type { ToolDefinition } from '../../shared/index.ts'
1
+ import type { ToolDefinition, SkillDefinition, ToolResult } from '../../shared/index.ts'
2
+ import { executeForkedSkill } from '../../skills/fork-executor'
2
3
 
3
4
  export const skillTool: ToolDefinition = {
4
5
  name: 'Skill',
@@ -34,7 +35,36 @@ export const skillTool: ToolDefinition = {
34
35
  }
35
36
  }
36
37
 
37
- // Build skill invocation content
38
+ // Check if skill has context: fork — execute in isolated subagent
39
+ if (skill.context === 'fork') {
40
+ const registry = ctx.registry
41
+ if (!registry) {
42
+ return {
43
+ success: false,
44
+ content: '',
45
+ error: 'Provider registry not available for forked skill execution.',
46
+ }
47
+ }
48
+
49
+ try {
50
+ const result = await executeForkedSkill(
51
+ skill,
52
+ args,
53
+ registry,
54
+ ctx.toolRegistry || new Map(),
55
+ )
56
+ // Return to AI as internal context
57
+ return { success: true, content: `[Forked skill "${skillName}" result]:\n${result}` }
58
+ } catch (err) {
59
+ return {
60
+ success: false,
61
+ content: '',
62
+ error: `Forked skill execution failed: ${String(err)}`,
63
+ }
64
+ }
65
+ }
66
+
67
+ // Standard inline execution — return metadata for AI to follow
38
68
  const lines: string[] = [
39
69
  `── Skill Invoked: ${skill.name} ──`,
40
70
  `Type: ${skill.type} | Version: ${skill.version}`,
@@ -0,0 +1,144 @@
1
+ import { writeFileSync, mkdirSync, existsSync } from 'node:fs'
2
+ import { join } from 'node:path'
3
+ import type { ToolDefinition } from '../../shared/index.ts'
4
+ import { ARTIFACTS_DIR, ARTIFACT_MAX_SIZE } from '../../shared/constants'
5
+ import { addToManifest, readManifest, archiveVersion } from '../../artifacts/manifest'
6
+
7
+ const NAME_PATTERN = /^[a-z0-9][a-z0-9-]*[a-z0-9]$/
8
+
9
+ export const artifactTool: ToolDefinition = {
10
+ name: 'Artifact',
11
+ description:
12
+ 'Create an interactive HTML or SVG artifact that opens in the browser. Use for dashboards, visualizations, reports, and interactive demos. Content must be self-contained — no external CDN references.',
13
+ category: 'artifact',
14
+ permission: 'ask',
15
+ parameters: {
16
+ type: 'object',
17
+ properties: {
18
+ name: {
19
+ type: 'string',
20
+ description: 'Short kebab-case name (e.g. "user-dashboard", "pipeline-diagram")',
21
+ },
22
+ type: {
23
+ type: 'string',
24
+ enum: ['html', 'svg'],
25
+ description: 'Artifact type',
26
+ },
27
+ content: {
28
+ type: 'string',
29
+ description: 'Full HTML or SVG content (self-contained, inline CSS/JS only)',
30
+ },
31
+ },
32
+ required: ['name', 'type', 'content'],
33
+ },
34
+ async execute(params, ctx) {
35
+ const name = params.name as string
36
+ const type = params.type as string
37
+ const content = params.content as string
38
+
39
+ // Validate name
40
+ if (!NAME_PATTERN.test(name) || name.length < 3) {
41
+ return {
42
+ success: false,
43
+ content: '',
44
+ error:
45
+ `Invalid artifact name "${name}". Use kebab-case (letters, numbers, hyphens), ` +
46
+ `at least 3 characters, e.g. "my-dashboard".`,
47
+ }
48
+ }
49
+
50
+ // Validate size
51
+ const size = Buffer.byteLength(content, 'utf-8')
52
+ if (size > ARTIFACT_MAX_SIZE) {
53
+ return {
54
+ success: false,
55
+ content: '',
56
+ error: `Content size ${(size / 1_048_576).toFixed(1)}MB exceeds ${ARTIFACT_MAX_SIZE / 1_048_576}MB limit.`,
57
+ }
58
+ }
59
+
60
+ // Determine output paths
61
+ const baseDir = join(ctx.cwd, ARTIFACTS_DIR)
62
+ const sessionDir = join(baseDir, ctx.sessionId)
63
+ mkdirSync(sessionDir, { recursive: true })
64
+
65
+ const ext = type === 'svg' ? '.svg' : '.html'
66
+ const filename = `${name}${ext}`
67
+ const filepath = join(sessionDir, filename)
68
+
69
+ // Start server if not running
70
+ let port: number | undefined
71
+ if (ctx.artifactServer && !ctx.artifactServer.isRunning()) {
72
+ port = await ctx.artifactServer.start()
73
+ } else if (ctx.artifactServer) {
74
+ port = ctx.artifactServer.getPort()
75
+ }
76
+
77
+ // Archive previous version if this artifact already exists
78
+ let archivedVersion: string | undefined
79
+ const isUpdate = existsSync(filepath)
80
+ if (isUpdate) {
81
+ const manifest = readManifest(baseDir)
82
+ const existing = manifest.artifacts.find(
83
+ (a) => a.name === name && a.sessionId === ctx.sessionId,
84
+ )
85
+ if (existing) {
86
+ archivedVersion = archiveVersion(baseDir, existing)
87
+ }
88
+ }
89
+
90
+ // Write artifact
91
+ writeFileSync(filepath, content, 'utf-8')
92
+
93
+ // Build URL and manifest entry
94
+ const url = port
95
+ ? `http://localhost:${port}/${ctx.sessionId}/${filename}`
96
+ : `file://${filepath}`
97
+
98
+ // Preserve existing version count
99
+ const manifestPre = readManifest(baseDir)
100
+ const prev = manifestPre.artifacts.find((a) => a.name === name && a.sessionId === ctx.sessionId)
101
+ const versionCount = prev?.versionCount || (isUpdate ? 1 : undefined)
102
+
103
+ addToManifest(
104
+ baseDir,
105
+ {
106
+ name,
107
+ path: filepath,
108
+ url,
109
+ size,
110
+ type: type as 'html' | 'svg',
111
+ createdAt: new Date().toISOString(),
112
+ sessionId: ctx.sessionId,
113
+ versions: prev?.versions,
114
+ versionCount,
115
+ },
116
+ port,
117
+ )
118
+
119
+ // Notify SSE clients to refresh Gallery
120
+ if (ctx.artifactServer) {
121
+ ctx.artifactServer.notifyReload()
122
+ }
123
+
124
+ const galleryUrl = port ? `http://localhost:${port}` : undefined
125
+ const versionLine = archivedVersion ? ` Prev archived as: ${archivedVersion}` : ''
126
+ const galleryLine = galleryUrl ? `Gallery: ${galleryUrl}` : ''
127
+
128
+ return {
129
+ success: true,
130
+ content: [
131
+ `✓ Artifact "${name}" saved${isUpdate ? ' (updated)' : ''}.`,
132
+ ` URL: ${url}`,
133
+ ` Size: ${size.toLocaleString()} bytes`,
134
+ versionLine,
135
+ galleryLine,
136
+ '',
137
+ `Open in browser: /artifact open ${name}`,
138
+ `List all: /artifact list`,
139
+ ]
140
+ .filter(Boolean)
141
+ .join('\n'),
142
+ }
143
+ },
144
+ }
@@ -0,0 +1,39 @@
1
+ import { execSync } from 'node:child_process'
2
+
3
+ const ALLOWED_APPS = new Set([
4
+ 'Finder',
5
+ 'Safari',
6
+ 'Google Chrome',
7
+ 'Firefox',
8
+ 'Terminal',
9
+ 'VS Code',
10
+ 'System Settings',
11
+ 'Activity Monitor',
12
+ 'TextEdit',
13
+ 'Preview',
14
+ ])
15
+
16
+ export function launchApp(appName: string): { success: boolean; message: string } {
17
+ if (!ALLOWED_APPS.has(appName)) {
18
+ return {
19
+ success: false,
20
+ message: `App "${appName}" is not in the allowed list. Allowed: ${[...ALLOWED_APPS].join(', ')}`,
21
+ }
22
+ }
23
+
24
+ try {
25
+ const platform = process.platform
26
+ if (platform === 'darwin') {
27
+ execSync(`open -a "${appName}"`, { timeout: 5000 })
28
+ } else if (platform === 'linux') {
29
+ execSync(`xdg-open "${appName}"`, { timeout: 5000 })
30
+ } else if (platform === 'win32') {
31
+ execSync(`start "" "${appName}"`, { timeout: 5000, shell: 'cmd.exe' })
32
+ } else {
33
+ return { success: false, message: `Unsupported platform: ${platform}` }
34
+ }
35
+ return { success: true, message: `Launched: ${appName}` }
36
+ } catch (err) {
37
+ return { success: false, message: `Failed to launch ${appName}: ${String(err)}` }
38
+ }
39
+ }
@@ -0,0 +1,48 @@
1
+ let browserPromise: Promise<unknown> | null = null
2
+
3
+ async function getPage(): Promise<unknown> {
4
+ if (!browserPromise) {
5
+ // Dynamic import — Playwright is optional
6
+ let playwrightModule: { chromium: unknown }
7
+ try {
8
+ playwrightModule = await import('playwright')
9
+ } catch {
10
+ throw new Error('Playwright is not installed. Install it with: npm install playwright')
11
+ }
12
+ const { chromium } = playwrightModule as {
13
+ chromium: {
14
+ launch: (opts?: Record<string, unknown>) => Promise<{
15
+ newPage: () => Promise<unknown>
16
+ }>
17
+ }
18
+ }
19
+ const browser = await chromium.launch({ headless: false })
20
+ browserPromise = browser.newPage()
21
+ }
22
+ return browserPromise
23
+ }
24
+
25
+ export async function browserNavigate(url: string): Promise<string> {
26
+ const page = (await getPage()) as {
27
+ goto: (u: string) => Promise<void>
28
+ url: () => string
29
+ }
30
+ await page.goto(url)
31
+ return `Navigated to: ${page.url()}`
32
+ }
33
+
34
+ export async function browserSnapshot(): Promise<string> {
35
+ const page = (await getPage()) as {
36
+ accessibility: { snapshot: () => Promise<unknown> }
37
+ }
38
+ const snapshot = await page.accessibility.snapshot()
39
+ return JSON.stringify(snapshot, null, 2)
40
+ }
41
+
42
+ export async function browserClick(uid: string): Promise<string> {
43
+ const page = (await getPage()) as {
44
+ locator: (s: string) => { click: () => Promise<void> }
45
+ }
46
+ await page.locator(`[uid="${uid}"]`).click()
47
+ return `Clicked: ${uid}`
48
+ }
@@ -0,0 +1,88 @@
1
+ import type { ToolDefinition } from '../../shared/index.ts'
2
+ import { takeScreenshot } from './screenshot'
3
+ import { launchApp } from './app-launcher'
4
+ import { browserNavigate, browserSnapshot, browserClick } from './browser'
5
+
6
+ export const computerUseTool: ToolDefinition = {
7
+ name: 'ComputerUse',
8
+ description:
9
+ 'Take screenshots, launch apps, or control a browser. Always requires user approval.',
10
+ category: 'system',
11
+ permission: 'ask', // ALWAYS ask — security requirement
12
+ parameters: {
13
+ type: 'object',
14
+ properties: {
15
+ action: {
16
+ type: 'string',
17
+ enum: ['screenshot', 'launch', 'browser_navigate', 'browser_snapshot', 'browser_click'],
18
+ description: 'The computer-use action to perform',
19
+ },
20
+ target: {
21
+ type: 'string',
22
+ description: 'App name (launch), URL (browser_navigate), or element UID (browser_click)',
23
+ },
24
+ text: {
25
+ type: 'string',
26
+ description: 'Text to type (reserved for future browser_type action)',
27
+ },
28
+ },
29
+ required: ['action'],
30
+ },
31
+ async execute(params, _ctx) {
32
+ const action = params.action as string
33
+ const target = (params.target as string) || ''
34
+
35
+ switch (action) {
36
+ case 'screenshot': {
37
+ const result = await takeScreenshot()
38
+ return result.success
39
+ ? { success: true, content: `Screenshot captured: ${result.data!.slice(0, 100)}...` }
40
+ : { success: false, content: '', error: result.error }
41
+ }
42
+ case 'launch': {
43
+ const result = launchApp(target)
44
+ return result.success
45
+ ? { success: true, content: result.message }
46
+ : { success: false, content: '', error: result.message }
47
+ }
48
+ case 'browser_navigate': {
49
+ try {
50
+ const url = await browserNavigate(target)
51
+ return { success: true, content: url }
52
+ } catch (err) {
53
+ return {
54
+ success: false,
55
+ content: '',
56
+ error: `Browser navigation failed: ${String(err)}`,
57
+ }
58
+ }
59
+ }
60
+ case 'browser_snapshot': {
61
+ try {
62
+ const snapshot = await browserSnapshot()
63
+ return { success: true, content: snapshot }
64
+ } catch (err) {
65
+ return {
66
+ success: false,
67
+ content: '',
68
+ error: `Browser snapshot failed: ${String(err)}`,
69
+ }
70
+ }
71
+ }
72
+ case 'browser_click': {
73
+ try {
74
+ const result = await browserClick(target)
75
+ return { success: true, content: result }
76
+ } catch (err) {
77
+ return {
78
+ success: false,
79
+ content: '',
80
+ error: `Browser click failed: ${String(err)}`,
81
+ }
82
+ }
83
+ }
84
+ default:
85
+ return { success: false, content: '', error: `Unknown action: ${action}` }
86
+ }
87
+ },
88
+ }
@@ -0,0 +1,7 @@
1
+ declare module 'playwright' {
2
+ export const chromium: {
3
+ launch: (opts?: Record<string, unknown>) => Promise<{
4
+ newPage: () => Promise<unknown>
5
+ }>
6
+ }
7
+ }