@miphamai/cli 0.26.0 → 0.27.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/package.json +1 -1
- package/src/core/permission.ts +65 -10
- package/src/tools/agent/enter-plan.ts +5 -5
- package/src/tools/agent/exit-plan.ts +36 -27
- package/src/tools/exec/bash.ts +113 -0
package/package.json
CHANGED
package/src/core/permission.ts
CHANGED
|
@@ -9,6 +9,53 @@ import type { PermissionRuleEntry } from '../shared/index.ts'
|
|
|
9
9
|
import { matchBashRule, compileRule } from './permission-rules'
|
|
10
10
|
import { loadPermissionConfig, nextMode, clampMode, MODE_CYCLE } from './permission-config'
|
|
11
11
|
|
|
12
|
+
/**
|
|
13
|
+
* Check if a Bash command is a "verification-only" command that should be
|
|
14
|
+
* auto-approved in acceptEdits mode. These are non-destructive read/check
|
|
15
|
+
* operations that form the core of the vibe coding edit→test→fix loop.
|
|
16
|
+
*/
|
|
17
|
+
function isVerificationCommand(input: Record<string, unknown>): boolean {
|
|
18
|
+
const cmd = (input.command as string) || ''
|
|
19
|
+
// Patterns for verification-only commands (no side effects on codebase)
|
|
20
|
+
const verifyPatterns = [
|
|
21
|
+
/\bpnpm\s+test\b/, // test runner
|
|
22
|
+
/\bpnpm\s+t\b/, // shorthand test
|
|
23
|
+
/\bpnpm\s+typecheck\b/, // type checking
|
|
24
|
+
/\bpnpm\s+lint\b/, // linting
|
|
25
|
+
/\bpnpm\s+format:check\b/, // format check
|
|
26
|
+
/\bnpm\s+test\b/, // npm test
|
|
27
|
+
/\bnpm\s+run\s+test\b/, // npm run test
|
|
28
|
+
/\bvitest\b/, // vitest runner
|
|
29
|
+
/\bjest\b/, // jest runner
|
|
30
|
+
/\btsc\s+(?!init)/, // TypeScript compiler (not tsc init)
|
|
31
|
+
/\btsc\s+--noEmit\b/, // type check only
|
|
32
|
+
/\beslint\b/, // eslint
|
|
33
|
+
/\bprettier\s+--check\b/, // prettier check
|
|
34
|
+
/\bpytest\b/, // python test runner
|
|
35
|
+
/\bruff\s+check\b/, // python linter
|
|
36
|
+
/\bcargo\s+test\b/, // rust test
|
|
37
|
+
/\bcargo\s+check\b/, // rust check
|
|
38
|
+
/\bgo\s+test\b/, // go test
|
|
39
|
+
/\bgo\s+vet\b/, // go vet
|
|
40
|
+
/\bmake\s+test\b/, // make test
|
|
41
|
+
/\bgit\s+status\b/, // git status (read-only)
|
|
42
|
+
/\bgit\s+diff\b/, // git diff (read-only)
|
|
43
|
+
/\bgit\s+log\b/, // git log (read-only)
|
|
44
|
+
/\bgit\s+branch\b/, // git branch (read-only)
|
|
45
|
+
/\bls\b/, // list files
|
|
46
|
+
/\bcat\b/, // read file
|
|
47
|
+
/\bhead\b/, // read file start
|
|
48
|
+
/\btail\b/, // read file end
|
|
49
|
+
/\bwhich\b/, // find binary
|
|
50
|
+
/\becho\b/, // print text
|
|
51
|
+
/\bnode\s+-v\b/, // node version
|
|
52
|
+
/\bpython\s+--version\b/, // python version
|
|
53
|
+
/\bwhoami\b/, // current user
|
|
54
|
+
/\bpwd\b/, // current directory
|
|
55
|
+
]
|
|
56
|
+
return verifyPatterns.some((p) => p.test(cmd))
|
|
57
|
+
}
|
|
58
|
+
|
|
12
59
|
const VALID_MODES: Set<string> = new Set<string>(MODE_CYCLE)
|
|
13
60
|
|
|
14
61
|
export class PermissionSystem {
|
|
@@ -276,7 +323,7 @@ export class PermissionSystem {
|
|
|
276
323
|
}
|
|
277
324
|
|
|
278
325
|
// 5. Mode baseline
|
|
279
|
-
const baseline = this.modeBaseline(tool)
|
|
326
|
+
const baseline = this.modeBaseline(tool, input)
|
|
280
327
|
if (baseline !== 'mode-baseline') {
|
|
281
328
|
this.checkCache.set(cacheKey, baseline)
|
|
282
329
|
return baseline
|
|
@@ -323,21 +370,29 @@ export class PermissionSystem {
|
|
|
323
370
|
return rule.pattern === tool.name || rule.compiled.test(tool.name)
|
|
324
371
|
}
|
|
325
372
|
|
|
326
|
-
private modeBaseline(
|
|
373
|
+
private modeBaseline(
|
|
374
|
+
tool: ToolDefinition,
|
|
375
|
+
input?: Record<string, unknown>,
|
|
376
|
+
): PermissionLevel | 'mode-baseline' {
|
|
327
377
|
switch (this.mode) {
|
|
328
378
|
case 'default':
|
|
329
379
|
// Delegate to tool.permission (backward compat)
|
|
330
380
|
return 'mode-baseline'
|
|
331
381
|
|
|
332
382
|
case 'acceptEdits':
|
|
333
|
-
// Reads + file edits free; Bash
|
|
334
|
-
|
|
335
|
-
|
|
336
|
-
|
|
337
|
-
|
|
338
|
-
:
|
|
339
|
-
|
|
340
|
-
|
|
383
|
+
// Reads + file edits free; Bash auto-approved for verification commands
|
|
384
|
+
if (tool.category === 'file' && tool.name !== 'Bash') {
|
|
385
|
+
return 'bypass'
|
|
386
|
+
}
|
|
387
|
+
if (tool.name === 'Bash') {
|
|
388
|
+
// Vibe coding fix: auto-approve verification commands
|
|
389
|
+
// so the edit→test→fix loop isn't interrupted by permission prompts
|
|
390
|
+
if (input && isVerificationCommand(input)) {
|
|
391
|
+
return 'bypass'
|
|
392
|
+
}
|
|
393
|
+
return 'ask'
|
|
394
|
+
}
|
|
395
|
+
return 'ask'
|
|
341
396
|
|
|
342
397
|
case 'plan':
|
|
343
398
|
// Only reads, no writes or executes
|
|
@@ -80,12 +80,12 @@ export const enterPlanModeTool: ToolDefinition = {
|
|
|
80
80
|
' ⚠️ All other tools — require confirmation',
|
|
81
81
|
'',
|
|
82
82
|
'Design your approach, explore the codebase, then:',
|
|
83
|
-
' • Use ExitPlanMode to submit your plan for
|
|
84
|
-
' •
|
|
83
|
+
' • Use ExitPlanMode to submit your plan for user review',
|
|
84
|
+
' • Present your plan and ask for explicit approval',
|
|
85
|
+
' • The user must say "approved" before you switch to acceptEdits mode',
|
|
85
86
|
'',
|
|
86
|
-
'
|
|
87
|
-
'
|
|
88
|
-
' approved: false → revert to default mode',
|
|
87
|
+
'⚠️ You CANNOT self-approve your plan. The user must explicitly confirm.',
|
|
88
|
+
' After ExitPlanMode, wait for user approval before making code changes.',
|
|
89
89
|
].join('\n'),
|
|
90
90
|
}
|
|
91
91
|
},
|
|
@@ -1,53 +1,62 @@
|
|
|
1
|
+
import { readFileSync } from 'node:fs'
|
|
2
|
+
import { join } from 'node:path'
|
|
1
3
|
import type { ToolDefinition } from '../../shared/index.ts'
|
|
2
4
|
|
|
3
5
|
export const exitPlanModeTool: ToolDefinition = {
|
|
4
6
|
name: 'ExitPlanMode',
|
|
5
7
|
description:
|
|
6
|
-
'Exit plan mode and
|
|
7
|
-
'
|
|
8
|
-
'
|
|
9
|
-
'
|
|
8
|
+
'Exit plan mode and present your plan for user approval. ' +
|
|
9
|
+
'This tool does NOT switch to implementation mode — the user must explicitly approve first. ' +
|
|
10
|
+
'After calling this, present your plan and ask the user to confirm. ' +
|
|
11
|
+
'The user can approve by saying "approved" or "/approve", or by cycling to acceptEdits mode with Shift+Tab.',
|
|
10
12
|
category: 'agent',
|
|
11
13
|
permission: 'auto',
|
|
12
14
|
parameters: {
|
|
13
15
|
type: 'object',
|
|
14
16
|
properties: {
|
|
15
|
-
|
|
16
|
-
type: '
|
|
17
|
+
planFile: {
|
|
18
|
+
type: 'string',
|
|
17
19
|
description:
|
|
18
|
-
'
|
|
20
|
+
'Path to the plan file you wrote (e.g., .mipham/plans/plan-2026-08-10T12-00-00.md). If omitted, the most recent plan file is used.',
|
|
19
21
|
},
|
|
20
22
|
},
|
|
21
|
-
required: [
|
|
23
|
+
required: [],
|
|
22
24
|
},
|
|
23
|
-
async execute(params,
|
|
24
|
-
const
|
|
25
|
+
async execute(params, ctx) {
|
|
26
|
+
const planDir = join(ctx.cwd, '.mipham', 'plans')
|
|
27
|
+
const planFile = (params.planFile as string) || ''
|
|
25
28
|
|
|
26
|
-
|
|
27
|
-
|
|
28
|
-
|
|
29
|
-
|
|
30
|
-
|
|
31
|
-
|
|
32
|
-
'✓ Exiting plan mode.',
|
|
33
|
-
'✓ Switching to acceptEdits mode — reads and file edits are auto-approved.',
|
|
34
|
-
'',
|
|
35
|
-
'You can now implement the plan. The plan file is in .mipham/plans/.',
|
|
36
|
-
'',
|
|
37
|
-
'Use Shift+Tab to cycle permission modes if you need to change.',
|
|
38
|
-
].join('\n'),
|
|
29
|
+
// Try to read the plan to confirm it exists
|
|
30
|
+
let planContent = ''
|
|
31
|
+
try {
|
|
32
|
+
const planPath = planFile || ''
|
|
33
|
+
if (planPath) {
|
|
34
|
+
planContent = readFileSync(planPath, 'utf-8')
|
|
39
35
|
}
|
|
36
|
+
} catch {
|
|
37
|
+
// Plan file not found — still exit plan mode
|
|
40
38
|
}
|
|
41
39
|
|
|
42
40
|
return {
|
|
43
41
|
success: true,
|
|
44
42
|
content: [
|
|
45
|
-
'── Plan
|
|
43
|
+
'── Plan Ready for Review ──',
|
|
46
44
|
'',
|
|
47
|
-
'✓
|
|
45
|
+
'✓ Exiting plan mode.',
|
|
46
|
+
'✓ Plan file saved. Present your plan to the user now.',
|
|
48
47
|
'',
|
|
49
|
-
'
|
|
50
|
-
'
|
|
48
|
+
'⚠️ IMPORTANT: You are still in limited permission mode.',
|
|
49
|
+
' The user must explicitly approve before you can make changes.',
|
|
50
|
+
'',
|
|
51
|
+
'Next steps:',
|
|
52
|
+
' 1. Present your plan to the user (summarize key decisions)',
|
|
53
|
+
' 2. Ask: "Does this plan look good? Reply approved to begin."',
|
|
54
|
+
' 3. Wait for the user to explicitly say "approved" or "/approve"',
|
|
55
|
+
' 4. Only then switch to acceptEdits mode (Shift+Tab or user action)',
|
|
56
|
+
'',
|
|
57
|
+
'DO NOT start implementing until the user explicitly approves.',
|
|
58
|
+
'DO NOT call ExitPlanMode with approved:true — that parameter no longer exists.',
|
|
59
|
+
planContent ? `\n── Plan Content (for reference) ──\n\n${planContent.slice(0, 3000)}` : '',
|
|
51
60
|
].join('\n'),
|
|
52
61
|
}
|
|
53
62
|
},
|
package/src/tools/exec/bash.ts
CHANGED
|
@@ -185,6 +185,105 @@ export function detectViolations(stderr: string): string[] {
|
|
|
185
185
|
return violations
|
|
186
186
|
}
|
|
187
187
|
|
|
188
|
+
/**
|
|
189
|
+
* Vibe coding: Parse stderr output for error locations (file path + line + column).
|
|
190
|
+
* Supports common tool formats: TypeScript, ESLint, pytest, Rust, Go, Prettier, etc.
|
|
191
|
+
* Returns up to 10 unique locations sorted by file then line.
|
|
192
|
+
*/
|
|
193
|
+
interface ErrorLocation {
|
|
194
|
+
file: string
|
|
195
|
+
line: number
|
|
196
|
+
col?: number
|
|
197
|
+
message?: string
|
|
198
|
+
}
|
|
199
|
+
|
|
200
|
+
function parseErrorLocations(stderr: string): ErrorLocation[] {
|
|
201
|
+
const locations: ErrorLocation[] = []
|
|
202
|
+
|
|
203
|
+
// TypeScript / ESLint / Prettier: path(line,col): message
|
|
204
|
+
// e.g., src/foo.ts(42,10): error TS2304: Cannot find name 'foo'
|
|
205
|
+
const tsPattern = /([^\s(]+)\((\d+),(\d+)\):\s*(.+)/g
|
|
206
|
+
let match: RegExpExecArray | null
|
|
207
|
+
let m: RegExpExecArray | null
|
|
208
|
+
while ((m = tsPattern.exec(stderr)) !== null) {
|
|
209
|
+
locations.push({
|
|
210
|
+
file: m[1]!,
|
|
211
|
+
line: parseInt(m[2]!),
|
|
212
|
+
col: parseInt(m[3]!),
|
|
213
|
+
message: (m[4] || '').slice(0, 120),
|
|
214
|
+
})
|
|
215
|
+
}
|
|
216
|
+
|
|
217
|
+
// pytest / Python: path:line: message
|
|
218
|
+
// e.g., tests/test_foo.py:42: AssertionError: ...
|
|
219
|
+
const pyPattern = /([^\s:]+\.py):(\d+):\s*(.+)/g
|
|
220
|
+
while ((match = pyPattern.exec(stderr)) !== null) {
|
|
221
|
+
const pm = match
|
|
222
|
+
locations.push({
|
|
223
|
+
file: pm[1]!,
|
|
224
|
+
line: parseInt(pm[2]!),
|
|
225
|
+
message: (pm[3] || '').slice(0, 120),
|
|
226
|
+
})
|
|
227
|
+
}
|
|
228
|
+
|
|
229
|
+
// Rust: --> path:line:col
|
|
230
|
+
// e.g., --> src/main.rs:42:10
|
|
231
|
+
const rustPattern = /-->\s*([^\s:]+):(\d+):(\d+)/g
|
|
232
|
+
while ((match = rustPattern.exec(stderr)) !== null) {
|
|
233
|
+
const rm = match
|
|
234
|
+
locations.push({
|
|
235
|
+
file: rm[1]!,
|
|
236
|
+
line: parseInt(rm[2]!),
|
|
237
|
+
col: parseInt(rm[3]!),
|
|
238
|
+
})
|
|
239
|
+
}
|
|
240
|
+
|
|
241
|
+
// Go: path:line:col: message
|
|
242
|
+
// e.g., ./main.go:42:10: undefined: foo
|
|
243
|
+
const goPattern = /([^\s:]+\.go):(\d+):(\d+):\s*(.+)/g
|
|
244
|
+
while ((match = goPattern.exec(stderr)) !== null) {
|
|
245
|
+
const gm2 = match
|
|
246
|
+
locations.push({
|
|
247
|
+
file: gm2[1]!,
|
|
248
|
+
line: parseInt(gm2[2]!),
|
|
249
|
+
col: parseInt(gm2[3]!),
|
|
250
|
+
message: (gm2[4] || '').slice(0, 120),
|
|
251
|
+
})
|
|
252
|
+
}
|
|
253
|
+
|
|
254
|
+
// Generic: path:line (any file extension)
|
|
255
|
+
// e.g., src/foo.ts:42
|
|
256
|
+
const genericPattern = /([^\s:]+\.[a-zA-Z]{1,6}):(\d+)\b/g
|
|
257
|
+
while ((match = genericPattern.exec(stderr)) !== null) {
|
|
258
|
+
const gm = match
|
|
259
|
+
const file = gm[1]!
|
|
260
|
+
// Skip if we already have this exact location from a more specific pattern
|
|
261
|
+
const alreadyHave = locations.some((l) => l.file === file && l.line === parseInt(gm[2]!))
|
|
262
|
+
if (!alreadyHave) {
|
|
263
|
+
locations.push({ file, line: parseInt(match[2]!) })
|
|
264
|
+
}
|
|
265
|
+
}
|
|
266
|
+
|
|
267
|
+
// Deduplicate and sort: same file+line → keep first
|
|
268
|
+
const seen = new Set<string>()
|
|
269
|
+
const unique: ErrorLocation[] = []
|
|
270
|
+
for (const loc of locations) {
|
|
271
|
+
const key = `${loc.file}:${loc.line}`
|
|
272
|
+
if (!seen.has(key)) {
|
|
273
|
+
seen.add(key)
|
|
274
|
+
unique.push(loc)
|
|
275
|
+
}
|
|
276
|
+
}
|
|
277
|
+
|
|
278
|
+
// Sort by file path then line number
|
|
279
|
+
unique.sort((a, b) => {
|
|
280
|
+
const fileCmp = a.file.localeCompare(b.file)
|
|
281
|
+
return fileCmp !== 0 ? fileCmp : a.line - b.line
|
|
282
|
+
})
|
|
283
|
+
|
|
284
|
+
return unique.slice(0, 10)
|
|
285
|
+
}
|
|
286
|
+
|
|
188
287
|
export const bashTool: ToolDefinition = {
|
|
189
288
|
name: 'Bash',
|
|
190
289
|
description:
|
|
@@ -283,6 +382,20 @@ export const bashTool: ToolDefinition = {
|
|
|
283
382
|
if (violations.length > 0) {
|
|
284
383
|
errorContent += '\n\n── Sandbox Violations ──\n' + violations.join('\n')
|
|
285
384
|
}
|
|
385
|
+
// Vibe coding fix: auto-parse error locations from stderr
|
|
386
|
+
const errorLocations = parseErrorLocations(rawStderr)
|
|
387
|
+
if (errorLocations.length > 0) {
|
|
388
|
+
errorContent +=
|
|
389
|
+
'\n\n── Error Locations (for quick fix) ──\n' +
|
|
390
|
+
errorLocations
|
|
391
|
+
.map(
|
|
392
|
+
(l) =>
|
|
393
|
+
` ${l.file}:${l.line}` +
|
|
394
|
+
(l.col ? `:${l.col}` : '') +
|
|
395
|
+
(l.message ? ` — ${l.message}` : ''),
|
|
396
|
+
)
|
|
397
|
+
.join('\n')
|
|
398
|
+
}
|
|
286
399
|
return {
|
|
287
400
|
success: false,
|
|
288
401
|
content: errorContent,
|