Skip to content

Commit a82fdd7

Browse files
committed
feat: 添加审批模式选择命令
1 parent 2fa75aa commit a82fdd7

6 files changed

Lines changed: 382 additions & 1 deletion

File tree

src/commands.ts

Lines changed: 2 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -227,6 +227,7 @@ import {
227227
import rateLimitOptions from './commands/rate-limit-options/index.js'
228228
import statusline from './commands/statusline.js'
229229
import effort from './commands/effort/index.js'
230+
import approval from './commands/approval/index.js'
230231
import stats from './commands/stats/index.js'
231232
// insights.ts is 113KB (3200 lines, includes diffLines/html rendering). Lazy
232233
// shim defers the heavy module until /insights is actually invoked.
@@ -299,6 +300,7 @@ const COMMANDS = memoize((): Command[] => [
299300
addDir,
300301
advisor,
301302
autonomy,
303+
approval,
302304
provider,
303305
agents,
304306
branch,
Lines changed: 62 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,62 @@
1+
import { describe, expect, test } from 'bun:test'
2+
import { formatApprovalMode, parseApprovalModeArg } from '../approvalModes.js'
3+
4+
describe('parseApprovalModeArg', () => {
5+
test('treats empty and status arguments as current mode requests', () => {
6+
expect(parseApprovalModeArg('')).toEqual({ type: 'current' })
7+
expect(parseApprovalModeArg('status')).toEqual({ type: 'current' })
8+
expect(parseApprovalModeArg('show')).toEqual({ type: 'current' })
9+
})
10+
11+
test('parses standard approval modes', () => {
12+
expect(parseApprovalModeArg('default')).toEqual({
13+
type: 'mode',
14+
mode: 'default',
15+
})
16+
expect(parseApprovalModeArg('accept-edits')).toEqual({
17+
type: 'mode',
18+
mode: 'acceptEdits',
19+
})
20+
expect(parseApprovalModeArg('plan')).toEqual({ type: 'mode', mode: 'plan' })
21+
expect(parseApprovalModeArg('auto')).toEqual({ type: 'mode', mode: 'auto' })
22+
expect(parseApprovalModeArg('dont-ask')).toEqual({
23+
type: 'mode',
24+
mode: 'dontAsk',
25+
})
26+
})
27+
28+
test('maps full access aliases to bypassPermissions', () => {
29+
expect(parseApprovalModeArg('full-access')).toEqual({
30+
type: 'mode',
31+
mode: 'bypassPermissions',
32+
})
33+
expect(parseApprovalModeArg('full_access')).toEqual({
34+
type: 'mode',
35+
mode: 'bypassPermissions',
36+
})
37+
expect(parseApprovalModeArg('bypass')).toEqual({
38+
type: 'mode',
39+
mode: 'bypassPermissions',
40+
})
41+
expect(parseApprovalModeArg('allow-all')).toEqual({
42+
type: 'mode',
43+
mode: 'bypassPermissions',
44+
})
45+
})
46+
47+
test('reports invalid arguments', () => {
48+
const result = parseApprovalModeArg('wat')
49+
expect(result.type).toBe('invalid')
50+
if (result.type === 'invalid') {
51+
expect(result.message).toContain('Invalid approval mode: wat')
52+
}
53+
})
54+
})
55+
56+
describe('formatApprovalMode', () => {
57+
test('uses the user-facing full access name for bypassPermissions', () => {
58+
expect(formatApprovalMode('bypassPermissions')).toBe(
59+
'Full access (bypassPermissions)',
60+
)
61+
})
62+
})

src/commands/approval/approval.tsx

Lines changed: 185 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,185 @@
1+
import * as React from 'react';
2+
import { Box, Text } from '@anthropic/ink';
3+
import type { OptionWithDescription } from '../../components/CustomSelect/select.js';
4+
import { Select } from '../../components/CustomSelect/select.js';
5+
import {
6+
type AnalyticsMetadata_I_VERIFIED_THIS_IS_NOT_CODE_OR_FILEPATHS,
7+
logEvent,
8+
} from '../../services/analytics/index.js';
9+
import { useAppState, useSetAppState } from '../../state/AppState.js';
10+
import type { ToolPermissionContext } from '../../Tool.js';
11+
import type { LocalJSXCommandOnDone } from '../../types/command.js';
12+
import type { PermissionMode } from '../../utils/permissions/PermissionMode.js';
13+
import {
14+
getAutoModeUnavailableNotification,
15+
getAutoModeUnavailableReason,
16+
isAutoModeGateEnabled,
17+
isBypassPermissionsModeDisabled,
18+
transitionPermissionMode,
19+
} from '../../utils/permissions/permissionSetup.js';
20+
import {
21+
APPROVAL_MODE_DESCRIPTORS,
22+
formatApprovalMode,
23+
getApprovalModeDescriptor,
24+
parseApprovalModeArg,
25+
} from './approvalModes.js';
26+
27+
type ApprovalCommandResult = {
28+
message: string;
29+
modeUpdate?: PermissionMode;
30+
};
31+
32+
function getModeUnavailableMessage(mode: PermissionMode, context: ToolPermissionContext): string | undefined {
33+
if (mode === 'bypassPermissions') {
34+
if (isBypassPermissionsModeDisabled()) {
35+
return 'Full access is disabled by settings or organization policy.';
36+
}
37+
if (!context.isBypassPermissionsModeAvailable) {
38+
return 'Full access is not available in this session. Start ccb with --allow-dangerously-skip-permissions to make it selectable, or --dangerously-skip-permissions to start directly in full access mode.';
39+
}
40+
}
41+
42+
if (mode === 'auto' && !isAutoModeGateEnabled()) {
43+
const reason = getAutoModeUnavailableReason();
44+
return reason
45+
? `Auto approval is unavailable: ${getAutoModeUnavailableNotification(reason)}`
46+
: 'Auto approval is unavailable in this session.';
47+
}
48+
49+
return undefined;
50+
}
51+
52+
function applyApprovalMode(context: ToolPermissionContext, mode: PermissionMode): ApprovalCommandResult {
53+
const unavailableMessage = getModeUnavailableMessage(mode, context);
54+
if (unavailableMessage) {
55+
return { message: unavailableMessage };
56+
}
57+
58+
if (context.mode === mode) {
59+
return { message: `Approval mode is already ${formatApprovalMode(mode)}.` };
60+
}
61+
62+
logEvent('tengu_approval_command', {
63+
mode: mode as AnalyticsMetadata_I_VERIFIED_THIS_IS_NOT_CODE_OR_FILEPATHS,
64+
});
65+
66+
const descriptor = getApprovalModeDescriptor(mode);
67+
return {
68+
message: `Approval mode set to ${formatApprovalMode(mode)}: ${descriptor.description}`,
69+
modeUpdate: mode,
70+
};
71+
}
72+
73+
function applyModeUpdate(context: ToolPermissionContext, mode: PermissionMode): ToolPermissionContext {
74+
const next = transitionPermissionMode(context.mode, mode, context);
75+
return { ...next, mode };
76+
}
77+
78+
export function showCurrentApprovalMode(context: ToolPermissionContext): ApprovalCommandResult {
79+
const descriptor = getApprovalModeDescriptor(context.mode);
80+
return {
81+
message: `Current approval mode: ${formatApprovalMode(context.mode)} (${descriptor.description})`,
82+
};
83+
}
84+
85+
export function executeApproval(args: string, context: ToolPermissionContext): ApprovalCommandResult {
86+
const parsed = parseApprovalModeArg(args);
87+
switch (parsed.type) {
88+
case 'current':
89+
return showCurrentApprovalMode(context);
90+
case 'help':
91+
return {
92+
message:
93+
'Usage: /approval [default|accept-edits|plan|auto|dont-ask|full-access]\n\nApproval modes:\n- default: Ask before tools that need approval\n- accept-edits: Auto-approve file edits, still ask for risky actions\n- plan: Plan first, do not make changes until you approve\n- auto: Use the automatic approval classifier when available\n- dont-ask: Deny anything not already pre-approved\n- full-access: Allow tool use without approval prompts',
94+
};
95+
case 'mode':
96+
return applyApprovalMode(context, parsed.mode);
97+
case 'invalid':
98+
return { message: parsed.message };
99+
}
100+
}
101+
102+
function ApplyApprovalAndClose({
103+
result,
104+
onDone,
105+
}: {
106+
result: ApprovalCommandResult;
107+
onDone: (result: string) => void;
108+
}): React.ReactNode {
109+
const setAppState = useSetAppState();
110+
const { message, modeUpdate } = result;
111+
React.useEffect(() => {
112+
if (modeUpdate) {
113+
setAppState(prev => ({
114+
...prev,
115+
toolPermissionContext: applyModeUpdate(prev.toolPermissionContext, modeUpdate),
116+
}));
117+
}
118+
onDone(message);
119+
}, [setAppState, message, modeUpdate, onDone]);
120+
return null;
121+
}
122+
123+
function ApprovalPicker({ onDone }: { onDone: (result: string) => void }): React.ReactNode {
124+
const toolPermissionContext = useAppState(s => s.toolPermissionContext);
125+
const setAppState = useSetAppState();
126+
127+
const options: OptionWithDescription<PermissionMode>[] = APPROVAL_MODE_DESCRIPTORS.map(descriptor => {
128+
const unavailableMessage = getModeUnavailableMessage(descriptor.mode, toolPermissionContext);
129+
return {
130+
label: descriptor.label,
131+
value: descriptor.mode,
132+
description:
133+
descriptor.mode === toolPermissionContext.mode
134+
? `${descriptor.description} (current)`
135+
: (unavailableMessage ?? descriptor.description),
136+
disabled: unavailableMessage !== undefined,
137+
};
138+
});
139+
140+
function handleSelect(mode: PermissionMode): void {
141+
const result = applyApprovalMode(toolPermissionContext, mode);
142+
if (result.modeUpdate) {
143+
setAppState(prev => ({
144+
...prev,
145+
toolPermissionContext: applyModeUpdate(prev.toolPermissionContext, result.modeUpdate!),
146+
}));
147+
}
148+
onDone(result.message);
149+
}
150+
151+
return (
152+
<Box flexDirection="column" gap={1}>
153+
<Text>Select approval mode</Text>
154+
<Select
155+
options={options}
156+
defaultValue={toolPermissionContext.mode}
157+
defaultFocusValue={toolPermissionContext.mode}
158+
onChange={handleSelect}
159+
onCancel={() => onDone('Approval mode unchanged.')}
160+
visibleOptionCount={6}
161+
/>
162+
</Box>
163+
);
164+
}
165+
166+
export async function call(onDone: LocalJSXCommandOnDone, _context: unknown, args?: string): Promise<React.ReactNode> {
167+
args = args?.trim() || '';
168+
169+
if (!args) {
170+
return <ApprovalPicker onDone={onDone} />;
171+
}
172+
return <ApprovalCommandWithArgs args={args} onDone={onDone} />;
173+
}
174+
175+
function ApprovalCommandWithArgs({
176+
args,
177+
onDone,
178+
}: {
179+
args: string;
180+
onDone: (result: string) => void;
181+
}): React.ReactNode {
182+
const toolPermissionContext = useAppState(s => s.toolPermissionContext);
183+
const result = executeApproval(args, toolPermissionContext);
184+
return <ApplyApprovalAndClose result={result} onDone={onDone} />;
185+
}
Lines changed: 117 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,117 @@
1+
import type { PermissionMode } from '../../utils/permissions/PermissionMode.js'
2+
3+
export type ApprovalModeDescriptor = {
4+
mode: PermissionMode
5+
label: string
6+
description: string
7+
aliases: readonly string[]
8+
}
9+
10+
export const APPROVAL_MODE_DESCRIPTORS: readonly ApprovalModeDescriptor[] = [
11+
{
12+
mode: 'default',
13+
label: 'Default',
14+
description: 'Ask before tools that need approval',
15+
aliases: ['default', 'ask', 'normal'],
16+
},
17+
{
18+
mode: 'acceptEdits',
19+
label: 'Accept edits',
20+
description: 'Auto-approve file edits, still ask for risky actions',
21+
aliases: ['acceptedits', 'accept-edits', 'accept', 'edits', 'edit'],
22+
},
23+
{
24+
mode: 'plan',
25+
label: 'Plan',
26+
description: 'Plan first, do not make changes until you approve',
27+
aliases: ['plan', 'planning'],
28+
},
29+
{
30+
mode: 'auto',
31+
label: 'Auto',
32+
description: 'Use the automatic approval classifier when available',
33+
aliases: ['auto', 'automatic'],
34+
},
35+
{
36+
mode: 'dontAsk',
37+
label: "Don't ask",
38+
description: 'Deny anything not already pre-approved',
39+
aliases: ['dontask', "don'task", 'dont-ask', "don't-ask", 'deny'],
40+
},
41+
{
42+
mode: 'bypassPermissions',
43+
label: 'Full access',
44+
description: 'Allow tool use without approval prompts',
45+
aliases: [
46+
'bypasspermissions',
47+
'bypass-permissions',
48+
'bypass',
49+
'full',
50+
'fullaccess',
51+
'full-access',
52+
'all',
53+
'allow-all',
54+
'unrestricted',
55+
],
56+
},
57+
]
58+
59+
export type ApprovalModeArgResult =
60+
| { type: 'current' }
61+
| { type: 'help' }
62+
| { type: 'mode'; mode: PermissionMode }
63+
| { type: 'invalid'; message: string }
64+
65+
const HELP_ARGS = new Set(['help', '-h', '--help'])
66+
const CURRENT_ARGS = new Set(['', 'current', 'status', 'show'])
67+
68+
const MODE_BY_ALIAS = new Map<string, PermissionMode>(
69+
APPROVAL_MODE_DESCRIPTORS.flatMap(descriptor =>
70+
descriptor.aliases.map(
71+
alias => [normalizeApprovalArg(alias), descriptor.mode] as const,
72+
),
73+
),
74+
)
75+
76+
export function normalizeApprovalArg(arg: string): string {
77+
return arg
78+
.trim()
79+
.toLowerCase()
80+
.replace(/[\s_]+/g, '-')
81+
}
82+
83+
export function parseApprovalModeArg(args: string): ApprovalModeArgResult {
84+
const normalized = normalizeApprovalArg(args)
85+
if (CURRENT_ARGS.has(normalized)) {
86+
return { type: 'current' }
87+
}
88+
if (HELP_ARGS.has(normalized)) {
89+
return { type: 'help' }
90+
}
91+
92+
const mode = MODE_BY_ALIAS.get(normalized)
93+
if (mode) {
94+
return { type: 'mode', mode }
95+
}
96+
97+
return {
98+
type: 'invalid',
99+
message: `Invalid approval mode: ${args}. Valid options are: default, accept-edits, plan, auto, dont-ask, full-access`,
100+
}
101+
}
102+
103+
export function getApprovalModeDescriptor(
104+
mode: PermissionMode,
105+
): ApprovalModeDescriptor {
106+
return (
107+
APPROVAL_MODE_DESCRIPTORS.find(descriptor => descriptor.mode === mode) ??
108+
APPROVAL_MODE_DESCRIPTORS[0]!
109+
)
110+
}
111+
112+
export function formatApprovalMode(mode: PermissionMode): string {
113+
const descriptor = getApprovalModeDescriptor(mode)
114+
return mode === 'bypassPermissions'
115+
? `${descriptor.label} (${mode})`
116+
: descriptor.label
117+
}

src/commands/approval/index.ts

Lines changed: 14 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,14 @@
1+
import type { Command } from '../../commands.js'
2+
import { shouldInferenceConfigCommandBeImmediate } from '../../utils/immediateCommand.js'
3+
4+
export default {
5+
type: 'local-jsx',
6+
name: 'approval',
7+
aliases: ['approvals', 'permission-mode'],
8+
description: 'Choose how tool approval prompts are handled',
9+
argumentHint: '[default|accept-edits|plan|auto|dont-ask|full-access]',
10+
get immediate() {
11+
return shouldInferenceConfigCommandBeImmediate()
12+
},
13+
load: () => import('./approval.js'),
14+
} satisfies Command

0 commit comments

Comments
 (0)