@miphamai/cli 0.2.3

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 (67) hide show
  1. package/README.md +76 -0
  2. package/assets/icon.icns +0 -0
  3. package/assets/icon.jpg +0 -0
  4. package/bin/mipham +51 -0
  5. package/bin/mipham.ts +8 -0
  6. package/package.json +59 -0
  7. package/skills/mipham/om-model-optimize.mipham-skill.md +20 -0
  8. package/skills/mipham/om-security.mipham-skill.md +21 -0
  9. package/skills/standard/code-review.SKILL.md +116 -0
  10. package/skills/standard/compassionate-communication.SKILL.md +80 -0
  11. package/skills/standard/doc-generator.SKILL.md +21 -0
  12. package/skills/standard/github-ops.SKILL.md +22 -0
  13. package/skills/standard/memory.SKILL.md +22 -0
  14. package/skills/standard/mipham-code-setup.SKILL.md +113 -0
  15. package/skills/standard/security-review.SKILL.md +122 -0
  16. package/skills/standard/self-review.SKILL.md +20 -0
  17. package/skills/standard/superpower.SKILL.md +19 -0
  18. package/skills/standard/tdd.SKILL.md +20 -0
  19. package/skills/standard/web-search.SKILL.md +23 -0
  20. package/src/agent/sub-agent.ts +122 -0
  21. package/src/config/defaults.ts +10 -0
  22. package/src/config/loader.ts +30 -0
  23. package/src/core/context.ts +135 -0
  24. package/src/core/engine.ts +193 -0
  25. package/src/core/hooks.ts +116 -0
  26. package/src/core/instructions.ts +126 -0
  27. package/src/core/permission.ts +54 -0
  28. package/src/core/session-store.ts +136 -0
  29. package/src/index.tsx +93 -0
  30. package/src/mcp/client.ts +170 -0
  31. package/src/mcp/protocol.ts +104 -0
  32. package/src/mcp/transport.ts +169 -0
  33. package/src/mcp/types.ts +112 -0
  34. package/src/providers/anthropic.ts +286 -0
  35. package/src/providers/bootstrap.ts +28 -0
  36. package/src/providers/openai-compat.ts +158 -0
  37. package/src/providers/registry.ts +70 -0
  38. package/src/security/path.ts +90 -0
  39. package/src/security/url.ts +96 -0
  40. package/src/shared/constants.ts +368 -0
  41. package/src/shared/index.ts +2 -0
  42. package/src/shared/types.ts +161 -0
  43. package/src/skills/loader.ts +109 -0
  44. package/src/skills/mipham/runtime.ts +63 -0
  45. package/src/skills/standard/runtime.ts +59 -0
  46. package/src/tools/agent/agent.ts +51 -0
  47. package/src/tools/agent/memory.ts +51 -0
  48. package/src/tools/agent/plan.ts +87 -0
  49. package/src/tools/agent/skill.ts +58 -0
  50. package/src/tools/exec/bash.ts +111 -0
  51. package/src/tools/exec/git.ts +63 -0
  52. package/src/tools/exec/task.ts +69 -0
  53. package/src/tools/file/edit.ts +57 -0
  54. package/src/tools/file/glob.ts +29 -0
  55. package/src/tools/file/grep.ts +52 -0
  56. package/src/tools/file/read.ts +38 -0
  57. package/src/tools/file/write.ts +27 -0
  58. package/src/tools/index.ts +126 -0
  59. package/src/tools/network/web-fetch.ts +61 -0
  60. package/src/tools/network/web-search.ts +32 -0
  61. package/src/tools/system/config.ts +68 -0
  62. package/src/tools/system/mcp.ts +75 -0
  63. package/src/ui/app.tsx +317 -0
  64. package/src/ui/chat.tsx +76 -0
  65. package/src/ui/commands.ts +2232 -0
  66. package/src/ui/input.tsx +83 -0
  67. package/src/ui/picker.tsx +209 -0
@@ -0,0 +1,158 @@
1
+ import type {
2
+ ProviderConfig,
3
+ ModelInfo,
4
+ Message,
5
+ StreamChunk,
6
+ ContentBlock,
7
+ } from '../shared/index.ts'
8
+ import type { ProviderInstance, ChatRequest } from './registry'
9
+
10
+ export class OpenAICompatProvider implements ProviderInstance {
11
+ constructor(public config: ProviderConfig) {}
12
+
13
+ async *chat(req: ChatRequest): AsyncGenerator<StreamChunk> {
14
+ const baseUrl = this.config.baseUrl?.replace(/\/+$/, '') || 'https://api.openai.com/v1'
15
+ const apiKey = this.resolveApiKey(this.config.apiKey)
16
+
17
+ const body = {
18
+ model: req.model,
19
+ messages: this.convertMessages(req.messages, req.systemPrompt),
20
+ stream: true,
21
+ max_tokens: req.maxTokens,
22
+ temperature: req.temperature,
23
+ tools: req.tools?.map((t) => ({ type: 'function', function: t })),
24
+ }
25
+
26
+ const response = await fetch(`${baseUrl}/chat/completions`, {
27
+ method: 'POST',
28
+ headers: {
29
+ 'Content-Type': 'application/json',
30
+ Authorization: `Bearer ${apiKey}`,
31
+ },
32
+ body: JSON.stringify(body),
33
+ })
34
+
35
+ if (!response.ok) {
36
+ const errText = await response.text()
37
+ yield { type: 'error', error: `OpenAI API error ${response.status}: ${errText}` }
38
+ return
39
+ }
40
+
41
+ if (!response.body) {
42
+ yield { type: 'error', error: 'No response body' }
43
+ return
44
+ }
45
+
46
+ const reader = response.body.getReader()
47
+ const decoder = new TextDecoder()
48
+ let buffer = ''
49
+
50
+ while (true) {
51
+ const { done, value } = await reader.read()
52
+ if (done) break
53
+
54
+ buffer += decoder.decode(value, { stream: true })
55
+ const lines = buffer.split('\n')
56
+ buffer = lines.pop() || ''
57
+
58
+ for (const line of lines) {
59
+ const trimmed = line.trim()
60
+ if (!trimmed || !trimmed.startsWith('data: ')) continue
61
+ const data = trimmed.slice(6)
62
+ if (data === '[DONE]') {
63
+ yield { type: 'stop' }
64
+ return
65
+ }
66
+
67
+ try {
68
+ const parsed = JSON.parse(data)
69
+ const choice = parsed.choices?.[0]
70
+ if (!choice) continue
71
+
72
+ const delta = choice.delta
73
+
74
+ if (delta?.tool_calls) {
75
+ for (const tc of delta.tool_calls) {
76
+ yield {
77
+ type: 'tool_use',
78
+ toolUse: {
79
+ type: 'tool_use',
80
+ id: tc.id || `call_${Date.now()}`,
81
+ name: tc.function?.name || '',
82
+ input: tc.function?.arguments ? JSON.parse(tc.function.arguments) : {},
83
+ },
84
+ }
85
+ }
86
+ continue
87
+ }
88
+
89
+ if (delta?.content) {
90
+ yield { type: 'text', content: delta.content }
91
+ }
92
+
93
+ if (choice.finish_reason === 'stop') {
94
+ yield { type: 'stop' }
95
+ }
96
+ } catch {
97
+ // skip unparseable chunks
98
+ }
99
+ }
100
+ }
101
+
102
+ yield { type: 'stop' }
103
+ }
104
+
105
+ async listModels(): Promise<ModelInfo[]> {
106
+ return this.config.models.filter((m) => m.status === 'active')
107
+ }
108
+
109
+ async healthCheck(): Promise<boolean> {
110
+ try {
111
+ const baseUrl = this.config.baseUrl?.replace(/\/+$/, '') || 'https://api.openai.com/v1'
112
+ const apiKey = this.resolveApiKey(this.config.apiKey)
113
+ const res = await fetch(`${baseUrl}/models`, {
114
+ headers: { Authorization: `Bearer ${apiKey}` },
115
+ })
116
+ return res.ok
117
+ } catch {
118
+ return false
119
+ }
120
+ }
121
+
122
+ private convertMessages(
123
+ messages: Message[],
124
+ systemPrompt?: string,
125
+ ): { role: string; content: string | unknown[] }[] {
126
+ const result: { role: string; content: string | unknown[] }[] = []
127
+
128
+ if (systemPrompt) {
129
+ result.push({ role: 'system', content: systemPrompt })
130
+ }
131
+
132
+ for (const msg of messages) {
133
+ if (typeof msg.content === 'string') {
134
+ result.push({ role: msg.role, content: msg.content })
135
+ } else {
136
+ const parts: unknown[] = []
137
+ for (const block of msg.content) {
138
+ if (block.type === 'text') {
139
+ parts.push({ type: 'text', text: block.text })
140
+ } else if (block.type === 'image_url') {
141
+ parts.push({ type: 'image_url', image_url: block.image_url })
142
+ }
143
+ }
144
+ result.push({ role: msg.role, content: parts })
145
+ }
146
+ }
147
+
148
+ return result
149
+ }
150
+
151
+ private resolveApiKey(keyTemplate: string): string {
152
+ const match = keyTemplate.match(/^\$\{(.+)\}$/)
153
+ if (match?.[1]) {
154
+ return process.env[match[1]] || ''
155
+ }
156
+ return keyTemplate
157
+ }
158
+ }
@@ -0,0 +1,70 @@
1
+ import type { ProviderConfig, ModelInfo, Message, StreamChunk } from '../shared/index.ts'
2
+
3
+ export interface ProviderInstance {
4
+ config: ProviderConfig
5
+ chat(req: ChatRequest): AsyncGenerator<StreamChunk>
6
+ listModels(): Promise<ModelInfo[]>
7
+ healthCheck(): Promise<boolean>
8
+ }
9
+
10
+ export interface ChatRequest {
11
+ model: string
12
+ messages: Message[]
13
+ systemPrompt?: string
14
+ tools?: Record<string, unknown>[]
15
+ maxTokens?: number
16
+ temperature?: number
17
+ }
18
+
19
+ export class ProviderRegistry {
20
+ private providers = new Map<string, ProviderInstance>()
21
+ private activeProviderId: string
22
+ private activeModelId: string
23
+
24
+ constructor(providers: ProviderConfig[], defaultProvider: string, defaultModel: string) {
25
+ this.activeProviderId = defaultProvider
26
+ this.activeModelId = defaultModel
27
+ }
28
+
29
+ register(id: string, instance: ProviderInstance): void {
30
+ this.providers.set(id, instance)
31
+ }
32
+
33
+ get(id: string): ProviderInstance | undefined {
34
+ return this.providers.get(id)
35
+ }
36
+
37
+ getActive(): ProviderInstance {
38
+ const p = this.providers.get(this.activeProviderId)
39
+ if (!p) throw new Error(`Provider "${this.activeProviderId}" not registered`)
40
+ return p
41
+ }
42
+
43
+ getActiveModel(): string {
44
+ return this.activeModelId
45
+ }
46
+
47
+ switchProvider(providerId: string, modelId?: string): void {
48
+ if (!this.providers.has(providerId)) {
49
+ throw new Error(
50
+ `Provider "${providerId}" not registered. Available: ${this.listIds().join(', ')}`,
51
+ )
52
+ }
53
+ this.activeProviderId = providerId
54
+ if (modelId) this.activeModelId = modelId
55
+ }
56
+
57
+ listIds(): string[] {
58
+ return Array.from(this.providers.keys())
59
+ }
60
+
61
+ listModels(): ModelInfo[] {
62
+ const provider = this.getActive()
63
+ return provider.config.models.filter((m) => m.status === 'active')
64
+ }
65
+
66
+ async *chat(req: ChatRequest): AsyncGenerator<StreamChunk> {
67
+ const provider = this.getActive()
68
+ yield* provider.chat({ ...req, model: req.model || this.activeModelId })
69
+ }
70
+ }
@@ -0,0 +1,90 @@
1
+ import { resolve, normalize } from 'node:path'
2
+ import { realpathSync, existsSync } from 'node:fs'
3
+
4
+ /**
5
+ * Sensitive system paths that tools should never access.
6
+ * Relative to filesystem root; checked after resolving symlinks.
7
+ */
8
+ const BLOCKED_PATHS = ['/etc', '/proc', '/sys', '/dev', '/boot', '/root']
9
+
10
+ /**
11
+ * Resolve a user-supplied path relative to cwd, with sandbox enforcement.
12
+ *
13
+ * Rules:
14
+ * - The resolved canonical path MUST be within `cwd` (or a subdirectory).
15
+ * - Symlinks are resolved via realpathSync so attackers can't use
16
+ * `ln -s /etc project/foo` to escape the sandbox.
17
+ * - Sensitive system directories are always rejected.
18
+ * - If the path doesn't exist yet (e.g. for Write), we resolve its parent
19
+ * directory and check that the target is still within cwd.
20
+ *
21
+ * @returns The safe canonical path.
22
+ * @throws {Error} with a human-readable message if the path is blocked.
23
+ */
24
+ export function resolveSafe(cwd: string, inputPath: string): string {
25
+ const raw = resolve(cwd, inputPath)
26
+ const normalized = normalize(raw)
27
+
28
+ // Resolve symlinks if the path (or its parent) already exists on disk
29
+ let canonical: string
30
+ if (existsSync(normalized)) {
31
+ canonical = realpathSync(normalized)
32
+ } else {
33
+ // For paths that don't exist yet (e.g. Write tool creating a new file),
34
+ // walk up to find the first existing parent, resolve that, then
35
+ // reconstruct the full path.
36
+ const existingParent = findExistingParent(normalized)
37
+ if (!existingParent) {
38
+ throw new Error(`Path rejected: no existing parent directory found for "${inputPath}"`)
39
+ }
40
+ const realParent = realpathSync(existingParent)
41
+ const relative = normalized.slice(existingParent.length)
42
+ canonical = realParent + relative
43
+ }
44
+
45
+ // Normalize cwd as well (it may contain symlinks)
46
+ const realCwd = realpathSync(cwd)
47
+
48
+ // Check 1: must be within cwd
49
+ if (!isWithin(canonical, realCwd)) {
50
+ throw new Error(
51
+ `Path rejected: "${inputPath}" is outside the project workspace.\n` +
52
+ `Resolved: ${canonical}\nWorkspace: ${realCwd}`,
53
+ )
54
+ }
55
+
56
+ // Check 2: must not target sensitive system directories
57
+ for (const blocked of BLOCKED_PATHS) {
58
+ if (canonical === blocked || canonical.startsWith(blocked + '/')) {
59
+ throw new Error(
60
+ `Path rejected: "${inputPath}" resolves to a protected system directory (${blocked}).`,
61
+ )
62
+ }
63
+ }
64
+
65
+ return canonical
66
+ }
67
+
68
+ /**
69
+ * Find the first existing parent directory of a path.
70
+ * Returns undefined if no parent exists (shouldn't happen for valid paths).
71
+ */
72
+ function findExistingParent(p: string): string | undefined {
73
+ let current = p
74
+ while (current.length > 1) {
75
+ const parent = current.slice(0, current.lastIndexOf('/')) || '/'
76
+ if (existsSync(parent)) return parent
77
+ current = parent
78
+ }
79
+ return undefined
80
+ }
81
+
82
+ /**
83
+ * Check if `child` is within `parent` (or equal to it).
84
+ */
85
+ function isWithin(child: string, parent: string): boolean {
86
+ // Normalize trailing slashes for comparison
87
+ const c = child.endsWith('/') ? child.slice(0, -1) : child
88
+ const p = parent.endsWith('/') ? parent.slice(0, -1) : parent
89
+ return c === p || c.startsWith(p + '/')
90
+ }
@@ -0,0 +1,96 @@
1
+ import dns from 'node:dns'
2
+ const { lookupSync } = dns as unknown as {
3
+ lookupSync: (
4
+ hostname: string,
5
+ options?: { all?: boolean },
6
+ ) => Array<{ address: string; family: number }>
7
+ }
8
+
9
+ // ── Protocol allow-list ──
10
+ const ALLOWED_PROTOCOLS = ['http:', 'https:']
11
+
12
+ // ── IPv4 private / reserved ranges ──
13
+ const IPV4_BLOCKED = [
14
+ { prefix: 0x7f000000, mask: 0xff000000 }, // 127.0.0.0/8 loopback
15
+ { prefix: 0x0a000000, mask: 0xff000000 }, // 10.0.0.0/8 private
16
+ { prefix: 0xac100000, mask: 0xfff00000 }, // 172.16.0.0/12 private
17
+ { prefix: 0xc0a80000, mask: 0xffff0000 }, // 192.168.0.0/16 private
18
+ { prefix: 0x00000000, mask: 0xff000000 }, // 0.0.0.0/8 "this network"
19
+ { prefix: 0xa9fe0000, mask: 0xffff0000 }, // 169.254.0.0/16 link-local
20
+ ]
21
+
22
+ /** Validate a URL for SSRF safety. Returns null if safe, or an error string. */
23
+ export function validateUrl(urlStr: string): string | null {
24
+ let parsed: URL
25
+ try {
26
+ parsed = new URL(urlStr)
27
+ } catch {
28
+ return `Invalid URL: "${urlStr}"`
29
+ }
30
+
31
+ // 1. Protocol allow-list
32
+ if (!ALLOWED_PROTOCOLS.includes(parsed.protocol)) {
33
+ return `URL protocol "${parsed.protocol}" is not allowed. Only http: and https: are permitted.`
34
+ }
35
+
36
+ // Hostname may include brackets for IPv6 (e.g. "[::1]") — strip them for checks
37
+ const hostname = parsed.hostname
38
+
39
+ // 2. Check for raw IPv4 in hostname
40
+ if (isBlockedIPv4(hostname)) {
41
+ return `URL hostname "${hostname}" resolves to a blocked IP range (private / loopback / link-local).`
42
+ }
43
+
44
+ // 3. Check for raw IPv6 loopback / private
45
+ // Strip brackets from IPv6 hostnames (Bun's URL parser keeps them)
46
+ const ipv6Host = hostname.startsWith('[') ? hostname.slice(1, -1) : hostname
47
+ if (isBlockedIPv6(ipv6Host)) {
48
+ return `URL hostname "${hostname}" resolves to a blocked IPv6 address.`
49
+ }
50
+
51
+ // 4. DNS rebinding protection: resolve hostname and check resolved IPs
52
+ // Use the bracket-less version for DNS lookups
53
+ const dnsHostname = hostname.startsWith('[') ? hostname.slice(1, -1) : hostname
54
+ try {
55
+ const addresses = lookupSync(dnsHostname, { all: true })
56
+ for (const addr of addresses) {
57
+ if (isBlockedIPv4(addr.address)) {
58
+ return `URL hostname "${hostname}" resolved to ${addr.address} (blocked IP range).`
59
+ }
60
+ if (isBlockedIPv6(addr.address)) {
61
+ return `URL hostname "${hostname}" resolved to ${addr.address} (blocked IPv6 address).`
62
+ }
63
+ }
64
+ } catch {
65
+ // DNS resolution failed — that's OK for validation; the fetch will also fail
66
+ }
67
+
68
+ return null // safe
69
+ }
70
+
71
+ function isBlockedIPv4(ip: string): boolean {
72
+ // Skip if it's not an IPv4 string
73
+ const parts = ip.split('.')
74
+ if (parts.length !== 4) return false
75
+
76
+ const nums = parts.map((p) => parseInt(p, 10))
77
+ if (nums.some((n) => isNaN(n) || n < 0 || n > 255)) return false
78
+
79
+ // Use >>> 0 to avoid signed 32-bit overflow for octets > 127
80
+ const asInt = ((nums[0]! << 24) | (nums[1]! << 16) | (nums[2]! << 8) | nums[3]!) >>> 0
81
+
82
+ return IPV4_BLOCKED.some(({ prefix, mask }) => (asInt & mask) >>> 0 === prefix >>> 0)
83
+ }
84
+
85
+ function isBlockedIPv6(ip: string): boolean {
86
+ // IPv6 loopback
87
+ if (ip === '::1' || ip === '0:0:0:0:0:0:0:1') return true
88
+ // IPv6 link-local (fe80::/10)
89
+ if (ip.startsWith('fe8') || ip.startsWith('fe9') || ip.startsWith('fea') || ip.startsWith('feb'))
90
+ return true
91
+ // IPv6 unique local (fc00::/7)
92
+ if (ip.startsWith('fc') || ip.startsWith('fd')) return true
93
+ // IPv6 unspecified
94
+ if (ip === '::' || ip === '0:0:0:0:0:0:0:0') return true
95
+ return false
96
+ }