@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
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@miphamai/cli",
3
- "version": "0.85.3",
3
+ "version": "0.85.4",
4
4
  "description": "Mipham Code — Multi-model open-core intelligent coding terminal by MiphamAI",
5
5
  "keywords": [
6
6
  "ai",
@@ -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> [--user] ──
182
- const positional = args.filter((a) => !a.startsWith('--'))
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 = positional[0]
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
- // A rule that can't match is worse than no rule: it reads as protection
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> [--user]\n\n` +
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 (verb !== 'remove' && !rule) {
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
- if (invalid && verb !== 'remove') {
204
- return { content: `Invalid rule "${rule}": ${invalid}.\n\n${usage}` }
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 removed = removeSettingsRule(rule!, scope)
211
- if (!removed) {
212
- return { content: `No rule "${rule}" in ${settingsPathFor(scope)}.` }
213
- }
214
- perm.removeRule(rule!)
215
- return {
216
- content: `Removed from ${removed.path}\n\npermissions.${removed.key}:\n ${rule}`,
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
- const path = addSettingsRule(verb, rule!, scope)
221
- if (verb === 'allow') perm.allow(rule!)
222
- else perm.deny(rule!)
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 ${rule}\n\n` +
227
- `This rule persists across sessions and applies from now on.`,
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: 200,
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: `classifier response unreadable: ${parsed.detail}`,
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.3' as const
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