Skip to content

Commit e7b8dec

Browse files
committed
feat(plan): add copy subcommand to plan (#20491)
1 parent fca29b0 commit e7b8dec

4 files changed

Lines changed: 101 additions & 2 deletions

File tree

docs/cli/plan-mode.md

Lines changed: 5 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -21,6 +21,7 @@ implementation. It allows you to:
2121
- [Entering Plan Mode](#entering-plan-mode)
2222
- [Planning Workflow](#planning-workflow)
2323
- [Exiting Plan Mode](#exiting-plan-mode)
24+
- [Commands](#commands)
2425
- [Tool Restrictions](#tool-restrictions)
2526
- [Customizing Planning with Skills](#customizing-planning-with-skills)
2627
- [Customizing Policies](#customizing-policies)
@@ -126,6 +127,10 @@ To exit Plan Mode, you can:
126127
- **Tool:** Gemini CLI calls the [`exit_plan_mode`] tool to present the
127128
finalized plan for your approval.
128129

130+
### Commands
131+
132+
- **`/plan copy`**: Copy the currently approved plan to your clipboard.
133+
129134
## Tool Restrictions
130135

131136
Plan Mode enforces strict safety policies to prevent accidental changes.

docs/reference/commands.md

Lines changed: 3 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -270,6 +270,9 @@ Slash commands provide meta-level control over the CLI itself.
270270
one has been generated.
271271
- **Note:** This feature requires the `experimental.plan` setting to be
272272
enabled in your configuration.
273+
- **Sub-commands:**
274+
- **`copy`**:
275+
- **Description:** Copy the currently approved plan to your clipboard.
273276

274277
### `/policies`
275278

packages/cli/src/ui/commands/planCommand.test.ts

Lines changed: 50 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -14,7 +14,9 @@ import {
1414
coreEvents,
1515
processSingleFileContent,
1616
type ProcessedFileReadResult,
17+
readFileWithEncoding,
1718
} from '@google/gemini-cli-core';
19+
import { copyToClipboard } from '../utils/commandUtils.js';
1820

1921
vi.mock('@google/gemini-cli-core', async (importOriginal) => {
2022
const actual =
@@ -25,6 +27,7 @@ vi.mock('@google/gemini-cli-core', async (importOriginal) => {
2527
emitFeedback: vi.fn(),
2628
},
2729
processSingleFileContent: vi.fn(),
30+
readFileWithEncoding: vi.fn(),
2831
partToString: vi.fn((val) => val),
2932
};
3033
});
@@ -35,9 +38,14 @@ vi.mock('node:path', async (importOriginal) => {
3538
...actual,
3639
default: { ...actual },
3740
join: vi.fn((...args) => args.join('/')),
41+
basename: vi.fn((p) => p.split('/').pop()),
3842
};
3943
});
4044

45+
vi.mock('../utils/commandUtils.js', () => ({
46+
copyToClipboard: vi.fn(),
47+
}));
48+
4149
describe('planCommand', () => {
4250
let mockContext: CommandContext;
4351

@@ -115,4 +123,46 @@ describe('planCommand', () => {
115123
text: '# Approved Plan Content',
116124
});
117125
});
126+
127+
describe('copy subcommand', () => {
128+
it('should copy the approved plan to clipboard', async () => {
129+
const mockPlanPath = '/mock/plans/dir/approved-plan.md';
130+
vi.mocked(
131+
mockContext.services.config!.getApprovedPlanPath,
132+
).mockReturnValue(mockPlanPath);
133+
vi.mocked(readFileWithEncoding).mockResolvedValue('# Plan Content');
134+
135+
const copySubCommand = planCommand.subCommands?.find(
136+
(sc) => sc.name === 'copy',
137+
);
138+
if (!copySubCommand?.action) throw new Error('Copy action missing');
139+
140+
await copySubCommand.action(mockContext, '');
141+
142+
expect(readFileWithEncoding).toHaveBeenCalledWith(mockPlanPath);
143+
expect(copyToClipboard).toHaveBeenCalledWith('# Plan Content');
144+
expect(coreEvents.emitFeedback).toHaveBeenCalledWith(
145+
'info',
146+
'Plan copied to clipboard (approved-plan.md).',
147+
);
148+
});
149+
150+
it('should warn if no approved plan is found', async () => {
151+
vi.mocked(
152+
mockContext.services.config!.getApprovedPlanPath,
153+
).mockReturnValue(undefined);
154+
155+
const copySubCommand = planCommand.subCommands?.find(
156+
(sc) => sc.name === 'copy',
157+
);
158+
if (!copySubCommand?.action) throw new Error('Copy action missing');
159+
160+
await copySubCommand.action(mockContext, '');
161+
162+
expect(coreEvents.emitFeedback).toHaveBeenCalledWith(
163+
'warning',
164+
'No approved plan found to copy.',
165+
);
166+
});
167+
});
118168
});

packages/cli/src/ui/commands/planCommand.ts

Lines changed: 43 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -4,22 +4,54 @@
44
* SPDX-License-Identifier: Apache-2.0
55
*/
66

7-
import { CommandKind, type SlashCommand } from './types.js';
7+
import {
8+
type CommandContext,
9+
CommandKind,
10+
type SlashCommand,
11+
} from './types.js';
812
import {
913
ApprovalMode,
1014
coreEvents,
1115
debugLogger,
1216
processSingleFileContent,
1317
partToString,
18+
readFileWithEncoding,
1419
} from '@google/gemini-cli-core';
1520
import { MessageType } from '../types.js';
1621
import * as path from 'node:path';
22+
import { copyToClipboard } from '../utils/commandUtils.js';
23+
24+
async function copyAction(context: CommandContext) {
25+
const config = context.services.config;
26+
if (!config) {
27+
debugLogger.debug('Plan copy command: config is not available in context');
28+
return;
29+
}
30+
31+
const planPath = config.getApprovedPlanPath();
32+
33+
if (!planPath) {
34+
coreEvents.emitFeedback('warning', 'No approved plan found to copy.');
35+
return;
36+
}
37+
38+
try {
39+
const content = await readFileWithEncoding(planPath);
40+
await copyToClipboard(content);
41+
coreEvents.emitFeedback(
42+
'info',
43+
`Plan copied to clipboard (${path.basename(planPath)}).`,
44+
);
45+
} catch (error) {
46+
coreEvents.emitFeedback('error', `Failed to copy plan: ${error}`, error);
47+
}
48+
}
1749

1850
export const planCommand: SlashCommand = {
1951
name: 'plan',
2052
description: 'Switch to Plan Mode and view current plan',
2153
kind: CommandKind.BUILT_IN,
22-
autoExecute: true,
54+
autoExecute: false,
2355
action: async (context) => {
2456
const config = context.services.config;
2557
if (!config) {
@@ -62,4 +94,13 @@ export const planCommand: SlashCommand = {
6294
);
6395
}
6496
},
97+
subCommands: [
98+
{
99+
name: 'copy',
100+
description: 'Copy the currently approved plan to your clipboard',
101+
kind: CommandKind.BUILT_IN,
102+
autoExecute: true,
103+
action: copyAction,
104+
},
105+
],
65106
};

0 commit comments

Comments
 (0)