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
24 changes: 19 additions & 5 deletions packages/deskstudio/bin/deskstudio.mjs
Original file line number Diff line number Diff line change
Expand Up @@ -10,9 +10,13 @@
import { existsSync, mkdirSync, readFileSync, writeFileSync, readdirSync, statSync, realpathSync, renameSync } from 'node:fs';
import { dirname, join, relative, resolve } from 'node:path';
import { createHash } from 'node:crypto';
import { fileURLToPath } from 'node:url';
import { fileURLToPath, pathToFileURL } from 'node:url';
import { resolveRoot } from './root.mjs';

const runnerRoot = resolveRoot();
const { chooseDefaults } = await import(pathToFileURL(join(runnerRoot, 'runner', 'models.mjs')).href);
const { detect } = await import(pathToFileURL(join(runnerRoot, 'runner', 'providers', 'index.mjs')).href);

export const AGENT_FILES = {
codex: { file: 'AGENTS.md', adapter: 'agents.md' },
opencode: { file: 'AGENTS.md', adapter: 'opencode.md' },
Expand All @@ -27,7 +31,6 @@ const END = '<!-- desk:end -->';

const SEED_PROJECT = {
quality: 'studio',
models: { director: 'anthropic/opus', senior: 'anthropic/sonnet', builder: 'anthropic/haiku' },
presets: {
studio: { director: 'anthropic/opus', senior: 'anthropic/sonnet', builder: 'anthropic/haiku' },
best: { director: 'anthropic/fable', senior: 'anthropic/opus', builder: 'anthropic/sonnet' },
Expand Down Expand Up @@ -109,7 +112,7 @@ function copyTree(src, dst, { force, rewrite, previous, installed, root = dst },
}
}

export function add({ dir = process.cwd(), agents = ALL_AGENTS, force = false, log = s => process.stdout.write(s + '\n') } = {}) {
export function add({ dir = process.cwd(), agents = ALL_AGENTS, force = false, log = s => process.stdout.write(s + '\n'), detectProviders = detect } = {}) {
const target = resolve(dir);
const root = resolveRoot(join('skills', 'desk', 'SKILL.md'));
const adaptersDir = resolveRoot(join('adapters', 'agents.md')) + '/adapters';
Expand All @@ -123,13 +126,25 @@ export function add({ dir = process.cwd(), agents = ALL_AGENTS, force = false, l
if (!agents.length) return out;
}

for (const agent of agents) {
if (!AGENT_FILES[agent]) throw new Error(`Unknown agent "${agent}". Agents: ${ALL_AGENTS.join(', ')}, claude, all.`);
}

const manifestFile = join(target, '.desk', 'installed.json');
const previous = installedHashes(manifestFile);

// 1. .desk/ seeds. Never DESIGN.md, the first job finds the brand in the project.
const desk = join(target, '.desk');
let projectSeed;
if (!existsSync(join(desk, 'project.json'))) {
const chosen = chooseDefaults(detectProviders({ fresh: true }));
if (!chosen.models) throw new Error('No model provider is ready. Sign in with `codex login`, `claude auth login` or `agy`, then rerun `deskstudio add`. Run `desk-run doctor` to check provider access.');
// The runner selects the studio preset before the top-level model map.
projectSeed = { ...SEED_PROJECT, models: chosen.models, presets: { ...SEED_PROJECT.presets, studio: { ...chosen.models } } };
log(`Project models: ${Object.entries(chosen.models).map(([tier, spec]) => `${tier} ${spec}`).join(', ')} (${chosen.reason})`);
}
mkdirSync(desk, { recursive: true });
for (const [name, text] of [['project.json', JSON.stringify(SEED_PROJECT, null, 2) + '\n'], ['taste.md', SEED_TASTE], ['.gitignore', SEED_GITIGNORE]]) {
for (const [name, text] of [['project.json', JSON.stringify(projectSeed, null, 2) + '\n'], ['taste.md', SEED_TASTE], ['.gitignore', SEED_GITIGNORE]]) {
const f = join(desk, name);
if (existsSync(f)) { out.seeds.push({ name, status: 'kept' }); continue; }
writeFileSync(f, text);
Expand All @@ -156,7 +171,6 @@ export function add({ dir = process.cwd(), agents = ALL_AGENTS, force = false, l
const done = new Set();
for (const agent of agents) {
const spec = AGENT_FILES[agent];
if (!spec) throw new Error(`Unknown agent "${agent}". Agents: ${ALL_AGENTS.join(', ')}, claude, all.`);
if (done.has(spec.file)) continue;
done.add(spec.file);
const adapter = readFileSync(join(adaptersDir, spec.adapter), 'utf8');
Expand Down
87 changes: 87 additions & 0 deletions runner/test/installer-models.test.mjs
Original file line number Diff line number Diff line change
@@ -0,0 +1,87 @@
import { test } from 'node:test';
import assert from 'node:assert/strict';
import { existsSync, mkdtempSync, mkdirSync, readFileSync, writeFileSync, rmSync } from 'node:fs';
import { tmpdir } from 'node:os';
import { join } from 'node:path';
import { execFileSync } from 'node:child_process';
import { add } from '../../packages/deskstudio/bin/deskstudio.mjs';
import { chooseDefaults, PRESETS } from '../models.mjs';
import { runJob } from '../run.mjs';

const quiet = () => {};
const temp = t => {
const dir = mkdtempSync(join(tmpdir(), 'desk-install-models-'));
t.after(() => rmSync(dir, { recursive: true, force: true }));
return dir;
};
const projectFile = dir => join(dir, '.desk', 'project.json');

for (const [label, detected, expected] of [
['Codex and Antigravity, Claude signed out', { anthropic: { via: null }, openai: { via: 'cli' }, google: { via: 'cli' } },
{ director: 'openai/large', senior: 'openai/mini', builder: 'openai/nano' }],
['Antigravity alone', { google: { via: 'cli' } },
{ director: 'google/flash-high', senior: 'google/flash', builder: 'google/flash-low' }],
['Claude alone', { anthropic: { via: 'cli' } }, PRESETS.studio],
['Claude and Codex', { anthropic: { via: 'cli' }, openai: { via: 'cli' } }, PRESETS.studio],
]) {
test(`fresh install uses doctor defaults at the first model call: ${label}`, async t => {
const dir = temp(t);
add({ dir, agents: ['codex'], log: quiet, detectProviders: options => {
assert.equal(options.fresh, true);
return detected;
} });
const cfg = JSON.parse(readFileSync(projectFile(dir), 'utf8'));
assert.deepEqual(cfg.models, expected);
assert.deepEqual(cfg.models, chooseDefaults(detected).models);
assert.deepEqual(cfg.presets.studio, expected);
assert.deepEqual(cfg.presets.best, PRESETS.best);
assert.deepEqual(cfg.presets.thrifty, PRESETS.thrifty);
const calls = [];
const stop = new Error('stopped at the first model call');
const result = await runJob({ command: 'design', ask: 'a launch card', project: dir, log: quiet,
invoke: async ({ spec }) => { calls.push(spec); throw stop; } });
assert.equal(result.error, stop);
assert.deepEqual(result.state.models, expected, 'the active studio preset must not restore Anthropic');
assert.deepEqual(calls, [expected.director]);
});
}

test('existing explicit models and custom presets remain byte for byte, even with --force', t => {
const dir = temp(t);
mkdirSync(join(dir, '.desk'));
const original = JSON.stringify({ quality: 'custom', models: { director: 'google/pro', senior: 'openai/mini', builder: 'google/flash' },
presets: { custom: { director: 'openai/large', senior: 'google/flash', builder: 'openai/nano' } }, maxRounds: 3 }, null, 4) + '\n';
writeFileSync(projectFile(dir), original);
for (const force of [false, true]) {
add({ dir, agents: ['codex'], force, log: quiet, detectProviders: () => { throw new Error('must not detect for an existing project'); } });
assert.equal(readFileSync(projectFile(dir), 'utf8'), original);
}
});

test('no provider stops before writing ready-to-run files and explains how to retry', t => {
const dir = temp(t);
assert.throws(() => add({ dir, agents: ['codex'], log: quiet, detectProviders: () => ({}) }),
/No model provider is ready.*codex login.*rerun `deskstudio add`.*desk-run doctor/);
assert.equal(existsSync(join(dir, '.desk')), false);
assert.equal(existsSync(join(dir, 'AGENTS.md')), false);
});

test('the installer CLI detects a signed-in Codex without an API key', { skip: process.platform === 'win32' }, t => {
const dir = temp(t);
const tools = join(dir, 'bin');
mkdirSync(tools);
writeFileSync(join(tools, 'codex'), `#!${process.execPath}
if (process.argv[2] === '--version') console.log('codex-cli 0.163.0');
else if (process.argv.slice(2).join(' ') === 'login status') console.log('Logged in using ChatGPT');
else { console.error('unexpected model call'); process.exit(1); }
`, { mode: 0o755 });
const env = { ...process.env, PATH: tools, HOME: dir, CODEX_HOME: dir };
for (const name of ['ANTHROPIC_API_KEY', 'OPENAI_API_KEY', 'GEMINI_API_KEY', 'GOOGLE_API_KEY']) delete env[name];
const project = join(dir, 'project');
const bin = new URL('../../packages/deskstudio/bin/deskstudio.mjs', import.meta.url);
const out = execFileSync(process.execPath, [bin.pathname, 'add', '--agent', 'codex', '--dir', project], { env, encoding: 'utf8' });
assert.match(out, /Project models: director openai\/large/);
const cfg = JSON.parse(readFileSync(projectFile(project), 'utf8'));
assert.deepEqual(cfg.models, { director: 'openai/large', senior: 'openai/mini', builder: 'openai/nano' });
assert.deepEqual(cfg.presets.studio, cfg.models);
});
10 changes: 8 additions & 2 deletions runner/test/installer.test.mjs
Original file line number Diff line number Diff line change
Expand Up @@ -18,7 +18,9 @@ if (!existsSync(join(ROOT, 'adapters', 'agents.md'))) {
const { build } = await import(pathToFileURL(join(ROOT, 'scripts', 'build-adapters.mjs')).href);
build({ log: quiet });
}
const { add, upsertBlock } = await import(pathToFileURL(bin).href);
const { add: install, upsertBlock } = await import(pathToFileURL(bin).href);
const anthropic = () => ({ anthropic: { via: 'cli' } });
const add = options => install({ detectProviders: anthropic, ...options });

const snapshot = dir => {
const out = {};
Expand Down Expand Up @@ -106,6 +108,8 @@ test('the installer runs through an npm-style binary symlink', { skip: process.p
const dir = mkdtempSync(join(tmpdir(), 'desk-npm-bin-'));
const link = join(dir, 'deskstudio');
const project = join(dir, 'project');
mkdirSync(join(project, '.desk'), { recursive: true });
writeFileSync(join(project, '.desk', 'project.json'), '{}\n');
symlinkSync(bin, link);
const out = execFileSync(process.execPath, [link, 'add', '--agent', 'codex', '--dir', project], { encoding: 'utf8' });
assert.ok(out.includes('AGENTS.md: created'));
Expand Down Expand Up @@ -178,7 +182,9 @@ test('a later package updates tracked files, restores missing files and keeps ed
cpSync(dirname(bin), join(pkg, 'bin'), { recursive: true });
cpSync(join(ROOT, 'skills'), join(pkg, 'skills'), { recursive: true });
cpSync(join(ROOT, 'adapters'), join(pkg, 'adapters'), { recursive: true });
const { add: install } = await import(pathToFileURL(join(pkg, 'bin', 'deskstudio.mjs')).href);
cpSync(join(ROOT, 'runner'), join(pkg, 'runner'), { recursive: true });
const { add: packageInstall } = await import(pathToFileURL(join(pkg, 'bin', 'deskstudio.mjs')).href);
const install = options => packageInstall({ detectProviders: anthropic, ...options });
const project = join(dir, 'project');
install({ dir: project, agents: ['codex'], log: quiet });
const changed = 'roles/content-designer.md';
Expand Down
Loading