Merge branch 'main' into tobias-sync

This commit is contained in:
Peter Steinberger
2026-01-09 13:42:34 +01:00
436 changed files with 27171 additions and 5489 deletions

View File

@@ -4,6 +4,7 @@ import {
resolveAgentWorkspaceDir,
} from "../agents/agent-scope.js";
import { ensureAuthProfileStore } from "../agents/auth-profiles.js";
import { runClaudeCliAgent } from "../agents/claude-cli-runner.js";
import { lookupContextTokens } from "../agents/context.js";
import {
DEFAULT_CONTEXT_TOKENS,
@@ -336,6 +337,7 @@ export async function agentCommand(
cfg,
catalog: modelCatalog,
defaultProvider,
defaultModel,
});
allowedModelKeys = allowed.allowedKeys;
allowedModelCatalog = allowed.allowedCatalog;
@@ -347,7 +349,11 @@ export async function agentCommand(
const overrideModel = sessionEntry.modelOverride?.trim();
if (overrideModel) {
const key = modelKey(overrideProvider, overrideModel);
if (allowedModelKeys.size > 0 && !allowedModelKeys.has(key)) {
if (
overrideProvider !== "claude-cli" &&
allowedModelKeys.size > 0 &&
!allowedModelKeys.has(key)
) {
delete sessionEntry.providerOverride;
delete sessionEntry.modelOverride;
sessionEntry.updatedAt = Date.now();
@@ -362,7 +368,11 @@ export async function agentCommand(
if (storedModelOverride) {
const candidateProvider = storedProviderOverride || defaultProvider;
const key = modelKey(candidateProvider, storedModelOverride);
if (allowedModelKeys.size === 0 || allowedModelKeys.has(key)) {
if (
candidateProvider === "claude-cli" ||
allowedModelKeys.size === 0 ||
allowedModelKeys.has(key)
) {
provider = candidateProvider;
model = storedModelOverride;
}
@@ -401,6 +411,7 @@ export async function agentCommand(
let result: Awaited<ReturnType<typeof runEmbeddedPiAgent>>;
let fallbackProvider = provider;
let fallbackModel = model;
const claudeSessionId = sessionEntry?.claudeCliSessionId?.trim();
try {
const messageProvider = resolveMessageProvider(
opts.messageProvider,
@@ -410,8 +421,25 @@ export async function agentCommand(
cfg,
provider,
model,
run: (providerOverride, modelOverride) =>
runEmbeddedPiAgent({
run: (providerOverride, modelOverride) => {
if (providerOverride === "claude-cli") {
return runClaudeCliAgent({
sessionId,
sessionKey,
sessionFile,
workspaceDir,
config: cfg,
prompt: body,
provider: providerOverride,
model: modelOverride,
thinkLevel: resolvedThinkLevel,
timeoutMs,
runId,
extraSystemPrompt: opts.extraSystemPrompt,
claudeSessionId,
});
}
return runEmbeddedPiAgent({
sessionId,
sessionKey,
messageProvider,
@@ -445,7 +473,8 @@ export async function agentCommand(
data: evt.data,
});
},
}),
});
},
});
result = fallbackResult.result;
fallbackProvider = fallbackResult.provider;
@@ -501,6 +530,10 @@ export async function agentCommand(
model: modelUsed,
contextTokens,
};
if (providerUsed === "claude-cli") {
const cliSessionId = result.meta.agentMeta?.sessionId?.trim();
if (cliSessionId) next.claudeCliSessionId = cliSessionId;
}
next.abortedLastRun = result.meta.aborted ?? false;
if (hasNonzeroUsage(usage)) {
const input = usage.input ?? 0;

View File

@@ -955,12 +955,15 @@ export async function agentsAddCommand(
initialValue: false,
});
if (wantsAuth) {
const authStore = ensureAuthProfileStore(agentDir);
const authStore = ensureAuthProfileStore(agentDir, {
allowKeychainPrompt: false,
});
const authChoice = (await prompter.select({
message: "Model/auth choice",
options: buildAuthChoiceOptions({
store: authStore,
includeSkip: true,
includeClaudeCliIfMissing: true,
}),
})) as AuthChoice;

View File

@@ -0,0 +1,59 @@
import { describe, expect, it } from "vitest";
import {
type AuthProfileStore,
CLAUDE_CLI_PROFILE_ID,
} from "../agents/auth-profiles.js";
import { buildAuthChoiceOptions } from "./auth-choice-options.js";
describe("buildAuthChoiceOptions", () => {
it("includes Claude CLI option on macOS even when missing", () => {
const store: AuthProfileStore = { version: 1, profiles: {} };
const options = buildAuthChoiceOptions({
store,
includeSkip: false,
includeClaudeCliIfMissing: true,
platform: "darwin",
});
const claudeCli = options.find((opt) => opt.value === "claude-cli");
expect(claudeCli).toBeDefined();
expect(claudeCli?.hint).toBe("requires Keychain access");
});
it("skips missing Claude CLI option off macOS", () => {
const store: AuthProfileStore = { version: 1, profiles: {} };
const options = buildAuthChoiceOptions({
store,
includeSkip: false,
includeClaudeCliIfMissing: true,
platform: "linux",
});
expect(options.find((opt) => opt.value === "claude-cli")).toBeUndefined();
});
it("uses token hint when Claude CLI credentials exist", () => {
const store: AuthProfileStore = {
version: 1,
profiles: {
[CLAUDE_CLI_PROFILE_ID]: {
type: "token",
provider: "anthropic",
token: "token",
expires: Date.now() + 60 * 60 * 1000,
},
},
};
const options = buildAuthChoiceOptions({
store,
includeSkip: false,
includeClaudeCliIfMissing: true,
platform: "darwin",
});
const claudeCli = options.find((opt) => opt.value === "claude-cli");
expect(claudeCli?.hint).toContain("token ok");
});
});

View File

@@ -45,8 +45,11 @@ function formatOAuthHint(
export function buildAuthChoiceOptions(params: {
store: AuthProfileStore;
includeSkip: boolean;
includeClaudeCliIfMissing?: boolean;
platform?: NodeJS.Platform;
}): AuthChoiceOption[] {
const options: AuthChoiceOption[] = [];
const platform = params.platform ?? process.platform;
const codexCli = params.store.profiles[CODEX_CLI_PROFILE_ID];
if (codexCli?.type === "oauth") {
@@ -58,25 +61,38 @@ export function buildAuthChoiceOptions(params: {
}
const claudeCli = params.store.profiles[CLAUDE_CLI_PROFILE_ID];
if (claudeCli?.type === "oauth") {
if (claudeCli?.type === "oauth" || claudeCli?.type === "token") {
options.push({
value: "claude-cli",
label: "Anthropic OAuth (Claude CLI)",
hint: formatOAuthHint(claudeCli.expires),
});
} else if (params.includeClaudeCliIfMissing && platform === "darwin") {
options.push({
value: "claude-cli",
label: "Anthropic OAuth (Claude CLI)",
hint: "requires Keychain access",
});
}
options.push({ value: "oauth", label: "Anthropic OAuth (Claude Pro/Max)" });
options.push({
value: "token",
label: "Anthropic token (paste setup-token)",
hint: "Run `claude setup-token`, then paste the token",
});
options.push({
value: "openai-codex",
label: "OpenAI Codex (ChatGPT OAuth)",
});
options.push({ value: "openai-api-key", label: "OpenAI API key" });
options.push({
value: "antigravity",
label: "Google Antigravity (Claude Opus 4.5, Gemini 3, etc.)",
});
options.push({ value: "gemini-api-key", label: "Google Gemini API key" });
options.push({ value: "apiKey", label: "Anthropic API key" });
// Token flow is currently Anthropic-only; use CLI for advanced providers.
options.push({ value: "minimax-cloud", label: "MiniMax M2.1 (minimax.io)" });
options.push({ value: "minimax", label: "Minimax M2.1 (LM Studio)" });
if (params.includeSkip) {

View File

@@ -1,5 +1,4 @@
import {
loginAnthropic,
loginOpenAICodex,
type OAuthCredentials,
type OAuthProvider,
@@ -10,6 +9,7 @@ import {
CODEX_CLI_PROFILE_ID,
ensureAuthProfileStore,
listProfilesForProvider,
upsertAuthProfile,
} from "../agents/auth-profiles.js";
import { DEFAULT_MODEL, DEFAULT_PROVIDER } from "../agents/defaults.js";
import {
@@ -19,12 +19,21 @@ import {
import { loadModelCatalog } from "../agents/model-catalog.js";
import { resolveConfiguredModelRef } from "../agents/model-selection.js";
import type { ClawdbotConfig } from "../config/config.js";
import { upsertSharedEnvVar } from "../infra/env-file.js";
import type { RuntimeEnv } from "../runtime.js";
import type { WizardPrompter } from "../wizard/prompts.js";
import {
isRemoteEnvironment,
loginAntigravityVpsAware,
} from "./antigravity-oauth.js";
import {
buildTokenProfileId,
validateAnthropicSetupToken,
} from "./auth-token.js";
import {
applyGoogleGeminiModelDefault,
GOOGLE_GEMINI_DEFAULT_MODEL,
} from "./google-gemini-model-default.js";
import {
applyAuthProfileConfig,
applyMinimaxConfig,
@@ -33,6 +42,7 @@ import {
applyMinimaxProviderConfig,
MINIMAX_HOSTED_MODEL_REF,
setAnthropicApiKey,
setGeminiApiKey,
setMinimaxApiKey,
writeOAuthCredentials,
} from "./onboard-auth.js";
@@ -131,60 +141,158 @@ export async function applyAuthChoice(params: {
);
};
if (params.authChoice === "oauth") {
await params.prompter.note(
"Browser will open. Paste the code shown after login (code#state).",
"Anthropic OAuth",
);
const spin = params.prompter.progress("Waiting for authorization…");
let oauthCreds: OAuthCredentials | null = null;
try {
oauthCreds = await loginAnthropic(
async (url) => {
await openUrl(url);
params.runtime.log(`Open: ${url}`);
},
async () => {
const code = await params.prompter.text({
message: "Paste authorization code (code#state)",
validate: (value) => (value?.trim() ? undefined : "Required"),
});
return String(code);
},
if (params.authChoice === "claude-cli") {
const store = ensureAuthProfileStore(params.agentDir, {
allowKeychainPrompt: false,
});
const hasClaudeCli = Boolean(store.profiles[CLAUDE_CLI_PROFILE_ID]);
if (!hasClaudeCli && process.platform === "darwin") {
await params.prompter.note(
[
"macOS will show a Keychain prompt next.",
'Choose "Always Allow" so the launchd gateway can start without prompts.',
'If you choose "Allow" or "Deny", each restart will block on a Keychain alert.',
].join("\n"),
"Claude CLI Keychain",
);
spin.stop("OAuth complete");
if (oauthCreds) {
await writeOAuthCredentials("anthropic", oauthCreds, params.agentDir);
const profileId = `anthropic:${oauthCreds.email ?? "default"}`;
nextConfig = applyAuthProfileConfig(nextConfig, {
profileId,
provider: "anthropic",
mode: "oauth",
email: oauthCreds.email ?? undefined,
});
const proceed = await params.prompter.confirm({
message: "Check Keychain for Claude CLI credentials now?",
initialValue: true,
});
if (!proceed) {
return { config: nextConfig, agentModelOverride };
}
} catch (err) {
spin.stop("OAuth failed");
params.runtime.error(String(err));
await params.prompter.note(
"Trouble with OAuth? See https://docs.clawd.bot/start/faq",
"OAuth help",
);
}
} else if (params.authChoice === "claude-cli") {
const store = ensureAuthProfileStore(params.agentDir);
if (!store.profiles[CLAUDE_CLI_PROFILE_ID]) {
await params.prompter.note(
"No Claude CLI credentials found at ~/.claude/.credentials.json.",
"Claude CLI OAuth",
);
return { config: nextConfig, agentModelOverride };
const storeWithKeychain = hasClaudeCli
? store
: ensureAuthProfileStore(params.agentDir, {
allowKeychainPrompt: true,
});
if (!storeWithKeychain.profiles[CLAUDE_CLI_PROFILE_ID]) {
if (process.stdin.isTTY) {
const runNow = await params.prompter.confirm({
message: "Run `claude setup-token` now?",
initialValue: true,
});
if (runNow) {
const res = await (async () => {
const { spawnSync } = await import("node:child_process");
return spawnSync("claude", ["setup-token"], { stdio: "inherit" });
})();
if (res.error) {
await params.prompter.note(
`Failed to run claude: ${String(res.error)}`,
"Claude setup-token",
);
}
}
} else {
await params.prompter.note(
"`claude setup-token` requires an interactive TTY.",
"Claude setup-token",
);
}
const refreshed = ensureAuthProfileStore(params.agentDir, {
allowKeychainPrompt: true,
});
if (!refreshed.profiles[CLAUDE_CLI_PROFILE_ID]) {
await params.prompter.note(
process.platform === "darwin"
? 'No Claude CLI credentials found in Keychain ("Claude Code-credentials") or ~/.claude/.credentials.json.'
: "No Claude CLI credentials found at ~/.claude/.credentials.json.",
"Claude CLI OAuth",
);
return { config: nextConfig, agentModelOverride };
}
}
nextConfig = applyAuthProfileConfig(nextConfig, {
profileId: CLAUDE_CLI_PROFILE_ID,
provider: "anthropic",
mode: "oauth",
mode: "token",
});
} else if (params.authChoice === "token" || params.authChoice === "oauth") {
const provider = (await params.prompter.select({
message: "Token provider",
options: [{ value: "anthropic", label: "Anthropic (only supported)" }],
})) as "anthropic";
await params.prompter.note(
[
"Run `claude setup-token` in your terminal.",
"Then paste the generated token below.",
].join("\n"),
"Anthropic token",
);
const tokenRaw = await params.prompter.text({
message: "Paste Anthropic setup-token",
validate: (value) => validateAnthropicSetupToken(String(value ?? "")),
});
const token = String(tokenRaw).trim();
const profileNameRaw = await params.prompter.text({
message: "Token name (blank = default)",
placeholder: "default",
});
const namedProfileId = buildTokenProfileId({
provider,
name: String(profileNameRaw ?? ""),
});
upsertAuthProfile({
profileId: namedProfileId,
agentDir: params.agentDir,
credential: {
type: "token",
provider,
token,
},
});
nextConfig = applyAuthProfileConfig(nextConfig, {
profileId: namedProfileId,
provider,
mode: "token",
});
} else if (params.authChoice === "openai-api-key") {
const envKey = resolveEnvApiKey("openai");
if (envKey) {
const useExisting = await params.prompter.confirm({
message: `Use existing OPENAI_API_KEY (${envKey.source})?`,
initialValue: true,
});
if (useExisting) {
const result = upsertSharedEnvVar({
key: "OPENAI_API_KEY",
value: envKey.apiKey,
});
if (!process.env.OPENAI_API_KEY) {
process.env.OPENAI_API_KEY = envKey.apiKey;
}
await params.prompter.note(
`Copied OPENAI_API_KEY to ${result.path} for launchd compatibility.`,
"OpenAI API key",
);
return { config: nextConfig, agentModelOverride };
}
}
const key = await params.prompter.text({
message: "Enter OpenAI API key",
validate: (value) => (value?.trim() ? undefined : "Required"),
});
const trimmed = String(key).trim();
const result = upsertSharedEnvVar({
key: "OPENAI_API_KEY",
value: trimmed,
});
process.env.OPENAI_API_KEY = trimmed;
await params.prompter.note(
`Saved OPENAI_API_KEY to ${result.path} for launchd compatibility.`,
"OpenAI API key",
);
} else if (params.authChoice === "openai-codex") {
const isRemote = isRemoteEnvironment();
await params.prompter.note(
@@ -390,6 +498,30 @@ export async function applyAuthChoice(params: {
"OAuth help",
);
}
} else if (params.authChoice === "gemini-api-key") {
const key = await params.prompter.text({
message: "Enter Gemini API key",
validate: (value) => (value?.trim() ? undefined : "Required"),
});
await setGeminiApiKey(String(key).trim(), params.agentDir);
nextConfig = applyAuthProfileConfig(nextConfig, {
profileId: "google:default",
provider: "google",
mode: "api_key",
});
if (params.setDefaultModel) {
const applied = applyGoogleGeminiModelDefault(nextConfig);
nextConfig = applied.next;
if (applied.changed) {
await params.prompter.note(
`Default model set to ${GOOGLE_GEMINI_DEFAULT_MODEL}`,
"Model configured",
);
}
} else {
agentModelOverride = GOOGLE_GEMINI_DEFAULT_MODEL;
await noteAgentModel(GOOGLE_GEMINI_DEFAULT_MODEL);
}
} else if (params.authChoice === "apiKey") {
const key = await params.prompter.text({
message: "Enter Anthropic API key",

View File

@@ -0,0 +1,37 @@
import { normalizeProviderId } from "../agents/model-selection.js";
export const ANTHROPIC_SETUP_TOKEN_PREFIX = "sk-ant-oat01-";
export const ANTHROPIC_SETUP_TOKEN_MIN_LENGTH = 80;
export const DEFAULT_TOKEN_PROFILE_NAME = "default";
export function normalizeTokenProfileName(raw: string): string {
const trimmed = raw.trim();
if (!trimmed) return DEFAULT_TOKEN_PROFILE_NAME;
const slug = trimmed
.toLowerCase()
.replace(/[^a-z0-9._-]+/g, "-")
.replace(/-+/g, "-")
.replace(/^-+|-+$/g, "");
return slug || DEFAULT_TOKEN_PROFILE_NAME;
}
export function buildTokenProfileId(params: {
provider: string;
name: string;
}): string {
const provider = normalizeProviderId(params.provider);
const name = normalizeTokenProfileName(params.name);
return `${provider}:${name}`;
}
export function validateAnthropicSetupToken(raw: string): string | undefined {
const trimmed = raw.trim();
if (!trimmed) return "Required";
if (!trimmed.startsWith(ANTHROPIC_SETUP_TOKEN_PREFIX)) {
return `Expected token starting with ${ANTHROPIC_SETUP_TOKEN_PREFIX}`;
}
if (trimmed.length < ANTHROPIC_SETUP_TOKEN_MIN_LENGTH) {
return "Token looks too short; paste the full setup-token";
}
return undefined;
}

View File

@@ -1,17 +1,16 @@
import path from "node:path";
import {
confirm,
intro,
multiselect,
note,
outro,
select,
confirm as clackConfirm,
intro as clackIntro,
multiselect as clackMultiselect,
note as clackNote,
outro as clackOutro,
select as clackSelect,
text as clackText,
spinner,
text,
} from "@clack/prompts";
import {
loginAnthropic,
loginOpenAICodex,
type OAuthCredentials,
type OAuthProvider,
@@ -20,7 +19,9 @@ import {
CLAUDE_CLI_PROFILE_ID,
CODEX_CLI_PROFILE_ID,
ensureAuthProfileStore,
upsertAuthProfile,
} from "../agents/auth-profiles.js";
import { resolveEnvApiKey } from "../agents/model-auth.js";
import { createCliProgress } from "../cli/progress.js";
import type { ClawdbotConfig } from "../config/config.js";
import {
@@ -31,10 +32,18 @@ import {
} from "../config/config.js";
import { GATEWAY_LAUNCH_AGENT_LABEL } from "../daemon/constants.js";
import { resolveGatewayProgramArguments } from "../daemon/program-args.js";
import { resolvePreferredNodePath } from "../daemon/runtime-paths.js";
import { resolveGatewayService } from "../daemon/service.js";
import { buildServiceEnvironment } from "../daemon/service-env.js";
import { ensureControlUiAssetsBuilt } from "../infra/control-ui-assets.js";
import { upsertSharedEnvVar } from "../infra/env-file.js";
import type { RuntimeEnv } from "../runtime.js";
import { defaultRuntime } from "../runtime.js";
import {
stylePromptHint,
stylePromptMessage,
stylePromptTitle,
} from "../terminal/prompt-style.js";
import { theme } from "../terminal/theme.js";
import { resolveUserPath, sleep } from "../utils.js";
import { createClackPrompter } from "../wizard/clack-prompter.js";
@@ -43,17 +52,26 @@ import {
loginAntigravityVpsAware,
} from "./antigravity-oauth.js";
import { buildAuthChoiceOptions } from "./auth-choice-options.js";
import {
buildTokenProfileId,
validateAnthropicSetupToken,
} from "./auth-token.js";
import {
DEFAULT_GATEWAY_DAEMON_RUNTIME,
GATEWAY_DAEMON_RUNTIME_OPTIONS,
type GatewayDaemonRuntime,
} from "./daemon-runtime.js";
import {
applyGoogleGeminiModelDefault,
GOOGLE_GEMINI_DEFAULT_MODEL,
} from "./google-gemini-model-default.js";
import { healthCommand } from "./health.js";
import {
applyAuthProfileConfig,
applyMinimaxConfig,
applyMinimaxHostedConfig,
setAnthropicApiKey,
setGeminiApiKey,
setMinimaxApiKey,
writeOAuthCredentials,
} from "./onboard-auth.js";
@@ -94,6 +112,43 @@ type ConfigureWizardParams = {
sections?: WizardSection[];
};
const intro = (message: string) =>
clackIntro(stylePromptTitle(message) ?? message);
const outro = (message: string) =>
clackOutro(stylePromptTitle(message) ?? message);
const note = (message: string, title?: string) =>
clackNote(message, stylePromptTitle(title));
const text = (params: Parameters<typeof clackText>[0]) =>
clackText({
...params,
message: stylePromptMessage(params.message),
});
const confirm = (params: Parameters<typeof clackConfirm>[0]) =>
clackConfirm({
...params,
message: stylePromptMessage(params.message),
});
const select = <T>(params: Parameters<typeof clackSelect<T>>[0]) =>
clackSelect({
...params,
message: stylePromptMessage(params.message),
options: params.options.map((opt) =>
opt.hint === undefined
? opt
: { ...opt, hint: stylePromptHint(opt.hint) },
),
});
const multiselect = <T>(params: Parameters<typeof clackMultiselect<T>>[0]) =>
clackMultiselect({
...params,
message: stylePromptMessage(params.message),
options: params.options.map((opt) =>
opt.hint === undefined
? opt
: { ...opt, hint: stylePromptHint(opt.hint) },
),
});
const startOscSpinner = (label: string) => {
const spin = spinner();
spin.start(theme.accent(label));
@@ -286,17 +341,23 @@ async function promptAuthConfig(
await select({
message: "Model/auth choice",
options: buildAuthChoiceOptions({
store: ensureAuthProfileStore(),
store: ensureAuthProfileStore(undefined, {
allowKeychainPrompt: false,
}),
includeSkip: true,
includeClaudeCliIfMissing: true,
}),
}),
runtime,
) as
| "oauth"
| "claude-cli"
| "token"
| "openai-codex"
| "openai-api-key"
| "codex-cli"
| "antigravity"
| "gemini-api-key"
| "apiKey"
| "minimax-cloud"
| "minimax"
@@ -304,52 +365,138 @@ async function promptAuthConfig(
let next = cfg;
if (authChoice === "oauth") {
note(
"Browser will open. Paste the code shown after login (code#state).",
"Anthropic OAuth",
);
const spin = startOscSpinner("Waiting for authorization…");
let oauthCreds: OAuthCredentials | null = null;
try {
oauthCreds = await loginAnthropic(
async (url) => {
await openUrl(url);
runtime.log(`Open: ${url}`);
},
async () => {
const code = guardCancel(
await text({
message: "Paste authorization code (code#state)",
validate: (value) => (value?.trim() ? undefined : "Required"),
}),
runtime,
);
return String(code);
},
if (authChoice === "claude-cli") {
const store = ensureAuthProfileStore(undefined, {
allowKeychainPrompt: false,
});
if (!store.profiles[CLAUDE_CLI_PROFILE_ID] && process.stdin.isTTY) {
note(
[
"No Claude CLI credentials found yet.",
"If you have a Claude Pro/Max subscription, run `claude setup-token`.",
].join("\n"),
"Claude CLI",
);
spin.stop("OAuth complete");
if (oauthCreds) {
await writeOAuthCredentials("anthropic", oauthCreds);
const profileId = `anthropic:${oauthCreds.email ?? "default"}`;
next = applyAuthProfileConfig(next, {
profileId,
provider: "anthropic",
mode: "oauth",
email: oauthCreds.email ?? undefined,
});
const runNow = guardCancel(
await confirm({
message: "Run `claude setup-token` now?",
initialValue: true,
}),
runtime,
);
if (runNow) {
const res = await (async () => {
const { spawnSync } = await import("node:child_process");
return spawnSync("claude", ["setup-token"], { stdio: "inherit" });
})();
if (res.error) {
note(
`Failed to run claude: ${String(res.error)}`,
"Claude setup-token",
);
}
}
} catch (err) {
spin.stop("OAuth failed");
runtime.error(String(err));
note("Trouble with OAuth? See https://docs.clawd.bot/start/faq", "OAuth");
}
} else if (authChoice === "claude-cli") {
next = applyAuthProfileConfig(next, {
profileId: CLAUDE_CLI_PROFILE_ID,
provider: "anthropic",
mode: "oauth",
mode: "token",
});
} else if (authChoice === "token" || authChoice === "oauth") {
const provider = guardCancel(
await select({
message: "Token provider",
options: [
{
value: "anthropic",
label: "Anthropic (only supported)",
},
],
}),
runtime,
) as "anthropic";
note(
[
"Run `claude setup-token` in your terminal.",
"Then paste the generated token below.",
].join("\n"),
"Anthropic token",
);
const tokenRaw = guardCancel(
await text({
message: "Paste Anthropic setup-token",
validate: (value) => validateAnthropicSetupToken(String(value ?? "")),
}),
runtime,
);
const token = String(tokenRaw).trim();
const profileNameRaw = guardCancel(
await text({
message: "Token name (blank = default)",
placeholder: "default",
}),
runtime,
);
const profileId = buildTokenProfileId({
provider,
name: String(profileNameRaw ?? ""),
});
upsertAuthProfile({
profileId,
credential: {
type: "token",
provider,
token,
},
});
next = applyAuthProfileConfig(next, { profileId, provider, mode: "token" });
} else if (authChoice === "openai-api-key") {
const envKey = resolveEnvApiKey("openai");
if (envKey) {
const useExisting = guardCancel(
await confirm({
message: `Use existing OPENAI_API_KEY (${envKey.source})?`,
initialValue: true,
}),
runtime,
);
if (useExisting) {
const result = upsertSharedEnvVar({
key: "OPENAI_API_KEY",
value: envKey.apiKey,
});
if (!process.env.OPENAI_API_KEY) {
process.env.OPENAI_API_KEY = envKey.apiKey;
}
note(
`Copied OPENAI_API_KEY to ${result.path} for launchd compatibility.`,
"OpenAI API key",
);
}
}
const key = guardCancel(
await text({
message: "Enter OpenAI API key",
validate: (value) => (value?.trim() ? undefined : "Required"),
}),
runtime,
);
const trimmed = String(key).trim();
const result = upsertSharedEnvVar({
key: "OPENAI_API_KEY",
value: trimmed,
});
process.env.OPENAI_API_KEY = trimmed;
note(
`Saved OPENAI_API_KEY to ${result.path} for launchd compatibility.`,
"OpenAI API key",
);
} else if (authChoice === "openai-codex") {
const isRemote = isRemoteEnvironment();
note(
@@ -511,6 +658,28 @@ async function promptAuthConfig(
runtime.error(String(err));
note("Trouble with OAuth? See https://docs.clawd.bot/start/faq", "OAuth");
}
} else if (authChoice === "gemini-api-key") {
const key = guardCancel(
await text({
message: "Enter Gemini API key",
validate: (value) => (value?.trim() ? undefined : "Required"),
}),
runtime,
);
await setGeminiApiKey(String(key).trim());
next = applyAuthProfileConfig(next, {
profileId: "google:default",
provider: "google",
mode: "api_key",
});
const applied = applyGoogleGeminiModelDefault(next);
next = applied.next;
if (applied.changed) {
note(
`Default model set to ${GOOGLE_GEMINI_DEFAULT_MODEL}`,
"Model configured",
);
}
} else if (authChoice === "apiKey") {
const key = guardCancel(
await text({
@@ -544,13 +713,24 @@ async function promptAuthConfig(
next = applyMinimaxConfig(next);
}
const currentModel =
typeof next.agent?.model === "string"
? next.agent?.model
: (next.agent?.model?.primary ?? "");
const preferAnthropic =
authChoice === "claude-cli" ||
authChoice === "token" ||
authChoice === "oauth" ||
authChoice === "apiKey";
const modelInitialValue =
preferAnthropic && !currentModel.startsWith("anthropic/")
? "anthropic/claude-opus-4-5"
: currentModel;
const modelInput = guardCancel(
await text({
message: "Default model (blank to keep)",
initialValue:
typeof next.agent?.model === "string"
? next.agent?.model
: (next.agent?.model?.primary ?? ""),
initialValue: modelInitialValue,
}),
runtime,
);
@@ -629,18 +809,24 @@ async function maybeInstallDaemon(params: {
const devMode =
process.argv[1]?.includes(`${path.sep}src${path.sep}`) &&
process.argv[1]?.endsWith(".ts");
const nodePath = await resolvePreferredNodePath({
env: process.env,
runtime: daemonRuntime,
});
const { programArguments, workingDirectory } =
await resolveGatewayProgramArguments({
port: params.port,
dev: devMode,
runtime: daemonRuntime,
nodePath,
});
const environment: Record<string, string | undefined> = {
PATH: process.env.PATH,
CLAWDBOT_GATEWAY_TOKEN: params.gatewayToken,
CLAWDBOT_LAUNCHD_LABEL:
const environment = buildServiceEnvironment({
env: process.env,
port: params.port,
token: params.gatewayToken,
launchdLabel:
process.platform === "darwin" ? GATEWAY_LAUNCH_AGENT_LABEL : undefined,
};
});
await service.install({
env: process.env,
stdout: process.stdout,
@@ -766,13 +952,41 @@ export async function runConfigureWizard(
await multiselect({
message: "Select sections to configure",
options: [
{ value: "workspace", label: "Workspace" },
{ value: "model", label: "Model/auth" },
{ value: "gateway", label: "Gateway config" },
{ value: "daemon", label: "Gateway daemon" },
{ value: "providers", label: "Providers" },
{ value: "skills", label: "Skills" },
{ value: "health", label: "Health check" },
{
value: "workspace",
label: "Workspace",
hint: "Set agent workspace + ensure sessions",
},
{
value: "model",
label: "Model/auth",
hint: "Pick model + auth profile sources",
},
{
value: "gateway",
label: "Gateway config",
hint: "Port/bind/auth/control UI settings",
},
{
value: "daemon",
label: "Gateway daemon",
hint: "Install/manage the background service",
},
{
value: "providers",
label: "Providers",
hint: "Link WhatsApp/Telegram/etc and defaults",
},
{
value: "skills",
label: "Skills",
hint: "Install/enable workspace skills",
},
{
value: "health",
label: "Health check",
hint: "Run gateway + provider checks",
},
],
}),
runtime,
@@ -885,58 +1099,65 @@ export async function runConfigureWizard(
runtime.error(controlUiAssets.message);
}
const bind = nextConfig.gateway?.bind ?? "loopback";
const links = resolveControlUiLinks({
bind,
port: gatewayPort,
basePath: nextConfig.gateway?.controlUi?.basePath,
});
const gatewayProbe = await probeGatewayReachable({
url: links.wsUrl,
token:
nextConfig.gateway?.auth?.token ?? process.env.CLAWDBOT_GATEWAY_TOKEN,
password:
nextConfig.gateway?.auth?.password ??
process.env.CLAWDBOT_GATEWAY_PASSWORD,
});
const gatewayStatusLine = gatewayProbe.ok
? "Gateway: reachable"
: `Gateway: not detected${gatewayProbe.detail ? ` (${gatewayProbe.detail})` : ""}`;
note(
(() => {
const bind = nextConfig.gateway?.bind ?? "loopback";
const links = resolveControlUiLinks({
bind,
port: gatewayPort,
basePath: nextConfig.gateway?.controlUi?.basePath,
});
return [
`Web UI: ${links.httpUrl}`,
`Gateway WS: ${links.wsUrl}`,
"Docs: https://docs.clawd.bot/web/control-ui",
].join("\n");
})(),
[
`Web UI: ${links.httpUrl}`,
`Gateway WS: ${links.wsUrl}`,
gatewayStatusLine,
"Docs: https://docs.clawd.bot/web/control-ui",
].join("\n"),
"Control UI",
);
const browserSupport = await detectBrowserOpenSupport();
if (!browserSupport.ok) {
note(
formatControlUiSshHint({
port: gatewayPort,
basePath: nextConfig.gateway?.controlUi?.basePath,
token: gatewayToken,
}),
"Open Control UI",
);
} else {
const wantsOpen = guardCancel(
await confirm({
message: "Open Control UI now?",
initialValue: false,
}),
runtime,
);
if (wantsOpen) {
const bind = nextConfig.gateway?.bind ?? "loopback";
const links = resolveControlUiLinks({
bind,
port: gatewayPort,
basePath: nextConfig.gateway?.controlUi?.basePath,
});
const opened = await openUrl(links.httpUrl);
if (!opened) {
note(
formatControlUiSshHint({
port: gatewayPort,
basePath: nextConfig.gateway?.controlUi?.basePath,
token: gatewayToken,
}),
"Open Control UI",
);
if (gatewayProbe.ok) {
if (!browserSupport.ok) {
note(
formatControlUiSshHint({
port: gatewayPort,
basePath: nextConfig.gateway?.controlUi?.basePath,
token: gatewayToken,
}),
"Open Control UI",
);
} else {
const wantsOpen = guardCancel(
await confirm({
message: "Open Control UI now?",
initialValue: false,
}),
runtime,
);
if (wantsOpen) {
const opened = await openUrl(links.httpUrl);
if (!opened) {
note(
formatControlUiSshHint({
port: gatewayPort,
basePath: nextConfig.gateway?.controlUi?.basePath,
token: gatewayToken,
}),
"Open Control UI",
);
}
}
}
}

View File

@@ -10,7 +10,7 @@ export const GATEWAY_DAEMON_RUNTIME_OPTIONS: Array<{
{
value: "node",
label: "Node (recommended)",
hint: "Required for WhatsApp (Baileys WebSocket). Bun can corrupt memory on reconnect.",
hint: "Required for WhatsApp + Telegram. Bun can corrupt memory on reconnect.",
},
];

View File

@@ -1,12 +1,24 @@
import { note } from "@clack/prompts";
import { note as clackNote } from "@clack/prompts";
import {
buildAuthHealthSummary,
DEFAULT_OAUTH_WARN_MS,
formatRemainingShort,
} from "../agents/auth-health.js";
import {
CLAUDE_CLI_PROFILE_ID,
CODEX_CLI_PROFILE_ID,
ensureAuthProfileStore,
repairOAuthProfileIdMismatch,
resolveApiKeyForProfile,
} from "../agents/auth-profiles.js";
import type { ClawdbotConfig } from "../config/config.js";
import { stylePromptTitle } from "../terminal/prompt-style.js";
import type { DoctorPrompter } from "./doctor-prompter.js";
const note = (message: string, title?: string) =>
clackNote(message, stylePromptTitle(title));
export async function maybeRepairAnthropicOAuthProfileId(
cfg: ClawdbotConfig,
prompter: DoctorPrompter,
@@ -28,3 +40,116 @@ export async function maybeRepairAnthropicOAuthProfileId(
if (!apply) return cfg;
return repair.config;
}
type AuthIssue = {
profileId: string;
provider: string;
status: string;
remainingMs?: number;
};
function formatAuthIssueHint(issue: AuthIssue): string | null {
if (
issue.provider === "anthropic" &&
issue.profileId === CLAUDE_CLI_PROFILE_ID
) {
return "Run `claude setup-token` on the gateway host.";
}
if (
issue.provider === "openai-codex" &&
issue.profileId === CODEX_CLI_PROFILE_ID
) {
return "Run `codex login` (or `clawdbot configure` → OpenAI Codex OAuth).";
}
return "Re-auth via `clawdbot configure` or `clawdbot onboard`.";
}
function formatAuthIssueLine(issue: AuthIssue): string {
const remaining =
issue.remainingMs !== undefined
? ` (${formatRemainingShort(issue.remainingMs)})`
: "";
const hint = formatAuthIssueHint(issue);
return `- ${issue.profileId}: ${issue.status}${remaining}${hint ? `${hint}` : ""}`;
}
export async function noteAuthProfileHealth(params: {
cfg: ClawdbotConfig;
prompter: DoctorPrompter;
allowKeychainPrompt: boolean;
}): Promise<void> {
const store = ensureAuthProfileStore(undefined, {
allowKeychainPrompt: params.allowKeychainPrompt,
});
let summary = buildAuthHealthSummary({
store,
cfg: params.cfg,
warnAfterMs: DEFAULT_OAUTH_WARN_MS,
});
const findIssues = () =>
summary.profiles.filter(
(profile) =>
(profile.type === "oauth" || profile.type === "token") &&
(profile.status === "expired" ||
profile.status === "expiring" ||
profile.status === "missing"),
);
let issues = findIssues();
if (issues.length === 0) return;
const shouldRefresh = await params.prompter.confirmRepair({
message: "Refresh expiring OAuth tokens now? (static tokens need re-auth)",
initialValue: true,
});
if (shouldRefresh) {
const refreshTargets = issues.filter(
(issue) =>
issue.type === "oauth" &&
["expired", "expiring", "missing"].includes(issue.status),
);
const errors: string[] = [];
for (const profile of refreshTargets) {
try {
await resolveApiKeyForProfile({
cfg: params.cfg,
store,
profileId: profile.profileId,
});
} catch (err) {
errors.push(
`- ${profile.profileId}: ${err instanceof Error ? err.message : String(err)}`,
);
}
}
if (errors.length > 0) {
note(errors.join("\n"), "OAuth refresh errors");
}
summary = buildAuthHealthSummary({
store: ensureAuthProfileStore(undefined, {
allowKeychainPrompt: false,
}),
cfg: params.cfg,
warnAfterMs: DEFAULT_OAUTH_WARN_MS,
});
issues = findIssues();
}
if (issues.length > 0) {
note(
issues
.map((issue) =>
formatAuthIssueLine({
profileId: issue.profileId,
provider: issue.provider,
status: issue.status,
remainingMs: issue.remainingMs,
}),
)
.join("\n"),
"Model auth",
);
}
}

View File

@@ -1,6 +1,6 @@
import path from "node:path";
import { note } from "@clack/prompts";
import { note as clackNote } from "@clack/prompts";
import type { ClawdbotConfig } from "../config/config.js";
import { resolveGatewayPort, resolveIsNixMode } from "../config/paths.js";
@@ -14,8 +14,18 @@ import {
uninstallLegacyGatewayServices,
} from "../daemon/legacy.js";
import { resolveGatewayProgramArguments } from "../daemon/program-args.js";
import {
resolvePreferredNodePath,
resolveSystemNodePath,
} from "../daemon/runtime-paths.js";
import { resolveGatewayService } from "../daemon/service.js";
import {
auditGatewayServiceConfig,
needsNodeRuntimeMigration,
} from "../daemon/service-audit.js";
import { buildServiceEnvironment } from "../daemon/service-env.js";
import type { RuntimeEnv } from "../runtime.js";
import { stylePromptTitle } from "../terminal/prompt-style.js";
import {
DEFAULT_GATEWAY_DAEMON_RUNTIME,
GATEWAY_DAEMON_RUNTIME_OPTIONS,
@@ -23,6 +33,21 @@ import {
} from "./daemon-runtime.js";
import type { DoctorOptions, DoctorPrompter } from "./doctor-prompter.js";
const note = (message: string, title?: string) =>
clackNote(message, stylePromptTitle(title));
function detectGatewayRuntime(
programArguments: string[] | undefined,
): GatewayDaemonRuntime {
const first = programArguments?.[0];
if (first) {
const base = path.basename(first).toLowerCase();
if (base === "bun" || base === "bun.exe") return "bun";
if (base === "node" || base === "node.exe") return "node";
}
return DEFAULT_GATEWAY_DAEMON_RUNTIME;
}
export async function maybeMigrateLegacyGatewayService(
cfg: ClawdbotConfig,
mode: "local" | "remote",
@@ -90,19 +115,24 @@ export async function maybeMigrateLegacyGatewayService(
process.argv[1]?.includes(`${path.sep}src${path.sep}`) &&
process.argv[1]?.endsWith(".ts");
const port = resolveGatewayPort(cfg, process.env);
const nodePath = await resolvePreferredNodePath({
env: process.env,
runtime: daemonRuntime,
});
const { programArguments, workingDirectory } =
await resolveGatewayProgramArguments({
port,
dev: devMode,
runtime: daemonRuntime,
nodePath,
});
const environment: Record<string, string | undefined> = {
PATH: process.env.PATH,
CLAWDBOT_GATEWAY_TOKEN:
cfg.gateway?.auth?.token ?? process.env.CLAWDBOT_GATEWAY_TOKEN,
CLAWDBOT_LAUNCHD_LABEL:
const environment = buildServiceEnvironment({
env: process.env,
port,
token: cfg.gateway?.auth?.token ?? process.env.CLAWDBOT_GATEWAY_TOKEN,
launchdLabel:
process.platform === "darwin" ? GATEWAY_LAUNCH_AGENT_LABEL : undefined,
};
});
await service.install({
env: process.env,
stdout: process.stdout,
@@ -112,6 +142,116 @@ export async function maybeMigrateLegacyGatewayService(
});
}
export async function maybeRepairGatewayServiceConfig(
cfg: ClawdbotConfig,
mode: "local" | "remote",
runtime: RuntimeEnv,
prompter: DoctorPrompter,
) {
if (resolveIsNixMode(process.env)) {
note("Nix mode detected; skip service updates.", "Gateway");
return;
}
if (mode === "remote") {
note("Gateway mode is remote; skipped local service audit.", "Gateway");
return;
}
const service = resolveGatewayService();
let command: Awaited<ReturnType<typeof service.readCommand>> | null = null;
try {
command = await service.readCommand(process.env);
} catch {
command = null;
}
if (!command) return;
const audit = await auditGatewayServiceConfig({
env: process.env,
command,
});
if (audit.issues.length === 0) return;
note(
audit.issues
.map((issue) =>
issue.detail
? `- ${issue.message} (${issue.detail})`
: `- ${issue.message}`,
)
.join("\n"),
"Gateway service config",
);
const aggressiveIssues = audit.issues.filter(
(issue) => issue.level === "aggressive",
);
const needsAggressive = aggressiveIssues.length > 0;
if (needsAggressive && !prompter.shouldForce) {
note(
"Custom or unexpected service edits detected. Rerun with --force to overwrite.",
"Gateway service config",
);
}
const repair = needsAggressive
? await prompter.confirmAggressive({
message: "Overwrite gateway service config with current defaults now?",
initialValue: Boolean(prompter.shouldForce),
})
: await prompter.confirmRepair({
message:
"Update gateway service config to the recommended defaults now?",
initialValue: true,
});
if (!repair) return;
const needsNodeRuntime = needsNodeRuntimeMigration(audit.issues);
const systemNodePath = needsNodeRuntime
? await resolveSystemNodePath(process.env)
: null;
if (needsNodeRuntime && !systemNodePath) {
note(
"System Node 22+ not found. Install via Homebrew/apt/choco and rerun doctor to migrate off Bun/version managers.",
"Gateway runtime",
);
}
const devMode =
process.argv[1]?.includes(`${path.sep}src${path.sep}`) &&
process.argv[1]?.endsWith(".ts");
const port = resolveGatewayPort(cfg, process.env);
const runtimeChoice = detectGatewayRuntime(command.programArguments);
const { programArguments, workingDirectory } =
await resolveGatewayProgramArguments({
port,
dev: devMode,
runtime: needsNodeRuntime && systemNodePath ? "node" : runtimeChoice,
nodePath: systemNodePath ?? undefined,
});
const environment = buildServiceEnvironment({
env: process.env,
port,
token: cfg.gateway?.auth?.token ?? process.env.CLAWDBOT_GATEWAY_TOKEN,
launchdLabel:
process.platform === "darwin" ? GATEWAY_LAUNCH_AGENT_LABEL : undefined,
});
try {
await service.install({
env: process.env,
stdout: process.stdout,
programArguments,
workingDirectory,
environment,
});
} catch (err) {
runtime.error(`Gateway service update failed: ${String(err)}`);
}
}
export async function maybeScanExtraGatewayServices(options: DoctorOptions) {
const extraServices = await findExtraGatewayServices(process.env, {
deep: options.deep,

View File

@@ -1,7 +1,7 @@
import os from "node:os";
import path from "node:path";
import { note } from "@clack/prompts";
import { note as clackNote } from "@clack/prompts";
import type { ClawdbotConfig } from "../config/config.js";
import {
@@ -12,8 +12,12 @@ import {
writeConfigFile,
} from "../config/config.js";
import type { RuntimeEnv } from "../runtime.js";
import { stylePromptTitle } from "../terminal/prompt-style.js";
import { resolveUserPath } from "../utils.js";
const note = (message: string, title?: string) =>
clackNote(message, stylePromptTitle(title));
function resolveLegacyConfigPath(env: NodeJS.ProcessEnv): string {
const override = env.CLAWDIS_CONFIG_PATH?.trim();
if (override) return override;

View File

@@ -1,6 +1,10 @@
import { confirm, select } from "@clack/prompts";
import type { RuntimeEnv } from "../runtime.js";
import {
stylePromptHint,
stylePromptMessage,
} from "../terminal/prompt-style.js";
import { guardCancel } from "./onboard-helpers.js";
export type DoctorOptions = {
@@ -8,14 +12,22 @@ export type DoctorOptions = {
yes?: boolean;
nonInteractive?: boolean;
deep?: boolean;
repair?: boolean;
force?: boolean;
};
export type DoctorPrompter = {
confirm: (params: Parameters<typeof confirm>[0]) => Promise<boolean>;
confirmRepair: (params: Parameters<typeof confirm>[0]) => Promise<boolean>;
confirmAggressive: (
params: Parameters<typeof confirm>[0],
) => Promise<boolean>;
confirmSkipInNonInteractive: (
params: Parameters<typeof confirm>[0],
) => Promise<boolean>;
select: <T>(params: Parameters<typeof select>[0], fallback: T) => Promise<T>;
shouldRepair: boolean;
shouldForce: boolean;
};
export function createDoctorPrompter(params: {
@@ -24,24 +36,69 @@ export function createDoctorPrompter(params: {
}): DoctorPrompter {
const yes = params.options.yes === true;
const requestedNonInteractive = params.options.nonInteractive === true;
const shouldRepair = params.options.repair === true || yes;
const shouldForce = params.options.force === true;
const isTty = Boolean(process.stdin.isTTY);
const nonInteractive = requestedNonInteractive || (!isTty && !yes);
const canPrompt = isTty && !yes && !nonInteractive;
const confirmDefault = async (p: Parameters<typeof confirm>[0]) => {
if (nonInteractive) return false;
if (shouldRepair) return true;
if (!canPrompt) return Boolean(p.initialValue ?? false);
return guardCancel(await confirm(p), params.runtime) === true;
return (
guardCancel(
await confirm({
...p,
message: stylePromptMessage(p.message),
}),
params.runtime,
) === true
);
};
return {
confirm: confirmDefault,
confirmSkipInNonInteractive: async (p) => {
confirmRepair: async (p) => {
if (nonInteractive) return false;
return confirmDefault(p);
},
select: async <T>(p: Parameters<typeof select>[0], fallback: T) => {
if (!canPrompt) return fallback;
return guardCancel(await select(p), params.runtime) as T;
confirmAggressive: async (p) => {
if (nonInteractive) return false;
if (shouldRepair && shouldForce) return true;
if (shouldRepair && !shouldForce) return false;
if (!canPrompt) return Boolean(p.initialValue ?? false);
return (
guardCancel(
await confirm({
...p,
message: stylePromptMessage(p.message),
}),
params.runtime,
) === true
);
},
confirmSkipInNonInteractive: async (p) => {
if (nonInteractive) return false;
if (shouldRepair) return true;
return confirmDefault(p);
},
select: async <T>(p: Parameters<typeof select>[0], fallback: T) => {
if (!canPrompt || shouldRepair) return fallback;
return guardCancel(
await select({
...p,
message: stylePromptMessage(p.message),
options: p.options.map((opt) =>
opt.hint === undefined
? opt
: { ...opt, hint: stylePromptHint(opt.hint) },
),
}),
params.runtime,
) as T;
},
shouldRepair,
shouldForce,
};
}

View File

@@ -1,7 +1,7 @@
import fs from "node:fs";
import path from "node:path";
import { note } from "@clack/prompts";
import { note as clackNote } from "@clack/prompts";
import {
DEFAULT_SANDBOX_BROWSER_IMAGE,
@@ -12,9 +12,13 @@ import {
import type { ClawdbotConfig } from "../config/config.js";
import { runCommandWithTimeout, runExec } from "../process/exec.js";
import type { RuntimeEnv } from "../runtime.js";
import { stylePromptTitle } from "../terminal/prompt-style.js";
import { replaceModernName } from "./doctor-legacy-config.js";
import type { DoctorPrompter } from "./doctor-prompter.js";
const note = (message: string, title?: string) =>
clackNote(message, stylePromptTitle(title));
type SandboxScriptInfo = {
scriptPath: string;
cwd: string;

View File

@@ -1,11 +1,15 @@
import { note } from "@clack/prompts";
import { note as clackNote } from "@clack/prompts";
import type { ClawdbotConfig } from "../config/config.js";
import { readProviderAllowFromStore } from "../pairing/pairing-store.js";
import { readTelegramAllowFromStore } from "../telegram/pairing-store.js";
import { resolveTelegramToken } from "../telegram/token.js";
import { stylePromptTitle } from "../terminal/prompt-style.js";
import { normalizeE164 } from "../utils.js";
const note = (message: string, title?: string) =>
clackNote(message, stylePromptTitle(title));
export async function noteSecurityWarnings(cfg: ClawdbotConfig) {
const warnings: string[] = [];

View File

@@ -2,7 +2,7 @@ import fs from "node:fs";
import os from "node:os";
import path from "node:path";
import { note } from "@clack/prompts";
import { note as clackNote } from "@clack/prompts";
import type { ClawdbotConfig } from "../config/config.js";
import { resolveOAuthDir, resolveStateDir } from "../config/paths.js";
@@ -14,6 +14,10 @@ import {
resolveStorePath,
} from "../config/sessions.js";
import { DEFAULT_AGENT_ID, normalizeAgentId } from "../routing/session-key.js";
import { stylePromptTitle } from "../terminal/prompt-style.js";
const note = (message: string, title?: string) =>
clackNote(message, stylePromptTitle(title));
type DoctorPrompterLike = {
confirmSkipInNonInteractive: (params: {
@@ -123,6 +127,7 @@ function findOtherStateDirs(stateDir: string): string[] {
export async function noteStateIntegrity(
cfg: ClawdbotConfig,
prompter: DoctorPrompterLike,
configPath?: string,
) {
const warnings: string[] = [];
const changes: string[] = [];
@@ -186,6 +191,49 @@ export async function noteStateIntegrity(
}
}
}
if (stateDirExists && process.platform !== "win32") {
try {
const stat = fs.statSync(stateDir);
if ((stat.mode & 0o077) !== 0) {
warnings.push(
`- State directory permissions are too open (${stateDir}). Recommend chmod 700.`,
);
const tighten = await prompter.confirmSkipInNonInteractive({
message: `Tighten permissions on ${stateDir} to 700?`,
initialValue: true,
});
if (tighten) {
fs.chmodSync(stateDir, 0o700);
changes.push(`- Tightened permissions on ${stateDir} to 700`);
}
}
} catch (err) {
warnings.push(`- Failed to read ${stateDir} permissions: ${String(err)}`);
}
}
if (configPath && existsFile(configPath) && process.platform !== "win32") {
try {
const stat = fs.statSync(configPath);
if ((stat.mode & 0o077) !== 0) {
warnings.push(
`- Config file is group/world readable (${configPath}). Recommend chmod 600.`,
);
const tighten = await prompter.confirmSkipInNonInteractive({
message: `Tighten permissions on ${configPath} to 600?`,
initialValue: true,
});
if (tighten) {
fs.chmodSync(configPath, 0o600);
changes.push(`- Tightened permissions on ${configPath} to 600`);
}
}
} catch (err) {
warnings.push(
`- Failed to read config permissions (${configPath}): ${String(err)}`,
);
}
}
if (stateDirExists) {
const dirCandidates = new Map<string, string>();

View File

@@ -94,6 +94,7 @@ const serviceIsLoaded = vi.fn().mockResolvedValue(false);
const serviceStop = vi.fn().mockResolvedValue(undefined);
const serviceRestart = vi.fn().mockResolvedValue(undefined);
const serviceUninstall = vi.fn().mockResolvedValue(undefined);
const callGateway = vi.fn().mockRejectedValue(new Error("gateway closed"));
vi.mock("@clack/prompts", () => ({
confirm,
@@ -133,6 +134,14 @@ vi.mock("../daemon/program-args.js", () => ({
resolveGatewayProgramArguments,
}));
vi.mock("../gateway/call.js", async (importOriginal) => {
const actual = await importOriginal<typeof import("../gateway/call.js")>();
return {
...actual,
callGateway,
};
});
vi.mock("../process/exec.js", () => ({
runExec,
runCommandWithTimeout,

View File

@@ -1,5 +1,9 @@
import path from "node:path";
import { intro, note, outro } from "@clack/prompts";
import {
intro as clackIntro,
note as clackNote,
outro as clackOutro,
} from "@clack/prompts";
import { buildWorkspaceSkillStatus } from "../agents/skills-status.js";
import type { ClawdbotConfig } from "../config/config.js";
import {
@@ -12,24 +16,32 @@ import {
import { GATEWAY_LAUNCH_AGENT_LABEL } from "../daemon/constants.js";
import { readLastGatewayErrorLine } from "../daemon/diagnostics.js";
import { resolveGatewayProgramArguments } from "../daemon/program-args.js";
import { resolvePreferredNodePath } from "../daemon/runtime-paths.js";
import { resolveGatewayService } from "../daemon/service.js";
import { buildGatewayConnectionDetails } from "../gateway/call.js";
import { buildServiceEnvironment } from "../daemon/service-env.js";
import { buildGatewayConnectionDetails, callGateway } from "../gateway/call.js";
import { formatPortDiagnostics, inspectPortUsage } from "../infra/ports.js";
import { collectProvidersStatusIssues } from "../infra/providers-status-issues.js";
import type { RuntimeEnv } from "../runtime.js";
import { defaultRuntime } from "../runtime.js";
import { stylePromptTitle } from "../terminal/prompt-style.js";
import { resolveUserPath, sleep } from "../utils.js";
import {
DEFAULT_GATEWAY_DAEMON_RUNTIME,
GATEWAY_DAEMON_RUNTIME_OPTIONS,
type GatewayDaemonRuntime,
} from "./daemon-runtime.js";
import { maybeRepairAnthropicOAuthProfileId } from "./doctor-auth.js";
import {
maybeRepairAnthropicOAuthProfileId,
noteAuthProfileHealth,
} from "./doctor-auth.js";
import {
buildGatewayRuntimeHints,
formatGatewayRuntimeSummary,
} from "./doctor-format.js";
import {
maybeMigrateLegacyGatewayService,
maybeRepairGatewayServiceConfig,
maybeScanExtraGatewayServices,
} from "./doctor-gateway-services.js";
import {
@@ -64,6 +76,13 @@ import {
} from "./onboard-helpers.js";
import { ensureSystemdUserLingerInteractive } from "./systemd-linger.js";
const intro = (message: string) =>
clackIntro(stylePromptTitle(message) ?? message);
const outro = (message: string) =>
clackOutro(stylePromptTitle(message) ?? message);
const note = (message: string, title?: string) =>
clackNote(message, stylePromptTitle(title));
function resolveMode(cfg: ClawdbotConfig): "local" | "remote" {
return cfg.gateway?.mode === "remote" ? "remote" : "local";
}
@@ -120,6 +139,12 @@ export async function doctorCommand(
}
cfg = await maybeRepairAnthropicOAuthProfileId(cfg, prompter);
await noteAuthProfileHealth({
cfg,
prompter,
allowKeychainPrompt:
options.nonInteractive !== true && Boolean(process.stdin.isTTY),
});
const gatewayDetails = buildGatewayConnectionDetails({ config: cfg });
if (gatewayDetails.remoteFallbackNote) {
note(gatewayDetails.remoteFallbackNote, "Gateway");
@@ -128,10 +153,13 @@ export async function doctorCommand(
const legacyState = await detectLegacyStateMigrations({ cfg });
if (legacyState.preview.length > 0) {
note(legacyState.preview.join("\n"), "Legacy state detected");
const migrate = await prompter.confirm({
message: "Migrate legacy state (sessions/agent/WhatsApp auth) now?",
initialValue: true,
});
const migrate =
options.nonInteractive === true
? true
: await prompter.confirm({
message: "Migrate legacy state (sessions/agent/WhatsApp auth) now?",
initialValue: true,
});
if (migrate) {
const migrated = await runLegacyStateMigrations({
detected: legacyState,
@@ -145,7 +173,11 @@ export async function doctorCommand(
}
}
await noteStateIntegrity(cfg, prompter);
await noteStateIntegrity(
cfg,
prompter,
snapshot.path ?? CONFIG_PATH_CLAWDBOT,
);
cfg = await maybeRepairSandboxImages(cfg, runtime, prompter);
noteSandboxScopeWarnings(cfg);
@@ -157,6 +189,12 @@ export async function doctorCommand(
prompter,
);
await maybeScanExtraGatewayServices(options);
await maybeRepairGatewayServiceConfig(
cfg,
resolveMode(cfg),
runtime,
prompter,
);
await noteSecurityWarnings(cfg);
@@ -223,6 +261,30 @@ export async function doctorCommand(
}
}
if (healthOk) {
try {
const status = await callGateway<Record<string, unknown>>({
method: "providers.status",
params: { probe: true, timeoutMs: 5000 },
timeoutMs: 6000,
});
const issues = collectProvidersStatusIssues(status);
if (issues.length > 0) {
note(
issues
.map(
(issue) =>
`- ${issue.provider} ${issue.accountId}: ${issue.message}${issue.fix ? ` (${issue.fix})` : ""}`,
)
.join("\n"),
"Provider warnings",
);
}
} catch {
// ignore: doctor already reported gateway health
}
}
if (!healthOk) {
const service = resolveGatewayService();
const loaded = await service.isLoaded({ env: process.env });
@@ -266,25 +328,27 @@ export async function doctorCommand(
process.argv[1]?.includes(`${path.sep}src${path.sep}`) &&
process.argv[1]?.endsWith(".ts");
const port = resolveGatewayPort(cfg, process.env);
const nodePath = await resolvePreferredNodePath({
env: process.env,
runtime: daemonRuntime,
});
const { programArguments, workingDirectory } =
await resolveGatewayProgramArguments({
port,
dev: devMode,
runtime: daemonRuntime,
nodePath,
});
const environment: Record<string, string | undefined> = {
PATH: process.env.PATH,
CLAWDBOT_PROFILE: process.env.CLAWDBOT_PROFILE,
CLAWDBOT_STATE_DIR: process.env.CLAWDBOT_STATE_DIR,
CLAWDBOT_CONFIG_PATH: process.env.CLAWDBOT_CONFIG_PATH,
CLAWDBOT_GATEWAY_PORT: String(port),
CLAWDBOT_GATEWAY_TOKEN:
const environment = buildServiceEnvironment({
env: process.env,
port,
token:
cfg.gateway?.auth?.token ?? process.env.CLAWDBOT_GATEWAY_TOKEN,
CLAWDBOT_LAUNCHD_LABEL:
launchdLabel:
process.platform === "darwin"
? GATEWAY_LAUNCH_AGENT_LABEL
: undefined,
};
});
await service.install({
env: process.env,
stdout: process.stdout,

View File

@@ -0,0 +1,176 @@
import { describe, expect, it, vi } from "vitest";
const loadConfig = vi.fn(() => ({
gateway: {
mode: "remote",
remote: { url: "ws://remote.example:18789", token: "rtok" },
auth: { token: "ltok" },
},
}));
const resolveGatewayPort = vi.fn(() => 18789);
const discoverGatewayBeacons = vi.fn(async () => []);
const pickPrimaryTailnetIPv4 = vi.fn(() => "100.64.0.10");
const sshStop = vi.fn(async () => {});
const startSshPortForward = vi.fn(async () => ({
parsedTarget: { user: "me", host: "studio", port: 22 },
localPort: 18789,
remotePort: 18789,
pid: 123,
stderr: [],
stop: sshStop,
}));
const probeGateway = vi.fn(async ({ url }: { url: string }) => {
if (url.includes("127.0.0.1")) {
return {
ok: true,
url,
connectLatencyMs: 12,
error: null,
close: null,
health: { ok: true },
status: { web: { linked: false }, sessions: { count: 0 } },
presence: [
{ mode: "gateway", reason: "self", host: "local", ip: "127.0.0.1" },
],
configSnapshot: {
path: "/tmp/cfg.json",
exists: true,
valid: true,
config: {
gateway: { mode: "local" },
bridge: { enabled: true, port: 18790 },
},
issues: [],
legacyIssues: [],
},
};
}
return {
ok: true,
url,
connectLatencyMs: 34,
error: null,
close: null,
health: { ok: true },
status: { web: { linked: true }, sessions: { count: 2 } },
presence: [
{ mode: "gateway", reason: "self", host: "remote", ip: "100.64.0.2" },
],
configSnapshot: {
path: "/tmp/remote.json",
exists: true,
valid: true,
config: { gateway: { mode: "remote" }, bridge: { enabled: false } },
issues: [],
legacyIssues: [],
},
};
});
vi.mock("../config/config.js", () => ({
loadConfig: () => loadConfig(),
resolveGatewayPort: (cfg: unknown) => resolveGatewayPort(cfg),
}));
vi.mock("../infra/bonjour-discovery.js", () => ({
discoverGatewayBeacons: (opts: unknown) => discoverGatewayBeacons(opts),
}));
vi.mock("../infra/tailnet.js", () => ({
pickPrimaryTailnetIPv4: () => pickPrimaryTailnetIPv4(),
}));
vi.mock("../infra/ssh-tunnel.js", () => ({
startSshPortForward: (opts: unknown) => startSshPortForward(opts),
}));
vi.mock("../gateway/probe.js", () => ({
probeGateway: (opts: unknown) => probeGateway(opts),
}));
describe("gateway-status command", () => {
it("prints human output by default", async () => {
const runtimeLogs: string[] = [];
const runtimeErrors: string[] = [];
const runtime = {
log: (msg: string) => runtimeLogs.push(msg),
error: (msg: string) => runtimeErrors.push(msg),
exit: (code: number) => {
throw new Error(`__exit__:${code}`);
},
};
const { gatewayStatusCommand } = await import("./gateway-status.js");
await gatewayStatusCommand(
{ timeout: "1000" },
runtime as unknown as import("../runtime.js").RuntimeEnv,
);
expect(runtimeErrors).toHaveLength(0);
expect(runtimeLogs.join("\n")).toContain("Gateway Status");
expect(runtimeLogs.join("\n")).toContain("Discovery (this machine)");
expect(runtimeLogs.join("\n")).toContain("Targets");
});
it("prints a structured JSON envelope when --json is set", async () => {
const runtimeLogs: string[] = [];
const runtimeErrors: string[] = [];
const runtime = {
log: (msg: string) => runtimeLogs.push(msg),
error: (msg: string) => runtimeErrors.push(msg),
exit: (code: number) => {
throw new Error(`__exit__:${code}`);
},
};
const { gatewayStatusCommand } = await import("./gateway-status.js");
await gatewayStatusCommand(
{ timeout: "1000", json: true },
runtime as unknown as import("../runtime.js").RuntimeEnv,
);
expect(runtimeErrors).toHaveLength(0);
const parsed = JSON.parse(runtimeLogs.join("\n")) as Record<
string,
unknown
>;
expect(parsed.ok).toBe(true);
expect(parsed.targets).toBeTruthy();
const targets = parsed.targets as Array<Record<string, unknown>>;
expect(targets.length).toBeGreaterThanOrEqual(2);
expect(targets[0]?.health).toBeTruthy();
expect(targets[0]?.summary).toBeTruthy();
});
it("supports SSH tunnel targets", async () => {
const runtimeLogs: string[] = [];
const runtime = {
log: (msg: string) => runtimeLogs.push(msg),
error: (_msg: string) => {},
exit: (code: number) => {
throw new Error(`__exit__:${code}`);
},
};
startSshPortForward.mockClear();
sshStop.mockClear();
probeGateway.mockClear();
const { gatewayStatusCommand } = await import("./gateway-status.js");
await gatewayStatusCommand(
{ timeout: "1000", json: true, ssh: "me@studio" },
runtime as unknown as import("../runtime.js").RuntimeEnv,
);
expect(startSshPortForward).toHaveBeenCalledTimes(1);
expect(probeGateway).toHaveBeenCalled();
expect(sshStop).toHaveBeenCalledTimes(1);
const parsed = JSON.parse(runtimeLogs.join("\n")) as Record<
string,
unknown
>;
const targets = parsed.targets as Array<Record<string, unknown>>;
expect(targets.some((t) => t.kind === "sshTunnel")).toBe(true);
});
});

View File

@@ -0,0 +1,652 @@
import { withProgress } from "../cli/progress.js";
import { loadConfig, resolveGatewayPort } from "../config/config.js";
import type { ClawdbotConfig, ConfigFileSnapshot } from "../config/types.js";
import { type GatewayProbeResult, probeGateway } from "../gateway/probe.js";
import { discoverGatewayBeacons } from "../infra/bonjour-discovery.js";
import { startSshPortForward } from "../infra/ssh-tunnel.js";
import { pickPrimaryTailnetIPv4 } from "../infra/tailnet.js";
import type { RuntimeEnv } from "../runtime.js";
import { colorize, isRich, theme } from "../terminal/theme.js";
type TargetKind = "explicit" | "configRemote" | "localLoopback" | "sshTunnel";
type GatewayStatusTarget = {
id: string;
kind: TargetKind;
url: string;
active: boolean;
tunnel?: {
kind: "ssh";
target: string;
localPort: number;
remotePort: number;
pid: number | null;
};
};
type GatewayConfigSummary = {
path: string | null;
exists: boolean;
valid: boolean;
issues: Array<{ path: string; message: string }>;
legacyIssues: Array<{ path: string; message: string }>;
gateway: {
mode: string | null;
bind: string | null;
port: number | null;
controlUiEnabled: boolean | null;
controlUiBasePath: string | null;
authMode: string | null;
authTokenConfigured: boolean;
authPasswordConfigured: boolean;
remoteUrl: string | null;
remoteTokenConfigured: boolean;
remotePasswordConfigured: boolean;
tailscaleMode: string | null;
};
bridge: {
enabled: boolean | null;
bind: string | null;
port: number | null;
};
discovery: {
wideAreaEnabled: boolean | null;
};
};
function parseIntOrNull(value: unknown): number | null {
const s =
typeof value === "string"
? value.trim()
: typeof value === "number" || typeof value === "bigint"
? String(value)
: "";
if (!s) return null;
const n = Number.parseInt(s, 10);
return Number.isFinite(n) ? n : null;
}
function parseTimeoutMs(raw: unknown, fallbackMs: number): number {
const value =
typeof raw === "string"
? raw.trim()
: typeof raw === "number" || typeof raw === "bigint"
? String(raw)
: "";
if (!value) return fallbackMs;
const parsed = Number.parseInt(value, 10);
if (!Number.isFinite(parsed) || parsed <= 0) {
throw new Error(`invalid --timeout: ${value}`);
}
return parsed;
}
function normalizeWsUrl(value: string): string | null {
const trimmed = value.trim();
if (!trimmed) return null;
if (!trimmed.startsWith("ws://") && !trimmed.startsWith("wss://"))
return null;
return trimmed;
}
function resolveTargets(
cfg: ClawdbotConfig,
explicitUrl?: string,
): GatewayStatusTarget[] {
const targets: GatewayStatusTarget[] = [];
const add = (t: GatewayStatusTarget) => {
if (!targets.some((x) => x.url === t.url)) targets.push(t);
};
const explicit =
typeof explicitUrl === "string" ? normalizeWsUrl(explicitUrl) : null;
if (explicit)
add({ id: "explicit", kind: "explicit", url: explicit, active: true });
const remoteUrl =
typeof cfg.gateway?.remote?.url === "string"
? normalizeWsUrl(cfg.gateway.remote.url)
: null;
if (remoteUrl) {
add({
id: "configRemote",
kind: "configRemote",
url: remoteUrl,
active: cfg.gateway?.mode === "remote",
});
}
const port = resolveGatewayPort(cfg);
add({
id: "localLoopback",
kind: "localLoopback",
url: `ws://127.0.0.1:${port}`,
active: cfg.gateway?.mode !== "remote",
});
return targets;
}
function resolveProbeBudgetMs(overallMs: number, kind: TargetKind): number {
if (kind === "localLoopback") return Math.min(800, overallMs);
if (kind === "sshTunnel") return Math.min(2000, overallMs);
return Math.min(1500, overallMs);
}
function sanitizeSshTarget(value: unknown): string | null {
if (typeof value !== "string") return null;
const trimmed = value.trim();
if (!trimmed) return null;
return trimmed.replace(/^ssh\s+/, "");
}
function resolveAuthForTarget(
cfg: ClawdbotConfig,
target: GatewayStatusTarget,
overrides: { token?: string; password?: string },
): { token?: string; password?: string } {
const tokenOverride = overrides.token?.trim()
? overrides.token.trim()
: undefined;
const passwordOverride = overrides.password?.trim()
? overrides.password.trim()
: undefined;
if (tokenOverride || passwordOverride) {
return { token: tokenOverride, password: passwordOverride };
}
if (target.kind === "configRemote") {
const token =
typeof cfg.gateway?.remote?.token === "string"
? cfg.gateway.remote.token.trim()
: "";
const remotePassword = (
cfg.gateway?.remote as { password?: unknown } | undefined
)?.password;
const password =
typeof remotePassword === "string" ? remotePassword.trim() : "";
return {
token: token.length > 0 ? token : undefined,
password: password.length > 0 ? password : undefined,
};
}
const envToken = process.env.CLAWDBOT_GATEWAY_TOKEN?.trim() || "";
const envPassword = process.env.CLAWDBOT_GATEWAY_PASSWORD?.trim() || "";
const cfgToken =
typeof cfg.gateway?.auth?.token === "string"
? cfg.gateway.auth.token.trim()
: "";
const cfgPassword =
typeof cfg.gateway?.auth?.password === "string"
? cfg.gateway.auth.password.trim()
: "";
return {
token: envToken || cfgToken || undefined,
password: envPassword || cfgPassword || undefined,
};
}
function pickGatewaySelfPresence(
presence: unknown,
): { host?: string; ip?: string; version?: string; platform?: string } | null {
if (!Array.isArray(presence)) return null;
const entries = presence as Array<Record<string, unknown>>;
const self =
entries.find((e) => e.mode === "gateway" && e.reason === "self") ??
entries.find(
(e) =>
typeof e.text === "string" && String(e.text).startsWith("Gateway:"),
) ??
null;
if (!self) return null;
return {
host: typeof self.host === "string" ? self.host : undefined,
ip: typeof self.ip === "string" ? self.ip : undefined,
version: typeof self.version === "string" ? self.version : undefined,
platform: typeof self.platform === "string" ? self.platform : undefined,
};
}
function extractConfigSummary(snapshotUnknown: unknown): GatewayConfigSummary {
const snap = snapshotUnknown as Partial<ConfigFileSnapshot> | null;
const path = typeof snap?.path === "string" ? snap.path : null;
const exists = Boolean(snap?.exists);
const valid = Boolean(snap?.valid);
const issuesRaw = Array.isArray(snap?.issues) ? snap.issues : [];
const legacyRaw = Array.isArray(snap?.legacyIssues) ? snap.legacyIssues : [];
const cfg = (snap?.config ?? {}) as Record<string, unknown>;
const gateway = (cfg.gateway ?? {}) as Record<string, unknown>;
const bridge = (cfg.bridge ?? {}) as Record<string, unknown>;
const discovery = (cfg.discovery ?? {}) as Record<string, unknown>;
const wideArea = (discovery.wideArea ?? {}) as Record<string, unknown>;
const remote = (gateway.remote ?? {}) as Record<string, unknown>;
const auth = (gateway.auth ?? {}) as Record<string, unknown>;
const controlUi = (gateway.controlUi ?? {}) as Record<string, unknown>;
const tailscale = (gateway.tailscale ?? {}) as Record<string, unknown>;
const authMode = typeof auth.mode === "string" ? auth.mode : null;
const authTokenConfigured =
typeof auth.token === "string" ? auth.token.trim().length > 0 : false;
const authPasswordConfigured =
typeof auth.password === "string" ? auth.password.trim().length > 0 : false;
const remoteUrl =
typeof remote.url === "string" ? normalizeWsUrl(remote.url) : null;
const remoteTokenConfigured =
typeof remote.token === "string" ? remote.token.trim().length > 0 : false;
const remotePasswordConfigured =
typeof remote.password === "string"
? String(remote.password).trim().length > 0
: false;
const bridgeEnabled =
typeof bridge.enabled === "boolean" ? bridge.enabled : null;
const bridgeBind = typeof bridge.bind === "string" ? bridge.bind : null;
const bridgePort = parseIntOrNull(bridge.port);
const wideAreaEnabled =
typeof wideArea.enabled === "boolean" ? wideArea.enabled : null;
return {
path,
exists,
valid,
issues: issuesRaw
.filter((i): i is { path: string; message: string } =>
Boolean(
i && typeof i.path === "string" && typeof i.message === "string",
),
)
.map((i) => ({ path: i.path, message: i.message })),
legacyIssues: legacyRaw
.filter((i): i is { path: string; message: string } =>
Boolean(
i && typeof i.path === "string" && typeof i.message === "string",
),
)
.map((i) => ({ path: i.path, message: i.message })),
gateway: {
mode: typeof gateway.mode === "string" ? gateway.mode : null,
bind: typeof gateway.bind === "string" ? gateway.bind : null,
port: parseIntOrNull(gateway.port),
controlUiEnabled:
typeof controlUi.enabled === "boolean" ? controlUi.enabled : null,
controlUiBasePath:
typeof controlUi.basePath === "string" ? controlUi.basePath : null,
authMode,
authTokenConfigured,
authPasswordConfigured,
remoteUrl,
remoteTokenConfigured,
remotePasswordConfigured,
tailscaleMode: typeof tailscale.mode === "string" ? tailscale.mode : null,
},
bridge: {
enabled: bridgeEnabled,
bind: bridgeBind,
port: bridgePort,
},
discovery: { wideAreaEnabled },
};
}
function buildNetworkHints(cfg: ClawdbotConfig) {
const tailnetIPv4 = pickPrimaryTailnetIPv4();
const port = resolveGatewayPort(cfg);
return {
localLoopbackUrl: `ws://127.0.0.1:${port}`,
localTailnetUrl: tailnetIPv4 ? `ws://${tailnetIPv4}:${port}` : null,
tailnetIPv4: tailnetIPv4 ?? null,
};
}
function renderTargetHeader(target: GatewayStatusTarget, rich: boolean) {
const kindLabel =
target.kind === "localLoopback"
? "Local loopback"
: target.kind === "sshTunnel"
? "Remote over SSH"
: target.kind === "configRemote"
? target.active
? "Remote (configured)"
: "Remote (configured, inactive)"
: "URL (explicit)";
return `${colorize(rich, theme.heading, kindLabel)} ${colorize(rich, theme.muted, target.url)}`;
}
function renderProbeSummaryLine(probe: GatewayProbeResult, rich: boolean) {
if (probe.ok) {
const latency =
typeof probe.connectLatencyMs === "number"
? `${probe.connectLatencyMs}ms`
: "unknown";
return `${colorize(rich, theme.success, "Connect: ok")} (${latency})`;
}
const detail = probe.error ? ` - ${probe.error}` : "";
return `${colorize(rich, theme.error, "Connect: failed")}${detail}`;
}
export async function gatewayStatusCommand(
opts: {
url?: string;
token?: string;
password?: string;
timeout?: unknown;
json?: boolean;
ssh?: string;
sshIdentity?: string;
sshAuto?: boolean;
},
runtime: RuntimeEnv,
) {
const startedAt = Date.now();
const cfg = loadConfig();
const rich = isRich() && opts.json !== true;
const overallTimeoutMs = parseTimeoutMs(opts.timeout, 3000);
const baseTargets = resolveTargets(cfg, opts.url);
const network = buildNetworkHints(cfg);
const discoveryTimeoutMs = Math.min(1200, overallTimeoutMs);
const discoveryPromise = discoverGatewayBeacons({
timeoutMs: discoveryTimeoutMs,
});
let sshTarget =
sanitizeSshTarget(opts.ssh) ??
sanitizeSshTarget(cfg.gateway?.remote?.sshTarget);
const sshIdentity =
sanitizeSshTarget(opts.sshIdentity) ??
sanitizeSshTarget(cfg.gateway?.remote?.sshIdentity);
const remotePort = resolveGatewayPort(cfg);
let sshTunnelError: string | null = null;
let sshTunnelStarted = false;
const { discovery, probed } = await withProgress(
{
label: "Inspecting gateways…",
indeterminate: true,
enabled: opts.json !== true,
},
async () => {
const tryStartTunnel = async () => {
if (!sshTarget) return null;
try {
const tunnel = await startSshPortForward({
target: sshTarget,
identity: sshIdentity ?? undefined,
localPortPreferred: remotePort,
remotePort,
timeoutMs: Math.min(1500, overallTimeoutMs),
});
sshTunnelStarted = true;
return tunnel;
} catch (err) {
sshTunnelError = err instanceof Error ? err.message : String(err);
return null;
}
};
const discoveryTask = discoveryPromise.catch(() => []);
const tunnelTask = sshTarget ? tryStartTunnel() : Promise.resolve(null);
const [discovery, tunnelFirst] = await Promise.all([
discoveryTask,
tunnelTask,
]);
if (!sshTarget && opts.sshAuto) {
const user = process.env.USER?.trim() || "";
const candidates = discovery
.map((b) => {
const host = b.tailnetDns || b.lanHost || b.host;
if (!host?.trim()) return null;
const sshPort =
typeof b.sshPort === "number" && b.sshPort > 0 ? b.sshPort : 22;
const base = user ? `${user}@${host.trim()}` : host.trim();
return sshPort !== 22 ? `${base}:${sshPort}` : base;
})
.filter((x): x is string => Boolean(x));
if (candidates.length > 0) sshTarget = candidates[0] ?? null;
}
const tunnel =
tunnelFirst ||
(sshTarget && !sshTunnelStarted && !sshTunnelError
? await tryStartTunnel()
: null);
const tunnelTarget: GatewayStatusTarget | null = tunnel
? {
id: "sshTunnel",
kind: "sshTunnel",
url: `ws://127.0.0.1:${tunnel.localPort}`,
active: true,
tunnel: {
kind: "ssh",
target: sshTarget ?? "",
localPort: tunnel.localPort,
remotePort,
pid: tunnel.pid,
},
}
: null;
const targets: GatewayStatusTarget[] = tunnelTarget
? [
tunnelTarget,
...baseTargets.filter((t) => t.url !== tunnelTarget.url),
]
: baseTargets;
try {
const probed = await Promise.all(
targets.map(async (target) => {
const auth = resolveAuthForTarget(cfg, target, {
token: typeof opts.token === "string" ? opts.token : undefined,
password:
typeof opts.password === "string" ? opts.password : undefined,
});
const timeoutMs = resolveProbeBudgetMs(
overallTimeoutMs,
target.kind,
);
const probe = await probeGateway({
url: target.url,
auth,
timeoutMs,
});
const configSummary = probe.configSnapshot
? extractConfigSummary(probe.configSnapshot)
: null;
const self = pickGatewaySelfPresence(probe.presence);
return { target, probe, configSummary, self };
}),
);
return { discovery, probed };
} finally {
if (tunnel) {
try {
await tunnel.stop();
} catch {
// best-effort
}
}
}
},
);
const reachable = probed.filter((p) => p.probe.ok);
const ok = reachable.length > 0;
const multipleGateways = reachable.length > 1;
const primary =
reachable.find((p) => p.target.kind === "explicit") ??
reachable.find((p) => p.target.kind === "sshTunnel") ??
reachable.find((p) => p.target.kind === "configRemote") ??
reachable.find((p) => p.target.kind === "localLoopback") ??
null;
const warnings: Array<{
code: string;
message: string;
targetIds?: string[];
}> = [];
if (sshTarget && !sshTunnelStarted) {
warnings.push({
code: "ssh_tunnel_failed",
message: sshTunnelError
? `SSH tunnel failed: ${String(sshTunnelError)}`
: "SSH tunnel failed to start; falling back to direct probes.",
});
}
if (multipleGateways) {
warnings.push({
code: "multiple_gateways",
message:
"Unconventional setup: multiple reachable gateways detected. Usually only one gateway should exist on a network.",
targetIds: reachable.map((p) => p.target.id),
});
}
if (opts.json) {
runtime.log(
JSON.stringify(
{
ok,
ts: Date.now(),
durationMs: Date.now() - startedAt,
timeoutMs: overallTimeoutMs,
primaryTargetId: primary?.target.id ?? null,
warnings,
network,
discovery: {
timeoutMs: discoveryTimeoutMs,
count: discovery.length,
beacons: discovery.map((b) => ({
instanceName: b.instanceName,
displayName: b.displayName ?? null,
domain: b.domain ?? null,
host: b.host ?? null,
lanHost: b.lanHost ?? null,
tailnetDns: b.tailnetDns ?? null,
bridgePort: b.bridgePort ?? null,
gatewayPort: b.gatewayPort ?? null,
sshPort: b.sshPort ?? null,
wsUrl: (() => {
const host = b.tailnetDns || b.lanHost || b.host;
const port = b.gatewayPort ?? 18789;
return host ? `ws://${host}:${port}` : null;
})(),
})),
},
targets: probed.map((p) => ({
id: p.target.id,
kind: p.target.kind,
url: p.target.url,
active: p.target.active,
tunnel: p.target.tunnel ?? null,
connect: {
ok: p.probe.ok,
latencyMs: p.probe.connectLatencyMs,
error: p.probe.error,
close: p.probe.close,
},
self: p.self,
config: p.configSummary,
health: p.probe.health,
summary: p.probe.status,
presence: p.probe.presence,
})),
},
null,
2,
),
);
if (!ok) runtime.exit(1);
return;
}
runtime.log(colorize(rich, theme.heading, "Gateway Status"));
runtime.log(
ok
? `${colorize(rich, theme.success, "Reachable")}: yes`
: `${colorize(rich, theme.error, "Reachable")}: no`,
);
runtime.log(
colorize(rich, theme.muted, `Probe budget: ${overallTimeoutMs}ms`),
);
if (warnings.length > 0) {
runtime.log("");
runtime.log(colorize(rich, theme.warn, "Warning:"));
for (const w of warnings) runtime.log(`- ${w.message}`);
}
runtime.log("");
runtime.log(colorize(rich, theme.heading, "Discovery (this machine)"));
runtime.log(
discovery.length > 0
? `Found ${discovery.length} gateway(s) via Bonjour (local. + clawdbot.internal.)`
: "Found 0 gateways via Bonjour (local. + clawdbot.internal.)",
);
if (discovery.length === 0) {
runtime.log(
colorize(
rich,
theme.muted,
"Tip: if the gateway is remote, mDNS wont cross networks; use Wide-Area Bonjour (split DNS) or SSH tunnels.",
),
);
}
runtime.log("");
runtime.log(colorize(rich, theme.heading, "Targets"));
for (const p of probed) {
runtime.log(renderTargetHeader(p.target, rich));
runtime.log(` ${renderProbeSummaryLine(p.probe, rich)}`);
if (p.target.tunnel?.kind === "ssh") {
runtime.log(
` ${colorize(rich, theme.muted, "ssh")}: ${colorize(rich, theme.command, p.target.tunnel.target)}`,
);
}
if (p.probe.ok && p.self) {
const host = p.self.host ?? "unknown";
const ip = p.self.ip ? ` (${p.self.ip})` : "";
const platform = p.self.platform ? ` · ${p.self.platform}` : "";
const version = p.self.version ? ` · app ${p.self.version}` : "";
runtime.log(
` ${colorize(rich, theme.info, "Gateway")}: ${host}${ip}${platform}${version}`,
);
}
if (p.configSummary) {
const c = p.configSummary;
const bridge =
c.bridge.enabled === false
? "disabled"
: c.bridge.enabled === true
? "enabled"
: "unknown";
const wideArea =
c.discovery.wideAreaEnabled === true
? "enabled"
: c.discovery.wideAreaEnabled === false
? "disabled"
: "unknown";
runtime.log(
` ${colorize(rich, theme.info, "Bridge")}: ${bridge}${c.bridge.bind ? ` · bind ${c.bridge.bind}` : ""}${c.bridge.port ? ` · port ${c.bridge.port}` : ""}`,
);
runtime.log(
` ${colorize(rich, theme.info, "Wide-area discovery")}: ${wideArea}`,
);
}
runtime.log("");
}
if (!ok) runtime.exit(1);
}

View File

@@ -0,0 +1,38 @@
import { describe, expect, it } from "vitest";
import type { ClawdbotConfig } from "../config/config.js";
import {
applyGoogleGeminiModelDefault,
GOOGLE_GEMINI_DEFAULT_MODEL,
} from "./google-gemini-model-default.js";
describe("applyGoogleGeminiModelDefault", () => {
it("sets gemini default when model is unset", () => {
const cfg: ClawdbotConfig = { agent: {} };
const applied = applyGoogleGeminiModelDefault(cfg);
expect(applied.changed).toBe(true);
expect(applied.next.agent?.model).toEqual({
primary: GOOGLE_GEMINI_DEFAULT_MODEL,
});
});
it("overrides existing model", () => {
const cfg: ClawdbotConfig = {
agent: { model: "anthropic/claude-opus-4-5" },
};
const applied = applyGoogleGeminiModelDefault(cfg);
expect(applied.changed).toBe(true);
expect(applied.next.agent?.model).toEqual({
primary: GOOGLE_GEMINI_DEFAULT_MODEL,
});
});
it("no-ops when already gemini default", () => {
const cfg: ClawdbotConfig = {
agent: { model: GOOGLE_GEMINI_DEFAULT_MODEL },
};
const applied = applyGoogleGeminiModelDefault(cfg);
expect(applied.changed).toBe(false);
expect(applied.next).toEqual(cfg);
});
});

View File

@@ -0,0 +1,38 @@
import type { ClawdbotConfig } from "../config/config.js";
import type { AgentModelListConfig } from "../config/types.js";
export const GOOGLE_GEMINI_DEFAULT_MODEL = "google/gemini-3-pro-preview";
function resolvePrimaryModel(
model?: AgentModelListConfig | string,
): string | undefined {
if (typeof model === "string") return model;
if (model && typeof model === "object" && typeof model.primary === "string") {
return model.primary;
}
return undefined;
}
export function applyGoogleGeminiModelDefault(cfg: ClawdbotConfig): {
next: ClawdbotConfig;
changed: boolean;
} {
const current = resolvePrimaryModel(cfg.agent?.model)?.trim();
if (current === GOOGLE_GEMINI_DEFAULT_MODEL) {
return { next: cfg, changed: false };
}
return {
next: {
...cfg,
agent: {
...cfg.agent,
model:
cfg.agent?.model && typeof cfg.agent.model === "object"
? { ...cfg.agent.model, primary: GOOGLE_GEMINI_DEFAULT_MODEL }
: { primary: GOOGLE_GEMINI_DEFAULT_MODEL },
},
},
changed: true,
};
}

View File

@@ -0,0 +1,153 @@
import { afterAll, beforeEach, describe, expect, it, vi } from "vitest";
import type { CliDeps } from "../cli/deps.js";
import type { RuntimeEnv } from "../runtime.js";
import { messageCommand } from "./message.js";
let testConfig: Record<string, unknown> = {};
vi.mock("../config/config.js", async (importOriginal) => {
const actual = await importOriginal<typeof import("../config/config.js")>();
return {
...actual,
loadConfig: () => testConfig,
};
});
const callGatewayMock = vi.fn();
vi.mock("../gateway/call.js", () => ({
callGateway: (...args: unknown[]) => callGatewayMock(...args),
randomIdempotencyKey: () => "idem-1",
}));
const webAuthExists = vi.fn(async () => false);
vi.mock("../web/session.js", () => ({
webAuthExists: (...args: unknown[]) => webAuthExists(...args),
}));
const handleDiscordAction = vi.fn(async () => ({ details: { ok: true } }));
vi.mock("../agents/tools/discord-actions.js", () => ({
handleDiscordAction: (...args: unknown[]) => handleDiscordAction(...args),
}));
const handleSlackAction = vi.fn(async () => ({ details: { ok: true } }));
vi.mock("../agents/tools/slack-actions.js", () => ({
handleSlackAction: (...args: unknown[]) => handleSlackAction(...args),
}));
const handleTelegramAction = vi.fn(async () => ({ details: { ok: true } }));
vi.mock("../agents/tools/telegram-actions.js", () => ({
handleTelegramAction: (...args: unknown[]) => handleTelegramAction(...args),
}));
const handleWhatsAppAction = vi.fn(async () => ({ details: { ok: true } }));
vi.mock("../agents/tools/whatsapp-actions.js", () => ({
handleWhatsAppAction: (...args: unknown[]) => handleWhatsAppAction(...args),
}));
const originalTelegramToken = process.env.TELEGRAM_BOT_TOKEN;
const originalDiscordToken = process.env.DISCORD_BOT_TOKEN;
beforeEach(() => {
process.env.TELEGRAM_BOT_TOKEN = "";
process.env.DISCORD_BOT_TOKEN = "";
testConfig = {};
callGatewayMock.mockReset();
webAuthExists.mockReset().mockResolvedValue(false);
handleDiscordAction.mockReset();
handleSlackAction.mockReset();
handleTelegramAction.mockReset();
handleWhatsAppAction.mockReset();
});
afterAll(() => {
process.env.TELEGRAM_BOT_TOKEN = originalTelegramToken;
process.env.DISCORD_BOT_TOKEN = originalDiscordToken;
});
const runtime: RuntimeEnv = {
log: vi.fn(),
error: vi.fn(),
exit: vi.fn(() => {
throw new Error("exit");
}),
};
const makeDeps = (overrides: Partial<CliDeps> = {}): CliDeps => ({
sendMessageWhatsApp: vi.fn(),
sendMessageTelegram: vi.fn(),
sendMessageDiscord: vi.fn(),
sendMessageSlack: vi.fn(),
sendMessageSignal: vi.fn(),
sendMessageIMessage: vi.fn(),
...overrides,
});
describe("messageCommand", () => {
it("defaults provider when only one configured", async () => {
process.env.TELEGRAM_BOT_TOKEN = "token-abc";
const deps = makeDeps();
await messageCommand(
{
to: "123",
message: "hi",
},
deps,
runtime,
);
expect(handleTelegramAction).toHaveBeenCalled();
});
it("requires provider when multiple configured", async () => {
process.env.TELEGRAM_BOT_TOKEN = "token-abc";
process.env.DISCORD_BOT_TOKEN = "token-discord";
const deps = makeDeps();
await expect(
messageCommand(
{
to: "123",
message: "hi",
},
deps,
runtime,
),
).rejects.toThrow(/Provider is required/);
});
it("sends via gateway for WhatsApp", async () => {
callGatewayMock.mockResolvedValueOnce({ messageId: "g1" });
const deps = makeDeps();
await messageCommand(
{
action: "send",
provider: "whatsapp",
to: "+1",
message: "hi",
},
deps,
runtime,
);
expect(callGatewayMock).toHaveBeenCalled();
});
it("routes discord polls through message action", async () => {
const deps = makeDeps();
await messageCommand(
{
action: "poll",
provider: "discord",
to: "channel:123",
pollQuestion: "Snack?",
pollOption: ["Pizza", "Sushi"],
},
deps,
runtime,
);
expect(handleDiscordAction).toHaveBeenCalledWith(
expect.objectContaining({
action: "poll",
to: "channel:123",
}),
expect.any(Object),
);
});
});

1120
src/commands/message.ts Normal file

File diff suppressed because it is too large Load Diff

View File

@@ -3,6 +3,11 @@ export {
modelsAliasesListCommand,
modelsAliasesRemoveCommand,
} from "./models/aliases.js";
export {
modelsAuthAddCommand,
modelsAuthPasteTokenCommand,
modelsAuthSetupTokenCommand,
} from "./models/auth.js";
export {
modelsFallbacksAddCommand,
modelsFallbacksClearCommand,

236
src/commands/models/auth.ts Normal file
View File

@@ -0,0 +1,236 @@
import { spawnSync } from "node:child_process";
import {
confirm as clackConfirm,
select as clackSelect,
text as clackText,
} from "@clack/prompts";
import {
CLAUDE_CLI_PROFILE_ID,
ensureAuthProfileStore,
upsertAuthProfile,
} from "../../agents/auth-profiles.js";
import { normalizeProviderId } from "../../agents/model-selection.js";
import { parseDurationMs } from "../../cli/parse-duration.js";
import { CONFIG_PATH_CLAWDBOT } from "../../config/config.js";
import type { RuntimeEnv } from "../../runtime.js";
import {
stylePromptHint,
stylePromptMessage,
} from "../../terminal/prompt-style.js";
import { applyAuthProfileConfig } from "../onboard-auth.js";
import { updateConfig } from "./shared.js";
const confirm = (params: Parameters<typeof clackConfirm>[0]) =>
clackConfirm({
...params,
message: stylePromptMessage(params.message),
});
const text = (params: Parameters<typeof clackText>[0]) =>
clackText({
...params,
message: stylePromptMessage(params.message),
});
const select = <T>(params: Parameters<typeof clackSelect<T>>[0]) =>
clackSelect({
...params,
message: stylePromptMessage(params.message),
options: params.options.map((opt) =>
opt.hint === undefined
? opt
: { ...opt, hint: stylePromptHint(opt.hint) },
),
});
type TokenProvider = "anthropic";
function resolveTokenProvider(raw?: string): TokenProvider | "custom" | null {
const trimmed = raw?.trim();
if (!trimmed) return null;
const normalized = normalizeProviderId(trimmed);
if (normalized === "anthropic") return "anthropic";
return "custom";
}
function resolveDefaultTokenProfileId(provider: string): string {
return `${normalizeProviderId(provider)}:manual`;
}
export async function modelsAuthSetupTokenCommand(
opts: { provider?: string; yes?: boolean },
runtime: RuntimeEnv,
) {
const provider = resolveTokenProvider(opts.provider ?? "anthropic");
if (provider !== "anthropic") {
throw new Error(
"Only --provider anthropic is supported for setup-token (uses `claude setup-token`).",
);
}
if (!process.stdin.isTTY) {
throw new Error("setup-token requires an interactive TTY.");
}
if (!opts.yes) {
const proceed = await confirm({
message: "Run `claude setup-token` now?",
initialValue: true,
});
if (!proceed) return;
}
const res = spawnSync("claude", ["setup-token"], { stdio: "inherit" });
if (res.error) throw res.error;
if (typeof res.status === "number" && res.status !== 0) {
throw new Error(`claude setup-token failed (exit ${res.status})`);
}
const store = ensureAuthProfileStore(undefined, {
allowKeychainPrompt: true,
});
const synced = store.profiles[CLAUDE_CLI_PROFILE_ID];
if (!synced) {
throw new Error(
`No Claude CLI credentials found after setup-token. Expected auth profile ${CLAUDE_CLI_PROFILE_ID}.`,
);
}
await updateConfig((cfg) =>
applyAuthProfileConfig(cfg, {
profileId: CLAUDE_CLI_PROFILE_ID,
provider: "anthropic",
mode: "token",
}),
);
runtime.log(`Updated ${CONFIG_PATH_CLAWDBOT}`);
runtime.log(`Auth profile: ${CLAUDE_CLI_PROFILE_ID} (anthropic/token)`);
}
export async function modelsAuthPasteTokenCommand(
opts: {
provider?: string;
profileId?: string;
expiresIn?: string;
},
runtime: RuntimeEnv,
) {
const rawProvider = opts.provider?.trim();
if (!rawProvider) {
throw new Error("Missing --provider.");
}
const provider = normalizeProviderId(rawProvider);
const profileId =
opts.profileId?.trim() || resolveDefaultTokenProfileId(provider);
const tokenInput = await text({
message: `Paste token for ${provider}`,
validate: (value) => (value?.trim() ? undefined : "Required"),
});
const token = String(tokenInput).trim();
const expires =
opts.expiresIn?.trim() && opts.expiresIn.trim().length > 0
? Date.now() +
parseDurationMs(String(opts.expiresIn).trim(), { defaultUnit: "d" })
: undefined;
upsertAuthProfile({
profileId,
credential: {
type: "token",
provider,
token,
...(expires ? { expires } : {}),
},
});
await updateConfig((cfg) =>
applyAuthProfileConfig(cfg, { profileId, provider, mode: "token" }),
);
runtime.log(`Updated ${CONFIG_PATH_CLAWDBOT}`);
runtime.log(`Auth profile: ${profileId} (${provider}/token)`);
}
export async function modelsAuthAddCommand(
_opts: Record<string, never>,
runtime: RuntimeEnv,
) {
const provider = (await select({
message: "Token provider",
options: [
{ value: "anthropic", label: "anthropic" },
{ value: "custom", label: "custom (type provider id)" },
],
})) as TokenProvider | "custom";
const providerId =
provider === "custom"
? normalizeProviderId(
String(
await text({
message: "Provider id",
validate: (value) => (value?.trim() ? undefined : "Required"),
}),
),
)
: provider;
const method = (await select({
message: "Token method",
options: [
...(providerId === "anthropic"
? [
{
value: "setup-token",
label: "setup-token (claude)",
hint: "Runs `claude setup-token` (recommended)",
},
]
: []),
{ value: "paste", label: "paste token" },
],
})) as "setup-token" | "paste";
if (method === "setup-token") {
await modelsAuthSetupTokenCommand({ provider: providerId }, runtime);
return;
}
const profileIdDefault = resolveDefaultTokenProfileId(providerId);
const profileId = String(
await text({
message: "Profile id",
initialValue: profileIdDefault,
validate: (value) => (value?.trim() ? undefined : "Required"),
}),
).trim();
const wantsExpiry = await confirm({
message: "Does this token expire?",
initialValue: false,
});
const expiresIn = wantsExpiry
? String(
await text({
message: "Expires in (duration)",
initialValue: "365d",
validate: (value) => {
try {
parseDurationMs(String(value ?? ""), { defaultUnit: "d" });
return undefined;
} catch {
return "Invalid duration (e.g. 365d, 12h, 30m)";
}
},
}),
).trim()
: undefined;
await modelsAuthPasteTokenCommand(
{ provider: providerId, profileId, expiresIn },
runtime,
);
}

View File

@@ -77,12 +77,17 @@ vi.mock("../../agents/agent-paths.js", () => ({
resolveClawdbotAgentDir: mocks.resolveClawdbotAgentDir,
}));
vi.mock("../../agents/auth-profiles.js", () => ({
ensureAuthProfileStore: mocks.ensureAuthProfileStore,
listProfilesForProvider: mocks.listProfilesForProvider,
resolveAuthProfileDisplayLabel: mocks.resolveAuthProfileDisplayLabel,
resolveAuthStorePathForDisplay: mocks.resolveAuthStorePathForDisplay,
}));
vi.mock("../../agents/auth-profiles.js", async (importOriginal) => {
const actual =
await importOriginal<typeof import("../../agents/auth-profiles.js")>();
return {
...actual,
ensureAuthProfileStore: mocks.ensureAuthProfileStore,
listProfilesForProvider: mocks.listProfilesForProvider,
resolveAuthProfileDisplayLabel: mocks.resolveAuthProfileDisplayLabel,
resolveAuthStorePathForDisplay: mocks.resolveAuthStorePathForDisplay,
};
});
vi.mock("../../agents/model-auth.js", () => ({
resolveEnvApiKey: mocks.resolveEnvApiKey,
@@ -126,6 +131,9 @@ describe("modelsStatusCommand auth overview", () => {
expect(payload.auth.shellEnvFallback.appliedKeys).toContain(
"OPENAI_API_KEY",
);
expect(payload.auth.missingProvidersInUse).toEqual([]);
expect(payload.auth.oauth.warnAfterMs).toBeGreaterThan(0);
expect(payload.auth.oauth.profiles.length).toBeGreaterThan(0);
const providers = payload.auth.providers as Array<{
provider: string;
@@ -152,4 +160,27 @@ describe("modelsStatusCommand auth overview", () => {
),
).toBe(true);
});
it("exits non-zero when auth is missing", async () => {
const originalProfiles = { ...mocks.store.profiles };
mocks.store.profiles = {};
const localRuntime = {
log: vi.fn(),
error: vi.fn(),
exit: vi.fn(),
};
const originalEnvImpl = mocks.resolveEnvApiKey.getMockImplementation();
mocks.resolveEnvApiKey.mockImplementation(() => null);
try {
await modelsStatusCommand(
{ check: true, plain: true },
localRuntime as never,
);
expect(localRuntime.exit).toHaveBeenCalledWith(1);
} finally {
mocks.store.profiles = originalProfiles;
mocks.resolveEnvApiKey.mockImplementation(originalEnvImpl);
}
});
});

View File

@@ -7,6 +7,11 @@ import {
} from "@mariozechner/pi-coding-agent";
import { resolveClawdbotAgentDir } from "../../agents/agent-paths.js";
import {
buildAuthHealthSummary,
DEFAULT_OAUTH_WARN_MS,
formatRemainingShort,
} from "../../agents/auth-health.js";
import {
type AuthProfileStore,
ensureAuthProfileStore,
@@ -154,6 +159,7 @@ type ProviderAuthOverview = {
profiles: {
count: number;
oauth: number;
token: number;
apiKey: number;
labels: string[];
};
@@ -175,6 +181,9 @@ function resolveProviderAuthOverview(params: {
if (profile.type === "api_key") {
return `${profileId}=${maskApiKey(profile.key)}`;
}
if (profile.type === "token") {
return `${profileId}=token:${maskApiKey(profile.token)}`;
}
const display = resolveAuthProfileDisplayLabel({ cfg, store, profileId });
const suffix =
display === profileId
@@ -187,6 +196,9 @@ function resolveProviderAuthOverview(params: {
const oauthCount = profiles.filter(
(id) => store.profiles[id]?.type === "oauth",
).length;
const tokenCount = profiles.filter(
(id) => store.profiles[id]?.type === "token",
).length;
const apiKeyCount = profiles.filter(
(id) => store.profiles[id]?.type === "api_key",
).length;
@@ -222,6 +234,7 @@ function resolveProviderAuthOverview(params: {
profiles: {
count: profiles.length,
oauth: oauthCount,
token: tokenCount,
apiKey: apiKeyCount,
labels,
},
@@ -599,7 +612,7 @@ export async function modelsListCommand(
}
export async function modelsStatusCommand(
opts: { json?: boolean; plain?: boolean },
opts: { json?: boolean; plain?: boolean; check?: boolean },
runtime: RuntimeEnv,
) {
ensureFlagCompatibility(opts);
@@ -656,6 +669,7 @@ export async function modelsStatusCommand(
.filter(Boolean),
);
const providersFromModels = new Set<string>();
const providersInUse = new Set<string>();
for (const raw of [
defaultLabel,
...fallbacks,
@@ -666,6 +680,15 @@ export async function modelsStatusCommand(
const parsed = parseModelRef(String(raw ?? ""), DEFAULT_PROVIDER);
if (parsed?.provider) providersFromModels.add(parsed.provider);
}
for (const raw of [
defaultLabel,
...fallbacks,
imageModel,
...imageFallbacks,
]) {
const parsed = parseModelRef(String(raw ?? ""), DEFAULT_PROVIDER);
if (parsed?.provider) providersInUse.add(parsed.provider);
}
const providersFromEnv = new Set<string>();
// Keep in sync with resolveEnvApiKey() mappings (we want visibility even when
@@ -715,17 +738,51 @@ export async function modelsStatusCommand(
Boolean(entry.modelsJson);
return hasAny;
});
const providerAuthMap = new Map(
providerAuth.map((entry) => [entry.provider, entry]),
);
const missingProvidersInUse = Array.from(providersInUse)
.filter((provider) => !providerAuthMap.has(provider))
.sort((a, b) => a.localeCompare(b));
const providersWithOauth = providerAuth
.filter(
(entry) => entry.profiles.oauth > 0 || entry.env?.value === "OAuth (env)",
(entry) =>
entry.profiles.oauth > 0 ||
entry.profiles.token > 0 ||
entry.env?.value === "OAuth (env)",
)
.map((entry) => {
const count =
entry.profiles.oauth || (entry.env?.value === "OAuth (env)" ? 1 : 0);
entry.profiles.oauth +
entry.profiles.token +
(entry.env?.value === "OAuth (env)" ? 1 : 0);
return `${entry.provider} (${count})`;
});
const authHealth = buildAuthHealthSummary({
store,
cfg,
warnAfterMs: DEFAULT_OAUTH_WARN_MS,
providers,
});
const oauthProfiles = authHealth.profiles.filter(
(profile) => profile.type === "oauth" || profile.type === "token",
);
const checkStatus = (() => {
const hasExpiredOrMissing =
oauthProfiles.some((profile) =>
["expired", "missing"].includes(profile.status),
) || missingProvidersInUse.length > 0;
const hasExpiring = oauthProfiles.some(
(profile) => profile.status === "expiring",
);
if (hasExpiredOrMissing) return 1;
if (hasExpiring) return 2;
return 0;
})();
if (opts.json) {
runtime.log(
JSON.stringify(
@@ -746,18 +803,30 @@ export async function modelsStatusCommand(
appliedKeys: applied,
},
providersWithOAuth: providersWithOauth,
missingProvidersInUse,
providers: providerAuth,
oauth: {
warnAfterMs: authHealth.warnAfterMs,
profiles: authHealth.profiles,
providers: authHealth.providers,
},
},
},
null,
2,
),
);
if (opts.check) {
runtime.exit(checkStatus);
}
return;
}
if (opts.plain) {
runtime.log(resolvedLabel);
if (opts.check) {
runtime.exit(checkStatus);
}
return;
}
@@ -870,7 +939,7 @@ export async function modelsStatusCommand(
);
runtime.log(
`${label(
`Providers w/ OAuth (${providersWithOauth.length || 0})`,
`Providers w/ OAuth/tokens (${providersWithOauth.length || 0})`,
)}${colorize(rich, theme.muted, ":")} ${colorize(
rich,
providersWithOauth.length ? theme.info : theme.muted,
@@ -897,7 +966,7 @@ export async function modelsStatusCommand(
bits.push(
formatKeyValue(
"profiles",
`${entry.profiles.count} (oauth=${entry.profiles.oauth}, api_key=${entry.profiles.apiKey})`,
`${entry.profiles.count} (oauth=${entry.profiles.oauth}, token=${entry.profiles.token}, api_key=${entry.profiles.apiKey})`,
rich,
),
);
@@ -933,4 +1002,52 @@ export async function modelsStatusCommand(
}
runtime.log(`- ${theme.heading(entry.provider)} ${bits.join(separator)}`);
}
if (missingProvidersInUse.length > 0) {
runtime.log("");
runtime.log(colorize(rich, theme.heading, "Missing auth"));
for (const provider of missingProvidersInUse) {
const hint =
provider === "anthropic"
? "Run `claude setup-token` or `clawdbot configure`."
: "Run `clawdbot configure` or set an API key env var.";
runtime.log(`- ${theme.heading(provider)} ${hint}`);
}
}
runtime.log("");
runtime.log(colorize(rich, theme.heading, "OAuth/token status"));
if (oauthProfiles.length === 0) {
runtime.log(colorize(rich, theme.muted, "- none"));
return;
}
const formatStatus = (status: string) => {
if (status === "ok") return colorize(rich, theme.success, "ok");
if (status === "static") return colorize(rich, theme.muted, "static");
if (status === "expiring") return colorize(rich, theme.warn, "expiring");
if (status === "missing") return colorize(rich, theme.warn, "unknown");
return colorize(rich, theme.error, "expired");
};
for (const profile of oauthProfiles) {
const labelText = profile.label || profile.profileId;
const label = colorize(rich, theme.accent, labelText);
const status = formatStatus(profile.status);
const expiry =
profile.status === "static"
? ""
: profile.expiresAt
? ` expires in ${formatRemainingShort(profile.remainingMs)}`
: " expires unknown";
const source =
profile.source !== "store"
? colorize(rich, theme.muted, ` (${profile.source})`)
: "";
runtime.log(`- ${label} ${status}${expiry}${source}`);
}
if (opts.check) {
runtime.exit(checkStatus);
}
}

View File

@@ -1,4 +1,8 @@
import { cancel, isCancel, multiselect } from "@clack/prompts";
import {
cancel,
multiselect as clackMultiselect,
isCancel,
} from "@clack/prompts";
import { resolveApiKeyForProvider } from "../../agents/model-auth.js";
import {
type ModelScanResult,
@@ -7,11 +11,27 @@ import {
import { withProgressTotals } from "../../cli/progress.js";
import { CONFIG_PATH_CLAWDBOT, loadConfig } from "../../config/config.js";
import type { RuntimeEnv } from "../../runtime.js";
import {
stylePromptHint,
stylePromptMessage,
stylePromptTitle,
} from "../../terminal/prompt-style.js";
import { formatMs, formatTokenK, updateConfig } from "./shared.js";
const MODEL_PAD = 42;
const CTX_PAD = 8;
const multiselect = <T>(params: Parameters<typeof clackMultiselect<T>>[0]) =>
clackMultiselect({
...params,
message: stylePromptMessage(params.message),
options: params.options.map((opt) =>
opt.hint === undefined
? opt
: { ...opt, hint: stylePromptHint(opt.hint) },
),
});
const pad = (value: string, size: number) => value.padEnd(size);
const truncate = (value: string, max: number) => {
@@ -268,7 +288,9 @@ export async function modelsScanCommand(
});
if (isCancel(selection)) {
cancel("Model scan cancelled.");
cancel(
stylePromptTitle("Model scan cancelled.") ?? "Model scan cancelled.",
);
runtime.exit(0);
}
@@ -285,7 +307,9 @@ export async function modelsScanCommand(
});
if (isCancel(imageSelection)) {
cancel("Model scan cancelled.");
cancel(
stylePromptTitle("Model scan cancelled.") ?? "Model scan cancelled.",
);
runtime.exit(0);
}

View File

@@ -5,7 +5,10 @@ import path from "node:path";
import type { OAuthCredentials } from "@mariozechner/pi-ai";
import { afterEach, describe, expect, it } from "vitest";
import { writeOAuthCredentials } from "./onboard-auth.js";
import {
applyAuthProfileConfig,
writeOAuthCredentials,
} from "./onboard-auth.js";
describe("writeOAuthCredentials", () => {
const previousStateDir = process.env.CLAWDBOT_STATE_DIR;
@@ -49,7 +52,7 @@ describe("writeOAuthCredentials", () => {
expires: Date.now() + 60_000,
} satisfies OAuthCredentials;
await writeOAuthCredentials("anthropic", creds);
await writeOAuthCredentials("openai-codex", creds);
// Now writes to the multi-agent path: agents/main/agent
const authProfilePath = path.join(
@@ -63,7 +66,7 @@ describe("writeOAuthCredentials", () => {
const parsed = JSON.parse(raw) as {
profiles?: Record<string, OAuthCredentials & { type?: string }>;
};
expect(parsed.profiles?.["anthropic:default"]).toMatchObject({
expect(parsed.profiles?.["openai-codex:default"]).toMatchObject({
refresh: "refresh-token",
access: "access-token",
type: "oauth",
@@ -77,3 +80,28 @@ describe("writeOAuthCredentials", () => {
).rejects.toThrow();
});
});
describe("applyAuthProfileConfig", () => {
it("promotes the newly selected profile to the front of auth.order", () => {
const next = applyAuthProfileConfig(
{
auth: {
profiles: {
"anthropic:default": { provider: "anthropic", mode: "api_key" },
},
order: { anthropic: ["anthropic:default"] },
},
},
{
profileId: "anthropic:claude-cli",
provider: "anthropic",
mode: "oauth",
},
);
expect(next.auth?.order?.anthropic).toEqual([
"anthropic:claude-cli",
"anthropic:default",
]);
});
});

View File

@@ -2,6 +2,13 @@ import type { OAuthCredentials, OAuthProvider } from "@mariozechner/pi-ai";
import { resolveDefaultAgentDir } from "../agents/agent-scope.js";
import { upsertAuthProfile } from "../agents/auth-profiles.js";
import type { ClawdbotConfig } from "../config/config.js";
import type { ModelDefinitionConfig } from "../config/types.js";
const DEFAULT_MINIMAX_BASE_URL = "https://api.minimax.io/v1";
export const MINIMAX_HOSTED_MODEL_ID = "MiniMax-M2.1";
const DEFAULT_MINIMAX_CONTEXT_WINDOW = 200000;
const DEFAULT_MINIMAX_MAX_TOKENS = 8192;
export const MINIMAX_HOSTED_MODEL_REF = `minimax/${MINIMAX_HOSTED_MODEL_ID}`;
const DEFAULT_MINIMAX_BASE_URL = "https://api.minimax.io/v1";
export const MINIMAX_HOSTED_MODEL_ID = "MiniMax-M2.1";
@@ -39,6 +46,19 @@ export async function setAnthropicApiKey(key: string, agentDir?: string) {
});
}
export async function setGeminiApiKey(key: string, agentDir?: string) {
// Write to the multi-agent path so gateway finds credentials on startup
upsertAuthProfile({
profileId: "google:default",
credential: {
type: "api_key",
provider: "google",
key,
},
agentDir: agentDir ?? resolveDefaultAgentDir(),
});
}
export async function setMinimaxApiKey(key: string, agentDir?: string) {
// Write to the multi-agent path so gateway finds credentials on startup
upsertAuthProfile({
@@ -57,8 +77,9 @@ export function applyAuthProfileConfig(
params: {
profileId: string;
provider: string;
mode: "api_key" | "oauth";
mode: "api_key" | "oauth" | "token";
email?: string;
preferProfileFirst?: boolean;
},
): ClawdbotConfig {
const profiles = {
@@ -73,13 +94,23 @@ export function applyAuthProfileConfig(
// Only maintain `auth.order` when the user explicitly configured it.
// Default behavior: no explicit order -> resolveAuthProfileOrder can round-robin by lastUsed.
const existingProviderOrder = cfg.auth?.order?.[params.provider];
const preferProfileFirst = params.preferProfileFirst ?? true;
const reorderedProviderOrder =
existingProviderOrder && preferProfileFirst
? [
params.profileId,
...existingProviderOrder.filter(
(profileId) => profileId !== params.profileId,
),
]
: existingProviderOrder;
const order =
existingProviderOrder !== undefined
? {
...cfg.auth?.order,
[params.provider]: existingProviderOrder.includes(params.profileId)
? existingProviderOrder
: [...existingProviderOrder, params.profileId],
[params.provider]: reorderedProviderOrder?.includes(params.profileId)
? reorderedProviderOrder
: [...(reorderedProviderOrder ?? []), params.profileId],
}
: cfg.auth?.order;
return {
@@ -149,24 +180,32 @@ export function applyMinimaxHostedProviderConfig(
};
const providers = { ...cfg.models?.providers };
if (!providers.minimax) {
providers.minimax = {
baseUrl: params?.baseUrl?.trim() || DEFAULT_MINIMAX_BASE_URL,
apiKey: "minimax",
api: "openai-completions",
models: [
{
id: MINIMAX_HOSTED_MODEL_ID,
name: "MiniMax M2.1",
reasoning: false,
input: ["text"],
cost: { input: 0, output: 0, cacheRead: 0, cacheWrite: 0 },
contextWindow: DEFAULT_MINIMAX_CONTEXT_WINDOW,
maxTokens: DEFAULT_MINIMAX_MAX_TOKENS,
},
],
};
}
const hostedModel: ModelDefinitionConfig = {
id: MINIMAX_HOSTED_MODEL_ID,
name: "MiniMax M2.1",
reasoning: false,
input: ["text"],
cost: { input: 0, output: 0, cacheRead: 0, cacheWrite: 0 },
contextWindow: DEFAULT_MINIMAX_CONTEXT_WINDOW,
maxTokens: DEFAULT_MINIMAX_MAX_TOKENS,
};
const existingProvider = providers.minimax;
const existingModels = Array.isArray(existingProvider?.models)
? existingProvider.models
: [];
const hasHostedModel = existingModels.some(
(model) => model.id === MINIMAX_HOSTED_MODEL_ID,
);
const mergedModels = hasHostedModel
? existingModels
: [...existingModels, hostedModel];
providers.minimax = {
...existingProvider,
baseUrl: params?.baseUrl?.trim() || DEFAULT_MINIMAX_BASE_URL,
apiKey: "minimax",
api: "openai-completions",
models: mergedModels.length > 0 ? mergedModels : [hostedModel],
};
return {
...cfg,

View File

@@ -17,6 +17,7 @@ import { normalizeControlUiBasePath } from "../gateway/control-ui.js";
import { pickPrimaryTailnetIPv4 } from "../infra/tailnet.js";
import { runCommandWithTimeout } from "../process/exec.js";
import type { RuntimeEnv } from "../runtime.js";
import { stylePromptTitle } from "../terminal/prompt-style.js";
import { CONFIG_DIR, resolveUserPath } from "../utils.js";
import { VERSION } from "../version.js";
import type {
@@ -27,7 +28,7 @@ import type {
export function guardCancel<T>(value: T, runtime: RuntimeEnv): T {
if (isCancel(value)) {
cancel("Setup cancelled.");
cancel(stylePromptTitle("Setup cancelled.") ?? "Setup cancelled.");
runtime.exit(0);
}
return value;

View File

@@ -4,6 +4,7 @@ import {
CODEX_CLI_PROFILE_ID,
ensureAuthProfileStore,
} from "../agents/auth-profiles.js";
import { resolveEnvApiKey } from "../agents/model-auth.js";
import {
type ClawdbotConfig,
CONFIG_PATH_CLAWDBOT,
@@ -13,7 +14,10 @@ import {
} from "../config/config.js";
import { GATEWAY_LAUNCH_AGENT_LABEL } from "../daemon/constants.js";
import { resolveGatewayProgramArguments } from "../daemon/program-args.js";
import { resolvePreferredNodePath } from "../daemon/runtime-paths.js";
import { resolveGatewayService } from "../daemon/service.js";
import { buildServiceEnvironment } from "../daemon/service-env.js";
import { upsertSharedEnvVar } from "../infra/env-file.js";
import type { RuntimeEnv } from "../runtime.js";
import { defaultRuntime } from "../runtime.js";
import { resolveUserPath, sleep } from "../utils.js";
@@ -21,12 +25,14 @@ import {
DEFAULT_GATEWAY_DAEMON_RUNTIME,
isGatewayDaemonRuntime,
} from "./daemon-runtime.js";
import { applyGoogleGeminiModelDefault } from "./google-gemini-model-default.js";
import { healthCommand } from "./health.js";
import {
applyAuthProfileConfig,
applyMinimaxConfig,
applyMinimaxHostedConfig,
setAnthropicApiKey,
setGeminiApiKey,
setMinimaxApiKey,
} from "./onboard-auth.js";
import {
@@ -119,6 +125,33 @@ export async function runNonInteractiveOnboarding(
provider: "anthropic",
mode: "api_key",
});
} else if (authChoice === "gemini-api-key") {
const key = opts.geminiApiKey?.trim();
if (!key) {
runtime.error("Missing --gemini-api-key");
runtime.exit(1);
return;
}
await setGeminiApiKey(key);
nextConfig = applyAuthProfileConfig(nextConfig, {
profileId: "google:default",
provider: "google",
mode: "api_key",
});
nextConfig = applyGoogleGeminiModelDefault(nextConfig).next;
} else if (authChoice === "openai-api-key") {
const key = opts.openaiApiKey?.trim() || resolveEnvApiKey("openai")?.apiKey;
if (!key) {
runtime.error("Missing --openai-api-key (or OPENAI_API_KEY in env).");
runtime.exit(1);
return;
}
const result = upsertSharedEnvVar({
key: "OPENAI_API_KEY",
value: key,
});
process.env.OPENAI_API_KEY = key;
runtime.log(`Saved OPENAI_API_KEY to ${result.path}`);
} else if (authChoice === "minimax-cloud") {
const key = opts.minimaxApiKey?.trim();
if (!key) {
@@ -134,10 +167,14 @@ export async function runNonInteractiveOnboarding(
});
nextConfig = applyMinimaxHostedConfig(nextConfig);
} else if (authChoice === "claude-cli") {
const store = ensureAuthProfileStore();
const store = ensureAuthProfileStore(undefined, {
allowKeychainPrompt: false,
});
if (!store.profiles[CLAUDE_CLI_PROFILE_ID]) {
runtime.error(
"No Claude CLI credentials found at ~/.claude/.credentials.json",
process.platform === "darwin"
? 'No Claude CLI credentials found. Run interactive onboarding to approve Keychain access for "Claude Code-credentials".'
: "No Claude CLI credentials found at ~/.claude/.credentials.json",
);
runtime.exit(1);
return;
@@ -145,7 +182,7 @@ export async function runNonInteractiveOnboarding(
nextConfig = applyAuthProfileConfig(nextConfig, {
profileId: CLAUDE_CLI_PROFILE_ID,
provider: "anthropic",
mode: "oauth",
mode: "token",
});
} else if (authChoice === "codex-cli") {
const store = ensureAuthProfileStore();
@@ -163,17 +200,18 @@ export async function runNonInteractiveOnboarding(
} else if (authChoice === "minimax") {
nextConfig = applyMinimaxConfig(nextConfig);
} else if (
authChoice === "token" ||
authChoice === "oauth" ||
authChoice === "openai-codex" ||
authChoice === "antigravity"
) {
runtime.error(
`${
authChoice === "oauth" || authChoice === "openai-codex"
? "OAuth"
: "Antigravity"
} requires interactive mode.`,
);
const label =
authChoice === "antigravity"
? "Antigravity"
: authChoice === "token"
? "Token"
: "OAuth";
runtime.error(`${label} requires interactive mode.`);
runtime.exit(1);
return;
}
@@ -288,18 +326,24 @@ export async function runNonInteractiveOnboarding(
const devMode =
process.argv[1]?.includes(`${path.sep}src${path.sep}`) &&
process.argv[1]?.endsWith(".ts");
const nodePath = await resolvePreferredNodePath({
env: process.env,
runtime: daemonRuntimeRaw,
});
const { programArguments, workingDirectory } =
await resolveGatewayProgramArguments({
port,
dev: devMode,
runtime: daemonRuntimeRaw,
nodePath,
});
const environment: Record<string, string | undefined> = {
PATH: process.env.PATH,
CLAWDBOT_GATEWAY_TOKEN: gatewayToken,
CLAWDBOT_LAUNCHD_LABEL:
const environment = buildServiceEnvironment({
env: process.env,
port,
token: gatewayToken,
launchdLabel:
process.platform === "darwin" ? GATEWAY_LAUNCH_AGENT_LABEL : undefined,
};
});
await service.install({
env: process.env,
stdout: process.stdout,

View File

@@ -541,8 +541,12 @@ async function promptWhatsAppAllowFrom(
const existingResponsePrefix = cfg.messages?.responsePrefix;
if (options?.forceAllowlist) {
await prompter.note(
"We need the sender/owner number so Clawdbot can allowlist you.",
"WhatsApp number",
);
const entry = await prompter.text({
message: "Your WhatsApp number (E.164)",
message: "Your personal WhatsApp number (the phone you will message from)",
placeholder: "+15555550123",
initialValue: existingAllowFrom[0],
validate: (value) => {
@@ -604,8 +608,12 @@ async function promptWhatsAppAllowFrom(
})) as "personal" | "separate";
if (phoneMode === "personal") {
await prompter.note(
"We need the sender/owner number so Clawdbot can allowlist you.",
"WhatsApp number",
);
const entry = await prompter.text({
message: "Your WhatsApp number (E.164)",
message: "Your personal WhatsApp number (the phone you will message from)",
placeholder: "+15555550123",
initialValue: existingAllowFrom[0],
validate: (value) => {

View File

@@ -5,10 +5,13 @@ export type OnboardMode = "local" | "remote";
export type AuthChoice =
| "oauth"
| "claude-cli"
| "token"
| "openai-codex"
| "openai-api-key"
| "codex-cli"
| "antigravity"
| "apiKey"
| "gemini-api-key"
| "minimax-cloud"
| "minimax"
| "skip";
@@ -25,6 +28,8 @@ export type OnboardOptions = {
nonInteractive?: boolean;
authChoice?: AuthChoice;
anthropicApiKey?: string;
openaiApiKey?: string;
geminiApiKey?: string;
minimaxApiKey?: string;
gatewayPort?: number;
gatewayBind?: GatewayBind;

View File

@@ -1,110 +0,0 @@
import { beforeEach, describe, expect, it, vi } from "vitest";
import type { CliDeps } from "../cli/deps.js";
import { pollCommand } from "./poll.js";
let testConfig: Record<string, unknown> = {};
vi.mock("../config/config.js", async (importOriginal) => {
const actual = await importOriginal<typeof import("../config/config.js")>();
return {
...actual,
loadConfig: () => testConfig,
};
});
const callGatewayMock = vi.fn();
vi.mock("../gateway/call.js", () => ({
callGateway: (...args: unknown[]) => callGatewayMock(...args),
randomIdempotencyKey: () => "idem-1",
}));
const runtime = {
log: vi.fn(),
error: vi.fn(),
exit: vi.fn(),
};
const deps: CliDeps = {
sendMessageWhatsApp: vi.fn(),
sendMessageTelegram: vi.fn(),
sendMessageDiscord: vi.fn(),
sendMessageSlack: vi.fn(),
sendMessageSignal: vi.fn(),
sendMessageIMessage: vi.fn(),
};
describe("pollCommand", () => {
beforeEach(() => {
callGatewayMock.mockReset();
runtime.log.mockReset();
runtime.error.mockReset();
runtime.exit.mockReset();
testConfig = {};
});
it("routes through gateway", async () => {
callGatewayMock.mockResolvedValueOnce({ messageId: "p1" });
await pollCommand(
{
to: "+1",
question: "hi?",
option: ["y", "n"],
},
deps,
runtime,
);
expect(callGatewayMock).toHaveBeenCalledWith(
expect.objectContaining({ method: "poll" }),
);
});
it("does not override remote gateway URL", async () => {
callGatewayMock.mockResolvedValueOnce({ messageId: "p1" });
testConfig = {
gateway: { mode: "remote", remote: { url: "wss://remote.example" } },
};
await pollCommand(
{
to: "+1",
question: "hi?",
option: ["y", "n"],
},
deps,
runtime,
);
const args = callGatewayMock.mock.calls.at(-1)?.[0] as
| Record<string, unknown>
| undefined;
expect(args?.url).toBeUndefined();
});
it("emits json output with gateway metadata", async () => {
callGatewayMock.mockResolvedValueOnce({ messageId: "p1", channelId: "C1" });
await pollCommand(
{
to: "channel:C1",
question: "hi?",
option: ["y", "n"],
provider: "discord",
json: true,
},
deps,
runtime,
);
const lastLog = runtime.log.mock.calls.at(-1)?.[0] as string | undefined;
expect(lastLog).toBeDefined();
const payload = JSON.parse(lastLog ?? "{}") as Record<string, unknown>;
expect(payload).toMatchObject({
provider: "discord",
via: "gateway",
to: "channel:C1",
messageId: "p1",
channelId: "C1",
mediaUrl: null,
question: "hi?",
options: ["y", "n"],
maxSelections: 1,
durationHours: null,
});
});
});

View File

@@ -1,121 +0,0 @@
import type { CliDeps } from "../cli/deps.js";
import { withProgress } from "../cli/progress.js";
import { callGateway, randomIdempotencyKey } from "../gateway/call.js";
import { success } from "../globals.js";
import { buildOutboundResultEnvelope } from "../infra/outbound/envelope.js";
import {
buildOutboundDeliveryJson,
formatGatewaySummary,
} from "../infra/outbound/format.js";
import { normalizePollInput, type PollInput } from "../polls.js";
import type { RuntimeEnv } from "../runtime.js";
function parseIntOption(value: unknown, label: string): number | undefined {
if (value === undefined || value === null) return undefined;
if (typeof value !== "string" || value.trim().length === 0) return undefined;
const parsed = Number.parseInt(value, 10);
if (!Number.isFinite(parsed)) {
throw new Error(`${label} must be a number`);
}
return parsed;
}
export async function pollCommand(
opts: {
to: string;
question: string;
option: string[];
maxSelections?: string;
durationHours?: string;
provider?: string;
json?: boolean;
dryRun?: boolean;
},
_deps: CliDeps,
runtime: RuntimeEnv,
) {
const provider = (opts.provider ?? "whatsapp").toLowerCase();
if (provider !== "whatsapp" && provider !== "discord") {
throw new Error(`Unsupported poll provider: ${provider}`);
}
const maxSelections = parseIntOption(opts.maxSelections, "max-selections");
const durationHours = parseIntOption(opts.durationHours, "duration-hours");
const pollInput: PollInput = {
question: opts.question,
options: opts.option,
maxSelections,
durationHours,
};
const maxOptions = provider === "discord" ? 10 : 12;
const normalized = normalizePollInput(pollInput, { maxOptions });
if (opts.dryRun) {
runtime.log(
`[dry-run] would send poll via ${provider} -> ${opts.to}:\n Question: ${normalized.question}\n Options: ${normalized.options.join(", ")}\n Max selections: ${normalized.maxSelections}`,
);
return;
}
const result = await withProgress(
{
label: `Sending poll via ${provider}`,
indeterminate: true,
enabled: opts.json !== true,
},
async () =>
await callGateway<{
messageId: string;
toJid?: string;
channelId?: string;
}>({
method: "poll",
params: {
to: opts.to,
question: normalized.question,
options: normalized.options,
maxSelections: normalized.maxSelections,
durationHours: normalized.durationHours,
provider,
idempotencyKey: randomIdempotencyKey(),
},
timeoutMs: 10_000,
clientName: "cli",
mode: "cli",
}),
);
runtime.log(
success(
formatGatewaySummary({
action: "Poll sent",
provider,
messageId: result.messageId ?? null,
}),
),
);
if (opts.json) {
runtime.log(
JSON.stringify(
{
...buildOutboundResultEnvelope({
delivery: buildOutboundDeliveryJson({
provider,
via: "gateway",
to: opts.to,
result,
mediaUrl: null,
}),
}),
question: normalized.question,
options: normalized.options,
maxSelections: normalized.maxSelections,
durationHours: normalized.durationHours ?? null,
},
null,
2,
),
);
}
}

View File

@@ -323,4 +323,112 @@ describe("providers command", () => {
expect(whatsappIndex).toBeGreaterThan(-1);
expect(telegramIndex).toBeLessThan(whatsappIndex);
});
it("surfaces Discord privileged intent issues in providers status output", () => {
const lines = formatGatewayProvidersStatusLines({
discordAccounts: [
{
accountId: "default",
enabled: true,
configured: true,
application: { intents: { messageContent: "disabled" } },
},
],
});
expect(lines.join("\n")).toMatch(/Warnings:/);
expect(lines.join("\n")).toMatch(/Message Content Intent is disabled/i);
expect(lines.join("\n")).toMatch(/Run: clawdbot doctor/);
});
it("surfaces Discord permission audit issues in providers status output", () => {
const lines = formatGatewayProvidersStatusLines({
discordAccounts: [
{
accountId: "default",
enabled: true,
configured: true,
audit: {
unresolvedChannels: 1,
channels: [
{
channelId: "111",
ok: false,
missing: ["ViewChannel", "SendMessages"],
},
],
},
},
],
});
expect(lines.join("\n")).toMatch(/Warnings:/);
expect(lines.join("\n")).toMatch(/permission audit/i);
expect(lines.join("\n")).toMatch(/Channel 111/i);
});
it("surfaces Telegram privacy-mode hints when allowUnmentionedGroups is enabled", () => {
const lines = formatGatewayProvidersStatusLines({
telegramAccounts: [
{
accountId: "default",
enabled: true,
configured: true,
allowUnmentionedGroups: true,
},
],
});
expect(lines.join("\n")).toMatch(/Warnings:/);
expect(lines.join("\n")).toMatch(/Telegram Bot API privacy mode/i);
});
it("surfaces Telegram group membership audit issues in providers status output", () => {
const lines = formatGatewayProvidersStatusLines({
telegramAccounts: [
{
accountId: "default",
enabled: true,
configured: true,
audit: {
hasWildcardUnmentionedGroups: true,
unresolvedGroups: 1,
groups: [
{
chatId: "-1001",
ok: false,
status: "left",
error: "not in group",
},
],
},
},
],
});
expect(lines.join("\n")).toMatch(/Warnings:/);
expect(lines.join("\n")).toMatch(/membership probing is not possible/i);
expect(lines.join("\n")).toMatch(/Group -1001/i);
});
it("surfaces WhatsApp auth/runtime hints when unlinked or disconnected", () => {
const unlinked = formatGatewayProvidersStatusLines({
whatsappAccounts: [
{ accountId: "default", enabled: true, linked: false },
],
});
expect(unlinked.join("\n")).toMatch(/WhatsApp/i);
expect(unlinked.join("\n")).toMatch(/Not linked/i);
const disconnected = formatGatewayProvidersStatusLines({
whatsappAccounts: [
{
accountId: "default",
enabled: true,
linked: true,
running: true,
connected: false,
reconnectAttempts: 5,
lastError: "connection closed",
},
],
});
expect(disconnected.join("\n")).toMatch(/disconnected/i);
});
});

View File

@@ -1,5 +1,6 @@
import fs from "node:fs/promises";
import { parseLogLine } from "../../logging/parse-log-line.js";
import { getResolvedLoggerSettings } from "../../logging.js";
import { defaultRuntime, type RuntimeEnv } from "../../runtime.js";
import { theme } from "../../terminal/theme.js";
@@ -10,14 +11,7 @@ export type ProvidersLogsOptions = {
json?: boolean;
};
type LogLine = {
time?: string;
level?: string;
subsystem?: string;
module?: string;
message: string;
raw: string;
};
type LogLine = ReturnType<typeof parseLogLine>;
const DEFAULT_LIMIT = 200;
const MAX_BYTES = 1_000_000;
@@ -37,59 +31,7 @@ function parseProviderFilter(raw?: string) {
return PROVIDERS.has(trimmed) ? trimmed : "all";
}
function extractMessage(value: Record<string, unknown>): string {
const parts: string[] = [];
for (const key of Object.keys(value)) {
if (!/^\d+$/.test(key)) continue;
const item = value[key];
if (typeof item === "string") {
parts.push(item);
} else if (item != null) {
parts.push(JSON.stringify(item));
}
}
return parts.join(" ");
}
function parseMetaName(raw?: unknown): { subsystem?: string; module?: string } {
if (typeof raw !== "string") return {};
try {
const parsed = JSON.parse(raw) as Record<string, unknown>;
return {
subsystem:
typeof parsed.subsystem === "string" ? parsed.subsystem : undefined,
module: typeof parsed.module === "string" ? parsed.module : undefined,
};
} catch {
return {};
}
}
function parseLogLine(raw: string): LogLine | null {
try {
const parsed = JSON.parse(raw) as Record<string, unknown>;
const meta = parsed._meta as Record<string, unknown> | undefined;
const nameMeta = parseMetaName(meta?.name);
return {
time:
typeof parsed.time === "string"
? parsed.time
: typeof meta?.date === "string"
? meta.date
: undefined,
level:
typeof meta?.logLevelName === "string" ? meta.logLevelName : undefined,
subsystem: nameMeta.subsystem,
module: nameMeta.module,
message: extractMessage(parsed),
raw,
};
} catch {
return null;
}
}
function matchesProvider(line: LogLine, provider: string) {
function matchesProvider(line: NonNullable<LogLine>, provider: string) {
if (provider === "all") return true;
const needle = `gateway/providers/${provider}`;
if (line.subsystem?.includes(needle)) return true;
@@ -139,7 +81,7 @@ export async function providersLogsCommand(
const rawLines = await readTailLines(file, limit * 4);
const parsed = rawLines
.map(parseLogLine)
.filter((line): line is LogLine => Boolean(line));
.filter((line): line is NonNullable<LogLine> => Boolean(line));
const filtered = parsed.filter((line) => matchesProvider(line, provider));
const lines = filtered.slice(Math.max(0, filtered.length - limit));

View File

@@ -13,6 +13,7 @@ import {
resolveIMessageAccount,
} from "../../imessage/accounts.js";
import { formatAge } from "../../infra/provider-summary.js";
import { collectProvidersStatusIssues } from "../../infra/providers-status-issues.js";
import { listChatProviders } from "../../providers/registry.js";
import { defaultRuntime, type RuntimeEnv } from "../../runtime.js";
import {
@@ -74,6 +75,21 @@ export function formatGatewayProvidersStatusLines(
if (typeof account.running === "boolean") {
bits.push(account.running ? "running" : "stopped");
}
if (typeof account.connected === "boolean") {
bits.push(account.connected ? "connected" : "disconnected");
}
const inboundAt =
typeof account.lastInboundAt === "number" &&
Number.isFinite(account.lastInboundAt)
? account.lastInboundAt
: null;
const outboundAt =
typeof account.lastOutboundAt === "number" &&
Number.isFinite(account.lastOutboundAt)
? account.lastOutboundAt
: null;
if (inboundAt) bits.push(`in:${formatAge(Date.now() - inboundAt)}`);
if (outboundAt) bits.push(`out:${formatAge(Date.now() - outboundAt)}`);
if (typeof account.mode === "string" && account.mode.length > 0) {
bits.push(`mode:${account.mode}`);
}
@@ -98,6 +114,20 @@ export function formatGatewayProvidersStatusLines(
) {
bits.push(`app:${account.appTokenSource}`);
}
const application = account.application as
| { intents?: { messageContent?: string } }
| undefined;
const messageContent = application?.intents?.messageContent;
if (
typeof messageContent === "string" &&
messageContent.length > 0 &&
messageContent !== "enabled"
) {
bits.push(`intents:content=${messageContent}`);
}
if (account.allowUnmentionedGroups === true) {
bits.push("groups:unmentioned");
}
if (typeof account.baseUrl === "string" && account.baseUrl) {
bits.push(`url:${account.baseUrl}`);
}
@@ -105,6 +135,10 @@ export function formatGatewayProvidersStatusLines(
if (probe && typeof probe.ok === "boolean") {
bits.push(probe.ok ? "works" : "probe failed");
}
const audit = account.audit as { ok?: boolean } | undefined;
if (audit && typeof audit.ok === "boolean") {
bits.push(audit.ok ? "audit ok" : "audit failed");
}
if (typeof account.lastError === "string" && account.lastError) {
bits.push(`error:${account.lastError}`);
}
@@ -150,6 +184,17 @@ export function formatGatewayProvidersStatusLines(
}
lines.push("");
const issues = collectProvidersStatusIssues(payload);
if (issues.length > 0) {
lines.push(theme.warn("Warnings:"));
for (const issue of issues) {
lines.push(
`- ${issue.provider} ${issue.accountId}: ${issue.message}${issue.fix ? ` (${issue.fix})` : ""}`,
);
}
lines.push(`- Run: clawdbot doctor`);
lines.push("");
}
lines.push(
`Tip: ${formatDocsLink("/cli#status", "status --deep")} runs local probes without a gateway.`,
);
@@ -329,10 +374,15 @@ export async function providersStatusCommand(
runtime: RuntimeEnv = defaultRuntime,
) {
const timeoutMs = Number(opts.timeout ?? 10_000);
const statusLabel = opts.probe
? "Checking provider status (probe)…"
: "Checking provider status…";
const shouldLogStatus = opts.json !== true && !process.stderr.isTTY;
if (shouldLogStatus) runtime.log(statusLabel);
try {
const payload = await withProgress(
{
label: "Checking provider status…",
label: statusLabel,
indeterminate: true,
enabled: opts.json !== true,
},

View File

@@ -0,0 +1,162 @@
/**
* Display utilities for sandbox CLI
*/
import type {
SandboxBrowserInfo,
SandboxContainerInfo,
} from "../agents/sandbox.js";
import type { RuntimeEnv } from "../runtime.js";
import {
formatAge,
formatImageMatch,
formatSimpleStatus,
formatStatus,
} from "./sandbox-formatters.js";
type DisplayConfig<T> = {
emptyMessage: string;
title: string;
renderItem: (item: T, runtime: RuntimeEnv) => void;
};
function displayItems<T>(
items: T[],
config: DisplayConfig<T>,
runtime: RuntimeEnv,
): void {
if (items.length === 0) {
runtime.log(config.emptyMessage);
return;
}
runtime.log(`\n${config.title}\n`);
for (const item of items) {
config.renderItem(item, runtime);
}
}
export function displayContainers(
containers: SandboxContainerInfo[],
runtime: RuntimeEnv,
): void {
displayItems(
containers,
{
emptyMessage: "No sandbox containers found.",
title: "📦 Sandbox Containers:",
renderItem: (container, rt) => {
rt.log(` ${container.containerName}`);
rt.log(` Status: ${formatStatus(container.running)}`);
rt.log(
` Image: ${container.image} ${formatImageMatch(container.imageMatch)}`,
);
rt.log(` Age: ${formatAge(Date.now() - container.createdAtMs)}`);
rt.log(
` Idle: ${formatAge(Date.now() - container.lastUsedAtMs)}`,
);
rt.log(` Session: ${container.sessionKey}`);
rt.log("");
},
},
runtime,
);
}
export function displayBrowsers(
browsers: SandboxBrowserInfo[],
runtime: RuntimeEnv,
): void {
displayItems(
browsers,
{
emptyMessage: "No sandbox browser containers found.",
title: "🌐 Sandbox Browser Containers:",
renderItem: (browser, rt) => {
rt.log(` ${browser.containerName}`);
rt.log(` Status: ${formatStatus(browser.running)}`);
rt.log(
` Image: ${browser.image} ${formatImageMatch(browser.imageMatch)}`,
);
rt.log(` CDP: ${browser.cdpPort}`);
if (browser.noVncPort) {
rt.log(` noVNC: ${browser.noVncPort}`);
}
rt.log(` Age: ${formatAge(Date.now() - browser.createdAtMs)}`);
rt.log(` Idle: ${formatAge(Date.now() - browser.lastUsedAtMs)}`);
rt.log(` Session: ${browser.sessionKey}`);
rt.log("");
},
},
runtime,
);
}
export function displaySummary(
containers: SandboxContainerInfo[],
browsers: SandboxBrowserInfo[],
runtime: RuntimeEnv,
): void {
const totalCount = containers.length + browsers.length;
const runningCount =
containers.filter((c) => c.running).length +
browsers.filter((b) => b.running).length;
const mismatchCount =
containers.filter((c) => !c.imageMatch).length +
browsers.filter((b) => !b.imageMatch).length;
runtime.log(`Total: ${totalCount} (${runningCount} running)`);
if (mismatchCount > 0) {
runtime.log(
`\n⚠ ${mismatchCount} container(s) with image mismatch detected.`,
);
runtime.log(
` Run 'clawdbot sandbox recreate --all' to update all containers.`,
);
}
}
export function displayRecreatePreview(
containers: SandboxContainerInfo[],
browsers: SandboxBrowserInfo[],
runtime: RuntimeEnv,
): void {
runtime.log("\nContainers to be recreated:\n");
if (containers.length > 0) {
runtime.log("📦 Sandbox Containers:");
for (const container of containers) {
runtime.log(
` - ${container.containerName} (${formatSimpleStatus(container.running)})`,
);
}
}
if (browsers.length > 0) {
runtime.log("\n🌐 Browser Containers:");
for (const browser of browsers) {
runtime.log(
` - ${browser.containerName} (${formatSimpleStatus(browser.running)})`,
);
}
}
const total = containers.length + browsers.length;
runtime.log(`\nTotal: ${total} container(s)`);
}
export function displayRecreateResult(
result: { successCount: number; failCount: number },
runtime: RuntimeEnv,
): void {
runtime.log(
`\nDone: ${result.successCount} removed, ${result.failCount} failed`,
);
if (result.successCount > 0) {
runtime.log(
"\nContainers will be automatically recreated when the agent is next used.",
);
}
}

View File

@@ -0,0 +1,152 @@
import { describe, expect, it } from "vitest";
import {
countMismatches,
countRunning,
formatAge,
formatImageMatch,
formatSimpleStatus,
formatStatus,
} from "./sandbox-formatters.js";
describe("sandbox-formatters", () => {
describe("formatStatus", () => {
it("should format running status", () => {
expect(formatStatus(true)).toBe("🟢 running");
});
it("should format stopped status", () => {
expect(formatStatus(false)).toBe("⚫ stopped");
});
});
describe("formatSimpleStatus", () => {
it("should format running status without emoji", () => {
expect(formatSimpleStatus(true)).toBe("running");
});
it("should format stopped status without emoji", () => {
expect(formatSimpleStatus(false)).toBe("stopped");
});
});
describe("formatImageMatch", () => {
it("should format matching image", () => {
expect(formatImageMatch(true)).toBe("✓");
});
it("should format mismatched image", () => {
expect(formatImageMatch(false)).toBe("⚠️ mismatch");
});
});
describe("formatAge", () => {
it("should format seconds", () => {
expect(formatAge(5000)).toBe("5s");
expect(formatAge(45000)).toBe("45s");
});
it("should format minutes", () => {
expect(formatAge(60000)).toBe("1m");
expect(formatAge(90000)).toBe("1m");
expect(formatAge(300000)).toBe("5m");
});
it("should format hours and minutes", () => {
expect(formatAge(3600000)).toBe("1h 0m");
expect(formatAge(3660000)).toBe("1h 1m");
expect(formatAge(7200000)).toBe("2h 0m");
expect(formatAge(5400000)).toBe("1h 30m");
});
it("should format days and hours", () => {
expect(formatAge(86400000)).toBe("1d 0h");
expect(formatAge(90000000)).toBe("1d 1h");
expect(formatAge(172800000)).toBe("2d 0h");
expect(formatAge(183600000)).toBe("2d 3h");
});
it("should handle zero", () => {
expect(formatAge(0)).toBe("0s");
});
it("should handle edge cases", () => {
expect(formatAge(59999)).toBe("59s"); // Just under 1 minute
expect(formatAge(3599999)).toBe("59m"); // Just under 1 hour
expect(formatAge(86399999)).toBe("23h 59m"); // Just under 1 day
});
});
describe("countRunning", () => {
it("should count running items", () => {
const items = [
{ running: true, name: "a" },
{ running: false, name: "b" },
{ running: true, name: "c" },
{ running: false, name: "d" },
];
expect(countRunning(items)).toBe(2);
});
it("should return 0 for empty array", () => {
expect(countRunning([])).toBe(0);
});
it("should return 0 when no items running", () => {
const items = [
{ running: false, name: "a" },
{ running: false, name: "b" },
];
expect(countRunning(items)).toBe(0);
});
it("should count all when all running", () => {
const items = [
{ running: true, name: "a" },
{ running: true, name: "b" },
{ running: true, name: "c" },
];
expect(countRunning(items)).toBe(3);
});
});
describe("countMismatches", () => {
it("should count image mismatches", () => {
const items = [
{ imageMatch: true, name: "a" },
{ imageMatch: false, name: "b" },
{ imageMatch: true, name: "c" },
{ imageMatch: false, name: "d" },
{ imageMatch: false, name: "e" },
];
expect(countMismatches(items)).toBe(3);
});
it("should return 0 for empty array", () => {
expect(countMismatches([])).toBe(0);
});
it("should return 0 when all match", () => {
const items = [
{ imageMatch: true, name: "a" },
{ imageMatch: true, name: "b" },
];
expect(countMismatches(items)).toBe(0);
});
it("should count all when none match", () => {
const items = [
{ imageMatch: false, name: "a" },
{ imageMatch: false, name: "b" },
{ imageMatch: false, name: "c" },
];
expect(countMismatches(items)).toBe(3);
});
});
});

View File

@@ -0,0 +1,53 @@
/**
* Formatting utilities for sandbox CLI output
*/
export function formatStatus(running: boolean): string {
return running ? "🟢 running" : "⚫ stopped";
}
export function formatSimpleStatus(running: boolean): string {
return running ? "running" : "stopped";
}
export function formatImageMatch(matches: boolean): string {
return matches ? "✓" : "⚠️ mismatch";
}
export function formatAge(ms: number): string {
const seconds = Math.floor(ms / 1000);
const minutes = Math.floor(seconds / 60);
const hours = Math.floor(minutes / 60);
const days = Math.floor(hours / 24);
if (days > 0) return `${days}d ${hours % 24}h`;
if (hours > 0) return `${hours}h ${minutes % 60}m`;
if (minutes > 0) return `${minutes}m`;
return `${seconds}s`;
}
/**
* Type guard and counter utilities
*/
export type ContainerItem = {
running: boolean;
imageMatch: boolean;
containerName: string;
sessionKey: string;
image: string;
createdAtMs: number;
lastUsedAtMs: number;
};
export function countRunning<T extends { running: boolean }>(
items: T[],
): number {
return items.filter((item) => item.running).length;
}
export function countMismatches<T extends { imageMatch: boolean }>(
items: T[],
): number {
return items.filter((item) => !item.imageMatch).length;
}

View File

@@ -0,0 +1,405 @@
import { beforeEach, describe, expect, it, vi } from "vitest";
import type {
SandboxBrowserInfo,
SandboxContainerInfo,
} from "../agents/sandbox.js";
// --- Mocks ---
const mocks = vi.hoisted(() => ({
listSandboxContainers: vi.fn(),
listSandboxBrowsers: vi.fn(),
removeSandboxContainer: vi.fn(),
removeSandboxBrowserContainer: vi.fn(),
clackConfirm: vi.fn(),
}));
vi.mock("../agents/sandbox.js", () => ({
listSandboxContainers: mocks.listSandboxContainers,
listSandboxBrowsers: mocks.listSandboxBrowsers,
removeSandboxContainer: mocks.removeSandboxContainer,
removeSandboxBrowserContainer: mocks.removeSandboxBrowserContainer,
}));
vi.mock("@clack/prompts", () => ({
confirm: mocks.clackConfirm,
}));
import { sandboxListCommand, sandboxRecreateCommand } from "./sandbox.js";
// --- Test Factories ---
const NOW = Date.now();
function createContainer(
overrides: Partial<SandboxContainerInfo> = {},
): SandboxContainerInfo {
return {
containerName: "clawd-sandbox-test",
sessionKey: "test-session",
image: "clawd/sandbox:latest",
imageMatch: true,
running: true,
createdAtMs: NOW - 3600000,
lastUsedAtMs: NOW - 600000,
...overrides,
};
}
function createBrowser(
overrides: Partial<SandboxBrowserInfo> = {},
): SandboxBrowserInfo {
return {
containerName: "clawd-browser-test",
sessionKey: "test-session",
image: "clawd/browser:latest",
imageMatch: true,
running: true,
createdAtMs: NOW - 3600000,
lastUsedAtMs: NOW - 600000,
cdpPort: 9222,
noVncPort: 5900,
...overrides,
};
}
// --- Test Helpers ---
function createMockRuntime() {
return {
log: vi.fn(),
error: vi.fn(),
exit: vi.fn(),
};
}
function setupDefaultMocks() {
mocks.listSandboxContainers.mockResolvedValue([]);
mocks.listSandboxBrowsers.mockResolvedValue([]);
mocks.removeSandboxContainer.mockResolvedValue(undefined);
mocks.removeSandboxBrowserContainer.mockResolvedValue(undefined);
mocks.clackConfirm.mockResolvedValue(true);
}
function expectLogContains(
runtime: ReturnType<typeof createMockRuntime>,
text: string,
) {
expect(runtime.log).toHaveBeenCalledWith(expect.stringContaining(text));
}
function expectErrorContains(
runtime: ReturnType<typeof createMockRuntime>,
text: string,
) {
expect(runtime.error).toHaveBeenCalledWith(expect.stringContaining(text));
}
// --- Tests ---
describe("sandboxListCommand", () => {
let runtime: ReturnType<typeof createMockRuntime>;
beforeEach(() => {
vi.clearAllMocks();
setupDefaultMocks();
runtime = createMockRuntime();
});
describe("human format output", () => {
it("should display containers", async () => {
const container1 = createContainer({ containerName: "container-1" });
const container2 = createContainer({
containerName: "container-2",
imageMatch: false,
});
mocks.listSandboxContainers.mockResolvedValue([container1, container2]);
await sandboxListCommand(
{ browser: false, json: false },
runtime as never,
);
expectLogContains(runtime, "📦 Sandbox Containers");
expectLogContains(runtime, container1.containerName);
expectLogContains(runtime, container2.containerName);
expectLogContains(runtime, "Total");
});
it("should display browsers when --browser flag is set", async () => {
const browser = createBrowser({ containerName: "browser-1" });
mocks.listSandboxBrowsers.mockResolvedValue([browser]);
await sandboxListCommand(
{ browser: true, json: false },
runtime as never,
);
expectLogContains(runtime, "🌐 Sandbox Browser Containers");
expectLogContains(runtime, browser.containerName);
expectLogContains(runtime, String(browser.cdpPort));
});
it("should show warning when image mismatches detected", async () => {
const mismatchContainer = createContainer({ imageMatch: false });
mocks.listSandboxContainers.mockResolvedValue([mismatchContainer]);
await sandboxListCommand(
{ browser: false, json: false },
runtime as never,
);
expectLogContains(runtime, "⚠️");
expectLogContains(runtime, "image mismatch");
expectLogContains(runtime, "clawdbot sandbox recreate --all");
});
it("should display message when no containers found", async () => {
await sandboxListCommand(
{ browser: false, json: false },
runtime as never,
);
expect(runtime.log).toHaveBeenCalledWith("No sandbox containers found.");
});
});
describe("JSON output", () => {
it("should output JSON format", async () => {
const container = createContainer();
mocks.listSandboxContainers.mockResolvedValue([container]);
await sandboxListCommand(
{ browser: false, json: true },
runtime as never,
);
const loggedJson = runtime.log.mock.calls[0][0];
const parsed = JSON.parse(loggedJson);
expect(parsed.containers).toHaveLength(1);
expect(parsed.containers[0].containerName).toBe(container.containerName);
expect(parsed.browsers).toHaveLength(0);
});
});
describe("error handling", () => {
it("should handle errors gracefully", async () => {
mocks.listSandboxContainers.mockRejectedValue(
new Error("Docker not available"),
);
await sandboxListCommand(
{ browser: false, json: false },
runtime as never,
);
expect(runtime.log).toHaveBeenCalledWith("No sandbox containers found.");
});
});
});
describe("sandboxRecreateCommand", () => {
let runtime: ReturnType<typeof createMockRuntime>;
beforeEach(() => {
vi.clearAllMocks();
setupDefaultMocks();
runtime = createMockRuntime();
});
describe("validation", () => {
it("should error if no filter is specified", async () => {
await sandboxRecreateCommand(
{ all: false, browser: false, force: false },
runtime as never,
);
expectErrorContains(
runtime,
"Please specify --all, --session <key>, or --agent <id>",
);
expect(runtime.exit).toHaveBeenCalledWith(1);
expect(mocks.listSandboxContainers).not.toHaveBeenCalled();
expect(mocks.listSandboxBrowsers).not.toHaveBeenCalled();
});
it("should error if multiple filters specified", async () => {
await sandboxRecreateCommand(
{ all: true, session: "test", browser: false, force: false },
runtime as never,
);
expectErrorContains(
runtime,
"Please specify only one of: --all, --session, --agent",
);
expect(runtime.exit).toHaveBeenCalledWith(1);
expect(mocks.listSandboxContainers).not.toHaveBeenCalled();
expect(mocks.listSandboxBrowsers).not.toHaveBeenCalled();
});
});
describe("filtering", () => {
it("should filter by session", async () => {
const match = createContainer({ sessionKey: "target-session" });
const noMatch = createContainer({ sessionKey: "other-session" });
mocks.listSandboxContainers.mockResolvedValue([match, noMatch]);
await sandboxRecreateCommand(
{ session: "target-session", browser: false, force: true },
runtime as never,
);
expect(mocks.removeSandboxContainer).toHaveBeenCalledTimes(1);
expect(mocks.removeSandboxContainer).toHaveBeenCalledWith(
match.containerName,
);
});
it("should filter by agent (exact + subkeys)", async () => {
const agent = createContainer({ sessionKey: "agent:work" });
const agentSub = createContainer({ sessionKey: "agent:work:subtask" });
const other = createContainer({ sessionKey: "test-session" });
mocks.listSandboxContainers.mockResolvedValue([agent, agentSub, other]);
await sandboxRecreateCommand(
{ agent: "work", browser: false, force: true },
runtime as never,
);
expect(mocks.removeSandboxContainer).toHaveBeenCalledTimes(2);
expect(mocks.removeSandboxContainer).toHaveBeenCalledWith(
agent.containerName,
);
expect(mocks.removeSandboxContainer).toHaveBeenCalledWith(
agentSub.containerName,
);
});
it("should remove all when --all flag set", async () => {
const containers = [createContainer(), createContainer()];
mocks.listSandboxContainers.mockResolvedValue(containers);
await sandboxRecreateCommand(
{ all: true, browser: false, force: true },
runtime as never,
);
expect(mocks.removeSandboxContainer).toHaveBeenCalledTimes(2);
});
it("should handle browsers when --browser flag set", async () => {
const browsers = [createBrowser(), createBrowser()];
mocks.listSandboxBrowsers.mockResolvedValue(browsers);
await sandboxRecreateCommand(
{ all: true, browser: true, force: true },
runtime as never,
);
expect(mocks.removeSandboxBrowserContainer).toHaveBeenCalledTimes(2);
expect(mocks.removeSandboxContainer).not.toHaveBeenCalled();
});
});
describe("confirmation flow", () => {
it("should require confirmation without --force", async () => {
mocks.listSandboxContainers.mockResolvedValue([createContainer()]);
mocks.clackConfirm.mockResolvedValue(true);
await sandboxRecreateCommand(
{ all: true, browser: false, force: false },
runtime as never,
);
expect(mocks.clackConfirm).toHaveBeenCalled();
expect(mocks.removeSandboxContainer).toHaveBeenCalled();
});
it("should cancel when user declines", async () => {
mocks.listSandboxContainers.mockResolvedValue([createContainer()]);
mocks.clackConfirm.mockResolvedValue(false);
await sandboxRecreateCommand(
{ all: true, browser: false, force: false },
runtime as never,
);
expect(runtime.log).toHaveBeenCalledWith("Cancelled.");
expect(mocks.removeSandboxContainer).not.toHaveBeenCalled();
});
it("should cancel on clack cancel symbol", async () => {
mocks.listSandboxContainers.mockResolvedValue([createContainer()]);
mocks.clackConfirm.mockResolvedValue(Symbol.for("clack:cancel"));
await sandboxRecreateCommand(
{ all: true, browser: false, force: false },
runtime as never,
);
expect(runtime.log).toHaveBeenCalledWith("Cancelled.");
expect(mocks.removeSandboxContainer).not.toHaveBeenCalled();
});
it("should skip confirmation with --force", async () => {
mocks.listSandboxContainers.mockResolvedValue([createContainer()]);
await sandboxRecreateCommand(
{ all: true, browser: false, force: true },
runtime as never,
);
expect(mocks.clackConfirm).not.toHaveBeenCalled();
expect(mocks.removeSandboxContainer).toHaveBeenCalled();
});
});
describe("execution", () => {
it("should show message when no containers match", async () => {
await sandboxRecreateCommand(
{ all: true, browser: false, force: true },
runtime as never,
);
expect(runtime.log).toHaveBeenCalledWith(
"No containers found matching the criteria.",
);
expect(mocks.removeSandboxContainer).not.toHaveBeenCalled();
});
it("should handle removal errors and exit with code 1", async () => {
mocks.listSandboxContainers.mockResolvedValue([
createContainer({ containerName: "success" }),
createContainer({ containerName: "failure" }),
]);
mocks.removeSandboxContainer
.mockResolvedValueOnce(undefined)
.mockRejectedValueOnce(new Error("Removal failed"));
await sandboxRecreateCommand(
{ all: true, browser: false, force: true },
runtime as never,
);
expectErrorContains(runtime, "Failed to remove");
expectLogContains(runtime, "1 removed, 1 failed");
expect(runtime.exit).toHaveBeenCalledWith(1);
});
it("should display success message", async () => {
mocks.listSandboxContainers.mockResolvedValue([createContainer()]);
await sandboxRecreateCommand(
{ all: true, browser: false, force: true },
runtime as never,
);
expectLogContains(runtime, "✓ Removed");
expectLogContains(runtime, "1 removed, 0 failed");
expectLogContains(runtime, "automatically recreated");
});
});
});

217
src/commands/sandbox.ts Normal file
View File

@@ -0,0 +1,217 @@
import { confirm as clackConfirm } from "@clack/prompts";
import {
listSandboxBrowsers,
listSandboxContainers,
removeSandboxBrowserContainer,
removeSandboxContainer,
type SandboxBrowserInfo,
type SandboxContainerInfo,
} from "../agents/sandbox.js";
import type { RuntimeEnv } from "../runtime.js";
import {
displayBrowsers,
displayContainers,
displayRecreatePreview,
displayRecreateResult,
displaySummary,
} from "./sandbox-display.js";
// --- Types ---
type SandboxListOptions = {
browser: boolean;
json: boolean;
};
type SandboxRecreateOptions = {
all: boolean;
session?: string;
agent?: string;
browser: boolean;
force: boolean;
};
type ContainerItem = SandboxContainerInfo | SandboxBrowserInfo;
type FilteredContainers = {
containers: SandboxContainerInfo[];
browsers: SandboxBrowserInfo[];
};
// --- List Command ---
export async function sandboxListCommand(
opts: SandboxListOptions,
runtime: RuntimeEnv,
): Promise<void> {
const containers = opts.browser
? []
: await listSandboxContainers().catch(() => []);
const browsers = opts.browser
? await listSandboxBrowsers().catch(() => [])
: [];
if (opts.json) {
runtime.log(JSON.stringify({ containers, browsers }, null, 2));
return;
}
if (opts.browser) {
displayBrowsers(browsers, runtime);
} else {
displayContainers(containers, runtime);
}
displaySummary(containers, browsers, runtime);
}
// --- Recreate Command ---
export async function sandboxRecreateCommand(
opts: SandboxRecreateOptions,
runtime: RuntimeEnv,
): Promise<void> {
if (!validateRecreateOptions(opts, runtime)) {
return;
}
const filtered = await fetchAndFilterContainers(opts);
if (filtered.containers.length + filtered.browsers.length === 0) {
runtime.log("No containers found matching the criteria.");
return;
}
displayRecreatePreview(filtered.containers, filtered.browsers, runtime);
if (!opts.force && !(await confirmRecreate())) {
runtime.log("Cancelled.");
return;
}
const result = await removeContainers(filtered, runtime);
displayRecreateResult(result, runtime);
if (result.failCount > 0) {
runtime.exit(1);
}
}
// --- Validation ---
function validateRecreateOptions(
opts: SandboxRecreateOptions,
runtime: RuntimeEnv,
): boolean {
if (!opts.all && !opts.session && !opts.agent) {
runtime.error("Please specify --all, --session <key>, or --agent <id>");
runtime.exit(1);
return false;
}
const exclusiveCount = [opts.all, opts.session, opts.agent].filter(
Boolean,
).length;
if (exclusiveCount > 1) {
runtime.error("Please specify only one of: --all, --session, --agent");
runtime.exit(1);
return false;
}
return true;
}
// --- Filtering ---
async function fetchAndFilterContainers(
opts: SandboxRecreateOptions,
): Promise<FilteredContainers> {
const allContainers = await listSandboxContainers().catch(() => []);
const allBrowsers = await listSandboxBrowsers().catch(() => []);
let containers = opts.browser ? [] : allContainers;
let browsers = opts.browser ? allBrowsers : [];
if (opts.session) {
containers = containers.filter((c) => c.sessionKey === opts.session);
browsers = browsers.filter((b) => b.sessionKey === opts.session);
} else if (opts.agent) {
const matchesAgent = createAgentMatcher(opts.agent);
containers = containers.filter(matchesAgent);
browsers = browsers.filter(matchesAgent);
}
return { containers, browsers };
}
function createAgentMatcher(agentId: string) {
const agentPrefix = `agent:${agentId}`;
return (item: ContainerItem) =>
item.sessionKey === agentPrefix ||
item.sessionKey.startsWith(`${agentPrefix}:`);
}
// --- Container Operations ---
async function confirmRecreate(): Promise<boolean> {
const result = await clackConfirm({
message: "This will stop and remove these containers. Continue?",
initialValue: false,
});
return result !== false && result !== Symbol.for("clack:cancel");
}
async function removeContainers(
filtered: FilteredContainers,
runtime: RuntimeEnv,
): Promise<{ successCount: number; failCount: number }> {
runtime.log("\nRemoving containers...\n");
let successCount = 0;
let failCount = 0;
for (const container of filtered.containers) {
const result = await removeContainer(
container.containerName,
removeSandboxContainer,
runtime,
);
if (result.success) {
successCount++;
} else {
failCount++;
}
}
for (const browser of filtered.browsers) {
const result = await removeContainer(
browser.containerName,
removeSandboxBrowserContainer,
runtime,
);
if (result.success) {
successCount++;
} else {
failCount++;
}
}
return { successCount, failCount };
}
async function removeContainer(
containerName: string,
removeFn: (name: string) => Promise<void>,
runtime: RuntimeEnv,
): Promise<{ success: boolean }> {
try {
await removeFn(containerName);
runtime.log(`✓ Removed ${containerName}`);
return { success: true };
} catch (err) {
runtime.error(`✗ Failed to remove ${containerName}: ${String(err)}`);
return { success: false };
}
}

View File

@@ -1,253 +0,0 @@
import { afterAll, beforeEach, describe, expect, it, vi } from "vitest";
import type { CliDeps } from "../cli/deps.js";
import type { RuntimeEnv } from "../runtime.js";
import { sendCommand } from "./send.js";
let testConfig: Record<string, unknown> = {};
vi.mock("../config/config.js", async (importOriginal) => {
const actual = await importOriginal<typeof import("../config/config.js")>();
return {
...actual,
loadConfig: () => testConfig,
};
});
const callGatewayMock = vi.fn();
vi.mock("../gateway/call.js", () => ({
callGateway: (...args: unknown[]) => callGatewayMock(...args),
randomIdempotencyKey: () => "idem-1",
}));
const originalTelegramToken = process.env.TELEGRAM_BOT_TOKEN;
const originalDiscordToken = process.env.DISCORD_BOT_TOKEN;
beforeEach(() => {
process.env.TELEGRAM_BOT_TOKEN = "token-abc";
process.env.DISCORD_BOT_TOKEN = "token-discord";
testConfig = {};
});
afterAll(() => {
process.env.TELEGRAM_BOT_TOKEN = originalTelegramToken;
process.env.DISCORD_BOT_TOKEN = originalDiscordToken;
});
const runtime: RuntimeEnv = {
log: vi.fn(),
error: vi.fn(),
exit: vi.fn(() => {
throw new Error("exit");
}),
};
const makeDeps = (overrides: Partial<CliDeps> = {}): CliDeps => ({
sendMessageWhatsApp: vi.fn(),
sendMessageTelegram: vi.fn(),
sendMessageDiscord: vi.fn(),
sendMessageSlack: vi.fn(),
sendMessageSignal: vi.fn(),
sendMessageIMessage: vi.fn(),
...overrides,
});
describe("sendCommand", () => {
it("skips send on dry-run", async () => {
const deps = makeDeps();
await sendCommand(
{
to: "+1",
message: "hi",
dryRun: true,
},
deps,
runtime,
);
expect(deps.sendMessageWhatsApp).not.toHaveBeenCalled();
});
it("sends via gateway", async () => {
callGatewayMock.mockResolvedValueOnce({ messageId: "g1" });
const deps = makeDeps();
await sendCommand(
{
to: "+1",
message: "hi",
},
deps,
runtime,
);
expect(callGatewayMock).toHaveBeenCalled();
expect(runtime.log).toHaveBeenCalledWith(expect.stringContaining("g1"));
});
it("does not override remote gateway URL", async () => {
callGatewayMock.mockResolvedValueOnce({ messageId: "g2" });
testConfig = {
gateway: { mode: "remote", remote: { url: "wss://remote.example" } },
};
const deps = makeDeps();
await sendCommand(
{
to: "+1",
message: "hi",
},
deps,
runtime,
);
const args = callGatewayMock.mock.calls.at(-1)?.[0] as
| Record<string, unknown>
| undefined;
expect(args?.url).toBeUndefined();
});
it("passes gifPlayback to gateway send", async () => {
callGatewayMock.mockClear();
callGatewayMock.mockResolvedValueOnce({ messageId: "g1" });
const deps = makeDeps();
await sendCommand(
{
to: "+1",
message: "hi",
gifPlayback: true,
},
deps,
runtime,
);
expect(callGatewayMock).toHaveBeenCalledWith(
expect.objectContaining({
method: "send",
params: expect.objectContaining({ gifPlayback: true }),
}),
);
});
it("routes to telegram provider", async () => {
const deps = makeDeps({
sendMessageTelegram: vi
.fn()
.mockResolvedValue({ messageId: "t1", chatId: "123" }),
});
testConfig = { telegram: { botToken: "token-abc" } };
await sendCommand(
{ to: "123", message: "hi", provider: "telegram" },
deps,
runtime,
);
expect(deps.sendMessageTelegram).toHaveBeenCalledWith(
"123",
"hi",
expect.objectContaining({ accountId: undefined, verbose: false }),
);
expect(deps.sendMessageWhatsApp).not.toHaveBeenCalled();
});
it("uses config token for telegram when env is missing", async () => {
process.env.TELEGRAM_BOT_TOKEN = "";
testConfig = { telegram: { botToken: "cfg-token" } };
const deps = makeDeps({
sendMessageTelegram: vi
.fn()
.mockResolvedValue({ messageId: "t1", chatId: "123" }),
});
await sendCommand(
{ to: "123", message: "hi", provider: "telegram" },
deps,
runtime,
);
expect(deps.sendMessageTelegram).toHaveBeenCalledWith(
"123",
"hi",
expect.objectContaining({ accountId: undefined, verbose: false }),
);
});
it("routes to discord provider", async () => {
const deps = makeDeps({
sendMessageDiscord: vi
.fn()
.mockResolvedValue({ messageId: "d1", channelId: "chan" }),
});
await sendCommand(
{ to: "channel:chan", message: "hi", provider: "discord" },
deps,
runtime,
);
expect(deps.sendMessageDiscord).toHaveBeenCalledWith(
"channel:chan",
"hi",
expect.objectContaining({ verbose: false }),
);
expect(deps.sendMessageWhatsApp).not.toHaveBeenCalled();
});
it("routes to signal provider", async () => {
const deps = makeDeps({
sendMessageSignal: vi.fn().mockResolvedValue({ messageId: "s1" }),
});
await sendCommand(
{ to: "+15551234567", message: "hi", provider: "signal" },
deps,
runtime,
);
expect(deps.sendMessageSignal).toHaveBeenCalledWith(
"+15551234567",
"hi",
expect.objectContaining({ maxBytes: undefined }),
);
expect(deps.sendMessageWhatsApp).not.toHaveBeenCalled();
});
it("routes to slack provider", async () => {
const deps = makeDeps({
sendMessageSlack: vi
.fn()
.mockResolvedValue({ messageId: "s1", channelId: "C123" }),
});
await sendCommand(
{ to: "channel:C123", message: "hi", provider: "slack" },
deps,
runtime,
);
expect(deps.sendMessageSlack).toHaveBeenCalledWith(
"channel:C123",
"hi",
expect.objectContaining({ accountId: undefined }),
);
expect(deps.sendMessageWhatsApp).not.toHaveBeenCalled();
});
it("routes to imessage provider", async () => {
const deps = makeDeps({
sendMessageIMessage: vi.fn().mockResolvedValue({ messageId: "i1" }),
});
await sendCommand(
{ to: "chat_id:42", message: "hi", provider: "imessage" },
deps,
runtime,
);
expect(deps.sendMessageIMessage).toHaveBeenCalledWith(
"chat_id:42",
"hi",
expect.objectContaining({ maxBytes: undefined }),
);
expect(deps.sendMessageWhatsApp).not.toHaveBeenCalled();
});
it("emits json output", async () => {
callGatewayMock.mockResolvedValueOnce({ messageId: "direct2" });
const deps = makeDeps();
await sendCommand(
{
to: "+1",
message: "hi",
json: true,
},
deps,
runtime,
);
expect(runtime.log).toHaveBeenCalledWith(
expect.stringContaining('"provider": "whatsapp"'),
);
});
});

View File

@@ -1,148 +0,0 @@
import type { CliDeps } from "../cli/deps.js";
import { withProgress } from "../cli/progress.js";
import { loadConfig } from "../config/config.js";
import { callGateway, randomIdempotencyKey } from "../gateway/call.js";
import { success } from "../globals.js";
import { deliverOutboundPayloads } from "../infra/outbound/deliver.js";
import { buildOutboundResultEnvelope } from "../infra/outbound/envelope.js";
import {
buildOutboundDeliveryJson,
formatGatewaySummary,
formatOutboundDeliverySummary,
} from "../infra/outbound/format.js";
import { resolveOutboundTarget } from "../infra/outbound/targets.js";
import type { RuntimeEnv } from "../runtime.js";
import { normalizeMessageProvider } from "../utils/message-provider.js";
export async function sendCommand(
opts: {
to: string;
message: string;
provider?: string;
json?: boolean;
dryRun?: boolean;
media?: string;
gifPlayback?: boolean;
account?: string;
},
deps: CliDeps,
runtime: RuntimeEnv,
) {
const provider = normalizeMessageProvider(opts.provider) ?? "whatsapp";
if (opts.dryRun) {
runtime.log(
`[dry-run] would send via ${provider} -> ${opts.to}: ${opts.message}${opts.media ? ` (media ${opts.media})` : ""}`,
);
return;
}
if (
provider === "telegram" ||
provider === "discord" ||
provider === "slack" ||
provider === "signal" ||
provider === "imessage"
) {
const resolvedTarget = resolveOutboundTarget({
provider,
to: opts.to,
});
if (!resolvedTarget.ok) {
throw resolvedTarget.error;
}
const results = await withProgress(
{
label: `Sending via ${provider}`,
indeterminate: true,
enabled: opts.json !== true,
},
async () =>
await deliverOutboundPayloads({
cfg: loadConfig(),
provider,
to: resolvedTarget.to,
payloads: [{ text: opts.message, mediaUrl: opts.media }],
deps: {
sendWhatsApp: deps.sendMessageWhatsApp,
sendTelegram: deps.sendMessageTelegram,
sendDiscord: deps.sendMessageDiscord,
sendSlack: deps.sendMessageSlack,
sendSignal: deps.sendMessageSignal,
sendIMessage: deps.sendMessageIMessage,
},
}),
);
const last = results.at(-1);
const summary = formatOutboundDeliverySummary(provider, last);
runtime.log(success(summary));
if (opts.json) {
runtime.log(
JSON.stringify(
buildOutboundDeliveryJson({
provider,
via: "direct",
to: opts.to,
result: last,
mediaUrl: opts.media,
}),
null,
2,
),
);
}
return;
}
// Always send via gateway over WS to avoid multi-session corruption.
const sendViaGateway = async () =>
callGateway<{
messageId: string;
}>({
method: "send",
params: {
to: opts.to,
message: opts.message,
mediaUrl: opts.media,
gifPlayback: opts.gifPlayback,
accountId: opts.account,
provider,
idempotencyKey: randomIdempotencyKey(),
},
timeoutMs: 10_000,
clientName: "cli",
mode: "cli",
});
const result = await withProgress(
{
label: `Sending via ${provider}`,
indeterminate: true,
enabled: opts.json !== true,
},
async () => await sendViaGateway(),
);
runtime.log(
success(
formatGatewaySummary({ provider, messageId: result.messageId ?? null }),
),
);
if (opts.json) {
runtime.log(
JSON.stringify(
buildOutboundResultEnvelope({
delivery: buildOutboundDeliveryJson({
provider,
via: "gateway",
to: opts.to,
result,
mediaUrl: opts.media ?? null,
}),
}),
null,
2,
),
);
}
}

View File

@@ -1,10 +1,14 @@
import { note } from "@clack/prompts";
import { note as clackNote } from "@clack/prompts";
import {
enableSystemdUserLinger,
readSystemdUserLingerStatus,
} from "../daemon/systemd.js";
import type { RuntimeEnv } from "../runtime.js";
import { stylePromptTitle } from "../terminal/prompt-style.js";
const note = (message: string, title?: string) =>
clackNote(message, stylePromptTitle(title));
export type LingerPrompter = {
confirm?: (params: {