From e8f1caa219cadae1ab8e06d32541112dfb80e3c6 Mon Sep 17 00:00:00 2001 From: catlog22 Date: Thu, 11 Dec 2025 23:06:47 +0800 Subject: [PATCH] feat: Enhance CLI components with icons and improve file editing capabilities - Added icons to the CLI History and CLI Tools headers for better UI representation. - Updated CLI Status component to include tool-specific classes for styling. - Refactored CCW Install Panel to improve layout and functionality, including upgrade and uninstall buttons. - Enhanced the edit-file tool with new features: - Support for creating parent directories when writing files. - Added dryRun mode for previewing changes without modifying files. - Implemented a unified diff output for changes made. - Enabled multi-edit support in update mode. - Introduced a new Smart Search Tool with multiple search modes (auto, exact, fuzzy, semantic, graph) and intent classification. - Created a Write File Tool to handle file creation and overwriting with backup options. --- .claude/workflows/tool-strategy.md | 47 +- ccw/package.json | 2 +- ccw/src/commands/install.js | 7 + ccw/src/commands/upgrade.js | 7 + ccw/src/core/server.js | 52 ++ ccw/src/templates/dashboard-css/10-cli.css | 106 ++-- .../dashboard-js/components/cli-history.js | 4 +- .../dashboard-js/components/cli-status.js | 6 +- .../dashboard-js/views/cli-manager.js | 47 +- ccw/src/tools/edit-file.js | 279 ++++++++-- ccw/src/tools/index.js | 4 + ccw/src/tools/smart-search.js | 487 ++++++++++++++++++ ccw/src/tools/write-file.js | 152 ++++++ 13 files changed, 1087 insertions(+), 113 deletions(-) create mode 100644 ccw/src/tools/smart-search.js create mode 100644 ccw/src/tools/write-file.js diff --git a/.claude/workflows/tool-strategy.md b/.claude/workflows/tool-strategy.md index 9ca703e5..7e336586 100644 --- a/.claude/workflows/tool-strategy.md +++ b/.claude/workflows/tool-strategy.md @@ -26,35 +26,44 @@ ccw tool exec classify_folders '{"path": "./src"}' **Available Tools**: `ccw tool list` -### edit_file Tool (AI-Powered Editing) +### edit_file Tool **When to Use**: Edit tool fails 1+ times on same file -### Usage - -**Best for**: Code block replacements, function rewrites, multi-line changes - ```bash -ccw tool exec edit_file --path "file.py" --old "def old(): - pass" --new "def new(): - return True" +# CLI shorthand +ccw tool exec edit_file --path "file.py" --old "old code" --new "new code" + +# JSON (recommended) +ccw tool exec edit_file '{"path": "file.py", "oldText": "old", "newText": "new"}' + +# dryRun - preview without modifying +ccw tool exec edit_file '{"path": "file.py", "oldText": "old", "newText": "new", "dryRun": true}' + +# Multiple edits +ccw tool exec edit_file '{"path": "file.py", "edits": [{"oldText": "a", "newText": "b"}, {"oldText": "c", "newText": "d"}]}' + +# Line mode +ccw tool exec edit_file '{"path": "file.py", "mode": "line", "operation": "insert_after", "line": 10, "text": "new"}' ``` -**Parameters**: -- `--path`: File path to edit -- `--old`: Text to find and replace -- `--new`: New text to insert +**Parameters**: `path`*, `oldText`, `newText`, `edits[]`, `dryRun`, `replaceAll`, `mode` (update|line) -**Features**: -- ✅ Exact text matching (precise and predictable) -- ✅ Auto line ending adaptation (CRLF/LF) -- ✅ No JSON escaping issues -- ✅ Multi-line text supported with quotes +### write_file Tool + +**When to Use**: Create new files or overwrite existing content + +```bash +ccw tool exec write_file '{"path": "file.txt", "content": "Hello"}' +ccw tool exec write_file '{"path": "file.txt", "content": "new", "backup": true}' +``` + +**Parameters**: `path`*, `content`*, `createDirectories`, `backup`, `encoding` ### Fallback Strategy -1. **Edit fails 1+ times** → Use `ccw tool exec edit_file` -2. **Still fails** → Use Write to recreate file +1. **Edit fails 1+ times** → `ccw tool exec edit_file` +2. **Still fails** → `ccw tool exec write_file` ## ⚡ sed Line Operations (Line Mode Alternative) diff --git a/ccw/package.json b/ccw/package.json index a72b5d52..3a984e7a 100644 --- a/ccw/package.json +++ b/ccw/package.json @@ -1,6 +1,6 @@ { "name": "ccw", - "version": "1.0.0", + "version": "6.1.4", "description": "Claude Code Workflow CLI - Dashboard viewer for workflow sessions and reviews", "type": "module", "main": "src/index.js", diff --git a/ccw/src/commands/install.js b/ccw/src/commands/install.js index 826595ff..7e0c6d70 100644 --- a/ccw/src/commands/install.js +++ b/ccw/src/commands/install.js @@ -331,6 +331,13 @@ async function copyDirectory(src, dest, manifest = null, excludeDirs = []) { */ function getVersion() { try { + // First try root package.json (parent of ccw) + const rootPkgPath = join(getSourceDir(), 'package.json'); + if (existsSync(rootPkgPath)) { + const pkg = JSON.parse(readFileSync(rootPkgPath, 'utf8')); + if (pkg.version) return pkg.version; + } + // Fallback to ccw package.json const pkgPath = join(getPackageRoot(), 'package.json'); const pkg = JSON.parse(readFileSync(pkgPath, 'utf8')); return pkg.version || '1.0.0'; diff --git a/ccw/src/commands/upgrade.js b/ccw/src/commands/upgrade.js index fffa8d99..3efa2287 100644 --- a/ccw/src/commands/upgrade.js +++ b/ccw/src/commands/upgrade.js @@ -32,6 +32,13 @@ function getSourceDir() { */ function getVersion() { try { + // First try root package.json (parent of ccw) + const rootPkgPath = join(getSourceDir(), 'package.json'); + if (existsSync(rootPkgPath)) { + const pkg = JSON.parse(readFileSync(rootPkgPath, 'utf8')); + if (pkg.version) return pkg.version; + } + // Fallback to ccw package.json const pkgPath = join(getPackageRoot(), 'package.json'); const pkg = JSON.parse(readFileSync(pkgPath, 'utf8')); return pkg.version || '1.0.0'; diff --git a/ccw/src/core/server.js b/ccw/src/core/server.js index 1e9b801c..584697f2 100644 --- a/ccw/src/core/server.js +++ b/ccw/src/core/server.js @@ -459,6 +459,58 @@ export async function startServer(options = {}) { return; } + // API: CCW Upgrade + if (pathname === '/api/ccw/upgrade' && req.method === 'POST') { + handlePostRequest(req, res, async (body) => { + const { path: installPath } = body; + + try { + const { spawn } = await import('child_process'); + + // Run ccw upgrade command + const args = installPath ? ['upgrade', '--all'] : ['upgrade', '--all']; + const upgradeProcess = spawn('ccw', args, { + shell: true, + stdio: ['ignore', 'pipe', 'pipe'] + }); + + let stdout = ''; + let stderr = ''; + + upgradeProcess.stdout.on('data', (data) => { + stdout += data.toString(); + }); + + upgradeProcess.stderr.on('data', (data) => { + stderr += data.toString(); + }); + + return new Promise((resolve) => { + upgradeProcess.on('close', (code) => { + if (code === 0) { + resolve({ success: true, message: 'Upgrade completed', output: stdout }); + } else { + resolve({ success: false, error: stderr || 'Upgrade failed', output: stdout, status: 500 }); + } + }); + + upgradeProcess.on('error', (err) => { + resolve({ success: false, error: err.message, status: 500 }); + }); + + // Timeout after 2 minutes + setTimeout(() => { + upgradeProcess.kill(); + resolve({ success: false, error: 'Upgrade timed out', status: 504 }); + }, 120000); + }); + } catch (err) { + return { success: false, error: err.message, status: 500 }; + } + }); + return; + } + // API: CLI Execution History if (pathname === '/api/cli/history') { const projectPath = url.searchParams.get('path') || initialPath; diff --git a/ccw/src/templates/dashboard-css/10-cli.css b/ccw/src/templates/dashboard-css/10-cli.css index 2cfaad0c..85a462ca 100644 --- a/ccw/src/templates/dashboard-css/10-cli.css +++ b/ccw/src/templates/dashboard-css/10-cli.css @@ -13,8 +13,9 @@ .cli-manager-grid { display: grid; - grid-template-columns: 1fr 1fr; + grid-template-columns: 1.2fr 0.8fr; gap: 1.25rem; + align-items: start; } @media (max-width: 768px) { @@ -46,12 +47,15 @@ display: flex; align-items: center; justify-content: space-between; - padding: 0.875rem 1rem; + padding: 0.625rem 0.75rem; border-bottom: 1px solid hsl(var(--border)); background: hsl(var(--muted) / 0.3); } .cli-status-header h3 { + display: flex; + align-items: center; + gap: 0.5rem; font-size: 0.8125rem; font-weight: 600; color: hsl(var(--foreground)); @@ -59,30 +63,57 @@ letter-spacing: -0.01em; } +.cli-status-header h3 i { + color: hsl(var(--muted-foreground)); +} + .cli-tools-grid { display: grid; grid-template-columns: repeat(3, 1fr); - gap: 0.625rem; - padding: 0.875rem; + gap: 0.5rem; + padding: 0.5rem 0.625rem; } .cli-tool-card { - padding: 0.875rem 0.75rem; + padding: 0.625rem 0.5rem; border-radius: 0.5rem; background: hsl(var(--background)); text-align: center; - border: 1px solid hsl(var(--border)); + border: 1.5px solid hsl(var(--border)); transition: all 0.2s ease; } .cli-tool-card.available { - border-color: hsl(var(--success) / 0.4); - background: hsl(var(--success) / 0.03); + background: hsl(var(--background)); } .cli-tool-card.available:hover { - border-color: hsl(var(--success) / 0.6); - background: hsl(var(--success) / 0.06); + box-shadow: 0 2px 8px hsl(var(--foreground) / 0.08); +} + +/* Tool-specific border colors */ +.cli-tool-card.tool-gemini.available { + border-color: hsl(210 80% 55% / 0.5); +} + +.cli-tool-card.tool-gemini.available:hover { + border-color: hsl(210 80% 55% / 0.7); +} + +.cli-tool-card.tool-qwen.available { + border-color: hsl(280 70% 55% / 0.5); +} + +.cli-tool-card.tool-qwen.available:hover { + border-color: hsl(280 70% 55% / 0.7); +} + +.cli-tool-card.tool-codex.available { + border-color: hsl(142 71% 45% / 0.5); +} + +.cli-tool-card.tool-codex.available:hover { + border-color: hsl(142 71% 45% / 0.7); } .cli-tool-card.unavailable { @@ -94,8 +125,8 @@ display: flex; align-items: center; justify-content: center; - gap: 0.5rem; - margin-bottom: 0.375rem; + gap: 0.375rem; + margin-bottom: 0.1875rem; } .cli-tool-status { @@ -134,7 +165,7 @@ .cli-tool-info { font-size: 0.6875rem; - margin-bottom: 0.5rem; + margin-bottom: 0.3125rem; color: hsl(var(--muted-foreground)); } @@ -222,6 +253,9 @@ } .cli-history-header h3 { + display: flex; + align-items: center; + gap: 0.5rem; font-size: 0.8125rem; font-weight: 600; color: hsl(var(--foreground)); @@ -229,6 +263,10 @@ letter-spacing: -0.01em; } +.cli-history-header h3 i { + color: hsl(var(--muted-foreground)); +} + .cli-history-controls { display: flex; align-items: center; @@ -683,7 +721,7 @@ /* CCW Install Content */ .ccw-install-content { - padding: 0.875rem; + padding: 0.5rem 0.625rem; } /* CCW Empty State */ @@ -727,7 +765,7 @@ .ccw-carousel-card { flex: 0 0 100%; min-width: 0; - padding: 1rem; + padding: 0.625rem 0.75rem; background: hsl(var(--background)); border: 1px solid hsl(var(--border)); border-radius: 0.5rem; @@ -744,18 +782,28 @@ display: flex; align-items: center; justify-content: space-between; - margin-bottom: 0.75rem; + margin-bottom: 0.375rem; +} + +.ccw-card-header-right { + display: flex; + align-items: center; + gap: 0.375rem; } .ccw-card-mode { display: flex; align-items: center; - gap: 0.5rem; + gap: 0.375rem; font-weight: 600; - font-size: 0.875rem; + font-size: 0.8125rem; color: hsl(var(--foreground)); } +.btn-icon-sm { + padding: 0.25rem; +} + .ccw-card-mode.global { color: hsl(var(--primary)); } @@ -776,13 +824,13 @@ /* Carousel Card Path */ .ccw-card-path { - font-size: 0.75rem; + font-size: 0.6875rem; color: hsl(var(--muted-foreground)); font-family: 'SF Mono', 'Consolas', 'Liberation Mono', monospace; background: hsl(var(--muted) / 0.5); - padding: 0.5rem 0.625rem; + padding: 0.3125rem 0.5rem; border-radius: 0.375rem; - margin-bottom: 0.75rem; + margin-bottom: 0.375rem; overflow: hidden; text-overflow: ellipsis; white-space: nowrap; @@ -791,10 +839,9 @@ /* Carousel Card Meta */ .ccw-card-meta { display: flex; - gap: 1rem; - font-size: 0.6875rem; + gap: 0.75rem; + font-size: 0.625rem; color: hsl(var(--muted-foreground)); - margin-bottom: 0.75rem; } .ccw-card-meta span { @@ -803,14 +850,7 @@ gap: 0.25rem; } -/* Carousel Card Actions */ -.ccw-card-actions { - display: flex; - justify-content: flex-end; - gap: 0.375rem; - padding-top: 0.75rem; - border-top: 1px solid hsl(var(--border)); -} +/* Carousel Card Actions - moved to header */ /* Carousel Navigation Buttons */ .ccw-carousel-btn { @@ -844,7 +884,7 @@ display: flex; justify-content: center; gap: 0.5rem; - margin-top: 0.75rem; + margin-top: 0.5rem; } .ccw-carousel-dot { diff --git a/ccw/src/templates/dashboard-js/components/cli-history.js b/ccw/src/templates/dashboard-js/components/cli-history.js index f16d0b41..c2bc4382 100644 --- a/ccw/src/templates/dashboard-js/components/cli-history.js +++ b/ccw/src/templates/dashboard-js/components/cli-history.js @@ -56,7 +56,7 @@ function renderCliHistory() { if (cliExecutionHistory.length === 0) { container.innerHTML = `
-

Execution History

+

Execution History

${renderHistorySearch()} ${renderToolFilter()} @@ -116,7 +116,7 @@ function renderCliHistory() { container.innerHTML = `
-

Execution History

+

Execution History

${renderHistorySearch()} ${renderToolFilter()} diff --git a/ccw/src/templates/dashboard-js/components/cli-status.js b/ccw/src/templates/dashboard-js/components/cli-status.js index a2eae932..18ceb0a9 100644 --- a/ccw/src/templates/dashboard-js/components/cli-status.js +++ b/ccw/src/templates/dashboard-js/components/cli-status.js @@ -55,7 +55,7 @@ function renderCliStatus() { const isDefault = defaultCliTool === tool; return ` -
+
${tool.charAt(0).toUpperCase() + tool.slice(1)} @@ -77,9 +77,9 @@ function renderCliStatus() { container.innerHTML = `
-

CLI Tools

+

CLI Tools

diff --git a/ccw/src/templates/dashboard-js/views/cli-manager.js b/ccw/src/templates/dashboard-js/views/cli-manager.js index 6e966f91..689c311e 100644 --- a/ccw/src/templates/dashboard-js/views/cli-manager.js +++ b/ccw/src/templates/dashboard-js/views/cli-manager.js @@ -78,7 +78,7 @@ function renderCcwInstallPanel() { var container = document.getElementById('ccw-install-panel'); if (!container) return; - var html = '

CCW Installations

' + + var html = '

CCW Installations

' + '
' + '' + @@ -119,19 +119,19 @@ function renderCcwInstallPanel() { '' + '' + inst.installation_mode + '' + '
' + + '
' + 'v' + version + '' + + '' + + '' + + '
' + '
' + '
' + escapeHtml(inst.installation_path) + '
' + '
' + ' ' + installDate + '' + ' ' + (inst.files_count || 0) + ' files' + '
' + - '
' + - '' + - '' + - '
' + '
'; } @@ -336,16 +336,31 @@ function runCcwInstall(mode, customPath) { } } -function runCcwUpgrade() { - var command = 'ccw upgrade'; - if (navigator.clipboard) { - navigator.clipboard.writeText(command).then(function() { - showRefreshToast('Command copied: ' + command, 'success'); - }).catch(function() { - showRefreshToast('Run: ' + command, 'info'); +async function runCcwUpgrade() { + showRefreshToast('Starting upgrade...', 'info'); + + try { + var response = await fetch('/api/ccw/upgrade', { + method: 'POST', + headers: { 'Content-Type': 'application/json' }, + body: JSON.stringify({}) }); - } else { - showRefreshToast('Run: ' + command, 'info'); + + var result = await response.json(); + + if (result.success) { + showRefreshToast('Upgrade completed! Refreshing...', 'success'); + // Reload installations after upgrade + setTimeout(function() { + loadCcwInstallations().then(function() { + renderCcwInstallPanel(); + }); + }, 1000); + } else { + showRefreshToast('Upgrade failed: ' + (result.error || 'Unknown error'), 'error'); + } + } catch (err) { + showRefreshToast('Upgrade error: ' + err.message, 'error'); } } diff --git a/ccw/src/tools/edit-file.js b/ccw/src/tools/edit-file.js index 8f440308..5452d2df 100644 --- a/ccw/src/tools/edit-file.js +++ b/ccw/src/tools/edit-file.js @@ -3,10 +3,16 @@ * Two complementary modes: * - update: Content-driven text replacement (AI primary use) * - line: Position-driven line operations (precise control) + * + * Features: + * - dryRun mode for previewing changes + * - Git-style diff output + * - Multi-edit support in update mode + * - Auto line-ending adaptation (CRLF/LF) */ -import { readFileSync, writeFileSync, existsSync } from 'fs'; -import { resolve, isAbsolute } from 'path'; +import { readFileSync, writeFileSync, existsSync, mkdirSync } from 'fs'; +import { resolve, isAbsolute, dirname } from 'path'; /** * Resolve file path and read content @@ -29,51 +35,218 @@ function readFile(filePath) { } /** - * Write content to file + * Write content to file with optional parent directory creation * @param {string} filePath - Path to file * @param {string} content - Content to write + * @param {boolean} createDirs - Create parent directories if needed */ -function writeFile(filePath, content) { +function writeFile(filePath, content, createDirs = false) { try { + if (createDirs) { + const dir = dirname(filePath); + if (!existsSync(dir)) { + mkdirSync(dir, { recursive: true }); + } + } writeFileSync(filePath, content, 'utf8'); } catch (error) { throw new Error(`Failed to write file: ${error.message}`); } } +/** + * Normalize line endings to LF + * @param {string} text - Input text + * @returns {string} - Text with LF line endings + */ +function normalizeLineEndings(text) { + return text.replace(/\r\n/g, '\n'); +} + +/** + * Create unified diff between two strings + * @param {string} original - Original content + * @param {string} modified - Modified content + * @param {string} filePath - File path for diff header + * @returns {string} - Unified diff string + */ +function createUnifiedDiff(original, modified, filePath) { + const origLines = normalizeLineEndings(original).split('\n'); + const modLines = normalizeLineEndings(modified).split('\n'); + + const diffLines = [ + `--- a/${filePath}`, + `+++ b/${filePath}` + ]; + + // Simple diff algorithm - find changes + let i = 0, j = 0; + let hunk = []; + let hunkStart = 0; + let origStart = 0; + let modStart = 0; + + while (i < origLines.length || j < modLines.length) { + if (i < origLines.length && j < modLines.length && origLines[i] === modLines[j]) { + // Context line + if (hunk.length > 0) { + hunk.push(` ${origLines[i]}`); + } + i++; + j++; + } else { + // Start or continue hunk + if (hunk.length === 0) { + origStart = i + 1; + modStart = j + 1; + // Add context before + const contextStart = Math.max(0, i - 3); + for (let c = contextStart; c < i; c++) { + hunk.push(` ${origLines[c]}`); + } + origStart = contextStart + 1; + modStart = contextStart + 1; + } + + // Find where lines match again + let foundMatch = false; + for (let lookAhead = 1; lookAhead <= 10; lookAhead++) { + if (i + lookAhead < origLines.length && j < modLines.length && + origLines[i + lookAhead] === modLines[j]) { + // Remove lines from original + for (let r = 0; r < lookAhead; r++) { + hunk.push(`-${origLines[i + r]}`); + } + i += lookAhead; + foundMatch = true; + break; + } + if (j + lookAhead < modLines.length && i < origLines.length && + modLines[j + lookAhead] === origLines[i]) { + // Add lines to modified + for (let a = 0; a < lookAhead; a++) { + hunk.push(`+${modLines[j + a]}`); + } + j += lookAhead; + foundMatch = true; + break; + } + } + + if (!foundMatch) { + // Replace line + if (i < origLines.length) { + hunk.push(`-${origLines[i]}`); + i++; + } + if (j < modLines.length) { + hunk.push(`+${modLines[j]}`); + j++; + } + } + } + + // Flush hunk if we've had 3 context lines after changes + const lastChangeIdx = hunk.findLastIndex(l => l.startsWith('+') || l.startsWith('-')); + if (lastChangeIdx >= 0 && hunk.length - lastChangeIdx > 3) { + const origCount = hunk.filter(l => !l.startsWith('+')).length; + const modCount = hunk.filter(l => !l.startsWith('-')).length; + diffLines.push(`@@ -${origStart},${origCount} +${modStart},${modCount} @@`); + diffLines.push(...hunk); + hunk = []; + } + } + + // Flush remaining hunk + if (hunk.length > 0) { + const origCount = hunk.filter(l => !l.startsWith('+')).length; + const modCount = hunk.filter(l => !l.startsWith('-')).length; + diffLines.push(`@@ -${origStart},${origCount} +${modStart},${modCount} @@`); + diffLines.push(...hunk); + } + + return diffLines.length > 2 ? diffLines.join('\n') : ''; +} + /** * Mode: update - Simple text replacement * Auto-adapts line endings (CRLF/LF) + * Supports multiple edits via 'edits' array */ -function executeUpdateMode(content, params) { - const { oldText, newText, replaceAll } = params; - - if (!oldText) throw new Error('Parameter "oldText" is required for update mode'); - if (newText === undefined) throw new Error('Parameter "newText" is required for update mode'); +function executeUpdateMode(content, params, filePath) { + const { oldText, newText, replaceAll, edits, dryRun = false } = params; // Detect original line ending const hasCRLF = content.includes('\r\n'); - - // Normalize to LF for matching - const normalize = (str) => str.replace(/\r\n/g, '\n'); - const normalizedContent = normalize(content); - const normalizedOld = normalize(oldText); - const normalizedNew = normalize(newText); + const normalizedContent = normalizeLineEndings(content); + const originalContent = normalizedContent; let newContent = normalizedContent; let status = 'not found'; let replacements = 0; + const editResults = []; - if (newContent.includes(normalizedOld)) { - if (replaceAll) { - const parts = newContent.split(normalizedOld); - replacements = parts.length - 1; - newContent = parts.join(normalizedNew); - status = 'replaced_all'; - } else { - newContent = newContent.replace(normalizedOld, normalizedNew); + // Support multiple edits via 'edits' array (like reference impl) + const editOperations = edits || (oldText !== undefined ? [{ oldText, newText }] : []); + + if (editOperations.length === 0) { + throw new Error('Either "oldText/newText" or "edits" array is required for update mode'); + } + + for (const edit of editOperations) { + const normalizedOld = normalizeLineEndings(edit.oldText || ''); + const normalizedNew = normalizeLineEndings(edit.newText || ''); + + if (!normalizedOld) { + editResults.push({ status: 'error', message: 'Empty oldText' }); + continue; + } + + if (newContent.includes(normalizedOld)) { + if (replaceAll) { + const parts = newContent.split(normalizedOld); + const count = parts.length - 1; + newContent = parts.join(normalizedNew); + replacements += count; + editResults.push({ status: 'replaced_all', count }); + } else { + newContent = newContent.replace(normalizedOld, normalizedNew); + replacements += 1; + editResults.push({ status: 'replaced', count: 1 }); + } status = 'replaced'; - replacements = 1; + } else { + // Try fuzzy match (trimmed whitespace) + const lines = newContent.split('\n'); + const oldLines = normalizedOld.split('\n'); + let matchFound = false; + + for (let i = 0; i <= lines.length - oldLines.length; i++) { + const potentialMatch = lines.slice(i, i + oldLines.length); + const isMatch = oldLines.every((oldLine, j) => + oldLine.trim() === potentialMatch[j].trim() + ); + + if (isMatch) { + // Preserve indentation of first line + const indent = lines[i].match(/^\s*/)?.[0] || ''; + const newLines = normalizedNew.split('\n').map((line, j) => { + if (j === 0) return indent + line.trimStart(); + return line; + }); + lines.splice(i, oldLines.length, ...newLines); + newContent = lines.join('\n'); + replacements += 1; + editResults.push({ status: 'replaced_fuzzy', count: 1 }); + matchFound = true; + status = 'replaced'; + break; + } + } + + if (!matchFound) { + editResults.push({ status: 'not_found', oldText: normalizedOld.substring(0, 50) }); + } } } @@ -82,17 +255,23 @@ function executeUpdateMode(content, params) { newContent = newContent.replace(/\n/g, '\r\n'); } + // Generate diff if content changed + let diff = ''; + if (originalContent !== normalizeLineEndings(newContent)) { + diff = createUnifiedDiff(originalContent, normalizeLineEndings(newContent), filePath); + } + return { content: newContent, modified: content !== newContent, - status, + status: replacements > 0 ? 'replaced' : 'not found', replacements, - message: - status === 'replaced_all' - ? `Text replaced successfully (${replacements} occurrences)` - : status === 'replaced' - ? 'Text replaced successfully' - : 'oldText not found in file' + editResults, + diff, + dryRun, + message: replacements > 0 + ? `${replacements} replacement(s) made${dryRun ? ' (dry run)' : ''}` + : 'No matches found' }; } @@ -179,7 +358,7 @@ function executeLineMode(content, params) { * Main execute function - routes to appropriate mode */ async function execute(params) { - const { path: filePath, mode = 'update' } = params; + const { path: filePath, mode = 'update', dryRun = false } = params; if (!filePath) throw new Error('Parameter "path" is required'); @@ -188,7 +367,7 @@ async function execute(params) { let result; switch (mode) { case 'update': - result = executeUpdateMode(content, params); + result = executeUpdateMode(content, params, filePath); break; case 'line': result = executeLineMode(content, params); @@ -197,8 +376,8 @@ async function execute(params) { throw new Error(`Unknown mode: ${mode}. Valid modes: update, line`); } - // Write if modified - if (result.modified) { + // Write if modified and not dry run + if (result.modified && !dryRun) { writeFile(resolvedPath, result.content); } @@ -212,9 +391,14 @@ async function execute(params) { */ export const editFileTool = { name: 'edit_file', - description: `Update file with two modes: -- update: Replace oldText with newText (default) -- line: Position-driven line operations`, + description: `Edit file with two modes: +- update: Replace oldText with newText (default). Supports multiple edits via 'edits' array. +- line: Position-driven line operations (insert_before, insert_after, replace, delete) + +Features: +- dryRun: Preview changes without modifying file (returns diff) +- Auto line ending adaptation (CRLF/LF) +- Fuzzy matching for whitespace differences`, parameters: { type: 'object', properties: { @@ -228,15 +412,32 @@ export const editFileTool = { description: 'Edit mode (default: update)', default: 'update' }, + dryRun: { + type: 'boolean', + description: 'Preview changes using git-style diff without modifying file (default: false)', + default: false + }, // Update mode params oldText: { type: 'string', - description: '[update mode] Text to find and replace' + description: '[update mode] Text to find and replace (use oldText/newText OR edits array)' }, newText: { type: 'string', description: '[update mode] Replacement text' }, + edits: { + type: 'array', + description: '[update mode] Array of {oldText, newText} for multiple replacements', + items: { + type: 'object', + properties: { + oldText: { type: 'string', description: 'Text to search for - must match exactly' }, + newText: { type: 'string', description: 'Text to replace with' } + }, + required: ['oldText', 'newText'] + } + }, replaceAll: { type: 'boolean', description: '[update mode] Replace all occurrences of oldText (default: false)' diff --git a/ccw/src/tools/index.js b/ccw/src/tools/index.js index 412412ef..fe037e21 100644 --- a/ccw/src/tools/index.js +++ b/ccw/src/tools/index.js @@ -5,6 +5,7 @@ import http from 'http'; import { editFileTool } from './edit-file.js'; +import { writeFileTool } from './write-file.js'; import { getModulesByDepthTool } from './get-modules-by-depth.js'; import { classifyFoldersTool } from './classify-folders.js'; import { detectChangedModulesTool } from './detect-changed-modules.js'; @@ -16,6 +17,7 @@ import { updateModuleClaudeTool } from './update-module-claude.js'; import { convertTokensToCssTool } from './convert-tokens-to-css.js'; import { sessionManagerTool } from './session-manager.js'; import { cliExecutorTool } from './cli-executor.js'; +import { smartSearchTool } from './smart-search.js'; // Tool registry - add new tools here const tools = new Map(); @@ -249,6 +251,7 @@ export function getAllToolSchemas() { // Register built-in tools registerTool(editFileTool); +registerTool(writeFileTool); registerTool(getModulesByDepthTool); registerTool(classifyFoldersTool); registerTool(detectChangedModulesTool); @@ -260,6 +263,7 @@ registerTool(updateModuleClaudeTool); registerTool(convertTokensToCssTool); registerTool(sessionManagerTool); registerTool(cliExecutorTool); +registerTool(smartSearchTool); // Export for external tool registration export { registerTool }; diff --git a/ccw/src/tools/smart-search.js b/ccw/src/tools/smart-search.js new file mode 100644 index 00000000..37902877 --- /dev/null +++ b/ccw/src/tools/smart-search.js @@ -0,0 +1,487 @@ +/** + * Smart Search Tool - Unified search with mode-based execution + * Modes: auto, exact, fuzzy, semantic, graph + * + * Features: + * - Intent classification (auto mode) + * - Multi-backend search routing + * - Result fusion with RRF ranking + * - Configurable search parameters + */ + +import { spawn, execSync } from 'child_process'; +import { existsSync, readdirSync, statSync } from 'fs'; +import { join, resolve, isAbsolute } from 'path'; + +// Search mode constants +const SEARCH_MODES = ['auto', 'exact', 'fuzzy', 'semantic', 'graph']; + +// Classification confidence threshold +const CONFIDENCE_THRESHOLD = 0.7; + +/** + * Detection heuristics for intent classification + */ + +/** + * Detect literal string query (simple alphanumeric or quoted strings) + */ +function detectLiteral(query) { + return /^[a-zA-Z0-9_-]+$/.test(query) || /^["'].*["']$/.test(query); +} + +/** + * Detect regex pattern (contains regex metacharacters) + */ +function detectRegex(query) { + return /[.*+?^${}()|[\]\]/.test(query); +} + +/** + * Detect natural language query (sentence structure, questions, multi-word phrases) + */ +function detectNaturalLanguage(query) { + return query.split(/\s+/).length >= 3 || /\?$/.test(query); +} + +/** + * Detect file path query (path separators, file extensions) + */ +function detectFilePath(query) { + return /[/\]/.test(query) || /\.[a-z]{2,4}$/i.test(query); +} + +/** + * Detect relationship query (import, export, dependency keywords) + */ +function detectRelationship(query) { + return /(import|export|uses?|depends?|calls?|extends?)\s/i.test(query); +} + +/** + * Classify query intent and recommend search mode + * @param {string} query - Search query string + * @returns {{mode: string, confidence: number, reasoning: string}} + */ +function classifyIntent(query) { + // Initialize mode scores + const scores = { + exact: 0, + fuzzy: 0, + semantic: 0, + graph: 0 + }; + + // Apply detection heuristics with weighted scoring + if (detectLiteral(query)) { + scores.exact += 0.8; + } + + if (detectRegex(query)) { + scores.fuzzy += 0.7; + } + + if (detectNaturalLanguage(query)) { + scores.semantic += 0.9; + } + + if (detectFilePath(query)) { + scores.exact += 0.6; + } + + if (detectRelationship(query)) { + scores.graph += 0.85; + } + + // Find mode with highest confidence score + const mode = Object.keys(scores).reduce((a, b) => scores[a] > scores[b] ? a : b); + const confidence = scores[mode]; + + // Build reasoning string + const detectedPatterns = []; + if (detectLiteral(query)) detectedPatterns.push('literal'); + if (detectRegex(query)) detectedPatterns.push('regex'); + if (detectNaturalLanguage(query)) detectedPatterns.push('natural language'); + if (detectFilePath(query)) detectedPatterns.push('file path'); + if (detectRelationship(query)) detectedPatterns.push('relationship'); + + const reasoning = `Query classified as ${mode} (confidence: ${confidence.toFixed(2)}, detected: ${detectedPatterns.join(', ')})`; + + return { mode, confidence, reasoning }; +} + + +n// Classification confidence threshold +const CONFIDENCE_THRESHOLD = 0.7; + +/** +/** + * Check if a tool is available in PATH + * @param {string} toolName - Tool executable name + * @returns {boolean} + */ +function checkToolAvailability(toolName) { + try { + const isWindows = process.platform === 'win32'; + const command = isWindows ? 'where' : 'which'; + execSync(`${command} ${toolName}`, { stdio: 'ignore' }); + return true; + } catch { + return false; + } +} + +/** + * Build ripgrep command arguments + * @param {Object} params - Search parameters + * @returns {{command: string, args: string[]}} + */ +function buildRipgrepCommand(params) { + const { query, paths = ['.'], contextLines = 0, maxResults = 100, includeHidden = false } = params; + + const args = [ + '-n', // Show line numbers + '--color=never', // Disable color output + '--json' // Output in JSON format + ]; + + // Add context lines if specified + if (contextLines > 0) { + args.push('-C', contextLines.toString()); + } + + // Add max results limit + if (maxResults > 0) { + args.push('--max-count', maxResults.toString()); + } + + // Include hidden files if specified + if (includeHidden) { + args.push('--hidden'); + } + + // Use literal/fixed string matching for exact mode + args.push('-F', query); + + // Add search paths + args.push(...paths); + + return { command: 'rg', args }; +} + +/** + * Mode: auto - Intent classification and mode selection + * Analyzes query to determine optimal search mode + */ +/** + * Mode: auto - Intent classification and mode selection + * Analyzes query to determine optimal search mode + */ +async function executeAutoMode(params) { + const { query } = params; + + // Classify intent + const classification = classifyIntent(query); + + // Route to appropriate mode based on classification + switch (classification.mode) { + case 'exact': + // Execute exact mode and enrich result with classification metadata + const exactResult = await executeExactMode(params); + return { + ...exactResult, + metadata: { + ...exactResult.metadata, + classified_as: classification.mode, + confidence: classification.confidence, + reasoning: classification.reasoning + } + }; + + case 'fuzzy': + case 'semantic': + case 'graph': + // These modes not yet implemented + return { + success: false, + error: `${classification.mode} mode not yet implemented`, + metadata: { + classified_as: classification.mode, + confidence: classification.confidence, + reasoning: classification.reasoning + } + }; + + default: + // Fallback to exact mode with warning + const fallbackResult = await executeExactMode(params); + return { + ...fallbackResult, + metadata: { + ...fallbackResult.metadata, + classified_as: 'exact', + confidence: 0.5, + reasoning: 'Fallback to exact mode due to unknown classification' + } + }; + } +} +/** + * Mode: exact - Precise file path and content matching + * Uses ripgrep for literal string matching + */ +async function executeExactMode(params) { + const { query, paths = [], contextLines = 0, maxResults = 100, includeHidden = false } = params; + + // Check ripgrep availability + if (!checkToolAvailability('rg')) { + return { + success: false, + error: 'ripgrep not available - please install ripgrep (rg) to use exact search mode' + }; + } + + // Build ripgrep command + const { command, args } = buildRipgrepCommand({ + query, + paths: paths.length > 0 ? paths : ['.'], + contextLines, + maxResults, + includeHidden + }); + + return new Promise((resolve) => { + const child = spawn(command, args, { + cwd: process.cwd(), + stdio: ['ignore', 'pipe', 'pipe'] + }); + + let stdout = ''; + let stderr = ''; + + // Collect stdout + child.stdout.on('data', (data) => { + stdout += data.toString(); + }); + + // Collect stderr + child.stderr.on('data', (data) => { + stderr += data.toString(); + }); + + // Handle completion + child.on('close', (code) => { + // Parse ripgrep JSON output + const results = []; + + if (code === 0 || (code === 1 && stdout.trim())) { + // Code 0: matches found, Code 1: no matches (but may have output) + const lines = stdout.split('\n').filter(line => line.trim()); + + for (const line of lines) { + try { + const item = JSON.parse(line); + + // Only process match type items + if (item.type === 'match') { + const match = { + file: item.data.path.text, + line: item.data.line_number, + column: item.data.submatches && item.data.submatches[0] ? item.data.submatches[0].start + 1 : 1, + content: item.data.lines.text.trim() + }; + results.push(match); + } + } catch (err) { + // Skip malformed JSON lines + continue; + } + } + + resolve({ + success: true, + results, + metadata: { + mode: 'exact', + backend: 'ripgrep', + count: results.length, + query + } + }); + } else { + // Error occurred + resolve({ + success: false, + error: `ripgrep execution failed with code ${code}: ${stderr}`, + results: [] + }); + } + }); + + // Handle spawn errors + child.on('error', (error) => { + resolve({ + success: false, + error: `Failed to spawn ripgrep: ${error.message}`, + results: [] + }); + }); + }); +} + +/** + * Mode: fuzzy - Approximate matching with tolerance + * Uses fuzzy matching algorithms for typo-tolerant search + */ +async function executeFuzzyMode(params) { + const { query, paths = [], maxResults = 100 } = params; + + // TODO: Implement fuzzy search + // - Use fuse.js for content fuzzy matching + // - Support approximate file path matching + // - Configure similarity threshold + // - Return ranked results + + return { + success: false, + error: 'Fuzzy mode not implemented - fuzzy matching engine pending' + }; +} + +/** + * Mode: semantic - Natural language understanding search + * Uses LLM or embeddings for semantic similarity + */ +async function executeSemanticMode(params) { + const { query, paths = [], maxResults = 100 } = params; + + // TODO: Implement semantic search + // - Option 1: Use Gemini CLI via cli-executor.js + // - Option 2: Use local embeddings (transformers.js) + // - Generate query embedding + // - Compare with code embeddings + // - Return semantically similar results + + return { + success: false, + error: 'Semantic mode not implemented - LLM/embedding integration pending' + }; +} + +/** + * Mode: graph - Dependency and relationship traversal + * Analyzes code relationships (imports, exports, dependencies) + */ +async function executeGraphMode(params) { + const { query, paths = [], maxResults = 100 } = params; + + // TODO: Implement graph search + // - Parse import/export statements + // - Build dependency graph + // - Traverse relationships + // - Find related modules + // - Return graph results + + return { + success: false, + error: 'Graph mode not implemented - dependency analysis pending' + }; +} + +/** + * Main execute function - routes to appropriate mode handler + */ +async function execute(params) { + const { query, mode = 'auto', paths = [], contextLines = 0, maxResults = 100, includeHidden = false } = params; + + // Validate required parameters + if (!query || typeof query !== 'string') { + throw new Error('Parameter "query" is required and must be a string'); + } + + // Validate mode + if (!SEARCH_MODES.includes(mode)) { + throw new Error(`Invalid mode: ${mode}. Valid modes: ${SEARCH_MODES.join(', ')}`); + } + + // Route to mode-specific handler + switch (mode) { + case 'auto': + return executeAutoMode(params); + + case 'exact': + return executeExactMode(params); + + case 'fuzzy': + return executeFuzzyMode(params); + + case 'semantic': + return executeSemanticMode(params); + + case 'graph': + return executeGraphMode(params); + + default: + throw new Error(`Unsupported mode: ${mode}`); + } +} + +/** + * Smart Search Tool Definition + */ +export const smartSearchTool = { + name: 'smart_search', + description: `Unified search with intelligent mode selection. + +Modes: +- auto: Classify intent and recommend optimal search mode (default) +- exact: Precise literal matching via ripgrep +- fuzzy: Approximate matching with typo tolerance +- semantic: Natural language understanding via LLM/embeddings +- graph: Dependency relationship traversal + +Features: +- Multi-backend search coordination +- Result fusion with RRF ranking +- Configurable result limits and context`, + parameters: { + type: 'object', + properties: { + query: { + type: 'string', + description: 'Search query (file pattern, text content, or natural language)' + }, + mode: { + type: 'string', + enum: SEARCH_MODES, + description: 'Search mode (default: auto)', + default: 'auto' + }, + paths: { + type: 'array', + description: 'Paths to search within (default: current directory)', + items: { + type: 'string' + }, + default: [] + }, + contextLines: { + type: 'number', + description: 'Number of context lines around matches (default: 0)', + default: 0 + }, + maxResults: { + type: 'number', + description: 'Maximum number of results to return (default: 100)', + default: 100 + }, + includeHidden: { + type: 'boolean', + description: 'Include hidden files/directories (default: false)', + default: false + } + }, + required: ['query'] + }, + execute +}; diff --git a/ccw/src/tools/write-file.js b/ccw/src/tools/write-file.js new file mode 100644 index 00000000..15775406 --- /dev/null +++ b/ccw/src/tools/write-file.js @@ -0,0 +1,152 @@ +/** + * Write File Tool - Create or overwrite files + * + * Features: + * - Create new files or overwrite existing + * - Auto-create parent directories + * - Support for text content with proper encoding + * - Optional backup before overwrite + */ + +import { writeFileSync, readFileSync, existsSync, mkdirSync, renameSync } from 'fs'; +import { resolve, isAbsolute, dirname, basename } from 'path'; + +/** + * Ensure parent directory exists + * @param {string} filePath - Path to file + */ +function ensureDir(filePath) { + const dir = dirname(filePath); + if (!existsSync(dir)) { + mkdirSync(dir, { recursive: true }); + } +} + +/** + * Create backup of existing file + * @param {string} filePath - Path to file + * @returns {string|null} - Backup path or null if no backup created + */ +function createBackup(filePath) { + if (!existsSync(filePath)) { + return null; + } + + const dir = dirname(filePath); + const name = basename(filePath); + const timestamp = new Date().toISOString().replace(/[:.]/g, '-'); + const backupPath = resolve(dir, `.${name}.${timestamp}.bak`); + + try { + const content = readFileSync(filePath); + writeFileSync(backupPath, content); + return backupPath; + } catch (error) { + throw new Error(`Failed to create backup: ${error.message}`); + } +} + +/** + * Execute write file operation + * @param {Object} params - Parameters + * @returns {Promise} - Result + */ +async function execute(params) { + const { + path: filePath, + content, + createDirectories = true, + backup = false, + encoding = 'utf8' + } = params; + + if (!filePath) { + throw new Error('Parameter "path" is required'); + } + + if (content === undefined) { + throw new Error('Parameter "content" is required'); + } + + // Resolve path + const resolvedPath = isAbsolute(filePath) ? filePath : resolve(process.cwd(), filePath); + const fileExists = existsSync(resolvedPath); + + // Create parent directories if needed + if (createDirectories) { + ensureDir(resolvedPath); + } else if (!existsSync(dirname(resolvedPath))) { + throw new Error(`Parent directory does not exist: ${dirname(resolvedPath)}`); + } + + // Create backup if requested and file exists + let backupPath = null; + if (backup && fileExists) { + backupPath = createBackup(resolvedPath); + } + + // Write file + try { + writeFileSync(resolvedPath, content, { encoding }); + + return { + success: true, + path: resolvedPath, + created: !fileExists, + overwritten: fileExists, + backupPath, + bytes: Buffer.byteLength(content, encoding), + message: fileExists + ? `Successfully overwrote ${filePath}${backupPath ? ` (backup: ${backupPath})` : ''}` + : `Successfully created ${filePath}` + }; + } catch (error) { + throw new Error(`Failed to write file: ${error.message}`); + } +} + +/** + * Write File Tool Definition + */ +export const writeFileTool = { + name: 'write_file', + description: `Create a new file or overwrite an existing file with content. + +Features: +- Creates parent directories automatically (configurable) +- Optional backup before overwrite +- Supports text content with proper encoding + +Use with caution as it will overwrite existing files without warning unless backup is enabled.`, + parameters: { + type: 'object', + properties: { + path: { + type: 'string', + description: 'Path to the file to create or overwrite' + }, + content: { + type: 'string', + description: 'Content to write to the file' + }, + createDirectories: { + type: 'boolean', + description: 'Create parent directories if they do not exist (default: true)', + default: true + }, + backup: { + type: 'boolean', + description: 'Create backup of existing file before overwriting (default: false)', + default: false + }, + encoding: { + type: 'string', + description: 'File encoding (default: utf8)', + default: 'utf8', + enum: ['utf8', 'utf-8', 'ascii', 'latin1', 'binary', 'hex', 'base64'] + } + }, + required: ['path', 'content'] + }, + execute +};