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) <noreply@anthropic.com>
This commit is contained in:
127
setup/auto.ts
127
setup/auto.ts
@@ -14,11 +14,12 @@
|
|||||||
* "Terminal Agent".
|
* "Terminal Agent".
|
||||||
* NANOCLAW_SKIP comma-separated step names to skip
|
* NANOCLAW_SKIP comma-separated step names to skip
|
||||||
* (environment|container|onecli|auth|mounts|
|
* (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
|
* Timezone is auto-detected after the CLI agent step. UTC resolves are
|
||||||
* pnpm exec tsx setup/index.ts --step timezone -- --tz <zone>
|
* confirmed with the user, and free-text replies fall through to a
|
||||||
* later if autodetect is wrong.
|
* headless `claude -p` call for IANA-zone resolution.
|
||||||
*/
|
*/
|
||||||
import { spawn, spawnSync } from 'child_process';
|
import { spawn, spawnSync } from 'child_process';
|
||||||
|
|
||||||
@@ -31,9 +32,14 @@ import { runTelegramChannel } from './channels/telegram.js';
|
|||||||
import { runWhatsAppChannel } from './channels/whatsapp.js';
|
import { runWhatsAppChannel } from './channels/whatsapp.js';
|
||||||
import { pingCliAgent, type PingResult } from './lib/agent-ping.js';
|
import { pingCliAgent, type PingResult } from './lib/agent-ping.js';
|
||||||
import { offerClaudeAssist } from './lib/claude-assist.js';
|
import { offerClaudeAssist } from './lib/claude-assist.js';
|
||||||
|
import {
|
||||||
|
claudeCliAvailable,
|
||||||
|
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 { brandBold, brandChip, dimWrap, fitToWidth, wrapForGutter } from './lib/theme.js';
|
import { brandBold, brandChip, dimWrap, fitToWidth, wrapForGutter } from './lib/theme.js';
|
||||||
|
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();
|
||||||
@@ -217,6 +223,10 @@ async function main(): Promise<void> {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
if (!skip.has('timezone')) {
|
||||||
|
await runTimezoneStep();
|
||||||
|
}
|
||||||
|
|
||||||
if (!skip.has('channel')) {
|
if (!skip.has('channel')) {
|
||||||
const choice = await askChannelChoice();
|
const choice = await askChannelChoice();
|
||||||
if (choice === 'telegram') {
|
if (choice === 'telegram') {
|
||||||
@@ -510,6 +520,115 @@ async function runPasteAuth(method: 'oauth' | 'api'): Promise<void> {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// ─── 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 <zone>` path. Free-text answers get
|
||||||
|
* a headless `claude -p` pass to resolve them to a real IANA zone.
|
||||||
|
*/
|
||||||
|
async function runTimezoneStep(): Promise<void> {
|
||||||
|
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 ────────────────────────────────────
|
// ─── prompts owned by the sequencer ────────────────────────────────────
|
||||||
|
|
||||||
async function askDisplayName(fallback: string): Promise<string> {
|
async function askDisplayName(fallback: string): Promise<string> {
|
||||||
|
|||||||
@@ -56,6 +56,7 @@ const STEP_FILES: Record<string, string[]> = {
|
|||||||
mounts: ['setup/mounts.ts'],
|
mounts: ['setup/mounts.ts'],
|
||||||
service: ['setup/service.ts'],
|
service: ['setup/service.ts'],
|
||||||
'cli-agent': ['setup/cli-agent.ts', 'scripts/init-cli-agent.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'],
|
channel: ['setup/auto.ts'],
|
||||||
verify: ['setup/verify.ts'],
|
verify: ['setup/verify.ts'],
|
||||||
// Channel-specific sub-steps:
|
// Channel-specific sub-steps:
|
||||||
|
|||||||
126
setup/lib/tz-from-claude.ts
Normal file
126
setup/lib/tz-from-claude.ts
Normal file
@@ -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<string | null> {
|
||||||
|
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<string | null> {
|
||||||
|
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;
|
||||||
|
}
|
||||||
Reference in New Issue
Block a user