Merge branch 'main' into feat/channel-approval-flow
This commit is contained in:
30
assets/setup-splash.txt
Normal file
30
assets/setup-splash.txt
Normal file
@@ -0,0 +1,30 @@
|
|||||||
|
|
||||||
|
[38;2;43;183;206m⠀⠀⠀⠀⠀⠀⠀⠀⠀⠀⠀⠀⠀⠀⠰⣄⠘⣦⠀⠀⠀⠀⠀⠀⠀⠀⠀⠀⠀[39m
|
||||||
|
[38;2;43;183;206m⠀⠀⠀⠀⠀⠀⠀⠀⠀⠀⠀⠀⠀⠀⠀⢹⡆⢸⡆⠀⠀⠀⠀⠀⠀⠀⠀⠀⠀[39m [2m[38;2;43;183;206m°[39m[22m
|
||||||
|
[38;2;43;183;206m⠀⠀⠀⠀⠀⠀⠀⠀⠀⠀⠀⠀⠀⠀⠀⢸⡇⢸⡇⠀⠀⠀⠀⠀⠀⠀⠀⠀⠀[39m
|
||||||
|
[38;2;43;183;206m⠀⠀⠀⠀⠀⢀⣠⣴⠾⠟⠛⠛⠿⢶⣦⣾⠇⣾⠁⠀⠀⠀⢀⣤⣤⠀⢀⣄⠀[39m
|
||||||
|
[38;2;43;183;206m⠀⠀⠀⠀⣴⡿⡋⠀⠀⠀⠀⠀⢤⣾⣿⢛⢿⣏⠀⠀⠀⢰⣟⣽⡏⠀⣸⡿⣧[39m
|
||||||
|
[2m[38;2;43;183;206mo[39m[22m [38;2;43;183;206m⠀⠀⢀⣾⠋⠀⠀⠀⠀⠀⠀⠀⠀⠘⠈⣧⣀⣿⣧⠀⠀⣿⣼⣿⣇⣾⠋⢠⣿[39m
|
||||||
|
[38;2;43;183;206m⠀⠀⣾⢃⠀⢲⣷⡋⣰⡀⢀⣀⣀⡀⠠⣿⣿⣠⣿⣇⠀⣿⢻⣉⠉⠙⠠⣼⠇[39m
|
||||||
|
[38;2;43;183;206m⠀⣼⡏⠃⠀⢸⣿⣿⡿⠃⣾⣷⣻⣿⡏⢹⠿⠿⣿⣿⢀⣿⣐⠙⣷⣦⡾⠋⠀[39m [2m[38;2;43;183;206mo[39m[22m
|
||||||
|
[38;2;43;183;206m⢠⣿⡃⠀⠀⠀⠀⠀⠈⠀⠀⠉⠙⠁⠀⠀⠀⠐⣿⣿⣟⠁⣿⣿⠟⠋⠀⠀⠀[39m
|
||||||
|
[2m[38;2;43;183;206m°[39m[22m [38;2;43;183;206m⢸⣿⣧⡀⠀⠀⠀⠀⠀⠀⠀⠀⠀⠀⢀⣀⣨⣿⣿⣿⣿⣿⠟⠁⠀⠀⠀⠀⠀[39m
|
||||||
|
[38;2;43;183;206m⢸⣿⣿⣷⣤⣤⠀⣀⢀⠀⢀⣀⣠⣴⣶⣿⣿⣿⣿⡿⠛⠁⠀⠀⠀⠀⠀⠀⠀[39m
|
||||||
|
[38;2;43;183;206m⣿⢋⠿⣿⣿⣿⣿⡿⣿⣿⣿⣿⣿⣿⠿⠿⠿⣿⣅⡀⠀⠀⠀⠀⠀⠀⠀⠀⠀[39m [38;2;43;183;206mO[39m
|
||||||
|
[38;2;43;183;206m⣿⣿⠙⢾⣽⣟⣿⣿⣼⣿⣿⣿⣩⣶⣶⣦⠀⠀⠩⢻⣆⠀⠀⠀⠀⠀⠀⠀⠀[39m
|
||||||
|
[38;2;43;183;206m⠘⣿⣶⣤⣿⣿⣿⣿⣵⢖⡀⠉⠹⡛⢷⣝⡿⠁⠀⠀⣿⡆⠀⠀⠀⠀⠀⠀⠀[39m
|
||||||
|
[38;2;43;183;206m⠀⢹⣯⣽⣟⣛⣻⣿⣿⣾⣽⢶⣽⣿⣿⣿⣏⠀⠠⣤⣿⡇⠀⠀⠀⠀⠀⠀⠀[39m
|
||||||
|
[38;2;43;183;206m⠀⠀⠻⣿⣶⣾⣿⢿⣻⣿⣿⣿⣿⣿⣿⣏⣛⣧⣦⣿⣿⣧⣄⠀⠀⠀⠀⠀⠀[39m
|
||||||
|
[38;2;43;183;206mo[39m [38;2;43;183;206m⠀⠀⠀⠈⠻⣿⣶⣥⣼⣿⣿⣽⣿⣿⣿⣷⣶⣾⣿⣿⣯⣘⣿⣧⠀⠀⠀⠀⠀[39m
|
||||||
|
[38;2;43;183;206m⠀⠀⠀⠀⠤⣤⣿⣿⣿⣿⣿⣿⣿⣿⣿⣿⣿⣿⣿⣿⠿⠿⠿⠋⠀⠀⠀⠀⠀[39m
|
||||||
|
|
||||||
|
[1m _ _ [22m[38;2;43;183;206m[1m ___ _ [22m[39m
|
||||||
|
[1m| \| |__ _ _ _ ___ [22m[38;2;43;183;206m[1m / __| |__ ___ __ __[22m[39m
|
||||||
|
[1m| .` / _` | ' \/ _ \[22m[38;2;43;183;206m[1m| (__| / _` \ V V /[22m[39m
|
||||||
|
[1m|_|\_\__,_|_||_\___/[22m[38;2;43;183;206m[1m \___|_\__,_|\_/\_/ [22m[39m
|
||||||
|
|
||||||
|
[2mSmall.[22m
|
||||||
|
[2mRuns on your machine.[22m
|
||||||
|
[2mYours to modify.[22m
|
||||||
|
|
||||||
|
[38;2;5;62;165m════════════════════════════════════════[39m
|
||||||
14
nanoclaw.sh
14
nanoclaw.sh
@@ -129,10 +129,13 @@ rm -f "$PROGRESS_LOG"
|
|||||||
mkdir -p "$STEPS_DIR" "$LOGS_DIR"
|
mkdir -p "$STEPS_DIR" "$LOGS_DIR"
|
||||||
write_header
|
write_header
|
||||||
|
|
||||||
# NanoClaw wordmark — clack's intro carries the "let's get you set up" framing,
|
# NanoClaw splash — under-the-sea lobster mascot in truecolor braille,
|
||||||
# so we don't print a subtitle here. setup:auto sees NANOCLAW_BOOTSTRAPPED=1 and
|
# with the figlet wordmark and taglines below. Pre-rendered into
|
||||||
# skips re-printing the wordmark, keeping the flow visually continuous.
|
# assets/setup-splash.txt (built from assets/nanoclaw-icon.png via chafa +
|
||||||
printf '\n %s%s\n\n' "$(bold 'Nano')" "$(brand_bold 'Claw')"
|
# figlet); the bash script just streams the literal frame. clack's intro
|
||||||
|
# then carries the "let's get you set up" framing — setup:auto sees
|
||||||
|
# NANOCLAW_BOOTSTRAPPED=1 and skips re-printing the wordmark.
|
||||||
|
cat "$PROJECT_ROOT/assets/setup-splash.txt"
|
||||||
|
|
||||||
# ─── pre-flight: Homebrew on macOS ─────────────────────────────────────
|
# ─── pre-flight: Homebrew on macOS ─────────────────────────────────────
|
||||||
# setup/install-node.sh and setup/install-docker.sh both require `brew` on
|
# setup/install-node.sh and setup/install-docker.sh both require `brew` on
|
||||||
@@ -188,9 +191,6 @@ BOOTSTRAP_RAW="${STEPS_DIR}/01-bootstrap.log"
|
|||||||
BOOTSTRAP_LABEL="Installing the basics"
|
BOOTSTRAP_LABEL="Installing the basics"
|
||||||
BOOTSTRAP_START=$(date +%s)
|
BOOTSTRAP_START=$(date +%s)
|
||||||
|
|
||||||
# One-line "why" that teaches a differentiator while the user waits.
|
|
||||||
printf '%s %s\n' "$(gray '│')" \
|
|
||||||
"$(dim "Small. Runs on your machine. Yours to modify.")"
|
|
||||||
spinner_start "$BOOTSTRAP_LABEL"
|
spinner_start "$BOOTSTRAP_LABEL"
|
||||||
|
|
||||||
# Run in the background so we can tick elapsed time. Capture exit code via
|
# Run in the background so we can tick elapsed time. Capture exit code via
|
||||||
|
|||||||
@@ -53,7 +53,7 @@ import { claudeCliAvailable, resolveTimezoneViaClaude } from './lib/tz-from-clau
|
|||||||
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';
|
||||||
import { accentGreen, brandBody, brandBold, brandChip, dimWrap, fitToWidth, note, wrapForGutter } from './lib/theme.js';
|
import { accentGreen, brandBody, brandBold, brandChip, dimWrap, fitToWidth, fmtDuration, note, wrapForGutter } from './lib/theme.js';
|
||||||
import { isValidTimezone } from '../src/timezone.js';
|
import { isValidTimezone } from '../src/timezone.js';
|
||||||
|
|
||||||
const CLI_AGENT_NAME = 'Terminal Agent';
|
const CLI_AGENT_NAME = 'Terminal Agent';
|
||||||
@@ -579,18 +579,16 @@ async function confirmAssistantResponds(): Promise<PingResult> {
|
|||||||
const s = p.spinner();
|
const s = p.spinner();
|
||||||
const start = Date.now();
|
const start = Date.now();
|
||||||
const label = 'Waking your assistant…';
|
const label = 'Waking your assistant…';
|
||||||
s.start(fitToWidth(label, ' (999s)'));
|
s.start(fitToWidth(label, ' (99m 59s)'));
|
||||||
const tick = setInterval(() => {
|
const tick = setInterval(() => {
|
||||||
const elapsed = Math.round((Date.now() - start) / 1000);
|
const suffix = ` (${fmtDuration(Date.now() - start)})`;
|
||||||
const suffix = ` (${elapsed}s)`;
|
|
||||||
s.message(`${fitToWidth(label, suffix)}${k.dim(suffix)}`);
|
s.message(`${fitToWidth(label, suffix)}${k.dim(suffix)}`);
|
||||||
}, 1000);
|
}, 1000);
|
||||||
|
|
||||||
const result = await pingCliAgent();
|
const result = await pingCliAgent();
|
||||||
|
|
||||||
clearInterval(tick);
|
clearInterval(tick);
|
||||||
const elapsed = Math.round((Date.now() - start) / 1000);
|
const suffix = ` (${fmtDuration(Date.now() - start)})`;
|
||||||
const suffix = ` (${elapsed}s)`;
|
|
||||||
if (result === 'ok') {
|
if (result === 'ok') {
|
||||||
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 {
|
||||||
|
|||||||
@@ -28,10 +28,10 @@ import k from 'kleur';
|
|||||||
|
|
||||||
import * as setupLog from '../logs.js';
|
import * as setupLog from '../logs.js';
|
||||||
import { brightSelect } from '../lib/bright-select.js';
|
import { brightSelect } from '../lib/bright-select.js';
|
||||||
import { confirmThenOpen } from '../lib/browser.js';
|
import { confirmThenOpen, formatNoteLink } from '../lib/browser.js';
|
||||||
import { askOperatorRole } from '../lib/role-prompt.js';
|
import { askOperatorRole } from '../lib/role-prompt.js';
|
||||||
import { ensureAnswer, fail, runQuietChild } from '../lib/runner.js';
|
import { ensureAnswer, fail, runQuietChild } from '../lib/runner.js';
|
||||||
import { accentGreen, brandBody, note } from '../lib/theme.js';
|
import { accentGreen, brandBody, fmtDuration, note } from '../lib/theme.js';
|
||||||
|
|
||||||
const DEFAULT_AGENT_NAME = 'Nano';
|
const DEFAULT_AGENT_NAME = 'Nano';
|
||||||
const DISCORD_API = 'https://discord.com/api/v10';
|
const DISCORD_API = 'https://discord.com/api/v10';
|
||||||
@@ -164,9 +164,8 @@ async function walkThroughBotCreation(): Promise<void> {
|
|||||||
' 2. In the "Bot" tab, click "Reset Token" and copy the token',
|
' 2. In the "Bot" tab, click "Reset Token" and copy the token',
|
||||||
' 3. On the same tab, enable "Message Content Intent"',
|
' 3. On the same tab, enable "Message Content Intent"',
|
||||||
' (under Privileged Gateway Intents)',
|
' (under Privileged Gateway Intents)',
|
||||||
'',
|
formatNoteLink(url),
|
||||||
k.dim(url),
|
].filter((line): line is string => line !== null).join('\n'),
|
||||||
].join('\n'),
|
|
||||||
'Create a Discord bot',
|
'Create a Discord bot',
|
||||||
);
|
);
|
||||||
await confirmThenOpen(url, 'Press Enter to open the Developer Portal');
|
await confirmThenOpen(url, 'Press Enter to open the Developer Portal');
|
||||||
@@ -224,9 +223,8 @@ async function walkThroughServerCreation(): Promise<void> {
|
|||||||
' 1. In Discord, click the "+" at the bottom of the server list',
|
' 1. In Discord, click the "+" at the bottom of the server list',
|
||||||
' 2. Choose "Create My Own" → "For me and my friends"',
|
' 2. Choose "Create My Own" → "For me and my friends"',
|
||||||
' 3. Give it any name (e.g. "NanoClaw")',
|
' 3. Give it any name (e.g. "NanoClaw")',
|
||||||
'',
|
formatNoteLink(url),
|
||||||
k.dim(url),
|
].filter((line): line is string => line !== null).join('\n'),
|
||||||
].join('\n'),
|
|
||||||
'Create a Discord server',
|
'Create a Discord server',
|
||||||
);
|
);
|
||||||
await confirmThenOpen(url, 'Press Enter to open Discord');
|
await confirmThenOpen(url, 'Press Enter to open Discord');
|
||||||
@@ -289,9 +287,8 @@ async function validateDiscordToken(token: string): Promise<string> {
|
|||||||
username?: string;
|
username?: string;
|
||||||
message?: string;
|
message?: string;
|
||||||
};
|
};
|
||||||
const elapsedS = Math.round((Date.now() - start) / 1000);
|
|
||||||
if (res.ok && data.username) {
|
if (res.ok && data.username) {
|
||||||
s.stop(`Found your bot: @${data.username}. ${k.dim(`(${elapsedS}s)`)}`);
|
s.stop(`Found your bot: @${data.username}. ${k.dim(`(${fmtDuration(Date.now() - start)})`)}`);
|
||||||
setupLog.step('discord-validate', 'success', Date.now() - start, {
|
setupLog.step('discord-validate', 'success', Date.now() - start, {
|
||||||
BOT_USERNAME: data.username,
|
BOT_USERNAME: data.username,
|
||||||
BOT_ID: data.id ?? '',
|
BOT_ID: data.id ?? '',
|
||||||
@@ -309,8 +306,7 @@ async function validateDiscordToken(token: string): Promise<string> {
|
|||||||
'Copy the token again from the Developer Portal and retry setup.',
|
'Copy the token again from the Developer Portal and retry setup.',
|
||||||
);
|
);
|
||||||
} catch (err) {
|
} catch (err) {
|
||||||
const elapsedS = Math.round((Date.now() - start) / 1000);
|
s.stop(`Couldn't reach Discord. ${k.dim(`(${fmtDuration(Date.now() - start)})`)}`, 1);
|
||||||
s.stop(`Couldn't reach Discord. ${k.dim(`(${elapsedS}s)`)}`, 1);
|
|
||||||
const message = err instanceof Error ? err.message : String(err);
|
const message = err instanceof Error ? err.message : String(err);
|
||||||
setupLog.step('discord-validate', 'failed', Date.now() - start, {
|
setupLog.step('discord-validate', 'failed', Date.now() - start, {
|
||||||
ERROR: message,
|
ERROR: message,
|
||||||
@@ -338,7 +334,6 @@ async function fetchApplicationInfo(token: string): Promise<AppInfo> {
|
|||||||
team?: unknown;
|
team?: unknown;
|
||||||
message?: string;
|
message?: string;
|
||||||
};
|
};
|
||||||
const elapsedS = Math.round((Date.now() - start) / 1000);
|
|
||||||
if (!res.ok || !data.id || !data.verify_key) {
|
if (!res.ok || !data.id || !data.verify_key) {
|
||||||
const reason = data.message ?? `HTTP ${res.status}`;
|
const reason = data.message ?? `HTTP ${res.status}`;
|
||||||
s.stop(`Couldn't read application info: ${reason}`, 1);
|
s.stop(`Couldn't read application info: ${reason}`, 1);
|
||||||
@@ -351,7 +346,7 @@ async function fetchApplicationInfo(token: string): Promise<AppInfo> {
|
|||||||
'Re-run setup. If it keeps failing, check the bot token has the right scopes.',
|
'Re-run setup. If it keeps failing, check the bot token has the right scopes.',
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
s.stop(`Got your application details. ${k.dim(`(${elapsedS}s)`)}`);
|
s.stop(`Got your application details. ${k.dim(`(${fmtDuration(Date.now() - start)})`)}`);
|
||||||
// owner is populated for solo applications; team-owned apps return a
|
// owner is populated for solo applications; team-owned apps return a
|
||||||
// team object instead and we'll fall back to a manual user-id prompt.
|
// team object instead and we'll fall back to a manual user-id prompt.
|
||||||
const owner =
|
const owner =
|
||||||
@@ -369,8 +364,7 @@ async function fetchApplicationInfo(token: string): Promise<AppInfo> {
|
|||||||
owner,
|
owner,
|
||||||
};
|
};
|
||||||
} catch (err) {
|
} catch (err) {
|
||||||
const elapsedS = Math.round((Date.now() - start) / 1000);
|
s.stop(`Couldn't reach Discord. ${k.dim(`(${fmtDuration(Date.now() - start)})`)}`, 1);
|
||||||
s.stop(`Couldn't reach Discord. ${k.dim(`(${elapsedS}s)`)}`, 1);
|
|
||||||
const message = err instanceof Error ? err.message : String(err);
|
const message = err instanceof Error ? err.message : String(err);
|
||||||
setupLog.step('discord-app-info', 'failed', Date.now() - start, {
|
setupLog.step('discord-app-info', 'failed', Date.now() - start, {
|
||||||
ERROR: message,
|
ERROR: message,
|
||||||
@@ -450,9 +444,8 @@ async function promptInviteBot(
|
|||||||
'',
|
'',
|
||||||
' 1. Pick any server you\'re in (a personal one is fine)',
|
' 1. Pick any server you\'re in (a personal one is fine)',
|
||||||
' 2. Click "Authorize"',
|
' 2. Click "Authorize"',
|
||||||
'',
|
formatNoteLink(url),
|
||||||
k.dim(url),
|
].filter((line): line is string => line !== null).join('\n'),
|
||||||
].join('\n'),
|
|
||||||
'Add bot to a server',
|
'Add bot to a server',
|
||||||
);
|
);
|
||||||
await confirmThenOpen(url, 'Press Enter to open the invite page');
|
await confirmThenOpen(url, 'Press Enter to open the invite page');
|
||||||
@@ -479,7 +472,6 @@ async function openDmChannel(token: string, userId: string): Promise<string> {
|
|||||||
body: JSON.stringify({ recipient_id: userId }),
|
body: JSON.stringify({ recipient_id: userId }),
|
||||||
});
|
});
|
||||||
const data = (await res.json()) as { id?: string; message?: string };
|
const data = (await res.json()) as { id?: string; message?: string };
|
||||||
const elapsedS = Math.round((Date.now() - start) / 1000);
|
|
||||||
if (!res.ok || !data.id) {
|
if (!res.ok || !data.id) {
|
||||||
const reason = data.message ?? `HTTP ${res.status}`;
|
const reason = data.message ?? `HTTP ${res.status}`;
|
||||||
s.stop(`Couldn't open a DM channel: ${reason}`, 1);
|
s.stop(`Couldn't open a DM channel: ${reason}`, 1);
|
||||||
@@ -492,14 +484,13 @@ async function openDmChannel(token: string, userId: string): Promise<string> {
|
|||||||
'Make sure the bot is in a server you\'re also in, then retry setup.',
|
'Make sure the bot is in a server you\'re also in, then retry setup.',
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
s.stop(`DM channel ready. ${k.dim(`(${elapsedS}s)`)}`);
|
s.stop(`DM channel ready. ${k.dim(`(${fmtDuration(Date.now() - start)})`)}`);
|
||||||
setupLog.step('discord-open-dm', 'success', Date.now() - start, {
|
setupLog.step('discord-open-dm', 'success', Date.now() - start, {
|
||||||
DM_CHANNEL_ID: data.id,
|
DM_CHANNEL_ID: data.id,
|
||||||
});
|
});
|
||||||
return data.id;
|
return data.id;
|
||||||
} catch (err) {
|
} catch (err) {
|
||||||
const elapsedS = Math.round((Date.now() - start) / 1000);
|
s.stop(`Couldn't reach Discord. ${k.dim(`(${fmtDuration(Date.now() - start)})`)}`, 1);
|
||||||
s.stop(`Couldn't reach Discord. ${k.dim(`(${elapsedS}s)`)}`, 1);
|
|
||||||
const message = err instanceof Error ? err.message : String(err);
|
const message = err instanceof Error ? err.message : String(err);
|
||||||
setupLog.step('discord-open-dm', 'failed', Date.now() - start, {
|
setupLog.step('discord-open-dm', 'failed', Date.now() - start, {
|
||||||
ERROR: message,
|
ERROR: message,
|
||||||
|
|||||||
@@ -44,7 +44,7 @@ import {
|
|||||||
writeStepEntry,
|
writeStepEntry,
|
||||||
} from '../lib/runner.js';
|
} from '../lib/runner.js';
|
||||||
import { askOperatorRole } from '../lib/role-prompt.js';
|
import { askOperatorRole } from '../lib/role-prompt.js';
|
||||||
import { accentGreen, note } from '../lib/theme.js';
|
import { accentGreen, fmtDuration, note } from '../lib/theme.js';
|
||||||
|
|
||||||
const DEFAULT_AGENT_NAME = 'Nano';
|
const DEFAULT_AGENT_NAME = 'Nano';
|
||||||
|
|
||||||
@@ -324,8 +324,7 @@ async function restartService(): Promise<void> {
|
|||||||
// Give the adapter a moment to connect to signal-cli before
|
// Give the adapter a moment to connect to signal-cli before
|
||||||
// init-first-agent's welcome DM hits the delivery path.
|
// init-first-agent's welcome DM hits the delivery path.
|
||||||
await new Promise((r) => setTimeout(r, 5000));
|
await new Promise((r) => setTimeout(r, 5000));
|
||||||
const elapsed = Math.round((Date.now() - start) / 1000);
|
s.stop(`NanoClaw restarted. ${k.dim(`(${fmtDuration(Date.now() - start)})`)}`);
|
||||||
s.stop(`NanoClaw restarted. ${k.dim(`(${elapsed}s)`)}`);
|
|
||||||
setupLog.step('signal-restart', 'success', Date.now() - start, {
|
setupLog.step('signal-restart', 'success', Date.now() - start, {
|
||||||
PLATFORM: platform,
|
PLATFORM: platform,
|
||||||
});
|
});
|
||||||
|
|||||||
@@ -25,10 +25,10 @@ import * as p from '@clack/prompts';
|
|||||||
import k from 'kleur';
|
import k from 'kleur';
|
||||||
|
|
||||||
import * as setupLog from '../logs.js';
|
import * as setupLog from '../logs.js';
|
||||||
import { confirmThenOpen } from '../lib/browser.js';
|
import { confirmThenOpen, formatNoteLink } from '../lib/browser.js';
|
||||||
import { askOperatorRole } from '../lib/role-prompt.js';
|
import { askOperatorRole } from '../lib/role-prompt.js';
|
||||||
import { ensureAnswer, fail, runQuietChild } from '../lib/runner.js';
|
import { ensureAnswer, fail, runQuietChild } from '../lib/runner.js';
|
||||||
import { accentGreen, note, wrapForGutter } from '../lib/theme.js';
|
import { accentGreen, fmtDuration, note, wrapForGutter } from '../lib/theme.js';
|
||||||
|
|
||||||
const SLACK_API = 'https://slack.com/api';
|
const SLACK_API = 'https://slack.com/api';
|
||||||
const SLACK_APPS_URL = 'https://api.slack.com/apps';
|
const SLACK_APPS_URL = 'https://api.slack.com/apps';
|
||||||
@@ -135,9 +135,8 @@ async function walkThroughAppCreation(): Promise<void> {
|
|||||||
' slash commands and messages from the messages tab"',
|
' slash commands and messages from the messages tab"',
|
||||||
' 4. Basic Information → copy the "Signing Secret"',
|
' 4. Basic Information → copy the "Signing Secret"',
|
||||||
' 5. Install to Workspace → copy the "Bot User OAuth Token" (xoxb-…)',
|
' 5. Install to Workspace → copy the "Bot User OAuth Token" (xoxb-…)',
|
||||||
'',
|
formatNoteLink(SLACK_APPS_URL),
|
||||||
k.dim(SLACK_APPS_URL),
|
].filter((line): line is string => line !== null).join('\n'),
|
||||||
].join('\n'),
|
|
||||||
'Create a Slack app',
|
'Create a Slack app',
|
||||||
);
|
);
|
||||||
await confirmThenOpen(SLACK_APPS_URL, 'Press Enter to open Slack app settings');
|
await confirmThenOpen(SLACK_APPS_URL, 'Press Enter to open Slack app settings');
|
||||||
@@ -241,10 +240,9 @@ async function validateSlackToken(token: string): Promise<WorkspaceInfo> {
|
|||||||
user_id?: string;
|
user_id?: string;
|
||||||
error?: string;
|
error?: string;
|
||||||
};
|
};
|
||||||
const elapsedS = Math.round((Date.now() - start) / 1000);
|
|
||||||
if (data.ok && data.team && data.user) {
|
if (data.ok && data.team && data.user) {
|
||||||
s.stop(
|
s.stop(
|
||||||
`Connected to ${data.team} as @${data.user}. ${k.dim(`(${elapsedS}s)`)}`,
|
`Connected to ${data.team} as @${data.user}. ${k.dim(`(${fmtDuration(Date.now() - start)})`)}`,
|
||||||
);
|
);
|
||||||
const info: WorkspaceInfo = {
|
const info: WorkspaceInfo = {
|
||||||
teamName: data.team,
|
teamName: data.team,
|
||||||
@@ -273,8 +271,7 @@ async function validateSlackToken(token: string): Promise<WorkspaceInfo> {
|
|||||||
: `Slack said "${reason}". Check the token scopes and workspace install, then retry.`,
|
: `Slack said "${reason}". Check the token scopes and workspace install, then retry.`,
|
||||||
);
|
);
|
||||||
} catch (err) {
|
} catch (err) {
|
||||||
const elapsedS = Math.round((Date.now() - start) / 1000);
|
s.stop(`Couldn't reach Slack. ${k.dim(`(${fmtDuration(Date.now() - start)})`)}`, 1);
|
||||||
s.stop(`Couldn't reach Slack. ${k.dim(`(${elapsedS}s)`)}`, 1);
|
|
||||||
const message = err instanceof Error ? err.message : String(err);
|
const message = err instanceof Error ? err.message : String(err);
|
||||||
setupLog.step('slack-validate', 'failed', Date.now() - start, {
|
setupLog.step('slack-validate', 'failed', Date.now() - start, {
|
||||||
ERROR: message,
|
ERROR: message,
|
||||||
@@ -334,9 +331,8 @@ async function openDmChannel(token: string, userId: string): Promise<string> {
|
|||||||
channel?: { id?: string };
|
channel?: { id?: string };
|
||||||
error?: string;
|
error?: string;
|
||||||
};
|
};
|
||||||
const elapsedS = Math.round((Date.now() - start) / 1000);
|
|
||||||
if (data.ok && data.channel?.id) {
|
if (data.ok && data.channel?.id) {
|
||||||
s.stop(`DM channel ready. ${k.dim(`(${elapsedS}s)`)}`);
|
s.stop(`DM channel ready. ${k.dim(`(${fmtDuration(Date.now() - start)})`)}`);
|
||||||
setupLog.step('slack-open-dm', 'success', Date.now() - start, {
|
setupLog.step('slack-open-dm', 'success', Date.now() - start, {
|
||||||
DM_CHANNEL_ID: data.channel.id,
|
DM_CHANNEL_ID: data.channel.id,
|
||||||
});
|
});
|
||||||
@@ -360,8 +356,7 @@ async function openDmChannel(token: string, userId: string): Promise<string> {
|
|||||||
`Slack said "${reason}". Check the member ID and app permissions, then retry.`,
|
`Slack said "${reason}". Check the member ID and app permissions, then retry.`,
|
||||||
);
|
);
|
||||||
} catch (err) {
|
} catch (err) {
|
||||||
const elapsedS = Math.round((Date.now() - start) / 1000);
|
s.stop(`Couldn't reach Slack. ${k.dim(`(${fmtDuration(Date.now() - start)})`)}`, 1);
|
||||||
s.stop(`Couldn't reach Slack. ${k.dim(`(${elapsedS}s)`)}`, 1);
|
|
||||||
const message = err instanceof Error ? err.message : String(err);
|
const message = err instanceof Error ? err.message : String(err);
|
||||||
setupLog.step('slack-open-dm', 'failed', Date.now() - start, {
|
setupLog.step('slack-open-dm', 'failed', Date.now() - start, {
|
||||||
ERROR: message,
|
ERROR: message,
|
||||||
|
|||||||
@@ -21,7 +21,7 @@ import * as p from '@clack/prompts';
|
|||||||
import k from 'kleur';
|
import k from 'kleur';
|
||||||
|
|
||||||
import * as setupLog from '../logs.js';
|
import * as setupLog from '../logs.js';
|
||||||
import { confirmThenOpen } from '../lib/browser.js';
|
import { confirmThenOpen, formatNoteLink } from '../lib/browser.js';
|
||||||
import { askOperatorRole } from '../lib/role-prompt.js';
|
import { askOperatorRole } from '../lib/role-prompt.js';
|
||||||
import {
|
import {
|
||||||
type Block,
|
type Block,
|
||||||
@@ -33,7 +33,7 @@ import {
|
|||||||
spawnStep,
|
spawnStep,
|
||||||
writeStepEntry,
|
writeStepEntry,
|
||||||
} from '../lib/runner.js';
|
} from '../lib/runner.js';
|
||||||
import { accentGreen, brandBold, fitToWidth, note } from '../lib/theme.js';
|
import { accentGreen, brandBold, fitToWidth, fmtDuration, note } from '../lib/theme.js';
|
||||||
|
|
||||||
const DEFAULT_AGENT_NAME = 'Nano';
|
const DEFAULT_AGENT_NAME = 'Nano';
|
||||||
|
|
||||||
@@ -50,9 +50,8 @@ export async function runTelegramChannel(displayName: string): Promise<void> {
|
|||||||
note(
|
note(
|
||||||
[
|
[
|
||||||
`Opening @${botUsername} in Telegram so it's ready when the pairing code shows up.`,
|
`Opening @${botUsername} in Telegram so it's ready when the pairing code shows up.`,
|
||||||
'',
|
formatNoteLink(botUrl),
|
||||||
k.dim(botUrl),
|
].filter((line): line is string => line !== null).join('\n'),
|
||||||
].join('\n'),
|
|
||||||
'Open Telegram',
|
'Open Telegram',
|
||||||
);
|
);
|
||||||
await confirmThenOpen(botUrl, 'Press Enter to open Telegram');
|
await confirmThenOpen(botUrl, 'Press Enter to open Telegram');
|
||||||
@@ -191,10 +190,9 @@ async function validateTelegramToken(token: string): Promise<string> {
|
|||||||
result?: { username?: string; id?: number };
|
result?: { username?: string; id?: number };
|
||||||
description?: string;
|
description?: string;
|
||||||
};
|
};
|
||||||
const elapsedS = Math.round((Date.now() - start) / 1000);
|
|
||||||
if (data.ok && data.result?.username) {
|
if (data.ok && data.result?.username) {
|
||||||
const username = data.result.username;
|
const username = data.result.username;
|
||||||
s.stop(`Found your bot: @${username}. ${k.dim(`(${elapsedS}s)`)}`);
|
s.stop(`Found your bot: @${username}. ${k.dim(`(${fmtDuration(Date.now() - start)})`)}`);
|
||||||
setupLog.step('telegram-validate', 'success', Date.now() - start, {
|
setupLog.step('telegram-validate', 'success', Date.now() - start, {
|
||||||
BOT_USERNAME: username,
|
BOT_USERNAME: username,
|
||||||
BOT_ID: data.result.id ?? '',
|
BOT_ID: data.result.id ?? '',
|
||||||
@@ -212,8 +210,7 @@ async function validateTelegramToken(token: string): Promise<string> {
|
|||||||
'Copy the token again from @BotFather and try setup once more.',
|
'Copy the token again from @BotFather and try setup once more.',
|
||||||
);
|
);
|
||||||
} catch (err) {
|
} catch (err) {
|
||||||
const elapsedS = Math.round((Date.now() - start) / 1000);
|
s.stop(`Couldn't reach Telegram. ${k.dim(`(${fmtDuration(Date.now() - start)})`)}`, 1);
|
||||||
s.stop(`Couldn't reach Telegram. ${k.dim(`(${elapsedS}s)`)}`, 1);
|
|
||||||
const message = err instanceof Error ? err.message : String(err);
|
const message = err instanceof Error ? err.message : String(err);
|
||||||
setupLog.step('telegram-validate', 'failed', Date.now() - start, {
|
setupLog.step('telegram-validate', 'failed', Date.now() - start, {
|
||||||
ERROR: message,
|
ERROR: message,
|
||||||
|
|||||||
@@ -46,7 +46,7 @@ import {
|
|||||||
writeStepEntry,
|
writeStepEntry,
|
||||||
} from '../lib/runner.js';
|
} from '../lib/runner.js';
|
||||||
import { askOperatorRole } from '../lib/role-prompt.js';
|
import { askOperatorRole } from '../lib/role-prompt.js';
|
||||||
import { accentGreen, brandBody, brandBold, note } from '../lib/theme.js';
|
import { accentGreen, brandBody, brandBold, fmtDuration, note } from '../lib/theme.js';
|
||||||
|
|
||||||
const DEFAULT_AGENT_NAME = 'Nano';
|
const DEFAULT_AGENT_NAME = 'Nano';
|
||||||
const AUTH_CREDS_PATH = path.join(process.cwd(), 'store', 'auth', 'creds.json');
|
const AUTH_CREDS_PATH = path.join(process.cwd(), 'store', 'auth', 'creds.json');
|
||||||
@@ -379,8 +379,7 @@ async function restartService(): Promise<void> {
|
|||||||
// Give the adapter a moment to reconnect before init-first-agent's
|
// Give the adapter a moment to reconnect before init-first-agent's
|
||||||
// welcome DM hits the delivery path.
|
// welcome DM hits the delivery path.
|
||||||
await new Promise((r) => setTimeout(r, 5000));
|
await new Promise((r) => setTimeout(r, 5000));
|
||||||
const elapsed = Math.round((Date.now() - start) / 1000);
|
s.stop(`NanoClaw restarted. ${k.dim(`(${fmtDuration(Date.now() - start)})`)}`);
|
||||||
s.stop(`NanoClaw restarted. ${k.dim(`(${elapsed}s)`)}`);
|
|
||||||
setupLog.step('whatsapp-restart', 'success', Date.now() - start, {
|
setupLog.step('whatsapp-restart', 'success', Date.now() - start, {
|
||||||
PLATFORM: platform,
|
PLATFORM: platform,
|
||||||
});
|
});
|
||||||
|
|||||||
@@ -9,12 +9,19 @@
|
|||||||
* `confirmThenOpen` pauses for the operator before triggering the open —
|
* `confirmThenOpen` pauses for the operator before triggering the open —
|
||||||
* the browser tends to steal focus when it pops, and a split-second
|
* the browser tends to steal focus when it pops, and a split-second
|
||||||
* "wait what just happened" moment is worse than letting the user hit
|
* "wait what just happened" moment is worse than letting the user hit
|
||||||
* Enter when they're ready.
|
* Enter when they're ready. On headless devices (no graphical session
|
||||||
|
* available) it skips both the prompt and the open: there's no browser
|
||||||
|
* to launch, the surrounding `note(...)` already shows the URL for
|
||||||
|
* copy-paste on another device, and the next prompt in the channel
|
||||||
|
* flow ("Got your bot token?" etc.) provides the natural completion
|
||||||
|
* confirmation.
|
||||||
*/
|
*/
|
||||||
import { spawn } from 'child_process';
|
import { spawn } from 'child_process';
|
||||||
|
|
||||||
import * as p from '@clack/prompts';
|
import * as p from '@clack/prompts';
|
||||||
|
import k from 'kleur';
|
||||||
|
|
||||||
|
import { isHeadless } from '../platform.js';
|
||||||
import { ensureAnswer } from './runner.js';
|
import { ensureAnswer } from './runner.js';
|
||||||
|
|
||||||
/** Best-effort open of a URL in the user's default browser. Silent on failure. */
|
/** Best-effort open of a URL in the user's default browser. Silent on failure. */
|
||||||
@@ -32,18 +39,43 @@ export function openUrl(url: string): void {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Format a URL for inclusion in a setup `note(...)` card. On
|
||||||
|
* headless devices we surface the URL inside the card with a
|
||||||
|
* "Get started:" label at full strength — copy-pasting onto
|
||||||
|
* another device is the actual action, not an incidental
|
||||||
|
* reference. The leading `\n` acts as a visual separator from
|
||||||
|
* the body steps above; callers `.filter(line => line !== null)`
|
||||||
|
* before joining, so on GUI we drop the line entirely (and the
|
||||||
|
* URL ends up below the next-step confirm prompt as a "if
|
||||||
|
* browser does not appear, please visit" fallback — see
|
||||||
|
* `confirmThenOpen`).
|
||||||
|
*/
|
||||||
|
export function formatNoteLink(url: string): string | null {
|
||||||
|
if (isHeadless()) return `\nGet started: ${url}`;
|
||||||
|
return null;
|
||||||
|
}
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* Gate a browser-open on a confirm so the user is ready for their browser
|
* Gate a browser-open on a confirm so the user is ready for their browser
|
||||||
* to take focus. Proceeds on cancel as well — the user can always copy the
|
* to take focus. Proceeds on cancel as well. On headless devices both the
|
||||||
* URL from the note that precedes the prompt.
|
* prompt and the open are skipped — the URL is already surfaced inside
|
||||||
|
* the surrounding note (via `formatNoteLink`).
|
||||||
|
*
|
||||||
|
* On GUI devices the confirm message includes the fallback URL on the
|
||||||
|
* lines below the action ("If browser does not appear, please visit:
|
||||||
|
* <url>" in dim) so the user has a copy-paste path right next to the
|
||||||
|
* action button without needing to scroll back up to the card.
|
||||||
*/
|
*/
|
||||||
export async function confirmThenOpen(
|
export async function confirmThenOpen(
|
||||||
url: string,
|
url: string,
|
||||||
message = 'Press Enter to open your browser',
|
message = 'Press Enter to open your browser',
|
||||||
): Promise<void> {
|
): Promise<void> {
|
||||||
|
if (isHeadless()) return;
|
||||||
|
const fallback = `\n${k.dim(`If browser does not appear, please visit: ${url}`)}`;
|
||||||
ensureAnswer(
|
ensureAnswer(
|
||||||
await p.confirm({
|
await p.confirm({
|
||||||
message,
|
message: `${message}${fallback}`,
|
||||||
initialValue: true,
|
initialValue: true,
|
||||||
}),
|
}),
|
||||||
);
|
);
|
||||||
|
|||||||
@@ -28,7 +28,7 @@ import * as p from '@clack/prompts';
|
|||||||
import k from 'kleur';
|
import k from 'kleur';
|
||||||
|
|
||||||
import { ensureAnswer } from './runner.js';
|
import { ensureAnswer } from './runner.js';
|
||||||
import { brandBody, fitToWidth, note } from './theme.js';
|
import { brandBody, fitToWidth, fmtDuration, note } from './theme.js';
|
||||||
|
|
||||||
export interface AssistContext {
|
export interface AssistContext {
|
||||||
stepName: string;
|
stepName: string;
|
||||||
@@ -295,9 +295,8 @@ async function queryClaudeUnderSpinner(
|
|||||||
// Move cursor back to the start of the block (WINDOW_SIZE + 1 = header + window).
|
// Move cursor back to the start of the block (WINDOW_SIZE + 1 = header + window).
|
||||||
out.write(`\x1b[${WINDOW_SIZE + 1}A`);
|
out.write(`\x1b[${WINDOW_SIZE + 1}A`);
|
||||||
|
|
||||||
const elapsed = Math.round((Date.now() - start) / 1000);
|
|
||||||
const icon = SPINNER_FRAMES[frameIdx % SPINNER_FRAMES.length];
|
const icon = SPINNER_FRAMES[frameIdx % SPINNER_FRAMES.length];
|
||||||
const suffix = ` (${elapsed}s)`;
|
const suffix = ` (${fmtDuration(Date.now() - start)})`;
|
||||||
const header = fitToWidth('Asking Claude to diagnose…', suffix);
|
const header = fitToWidth('Asking Claude to diagnose…', suffix);
|
||||||
out.write(`\x1b[2K${k.cyan(icon)} ${header}${k.dim(suffix)}\n`);
|
out.write(`\x1b[2K${k.cyan(icon)} ${header}${k.dim(suffix)}\n`);
|
||||||
|
|
||||||
@@ -355,8 +354,7 @@ async function queryClaudeUnderSpinner(
|
|||||||
clearBlock();
|
clearBlock();
|
||||||
out.write(SHOW_CURSOR);
|
out.write(SHOW_CURSOR);
|
||||||
process.off('exit', restoreCursorOnExit);
|
process.off('exit', restoreCursorOnExit);
|
||||||
const elapsed = Math.round((Date.now() - start) / 1000);
|
const suffix = ` (${fmtDuration(Date.now() - start)})`;
|
||||||
const suffix = ` (${elapsed}s)`;
|
|
||||||
if (kind === 'ok') {
|
if (kind === 'ok') {
|
||||||
p.log.success(`${brandBody(fitToWidth('Claude replied.', suffix))}${k.dim(suffix)}`);
|
p.log.success(`${brandBody(fitToWidth('Claude replied.', suffix))}${k.dim(suffix)}`);
|
||||||
resolve(payload);
|
resolve(payload);
|
||||||
|
|||||||
@@ -20,7 +20,7 @@ import k from 'kleur';
|
|||||||
import * as setupLog from '../logs.js';
|
import * as setupLog from '../logs.js';
|
||||||
import { offerClaudeAssist } from './claude-assist.js';
|
import { offerClaudeAssist } from './claude-assist.js';
|
||||||
import { emit as phEmit } from './diagnostics.js';
|
import { emit as phEmit } from './diagnostics.js';
|
||||||
import { brandBody, fitToWidth } from './theme.js';
|
import { brandBody, fitToWidth, fmtDuration } from './theme.js';
|
||||||
|
|
||||||
export type Fields = Record<string, string>;
|
export type Fields = Record<string, string>;
|
||||||
export type Block = { type: string; fields: Fields };
|
export type Block = { type: string; fields: Fields };
|
||||||
@@ -307,18 +307,16 @@ async function runUnderSpinner<
|
|||||||
): Promise<T> {
|
): Promise<T> {
|
||||||
const s = p.spinner();
|
const s = p.spinner();
|
||||||
const start = Date.now();
|
const start = Date.now();
|
||||||
s.start(fitToWidth(labels.running, ' (999s)'));
|
s.start(fitToWidth(labels.running, ' (99m 59s)'));
|
||||||
const tick = setInterval(() => {
|
const tick = setInterval(() => {
|
||||||
const elapsed = Math.round((Date.now() - start) / 1000);
|
const suffix = ` (${fmtDuration(Date.now() - start)})`;
|
||||||
const suffix = ` (${elapsed}s)`;
|
|
||||||
s.message(`${fitToWidth(labels.running, suffix)}${k.dim(suffix)}`);
|
s.message(`${fitToWidth(labels.running, suffix)}${k.dim(suffix)}`);
|
||||||
}, 1000);
|
}, 1000);
|
||||||
|
|
||||||
const result = await work();
|
const result = await work();
|
||||||
|
|
||||||
clearInterval(tick);
|
clearInterval(tick);
|
||||||
const elapsed = Math.round((Date.now() - start) / 1000);
|
const suffix = ` (${fmtDuration(Date.now() - start)})`;
|
||||||
const suffix = ` (${elapsed}s)`;
|
|
||||||
if (result.ok) {
|
if (result.ok) {
|
||||||
const isSkipped = result.terminal?.fields.STATUS === 'skipped';
|
const isSkipped = result.terminal?.fields.STATUS === 'skipped';
|
||||||
const msg = isSkipped && labels.skipped ? labels.skipped : labels.done;
|
const msg = isSkipped && labels.skipped ? labels.skipped : labels.done;
|
||||||
|
|||||||
@@ -51,6 +51,22 @@ export function accentGreen(s: string): string {
|
|||||||
return k.green(s);
|
return k.green(s);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Format an elapsed-time duration (in milliseconds) for the spinner
|
||||||
|
* suffixes setup writes everywhere. Sub-minute durations stay in plain
|
||||||
|
* seconds (`47s`); once the timer crosses 60 seconds we switch to the
|
||||||
|
* `Xm Ys` form (`2m 34s`) so a long step doesn't read as `247s` or
|
||||||
|
* similar. The format is consistent above 60s — `4m 0s` over `4m` —
|
||||||
|
* so live spinner output doesn't change shape at every whole minute.
|
||||||
|
*/
|
||||||
|
export function fmtDuration(ms: number): string {
|
||||||
|
const totalSec = Math.round(ms / 1000);
|
||||||
|
if (totalSec < 60) return `${totalSec}s`;
|
||||||
|
const m = Math.floor(totalSec / 60);
|
||||||
|
const s = totalSec % 60;
|
||||||
|
return `${m}m ${s}s`;
|
||||||
|
}
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* Brand body color for setup-flow prose. Used for card bodies (via the
|
* Brand body color for setup-flow prose. Used for card bodies (via the
|
||||||
* `note()` formatter) and `p.log.*` body arguments — anywhere the
|
* `note()` formatter) and `p.log.*` body arguments — anywhere the
|
||||||
|
|||||||
@@ -17,7 +17,7 @@ import * as p from '@clack/prompts';
|
|||||||
import k from 'kleur';
|
import k from 'kleur';
|
||||||
|
|
||||||
import { isValidTimezone } from '../../src/timezone.js';
|
import { isValidTimezone } from '../../src/timezone.js';
|
||||||
import { fitToWidth } from './theme.js';
|
import { fitToWidth, fmtDuration } from './theme.js';
|
||||||
|
|
||||||
export function claudeCliAvailable(): boolean {
|
export function claudeCliAvailable(): boolean {
|
||||||
try {
|
try {
|
||||||
@@ -44,18 +44,16 @@ export async function resolveTimezoneViaClaude(
|
|||||||
const s = p.spinner();
|
const s = p.spinner();
|
||||||
const start = Date.now();
|
const start = Date.now();
|
||||||
const label = 'Looking up that timezone…';
|
const label = 'Looking up that timezone…';
|
||||||
s.start(fitToWidth(label, ' (999s)'));
|
s.start(fitToWidth(label, ' (99m 59s)'));
|
||||||
const tick = setInterval(() => {
|
const tick = setInterval(() => {
|
||||||
const elapsed = Math.round((Date.now() - start) / 1000);
|
const suffix = ` (${fmtDuration(Date.now() - start)})`;
|
||||||
const suffix = ` (${elapsed}s)`;
|
|
||||||
s.message(`${fitToWidth(label, suffix)}${k.dim(suffix)}`);
|
s.message(`${fitToWidth(label, suffix)}${k.dim(suffix)}`);
|
||||||
}, 1000);
|
}, 1000);
|
||||||
|
|
||||||
const reply = await queryClaude(prompt);
|
const reply = await queryClaude(prompt);
|
||||||
|
|
||||||
clearInterval(tick);
|
clearInterval(tick);
|
||||||
const elapsed = Math.round((Date.now() - start) / 1000);
|
const suffix = ` (${fmtDuration(Date.now() - start)})`;
|
||||||
const suffix = ` (${elapsed}s)`;
|
|
||||||
|
|
||||||
const resolved = reply ? extractTimezone(reply) : null;
|
const resolved = reply ? extractTimezone(reply) : null;
|
||||||
if (resolved) {
|
if (resolved) {
|
||||||
|
|||||||
Reference in New Issue
Block a user