1297 lines
40 KiB
TypeScript
1297 lines
40 KiB
TypeScript
/**
|
|
* Unified first-run onboarding wizard.
|
|
*
|
|
* Replaces the raw API-key-only wizard with a branded, clack-based experience
|
|
* that guides users through LLM provider authentication before the TUI launches.
|
|
*
|
|
* Flow: logo -> choose LLM provider -> authenticate (OAuth or API key) ->
|
|
* optional tool keys -> summary -> TUI launches.
|
|
*
|
|
* All steps are skippable. All errors are recoverable. Never crashes boot.
|
|
*/
|
|
|
|
import { execFile } from "node:child_process";
|
|
import { existsSync, mkdirSync, readFileSync, writeFileSync } from "node:fs";
|
|
import { dirname, join } from "node:path";
|
|
import type { AuthStorage } from "@singularity-forge/pi-coding-agent";
|
|
import { agentDir } from "./app-paths.js";
|
|
import { isClaudeCliReady } from "./claude-cli-check.js";
|
|
import { renderLogo } from "./logo.js";
|
|
|
|
// ─── Types ────────────────────────────────────────────────────────────────────
|
|
|
|
interface ToolKeyConfig {
|
|
provider: string;
|
|
envVar: string;
|
|
label: string;
|
|
hint: string;
|
|
}
|
|
|
|
type ClackModule = typeof import("@clack/prompts");
|
|
type PicoModule = {
|
|
cyan: (s: string) => string;
|
|
green: (s: string) => string;
|
|
yellow: (s: string) => string;
|
|
dim: (s: string) => string;
|
|
bold: (s: string) => string;
|
|
red: (s: string) => string;
|
|
reset: (s: string) => string;
|
|
};
|
|
|
|
// ─── Constants ────────────────────────────────────────────────────────────────
|
|
|
|
const TOOL_KEYS: ToolKeyConfig[] = [
|
|
{
|
|
provider: "context7",
|
|
envVar: "CONTEXT7_API_KEY",
|
|
label: "Context7",
|
|
hint: "up-to-date library docs",
|
|
},
|
|
{
|
|
provider: "jina",
|
|
envVar: "JINA_API_KEY",
|
|
label: "Jina AI",
|
|
hint: "clean web page extraction",
|
|
},
|
|
{
|
|
provider: "groq",
|
|
envVar: "GROQ_API_KEY",
|
|
label: "Groq",
|
|
hint: "voice transcription — free at console.groq.com",
|
|
},
|
|
];
|
|
|
|
/** Known LLM provider IDs that, if authed, mean the user doesn't need onboarding */
|
|
const LLM_PROVIDER_IDS = [
|
|
"anthropic",
|
|
"anthropic-vertex",
|
|
"claude-code",
|
|
"openai",
|
|
"github-copilot",
|
|
"openai-codex",
|
|
"google-gemini-cli",
|
|
"groq",
|
|
"xai",
|
|
"openrouter",
|
|
"mistral",
|
|
"xiaomi",
|
|
"ollama",
|
|
"ollama-cloud",
|
|
"custom-openai",
|
|
];
|
|
|
|
/** API key prefix validation — loose checks to catch obvious mistakes */
|
|
const API_KEY_PREFIXES: Record<string, string[]> = {
|
|
anthropic: ["sk-ant-"],
|
|
openai: ["sk-"],
|
|
};
|
|
|
|
const OTHER_PROVIDERS = [
|
|
{ value: "groq", label: "Groq", hint: "console.groq.com/keys" },
|
|
{ value: "xai", label: "xAI (Grok)", hint: "console.x.ai" },
|
|
{
|
|
value: "openrouter",
|
|
label: "OpenRouter",
|
|
hint: "200+ models — openrouter.ai/keys",
|
|
},
|
|
{ value: "mistral", label: "Mistral", hint: "console.mistral.ai/api-keys" },
|
|
{
|
|
value: "xiaomi",
|
|
label: "Xiaomi MiMo",
|
|
hint: "token-plan-ams.xiaomimimo.com",
|
|
},
|
|
{ value: "ollama-cloud", label: "Ollama Cloud" },
|
|
{
|
|
value: "custom-openai",
|
|
label: "Custom (OpenAI-compatible)",
|
|
hint: "Ollama, LM Studio, vLLM, proxies — see docs/providers.md",
|
|
},
|
|
];
|
|
|
|
// ─── Dynamic imports ──────────────────────────────────────────────────────────
|
|
|
|
/**
|
|
* Dynamically import @clack/prompts.
|
|
* Dynamic import with fallback so the module doesn't crash if it's missing.
|
|
*/
|
|
async function loadClack(): Promise<ClackModule> {
|
|
try {
|
|
return await import("@clack/prompts");
|
|
} catch {
|
|
throw new Error(
|
|
"[forge] @clack/prompts not found — onboarding wizard requires this dependency",
|
|
);
|
|
}
|
|
}
|
|
|
|
/**
|
|
* Build the PicoModule color surface from chalk. Chalk is already a
|
|
* dependency of the CLI; this adapter keeps the onboarding call sites stable
|
|
* while removing the redundant picocolors dep.
|
|
*/
|
|
async function loadPico(): Promise<PicoModule> {
|
|
try {
|
|
const { default: chalk } = await import("chalk");
|
|
return {
|
|
cyan: (s: string) => chalk.cyan(s),
|
|
green: (s: string) => chalk.green(s),
|
|
yellow: (s: string) => chalk.yellow(s),
|
|
dim: (s: string) => chalk.dim(s),
|
|
bold: (s: string) => chalk.bold(s),
|
|
red: (s: string) => chalk.red(s),
|
|
reset: (s: string) => chalk.reset(s),
|
|
};
|
|
} catch {
|
|
// Fallback: return identity functions
|
|
const identity = (s: string) => s;
|
|
return {
|
|
cyan: identity,
|
|
green: identity,
|
|
yellow: identity,
|
|
dim: identity,
|
|
bold: identity,
|
|
red: identity,
|
|
reset: identity,
|
|
};
|
|
}
|
|
}
|
|
|
|
// ─── Utilities ────────────────────────────────────────────────────────────────
|
|
|
|
/** Open a URL in the system browser (best-effort, non-blocking) */
|
|
function openBrowser(url: string): void {
|
|
if (process.platform === "win32") {
|
|
// PowerShell's Start-Process handles URLs with '&' safely; cmd /c start does not.
|
|
execFile(
|
|
"powershell",
|
|
["-c", `Start-Process '${url.replace(/'/g, "''")}'`],
|
|
() => {},
|
|
);
|
|
} else {
|
|
const cmd = process.platform === "darwin" ? "open" : "xdg-open";
|
|
execFile(cmd, [url], () => {});
|
|
}
|
|
}
|
|
|
|
/** Sentinel returned by runStep when the user cancels — tells the caller
|
|
* to abort the entire wizard. */
|
|
const STEP_CANCELLED = Symbol("step-cancelled");
|
|
type StepCancelled = typeof STEP_CANCELLED;
|
|
|
|
/**
|
|
* Run a single onboarding step with shared error handling:
|
|
* - user cancel (Ctrl+C) → p.cancel(cancelMessage), returns STEP_CANCELLED
|
|
* - other error → p.log.warn + optional info follow-up, returns null
|
|
* - success → the step's return value
|
|
*/
|
|
async function runStep<T>(
|
|
p: ClackModule,
|
|
warnLabel: string,
|
|
fn: () => Promise<T>,
|
|
opts: { cancelMessage?: string; errorInfo?: string } = {},
|
|
): Promise<T | null | StepCancelled> {
|
|
try {
|
|
return await fn();
|
|
} catch (err) {
|
|
if (p.isCancel(err)) {
|
|
p.cancel(opts.cancelMessage ?? "Setup cancelled.");
|
|
return STEP_CANCELLED;
|
|
}
|
|
p.log.warn(
|
|
`${warnLabel}: ${err instanceof Error ? err.message : String(err)}`,
|
|
);
|
|
if (opts.errorInfo) p.log.info(opts.errorInfo);
|
|
return null;
|
|
}
|
|
}
|
|
|
|
// ─── Public API ───────────────────────────────────────────────────────────────
|
|
|
|
/**
|
|
* Determine if the onboarding wizard should run.
|
|
*
|
|
* Returns true when:
|
|
* - No LLM provider auth is available
|
|
* - We're on a TTY (interactive terminal)
|
|
*
|
|
* Returns false (skip wizard) when:
|
|
* - Any LLM provider is already available via auth.json, env vars, runtime overrides, or fallback auth
|
|
* - A default provider is already configured in settings (covers extension-based providers
|
|
* that may not require credentials in auth.json)
|
|
* - Not a TTY (piped input, subagent, CI)
|
|
*/
|
|
export function shouldRunOnboarding(
|
|
authStorage: AuthStorage,
|
|
settingsDefaultProvider?: string,
|
|
): boolean {
|
|
if (!process.stdin.isTTY) return false;
|
|
if (settingsDefaultProvider) return false;
|
|
// Check if any LLM provider has credentials
|
|
const hasLlmAuth = LLM_PROVIDER_IDS.some((id) => authStorage.hasAuth(id));
|
|
return !hasLlmAuth;
|
|
}
|
|
|
|
/**
|
|
* Run the unified onboarding wizard.
|
|
*
|
|
* Walks the user through:
|
|
* 1. Choose LLM provider
|
|
* 2. Authenticate (OAuth or API key)
|
|
* 3. Optional tool API keys
|
|
* 4. Summary
|
|
*
|
|
* All steps are skippable. All errors are recoverable.
|
|
* Writes status to stderr during execution.
|
|
*/
|
|
export async function runOnboarding(authStorage: AuthStorage): Promise<void> {
|
|
let p: ClackModule;
|
|
let pc: PicoModule;
|
|
try {
|
|
[p, pc] = await Promise.all([loadClack(), loadPico()]);
|
|
} catch (err) {
|
|
// If clack isn't available, fall back silently — don't block boot
|
|
process.stderr.write(
|
|
`[forge] Onboarding wizard unavailable: ${err instanceof Error ? err.message : String(err)}\n`,
|
|
);
|
|
return;
|
|
}
|
|
|
|
// ── Intro ─────────────────────────────────────────────────────────────────
|
|
process.stderr.write(renderLogo(pc.cyan));
|
|
p.intro(pc.bold("Welcome to SF — let's get you set up"));
|
|
|
|
// ── LLM Provider Selection ────────────────────────────────────────────────
|
|
const llmResult = await runStep(
|
|
p,
|
|
"LLM setup failed",
|
|
() => runLlmStep(p, pc, authStorage),
|
|
{
|
|
cancelMessage: "Setup cancelled — you can run /login inside SF later.",
|
|
errorInfo:
|
|
"You can configure your LLM provider later with /login inside SF.",
|
|
},
|
|
);
|
|
if (llmResult === STEP_CANCELLED) return;
|
|
const llmConfigured = llmResult ?? false;
|
|
|
|
// ── Web Search Provider ──────────────────────────────────────────────────
|
|
const searchResult = await runStep(p, "Web search setup failed", () =>
|
|
runWebSearchStep(p, pc, authStorage, llmConfigured),
|
|
);
|
|
if (searchResult === STEP_CANCELLED) return;
|
|
const searchConfigured = searchResult;
|
|
|
|
// ── Remote Questions ─────────────────────────────────────────────────────
|
|
const remoteResult = await runStep(p, "Remote questions setup failed", () =>
|
|
runRemoteQuestionsStep(p, pc, authStorage),
|
|
);
|
|
if (remoteResult === STEP_CANCELLED) return;
|
|
const remoteConfigured = remoteResult;
|
|
|
|
// ── Tool API Keys ─────────────────────────────────────────────────────────
|
|
const toolResult = await runStep(p, "Tool key setup failed", () =>
|
|
runToolKeysStep(p, pc, authStorage),
|
|
);
|
|
if (toolResult === STEP_CANCELLED) return;
|
|
const toolKeyCount = toolResult ?? 0;
|
|
|
|
// ── Summary ───────────────────────────────────────────────────────────────
|
|
const summaryLines: string[] = [];
|
|
if (llmConfigured) {
|
|
// Re-read what provider was stored
|
|
const authed = authStorage
|
|
.list()
|
|
.filter((id) => LLM_PROVIDER_IDS.includes(id));
|
|
if (authed.length > 0) {
|
|
const name = authed[0];
|
|
summaryLines.push(`${pc.green("✓")} LLM provider: ${name}`);
|
|
} else {
|
|
summaryLines.push(`${pc.green("✓")} LLM provider configured`);
|
|
}
|
|
} else {
|
|
summaryLines.push(
|
|
`${pc.yellow("↷")} LLM provider: skipped — use /login inside SF`,
|
|
);
|
|
}
|
|
|
|
if (searchConfigured) {
|
|
summaryLines.push(`${pc.green("✓")} Web search: ${searchConfigured}`);
|
|
} else {
|
|
summaryLines.push(
|
|
`${pc.dim("↷")} Web search: not configured — use /search-provider inside SF`,
|
|
);
|
|
}
|
|
|
|
if (remoteConfigured) {
|
|
summaryLines.push(`${pc.green("✓")} Remote questions: ${remoteConfigured}`);
|
|
} else {
|
|
summaryLines.push(
|
|
`${pc.dim("↷")} Remote questions: not configured — use /sf remote inside SF`,
|
|
);
|
|
}
|
|
|
|
if (toolKeyCount > 0) {
|
|
summaryLines.push(
|
|
`${pc.green("✓")} ${toolKeyCount} tool key${toolKeyCount > 1 ? "s" : ""} saved`,
|
|
);
|
|
} else {
|
|
summaryLines.push(`${pc.dim("↷")} Tool keys: none configured`);
|
|
}
|
|
|
|
p.note(summaryLines.join("\n"), "Setup complete");
|
|
p.outro(pc.dim("Launching SF..."));
|
|
}
|
|
|
|
// ─── LLM Authentication Step ──────────────────────────────────────────────────
|
|
|
|
async function runLlmStep(
|
|
p: ClackModule,
|
|
pc: PicoModule,
|
|
authStorage: AuthStorage,
|
|
): Promise<boolean> {
|
|
// Build the OAuth provider list dynamically from what's registered
|
|
const oauthProviders = authStorage.getOAuthProviders();
|
|
const oauthMap = new Map(oauthProviders.map((op) => [op.id, op]));
|
|
|
|
// Check if already authenticated
|
|
const existingAuth = LLM_PROVIDER_IDS.find((id) => authStorage.hasAuth(id));
|
|
|
|
// ── Step 1: How do you want to authenticate? ─────────────────────────────
|
|
type AuthOption = { value: string; label: string; hint?: string };
|
|
const authOptions: AuthOption[] = [];
|
|
|
|
if (existingAuth) {
|
|
authOptions.push({
|
|
value: "keep",
|
|
label: `Keep current (${existingAuth})`,
|
|
hint: "already configured",
|
|
});
|
|
}
|
|
|
|
// Show Claude Code CLI option at the top when the CLI is installed and authenticated (#3772).
|
|
// This is the only TOS-compliant path for Anthropic subscription users.
|
|
if (isClaudeCliReady()) {
|
|
authOptions.push({
|
|
value: "claude-cli",
|
|
label: "Use Claude Code CLI",
|
|
hint: "recommended — uses your existing Claude subscription",
|
|
});
|
|
}
|
|
|
|
authOptions.push(
|
|
{
|
|
value: "browser",
|
|
label: "Sign in with your browser",
|
|
hint: "GitHub Copilot, ChatGPT, Google, etc.",
|
|
},
|
|
{
|
|
value: "api-key",
|
|
label: "Paste an API key",
|
|
hint: "from your provider dashboard",
|
|
},
|
|
{
|
|
value: "skip",
|
|
label: "Skip for now",
|
|
hint: "use /login inside SF later",
|
|
},
|
|
);
|
|
|
|
const method = await p.select({
|
|
message: existingAuth
|
|
? `LLM provider: ${existingAuth} — change it?`
|
|
: "How do you want to sign in?",
|
|
options: authOptions,
|
|
});
|
|
|
|
if (p.isCancel(method) || method === "skip") return false;
|
|
if (method === "keep") return true;
|
|
|
|
// ── Claude Code CLI path (#3772) ────────────────────────────────────────
|
|
if (method === "claude-cli") {
|
|
p.log.success(
|
|
"Claude Code CLI detected — routing through local CLI (TOS-compliant)",
|
|
);
|
|
p.log.info(
|
|
"Your Claude subscription will be used for inference. No API key needed.",
|
|
);
|
|
// Store sentinel so hasAuth('claude-code') returns true on future boots
|
|
authStorage.set("claude-code", { type: "api_key", key: "cli" });
|
|
// Persist claude-code as the default provider so the startup migration in
|
|
// cli.ts does not need to fire and the user is not left on "anthropic".
|
|
const settingsPath = join(agentDir, "settings.json");
|
|
try {
|
|
const raw = existsSync(settingsPath)
|
|
? JSON.parse(readFileSync(settingsPath, "utf-8"))
|
|
: {};
|
|
raw.defaultProvider = "claude-code";
|
|
mkdirSync(dirname(settingsPath), { recursive: true });
|
|
writeFileSync(settingsPath, JSON.stringify(raw, null, 2), "utf-8");
|
|
} catch {
|
|
/* non-fatal — startup migration will catch it */
|
|
}
|
|
return true;
|
|
}
|
|
|
|
// ── Step 2: Which provider? ──────────────────────────────────────────────
|
|
if (method === "browser") {
|
|
// Anthropic OAuth is removed from browser auth — it violates Anthropic TOS for
|
|
// third-party apps (#3772). Anthropic subscription users should use the Claude
|
|
// Code CLI path (shown above when CLI is installed) or paste an API key.
|
|
const provider = await p.select({
|
|
message: "Choose provider",
|
|
options: [
|
|
{ value: "github-copilot", label: "GitHub Copilot" },
|
|
{ value: "openai-codex", label: "ChatGPT Plus/Pro (Codex)" },
|
|
{ value: "google-gemini-cli", label: "Google Gemini CLI" },
|
|
],
|
|
});
|
|
if (p.isCancel(provider)) return false;
|
|
return await runOAuthFlow(p, pc, authStorage, provider as string, oauthMap);
|
|
}
|
|
|
|
if (method === "api-key") {
|
|
const provider = await p.select({
|
|
message: "Choose provider",
|
|
options: [
|
|
{ value: "anthropic", label: "Anthropic (Claude)" },
|
|
{ value: "openai", label: "OpenAI" },
|
|
...OTHER_PROVIDERS.map((op) => ({ value: op.value, label: op.label })),
|
|
],
|
|
});
|
|
if (p.isCancel(provider)) return false;
|
|
if (provider === "custom-openai") {
|
|
return await runCustomOpenAIFlow(p, pc, authStorage);
|
|
}
|
|
if (provider === "ollama") {
|
|
return await runOllamaLocalFlow(p, pc, authStorage);
|
|
}
|
|
const label =
|
|
provider === "anthropic"
|
|
? "Anthropic"
|
|
: provider === "openai"
|
|
? "OpenAI"
|
|
: (OTHER_PROVIDERS.find((op) => op.value === provider)?.label ??
|
|
String(provider));
|
|
return await runApiKeyFlow(p, pc, authStorage, provider as string, label);
|
|
}
|
|
|
|
return false;
|
|
}
|
|
|
|
// ─── OAuth Flow ───────────────────────────────────────────────────────────────
|
|
|
|
async function runOAuthFlow(
|
|
p: ClackModule,
|
|
pc: PicoModule,
|
|
authStorage: AuthStorage,
|
|
providerId: string,
|
|
oauthMap: Map<
|
|
string,
|
|
{ id: string; name?: string; usesCallbackServer?: boolean }
|
|
>,
|
|
): Promise<boolean> {
|
|
const providerInfo = oauthMap.get(providerId);
|
|
const providerName = providerInfo?.name ?? providerId;
|
|
const usesCallbackServer = providerInfo?.usesCallbackServer ?? false;
|
|
|
|
const s = p.spinner();
|
|
s.start(`Authenticating with ${providerName}...`);
|
|
|
|
try {
|
|
await authStorage.login(
|
|
providerId as any,
|
|
{
|
|
onAuth: (info: { url: string; instructions?: string }) => {
|
|
s.stop(`Opening browser for ${providerName}`);
|
|
openBrowser(info.url);
|
|
p.log.info(`${pc.dim("URL:")} ${pc.cyan(info.url)}`);
|
|
if (info.instructions) {
|
|
p.log.info(pc.yellow(info.instructions));
|
|
}
|
|
},
|
|
onPrompt: async (prompt: { message: string; placeholder?: string }) => {
|
|
const result = await p.text({
|
|
message: prompt.message,
|
|
placeholder: prompt.placeholder,
|
|
});
|
|
if (p.isCancel(result)) return "";
|
|
return result as string;
|
|
},
|
|
onProgress: (message: string) => {
|
|
p.log.step(pc.dim(message));
|
|
},
|
|
onManualCodeInput: usesCallbackServer
|
|
? async () => {
|
|
const result = await p.text({
|
|
message: "Paste the redirect URL from your browser:",
|
|
placeholder: "http://localhost:...",
|
|
});
|
|
if (p.isCancel(result)) return "";
|
|
return result as string;
|
|
}
|
|
: undefined,
|
|
} as any,
|
|
);
|
|
|
|
p.log.success(`Authenticated with ${pc.green(providerName)}`);
|
|
return true;
|
|
} catch (err) {
|
|
s.stop(`${providerName} authentication failed`);
|
|
const errorMsg = err instanceof Error ? err.message : String(err);
|
|
p.log.warn(`OAuth error: ${errorMsg}`);
|
|
|
|
// Offer retry or skip
|
|
const retry = await p.select({
|
|
message: "What would you like to do?",
|
|
options: [
|
|
{ value: "retry", label: "Try again" },
|
|
{ value: "skip", label: "Skip — configure later with /login" },
|
|
],
|
|
});
|
|
|
|
if (p.isCancel(retry) || retry === "skip") return false;
|
|
// Recursive retry
|
|
return runOAuthFlow(p, pc, authStorage, providerId, oauthMap);
|
|
}
|
|
}
|
|
|
|
// ─── API Key Flow ─────────────────────────────────────────────────────────────
|
|
|
|
async function runApiKeyFlow(
|
|
p: ClackModule,
|
|
pc: PicoModule,
|
|
authStorage: AuthStorage,
|
|
providerId: string,
|
|
providerLabel: string,
|
|
): Promise<boolean> {
|
|
const key = await p.password({
|
|
message: `Paste your ${providerLabel} API key:`,
|
|
mask: "●",
|
|
});
|
|
|
|
if (p.isCancel(key) || !key) return false;
|
|
const trimmed = (key as string).trim();
|
|
if (!trimmed) return false;
|
|
|
|
// Basic prefix validation
|
|
const expectedPrefixes = API_KEY_PREFIXES[providerId];
|
|
if (
|
|
expectedPrefixes &&
|
|
!expectedPrefixes.some((pfx) => trimmed.startsWith(pfx))
|
|
) {
|
|
p.log.warn(
|
|
`Key doesn't start with expected prefix (${expectedPrefixes.join(" or ")}). Saving anyway.`,
|
|
);
|
|
}
|
|
|
|
authStorage.set(providerId, { type: "api_key", key: trimmed });
|
|
p.log.success(`API key saved for ${pc.green(providerLabel)}`);
|
|
|
|
// Provider-specific post-setup hints
|
|
if (providerId === "openrouter") {
|
|
p.log.info(
|
|
`Use ${pc.cyan("/model")} inside SF to pick an OpenRouter model.`,
|
|
);
|
|
p.log.info(
|
|
`To add custom models or control routing, see ${pc.dim("docs/providers.md#openrouter")}`,
|
|
);
|
|
}
|
|
|
|
return true;
|
|
}
|
|
|
|
// ─── Ollama Local Flow ───────────────────────────────────────────────────────
|
|
|
|
async function runOllamaLocalFlow(
|
|
p: ClackModule,
|
|
pc: PicoModule,
|
|
authStorage: AuthStorage,
|
|
): Promise<boolean> {
|
|
const host = process.env.OLLAMA_HOST || "http://localhost:11434";
|
|
|
|
const s = p.spinner();
|
|
s.start(`Checking Ollama at ${host}...`);
|
|
|
|
try {
|
|
const controller = new AbortController();
|
|
const timeout = setTimeout(() => controller.abort(), 3000);
|
|
const response = await fetch(host, { signal: controller.signal });
|
|
clearTimeout(timeout);
|
|
|
|
if (response.ok) {
|
|
s.stop(`Ollama is running at ${pc.green(host)}`);
|
|
// Store a placeholder so the provider is recognized as authenticated
|
|
authStorage.set("ollama", { type: "api_key", key: "ollama" });
|
|
p.log.success(
|
|
`${pc.green("Ollama (Local)")} configured — no API key needed`,
|
|
);
|
|
p.log.info(
|
|
pc.dim(
|
|
"Models are discovered automatically from your local Ollama instance.",
|
|
),
|
|
);
|
|
return true;
|
|
} else {
|
|
s.stop("Ollama check failed");
|
|
p.log.warn(`Ollama responded with status ${response.status} at ${host}`);
|
|
}
|
|
} catch {
|
|
s.stop("Ollama not detected");
|
|
p.log.warn(`Could not reach Ollama at ${host}`);
|
|
p.log.info(
|
|
pc.dim('Install Ollama from https://ollama.com and run "ollama serve"'),
|
|
);
|
|
p.log.info(pc.dim("Set OLLAMA_HOST if using a non-default address."));
|
|
}
|
|
|
|
// Even if not reachable now, save the config — the extension will detect it at runtime
|
|
const proceed = await p.confirm({
|
|
message:
|
|
"Save Ollama as your provider anyway? (it will auto-detect when running)",
|
|
});
|
|
|
|
if (p.isCancel(proceed) || !proceed) return false;
|
|
|
|
authStorage.set("ollama", { type: "api_key", key: "ollama" });
|
|
p.log.success(
|
|
`${pc.green("Ollama (Local)")} saved — models will appear when Ollama is running`,
|
|
);
|
|
return true;
|
|
}
|
|
|
|
// ─── Custom OpenAI-compatible Flow ────────────────────────────────────────────
|
|
|
|
async function runCustomOpenAIFlow(
|
|
p: ClackModule,
|
|
pc: PicoModule,
|
|
authStorage: AuthStorage,
|
|
): Promise<boolean> {
|
|
p.log.info(
|
|
pc.dim(
|
|
"Common endpoints:\n Ollama: http://localhost:11434/v1\n LM Studio: http://localhost:1234/v1\n vLLM: http://localhost:8000/v1",
|
|
),
|
|
);
|
|
|
|
// Prompt for base URL
|
|
const baseUrl = await p.text({
|
|
message: "Base URL of your OpenAI-compatible endpoint:",
|
|
placeholder: "http://localhost:11434/v1",
|
|
validate: (val) => {
|
|
const trimmed = val?.trim();
|
|
if (!trimmed) return "Base URL is required";
|
|
try {
|
|
new URL(trimmed);
|
|
} catch {
|
|
return "Must be a valid URL (e.g. https://my-proxy.example.com/v1)";
|
|
}
|
|
},
|
|
});
|
|
if (p.isCancel(baseUrl) || !baseUrl) return false;
|
|
const trimmedUrl = (baseUrl as string).trim();
|
|
|
|
// Prompt for API key
|
|
const apiKey = await p.password({
|
|
message: "API key for this endpoint:",
|
|
mask: "●",
|
|
});
|
|
if (p.isCancel(apiKey) || !apiKey) return false;
|
|
const trimmedKey = (apiKey as string).trim();
|
|
if (!trimmedKey) return false;
|
|
|
|
// Prompt for model ID
|
|
const modelId = await p.text({
|
|
message: "Model ID to use:",
|
|
placeholder: "gpt-4o",
|
|
validate: (val) => {
|
|
if (!val?.trim()) return "Model ID is required";
|
|
},
|
|
});
|
|
if (p.isCancel(modelId) || !modelId) return false;
|
|
const trimmedModelId = (modelId as string).trim();
|
|
|
|
// Save API key to auth storage
|
|
authStorage.set("custom-openai", { type: "api_key", key: trimmedKey });
|
|
|
|
// Write or merge into models.json
|
|
const modelsJsonPath = join(agentDir, "models.json");
|
|
let config: { providers: Record<string, any> } = { providers: {} };
|
|
|
|
if (existsSync(modelsJsonPath)) {
|
|
try {
|
|
config = JSON.parse(readFileSync(modelsJsonPath, "utf-8"));
|
|
if (!config.providers) config.providers = {};
|
|
} catch {
|
|
// If existing file is corrupt, start fresh
|
|
config = { providers: {} };
|
|
}
|
|
}
|
|
|
|
config.providers["custom-openai"] = {
|
|
baseUrl: trimmedUrl,
|
|
apiKey: `env:CUSTOM_OPENAI_API_KEY`,
|
|
api: "openai-completions",
|
|
models: [
|
|
{
|
|
id: trimmedModelId,
|
|
name: trimmedModelId,
|
|
reasoning: false,
|
|
input: ["text"],
|
|
contextWindow: 128000,
|
|
maxTokens: 16384,
|
|
cost: { input: 0, output: 0, cacheRead: 0, cacheWrite: 0 },
|
|
},
|
|
],
|
|
};
|
|
|
|
// Ensure parent directory exists
|
|
const dir = dirname(modelsJsonPath);
|
|
if (!existsSync(dir)) {
|
|
mkdirSync(dir, { recursive: true });
|
|
}
|
|
writeFileSync(modelsJsonPath, JSON.stringify(config, null, 2), "utf-8");
|
|
|
|
// Also set env var so the current session picks up the key via fallback resolver
|
|
process.env.CUSTOM_OPENAI_API_KEY = trimmedKey;
|
|
|
|
p.log.success(`Custom endpoint saved: ${pc.green(trimmedUrl)}`);
|
|
p.log.info(`Model: ${pc.cyan(trimmedModelId)}`);
|
|
p.log.info(`Config written to ${pc.dim(modelsJsonPath)}`);
|
|
p.log.info(
|
|
`If you get role or streaming errors, add compat settings to models.json.`,
|
|
);
|
|
p.log.info(`See ${pc.dim("docs/providers.md#common-pitfalls")} for details.`);
|
|
return true;
|
|
}
|
|
|
|
// ─── Web Search Provider Step ─────────────────────────────────────────────────
|
|
|
|
async function runWebSearchStep(
|
|
p: ClackModule,
|
|
pc: PicoModule,
|
|
authStorage: AuthStorage,
|
|
isAnthropicAuth: boolean,
|
|
): Promise<string | null> {
|
|
// Check which LLM provider was configured
|
|
const authed = authStorage
|
|
.list()
|
|
.filter((id) => LLM_PROVIDER_IDS.includes(id));
|
|
const isAnthropic = isAnthropicAuth && authed.includes("anthropic");
|
|
|
|
// Check if web search is already configured
|
|
const hasBrave = !!process.env.BRAVE_API_KEY || authStorage.has("brave");
|
|
const hasTavily = !!process.env.TAVILY_API_KEY || authStorage.has("tavily");
|
|
const hasSerper = !!process.env.SERPER_API_KEY || authStorage.has("serper");
|
|
const hasExa = !!process.env.EXA_API_KEY || authStorage.has("exa");
|
|
const existingSearch = hasBrave
|
|
? "Brave Search"
|
|
: hasTavily
|
|
? "Tavily"
|
|
: hasSerper
|
|
? "Serper"
|
|
: hasExa
|
|
? "Exa"
|
|
: null;
|
|
|
|
// Build options based on what's available
|
|
type SearchOption = { value: string; label: string; hint?: string };
|
|
const options: SearchOption[] = [];
|
|
|
|
if (existingSearch) {
|
|
options.push({
|
|
value: "keep",
|
|
label: `Keep current (${existingSearch})`,
|
|
hint: "already configured",
|
|
});
|
|
}
|
|
|
|
if (isAnthropic) {
|
|
options.push({
|
|
value: "anthropic-native",
|
|
label: "Anthropic built-in web search",
|
|
hint: "no API key needed — already included with Claude",
|
|
});
|
|
}
|
|
|
|
options.push(
|
|
{
|
|
value: "brave",
|
|
label: "Brave Search",
|
|
hint: "requires API key — brave.com/search/api",
|
|
},
|
|
{ value: "tavily", label: "Tavily", hint: "requires API key — tavily.com" },
|
|
{ value: "serper", label: "Serper", hint: "requires API key — serper.dev" },
|
|
{
|
|
value: "exa",
|
|
label: "Exa Search",
|
|
hint: "requires API key — dashboard.exa.ai",
|
|
},
|
|
{
|
|
value: "skip",
|
|
label: "Skip for now",
|
|
hint: "use /search-provider inside SF later",
|
|
},
|
|
);
|
|
|
|
const choice = await p.select({
|
|
message: "How do you want to search the web?",
|
|
options,
|
|
});
|
|
|
|
if (p.isCancel(choice) || choice === "skip") return null;
|
|
if (choice === "keep") return existingSearch;
|
|
|
|
if (choice === "anthropic-native") {
|
|
p.log.success(
|
|
`Web search: ${pc.green("Anthropic built-in")} — works out of the box`,
|
|
);
|
|
return "Anthropic built-in";
|
|
}
|
|
|
|
if (choice === "brave") {
|
|
const key = await p.password({
|
|
message: `Paste your Brave Search API key ${pc.dim("(brave.com/search/api)")}:`,
|
|
mask: "●",
|
|
});
|
|
if (p.isCancel(key) || !(key as string)?.trim()) return null;
|
|
const trimmed = (key as string).trim();
|
|
authStorage.set("brave", { type: "api_key", key: trimmed });
|
|
process.env.BRAVE_API_KEY = trimmed;
|
|
p.log.success(`Web search: ${pc.green("Brave Search")} configured`);
|
|
return "Brave Search";
|
|
}
|
|
|
|
if (choice === "tavily") {
|
|
const key = await p.password({
|
|
message: `Paste your Tavily API key ${pc.dim("(tavily.com)")}:`,
|
|
mask: "●",
|
|
});
|
|
if (p.isCancel(key) || !(key as string)?.trim()) return null;
|
|
const trimmed = (key as string).trim();
|
|
authStorage.set("tavily", { type: "api_key", key: trimmed });
|
|
process.env.TAVILY_API_KEY = trimmed;
|
|
p.log.success(`Web search: ${pc.green("Tavily")} configured`);
|
|
return "Tavily";
|
|
}
|
|
|
|
if (choice === "serper") {
|
|
const key = await p.password({
|
|
message: `Paste your Serper API key ${pc.dim("(serper.dev)")}:`,
|
|
mask: "●",
|
|
});
|
|
if (p.isCancel(key) || !(key as string)?.trim()) return null;
|
|
const trimmed = (key as string).trim();
|
|
authStorage.set("serper", { type: "api_key", key: trimmed });
|
|
process.env.SERPER_API_KEY = trimmed;
|
|
p.log.success(`Web search: ${pc.green("Serper")} configured`);
|
|
return "Serper";
|
|
}
|
|
|
|
if (choice === "exa") {
|
|
const key = await p.password({
|
|
message: `Paste your Exa API key ${pc.dim("(dashboard.exa.ai)")}:`,
|
|
mask: "●",
|
|
});
|
|
if (p.isCancel(key) || !(key as string)?.trim()) return null;
|
|
const trimmed = (key as string).trim();
|
|
authStorage.set("exa", { type: "api_key", key: trimmed });
|
|
process.env.EXA_API_KEY = trimmed;
|
|
p.log.success(`Web search: ${pc.green("Exa")} configured`);
|
|
return "Exa";
|
|
}
|
|
|
|
return null;
|
|
}
|
|
|
|
// ─── Tool API Keys Step ───────────────────────────────────────────────────────
|
|
|
|
async function runToolKeysStep(
|
|
p: ClackModule,
|
|
pc: PicoModule,
|
|
authStorage: AuthStorage,
|
|
): Promise<number> {
|
|
// Filter to keys not already configured
|
|
const missing = TOOL_KEYS.filter(
|
|
(tk) => !authStorage.has(tk.provider) && !process.env[tk.envVar],
|
|
);
|
|
if (missing.length === 0) return 0;
|
|
|
|
const wantToolKeys = await p.confirm({
|
|
message: "Set up optional tool API keys? (web search, docs, etc.)",
|
|
initialValue: false,
|
|
});
|
|
|
|
if (p.isCancel(wantToolKeys) || !wantToolKeys) return 0;
|
|
|
|
let savedCount = 0;
|
|
for (const tk of missing) {
|
|
const key = await p.password({
|
|
message: `${tk.label} ${pc.dim(`(${tk.hint})`)} — Enter to skip:`,
|
|
mask: "●",
|
|
});
|
|
|
|
if (p.isCancel(key)) break;
|
|
|
|
const trimmed = (key as string | undefined)?.trim();
|
|
if (trimmed) {
|
|
authStorage.set(tk.provider, { type: "api_key", key: trimmed });
|
|
process.env[tk.envVar] = trimmed;
|
|
p.log.success(`${tk.label} saved`);
|
|
savedCount++;
|
|
} else {
|
|
// Store empty key so wizard doesn't re-ask on next launch
|
|
authStorage.set(tk.provider, { type: "api_key", key: "" });
|
|
p.log.info(pc.dim(`${tk.label} skipped`));
|
|
}
|
|
}
|
|
|
|
return savedCount;
|
|
}
|
|
|
|
// ─── Remote Questions Step ────────────────────────────────────────────────────
|
|
|
|
async function runRemoteQuestionsStep(
|
|
p: ClackModule,
|
|
pc: PicoModule,
|
|
authStorage: AuthStorage,
|
|
): Promise<string | null> {
|
|
// Check existing config — use getCredentialsForProvider to skip empty-key entries
|
|
const hasValidKey = (provider: string) =>
|
|
authStorage
|
|
.getCredentialsForProvider(provider)
|
|
.some((c: any) => c.type === "api_key" && c.key);
|
|
const hasDiscord = hasValidKey("discord_bot");
|
|
const hasSlack = hasValidKey("slack_bot");
|
|
const hasTelegram = hasValidKey("telegram_bot");
|
|
const existingChannel = hasDiscord
|
|
? "Discord"
|
|
: hasSlack
|
|
? "Slack"
|
|
: hasTelegram
|
|
? "Telegram"
|
|
: null;
|
|
|
|
type RemoteOption = { value: string; label: string; hint?: string };
|
|
const options: RemoteOption[] = [];
|
|
|
|
if (existingChannel) {
|
|
options.push({
|
|
value: "keep",
|
|
label: `Keep current (${existingChannel})`,
|
|
hint: "already configured",
|
|
});
|
|
}
|
|
|
|
options.push(
|
|
{
|
|
value: "discord",
|
|
label: "Discord",
|
|
hint: "receive questions in a Discord channel",
|
|
},
|
|
{
|
|
value: "slack",
|
|
label: "Slack",
|
|
hint: "receive questions in a Slack channel",
|
|
},
|
|
{
|
|
value: "telegram",
|
|
label: "Telegram",
|
|
hint: "receive questions via Telegram bot",
|
|
},
|
|
{
|
|
value: "skip",
|
|
label: "Skip for now",
|
|
hint: "use /sf remote inside SF later",
|
|
},
|
|
);
|
|
|
|
const choice = await p.select({
|
|
message: "Set up remote questions? (get notified when SF needs input)",
|
|
options,
|
|
});
|
|
|
|
if (p.isCancel(choice) || choice === "skip") return null;
|
|
if (choice === "keep") return existingChannel;
|
|
|
|
if (choice === "discord") {
|
|
const token = await p.password({
|
|
message: "Paste your Discord bot token:",
|
|
mask: "●",
|
|
});
|
|
if (p.isCancel(token) || !(token as string)?.trim()) return null;
|
|
const trimmed = (token as string).trim();
|
|
|
|
authStorage.set("discord_bot", { type: "api_key", key: trimmed });
|
|
process.env.DISCORD_BOT_TOKEN = trimmed;
|
|
|
|
const channelName = await runDiscordChannelStep(p, pc, trimmed);
|
|
return channelName ? `Discord #${channelName}` : "Discord";
|
|
}
|
|
|
|
if (choice === "slack") {
|
|
const token = await p.password({
|
|
message: `Paste your Slack bot token ${pc.dim("(xoxb-...)")}:`,
|
|
mask: "●",
|
|
});
|
|
if (p.isCancel(token) || !(token as string)?.trim()) return null;
|
|
const trimmed = (token as string).trim();
|
|
if (!trimmed.startsWith("xoxb-")) {
|
|
p.log.warn("Invalid token format — Slack bot tokens start with xoxb-.");
|
|
return null;
|
|
}
|
|
|
|
// Validate
|
|
const s = p.spinner();
|
|
s.start("Validating Slack token...");
|
|
try {
|
|
const res = await fetch("https://slack.com/api/auth.test", {
|
|
headers: { Authorization: `Bearer ${trimmed}` },
|
|
signal: AbortSignal.timeout(15_000),
|
|
});
|
|
const data = (await res.json()) as any;
|
|
if (!data?.ok) {
|
|
s.stop("Slack token validation failed");
|
|
return null;
|
|
}
|
|
s.stop(`Slack authenticated as ${pc.green(data.user ?? "bot")}`);
|
|
} catch {
|
|
s.stop("Could not reach Slack API");
|
|
return null;
|
|
}
|
|
|
|
authStorage.set("slack_bot", { type: "api_key", key: trimmed });
|
|
process.env.SLACK_BOT_TOKEN = trimmed;
|
|
|
|
const channelId = await p.text({
|
|
message: "Paste the Slack channel ID (e.g. C0123456789):",
|
|
validate: (val) => {
|
|
if (!val || !/^[A-Z0-9]{9,12}$/.test(val.trim()))
|
|
return "Expected 9-12 uppercase alphanumeric characters";
|
|
},
|
|
});
|
|
if (p.isCancel(channelId) || !channelId) return null;
|
|
|
|
const { saveRemoteQuestionsConfig } = await import(
|
|
"./remote-questions-config.js"
|
|
);
|
|
saveRemoteQuestionsConfig("slack", (channelId as string).trim());
|
|
p.log.success(`Slack channel: ${pc.green((channelId as string).trim())}`);
|
|
return "Slack";
|
|
}
|
|
|
|
if (choice === "telegram") {
|
|
const token = await p.password({
|
|
message: "Paste your Telegram bot token (from @BotFather):",
|
|
mask: "●",
|
|
});
|
|
if (p.isCancel(token) || !(token as string)?.trim()) return null;
|
|
const trimmed = (token as string).trim();
|
|
if (!/^\d+:[A-Za-z0-9_-]+$/.test(trimmed)) {
|
|
p.log.warn(
|
|
"Invalid token format — Telegram bot tokens look like 123456789:ABCdefGHI...",
|
|
);
|
|
return null;
|
|
}
|
|
|
|
// Validate
|
|
const s = p.spinner();
|
|
s.start("Validating Telegram bot token...");
|
|
try {
|
|
const res = await fetch(`https://api.telegram.org/bot${trimmed}/getMe`, {
|
|
signal: AbortSignal.timeout(15_000),
|
|
});
|
|
const data = (await res.json()) as any;
|
|
if (!data?.ok || !data?.result?.id) {
|
|
s.stop("Telegram token validation failed");
|
|
return null;
|
|
}
|
|
s.stop(
|
|
`Telegram bot: ${pc.green(data.result.first_name ?? data.result.username ?? "bot")}`,
|
|
);
|
|
} catch {
|
|
s.stop("Could not reach Telegram API");
|
|
return null;
|
|
}
|
|
|
|
authStorage.set("telegram_bot", { type: "api_key", key: trimmed });
|
|
process.env.TELEGRAM_BOT_TOKEN = trimmed;
|
|
|
|
const chatId = await p.text({
|
|
message: "Paste the Telegram chat ID (e.g. -1001234567890):",
|
|
validate: (val) => {
|
|
if (!val || !/^-?\d{5,20}$/.test(val.trim()))
|
|
return "Expected a numeric chat ID (can be negative for groups)";
|
|
},
|
|
});
|
|
if (p.isCancel(chatId) || !chatId) return null;
|
|
const trimmedChatId = (chatId as string).trim();
|
|
|
|
// Test send
|
|
const ts = p.spinner();
|
|
ts.start("Testing message delivery...");
|
|
try {
|
|
const res = await fetch(
|
|
`https://api.telegram.org/bot${trimmed}/sendMessage`,
|
|
{
|
|
method: "POST",
|
|
headers: { "Content-Type": "application/json" },
|
|
body: JSON.stringify({
|
|
chat_id: trimmedChatId,
|
|
text: "SF remote questions connected.",
|
|
}),
|
|
signal: AbortSignal.timeout(15_000),
|
|
},
|
|
);
|
|
const data = (await res.json()) as any;
|
|
if (!data?.ok) {
|
|
ts.stop(
|
|
`Could not send to chat: ${data?.description ?? "unknown error"}`,
|
|
);
|
|
return null;
|
|
}
|
|
ts.stop("Test message sent");
|
|
} catch {
|
|
ts.stop("Could not reach Telegram API");
|
|
return null;
|
|
}
|
|
|
|
const { saveRemoteQuestionsConfig } = await import(
|
|
"./remote-questions-config.js"
|
|
);
|
|
saveRemoteQuestionsConfig("telegram", trimmedChatId);
|
|
p.log.success(`Telegram chat: ${pc.green(trimmedChatId)}`);
|
|
return "Telegram";
|
|
}
|
|
|
|
return null;
|
|
}
|
|
|
|
async function runDiscordChannelStep(
|
|
p: ClackModule,
|
|
pc: PicoModule,
|
|
token: string,
|
|
): Promise<string | null> {
|
|
const headers = { Authorization: `Bot ${token}` };
|
|
|
|
// Validate token
|
|
const s = p.spinner();
|
|
s.start("Validating Discord bot token...");
|
|
let auth: any;
|
|
try {
|
|
const res = await fetch("https://discord.com/api/v10/users/@me", {
|
|
headers,
|
|
signal: AbortSignal.timeout(15_000),
|
|
});
|
|
auth = await res.json();
|
|
} catch {
|
|
s.stop("Could not reach Discord API");
|
|
return null;
|
|
}
|
|
if (!auth?.id) {
|
|
s.stop("Discord token validation failed");
|
|
return null;
|
|
}
|
|
s.stop(`Bot authenticated as ${pc.green(auth.username ?? "unknown")}`);
|
|
|
|
// Fetch guilds
|
|
let guilds: Array<{ id: string; name: string }>;
|
|
try {
|
|
const res = await fetch("https://discord.com/api/v10/users/@me/guilds", {
|
|
headers,
|
|
signal: AbortSignal.timeout(15_000),
|
|
});
|
|
const data = await res.json();
|
|
guilds = Array.isArray(data) ? data : [];
|
|
} catch {
|
|
p.log.warn(
|
|
"Could not fetch Discord servers — configure channel later with /sf remote discord",
|
|
);
|
|
return null;
|
|
}
|
|
|
|
if (guilds.length === 0) {
|
|
p.log.warn(
|
|
"Bot is not in any Discord servers — configure channel later with /sf remote discord",
|
|
);
|
|
return null;
|
|
}
|
|
|
|
// Select guild
|
|
let guildId: string;
|
|
let guildName: string;
|
|
if (guilds.length === 1) {
|
|
guildId = guilds[0].id;
|
|
guildName = guilds[0].name;
|
|
p.log.info(`Server: ${pc.green(guildName)}`);
|
|
} else {
|
|
const choice = await p.select({
|
|
message: "Which Discord server?",
|
|
options: guilds.map((g) => ({ value: g.id, label: g.name })),
|
|
});
|
|
if (p.isCancel(choice)) return null;
|
|
guildId = choice as string;
|
|
guildName = guilds.find((g) => g.id === guildId)?.name ?? guildId;
|
|
}
|
|
|
|
// Fetch channels
|
|
let channels: Array<{ id: string; name: string; type: number }>;
|
|
try {
|
|
const res = await fetch(
|
|
`https://discord.com/api/v10/guilds/${guildId}/channels`,
|
|
{ headers, signal: AbortSignal.timeout(15_000) },
|
|
);
|
|
const data = await res.json();
|
|
channels = Array.isArray(data)
|
|
? data.filter((ch: any) => ch.type === 0 || ch.type === 5)
|
|
: [];
|
|
} catch {
|
|
p.log.warn(
|
|
"Could not fetch channels — configure later with /sf remote discord",
|
|
);
|
|
return null;
|
|
}
|
|
|
|
if (channels.length === 0) {
|
|
p.log.warn(
|
|
"No text channels found — configure later with /sf remote discord",
|
|
);
|
|
return null;
|
|
}
|
|
|
|
// Select channel
|
|
const MANUAL_VALUE = "__manual__";
|
|
const channelChoice = await p.select({
|
|
message: "Which channel should SF use for remote questions?",
|
|
options: [
|
|
...channels.map((ch) => ({ value: ch.id, label: `#${ch.name}` })),
|
|
{ value: MANUAL_VALUE, label: "Enter channel ID manually" },
|
|
],
|
|
});
|
|
if (p.isCancel(channelChoice)) return null;
|
|
|
|
let channelId: string;
|
|
if (channelChoice === MANUAL_VALUE) {
|
|
const manualId = await p.text({
|
|
message: "Paste the Discord channel ID:",
|
|
placeholder: "1234567890123456789",
|
|
validate: (val) => {
|
|
if (!val || !/^\d{17,20}$/.test(val.trim()))
|
|
return "Expected 17-20 digit numeric ID";
|
|
},
|
|
});
|
|
if (p.isCancel(manualId) || !manualId) return null;
|
|
channelId = (manualId as string).trim();
|
|
} else {
|
|
channelId = channelChoice as string;
|
|
}
|
|
|
|
// Save remote questions config
|
|
const { saveRemoteQuestionsConfig } = await import(
|
|
"./remote-questions-config.js"
|
|
);
|
|
saveRemoteQuestionsConfig("discord", channelId);
|
|
const channelName = channels.find((ch) => ch.id === channelId)?.name;
|
|
p.log.success(
|
|
`Discord channel: ${pc.green(channelName ? `#${channelName}` : channelId)}`,
|
|
);
|
|
return channelName ?? null;
|
|
}
|