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
428 changes: 275 additions & 153 deletions src/cli.ts

Large diffs are not rendered by default.

78 changes: 78 additions & 0 deletions src/onboarding.test.ts
Original file line number Diff line number Diff line change
@@ -1,6 +1,9 @@
import assert from "node:assert/strict";
import test from "node:test";
import {
isNonInteractiveInit,
parseInitArgs,
resolveInitAnswers,
resolveOnboardingUsage,
updateOnboardingSubagentsConfig,
} from "./onboarding.js";
Expand Down Expand Up @@ -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" },
);
});
112 changes: 112 additions & 0 deletions src/onboarding.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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 <id,...>] [--public-url <url>] [--roots <path,...>] [--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 = () => {
Comment thread
coderabbitai[bot] marked this conversation as resolved.
Resolved
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 <id,...>.");
}

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 };
}
4 changes: 3 additions & 1 deletion test/agents-replay/providers.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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<string>(),
};

Expand Down
22 changes: 22 additions & 0 deletions website/docs/01-quickstart.mdx
Original file line number Diff line number Diff line change
Expand Up @@ -74,6 +74,28 @@ The installer brings its own Node when yours is missing or outside
</Step>
</Steps>

## 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 <claude-code|codex|opencode|agents>
(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
Expand Down
22 changes: 21 additions & 1 deletion website/docs/reference/cli.mdx
Original file line number Diff line number Diff line change
Expand Up @@ -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. |
Expand All @@ -17,6 +17,26 @@ description: Every devspace command — setup, the MCP server, subagents, workfl
| `devspace show-changes <review-ref> [--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
Expand Down
2 changes: 2 additions & 0 deletions website/islands/DelegationDiagram.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -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;
Expand Down
15 changes: 15 additions & 0 deletions website/landing/Shell.astro
Original file line number Diff line number Diff line change
Expand Up @@ -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 = {
Expand Down Expand Up @@ -72,6 +76,8 @@ const structuredData = {
</head>
<body class={bodyClass}>
<slot />
<!-- Copy buttons only change their visible text, so announce the result here too. -->
<p class="sr-only" role="status" data-copy-status></p>
</body>
</html>

Expand All @@ -94,6 +100,14 @@ const structuredData = {

target.dataset.copied = "";
setTimeout(() => delete target.dataset.copied, 1400);

const status = document.querySelector<HTMLElement>("[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);
}
});
</script>

Expand All @@ -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; }
</style>
17 changes: 17 additions & 0 deletions website/landing/content.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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 <claude-code|codex|opencode|agents>
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`;
Comment thread
coderabbitai[bot] marked this conversation as resolved.
Resolved

export const GITHUB = "https://github.com/Waishnav/devspace";
export const NPM = "https://www.npmjs.com/package/@waishnav/devspace";
export const DOCS = "/docs";
Expand Down
Loading
Loading