Skip to content

Commit 7992389

Browse files
prosdevclaude
andcommitted
feat(cli): add dev setup command and ensureAntfly utility
Part 1.5 of antfly migration: - `dev setup` — one-time setup: detects Docker/native, starts server - Docker-first: pulls image, starts container on port 18080 - Native fallback: checks binary, pulls model, starts swarm - Offers to install if nothing found - `ensureAntfly()` — auto-starts server from any command - Checks if running → starts Docker container or native binary - Polls for readiness with timeout - All commands can call this before operations - Registered setup command in CLI entry point Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
1 parent 115eb76 commit 7992389

3 files changed

Lines changed: 309 additions & 0 deletions

File tree

packages/cli/src/cli.ts

Lines changed: 2 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -14,6 +14,7 @@ import { mapCommand } from './commands/map.js';
1414
import { mcpCommand } from './commands/mcp.js';
1515
import { planCommand } from './commands/plan.js';
1616
import { searchCommand } from './commands/search.js';
17+
import { setupCommand } from './commands/setup.js';
1718
import { statsCommand } from './commands/stats.js';
1819
import { storageCommand } from './commands/storage.js';
1920
import { updateCommand } from './commands/update.js';
@@ -45,6 +46,7 @@ program.addCommand(compactCommand);
4546
program.addCommand(cleanCommand);
4647
program.addCommand(storageCommand);
4748
program.addCommand(mcpCommand);
49+
program.addCommand(setupCommand);
4850

4951
// Show help if no command provided
5052
if (process.argv.length === 2) {

packages/cli/src/commands/setup.ts

Lines changed: 147 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,147 @@
1+
/**
2+
* dev setup — One-time setup for dev-agent's search backend
3+
*
4+
* Docker-first, native fallback. Handles installation, model download,
5+
* and server startup so users never need to run `antfly` directly.
6+
*/
7+
8+
import { execSync } from 'node:child_process';
9+
import * as readline from 'node:readline';
10+
import { Command } from 'commander';
11+
import ora from 'ora';
12+
import {
13+
ensureAntfly,
14+
getAntflyUrl,
15+
getNativeVersion,
16+
hasDocker,
17+
hasModel,
18+
hasNativeBinary,
19+
isServerReady,
20+
pullModel,
21+
} from '../utils/antfly.js';
22+
import { logger } from '../utils/logger.js';
23+
24+
const DEFAULT_MODEL = 'BAAI/bge-small-en-v1.5';
25+
26+
async function confirm(question: string): Promise<boolean> {
27+
const rl = readline.createInterface({ input: process.stdin, output: process.stdout });
28+
return new Promise((resolve) => {
29+
rl.question(`${question} (Y/n) `, (answer) => {
30+
rl.close();
31+
resolve(answer.toLowerCase() !== 'n');
32+
});
33+
});
34+
}
35+
36+
export const setupCommand = new Command('setup')
37+
.description('One-time setup: install search backend and embedding model')
38+
.option('--model <name>', 'Termite embedding model', DEFAULT_MODEL)
39+
.action(async (options) => {
40+
const model = options.model as string;
41+
const spinner = ora();
42+
43+
try {
44+
// ── Step 1: Check runtime ──
45+
if (hasDocker()) {
46+
logger.info('Docker found');
47+
48+
// Check if server is already running
49+
if (await isServerReady()) {
50+
logger.info('Antfly server already running');
51+
logger.log("\n Nothing to do — you're all set!\n");
52+
logger.log(' Next steps:');
53+
logger.log(' dev index . Index your repository');
54+
logger.log(' dev mcp install --cursor Connect to Cursor\n');
55+
return;
56+
}
57+
58+
// Pull image and start
59+
spinner.start('Pulling Antfly image...');
60+
try {
61+
execSync(`docker pull --platform linux/amd64 ${getDockerImage()}`, { stdio: 'pipe' });
62+
spinner.succeed('Antfly image ready');
63+
} catch {
64+
spinner.succeed('Antfly image available');
65+
}
66+
67+
spinner.start('Starting Antfly server...');
68+
await ensureAntfly({ quiet: true });
69+
spinner.succeed(`Antfly running on ${getAntflyUrl()}`);
70+
} else if (hasNativeBinary()) {
71+
// ── Native fallback ──
72+
const version = getNativeVersion();
73+
logger.info(`Antfly ${version} found (native)`);
74+
logger.info('Docker not found — using native binary');
75+
76+
// Check if server is already running
77+
if (await isServerReady()) {
78+
logger.info('Antfly server already running');
79+
} else {
80+
// Pull embedding model (Docker image bundles models, native needs manual pull)
81+
if (!hasModel(model)) {
82+
spinner.start(`Pulling embedding model: ${model}...`);
83+
pullModel(model);
84+
spinner.succeed(`Embedding model ready: ${model}`);
85+
} else {
86+
logger.info(`Embedding model ready: ${model}`);
87+
}
88+
89+
spinner.start('Starting Antfly server...');
90+
await ensureAntfly({ quiet: true });
91+
spinner.succeed(`Antfly running on ${getAntflyUrl()}`);
92+
}
93+
} else {
94+
// ── Nothing installed ──
95+
const platform = process.platform;
96+
const installCmd =
97+
platform === 'darwin'
98+
? 'brew install --cask antflydb/antfly/antfly'
99+
: 'curl -fsSL https://releases.antfly.io/antfly/latest/install.sh | sh -s -- --omni';
100+
101+
if (hasDocker === undefined) {
102+
// This shouldn't happen but just in case
103+
logger.error('No runtime found.');
104+
}
105+
106+
const shouldInstall = await confirm('\nAntfly is not installed. Install it now?');
107+
108+
if (shouldInstall) {
109+
spinner.start(
110+
`Installing via ${platform === 'darwin' ? 'Homebrew' : 'install script'}...`
111+
);
112+
execSync(installCmd, { stdio: 'inherit' });
113+
spinner.succeed('Antfly installed');
114+
115+
// Pull model and start
116+
if (!hasModel(model)) {
117+
spinner.start(`Pulling embedding model: ${model}...`);
118+
pullModel(model);
119+
spinner.succeed(`Embedding model ready: ${model}`);
120+
}
121+
122+
spinner.start('Starting Antfly server...');
123+
await ensureAntfly({ quiet: true });
124+
spinner.succeed(`Antfly running on ${getAntflyUrl()}`);
125+
} else {
126+
logger.log('\nInstall manually, then run `dev setup` again:');
127+
logger.log(` Docker: https://docker.com/get-started`);
128+
logger.log(` Native: ${installCmd}\n`);
129+
return;
130+
}
131+
}
132+
133+
// ── Success ──
134+
logger.log('\n Setup complete!\n');
135+
logger.log(' Next steps:');
136+
logger.log(' dev index . Index your repository');
137+
logger.log(' dev mcp install --cursor Connect to Cursor\n');
138+
} catch (error) {
139+
spinner.fail('Setup failed');
140+
logger.error(error instanceof Error ? error.message : String(error));
141+
process.exit(1);
142+
}
143+
});
144+
145+
function getDockerImage(): string {
146+
return 'ghcr.io/antflydb/antfly:latest';
147+
}

packages/cli/src/utils/antfly.ts

Lines changed: 160 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,160 @@
1+
/**
2+
* Antfly server lifecycle management
3+
*
4+
* Docker-first, native fallback. The user never needs to run `antfly` directly.
5+
*/
6+
7+
import { execSync, spawn } from 'node:child_process';
8+
import { logger } from './logger.js';
9+
10+
const DEFAULT_ANTFLY_URL = process.env.ANTFLY_URL ?? 'http://localhost:18080/api/v1';
11+
const CONTAINER_NAME = 'dev-agent-antfly';
12+
const DOCKER_IMAGE = 'ghcr.io/antflydb/antfly:latest';
13+
const DOCKER_PORT = 18080;
14+
const STARTUP_TIMEOUT_MS = 30_000;
15+
const POLL_INTERVAL_MS = 500;
16+
17+
/**
18+
* Ensure antfly is running. Auto-starts if needed.
19+
*
20+
* Priority: Docker container → native binary → error with guidance.
21+
*/
22+
export async function ensureAntfly(options?: { quiet?: boolean }): Promise<string> {
23+
const url = getAntflyUrl();
24+
25+
// 1. Already running?
26+
if (await isServerReady(url)) {
27+
return url;
28+
}
29+
30+
// 2. Try Docker first
31+
if (hasDocker()) {
32+
if (isContainerExists()) {
33+
if (!options?.quiet) logger.info('Starting Antfly container...');
34+
execSync(`docker start ${CONTAINER_NAME}`, { stdio: 'pipe' });
35+
} else {
36+
if (!options?.quiet) logger.info('Starting Antfly via Docker...');
37+
execSync(
38+
`docker run -d --name ${CONTAINER_NAME} -p ${DOCKER_PORT}:8080 --platform linux/amd64 ${DOCKER_IMAGE} swarm`,
39+
{ stdio: 'pipe' }
40+
);
41+
}
42+
43+
await waitForServer(url);
44+
if (!options?.quiet) logger.info(`Antfly running on ${url}`);
45+
return url;
46+
}
47+
48+
// 3. Native fallback
49+
if (hasNativeBinary()) {
50+
if (!options?.quiet) logger.info('Starting Antfly server...');
51+
const child = spawn('antfly', ['swarm'], {
52+
detached: true,
53+
stdio: 'ignore',
54+
});
55+
child.unref();
56+
57+
await waitForServer(url);
58+
if (!options?.quiet) logger.info(`Antfly running on ${url}`);
59+
return url;
60+
}
61+
62+
// 4. Nothing available
63+
throw new Error(
64+
'Antfly is not installed. Run `dev setup` to install, or:\n' +
65+
' Docker: docker pull ghcr.io/antflydb/antfly:latest\n' +
66+
' Native: brew install --cask antflydb/antfly/antfly'
67+
);
68+
}
69+
70+
export function getAntflyUrl(): string {
71+
return process.env.ANTFLY_URL ?? DEFAULT_ANTFLY_URL;
72+
}
73+
74+
export function hasDocker(): boolean {
75+
try {
76+
execSync('docker info', { stdio: 'pipe', timeout: 5000 });
77+
return true;
78+
} catch {
79+
return false;
80+
}
81+
}
82+
83+
export function hasNativeBinary(): boolean {
84+
try {
85+
execSync('antfly --version', { stdio: 'pipe', timeout: 5000 });
86+
return true;
87+
} catch {
88+
return false;
89+
}
90+
}
91+
92+
export function isContainerExists(): boolean {
93+
try {
94+
const result = execSync(`docker ps -a --filter name=${CONTAINER_NAME} --format "{{.Names}}"`, {
95+
encoding: 'utf-8',
96+
stdio: ['pipe', 'pipe', 'pipe'],
97+
});
98+
return result.trim() === CONTAINER_NAME;
99+
} catch {
100+
return false;
101+
}
102+
}
103+
104+
export async function isServerReady(url?: string): Promise<boolean> {
105+
const baseUrl = (url ?? getAntflyUrl()).replace('/api/v1', '');
106+
try {
107+
const resp = await fetch(`${baseUrl}/api/v1/tables`, { signal: AbortSignal.timeout(3000) });
108+
return resp.ok;
109+
} catch {
110+
return false;
111+
}
112+
}
113+
114+
async function waitForServer(url: string): Promise<void> {
115+
const start = Date.now();
116+
while (Date.now() - start < STARTUP_TIMEOUT_MS) {
117+
if (await isServerReady(url)) return;
118+
await new Promise((r) => setTimeout(r, POLL_INTERVAL_MS));
119+
}
120+
throw new Error(
121+
`Antfly server did not start within ${STARTUP_TIMEOUT_MS / 1000}s. Check: docker logs ${CONTAINER_NAME}`
122+
);
123+
}
124+
125+
/**
126+
* Get the antfly version (native binary).
127+
*/
128+
export function getNativeVersion(): string | null {
129+
try {
130+
return execSync('antfly --version', {
131+
encoding: 'utf-8',
132+
stdio: ['pipe', 'pipe', 'pipe'],
133+
}).trim();
134+
} catch {
135+
return null;
136+
}
137+
}
138+
139+
/**
140+
* Pull a Termite embedding model (native binary only).
141+
*/
142+
export function pullModel(model: string): void {
143+
execSync(`antfly termite pull ${model}`, { stdio: 'inherit' });
144+
}
145+
146+
/**
147+
* Check if a Termite model is available locally (native binary only).
148+
*/
149+
export function hasModel(model: string): boolean {
150+
try {
151+
const output = execSync('antfly termite list', {
152+
encoding: 'utf-8',
153+
stdio: ['pipe', 'pipe', 'pipe'],
154+
});
155+
const shortName = model.split('/').pop() ?? model;
156+
return output.includes(shortName);
157+
} catch {
158+
return false;
159+
}
160+
}

0 commit comments

Comments
 (0)