Merge branch 'main' into setup-color-choices
This commit is contained in:
@@ -46,6 +46,7 @@ import {
|
|||||||
} from './lib/setup-config-parse.js';
|
} from './lib/setup-config-parse.js';
|
||||||
import { runAdvancedScreen } from './lib/setup-config-screen.js';
|
import { runAdvancedScreen } from './lib/setup-config-screen.js';
|
||||||
import { runWindowedStep } from './lib/windowed-runner.js';
|
import { runWindowedStep } from './lib/windowed-runner.js';
|
||||||
|
import { detectRegisteredGroups, detectExistingDisplayName } from './environment.js';
|
||||||
import { pollHealth } from './onecli.js';
|
import { pollHealth } from './onecli.js';
|
||||||
import { getLaunchdLabel, getSystemdUnit } from '../src/install-slug.js';
|
import { getLaunchdLabel, getSystemdUnit } from '../src/install-slug.js';
|
||||||
import { claudeCliAvailable, resolveTimezoneViaClaude } from './lib/tz-from-claude.js';
|
import { claudeCliAvailable, resolveTimezoneViaClaude } from './lib/tz-from-claude.js';
|
||||||
@@ -121,6 +122,39 @@ async function main(): Promise<void> {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// Detect existing .env and offer to reuse it so the user doesn't have to
|
||||||
|
// paste credentials again on a re-run.
|
||||||
|
const existingEnv = detectExistingEnv();
|
||||||
|
if (existingEnv) {
|
||||||
|
const lines = Object.values(existingEnv.groups).map(
|
||||||
|
(g) => ` ${k.green('✓')} ${g.label}`,
|
||||||
|
);
|
||||||
|
note(lines.join('\n'), 'Found existing configuration');
|
||||||
|
|
||||||
|
const reuseChoice = ensureAnswer(
|
||||||
|
await brightSelect({
|
||||||
|
message: 'Use this existing environment?',
|
||||||
|
options: [
|
||||||
|
{ value: 'reuse', label: 'Yes, use what I already have', hint: 'recommended' },
|
||||||
|
{ value: 'fresh', label: 'No, start fresh' },
|
||||||
|
],
|
||||||
|
initialValue: 'reuse',
|
||||||
|
}),
|
||||||
|
) as 'reuse' | 'fresh';
|
||||||
|
setupLog.userInput('existing_env_choice', reuseChoice);
|
||||||
|
|
||||||
|
if (reuseChoice === 'reuse') {
|
||||||
|
for (const [key, value] of Object.entries(existingEnv.raw)) {
|
||||||
|
if (!process.env[key]) process.env[key] = value;
|
||||||
|
}
|
||||||
|
if (existingEnv.groups.onecli) skip.add('onecli');
|
||||||
|
if (detectRegisteredGroups(process.cwd())) {
|
||||||
|
skip.add('cli-agent');
|
||||||
|
skip.add('first-chat');
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
if (!skip.has('container')) {
|
if (!skip.has('container')) {
|
||||||
p.log.message(brandBody(dimWrap('Your assistant lives in its own sandbox. It can only see what you explicitly share.', 4)));
|
p.log.message(brandBody(dimWrap('Your assistant lives in its own sandbox. It can only see what you explicitly share.', 4)));
|
||||||
p.log.message(
|
p.log.message(
|
||||||
@@ -301,14 +335,17 @@ async function main(): Promise<void> {
|
|||||||
}
|
}
|
||||||
|
|
||||||
let displayName: string | undefined;
|
let displayName: string | undefined;
|
||||||
const needsDisplayName = !skip.has('cli-agent') || !skip.has('channel');
|
async function resolveDisplayName(): Promise<string> {
|
||||||
if (needsDisplayName) {
|
if (displayName) return displayName;
|
||||||
const fallback = process.env.USER?.trim() || 'Operator';
|
|
||||||
const preset = process.env.NANOCLAW_DISPLAY_NAME?.trim();
|
const preset = process.env.NANOCLAW_DISPLAY_NAME?.trim();
|
||||||
displayName = preset || (await askDisplayName(fallback));
|
const existing = detectExistingDisplayName(process.cwd());
|
||||||
|
const fallback = process.env.USER?.trim() || 'Operator';
|
||||||
|
displayName = preset || existing || (await askDisplayName(fallback));
|
||||||
|
return displayName;
|
||||||
}
|
}
|
||||||
|
|
||||||
if (!skip.has('cli-agent')) {
|
if (!skip.has('cli-agent')) {
|
||||||
|
await resolveDisplayName();
|
||||||
const res = await runQuietStep(
|
const res = await runQuietStep(
|
||||||
'cli-agent',
|
'cli-agent',
|
||||||
{
|
{
|
||||||
@@ -379,6 +416,9 @@ async function main(): Promise<void> {
|
|||||||
let channelChoice: ChannelChoice = 'skip';
|
let channelChoice: ChannelChoice = 'skip';
|
||||||
if (!skip.has('channel')) {
|
if (!skip.has('channel')) {
|
||||||
channelChoice = await askChannelChoice();
|
channelChoice = await askChannelChoice();
|
||||||
|
if (channelChoice !== 'skip') {
|
||||||
|
await resolveDisplayName();
|
||||||
|
}
|
||||||
if (channelChoice === 'telegram') {
|
if (channelChoice === 'telegram') {
|
||||||
await runTelegramChannel(displayName!);
|
await runTelegramChannel(displayName!);
|
||||||
} else if (channelChoice === 'discord') {
|
} else if (channelChoice === 'discord') {
|
||||||
@@ -716,6 +756,7 @@ async function runPasteAuth(method: 'oauth' | 'api'): Promise<void> {
|
|||||||
const answer = ensureAnswer(
|
const answer = ensureAnswer(
|
||||||
await p.password({
|
await p.password({
|
||||||
message: `Paste your ${label}`,
|
message: `Paste your ${label}`,
|
||||||
|
clearOnError: true,
|
||||||
validate: (v) => {
|
validate: (v) => {
|
||||||
if (!v || !v.trim()) return 'Required';
|
if (!v || !v.trim()) return 'Required';
|
||||||
if (!v.trim().startsWith(prefix)) {
|
if (!v.trim().startsWith(prefix)) {
|
||||||
@@ -1022,6 +1063,56 @@ async function askChannelChoice(): Promise<ChannelChoice> {
|
|||||||
|
|
||||||
// ─── interactive / env helpers ─────────────────────────────────────────
|
// ─── interactive / env helpers ─────────────────────────────────────────
|
||||||
|
|
||||||
|
interface ExistingEnvGroup {
|
||||||
|
label: string;
|
||||||
|
keys: string[];
|
||||||
|
}
|
||||||
|
|
||||||
|
const ENV_KEY_GROUPS: Record<string, { label: string; keys: string[] }> = {
|
||||||
|
onecli: { label: 'OneCLI', keys: ['ONECLI_URL'] },
|
||||||
|
telegram: { label: 'Telegram', keys: ['TELEGRAM_BOT_TOKEN'] },
|
||||||
|
discord: { label: 'Discord', keys: ['DISCORD_BOT_TOKEN', 'DISCORD_APPLICATION_ID', 'DISCORD_PUBLIC_KEY'] },
|
||||||
|
slack: { label: 'Slack', keys: ['SLACK_BOT_TOKEN', 'SLACK_SIGNING_SECRET'] },
|
||||||
|
signal: { label: 'Signal', keys: ['SIGNAL_ACCOUNT'] },
|
||||||
|
teams: { label: 'Teams', keys: ['TEAMS_APP_ID', 'TEAMS_APP_PASSWORD', 'TEAMS_APP_TENANT_ID', 'TEAMS_APP_TYPE'] },
|
||||||
|
whatsapp: { label: 'WhatsApp', keys: ['ASSISTANT_HAS_OWN_NUMBER'] },
|
||||||
|
imessage: { label: 'iMessage', keys: ['IMESSAGE_LOCAL', 'IMESSAGE_ENABLED', 'IMESSAGE_SERVER_URL', 'IMESSAGE_API_KEY'] },
|
||||||
|
};
|
||||||
|
|
||||||
|
function detectExistingEnv(): { groups: Record<string, ExistingEnvGroup>; raw: Record<string, string> } | null {
|
||||||
|
const envPath = path.join(process.cwd(), '.env');
|
||||||
|
if (!fs.existsSync(envPath)) return null;
|
||||||
|
|
||||||
|
let content: string;
|
||||||
|
try {
|
||||||
|
content = fs.readFileSync(envPath, 'utf-8');
|
||||||
|
} catch {
|
||||||
|
return null;
|
||||||
|
}
|
||||||
|
|
||||||
|
const raw: Record<string, string> = {};
|
||||||
|
for (const line of content.split('\n')) {
|
||||||
|
const trimmed = line.trim();
|
||||||
|
if (!trimmed || trimmed.startsWith('#')) continue;
|
||||||
|
const eq = trimmed.indexOf('=');
|
||||||
|
if (eq < 1) continue;
|
||||||
|
raw[trimmed.slice(0, eq)] = trimmed.slice(eq + 1);
|
||||||
|
}
|
||||||
|
|
||||||
|
if (Object.keys(raw).length === 0) return null;
|
||||||
|
|
||||||
|
const groups: Record<string, ExistingEnvGroup> = {};
|
||||||
|
for (const [id, def] of Object.entries(ENV_KEY_GROUPS)) {
|
||||||
|
const found = def.keys.filter((key) => raw[key] !== undefined);
|
||||||
|
if (found.length > 0) {
|
||||||
|
groups[id] = { label: def.label, keys: found };
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
if (Object.keys(groups).length === 0) return null;
|
||||||
|
return { groups, raw };
|
||||||
|
}
|
||||||
|
|
||||||
function anthropicSecretExists(): boolean {
|
function anthropicSecretExists(): boolean {
|
||||||
try {
|
try {
|
||||||
const res = spawnSync('onecli', ['secrets', 'list'], {
|
const res = spawnSync('onecli', ['secrets', 'list'], {
|
||||||
|
|||||||
@@ -240,9 +240,22 @@ async function walkThroughServerCreation(): Promise<void> {
|
|||||||
}
|
}
|
||||||
|
|
||||||
async function collectDiscordToken(): Promise<string> {
|
async function collectDiscordToken(): Promise<string> {
|
||||||
|
const existing = process.env.DISCORD_BOT_TOKEN?.trim();
|
||||||
|
if (existing && /^[A-Za-z0-9._-]{50,}$/.test(existing)) {
|
||||||
|
const reuse = ensureAnswer(await p.confirm({
|
||||||
|
message: `Found an existing Discord bot token (${existing.slice(0, 10)}…). Use it?`,
|
||||||
|
initialValue: true,
|
||||||
|
}));
|
||||||
|
if (reuse) {
|
||||||
|
setupLog.userInput('discord_token', 'reused-existing');
|
||||||
|
return existing;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
const answer = ensureAnswer(
|
const answer = ensureAnswer(
|
||||||
await p.password({
|
await p.password({
|
||||||
message: 'Paste your bot token',
|
message: 'Paste your bot token',
|
||||||
|
clearOnError: true,
|
||||||
validate: (v) => {
|
validate: (v) => {
|
||||||
const t = (v ?? '').trim();
|
const t = (v ?? '').trim();
|
||||||
if (!t) return 'Token is required';
|
if (!t) return 'Token is required';
|
||||||
|
|||||||
@@ -222,6 +222,19 @@ async function walkThroughFullDiskAccess(): Promise<void> {
|
|||||||
}
|
}
|
||||||
|
|
||||||
async function collectRemoteCreds(): Promise<RemoteCreds> {
|
async function collectRemoteCreds(): Promise<RemoteCreds> {
|
||||||
|
const existingUrl = process.env.IMESSAGE_SERVER_URL?.trim();
|
||||||
|
const existingKey = process.env.IMESSAGE_API_KEY?.trim();
|
||||||
|
if (existingUrl && existingKey && /^https?:\/\//i.test(existingUrl)) {
|
||||||
|
const reuse = ensureAnswer(await p.confirm({
|
||||||
|
message: `Found existing Photon credentials (${existingUrl}). Use them?`,
|
||||||
|
initialValue: true,
|
||||||
|
}));
|
||||||
|
if (reuse) {
|
||||||
|
setupLog.userInput('imessage_remote_creds', 'reused-existing');
|
||||||
|
return { serverUrl: existingUrl, apiKey: existingKey };
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
note(
|
note(
|
||||||
[
|
[
|
||||||
"Photon is a separate service that owns an iMessage account and",
|
"Photon is a separate service that owns an iMessage account and",
|
||||||
@@ -250,6 +263,7 @@ async function collectRemoteCreds(): Promise<RemoteCreds> {
|
|||||||
const keyAnswer = ensureAnswer(
|
const keyAnswer = ensureAnswer(
|
||||||
await p.password({
|
await p.password({
|
||||||
message: 'Photon API key',
|
message: 'Photon API key',
|
||||||
|
clearOnError: true,
|
||||||
validate: (v) => ((v ?? '').trim() ? undefined : 'API key is required'),
|
validate: (v) => ((v ?? '').trim() ? undefined : 'API key is required'),
|
||||||
}),
|
}),
|
||||||
);
|
);
|
||||||
|
|||||||
@@ -151,9 +151,22 @@ async function walkThroughAppCreation(): Promise<void> {
|
|||||||
}
|
}
|
||||||
|
|
||||||
async function collectBotToken(): Promise<string> {
|
async function collectBotToken(): Promise<string> {
|
||||||
|
const existing = process.env.SLACK_BOT_TOKEN?.trim();
|
||||||
|
if (existing && existing.startsWith('xoxb-') && existing.length >= 24) {
|
||||||
|
const reuse = ensureAnswer(await p.confirm({
|
||||||
|
message: `Found an existing Slack bot token (${existing.slice(0, 10)}…). Use it?`,
|
||||||
|
initialValue: true,
|
||||||
|
}));
|
||||||
|
if (reuse) {
|
||||||
|
setupLog.userInput('slack_bot_token', 'reused-existing');
|
||||||
|
return existing;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
const answer = ensureAnswer(
|
const answer = ensureAnswer(
|
||||||
await p.password({
|
await p.password({
|
||||||
message: 'Paste your Slack bot token',
|
message: 'Paste your Slack bot token',
|
||||||
|
clearOnError: true,
|
||||||
validate: (v) => {
|
validate: (v) => {
|
||||||
const t = (v ?? '').trim();
|
const t = (v ?? '').trim();
|
||||||
if (!t) return 'Token is required';
|
if (!t) return 'Token is required';
|
||||||
@@ -172,9 +185,22 @@ async function collectBotToken(): Promise<string> {
|
|||||||
}
|
}
|
||||||
|
|
||||||
async function collectSigningSecret(): Promise<string> {
|
async function collectSigningSecret(): Promise<string> {
|
||||||
|
const existing = process.env.SLACK_SIGNING_SECRET?.trim();
|
||||||
|
if (existing && /^[a-f0-9]{16,}$/i.test(existing)) {
|
||||||
|
const reuse = ensureAnswer(await p.confirm({
|
||||||
|
message: 'Found an existing Slack signing secret. Use it?',
|
||||||
|
initialValue: true,
|
||||||
|
}));
|
||||||
|
if (reuse) {
|
||||||
|
setupLog.userInput('slack_signing_secret', 'reused-existing');
|
||||||
|
return existing;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
const answer = ensureAnswer(
|
const answer = ensureAnswer(
|
||||||
await p.password({
|
await p.password({
|
||||||
message: 'Paste your Slack signing secret',
|
message: 'Paste your Slack signing secret',
|
||||||
|
clearOnError: true,
|
||||||
validate: (v) => {
|
validate: (v) => {
|
||||||
const t = (v ?? '').trim();
|
const t = (v ?? '').trim();
|
||||||
if (!t) return 'Signing secret is required';
|
if (!t) return 'Signing secret is required';
|
||||||
|
|||||||
@@ -60,6 +60,28 @@ export async function runTeamsChannel(_displayName: string): Promise<void> {
|
|||||||
const collected: Collected = {};
|
const collected: Collected = {};
|
||||||
const completed: string[] = [];
|
const completed: string[] = [];
|
||||||
|
|
||||||
|
const existingAppId = process.env.TEAMS_APP_ID?.trim();
|
||||||
|
const existingPassword = process.env.TEAMS_APP_PASSWORD?.trim();
|
||||||
|
if (existingAppId && existingPassword) {
|
||||||
|
const reuse = ensureAnswer(await p.confirm({
|
||||||
|
message: `Found existing Teams credentials (App ID: ${existingAppId.slice(0, 8)}…). Use them?`,
|
||||||
|
initialValue: true,
|
||||||
|
}));
|
||||||
|
if (reuse) {
|
||||||
|
collected.appId = existingAppId;
|
||||||
|
collected.appPassword = existingPassword;
|
||||||
|
collected.appType = (process.env.TEAMS_APP_TYPE?.trim() as 'SingleTenant' | 'MultiTenant') || 'MultiTenant';
|
||||||
|
if (collected.appType === 'SingleTenant') {
|
||||||
|
collected.tenantId = process.env.TEAMS_APP_TENANT_ID?.trim();
|
||||||
|
}
|
||||||
|
setupLog.userInput('teams_credentials', 'reused-existing');
|
||||||
|
await installAdapter(collected);
|
||||||
|
completed.push('Adapter installed and service restarted (reused existing credentials).');
|
||||||
|
await finishWithHandoff(collected, completed);
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
printIntro();
|
printIntro();
|
||||||
|
|
||||||
await confirmPrereqs({ collected, completed });
|
await confirmPrereqs({ collected, completed });
|
||||||
@@ -277,6 +299,7 @@ async function stepClientSecret(args: {
|
|||||||
const answer = ensureAnswer(
|
const answer = ensureAnswer(
|
||||||
await p.password({
|
await p.password({
|
||||||
message: 'Paste the client secret Value',
|
message: 'Paste the client secret Value',
|
||||||
|
clearOnError: true,
|
||||||
validate: validateWithHelpEscape((v) => {
|
validate: validateWithHelpEscape((v) => {
|
||||||
const t = (v ?? '').trim();
|
const t = (v ?? '').trim();
|
||||||
if (!t) return 'Required';
|
if (!t) return 'Required';
|
||||||
|
|||||||
@@ -132,6 +132,18 @@ export async function runTelegramChannel(displayName: string): Promise<void> {
|
|||||||
}
|
}
|
||||||
|
|
||||||
async function collectTelegramToken(): Promise<string> {
|
async function collectTelegramToken(): Promise<string> {
|
||||||
|
const existing = process.env.TELEGRAM_BOT_TOKEN?.trim();
|
||||||
|
if (existing && /^[0-9]+:[A-Za-z0-9_-]{35,}$/.test(existing)) {
|
||||||
|
const reuse = ensureAnswer(await p.confirm({
|
||||||
|
message: `Found an existing Telegram bot token (${existing.slice(0, 8)}…). Use it?`,
|
||||||
|
initialValue: true,
|
||||||
|
}));
|
||||||
|
if (reuse) {
|
||||||
|
setupLog.userInput('telegram_token', 'reused-existing');
|
||||||
|
return existing;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
note(
|
note(
|
||||||
[
|
[
|
||||||
"Your assistant talks to you through a Telegram bot you create.",
|
"Your assistant talks to you through a Telegram bot you create.",
|
||||||
@@ -150,6 +162,7 @@ async function collectTelegramToken(): Promise<string> {
|
|||||||
const answer = ensureAnswer(
|
const answer = ensureAnswer(
|
||||||
await p.password({
|
await p.password({
|
||||||
message: 'Paste your bot token',
|
message: 'Paste your bot token',
|
||||||
|
clearOnError: true,
|
||||||
validate: (v) => {
|
validate: (v) => {
|
||||||
if (!v || !v.trim()) return "Token is required";
|
if (!v || !v.trim()) return "Token is required";
|
||||||
if (!/^[0-9]+:[A-Za-z0-9_-]{35,}$/.test(v.trim())) {
|
if (!/^[0-9]+:[A-Za-z0-9_-]{35,}$/.test(v.trim())) {
|
||||||
|
|||||||
@@ -11,6 +11,24 @@ import { log } from '../src/log.js';
|
|||||||
import { commandExists, getPlatform, isHeadless, isWSL } from './platform.js';
|
import { commandExists, getPlatform, isHeadless, isWSL } from './platform.js';
|
||||||
import { emitStatus } from './status.js';
|
import { emitStatus } from './status.js';
|
||||||
|
|
||||||
|
export function detectExistingDisplayName(projectRoot: string): string | null {
|
||||||
|
const dbPath = path.join(projectRoot, 'data', 'v2.db');
|
||||||
|
if (!fs.existsSync(dbPath)) return null;
|
||||||
|
|
||||||
|
let db: Database.Database | null = null;
|
||||||
|
try {
|
||||||
|
db = new Database(dbPath, { readonly: true });
|
||||||
|
const row = db
|
||||||
|
.prepare(`SELECT display_name FROM users WHERE id = 'cli:local'`)
|
||||||
|
.get() as { display_name: string } | undefined;
|
||||||
|
return row?.display_name?.trim() || null;
|
||||||
|
} catch {
|
||||||
|
return null;
|
||||||
|
} finally {
|
||||||
|
db?.close();
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
export function detectRegisteredGroups(projectRoot: string): boolean {
|
export function detectRegisteredGroups(projectRoot: string): boolean {
|
||||||
if (fs.existsSync(path.join(projectRoot, 'data', 'registered_groups.json'))) {
|
if (fs.existsSync(path.join(projectRoot, 'data', 'registered_groups.json'))) {
|
||||||
return true;
|
return true;
|
||||||
|
|||||||
@@ -2,8 +2,11 @@
|
|||||||
* Offer Claude-assisted debugging when a setup step fails.
|
* Offer Claude-assisted debugging when a setup step fails.
|
||||||
*
|
*
|
||||||
* Flow:
|
* Flow:
|
||||||
* 1. Check `claude` is on PATH and has a working credential. If not,
|
* 1. Check `claude` is on PATH — if not, offer to install it via
|
||||||
* silently skip — pre-auth failures can't use this path.
|
* setup/install-claude.sh. Then check auth via `claude auth status`
|
||||||
|
* — if not signed in, offer to run `claude setup-token` (browser
|
||||||
|
* OAuth with code-paste fallback for headless/remote systems).
|
||||||
|
* If either is declined or fails, silently skip.
|
||||||
* 2. Ask the user for consent ("Want me to ask Claude for a fix?").
|
* 2. Ask the user for consent ("Want me to ask Claude for a fix?").
|
||||||
* 3. Build a minimal prompt: the one-paragraph situation, the failing
|
* 3. Build a minimal prompt: the one-paragraph situation, the failing
|
||||||
* step's name/message/hint, and a short list of *file references*
|
* step's name/message/hint, and a short list of *file references*
|
||||||
@@ -16,8 +19,9 @@
|
|||||||
*
|
*
|
||||||
* Skippable with NANOCLAW_SKIP_CLAUDE_ASSIST=1 for CI/scripted runs.
|
* Skippable with NANOCLAW_SKIP_CLAUDE_ASSIST=1 for CI/scripted runs.
|
||||||
*/
|
*/
|
||||||
import { execSync, spawn } from 'child_process';
|
import { execSync, spawn, spawnSync } from 'child_process';
|
||||||
import fs from 'fs';
|
import fs from 'fs';
|
||||||
|
import os from 'os';
|
||||||
import path from 'path';
|
import path from 'path';
|
||||||
|
|
||||||
import * as p from '@clack/prompts';
|
import * as p from '@clack/prompts';
|
||||||
@@ -90,7 +94,7 @@ export async function offerClaudeAssist(
|
|||||||
projectRoot: string = process.cwd(),
|
projectRoot: string = process.cwd(),
|
||||||
): Promise<boolean> {
|
): Promise<boolean> {
|
||||||
if (process.env.NANOCLAW_SKIP_CLAUDE_ASSIST === '1') return false;
|
if (process.env.NANOCLAW_SKIP_CLAUDE_ASSIST === '1') return false;
|
||||||
if (!isClaudeUsable()) return false;
|
if (!(await ensureClaudeReady(projectRoot))) return false;
|
||||||
|
|
||||||
const want = ensureAnswer(
|
const want = ensureAnswer(
|
||||||
await p.confirm({
|
await p.confirm({
|
||||||
@@ -128,15 +132,101 @@ export async function offerClaudeAssist(
|
|||||||
return true;
|
return true;
|
||||||
}
|
}
|
||||||
|
|
||||||
function isClaudeUsable(): boolean {
|
function isClaudeInstalled(): boolean {
|
||||||
try {
|
try {
|
||||||
execSync('command -v claude', { stdio: 'ignore' });
|
execSync('command -v claude', { stdio: 'ignore' });
|
||||||
|
return true;
|
||||||
} catch {
|
} catch {
|
||||||
return false;
|
return false;
|
||||||
}
|
}
|
||||||
// Availability without auth is half the story; a real query will still
|
}
|
||||||
// fail if the token isn't registered. We try first and surface the error
|
|
||||||
// rather than pre-checking auth with a separate round trip.
|
function isClaudeAuthenticated(): boolean {
|
||||||
|
try {
|
||||||
|
execSync('claude auth status', { stdio: 'ignore', timeout: 5_000 });
|
||||||
|
return true;
|
||||||
|
} catch {
|
||||||
|
return false;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
async function ensureClaudeReady(projectRoot: string): Promise<boolean> {
|
||||||
|
if (!isClaudeInstalled()) {
|
||||||
|
const install = ensureAnswer(
|
||||||
|
await p.confirm({
|
||||||
|
message:
|
||||||
|
'Claude CLI is needed to diagnose this. Install it now?',
|
||||||
|
initialValue: true,
|
||||||
|
}),
|
||||||
|
);
|
||||||
|
if (!install) return false;
|
||||||
|
|
||||||
|
const code = spawnSync('bash', ['setup/install-claude.sh'], {
|
||||||
|
cwd: projectRoot,
|
||||||
|
stdio: 'inherit',
|
||||||
|
}).status;
|
||||||
|
if (code !== 0 || !isClaudeInstalled()) {
|
||||||
|
p.log.error("Couldn't install the Claude CLI.");
|
||||||
|
return false;
|
||||||
|
}
|
||||||
|
p.log.success('Claude CLI installed.');
|
||||||
|
}
|
||||||
|
|
||||||
|
if (!isClaudeAuthenticated()) {
|
||||||
|
const auth = ensureAnswer(
|
||||||
|
await p.confirm({
|
||||||
|
message:
|
||||||
|
"Claude CLI isn't signed in. Sign in now? (a browser will open)",
|
||||||
|
initialValue: true,
|
||||||
|
}),
|
||||||
|
);
|
||||||
|
if (!auth) return false;
|
||||||
|
|
||||||
|
// setup-token has an interactive TUI; reset terminal to cooked mode
|
||||||
|
// so its prompts render correctly after clack's raw-mode prompts.
|
||||||
|
spawnSync('stty', ['sane'], { stdio: 'inherit' });
|
||||||
|
|
||||||
|
// Run under script(1) to capture the OAuth token from PTY output
|
||||||
|
// while preserving interactive TTY for the browser OAuth flow.
|
||||||
|
// Same approach as register-claude-token.sh, but we set the env var
|
||||||
|
// instead of writing to OneCLI.
|
||||||
|
const tmpfile = path.join(os.tmpdir(), `claude-setup-token-${process.pid}`);
|
||||||
|
try {
|
||||||
|
const isUtilLinux = (() => {
|
||||||
|
try {
|
||||||
|
return execSync('script --version 2>&1', { encoding: 'utf-8' }).includes('util-linux');
|
||||||
|
} catch { return false; }
|
||||||
|
})();
|
||||||
|
const scriptArgs = isUtilLinux
|
||||||
|
? ['-q', '-c', 'claude setup-token', tmpfile]
|
||||||
|
: ['-q', tmpfile, 'claude', 'setup-token'];
|
||||||
|
|
||||||
|
spawnSync('script', scriptArgs, {
|
||||||
|
cwd: projectRoot,
|
||||||
|
stdio: 'inherit',
|
||||||
|
});
|
||||||
|
|
||||||
|
if (!isClaudeAuthenticated() && fs.existsSync(tmpfile)) {
|
||||||
|
const raw = fs.readFileSync(tmpfile, 'utf-8');
|
||||||
|
const stripped = raw
|
||||||
|
.replace(/\x1b\[[0-9;]*[a-zA-Z]/g, '')
|
||||||
|
.replace(/[\n\r]/g, '');
|
||||||
|
const matches = stripped.match(/(sk-ant-oat[A-Za-z0-9_-]{80,500}AA)/g);
|
||||||
|
if (matches) {
|
||||||
|
process.env.CLAUDE_CODE_OAUTH_TOKEN = matches[matches.length - 1];
|
||||||
|
}
|
||||||
|
}
|
||||||
|
} finally {
|
||||||
|
try { fs.unlinkSync(tmpfile); } catch {}
|
||||||
|
}
|
||||||
|
|
||||||
|
if (!isClaudeAuthenticated()) {
|
||||||
|
p.log.error("Couldn't complete Claude sign-in.");
|
||||||
|
return false;
|
||||||
|
}
|
||||||
|
p.log.success('Claude CLI signed in.');
|
||||||
|
}
|
||||||
|
|
||||||
return true;
|
return true;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@@ -115,7 +115,7 @@ async function promptOne(e: Entry, values: ConfigValues): Promise<void> {
|
|||||||
};
|
};
|
||||||
const ans = ensureAnswer(
|
const ans = ensureAnswer(
|
||||||
e.secret
|
e.secret
|
||||||
? await p.password({ message: e.label, validate })
|
? await p.password({ message: e.label, clearOnError: true, validate })
|
||||||
: await p.text({
|
: await p.text({
|
||||||
message: e.label,
|
message: e.label,
|
||||||
placeholder: e.placeholder ?? e.default,
|
placeholder: e.placeholder ?? e.default,
|
||||||
|
|||||||
Reference in New Issue
Block a user