@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.
- package/README.md +76 -0
- package/assets/icon.icns +0 -0
- package/assets/icon.jpg +0 -0
- package/bin/mipham +51 -0
- package/bin/mipham.ts +8 -0
- package/package.json +59 -0
- package/skills/mipham/om-model-optimize.mipham-skill.md +20 -0
- package/skills/mipham/om-security.mipham-skill.md +21 -0
- package/skills/standard/code-review.SKILL.md +116 -0
- package/skills/standard/compassionate-communication.SKILL.md +80 -0
- package/skills/standard/doc-generator.SKILL.md +21 -0
- package/skills/standard/github-ops.SKILL.md +22 -0
- package/skills/standard/memory.SKILL.md +22 -0
- package/skills/standard/mipham-code-setup.SKILL.md +113 -0
- package/skills/standard/security-review.SKILL.md +122 -0
- package/skills/standard/self-review.SKILL.md +20 -0
- package/skills/standard/superpower.SKILL.md +19 -0
- package/skills/standard/tdd.SKILL.md +20 -0
- package/skills/standard/web-search.SKILL.md +23 -0
- package/src/agent/sub-agent.ts +122 -0
- package/src/config/defaults.ts +10 -0
- package/src/config/loader.ts +30 -0
- package/src/core/context.ts +135 -0
- package/src/core/engine.ts +193 -0
- package/src/core/hooks.ts +116 -0
- package/src/core/instructions.ts +126 -0
- package/src/core/permission.ts +54 -0
- package/src/core/session-store.ts +136 -0
- package/src/index.tsx +93 -0
- package/src/mcp/client.ts +170 -0
- package/src/mcp/protocol.ts +104 -0
- package/src/mcp/transport.ts +169 -0
- package/src/mcp/types.ts +112 -0
- package/src/providers/anthropic.ts +286 -0
- package/src/providers/bootstrap.ts +28 -0
- package/src/providers/openai-compat.ts +158 -0
- package/src/providers/registry.ts +70 -0
- package/src/security/path.ts +90 -0
- package/src/security/url.ts +96 -0
- package/src/shared/constants.ts +368 -0
- package/src/shared/index.ts +2 -0
- package/src/shared/types.ts +161 -0
- package/src/skills/loader.ts +109 -0
- package/src/skills/mipham/runtime.ts +63 -0
- package/src/skills/standard/runtime.ts +59 -0
- package/src/tools/agent/agent.ts +51 -0
- package/src/tools/agent/memory.ts +51 -0
- package/src/tools/agent/plan.ts +87 -0
- package/src/tools/agent/skill.ts +58 -0
- package/src/tools/exec/bash.ts +111 -0
- package/src/tools/exec/git.ts +63 -0
- package/src/tools/exec/task.ts +69 -0
- package/src/tools/file/edit.ts +57 -0
- package/src/tools/file/glob.ts +29 -0
- package/src/tools/file/grep.ts +52 -0
- package/src/tools/file/read.ts +38 -0
- package/src/tools/file/write.ts +27 -0
- package/src/tools/index.ts +126 -0
- package/src/tools/network/web-fetch.ts +61 -0
- package/src/tools/network/web-search.ts +32 -0
- package/src/tools/system/config.ts +68 -0
- package/src/tools/system/mcp.ts +75 -0
- package/src/ui/app.tsx +317 -0
- package/src/ui/chat.tsx +76 -0
- package/src/ui/commands.ts +2232 -0
- package/src/ui/input.tsx +83 -0
- package/src/ui/picker.tsx +209 -0
|
@@ -0,0 +1,116 @@
|
|
|
1
|
+
import type {
|
|
2
|
+
HookDefinition,
|
|
3
|
+
HookEvent,
|
|
4
|
+
HookContext,
|
|
5
|
+
HookResult,
|
|
6
|
+
ToolResult,
|
|
7
|
+
} from '../shared/index.ts'
|
|
8
|
+
|
|
9
|
+
export class HookEngine {
|
|
10
|
+
private hooks: HookDefinition[] = []
|
|
11
|
+
|
|
12
|
+
register(hook: HookDefinition): void {
|
|
13
|
+
this.hooks.push(hook)
|
|
14
|
+
}
|
|
15
|
+
|
|
16
|
+
unregister(event: HookEvent, toolName?: string): void {
|
|
17
|
+
this.hooks = this.hooks.filter(
|
|
18
|
+
(h) => !(h.event === event && (!toolName || h.toolName === toolName)),
|
|
19
|
+
)
|
|
20
|
+
}
|
|
21
|
+
|
|
22
|
+
async executePreToolUse(
|
|
23
|
+
toolName: string,
|
|
24
|
+
toolInput: Record<string, unknown>,
|
|
25
|
+
sessionId: string,
|
|
26
|
+
): Promise<HookResult> {
|
|
27
|
+
const ctx: HookContext = {
|
|
28
|
+
event: 'PreToolUse',
|
|
29
|
+
toolName,
|
|
30
|
+
toolInput,
|
|
31
|
+
sessionId,
|
|
32
|
+
}
|
|
33
|
+
|
|
34
|
+
return this.runHooks('PreToolUse', toolName, ctx)
|
|
35
|
+
}
|
|
36
|
+
|
|
37
|
+
async executePostToolUse(
|
|
38
|
+
toolName: string,
|
|
39
|
+
toolInput: Record<string, unknown>,
|
|
40
|
+
toolResult: ToolResult,
|
|
41
|
+
sessionId: string,
|
|
42
|
+
): Promise<HookResult> {
|
|
43
|
+
const ctx: HookContext = {
|
|
44
|
+
event: 'PostToolUse',
|
|
45
|
+
toolName,
|
|
46
|
+
toolInput,
|
|
47
|
+
toolResult,
|
|
48
|
+
sessionId,
|
|
49
|
+
}
|
|
50
|
+
|
|
51
|
+
return this.runHooks('PostToolUse', toolName, ctx)
|
|
52
|
+
}
|
|
53
|
+
|
|
54
|
+
async executeSessionStart(sessionId: string): Promise<HookResult> {
|
|
55
|
+
const ctx: HookContext = {
|
|
56
|
+
event: 'SessionStart',
|
|
57
|
+
sessionId,
|
|
58
|
+
}
|
|
59
|
+
|
|
60
|
+
return this.runHooks('SessionStart', undefined, ctx)
|
|
61
|
+
}
|
|
62
|
+
|
|
63
|
+
async executeSessionEnd(sessionId: string): Promise<HookResult> {
|
|
64
|
+
const ctx: HookContext = {
|
|
65
|
+
event: 'SessionEnd',
|
|
66
|
+
sessionId,
|
|
67
|
+
}
|
|
68
|
+
|
|
69
|
+
return this.runHooks('SessionEnd', undefined, ctx)
|
|
70
|
+
}
|
|
71
|
+
|
|
72
|
+
async executeNotification(message: string, sessionId: string): Promise<HookResult> {
|
|
73
|
+
const ctx: HookContext = {
|
|
74
|
+
event: 'Notification',
|
|
75
|
+
sessionId,
|
|
76
|
+
toolInput: { message },
|
|
77
|
+
}
|
|
78
|
+
|
|
79
|
+
return this.runHooks('Notification', undefined, ctx)
|
|
80
|
+
}
|
|
81
|
+
|
|
82
|
+
private async runHooks(
|
|
83
|
+
event: HookEvent,
|
|
84
|
+
toolName: string | undefined,
|
|
85
|
+
ctx: HookContext,
|
|
86
|
+
): Promise<HookResult> {
|
|
87
|
+
const matching = this.hooks.filter(
|
|
88
|
+
(h) => h.event === event && (!toolName || !h.toolName || h.toolName === toolName),
|
|
89
|
+
)
|
|
90
|
+
|
|
91
|
+
const result: HookResult = { allowed: true }
|
|
92
|
+
|
|
93
|
+
for (const hook of matching) {
|
|
94
|
+
try {
|
|
95
|
+
const hookResult = await hook.handler(ctx)
|
|
96
|
+
if (!hookResult.allowed) {
|
|
97
|
+
return hookResult // Block on first deny
|
|
98
|
+
}
|
|
99
|
+
if (hookResult.modifiedInput) {
|
|
100
|
+
result.modifiedInput = {
|
|
101
|
+
...(result.modifiedInput || {}),
|
|
102
|
+
...hookResult.modifiedInput,
|
|
103
|
+
}
|
|
104
|
+
}
|
|
105
|
+
} catch {
|
|
106
|
+
// Hook failures do not block execution
|
|
107
|
+
}
|
|
108
|
+
}
|
|
109
|
+
|
|
110
|
+
return result
|
|
111
|
+
}
|
|
112
|
+
|
|
113
|
+
listHooks(): HookDefinition[] {
|
|
114
|
+
return [...this.hooks]
|
|
115
|
+
}
|
|
116
|
+
}
|
|
@@ -0,0 +1,126 @@
|
|
|
1
|
+
import { readFileSync, existsSync, readdirSync, statSync } from 'node:fs'
|
|
2
|
+
import { join } from 'node:path'
|
|
3
|
+
import { parse as parseYaml } from 'yaml'
|
|
4
|
+
import type { InstructionFile } from '../shared/index.ts'
|
|
5
|
+
|
|
6
|
+
interface FrontmatterResult {
|
|
7
|
+
data: Record<string, unknown>
|
|
8
|
+
content: string
|
|
9
|
+
}
|
|
10
|
+
|
|
11
|
+
function parseFrontmatter(raw: string): FrontmatterResult {
|
|
12
|
+
const match = raw.match(/^---\n([\s\S]*?)\n---\n([\s\S]*)$/)
|
|
13
|
+
if (!match) {
|
|
14
|
+
return { data: {}, content: raw }
|
|
15
|
+
}
|
|
16
|
+
return {
|
|
17
|
+
data: parseYaml(match[1] || '') as Record<string, unknown>,
|
|
18
|
+
content: match[2] || '',
|
|
19
|
+
}
|
|
20
|
+
}
|
|
21
|
+
|
|
22
|
+
export class InstructionsLoader {
|
|
23
|
+
private instructions: InstructionFile[] = []
|
|
24
|
+
|
|
25
|
+
loadAll(cwd: string): void {
|
|
26
|
+
this.instructions = []
|
|
27
|
+
|
|
28
|
+
// Tier 1: Ancestor-level CLAUDE.md (for Claude Code compatibility)
|
|
29
|
+
// ../../ = Rismed_Ronxin_Capital
|
|
30
|
+
this.tryLoad(join(cwd, '..', '..', 'CLAUDE.md'), 'group')
|
|
31
|
+
// ../ = One_Mipham_Corporation
|
|
32
|
+
this.tryLoad(join(cwd, '..', 'CLAUDE.md'), 'company')
|
|
33
|
+
|
|
34
|
+
// Tier 1b: Group-level MIPHAM.md — One_Mipham_Corporation is the root
|
|
35
|
+
this.tryLoad(join(cwd, '..', 'MIPHAM.md'), 'group')
|
|
36
|
+
|
|
37
|
+
// Tier 2: Project-level (CLAUDE.md + MIPHAM.md at cwd)
|
|
38
|
+
this.tryLoad(join(cwd, 'MIPHAM.md'), 'project')
|
|
39
|
+
this.tryLoad(join(cwd, 'CLAUDE.md'), 'project')
|
|
40
|
+
|
|
41
|
+
// Tier 2b: Directory-level MIPHAM.md (recursive, up to 3 levels)
|
|
42
|
+
this.tryLoadRecursive(cwd, 'directory')
|
|
43
|
+
|
|
44
|
+
// Tier 3: User-level ~/.mipham/USER.md
|
|
45
|
+
const home = process.env.HOME || '~'
|
|
46
|
+
this.tryLoad(join(home, '.mipham', 'USER.md'), 'user')
|
|
47
|
+
}
|
|
48
|
+
|
|
49
|
+
buildSystemPrompt(): string {
|
|
50
|
+
const parts: string[] = []
|
|
51
|
+
|
|
52
|
+
for (const inst of this.instructions) {
|
|
53
|
+
const levelLabel: Record<string, string> = {
|
|
54
|
+
group: 'Group Policy',
|
|
55
|
+
company: 'Company Policy',
|
|
56
|
+
project: 'Project Rules',
|
|
57
|
+
directory: 'Directory Rules',
|
|
58
|
+
user: 'User Preferences',
|
|
59
|
+
}
|
|
60
|
+
parts.push(`<!-- ${levelLabel[inst.level] || inst.level} (${inst.path}) -->\n${inst.content}`)
|
|
61
|
+
}
|
|
62
|
+
|
|
63
|
+
return parts.join('\n\n---\n\n')
|
|
64
|
+
}
|
|
65
|
+
|
|
66
|
+
list(): InstructionFile[] {
|
|
67
|
+
return [...this.instructions]
|
|
68
|
+
}
|
|
69
|
+
|
|
70
|
+
private tryLoad(path: string, level: InstructionFile['level']): void {
|
|
71
|
+
if (!existsSync(path)) return
|
|
72
|
+
|
|
73
|
+
try {
|
|
74
|
+
const raw = readFileSync(path, 'utf-8')
|
|
75
|
+
const { data, content } = parseFrontmatter(raw)
|
|
76
|
+
this.instructions.push({
|
|
77
|
+
path,
|
|
78
|
+
level,
|
|
79
|
+
privacy: (data.privacy as InstructionFile['privacy']) || 'project',
|
|
80
|
+
language: (data.language as string) || 'en-US',
|
|
81
|
+
content,
|
|
82
|
+
frontmatter: data,
|
|
83
|
+
})
|
|
84
|
+
} catch {
|
|
85
|
+
// Silently skip unreadable files
|
|
86
|
+
}
|
|
87
|
+
}
|
|
88
|
+
|
|
89
|
+
private tryLoadRecursive(dir: string, level: InstructionFile['level']): void {
|
|
90
|
+
const maxDepth = 3
|
|
91
|
+
|
|
92
|
+
const walk = (current: string, depth: number) => {
|
|
93
|
+
if (depth > maxDepth) return
|
|
94
|
+
|
|
95
|
+
// Check for MIPHAM.md in this directory
|
|
96
|
+
const miphamPath = join(current, 'MIPHAM.md')
|
|
97
|
+
if (existsSync(miphamPath) && current !== dir) {
|
|
98
|
+
this.tryLoad(miphamPath, level)
|
|
99
|
+
}
|
|
100
|
+
// Also check for CLAUDE.md
|
|
101
|
+
const claudeMdPath = join(current, 'CLAUDE.md')
|
|
102
|
+
if (existsSync(claudeMdPath) && current !== dir) {
|
|
103
|
+
this.tryLoad(claudeMdPath, level)
|
|
104
|
+
}
|
|
105
|
+
|
|
106
|
+
try {
|
|
107
|
+
const items = readdirSync(current, { withFileTypes: true })
|
|
108
|
+
for (const item of items) {
|
|
109
|
+
if (
|
|
110
|
+
item.isDirectory() &&
|
|
111
|
+
!item.name.startsWith('.') &&
|
|
112
|
+
item.name !== 'node_modules' &&
|
|
113
|
+
item.name !== 'dist' &&
|
|
114
|
+
item.name !== '.next'
|
|
115
|
+
) {
|
|
116
|
+
walk(join(current, item.name), depth + 1)
|
|
117
|
+
}
|
|
118
|
+
}
|
|
119
|
+
} catch {
|
|
120
|
+
// skip unreadable directories
|
|
121
|
+
}
|
|
122
|
+
}
|
|
123
|
+
|
|
124
|
+
walk(dir, 0)
|
|
125
|
+
}
|
|
126
|
+
}
|
|
@@ -0,0 +1,54 @@
|
|
|
1
|
+
import type { ToolDefinition, ToolPermission, PermissionLevel } from '../shared/index.ts'
|
|
2
|
+
|
|
3
|
+
export class PermissionSystem {
|
|
4
|
+
private rules = new Map<string, PermissionLevel>()
|
|
5
|
+
|
|
6
|
+
constructor(private defaultLevel: ToolPermission = 'auto') {}
|
|
7
|
+
|
|
8
|
+
setDefaultLevel(level: PermissionLevel): void {
|
|
9
|
+
this.defaultLevel = level
|
|
10
|
+
}
|
|
11
|
+
|
|
12
|
+
getDefaultLevel(): PermissionLevel {
|
|
13
|
+
return this.defaultLevel
|
|
14
|
+
}
|
|
15
|
+
|
|
16
|
+
setRule(toolName: string, level: PermissionLevel): void {
|
|
17
|
+
this.rules.set(toolName, level)
|
|
18
|
+
}
|
|
19
|
+
|
|
20
|
+
removeRule(toolName: string): void {
|
|
21
|
+
this.rules.delete(toolName)
|
|
22
|
+
}
|
|
23
|
+
|
|
24
|
+
check(tool: ToolDefinition, _input: Record<string, unknown>): PermissionLevel {
|
|
25
|
+
const ruleLevel = this.rules.get(tool.name)
|
|
26
|
+
if (ruleLevel) return ruleLevel
|
|
27
|
+
return tool.permission
|
|
28
|
+
}
|
|
29
|
+
|
|
30
|
+
needsApproval(tool: ToolDefinition, input: Record<string, unknown>): boolean {
|
|
31
|
+
return this.check(tool, input) === 'ask'
|
|
32
|
+
}
|
|
33
|
+
|
|
34
|
+
isBypassed(tool: ToolDefinition, input: Record<string, unknown>): boolean {
|
|
35
|
+
return this.check(tool, input) === 'bypass'
|
|
36
|
+
}
|
|
37
|
+
|
|
38
|
+
listRules(): Map<string, PermissionLevel> {
|
|
39
|
+
return new Map(this.rules)
|
|
40
|
+
}
|
|
41
|
+
|
|
42
|
+
getByCategory(
|
|
43
|
+
tools: Map<string, ToolDefinition>,
|
|
44
|
+
category: string,
|
|
45
|
+
): Array<{ name: string; level: PermissionLevel }> {
|
|
46
|
+
const result: Array<{ name: string; level: PermissionLevel }> = []
|
|
47
|
+
for (const [name, tool] of tools) {
|
|
48
|
+
if (tool.category === category) {
|
|
49
|
+
result.push({ name, level: this.check(tool, {}) })
|
|
50
|
+
}
|
|
51
|
+
}
|
|
52
|
+
return result
|
|
53
|
+
}
|
|
54
|
+
}
|
|
@@ -0,0 +1,136 @@
|
|
|
1
|
+
import {
|
|
2
|
+
mkdirSync,
|
|
3
|
+
readdirSync,
|
|
4
|
+
readFileSync,
|
|
5
|
+
writeFileSync,
|
|
6
|
+
unlinkSync,
|
|
7
|
+
existsSync,
|
|
8
|
+
} from 'node:fs'
|
|
9
|
+
import { join } from 'node:path'
|
|
10
|
+
import type { Message } from '../shared/types'
|
|
11
|
+
|
|
12
|
+
export interface SessionMetadata {
|
|
13
|
+
name: string
|
|
14
|
+
createdAt: string
|
|
15
|
+
updatedAt: string
|
|
16
|
+
provider: string
|
|
17
|
+
model: string
|
|
18
|
+
messageCount: number
|
|
19
|
+
}
|
|
20
|
+
|
|
21
|
+
export interface StoredSession {
|
|
22
|
+
metadata: SessionMetadata
|
|
23
|
+
messages: Message[]
|
|
24
|
+
}
|
|
25
|
+
|
|
26
|
+
const HOME = process.env.HOME || '~'
|
|
27
|
+
const SESSIONS_DIR = join(HOME, '.mipham', 'sessions')
|
|
28
|
+
|
|
29
|
+
function ensureDir(): void {
|
|
30
|
+
mkdirSync(SESSIONS_DIR, { recursive: true })
|
|
31
|
+
}
|
|
32
|
+
|
|
33
|
+
function filePath(name: string): string {
|
|
34
|
+
// Sanitize name for filesystem
|
|
35
|
+
const safe = name.replace(/[^a-zA-Z0-9_-]/g, '_').slice(0, 100)
|
|
36
|
+
return join(SESSIONS_DIR, `${safe}.jsonl`)
|
|
37
|
+
}
|
|
38
|
+
|
|
39
|
+
export class SessionStore {
|
|
40
|
+
/**
|
|
41
|
+
* Save a session as JSONL (one JSON object per line).
|
|
42
|
+
*/
|
|
43
|
+
static save(
|
|
44
|
+
name: string,
|
|
45
|
+
messages: Message[],
|
|
46
|
+
metadata?: { provider?: string; model?: string },
|
|
47
|
+
): void {
|
|
48
|
+
ensureDir()
|
|
49
|
+
const path = filePath(name)
|
|
50
|
+
|
|
51
|
+
const session: StoredSession = {
|
|
52
|
+
metadata: {
|
|
53
|
+
name,
|
|
54
|
+
createdAt: new Date().toISOString(),
|
|
55
|
+
updatedAt: new Date().toISOString(),
|
|
56
|
+
provider: metadata?.provider || 'unknown',
|
|
57
|
+
model: metadata?.model || 'unknown',
|
|
58
|
+
messageCount: messages.length,
|
|
59
|
+
},
|
|
60
|
+
messages,
|
|
61
|
+
}
|
|
62
|
+
|
|
63
|
+
writeFileSync(path, JSON.stringify(session) + '\n', 'utf-8')
|
|
64
|
+
}
|
|
65
|
+
|
|
66
|
+
/**
|
|
67
|
+
* Load a saved session. Returns null if not found or unparseable.
|
|
68
|
+
*/
|
|
69
|
+
static load(name: string): StoredSession | null {
|
|
70
|
+
const path = filePath(name)
|
|
71
|
+
if (!existsSync(path)) return null
|
|
72
|
+
|
|
73
|
+
try {
|
|
74
|
+
const raw = readFileSync(path, 'utf-8')
|
|
75
|
+
const session = JSON.parse(raw) as StoredSession
|
|
76
|
+
// Validate structure
|
|
77
|
+
if (!session.metadata || !Array.isArray(session.messages)) {
|
|
78
|
+
return null
|
|
79
|
+
}
|
|
80
|
+
return session
|
|
81
|
+
} catch {
|
|
82
|
+
return null
|
|
83
|
+
}
|
|
84
|
+
}
|
|
85
|
+
|
|
86
|
+
/**
|
|
87
|
+
* List all saved sessions, most recent first.
|
|
88
|
+
*/
|
|
89
|
+
static list(): SessionMetadata[] {
|
|
90
|
+
ensureDir()
|
|
91
|
+
try {
|
|
92
|
+
const files = readdirSync(SESSIONS_DIR).filter((f) => f.endsWith('.jsonl'))
|
|
93
|
+
|
|
94
|
+
const sessions: SessionMetadata[] = []
|
|
95
|
+
for (const file of files) {
|
|
96
|
+
try {
|
|
97
|
+
const raw = readFileSync(join(SESSIONS_DIR, file), 'utf-8')
|
|
98
|
+
const session = JSON.parse(raw) as StoredSession
|
|
99
|
+
if (session.metadata) {
|
|
100
|
+
sessions.push(session.metadata)
|
|
101
|
+
}
|
|
102
|
+
} catch {
|
|
103
|
+
// Skip corrupt files
|
|
104
|
+
}
|
|
105
|
+
}
|
|
106
|
+
|
|
107
|
+
sessions.sort((a, b) => new Date(b.updatedAt).getTime() - new Date(a.updatedAt).getTime())
|
|
108
|
+
return sessions
|
|
109
|
+
} catch {
|
|
110
|
+
return []
|
|
111
|
+
}
|
|
112
|
+
}
|
|
113
|
+
|
|
114
|
+
/**
|
|
115
|
+
* Delete a saved session.
|
|
116
|
+
*/
|
|
117
|
+
static delete(name: string): boolean {
|
|
118
|
+
const path = filePath(name)
|
|
119
|
+
if (!existsSync(path)) return false
|
|
120
|
+
try {
|
|
121
|
+
unlinkSync(path)
|
|
122
|
+
return true
|
|
123
|
+
} catch {
|
|
124
|
+
return false
|
|
125
|
+
}
|
|
126
|
+
}
|
|
127
|
+
|
|
128
|
+
/**
|
|
129
|
+
* Auto-save with timestamp-based name.
|
|
130
|
+
*/
|
|
131
|
+
static autoSave(messages: Message[], metadata?: { provider?: string; model?: string }): string {
|
|
132
|
+
const name = `session-${new Date().toISOString().replace(/[:.]/g, '-').slice(0, 19)}`
|
|
133
|
+
SessionStore.save(name, messages, metadata)
|
|
134
|
+
return name
|
|
135
|
+
}
|
|
136
|
+
}
|
package/src/index.tsx
ADDED
|
@@ -0,0 +1,93 @@
|
|
|
1
|
+
import { render } from 'ink'
|
|
2
|
+
import { App } from './ui/app'
|
|
3
|
+
import { loadConfig } from './config/loader'
|
|
4
|
+
import { bootstrapProviders } from './providers/bootstrap'
|
|
5
|
+
import { InstructionsLoader } from './core/instructions'
|
|
6
|
+
import { ContextManager } from './core/context'
|
|
7
|
+
import { QueryEngine } from './core/engine'
|
|
8
|
+
import { SessionStore } from './core/session-store'
|
|
9
|
+
import { SkillsLoader } from './skills/loader'
|
|
10
|
+
import { createToolRegistry } from './tools'
|
|
11
|
+
|
|
12
|
+
interface RunOptions {
|
|
13
|
+
model?: string
|
|
14
|
+
provider?: string
|
|
15
|
+
lang?: string
|
|
16
|
+
permission?: string
|
|
17
|
+
resume?: string
|
|
18
|
+
}
|
|
19
|
+
|
|
20
|
+
export async function runApp(options: RunOptions): Promise<void> {
|
|
21
|
+
// Set terminal window title
|
|
22
|
+
process.stdout.write('\x1b]0;Mipham Code\x07')
|
|
23
|
+
|
|
24
|
+
// Load configuration
|
|
25
|
+
const config = loadConfig()
|
|
26
|
+
|
|
27
|
+
// Bootstrap providers
|
|
28
|
+
const defaultProvider = options.provider || config.defaultProvider
|
|
29
|
+
const defaultModel = options.model || config.defaultModel
|
|
30
|
+
const registry = bootstrapProviders(config.providers, defaultProvider, defaultModel)
|
|
31
|
+
|
|
32
|
+
// Load instructions
|
|
33
|
+
const instructions = new InstructionsLoader()
|
|
34
|
+
instructions.loadAll(process.cwd())
|
|
35
|
+
|
|
36
|
+
// Load skills
|
|
37
|
+
const skillsLoader = new SkillsLoader()
|
|
38
|
+
skillsLoader.loadBuiltin(process.cwd())
|
|
39
|
+
if (config.skills?.paths) {
|
|
40
|
+
skillsLoader.loadExternal(config.skills.paths)
|
|
41
|
+
}
|
|
42
|
+
|
|
43
|
+
// Initialize context — restore saved session if available
|
|
44
|
+
const context = new ContextManager({ maxTokens: 200_000, compactionThreshold: 0.9 })
|
|
45
|
+
|
|
46
|
+
if (options.resume) {
|
|
47
|
+
const saved = SessionStore.load(options.resume)
|
|
48
|
+
if (saved) {
|
|
49
|
+
for (const msg of saved.messages) {
|
|
50
|
+
context.addMessage(msg)
|
|
51
|
+
}
|
|
52
|
+
context.setSystemPrompt(instructions.buildSystemPrompt())
|
|
53
|
+
}
|
|
54
|
+
}
|
|
55
|
+
|
|
56
|
+
if (context.getMessageCount() === 0) {
|
|
57
|
+
context.setSystemPrompt(instructions.buildSystemPrompt())
|
|
58
|
+
}
|
|
59
|
+
|
|
60
|
+
// Create tool registry with all 16 tools
|
|
61
|
+
const tools = createToolRegistry()
|
|
62
|
+
|
|
63
|
+
// Create query engine
|
|
64
|
+
const engine = new QueryEngine(registry, context, tools)
|
|
65
|
+
|
|
66
|
+
// Auto-save session on exit
|
|
67
|
+
let autoSaveName: string | undefined
|
|
68
|
+
const saveAndExit = () => {
|
|
69
|
+
if (context.getMessageCount() > 0) {
|
|
70
|
+
autoSaveName = SessionStore.autoSave(context.getMessages(), {
|
|
71
|
+
provider: defaultProvider,
|
|
72
|
+
model: defaultModel,
|
|
73
|
+
})
|
|
74
|
+
}
|
|
75
|
+
process.exit(0)
|
|
76
|
+
}
|
|
77
|
+
|
|
78
|
+
process.on('SIGINT', saveAndExit)
|
|
79
|
+
process.on('SIGTERM', saveAndExit)
|
|
80
|
+
|
|
81
|
+
const { waitUntilExit } = render(
|
|
82
|
+
<App
|
|
83
|
+
engine={engine}
|
|
84
|
+
config={config}
|
|
85
|
+
initialProvider={defaultProvider}
|
|
86
|
+
initialModel={defaultModel}
|
|
87
|
+
lang={options.lang}
|
|
88
|
+
skillsLoader={skillsLoader}
|
|
89
|
+
/>,
|
|
90
|
+
)
|
|
91
|
+
await waitUntilExit()
|
|
92
|
+
saveAndExit()
|
|
93
|
+
}
|
|
@@ -0,0 +1,170 @@
|
|
|
1
|
+
import type { McpServerConfig } from '../shared/types'
|
|
2
|
+
import type {
|
|
3
|
+
ConnectionStatus,
|
|
4
|
+
ConnectionInfo,
|
|
5
|
+
ToolDefinition,
|
|
6
|
+
ToolCallResult,
|
|
7
|
+
InitializeResult,
|
|
8
|
+
} from './types'
|
|
9
|
+
import { StdioTransport } from './transport'
|
|
10
|
+
import { McpProtocol } from './protocol'
|
|
11
|
+
|
|
12
|
+
interface ActiveConnection {
|
|
13
|
+
config: McpServerConfig
|
|
14
|
+
transport: StdioTransport
|
|
15
|
+
protocol: McpProtocol
|
|
16
|
+
status: ConnectionStatus
|
|
17
|
+
tools: ToolDefinition[]
|
|
18
|
+
serverInfo?: { name: string; version: string }
|
|
19
|
+
error?: string
|
|
20
|
+
}
|
|
21
|
+
|
|
22
|
+
/**
|
|
23
|
+
* Singleton MCP client — manages connections to multiple MCP servers.
|
|
24
|
+
*
|
|
25
|
+
* Lifecycle:
|
|
26
|
+
* 1. connect(config) — spawn process, initialize, discover tools
|
|
27
|
+
* 2. callTool(server, tool, params) — execute tool on connected server
|
|
28
|
+
* 3. disconnect(name) or closeAll() — kill subprocess, clean up
|
|
29
|
+
*
|
|
30
|
+
* Backward-compatible with the previous stub McpClient API.
|
|
31
|
+
*/
|
|
32
|
+
export class McpClient {
|
|
33
|
+
private static instance: McpClient | null = null
|
|
34
|
+
private connections = new Map<string, ActiveConnection>()
|
|
35
|
+
|
|
36
|
+
/** Get or create the singleton instance. */
|
|
37
|
+
static getInstance(): McpClient {
|
|
38
|
+
if (!McpClient.instance) {
|
|
39
|
+
McpClient.instance = new McpClient()
|
|
40
|
+
}
|
|
41
|
+
return McpClient.instance
|
|
42
|
+
}
|
|
43
|
+
|
|
44
|
+
/** Reset the singleton (useful for testing). */
|
|
45
|
+
static resetInstance(): void {
|
|
46
|
+
McpClient.instance = null
|
|
47
|
+
}
|
|
48
|
+
|
|
49
|
+
async connect(config: McpServerConfig): Promise<void> {
|
|
50
|
+
// Skip if already connected
|
|
51
|
+
const existing = this.connections.get(config.name)
|
|
52
|
+
if (existing?.status === 'connected') return
|
|
53
|
+
|
|
54
|
+
const transport = new StdioTransport()
|
|
55
|
+
const protocol = new McpProtocol(transport)
|
|
56
|
+
|
|
57
|
+
const connection: ActiveConnection = {
|
|
58
|
+
config,
|
|
59
|
+
transport,
|
|
60
|
+
protocol,
|
|
61
|
+
status: 'connecting',
|
|
62
|
+
tools: [],
|
|
63
|
+
}
|
|
64
|
+
|
|
65
|
+
this.connections.set(config.name, connection)
|
|
66
|
+
|
|
67
|
+
try {
|
|
68
|
+
const initResult: InitializeResult = await protocol.initialize(
|
|
69
|
+
config.command,
|
|
70
|
+
config.args,
|
|
71
|
+
config.env,
|
|
72
|
+
)
|
|
73
|
+
|
|
74
|
+
connection.status = 'connected'
|
|
75
|
+
connection.serverInfo = initResult.serverInfo
|
|
76
|
+
|
|
77
|
+
// Discover tools
|
|
78
|
+
if (initResult.capabilities.tools) {
|
|
79
|
+
connection.tools = await protocol.listTools()
|
|
80
|
+
}
|
|
81
|
+
} catch (err) {
|
|
82
|
+
connection.status = 'error'
|
|
83
|
+
connection.error = String(err)
|
|
84
|
+
throw err
|
|
85
|
+
}
|
|
86
|
+
}
|
|
87
|
+
|
|
88
|
+
disconnect(name: string): void {
|
|
89
|
+
const conn = this.connections.get(name)
|
|
90
|
+
if (!conn) return
|
|
91
|
+
|
|
92
|
+
try {
|
|
93
|
+
conn.transport.close()
|
|
94
|
+
} catch {
|
|
95
|
+
/* best effort */
|
|
96
|
+
}
|
|
97
|
+
this.connections.delete(name)
|
|
98
|
+
}
|
|
99
|
+
|
|
100
|
+
async closeAll(): Promise<void> {
|
|
101
|
+
const names = Array.from(this.connections.keys())
|
|
102
|
+
for (const name of names) {
|
|
103
|
+
try {
|
|
104
|
+
await this.connections.get(name)?.transport.close()
|
|
105
|
+
} catch {
|
|
106
|
+
/* best effort */
|
|
107
|
+
}
|
|
108
|
+
this.connections.delete(name)
|
|
109
|
+
}
|
|
110
|
+
McpClient.instance = null
|
|
111
|
+
}
|
|
112
|
+
|
|
113
|
+
getConnection(name: string): ConnectionInfo | undefined {
|
|
114
|
+
const conn = this.connections.get(name)
|
|
115
|
+
if (!conn) return undefined
|
|
116
|
+
|
|
117
|
+
return {
|
|
118
|
+
config: {
|
|
119
|
+
name: conn.config.name,
|
|
120
|
+
command: conn.config.command,
|
|
121
|
+
args: conn.config.args,
|
|
122
|
+
},
|
|
123
|
+
status: conn.status,
|
|
124
|
+
tools: conn.tools,
|
|
125
|
+
error: conn.error,
|
|
126
|
+
serverInfo: conn.serverInfo,
|
|
127
|
+
}
|
|
128
|
+
}
|
|
129
|
+
|
|
130
|
+
listConnections(): ConnectionInfo[] {
|
|
131
|
+
return Array.from(this.connections.values()).map((conn) => ({
|
|
132
|
+
config: {
|
|
133
|
+
name: conn.config.name,
|
|
134
|
+
command: conn.config.command,
|
|
135
|
+
args: conn.config.args,
|
|
136
|
+
},
|
|
137
|
+
status: conn.status,
|
|
138
|
+
tools: conn.tools,
|
|
139
|
+
error: conn.error,
|
|
140
|
+
serverInfo: conn.serverInfo,
|
|
141
|
+
}))
|
|
142
|
+
}
|
|
143
|
+
|
|
144
|
+
getTools(name: string): ToolDefinition[] {
|
|
145
|
+
return this.connections.get(name)?.tools || []
|
|
146
|
+
}
|
|
147
|
+
|
|
148
|
+
async callTool(
|
|
149
|
+
serverName: string,
|
|
150
|
+
toolName: string,
|
|
151
|
+
params?: Record<string, unknown>,
|
|
152
|
+
): Promise<ToolCallResult> {
|
|
153
|
+
const conn = this.connections.get(serverName)
|
|
154
|
+
if (!conn || conn.status !== 'connected') {
|
|
155
|
+
return {
|
|
156
|
+
content: [{ type: 'text', text: `Error: MCP server "${serverName}" not connected` }],
|
|
157
|
+
isError: true,
|
|
158
|
+
}
|
|
159
|
+
}
|
|
160
|
+
|
|
161
|
+
try {
|
|
162
|
+
return await conn.protocol.callTool(toolName, params)
|
|
163
|
+
} catch (err) {
|
|
164
|
+
return {
|
|
165
|
+
content: [{ type: 'text', text: `MCP tool error: ${String(err)}` }],
|
|
166
|
+
isError: true,
|
|
167
|
+
}
|
|
168
|
+
}
|
|
169
|
+
}
|
|
170
|
+
}
|