Skip to content
Merged
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
2 changes: 2 additions & 0 deletions apps/cli/src/cli.ts
Original file line number Diff line number Diff line change
Expand Up @@ -56,6 +56,8 @@ async function main(): Promise<number> {
allowedTools: args.allowedTools,
disallowedTools: args.disallowedTools,
maxTurns: args.maxTurns,
jsonSchema: args.jsonSchema,
includePartialMessages: args.includePartialMessages,
});
}

Expand Down
48 changes: 48 additions & 0 deletions apps/cli/src/headless.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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.`;
Expand Down Expand Up @@ -189,9 +195,14 @@ export async function runHeadless(opts: HeadlessOpts): Promise<number> {

// ─── 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);
Expand Down Expand Up @@ -253,6 +264,15 @@ export async function runHeadless(opts: HeadlessOpts): Promise<number> {
.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(
{
Expand All @@ -261,6 +281,7 @@ export async function runHeadless(opts: HeadlessOpts): Promise<number> {
usage: result.usage,
events: collectedEvents,
exitCode,
...(schemaError ? { schemaError } : {}),
},
null,
2,
Expand All @@ -287,6 +308,33 @@ export async function runHeadless(opts: HeadlessOpts): Promise<number> {
return exitCode;
}

async function validateAgainstSchema(schemaPath: string, output: string): Promise<string | null> {
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<string, unknown>;
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 {
Expand Down
18 changes: 18 additions & 0 deletions packages/core/src/index.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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,
Expand Down
67 changes: 67 additions & 0 deletions packages/core/src/launchd/index.test.ts
Original file line number Diff line number Diff line change
@@ -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(`<string>${LAUNCHD_LABEL}</string>`);
expect(xml).toContain('<string>/usr/local/bin/deepcode</string>');
expect(xml).toContain('<integer>30</integer>');
});

it('escapes XML special chars in paths', () => {
const xml = buildPlist({
binPath: '/path & dir/deepcode<bin>',
home: '/Users/x',
});
expect(xml).toContain('&amp;');
expect(xml).toContain('&lt;bin&gt;');
});

it('splits subcommand into separate ProgramArguments', () => {
const xml = buildPlist({
binPath: '/usr/local/bin/deepcode',
subcommand: 'scheduler run',
home: '/Users/x',
});
expect(xml).toContain('<string>scheduler</string>');
expect(xml).toContain('<string>run</string>');
});
});

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);
});
});
97 changes: 97 additions & 0 deletions packages/core/src/launchd/index.ts
Original file line number Diff line number Diff line change
@@ -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) => ` <string>${escapeXml(s)}</string>`)
.join('\n');
return `<?xml version="1.0" encoding="UTF-8"?>
<!DOCTYPE plist PUBLIC "-//Apple//DTD PLIST 1.0//EN" "http://www.apple.com/DTDs/PropertyList-1.0.dtd">
<plist version="1.0">
<dict>
<key>Label</key>
<string>${LAUNCHD_LABEL}</string>
<key>ProgramArguments</key>
<array>
${programArgs}
</array>
<key>StartInterval</key>
<integer>${interval}</integer>
<key>StandardOutPath</key>
<string>${escapeXml(join(opts.home ?? homedir(), '.deepcode', 'scheduler.log'))}</string>
<key>StandardErrorPath</key>
<string>${escapeXml(join(opts.home ?? homedir(), '.deepcode', 'scheduler.err.log'))}</string>
<key>RunAtLoad</key>
<false/>
</dict>
</plist>
`;
}

/**
* Write the plist to ~/Library/LaunchAgents/. Caller is responsible for
* `launchctl load -w <path>` (we don't shell out from a pure module).
* Returns the absolute path of the written plist.
*/
export async function installPlist(opts: LaunchdInstallOpts): Promise<string> {
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<boolean> {
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, '&amp;')
.replace(/</g, '&lt;')
.replace(/>/g, '&gt;')
.replace(/"/g, '&quot;');
}
93 changes: 93 additions & 0 deletions packages/core/src/worktree/index.test.ts
Original file line number Diff line number Diff line change
@@ -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<string> {
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
});
});
Loading
Loading