diff --git a/.changeset/swift-penguins-march.md b/.changeset/swift-penguins-march.md new file mode 100644 index 00000000000..50ed6d09eed --- /dev/null +++ b/.changeset/swift-penguins-march.md @@ -0,0 +1,5 @@ +--- +"@kilocode/cli": minor +--- + +Add --append-system-prompt-file option to read custom instructions from a file diff --git a/cli/README.md b/cli/README.md index bd6fc54ed48..c02964206eb 100644 --- a/cli/README.md +++ b/cli/README.md @@ -70,6 +70,22 @@ kilocode --parallel --auto "improve xyz" kilocode --parallel --auto "improve abc" ``` +### Custom System Prompt + +Append custom instructions to the system prompt: + +```bash +# Inline text +kilocode --append-system-prompt "Always use TypeScript strict mode" + +# From a file +kilocode --append-system-prompt-file ./prompts/custom-instructions.md + +# Both (inline text first, then file content) +kilocode --append-system-prompt "Context: Production deployment" \ + --append-system-prompt-file ./prompts/deploy-guidelines.md +``` + ### Autonomous mode (Non-Interactive) Autonomous mode allows Kilo Code to run in automated environments like CI/CD pipelines without requiring user interaction. diff --git a/cli/src/__tests__/append-system-prompt.test.ts b/cli/src/__tests__/append-system-prompt.test.ts index 7372335407f..07847834cf2 100644 --- a/cli/src/__tests__/append-system-prompt.test.ts +++ b/cli/src/__tests__/append-system-prompt.test.ts @@ -1,6 +1,22 @@ -import { describe, it, expect } from "vitest" +import { describe, it, expect, vi, beforeEach, afterEach } from "vitest" +import { existsSync, readFileSync } from "fs" +import { resolve } from "path" import type { CLIOptions } from "../types/cli.js" +// Mock fs module +vi.mock("fs", () => ({ + existsSync: vi.fn(), + readFileSync: vi.fn(), +})) + +// Mock process.exit to prevent test termination +const _mockExit = vi.spyOn(process, "exit").mockImplementation((code?: string | number | null | undefined): never => { + throw new Error(`process.exit(${code})`) +}) + +// Mock console.error to capture error messages +const _mockConsoleError = vi.spyOn(console, "error").mockImplementation(() => {}) + describe("Append System Prompt CLI Option", () => { describe("CLIOptions type", () => { it("should accept appendSystemPrompt as a string option", () => { @@ -106,4 +122,409 @@ Rule 2: Keep it simple` expect(result).toBe(expectedPrompt) }) }) + + describe("CLIOptions type with appendSystemPromptFile", () => { + it("should accept appendSystemPromptFile as a string option", () => { + const options: CLIOptions = { + mode: "code", + workspace: "/test/workspace", + appendSystemPromptFile: "./custom-instructions.md", + } + + expect(options.appendSystemPromptFile).toBe("./custom-instructions.md") + }) + + it("should allow appendSystemPromptFile to be undefined", () => { + const options: CLIOptions = { + mode: "code", + workspace: "/test/workspace", + } + + expect(options.appendSystemPromptFile).toBeUndefined() + }) + + it("should accept both appendSystemPrompt and appendSystemPromptFile", () => { + const options: CLIOptions = { + mode: "code", + workspace: "/test/workspace", + appendSystemPrompt: "Inline instructions", + appendSystemPromptFile: "./file-instructions.md", + } + + expect(options.appendSystemPrompt).toBe("Inline instructions") + expect(options.appendSystemPromptFile).toBe("./file-instructions.md") + }) + }) +}) + +/** + * Tests for the --append-system-prompt-file CLI option. + * + * This option allows users to specify a file containing custom instructions + * to append to the system prompt. + */ +describe("Append System Prompt File CLI Option", () => { + beforeEach(() => { + vi.clearAllMocks() + }) + + afterEach(() => { + vi.restoreAllMocks() + }) + + describe("File reading functionality", () => { + it("should read content from a file", () => { + const fileContent = "Custom instructions from file" + vi.mocked(existsSync).mockReturnValue(true) + vi.mocked(readFileSync).mockReturnValue(fileContent) + + const filePath = "/test/path/instructions.md" + const resolvedPath = resolve(filePath) + + // Simulate the file reading logic from index.ts + expect(existsSync(resolvedPath)).toBe(true) + const content = readFileSync(resolvedPath, "utf-8") + expect(content).toBe(fileContent) + }) + + it("should handle empty files gracefully", () => { + vi.mocked(existsSync).mockReturnValue(true) + vi.mocked(readFileSync).mockReturnValue("") + + const filePath = "/test/path/empty.md" + const resolvedPath = resolve(filePath) + + expect(existsSync(resolvedPath)).toBe(true) + const content = readFileSync(resolvedPath, "utf-8") + expect(content).toBe("") + }) + + it("should handle files with unicode characters", () => { + const unicodeContent = "Instructions with unicode: こんにちは 🎉 émoji café" + vi.mocked(existsSync).mockReturnValue(true) + vi.mocked(readFileSync).mockReturnValue(unicodeContent) + + const filePath = "/test/path/unicode.md" + const resolvedPath = resolve(filePath) + + expect(existsSync(resolvedPath)).toBe(true) + const content = readFileSync(resolvedPath, "utf-8") + expect(content).toBe(unicodeContent) + }) + + it("should handle files with multi-line content", () => { + const multiLineContent = `Line 1: First instruction +Line 2: Second instruction +Line 3: Third instruction` + vi.mocked(existsSync).mockReturnValue(true) + vi.mocked(readFileSync).mockReturnValue(multiLineContent) + + const filePath = "/test/path/multiline.md" + const resolvedPath = resolve(filePath) + + const content = readFileSync(resolvedPath, "utf-8") + expect(content).toBe(multiLineContent) + expect(content.split("\n").length).toBe(3) + }) + }) + + describe("File existence validation", () => { + it("should error if file does not exist", () => { + vi.mocked(existsSync).mockReturnValue(false) + + const filePath = "/non/existent/file.md" + const resolvedPath = resolve(filePath) + + // Simulate the validation logic from index.ts + const fileExists = existsSync(resolvedPath) + expect(fileExists).toBe(false) + + // When file doesn't exist, index.ts outputs this error and exits + const expectedError = `Error: System prompt file not found: ${resolvedPath}` + expect(expectedError).toContain("System prompt file not found") + }) + + it("should detect existing file", () => { + vi.mocked(existsSync).mockReturnValue(true) + + const filePath = "/existing/file.md" + const resolvedPath = resolve(filePath) + + expect(existsSync(resolvedPath)).toBe(true) + }) + }) + + describe("File read error handling", () => { + it("should error if file cannot be read", () => { + vi.mocked(existsSync).mockReturnValue(true) + vi.mocked(readFileSync).mockImplementation(() => { + throw new Error("EACCES: permission denied") + }) + + const filePath = "/protected/file.md" + const resolvedPath = resolve(filePath) + + expect(existsSync(resolvedPath)).toBe(true) + expect(() => readFileSync(resolvedPath, "utf-8")).toThrow("EACCES: permission denied") + }) + + it("should handle ENOENT errors", () => { + vi.mocked(existsSync).mockReturnValue(true) // File appears to exist but read fails + vi.mocked(readFileSync).mockImplementation(() => { + throw new Error("ENOENT: no such file or directory") + }) + + expect(() => readFileSync("/some/path", "utf-8")).toThrow("ENOENT") + }) + + it("should handle EISDIR errors (trying to read a directory)", () => { + vi.mocked(existsSync).mockReturnValue(true) + vi.mocked(readFileSync).mockImplementation(() => { + throw new Error("EISDIR: illegal operation on a directory") + }) + + expect(() => readFileSync("/some/directory", "utf-8")).toThrow("EISDIR") + }) + }) + + describe("Combining inline text and file content", () => { + it("should combine inline text and file content with inline first", () => { + const inlineText = "Inline instructions" + const fileContent = "File instructions" + vi.mocked(existsSync).mockReturnValue(true) + vi.mocked(readFileSync).mockReturnValue(fileContent) + + // Simulate the combination logic from index.ts lines 231-234 + let combinedSystemPrompt = inlineText + const fileContentRead = readFileSync("/test/file.md", "utf-8") as string + + combinedSystemPrompt = combinedSystemPrompt + ? `${combinedSystemPrompt}\n\n${fileContentRead}` + : fileContentRead + + expect(combinedSystemPrompt).toBe("Inline instructions\n\nFile instructions") + }) + + it("should use only file content when no inline text provided", () => { + const fileContent = "File instructions only" + vi.mocked(existsSync).mockReturnValue(true) + vi.mocked(readFileSync).mockReturnValue(fileContent) + + // Simulate the combination logic from index.ts + let combinedSystemPrompt = "" // No inline text + const fileContentRead = readFileSync("/test/file.md", "utf-8") as string + + combinedSystemPrompt = combinedSystemPrompt + ? `${combinedSystemPrompt}\n\n${fileContentRead}` + : fileContentRead + + expect(combinedSystemPrompt).toBe("File instructions only") + }) + + it("should maintain proper separation with double newline", () => { + const inlineText = "First part" + const fileContent = "Second part" + vi.mocked(existsSync).mockReturnValue(true) + vi.mocked(readFileSync).mockReturnValue(fileContent) + + const combined = `${inlineText}\n\n${fileContent}` + + // Verify the separator is exactly two newlines + expect(combined).toContain("\n\n") + expect(combined.split("\n\n")).toEqual(["First part", "Second part"]) + }) + + it("should handle empty inline text with file content", () => { + vi.mocked(existsSync).mockReturnValue(true) + vi.mocked(readFileSync).mockReturnValue("File content") + + // When inline is empty string, use file content only + const inlineText = "" + const fileContent = "File content" + + const combinedSystemPrompt = inlineText ? `${inlineText}\n\n${fileContent}` : fileContent + + expect(combinedSystemPrompt).toBe("File content") + }) + + it("should handle undefined inline text with file content", () => { + vi.mocked(existsSync).mockReturnValue(true) + vi.mocked(readFileSync).mockReturnValue("File content") + + // When inline is undefined, use file content only + const inlineText: string | undefined = undefined + const fileContent = "File content" + + const combinedSystemPrompt = inlineText ? `${inlineText}\n\n${fileContent}` : fileContent + + expect(combinedSystemPrompt).toBe("File content") + }) + }) + + describe("Path resolution", () => { + it("should resolve relative paths from cwd", () => { + const relativePath = "./custom-instructions.md" + const resolvedPath = resolve(relativePath) + + // resolve() should convert relative to absolute based on cwd + expect(resolvedPath).not.toBe(relativePath) + expect(resolvedPath.startsWith("/")).toBe(true) + expect(resolvedPath.endsWith("custom-instructions.md")).toBe(true) + }) + + it("should handle absolute paths directly", () => { + const absolutePath = "/home/user/config/instructions.md" + const resolvedPath = resolve(absolutePath) + + // resolve() should return absolute paths as-is + expect(resolvedPath).toBe(absolutePath) + }) + + it("should resolve parent directory references", () => { + const relativePath = "../parent-dir/instructions.md" + const resolvedPath = resolve(relativePath) + + // Should resolve .. to parent directory + expect(resolvedPath).not.toContain("..") + expect(resolvedPath.endsWith("instructions.md")).toBe(true) + }) + + it("should resolve nested relative paths", () => { + const relativePath = "./config/prompts/instructions.md" + const resolvedPath = resolve(relativePath) + + expect(resolvedPath.endsWith("config/prompts/instructions.md")).toBe(true) + }) + + it("should handle paths without leading ./", () => { + const relativePath = "instructions.md" + const resolvedPath = resolve(relativePath) + + // Should still resolve from cwd + expect(resolvedPath.startsWith("/")).toBe(true) + expect(resolvedPath.endsWith("instructions.md")).toBe(true) + }) + }) + + describe("CLI flag parsing", () => { + it("should parse --append-system-prompt-file flag with value", () => { + // This test validates the expected behavior when the flag is parsed + const mockArgs = ["--append-system-prompt-file", "./instructions.md"] + const expectedValue = "./instructions.md" + + // Simulate what commander.js would do + const parsedValue = mockArgs[1] + expect(parsedValue).toBe(expectedValue) + }) + + it("should handle --append-system-prompt-file with absolute path", () => { + const mockArgs = ["--append-system-prompt-file", "/home/user/instructions.md"] + const expectedValue = "/home/user/instructions.md" + + const parsedValue = mockArgs[1] + expect(parsedValue).toBe(expectedValue) + }) + + it("should handle both --append-system-prompt and --append-system-prompt-file together", () => { + const mockArgs = ["--append-system-prompt", "Inline text", "--append-system-prompt-file", "./file.md"] + + // Simulate parsing both flags + const inlineIndex = mockArgs.indexOf("--append-system-prompt") + const fileIndex = mockArgs.indexOf("--append-system-prompt-file") + + expect(mockArgs[inlineIndex + 1]).toBe("Inline text") + expect(mockArgs[fileIndex + 1]).toBe("./file.md") + }) + }) + + describe("Integration with system prompt", () => { + it("should properly integrate file content into system prompt", () => { + const basePrompt = "You are Kilo Code, an AI assistant." + const fileContent = "Always prioritize code quality." + + vi.mocked(existsSync).mockReturnValue(true) + vi.mocked(readFileSync).mockReturnValue(fileContent) + + const result = `${basePrompt}\n\n${fileContent}` + expect(result).toBe("You are Kilo Code, an AI assistant.\n\nAlways prioritize code quality.") + }) + + it("should handle file content with special characters", () => { + const basePrompt = "Base prompt." + const fileContent = "Use `code blocks` and **bold** text" + + vi.mocked(existsSync).mockReturnValue(true) + vi.mocked(readFileSync).mockReturnValue(fileContent) + + const result = `${basePrompt}\n\n${fileContent}` + expect(result).toContain("`code blocks`") + expect(result).toContain("**bold**") + }) + + it("should combine all three: base + inline + file", () => { + const basePrompt = "Base prompt." + const inlineText = "Inline additions." + const fileContent = "File additions." + + vi.mocked(existsSync).mockReturnValue(true) + vi.mocked(readFileSync).mockReturnValue(fileContent) + + // First combine inline and file + const combinedAdditions = `${inlineText}\n\n${fileContent}` + // Then append to base + const fullPrompt = `${basePrompt}\n\n${combinedAdditions}` + + expect(fullPrompt).toBe("Base prompt.\n\nInline additions.\n\nFile additions.") + }) + }) + + describe("Edge cases", () => { + it("should handle files with only whitespace", () => { + vi.mocked(existsSync).mockReturnValue(true) + vi.mocked(readFileSync).mockReturnValue(" \n\n ") + + const content = readFileSync("/test/whitespace.md", "utf-8") + expect(content).toBe(" \n\n ") + }) + + it("should handle files with trailing newlines", () => { + vi.mocked(existsSync).mockReturnValue(true) + vi.mocked(readFileSync).mockReturnValue("Content\n\n") + + const content = readFileSync("/test/trailing.md", "utf-8") + expect(content).toBe("Content\n\n") + }) + + it("should handle files with Windows line endings", () => { + vi.mocked(existsSync).mockReturnValue(true) + vi.mocked(readFileSync).mockReturnValue("Line 1\r\nLine 2\r\n") + + const content = readFileSync("/test/windows.md", "utf-8") + expect(content).toContain("\r\n") + }) + + it("should handle very long file content", () => { + const longContent = "x".repeat(100000) + vi.mocked(existsSync).mockReturnValue(true) + vi.mocked(readFileSync).mockReturnValue(longContent) + + const content = readFileSync("/test/long.md", "utf-8") + expect(content.length).toBe(100000) + }) + + it("should handle file paths with spaces", () => { + const pathWithSpaces = "/path/with spaces/file name.md" + const resolvedPath = resolve(pathWithSpaces) + + expect(resolvedPath).toContain("with spaces") + expect(resolvedPath).toContain("file name.md") + }) + + it("should handle symbolic characters in path", () => { + const pathWithSymbols = "/path/with-dashes_underscores/file.md" + const resolvedPath = resolve(pathWithSymbols) + + expect(resolvedPath).toBe(pathWithSymbols) + }) + }) }) diff --git a/cli/src/index.ts b/cli/src/index.ts index c7f3ef0ef28..9fd583f7511 100644 --- a/cli/src/index.ts +++ b/cli/src/index.ts @@ -5,7 +5,8 @@ import { loadEnvFile } from "./utils/env-loader.js" loadEnvFile() import { Command } from "commander" -import { existsSync } from "fs" +import { existsSync, readFileSync } from "fs" +import { resolve } from "path" import { CLI } from "./cli.js" import { DEFAULT_MODES, getAllModes } from "./constants/modes/defaults.js" import { getTelemetryService } from "./services/telemetry/index.js" @@ -53,6 +54,7 @@ program .option("-f, --fork ", "Fork a session by ID") .option("--nosplash", "Disable the welcome message and update notifications", false) .option("--append-system-prompt ", "Append custom instructions to the system prompt") + .option("--append-system-prompt-file ", "Read custom instructions from a file to append to the system prompt") .option("--on-task-completed ", "Send a custom prompt to the agent when the task completes") .option( "--attach ", @@ -210,6 +212,32 @@ program } } + // Handle --append-system-prompt-file option + let combinedSystemPrompt = options.appendSystemPrompt || "" + + if (options.appendSystemPromptFile) { + // resolve() handles both absolute and relative paths: + // - Absolute paths (e.g., /home/user/file.md) → returned as-is + // - Relative paths (e.g., ./file.md) → resolved from process.cwd() + const filePath = resolve(options.appendSystemPromptFile) + + if (!existsSync(filePath)) { + console.error(`Error: System prompt file not found: ${filePath}`) + process.exit(1) + } + + try { + const fileContent = readFileSync(filePath, "utf-8") + // Combine: inline text first, then file content + combinedSystemPrompt = combinedSystemPrompt ? `${combinedSystemPrompt}\n\n${fileContent}` : fileContent + } catch (error) { + console.error( + `Error reading system prompt file: ${error instanceof Error ? error.message : String(error)}`, + ) + process.exit(1) + } + } + // Track autonomous mode start if applicable if (options.auto && finalPrompt) { getTelemetryService().trackCIModeStarted(finalPrompt.length, options.timeout) @@ -311,7 +339,7 @@ program session: options.session, fork: options.fork, noSplash: options.nosplash, - appendSystemPrompt: options.appendSystemPrompt, + appendSystemPrompt: combinedSystemPrompt || undefined, attachments: attachments.length > 0 ? attachments : undefined, onTaskCompleted: options.onTaskCompleted, }) diff --git a/cli/src/types/cli.ts b/cli/src/types/cli.ts index 93712d81f45..63ec722605b 100644 --- a/cli/src/types/cli.ts +++ b/cli/src/types/cli.ts @@ -34,6 +34,7 @@ export interface CLIOptions { fork?: string noSplash?: boolean appendSystemPrompt?: string + appendSystemPromptFile?: string attachments?: string[] | undefined onTaskCompleted?: string } diff --git a/packages/core-schemas/src/messages/cli.ts b/packages/core-schemas/src/messages/cli.ts index d7d3f68c5c9..c73c72a336e 100644 --- a/packages/core-schemas/src/messages/cli.ts +++ b/packages/core-schemas/src/messages/cli.ts @@ -55,6 +55,7 @@ export const cliOptionsSchema = z.object({ fork: z.string().optional(), noSplash: z.boolean().optional(), appendSystemPrompt: z.string().optional(), + appendSystemPromptFile: z.string().optional(), }) // Inferred types