@miphamai/cli 0.54.0 → 0.56.0
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/bin/mipham.ts +21 -2
- package/package.json +1 -1
- package/src/agent/agent-registry.ts +5 -2
- package/src/agent/cross-session/discovery.ts +3 -1
- package/src/agent/message-router.ts +10 -0
- package/src/commands/project.ts +4 -3
- package/src/config/loader.ts +2 -2
- package/src/core/engine.ts +4 -0
- package/src/core/instructions.ts +15 -2
- package/src/index.tsx +46 -33
- package/src/plugin/claude-plugin.ts +174 -0
- package/src/plugin/plugin-loader.ts +25 -0
- package/src/plugin/plugin-manager.ts +46 -17
- package/src/plugin/plugin-validator.ts +58 -11
- package/src/shared/atomic-write.ts +17 -0
- package/src/shared/deleted-cwd.ts +26 -0
- package/src/shared/graft-savings.ts +34 -0
- package/src/shared/graft-stats.ts +36 -0
- package/src/shared/package-info.ts +1 -1
- package/src/shared/types.ts +1 -0
- package/src/skills/loader.ts +16 -35
- package/src/skills/seam.ts +1 -1
- package/src/tools/agent/memory.ts +20 -2
- package/src/ui/app.tsx +15 -0
- package/src/ui/config-wizard.tsx +3 -2
- package/src/ui/graft-status.tsx +59 -0
- package/src/core/output-styles.ts +0 -95
package/bin/mipham.ts
CHANGED
|
@@ -87,7 +87,9 @@ async function runWorkflowCLI(): Promise<boolean> {
|
|
|
87
87
|
})
|
|
88
88
|
|
|
89
89
|
// Commander will handle --help, unknown commands, etc.
|
|
90
|
-
|
|
90
|
+
// Skip node + script + the leading `workflow` keyword (already matched by the
|
|
91
|
+
// caller); `from: 'user'` stops commander from stripping two more elements.
|
|
92
|
+
await program.parseAsync(process.argv.slice(3), { from: 'user' })
|
|
91
93
|
return true
|
|
92
94
|
}
|
|
93
95
|
|
|
@@ -185,7 +187,9 @@ async function runPluginCLI(): Promise<boolean> {
|
|
|
185
187
|
process.exit(result.success ? 0 : 1)
|
|
186
188
|
})
|
|
187
189
|
|
|
188
|
-
|
|
190
|
+
// Skip node + script + the leading `plugin` keyword (already matched by the
|
|
191
|
+
// caller); `from: 'user'` stops commander from stripping two more elements.
|
|
192
|
+
await program.parseAsync(process.argv.slice(3), { from: 'user' })
|
|
189
193
|
return true
|
|
190
194
|
}
|
|
191
195
|
|
|
@@ -1054,6 +1058,21 @@ async function runTokenCLI(): Promise<boolean> {
|
|
|
1054
1058
|
}
|
|
1055
1059
|
|
|
1056
1060
|
async function main() {
|
|
1061
|
+
// ── Deleted-cwd guard ──────────────────────────────────────────────────
|
|
1062
|
+
// `process.cwd()` throws ENOENT when the directory the process was launched
|
|
1063
|
+
// from no longer exists (e.g. a removed git worktree). Print a clear message
|
|
1064
|
+
// instead of a raw crash dump (matches Claude Code 2.1.239).
|
|
1065
|
+
const { isDeletedCwdError, deletedCwdMessage } = await import('../src/shared/deleted-cwd')
|
|
1066
|
+
try {
|
|
1067
|
+
process.cwd()
|
|
1068
|
+
} catch (err) {
|
|
1069
|
+
if (isDeletedCwdError(err)) {
|
|
1070
|
+
console.error(deletedCwdMessage())
|
|
1071
|
+
process.exit(1)
|
|
1072
|
+
}
|
|
1073
|
+
throw err
|
|
1074
|
+
}
|
|
1075
|
+
|
|
1057
1076
|
// ── Disable terminal special characters ──────────────────────────────────
|
|
1058
1077
|
// Terminal intercepts Ctrl+S (XOFF), Ctrl+T (SIGINFO/status), Ctrl+R (rprnt)
|
|
1059
1078
|
// before the app sees them. Disable these so Agent Dashboard shortcuts work.
|
package/package.json
CHANGED
|
@@ -10,9 +10,12 @@ interface FrontmatterResult {
|
|
|
10
10
|
}
|
|
11
11
|
|
|
12
12
|
function parseFrontmatter(raw: string): FrontmatterResult {
|
|
13
|
-
|
|
13
|
+
// Strip a leading UTF-8 BOM — otherwise `^---` never matches and a
|
|
14
|
+
// BOM-prefixed file is silently treated as body text (effectively ignored).
|
|
15
|
+
const src = raw.replace(/^\uFEFF/, '')
|
|
16
|
+
const match = src.match(/^---\n([\s\S]*?)\n---\n?([\s\S]*)$/)
|
|
14
17
|
if (!match) {
|
|
15
|
-
return { data: {}, content:
|
|
18
|
+
return { data: {}, content: src }
|
|
16
19
|
}
|
|
17
20
|
return {
|
|
18
21
|
data: parseYaml(match[1] || '') as Record<string, unknown>,
|
|
@@ -9,7 +9,7 @@ import {
|
|
|
9
9
|
} from 'node:fs'
|
|
10
10
|
import { join, basename } from 'node:path'
|
|
11
11
|
import { homedir, hostname } from 'node:os'
|
|
12
|
-
import type { SessionInfo } from '../../shared/types'
|
|
12
|
+
import type { SessionInfo, CrossSessionInbound } from '../../shared/types'
|
|
13
13
|
|
|
14
14
|
const MIPHAM_HOME = join(homedir(), '.mipham')
|
|
15
15
|
const ACTIVE_SESSIONS_DIR = join(MIPHAM_HOME, '.active-sessions')
|
|
@@ -97,6 +97,7 @@ export function createSessionInfo(
|
|
|
97
97
|
cwd?: string,
|
|
98
98
|
provider?: string,
|
|
99
99
|
model?: string,
|
|
100
|
+
crossSessionInbound?: CrossSessionInbound,
|
|
100
101
|
): SessionInfo {
|
|
101
102
|
return {
|
|
102
103
|
id: sessionId,
|
|
@@ -107,6 +108,7 @@ export function createSessionInfo(
|
|
|
107
108
|
cwd,
|
|
108
109
|
provider,
|
|
109
110
|
model,
|
|
111
|
+
crossSessionInbound,
|
|
110
112
|
}
|
|
111
113
|
}
|
|
112
114
|
|
|
@@ -90,6 +90,16 @@ export class MessageRouter {
|
|
|
90
90
|
}
|
|
91
91
|
const targetSession = resolution.session!
|
|
92
92
|
|
|
93
|
+
// Recipient refuses inbound messages — report "refused" instead of a silent
|
|
94
|
+
// success so the sender knows the message was not delivered.
|
|
95
|
+
if (targetSession.crossSessionInbound === 'deny') {
|
|
96
|
+
return {
|
|
97
|
+
success: false,
|
|
98
|
+
routedTo: 'inbox',
|
|
99
|
+
error: `Session "${targetSession.name}" refuses inbound messages.`,
|
|
100
|
+
}
|
|
101
|
+
}
|
|
102
|
+
|
|
93
103
|
// Get sender info — resolve the human-readable name when the sender is a
|
|
94
104
|
// registered session, else fall back to the raw id (e.g. non-session senders).
|
|
95
105
|
const senderName = discoverSessions().find((s) => s.id === from)?.name ?? from
|
package/src/commands/project.ts
CHANGED
|
@@ -7,6 +7,7 @@
|
|
|
7
7
|
*/
|
|
8
8
|
import type { CommandHandler, CommandContext, CommandResult } from '../ui/commands.js'
|
|
9
9
|
import { getWorkspaceTrust } from '../core/workspace-trust'
|
|
10
|
+
import { atomicWriteFileSync } from '../shared/atomic-write'
|
|
10
11
|
|
|
11
12
|
export {
|
|
12
13
|
initCmd,
|
|
@@ -20,7 +21,7 @@ export {
|
|
|
20
21
|
}
|
|
21
22
|
|
|
22
23
|
const initCmd: CommandHandler = async (ctx) => {
|
|
23
|
-
const { existsSync, mkdirSync
|
|
24
|
+
const { existsSync, mkdirSync } = await import('node:fs')
|
|
24
25
|
const { join } = await import('node:path')
|
|
25
26
|
const { homedir } = await import('node:os')
|
|
26
27
|
|
|
@@ -78,7 +79,7 @@ providers:
|
|
|
78
79
|
${providerYaml}
|
|
79
80
|
`
|
|
80
81
|
|
|
81
|
-
|
|
82
|
+
atomicWriteFileSync(userConfigPath, configContent)
|
|
82
83
|
return {
|
|
83
84
|
content: `✅ Mipham Code initialized!
|
|
84
85
|
|
|
@@ -434,7 +435,7 @@ permission: ask
|
|
|
434
435
|
providers:
|
|
435
436
|
${providerYaml}
|
|
436
437
|
`
|
|
437
|
-
|
|
438
|
+
atomicWriteFileSync(configPath, defaultConfig)
|
|
438
439
|
created.push('.mipham/config.yml')
|
|
439
440
|
} else {
|
|
440
441
|
skipped.push('.mipham/config.yml (already exists)')
|
package/src/config/loader.ts
CHANGED
|
@@ -6,11 +6,11 @@ import {
|
|
|
6
6
|
readdirSync,
|
|
7
7
|
unlinkSync,
|
|
8
8
|
chmodSync,
|
|
9
|
-
writeFileSync,
|
|
10
9
|
} from 'node:fs'
|
|
11
10
|
import { join } from 'node:path'
|
|
12
11
|
import { homedir } from 'node:os'
|
|
13
12
|
import { parse as parseYaml, stringify as stringifyYaml } from 'yaml'
|
|
13
|
+
import { atomicWriteFileSync } from '../shared/atomic-write'
|
|
14
14
|
import type {
|
|
15
15
|
MiphamConfig,
|
|
16
16
|
ProviderConfig,
|
|
@@ -533,7 +533,7 @@ export function saveProviderApiKey(providerId: string, apiKey: string): boolean
|
|
|
533
533
|
doc.providers = providers
|
|
534
534
|
|
|
535
535
|
// Write back
|
|
536
|
-
|
|
536
|
+
atomicWriteFileSync(configPath, stringifyYaml(doc), { mode: 0o600 })
|
|
537
537
|
return true
|
|
538
538
|
} catch (err: unknown) {
|
|
539
539
|
const msg = err instanceof Error ? err.message : String(err)
|
package/src/core/engine.ts
CHANGED
|
@@ -38,6 +38,7 @@ import { UsageTracker } from './usage-tracker'
|
|
|
38
38
|
import { getMetrics } from './metrics'
|
|
39
39
|
import { buildRequest, sendInferenceCheck, isInferenceHookEnabled } from './inference-hook'
|
|
40
40
|
import { getFileInboxTransport } from '../agent/cross-session/file-inbox'
|
|
41
|
+
import { accumulateGraftSavings } from '../shared/graft-savings'
|
|
41
42
|
import { getMessageBus, type AgentMessage } from '../agent/message-bus'
|
|
42
43
|
import { createT } from '../i18n-core/t'
|
|
43
44
|
import enUS from '../i18n-core/locales/en-US.json'
|
|
@@ -1127,6 +1128,9 @@ export class QueryEngine {
|
|
|
1127
1128
|
tracker.persist()
|
|
1128
1129
|
}
|
|
1129
1130
|
|
|
1131
|
+
// Accumulate graft token savings from "[graft] tokens saved ≈ N" footers
|
|
1132
|
+
accumulateGraftSavings(result.content)
|
|
1133
|
+
|
|
1130
1134
|
return result
|
|
1131
1135
|
} catch (err) {
|
|
1132
1136
|
// P1-3: Trigger PostToolUseFailure hook on tool execution errors
|
package/src/core/instructions.ts
CHANGED
|
@@ -10,9 +10,12 @@ interface FrontmatterResult {
|
|
|
10
10
|
}
|
|
11
11
|
|
|
12
12
|
function parseFrontmatter(raw: string): FrontmatterResult {
|
|
13
|
-
|
|
13
|
+
// Strip a leading UTF-8 BOM — otherwise `^---` never matches and a
|
|
14
|
+
// BOM-prefixed file is silently treated as body text (effectively ignored).
|
|
15
|
+
const src = raw.replace(/^\uFEFF/, '')
|
|
16
|
+
const match = src.match(/^---\n([\s\S]*?)\n---\n([\s\S]*)$/)
|
|
14
17
|
if (!match) {
|
|
15
|
-
return { data: {}, content:
|
|
18
|
+
return { data: {}, content: src }
|
|
16
19
|
}
|
|
17
20
|
return {
|
|
18
21
|
data: parseYaml(match[1] || '') as Record<string, unknown>,
|
|
@@ -101,6 +104,16 @@ export class InstructionsLoader {
|
|
|
101
104
|
parts.push(this.buildPermissionContext(permissionMode))
|
|
102
105
|
}
|
|
103
106
|
|
|
107
|
+
// 开场克制:寒暄只回一句短问候,不上能力清单(避免把「你好」当「你是谁」处理)
|
|
108
|
+
parts.push(`## Greeting Restraint
|
|
109
|
+
|
|
110
|
+
When the user's message is only a greeting or small talk ("hello", "hi",
|
|
111
|
+
"你好", "您好", "在吗", "早上好"), reply with ONE brief, warm line —
|
|
112
|
+
e.g. "你好,我是 Mipham,有什么可以帮你的?" Do NOT introduce yourself,
|
|
113
|
+
list your capabilities/tools/MCP servers, or enumerate what you can do.
|
|
114
|
+
Only describe your capabilities when the user explicitly asks ("你是谁",
|
|
115
|
+
"你能做什么", "what can you do", "introduce yourself").`)
|
|
116
|
+
|
|
104
117
|
// Inject critical thinking self-check layer (for analysis/comparison tasks)
|
|
105
118
|
parts.push(`## Critical Thinking Self-Check
|
|
106
119
|
|
package/src/index.tsx
CHANGED
|
@@ -28,7 +28,7 @@ import { QueryEngine } from './core/engine'
|
|
|
28
28
|
import { ExperienceRuleEngine } from './core/rule-engine.js'
|
|
29
29
|
import { SessionLog } from './core/session-log'
|
|
30
30
|
import { SessionStore } from './core/session-store'
|
|
31
|
-
import type { PermissionLevel, MiphamConfig } from './shared/types'
|
|
31
|
+
import type { PermissionLevel, MiphamConfig, McpServerConfig } from './shared/types'
|
|
32
32
|
import { SkillsLoader } from './skills/loader'
|
|
33
33
|
import { PluginManager } from './plugin/plugin-manager'
|
|
34
34
|
import { loadPlugins } from './plugin/plugin-loader'
|
|
@@ -194,6 +194,38 @@ function SetupGate(props: SetupGateProps) {
|
|
|
194
194
|
})
|
|
195
195
|
}
|
|
196
196
|
|
|
197
|
+
/**
|
|
198
|
+
* Connect all MCP servers in parallel and register their tools into the shared
|
|
199
|
+
* registry. Runs lazily in the background so a slow or dead server (e.g. a 15s
|
|
200
|
+
* connect timeout) never blocks first paint. Failures are non-fatal.
|
|
201
|
+
*/
|
|
202
|
+
async function connectMcpServers(
|
|
203
|
+
mcpServers: McpServerConfig[],
|
|
204
|
+
tools: ReturnType<typeof createToolRegistry>,
|
|
205
|
+
): Promise<void> {
|
|
206
|
+
if (mcpServers.length === 0) return
|
|
207
|
+
const mcp = McpClient.getInstance()
|
|
208
|
+
// Wire runtime tool-list changes before connect so mid-connect updates land.
|
|
209
|
+
syncMcpToolsOnChange(mcp, tools)
|
|
210
|
+
const results = await Promise.allSettled(
|
|
211
|
+
mcpServers.map(async (server) => {
|
|
212
|
+
await mcp.connect(server)
|
|
213
|
+
const count = registerMcpServerTools(server.name, tools)
|
|
214
|
+
if (count > 0) {
|
|
215
|
+
process.stderr.write(`[mcp] "${server.name}": registered ${count} tools\n`)
|
|
216
|
+
}
|
|
217
|
+
}),
|
|
218
|
+
)
|
|
219
|
+
for (let i = 0; i < results.length; i++) {
|
|
220
|
+
const result = results[i]!
|
|
221
|
+
if (result.status === 'rejected') {
|
|
222
|
+
process.stderr.write(
|
|
223
|
+
`[mcp] Failed to connect "${mcpServers[i]!.name}": ${String(result.reason)}\n`,
|
|
224
|
+
)
|
|
225
|
+
}
|
|
226
|
+
}
|
|
227
|
+
}
|
|
228
|
+
|
|
197
229
|
export async function runApp(options: RunOptions): Promise<void> {
|
|
198
230
|
// Metrics: count CLI invocation + active session
|
|
199
231
|
getMetrics().cliInvocations.inc()
|
|
@@ -364,7 +396,7 @@ export async function runApp(options: RunOptions): Promise<void> {
|
|
|
364
396
|
if (context.getMessageCount() === 0) {
|
|
365
397
|
const basePrompt = instructions.buildSystemPrompt(config.permission as string)
|
|
366
398
|
const memoryReminder = loadSessionMemories(basePrompt)
|
|
367
|
-
const skillsReminder = skillsLoader.buildSystemReminder(
|
|
399
|
+
const skillsReminder = skillsLoader.buildSystemReminder()
|
|
368
400
|
|
|
369
401
|
// Inject previous session summary for AI continuity
|
|
370
402
|
let prompt = basePrompt
|
|
@@ -455,33 +487,10 @@ export async function runApp(options: RunOptions): Promise<void> {
|
|
|
455
487
|
mountSkills(vajraContext, skillsLoader)
|
|
456
488
|
mountLlm(vajraContext, registry)
|
|
457
489
|
|
|
458
|
-
// Connect MCP servers
|
|
459
|
-
//
|
|
460
|
-
|
|
461
|
-
|
|
462
|
-
const mcp = McpClient.getInstance()
|
|
463
|
-
const results = await Promise.allSettled(
|
|
464
|
-
mcpServers.map(async (server) => {
|
|
465
|
-
await mcp.connect(server)
|
|
466
|
-
const count = registerMcpServerTools(server.name, tools)
|
|
467
|
-
if (count > 0) {
|
|
468
|
-
process.stderr.write(`[mcp] "${server.name}": registered ${count} tools\n`)
|
|
469
|
-
}
|
|
470
|
-
}),
|
|
471
|
-
)
|
|
472
|
-
// Log failures (non-fatal — app starts without that server's tools)
|
|
473
|
-
for (let i = 0; i < results.length; i++) {
|
|
474
|
-
const result = results[i]!
|
|
475
|
-
if (result.status === 'rejected') {
|
|
476
|
-
process.stderr.write(
|
|
477
|
-
`[mcp] Failed to connect "${mcpServers[i]!.name}": ${String(result.reason)}\n`,
|
|
478
|
-
)
|
|
479
|
-
}
|
|
480
|
-
}
|
|
481
|
-
}
|
|
482
|
-
|
|
483
|
-
// Wire runtime MCP tool changes (tools/list_changed) into the central registry.
|
|
484
|
-
syncMcpToolsOnChange(McpClient.getInstance(), tools)
|
|
490
|
+
// Connect MCP servers lazily in the background: the UI renders immediately,
|
|
491
|
+
// and each server's tools register into the shared registry as it connects.
|
|
492
|
+
// A dead server (e.g. a 15s connect timeout) no longer blocks startup.
|
|
493
|
+
void connectMcpServers(config.skills?.mcpServers ?? [], tools)
|
|
485
494
|
|
|
486
495
|
// Initialize hook engine — register skill-defined hooks
|
|
487
496
|
const hookEngine = new HookEngine()
|
|
@@ -549,12 +558,20 @@ export async function runApp(options: RunOptions): Promise<void> {
|
|
|
549
558
|
// so `@mipham-code` can address this session; uniqueness is enforced against
|
|
550
559
|
// other live sessions (suffix -2, -3, …). The id stays a stable `session-<ts>`.
|
|
551
560
|
const defaultName = basename(process.cwd()) || 'session'
|
|
561
|
+
|
|
562
|
+
// Load cross-session config first so the registered SessionInfo carries this
|
|
563
|
+
// session's inbound policy — senders discover it and report "refused" instead
|
|
564
|
+
// of a silent drop when policy is 'deny'.
|
|
565
|
+
const crossSessionConfig = loadCrossSessionConfig(process.cwd())
|
|
566
|
+
engine.setCrossSessionConfig(crossSessionConfig)
|
|
567
|
+
|
|
552
568
|
const sessionInfo = createSessionInfo(
|
|
553
569
|
sessionName,
|
|
554
570
|
ensureUniqueSessionName(defaultName, discoverSessions()),
|
|
555
571
|
process.cwd(),
|
|
556
572
|
defaultProvider,
|
|
557
573
|
defaultModel,
|
|
574
|
+
crossSessionConfig.crossSessionInbound,
|
|
558
575
|
)
|
|
559
576
|
registerActiveSession(sessionInfo)
|
|
560
577
|
|
|
@@ -568,10 +585,6 @@ export async function runApp(options: RunOptions): Promise<void> {
|
|
|
568
585
|
heartbeatInterval.unref()
|
|
569
586
|
}
|
|
570
587
|
|
|
571
|
-
// Load cross-session config and wire into the engine's inbound policy
|
|
572
|
-
const crossSessionConfig = loadCrossSessionConfig(process.cwd())
|
|
573
|
-
engine.setCrossSessionConfig(crossSessionConfig)
|
|
574
|
-
|
|
575
588
|
// P2-1: Trigger SessionStart hooks after full initialization
|
|
576
589
|
hookEngine.executeSessionStart(sessionName).catch(() => {
|
|
577
590
|
// Hook failures never block session startup
|
|
@@ -0,0 +1,174 @@
|
|
|
1
|
+
import { readFileSync, existsSync, readdirSync, statSync } from 'node:fs'
|
|
2
|
+
import { join } from 'node:path'
|
|
3
|
+
import type { SkillsLoader } from '../skills/loader'
|
|
4
|
+
import type { AgentRegistry } from '../agent/agent-registry'
|
|
5
|
+
import type { McpClient } from '../mcp/client'
|
|
6
|
+
import type { McpServerConfig, ToolDefinition } from '../shared/types'
|
|
7
|
+
import { registerMcpServerTools } from '../mcp/registry'
|
|
8
|
+
|
|
9
|
+
/**
|
|
10
|
+
* Claude marketplace plugin adapter — loads the portable content of a Claude
|
|
11
|
+
* plugin (`.claude-plugin/plugin.json` + skills/agents/MCP) into Mipham Code's
|
|
12
|
+
* own subsystems. Commands (`commands/*.md`) and hooks (`hooks/hooks.json`) are
|
|
13
|
+
* not mapped here: Mipham Code has no file-based slash-command system, and the
|
|
14
|
+
* hook matcher semantics differ.
|
|
15
|
+
*/
|
|
16
|
+
|
|
17
|
+
interface ClaudeManifest {
|
|
18
|
+
name: string
|
|
19
|
+
version?: string
|
|
20
|
+
mcpServers?: Record<string, unknown> | string
|
|
21
|
+
[key: string]: unknown
|
|
22
|
+
}
|
|
23
|
+
|
|
24
|
+
/** Read the Claude manifest at `<dir>/.claude-plugin/plugin.json`. */
|
|
25
|
+
export function readClaudeManifest(dir: string): ClaudeManifest | null {
|
|
26
|
+
const path = join(dir, '.claude-plugin', 'plugin.json')
|
|
27
|
+
if (!existsSync(path)) return null
|
|
28
|
+
try {
|
|
29
|
+
return JSON.parse(readFileSync(path, 'utf-8')) as ClaudeManifest
|
|
30
|
+
} catch {
|
|
31
|
+
return null
|
|
32
|
+
}
|
|
33
|
+
}
|
|
34
|
+
|
|
35
|
+
/** Expand `${CLAUDE_PLUGIN_ROOT}` (Claude's plugin-root placeholder). */
|
|
36
|
+
function expandPluginRoot(value: string, pluginRoot: string): string {
|
|
37
|
+
return value.replace(/\$\{CLAUDE_PLUGIN_ROOT\}/g, pluginRoot)
|
|
38
|
+
}
|
|
39
|
+
|
|
40
|
+
/** Load Claude skills — `skills/<name>/SKILL.md` (no leading dot on SKILL.md). */
|
|
41
|
+
function loadClaudeSkills(dir: string, skillsLoader: SkillsLoader): void {
|
|
42
|
+
const skillsDir = join(dir, 'skills')
|
|
43
|
+
if (!existsSync(skillsDir)) return
|
|
44
|
+
let entries: string[] = []
|
|
45
|
+
try {
|
|
46
|
+
entries = readdirSync(skillsDir)
|
|
47
|
+
} catch {
|
|
48
|
+
return
|
|
49
|
+
}
|
|
50
|
+
for (const entry of entries) {
|
|
51
|
+
let skillFile: string | null = null
|
|
52
|
+
try {
|
|
53
|
+
const full = join(skillsDir, entry)
|
|
54
|
+
if (statSync(full).isDirectory()) {
|
|
55
|
+
const candidate = join(full, 'SKILL.md')
|
|
56
|
+
if (existsSync(candidate)) skillFile = candidate
|
|
57
|
+
} else if (entry === 'SKILL.md') {
|
|
58
|
+
skillFile = full
|
|
59
|
+
}
|
|
60
|
+
} catch {
|
|
61
|
+
continue
|
|
62
|
+
}
|
|
63
|
+
if (skillFile) skillsLoader.loadSkillFile(skillFile, 'standard')
|
|
64
|
+
}
|
|
65
|
+
}
|
|
66
|
+
|
|
67
|
+
/** Map a Claude MCP server entry to a Mipham `McpServerConfig`. */
|
|
68
|
+
function toMcpServerConfig(name: string, raw: Record<string, unknown>): McpServerConfig | null {
|
|
69
|
+
const command = typeof raw.command === 'string' ? raw.command : undefined
|
|
70
|
+
const url = typeof raw.url === 'string' ? raw.url : undefined
|
|
71
|
+
if (!command && !url) return null
|
|
72
|
+
|
|
73
|
+
const cfg: McpServerConfig = { name }
|
|
74
|
+
if (command) {
|
|
75
|
+
cfg.command = command
|
|
76
|
+
cfg.args = Array.isArray(raw.args) ? raw.args.map(String) : undefined
|
|
77
|
+
} else {
|
|
78
|
+
cfg.url = url
|
|
79
|
+
}
|
|
80
|
+
if (raw.env && typeof raw.env === 'object') {
|
|
81
|
+
cfg.env = raw.env as Record<string, string>
|
|
82
|
+
}
|
|
83
|
+
if (raw.headers && typeof raw.headers === 'object') {
|
|
84
|
+
cfg.headers = raw.headers as Record<string, string>
|
|
85
|
+
}
|
|
86
|
+
return cfg
|
|
87
|
+
}
|
|
88
|
+
|
|
89
|
+
/** Load Claude MCP servers (`.mcp.json` + inline `mcpServers`) into the client. */
|
|
90
|
+
function loadClaudeMcp(
|
|
91
|
+
dir: string,
|
|
92
|
+
manifest: ClaudeManifest,
|
|
93
|
+
mcpClient: Pick<McpClient, 'connect'>,
|
|
94
|
+
toolsMap: Map<string, ToolDefinition>,
|
|
95
|
+
): string[] {
|
|
96
|
+
const servers: McpServerConfig[] = []
|
|
97
|
+
|
|
98
|
+
const collect = (obj: unknown): void => {
|
|
99
|
+
if (!obj || typeof obj !== 'object') return
|
|
100
|
+
for (const [name, raw] of Object.entries(obj as Record<string, Record<string, unknown>>)) {
|
|
101
|
+
const cfg = toMcpServerConfig(name, raw)
|
|
102
|
+
if (cfg) servers.push(cfg)
|
|
103
|
+
}
|
|
104
|
+
}
|
|
105
|
+
|
|
106
|
+
// 1. `.mcp.json` at the plugin root
|
|
107
|
+
const mcpJsonPath = join(dir, '.mcp.json')
|
|
108
|
+
if (existsSync(mcpJsonPath)) {
|
|
109
|
+
try {
|
|
110
|
+
collect(JSON.parse(readFileSync(mcpJsonPath, 'utf-8')).mcpServers)
|
|
111
|
+
} catch {
|
|
112
|
+
/* skip unparseable .mcp.json */
|
|
113
|
+
}
|
|
114
|
+
}
|
|
115
|
+
// 2. Inline `mcpServers` field in the manifest
|
|
116
|
+
collect(manifest.mcpServers)
|
|
117
|
+
|
|
118
|
+
for (const cfg of servers) {
|
|
119
|
+
if (cfg.command) cfg.command = expandPluginRoot(cfg.command, dir)
|
|
120
|
+
if (cfg.args) cfg.args = cfg.args.map((a) => expandPluginRoot(a, dir))
|
|
121
|
+
if (cfg.env) {
|
|
122
|
+
for (const key of Object.keys(cfg.env)) cfg.env[key] = expandPluginRoot(cfg.env[key]!, dir)
|
|
123
|
+
}
|
|
124
|
+
|
|
125
|
+
mcpClient
|
|
126
|
+
.connect(cfg)
|
|
127
|
+
.then(() => {
|
|
128
|
+
const count = registerMcpServerTools(cfg.name, toolsMap)
|
|
129
|
+
if (count > 0) {
|
|
130
|
+
process.stderr.write(
|
|
131
|
+
`[plugin] "${manifest.name}": registered ${count} MCP tools from "${cfg.name}"\n`,
|
|
132
|
+
)
|
|
133
|
+
}
|
|
134
|
+
})
|
|
135
|
+
.catch((err: unknown) => {
|
|
136
|
+
process.stderr.write(
|
|
137
|
+
`[plugin] Failed to connect MCP "${cfg.name}" from "${manifest.name}": ${String(err)}\n`,
|
|
138
|
+
)
|
|
139
|
+
})
|
|
140
|
+
}
|
|
141
|
+
|
|
142
|
+
return servers.map((s) => s.name)
|
|
143
|
+
}
|
|
144
|
+
|
|
145
|
+
/** Load a Claude plugin's portable content (skills + agents + MCP) into Mipham Code.
|
|
146
|
+
* Returns the names of the connected MCP servers so the caller can wire cleanup. */
|
|
147
|
+
export function loadClaudePlugin(
|
|
148
|
+
dir: string,
|
|
149
|
+
deps: {
|
|
150
|
+
skillsLoader: SkillsLoader
|
|
151
|
+
agentRegistry: AgentRegistry
|
|
152
|
+
mcpClient: Pick<McpClient, 'connect'>
|
|
153
|
+
toolsMap: Map<string, ToolDefinition>
|
|
154
|
+
},
|
|
155
|
+
): string[] {
|
|
156
|
+
const manifest = readClaudeManifest(dir)
|
|
157
|
+
if (!manifest) return []
|
|
158
|
+
|
|
159
|
+
loadClaudeSkills(dir, deps.skillsLoader)
|
|
160
|
+
|
|
161
|
+
// Claude agents use `.md` + frontmatter; largely compatible with Mipham's AgentDefinition.
|
|
162
|
+
const agentsDir = join(dir, 'agents')
|
|
163
|
+
if (existsSync(agentsDir)) {
|
|
164
|
+
try {
|
|
165
|
+
deps.agentRegistry.loadDirectory(agentsDir, 'user')
|
|
166
|
+
} catch (err) {
|
|
167
|
+
process.stderr.write(
|
|
168
|
+
`[plugin] Failed to load agents from "${manifest.name}": ${String(err)}\n`,
|
|
169
|
+
)
|
|
170
|
+
}
|
|
171
|
+
}
|
|
172
|
+
|
|
173
|
+
return loadClaudeMcp(dir, manifest, deps.mcpClient, deps.toolsMap)
|
|
174
|
+
}
|
|
@@ -7,6 +7,8 @@ import type { HookEngine } from '../core/hooks'
|
|
|
7
7
|
import type { McpClient } from '../mcp/client'
|
|
8
8
|
import { registerMcpServerTools } from '../mcp/registry'
|
|
9
9
|
import { executeHook } from '../core/hooks-executor'
|
|
10
|
+
import { detectPluginFormat } from './plugin-validator'
|
|
11
|
+
import { loadClaudePlugin } from './claude-plugin'
|
|
10
12
|
import type { McpServerConfig, ToolDefinition, HookConfig, HookEvent } from '../shared/types'
|
|
11
13
|
|
|
12
14
|
/**
|
|
@@ -24,6 +26,29 @@ export function loadPlugins(
|
|
|
24
26
|
toolsMap: Map<string, ToolDefinition>,
|
|
25
27
|
): void {
|
|
26
28
|
for (const plugin of pluginManager.getEnabled()) {
|
|
29
|
+
// Claude marketplace plugins: load their portable content via the adapter.
|
|
30
|
+
if (detectPluginFormat(plugin.path) === 'claude') {
|
|
31
|
+
const claudeMcpServers = loadClaudePlugin(plugin.path, {
|
|
32
|
+
skillsLoader,
|
|
33
|
+
agentRegistry,
|
|
34
|
+
mcpClient,
|
|
35
|
+
toolsMap,
|
|
36
|
+
})
|
|
37
|
+
pluginManager.onRemove(plugin.name, () => {
|
|
38
|
+
for (const serverName of claudeMcpServers) {
|
|
39
|
+
try {
|
|
40
|
+
const toolNames = mcpClient.disconnect(serverName)
|
|
41
|
+
for (const toolName of toolNames) {
|
|
42
|
+
toolsMap.delete(`mcp__${serverName}__${toolName}`)
|
|
43
|
+
}
|
|
44
|
+
} catch {
|
|
45
|
+
/* best effort */
|
|
46
|
+
}
|
|
47
|
+
}
|
|
48
|
+
})
|
|
49
|
+
continue
|
|
50
|
+
}
|
|
51
|
+
|
|
27
52
|
const mcpServers: string[] = []
|
|
28
53
|
const hookEvents: HookEvent[] = []
|
|
29
54
|
|
|
@@ -1,4 +1,12 @@
|
|
|
1
|
-
import {
|
|
1
|
+
import {
|
|
2
|
+
mkdirSync,
|
|
3
|
+
existsSync,
|
|
4
|
+
writeFileSync,
|
|
5
|
+
readFileSync,
|
|
6
|
+
readdirSync,
|
|
7
|
+
rmSync,
|
|
8
|
+
renameSync,
|
|
9
|
+
} from 'node:fs'
|
|
2
10
|
import { join } from 'node:path'
|
|
3
11
|
import { homedir } from 'node:os'
|
|
4
12
|
import { execSync } from 'node:child_process'
|
|
@@ -48,7 +56,7 @@ export class PluginManager {
|
|
|
48
56
|
const similarWarning = this.findSimilarWarning(validation.manifest.name)
|
|
49
57
|
this.plugins.push({
|
|
50
58
|
name: validation.manifest.name,
|
|
51
|
-
version: validation.manifest.version,
|
|
59
|
+
version: validation.manifest.version || '0.0.0',
|
|
52
60
|
path: destDir,
|
|
53
61
|
enabled: true,
|
|
54
62
|
installedAt: new Date().toISOString(),
|
|
@@ -78,44 +86,65 @@ export class PluginManager {
|
|
|
78
86
|
return { success: false, message: `Invalid package name: "${packageName}"` }
|
|
79
87
|
}
|
|
80
88
|
|
|
81
|
-
//
|
|
82
|
-
|
|
89
|
+
// Staging dir name derived from the npm package name (scope stripped). The
|
|
90
|
+
// final directory is renamed to the manifest `name` once validated — the two
|
|
91
|
+
// can differ (e.g. a scoped package whose manifest declares a shorter name).
|
|
92
|
+
const stagingName = packageName.replace(/^@.+\//, '').replace(/^mipham-plugin-/, '')
|
|
83
93
|
|
|
84
|
-
const
|
|
85
|
-
if (existsSync(
|
|
94
|
+
const stagingDir = join(this.pluginDir, stagingName)
|
|
95
|
+
if (existsSync(stagingDir)) {
|
|
86
96
|
return {
|
|
87
97
|
success: false,
|
|
88
|
-
message: `Plugin "${
|
|
98
|
+
message: `Plugin "${stagingName}" is already installed`,
|
|
89
99
|
}
|
|
90
100
|
}
|
|
91
101
|
|
|
92
102
|
try {
|
|
93
103
|
// Install the npm package into the plugins directory
|
|
94
|
-
mkdirSync(
|
|
104
|
+
mkdirSync(stagingDir, { recursive: true })
|
|
95
105
|
|
|
96
106
|
// Create a minimal package.json so npm install works in a subdirectory
|
|
97
107
|
writeFileSync(
|
|
98
|
-
join(
|
|
108
|
+
join(stagingDir, 'package.json'),
|
|
99
109
|
JSON.stringify({ private: true }, null, 2),
|
|
100
110
|
'utf-8',
|
|
101
111
|
)
|
|
102
112
|
|
|
103
|
-
execSync(`npm install ${packageName} --prefix "${
|
|
113
|
+
execSync(`npm install ${packageName} --prefix "${stagingDir}" --no-save`, {
|
|
104
114
|
encoding: 'utf-8',
|
|
105
115
|
stdio: 'pipe',
|
|
106
116
|
timeout: 60_000,
|
|
107
117
|
})
|
|
108
118
|
|
|
109
|
-
//
|
|
110
|
-
|
|
119
|
+
// npm installs the package into <stagingDir>/node_modules/<packageName>/ —
|
|
120
|
+
// validate there, then flatten it to the plugin dir root so its layout
|
|
121
|
+
// matches `install(sourcePath)`.
|
|
122
|
+
const pkgDir = join(stagingDir, 'node_modules', packageName)
|
|
123
|
+
const validation = validatePlugin(pkgDir)
|
|
111
124
|
if (!validation.valid) {
|
|
112
125
|
// Clean up on invalid plugin
|
|
113
|
-
rmSync(
|
|
126
|
+
rmSync(stagingDir, { recursive: true, force: true })
|
|
114
127
|
return { success: false, message: validation.errors.join('; ') }
|
|
115
128
|
}
|
|
116
129
|
|
|
130
|
+
this.copyDir(pkgDir, stagingDir)
|
|
131
|
+
rmSync(join(stagingDir, 'node_modules'), { recursive: true, force: true })
|
|
132
|
+
|
|
133
|
+
// Use the manifest `name` for the final directory + record + message so
|
|
134
|
+
// they all agree (npm package name is only the staging key).
|
|
135
|
+
const manifestName = validation.manifest?.name || stagingName
|
|
136
|
+
let destDir = stagingDir
|
|
137
|
+
if (manifestName !== stagingName) {
|
|
138
|
+
destDir = join(this.pluginDir, manifestName)
|
|
139
|
+
if (existsSync(destDir)) {
|
|
140
|
+
rmSync(stagingDir, { recursive: true, force: true })
|
|
141
|
+
return { success: false, message: `Plugin "${manifestName}" is already installed` }
|
|
142
|
+
}
|
|
143
|
+
renameSync(stagingDir, destDir)
|
|
144
|
+
}
|
|
145
|
+
|
|
117
146
|
this.plugins.push({
|
|
118
|
-
name:
|
|
147
|
+
name: manifestName,
|
|
119
148
|
version: validation.manifest?.version || '0.0.0',
|
|
120
149
|
path: destDir,
|
|
121
150
|
enabled: true,
|
|
@@ -123,18 +152,18 @@ export class PluginManager {
|
|
|
123
152
|
})
|
|
124
153
|
|
|
125
154
|
this.saveState()
|
|
126
|
-
const similarWarning = this.findSimilarWarning(
|
|
155
|
+
const similarWarning = this.findSimilarWarning(manifestName)
|
|
127
156
|
return {
|
|
128
157
|
success: true,
|
|
129
158
|
message:
|
|
130
|
-
`Plugin "${
|
|
159
|
+
`Plugin "${manifestName}" installed from npm` +
|
|
131
160
|
(similarWarning ? `\n⚠ ${similarWarning}` : ''),
|
|
132
161
|
}
|
|
133
162
|
} catch (err: unknown) {
|
|
134
163
|
const msg = err instanceof Error ? err.message : String(err)
|
|
135
164
|
// Clean up on failure
|
|
136
165
|
try {
|
|
137
|
-
rmSync(
|
|
166
|
+
rmSync(stagingDir, { recursive: true, force: true })
|
|
138
167
|
} catch {
|
|
139
168
|
/* ok */
|
|
140
169
|
}
|
|
@@ -1,32 +1,67 @@
|
|
|
1
|
-
import { readFileSync } from 'node:fs'
|
|
1
|
+
import { readFileSync, existsSync } from 'node:fs'
|
|
2
2
|
import { join } from 'node:path'
|
|
3
3
|
|
|
4
4
|
export interface PluginManifest {
|
|
5
5
|
name: string
|
|
6
|
-
version
|
|
6
|
+
version?: string
|
|
7
7
|
miphamVersion?: string
|
|
8
8
|
hooks?: Record<string, unknown>
|
|
9
|
+
/** Claude manifest fields (commands/agents/skills/mcpServers/…) — read by the Claude loader. */
|
|
10
|
+
[key: string]: unknown
|
|
9
11
|
}
|
|
10
12
|
|
|
11
|
-
|
|
13
|
+
/** Which manifest convention a plugin uses. */
|
|
14
|
+
export type PluginFormat = 'mipham' | 'claude'
|
|
15
|
+
|
|
16
|
+
export interface PluginValidation {
|
|
12
17
|
valid: boolean
|
|
13
18
|
errors: string[]
|
|
14
19
|
manifest?: PluginManifest
|
|
15
|
-
|
|
16
|
-
|
|
20
|
+
format: PluginFormat
|
|
21
|
+
/** Resolved manifest path (empty when no manifest found). */
|
|
22
|
+
manifestPath: string
|
|
23
|
+
}
|
|
24
|
+
|
|
25
|
+
/**
|
|
26
|
+
* Resolve a plugin's manifest. Mipham plugins use `plugin.json` at the plugin
|
|
27
|
+
* root; Claude marketplace plugins use `.claude-plugin/plugin.json`.
|
|
28
|
+
*/
|
|
29
|
+
function resolveManifestPath(dir: string): { path: string; format: PluginFormat } | null {
|
|
30
|
+
const mipham = join(dir, 'plugin.json')
|
|
31
|
+
if (existsSync(mipham)) return { path: mipham, format: 'mipham' }
|
|
32
|
+
const claude = join(dir, '.claude-plugin', 'plugin.json')
|
|
33
|
+
if (existsSync(claude)) return { path: claude, format: 'claude' }
|
|
34
|
+
return null
|
|
35
|
+
}
|
|
36
|
+
|
|
37
|
+
/** Detect a plugin's manifest convention (defaults to Mipham when absent). */
|
|
38
|
+
export function detectPluginFormat(dir: string): PluginFormat {
|
|
39
|
+
return resolveManifestPath(dir)?.format ?? 'mipham'
|
|
40
|
+
}
|
|
17
41
|
|
|
42
|
+
export function validatePlugin(dir: string): PluginValidation {
|
|
43
|
+
const resolved = resolveManifestPath(dir)
|
|
44
|
+
if (!resolved) {
|
|
45
|
+
return {
|
|
46
|
+
valid: false,
|
|
47
|
+
errors: ['No plugin manifest found (expected plugin.json or .claude-plugin/plugin.json)'],
|
|
48
|
+
format: 'mipham',
|
|
49
|
+
manifestPath: '',
|
|
50
|
+
}
|
|
51
|
+
}
|
|
52
|
+
|
|
53
|
+
const errors: string[] = []
|
|
18
54
|
try {
|
|
19
|
-
const raw = readFileSync(
|
|
55
|
+
const raw = readFileSync(resolved.path, 'utf-8')
|
|
20
56
|
const manifest = JSON.parse(raw) as PluginManifest
|
|
21
57
|
|
|
22
58
|
if (!manifest.name || !/^[a-z0-9-]+$/.test(manifest.name)) {
|
|
23
59
|
errors.push('Invalid plugin name: must be lowercase alphanumeric with hyphens')
|
|
24
60
|
}
|
|
25
|
-
|
|
61
|
+
// Mipham plugins require a version; Claude plugins only require `name`.
|
|
62
|
+
if (resolved.format === 'mipham' && !manifest.version) {
|
|
26
63
|
errors.push('Missing required field: version')
|
|
27
64
|
}
|
|
28
|
-
|
|
29
|
-
// Check for suspicious hooks
|
|
30
65
|
if (manifest.hooks) {
|
|
31
66
|
const hooksStr = JSON.stringify(manifest.hooks)
|
|
32
67
|
if (hooksStr.includes('rm -rf') || hooksStr.includes('curl') || hooksStr.includes('eval')) {
|
|
@@ -34,8 +69,20 @@ export function validatePlugin(dir: string): {
|
|
|
34
69
|
}
|
|
35
70
|
}
|
|
36
71
|
|
|
37
|
-
return {
|
|
72
|
+
return {
|
|
73
|
+
valid: errors.length === 0,
|
|
74
|
+
errors,
|
|
75
|
+
manifest,
|
|
76
|
+
format: resolved.format,
|
|
77
|
+
manifestPath: resolved.path,
|
|
78
|
+
}
|
|
38
79
|
} catch (err) {
|
|
39
|
-
|
|
80
|
+
const label = resolved.format === 'claude' ? '.claude-plugin/plugin.json' : 'plugin.json'
|
|
81
|
+
return {
|
|
82
|
+
valid: false,
|
|
83
|
+
errors: [`Failed to read ${label}: ${String(err)}`],
|
|
84
|
+
format: resolved.format,
|
|
85
|
+
manifestPath: resolved.path,
|
|
86
|
+
}
|
|
40
87
|
}
|
|
41
88
|
}
|
|
@@ -0,0 +1,17 @@
|
|
|
1
|
+
import { writeFileSync, renameSync } from 'node:fs'
|
|
2
|
+
|
|
3
|
+
/**
|
|
4
|
+
* Write a file atomically: write to a same-directory `.tmp` file, then rename
|
|
5
|
+
* over the target. Same-filesystem rename is atomic, so a crash or kill
|
|
6
|
+
* mid-write can never leave a truncated/corrupt file — readers see either the
|
|
7
|
+
* old or the new content, never a partial write.
|
|
8
|
+
*/
|
|
9
|
+
export function atomicWriteFileSync(
|
|
10
|
+
path: string,
|
|
11
|
+
content: string,
|
|
12
|
+
options: { mode?: number } = {},
|
|
13
|
+
): void {
|
|
14
|
+
const tmp = path + '.tmp'
|
|
15
|
+
writeFileSync(tmp, content, { encoding: 'utf-8', mode: options.mode ?? 0o600 })
|
|
16
|
+
renameSync(tmp, path)
|
|
17
|
+
}
|
|
@@ -0,0 +1,26 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Detection and messaging for the "current working directory was deleted"
|
|
3
|
+
* startup failure.
|
|
4
|
+
*
|
|
5
|
+
* `process.cwd()` throws `ENOENT` when the directory the process was launched
|
|
6
|
+
* from no longer exists (e.g. a removed git worktree). The CLI entry checks
|
|
7
|
+
* this up front and prints a clear, actionable message instead of letting the
|
|
8
|
+
* error surface as a raw crash dump (matches Claude Code 2.1.239).
|
|
9
|
+
*/
|
|
10
|
+
|
|
11
|
+
/** True when `err` is the ENOENT thrown by `process.cwd()` on a deleted cwd. */
|
|
12
|
+
export function isDeletedCwdError(err: unknown): boolean {
|
|
13
|
+
if (!(err instanceof Error)) return false
|
|
14
|
+
// Node sets `code = 'ENOENT'` with a message ending in `, uv_cwd`. Bun and
|
|
15
|
+
// other runtimes may omit the code but still name `getcwd`/`uv_cwd`.
|
|
16
|
+
return (err as NodeJS.ErrnoException).code === 'ENOENT' || /uv_cwd|getcwd/i.test(err.message)
|
|
17
|
+
}
|
|
18
|
+
|
|
19
|
+
/** Human-readable guidance shown when the launch directory was deleted. */
|
|
20
|
+
export function deletedCwdMessage(): string {
|
|
21
|
+
return (
|
|
22
|
+
`Mipham Code can't start: the current working directory no longer exists.\n\n` +
|
|
23
|
+
`The directory you launched from was deleted (for example, a removed git worktree).\n` +
|
|
24
|
+
`Change to a valid directory and run \`mipham\` again.`
|
|
25
|
+
)
|
|
26
|
+
}
|
|
@@ -0,0 +1,34 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Session-scoped accumulator for graft's token savings. Graft tools emit a
|
|
3
|
+
* "[graft] tokens saved ≈ N" footer on their output; the engine parses that
|
|
4
|
+
* footer after each tool result and adds it here, so the statusline can show
|
|
5
|
+
* "~N tok saved" (mirroring graft's own hooks.js `handleToolSavings`).
|
|
6
|
+
*/
|
|
7
|
+
|
|
8
|
+
/** Sum the "[graft] tokens saved ≈ N" footers in a tool output. */
|
|
9
|
+
export function parseGraftSavings(text: string): number {
|
|
10
|
+
let total = 0
|
|
11
|
+
for (const m of text.matchAll(/\[graft\] tokens saved ≈ ([\d,]+)/g)) {
|
|
12
|
+
const digits = m[1]
|
|
13
|
+
if (digits) total += Number(digits.replace(/,/g, '')) || 0
|
|
14
|
+
}
|
|
15
|
+
return total
|
|
16
|
+
}
|
|
17
|
+
|
|
18
|
+
let savings = 0
|
|
19
|
+
|
|
20
|
+
/** Add a tool result's graft savings to the session running total. */
|
|
21
|
+
export function accumulateGraftSavings(text: string): void {
|
|
22
|
+
if (!text) return
|
|
23
|
+
savings += parseGraftSavings(text)
|
|
24
|
+
}
|
|
25
|
+
|
|
26
|
+
/** The session's running graft token savings. */
|
|
27
|
+
export function getGraftSavings(): number {
|
|
28
|
+
return savings
|
|
29
|
+
}
|
|
30
|
+
|
|
31
|
+
/** Reset the running total (e.g. on session reset). */
|
|
32
|
+
export function resetGraftSavings(): void {
|
|
33
|
+
savings = 0
|
|
34
|
+
}
|
|
@@ -0,0 +1,36 @@
|
|
|
1
|
+
import { readFileSync, existsSync } from 'node:fs'
|
|
2
|
+
import { join } from 'node:path'
|
|
3
|
+
|
|
4
|
+
/** Graft's hook-maintained stats cache (`graft/.cache/stats.json`). */
|
|
5
|
+
export interface GraftStats {
|
|
6
|
+
nodeCount: number
|
|
7
|
+
edgeCount: number
|
|
8
|
+
staleCount: number
|
|
9
|
+
dirty: boolean
|
|
10
|
+
syncing: boolean
|
|
11
|
+
lastFile: string | null
|
|
12
|
+
}
|
|
13
|
+
|
|
14
|
+
/**
|
|
15
|
+
* Read graft's stats cache for the given project. Returns null when graft is
|
|
16
|
+
* not built/installed in `cwd` (no `graft/.cache/stats.json`, or an empty
|
|
17
|
+
* graph) — callers render nothing in that case.
|
|
18
|
+
*/
|
|
19
|
+
export function readGraftStats(cwd: string): GraftStats | null {
|
|
20
|
+
const statsPath = join(cwd, 'graft', '.cache', 'stats.json')
|
|
21
|
+
if (!existsSync(statsPath)) return null
|
|
22
|
+
try {
|
|
23
|
+
const raw = JSON.parse(readFileSync(statsPath, 'utf-8')) as Record<string, unknown>
|
|
24
|
+
if (typeof raw.nodeCount !== 'number' || raw.nodeCount === 0) return null
|
|
25
|
+
return {
|
|
26
|
+
nodeCount: raw.nodeCount,
|
|
27
|
+
edgeCount: typeof raw.edgeCount === 'number' ? raw.edgeCount : 0,
|
|
28
|
+
staleCount: typeof raw.staleCount === 'number' ? raw.staleCount : 0,
|
|
29
|
+
dirty: raw.dirty === true,
|
|
30
|
+
syncing: raw.syncing === true,
|
|
31
|
+
lastFile: typeof raw.lastFile === 'string' ? raw.lastFile : null,
|
|
32
|
+
}
|
|
33
|
+
} catch {
|
|
34
|
+
return null
|
|
35
|
+
}
|
|
36
|
+
}
|
|
@@ -9,7 +9,7 @@
|
|
|
9
9
|
export const PACKAGE_NAME = '@miphamai/cli' as const
|
|
10
10
|
|
|
11
11
|
/** 当前发布版本 */
|
|
12
|
-
export const PACKAGE_VERSION = '0.
|
|
12
|
+
export const PACKAGE_VERSION = '0.56.0' as const
|
|
13
13
|
|
|
14
14
|
/** npm install 全局安装命令 */
|
|
15
15
|
export const NPM_INSTALL_COMMAND = `npm install -g ${PACKAGE_NAME}` as const
|
package/src/shared/types.ts
CHANGED
package/src/skills/loader.ts
CHANGED
|
@@ -13,9 +13,12 @@ interface FrontmatterResult {
|
|
|
13
13
|
}
|
|
14
14
|
|
|
15
15
|
function parseFrontmatter(raw: string): FrontmatterResult {
|
|
16
|
-
|
|
16
|
+
// Strip a leading UTF-8 BOM — otherwise `^---` never matches and a
|
|
17
|
+
// BOM-prefixed file is silently treated as body text (effectively ignored).
|
|
18
|
+
const src = raw.replace(/^\uFEFF/, '')
|
|
19
|
+
const match = src.match(/^---\n([\s\S]*?)\n---\n([\s\S]*)$/)
|
|
17
20
|
if (!match) {
|
|
18
|
-
return { data: {}, content:
|
|
21
|
+
return { data: {}, content: src }
|
|
19
22
|
}
|
|
20
23
|
return {
|
|
21
24
|
data: parseYaml(match[1] || '') as Record<string, unknown>,
|
|
@@ -79,6 +82,11 @@ export class SkillsLoader implements Skills {
|
|
|
79
82
|
}
|
|
80
83
|
}
|
|
81
84
|
|
|
85
|
+
/** Load a single skill file by path (used by Claude plugins: `skills/<name>/SKILL.md`). */
|
|
86
|
+
loadSkillFile(path: string, type: 'standard' | 'mipham' = 'standard'): void {
|
|
87
|
+
this.tryLoad(path, type)
|
|
88
|
+
}
|
|
89
|
+
|
|
82
90
|
get(name: string): SkillDefinition | undefined {
|
|
83
91
|
return this.skills.get(name)
|
|
84
92
|
}
|
|
@@ -192,40 +200,13 @@ export class SkillsLoader implements Skills {
|
|
|
192
200
|
}
|
|
193
201
|
|
|
194
202
|
/**
|
|
195
|
-
*
|
|
196
|
-
*
|
|
197
|
-
*
|
|
198
|
-
*
|
|
199
|
-
*/
|
|
200
|
-
recall(context: string, limit: number = 5): SkillDefinition[] {
|
|
201
|
-
const ctxWords = new Set(context.toLowerCase().split(/\s+/))
|
|
202
|
-
const scored: Array<{ skill: SkillDefinition; score: number }> = []
|
|
203
|
-
|
|
204
|
-
for (const skill of this.list()) {
|
|
205
|
-
if (skill.disableModelInvocation) continue
|
|
206
|
-
let score = 0
|
|
207
|
-
for (const word of skill.name.toLowerCase().split(/[-_\s]+/)) {
|
|
208
|
-
if (word.length > 2 && ctxWords.has(word)) score += 3
|
|
209
|
-
}
|
|
210
|
-
for (const word of skill.description.toLowerCase().split(/\s+/)) {
|
|
211
|
-
if (word.length > 3 && ctxWords.has(word)) score += 1
|
|
212
|
-
}
|
|
213
|
-
if (score > 0) scored.push({ skill, score })
|
|
214
|
-
}
|
|
215
|
-
|
|
216
|
-
scored.sort((a, b) => b.score - a.score)
|
|
217
|
-
return scored.slice(0, limit).map((s) => s.skill)
|
|
218
|
-
}
|
|
219
|
-
|
|
220
|
-
/**
|
|
221
|
-
* Build the system-reminder block for AI auto-triggering.
|
|
222
|
-
* With a `context`, only the most relevant skills are injected (selective,
|
|
223
|
-
* to save tokens); without one, all skills are listed.
|
|
203
|
+
* Build the system-reminder block listing every skill the AI may invoke via
|
|
204
|
+
* the Skill tool. A single full listing keeps the whole catalog discoverable:
|
|
205
|
+
* at session start there is no query to match against, so a keyword "recall"
|
|
206
|
+
* would silently hide most skills. Capped at `maxTokens` to stay bounded.
|
|
224
207
|
*/
|
|
225
|
-
buildSystemReminder(
|
|
226
|
-
const selected =
|
|
227
|
-
? this.recall(context)
|
|
228
|
-
: this.list().filter((s) => !s.disableModelInvocation)
|
|
208
|
+
buildSystemReminder(maxTokens: number = 5000): string {
|
|
209
|
+
const selected = this.list().filter((s) => !s.disableModelInvocation)
|
|
229
210
|
|
|
230
211
|
if (selected.length === 0) return ''
|
|
231
212
|
|
package/src/skills/seam.ts
CHANGED
|
@@ -6,7 +6,7 @@ export interface Skills {
|
|
|
6
6
|
get(name: string): SkillDefinition | undefined
|
|
7
7
|
list(): SkillDefinition[]
|
|
8
8
|
has(name: string): boolean
|
|
9
|
-
buildSystemReminder(
|
|
9
|
+
buildSystemReminder(maxTokens?: number): string
|
|
10
10
|
}
|
|
11
11
|
|
|
12
12
|
/** 缝键:ctx.skills。 */
|
|
@@ -2,6 +2,7 @@ import { readFileSync, writeFileSync, mkdirSync, existsSync, readdirSync } from
|
|
|
2
2
|
import { join } from 'node:path'
|
|
3
3
|
import { homedir } from 'node:os'
|
|
4
4
|
import type { ToolDefinition } from '../../shared/index.ts'
|
|
5
|
+
import { MemoryManager } from '../../core/memory/memory-manager'
|
|
5
6
|
|
|
6
7
|
const MEMORY_DIR = join(homedir(), '.mipham', 'memory')
|
|
7
8
|
|
|
@@ -24,7 +25,7 @@ ${content}
|
|
|
24
25
|
|
|
25
26
|
export const memoryTool: ToolDefinition = {
|
|
26
27
|
name: 'Memory',
|
|
27
|
-
description: 'Read and
|
|
28
|
+
description: 'Read, write, list, and search persistent memory files in ~/.mipham/memory/.',
|
|
28
29
|
category: 'agent',
|
|
29
30
|
permission: 'auto',
|
|
30
31
|
parameters: {
|
|
@@ -32,11 +33,12 @@ export const memoryTool: ToolDefinition = {
|
|
|
32
33
|
properties: {
|
|
33
34
|
action: {
|
|
34
35
|
type: 'string',
|
|
35
|
-
enum: ['read', 'write', 'list'],
|
|
36
|
+
enum: ['read', 'write', 'list', 'search'],
|
|
36
37
|
description: 'Action to perform',
|
|
37
38
|
},
|
|
38
39
|
name: { type: 'string', description: 'Memory file name (slug)' },
|
|
39
40
|
content: { type: 'string', description: 'Content to write (for write action)' },
|
|
41
|
+
query: { type: 'string', description: 'Search query (for search action)' },
|
|
40
42
|
},
|
|
41
43
|
required: ['action'],
|
|
42
44
|
},
|
|
@@ -49,6 +51,22 @@ export const memoryTool: ToolDefinition = {
|
|
|
49
51
|
return { success: true, content: files.join('\n') || '(no memories)' }
|
|
50
52
|
}
|
|
51
53
|
|
|
54
|
+
// Semantic-less recall (keyword + wikilink + time-decay scoring) — lets the
|
|
55
|
+
// AI retrieve relevant memories mid-conversation, not only at session start.
|
|
56
|
+
if (action === 'search') {
|
|
57
|
+
const query = (params.query as string)?.trim()
|
|
58
|
+
if (!query) return { success: false, content: '', error: 'query is required' }
|
|
59
|
+
const mm = new MemoryManager(MEMORY_DIR)
|
|
60
|
+
mm.loadAll()
|
|
61
|
+
const results = mm.recall(query, 10)
|
|
62
|
+
if (results.length === 0) return { success: true, content: '(no matching memories)' }
|
|
63
|
+
const lines = results.map((entry) => {
|
|
64
|
+
const snippet = entry.content.replace(/\n+/g, ' ').trim().slice(0, 140)
|
|
65
|
+
return `- ${entry.name}: ${entry.description || snippet}\n ${snippet}`
|
|
66
|
+
})
|
|
67
|
+
return { success: true, content: lines.join('\n') }
|
|
68
|
+
}
|
|
69
|
+
|
|
52
70
|
const name = params.name as string
|
|
53
71
|
if (!name) return { success: false, content: '', error: 'name is required' }
|
|
54
72
|
const filePath = join(MEMORY_DIR, `${name}.md`)
|
package/src/ui/app.tsx
CHANGED
|
@@ -22,6 +22,18 @@ import { ChatPanel } from './chat'
|
|
|
22
22
|
import { InputBar } from './input'
|
|
23
23
|
import { ModelPicker } from './picker'
|
|
24
24
|
import { AgentFooter, type AgentEntry } from './agent-footer'
|
|
25
|
+
import { GraftStatusLine } from './graft-status'
|
|
26
|
+
|
|
27
|
+
/** Current context-window usage % — undefined when unknown (remote stub). */
|
|
28
|
+
function contextUsagePct(engine: QueryEngine | RemoteEngine): number | undefined {
|
|
29
|
+
const ctx = engine.getContext() as {
|
|
30
|
+
getEstimatedTokens(): number
|
|
31
|
+
getMaxTokens?(): number
|
|
32
|
+
}
|
|
33
|
+
const max = ctx.getMaxTokens?.()
|
|
34
|
+
if (!max || max <= 0) return undefined
|
|
35
|
+
return Math.round((ctx.getEstimatedTokens() / max) * 100)
|
|
36
|
+
}
|
|
25
37
|
import { AgentViewDashboard } from '../agent-view/dashboard'
|
|
26
38
|
import type { AgentViewManager } from '../agent-view/agent-view-manager'
|
|
27
39
|
import { WorkflowProgress } from './workflow-progress.js'
|
|
@@ -965,6 +977,9 @@ export function App({
|
|
|
965
977
|
agentProgress={agentProgress}
|
|
966
978
|
/>
|
|
967
979
|
|
|
980
|
+
{/* graft status line — mirrors graft's own "◤ graft · …" bar */}
|
|
981
|
+
<GraftStatusLine cwd={process.cwd()} ctxPct={contextUsagePct(engine)} />
|
|
982
|
+
|
|
968
983
|
{/* Status line — Claude Code style */}
|
|
969
984
|
<Box marginTop={1} flexDirection="column">
|
|
970
985
|
<Box flexDirection="row">
|
package/src/ui/config-wizard.tsx
CHANGED
|
@@ -14,7 +14,8 @@ import { useI18n } from '../i18n-context'
|
|
|
14
14
|
import TextInput from 'ink-text-input'
|
|
15
15
|
import { DEFAULT_PROVIDERS, OLLAMA_PRESET_MODELS } from '../shared/constants'
|
|
16
16
|
import type { ModelInfo } from '../shared/types'
|
|
17
|
-
import { mkdirSync
|
|
17
|
+
import { mkdirSync } from 'node:fs'
|
|
18
|
+
import { atomicWriteFileSync } from '../shared/atomic-write'
|
|
18
19
|
import { join } from 'node:path'
|
|
19
20
|
import { homedir } from 'node:os'
|
|
20
21
|
import { execSync } from 'node:child_process'
|
|
@@ -115,7 +116,7 @@ function writeConfigFile(providerId: string, modelId: string, apiKey: string): v
|
|
|
115
116
|
...models.map((m) => ` - id: ${m.id}`),
|
|
116
117
|
]
|
|
117
118
|
|
|
118
|
-
|
|
119
|
+
atomicWriteFileSync(join(configDir, 'config.yml'), lines.join('\n'))
|
|
119
120
|
}
|
|
120
121
|
|
|
121
122
|
function checkOllama(): { installed: boolean; running: boolean; models: string[] } {
|
|
@@ -0,0 +1,59 @@
|
|
|
1
|
+
import React, { useState } from 'react'
|
|
2
|
+
import { Box, Text } from 'ink'
|
|
3
|
+
import { basename } from 'node:path'
|
|
4
|
+
import { readGraftStats, type GraftStats } from '../shared/graft-stats'
|
|
5
|
+
import { getGraftSavings } from '../shared/graft-savings'
|
|
6
|
+
|
|
7
|
+
/** Freshness segment mirroring graft's own statusline (see @nanonets/graft format.js). */
|
|
8
|
+
function freshnessSegment(stats: GraftStats): { label: string; color: string } {
|
|
9
|
+
if (stats.syncing) return { label: 'syncing…', color: 'yellow' }
|
|
10
|
+
if (stats.dirty && stats.staleCount > 0)
|
|
11
|
+
return { label: `⚠ ${stats.staleCount} stale`, color: 'yellow' }
|
|
12
|
+
if (stats.dirty) return { label: '⚠ stale', color: 'yellow' }
|
|
13
|
+
return { label: '✓ synced', color: 'blue' }
|
|
14
|
+
}
|
|
15
|
+
|
|
16
|
+
/**
|
|
17
|
+
* Bottom-of-screen graft status line — mirrors graft's own "◤ graft · …" bar:
|
|
18
|
+
* ◤ graft · N nodes / E edges · ✓ synced · ~T tok saved
|
|
19
|
+
* ▸ ctx X% · last: file
|
|
20
|
+
* Reads `graft/.cache/stats.json` once at mount; the session's tok-saved total
|
|
21
|
+
* is read live each render (the app re-renders on the agent tick). Returns
|
|
22
|
+
* null (renders nothing) when graft isn't built here.
|
|
23
|
+
*/
|
|
24
|
+
export function GraftStatusLine({ cwd, ctxPct }: { cwd: string; ctxPct?: number }) {
|
|
25
|
+
const [stats] = useState(() => readGraftStats(cwd))
|
|
26
|
+
if (!stats) return null
|
|
27
|
+
const fresh = freshnessSegment(stats)
|
|
28
|
+
const saved = getGraftSavings()
|
|
29
|
+
const lastFile = stats.lastFile ? basename(stats.lastFile) : null
|
|
30
|
+
|
|
31
|
+
const bottom: string[] = []
|
|
32
|
+
if (typeof ctxPct === 'number') bottom.push(`ctx ${ctxPct}%`)
|
|
33
|
+
if (lastFile) bottom.push(`last: ${lastFile}`)
|
|
34
|
+
|
|
35
|
+
return (
|
|
36
|
+
<Box flexDirection="column">
|
|
37
|
+
<Box>
|
|
38
|
+
<Text dimColor>◤ </Text>
|
|
39
|
+
<Text color="blue">graft</Text>
|
|
40
|
+
<Text dimColor>
|
|
41
|
+
{' '}
|
|
42
|
+
· {stats.nodeCount} nodes / {stats.edgeCount} edges ·{' '}
|
|
43
|
+
</Text>
|
|
44
|
+
<Text color={fresh.color}>{fresh.label}</Text>
|
|
45
|
+
{saved > 0 && (
|
|
46
|
+
<>
|
|
47
|
+
<Text dimColor> · </Text>
|
|
48
|
+
<Text color="blue">~{saved.toLocaleString()} tok saved</Text>
|
|
49
|
+
</>
|
|
50
|
+
)}
|
|
51
|
+
</Box>
|
|
52
|
+
{bottom.length > 0 && (
|
|
53
|
+
<Box>
|
|
54
|
+
<Text dimColor>▸ {bottom.join(' · ')}</Text>
|
|
55
|
+
</Box>
|
|
56
|
+
)}
|
|
57
|
+
</Box>
|
|
58
|
+
)
|
|
59
|
+
}
|
|
@@ -1,95 +0,0 @@
|
|
|
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
|
-
}
|