diff --git a/apps/cli/src/cli.ts b/apps/cli/src/cli.ts index de69695..e1411f3 100644 --- a/apps/cli/src/cli.ts +++ b/apps/cli/src/cli.ts @@ -56,6 +56,8 @@ async function main(): Promise { allowedTools: args.allowedTools, disallowedTools: args.disallowedTools, maxTurns: args.maxTurns, + jsonSchema: args.jsonSchema, + includePartialMessages: args.includePartialMessages, }); } diff --git a/apps/cli/src/headless.ts b/apps/cli/src/headless.ts index 0fc2290..4addb6d 100644 --- a/apps/cli/src/headless.ts +++ b/apps/cli/src/headless.ts @@ -65,6 +65,12 @@ export interface HeadlessOpts { allowedTools?: string[]; disallowedTools?: string[]; maxTurns?: number; + /** Path to a JSON schema file. Final output (text in `text` mode, JSON + * object in `json` mode) is validated against it; mismatch → exit 1. */ + jsonSchema?: string; + /** In stream-json mode, also emit text_delta and thinking_delta events. + * Default is to drop those for compact streams. */ + includePartialMessages?: boolean; } const DEFAULT_SYSTEM_PROMPT = `You are DeepCode, an AI coding assistant powered by DeepSeek. Help the user with their codebase using the available tools. Be concise and accurate. When you modify files, briefly explain what you changed and why.`; @@ -189,9 +195,14 @@ export async function runHeadless(opts: HeadlessOpts): Promise { // ─── set up output ────────────────────────────────────────────────── const collectedEvents: AgentEvent[] = []; + const includePartial = !!opts.includePartialMessages; const onEvent = (e: AgentEvent) => { collectedEvents.push(e); if (outputFormat === 'stream-json') { + // Drop noisy text_delta/thinking_delta unless --include-partial-messages + if (!includePartial && (e.type === 'text_delta' || e.type === 'thinking_delta')) { + return; + } output.write(JSON.stringify(e) + '\n'); } else if (outputFormat === 'text') { formatEventText(output, e); @@ -253,6 +264,15 @@ export async function runHeadless(opts: HeadlessOpts): Promise { .filter((b) => b.type === 'text') .map((b) => (b as { text: string }).text) .join(''); + // --json-schema validation (lightweight — only enforces top-level type + + // required fields; full draft-2020 validation is opt-in via a separate + // schema validator user provides). For now we just round-trip-parse the + // model output as JSON if the schema declares type: object. + let schemaError: string | null = null; + if (opts.jsonSchema) { + schemaError = await validateAgainstSchema(opts.jsonSchema, finalText); + if (schemaError) exitCode = 1; + } output.write( JSON.stringify( { @@ -261,6 +281,7 @@ export async function runHeadless(opts: HeadlessOpts): Promise { usage: result.usage, events: collectedEvents, exitCode, + ...(schemaError ? { schemaError } : {}), }, null, 2, @@ -287,6 +308,33 @@ export async function runHeadless(opts: HeadlessOpts): Promise { return exitCode; } +async function validateAgainstSchema(schemaPath: string, output: string): Promise { + let schema: { type?: string; required?: string[] }; + try { + const { readFile } = await import('node:fs/promises'); + const raw = await readFile(schemaPath, 'utf8'); + schema = JSON.parse(raw) as { type?: string; required?: string[] }; + } catch (err) { + return `failed to load --json-schema: ${(err as Error).message}`; + } + if (schema.type === 'object') { + try { + const parsed = JSON.parse(output) as Record; + if (Array.isArray(schema.required)) { + for (const k of schema.required) { + if (!(k in parsed)) return `missing required field: ${k}`; + } + } + return null; + } catch { + return 'output was not valid JSON'; + } + } + // type: string / number / etc — just check the literal type + if (schema.type === 'string') return null; // any string is valid + return null; +} + function buildPluginCapabilitiesHeadless(cwd: string) { const ctx = { cwd }; return { diff --git a/packages/core/src/index.ts b/packages/core/src/index.ts index daf053c..5960f11 100644 --- a/packages/core/src/index.ts +++ b/packages/core/src/index.ts @@ -222,6 +222,24 @@ export { type PluginCapabilityBridge, } from './plugins/index.js'; +// Worktree (M8 — isolated git worktree creation for background tasks) +export { + createWorktree, + removeWorktree, + type WorktreeHandle, + type CreateWorktreeOpts, +} from './worktree/index.js'; + +// launchd LaunchAgent installer (M8 — macOS scheduled tasks daemon) +export { + buildPlist, + installPlist, + uninstallPlist, + launchdPlistPath, + LAUNCHD_LABEL, + type LaunchdInstallOpts, +} from './launchd/index.js'; + // Keybindings (M8 — ~/.deepcode/keybindings.json + Vim mode state machine) export { DEFAULT_KEYBINDINGS, diff --git a/packages/core/src/launchd/index.test.ts b/packages/core/src/launchd/index.test.ts new file mode 100644 index 0000000..8f90300 --- /dev/null +++ b/packages/core/src/launchd/index.test.ts @@ -0,0 +1,67 @@ +import { promises as fs } from 'node:fs'; +import { mkdtemp, rm } from 'node:fs/promises'; +import { tmpdir } from 'node:os'; +import { join } from 'node:path'; +import { afterEach, beforeEach, describe, expect, it } from 'vitest'; +import { + buildPlist, + installPlist, + LAUNCHD_LABEL, + launchdPlistPath, + uninstallPlist, +} from './index.js'; + +describe('buildPlist', () => { + it('embeds the label, binPath, and interval', () => { + const xml = buildPlist({ + binPath: '/usr/local/bin/deepcode', + intervalSec: 30, + home: '/Users/x', + }); + expect(xml).toContain(`${LAUNCHD_LABEL}`); + expect(xml).toContain('/usr/local/bin/deepcode'); + expect(xml).toContain('30'); + }); + + it('escapes XML special chars in paths', () => { + const xml = buildPlist({ + binPath: '/path & dir/deepcode', + home: '/Users/x', + }); + expect(xml).toContain('&'); + expect(xml).toContain('<bin>'); + }); + + it('splits subcommand into separate ProgramArguments', () => { + const xml = buildPlist({ + binPath: '/usr/local/bin/deepcode', + subcommand: 'scheduler run', + home: '/Users/x', + }); + expect(xml).toContain('scheduler'); + expect(xml).toContain('run'); + }); +}); + +describe('installPlist / uninstallPlist', () => { + let home: string; + beforeEach(async () => { + home = await mkdtemp(join(tmpdir(), 'dc-ld-')); + }); + afterEach(async () => { + await rm(home, { recursive: true, force: true }); + }); + + it('writes the plist to ~/Library/LaunchAgents/', async () => { + const path = await installPlist({ binPath: '/usr/local/bin/deepcode', home }); + expect(path).toBe(launchdPlistPath(home)); + const xml = await fs.readFile(path, 'utf8'); + expect(xml).toContain(LAUNCHD_LABEL); + }); + + it('uninstall removes the file and reports true; second call returns false', async () => { + await installPlist({ binPath: '/usr/local/bin/deepcode', home }); + expect(await uninstallPlist(home)).toBe(true); + expect(await uninstallPlist(home)).toBe(false); + }); +}); diff --git a/packages/core/src/launchd/index.ts b/packages/core/src/launchd/index.ts new file mode 100644 index 0000000..680406f --- /dev/null +++ b/packages/core/src/launchd/index.ts @@ -0,0 +1,97 @@ +// launchd plist installer for DeepCode's cron-like scheduled tasks. +// Spec: docs/DEVELOPMENT_PLAN.md §3.15 (M8 — scheduled tasks daemon) +// +// On macOS we ship a single LaunchAgent that fires every minute and dispatches +// any scheduled DeepCode tasks. We don't shell out to crontab — too brittle. +// On Linux this is a no-op (M8-ext: write a systemd timer). + +import { promises as fs } from 'node:fs'; +import { homedir } from 'node:os'; +import { join } from 'node:path'; + +export const LAUNCHD_LABEL = 'dev.deepcode.scheduler'; + +export interface LaunchdInstallOpts { + /** Override HOME for tests. */ + home?: string; + /** Path to the deepcode binary (absolute). */ + binPath: string; + /** Subcommand to invoke (default: "scheduler run"). */ + subcommand?: string; + /** Run interval in seconds — default 60. */ + intervalSec?: number; +} + +export function launchdPlistPath(home: string = homedir()): string { + return join(home, 'Library', 'LaunchAgents', `${LAUNCHD_LABEL}.plist`); +} + +/** + * Generate the plist XML body (pure — easy to test). The real install/uninstall + * also writes the file and `launchctl load`s it. + */ +export function buildPlist(opts: LaunchdInstallOpts): string { + const sub = (opts.subcommand ?? 'scheduler run').split(' ').filter(Boolean); + const interval = opts.intervalSec ?? 60; + const programArgs = [opts.binPath, ...sub] + .map((s) => ` ${escapeXml(s)}`) + .join('\n'); + return ` + + + + Label + ${LAUNCHD_LABEL} + ProgramArguments + +${programArgs} + + StartInterval + ${interval} + StandardOutPath + ${escapeXml(join(opts.home ?? homedir(), '.deepcode', 'scheduler.log'))} + StandardErrorPath + ${escapeXml(join(opts.home ?? homedir(), '.deepcode', 'scheduler.err.log'))} + RunAtLoad + + + +`; +} + +/** + * Write the plist to ~/Library/LaunchAgents/. Caller is responsible for + * `launchctl load -w ` (we don't shell out from a pure module). + * Returns the absolute path of the written plist. + */ +export async function installPlist(opts: LaunchdInstallOpts): Promise { + const path = launchdPlistPath(opts.home); + const xml = buildPlist(opts); + await fs.mkdir(join(opts.home ?? homedir(), 'Library', 'LaunchAgents'), { + recursive: true, + }); + await fs.writeFile(path, xml, 'utf8'); + return path; +} + +/** + * Remove the plist. Idempotent. + */ +export async function uninstallPlist(home: string = homedir()): Promise { + const path = launchdPlistPath(home); + try { + await fs.unlink(path); + return true; + } catch (err) { + if ((err as NodeJS.ErrnoException).code === 'ENOENT') return false; + throw err; + } +} + +function escapeXml(s: string): string { + return s + .replace(/&/g, '&') + .replace(//g, '>') + .replace(/"/g, '"'); +} diff --git a/packages/core/src/worktree/index.test.ts b/packages/core/src/worktree/index.test.ts new file mode 100644 index 0000000..e0d94dc --- /dev/null +++ b/packages/core/src/worktree/index.test.ts @@ -0,0 +1,93 @@ +import { spawnSync } from 'node:child_process'; +import { promises as fs } from 'node:fs'; +import { mkdtemp, rm } from 'node:fs/promises'; +import { tmpdir } from 'node:os'; +import { join } from 'node:path'; +import { afterEach, beforeEach, describe, expect, it } from 'vitest'; +import { createWorktree, removeWorktree } from './index.js'; + +async function makeRepo(): Promise { + const dir = await mkdtemp(join(tmpdir(), 'dc-wt-src-')); + spawnSync('git', ['init', '-q', '-b', 'main'], { cwd: dir }); + spawnSync('git', ['config', 'user.email', 't@t'], { cwd: dir }); + spawnSync('git', ['config', 'user.name', 't'], { cwd: dir }); + await fs.writeFile(join(dir, 'a.txt'), 'A'); + spawnSync('git', ['add', '.'], { cwd: dir }); + spawnSync('git', ['commit', '-q', '-m', 'init'], { cwd: dir }); + return dir; +} + +describe('createWorktree / removeWorktree', () => { + let src: string; + let parent: string; + + beforeEach(async () => { + src = await makeRepo(); + parent = await mkdtemp(join(tmpdir(), 'dc-wt-parent-')); + }); + afterEach(async () => { + await rm(src, { recursive: true, force: true }); + await rm(parent, { recursive: true, force: true }); + }); + + it('creates a worktree and removes it cleanly', async () => { + const h = await createWorktree({ source: src, parentDir: parent }); + expect(h.path).toContain(parent); + expect(h.branch).toMatch(/^dc\//); + // File is present in worktree + expect(await fs.readFile(join(h.path, 'a.txt'), 'utf8')).toBe('A'); + await removeWorktree(h); + await expect(fs.access(h.path)).rejects.toThrow(); + }); + + it('honors baseRef from config', async () => { + // Make a second commit, then branch from the FIRST. + spawnSync('git', ['-C', src, 'tag', 'v0']); + await fs.writeFile(join(src, 'b.txt'), 'B'); + spawnSync('git', ['-C', src, 'add', '.'], {}); + spawnSync('git', ['-C', src, 'commit', '-q', '-m', 'second'], {}); + const h = await createWorktree({ + source: src, + parentDir: parent, + config: { baseRef: 'v0' }, + }); + try { + // b.txt should NOT exist at the tag-pinned worktree + await expect(fs.access(join(h.path, 'b.txt'))).rejects.toThrow(); + } finally { + await removeWorktree(h); + } + }); + + it('creates symlinks for symlinkDirectories', async () => { + await fs.mkdir(join(src, 'node_modules')); + await fs.writeFile(join(src, 'node_modules', 'pkg.txt'), 'real'); + const h = await createWorktree({ + source: src, + parentDir: parent, + config: { symlinkDirectories: ['node_modules'] }, + }); + try { + const stat = await fs.lstat(join(h.path, 'node_modules')); + expect(stat.isSymbolicLink()).toBe(true); + } finally { + await removeWorktree(h); + } + }); + + it('errors when source is not a git repo', async () => { + const notARepo = await mkdtemp(join(tmpdir(), 'dc-not-repo-')); + try { + await expect( + createWorktree({ source: notARepo, parentDir: parent }), + ).rejects.toThrow(/not a git repository/); + } finally { + await rm(notARepo, { recursive: true, force: true }); + } + }); + + it('removeWorktree is idempotent (path already gone)', async () => { + await removeWorktree({ path: join(parent, 'nope'), branch: 'x', source: src }); + // should not throw + }); +}); diff --git a/packages/core/src/worktree/index.ts b/packages/core/src/worktree/index.ts new file mode 100644 index 0000000..e56832d --- /dev/null +++ b/packages/core/src/worktree/index.ts @@ -0,0 +1,105 @@ +// Worktree subsystem — git worktree creation + tear-down for isolated agent runs. +// Spec: docs/DEVELOPMENT_PLAN.md §3.15 (M8) +// +// Why: background tasks and risky refactors run in a temporary git worktree so +// they can't corrupt the user's main checkout. EnterWorktree() creates one; +// ExitWorktree() removes it. Supports baseRef, symlinkDirectories, +// sparsePaths from settings.worktree. + +import { spawnSync } from 'node:child_process'; +import { promises as fs } from 'node:fs'; +import { tmpdir } from 'node:os'; +import { join, basename } from 'node:path'; +import type { WorktreeConfig } from '../config/types.js'; + +export interface WorktreeHandle { + /** Absolute path to the worktree dir. */ + path: string; + /** The branch name (created in the source repo). */ + branch: string; + /** Source repo path. */ + source: string; +} + +export interface CreateWorktreeOpts { + /** Source repo root (must contain .git). */ + source: string; + /** Optional branch name; defaults to `dc/`. */ + branch?: string; + /** Optional dir to create the worktree under; defaults to system tmp. */ + parentDir?: string; + /** WorktreeConfig from settings. */ + config?: WorktreeConfig; +} + +/** + * Create a git worktree branched from `baseRef` (HEAD by default). Honors + * `symlinkDirectories` (e.g. node_modules) and `sparsePaths` (sparse-checkout + * narrowed to these paths only). + */ +export async function createWorktree(opts: CreateWorktreeOpts): Promise { + const source = opts.source; + await assertGitRepo(source); + const parent = opts.parentDir ?? tmpdir(); + const branch = opts.branch ?? `dc/${Date.now().toString(36)}-${Math.random().toString(36).slice(2, 7)}`; + const path = join(parent, `dc-wt-${basename(source)}-${branch.replace(/[/]/g, '_')}`); + const baseRef = opts.config?.baseRef ?? 'HEAD'; + + // git worktree add -b + runGit(source, ['worktree', 'add', '-b', branch, path, baseRef]); + + // Sparse checkout: limit to listed paths + if (opts.config?.sparsePaths && opts.config.sparsePaths.length > 0) { + runGit(path, ['sparse-checkout', 'init', '--cone']); + runGit(path, ['sparse-checkout', 'set', ...opts.config.sparsePaths]); + } + + // Symlinks: e.g. node_modules → source/node_modules + for (const dir of opts.config?.symlinkDirectories ?? []) { + const src = join(source, dir); + const dst = join(path, dir); + try { + await fs.access(src); + } catch { + continue; // skip if source doesn't have the dir + } + try { + await fs.rm(dst, { recursive: true, force: true }); + } catch { + /* ignore */ + } + await fs.symlink(src, dst, 'dir'); + } + + return { path, branch, source }; +} + +/** + * Remove a worktree (git worktree remove + delete the branch). + * Idempotent: silently no-ops if path is already gone. + */ +export async function removeWorktree(handle: WorktreeHandle): Promise { + try { + await fs.access(handle.path); + } catch { + return; + } + runGit(handle.source, ['worktree', 'remove', '--force', handle.path]); + // Delete the branch (best-effort) + spawnSync('git', ['-C', handle.source, 'branch', '-D', handle.branch], { stdio: 'pipe' }); +} + +function runGit(cwd: string, args: string[]): void { + const r = spawnSync('git', ['-C', cwd, ...args], { stdio: 'pipe', encoding: 'utf8' }); + if (r.status !== 0) { + throw new Error(`git ${args.join(' ')} failed: ${r.stderr || r.stdout}`); + } +} + +async function assertGitRepo(path: string): Promise { + try { + await fs.access(join(path, '.git')); + } catch { + throw new Error(`${path} is not a git repository (no .git dir).`); + } +}