feat(cli): add --append-system-prompt-file support

This commit is contained in:
Yehor Baklanov
2026-04-13 06:37:19 +00:00
parent efecd7c7d9
commit 45c231a717
8 changed files with 382 additions and 34 deletions
+3
View File
@@ -1014,6 +1014,7 @@ async function main(): Promise<void> {
: getSettingsPath(profileInfo.name));
const settings = resolvedSettings ?? loadSettings(expandedSettingsPath);
const cliproxyBridge = resolvedCliproxyBridge ?? resolveCliproxyBridgeMetadata(settings);
let imageAnalysisFallbackHookReady: boolean | undefined;
if (resolvedTarget === 'claude') {
if (imageAnalysisMcpReady) {
@@ -1254,6 +1255,7 @@ async function main(): Promise<void> {
const imageAnalysisArgs = imageAnalysisMcpReady
? appendThirdPartyImageAnalysisToolArgs(remainingArgs)
: remainingArgs;
const launchArgs = [
'--settings',
expandedSettingsPath,
@@ -1266,6 +1268,7 @@ async function main(): Promise<void> {
profileType: profileInfo.type,
settingsPath: expandedSettingsPath,
});
execClaude(claudeCli, launchArgs, { ...envVars, ...traceEnv });
} else if (profileInfo.type === 'account') {
// NEW FLOW: Account-based profile (work, personal)
+54 -12
View File
@@ -1,10 +1,21 @@
/**
* Claude launch argument helpers for first-class Image Analysis.
*
* Uses the same prompt injection mode as the user to avoid mixing
* `--append-system-prompt` and `--append-system-prompt-file` in one request.
*/
const APPEND_SYSTEM_PROMPT_FLAG = '--append-system-prompt';
const IMAGE_ANALYSIS_STEERING_PROMPT =
'For local image or PDF files, prefer the CCS MCP tool ImageAnalysis instead of Read. Use Read for text, code, and other plain files. If the user asks a specific question about the visual, pass that question as the focus field when useful. If ImageAnalysis is unavailable or fails, you may fall back to Read.';
import {
buildSteeringArg,
PROMPT_FLAG_INLINE,
PROMPT_FLAG_FILE,
} from '../prompt-injection-strategy';
const IMAGE_ANALYSIS_STEERING_PROMPT = {
name: 'ccs-prompt-image-analysis-tool',
content:
'For local image or PDF files, prefer the CCS MCP tool ImageAnalysis instead of Read. Use Read for text, code, and other plain files. If the user asks a specific question about the visual, pass that question as the focus field when useful. If ImageAnalysis is unavailable or fails, you may fall back to Read.',
};
function splitArgsAtTerminator(args: string[]): { optionArgs: string[]; trailingArgs: string[] } {
const terminatorIndex = args.indexOf('--');
@@ -26,15 +37,28 @@ function getImmediateFlagValue(args: string[], index: number): string | null {
return value;
}
function hasExactFlagValue(args: string[], flag: string, expectedValue: string): boolean {
function hasExactFlagValue(params: {
args: string[];
flag: string;
expectedValue: string;
allowPartiallyMatch?: boolean;
}): boolean {
const { args, flag, expectedValue, allowPartiallyMatch } = params;
for (let index = 0; index < args.length; index += 1) {
const arg = args[index];
if (arg === flag) {
const value = getImmediateFlagValue(args, index);
if (value === expectedValue) {
return true;
}
if (allowPartiallyMatch && value?.includes(expectedValue)) {
return true;
}
continue;
}
@@ -53,16 +77,34 @@ function hasExactFlagValue(args: string[], flag: string, expectedValue: string):
function ensureImageAnalysisSteeringPrompt(args: string[]): string[] {
const { optionArgs, trailingArgs } = splitArgsAtTerminator(args);
if (hasExactFlagValue(optionArgs, APPEND_SYSTEM_PROMPT_FLAG, IMAGE_ANALYSIS_STEERING_PROMPT)) {
if (
hasExactFlagValue({
args: optionArgs,
flag: PROMPT_FLAG_INLINE,
expectedValue: IMAGE_ANALYSIS_STEERING_PROMPT.content,
})
) {
return args;
}
return [
...optionArgs,
APPEND_SYSTEM_PROMPT_FLAG,
IMAGE_ANALYSIS_STEERING_PROMPT,
...trailingArgs,
];
if (
hasExactFlagValue({
args: optionArgs,
flag: PROMPT_FLAG_FILE,
expectedValue: IMAGE_ANALYSIS_STEERING_PROMPT.name,
allowPartiallyMatch: true,
})
) {
return args;
}
const steeringArg = buildSteeringArg({
args: optionArgs,
promptName: IMAGE_ANALYSIS_STEERING_PROMPT.name,
promptContent: IMAGE_ANALYSIS_STEERING_PROMPT.content,
});
return [...optionArgs, ...steeringArg, ...trailingArgs];
}
export function appendThirdPartyImageAnalysisToolArgs(args: string[]): string[] {
@@ -70,5 +112,5 @@ export function appendThirdPartyImageAnalysisToolArgs(args: string[]): string[]
}
export function getImageAnalysisSteeringPrompt(): string {
return IMAGE_ANALYSIS_STEERING_PROMPT;
return IMAGE_ANALYSIS_STEERING_PROMPT.content;
}
+92
View File
@@ -0,0 +1,92 @@
/**
* Shared prompt injection strategy.
*
* Detects which prompt injection mode the user is using and ensures CCS
* always uses the SAME mode so Claude CLI never receives mixed
* `--append-system-prompt` and `--append-system-prompt-file` flags.
*
* Rules:
* - User passes `--append-system-prompt` → all CCS prompts use inline
* - User passes `--append-system-prompt-file` → all CCS prompts use file
* - Neither present → default to inline (`--append-system-prompt`)
*/
import * as fs from 'fs';
import * as path from 'path';
import { getCcsDir } from './config-manager';
export type PromptInjectionMode = 'inline' | 'file';
/** `--append-system-prompt` — inline prompt text */
export const PROMPT_FLAG_INLINE = '--append-system-prompt';
/** `--append-system-prompt-file` — prompt read from file */
export const PROMPT_FLAG_FILE = '--append-system-prompt-file';
/**
* Detect which prompt injection mode to use based on user-provided args.
*
* - `--append-system-prompt-file` found (space or `=` form) → 'file'
* - `--append-system-prompt` found (space or `=` form) → 'inline'
* - Neither → 'inline' (default)
*/
export function detectPromptInjectionMode(args: string[]): PromptInjectionMode {
for (let i = 0; i < args.length; i += 1) {
const arg = args[i];
if (arg === PROMPT_FLAG_FILE || arg.startsWith(`${PROMPT_FLAG_FILE}=`)) {
return 'file';
}
}
return 'inline';
}
/**
* Build a `--append-system-prompt <text>` arg pair.
*/
export function buildInlineSteeringArg(params: { promptContent: string }): string[] {
return [PROMPT_FLAG_INLINE, params.promptContent];
}
/**
* Build a `--append-system-prompt-file <path>` arg pair.
* Writes the prompt to a temp file first.
*/
export function buildFileSteeringArg(params: {
promptFileName: string;
promptContent: string;
}): string[] {
const ccsDir = getCcsDir();
const promptsFolder = path.join(ccsDir, '/prompts');
if (!fs.existsSync(promptsFolder)) {
fs.mkdirSync(promptsFolder, { recursive: true });
}
const promptFile = path.join(promptsFolder, params.promptFileName);
fs.writeFileSync(promptFile, params.promptContent);
return [PROMPT_FLAG_FILE, promptFile];
}
/**
* Build steering prompt args in the given mode.
*/
export function buildSteeringArg(params: {
args: string[];
promptName: string;
promptContent: string;
}): string[] {
const mode = detectPromptInjectionMode(params.args);
if (mode === 'file') {
return buildFileSteeringArg({
promptFileName: `${params.promptName}.txt`,
promptContent: params.promptContent,
});
}
return buildInlineSteeringArg({ promptContent: params.promptContent });
}
+51 -11
View File
@@ -1,12 +1,23 @@
/**
* Claude launch argument helpers for third-party WebSearch.
*
* Uses the same prompt injection mode as the user to avoid mixing
* `--append-system-prompt` and `--append-system-prompt-file` in one request.
*/
import {
buildSteeringArg,
PROMPT_FLAG_INLINE,
PROMPT_FLAG_FILE,
} from '../prompt-injection-strategy';
const NATIVE_WEBSEARCH_TOOL = 'WebSearch';
const DISALLOWED_TOOLS_FLAG = '--disallowedTools';
const APPEND_SYSTEM_PROMPT_FLAG = '--append-system-prompt';
const THIRD_PARTY_WEBSEARCH_STEERING_PROMPT =
'For web lookup or current-information requests, prefer the CCS MCP tool WebSearch instead of Bash/curl/http fetches. If the user explicitly wants shell commands, or WebSearch is unavailable or fails, you may fall back to Bash/network tools.';
export const THIRD_PARTY_WEBSEARCH_STEERING_PROMPT = {
name: 'ccs-prompt-websearch-tool',
content:
'For web lookup or current-information requests, prefer the CCS MCP tool WebSearch instead of Bash/curl/http fetches. If the user explicitly wants shell commands, or WebSearch is unavailable or fails, you may fall back to Bash/network tools.',
};
function parseToolValue(rawValue: string): string[] {
return rawValue
@@ -68,15 +79,28 @@ function hasToolInFlag(args: string[], flag: string, toolName: string): boolean
return false;
}
function hasExactFlagValue(args: string[], flag: string, expectedValue: string): boolean {
function hasExactFlagValue(params: {
args: string[];
flag: string;
expectedValue: string;
allowPartiallyMatch?: boolean;
}): boolean {
const { args, flag, expectedValue, allowPartiallyMatch } = params;
for (let index = 0; index < args.length; index += 1) {
const arg = args[index];
if (arg === flag) {
const value = getImmediateFlagValue(args, index);
if (value === expectedValue) {
return true;
}
if (allowPartiallyMatch && value?.includes(expectedValue)) {
return true;
}
continue;
}
@@ -135,17 +159,33 @@ function ensureWebSearchSteeringPrompt(args: string[]): string[] {
const { optionArgs, trailingArgs } = splitArgsAtTerminator(args);
if (
hasExactFlagValue(optionArgs, APPEND_SYSTEM_PROMPT_FLAG, THIRD_PARTY_WEBSEARCH_STEERING_PROMPT)
hasExactFlagValue({
args: optionArgs,
flag: PROMPT_FLAG_INLINE,
expectedValue: THIRD_PARTY_WEBSEARCH_STEERING_PROMPT.content,
})
) {
return args;
}
return [
...optionArgs,
APPEND_SYSTEM_PROMPT_FLAG,
THIRD_PARTY_WEBSEARCH_STEERING_PROMPT,
...trailingArgs,
];
if (
hasExactFlagValue({
args: optionArgs,
flag: PROMPT_FLAG_FILE,
expectedValue: THIRD_PARTY_WEBSEARCH_STEERING_PROMPT.name,
allowPartiallyMatch: true,
})
) {
return args;
}
const steeringArgs = buildSteeringArg({
args: optionArgs,
promptName: THIRD_PARTY_WEBSEARCH_STEERING_PROMPT.name,
promptContent: THIRD_PARTY_WEBSEARCH_STEERING_PROMPT.content,
});
return [...optionArgs, ...steeringArgs, ...trailingArgs];
}
export function appendThirdPartyWebSearchToolArgs(args: string[]): string[] {
+44 -10
View File
@@ -11,13 +11,12 @@ import * as os from 'os';
import * as path from 'path';
import { getCcsDir } from '../config-manager';
import { createLogger } from '../../services/logging';
import { PROMPT_FLAG_INLINE, PROMPT_FLAG_FILE } from '../prompt-injection-strategy';
import { THIRD_PARTY_WEBSEARCH_STEERING_PROMPT } from './claude-tool-args';
const TRACE_FILE_NAME = 'websearch-trace.jsonl';
const NATIVE_WEBSEARCH_TOOL = 'WebSearch';
const DISALLOWED_TOOLS_FLAG = '--disallowedTools';
const APPEND_SYSTEM_PROMPT_FLAG = '--append-system-prompt';
const THIRD_PARTY_WEBSEARCH_STEERING_PROMPT =
'For web lookup or current-information requests, prefer the CCS MCP tool WebSearch instead of Bash/curl/http fetches. If the user explicitly wants shell commands, or WebSearch is unavailable or fails, you may fall back to Bash/network tools.';
const logger = createLogger('websearch');
function parseToolValue(rawValue: string): string[] {
@@ -58,14 +57,28 @@ function hasToolInFlag(args: string[], flag: string, toolName: string): boolean
return false;
}
function hasExactFlagValue(args: string[], flag: string, expectedValue: string): boolean {
function hasExactFlagValue(params: {
args: string[];
flag: string;
expectedValue: string;
allowIncludesValue?: boolean;
}): boolean {
const { args, flag, expectedValue, allowIncludesValue } = params;
for (let index = 0; index < args.length; index += 1) {
const arg = args[index];
if (arg === flag) {
if (getImmediateFlagValue(args, index) === expectedValue) {
const immediateFlagValue = getImmediateFlagValue(args, index);
if (immediateFlagValue === expectedValue) {
return true;
}
if (allowIncludesValue && immediateFlagValue?.includes(expectedValue)) {
return true;
}
continue;
}
@@ -179,16 +192,37 @@ function buildLaunchId(): string {
return `websearch-${Date.now()}-${process.pid}-${random}`;
}
function hasSteeringPromptInArgs(args: string[]): boolean {
if (
hasExactFlagValue({
args,
flag: PROMPT_FLAG_INLINE,
expectedValue: THIRD_PARTY_WEBSEARCH_STEERING_PROMPT.content,
})
) {
return true;
}
if (
hasExactFlagValue({
args,
flag: PROMPT_FLAG_FILE,
expectedValue: THIRD_PARTY_WEBSEARCH_STEERING_PROMPT.name,
allowIncludesValue: true,
})
) {
return true;
}
return false;
}
function summarizeLaunchArgs(args: string[]): Record<string, unknown> {
return {
argCount: args.length,
hasSettingsFlag: args.includes('--settings'),
nativeWebSearchDisallowed: hasToolInFlag(args, DISALLOWED_TOOLS_FLAG, NATIVE_WEBSEARCH_TOOL),
steeringPromptApplied: hasExactFlagValue(
args,
APPEND_SYSTEM_PROMPT_FLAG,
THIRD_PARTY_WEBSEARCH_STEERING_PROMPT
),
steeringPromptApplied: hasSteeringPromptInArgs(args),
};
}
@@ -36,4 +36,34 @@ describe('appendThirdPartyImageAnalysisToolArgs', () => {
'extra',
]);
});
// File mode: --append-system-prompt-file when user passes --append-system-prompt-file
it('uses --append-system-prompt-file when user passes --append-system-prompt-file', () => {
const result = appendThirdPartyImageAnalysisToolArgs([
'-p',
'describe',
'--append-system-prompt-file',
'/tmp/user-prompt.txt',
]);
expect(result).toContain('--append-system-prompt-file');
expect(result).not.toContain('--append-system-prompt');
const fileFlags = result.filter((arg) => arg === '--append-system-prompt-file');
expect(fileFlags.length).toBeGreaterThanOrEqual(2);
});
it('uses --append-system-prompt-file when user passes equals form', () => {
const result = appendThirdPartyImageAnalysisToolArgs([
'-p',
'describe',
'--append-system-prompt-file=/tmp/user-prompt.txt',
]);
expect(result).not.toContain('--append-system-prompt');
const fileFlags = result.filter(
(arg) => arg === '--append-system-prompt-file' || arg.startsWith('--append-system-prompt-file=')
);
expect(fileFlags.length).toBeGreaterThanOrEqual(2);
});
});
@@ -0,0 +1,79 @@
import { describe, expect, it } from 'bun:test';
import {
detectPromptInjectionMode,
buildInlineSteeringArg,
buildFileSteeringArg,
buildSteeringArg,
PROMPT_FLAG_INLINE,
PROMPT_FLAG_FILE,
} from '../../../src/utils/prompt-injection-strategy';
describe('detectPromptInjectionMode', () => {
it('returns inline when no prompt flags present', () => {
expect(detectPromptInjectionMode(['-p', 'hello'])).toBe('inline');
});
it('returns inline when only --append-system-prompt is present', () => {
expect(detectPromptInjectionMode(['--append-system-prompt', 'test'])).toBe('inline');
});
it('returns inline when --append-system-prompt equals form is present', () => {
expect(detectPromptInjectionMode(['--append-system-prompt=test'])).toBe('inline');
});
it('returns file when --append-system-prompt-file is present', () => {
expect(detectPromptInjectionMode(['--append-system-prompt-file', '/tmp/p.txt'])).toBe('file');
});
it('returns file when --append-system-prompt-file equals form is present', () => {
expect(detectPromptInjectionMode(['--append-system-prompt-file=/tmp/p.txt'])).toBe('file');
});
it('returns file even when --append-system-prompt is also present', () => {
expect(
detectPromptInjectionMode([
'--append-system-prompt',
'inline-text',
'--append-system-prompt-file',
'/tmp/p.txt',
])
).toBe('file');
});
});
describe('buildInlineSteeringArg', () => {
it('returns inline flag and prompt text', () => {
expect(buildInlineSteeringArg({promptContent: 'hello world'})).toEqual(['--append-system-prompt', 'hello world']);
});
});
describe('buildFileSteeringArg', () => {
it('returns file flag and writes temp file', () => {
const result = buildFileSteeringArg({promptFileName: 'ccs-test-prompt.txt', promptContent: 'hello world' });
expect(result[0]).toBe('--append-system-prompt-file');
expect(result[1]).toContain('ccs-test-prompt.txt');
});
});
describe('buildSteeringArg', () => {
it('delegates to inline in inline mode', () => {
expect(buildSteeringArg({
args: [PROMPT_FLAG_INLINE],
promptName: 'ignored.txt',
promptContent: 'hello',
})).toEqual([
'--append-system-prompt',
'hello',
]);
});
it('delegates to file in file mode', () => {
const result = buildSteeringArg({
args: [PROMPT_FLAG_FILE],
promptName: 'ccs-test',
promptContent: 'hello',
});
expect(result[0]).toBe('--append-system-prompt-file');
expect(result[1]).toContain('ccs-test.txt');
});
});
@@ -5,7 +5,7 @@ const STEERING_PROMPT =
'For web lookup or current-information requests, prefer the CCS MCP tool WebSearch instead of Bash/curl/http fetches. If the user explicitly wants shell commands, or WebSearch is unavailable or fails, you may fall back to Bash/network tools.';
describe('appendThirdPartyWebSearchToolArgs', () => {
it('appends native WebSearch suppression and steering prompt when no tool flags are present', () => {
it('appends native WebSearch suppression and inline steering prompt when no prompt flags are present', () => {
expect(appendThirdPartyWebSearchToolArgs(['smoke'])).toEqual([
'smoke',
'--disallowedTools',
@@ -129,4 +129,32 @@ describe('appendThirdPartyWebSearchToolArgs', () => {
STEERING_PROMPT,
]);
});
// File mode: --append-system-prompt-file when user passes --append-system-prompt-file
it('uses --append-system-prompt-file when user passes --append-system-prompt-file', () => {
const result = appendThirdPartyWebSearchToolArgs([
'smoke',
'--append-system-prompt-file',
'/tmp/user-prompt.txt',
]);
expect(result).toContain('--disallowedTools');
expect(result).toContain('WebSearch');
const fileFlags = result.filter((arg) => arg === '--append-system-prompt-file');
expect(fileFlags.length).toBeGreaterThanOrEqual(2);
// No inline flag should be present
expect(result).not.toContain('--append-system-prompt');
});
it('uses --append-system-prompt-file when user passes --append-system-prompt-file= form', () => {
const result = appendThirdPartyWebSearchToolArgs([
'smoke',
'--append-system-prompt-file=/tmp/user-prompt.txt',
]);
const fileFlags = result.filter(
(arg) => arg === '--append-system-prompt-file' || arg.startsWith('--append-system-prompt-file=')
);
expect(fileFlags.length).toBeGreaterThanOrEqual(2);
expect(result).not.toContain('--append-system-prompt');
});
});