Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
13 changes: 13 additions & 0 deletions src/main/index.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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'
Expand Down Expand Up @@ -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)
})
Expand Down
2 changes: 1 addition & 1 deletion src/main/repo-config.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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<string, boolean> = { ...(next.hiddenRightPanels || {}) }
const hidden: Record<string, boolean | undefined> = { ...(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
Expand Down
106 changes: 106 additions & 0 deletions src/main/tools.test.ts
Original file line number Diff line number Diff line change
@@ -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')
})
})
161 changes: 161 additions & 0 deletions src/main/tools.ts
Original file line number Diff line number Diff line change
@@ -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<string, unknown>
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<ToolRunResult> {
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<typeof spawn> | 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}`
})
})
})
}
13 changes: 13 additions & 0 deletions src/renderer/App.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -242,11 +242,18 @@ function DesktopApp(): JSX.Element {
// Cleared alongside the repo when the modal closes.
const [newWorktreeInitialPRNumber, setNewWorktreeInitialPRNumber] = useState<number | undefined>(undefined)
const [newWorktreeForkSource, setNewWorktreeForkSource] = useState<ForkSource | undefined>(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
Expand Down Expand Up @@ -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 && (
Expand Down Expand Up @@ -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)
}}
/></div></div>
)}
{!singleScreenMode && !showNewWorktree && !showActivity && !showCleanup && !showCommandCenter && reportIssueState === null && rightColumnHidden && (
Expand Down
2 changes: 2 additions & 0 deletions src/renderer/build-backend.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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) =>
Expand Down
Loading
Loading