From f727139c728b36664a96421186d912bf86f857da Mon Sep 17 00:00:00 2001 From: Sehoon Shon Date: Tue, 21 Apr 2026 22:03:59 -0700 Subject: [PATCH] feat(cli): add /note slash command to append or view notes --- .../cli/src/services/BuiltinCommandLoader.ts | 2 + .../cli/src/ui/commands/noteCommand.test.ts | 75 +++++++++++++++++++ packages/cli/src/ui/commands/noteCommand.ts | 51 +++++++++++++ 3 files changed, 128 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..5e1092dfd0 --- /dev/null +++ b/packages/cli/src/ui/commands/noteCommand.test.ts @@ -0,0 +1,75 @@ +/** + * @license + * Copyright 2026 Google LLC + * SPDX-License-Identifier: Apache-2.0 + */ + +import { describe, it, expect, vi, beforeEach } from 'vitest'; +import fs from 'node:fs/promises'; +import path from 'node:path'; +import { noteCommand } from './noteCommand.js'; +import { type CommandContext } from './types.js'; + +vi.mock('node:fs/promises'); + +describe('noteCommand', () => { + const mockContext = {} as CommandContext; + const notesPath = path.join(process.cwd(), 'notes.md'); + + beforeEach(() => { + vi.clearAllMocks(); + }); + + it('should return notes content when no args provided and file exists', async () => { + vi.mocked(fs.readFile).mockResolvedValue('existing note\n'); + + const result = await noteCommand.action!(mockContext, ''); + + expect(fs.readFile).toHaveBeenCalledWith(notesPath, 'utf8'); + expect(result).toEqual({ + type: 'message', + messageType: 'info', + content: expect.stringContaining('existing note'), + }); + }); + + it('should return info message when no args provided and file does not exist', async () => { + vi.mocked(fs.readFile).mockRejectedValue(new Error('File not found')); + + const result = await noteCommand.action!(mockContext, ' '); + + expect(result).toEqual({ + type: 'message', + messageType: 'info', + content: 'No notes found. Use "/note " to add one.', + }); + }); + + it('should append note to file when args are provided', async () => { + const note = 'this is a new note'; + vi.mocked(fs.appendFile).mockResolvedValue(undefined); + + const result = await noteCommand.action!(mockContext, note); + + expect(fs.appendFile).toHaveBeenCalledWith(notesPath, `${note}\n`); + expect(result).toEqual({ + type: 'message', + messageType: 'info', + content: expect.stringContaining('Note added'), + }); + }); + + it('should return error message when append fails', async () => { + vi.mocked(fs.appendFile).mockRejectedValue(new Error('Permission denied')); + + const result = await noteCommand.action!(mockContext, 'some note'); + + expect(result).toEqual({ + type: 'message', + messageType: 'error', + content: expect.stringContaining( + 'Failed to save note: Permission denied', + ), + }); + }); +}); diff --git a/packages/cli/src/ui/commands/noteCommand.ts b/packages/cli/src/ui/commands/noteCommand.ts new file mode 100644 index 0000000000..f7e2a00e16 --- /dev/null +++ b/packages/cli/src/ui/commands/noteCommand.ts @@ -0,0 +1,51 @@ +/** + * @license + * Copyright 2026 Google LLC + * SPDX-License-Identifier: Apache-2.0 + */ + +import fs from 'node:fs/promises'; +import path from 'node:path'; +import { CommandKind, type SlashCommand } from './types.js'; + +export const noteCommand: SlashCommand = { + name: 'note', + description: 'Append a note to notes.md or view current notes', + kind: CommandKind.BUILT_IN, + autoExecute: true, + action: async (_context, args) => { + const notesPath = path.join(process.cwd(), 'notes.md'); + + if (!args || args.trim().length === 0) { + try { + const content = await fs.readFile(notesPath, 'utf8'); + return { + type: 'message', + messageType: 'info', + content: `Current notes in ${notesPath}:\n\n${content}`, + }; + } catch { + return { + type: 'message', + messageType: 'info', + content: 'No notes found. Use "/note " to add one.', + }; + } + } + + try { + await fs.appendFile(notesPath, `${args}\n`); + return { + type: 'message', + messageType: 'info', + content: `Note added to ${notesPath}`, + }; + } catch (error) { + return { + type: 'message', + messageType: 'error', + content: `Failed to save note: ${error instanceof Error ? error.message : String(error)}`, + }; + } + }, +};