mirror of
https://github.com/openclaw/openclaw.git
synced 2026-05-08 05:01:23 +00:00
Merge branch 'openclaw:main' into qianfan
This commit is contained in:
@@ -430,6 +430,7 @@ export async function agentCommand(
|
||||
currentThreadTs: runContext.currentThreadTs,
|
||||
replyToMode: runContext.replyToMode,
|
||||
hasRepliedRef: runContext.hasRepliedRef,
|
||||
senderIsOwner: true,
|
||||
sessionFile,
|
||||
workspaceDir,
|
||||
config: cfg,
|
||||
|
||||
179
src/commands/doctor-completion.ts
Normal file
179
src/commands/doctor-completion.ts
Normal file
@@ -0,0 +1,179 @@
|
||||
import { spawnSync } from "node:child_process";
|
||||
import path from "node:path";
|
||||
import type { RuntimeEnv } from "../runtime.js";
|
||||
import type { DoctorPrompter } from "./doctor-prompter.js";
|
||||
import { resolveCliName } from "../cli/cli-name.js";
|
||||
import {
|
||||
completionCacheExists,
|
||||
installCompletion,
|
||||
isCompletionInstalled,
|
||||
resolveCompletionCachePath,
|
||||
resolveShellFromEnv,
|
||||
usesSlowDynamicCompletion,
|
||||
} from "../cli/completion-cli.js";
|
||||
import { resolveOpenClawPackageRoot } from "../infra/openclaw-root.js";
|
||||
import { note } from "../terminal/note.js";
|
||||
|
||||
type CompletionShell = "zsh" | "bash" | "fish" | "powershell";
|
||||
|
||||
/** Generate the completion cache by spawning the CLI. */
|
||||
async function generateCompletionCache(): Promise<boolean> {
|
||||
const root = await resolveOpenClawPackageRoot({
|
||||
moduleUrl: import.meta.url,
|
||||
argv1: process.argv[1],
|
||||
cwd: process.cwd(),
|
||||
});
|
||||
if (!root) {
|
||||
return false;
|
||||
}
|
||||
|
||||
const binPath = path.join(root, "openclaw.mjs");
|
||||
const result = spawnSync(process.execPath, [binPath, "completion", "--write-state"], {
|
||||
cwd: root,
|
||||
env: process.env,
|
||||
encoding: "utf-8",
|
||||
});
|
||||
|
||||
return result.status === 0;
|
||||
}
|
||||
|
||||
export type ShellCompletionStatus = {
|
||||
shell: CompletionShell;
|
||||
profileInstalled: boolean;
|
||||
cacheExists: boolean;
|
||||
cachePath: string;
|
||||
/** True if profile uses slow dynamic pattern like `source <(openclaw completion ...)` */
|
||||
usesSlowPattern: boolean;
|
||||
};
|
||||
|
||||
/** Check the status of shell completion for the current shell. */
|
||||
export async function checkShellCompletionStatus(
|
||||
binName = "openclaw",
|
||||
): Promise<ShellCompletionStatus> {
|
||||
const shell = resolveShellFromEnv() as CompletionShell;
|
||||
const profileInstalled = await isCompletionInstalled(shell, binName);
|
||||
const cacheExists = await completionCacheExists(shell, binName);
|
||||
const cachePath = resolveCompletionCachePath(shell, binName);
|
||||
const usesSlowPattern = await usesSlowDynamicCompletion(shell, binName);
|
||||
|
||||
return {
|
||||
shell,
|
||||
profileInstalled,
|
||||
cacheExists,
|
||||
cachePath,
|
||||
usesSlowPattern,
|
||||
};
|
||||
}
|
||||
|
||||
export type DoctorCompletionOptions = {
|
||||
nonInteractive?: boolean;
|
||||
};
|
||||
|
||||
/**
|
||||
* Doctor check for shell completion.
|
||||
* - If profile uses slow dynamic pattern: upgrade to cached version
|
||||
* - If profile has completion but no cache: auto-generate cache and upgrade profile
|
||||
* - If no completion at all: prompt to install (with user confirmation)
|
||||
*/
|
||||
export async function doctorShellCompletion(
|
||||
runtime: RuntimeEnv,
|
||||
prompter: DoctorPrompter,
|
||||
options: DoctorCompletionOptions = {},
|
||||
): Promise<void> {
|
||||
const cliName = resolveCliName();
|
||||
const status = await checkShellCompletionStatus(cliName);
|
||||
|
||||
// Profile uses slow dynamic pattern - upgrade to cached version
|
||||
if (status.usesSlowPattern) {
|
||||
note(
|
||||
`Your ${status.shell} profile uses slow dynamic completion (source <(...)).\nUpgrading to cached completion for faster shell startup...`,
|
||||
"Shell completion",
|
||||
);
|
||||
|
||||
// Ensure cache exists first
|
||||
if (!status.cacheExists) {
|
||||
const generated = await generateCompletionCache();
|
||||
if (!generated) {
|
||||
note(
|
||||
`Failed to generate completion cache. Run \`${cliName} completion --write-state\` manually.`,
|
||||
"Shell completion",
|
||||
);
|
||||
return;
|
||||
}
|
||||
}
|
||||
|
||||
// Upgrade profile to use cached file
|
||||
await installCompletion(status.shell, true, cliName);
|
||||
note(
|
||||
`Shell completion upgraded. Restart your shell or run: source ~/.${status.shell === "zsh" ? "zshrc" : status.shell === "bash" ? "bashrc" : "config/fish/config.fish"}`,
|
||||
"Shell completion",
|
||||
);
|
||||
return;
|
||||
}
|
||||
|
||||
// Profile has completion but no cache - auto-fix
|
||||
if (status.profileInstalled && !status.cacheExists) {
|
||||
note(
|
||||
`Shell completion is configured in your ${status.shell} profile but the cache is missing.\nRegenerating cache...`,
|
||||
"Shell completion",
|
||||
);
|
||||
const generated = await generateCompletionCache();
|
||||
if (generated) {
|
||||
note(`Completion cache regenerated at ${status.cachePath}`, "Shell completion");
|
||||
} else {
|
||||
note(
|
||||
`Failed to regenerate completion cache. Run \`${cliName} completion --write-state\` manually.`,
|
||||
"Shell completion",
|
||||
);
|
||||
}
|
||||
return;
|
||||
}
|
||||
|
||||
// No completion at all - prompt to install
|
||||
if (!status.profileInstalled) {
|
||||
if (options.nonInteractive) {
|
||||
// In non-interactive mode, just note that completion is not installed
|
||||
return;
|
||||
}
|
||||
|
||||
const shouldInstall = await prompter.confirm({
|
||||
message: `Enable ${status.shell} shell completion for ${cliName}?`,
|
||||
initialValue: true,
|
||||
});
|
||||
|
||||
if (shouldInstall) {
|
||||
// First generate the cache
|
||||
const generated = await generateCompletionCache();
|
||||
if (!generated) {
|
||||
note(
|
||||
`Failed to generate completion cache. Run \`${cliName} completion --write-state\` manually.`,
|
||||
"Shell completion",
|
||||
);
|
||||
return;
|
||||
}
|
||||
|
||||
// Then install to profile
|
||||
await installCompletion(status.shell, true, cliName);
|
||||
note(
|
||||
`Shell completion installed. Restart your shell or run: source ~/.${status.shell === "zsh" ? "zshrc" : status.shell === "bash" ? "bashrc" : "config/fish/config.fish"}`,
|
||||
"Shell completion",
|
||||
);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Ensure completion cache exists. Used during onboarding/update to fix
|
||||
* cases where profile has completion but no cache.
|
||||
* This is a silent fix - no prompts.
|
||||
*/
|
||||
export async function ensureCompletionCacheExists(binName = "openclaw"): Promise<boolean> {
|
||||
const shell = resolveShellFromEnv() as CompletionShell;
|
||||
const cacheExists = await completionCacheExists(shell, binName);
|
||||
|
||||
if (cacheExists) {
|
||||
return true;
|
||||
}
|
||||
|
||||
return generateCompletionCache();
|
||||
}
|
||||
@@ -26,6 +26,7 @@ import {
|
||||
maybeRepairAnthropicOAuthProfileId,
|
||||
noteAuthProfileHealth,
|
||||
} from "./doctor-auth.js";
|
||||
import { doctorShellCompletion } from "./doctor-completion.js";
|
||||
import { loadAndMaybeMigrateDoctorConfig } from "./doctor-config-flow.js";
|
||||
import { maybeRepairGatewayDaemon } from "./doctor-gateway-daemon-flow.js";
|
||||
import { checkGatewayHealth } from "./doctor-gateway-health.js";
|
||||
@@ -259,6 +260,11 @@ export async function doctorCommand(
|
||||
|
||||
noteWorkspaceStatus(cfg);
|
||||
|
||||
// Check and fix shell completion
|
||||
await doctorShellCompletion(runtime, prompter, {
|
||||
nonInteractive: options.nonInteractive,
|
||||
});
|
||||
|
||||
const { healthOk } = await checkGatewayHealth({
|
||||
runtime,
|
||||
cfg,
|
||||
|
||||
@@ -96,4 +96,96 @@ describe("onboard (non-interactive): Cloudflare AI Gateway", () => {
|
||||
process.env.OPENCLAW_GATEWAY_PASSWORD = prev.password;
|
||||
}
|
||||
}, 60_000);
|
||||
|
||||
it("infers auth choice from API key flags", async () => {
|
||||
const prev = {
|
||||
home: process.env.HOME,
|
||||
stateDir: process.env.OPENCLAW_STATE_DIR,
|
||||
configPath: process.env.OPENCLAW_CONFIG_PATH,
|
||||
skipChannels: process.env.OPENCLAW_SKIP_CHANNELS,
|
||||
skipGmail: process.env.OPENCLAW_SKIP_GMAIL_WATCHER,
|
||||
skipCron: process.env.OPENCLAW_SKIP_CRON,
|
||||
skipCanvas: process.env.OPENCLAW_SKIP_CANVAS_HOST,
|
||||
token: process.env.OPENCLAW_GATEWAY_TOKEN,
|
||||
password: process.env.OPENCLAW_GATEWAY_PASSWORD,
|
||||
};
|
||||
|
||||
process.env.OPENCLAW_SKIP_CHANNELS = "1";
|
||||
process.env.OPENCLAW_SKIP_GMAIL_WATCHER = "1";
|
||||
process.env.OPENCLAW_SKIP_CRON = "1";
|
||||
process.env.OPENCLAW_SKIP_CANVAS_HOST = "1";
|
||||
delete process.env.OPENCLAW_GATEWAY_TOKEN;
|
||||
delete process.env.OPENCLAW_GATEWAY_PASSWORD;
|
||||
|
||||
const tempHome = await fs.mkdtemp(path.join(os.tmpdir(), "openclaw-onboard-cf-gateway-infer-"));
|
||||
process.env.HOME = tempHome;
|
||||
process.env.OPENCLAW_STATE_DIR = tempHome;
|
||||
process.env.OPENCLAW_CONFIG_PATH = path.join(tempHome, "openclaw.json");
|
||||
vi.resetModules();
|
||||
|
||||
const runtime = {
|
||||
log: () => {},
|
||||
error: (msg: string) => {
|
||||
throw new Error(msg);
|
||||
},
|
||||
exit: (code: number) => {
|
||||
throw new Error(`exit:${code}`);
|
||||
},
|
||||
};
|
||||
|
||||
try {
|
||||
const { runNonInteractiveOnboarding } = await import("./onboard-non-interactive.js");
|
||||
await runNonInteractiveOnboarding(
|
||||
{
|
||||
nonInteractive: true,
|
||||
cloudflareAiGatewayAccountId: "cf-account-id",
|
||||
cloudflareAiGatewayGatewayId: "cf-gateway-id",
|
||||
cloudflareAiGatewayApiKey: "cf-gateway-test-key",
|
||||
skipHealth: true,
|
||||
skipChannels: true,
|
||||
skipSkills: true,
|
||||
json: true,
|
||||
},
|
||||
runtime,
|
||||
);
|
||||
|
||||
const { CONFIG_PATH } = await import("../config/config.js");
|
||||
const cfg = JSON.parse(await fs.readFile(CONFIG_PATH, "utf8")) as {
|
||||
auth?: {
|
||||
profiles?: Record<string, { provider?: string; mode?: string }>;
|
||||
};
|
||||
agents?: { defaults?: { model?: { primary?: string } } };
|
||||
};
|
||||
|
||||
expect(cfg.auth?.profiles?.["cloudflare-ai-gateway:default"]?.provider).toBe(
|
||||
"cloudflare-ai-gateway",
|
||||
);
|
||||
expect(cfg.auth?.profiles?.["cloudflare-ai-gateway:default"]?.mode).toBe("api_key");
|
||||
expect(cfg.agents?.defaults?.model?.primary).toBe("cloudflare-ai-gateway/claude-sonnet-4-5");
|
||||
|
||||
const { ensureAuthProfileStore } = await import("../agents/auth-profiles.js");
|
||||
const store = ensureAuthProfileStore();
|
||||
const profile = store.profiles["cloudflare-ai-gateway:default"];
|
||||
expect(profile?.type).toBe("api_key");
|
||||
if (profile?.type === "api_key") {
|
||||
expect(profile.provider).toBe("cloudflare-ai-gateway");
|
||||
expect(profile.key).toBe("cf-gateway-test-key");
|
||||
expect(profile.metadata).toEqual({
|
||||
accountId: "cf-account-id",
|
||||
gatewayId: "cf-gateway-id",
|
||||
});
|
||||
}
|
||||
} finally {
|
||||
await fs.rm(tempHome, { recursive: true, force: true });
|
||||
process.env.HOME = prev.home;
|
||||
process.env.OPENCLAW_STATE_DIR = prev.stateDir;
|
||||
process.env.OPENCLAW_CONFIG_PATH = prev.configPath;
|
||||
process.env.OPENCLAW_SKIP_CHANNELS = prev.skipChannels;
|
||||
process.env.OPENCLAW_SKIP_GMAIL_WATCHER = prev.skipGmail;
|
||||
process.env.OPENCLAW_SKIP_CRON = prev.skipCron;
|
||||
process.env.OPENCLAW_SKIP_CANVAS_HOST = prev.skipCanvas;
|
||||
process.env.OPENCLAW_GATEWAY_TOKEN = prev.token;
|
||||
process.env.OPENCLAW_GATEWAY_PASSWORD = prev.password;
|
||||
}
|
||||
}, 60_000);
|
||||
});
|
||||
|
||||
@@ -13,6 +13,7 @@ import {
|
||||
resolveControlUiLinks,
|
||||
waitForGatewayReachable,
|
||||
} from "../onboard-helpers.js";
|
||||
import { inferAuthChoiceFromFlags } from "./local/auth-choice-inference.js";
|
||||
import { applyNonInteractiveAuthChoice } from "./local/auth-choice.js";
|
||||
import { installGatewayDaemonNonInteractive } from "./local/daemon-install.js";
|
||||
import { applyNonInteractiveGatewayConfig } from "./local/gateway-config.js";
|
||||
@@ -49,7 +50,19 @@ export async function runNonInteractiveOnboardingLocal(params: {
|
||||
},
|
||||
};
|
||||
|
||||
const authChoice = opts.authChoice ?? "skip";
|
||||
const inferredAuthChoice = inferAuthChoiceFromFlags(opts);
|
||||
if (!opts.authChoice && inferredAuthChoice.matches.length > 1) {
|
||||
runtime.error(
|
||||
[
|
||||
"Multiple API key flags were provided for non-interactive onboarding.",
|
||||
"Use a single provider flag or pass --auth-choice explicitly.",
|
||||
`Flags: ${inferredAuthChoice.matches.map((match) => match.label).join(", ")}`,
|
||||
].join("\n"),
|
||||
);
|
||||
runtime.exit(1);
|
||||
return;
|
||||
}
|
||||
const authChoice = opts.authChoice ?? inferredAuthChoice.choice ?? "skip";
|
||||
const nextConfigAfterAuth = await applyNonInteractiveAuthChoice({
|
||||
nextConfig,
|
||||
authChoice,
|
||||
|
||||
@@ -0,0 +1,67 @@
|
||||
import type { AuthChoice, OnboardOptions } from "../../onboard-types.js";
|
||||
|
||||
type AuthChoiceFlag = {
|
||||
flag: keyof AuthChoiceFlagOptions;
|
||||
authChoice: AuthChoice;
|
||||
label: string;
|
||||
};
|
||||
|
||||
type AuthChoiceFlagOptions = Pick<
|
||||
OnboardOptions,
|
||||
| "anthropicApiKey"
|
||||
| "geminiApiKey"
|
||||
| "openaiApiKey"
|
||||
| "openrouterApiKey"
|
||||
| "aiGatewayApiKey"
|
||||
| "cloudflareAiGatewayApiKey"
|
||||
| "moonshotApiKey"
|
||||
| "kimiCodeApiKey"
|
||||
| "syntheticApiKey"
|
||||
| "veniceApiKey"
|
||||
| "zaiApiKey"
|
||||
| "xiaomiApiKey"
|
||||
| "minimaxApiKey"
|
||||
| "opencodeZenApiKey"
|
||||
>;
|
||||
|
||||
const AUTH_CHOICE_FLAG_MAP = [
|
||||
{ flag: "anthropicApiKey", authChoice: "apiKey", label: "--anthropic-api-key" },
|
||||
{ flag: "geminiApiKey", authChoice: "gemini-api-key", label: "--gemini-api-key" },
|
||||
{ flag: "openaiApiKey", authChoice: "openai-api-key", label: "--openai-api-key" },
|
||||
{ flag: "openrouterApiKey", authChoice: "openrouter-api-key", label: "--openrouter-api-key" },
|
||||
{ flag: "aiGatewayApiKey", authChoice: "ai-gateway-api-key", label: "--ai-gateway-api-key" },
|
||||
{
|
||||
flag: "cloudflareAiGatewayApiKey",
|
||||
authChoice: "cloudflare-ai-gateway-api-key",
|
||||
label: "--cloudflare-ai-gateway-api-key",
|
||||
},
|
||||
{ flag: "moonshotApiKey", authChoice: "moonshot-api-key", label: "--moonshot-api-key" },
|
||||
{ flag: "kimiCodeApiKey", authChoice: "kimi-code-api-key", label: "--kimi-code-api-key" },
|
||||
{ flag: "syntheticApiKey", authChoice: "synthetic-api-key", label: "--synthetic-api-key" },
|
||||
{ flag: "veniceApiKey", authChoice: "venice-api-key", label: "--venice-api-key" },
|
||||
{ flag: "zaiApiKey", authChoice: "zai-api-key", label: "--zai-api-key" },
|
||||
{ flag: "xiaomiApiKey", authChoice: "xiaomi-api-key", label: "--xiaomi-api-key" },
|
||||
{ flag: "minimaxApiKey", authChoice: "minimax-api", label: "--minimax-api-key" },
|
||||
{ flag: "opencodeZenApiKey", authChoice: "opencode-zen", label: "--opencode-zen-api-key" },
|
||||
] satisfies ReadonlyArray<AuthChoiceFlag>;
|
||||
|
||||
export type AuthChoiceInference = {
|
||||
choice?: AuthChoice;
|
||||
matches: AuthChoiceFlag[];
|
||||
};
|
||||
|
||||
// Infer auth choice from explicit provider API key flags.
|
||||
export function inferAuthChoiceFromFlags(opts: OnboardOptions): AuthChoiceInference {
|
||||
const matches = AUTH_CHOICE_FLAG_MAP.filter(({ flag }) => {
|
||||
const value = opts[flag];
|
||||
if (typeof value === "string") {
|
||||
return value.trim().length > 0;
|
||||
}
|
||||
return Boolean(value);
|
||||
});
|
||||
|
||||
return {
|
||||
choice: matches[0]?.authChoice,
|
||||
matches,
|
||||
};
|
||||
}
|
||||
@@ -1,3 +1,4 @@
|
||||
import type { HeartbeatEventPayload } from "../infra/heartbeat-events.js";
|
||||
import type { RuntimeEnv } from "../runtime.js";
|
||||
import { formatCliCommand } from "../cli/command-format.js";
|
||||
import { withProgress } from "../cli/progress.js";
|
||||
@@ -120,6 +121,14 @@ export async function statusCommand(
|
||||
}),
|
||||
)
|
||||
: undefined;
|
||||
const lastHeartbeat =
|
||||
opts.deep && gatewayReachable
|
||||
? await callGateway<HeartbeatEventPayload | null>({
|
||||
method: "last-heartbeat",
|
||||
params: {},
|
||||
timeoutMs: opts.timeoutMs,
|
||||
}).catch(() => null)
|
||||
: null;
|
||||
|
||||
const configChannel = normalizeUpdateChannel(cfg.update?.channel);
|
||||
const channelInfo = resolveEffectiveUpdateChannel({
|
||||
@@ -157,7 +166,7 @@ export async function statusCommand(
|
||||
nodeService: nodeDaemon,
|
||||
agents: agentStatus,
|
||||
securityAudit,
|
||||
...(health || usage ? { health, usage } : {}),
|
||||
...(health || usage || lastHeartbeat ? { health, usage, lastHeartbeat } : {}),
|
||||
},
|
||||
null,
|
||||
2,
|
||||
@@ -275,6 +284,21 @@ export async function statusCommand(
|
||||
.filter(Boolean);
|
||||
return parts.length > 0 ? parts.join(", ") : "disabled";
|
||||
})();
|
||||
const lastHeartbeatValue = (() => {
|
||||
if (!opts.deep) {
|
||||
return null;
|
||||
}
|
||||
if (!gatewayReachable) {
|
||||
return warn("unavailable");
|
||||
}
|
||||
if (!lastHeartbeat) {
|
||||
return muted("none");
|
||||
}
|
||||
const age = formatAge(Date.now() - lastHeartbeat.ts);
|
||||
const channel = lastHeartbeat.channel ?? "unknown";
|
||||
const accountLabel = lastHeartbeat.accountId ? `account ${lastHeartbeat.accountId}` : null;
|
||||
return [lastHeartbeat.status, `${age} ago`, channel, accountLabel].filter(Boolean).join(" · ");
|
||||
})();
|
||||
|
||||
const storeLabel =
|
||||
summary.sessions.paths.length > 1
|
||||
@@ -371,6 +395,7 @@ export async function statusCommand(
|
||||
{ Item: "Probes", Value: probesValue },
|
||||
{ Item: "Events", Value: eventsValue },
|
||||
{ Item: "Heartbeat", Value: heartbeatValue },
|
||||
...(lastHeartbeatValue ? [{ Item: "Last heartbeat", Value: lastHeartbeatValue }] : []),
|
||||
{
|
||||
Item: "Sessions",
|
||||
Value: `${summary.sessions.count} active · default ${defaults.model ?? "unknown"}${defaultCtx} · ${storeLabel}`,
|
||||
|
||||
Reference in New Issue
Block a user