@miphamai/cli 0.85.3 → 0.85.4
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
CHANGED
package/src/commands/project.ts
CHANGED
|
@@ -174,57 +174,108 @@ Run /setup for the full wizard, or /config to view current settings.`,
|
|
|
174
174
|
}
|
|
175
175
|
}
|
|
176
176
|
|
|
177
|
+
/**
|
|
178
|
+
* Split the rule argument(s) out of the whitespace-split slash-command args.
|
|
179
|
+
*
|
|
180
|
+
* Every surface that advertises this command prints the rule **quoted** — the denial
|
|
181
|
+
* messages (`i18n-core/locales/{en-US,zh-CN}.json`), this command's usage line and its
|
|
182
|
+
* examples, and the `config.yml` sample — and the narrower form the message recommends
|
|
183
|
+
* (`Bash(npm test)`) carries a space. Args arrive split on whitespace with the quotes
|
|
184
|
+
* still in them, so the rule is re-joined and split on quotes here instead. Without
|
|
185
|
+
* this the command rejects the exact spelling it tells the user to type:
|
|
186
|
+
* `/permissions allow "Git"` → `Invalid rule ""Git"": not a single tool name.`
|
|
187
|
+
*/
|
|
188
|
+
function parseRuleArgs(args: string[]): { rules: string[]; unbalanced: boolean } {
|
|
189
|
+
const rules: string[] = []
|
|
190
|
+
let current = ''
|
|
191
|
+
let quote: string | null = null
|
|
192
|
+
|
|
193
|
+
for (const ch of args.join(' ')) {
|
|
194
|
+
if (quote) {
|
|
195
|
+
if (ch === quote) quote = null
|
|
196
|
+
else current += ch
|
|
197
|
+
} else if (ch === '"' || ch === "'") {
|
|
198
|
+
quote = ch
|
|
199
|
+
} else if (/\s/.test(ch)) {
|
|
200
|
+
if (current) rules.push(current)
|
|
201
|
+
current = ''
|
|
202
|
+
} else {
|
|
203
|
+
current += ch
|
|
204
|
+
}
|
|
205
|
+
}
|
|
206
|
+
if (current) rules.push(current)
|
|
207
|
+
|
|
208
|
+
return { rules, unbalanced: quote !== null }
|
|
209
|
+
}
|
|
210
|
+
|
|
177
211
|
const permissionsCmd: CommandHandler = async (ctx, args) => {
|
|
178
212
|
const c = ctx.engine.getContext()
|
|
179
213
|
const msgs = c.getMessages()
|
|
180
214
|
|
|
181
|
-
// ── Rule persistence: allow/deny/remove <rule
|
|
182
|
-
|
|
215
|
+
// ── Rule persistence: allow/deny/remove <rule>... [--user] ──
|
|
216
|
+
// `--user` is matched exactly: a rule fragment may legitimately begin with `--`
|
|
217
|
+
// (`Bash(--version)`), and dropping it as "a flag" would corrupt the rule.
|
|
218
|
+
const rest = args.filter((a) => a !== '--user')
|
|
183
219
|
const scope: 'project' | 'user' = args.includes('--user') ? 'user' : 'project'
|
|
184
|
-
const verb =
|
|
185
|
-
const rule = positional[1]
|
|
220
|
+
const verb = rest[0]
|
|
186
221
|
|
|
187
222
|
if (verb === 'allow' || verb === 'deny' || verb === 'remove') {
|
|
188
223
|
const { validateRulePattern } = await import('../core/permission-rules')
|
|
189
224
|
const { addSettingsRule, removeSettingsRule, settingsPathFor } =
|
|
190
225
|
await import('../config/loader')
|
|
191
226
|
|
|
192
|
-
|
|
193
|
-
// that isn't there. Validate before writing.
|
|
194
|
-
const invalid = validateRulePattern(rule ?? '')
|
|
227
|
+
const { rules, unbalanced } = parseRuleArgs(rest.slice(1))
|
|
195
228
|
const usage =
|
|
196
|
-
`Usage: /permissions <allow|deny|remove> <rule
|
|
197
|
-
` rule Tool pattern — "Bash" or "Bash(npm test)".\n` +
|
|
229
|
+
`Usage: /permissions <allow|deny|remove> <rule>... [--user]\n\n` +
|
|
230
|
+
` rule Tool pattern — "Bash" or "Bash(npm test)". Quote it if it has spaces.\n` +
|
|
198
231
|
` --user Write to ~/.mipham/settings.json instead of .mipham/settings.json.`
|
|
199
232
|
|
|
200
|
-
if (
|
|
233
|
+
if (unbalanced) {
|
|
234
|
+
return { content: `Unbalanced quote in rule.\n\n${usage}` }
|
|
235
|
+
}
|
|
236
|
+
if (rules.length === 0) {
|
|
201
237
|
return { content: `Missing rule.\n\n${usage}` }
|
|
202
238
|
}
|
|
203
|
-
|
|
204
|
-
|
|
239
|
+
// A rule that can't match is worse than no rule: it reads as protection
|
|
240
|
+
// that isn't there. Validate every rule before writing any of them.
|
|
241
|
+
if (verb !== 'remove') {
|
|
242
|
+
for (const rule of rules) {
|
|
243
|
+
const invalid = validateRulePattern(rule)
|
|
244
|
+
if (invalid) {
|
|
245
|
+
return { content: `Invalid rule "${rule}": ${invalid}.\n\n${usage}` }
|
|
246
|
+
}
|
|
247
|
+
}
|
|
205
248
|
}
|
|
206
249
|
|
|
207
250
|
const perm = ctx.engine.getPermission()
|
|
208
251
|
|
|
209
252
|
if (verb === 'remove') {
|
|
210
|
-
const
|
|
211
|
-
|
|
212
|
-
|
|
213
|
-
|
|
214
|
-
|
|
215
|
-
|
|
216
|
-
|
|
253
|
+
const parts: string[] = []
|
|
254
|
+
for (const rule of rules) {
|
|
255
|
+
const removed = removeSettingsRule(rule, scope)
|
|
256
|
+
if (!removed) {
|
|
257
|
+
parts.push(`No rule "${rule}" in ${settingsPathFor(scope)}.`)
|
|
258
|
+
continue
|
|
259
|
+
}
|
|
260
|
+
perm.removeRule(rule)
|
|
261
|
+
parts.push(`Removed from ${removed.path}\n\npermissions.${removed.key}:\n ${rule}`)
|
|
217
262
|
}
|
|
263
|
+
return { content: parts.join('\n\n') }
|
|
218
264
|
}
|
|
219
265
|
|
|
220
|
-
|
|
221
|
-
|
|
222
|
-
|
|
266
|
+
let path = ''
|
|
267
|
+
for (const rule of rules) {
|
|
268
|
+
path = addSettingsRule(verb, rule, scope)
|
|
269
|
+
if (verb === 'allow') perm.allow(rule)
|
|
270
|
+
else perm.deny(rule)
|
|
271
|
+
}
|
|
223
272
|
return {
|
|
224
273
|
content:
|
|
225
274
|
`Added to ${path}\n\n` +
|
|
226
|
-
`permissions.${verb}:\n ${
|
|
227
|
-
|
|
275
|
+
`permissions.${verb}:\n${rules.map((r) => ` ${r}`).join('\n')}\n\n` +
|
|
276
|
+
(rules.length === 1
|
|
277
|
+
? `This rule persists across sessions and applies from now on.`
|
|
278
|
+
: `These rules persist across sessions and apply from now on.`),
|
|
228
279
|
}
|
|
229
280
|
}
|
|
230
281
|
|
|
@@ -399,11 +399,21 @@ export class LlmPermissionClassifier implements PermissionClassifier {
|
|
|
399
399
|
|
|
400
400
|
let text = ''
|
|
401
401
|
let streamError: string | undefined
|
|
402
|
+
let truncated = false
|
|
402
403
|
try {
|
|
403
404
|
for await (const chunk of this.llm.chat({
|
|
404
405
|
model: this.config.resolveModel(),
|
|
405
406
|
messages: [{ role: 'user', content: prompt }],
|
|
406
|
-
maxTokens
|
|
407
|
+
// NO `maxTokens`. This cap is shared with the model's **thinking**, and the
|
|
408
|
+
// configured model may be a reasoning one: `reasoning_content` is billed
|
|
409
|
+
// against `max_tokens` but is not what the loop below accumulates, so a cap
|
|
410
|
+
// sized for the 17-character reply starves the reply itself. Measured
|
|
411
|
+
// 2026-09-24 against the configured `deepseek-v4-pro` on three realistic
|
|
412
|
+
// calls: ~880 chars of reasoning consumed the whole budget, `finish_reason`
|
|
413
|
+
// came back `length`, the visible answer was empty 3/3, and every one of
|
|
414
|
+
// those calls was held back as an unreadable reply — i.e. precisely the calls
|
|
415
|
+
// worth classifying are the ones that failed. The provider's own default
|
|
416
|
+
// (`req.maxTokens || declaredMaxOutput || 8192`) is the budget now.
|
|
407
417
|
temperature: 0,
|
|
408
418
|
signal: controller.signal,
|
|
409
419
|
})) {
|
|
@@ -411,6 +421,10 @@ export class LlmPermissionClassifier implements PermissionClassifier {
|
|
|
411
421
|
// An in-stream error would otherwise look exactly like an empty reply —
|
|
412
422
|
// and an empty reply is what a *denial* looks like. Name it instead.
|
|
413
423
|
else if (chunk.type === 'error') streamError = chunk.error ?? 'provider error'
|
|
424
|
+
// The provider sets this for `finish_reason: 'length'`. Reading it is the
|
|
425
|
+
// difference between "the reply was cut off at the cap" and "the reply was
|
|
426
|
+
// unreadable" — two very different things to hand a user.
|
|
427
|
+
else if (chunk.type === 'stop' && chunk.truncated) truncated = true
|
|
414
428
|
}
|
|
415
429
|
} catch (error) {
|
|
416
430
|
return {
|
|
@@ -435,10 +449,14 @@ export class LlmPermissionClassifier implements PermissionClassifier {
|
|
|
435
449
|
return { allow: false, rule: parsed.rule, reason: parsed.reason }
|
|
436
450
|
}
|
|
437
451
|
// Unreadable reply ⇒ held back, and said to be retryable — the model did not
|
|
438
|
-
// rule, so treating this as a policy refusal would be a lie.
|
|
452
|
+
// rule, so treating this as a policy refusal would be a lie. A reply the
|
|
453
|
+
// provider flagged as cut off gets named as such: "unreadable" sends the reader
|
|
454
|
+
// hunting for a malformed response when the cause was a token ceiling.
|
|
439
455
|
return {
|
|
440
456
|
allow: false,
|
|
441
|
-
reason:
|
|
457
|
+
reason: truncated
|
|
458
|
+
? `classifier reply was cut off at the output token cap before it ruled (${parsed.detail})`
|
|
459
|
+
: `classifier response unreadable: ${parsed.detail}`,
|
|
442
460
|
retryable: true,
|
|
443
461
|
}
|
|
444
462
|
}
|
|
@@ -9,7 +9,7 @@
|
|
|
9
9
|
export const PACKAGE_NAME = '@miphamai/cli' as const
|
|
10
10
|
|
|
11
11
|
/** 当前发布版本 */
|
|
12
|
-
export const PACKAGE_VERSION = '0.85.
|
|
12
|
+
export const PACKAGE_VERSION = '0.85.4' as const
|
|
13
13
|
|
|
14
14
|
/** npm install 全局安装命令 */
|
|
15
15
|
export const NPM_INSTALL_COMMAND = `npm install -g ${PACKAGE_NAME}` as const
|