@miphamai/cli 0.10.0 → 0.11.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/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@miphamai/cli",
3
- "version": "0.10.0",
3
+ "version": "0.11.0",
4
4
  "description": "Mipham Code — Multi-model open-core intelligent coding terminal by MiphamAI",
5
5
  "keywords": [
6
6
  "ai",
@@ -1,4 +1,7 @@
1
1
  // apps/cli/src/agent/agent-context.ts
2
+ import { readdirSync, readFileSync, existsSync } from 'node:fs'
3
+ import { join } from 'node:path'
4
+ import { homedir } from 'node:os'
2
5
  import { ContextManager } from '../core/context'
3
6
  import type { ToolDefinition } from '../shared/index.ts'
4
7
  import type { AgentDefinition } from './types'
@@ -8,6 +11,50 @@ export interface AgentContextResult {
8
11
  allowedTools: ToolDefinition[]
9
12
  }
10
13
 
14
+ /**
15
+ * Load agent memory files from the appropriate scope directory.
16
+ * Returns combined content for injection into the system prompt.
17
+ */
18
+ function loadAgentMemory(agentName: string, scope: 'user' | 'project' | 'local'): string {
19
+ let memoryDir: string
20
+ const home = homedir()
21
+
22
+ switch (scope) {
23
+ case 'user':
24
+ memoryDir = join(home, '.mipham', 'agent-memory', agentName)
25
+ break
26
+ case 'project':
27
+ memoryDir = join(process.cwd(), '.mipham', 'agent-memory', agentName)
28
+ break
29
+ case 'local':
30
+ memoryDir = join(process.cwd(), '.mipham', 'agent-memory-local', agentName)
31
+ break
32
+ }
33
+
34
+ if (!existsSync(memoryDir)) return ''
35
+
36
+ try {
37
+ const files = readdirSync(memoryDir).filter((f) => f.endsWith('.md'))
38
+ if (files.length === 0) return ''
39
+
40
+ const contents: string[] = []
41
+ for (const file of files.slice(0, 10)) {
42
+ // max 10 files
43
+ try {
44
+ const content = readFileSync(join(memoryDir, file), 'utf-8').trim()
45
+ if (content) contents.push(content)
46
+ } catch {
47
+ // skip unreadable
48
+ }
49
+ }
50
+
51
+ if (contents.length === 0) return ''
52
+ return [`[Agent Memory — ${scope} scope]`, ...contents].join('\n\n')
53
+ } catch {
54
+ return ''
55
+ }
56
+ }
57
+
11
58
  /**
12
59
  * Create an isolated context and tool set for a sub-agent.
13
60
  *
@@ -15,6 +62,9 @@ export interface AgentContextResult {
15
62
  * 1. If `tools` is set, only those tools are allowed.
16
63
  * 2. If `disallowedTools` is set, those are removed from the full set.
17
64
  * 3. If neither is set, all tools are available.
65
+ *
66
+ * Agent memory: if agentDef.memory is set, loads memory files from the
67
+ * appropriate scope and injects them into the system prompt.
18
68
  */
19
69
  export function createAgentContext(
20
70
  agentDef: AgentDefinition,
@@ -27,7 +77,16 @@ export function createAgentContext(
27
77
  compactionThreshold: 0.85,
28
78
  })
29
79
 
30
- context.setSystemPrompt(agentDef.systemPrompt)
80
+ // Build system prompt with optional agent memory
81
+ let systemPrompt = agentDef.systemPrompt
82
+ if (agentDef.memory) {
83
+ const memory = loadAgentMemory(agentDef.name, agentDef.memory)
84
+ if (memory) {
85
+ systemPrompt = `${systemPrompt}\n\n---\n\n${memory}`
86
+ }
87
+ }
88
+
89
+ context.setSystemPrompt(systemPrompt)
31
90
 
32
91
  // Scope tools
33
92
  let allowedTools = Array.from(toolRegistry.values())
@@ -74,6 +74,7 @@ export class AgentRegistry {
74
74
  .map((s) => s.trim())
75
75
  : undefined,
76
76
  background: (data.background as boolean) || false,
77
+ memory: data.memory as 'user' | 'project' | 'local' | undefined,
77
78
  source,
78
79
  filePath: fullPath,
79
80
  }
@@ -12,6 +12,7 @@ export interface AgentFrontmatter {
12
12
  maxTurns?: number
13
13
  skills?: string
14
14
  background?: boolean
15
+ memory?: 'user' | 'project' | 'local' // agent memory scope
15
16
  }
16
17
 
17
18
  export interface AgentDefinition {
@@ -27,6 +28,7 @@ export interface AgentDefinition {
27
28
  background: boolean
28
29
  source: 'builtin' | 'project' | 'user'
29
30
  filePath?: string
31
+ memory?: 'user' | 'project' | 'local' // agent memory scope
30
32
  }
31
33
 
32
34
  export interface SubAgentOptions {
@@ -10,6 +10,7 @@ import { getMemoryManager } from './memory/memory-loader'
10
10
  import type { AgentViewManager } from '../agent-view/agent-view-manager'
11
11
  import type { SkillsLoader } from '../skills/loader'
12
12
  import { getBackgroundAgentRegistry } from '../agent/background-registry'
13
+ import { RulesLoader } from './rules-loader'
13
14
 
14
15
  export class QueryEngine {
15
16
  private hookEngine?: HookEngine
@@ -71,9 +72,40 @@ export class QueryEngine {
71
72
  this.skillsLoader = loader
72
73
  }
73
74
 
75
+ /** Rules loader for path-scoped rules injection. */
76
+ private rulesLoader?: RulesLoader
77
+ /** Files touched in the current turn (for rules matching). */
78
+ private touchedFiles: Set<string> = new Set()
79
+
80
+ /** Register the rules loader. */
81
+ setRulesLoader(loader: RulesLoader): void {
82
+ this.rulesLoader = loader
83
+ this.rulesLoader.load()
84
+ }
85
+
74
86
  /** Pending task notifications from background agents (cleared after draining). */
75
87
  private pendingTaskNotifications: Array<StreamChunk> = []
76
88
 
89
+ /** Track files touched by tools for rules matching. */
90
+ private trackTouchedFile(toolName: string, params: Record<string, unknown>): void {
91
+ const fileTools = ['Read', 'Write', 'Edit', 'Glob', 'Grep']
92
+ if (!fileTools.includes(toolName)) return
93
+ const filePath = (params.file_path || params.path || params.file) as string | undefined
94
+ if (filePath && typeof filePath === 'string') {
95
+ this.touchedFiles.add(filePath)
96
+ }
97
+ }
98
+
99
+ /** Inject matching rules as context after tool execution. */
100
+ private injectRules(): void {
101
+ if (!this.rulesLoader || this.touchedFiles.size === 0) return
102
+ const files = Array.from(this.touchedFiles)
103
+ const block = this.rulesLoader.buildContextBlock(files)
104
+ if (!block) return
105
+ this.context.addMessage({ role: 'user', content: block })
106
+ this.touchedFiles.clear()
107
+ }
108
+
77
109
  /**
78
110
  * Drain pending background task notifications.
79
111
  * Call this after tool execution to surface completed/failed background agent results.
@@ -351,6 +383,9 @@ export class QueryEngine {
351
383
  })
352
384
  }
353
385
 
386
+ // Inject path-scoped rules for touched files
387
+ this.injectRules()
388
+
354
389
  // Drain task notifications after tool execution
355
390
  for (const chunk of this.drainTaskNotifications()) {
356
391
  yield chunk
@@ -633,6 +668,9 @@ export class QueryEngine {
633
668
  backgroundAgentRegistry: getBackgroundAgentRegistry(),
634
669
  })
635
670
 
671
+ // Track touched files for rules matching
672
+ this.trackTouchedFile(name, effectiveParams)
673
+
636
674
  // Run PostToolUse hooks
637
675
  if (this.hookEngine) {
638
676
  await this.hookEngine.executePostToolUse(name, effectiveParams, result, 'session-1')
@@ -0,0 +1,95 @@
1
+ /**
2
+ * OutputStylesLoader — loads custom response persona styles from .mipham/output-styles/.
3
+ *
4
+ * Each .md file in the directory is a named style. The active style's content
5
+ * is injected into the system prompt to shape the AI's tone and communication style.
6
+ *
7
+ * Usage:
8
+ * const loader = new OutputStylesLoader(cwd)
9
+ * loader.list() // ['concise', 'academic', ...]
10
+ * loader.get('concise') // style content or undefined
11
+ * loader.setActive('concise') // switch active style
12
+ * loader.getActiveContent() // active style content (for system prompt)
13
+ */
14
+
15
+ import { readdirSync, readFileSync, existsSync } from 'node:fs'
16
+ import { join, basename, extname } from 'node:path'
17
+
18
+ export class OutputStylesLoader {
19
+ private stylesDir: string
20
+ private activeStyle: string | null = null
21
+
22
+ constructor(cwd: string) {
23
+ this.stylesDir = join(cwd, '.mipham', 'output-styles')
24
+ }
25
+
26
+ /**
27
+ * List all available style names (filename without .md extension).
28
+ */
29
+ list(): string[] {
30
+ if (!existsSync(this.stylesDir)) return []
31
+ try {
32
+ return readdirSync(this.stylesDir)
33
+ .filter((f) => f.endsWith('.md'))
34
+ .map((f) => basename(f, extname(f)))
35
+ } catch {
36
+ return []
37
+ }
38
+ }
39
+
40
+ /**
41
+ * Get the content of a named style. Returns undefined if not found.
42
+ */
43
+ get(name: string): string | undefined {
44
+ if (!existsSync(this.stylesDir)) return undefined
45
+ const filepath = join(this.stylesDir, `${name}.md`)
46
+ if (!existsSync(filepath)) return undefined
47
+ try {
48
+ return readFileSync(filepath, 'utf-8').trim()
49
+ } catch {
50
+ return undefined
51
+ }
52
+ }
53
+
54
+ /**
55
+ * Set the active output style.
56
+ */
57
+ setActive(name: string): boolean {
58
+ const content = this.get(name)
59
+ if (!content) return false
60
+ this.activeStyle = name
61
+ return true
62
+ }
63
+
64
+ /**
65
+ * Clear the active output style (revert to default personality).
66
+ */
67
+ clearActive(): void {
68
+ this.activeStyle = null
69
+ }
70
+
71
+ /**
72
+ * Get the active style name (null if none selected).
73
+ */
74
+ getActiveName(): string | null {
75
+ return this.activeStyle
76
+ }
77
+
78
+ /**
79
+ * Get the active style content for system prompt injection.
80
+ * Returns empty string if no style is active.
81
+ */
82
+ getActiveContent(): string {
83
+ if (!this.activeStyle) return ''
84
+ const content = this.get(this.activeStyle)
85
+ if (!content) return ''
86
+ return [
87
+ `[Output Style: ${this.activeStyle}]`,
88
+ 'Adopt the following communication style in all your responses:',
89
+ '',
90
+ content,
91
+ '',
92
+ '---',
93
+ ].join('\n')
94
+ }
95
+ }
@@ -0,0 +1,175 @@
1
+ /**
2
+ * RulesLoader — path-scoped rules from .mipham/rules/.
3
+ *
4
+ * Rules are markdown files with YAML frontmatter. They are injected into
5
+ * the conversation when the AI touches matching files.
6
+ *
7
+ * Directory structure:
8
+ * .mipham/rules/
9
+ * always.md — always loaded (no paths filter)
10
+ * typescript.md — loaded when touching *.ts files
11
+ * security.md — loaded when touching auth/ or crypto/ paths
12
+ *
13
+ * Frontmatter:
14
+ * ---
15
+ * paths: "apps/cli/src/**\/*.ts"
16
+ * description: TypeScript coding standards
17
+ * ---
18
+ */
19
+
20
+ import { readdirSync, readFileSync, existsSync } from 'node:fs'
21
+ import { join, relative } from 'node:path'
22
+
23
+ interface RuleFile {
24
+ name: string
25
+ paths: string[] // glob patterns, empty = always loaded
26
+ description: string
27
+ content: string
28
+ }
29
+
30
+ export class RulesLoader {
31
+ private rules: RuleFile[] = []
32
+ private rulesDir: string
33
+
34
+ constructor(cwd: string) {
35
+ this.rulesDir = join(cwd, '.mipham', 'rules')
36
+ }
37
+
38
+ /**
39
+ * Load all rules from .mipham/rules/. Call once at startup.
40
+ */
41
+ load(): void {
42
+ this.rules = []
43
+ if (!existsSync(this.rulesDir)) return
44
+
45
+ try {
46
+ const files = readdirSync(this.rulesDir).filter((f) => f.endsWith('.md'))
47
+ for (const file of files) {
48
+ const filepath = join(this.rulesDir, file)
49
+ try {
50
+ const raw = readFileSync(filepath, 'utf-8')
51
+ const { paths, description, content } = this.parseRule(raw, file)
52
+ this.rules.push({ name: file.replace(/\.md$/, ''), paths, description, content })
53
+ } catch {
54
+ // Skip unparseable files
55
+ }
56
+ }
57
+ } catch {
58
+ // Directory read error — rules unavailable
59
+ }
60
+ }
61
+
62
+ /**
63
+ * Get rules that match the given file paths.
64
+ * Rules with no paths filter ("always") are always included.
65
+ */
66
+ getMatchingRules(touchedFiles: string[]): RuleFile[] {
67
+ const matched: RuleFile[] = []
68
+
69
+ for (const rule of this.rules) {
70
+ // Always rules — no paths filter
71
+ if (rule.paths.length === 0) {
72
+ matched.push(rule)
73
+ continue
74
+ }
75
+
76
+ // Check if any touched file matches any rule path pattern
77
+ for (const file of touchedFiles) {
78
+ for (const pattern of rule.paths) {
79
+ if (this.matchPath(file, pattern)) {
80
+ matched.push(rule)
81
+ // Break inner loops — rule already matched
82
+ break
83
+ }
84
+ }
85
+ if (matched.includes(rule)) break
86
+ }
87
+ }
88
+
89
+ return matched
90
+ }
91
+
92
+ /**
93
+ * Build a context block to inject into the conversation.
94
+ */
95
+ buildContextBlock(touchedFiles: string[]): string {
96
+ const matched = this.getMatchingRules(touchedFiles)
97
+ if (matched.length === 0) return ''
98
+
99
+ const blocks = matched.map(
100
+ (r) => `[Rule: ${r.name}]${r.description ? ` — ${r.description}` : ''}\n${r.content}`,
101
+ )
102
+ return `\n<!-- Path-scoped rules matching: ${touchedFiles.join(', ')} -->\n${blocks.join('\n\n')}\n`
103
+ }
104
+
105
+ /**
106
+ * Count loaded rules.
107
+ */
108
+ count(): number {
109
+ return this.rules.length
110
+ }
111
+
112
+ /**
113
+ * List loaded rule names.
114
+ */
115
+ list(): string[] {
116
+ return this.rules.map((r) => r.name)
117
+ }
118
+
119
+ /**
120
+ * Simple glob matching. Supports **, *, and exact file matching.
121
+ * Returns true if file matches pattern.
122
+ */
123
+ private matchPath(file: string, pattern: string): boolean {
124
+ // Convert glob pattern to regex
125
+ let regexStr = pattern
126
+ .replace(/\./g, '\\.')
127
+ .replace(/\*\*/g, '<<GLOBSTAR>>')
128
+ .replace(/\*/g, '[^/]*')
129
+ .replace(/<<GLOBSTAR>>/g, '.*')
130
+
131
+ // If pattern doesn't start with ** or *, anchor to be a suffix match
132
+ if (!pattern.startsWith('**') && !pattern.startsWith('*')) {
133
+ regexStr = regexStr + '$'
134
+ }
135
+
136
+ try {
137
+ return new RegExp(regexStr).test(file)
138
+ } catch {
139
+ // Invalid pattern — fallback to simple includes
140
+ return file.includes(pattern.replace(/\*\*/g, '').replace(/\*/g, ''))
141
+ }
142
+ }
143
+
144
+ /**
145
+ * Parse a rule file — extract frontmatter and body.
146
+ */
147
+ private parseRule(
148
+ raw: string,
149
+ filename: string,
150
+ ): { paths: string[]; description: string; content: string } {
151
+ const match = raw.match(/^---\r?\n([\s\S]*?)\r?\n---\r?\n?([\s\S]*)$/)
152
+ if (!match) {
153
+ return { paths: [], description: '', content: raw.trim() }
154
+ }
155
+
156
+ const frontmatter = match[1] || ''
157
+ const body = (match[2] || '').trim()
158
+
159
+ const paths: string[] = []
160
+ let description = ''
161
+
162
+ for (const line of frontmatter.split('\n')) {
163
+ const pathMatch = line.match(/^paths:\s*"(.+)"$/)
164
+ if (pathMatch) {
165
+ pathMatch[1]!.split(',').forEach((p) => paths.push(p.trim()))
166
+ }
167
+ const descMatch = line.match(/^description:\s*(.+)$/)
168
+ if (descMatch) {
169
+ description = descMatch[1]!.trim()
170
+ }
171
+ }
172
+
173
+ return { paths, description, content: body }
174
+ }
175
+ }
package/src/mcp/client.ts CHANGED
@@ -151,6 +151,15 @@ export class McpClient {
151
151
  return this.connections.get(name)?.tools || []
152
152
  }
153
153
 
154
+ /** List all currently connected MCP server names. */
155
+ getConnectedServers(): string[] {
156
+ const names: string[] = []
157
+ for (const [name, conn] of this.connections) {
158
+ if (conn.status === 'connected') names.push(name)
159
+ }
160
+ return names
161
+ }
162
+
154
163
  async callTool(
155
164
  serverName: string,
156
165
  toolName: string,
@@ -20,6 +20,7 @@ import { webFetchTool } from './network/web-fetch'
20
20
  import { webSearchTool } from './network/web-search'
21
21
  import { configTool } from './system/config'
22
22
  import { mcpTool } from './system/mcp'
23
+ import { toolSearchTool } from './system/tool-search'
23
24
  import { artifactTool } from './artifact/artifact'
24
25
  import { reportFindingsTool } from './agent/report-findings'
25
26
  import { sendMessageTool } from './agent/send-message'
@@ -134,6 +135,7 @@ export function createToolRegistry(): Map<string, ToolDefinition> {
134
135
  // System tools
135
136
  withValidation(configTool),
136
137
  withValidation(mcpTool),
138
+ withValidation(toolSearchTool),
137
139
  // Artifact tools
138
140
  withValidation(artifactTool),
139
141
  // Computer Use tools
@@ -0,0 +1,143 @@
1
+ /**
2
+ * ToolSearch — search MCP server tools by name or description.
3
+ *
4
+ * Instead of loading all MCP tool definitions into the context upfront,
5
+ * the AI can call ToolSearch to discover relevant tools on demand.
6
+ * This saves ~85% of context tokens for large MCP servers (50-100 tools).
7
+ */
8
+
9
+ import type { ToolDefinition } from '../../shared/index.ts'
10
+ import { McpClient } from '../../mcp/client'
11
+
12
+ export const toolSearchTool: ToolDefinition = {
13
+ name: 'ToolSearch',
14
+ description:
15
+ 'Search available MCP server tools by name or description. ' +
16
+ 'Use this to discover tools on demand instead of loading all tool definitions into context. ' +
17
+ 'Returns matching tool names with their server and description.',
18
+ category: 'system',
19
+ permission: 'auto',
20
+ parameters: {
21
+ type: 'object',
22
+ properties: {
23
+ query: {
24
+ type: 'string',
25
+ description:
26
+ 'Search query — matches against tool names and descriptions. Leave empty to list all available MCP tools.',
27
+ },
28
+ server: {
29
+ type: 'string',
30
+ description: 'Optional: limit search to a specific MCP server name.',
31
+ },
32
+ limit: {
33
+ type: 'integer',
34
+ description: 'Max results to return (default: 20).',
35
+ },
36
+ },
37
+ required: [],
38
+ },
39
+ async execute(params, _ctx) {
40
+ const query = ((params.query as string) || '').toLowerCase()
41
+ const serverFilter = (params.server as string) || ''
42
+ const limit = (params.limit as number) || 20
43
+
44
+ const client = McpClient.getInstance()
45
+ const connectedServers = client.getConnectedServers()
46
+
47
+ if (connectedServers.length === 0) {
48
+ return {
49
+ success: true,
50
+ content:
51
+ '── MCP Tool Search ──\n\n' +
52
+ 'No MCP servers connected.\n\n' +
53
+ 'Connect MCP servers in .mcp.json or via /mcp add <name> <url>.',
54
+ }
55
+ }
56
+
57
+ // Filter by server if specified
58
+ const servers = serverFilter
59
+ ? connectedServers.filter((s) => s.toLowerCase().includes(serverFilter))
60
+ : connectedServers
61
+
62
+ if (serverFilter && servers.length === 0) {
63
+ return {
64
+ success: true,
65
+ content:
66
+ `── MCP Tool Search ──\n\n` +
67
+ `No connected server matching "${serverFilter}".\n\n` +
68
+ `Connected servers: ${connectedServers.join(', ')}`,
69
+ }
70
+ }
71
+
72
+ // Collect matching tools
73
+ interface Match {
74
+ server: string
75
+ tool: string
76
+ description: string
77
+ }
78
+
79
+ const matches: Match[] = []
80
+
81
+ for (const serverName of servers) {
82
+ try {
83
+ const tools = client.getTools(serverName)
84
+ for (const tool of tools) {
85
+ const nameMatch = !query || tool.name.toLowerCase().includes(query)
86
+ const descMatch = !query || (tool.description || '').toLowerCase().includes(query)
87
+ if (nameMatch || descMatch) {
88
+ matches.push({
89
+ server: serverName,
90
+ tool: tool.name,
91
+ description: (tool.description || '').slice(0, 120),
92
+ })
93
+ }
94
+ }
95
+ } catch {
96
+ // Server disconnected mid-search — skip
97
+ }
98
+ }
99
+
100
+ if (matches.length === 0) {
101
+ return {
102
+ success: true,
103
+ content:
104
+ `── MCP Tool Search ──\n\n` +
105
+ `No tools matching "${query}" across ${servers.length} server(s).\n\n` +
106
+ `Try a different query, or omit the query to list all tools.`,
107
+ }
108
+ }
109
+
110
+ // Truncate to limit
111
+ const limited = matches.slice(0, limit)
112
+
113
+ const lines: string[] = [
114
+ `── MCP Tool Search ──`,
115
+ '',
116
+ `${limited.length} of ${matches.length} matching tool(s) across ${servers.length} server(s):`,
117
+ '',
118
+ ]
119
+
120
+ // Group by server
121
+ const byServer = new Map<string, Match[]>()
122
+ for (const m of limited) {
123
+ const list = byServer.get(m.server) || []
124
+ list.push(m)
125
+ byServer.set(m.server, list)
126
+ }
127
+
128
+ for (const [server, tools] of byServer) {
129
+ lines.push(` ${server} (${tools.length}):`)
130
+ for (const t of tools) {
131
+ const desc = t.description ? ` — ${t.description}` : ''
132
+ lines.push(` mcp__${server.replace(/[^a-z0-9-]/g, '_')}__${t.tool}${desc}`)
133
+ }
134
+ lines.push('')
135
+ }
136
+
137
+ if (matches.length > limit) {
138
+ lines.push(`... and ${matches.length - limit} more. Use "query" to narrow results.`)
139
+ }
140
+
141
+ return { success: true, content: lines.join('\n') }
142
+ },
143
+ }
@@ -3182,64 +3182,183 @@ Full changelog: https://mipham.ai/code/releases`,
3182
3182
  // IDE — IDE integration guide
3183
3183
  // ═══════════════════════════════════════════════════════════════
3184
3184
 
3185
- const ideCmd: CommandHandler = () => ({
3186
- content: `── IDE Integration ──
3187
-
3188
- VS Code:
3189
- Install the Mipham Code extension from the VS Code marketplace.
3190
- • Open Command Palette (Cmd+Shift+P)
3191
- • Search "Mipham Code: Start"
3192
- • The terminal panel opens with Mipham Code loaded
3185
+ const ideCmd: CommandHandler = async (_ctx) => {
3186
+ const { mkdirSync, writeFileSync, existsSync } = await import('node:fs')
3187
+ const { join } = await import('node:path')
3193
3188
 
3194
- Or manually: add to .vscode/settings.json
3195
- {
3196
- "terminal.integrated.profiles.osx": {
3197
- "mipham": { "path": "bun", "args": ["run", "mipham"] }
3198
- }
3199
- }
3189
+ const cwd = process.cwd()
3190
+ const vscodeDir = join(cwd, '.vscode')
3191
+ mkdirSync(vscodeDir, { recursive: true })
3200
3192
 
3201
- JetBrains (IntelliJ / WebStorm / PyCharm):
3202
- • Settings → Tools → Terminal → Shell path
3203
- • Set to: bun run ~/path/to/mipham-code/apps/cli/bin/mipham
3193
+ const files: string[] = []
3204
3194
 
3205
- Terminal (any):
3206
- alias mipham='cd your-project && bun run path/to/mipham'
3195
+ // ── Detect bun path ──
3196
+ let bunPath = '/opt/homebrew/bin/bun'
3197
+ try {
3198
+ const { execSync } = await import('node:child_process')
3199
+ const detected = execSync('which bun 2>/dev/null || echo /opt/homebrew/bin/bun', {
3200
+ encoding: 'utf-8',
3201
+ }).trim()
3202
+ if (detected) bunPath = detected
3203
+ } catch {
3204
+ // Use default
3205
+ }
3206
+
3207
+ // ── settings.json: terminal profile ──
3208
+ const settingsPath = join(vscodeDir, 'settings.json')
3209
+ const settings = {
3210
+ 'terminal.integrated.profiles.osx': {
3211
+ mipham: {
3212
+ path: bunPath,
3213
+ args: ['run', 'mipham'],
3214
+ cwd: '${workspaceFolder}',
3215
+ },
3216
+ },
3217
+ }
3218
+ writeFileSync(settingsPath, JSON.stringify(settings, null, 2) + '\n', 'utf-8')
3219
+ files.push('.vscode/settings.json')
3220
+
3221
+ // ── keybindings.json: Cmd+Esc launch ──
3222
+ const keybindingsPath = join(vscodeDir, 'keybindings.json')
3223
+ const keybindings = [
3224
+ {
3225
+ key: 'cmd+escape',
3226
+ command: 'workbench.action.terminal.focus',
3227
+ when: 'terminalProcessSupported',
3228
+ },
3229
+ {
3230
+ key: 'cmd+shift+m',
3231
+ command: 'workbench.action.terminal.new',
3232
+ },
3233
+ ]
3234
+ writeFileSync(keybindingsPath, JSON.stringify(keybindings, null, 2) + '\n', 'utf-8')
3235
+ files.push('.vscode/keybindings.json')
3207
3236
 
3208
- Or install globally: ${NPM_INSTALL_COMMAND}
3237
+ // ── extensions.json: recommended ──
3238
+ const extensionsPath = join(vscodeDir, 'extensions.json')
3239
+ const extensions = {
3240
+ recommendations: ['miphamai.mipham-code'],
3241
+ }
3242
+ writeFileSync(extensionsPath, JSON.stringify(extensions, null, 2) + '\n', 'utf-8')
3243
+ files.push('.vscode/extensions.json')
3209
3244
 
3210
- Coming soon: dedicated VS Code & JetBrains plugin extensions.`,
3211
- })
3245
+ return {
3246
+ content: [
3247
+ '── VS Code Integration ──',
3248
+ '',
3249
+ `Generated in ${vscodeDir}:`,
3250
+ ...files.map((f) => ` ✅ ${f}`),
3251
+ '',
3252
+ 'What was configured:',
3253
+ ' • Terminal profile "mipham" — opens Mipham Code in integrated terminal',
3254
+ ' • Keyboard shortcut Cmd+Esc — focus terminal',
3255
+ ' • Cmd+Shift+M — new terminal',
3256
+ '',
3257
+ 'To use:',
3258
+ ' 1. Restart VS Code (or reload window: Cmd+Shift+P → Reload Window)',
3259
+ ' 2. Open terminal: Ctrl+` or Cmd+Esc',
3260
+ ' 3. Select "mipham" profile from the terminal dropdown',
3261
+ '',
3262
+ 'Install the VS Code extension for full integration:',
3263
+ ` code --install-extension miphamai.mipham-code`,
3264
+ '',
3265
+ 'JetBrains: Settings → Tools → Terminal → Shell path → bun run mipham',
3266
+ ].join('\n'),
3267
+ }
3268
+ }
3212
3269
 
3213
3270
  // ═══════════════════════════════════════════════════════════════
3214
3271
  // Terminal Setup — shell integration guide
3215
3272
  // ═══════════════════════════════════════════════════════════════
3216
3273
 
3217
- const terminalSetupCmd: CommandHandler = () => ({
3218
- content: `── Terminal Setup ──
3219
-
3220
- Install globally:
3221
- ${NPM_INSTALL_COMMAND}
3222
- mipham
3223
-
3224
- One-liner install:
3225
- curl -fsSL https://mipham.ai/install.sh | bash
3274
+ const terminalSetupCmd: CommandHandler = async () => {
3275
+ const { writeFileSync, appendFileSync, existsSync, mkdirSync } = await import('node:fs')
3276
+ const { join } = await import('node:path')
3277
+ const { homedir } = await import('node:os')
3226
3278
 
3227
- Add to shell profile (~/.zshrc or ~/.bashrc):
3228
- alias mipham='bun run ~/path/to/mipham-code/apps/cli/bin/mipham'
3279
+ const home = homedir()
3280
+ const lines: string[] = ['── Terminal Setup ──', '']
3281
+
3282
+ // ── 1. Generate standalone shell setup script ──
3283
+ const miphamDir = join(home, '.mipham')
3284
+ mkdirSync(miphamDir, { recursive: true })
3285
+
3286
+ const shellScript = join(miphamDir, 'shell-setup.sh')
3287
+ const shellContent = [
3288
+ '#!/bin/bash',
3289
+ '# Mipham Code — Shell Integration',
3290
+ '# Source this file in your shell profile:',
3291
+ '# source ~/.mipham/shell-setup.sh',
3292
+ '',
3293
+ `export MIPHAM_HOME="${home}/.mipham"`,
3294
+ '',
3295
+ '# Alias: launch Mipham Code in current directory',
3296
+ `alias mipham='cd $(pwd) && ${NPM_INSTALL_COMMAND} > /dev/null 2>&1; mipham'`,
3297
+ '',
3298
+ '# Or use the local development version:',
3299
+ '# alias mipham="bun run /path/to/mipham-code/apps/cli/bin/mipham"',
3300
+ '',
3301
+ '# Auto-detect provider from config',
3302
+ 'if [ -f ~/.mipham/config.yml ]; then',
3303
+ ' export MIPHAM_PROVIDER=$(grep "defaultProvider:" ~/.mipham/config.yml | awk "{print \$2}")',
3304
+ 'fi',
3305
+ ].join('\n')
3306
+ writeFileSync(shellScript, shellContent + '\n', 'utf-8')
3307
+ lines.push(` ✅ Generated: ${shellScript}`)
3308
+
3309
+ // ── 2. Append to shell profile ──
3310
+ const shell = process.env.SHELL || '/bin/zsh'
3311
+ const profileName = shell.includes('zsh') ? '.zshrc' : '.bashrc'
3312
+ const profilePath = join(home, profileName)
3313
+ const sourceLine = `\n# Mipham Code shell integration\n[ -f ~/.mipham/shell-setup.sh ] && source ~/.mipham/shell-setup.sh\n`
3229
3314
 
3230
- # Or with a specific provider/model:
3231
- alias mipham='mipham --provider anthropic --model claude-opus-4-8'
3315
+ try {
3316
+ const existing = existsSync(profilePath)
3317
+ ? require('node:fs').readFileSync(profilePath, 'utf-8')
3318
+ : ''
3319
+ if (existing.includes('shell-setup.sh')) {
3320
+ lines.push(` ⏭ ${profileName} already has Mipham Code integration`)
3321
+ } else {
3322
+ appendFileSync(profilePath, sourceLine, 'utf-8')
3323
+ lines.push(` ✅ Added to ~/${profileName}`)
3324
+ }
3325
+ } catch {
3326
+ lines.push(` ⚠️ Could not update ~/${profileName}. Add manually:`)
3327
+ lines.push(` echo '${sourceLine.trim()}' >> ~/${profileName}`)
3328
+ }
3232
3329
 
3233
- Upgrade:
3234
- curl -fsSL https://mipham.ai/install.sh | bash
3235
- # or: ${NPM_UPDATE_COMMAND}
3330
+ // ── 3. Global install check ──
3331
+ lines.push('')
3332
+ lines.push('── Installation ──')
3333
+ try {
3334
+ const { execSync } = await import('node:child_process')
3335
+ const miphamPath = execSync('which mipham 2>/dev/null || echo ""', { encoding: 'utf-8' }).trim()
3336
+ if (miphamPath) {
3337
+ lines.push(` ✅ mipham found at: ${miphamPath}`)
3338
+ const version = execSync('mipham --version 2>/dev/null || echo "unknown"', {
3339
+ encoding: 'utf-8',
3340
+ }).trim()
3341
+ lines.push(` 📦 Version: ${version}`)
3342
+ } else {
3343
+ lines.push(` ⚠️ mipham not in PATH. Install globally:`)
3344
+ lines.push(` ${NPM_INSTALL_COMMAND}`)
3345
+ lines.push(` or: curl -fsSL https://mipham.ai/install.sh | bash`)
3346
+ }
3347
+ } catch {
3348
+ lines.push(` 💡 Install: ${NPM_INSTALL_COMMAND}`)
3349
+ }
3236
3350
 
3237
- Verify installation:
3238
- mipham --version
3239
- mipham --help
3351
+ // ── 4. Verify ──
3352
+ lines.push('')
3353
+ lines.push('── Next Steps ──')
3354
+ lines.push(' 1. Restart your terminal or run: source ~/.mipham/shell-setup.sh')
3355
+ lines.push(' 2. Run: mipham --version')
3356
+ lines.push(' 3. Start coding: cd your-project && mipham')
3357
+ lines.push('')
3358
+ lines.push(`Works with: Zsh, Bash. Shell: ${shell}`)
3240
3359
 
3241
- Works with: Bash, Zsh, Fish, PowerShell, Windows Terminal`,
3242
- })
3360
+ return { content: lines.join('\n') }
3361
+ }
3243
3362
 
3244
3363
  // ═══════════════════════════════════════════════════════════════
3245
3364
  // Phase 4 — MCP Server Management