diff --git a/src/web-server/routes.ts b/src/web-server/routes.ts
index 25007940..956cae88 100644
--- a/src/web-server/routes.ts
+++ b/src/web-server/routes.ts
@@ -779,3 +779,190 @@ apiRoutes.get('/secrets/:profile/exists', (req: Request, res: Response) => {
keys: Object.keys(secrets), // Only key names, not values
});
});
+
+// ==================== Generic File API (Issue #73) ====================
+
+/**
+ * Security: Validate file path is within allowed directories
+ * - ~/.ccs/ directory: read/write allowed
+ * - ~/.claude/settings.json: read-only
+ */
+function validateFilePath(filePath: string): { valid: boolean; readonly: boolean; error?: string } {
+ const expandedPath = expandPath(filePath);
+ const normalizedPath = path.normalize(expandedPath);
+ const ccsDir = getCcsDir();
+ const claudeSettingsPath = expandPath('~/.claude/settings.json');
+
+ // Check if path is within ~/.ccs/
+ if (normalizedPath.startsWith(ccsDir)) {
+ // Block access to sensitive subdirectories
+ const relativePath = normalizedPath.slice(ccsDir.length);
+ if (relativePath.includes('/.git/') || relativePath.includes('/node_modules/')) {
+ return { valid: false, readonly: false, error: 'Access to this path is not allowed' };
+ }
+ return { valid: true, readonly: false };
+ }
+
+ // Allow read-only access to ~/.claude/settings.json
+ if (normalizedPath === claudeSettingsPath) {
+ return { valid: true, readonly: true };
+ }
+
+ return { valid: false, readonly: false, error: 'Access to this path is not allowed' };
+}
+
+/**
+ * GET /api/file - Read a file with path validation
+ * Query params: path (required)
+ * Returns: { content: string, mtime: number, readonly: boolean, path: string }
+ */
+apiRoutes.get('/file', (req: Request, res: Response): void => {
+ const filePath = req.query.path as string;
+
+ if (!filePath) {
+ res.status(400).json({ error: 'Missing required query parameter: path' });
+ return;
+ }
+
+ const validation = validateFilePath(filePath);
+ if (!validation.valid) {
+ res.status(403).json({ error: validation.error });
+ return;
+ }
+
+ const expandedPath = expandPath(filePath);
+
+ if (!fs.existsSync(expandedPath)) {
+ res.status(404).json({ error: 'File not found' });
+ return;
+ }
+
+ try {
+ const stat = fs.statSync(expandedPath);
+ const content = fs.readFileSync(expandedPath, 'utf8');
+
+ res.json({
+ content,
+ mtime: stat.mtime.getTime(),
+ readonly: validation.readonly,
+ path: expandedPath,
+ });
+ } catch (error) {
+ res.status(500).json({ error: (error as Error).message });
+ }
+});
+
+/**
+ * PUT /api/file - Write a file with conflict detection and backup
+ * Query params: path (required)
+ * Body: { content: string, expectedMtime?: number }
+ * Returns: { success: true, mtime: number, backupPath?: string }
+ */
+apiRoutes.put('/file', (req: Request, res: Response): void => {
+ const filePath = req.query.path as string;
+ const { content, expectedMtime } = req.body;
+
+ if (!filePath) {
+ res.status(400).json({ error: 'Missing required query parameter: path' });
+ return;
+ }
+
+ if (typeof content !== 'string') {
+ res.status(400).json({ error: 'Missing required field: content' });
+ return;
+ }
+
+ const validation = validateFilePath(filePath);
+ if (!validation.valid) {
+ res.status(403).json({ error: validation.error });
+ return;
+ }
+
+ if (validation.readonly) {
+ res.status(403).json({ error: 'File is read-only' });
+ return;
+ }
+
+ const expandedPath = expandPath(filePath);
+ const ccsDir = getCcsDir();
+
+ // Conflict detection (if file exists and expectedMtime provided)
+ if (fs.existsSync(expandedPath) && expectedMtime !== undefined) {
+ const stat = fs.statSync(expandedPath);
+ if (stat.mtime.getTime() !== expectedMtime) {
+ res.status(409).json({
+ error: 'File modified externally',
+ currentMtime: stat.mtime.getTime(),
+ });
+ return;
+ }
+ }
+
+ try {
+ // Create backup if file exists
+ let backupPath: string | undefined;
+ if (fs.existsSync(expandedPath)) {
+ const backupDir = path.join(ccsDir, 'backups');
+ if (!fs.existsSync(backupDir)) {
+ fs.mkdirSync(backupDir, { recursive: true });
+ }
+ const filename = path.basename(expandedPath);
+ const timestamp = new Date().toISOString().replace(/[:.]/g, '-');
+ backupPath = path.join(backupDir, `${filename}.${timestamp}.bak`);
+ fs.copyFileSync(expandedPath, backupPath);
+ }
+
+ // Ensure parent directory exists
+ const parentDir = path.dirname(expandedPath);
+ if (!fs.existsSync(parentDir)) {
+ fs.mkdirSync(parentDir, { recursive: true });
+ }
+
+ // Write atomically
+ const tempPath = expandedPath + '.tmp';
+ fs.writeFileSync(tempPath, content);
+ fs.renameSync(tempPath, expandedPath);
+
+ const newStat = fs.statSync(expandedPath);
+ res.json({
+ success: true,
+ mtime: newStat.mtime.getTime(),
+ backupPath,
+ });
+ } catch (error) {
+ res.status(500).json({ error: (error as Error).message });
+ }
+});
+
+/**
+ * GET /api/files - List editable files in ~/.ccs/
+ * Returns: { files: Array<{ name: string, path: string, mtime: number }> }
+ */
+apiRoutes.get('/files', (_req: Request, res: Response): void => {
+ const ccsDir = getCcsDir();
+
+ if (!fs.existsSync(ccsDir)) {
+ res.json({ files: [] });
+ return;
+ }
+
+ try {
+ const entries = fs.readdirSync(ccsDir, { withFileTypes: true });
+ const files = entries
+ .filter((entry) => entry.isFile() && entry.name.endsWith('.json'))
+ .map((entry) => {
+ const filePath = path.join(ccsDir, entry.name);
+ const stat = fs.statSync(filePath);
+ return {
+ name: entry.name,
+ path: `~/.ccs/${entry.name}`,
+ mtime: stat.mtime.getTime(),
+ };
+ })
+ .sort((a, b) => a.name.localeCompare(b.name));
+
+ res.json({ files });
+ } catch (error) {
+ res.status(500).json({ error: (error as Error).message });
+ }
+});
diff --git a/ui/bun.lock b/ui/bun.lock
index 14e0041b..f2f7ca2e 100644
--- a/ui/bun.lock
+++ b/ui/bun.lock
@@ -23,11 +23,13 @@
"clsx": "^2.1.1",
"date-fns": "^4.1.0",
"lucide-react": "^0.556.0",
+ "prism-react-renderer": "^2.4.1",
"react": "^19.2.0",
"react-day-picker": "^9.12.0",
"react-dom": "^19.2.0",
"react-hook-form": "^7.68.0",
"react-router-dom": "^7.10.1",
+ "react-simple-code-editor": "^0.14.1",
"recharts": "^2.12.0",
"sonner": "^2.0.7",
"tailwind-merge": "^3.4.0",
@@ -387,6 +389,8 @@
"@types/node": ["@types/node@24.10.1", "", { "dependencies": { "undici-types": "~7.16.0" } }, "sha512-GNWcUTRBgIRJD5zj+Tq0fKOJ5XZajIiBroOF0yvj2bSU1WvNdYS/dn9UxwsujGW4JX06dnHyjV2y9rRaybH0iQ=="],
+ "@types/prismjs": ["@types/prismjs@1.26.5", "", {}, "sha512-AUZTa7hQ2KY5L7AmtSiqxlhWxb4ina0yd8hNbl4TWuqnv/pFP0nDMb3YrfSBf4hJVGLh2YEIBfKaBW/9UEl6IQ=="],
+
"@types/react": ["@types/react@19.2.7", "", { "dependencies": { "csstype": "^3.2.2" } }, "sha512-MWtvHrGZLFttgeEj28VXHxpmwYbor/ATPYbBfSFZEIRK0ecCFLl2Qo55z52Hss+UV9CRN7trSeq1zbgx7YDWWg=="],
"@types/react-dom": ["@types/react-dom@19.2.3", "", { "peerDependencies": { "@types/react": "^19.2.0" } }, "sha512-jp2L/eY6fn+KgVVQAOqYItbF0VY/YApe5Mz2F0aykSO8gx31bYCZyvSeYxCHKvzHG5eZjc+zyaS5BrBWya2+kQ=="],
@@ -677,6 +681,8 @@
"prettier": ["prettier@3.7.4", "", { "bin": { "prettier": "bin/prettier.cjs" } }, "sha512-v6UNi1+3hSlVvv8fSaoUbggEM5VErKmmpGA7Pl3HF8V6uKY7rvClBOJlH6yNwQtfTueNkGVpOv/mtWL9L4bgRA=="],
+ "prism-react-renderer": ["prism-react-renderer@2.4.1", "", { "dependencies": { "@types/prismjs": "^1.26.0", "clsx": "^2.0.0" }, "peerDependencies": { "react": ">=16.0.0" } }, "sha512-ey8Ls/+Di31eqzUxC46h8MksNuGx/n0AAC8uKpwFau4RPDYLuE3EXTp8N8G2vX2N7UC/+IXeNUnlWBGGcAG+Ig=="],
+
"prop-types": ["prop-types@15.8.1", "", { "dependencies": { "loose-envify": "^1.4.0", "object-assign": "^4.1.1", "react-is": "^16.13.1" } }, "sha512-oj87CgZICdulUohogVAR7AjlC0327U4el4L6eAvOqCeudMDVU0NThNaV+b9Df4dXgSP1gXMTnPdhfe/2qDH5cg=="],
"punycode": ["punycode@2.3.1", "", {}, "sha512-vYt7UD1U9Wg6138shLtLOvdAu+8DsC/ilFtEVHcH+wydcSpNE20AfSOduf6MkRFahL5FY7X1oU7nKVZFtfq8Fg=="],
@@ -701,6 +707,8 @@
"react-router-dom": ["react-router-dom@7.10.1", "", { "dependencies": { "react-router": "7.10.1" }, "peerDependencies": { "react": ">=18", "react-dom": ">=18" } }, "sha512-JNBANI6ChGVjA5bwsUIwJk7LHKmqB4JYnYfzFwyp2t12Izva11elds2jx7Yfoup2zssedntwU0oZ5DEmk5Sdaw=="],
+ "react-simple-code-editor": ["react-simple-code-editor@0.14.1", "", { "peerDependencies": { "react": ">=16.8.0", "react-dom": ">=16.8.0" } }, "sha512-BR5DtNRy+AswWJECyA17qhUDvrrCZ6zXOCfkQY5zSmb96BVUbpVAv03WpcjcwtCwiLbIANx3gebHOcXYn1EHow=="],
+
"react-smooth": ["react-smooth@4.0.4", "", { "dependencies": { "fast-equals": "^5.0.1", "prop-types": "^15.8.1", "react-transition-group": "^4.4.5" }, "peerDependencies": { "react": "^16.8.0 || ^17.0.0 || ^18.0.0 || ^19.0.0", "react-dom": "^16.8.0 || ^17.0.0 || ^18.0.0 || ^19.0.0" } }, "sha512-gnGKTpYwqL0Iii09gHobNolvX4Kiq4PKx6eWBCYYix+8cdw+cGo3do906l1NBPKkSWx1DghC1dlWG9L2uGd61Q=="],
"react-style-singleton": ["react-style-singleton@2.2.3", "", { "dependencies": { "get-nonce": "^1.0.0", "tslib": "^2.0.0" }, "peerDependencies": { "@types/react": "*", "react": "^16.8.0 || ^17.0.0 || ^18.0.0 || ^19.0.0 || ^19.0.0-rc" }, "optionalPeers": ["@types/react"] }, "sha512-b6jSvxvVnyptAiLjbkWLE/lOnR4lfTtDAl+eUC7RZy+QQWc6wRzIV2CE6xBuMmDxc2qIihtDCZD5NPOFl7fRBQ=="],
diff --git a/ui/package.json b/ui/package.json
index acd632e1..7e02cbdb 100644
--- a/ui/package.json
+++ b/ui/package.json
@@ -34,11 +34,13 @@
"clsx": "^2.1.1",
"date-fns": "^4.1.0",
"lucide-react": "^0.556.0",
+ "prism-react-renderer": "^2.4.1",
"react": "^19.2.0",
"react-day-picker": "^9.12.0",
"react-dom": "^19.2.0",
"react-hook-form": "^7.68.0",
"react-router-dom": "^7.10.1",
+ "react-simple-code-editor": "^0.14.1",
"recharts": "^2.12.0",
"sonner": "^2.0.7",
"tailwind-merge": "^3.4.0",
diff --git a/ui/src/components/code-editor.tsx b/ui/src/components/code-editor.tsx
new file mode 100644
index 00000000..68a8bc8c
--- /dev/null
+++ b/ui/src/components/code-editor.tsx
@@ -0,0 +1,164 @@
+/**
+ * Code Editor Component
+ * Lightweight JSON editor with syntax highlighting, line numbers, and validation
+ * Uses react-simple-code-editor + prism-react-renderer for minimal bundle size (~18KB)
+ */
+
+import { useState, useCallback, useMemo } from 'react';
+import Editor from 'react-simple-code-editor';
+import { Highlight, themes } from 'prism-react-renderer';
+import { useTheme } from '@/hooks/use-theme';
+import { cn } from '@/lib/utils';
+import { AlertCircle, CheckCircle2 } from 'lucide-react';
+
+interface CodeEditorProps {
+ value: string;
+ onChange: (value: string) => void;
+ language?: 'json' | 'yaml';
+ readonly?: boolean;
+ className?: string;
+ minHeight?: string;
+}
+
+interface ValidationResult {
+ valid: boolean;
+ error?: string;
+ line?: number;
+}
+
+/**
+ * Validate JSON and extract error location
+ */
+function validateJson(code: string): ValidationResult {
+ if (!code.trim()) {
+ return { valid: true };
+ }
+
+ try {
+ JSON.parse(code);
+ return { valid: true };
+ } catch (e) {
+ const error = e as SyntaxError;
+ const message = error.message;
+
+ // Try to extract line number from error message
+ // Format: "... at position X" or "... at line Y column Z"
+ const posMatch = message.match(/position (\d+)/);
+ if (posMatch) {
+ const pos = parseInt(posMatch[1], 10);
+ const lines = code.substring(0, pos).split('\n');
+ return {
+ valid: false,
+ error: message,
+ line: lines.length,
+ };
+ }
+
+ return {
+ valid: false,
+ error: message,
+ };
+ }
+}
+
+export function CodeEditor({
+ value,
+ onChange,
+ language = 'json',
+ readonly = false,
+ className,
+ minHeight = '300px',
+}: CodeEditorProps) {
+ const { isDark } = useTheme();
+ const [isFocused, setIsFocused] = useState(false);
+
+ // Validate on every change for JSON
+ const validation = useMemo(() => {
+ if (language === 'json') {
+ return validateJson(value);
+ }
+ return { valid: true };
+ }, [value, language]);
+
+ // Highlight function using prism-react-renderer
+ const highlightCode = useCallback(
+ (code: string) => (
+