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
7 changes: 7 additions & 0 deletions README.md
Original file line number Diff line number Diff line change
Expand Up @@ -305,8 +305,15 @@ You can use Open Design without ever opening the GUI — call it as a skill, plu
od mcp install <agent>
# <agent> = claude | codex | cursor | copilot | openclaw | antigravity | gemini
# | pi | vibe | hermes | cline | kimi | trae | opencode

# Hosted equivalent for curl-based setup:
curl -fsSL https://open-design.ai/install.sh | sh -s <agent>
```

`install.sh` is a thin shell wrapper around `od mcp install`; it exists so the
hosted URL returns shell instead of the landing-page HTML fallback and fails
fast if your shell resolves a non-Open-Design `od` binary.

> **WSL2 users:** If your coding-agent CLIs run inside WSL2, follow the
> [`WSL2 setup guide`](docs/wsl-setup.md) first. Linux's `/usr/bin/od` can
> shadow Open Design's `od` command.
Expand Down
7 changes: 7 additions & 0 deletions apps/daemon/src/cli.ts
Original file line number Diff line number Diff line change
Expand Up @@ -88,9 +88,12 @@ const MCP_INSTALL_STRING_FLAGS = new Set([
'daemon-url',
'name',
]);
const MCP_INSTALL_CLI_PROBE_FLAG = 'open-design-cli-probe';
const MCP_INSTALL_CLI_PROBE_TOKEN = 'open-design-cli:mcp-install:v1';
const MCP_INSTALL_BOOLEAN_FLAGS = new Set([
'help',
'h',
MCP_INSTALL_CLI_PROBE_FLAG,
'json',
'print',
'dry-run',
Expand Down Expand Up @@ -1220,6 +1223,10 @@ async function runMcpInstall(args) {
printMcpInstallHelp();
process.exit(2);
}
if (flags[MCP_INSTALL_CLI_PROBE_FLAG]) {
console.log(MCP_INSTALL_CLI_PROBE_TOKEN);
return;
}
if (flags.help || flags.h) {
printMcpInstallHelp();
return;
Expand Down
43 changes: 43 additions & 0 deletions apps/daemon/tests/mcp-install-cli.test.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,43 @@
import { execFile } from 'node:child_process';
import { dirname, resolve as pathResolve } from 'node:path';
import { fileURLToPath } from 'node:url';
import { promisify } from 'node:util';
import { describe, expect, it } from 'vitest';

const execFileP = promisify(execFile);
const __dirname = dirname(fileURLToPath(import.meta.url));
const DAEMON_ROOT = pathResolve(__dirname, '..');
const REPO_ROOT = pathResolve(__dirname, '../../..');
const CLI_SRC = pathResolve(__dirname, '../src/cli.ts');
const TSX_CLI = pathResolve(REPO_ROOT, 'node_modules/tsx/dist/cli.mjs');

async function runCli(args: string[]): Promise<{ stdout: string; stderr: string; code: number | null }> {
const env: NodeJS.ProcessEnv = { ...process.env };
delete env.NODE_OPTIONS;
try {
const { stdout, stderr } = await execFileP(process.execPath, [TSX_CLI, CLI_SRC, ...args], {
cwd: DAEMON_ROOT,
env,
timeout: 15_000,
maxBuffer: 4 * 1024 * 1024,
});
return { stdout, stderr, code: 0 };
} catch (err) {
const failed = err as { stdout?: string; stderr?: string; code?: number | null };
return {
stdout: failed.stdout ?? '',
stderr: failed.stderr ?? '',
code: failed.code ?? 1,
};
}
}

describe('od mcp install CLI identity probe', () => {
it('emits a stable identity token without requiring an agent slug', async () => {
const result = await runCli(['mcp', 'install', '--open-design-cli-probe']);

expect(result.code).toBe(0);
expect(result.stderr).toBe('');
expect(result.stdout).toBe('open-design-cli:mcp-install:v1\n');
});
});
70 changes: 70 additions & 0 deletions apps/landing-page/public/install.sh
Original file line number Diff line number Diff line change
@@ -0,0 +1,70 @@
#!/usr/bin/env sh
# Open Design MCP installer wrapper.
#
# This file is served verbatim by the static landing page at:
# https://open-design.ai/install.sh
#
# It intentionally delegates to the product-owned installer:
# od mcp install <agent>
#
# Keeping the real installer in the daemon avoids duplicating the per-agent
# config planner in a hosted shell script. This wrapper exists so curl|sh no
# longer receives the landing-page HTML fallback, and so users get a clear
# error when the shell resolves /usr/bin/od or another non-Open-Design binary.

set -eu

usage() {
cat <<'EOF'
Open Design MCP installer

Usage:
curl -fsSL https://open-design.ai/install.sh | sh -s <agent> [options]

This is a thin hosted wrapper around:
od mcp install <agent> [options]

Examples:
curl -fsSL https://open-design.ai/install.sh | sh -s codex --print
curl -fsSL https://open-design.ai/install.sh | sh -s cursor --write-config

Options are forwarded to `od mcp install`. For the complete option list:
od mcp install --help
EOF
}

if [ "${1:-}" = "--help" ] || [ "${1:-}" = "-h" ]; then
usage
exit 0
fi

if [ "$#" -eq 0 ]; then
printf '%s\n\n' "Open Design install.sh: missing required <agent> argument." >&2
usage >&2
exit 2
fi

if ! command -v od >/dev/null 2>&1; then
cat >&2 <<'EOF'
Open Design install.sh: `od` was not found on PATH.

Install and open the Open Design desktop app, or run the daemon from a source
checkout so the Open Design CLI is available, then re-run this command.
EOF
exit 1
fi

od_probe="$(od mcp install --open-design-cli-probe 2>/dev/null || true)"
if [ "${od_probe}" != "open-design-cli:mcp-install:v1" ]; then
od_path="$(command -v od || true)"
cat >&2 <<EOF
Open Design install.sh: '${od_path}' does not look like the Open Design CLI.

On Linux and WSL2, /usr/bin/od is usually the coreutils octal-dump command and
can shadow Open Design's CLI. Put the Open Design CLI earlier on PATH, then
re-run this command.
EOF
exit 1
fi

exec od mcp install "$@"
114 changes: 114 additions & 0 deletions apps/landing-page/tests/install-sh-static.test.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,114 @@
import assert from 'node:assert/strict';
import { mkdtempSync, rmSync, writeFileSync, chmodSync, readFileSync } from 'node:fs';
import { tmpdir } from 'node:os';
import { join, resolve } from 'node:path';
import { spawnSync } from 'node:child_process';
import { test } from 'node:test';

const repoRoot = resolve(import.meta.dirname, '../../..');
const installScript = join(repoRoot, 'apps/landing-page/public/install.sh');

function runInstall(args: string[], pathDir: string) {
return spawnSync('sh', [installScript, ...args], {
encoding: 'utf8',
env: {
...process.env,
PATH: `${pathDir}${process.platform === 'win32' ? ';' : ':'}${process.env.PATH ?? ''}`,
},
});
}

test('landing page serves install.sh as a shell script, not the HTML app fallback', () => {
const body = readFileSync(installScript, 'utf8');

assert.match(body, /^#!\/usr\/bin\/env sh\n/);
assert.match(body, /od mcp install/);
assert.doesNotMatch(body, /<!doctype html/i);
assert.doesNotMatch(body, /<html/i);
});

test('install.sh delegates to the Open Design CLI installer with the requested agent', () => {
const tmp = mkdtempSync(join(tmpdir(), 'od-install-sh-'));
const argvOut = join(tmp, 'argv.txt');
const fakeOd = join(tmp, 'od');
writeFileSync(
fakeOd,
`#!/bin/sh
if [ "$1" = "mcp" ] && [ "$2" = "install" ] && [ "$3" = "--help" ]; then
printf '%s\\n' 'Usage text intentionally does not define CLI identity.'
exit 0
fi
if [ "$1" = "mcp" ] && [ "$2" = "install" ] && [ "$3" = "--open-design-cli-probe" ]; then
printf '%s\\n' 'open-design-cli:mcp-install:v1'
exit 0
fi
printf '%s\\n' "$@" > "${argvOut}"
`,
'utf8',
);
chmodSync(fakeOd, 0o755);

try {
const result = runInstall(['codex', '--print'], tmp);

assert.equal(result.status, 0, result.stderr);
assert.equal(readFileSync(argvOut, 'utf8'), 'mcp\ninstall\ncodex\n--print\n');
} finally {
rmSync(tmp, { recursive: true, force: true });
}
});

test('install.sh rejects a shadowed od binary even when its help exits successfully', () => {
const tmp = mkdtempSync(join(tmpdir(), 'od-install-sh-shadow-success-'));
const argvOut = join(tmp, 'argv.txt');
const fakeOd = join(tmp, 'od');
writeFileSync(
fakeOd,
`#!/bin/sh
if [ "$3" = "--help" ]; then
printf '%s\\n' 'Usage: od [OPTION]... [FILE]...'
exit 0
fi
if [ "$3" = "--open-design-cli-probe" ]; then
printf '%s\\n' 'Usage: od [OPTION]... [FILE]...'
exit 0
fi
printf '%s\\n' "$@" > "${argvOut}"
exit 0
`,
'utf8',
);
chmodSync(fakeOd, 0o755);

try {
const result = runInstall(['cursor'], tmp);

assert.equal(result.status, 1);
assert.match(result.stderr, /does not look like the Open Design CLI/);
assert.throws(() => readFileSync(argvOut, 'utf8'), /ENOENT/);
} finally {
rmSync(tmp, { recursive: true, force: true });
}
});

test('install.sh rejects a non-Open-Design od binary instead of calling coreutils od', () => {
const tmp = mkdtempSync(join(tmpdir(), 'od-install-sh-shadow-'));
const fakeOd = join(tmp, 'od');
writeFileSync(
fakeOd,
`#!/bin/sh
exit 1
`,
'utf8',
);
chmodSync(fakeOd, 0o755);

try {
const result = runInstall(['cursor'], tmp);

assert.equal(result.status, 1);
assert.match(result.stderr, /does not look like the Open Design CLI/);
} finally {
rmSync(tmp, { recursive: true, force: true });
}
});
Loading