@miphamai/cli 0.4.0 → 0.5.1

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (60) hide show
  1. package/bin/mipham +51 -24
  2. package/bin/mipham.ts +186 -0
  3. package/package.json +1 -1
  4. package/src/agent/agent-context.ts +56 -0
  5. package/src/agent/agent-registry.ts +134 -0
  6. package/src/agent/sub-agent.ts +73 -88
  7. package/src/agent/types.ts +39 -0
  8. package/src/agent-view/agent-view-manager.ts +217 -0
  9. package/src/agent-view/dashboard.tsx +218 -0
  10. package/src/agent-view/session-peek.tsx +72 -0
  11. package/src/agent-view/session-row.tsx +70 -0
  12. package/src/artifacts/server.ts +31 -0
  13. package/src/artifacts/versioning.ts +127 -0
  14. package/src/config/defaults.ts +2 -1
  15. package/src/core/context-compact.ts +50 -0
  16. package/src/core/context-drain.ts +54 -0
  17. package/src/core/context-microcompact.ts +132 -0
  18. package/src/core/context-snip.ts +74 -0
  19. package/src/core/context-token.ts +108 -0
  20. package/src/core/context.ts +169 -0
  21. package/src/core/engine.ts +144 -3
  22. package/src/core/hooks-config.ts +52 -0
  23. package/src/core/hooks-executor.ts +113 -0
  24. package/src/core/hooks.ts +90 -30
  25. package/src/core/instructions.ts +11 -0
  26. package/src/core/memory/memory-loader.ts +23 -0
  27. package/src/core/memory/memory-manager.ts +192 -0
  28. package/src/core/memory/memory-writer.ts +72 -0
  29. package/src/core/permission-config.ts +34 -0
  30. package/src/core/permission-rules.ts +66 -0
  31. package/src/core/permission.ts +221 -36
  32. package/src/index.tsx +20 -0
  33. package/src/plugin/plugin-loader.ts +36 -0
  34. package/src/plugin/plugin-manager.ts +125 -0
  35. package/src/plugin/plugin-validator.ts +41 -0
  36. package/src/shared/types.ts +61 -1
  37. package/src/skills/fork-executor.ts +40 -0
  38. package/src/skills/loader.ts +46 -0
  39. package/src/tools/agent/agent.ts +20 -11
  40. package/src/tools/agent/skill.ts +32 -2
  41. package/src/tools/computer/app-launcher.ts +39 -0
  42. package/src/tools/computer/browser.ts +48 -0
  43. package/src/tools/computer/computer-use.ts +88 -0
  44. package/src/tools/computer/playwright.d.ts +7 -0
  45. package/src/tools/computer/screenshot.ts +57 -0
  46. package/src/tools/index.ts +3 -0
  47. package/src/tools/network/web-fetch.ts +1 -1
  48. package/src/ui/app.tsx +14 -3
  49. package/src/ui/commands.ts +101 -32
  50. package/src/ui/input.tsx +123 -8
  51. package/src/ui/vim-motions.ts +96 -0
  52. package/src/workflow/budget.ts +33 -0
  53. package/src/workflow/journal.ts +105 -0
  54. package/src/workflow/primitives/agent.ts +51 -0
  55. package/src/workflow/primitives/parallel.ts +8 -0
  56. package/src/workflow/primitives/phase.ts +19 -0
  57. package/src/workflow/primitives/pipeline.ts +24 -0
  58. package/src/workflow/runtime.ts +87 -0
  59. package/src/workflow/sandbox.ts +79 -0
  60. package/dist/mipham +0 -0
package/src/core/hooks.ts CHANGED
@@ -4,8 +4,23 @@ import type {
4
4
  HookContext,
5
5
  HookResult,
6
6
  ToolResult,
7
+ HookConfig,
7
8
  } from '../shared/index.ts'
8
9
 
10
+ /**
11
+ * Merge two permission decisions, keeping the more restrictive one.
12
+ * Priority: deny > defer > ask > allow
13
+ */
14
+ export function mergePermissionDecision(
15
+ current: HookResult['permissionDecision'],
16
+ incoming: NonNullable<HookResult['permissionDecision']>,
17
+ ): HookResult['permissionDecision'] {
18
+ const priority: Record<string, number> = { deny: 3, defer: 2, ask: 1, allow: 0 }
19
+ const currScore = current ? (priority[current] ?? 0) : -1
20
+ const incScore = priority[incoming] ?? 0
21
+ return incScore > currScore ? incoming : current
22
+ }
23
+
9
24
  export class HookEngine {
10
25
  private hooks: HookDefinition[] = []
11
26
 
@@ -19,18 +34,14 @@ export class HookEngine {
19
34
  )
20
35
  }
21
36
 
37
+ // ── Existing event executors ──
38
+
22
39
  async executePreToolUse(
23
40
  toolName: string,
24
41
  toolInput: Record<string, unknown>,
25
42
  sessionId: string,
26
43
  ): Promise<HookResult> {
27
- const ctx: HookContext = {
28
- event: 'PreToolUse',
29
- toolName,
30
- toolInput,
31
- sessionId,
32
- }
33
-
44
+ const ctx: HookContext = { event: 'PreToolUse', toolName, toolInput, sessionId }
34
45
  return this.runHooks('PreToolUse', toolName, ctx)
35
46
  }
36
47
 
@@ -40,45 +51,64 @@ export class HookEngine {
40
51
  toolResult: ToolResult,
41
52
  sessionId: string,
42
53
  ): Promise<HookResult> {
43
- const ctx: HookContext = {
44
- event: 'PostToolUse',
45
- toolName,
46
- toolInput,
47
- toolResult,
48
- sessionId,
49
- }
50
-
54
+ const ctx: HookContext = { event: 'PostToolUse', toolName, toolInput, toolResult, sessionId }
51
55
  return this.runHooks('PostToolUse', toolName, ctx)
52
56
  }
53
57
 
54
58
  async executeSessionStart(sessionId: string): Promise<HookResult> {
55
- const ctx: HookContext = {
56
- event: 'SessionStart',
57
- sessionId,
58
- }
59
-
59
+ const ctx: HookContext = { event: 'SessionStart', sessionId }
60
60
  return this.runHooks('SessionStart', undefined, ctx)
61
61
  }
62
62
 
63
63
  async executeSessionEnd(sessionId: string): Promise<HookResult> {
64
- const ctx: HookContext = {
65
- event: 'SessionEnd',
66
- sessionId,
67
- }
68
-
64
+ const ctx: HookContext = { event: 'SessionEnd', sessionId }
69
65
  return this.runHooks('SessionEnd', undefined, ctx)
70
66
  }
71
67
 
72
68
  async executeNotification(message: string, sessionId: string): Promise<HookResult> {
69
+ const ctx: HookContext = { event: 'Notification', sessionId, toolInput: { message } }
70
+ return this.runHooks('Notification', undefined, ctx)
71
+ }
72
+
73
+ // ── NEW event executors ──
74
+
75
+ /** Stop hook: fires when AI completes a turn. Can block to force continuation. */
76
+ async executeStop(sessionId: string): Promise<HookResult> {
77
+ const ctx: HookContext = { event: 'Stop', sessionId }
78
+ return this.runHooks('Stop', undefined, ctx)
79
+ }
80
+
81
+ /** UserPromptSubmit: fires when user submits input. Can block malicious input. */
82
+ async executeUserPromptSubmit(prompt: string, sessionId: string): Promise<HookResult> {
83
+ const ctx: HookContext = { event: 'UserPromptSubmit', sessionId, userPrompt: prompt }
84
+ return this.runHooks('UserPromptSubmit', undefined, ctx)
85
+ }
86
+
87
+ /** PreCompact: fires before context compaction. */
88
+ async executePreCompact(sessionId: string): Promise<HookResult> {
89
+ const ctx: HookContext = { event: 'PreCompact', sessionId }
90
+ return this.runHooks('PreCompact', undefined, ctx)
91
+ }
92
+
93
+ /** PostCompact: fires after context compaction. Can inject additional context. */
94
+ async executePostCompact(sessionId: string): Promise<HookResult> {
95
+ const ctx: HookContext = { event: 'PostCompact', sessionId }
96
+ return this.runHooks('PostCompact', undefined, ctx)
97
+ }
98
+
99
+ /** ConfigChange: fires when configuration changes. */
100
+ async executeConfigChange(key: string, value: unknown, sessionId: string): Promise<HookResult> {
73
101
  const ctx: HookContext = {
74
- event: 'Notification',
102
+ event: 'ConfigChange',
75
103
  sessionId,
76
- toolInput: { message },
104
+ configKey: key,
105
+ configValue: value,
77
106
  }
78
-
79
- return this.runHooks('Notification', undefined, ctx)
107
+ return this.runHooks('ConfigChange', undefined, ctx)
80
108
  }
81
109
 
110
+ // ── Core execution ──
111
+
82
112
  private async runHooks(
83
113
  event: HookEvent,
84
114
  toolName: string | undefined,
@@ -93,15 +123,45 @@ export class HookEngine {
93
123
  for (const hook of matching) {
94
124
  try {
95
125
  const hookResult = await hook.handler(ctx)
126
+
127
+ // Block on first deny — stops further hook execution
96
128
  if (!hookResult.allowed) {
97
- return hookResult // Block on first deny
129
+ return { ...hookResult }
130
+ }
131
+
132
+ // Merge permission decisions (deny > defer > ask > allow)
133
+ if (hookResult.permissionDecision) {
134
+ result.permissionDecision = mergePermissionDecision(
135
+ result.permissionDecision,
136
+ hookResult.permissionDecision,
137
+ )
138
+ }
139
+
140
+ // Stop hook: block decision takes precedence
141
+ if (hookResult.decision === 'block') {
142
+ result.decision = 'block'
143
+ result.reason = hookResult.reason || result.reason
98
144
  }
145
+
146
+ // Merge modified inputs
99
147
  if (hookResult.modifiedInput) {
100
148
  result.modifiedInput = {
101
149
  ...(result.modifiedInput || {}),
102
150
  ...hookResult.modifiedInput,
103
151
  }
104
152
  }
153
+
154
+ // Collect additional context from multiple hooks
155
+ if (hookResult.additionalContext) {
156
+ result.additionalContext = result.additionalContext
157
+ ? result.additionalContext + '\n' + hookResult.additionalContext
158
+ : hookResult.additionalContext
159
+ }
160
+
161
+ // PostToolUse: replace output if provided
162
+ if (hookResult.updatedOutput) {
163
+ result.updatedOutput = hookResult.updatedOutput
164
+ }
105
165
  } catch {
106
166
  // Hook failures do not block execution
107
167
  }
@@ -21,6 +21,7 @@ function parseFrontmatter(raw: string): FrontmatterResult {
21
21
 
22
22
  export class InstructionsLoader {
23
23
  private instructions: InstructionFile[] = []
24
+ private skillsReminder = ''
24
25
 
25
26
  loadAll(cwd: string): void {
26
27
  this.instructions = []
@@ -60,9 +61,19 @@ export class InstructionsLoader {
60
61
  parts.push(`<!-- ${levelLabel[inst.level] || inst.level} (${inst.path}) -->\n${inst.content}`)
61
62
  }
62
63
 
64
+ // Append skills reminder after all instructions
65
+ if (this.skillsReminder) {
66
+ parts.push(this.skillsReminder)
67
+ }
68
+
63
69
  return parts.join('\n\n---\n\n')
64
70
  }
65
71
 
72
+ /** Set the skills system-reminder block to inject into the system prompt. */
73
+ setSkillsReminder(reminder: string): void {
74
+ this.skillsReminder = reminder
75
+ }
76
+
66
77
  list(): InstructionFile[] {
67
78
  return [...this.instructions]
68
79
  }
@@ -0,0 +1,23 @@
1
+ import { MemoryManager } from './memory-manager'
2
+ import type { MemoryManager as MemoryManagerType } from './memory-manager'
3
+
4
+ const MEMORY_DIR = `${process.env.HOME || '~'}/.mipham/memory`
5
+
6
+ let instance: MemoryManagerType | null = null
7
+
8
+ export function getMemoryManager(): MemoryManagerType {
9
+ if (!instance) {
10
+ instance = new MemoryManager(MEMORY_DIR)
11
+ instance.loadAll()
12
+ }
13
+ return instance
14
+ }
15
+
16
+ /**
17
+ * Load relevant memories for the current session and inject as
18
+ * a system-reminder block. Called at SessionStart.
19
+ */
20
+ export function loadSessionMemories(context: string): string {
21
+ const mm = getMemoryManager()
22
+ return mm.buildSystemReminder(context)
23
+ }
@@ -0,0 +1,192 @@
1
+ import {
2
+ mkdirSync,
3
+ readdirSync,
4
+ readFileSync,
5
+ writeFileSync,
6
+ unlinkSync,
7
+ existsSync,
8
+ } from 'node:fs'
9
+ import { join, extname } from 'node:path'
10
+
11
+ export interface MemoryMetadata {
12
+ type: 'user' | 'feedback' | 'project' | 'reference'
13
+ relevance: string[]
14
+ }
15
+
16
+ export interface MemoryEntry {
17
+ name: string
18
+ description: string
19
+ metadata: MemoryMetadata
20
+ content: string
21
+ filePath: string
22
+ updatedAt: Date
23
+ }
24
+
25
+ const INDEX_FILE = 'MEMORY.md'
26
+
27
+ export class MemoryManager {
28
+ private memories = new Map<string, MemoryEntry>()
29
+
30
+ constructor(private memoryDir: string) {
31
+ mkdirSync(memoryDir, { recursive: true })
32
+ }
33
+
34
+ loadAll(): void {
35
+ this.memories.clear()
36
+ if (!existsSync(this.memoryDir)) return
37
+
38
+ let entries: string[] = []
39
+ try {
40
+ entries = readdirSync(this.memoryDir)
41
+ } catch {
42
+ return
43
+ }
44
+
45
+ for (const entry of entries) {
46
+ if (entry === INDEX_FILE || extname(entry) !== '.md') continue
47
+ const filePath = join(this.memoryDir, entry)
48
+ try {
49
+ const raw = readFileSync(filePath, 'utf-8')
50
+ const parsed = this.parseMemoryFile(raw, filePath)
51
+ if (parsed) {
52
+ this.memories.set(parsed.name, parsed)
53
+ }
54
+ } catch {
55
+ // skip unparseable
56
+ }
57
+ }
58
+ }
59
+
60
+ write(name: string, content: string, metadata: MemoryMetadata): void {
61
+ const fileName = `${name}.md`
62
+ const filePath = join(this.memoryDir, fileName)
63
+
64
+ const body = this.formatMemoryFile(name, metadata, content)
65
+ writeFileSync(filePath, body, 'utf-8')
66
+
67
+ const entry: MemoryEntry = {
68
+ name,
69
+ description: metadata.relevance.join(', '),
70
+ metadata,
71
+ content,
72
+ filePath,
73
+ updatedAt: new Date(),
74
+ }
75
+
76
+ this.memories.set(name, entry)
77
+ this.updateIndex()
78
+ }
79
+
80
+ recall(context: string, limit: number = 10): MemoryEntry[] {
81
+ const contextLower = context.toLowerCase()
82
+ const scored: Array<{ entry: MemoryEntry; score: number }> = []
83
+
84
+ for (const entry of this.memories.values()) {
85
+ let score = 0
86
+ // Match against relevance tags
87
+ for (const tag of entry.metadata.relevance) {
88
+ if (contextLower.includes(tag.toLowerCase())) score += 3
89
+ }
90
+ // Match against content keywords
91
+ const contentWords = entry.content.toLowerCase().split(/\s+/)
92
+ const contextWords = new Set(contextLower.split(/\s+/))
93
+ for (const word of contentWords) {
94
+ if (contextWords.has(word) && word.length > 3) score += 1
95
+ }
96
+ if (score > 0) {
97
+ scored.push({ entry, score })
98
+ }
99
+ }
100
+
101
+ scored.sort((a, b) => b.score - a.score)
102
+ return scored.slice(0, limit).map((s) => s.entry)
103
+ }
104
+
105
+ delete(name: string): void {
106
+ const entry = this.memories.get(name)
107
+ if (!entry) return
108
+
109
+ try {
110
+ unlinkSync(entry.filePath)
111
+ } catch {
112
+ // file may already be gone
113
+ }
114
+ this.memories.delete(name)
115
+ this.updateIndex()
116
+ }
117
+
118
+ buildSystemReminder(context: string, maxTokens: number = 5000): string {
119
+ const relevant = this.recall(context, 10)
120
+ if (relevant.length === 0) return ''
121
+
122
+ const lines: string[] = ['<system-reminder>', 'Relevant memories from previous sessions:']
123
+
124
+ let tokenBudget = 50 // opening tags
125
+ for (const entry of relevant) {
126
+ const line = `- ${entry.name}: ${entry.content.slice(0, 200)}`
127
+ const lineTokens = Math.ceil(line.length / 4)
128
+ if (tokenBudget + lineTokens > maxTokens) break
129
+ lines.push(line)
130
+ tokenBudget += lineTokens
131
+ }
132
+
133
+ lines.push('</system-reminder>')
134
+ return lines.join('\n')
135
+ }
136
+
137
+ private updateIndex(): void {
138
+ const lines: string[] = [
139
+ '# Memory Index',
140
+ '',
141
+ `_Last updated: ${new Date().toISOString()}_`,
142
+ '',
143
+ ]
144
+
145
+ for (const entry of this.memories.values()) {
146
+ lines.push(`- [${entry.name}](${entry.name}.md) — ${entry.description}`)
147
+ }
148
+
149
+ writeFileSync(join(this.memoryDir, INDEX_FILE), lines.join('\n') + '\n', 'utf-8')
150
+ }
151
+
152
+ private parseMemoryFile(raw: string, filePath: string): MemoryEntry | null {
153
+ const match = raw.match(/^---\n([\s\S]*?)\n---\n([\s\S]*)$/)
154
+ if (!match) return null
155
+
156
+ const frontmatter = match[1] || ''
157
+ const content = (match[2] || '').trim()
158
+
159
+ // Parse simple YAML-like frontmatter
160
+ const nameMatch = frontmatter.match(/name:\s*(\S+)/)
161
+ const descMatch = frontmatter.match(/description:\s*(.+)/)
162
+ const typeMatch = frontmatter.match(/type:\s*(\S+)/)
163
+ const relevanceMatch = frontmatter.match(/relevance:\s*\[(.+)\]/)
164
+
165
+ if (!nameMatch) return null
166
+
167
+ return {
168
+ name: nameMatch[1]!,
169
+ description: descMatch?.[1]?.trim() || '',
170
+ metadata: {
171
+ type: (typeMatch?.[1] as MemoryMetadata['type']) || 'reference',
172
+ relevance: relevanceMatch?.[1]?.split(',').map((s) => s.trim()) || [],
173
+ },
174
+ content,
175
+ filePath,
176
+ updatedAt: new Date(),
177
+ }
178
+ }
179
+
180
+ private formatMemoryFile(name: string, metadata: MemoryMetadata, content: string): string {
181
+ return `---
182
+ name: ${name}
183
+ description: ${metadata.relevance.join(', ')}
184
+ metadata:
185
+ type: ${metadata.type}
186
+ relevance: [${metadata.relevance.join(', ')}]
187
+ ---
188
+
189
+ ${content}
190
+ `
191
+ }
192
+ }
@@ -0,0 +1,72 @@
1
+ import type { MemoryManager } from './memory-manager'
2
+
3
+ const MEMORY_TRIGGERS = [
4
+ {
5
+ pattern: /(?:以后|今后|从现在开始|always|from now on|记住|remember)\s*[::]\s*(.+)/i,
6
+ type: 'user' as const,
7
+ },
8
+ {
9
+ pattern: /(?:偏好|prefer|preference|习惯)\s*[::]\s*(.+)/i,
10
+ type: 'user' as const,
11
+ },
12
+ {
13
+ pattern: /(?:项目|project)\s+(?:使用|uses?|采用)\s+(.+)/i,
14
+ type: 'project' as const,
15
+ },
16
+ {
17
+ pattern: /(?:决策|decided?|决定)\s*[::]\s*(.+)/i,
18
+ type: 'project' as const,
19
+ },
20
+ ]
21
+
22
+ /**
23
+ * Analyze the user's message for memory-worthy content and persist it.
24
+ */
25
+ export function analyzeForMemory(userMessage: string, memoryManager: MemoryManager): void {
26
+ for (const trigger of MEMORY_TRIGGERS) {
27
+ const match = userMessage.match(trigger.pattern)
28
+ if (match?.[1]) {
29
+ const content = match[1].trim()
30
+ const name = `auto-${trigger.type}-${Date.now()}`
31
+ const relevance = extractKeywords(content)
32
+
33
+ memoryManager.write(name, content, {
34
+ type: trigger.type === 'user' ? 'user' : 'project',
35
+ relevance,
36
+ })
37
+ }
38
+ }
39
+ }
40
+
41
+ function extractKeywords(text: string): string[] {
42
+ // Simple keyword extraction: words > 3 chars, no stop words
43
+ const stopWords = new Set([
44
+ 'this',
45
+ 'that',
46
+ 'with',
47
+ 'from',
48
+ 'have',
49
+ 'been',
50
+ 'were',
51
+ 'they',
52
+ 'will',
53
+ 'would',
54
+ 'could',
55
+ 'should',
56
+ 'about',
57
+ 'which',
58
+ 'their',
59
+ '使用',
60
+ '以后',
61
+ '现在',
62
+ '可以',
63
+ '需要',
64
+ '不要',
65
+ '应该',
66
+ '已经',
67
+ ])
68
+ return text
69
+ .split(/\s+/)
70
+ .filter((w) => w.length > 3 && !stopWords.has(w.toLowerCase()))
71
+ .slice(0, 5)
72
+ }
@@ -0,0 +1,34 @@
1
+ import type { PermissionConfig, PermissionMode } from '../shared/index.ts'
2
+
3
+ const DEFAULT_CONFIG: PermissionConfig = {
4
+ mode: 'default',
5
+ allow: [],
6
+ deny: [],
7
+ }
8
+
9
+ /**
10
+ * Load permission configuration from a settings object.
11
+ * Merges with defaults for missing fields.
12
+ */
13
+ export function loadPermissionConfig(raw: Partial<PermissionConfig> = {}): PermissionConfig {
14
+ return {
15
+ mode: (raw.mode as PermissionMode) || DEFAULT_CONFIG.mode,
16
+ allow: Array.isArray(raw.allow) ? raw.allow : [...DEFAULT_CONFIG.allow],
17
+ deny: Array.isArray(raw.deny) ? raw.deny : [...DEFAULT_CONFIG.deny],
18
+ }
19
+ }
20
+
21
+ /** Valid mode transition order for Shift+Tab cycling. */
22
+ export const MODE_CYCLE: PermissionMode[] = [
23
+ 'default',
24
+ 'acceptEdits',
25
+ 'plan',
26
+ 'auto',
27
+ 'dontAsk',
28
+ 'bypassPermissions',
29
+ ]
30
+
31
+ export function nextMode(current: PermissionMode): PermissionMode {
32
+ const idx = MODE_CYCLE.indexOf(current)
33
+ return MODE_CYCLE[(idx + 1) % MODE_CYCLE.length]!
34
+ }
@@ -0,0 +1,66 @@
1
+ import type { PermissionRuleEntry } from '../shared/index.ts'
2
+
3
+ /**
4
+ * Match a Bash(command_pattern) rule against an actual tool call.
5
+ *
6
+ * Pattern formats:
7
+ * "Bash" → matches any Bash call
8
+ * "Bash(git:*)" → matches "git status", "git diff --cached", etc.
9
+ * "Bash(npm test:*)" → matches "npm test -- --coverage"
10
+ * "Write(/etc/*)" → matches Write to /etc/passwd, /etc/hosts, etc.
11
+ */
12
+ export function matchBashRule(
13
+ pattern: string,
14
+ toolName: string,
15
+ toolInput: Record<string, unknown>,
16
+ ): boolean {
17
+ // Check if pattern has a parenthesized sub-pattern
18
+ const parenMatch = pattern.match(/^(\w+)\((.+)\)$/)
19
+ if (!parenMatch) {
20
+ // Plain tool name match: "Bash", "Write"
21
+ return toolName === pattern
22
+ }
23
+
24
+ const [, baseTool, subPattern] = parenMatch
25
+
26
+ if (toolName !== baseTool!) return false
27
+
28
+ // For Bash: match against the command string
29
+ if (baseTool === 'Bash') {
30
+ const cmd = String(toolInput.command || '')
31
+ return wildcardMatch(subPattern!, cmd.trim())
32
+ }
33
+
34
+ // For Write/Edit: match against the file_path
35
+ if (baseTool === 'Write' || baseTool === 'Edit') {
36
+ const path = String(toolInput.file_path || '')
37
+ return wildcardMatch(subPattern!, path)
38
+ }
39
+
40
+ return false
41
+ }
42
+
43
+ export function wildcardMatch(pattern: string, input: string): boolean {
44
+ const regexStr =
45
+ '^' +
46
+ pattern
47
+ .replace(/[.+^${}()|[\]\\*?]/g, '\\$&') // escape regex specials (incl. * and ?)
48
+ .replace(/:/g, '[:\\s]') // : → match colon or whitespace
49
+ .replace(/\\\*/g, '.*') // * → .*
50
+ .replace(/\\\?/g, '.') + // ? → .
51
+ '$'
52
+ return new RegExp(regexStr).test(input)
53
+ }
54
+
55
+ /** Compile a rule pattern string into a PermissionRuleEntry. */
56
+ export function compileRule(pattern: string, level: 'allow' | 'deny' | 'ask'): PermissionRuleEntry {
57
+ const regexStr =
58
+ '^' +
59
+ pattern
60
+ .replace(/[.+^${}()|[\]\\*?]/g, '\\$&')
61
+ .replace(/:/g, '[:\\s]')
62
+ .replace(/\\\*/g, '.*')
63
+ .replace(/\\\?/g, '.') +
64
+ '$'
65
+ return { pattern, level, compiled: new RegExp(regexStr) }
66
+ }