From ad98bedc6e1870ba12ed1718d493b81019a5a959 Mon Sep 17 00:00:00 2001 From: Sehoon Shon Date: Tue, 21 Apr 2026 16:47:20 -0700 Subject: [PATCH] feat(cli): add /note command to append and view workspace notes --- .../cli/src/services/BuiltinCommandLoader.ts | 2 + .../cli/src/ui/commands/noteCommand.test.ts | 115 ++++++++++++++++++ packages/cli/src/ui/commands/noteCommand.ts | 89 ++++++++++++++ 3 files changed, 206 insertions(+) create mode 100644 packages/cli/src/ui/commands/noteCommand.test.ts create mode 100644 packages/cli/src/ui/commands/noteCommand.ts diff --git a/packages/cli/src/services/BuiltinCommandLoader.ts b/packages/cli/src/services/BuiltinCommandLoader.ts index 94b5986eb3..4b390e8af6 100644 --- a/packages/cli/src/services/BuiltinCommandLoader.ts +++ b/packages/cli/src/services/BuiltinCommandLoader.ts @@ -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, diff --git a/packages/cli/src/ui/commands/noteCommand.test.ts b/packages/cli/src/ui/commands/noteCommand.test.ts new file mode 100644 index 0000000000..afe1c70dde --- /dev/null +++ b/packages/cli/src/ui/commands/noteCommand.test.ts @@ -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'); + }); + }); +}); diff --git a/packages/cli/src/ui/commands/noteCommand.ts b/packages/cli/src/ui/commands/noteCommand.ts new file mode 100644 index 0000000000..4c56de5bbb --- /dev/null +++ b/packages/cli/src/ui/commands/noteCommand.ts @@ -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}`, + }; + } + }, +};