From 202ee71311ba75c386827b5dc5f9e328b51a365a Mon Sep 17 00:00:00 2001 From: gavrielc Date: Wed, 22 Apr 2026 16:44:53 +0300 Subject: [PATCH] feat(setup): auto-detect timezone after CLI agent step Adds a timezone step between cli-agent and channel wiring in setup:auto. Autodetect via --step timezone; if it resolves to UTC or fails, confirm with the user and accept either an IANA zone or a free-text description (e.g. "New York"). Free-text falls through to a headless `claude -p` call that returns a single IANA string, gated on the claude CLI being on PATH. Co-Authored-By: Claude Opus 4.7 (1M context) --- setup/auto.ts | 127 ++++++++++++++++++++++++++++++++++-- setup/lib/claude-assist.ts | 1 + setup/lib/tz-from-claude.ts | 126 +++++++++++++++++++++++++++++++++++ 3 files changed, 250 insertions(+), 4 deletions(-) create mode 100644 setup/lib/tz-from-claude.ts diff --git a/setup/auto.ts b/setup/auto.ts index 52586c229..0f949978a 100644 --- a/setup/auto.ts +++ b/setup/auto.ts @@ -14,11 +14,12 @@ * "Terminal Agent". * NANOCLAW_SKIP comma-separated step names to skip * (environment|container|onecli|auth|mounts| - * service|cli-agent|channel|verify|first-chat) + * service|cli-agent|timezone|channel|verify| + * first-chat) * - * Timezone defaults to the host system's TZ. Run - * pnpm exec tsx setup/index.ts --step timezone -- --tz - * later if autodetect is wrong. + * Timezone is auto-detected after the CLI agent step. UTC resolves are + * confirmed with the user, and free-text replies fall through to a + * headless `claude -p` call for IANA-zone resolution. */ import { spawn, spawnSync } from 'child_process'; @@ -31,9 +32,14 @@ import { runTelegramChannel } from './channels/telegram.js'; import { runWhatsAppChannel } from './channels/whatsapp.js'; import { pingCliAgent, type PingResult } from './lib/agent-ping.js'; import { offerClaudeAssist } from './lib/claude-assist.js'; +import { + claudeCliAvailable, + resolveTimezoneViaClaude, +} from './lib/tz-from-claude.js'; import * as setupLog from './logs.js'; import { ensureAnswer, fail, runQuietChild, runQuietStep } from './lib/runner.js'; import { brandBold, brandChip, dimWrap, fitToWidth, wrapForGutter } from './lib/theme.js'; +import { isValidTimezone } from '../src/timezone.js'; const CLI_AGENT_NAME = 'Terminal Agent'; const RUN_START = Date.now(); @@ -217,6 +223,10 @@ async function main(): Promise { } } + if (!skip.has('timezone')) { + await runTimezoneStep(); + } + if (!skip.has('channel')) { const choice = await askChannelChoice(); if (choice === 'telegram') { @@ -510,6 +520,115 @@ async function runPasteAuth(method: 'oauth' | 'api'): Promise { } } +// ─── timezone step ───────────────────────────────────────────────────── + +/** + * Auto-detect TZ, confirm with the user when it comes back as UTC (a + * common sign we're on a VPS that wasn't localised), and persist through + * the usual `--step timezone -- --tz ` path. Free-text answers get + * a headless `claude -p` pass to resolve them to a real IANA zone. + */ +async function runTimezoneStep(): Promise { + const res = await runQuietStep('timezone', { + running: 'Checking your timezone…', + done: 'Timezone set.', + }); + if (!res.ok && res.terminal?.fields.NEEDS_USER_INPUT !== 'true') { + await fail('timezone', "Couldn't determine your timezone."); + } + + const fields = res.terminal?.fields ?? {}; + const resolvedTz = fields.RESOLVED_TZ; + const needsInput = fields.NEEDS_USER_INPUT === 'true'; + const isUtc = + resolvedTz === 'UTC' || + resolvedTz === 'Etc/UTC' || + resolvedTz === 'Universal'; + + if (!needsInput && !isUtc && resolvedTz && resolvedTz !== 'none') { + return; + } + + // Either autodetect failed outright, or it landed on UTC and we should + // check that's really what the user wants before leaving it there. + const message = needsInput + ? "Your system didn't expose a timezone. Which one are you in?" + : "Your system reports UTC as the timezone. Is that right, or are you somewhere else?"; + + const choice = ensureAnswer( + await p.select({ + message, + options: needsInput + ? [ + { value: 'answer', label: "I'll tell you where I am" }, + { value: 'keep', label: 'Leave it as UTC' }, + ] + : [ + { value: 'keep', label: 'Keep UTC', hint: 'remote server / happy with UTC' }, + { value: 'answer', label: "I'm somewhere else" }, + ], + }), + ) as 'keep' | 'answer'; + setupLog.userInput('timezone_choice', choice); + + if (choice === 'keep') return; + + const answer = ensureAnswer( + await p.text({ + message: "Where are you? (city, region, or IANA zone)", + placeholder: 'e.g. New York, London, Asia/Tokyo', + validate: (v) => (v && v.trim() ? undefined : 'Required'), + }), + ); + const raw = (answer as string).trim(); + setupLog.userInput('timezone_input', raw); + + let tz: string | null = isValidTimezone(raw) ? raw : null; + if (!tz) { + if (claudeCliAvailable()) { + tz = await resolveTimezoneViaClaude(raw); + } else { + p.log.warn( + wrapForGutter( + "That's not a standard IANA zone and I can't call Claude to interpret it here — try again with a zone like `America/New_York` or `Europe/London`.", + 4, + ), + ); + } + } + + if (!tz) { + // One retry with a direct-IANA ask; if that fails too, leave the + // previously-detected value in .env and move on rather than looping. + const retryAnswer = ensureAnswer( + await p.text({ + message: 'Enter an IANA timezone string', + placeholder: 'e.g. America/New_York', + validate: (v) => { + const s = (v ?? '').trim(); + if (!s) return 'Required'; + if (!isValidTimezone(s)) return 'Not a valid IANA zone'; + return undefined; + }, + }), + ); + tz = (retryAnswer as string).trim(); + setupLog.userInput('timezone_retry', tz); + } + + const persist = await runQuietStep( + 'timezone', + { + running: `Saving timezone ${tz}…`, + done: `Timezone set to ${tz}.`, + }, + ['--tz', tz], + ); + if (!persist.ok) { + await fail('timezone', `Couldn't save timezone ${tz}.`); + } +} + // ─── prompts owned by the sequencer ──────────────────────────────────── async function askDisplayName(fallback: string): Promise { diff --git a/setup/lib/claude-assist.ts b/setup/lib/claude-assist.ts index 70b6a3dc7..551d938bb 100644 --- a/setup/lib/claude-assist.ts +++ b/setup/lib/claude-assist.ts @@ -56,6 +56,7 @@ const STEP_FILES: Record = { mounts: ['setup/mounts.ts'], service: ['setup/service.ts'], 'cli-agent': ['setup/cli-agent.ts', 'scripts/init-cli-agent.ts'], + timezone: ['setup/timezone.ts', 'setup/lib/tz-from-claude.ts'], channel: ['setup/auto.ts'], verify: ['setup/verify.ts'], // Channel-specific sub-steps: diff --git a/setup/lib/tz-from-claude.ts b/setup/lib/tz-from-claude.ts new file mode 100644 index 000000000..5486fbb20 --- /dev/null +++ b/setup/lib/tz-from-claude.ts @@ -0,0 +1,126 @@ +/** + * Headless Claude fallback for timezone resolution. + * + * When the user answers the UTC-confirmation prompt with something that + * isn't a valid IANA zone ("NYC", "Jerusalem time", "eastern"), spawn + * `claude -p` with a narrow prompt asking for a single IANA string and + * validate the reply with `isValidTimezone` before returning it. + * + * Gated on claude being on PATH — if the user did the paste-OAuth or + * paste-API auth path they may not have the CLI installed. Returns null + * in that case so the caller can ask them to try again with a canonical + * zone string. + */ +import { execSync, spawn } from 'child_process'; + +import * as p from '@clack/prompts'; +import k from 'kleur'; + +import { isValidTimezone } from '../../src/timezone.js'; +import { fitToWidth } from './theme.js'; + +export function claudeCliAvailable(): boolean { + try { + execSync('command -v claude', { stdio: 'ignore' }); + return true; + } catch { + return false; + } +} + +/** + * Ask headless Claude to map a free-text location/timezone description to + * a valid IANA zone. Shows a spinner with elapsed time. Returns the + * resolved zone string on success, or null if the CLI is missing, Claude + * errored, or the reply wasn't a valid IANA zone. + */ +export async function resolveTimezoneViaClaude( + input: string, +): Promise { + if (!claudeCliAvailable()) return null; + + const prompt = buildPrompt(input); + + const s = p.spinner(); + const start = Date.now(); + const label = 'Looking up that timezone…'; + s.start(fitToWidth(label, ' (999s)')); + const tick = setInterval(() => { + const elapsed = Math.round((Date.now() - start) / 1000); + const suffix = ` (${elapsed}s)`; + s.message(`${fitToWidth(label, suffix)}${k.dim(suffix)}`); + }, 1000); + + const reply = await queryClaude(prompt); + + clearInterval(tick); + const elapsed = Math.round((Date.now() - start) / 1000); + const suffix = ` (${elapsed}s)`; + + const resolved = reply ? extractTimezone(reply) : null; + if (resolved) { + s.stop( + `${fitToWidth(`Interpreted as ${resolved}.`, suffix)}${k.dim(suffix)}`, + ); + return resolved; + } + s.stop( + `${fitToWidth("Couldn't interpret that as a timezone.", suffix)}${k.dim( + suffix, + )}`, + 1, + ); + return null; +} + +function buildPrompt(input: string): string { + return [ + 'Convert the user\'s description of where they are into a single IANA', + 'timezone identifier (e.g. "America/New_York", "Europe/London",', + '"Asia/Jerusalem"). Respond with ONLY the IANA string on a single line,', + 'nothing else — no prose, no quotes, no punctuation. If you cannot', + 'determine a zone with reasonable confidence, reply with exactly:', + 'UNKNOWN', + '', + `User's description: ${input}`, + ].join('\n'); +} + +function queryClaude(prompt: string): Promise { + return new Promise((resolve) => { + const child = spawn('claude', ['-p', '--output-format', 'text'], { + stdio: ['pipe', 'pipe', 'pipe'], + }); + let stdout = ''; + let settled = false; + const settle = (value: string | null): void => { + if (settled) return; + settled = true; + resolve(value); + }; + + child.stdout.on('data', (c: Buffer) => { + stdout += c.toString('utf-8'); + }); + child.on('close', (code) => { + settle(code === 0 && stdout.trim() ? stdout : null); + }); + child.on('error', () => settle(null)); + + child.stdin.end(prompt); + }); +} + +function extractTimezone(reply: string): string | null { + // Claude occasionally prefixes with a backtick or wraps in quotes despite + // instructions; take the first line that looks like a zone. + const lines = reply + .split('\n') + .map((l) => l.trim().replace(/^["'`]+|["'`]+$/g, '')) + .filter(Boolean); + for (const line of lines) { + if (line === 'UNKNOWN') return null; + if (isValidTimezone(line)) return line; + } + return null; +}