Merge pull request #2030 from evenisse/feat/onecli-remote

v2: feat(setup): add remote OneCLI option in setup flow
This commit is contained in:
gavrielc
2026-04-27 00:11:18 +03:00
committed by GitHub
2 changed files with 162 additions and 105 deletions

View File

@@ -38,10 +38,8 @@ import { brightSelect } from './lib/bright-select.js';
import { offerClaudeAssist } from './lib/claude-assist.js'; import { offerClaudeAssist } from './lib/claude-assist.js';
import { runWindowedStep } from './lib/windowed-runner.js'; import { runWindowedStep } from './lib/windowed-runner.js';
import { getLaunchdLabel, getSystemdUnit } from '../src/install-slug.js'; import { getLaunchdLabel, getSystemdUnit } from '../src/install-slug.js';
import { import { pollHealth } from './onecli.js';
claudeCliAvailable, import { claudeCliAvailable, resolveTimezoneViaClaude } from './lib/tz-from-claude.js';
resolveTimezoneViaClaude,
} from './lib/tz-from-claude.js';
import * as setupLog from './logs.js'; import * as setupLog from './logs.js';
import { ensureAnswer, fail, runQuietChild, runQuietStep } from './lib/runner.js'; import { ensureAnswer, fail, runQuietChild, runQuietStep } from './lib/runner.js';
import { emit as phEmit } from './lib/diagnostics.js'; import { emit as phEmit } from './lib/diagnostics.js';
@@ -51,15 +49,7 @@ import { isValidTimezone } from '../src/timezone.js';
const CLI_AGENT_NAME = 'Terminal Agent'; const CLI_AGENT_NAME = 'Terminal Agent';
const RUN_START = Date.now(); const RUN_START = Date.now();
type ChannelChoice = type ChannelChoice = 'telegram' | 'discord' | 'whatsapp' | 'signal' | 'teams' | 'slack' | 'imessage' | 'skip';
| 'telegram'
| 'discord'
| 'whatsapp'
| 'signal'
| 'teams'
| 'slack'
| 'imessage'
| 'skip';
async function main(): Promise<void> { async function main(): Promise<void> {
printIntro(); printIntro();
@@ -88,12 +78,7 @@ async function main(): Promise<void> {
} }
if (!skip.has('container')) { if (!skip.has('container')) {
p.log.message( p.log.message(dimWrap('Your assistant lives in its own sandbox. It can only see what you explicitly share.', 4));
dimWrap(
'Your assistant lives in its own sandbox. It can only see what you explicitly share.',
4,
),
);
p.log.message( p.log.message(
dimWrap( dimWrap(
'The first build pulls a base image and installs a few tools. On a fresh machine this usually takes 310 minutes.', 'The first build pulls a base image and installs a few tools. On a fresh machine this usually takes 310 minutes.',
@@ -138,45 +123,96 @@ async function main(): Promise<void> {
), ),
); );
// Respect an existing OneCLI install. Re-running the installer would type OnecliChoice = 'reuse' | 'fresh' | 'remote';
// rebind the listener and knock any other app using that gateway
// offline — confirm with the user before doing that.
const existing = detectExistingOnecli(); const existing = detectExistingOnecli();
let reuse = false; const onecliOptions: { value: OnecliChoice; label: string; hint?: string }[] = [
if (existing) { ...(existing
const choice = ensureAnswer( ? [
await brightSelect({
message: `Found an existing OneCLI at ${existing.apiHost}. What would you like to do?`,
options: [
{ {
value: 'reuse', value: 'reuse' as OnecliChoice,
label: 'Use the existing instance', label: 'Use the existing instance on the same host',
hint: 'recommended — keeps other apps bound to this vault working', hint: 'recommended — keeps other apps bound to this vault working',
}, },
{ ]
value: 'fresh', : []),
label: 'Install a fresh instance for NanoClaw', {
hint: 'reinstalls onecli; other apps may need to reconnect', value: 'fresh',
label: 'Install a fresh instance for NanoClaw',
hint: existing ? 'reinstalls onecli; other apps may need to reconnect' : 'recommended',
},
{
value: 'remote',
label: 'Connect to an OneCLI on another host',
hint: 'point to a remote URL',
},
];
const onecliChoice = ensureAnswer(
await brightSelect<OnecliChoice>({
message: existing
? `Found an existing OneCLI at ${existing.apiHost}. What would you like to do?`
: 'How would you like to set up OneCLI?',
options: onecliOptions,
}),
) as OnecliChoice;
setupLog.userInput('onecli_choice', onecliChoice);
let remoteUrl: string | undefined;
if (onecliChoice === 'remote') {
while (true) {
const answer = ensureAnswer(
await p.text({
message: 'OneCLI URL on the remote machine',
placeholder: 'http://192.168.1.10:10254',
validate: (v) => {
const t = (v ?? '').trim();
if (!t) return 'Required';
if (!/^https?:\/\//i.test(t)) return 'Must start with http:// or https://';
return undefined;
}, },
], }),
}), );
) as 'reuse' | 'fresh'; remoteUrl = (answer as string).trim();
setupLog.userInput('onecli_choice', choice); setupLog.userInput('onecli_remote_url', remoteUrl);
reuse = choice === 'reuse';
const s = p.spinner();
s.start('Checking remote OneCLI…');
const healthy = await pollHealth(remoteUrl, 5000);
if (healthy) {
s.stop('Remote OneCLI is reachable.');
break;
}
s.stop(`Couldn't reach OneCLI at ${remoteUrl}.`, 1);
p.log.warn(wrapForGutter('Make sure OneCLI is running and accessible from this machine, then try again.', 4));
}
} }
const stepArgs =
onecliChoice === 'reuse' ? ['--reuse'] : onecliChoice === 'remote' ? ['--remote-url', remoteUrl!] : [];
const res = await runQuietStep( const res = await runQuietStep(
'onecli', 'onecli',
{ {
running: reuse running:
? 'Hooking up to your existing OneCLI…' onecliChoice === 'reuse'
: "Setting up OneCLI, your agent's vault…", ? 'Hooking up to your existing OneCLI…'
: onecliChoice === 'remote'
? `Connecting to remote OneCLI at ${remoteUrl}`
: "Setting up OneCLI, your agent's vault…",
done: 'OneCLI vault ready.', done: 'OneCLI vault ready.',
}, },
reuse ? ['--reuse'] : [], stepArgs,
); );
if (!res.ok) { if (!res.ok) {
const err = res.terminal?.fields.ERROR; const err = res.terminal?.fields.ERROR;
if (onecliChoice === 'remote') {
await fail(
'onecli',
`Couldn't connect to remote OneCLI (${err ?? 'unknown error'}).`,
'Check the URL and that OneCLI is running on the remote machine, then retry.',
);
}
if (err === 'onecli_not_on_path_after_install') { if (err === 'onecli_not_on_path_after_install') {
await fail( await fail(
'onecli', 'onecli',
@@ -217,19 +253,12 @@ async function main(): Promise<void> {
done: 'NanoClaw is running.', done: 'NanoClaw is running.',
}); });
if (!res.ok) { if (!res.ok) {
await fail( await fail('service', "Couldn't start NanoClaw.", 'See logs/nanoclaw.error.log for details.');
'service',
"Couldn't start NanoClaw.",
'See logs/nanoclaw.error.log for details.',
);
} }
if (res.terminal?.fields.DOCKER_GROUP_STALE === 'true') { if (res.terminal?.fields.DOCKER_GROUP_STALE === 'true') {
p.log.warn( p.log.warn("NanoClaw's permissions need a tweak before it can reach Docker.");
"NanoClaw's permissions need a tweak before it can reach Docker.",
);
p.log.message( p.log.message(
' sudo setfacl -m u:$(whoami):rw /var/run/docker.sock\n' + ' sudo setfacl -m u:$(whoami):rw /var/run/docker.sock\n' + ` systemctl --user restart ${getSystemdUnit()}`,
` systemctl --user restart ${getSystemdUnit()}`,
); );
} }
} }
@@ -294,7 +323,7 @@ async function main(): Promise<void> {
msg: msg:
ping === 'socket_error' ping === 'socket_error'
? "NanoClaw service isn't listening on its CLI socket." ? "NanoClaw service isn't listening on its CLI socket."
: "No reply from the assistant within 30 seconds.", : 'No reply from the assistant within 30 seconds.',
hint: hint:
ping === 'socket_error' ping === 'socket_error'
? 'Socket at data/cli.sock did not accept a connection.' ? 'Socket at data/cli.sock did not accept a connection.'
@@ -344,7 +373,7 @@ async function main(): Promise<void> {
if (!res.ok) { if (!res.ok) {
const notes: string[] = []; const notes: string[] = [];
if (res.terminal?.fields.CREDENTIALS !== 'configured') { if (res.terminal?.fields.CREDENTIALS !== 'configured') {
notes.push('• Your Claude account isn\'t connected. Re-run setup and try again.'); notes.push("• Your Claude account isn't connected. Re-run setup and try again.");
} }
const service = res.terminal?.fields.SERVICE; const service = res.terminal?.fields.SERVICE;
if (service === 'running_other_checkout') { if (service === 'running_other_checkout') {
@@ -370,7 +399,9 @@ async function main(): Promise<void> {
} }
} }
if (!res.terminal?.fields.CONFIGURED_CHANNELS) { if (!res.terminal?.fields.CONFIGURED_CHANNELS) {
notes.push('• Want to chat from your phone? Add a messaging app with `/add-telegram`, `/add-slack`, or `/add-discord`.'); notes.push(
'• Want to chat from your phone? Add a messaging app with `/add-telegram`, `/add-slack`, or `/add-discord`.',
);
} }
if (notes.length > 0) { if (notes.length > 0) {
p.note(notes.join('\n'), "What's left"); p.note(notes.join('\n'), "What's left");
@@ -404,9 +435,7 @@ async function main(): Promise<void> {
['Open Claude Code:', 'claude'], ['Open Claude Code:', 'claude'],
]; ];
const labelWidth = Math.max(...rows.map(([l]) => l.length)); const labelWidth = Math.max(...rows.map(([l]) => l.length));
const nextSteps = rows const nextSteps = rows.map(([l, c]) => `${k.cyan(l.padEnd(labelWidth))} ${c}`).join('\n');
.map(([l, c]) => `${k.cyan(l.padEnd(labelWidth))} ${c}`)
.join('\n');
p.note(nextSteps, 'Try these'); p.note(nextSteps, 'Try these');
// Always-on warning goes before the "check your DMs" directive so the // Always-on warning goes before the "check your DMs" directive so the
@@ -428,10 +457,7 @@ async function main(): Promise<void> {
// that the welcome-message signal was too easy to miss. Use p.note so it // that the welcome-message signal was too easy to miss. Use p.note so it
// renders with a visible box, cyan-bold the directive line, and put it // renders with a visible box, cyan-bold the directive line, and put it
// as the last thing before outro. // as the last thing before outro.
p.note( p.note(`${brandBold('→')} ${k.bold(`Check your ${dmTarget} — your assistant is saying hi.`)}`, 'Go say hi');
`${brandBold('→')} ${k.bold(`Check your ${dmTarget} — your assistant is saying hi.`)}`,
'Go say hi',
);
p.outro(k.green("You're set.")); p.outro(k.green("You're set."));
} else { } else {
p.outro(k.green("You're ready! Chat with `pnpm run chat hi`.")); p.outro(k.green("You're ready! Chat with `pnpm run chat hi`."));
@@ -491,9 +517,7 @@ async function confirmAssistantResponds(): Promise<PingResult> {
s.stop(`${k.bold(fitToWidth('Your assistant is ready.', suffix))}${k.dim(suffix)}`); s.stop(`${k.bold(fitToWidth('Your assistant is ready.', suffix))}${k.dim(suffix)}`);
} else { } else {
const msg = const msg =
result === 'socket_error' result === 'socket_error' ? "Couldn't reach the NanoClaw service." : "Your assistant didn't reply in time.";
? "Couldn't reach the NanoClaw service."
: "Your assistant didn't reply in time.";
s.stop(`${k.bold(fitToWidth(msg, suffix))}${k.dim(suffix)}`, 1); s.stop(`${k.bold(fitToWidth(msg, suffix))}${k.dim(suffix)}`, 1);
} }
return result; return result;
@@ -549,9 +573,7 @@ async function runFirstChat(): Promise<void> {
message: first message: first
? 'Try a quick hello — or press Enter to continue setup' ? 'Try a quick hello — or press Enter to continue setup'
: 'Another message? Press Enter to continue setup', : 'Another message? Press Enter to continue setup',
placeholder: first placeholder: first ? 'e.g. "hi, what can you do?"' : 'press Enter to continue',
? 'e.g. "hi, what can you do?"'
: 'press Enter to continue',
}), }),
); );
first = false; first = false;
@@ -567,11 +589,9 @@ function sendChatMessage(message: string): Promise<void> {
// agent's reply reads as a clean block under the prompt. Splitting on // agent's reply reads as a clean block under the prompt. Splitting on
// whitespace mirrors `pnpm run chat hello world` — chat.ts joins argv // whitespace mirrors `pnpm run chat hello world` — chat.ts joins argv
// with spaces on the far side. // with spaces on the far side.
const child = spawn( const child = spawn('pnpm', ['--silent', 'run', 'chat', ...message.split(/\s+/)], {
'pnpm', stdio: ['ignore', 'inherit', 'inherit'],
['--silent', 'run', 'chat', ...message.split(/\s+/)], });
{ stdio: ['ignore', 'inherit', 'inherit'] },
);
child.on('close', () => resolve()); child.on('close', () => resolve());
child.on('error', () => resolve()); child.on('error', () => resolve());
}); });
@@ -619,15 +639,11 @@ async function runAuthStep(): Promise<void> {
} }
async function runSubscriptionAuth(): Promise<void> { async function runSubscriptionAuth(): Promise<void> {
p.log.step("Opening the Claude sign-in flow…"); p.log.step('Opening the Claude sign-in flow…');
console.log( console.log(k.dim(' (a browser will open for sign-in; this part is interactive)'));
k.dim(' (a browser will open for sign-in; this part is interactive)'),
);
console.log(); console.log();
const start = Date.now(); const start = Date.now();
const code = await runInheritScript('bash', [ const code = await runInheritScript('bash', ['setup/register-claude-token.sh']);
'setup/register-claude-token.sh',
]);
const durationMs = Date.now() - start; const durationMs = Date.now() - start;
console.log(); console.log();
if (code !== 0) { if (code !== 0) {
@@ -667,11 +683,16 @@ async function runPasteAuth(method: 'oauth' | 'api'): Promise<void> {
'auth', 'auth',
'onecli', 'onecli',
[ [
'secrets', 'create', 'secrets',
'--name', 'Anthropic', 'create',
'--type', 'anthropic', '--name',
'--value', token, 'Anthropic',
'--host-pattern', 'api.anthropic.com', '--type',
'anthropic',
'--value',
token,
'--host-pattern',
'api.anthropic.com',
], ],
{ {
running: `Saving your ${label} to your OneCLI vault…`, running: `Saving your ${label} to your OneCLI vault…`,
@@ -710,10 +731,7 @@ async function runTimezoneStep(): Promise<void> {
const fields = res.terminal?.fields ?? {}; const fields = res.terminal?.fields ?? {};
const resolvedTz = fields.RESOLVED_TZ; const resolvedTz = fields.RESOLVED_TZ;
const needsInput = fields.NEEDS_USER_INPUT === 'true'; const needsInput = fields.NEEDS_USER_INPUT === 'true';
const isUtc = const isUtc = resolvedTz === 'UTC' || resolvedTz === 'Etc/UTC' || resolvedTz === 'Universal';
resolvedTz === 'UTC' ||
resolvedTz === 'Etc/UTC' ||
resolvedTz === 'Universal';
// Three branches: // Three branches:
// - no TZ detected: ask where they are (or leave as UTC) // - no TZ detected: ask where they are (or leave as UTC)
@@ -735,8 +753,8 @@ async function runTimezoneStep(): Promise<void> {
const message = needsInput const message = needsInput
? "Your system didn't expose a timezone. Which one are you in?" ? "Your system didn't expose a timezone. Which one are you in?"
: !isUtc : !isUtc
? "Where are you, then?" ? 'Where are you, then?'
: "Your system reports UTC as the timezone. Is that right, or are you somewhere else?"; : 'Your system reports UTC as the timezone. Is that right, or are you somewhere else?';
// For the non-UTC "detected-but-wrong" branch we skip the select and jump // For the non-UTC "detected-but-wrong" branch we skip the select and jump
// straight to the free-text prompt — the user already said "not that". // straight to the free-text prompt — the user already said "not that".
@@ -763,7 +781,7 @@ async function runTimezoneStep(): Promise<void> {
const answer = ensureAnswer( const answer = ensureAnswer(
await p.text({ await p.text({
message: "Where are you? (city, region, or IANA zone)", message: 'Where are you? (city, region, or IANA zone)',
placeholder: 'e.g. New York, London, Asia/Tokyo', placeholder: 'e.g. New York, London, Asia/Tokyo',
validate: (v) => (v && v.trim() ? undefined : 'Required'), validate: (v) => (v && v.trim() ? undefined : 'Required'),
}), }),
@@ -959,9 +977,7 @@ function printIntro(): void {
const wordmark = `${k.bold('Nano')}${brandBold('Claw')}`; const wordmark = `${k.bold('Nano')}${brandBold('Claw')}`;
if (isReexec) { if (isReexec) {
p.intro( p.intro(`${brandChip(' Welcome ')} ${wordmark} ${k.dim('· picking up where we left off')}`);
`${brandChip(' Welcome ')} ${wordmark} ${k.dim('· picking up where we left off')}`,
);
return; return;
} }

View File

@@ -103,6 +103,13 @@ function writeEnvOnecliUrl(url: string): void {
const ONECLI_CLI_FALLBACK_VERSION = '1.3.0'; const ONECLI_CLI_FALLBACK_VERSION = '1.3.0';
const ONECLI_CLI_REPO = 'onecli/onecli-cli'; const ONECLI_CLI_REPO = 'onecli/onecli-cli';
function installOnecliCliOnly(): { stdout: string; ok: boolean } {
const upstream = runInstall('curl -fsSL onecli.sh/cli/install | sh');
if (upstream.ok) return { stdout: upstream.stdout, ok: true };
const fallback = installOnecliCliDirect();
return { stdout: upstream.stdout + (upstream.stderr ?? '') + '\n' + fallback.stdout, ok: fallback.ok };
}
function installOnecli(): { stdout: string; ok: boolean } { function installOnecli(): { stdout: string; ok: boolean } {
let stdout = ''; let stdout = '';
@@ -163,14 +170,12 @@ function installOnecliCliDirect(): { stdout: string; ok: boolean } {
lines.push(s); lines.push(s);
}; };
const osName = const osName = process.platform === 'darwin' ? 'darwin' : process.platform === 'linux' ? 'linux' : null;
process.platform === 'darwin' ? 'darwin' : process.platform === 'linux' ? 'linux' : null;
if (!osName) { if (!osName) {
append(`Unsupported platform: ${process.platform}`); append(`Unsupported platform: ${process.platform}`);
return { stdout: lines.join('\n'), ok: false }; return { stdout: lines.join('\n'), ok: false };
} }
const arch = const arch = process.arch === 'x64' ? 'amd64' : process.arch === 'arm64' ? 'arm64' : null;
process.arch === 'x64' ? 'amd64' : process.arch === 'arm64' ? 'arm64' : null;
if (!arch) { if (!arch) {
append(`Unsupported arch: ${process.arch}`); append(`Unsupported arch: ${process.arch}`);
return { stdout: lines.join('\n'), ok: false }; return { stdout: lines.join('\n'), ok: false };
@@ -201,10 +206,9 @@ function installOnecliCliDirect(): { stdout: string; ok: boolean } {
try { try {
append(`Downloading ${url}`); append(`Downloading ${url}`);
execSync( execSync(`curl -fsSL -o ${JSON.stringify(archivePath)} ${JSON.stringify(url)}`, {
`curl -fsSL -o ${JSON.stringify(archivePath)} ${JSON.stringify(url)}`, stdio: ['ignore', 'pipe', 'pipe'],
{ stdio: ['ignore', 'pipe', 'pipe'] }, });
);
execSync(`tar -xzf ${JSON.stringify(archivePath)} -C ${JSON.stringify(tmpDir)}`, { execSync(`tar -xzf ${JSON.stringify(archivePath)} -C ${JSON.stringify(tmpDir)}`, {
stdio: ['ignore', 'pipe', 'pipe'], stdio: ['ignore', 'pipe', 'pipe'],
}); });
@@ -231,7 +235,7 @@ function installOnecliCliDirect(): { stdout: string; ok: boolean } {
} }
} }
async function pollHealth(url: string, timeoutMs: number): Promise<boolean> { export async function pollHealth(url: string, timeoutMs: number): Promise<boolean> {
// `/api/health` matches the path probe.sh uses — keep them aligned. // `/api/health` matches the path probe.sh uses — keep them aligned.
const deadline = Date.now() + timeoutMs; const deadline = Date.now() + timeoutMs;
while (Date.now() < deadline) { while (Date.now() < deadline) {
@@ -248,8 +252,45 @@ async function pollHealth(url: string, timeoutMs: number): Promise<boolean> {
export async function run(args: string[]): Promise<void> { export async function run(args: string[]): Promise<void> {
const reuse = args.includes('--reuse'); const reuse = args.includes('--reuse');
const remoteUrlIdx = args.indexOf('--remote-url');
const remoteUrl = remoteUrlIdx !== -1 ? args[remoteUrlIdx + 1] : null;
ensureShellProfilePath(); ensureShellProfilePath();
if (remoteUrl) {
log.info('Installing OneCLI CLI for remote gateway', { remoteUrl });
const res = installOnecliCliOnly();
if (!res.ok || !onecliVersion()) {
emitStatus('ONECLI', {
INSTALLED: false,
STATUS: 'failed',
ERROR: 'cli_install_failed',
HINT: 'CLI binary install failed. Make sure curl is installed and ~/.local/bin is writable.',
LOG: 'logs/setup.log',
});
process.exit(1);
}
try {
execFileSync('onecli', ['config', 'set', 'api-host', remoteUrl], {
stdio: 'ignore',
env: childEnv(),
});
} catch (err) {
log.warn('onecli config set api-host failed', { err });
}
writeEnvOnecliUrl(remoteUrl);
log.info('Wrote ONECLI_URL to .env', { url: remoteUrl });
const healthy = await pollHealth(remoteUrl, 5000);
emitStatus('ONECLI', {
INSTALLED: true,
REMOTE: true,
ONECLI_URL: remoteUrl,
HEALTHY: healthy,
STATUS: 'success',
LOG: 'logs/setup.log',
});
return;
}
if (reuse) { if (reuse) {
// Reuse-mode: don't touch the running gateway at all. Just verify it // Reuse-mode: don't touch the running gateway at all. Just verify it
// exists, read its api-host, write ONECLI_URL to .env, and move on. // exists, read its api-host, write ONECLI_URL to .env, and move on.