mirror of
https://github.com/google-gemini/gemini-cli.git
synced 2026-06-19 07:46:45 -07:00
feat(cli): add /note command to append and view workspace notes
This commit is contained in:
@@ -42,6 +42,7 @@ import { initCommand } from '../ui/commands/initCommand.js';
|
||||
import { mcpCommand } from '../ui/commands/mcpCommand.js';
|
||||
import { memoryCommand } from '../ui/commands/memoryCommand.js';
|
||||
import { modelCommand } from '../ui/commands/modelCommand.js';
|
||||
import { noteCommand } from '../ui/commands/noteCommand.js';
|
||||
import { oncallCommand } from '../ui/commands/oncallCommand.js';
|
||||
import { permissionsCommand } from '../ui/commands/permissionsCommand.js';
|
||||
import { planCommand } from '../ui/commands/planCommand.js';
|
||||
@@ -184,6 +185,7 @@ export class BuiltinCommandLoader implements ICommandLoader {
|
||||
: [mcpCommand]),
|
||||
memoryCommand,
|
||||
modelCommand,
|
||||
noteCommand,
|
||||
...(this.config?.getFolderTrust() ? [permissionsCommand] : []),
|
||||
...(this.config?.isPlanEnabled() ? [planCommand] : []),
|
||||
policiesCommand,
|
||||
|
||||
@@ -0,0 +1,115 @@
|
||||
/**
|
||||
* @license
|
||||
* Copyright 2025 Google LLC
|
||||
* SPDX-License-Identifier: Apache-2.0
|
||||
*/
|
||||
|
||||
import { vi, describe, it, expect, beforeEach } from 'vitest';
|
||||
import { noteCommand } from './noteCommand.js';
|
||||
import * as fsPromises from 'node:fs/promises';
|
||||
import { createMockCommandContext } from '../../test-utils/mockCommandContext.js';
|
||||
import * as path from 'node:path';
|
||||
import type { MessageActionReturn } from '@google/gemini-cli-core';
|
||||
|
||||
vi.mock('node:fs/promises');
|
||||
|
||||
describe('noteCommand', () => {
|
||||
const mockContext = createMockCommandContext();
|
||||
const workspaceRoot = process.cwd();
|
||||
const notesFile = path.join(workspaceRoot, 'notes.md');
|
||||
|
||||
beforeEach(() => {
|
||||
vi.clearAllMocks();
|
||||
});
|
||||
|
||||
describe('append action', () => {
|
||||
it('should append a note to notes.md when args are provided', async () => {
|
||||
const noteText = 'This is a test note';
|
||||
vi.mocked(fsPromises.appendFile).mockResolvedValue(undefined);
|
||||
|
||||
const result = (await noteCommand.action!(
|
||||
mockContext,
|
||||
noteText,
|
||||
)) as MessageActionReturn;
|
||||
|
||||
expect(fsPromises.appendFile).toHaveBeenCalledWith(
|
||||
notesFile,
|
||||
expect.stringContaining(noteText),
|
||||
);
|
||||
expect(fsPromises.appendFile).toHaveBeenCalledWith(
|
||||
notesFile,
|
||||
expect.stringContaining('## '),
|
||||
);
|
||||
expect(result).toEqual({
|
||||
type: 'message',
|
||||
messageType: 'info',
|
||||
content: `Note saved to ${notesFile}`,
|
||||
});
|
||||
});
|
||||
|
||||
it('should return a usage message when no args are provided', async () => {
|
||||
const result = (await noteCommand.action!(
|
||||
mockContext,
|
||||
' ',
|
||||
)) as MessageActionReturn;
|
||||
|
||||
expect(fsPromises.appendFile).not.toHaveBeenCalled();
|
||||
expect(result.content).toContain('Please provide a note to save');
|
||||
});
|
||||
|
||||
it('should return an error message when appendFile fails', async () => {
|
||||
vi.mocked(fsPromises.appendFile).mockRejectedValue(new Error('FS Error'));
|
||||
|
||||
const result = (await noteCommand.action!(
|
||||
mockContext,
|
||||
'some note',
|
||||
)) as MessageActionReturn;
|
||||
|
||||
expect(result.content).toContain('Failed to save note: FS Error');
|
||||
});
|
||||
});
|
||||
|
||||
describe('view subcommand', () => {
|
||||
const viewSubcommand = noteCommand.subCommands?.find(
|
||||
(s) => s.name === 'view',
|
||||
);
|
||||
|
||||
it('should read and display notes from notes.md', async () => {
|
||||
const notesContent = '## 4/21/2026\n\nTest note content';
|
||||
vi.mocked(fsPromises.readFile).mockResolvedValue(notesContent);
|
||||
|
||||
const result = (await viewSubcommand!.action!(
|
||||
mockContext,
|
||||
'',
|
||||
)) as MessageActionReturn;
|
||||
|
||||
expect(fsPromises.readFile).toHaveBeenCalledWith(notesFile, 'utf8');
|
||||
expect(result.content).toContain('### Current Notes');
|
||||
expect(result.content).toContain(notesContent);
|
||||
});
|
||||
|
||||
it('should return "No notes found" if notes.md does not exist', async () => {
|
||||
const error = new Error('Not found');
|
||||
Object.assign(error, { code: 'ENOENT' });
|
||||
vi.mocked(fsPromises.readFile).mockRejectedValue(error);
|
||||
|
||||
const result = (await viewSubcommand!.action!(
|
||||
mockContext,
|
||||
'',
|
||||
)) as MessageActionReturn;
|
||||
|
||||
expect(result.content).toContain('No notes found in this workspace.');
|
||||
});
|
||||
|
||||
it('should return an error message when readFile fails with other error', async () => {
|
||||
vi.mocked(fsPromises.readFile).mockRejectedValue(new Error('Read Error'));
|
||||
|
||||
const result = (await viewSubcommand!.action!(
|
||||
mockContext,
|
||||
'',
|
||||
)) as MessageActionReturn;
|
||||
|
||||
expect(result.content).toContain('Failed to read notes: Read Error');
|
||||
});
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,89 @@
|
||||
/**
|
||||
* @license
|
||||
* Copyright 2025 Google LLC
|
||||
* SPDX-License-Identifier: Apache-2.0
|
||||
*/
|
||||
|
||||
import { CommandKind, type SlashCommand } from './types.js';
|
||||
import * as fsPromises from 'node:fs/promises';
|
||||
import * as path from 'node:path';
|
||||
|
||||
export const noteCommand: SlashCommand = {
|
||||
name: 'note',
|
||||
description: 'Manage workspace notes (append or view)',
|
||||
kind: CommandKind.BUILT_IN,
|
||||
autoExecute: true,
|
||||
subCommands: [
|
||||
{
|
||||
name: 'view',
|
||||
description: 'View the current workspace notes',
|
||||
kind: CommandKind.BUILT_IN,
|
||||
autoExecute: true,
|
||||
action: async () => {
|
||||
const workspaceRoot = process.cwd();
|
||||
const notesFile = path.join(workspaceRoot, 'notes.md');
|
||||
|
||||
try {
|
||||
const content = await fsPromises.readFile(notesFile, 'utf8');
|
||||
return {
|
||||
type: 'message',
|
||||
messageType: 'info',
|
||||
content: `### Current Notes\n\n${content}`,
|
||||
};
|
||||
} catch (err: unknown) {
|
||||
if (
|
||||
err &&
|
||||
typeof err === 'object' &&
|
||||
'code' in err &&
|
||||
err.code === 'ENOENT'
|
||||
) {
|
||||
return {
|
||||
type: 'message',
|
||||
messageType: 'info',
|
||||
content: 'No notes found in this workspace.',
|
||||
};
|
||||
}
|
||||
const errorMessage = err instanceof Error ? err.message : String(err);
|
||||
return {
|
||||
type: 'message',
|
||||
messageType: 'error',
|
||||
content: `Failed to read notes: ${errorMessage}`,
|
||||
};
|
||||
}
|
||||
},
|
||||
},
|
||||
],
|
||||
action: async (context, args) => {
|
||||
if (!args.trim()) {
|
||||
return {
|
||||
type: 'message',
|
||||
messageType: 'info',
|
||||
content:
|
||||
'Please provide a note to save or use `/note view` to see your notes. Example: `/note This is a useful thought.`',
|
||||
};
|
||||
}
|
||||
|
||||
const workspaceRoot = process.cwd();
|
||||
const notesFile = path.join(workspaceRoot, 'notes.md');
|
||||
const timestamp = new Date().toLocaleString();
|
||||
|
||||
const noteEntry = `\n## ${timestamp}\n\n${args.trim()}\n`;
|
||||
|
||||
try {
|
||||
await fsPromises.appendFile(notesFile, noteEntry);
|
||||
|
||||
return {
|
||||
type: 'message',
|
||||
messageType: 'info',
|
||||
content: `Note saved to ${notesFile}`,
|
||||
};
|
||||
} catch (err: unknown) {
|
||||
const errorMessage = err instanceof Error ? err.message : String(err);
|
||||
return {
|
||||
type: 'message',
|
||||
messageType: 'error',
|
||||
content: `Failed to save note: ${errorMessage}`,
|
||||
};
|
||||
}
|
||||
},
|
||||
};
|
||||
Reference in New Issue
Block a user