@miphamai/cli 0.22.0 → 0.23.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/README.md CHANGED
@@ -16,7 +16,7 @@ Requirements: **Bun 1.2+** (recommended) or **Node.js 22+**
16
16
  ## Features
17
17
 
18
18
  - **9 AI Providers** — Anthropic Claude · OpenAI GPT · DeepSeek · Kimi (Moonshot) · Google Gemini · Doubao 豆包 · Tencent Hunyuan 混元 · Qwen 通义千问 · MiphamAI
19
- - **66 Slash Commands** — Interactive command picker (↑↓ Enter Esc), Claude Code-compatible, zero re-learning
19
+ - **66 Slash Commands** — Interactive command picker (↑↓ Enter Esc), intuitive command interface, zero re-learning
20
20
  - **16 Built-in Tools** — File ops, shell execution, agent dispatch, MCP integration, web search
21
21
  - **14 Skills + Marketplace** — 11 standard + 3 Mipham-exclusive skills. `/browse-skills` `/install-skill` community marketplace
22
22
  - **Self-Update** — `mipham update` / `/upgrade` — one command to check, backup config, upgrade, restore. API keys preserved
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@miphamai/cli",
3
- "version": "0.22.0",
3
+ "version": "0.23.0",
4
4
  "description": "Mipham Code — Multi-model open-core intelligent coding terminal by MiphamAI",
5
5
  "keywords": [
6
6
  "ai",
@@ -10,37 +10,85 @@ export const meta = {
10
10
  }
11
11
 
12
12
  phase('Scope')
13
- const targets = args.targets || (await agent(
14
- 'List all source files that need security auditing. Return { files: [{ path, reason }] }',
15
- { schema: { type: 'object', properties: { files: { type: 'array', items: { type: 'object', properties: { path: { type: 'string' }, reason: { type: 'string' } }, required: ['path', 'reason'] } } }, required: ['files'] } },
16
- )).files
13
+ const targets =
14
+ args.targets ||
15
+ (
16
+ await agent(
17
+ 'List all source files that need security auditing. Return { files: [{ path, reason }] }',
18
+ {
19
+ schema: {
20
+ type: 'object',
21
+ properties: {
22
+ files: {
23
+ type: 'array',
24
+ items: {
25
+ type: 'object',
26
+ properties: { path: { type: 'string' }, reason: { type: 'string' } },
27
+ required: ['path', 'reason'],
28
+ },
29
+ },
30
+ },
31
+ required: ['files'],
32
+ },
33
+ },
34
+ )
35
+ ).files
17
36
 
18
37
  log(`Auditing ${targets.length} files`)
19
38
 
20
39
  phase('Audit')
21
- const raw = (await pipeline(
22
- targets,
23
- t => agent(`Security audit ${t.path}: injection, auth, crypto, secrets, input validation. Return { findings: [{ severity, file, line, summary }] }`,
24
- { label: `audit:${t.path}`, schema: { type: 'object', properties: { findings: { type: 'array', items: { type: 'object', properties: { severity: { type: 'string', enum: ['critical', 'high', 'medium', 'low'] }, file: { type: 'string' }, line: { type: 'number' }, summary: { type: 'string' } }, required: ['severity', 'file', 'summary'] } } }, required: ['findings'] } },
25
- )),
40
+ const raw = await pipeline(targets, (t) =>
41
+ agent(
42
+ `Security audit ${t.path}: injection, auth, crypto, secrets, input validation. Return { findings: [{ severity, file, line, summary }] }`,
43
+ {
44
+ label: `audit:${t.path}`,
45
+ schema: {
46
+ type: 'object',
47
+ properties: {
48
+ findings: {
49
+ type: 'array',
50
+ items: {
51
+ type: 'object',
52
+ properties: {
53
+ severity: { type: 'string', enum: ['critical', 'high', 'medium', 'low'] },
54
+ file: { type: 'string' },
55
+ line: { type: 'number' },
56
+ summary: { type: 'string' },
57
+ },
58
+ required: ['severity', 'file', 'summary'],
59
+ },
60
+ },
61
+ },
62
+ required: ['findings'],
63
+ },
64
+ },
65
+ ),
26
66
  )
27
- const findings = raw.flatMap(r => (r && r.findings) || [])
67
+ const findings = raw.flatMap((r) => (r && r.findings) || [])
28
68
 
29
69
  log(`Found ${findings.length} potential issues`)
30
70
 
31
71
  phase('Verify')
32
72
  const verified = await parallel(
33
- findings.map(f => () =>
34
- verify(f, {
35
- mode: 'adversarial',
36
- skeptics: 3,
37
- threshold: 2,
38
- schema: { type: 'object', properties: { real: { type: 'boolean' }, reason: { type: 'string' } }, required: ['real', 'reason'] },
39
- })
73
+ findings.map(
74
+ (f) => () =>
75
+ verify(f, {
76
+ mode: 'adversarial',
77
+ skeptics: 3,
78
+ threshold: 2,
79
+ schema: {
80
+ type: 'object',
81
+ properties: { real: { type: 'boolean' }, reason: { type: 'string' } },
82
+ required: ['real', 'reason'],
83
+ },
84
+ }),
40
85
  ),
41
86
  )
42
87
 
43
- const confirmed = verified.filter(Boolean).filter(v => v.survives).map(v => v.finding)
88
+ const confirmed = verified
89
+ .filter(Boolean)
90
+ .filter((v) => v.survives)
91
+ .map((v) => v.finding)
44
92
  log(`${confirmed.length}/${findings.length} findings verified`)
45
93
 
46
94
  phase('Report')
@@ -0,0 +1,99 @@
1
+ import {
2
+ readdirSync,
3
+ readFileSync,
4
+ writeFileSync,
5
+ existsSync,
6
+ mkdirSync,
7
+ unlinkSync,
8
+ } from 'node:fs'
9
+ import { join } from 'node:path'
10
+ import { homedir, hostname } from 'node:os'
11
+ import type { SessionInfo } from '../../shared/types'
12
+
13
+ const MIPHAM_HOME = join(homedir(), '.mipham')
14
+ const ACTIVE_SESSIONS_DIR = join(MIPHAM_HOME, '.active-sessions')
15
+
16
+ /**
17
+ * Register the current session as active.
18
+ * Writes a SessionInfo JSON file + updates mtime for heartbeat.
19
+ */
20
+ export function registerActiveSession(info: SessionInfo): void {
21
+ mkdirSync(ACTIVE_SESSIONS_DIR, { recursive: true })
22
+ const filePath = join(ACTIVE_SESSIONS_DIR, `${info.id}.json`)
23
+ writeFileSync(filePath, JSON.stringify(info, null, 2), 'utf-8')
24
+ }
25
+
26
+ /**
27
+ * Update heartbeat (mtime) for an active session.
28
+ */
29
+ export function heartbeatSession(sessionId: string): void {
30
+ const filePath = join(ACTIVE_SESSIONS_DIR, `${sessionId}.json`)
31
+ if (existsSync(filePath)) {
32
+ // Touch the file by rewriting it
33
+ const raw = readFileSync(filePath, 'utf-8')
34
+ writeFileSync(filePath, raw, 'utf-8') // updates mtime
35
+ }
36
+ }
37
+
38
+ /**
39
+ * Unregister a session (called on shutdown).
40
+ */
41
+ export function unregisterSession(sessionId: string): void {
42
+ const filePath = join(ACTIVE_SESSIONS_DIR, `${sessionId}.json`)
43
+ try {
44
+ if (existsSync(filePath)) unlinkSync(filePath)
45
+ } catch {
46
+ // Best-effort
47
+ }
48
+ }
49
+
50
+ /**
51
+ * Discover all active sessions.
52
+ * Reads ~/.mipham/.active-sessions/ directory.
53
+ */
54
+ export function discoverSessions(): SessionInfo[] {
55
+ if (!existsSync(ACTIVE_SESSIONS_DIR)) return []
56
+
57
+ try {
58
+ const files = readdirSync(ACTIVE_SESSIONS_DIR).filter((f) => f.endsWith('.json'))
59
+
60
+ const sessions: SessionInfo[] = []
61
+ for (const file of files) {
62
+ try {
63
+ const raw = readFileSync(join(ACTIVE_SESSIONS_DIR, file), 'utf-8')
64
+ const info = JSON.parse(raw) as SessionInfo
65
+ sessions.push(info)
66
+ } catch {
67
+ // Skip corrupt files
68
+ }
69
+ }
70
+
71
+ // Sort by most recently active
72
+ sessions.sort((a, b) => new Date(b.startedAt).getTime() - new Date(a.startedAt).getTime())
73
+ return sessions
74
+ } catch {
75
+ return []
76
+ }
77
+ }
78
+
79
+ /**
80
+ * Create a SessionInfo for the current process.
81
+ */
82
+ export function createSessionInfo(
83
+ sessionId: string,
84
+ name: string,
85
+ cwd?: string,
86
+ provider?: string,
87
+ model?: string,
88
+ ): SessionInfo {
89
+ return {
90
+ id: sessionId,
91
+ name,
92
+ machine: hostname(),
93
+ pid: process.pid,
94
+ startedAt: new Date().toISOString(),
95
+ cwd,
96
+ provider,
97
+ model,
98
+ }
99
+ }
@@ -0,0 +1,118 @@
1
+ import {
2
+ mkdirSync,
3
+ writeFileSync,
4
+ readdirSync,
5
+ readFileSync,
6
+ unlinkSync,
7
+ existsSync,
8
+ renameSync,
9
+ } from 'node:fs'
10
+ import { join } from 'node:path'
11
+ import { homedir } from 'node:os'
12
+ import type { CrossSessionTransport } from './transport'
13
+ import type { AgentMessage } from '../message-bus'
14
+ import type { SessionInfo } from '../../shared/types'
15
+
16
+ const MIPHAM_HOME = join(homedir(), '.mipham')
17
+ const INBOX_DIR = join(MIPHAM_HOME, 'inbox')
18
+
19
+ /**
20
+ * Filesystem-based cross-session transport.
21
+ *
22
+ * Messages are stored as JSON files in ~/.mipham/inbox/<session-id>/.
23
+ * Each message file is named: <ISO-timestamp>-<msg-id>.json
24
+ */
25
+ export class FileInboxTransport implements CrossSessionTransport {
26
+ private ensureDir(dir: string): void {
27
+ mkdirSync(dir, { recursive: true })
28
+ }
29
+
30
+ async send(from: SessionInfo, toSessionId: string, msg: AgentMessage): Promise<boolean> {
31
+ try {
32
+ const inboxDir = join(INBOX_DIR, toSessionId)
33
+ this.ensureDir(inboxDir)
34
+
35
+ const timestamp = new Date().toISOString().replace(/[:.]/g, '-')
36
+ const filename = `${timestamp}-${msg.id}.json`
37
+ const filePath = join(inboxDir, filename)
38
+
39
+ // Atomic write: temp file then rename
40
+ const tmpPath = filePath + '.tmp'
41
+ writeFileSync(
42
+ tmpPath,
43
+ JSON.stringify({ ...msg, _fromSession: from.id, _fromMachine: from.machine }, null, 2),
44
+ 'utf-8',
45
+ )
46
+ renameSync(tmpPath, filePath)
47
+
48
+ return true
49
+ } catch {
50
+ return false
51
+ }
52
+ }
53
+
54
+ async poll(sessionId: string): Promise<AgentMessage[]> {
55
+ const inboxDir = join(INBOX_DIR, sessionId)
56
+ if (!existsSync(inboxDir)) return []
57
+
58
+ try {
59
+ const files = readdirSync(inboxDir)
60
+ .filter((f) => f.endsWith('.json') && !f.endsWith('.tmp'))
61
+ .sort() // chronological by timestamp prefix
62
+
63
+ const messages: AgentMessage[] = []
64
+ for (const file of files) {
65
+ try {
66
+ const raw = readFileSync(join(inboxDir, file), 'utf-8')
67
+ const parsed = JSON.parse(raw) as AgentMessage & {
68
+ _fromSession?: string
69
+ _fromMachine?: string
70
+ }
71
+
72
+ // Build the message body with cross-session origin prefix
73
+ const originPrefix = parsed._fromSession
74
+ ? `[From: ${parsed._fromSession}@${parsed._fromMachine || 'unknown'}]\n\n`
75
+ : ''
76
+
77
+ messages.push({
78
+ id: parsed.id,
79
+ from: parsed.from,
80
+ to: parsed.to,
81
+ summary: parsed.summary,
82
+ message: `${originPrefix}${parsed.message}`,
83
+ timestamp: new Date(parsed.timestamp),
84
+ read: parsed.read,
85
+ type: parsed.type,
86
+ })
87
+
88
+ // Mark as read by deleting the file (read-once)
89
+ unlinkSync(join(inboxDir, file))
90
+ } catch {
91
+ // Skip corrupt files
92
+ }
93
+ }
94
+
95
+ return messages
96
+ } catch {
97
+ return []
98
+ }
99
+ }
100
+
101
+ async listSessions(): Promise<SessionInfo[]> {
102
+ return discoverSessions()
103
+ }
104
+ }
105
+
106
+ // Import discoverSessions lazily to avoid circular dependency
107
+ import { discoverSessions } from './discovery'
108
+
109
+ // ── Singleton ──
110
+
111
+ let _transport: FileInboxTransport | null = null
112
+
113
+ export function getFileInboxTransport(): FileInboxTransport {
114
+ if (!_transport) {
115
+ _transport = new FileInboxTransport()
116
+ }
117
+ return _transport
118
+ }
@@ -0,0 +1,17 @@
1
+ import type { AgentMessage } from '../message-bus'
2
+ import type { SessionInfo } from '../../shared/types'
3
+
4
+ /**
5
+ * Transport layer for cross-session messaging.
6
+ * Implementations can use filesystem, unix sockets, TCP, etc.
7
+ */
8
+ export interface CrossSessionTransport {
9
+ /** Send a message to a destination session's inbox. Returns true on success. */
10
+ send(from: SessionInfo, toSessionId: string, msg: AgentMessage): Promise<boolean>
11
+
12
+ /** Poll for unread messages addressed to the given session. */
13
+ poll(sessionId: string): Promise<AgentMessage[]>
14
+
15
+ /** List all discoverable active sessions. */
16
+ listSessions(): Promise<SessionInfo[]>
17
+ }
@@ -0,0 +1,83 @@
1
+ import { getMessageBus } from './message-bus'
2
+ import { getFileInboxTransport } from './cross-session/file-inbox'
3
+ import { discoverSessions, createSessionInfo } from './cross-session/discovery'
4
+ import type { AgentMessage } from './message-bus'
5
+
6
+ export interface RouteResult {
7
+ success: boolean
8
+ routedTo: 'bus' | 'inbox' | 'unknown'
9
+ error?: string
10
+ messageId?: string
11
+ }
12
+
13
+ /**
14
+ * MessageRouter decides how to deliver a message based on the recipient.
15
+ *
16
+ * - "main" or background task IDs → in-memory AgentMessageBus (same process)
17
+ * - Session IDs matching discoverable sessions → file inbox (cross-session)
18
+ * - Unknown → error
19
+ */
20
+ export class MessageRouter {
21
+ /**
22
+ * Route a message to the appropriate transport.
23
+ */
24
+ async route(from: string, to: string, summary: string, message: string): Promise<RouteResult> {
25
+ // Same-process routing: "main" or background task IDs
26
+ if (to === 'main' || to.startsWith('bg-') || to.startsWith('sub-agent-')) {
27
+ try {
28
+ const bus = getMessageBus()
29
+ const msgId = bus.post(from, to, summary, message)
30
+ return { success: true, routedTo: 'bus', messageId: msgId }
31
+ } catch (err) {
32
+ return { success: false, routedTo: 'bus', error: String(err) }
33
+ }
34
+ }
35
+
36
+ // Cross-session routing
37
+ const sessions = discoverSessions()
38
+ const targetSession = sessions.find((s) => s.id === to)
39
+
40
+ if (!targetSession) {
41
+ return {
42
+ success: false,
43
+ routedTo: 'unknown',
44
+ error: `No active session found with ID "${to}". Use ListAgents to discover available sessions.`,
45
+ }
46
+ }
47
+
48
+ // Get sender info
49
+ const senderSession = createSessionInfo(from, from)
50
+
51
+ try {
52
+ const transport = getFileInboxTransport()
53
+ const msg: AgentMessage = {
54
+ id: `xmsg-${Date.now().toString(36)}-${Math.random().toString(36).slice(2, 8)}`,
55
+ from,
56
+ to,
57
+ summary: summary.slice(0, 200),
58
+ message,
59
+ timestamp: new Date(),
60
+ read: false,
61
+ type: 'message',
62
+ }
63
+
64
+ const delivered = await transport.send(senderSession, to, msg)
65
+ if (!delivered) {
66
+ return { success: false, routedTo: 'inbox', error: 'Failed to write message to inbox.' }
67
+ }
68
+
69
+ return { success: true, routedTo: 'inbox', messageId: msg.id }
70
+ } catch (err) {
71
+ return { success: false, routedTo: 'inbox', error: String(err) }
72
+ }
73
+ }
74
+ }
75
+
76
+ let _router: MessageRouter | null = null
77
+
78
+ export function getMessageRouter(): MessageRouter {
79
+ if (!_router) {
80
+ _router = new MessageRouter()
81
+ }
82
+ return _router
83
+ }
@@ -3,6 +3,7 @@ import type {
3
3
  InferenceHookConfig,
4
4
  CredentialMaskingConfig,
5
5
  BackgroundAgentConfig,
6
+ CrossSessionConfig,
6
7
  } from '../shared/index.ts'
7
8
  import { DEFAULT_PROVIDERS } from '../shared/index.ts'
8
9
  import { PACKAGE_VERSION } from '../shared/index.ts'
@@ -51,3 +52,8 @@ export const DEFAULT_BACKGROUND_AGENT_CONFIG: BackgroundAgentConfig = {
51
52
  auto_worktree: true,
52
53
  commit_coauthors: true,
53
54
  }
55
+
56
+ export const DEFAULT_CROSS_SESSION_CONFIG: CrossSessionConfig = {
57
+ crossSessionInbound: 'ask',
58
+ dialogExpiry: 300,
59
+ }
@@ -17,12 +17,14 @@ import type {
17
17
  InferenceHookConfig,
18
18
  CredentialMaskingConfig,
19
19
  BackgroundAgentConfig,
20
+ CrossSessionConfig,
20
21
  } from '../shared/index.ts'
21
22
  import {
22
23
  DEFAULT_CONFIG,
23
24
  DEFAULT_INFERENCE_HOOK_CONFIG,
24
25
  DEFAULT_CREDENTIAL_MASKING_CONFIG,
25
26
  DEFAULT_BACKGROUND_AGENT_CONFIG,
27
+ DEFAULT_CROSS_SESSION_CONFIG,
26
28
  } from './defaults'
27
29
 
28
30
  const MIPHAM_HOME = join(homedir(), '.mipham')
@@ -376,3 +378,34 @@ export function loadBackgroundAgentConfig(cwd: string = process.cwd()): Backgrou
376
378
 
377
379
  return merged
378
380
  }
381
+
382
+ /**
383
+ * Load cross-session messaging configuration from the same config sources.
384
+ * Merges project-level over user-level. Returns defaults if no section present.
385
+ */
386
+ export function loadCrossSessionConfig(cwd: string = process.cwd()): CrossSessionConfig {
387
+ const configPath = join(cwd, '.mipham', 'config.yml')
388
+ const userConfigPath = join(MIPHAM_HOME, 'config.yml')
389
+
390
+ let merged = { ...DEFAULT_CROSS_SESSION_CONFIG }
391
+
392
+ const paths = [userConfigPath, configPath] // project wins (loaded last)
393
+ for (const path of paths) {
394
+ try {
395
+ if (!existsSync(path)) continue
396
+ const raw = readFileSync(path, 'utf-8')
397
+ const parsed = parseYaml(raw) as Record<string, unknown>
398
+ const section = parsed.cross_session as Partial<CrossSessionConfig> | undefined
399
+ if (section) {
400
+ merged = {
401
+ crossSessionInbound: section.crossSessionInbound ?? merged.crossSessionInbound,
402
+ dialogExpiry: section.dialogExpiry ?? merged.dialogExpiry,
403
+ }
404
+ }
405
+ } catch {
406
+ // Silently skip malformed configs
407
+ }
408
+ }
409
+
410
+ return merged
411
+ }
@@ -0,0 +1,39 @@
1
+ import { CREDENTIAL_SENTINEL } from './types'
2
+ import type { CredentialMaskingConfig } from '../../shared/index.ts'
3
+
4
+ /**
5
+ * Filter sensitive environment variables before spawning a subprocess.
6
+ * Returns a sanitized copy of process.env with matching vars removed.
7
+ */
8
+ export function filterEnv(
9
+ env: Record<string, string | undefined>,
10
+ config: CredentialMaskingConfig,
11
+ ): Record<string, string | undefined> {
12
+ if (!config.enabled || !config.env_filter.enabled) return { ...env }
13
+
14
+ const filtered: Record<string, string | undefined> = {}
15
+ for (const [key, value] of Object.entries(env)) {
16
+ let blocked = false
17
+ for (const pattern of config.env_filter.patterns) {
18
+ try {
19
+ // Strip (?i) inline flags — JS uses the 'i' flag instead
20
+ const clean = pattern.replace(/^\(\?i\)/, '')
21
+ const regex = new RegExp(clean, 'i')
22
+ if (regex.test(key)) {
23
+ blocked = true
24
+ break
25
+ }
26
+ } catch {
27
+ // Invalid regex — skip
28
+ }
29
+ }
30
+ if (blocked) {
31
+ // Replace with sentinel so tools can detect they're filtered
32
+ filtered[key] = CREDENTIAL_SENTINEL
33
+ } else {
34
+ filtered[key] = value
35
+ }
36
+ }
37
+
38
+ return filtered
39
+ }
@@ -0,0 +1,35 @@
1
+ export { CREDENTIAL_SENTINEL } from './types'
2
+ export type { MaskingStrategy } from './types'
3
+
4
+ export { MaskingPipeline, getPipeline, maskContent } from './pipeline'
5
+ export { matchCredentialFile, matchPath, globToRegex } from './matcher'
6
+ export { filterEnv } from './env-filter'
7
+ export { maskOutput } from './output-scrub'
8
+
9
+ // Strategies
10
+ export { FullMaskingStrategy } from './strategies/full'
11
+ export { ExtractMaskingStrategy } from './strategies/extract'
12
+ export { JwtMaskingStrategy } from './strategies/jwt'
13
+ export { AwsMaskingStrategy } from './strategies/aws'
14
+
15
+ import { getPipeline } from './pipeline'
16
+ import { FullMaskingStrategy } from './strategies/full'
17
+ import { ExtractMaskingStrategy } from './strategies/extract'
18
+ import { JwtMaskingStrategy } from './strategies/jwt'
19
+ import { AwsMaskingStrategy } from './strategies/aws'
20
+
21
+ /**
22
+ * Initialize the masking pipeline with all built-in strategies.
23
+ * Call once at startup. Safe to call multiple times (idempotent).
24
+ */
25
+ let _initialized = false
26
+
27
+ export function initializePipeline(): void {
28
+ if (_initialized) return
29
+ const pipeline = getPipeline()
30
+ pipeline.register(new FullMaskingStrategy())
31
+ pipeline.register(new ExtractMaskingStrategy())
32
+ pipeline.register(new JwtMaskingStrategy())
33
+ pipeline.register(new AwsMaskingStrategy())
34
+ _initialized = true
35
+ }
@@ -0,0 +1,106 @@
1
+ import { homedir } from 'node:os'
2
+ import type { CredentialMaskingConfig, CredentialFileRule } from '../../shared/index.ts'
3
+
4
+ /**
5
+ * Check if a file path matches any credential file rule.
6
+ * Returns the matching rule, or null if no match.
7
+ */
8
+ export function matchCredentialFile(
9
+ filePath: string,
10
+ config: CredentialMaskingConfig,
11
+ ): CredentialFileRule | null {
12
+ if (!config.enabled || config.files.length === 0) return null
13
+
14
+ const expanded = expandHome(filePath)
15
+ for (const rule of config.files) {
16
+ if (matchPath(expanded, rule.path)) {
17
+ return rule
18
+ }
19
+ }
20
+ return null
21
+ }
22
+
23
+ // ── Internal helpers ──
24
+
25
+ /**
26
+ * Expand ~ to the user's home directory.
27
+ */
28
+ export function expandHome(p: string): string {
29
+ if (p.startsWith('~')) {
30
+ return homedir() + p.slice(1)
31
+ }
32
+ return p
33
+ }
34
+
35
+ /**
36
+ * Match a file path against a glob-like pattern.
37
+ * Supports:
38
+ * - ** globstar (matches any number of path segments)
39
+ * - * wildcard within a single path segment
40
+ * - ? single char
41
+ * - ~ home directory expansion
42
+ * - basename-only matching (e.g. ".env*" matches any .env file)
43
+ */
44
+ export function matchPath(filePath: string, pattern: string): boolean {
45
+ const expandedPattern = expandHome(pattern)
46
+ const normalizedFile = filePath.replace(/\\/g, '/')
47
+ const normalizedPattern = expandedPattern.replace(/\\/g, '/')
48
+
49
+ if (normalizedFile === normalizedPattern) return true
50
+
51
+ // File ends with pattern (for relative basename matching like ".env*")
52
+ if (normalizedFile.endsWith('/' + normalizedPattern)) return true
53
+
54
+ const regex = globToRegex(normalizedPattern)
55
+ if (regex.test(normalizedFile)) return true
56
+
57
+ // Basename-only matching: patterns without path separators match
58
+ // against the last component of the file path (e.g. ".env*" matches any .env file)
59
+ if (!normalizedPattern.includes('/')) {
60
+ const basename = normalizedFile.split('/').pop() || normalizedFile
61
+ if (regex.test(basename)) return true
62
+ }
63
+
64
+ return false
65
+ }
66
+
67
+ /**
68
+ * Convert a glob pattern to a RegExp.
69
+ * - ** → .* (matches any path segments)
70
+ * - * → [^/]* (matches within a single segment)
71
+ * - ? → . (single char)
72
+ * - All other special regex chars are escaped.
73
+ */
74
+ export function globToRegex(pattern: string): RegExp {
75
+ let regexStr = ''
76
+ let i = 0
77
+ while (i < pattern.length) {
78
+ const ch = pattern[i]!
79
+ if (ch === '*' && pattern[i + 1] === '*') {
80
+ // ** — match zero or more path segments
81
+ regexStr += '.*'
82
+ i += 2
83
+ // Skip trailing slash after **
84
+ if (pattern[i] === '/') i++
85
+ continue
86
+ }
87
+ if (ch === '*') {
88
+ regexStr += '[^/]*'
89
+ i++
90
+ continue
91
+ }
92
+ if (ch === '?') {
93
+ regexStr += '.'
94
+ i++
95
+ continue
96
+ }
97
+ // Escape regex special chars
98
+ if ('\\^$.|+()[]{}'.includes(ch)) {
99
+ regexStr += '\\' + ch
100
+ } else {
101
+ regexStr += ch
102
+ }
103
+ i++
104
+ }
105
+ return new RegExp('^' + regexStr + '$')
106
+ }