diff --git a/src/cli.ts b/src/cli.ts index 752555375..0c00dbdb4 100644 --- a/src/cli.ts +++ b/src/cli.ts @@ -49,8 +49,13 @@ import { presentAgentTargetCatalog, } from "./local-agent-presentation.js"; import { + type InitAnswers, + type InitFlags, type OnboardingDestination, SUBAGENT_SKILL_INSTALL_COMMAND, + isNonInteractiveInit, + parseInitArgs, + resolveInitAnswers, resolveOnboardingUsage, updateOnboardingSubagentsConfig, usesChatGpt, @@ -102,7 +107,7 @@ async function main(argv: string[]): Promise { return; case "init": - await runInit({ force: args.includes("--force") }); + await runInit(parseInitArgs(args)); return; case "doctor": @@ -182,196 +187,307 @@ async function ensureConfigured(): Promise { "Run:", " devspace init", "", + "Setup questions can also be answered with flags, for example:", + " devspace init --public-url https://your-tunnel-host.example.com --roots ~/work", + "", "Or provide DEVSPACE_OAUTH_OWNER_TOKEN.", ].join("\n"), ); } - await runInit({ force: false }); + await runInit({ force: false, yes: false, json: false }); } -async function runInit({ force }: { force: boolean }): Promise { +async function runInit(flags: InitFlags): Promise { const files = loadDevspaceFiles(); + const nonInteractive = isNonInteractiveInit(flags, Boolean(input.isTTY && output.isTTY)); + + if (!flags.force && files.configExists && files.authExists) { + if (flags.json) { + console.log(JSON.stringify({ + status: "already_configured", + config_path: files.configPath, + next_steps: ["Run `devspace init --force` with your answers to update it."], + }, null, 2)); + } else { + console.log(`DevSpace is already configured at ${files.dir}`); + console.log("Run `devspace init --force` to update it."); + } + + return; + } + + const currentSubagents = resolveSubagentsConfig(files.config.subagents ?? disabledSubagentsConfig()); + const availability = getLocalAgentProviderAvailabilitySnapshot(process.env, currentSubagents); + + const configuredProviders = currentSubagents.providers + .filter((provider) => provider.enabled) + .map((provider) => provider.id); + + const defaults = { + providers: configuredProviders.length > 0 + ? configuredProviders + : availability.filter((provider) => provider.available).map((provider) => provider.name), + knownProviders: availability.map((provider) => provider.name), + roots: files.config.workspaces.allowedRoots, + publicUrl: files.config.server.publicBaseUrl ?? null, + }; - if (!force && files.configExists && files.authExists) { - prompts.log.info(`DevSpace is already configured at ${files.dir}`); - prompts.log.info("Run `devspace init --force` to update it."); + if (nonInteractive) { + const answers = resolveInitAnswers(flags, defaults); + const publicUrlError = answers.publicUrl ? validateRequiredPublicBaseUrl(answers.publicUrl) : undefined; + + if (publicUrlError) throw new Error(`--public-url: ${publicUrlError}`); + const result = applyInitAnswers(files, currentSubagents, answers); + + const unavailable = availability + .filter((provider) => result.providers.includes(provider.name) && !provider.available) + .map((provider) => `${provider.name} (${provider.reason ?? "provider preflight failed"})`); + + printInitResult(result, files.authPath, unavailable, flags.json); return; } try { prompts.intro("DevSpace setup"); + const destinations: OnboardingDestination[] = [defaults.publicUrl ? "chatgpt" : "coding-agents"]; + // A person confirms the prompt's root, so the current directory is a fine suggestion there. + const roots = defaults.roots.length > 0 ? defaults.roots : [process.cwd()]; - const destinationAnswer = await prompts.multiselect({ - message: "Where do you want to use DevSpace?", - options: [ - { - value: "chatgpt", - label: "ChatGPT", - hint: "Connect ChatGPT to projects on this computer.", - }, - { - value: "coding-agents", - label: "Coding Agents", - hint: "Use DevSpace from Codex, Claude Code, OpenCode, Pi, and similar tools.", - }, - ], - initialValues: files.config.server.publicBaseUrl ? ["chatgpt"] : ["coding-agents"], - required: true, - }); + const result = applyInitAnswers( + files, + currentSubagents, + await promptInitAnswers({ ...defaults, roots, destinations }, availability, files.config.server.port), + ); - if (prompts.isCancel(destinationAnswer)) throw new SetupCancelledError(); - // SAFETY: The surrounding checks or construction already match this type. - const usage = resolveOnboardingUsage(destinationAnswer as OnboardingDestination[]); - const useChatGpt = usesChatGpt(usage); - const useCodingAgents = usesCodingAgents(usage); + printInteractiveInitResult(result); + } catch (error) { + if (error instanceof SetupCancelledError) { + prompts.cancel("Setup cancelled"); - let allowedRoots: string[] | undefined; + return; + } - if (useChatGpt) { - const defaultRoots = files.config.workspaces.allowedRoots.join(", ") || process.cwd(); + throw error; + } +} - const rootsAnswer = await textPrompt({ - message: `Which project folders can DevSpace access? Press Enter to use ${defaultRoots}`, - placeholder: defaultRoots, - defaultValue: defaultRoots, - validate: (value) => value?.trim() ? undefined : "Enter at least one project root.", - }); +async function promptInitAnswers( + defaults: { destinations: OnboardingDestination[]; providers: string[]; roots: string[]; publicUrl: string | null }, + availability: ReturnType, + port: number, +): Promise { + const destinationAnswer = await prompts.multiselect({ + message: "Where do you want to use DevSpace?", + options: [ + { + value: "chatgpt", + label: "ChatGPT", + hint: "Connect ChatGPT to projects on this computer.", + }, + { + value: "coding-agents", + label: "Coding Agents", + hint: "Use DevSpace from Codex, Claude Code, OpenCode, Pi, and similar tools.", + }, + ], + initialValues: defaults.destinations, + required: true, + }); - allowedRoots = rootsAnswer - .split(",") - .map((root) => resolve(expandHomePath(root.trim()))) - .filter(Boolean); - } + if (prompts.isCancel(destinationAnswer)) throw new SetupCancelledError(); + // SAFETY: The surrounding checks or construction already match this type. + const usage = resolveOnboardingUsage(destinationAnswer as OnboardingDestination[]); + let roots: string[] | undefined; + let publicUrl: string | undefined; - const port = files.config.server.port; - - let publicBaseUrl: string | null = null; - - if (useChatGpt) { - prompts.note( - [ - `Point your HTTPS tunnel or reverse proxy to http://127.0.0.1:${port}.`, - "Paste its public URL below.", - "", - "Example: https://your-tunnel-host.example.com", - ].join("\n"), - "Connect ChatGPT", - ); - publicBaseUrl = normalizePublicBaseUrl(await textPrompt({ - message: files.config.server.publicBaseUrl - ? `What public URL will ChatGPT connect to? Press Enter to keep ${files.config.server.publicBaseUrl}` - : "What public URL will ChatGPT connect to?", - placeholder: files.config.server.publicBaseUrl ?? "https://your-tunnel-host.example.com", - defaultValue: files.config.server.publicBaseUrl ?? "", - validate: validateRequiredPublicBaseUrl, - })); - } + if (usesChatGpt(usage)) { + const defaultRoots = defaults.roots.join(", "); - const currentSubagents = resolveSubagentsConfig(files.config.subagents ?? disabledSubagentsConfig()); + roots = (await textPrompt({ + message: `Which project folders can DevSpace access? Press Enter to use ${defaultRoots}`, + placeholder: defaultRoots, + defaultValue: defaultRoots, + validate: (value) => value?.trim() ? undefined : "Enter at least one project root.", + })).split(","); - const availability = getLocalAgentProviderAvailabilitySnapshot( - process.env, - currentSubagents, + prompts.note( + [ + `Point your HTTPS tunnel or reverse proxy to http://127.0.0.1:${port}.`, + "Paste its public URL below.", + "", + "Example: https://your-tunnel-host.example.com", + ].join("\n"), + "Connect ChatGPT", ); + publicUrl = await textPrompt({ + message: defaults.publicUrl + ? `What public URL will ChatGPT connect to? Press Enter to keep ${defaults.publicUrl}` + : "What public URL will ChatGPT connect to?", + placeholder: defaults.publicUrl ?? "https://your-tunnel-host.example.com", + defaultValue: defaults.publicUrl ?? "", + validate: validateRequiredPublicBaseUrl, + }); + } - const configuredProviders = currentSubagents.providers - .filter((provider) => provider.enabled) - .map((provider) => provider.id); + prompts.log.info( + "DevSpace can delegate work to these agents from ChatGPT or another coding agent.", + ); - const initialValues = configuredProviders.length > 0 - ? configuredProviders - : availability - .filter((provider) => provider.available) - .map((provider) => provider.name); + const providerAnswer = await prompts.multiselect({ + message: "Which agents can DevSpace use as subagents?", + options: availability.map((provider) => ({ + value: provider.name, + label: provider.name, + hint: provider.available + ? provider.note ?? "available" + : `unavailable: ${provider.reason ?? "provider preflight failed"}`, + })), + initialValues: defaults.providers, + // An MCP host works without subagents, so only a subagents-only setup must pick one. + required: !usesChatGpt(usage), + }); - prompts.log.info( - "DevSpace can delegate work to these agents from ChatGPT or another coding agent.", - ); + if (prompts.isCancel(providerAnswer)) throw new SetupCancelledError(); - const providerAnswer = await prompts.multiselect({ - message: "Which agents can DevSpace use as subagents?", - options: availability.map((provider) => ({ - value: provider.name, - label: provider.name, - hint: provider.available - ? provider.note ?? "available" - : `unavailable: ${provider.reason ?? "provider preflight failed"}`, - })), - initialValues, - required: true, - }); + // SAFETY: The surrounding checks or construction already match this type. + return { usage, providers: providerAnswer as string[], roots, publicUrl }; +} - if (prompts.isCancel(providerAnswer)) throw new SetupCancelledError(); - // SAFETY: The surrounding checks or construction already match this type. - const selectedProviders = providerAnswer as string[]; +interface InitResult { + configPath: string; + usage: InitAnswers["usage"]; + providers: string[]; + allowedRoots?: string[]; + publicBaseUrl?: string; + ownerToken: string; +} - const subagents = updateOnboardingSubagentsConfig( - currentSubagents, - selectedProviders, - ); +/** Persist setup answers, from prompts or flags, and return what was written. */ +function applyInitAnswers( + files: ReturnType, + currentSubagents: ReturnType, + answers: InitAnswers, +): InitResult { + const allowedRoots = answers.roots + ?.map((root) => root.trim()) + .filter(Boolean) + .map((root) => resolve(expandHomePath(root))); + + const publicBaseUrl = answers.publicUrl ? normalizePublicBaseUrl(answers.publicUrl) : undefined; + const auth = { ownerToken: files.auth.ownerToken ?? generateOwnerToken() }; + + setDevspaceConfigValues([ + { path: ["server", "port"], value: files.config.server.port }, + ...(publicBaseUrl ? [{ path: ["server", "publicBaseUrl"], value: publicBaseUrl }] : []), + ...(allowedRoots ? [{ path: ["workspaces", "allowedRoots"], value: allowedRoots }] : []), + { path: ["subagents"], value: updateOnboardingSubagentsConfig(currentSubagents, answers.providers) }, + ]); + writeDevspaceAuth(auth); + + return { + configPath: files.configPath, + usage: answers.usage, + providers: answers.providers, + allowedRoots, + publicBaseUrl, + ownerToken: auth.ownerToken, + }; +} + +function initNextSteps(result: InitResult): string[] { + return [ + ...(usesChatGpt(result.usage) ? ["Run `devspace serve` (restart it if it is already running), then connect ChatGPT."] : []), + ...(usesCodingAgents(result.usage) ? [`Install the Subagents skill: ${SUBAGENT_SKILL_INSTALL_COMMAND}`] : []), + ]; +} + +/** `devspace init --json` output; the MCP fields appear only when an MCP host is set up. */ +interface InitReport { + status: "configured"; + config_path: string; + providers: string[]; + unavailable_providers: string[]; + next_steps: string[]; + allowed_roots?: string[]; + mcp_url?: string; + /** Where the owner password lives; it is never printed here, so it stays out of tool logs. */ + owner_password_file?: string; +} - const auth = { - ownerToken: files.auth.ownerToken ?? generateOwnerToken(), +function printInitResult(result: InitResult, authPath: string, unavailable: string[], json: boolean): void { + const chatGpt = usesChatGpt(result.usage); + + if (json) { + const report: InitReport = { + status: "configured", + config_path: result.configPath, + providers: result.providers, + unavailable_providers: unavailable, + next_steps: initNextSteps(result), }; - setDevspaceConfigValues([ - { path: ["server", "port"], value: port }, - ...(useChatGpt - ? [{ path: ["server", "publicBaseUrl"], value: publicBaseUrl }] - : []), - ...(allowedRoots - ? [{ path: ["workspaces", "allowedRoots"], value: allowedRoots }] - : []), - { path: ["subagents"], value: subagents }, - ]); - writeDevspaceAuth(auth); - - const lines = [ - ...(allowedRoots ? [`Project folders: ${allowedRoots.join(", ")}`] : []), - `Subagents: ${selectedProviders.join(", ")}`, - ...(publicBaseUrl ? [`ChatGPT connection URL: ${publicBaseUrl}/mcp`] : []), - ]; - - prompts.note(lines.join("\n"), "DevSpace is ready"); - - if (useChatGpt) { - prompts.note( - [ - `Owner password: ${auth.ownerToken}`, - "Use this when ChatGPT asks you to approve DevSpace access.", - ].join("\n"), - "Owner password", - ); + if (chatGpt) { + report.allowed_roots = result.allowedRoots; + report.mcp_url = `${result.publicBaseUrl}/mcp`; + report.owner_password_file = authPath; } - if (useCodingAgents) { - prompts.note( - [ - SUBAGENT_SKILL_INSTALL_COMMAND, - "", - "The Skills CLI will let you choose which Coding Agents receive it.", - ].join("\n"), - "Install the Subagents skill", - ); - } + console.log(JSON.stringify(report, null, 2)); + + return; + } - const nextSteps = [ - useChatGpt ? "Run `devspace serve`, then connect ChatGPT." : undefined, - useCodingAgents ? "Run the skill command above before delegating from your Coding Agents." : undefined, - ].filter(Boolean).join(" "); + console.log([ + `DevSpace is configured at ${result.configPath}`, + `Subagents: ${result.providers.join(", ")}`, + ...unavailable.map((provider) => `Warning: ${provider} is not available yet.`), + ...(chatGpt ? [ + `Project folders: ${result.allowedRoots!.join(", ")}`, + `ChatGPT connection URL: ${result.publicBaseUrl}/mcp`, + `Owner password: stored in ${authPath}. You enter it when ChatGPT asks to approve DevSpace access.`, + ] : []), + ...initNextSteps(result), + ].join("\n")); +} - prompts.outro(nextSteps); - } catch (error) { - if (error instanceof SetupCancelledError) { - prompts.cancel("Setup cancelled"); +function printInteractiveInitResult(result: InitResult): void { + const lines = [ + ...(result.allowedRoots ? [`Project folders: ${result.allowedRoots.join(", ")}`] : []), + `Subagents: ${result.providers.join(", ")}`, + ...(result.publicBaseUrl ? [`ChatGPT connection URL: ${result.publicBaseUrl}/mcp`] : []), + ]; - return; - } + prompts.note(lines.join("\n"), "DevSpace is ready"); - throw error; + if (usesChatGpt(result.usage)) { + prompts.note( + [ + `Owner password: ${result.ownerToken}`, + "Use this when ChatGPT asks you to approve DevSpace access.", + ].join("\n"), + "Owner password", + ); + } + + if (usesCodingAgents(result.usage)) { + prompts.note( + [ + SUBAGENT_SKILL_INSTALL_COMMAND, + "", + "The Skills CLI will let you choose which Coding Agents receive it.", + ].join("\n"), + "Install the Subagents skill", + ); } + + const nextSteps = [ + usesChatGpt(result.usage) ? "Run `devspace serve`, then connect ChatGPT." : undefined, + usesCodingAgents(result.usage) ? "Run the skill command above before delegating from your Coding Agents." : undefined, + ].filter(Boolean).join(" "); + + prompts.outro(nextSteps); } async function serve(): Promise { @@ -565,7 +681,8 @@ function printHelp(): void { "Usage:", " devspace Run first-time setup if needed, then start the server", " devspace serve Start the server", - " devspace init Create or update ~/.devspace/config.jsonc and auth.json", + " devspace init [--yes] [--providers ] [--public-url ] [--roots ] [--force] [--json]", + " Create or update ~/.devspace/config.jsonc and auth.json", " devspace doctor Show config, runtime, and native dependency status", " devspace config get Print persisted config", " devspace config set publicBaseUrl ", @@ -1256,9 +1373,14 @@ function validateRequiredPublicBaseUrl(value: string | undefined): string | unde if (!trimmed) return "Enter the public URL from your tunnel or reverse proxy."; - if (trimmed.endsWith("/mcp")) return "Enter the base URL only, without /mcp."; + const invalid = validatePublicBaseUrl(trimmed); + + if (invalid) return invalid; - return validatePublicBaseUrl(trimmed); + // Check the parsed path, since the query and fragment are dropped when the URL is saved. + return new URL(trimmed).pathname.replace(/\/+$/, "").endsWith("/mcp") + ? "Enter the base URL only, without /mcp." + : undefined; } function validatePublicBaseUrl(value: string): string | undefined { diff --git a/src/onboarding.test.ts b/src/onboarding.test.ts index 9ca02237d..e1485bf9c 100644 --- a/src/onboarding.test.ts +++ b/src/onboarding.test.ts @@ -1,6 +1,9 @@ import assert from "node:assert/strict"; import test from "node:test"; import { + isNonInteractiveInit, + parseInitArgs, + resolveInitAnswers, resolveOnboardingUsage, updateOnboardingSubagentsConfig, } from "./onboarding.js"; @@ -74,3 +77,78 @@ test("onboarding toggles existing providers and preserves their settings", () => }, ); }); + +const DEFAULTS = { + providers: ["codex"], + knownProviders: ["codex", "claude", "pi"], + roots: ["/home/me/work"], + publicUrl: null, +}; + +test("init flags parse spaced and inline values and reject unknown options", () => { + assert.deepEqual( + parseInitArgs(["--providers", "codex, claude", "--roots=~/a,~/b", "--public-url", "https://t.example", "--json"]), + { + force: false, + yes: false, + json: true, + providers: ["codex", "claude"], + roots: ["~/a", "~/b"], + publicUrl: "https://t.example", + }, + ); + assert.throws(() => parseInitArgs(["--providers"]), /--providers needs a value/); + assert.throws(() => parseInitArgs(["--use", "chatgpt"]), /Unknown option: --use/); + assert.throws(() => parseInitArgs(["--force=false"]), /--force takes no value/); +}); + +test("init runs without prompts when given answers or no terminal", () => { + assert.equal(isNonInteractiveInit(parseInitArgs([]), true), false); + assert.equal(isNonInteractiveInit(parseInitArgs([]), false), true); + assert.equal(isNonInteractiveInit(parseInitArgs(["--yes"]), true), true); + assert.equal(isNonInteractiveInit(parseInitArgs(["--providers", "codex"]), true), true); +}); + +test("init sets up subagents from the prompt defaults when no public URL is known", () => { + assert.deepEqual(resolveInitAnswers(parseInitArgs(["--yes"]), DEFAULTS), { + usage: "coding-agents", + providers: ["codex"], + }); + assert.throws( + () => resolveInitAnswers(parseInitArgs(["--providers", "codex,nope"]), DEFAULTS), + /Unknown provider: nope. Known providers: codex, claude, pi/, + ); + assert.throws( + () => resolveInitAnswers(parseInitArgs(["--yes"]), { ...DEFAULTS, providers: [] }), + /No coding agents were detected/, + ); + assert.throws( + () => resolveInitAnswers(parseInitArgs(["--roots", "~/work"]), DEFAULTS), + /--roots needs --public-url/, + ); +}); + +test("init also serves an MCP host once a public URL is given or saved", () => { + assert.deepEqual( + resolveInitAnswers(parseInitArgs(["--public-url", "https://t.example"]), DEFAULTS), + { usage: "both", providers: ["codex"], roots: ["/home/me/work"], publicUrl: "https://t.example" }, + ); + assert.deepEqual( + resolveInitAnswers(parseInitArgs(["--yes"]), { ...DEFAULTS, publicUrl: "https://saved.example" }), + { usage: "both", providers: ["codex"], roots: ["/home/me/work"], publicUrl: "https://saved.example" }, + ); +}); + +test("init never guesses the roots an MCP host may open", () => { + assert.throws( + () => resolveInitAnswers(parseInitArgs(["--public-url", "https://t.example"]), { ...DEFAULTS, roots: [] }), + /--roots is required with --public-url/, + ); +}); + +test("init can serve an MCP host without any coding agent installed", () => { + assert.deepEqual( + resolveInitAnswers(parseInitArgs(["--public-url", "https://t.example", "--roots", "~/work"]), { ...DEFAULTS, providers: [] }), + { usage: "both", providers: [], roots: ["~/work"], publicUrl: "https://t.example" }, + ); +}); diff --git a/src/onboarding.ts b/src/onboarding.ts index 2039854f0..d11cceba4 100644 --- a/src/onboarding.ts +++ b/src/onboarding.ts @@ -66,3 +66,115 @@ export function updateOnboardingSubagentsConfig( ], }; } + +/** + * `devspace init` options. Each flag answers one setup question; `--yes` takes + * the defaults for the rest. A public URL is what additionally serves an MCP host. + */ +export interface InitFlags { + force: boolean; + yes: boolean; + json: boolean; + providers?: string[]; + roots?: string[]; + publicUrl?: string; +} + +export const INIT_USAGE = + "Usage: devspace init [--yes] [--providers ] [--public-url ] [--roots ] [--force] [--json]"; + +const list = (value: string) => value.split(",").map((item) => item.trim()).filter(Boolean); + +export function parseInitArgs(args: readonly string[]): InitFlags { + const flags: InitFlags = { force: false, yes: false, json: false }; + + for (let i = 0; i < args.length; i++) { + const arg = args[i]!; + const equals = arg.indexOf("="); + const name = equals === -1 ? arg : arg.slice(0, equals); + const inline = equals === -1 ? undefined : arg.slice(equals + 1); + + const flag = () => { + if (inline !== undefined) throw new Error(`${name} takes no value.\n${INIT_USAGE}`); + + return true; + }; + + const value = () => { + const next = inline ?? args[++i]; + + if (next === undefined || next.startsWith("--")) throw new Error(`${name} needs a value.\n${INIT_USAGE}`); + + return next; + }; + + switch (name) { + case "--force": flags.force = flag(); break; + case "--yes": case "-y": flags.yes = flag(); break; + case "--json": flags.json = flag(); break; + case "--providers": flags.providers = list(value()); break; + case "--roots": flags.roots = list(value()); break; + case "--public-url": flags.publicUrl = value().trim(); break; + default: throw new Error(`Unknown option: ${arg}\n${INIT_USAGE}`); + } + } + + return flags; +} + +/** Setup runs without prompts when asked to, when given answers, or when nobody can answer. */ +export function isNonInteractiveInit(flags: InitFlags, interactiveTerminal: boolean): boolean { + return flags.yes || flags.json || !interactiveTerminal + || [flags.providers, flags.roots, flags.publicUrl].some((value) => value !== undefined); +} + +export interface InitAnswers { + usage: OnboardingUsage; + providers: string[]; + /** Raw root paths; set only when DevSpace serves an MCP host. */ + roots?: string[]; + /** Raw public URL; set only when DevSpace serves an MCP host. */ + publicUrl?: string; +} + +export interface InitDefaults { + providers: string[]; + knownProviders: string[]; + /** Configured roots only; an MCP host never gets a guessed root. */ + roots: string[]; + publicUrl: string | null; +} + +/** + * Fill unanswered flags with the defaults the prompts preselect. Subagents are + * always set up; a public URL, given or already saved, also serves an MCP host. + */ +export function resolveInitAnswers(flags: InitFlags, defaults: InitDefaults): InitAnswers { + const providers = flags.providers ?? defaults.providers; + const unknown = providers.filter((id) => !defaults.knownProviders.includes(id)); + + if (unknown.length > 0) { + throw new Error(`Unknown provider: ${unknown.join(", ")}. Known providers: ${defaults.knownProviders.join(", ")}.`); + } + + const publicUrl = flags.publicUrl ?? defaults.publicUrl; + + if (!publicUrl) { + if (flags.roots) throw new Error("--roots needs --public-url: project folders apply only when an MCP host connects."); + + if (providers.length === 0) { + throw new Error("No coding agents were detected. Install one, or pass --providers ."); + } + + return { usage: "coding-agents", providers }; + } + + // Roots bound what an MCP host may open, so they are never guessed from wherever init happened to run. + const roots = flags.roots ?? defaults.roots; + + if (roots.length === 0) { + throw new Error("--roots is required with --public-url: the project folders an MCP host may open."); + } + + return { usage: "both", providers, roots, publicUrl }; +} diff --git a/test/agents-replay/providers.ts b/test/agents-replay/providers.ts index 8edd29f4a..9e63fc66d 100644 --- a/test/agents-replay/providers.ts +++ b/test/agents-replay/providers.ts @@ -113,7 +113,9 @@ const jsonRpc: ReplayProtocol = { : "id" in frame ? "response" : "unknown", requestId: (frame: JsonObject) => isJsonString(frame.method) ? frame.id : undefined, responseTo: (frame: JsonObject) => isJsonString(frame.method) ? undefined : frame.id, - volatileKeys: new Set(["id", "sessionId", "threadId", "turnId", "itemId", "timestamp"]), + // `version` is DevSpace's own release in clientInfo (and the provider's in its reply): it changes every + // release and says nothing about the protocol, so a version bump must not invalidate recordings. + volatileKeys: new Set(["id", "sessionId", "threadId", "turnId", "itemId", "timestamp", "version"]), elasticKinds: new Set(), }; diff --git a/website/docs/01-quickstart.mdx b/website/docs/01-quickstart.mdx index ef473a4db..2da06091d 100644 --- a/website/docs/01-quickstart.mdx +++ b/website/docs/01-quickstart.mdx @@ -74,6 +74,28 @@ The installer brings its own Node when yours is missing or outside +## Let your agent set itself up + +A coding agent can do all of the above for itself. Paste this into Codex, +Claude Code, or any agent with a shell — every step runs without prompts: + +```text +Set up DevSpace (https://devspace.run) on this computer so you can delegate +coding work to my other coding agents. + +1. Install or update it: + curl -fsSL https://devspace.run/install | DEVSPACE_NO_INIT=1 sh +2. Configure it: devspace init --yes +3. Install the subagents skill for yourself: + devspace agents skill --install + (Cursor: devspace agents skill --install cursor --project, from the project root) +4. Check it works: devspace agents targets +``` + +The **Copy prompt** button on [devspace.run](https://devspace.run) copies a +version that also covers Windows. To answer setup questions with flags, see +[init options](/reference/cli#init-options). + ## Your first delegation From a project directory, ask your harness to delegate — or try the commands diff --git a/website/docs/reference/cli.mdx b/website/docs/reference/cli.mdx index 5c8122609..5fc522b79 100644 --- a/website/docs/reference/cli.mdx +++ b/website/docs/reference/cli.mdx @@ -8,7 +8,7 @@ description: Every devspace command — setup, the MCP server, subagents, workfl | Command | Description | | --- | --- | | `devspace` | Run first-time setup if needed, then start the server. | -| `devspace init [--force]` | Create or update `~/.devspace/config.jsonc` and `auth.json`. | +| `devspace init [options]` | Create or update `~/.devspace/config.jsonc` and `auth.json`. See [init options](#init-options). | | `devspace serve` | Start the MCP server on `127.0.0.1:7676`. | | `devspace doctor` | Show config, runtime, Git, Bash, and native dependency status. | | `devspace config get` | Print the persisted config. | @@ -17,6 +17,26 @@ description: Every devspace command — setup, the MCP server, subagents, workfl | `devspace show-changes [--json]` | Inspect a Git-backed review checkpoint. | | `devspace -v`, `--version` | Print the installed version. | +### init options + +In a terminal, `devspace init` asks its setup questions. Each flag answers one +of them, and `--yes` takes the defaults for the rest. Without a terminal, +`init` uses the defaults too. + +| Flag | Default | Meaning | +| --- | --- | --- | +| `--providers` | Configured providers, else every detected one | Comma-separated subagent providers, such as `codex,claude`. | +| `--public-url` | The saved public URL | Your tunnel's HTTPS origin, without `/mcp`. Setting one also serves an MCP host such as ChatGPT. | +| `--roots` | Configured roots | Project folders an MCP host may open. Needs a public URL, and is required with one unless roots are already configured. | +| `--yes`, `-y` | — | Use the defaults for every unanswered question. | +| `--force` | — | Update an existing setup. | +| `--json` | — | Print the result as JSON. With a public URL it includes the MCP URL and where the owner password is stored, never the password itself. | + +```bash +devspace init --yes +devspace init --public-url https://your-tunnel-host.example.com --roots ~/work +``` + ## Subagents Agent commands need no MCP server, OAuth, or tunnel. They run from the project diff --git a/website/islands/DelegationDiagram.tsx b/website/islands/DelegationDiagram.tsx index cf34ecd37..49418f48e 100644 --- a/website/islands/DelegationDiagram.tsx +++ b/website/islands/DelegationDiagram.tsx @@ -14,11 +14,13 @@ const ROW = 62; // px per host slot const TICKS_PER_STEP = 8; const TICK_MS = 550; const AGENT_IDS = ["7Kq2", "Q9m4", "Xb31", "Lp58", "Tn0d", "Ve66", "Rw29", "Zc74"]; + // Agent rows, matching `.dm__agents li` height and gap in routing.css, so the // fan's curves land on each row's centre. const AGENT_ROW = 38; const AGENT_GAP = 9; const FAN_HEIGHT = PROVIDERS.length * AGENT_ROW + (PROVIDERS.length - 1) * AGENT_GAP; + const fanCurve = (i: number) => { const y = i * (AGENT_ROW + AGENT_GAP) + AGENT_ROW / 2; const mid = FAN_HEIGHT / 2; diff --git a/website/landing/Shell.astro b/website/landing/Shell.astro index 1f6a7d235..cabf15d29 100644 --- a/website/landing/Shell.astro +++ b/website/landing/Shell.astro @@ -8,14 +8,18 @@ interface Props { } const SITE = "https://devspace.run"; + const { title = "DevSpace — let your agents delegate to each other", description = "Connect Grok Bot, ChatGPT, and Claude to your machine, and let any agent delegate to Codex, Claude Code, OpenCode, Cursor, and more.", bodyClass, } = Astro.props; + const url = new URL(Astro.url.pathname, SITE).href; + // Blume generates the home route's card; scripts/og-cards.mjs redraws it after the build. const image = `${SITE}/og/index.png`; + // Docs pages get Blume's WebSite and SoftwareApplication graph; the landing page // uses its own shell, so it declares the same identity here. const structuredData = { @@ -72,6 +76,8 @@ const structuredData = { + +

@@ -94,6 +100,14 @@ const structuredData = { target.dataset.copied = ""; setTimeout(() => delete target.dataset.copied, 1400); + + const status = document.querySelector("[data-copy-status]"); + + // Clear first so a repeat copy is a new change that screen readers announce again. + if (status) { + status.textContent = ""; + setTimeout(() => (status.textContent = "Copied to clipboard"), 50); + } }); @@ -104,4 +118,5 @@ const structuredData = { img, svg { display: block; max-width: 100%; } a { color: inherit; } button { font: inherit; color: inherit; } + .sr-only { position: absolute; width: 1px; height: 1px; margin: -1px; overflow: hidden; clip: rect(0 0 0 0); white-space: nowrap; border: 0; } diff --git a/website/landing/content.ts b/website/landing/content.ts index 954024766..2323d982a 100644 --- a/website/landing/content.ts +++ b/website/landing/content.ts @@ -9,6 +9,23 @@ export const INSTALL_METHODS = [ ] as const; export const INSTALL = INSTALL_METHODS[0].command; + +// Pasted into a coding agent, which sets DevSpace up itself; every step runs +// without prompts, so the agent never waits on the user. +export const AGENT_PROMPT = `Set up DevSpace (https://devspace.run) on this computer so you can delegate coding work to my other coding agents, such as Codex and Claude Code. + +1. Install or update it: + - macOS / Linux: curl -fsSL https://devspace.run/install | DEVSPACE_NO_INIT=1 sh + - Windows (PowerShell): $env:DEVSPACE_NO_INIT=1; irm https://devspace.run/install.ps1 | iex + If \`devspace\` is not on PATH afterwards, use ~/.local/bin/devspace (Windows: & "$env:LOCALAPPDATA\\devspace\\bin\\devspace.cmd"). +2. Configure it: devspace init --yes + If it says DevSpace is already configured, keep that setup. +3. Install the subagents skill for yourself: devspace agents skill --install + For Cursor, run devspace agents skill --install cursor --project from the project root. +4. Check it works: devspace agents targets + +Then tell me which agents you can delegate to. Docs: https://devspace.run/docs/quickstart`; + export const GITHUB = "https://github.com/Waishnav/devspace"; export const NPM = "https://www.npmjs.com/package/@waishnav/devspace"; export const DOCS = "/docs"; diff --git a/website/landing/home/Landing.astro b/website/landing/home/Landing.astro index 52a38936b..a55ecfb48 100644 --- a/website/landing/home/Landing.astro +++ b/website/landing/home/Landing.astro @@ -13,7 +13,7 @@ import InstallCommand from "./InstallCommand.astro"; import { Code } from "astro:components"; import { workbenchLightTheme } from "./theme"; import Logo from "../../logos/Logo.astro"; -import { DOCS, GITHUB, INSTALL, MATRIX, PILLARS, FAQ } from "../content"; +import { AGENT_PROMPT, DOCS, GITHUB, INSTALL, MATRIX, PILLARS, FAQ } from "../content"; const REVIEWER_PROFILE = `--- name: reviewer @@ -58,6 +58,12 @@ effort: high {INSTALL} + Read the docs → @@ -242,6 +248,13 @@ effort: high } .hero__right p { margin: 0 0 30px; max-width: 46ch; font-size: 14.5px; } .hero__cta { display: flex; flex-wrap: wrap; gap: 10px; } + /* Agent marks stacked like avatars, so the prompt button reads as "for your agent". */ + .prompt__logos { display: flex; } + .prompt__logo { + display: grid; place-items: center; width: 24px; height: 24px; border-radius: 50%; + background: #fff; border: 1px solid var(--line); box-shadow: 0 0 0 2px var(--paper); + } + .prompt__logo + .prompt__logo { margin-left: -7px; } .stage { position: relative; padding: 14px 14px 0; background: #ededea; } .stage__art { height: 846px; } diff --git a/website/landing/home/SkillsPopover.astro b/website/landing/home/SkillsPopover.astro index f3b9d5381..41864113c 100644 --- a/website/landing/home/SkillsPopover.astro +++ b/website/landing/home/SkillsPopover.astro @@ -7,11 +7,13 @@ import Logo from "../../logos/Logo.astro"; import type { LogoId } from "../../logos"; const SKILLS = "https://skills.sh/waishnav/devspace"; + const AGENTS: ReadonlyArray<{ id: LogoId; name: string }> = [ { id: "grok", name: "Grok Bot" }, { id: "openai", name: "ChatGPT" }, { id: "claude", name: "Claude" }, ]; + const COMMANDS = [ { label: "Subagents", note: undefined, command: "npx skills add Waishnav/devspace --skill subagents --global" }, { label: "Dynamic workflows", note: "experimental", command: "npx skills add Waishnav/devspace --skill workflows --global" }, diff --git a/website/scripts/og-cards.mjs b/website/scripts/og-cards.mjs index 91bc08d48..67191052b 100644 --- a/website/scripts/og-cards.mjs +++ b/website/scripts/og-cards.mjs @@ -25,7 +25,9 @@ const fonts = [ { name: "IBM Plex Mono", weight: 500, data: await font("@fontsource/ibm-plex-mono/files/ibm-plex-mono-latin-500-normal.woff2") }, { name: "Hanken Grotesk", data: await font("@fontsource-variable/hanken-grotesk/files/hanken-grotesk-latin-wght-normal.woff2") }, ]; + const mark = `data:image/png;base64,${(await readFile(join(root, "public/logos/devspace-blue.png"))).toString("base64")}`; + const github = `data:image/svg+xml;base64,${Buffer.from( ``, ).toString("base64")}`; @@ -77,6 +79,7 @@ const card = ({ title, description }) => const ENTITIES = { amp: "&", lt: "<", gt: ">", quot: '"', "#39": "'", "#x27": "'" }; const decode = (value) => value.replace(/&(amp|lt|gt|quot|#39|#x27);/g, (_, name) => ENTITIES[name]); + const meta = (html, property) => decode(html.match(new RegExp(` 0) url.searchParams.set("subpath", segments.join("/")); + // SAFETY: a shallow clone of an is an . const row = github.cloneNode() as HTMLAnchorElement; row.href = url.href;