feat(cli): enable skill activation via slash commands

- Register agent skills as dynamic slash commands using SkillCommandLoader
- Allow client-initiated tool calls to skip user confirmation if policy is ASK_USER
- Support follow-up prompt in skill slash commands via postSubmitPrompt
- Add tests for SkillCommandLoader and policy bypass
This commit is contained in:
Taylor Mullen
2026-03-08 15:47:34 -07:00
parent e5d58c2b5a
commit 755c16cb5d
9 changed files with 235 additions and 1 deletions

View File

@@ -12,6 +12,11 @@ export interface ToolActionReturn {
type: 'tool';
toolName: string;
toolArgs: Record<string, unknown>;
/**
* Optional content to be submitted as a prompt to the Gemini model
* after the tool call completes.
*/
postSubmitPrompt?: PartListUnion;
}
/**

View File

@@ -145,6 +145,43 @@ describe('policy.ts', () => {
const result = await checkPolicy(toolCall, mockConfig);
expect(result.decision).toBe(PolicyDecision.ASK_USER);
});
it('should return ALLOW if decision is ASK_USER and request is client-initiated', async () => {
const mockPolicyEngine = {
check: vi.fn().mockResolvedValue({ decision: PolicyDecision.ASK_USER }),
} as unknown as Mocked<PolicyEngine>;
const mockConfig = {
getPolicyEngine: vi.fn().mockReturnValue(mockPolicyEngine),
isInteractive: vi.fn().mockReturnValue(true),
} as unknown as Mocked<Config>;
const toolCall = {
request: { name: 'test-tool', args: {}, isClientInitiated: true },
tool: { name: 'test-tool' },
} as ValidatingToolCall;
const result = await checkPolicy(toolCall, mockConfig);
expect(result.decision).toBe(PolicyDecision.ALLOW);
});
it('should still return DENY if request is client-initiated but policy says DENY', async () => {
const mockPolicyEngine = {
check: vi.fn().mockResolvedValue({ decision: PolicyDecision.DENY }),
} as unknown as Mocked<PolicyEngine>;
const mockConfig = {
getPolicyEngine: vi.fn().mockReturnValue(mockPolicyEngine),
} as unknown as Mocked<Config>;
const toolCall = {
request: { name: 'test-tool', args: {}, isClientInitiated: true },
tool: { name: 'test-tool' },
} as ValidatingToolCall;
const result = await checkPolicy(toolCall, mockConfig);
expect(result.decision).toBe(PolicyDecision.DENY);
});
});
describe('updatePolicy', () => {

View File

@@ -66,6 +66,19 @@ export async function checkPolicy(
const { decision } = result;
// If the tool call was initiated by the client (e.g. via a slash command),
// we treat it as implicitly confirmed by the user and bypass the
// confirmation prompt if the policy engine's decision is 'ASK_USER'.
if (
decision === PolicyDecision.ASK_USER &&
toolCall.request.isClientInitiated
) {
return {
decision: PolicyDecision.ALLOW,
rule: result.rule,
};
}
/*
* Return the full check result including the rule that matched.
* This is necessary to access metadata like custom deny messages.