diff --git a/src/main/index.ts b/src/main/index.ts index 69b367dc..4fb973ce 100644 --- a/src/main/index.ts +++ b/src/main/index.ts @@ -55,6 +55,7 @@ import { WorktreeWatcher } from './worktree-watcher' import { FileContentWatcher } from './file-content-watcher' import { SnoozeTimer } from './snooze-timer' import { getWeeklyStats } from './weekly-stats' +import { discoverTools, runTool } from './tools' import type { TerminalTab, PaneNode, PaneLeaf } from '../shared/state/terminals' import { getLeaves, mapLeaves } from '../shared/state/terminals' import { listWorktrees, listBranches, continueWorktree, isWorktreeDirty, defaultWorktreeDir, getChangedFiles, getFileDiff, getBranchCommits, getCommitDiff, getCommitMeta, getCommitChangedFiles, getCommitFileDiffSides, getCommitRangeChangedFiles, getCommitRangeFileDiffSides, getMainWorktreeStatus, prepareMainForMerge, mergeWorktreeLocally, getBranchSha, previewMergeConflicts, getBranchDiffStats, listAllFiles, listRecentCommitShas, readWorktreeFile, readWorktreeFileBinary, writeWorktreeFile, getFileDiffSides, getCurrentBranch, renameWorktreeBranch, symlinkClaudeSettings, pruneWorktrees, type MergeStrategy } from './worktree' @@ -1916,6 +1917,18 @@ function registerIpcHandlers(): void { return getBranchCommits(worktreePath) }) + transport.onRequest('tools:list', async (_ctx, worktreePath: string) => { + return discoverTools(worktreePath) + }) + + transport.onRequest('tools:run', async (_ctx, worktreePath: string, toolId: string) => { + const wt = store.getSnapshot().state.worktrees.list.find((w) => w.path === worktreePath) + return runTool(worktreePath, toolId, { + branch: wt?.branch ?? '', + repoRoot: wt?.repoRoot ?? worktreePath + }) + }) + transport.onRequest('worktree:commitDiff', async (_ctx, worktreePath: string, hash: string) => { return getCommitDiff(worktreePath, hash) }) diff --git a/src/main/repo-config.ts b/src/main/repo-config.ts index c1a49db2..26a95a63 100644 --- a/src/main/repo-config.ts +++ b/src/main/repo-config.ts @@ -60,7 +60,7 @@ export function saveRepoConfig(repoRoot: string, next: RepoConfig): RepoConfig { if (next.mergeStrategy) cleaned.mergeStrategy = next.mergeStrategy // Migrate legacy hideMergePanel / hidePrPanel into hiddenRightPanels // on write. Only the new field is persisted going forward. - const hidden: Record = { ...(next.hiddenRightPanels || {}) } + const hidden: Record = { ...(next.hiddenRightPanels || {}) } if (next.hideMergePanel && hidden.merge === undefined) hidden.merge = true if (next.hidePrPanel && hidden.pr === undefined) hidden.pr = true // Compact: drop `false` entries that match the default visibility diff --git a/src/main/tools.test.ts b/src/main/tools.test.ts new file mode 100644 index 00000000..a5985685 --- /dev/null +++ b/src/main/tools.test.ts @@ -0,0 +1,106 @@ +import { describe, it, expect, beforeEach, afterEach } from 'vitest' +import { mkdtempSync, mkdirSync, writeFileSync, rmSync, chmodSync } from 'fs' +import { tmpdir } from 'os' +import { join } from 'path' +import { discoverTools, runTool } from './tools' + +let root: string + +function addTool(id: string, manifest: unknown, script?: string): string { + const dir = join(root, '.ness/tools', id) + mkdirSync(dir, { recursive: true }) + writeFileSync(join(dir, 'tool.json'), JSON.stringify(manifest)) + if (script !== undefined) { + const scriptPath = join(dir, 'run.sh') + writeFileSync(scriptPath, script) + chmodSync(scriptPath, 0o755) + } + return dir +} + +beforeEach(() => { + root = mkdtempSync(join(tmpdir(), 'ness-tools-')) +}) + +afterEach(() => { + rmSync(root, { recursive: true, force: true }) +}) + +const ctx = { branch: 'feature/x', repoRoot: '/repo' } + +describe('discoverTools', () => { + it('returns [] when there is no tools directory', () => { + expect(discoverTools(root)).toEqual([]) + }) + + it('reads the manifest and defaults script + refresh', () => { + addTool('pr-comments', { title: 'PR Comments' }) + const [spec] = discoverTools(root) + expect(spec.id).toBe('pr-comments') + expect(spec.title).toBe('PR Comments') + expect(spec.script).toBe('run.sh') + expect(spec.refresh).toBe('manual') + }) + + it('falls back to the directory name when title is missing', () => { + addTool('deploys', {}) + expect(discoverTools(root)[0].title).toBe('deploys') + }) + + it('skips a directory whose manifest is malformed', () => { + const dir = join(root, '.ness/tools/broken') + mkdirSync(dir, { recursive: true }) + writeFileSync(join(dir, 'tool.json'), '{not json') + expect(discoverTools(root)).toEqual([]) + }) + + it('rejects a script path that escapes the tool directory', () => { + addTool('evil', { title: 'Evil', script: '../../../../bin/sh' }) + expect(discoverTools(root)).toEqual([]) + }) +}) + +describe('runTool', () => { + it('returns stdout as markdown', async () => { + addTool('hello', { title: 'Hello' }, '#!/bin/sh\necho "## Section"\necho "- a row"\n') + const res = await runTool(root, 'hello', ctx) + expect(res.ok).toBe(true) + expect(res.markdown).toContain('## Section') + expect(res.markdown).toContain('- a row') + }) + + it('exposes the ness env vars to the script', async () => { + addTool('env', { title: 'Env' }, '#!/bin/sh\necho "$NESS_BRANCH $NESS_TOOL_ID"\n') + const res = await runTool(root, 'env', ctx) + expect(res.markdown.trim()).toBe('feature/x env') + }) + + it('reports a non-zero exit but still surfaces any output', async () => { + addTool('fails', { title: 'Fails' }, '#!/bin/sh\necho "partial"\necho "boom" >&2\nexit 3\n') + const res = await runTool(root, 'fails', ctx) + expect(res.ok).toBe(false) + expect(res.markdown).toContain('partial') + expect(res.error).toContain('boom') + }) + + it('errors on an unknown tool id', async () => { + const res = await runTool(root, 'nope', ctx) + expect(res.ok).toBe(false) + expect(res.error).toContain('Unknown tool') + }) + + it('errors when the manifest points at a missing script', async () => { + addTool('noscript', { title: 'No Script' }) + const res = await runTool(root, 'noscript', ctx) + expect(res.ok).toBe(false) + expect(res.error).toContain('Script not found') + }) + + it('tells the user to chmod +x when the script is not executable', async () => { + addTool('noexec', { title: 'No Exec' }, '#!/bin/sh\necho hi\n') + chmodSync(join(root, '.ness/tools/noexec/run.sh'), 0o644) + const res = await runTool(root, 'noexec', ctx) + expect(res.ok).toBe(false) + expect(res.error).toContain('chmod +x') + }) +}) diff --git a/src/main/tools.ts b/src/main/tools.ts new file mode 100644 index 00000000..efbfdca6 --- /dev/null +++ b/src/main/tools.ts @@ -0,0 +1,161 @@ +import { spawn } from 'child_process' +import { existsSync, readdirSync, readFileSync, statSync } from 'fs' +import { join } from 'path' +import { log } from './debug' +import { + DEFAULT_TOOL_SCRIPT, + TOOL_MANIFEST_FILENAME, + TOOLS_DIRNAME, + type ToolRunResult, + type ToolSpec +} from '../shared/tools' + +const RUN_TIMEOUT_MS = 20_000 +const MAX_OUTPUT_BYTES = 256 * 1024 + + +/** Tools live in the worktree, not the repo root, so a branch can iterate + * on its own tooling and a PR that edits a tool exercises the new version. + * (Note this differs from `.ness.json`, which resolves against repoRoot.) */ +function toolsDir(worktreePath: string): string { + return join(worktreePath, TOOLS_DIRNAME) +} + +function parseManifest(dir: string, id: string): ToolSpec | null { + const manifestPath = join(dir, TOOL_MANIFEST_FILENAME) + if (!existsSync(manifestPath)) return null + try { + const raw = JSON.parse(readFileSync(manifestPath, 'utf-8')) as Record + const title = typeof raw.title === 'string' && raw.title.trim() ? raw.title.trim() : id + const script = + typeof raw.script === 'string' && raw.script.trim() ? raw.script.trim() : DEFAULT_TOOL_SCRIPT + // Keep the script inside its own tool directory — a manifest shouldn't + // be able to point at an arbitrary path elsewhere on disk. + if (script.startsWith('/') || script.split('/').includes('..')) { + log('tools', `tool ${id}: rejecting script path outside tool dir: ${script}`) + return null + } + return { + id, + title, + script, + dir, + refresh: raw.refresh === 'auto' ? 'auto' : 'manual' + } + } catch (err) { + log('tools', `tool ${id}: failed to parse manifest: ${(err as Error).message}`) + return null + } +} + +export function discoverTools(worktreePath: string): ToolSpec[] { + if (!worktreePath) return [] + const root = toolsDir(worktreePath) + if (!existsSync(root)) return [] + let entries: string[] + try { + entries = readdirSync(root) + } catch (err) { + log('tools', `failed to read ${root}: ${(err as Error).message}`) + return [] + } + const specs: ToolSpec[] = [] + for (const id of entries.sort()) { + if (id.startsWith('.')) continue + const dir = join(root, id) + try { + if (!statSync(dir).isDirectory()) continue + } catch { + continue + } + const spec = parseManifest(dir, id) + if (spec) specs.push(spec) + } + return specs +} + +export async function runTool( + worktreePath: string, + toolId: string, + ctx: { branch: string; repoRoot: string } +): Promise { + const spec = discoverTools(worktreePath).find((t) => t.id === toolId) + if (!spec) return { ok: false, markdown: '', error: `Unknown tool: ${toolId}` } + const scriptPath = join(spec.dir, spec.script) + if (!existsSync(scriptPath)) { + return { ok: false, markdown: '', error: `Script not found: ${spec.script}` } + } + + return new Promise((resolve) => { + let stdout = '' + let stderr = '' + let settled = false + const finish = (result: ToolRunResult): void => { + if (settled) return + settled = true + clearTimeout(timer) + resolve(result) + } + + let child: ReturnType | null = null + const timer = setTimeout(() => { + child?.kill('SIGKILL') + finish({ ok: false, markdown: stdout, error: `Timed out after ${RUN_TIMEOUT_MS / 1000}s` }) + }, RUN_TIMEOUT_MS) + + try { + // Spawned directly rather than through a login shell: the script's + // own shebang decides the interpreter, and rc-file chatter (nvm + // banners, starship init) can't leak into stdout — which here IS + // the panel body. PATH is already the login-shell PATH thanks to + // path-fix.ts at boot, so there's nothing to gain from `-ilc`. + child = spawn(scriptPath, [], { + cwd: worktreePath, + env: { + ...process.env, + NESS_WORKTREE_PATH: worktreePath, + NESS_BRANCH: ctx.branch, + NESS_REPO_ROOT: ctx.repoRoot, + NESS_TOOL_DIR: spec.dir, + NESS_TOOL_ID: spec.id + } + }) + } catch (err) { + finish({ ok: false, markdown: '', error: (err as Error).message }) + return + } + + child.stdout?.on('data', (d) => { + if (stdout.length < MAX_OUTPUT_BYTES) stdout += d.toString() + }) + child.stderr?.on('data', (d) => { + if (stderr.length < MAX_OUTPUT_BYTES) stderr += d.toString() + }) + child.on('error', (err) => { + const code = (err as NodeJS.ErrnoException).code + finish({ + ok: false, + markdown: '', + error: + code === 'EACCES' + ? `${spec.script} is not executable — run chmod +x` + : err.message + }) + }) + child.on('close', (code) => { + const truncated = stdout.length >= MAX_OUTPUT_BYTES + const markdown = truncated ? stdout.slice(0, MAX_OUTPUT_BYTES) : stdout + if (code === 0) { + finish({ ok: true, markdown }) + return + } + // A failing script that still printed something gets to render its + // own output — it may be formatting the error better than we can. + finish({ + ok: false, + markdown, + error: stderr.trim().slice(0, 500) || `Exited with code ${code}` + }) + }) + }) +} diff --git a/src/renderer/App.tsx b/src/renderer/App.tsx index 0bd1c7be..e0766672 100644 --- a/src/renderer/App.tsx +++ b/src/renderer/App.tsx @@ -242,11 +242,18 @@ function DesktopApp(): JSX.Element { // Cleared alongside the repo when the modal closes. const [newWorktreeInitialPRNumber, setNewWorktreeInitialPRNumber] = useState(undefined) const [newWorktreeForkSource, setNewWorktreeForkSource] = useState(undefined) + // Set by "Build a custom tool" in the right column so the screen opens + // with a branch name and kickoff prompt already filled in. Same + // transient lifetime as the two above. + const [newWorktreePrefill, setNewWorktreePrefill] = useState< + { branch: string; prompt: string } | undefined + >(undefined) useEffect(() => { if (!showNewWorktree) { setNewWorktreeRepo(undefined) setNewWorktreeInitialPRNumber(undefined) setNewWorktreeForkSource(undefined) + setNewWorktreePrefill(undefined) } }, [showNewWorktree]) // Chat's "Fork into new worktree" opens the create screen with the @@ -1643,6 +1650,8 @@ const setQuestStep = useCallback((next: QuestStep) => { defaultRepoRoot={newWorktreeRepo ?? (activeWorktreeId ? worktreeRepoByPath[activeWorktreeId] : undefined)} initialPRNumber={newWorktreeInitialPRNumber} forkSource={newWorktreeForkSource} + initialBranch={newWorktreePrefill?.branch} + initialPrompt={newWorktreePrefill?.prompt} /> )} {reportIssueState !== null && ( @@ -1784,6 +1793,10 @@ const setQuestStep = useCallback((next: QuestStep) => { if (activeWorktreeId) void backend.panesOpenReview(activeWorktreeId) }} onCollapse={() => setRightColumnHidden(true)} + onBuildCustomTool={(branch, prompt) => { + setNewWorktreePrefill({ branch, prompt }) + setShowNewWorktree(true) + }} /> )} {!singleScreenMode && !showNewWorktree && !showActivity && !showCleanup && !showCommandCenter && reportIssueState === null && rightColumnHidden && ( diff --git a/src/renderer/build-backend.ts b/src/renderer/build-backend.ts index f1344d44..9e0e6269 100644 --- a/src/renderer/build-backend.ts +++ b/src/renderer/build-backend.ts @@ -205,6 +205,8 @@ export function buildBackend( mode?: 'working' | 'branch' ) => req('worktree:fileDiffSides', worktreePath, filePath, staged, mode), getBranchCommits: (worktreePath: string) => req('worktree:branchCommits', worktreePath), + listTools: (worktreePath: string) => req('tools:list', worktreePath), + runTool: (worktreePath: string, toolId: string) => req('tools:run', worktreePath, toolId), getCommitDiff: (worktreePath: string, hash: string) => req('worktree:commitDiff', worktreePath, hash), getCommitMeta: (worktreePath: string, hash: string) => diff --git a/src/renderer/components/CustomToolPanel.tsx b/src/renderer/components/CustomToolPanel.tsx new file mode 100644 index 00000000..baacf22c --- /dev/null +++ b/src/renderer/components/CustomToolPanel.tsx @@ -0,0 +1,99 @@ +import { useCallback } from 'react' +import { RefreshCw, TriangleAlert } from 'lucide-react' +import type { ToolRunResult, ToolSpec } from '../types' +import { Tooltip } from './Tooltip' +import { RightPanel } from './RightPanel' +import { SidebarMarkdown, type NessLinkAction } from './SidebarMarkdown' +import { useWatchedQuery } from '../hooks/useWatchedQuery' +import { toolPanelKey } from '../../shared/state/repo-configs' +import { useBackend } from '../backend' + +interface CustomToolPanelProps { + spec: ToolSpec + worktreePath: string | null + onSendToAgent?: (text: string) => void + onOpenFile?: (filePath: string) => void +} + +export function CustomToolPanel({ + spec, + worktreePath, + onSendToAgent, + onOpenFile +}: CustomToolPanelProps): JSX.Element | null { + const backend = useBackend() + const fetcher = useCallback( + (path: string) => backend.runTool(path, spec.id), + [backend, spec.id] + ) + + const { data, loading, refresh } = useWatchedQuery({ + worktreePath, + cacheKey: `tool:${spec.id}`, + fetcher, + // A `manual` tool still runs on mount and on the refresh button, but + // never on a timer or a git change — tool scripts routinely hit the + // network, and the built-in panels' cadence would hammer an API. + fallbackPollMs: spec.refresh === 'auto' ? 30000 : 0, + revalidateOnFileChange: spec.refresh === 'auto' + }) + + const handleAction = useCallback( + (action: NessLinkAction) => { + if (action.verb === 'send') { + const text = action.params.get('text') + if (text) onSendToAgent?.(text) + return + } + if (action.verb === 'file') { + const path = action.params.get('path') + if (path) onOpenFile?.(path) + return + } + if (action.verb === 'refresh') refresh() + }, + [onSendToAgent, onOpenFile, refresh] + ) + + if (!worktreePath) return null + + const actions = ( + <> + {data && !data.ok && ( + + + + )} + + + + + ) + + const body = data?.markdown?.trim() + + return ( + +
+ {!data && loading &&
Running…
} + {body ? ( + + ) : ( + data && ( +
+ {data.ok ? 'No output' : data.error || 'Tool failed'} +
+ ) + )} +
+
+ ) +} diff --git a/src/renderer/components/NewWorktreeScreen.tsx b/src/renderer/components/NewWorktreeScreen.tsx index 084551ae..f3edb40f 100644 --- a/src/renderer/components/NewWorktreeScreen.tsx +++ b/src/renderer/components/NewWorktreeScreen.tsx @@ -57,6 +57,11 @@ interface NewWorktreeScreenProps { /** When set, the new worktree's first agent tab resumes a copy of this * conversation instead of starting empty. */ forkSource?: ForkSource + /** Pre-fill the branch name and kickoff prompt, for entry points that + * already know the task (e.g. "Build a custom tool"). Both stay + * editable — the point is to show the user what's about to run. */ + initialBranch?: string + initialPrompt?: string } /** Sort + clean the local-branch list from the backend. The backend already @@ -123,12 +128,12 @@ const STARTER_PROMPTS = [ const KBD_CHIP = 'text-xs text-faint bg-bg px-1.5 py-0.5 rounded border border-border font-mono' const KBD_CHIP_ON_ACCENT = 'text-xs text-white bg-white/20 px-1.5 py-0.5 rounded border border-white/30 font-mono' -export function NewWorktreeScreen({ onSubmit, onPRSubmit, onCancel, repoRoots, defaultRepoRoot, initialPRNumber, forkSource }: NewWorktreeScreenProps): JSX.Element { +export function NewWorktreeScreen({ onSubmit, onPRSubmit, onCancel, repoRoots, defaultRepoRoot, initialPRNumber, forkSource, initialBranch, initialPrompt }: NewWorktreeScreenProps): JSX.Element { const [mode, setMode] = useState<'fresh' | 'teleport' | 'pr'>(initialPRNumber ? 'pr' : 'fresh') const [selectedRepo, setSelectedRepo] = useState( defaultRepoRoot && repoRoots.includes(defaultRepoRoot) ? defaultRepoRoot : repoRoots[0] || '' ) - const [branch, setBranch] = useState('') + const [branch, setBranch] = useState(initialBranch ?? '') const [existingBranch, setExistingBranch] = useState(null) // Free-text ref: commit SHA, tag, remote-tracking ref (`origin/foo`), etc. // Used as the base the new branch (`refBranch`) is forked from — the @@ -138,7 +143,7 @@ export function NewWorktreeScreen({ onSubmit, onPRSubmit, onCancel, repoRoots, d // The name of the new branch to create at `refValue` on the Ref tab. const [refBranch, setRefBranch] = useState('') const [branchTab, setBranchTab] = useState<'new' | 'existing' | 'ref'>('new') - const [prompt, setPrompt] = useState('') + const [prompt, setPrompt] = useState(initialPrompt ?? '') const settings = useSettings() const [reviewPrompt, setReviewPrompt] = useState(settings.prReviewPrompt) const [teleportInput, setTeleportInput] = useState('') diff --git a/src/renderer/components/RightColumn.tsx b/src/renderer/components/RightColumn.tsx index 92579add..99b7bfb4 100644 --- a/src/renderer/components/RightColumn.tsx +++ b/src/renderer/components/RightColumn.tsx @@ -1,10 +1,16 @@ -import type { PRStatus, Worktree, RepoConfig } from '../types' +import { useCallback, useMemo } from 'react' +import type { PRStatus, Worktree, RepoConfig, ToolSpec } from '../types' import { effectiveHiddenRightPanels, effectiveRightPanelOrder, + isCustomToolPanelKey, + toolPanelKey, type HiddenRightPanels, type RightPanelKey } from '../../shared/state/repo-configs' +import { BUILD_CUSTOM_TOOL_BRANCH, BUILD_CUSTOM_TOOL_PROMPT } from '../../shared/tools' +import { useWatchedQuery } from '../hooks/useWatchedQuery' +import { CustomToolPanel } from './CustomToolPanel' import { PRStatusPanel, MergeLocallyPanel } from './PRStatusPanel' import { BranchCommitsPanel } from './BranchCommitsPanel' import { ChangedFilesPanel } from './ChangedFilesPanel' @@ -42,6 +48,9 @@ interface RightColumnProps { onOpenPR: (url: string) => void onOpenReview: () => void onCollapse: () => void + /** Opens the new-worktree screen pre-filled with a branch name and the + * custom-tool authoring contract as the kickoff prompt. */ + onBuildCustomTool: (branch: string, prompt: string) => void } export function RightColumn({ @@ -63,11 +72,29 @@ export function RightColumn({ onSendToAgent, onOpenPR, onOpenReview, - onCollapse + onCollapse, + onBuildCustomTool }: RightColumnProps): JSX.Element { const backend = useBackend() + + const toolsFetcher = useCallback( + (path: string) => backend.listTools(path), + [backend] + ) + const { data: toolsData } = useWatchedQuery({ + worktreePath: activeWorktreeId, + cacheKey: 'tools', + fetcher: toolsFetcher + }) + const tools = useMemo(() => toolsData ?? [], [toolsData]) + const toolKeys = useMemo(() => tools.map((t) => toolPanelKey(t.id)), [tools]) + const toolLabels = useMemo( + () => Object.fromEntries(tools.map((t) => [toolPanelKey(t.id), t.title])), + [tools] + ) + const hidden = effectiveHiddenRightPanels(activeRepoConfig) - const order = effectiveRightPanelOrder(activeRepoConfig) + const order = effectiveRightPanelOrder(activeRepoConfig, toolKeys) const handleChangeHidden = (next: HiddenRightPanels): void => { if (!activeRepoRoot) return @@ -89,6 +116,21 @@ export function RightColumn({ const renderPanel = (key: RightPanelKey): JSX.Element | null => { if (hidden[key]) return null + if (isCustomToolPanelKey(key)) { + const spec = tools.find((t) => toolPanelKey(t.id) === key) + if (!spec) return null + return ( + onSendToAgent(activeWorktreeId, text) : undefined + } + onOpenFile={onOpenFile} + /> + ) + } switch (key) { case 'merge': return ( @@ -159,9 +201,13 @@ export function RightColumn({