Skip to content
Closed
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
2 changes: 1 addition & 1 deletion apps/desktop/src/main/services/ai/agentExecutor.ts
Original file line number Diff line number Diff line change
@@ -1,4 +1,4 @@
export type AgentProvider = "claude" | "codex" | "cursor";
export type AgentProvider = "claude" | "codex" | "cursor" | "droid";

export type AgentPermissionMode = "read-only" | "edit" | "full-auto";

Expand Down
48 changes: 45 additions & 3 deletions apps/desktop/src/main/services/ai/aiIntegrationService.ts
Original file line number Diff line number Diff line change
Expand Up @@ -10,7 +10,7 @@
getModelById,
getAvailableModels,
listModelDescriptorsForProvider,
MODEL_REGISTRY,

Check warning on line 13 in apps/desktop/src/main/services/ai/aiIntegrationService.ts

View workflow job for this annotation

GitHub Actions / lint-desktop

'MODEL_REGISTRY' is defined but never used. Allowed unused vars must match /^_/u
resolveModelAlias,
enrichModelRegistry,
} from "../../../shared/modelRegistry";
Expand All @@ -24,7 +24,9 @@
import type { CompactionFlushService } from "../memory/compactionFlushService";
import { discoverLocalModels } from "./localModelDiscovery";
import { discoverCursorCliModelDescriptors, clearCursorCliModelsCache } from "../chat/cursorModelsDiscovery";
import { discoverDroidCliModelDescriptors, clearDroidCliModelsCache } from "../chat/droidModelsDiscovery";
import { resolveCursorAgentExecutable } from "./cursorAgentExecutable";
import { resolveDroidExecutable } from "./droidExecutable";
import { buildProviderConnections } from "./providerConnectionStatus";
import { getProviderRuntimeHealthVersion, resetProviderRuntimeHealth } from "./providerRuntimeHealth";
import { probeClaudeRuntimeHealth, resetClaudeRuntimeProbeCache } from "./claudeRuntimeProbe";
Expand Down Expand Up @@ -61,15 +63,17 @@
claude: boolean;
codex: boolean;
cursor: boolean;
droid: boolean;
};
models: {
claude: AgentModelDescriptor[];
codex: AgentModelDescriptor[];
cursor: AgentModelDescriptor[];
droid: AgentModelDescriptor[];
};
detectedAuth?: Array<{
type: "cli-subscription" | "api-key" | "openrouter" | "local";
cli?: "claude" | "codex" | "cursor";
cli?: "claude" | "codex" | "cursor" | "droid";
provider?: string;
source?: "config" | "env" | "store";
path?: string;
Expand Down Expand Up @@ -259,11 +263,17 @@
return out;
}

function toCliAvailability(auth: DetectedAuth[]): { claude: boolean; codex: boolean; cursor: boolean } {
function toCliAvailability(auth: DetectedAuth[]): {
claude: boolean;
codex: boolean;
cursor: boolean;
droid: boolean;
} {
return {
claude: auth.some((entry) => entry.type === "cli-subscription" && entry.cli === "claude"),
codex: auth.some((entry) => entry.type === "cli-subscription" && entry.cli === "codex"),
cursor: auth.some((entry) => entry.type === "cli-subscription" && entry.cli === "cursor"),
droid: auth.some((entry) => entry.type === "cli-subscription" && entry.cli === "droid"),
};
}

Expand Down Expand Up @@ -386,7 +396,8 @@
args.providerConnections
&& (args.providerConnections.claude.authAvailable
|| args.providerConnections.codex.authAvailable
|| args.providerConnections.cursor.authAvailable)
|| args.providerConnections.cursor.authAvailable
|| args.providerConnections.droid.authAvailable)
) {
return "subscription";
}
Expand All @@ -408,10 +419,16 @@
const claude = statuses.find((entry) => entry.cli === "claude");
const codex = statuses.find((entry) => entry.cli === "codex");
const cursor = statuses.find((entry) => entry.cli === "cursor");
const droid = statuses.find((entry) => entry.cli === "droid");
const factoryKey = Boolean(process.env.FACTORY_API_KEY?.trim());
return {
claude: Boolean(claude?.installed && (claude.authenticated || !claude.verified)),
codex: Boolean(codex?.installed && (codex.authenticated || !codex.verified)),
cursor: Boolean(cursor?.installed && (cursor.authenticated || !cursor.verified)),
droid: Boolean(
factoryKey
|| (droid?.installed && (droid.authenticated || !droid.verified)),
),
};
};

Expand Down Expand Up @@ -447,6 +464,25 @@
}
}

const hasDroidCliAuth = auth.some(
(entry) =>
entry.type === "cli-subscription"
&& entry.cli === "droid"
&& entry.authenticated !== false,
);
if (hasDroidCliAuth) {
try {
const { path: droidPath } = resolveDroidExecutable({ auth });
const droidModels = await discoverDroidCliModelDescriptors(droidPath);
available = [
...available.filter((descriptor) => !(descriptor.family === "factory" && descriptor.isCliWrapped)),
...droidModels,
];
} catch {
// Droid CLI missing or model discovery failed — omit dynamic Droid list
}
}

const discoveredLocalModels = await discoverLocalModels(auth);
if (discoveredLocalModels.length === 0) {
return available;
Expand Down Expand Up @@ -834,6 +870,8 @@
family = "openai";
} else if (provider === "cursor") {
family = "cursor";
} else if (provider === "droid") {
family = "factory";
} else {
family = "anthropic";
}
Expand Down Expand Up @@ -903,6 +941,7 @@
resetProviderRuntimeHealth();
resetClaudeRuntimeProbeCache();
clearCursorCliModelsCache();
clearDroidCliModelsCache();
modelListCache.clear();
runtimeHealthVersion = getProviderRuntimeHealthVersion();
}
Expand All @@ -925,12 +964,14 @@
claude: providerConnections.claude.runtimeAvailable,
codex: providerConnections.codex.runtimeAvailable,
cursor: providerConnections.cursor.runtimeAvailable,
droid: providerConnections.droid.runtimeAvailable,
};
const runtimeFilteredAvailable = available.filter((descriptor) => {
if (!descriptor.isCliWrapped) return true;
if (descriptor.family === "anthropic") return providerConnections.claude.runtimeAvailable;
if (descriptor.family === "openai") return providerConnections.codex.runtimeAvailable;
if (descriptor.family === "cursor") return providerConnections.cursor.runtimeAvailable;
if (descriptor.family === "factory") return providerConnections.droid.runtimeAvailable;
return true;
});
const result: AiIntegrationStatus = {
Expand All @@ -940,6 +981,7 @@
claude: availability.claude ? await listModels("claude") : [],
codex: availability.codex ? await listModels("codex") : [],
cursor: availability.cursor ? await listModels("cursor") : [],
droid: availability.droid ? await listModels("droid") : [],
},
detectedAuth: redactDetectedAuth(auth, cliStatuses),
providerConnections,
Expand Down
123 changes: 118 additions & 5 deletions apps/desktop/src/main/services/ai/authDetector.ts
Original file line number Diff line number Diff line change
Expand Up @@ -2,13 +2,17 @@
// Auth Detector — discovers available authentication methods
// ---------------------------------------------------------------------------

import { readFile } from "node:fs/promises";
import path from "node:path";
import { homedir } from "node:os";
import { spawnAsync } from "../shared/utils";
import {
augmentProcessPathWithShellAndKnownCliDirs,
resolveExecutableFromKnownLocations,
} from "./cliExecutableResolver";
import { resolveDroidExecutable } from "./droidExecutable";

type CliName = "claude" | "codex" | "cursor";
type CliName = "claude" | "codex" | "cursor" | "droid";

type ApiKeySource = "config" | "env" | "store";

Expand All @@ -34,7 +38,7 @@
export type DetectedAuth =
| {
type: "cli-subscription";
cli: CliName;
cli: "claude" | "codex" | "cursor" | "droid";
path: string;
authenticated: boolean;
verified: boolean;
Expand Down Expand Up @@ -62,10 +66,12 @@
["status", "--json"],
["status"],
],
droid: [["--version"], ["-V"], ["version"]],
};

function cliSpawnCommand(cli: CliName): string {
return cli === "cursor" ? "agent" : cli;
if (cli === "cursor") return "agent";
return cli;
}

const AUTH_INDICATORS = [
Expand Down Expand Up @@ -98,7 +104,7 @@
/run .*login/i,
];

const UNAUTH_INDICATORS = [...STRONG_UNAUTH_INDICATORS, ...WEAK_UNAUTH_INDICATORS];

Check warning on line 107 in apps/desktop/src/main/services/ai/authDetector.ts

View workflow job for this annotation

GitHub Actions / lint-desktop

'UNAUTH_INDICATORS' is assigned a value but never used. Allowed unused vars must match /^_/u

const UNSUPPORTED_INDICATORS = [
/unknown command/i,
Expand Down Expand Up @@ -356,6 +362,76 @@
return { authenticated: false, verified: false, paidPlan: false };
}

async function inspectDroidCliPresence(command: string): Promise<{
installed: boolean;
authenticated: boolean;
verified: boolean;
}> {
const probes = CLI_AUTH_PROBES.droid ?? [];
let sawVersionOk = false;
for (const args of probes) {
try {
const result = await spawnAsync(command, args, { timeout: 8_000 });
const combined = `${result.stdout ?? ""}\n${result.stderr ?? ""}`.trim();
if (result.status === 0 && combined.length > 0) {
sawVersionOk = true;
break;
}
if (result.status === 0) {
sawVersionOk = true;
break;
}
} catch {
// try next probe
}
}
if (!sawVersionOk) {
return { installed: false, authenticated: false, verified: false };
}

if (process.env.FACTORY_API_KEY?.trim()) {
return { installed: true, authenticated: true, verified: true };
}

const settingsPath = path.join(homedir(), ".factory", "settings.json");
try {
const raw = await readFile(settingsPath, "utf8");
const parsed = JSON.parse(raw) as Record<string, unknown>;
const tokenLike =
typeof parsed.accessToken === "string" && parsed.accessToken.trim().length > 0
? parsed.accessToken
: typeof parsed.token === "string" && parsed.token.trim().length > 0
? parsed.token
: null;
if (tokenLike) {
return { installed: true, authenticated: true, verified: true };
}
} catch {
// missing or unreadable settings — not authenticated via file
}

const authProbes: string[][] = [
["account", "status"],
["whoami"],
];
for (const args of authProbes) {
try {
const result = await spawnAsync(command, args, { timeout: 12_000 });
const combined = `${result.stdout ?? ""}\n${result.stderr ?? ""}`.trim();
if (hasPattern(combined, STRONG_UNAUTH_INDICATORS)) {
return { installed: true, authenticated: false, verified: true };
}
if (hasPattern(combined, AUTH_INDICATORS)) {
return { installed: true, authenticated: true, verified: true };
}
Comment thread
coderabbitai[bot] marked this conversation as resolved.
} catch {
// try next probe
}
}

return { installed: true, authenticated: false, verified: false };
}
Comment thread
coderabbitai[bot] marked this conversation as resolved.

const ENV_KEY_MAP: Record<string, string> = {
ANTHROPIC_API_KEY: "anthropic",
OPENAI_API_KEY: "openai",
Expand Down Expand Up @@ -701,7 +777,7 @@
await refreshProcessPathFromShell();
}

const cliChecks: CliName[] = ["claude", "codex", "cursor"];
const cliChecks: CliName[] = ["claude", "codex", "cursor", "droid"];

// Probe all CLIs in parallel
const statuses = await Promise.all(
Expand Down Expand Up @@ -730,6 +806,26 @@
paidPlan: auth.paidPlan,
};
}
if (cli === "droid") {
const resolved = resolveDroidExecutable({ env: process.env });
if (resolved.source === "fallback-command") {
return {
cli,
installed: false,
path: null,
authenticated: false,
verified: false,
};
}
const auth = await inspectDroidCliPresence(resolved.path);
return {
cli,
installed: auth.installed,
path: resolved.path,
authenticated: auth.authenticated,
verified: auth.verified,
};
}
const auth = await inspectCliAuthentication(cli, cmd);
return {
cli,
Expand All @@ -754,7 +850,7 @@
// 1. CLI subscriptions (connected and authenticated)
const cliStatuses = await detectCliAuthStatuses(options);
for (const cli of cliStatuses) {
if (cli.cli !== "claude" && cli.cli !== "codex" && cli.cli !== "cursor") continue;
if (cli.cli !== "claude" && cli.cli !== "codex" && cli.cli !== "cursor" && cli.cli !== "droid") continue;
if (!cli.installed) continue;
if (!cli.authenticated && cli.verified) continue;
results.push({
Expand Down Expand Up @@ -783,6 +879,23 @@
}
}

const factoryKey = process.env.FACTORY_API_KEY?.trim();
if (factoryKey) {
const hasDroidCli = results.some((r) => r.type === "cli-subscription" && r.cli === "droid");
if (!hasDroidCli) {
const resolved = resolveDroidExecutable({ env: process.env, auth: results });
if (resolved.source !== "fallback-command") {
results.push({
type: "cli-subscription",
cli: "droid",
path: resolved.path,
authenticated: true,
verified: true,
});
}
}
}
Comment thread
coderabbitai[bot] marked this conversation as resolved.

// 2. API keys from config + secure local store
const mergedApiKeys = new Map<string, { key: string; source: Exclude<ApiKeySource, "env"> }>();
const normalizedConfig = normalizeApiKeys(configApiKeys);
Expand Down
44 changes: 44 additions & 0 deletions apps/desktop/src/main/services/ai/droidExecutable.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,44 @@
import type { DetectedAuth } from "./authDetector";
import { resolveExecutableFromKnownLocations } from "./cliExecutableResolver";

export type DroidExecutableResolution = {
path: string;
source: "auth" | "path" | "common-dir" | "fallback-command";
};

function findDroidAuthPath(auth?: DetectedAuth[]): string | null {
for (const entry of auth ?? []) {
if (entry.type !== "cli-subscription" || entry.cli !== "droid") continue;
const candidate = entry.path.trim();
if (candidate) return candidate;
}
return null;
}

/** Resolves the Factory Droid CLI binary (`droid`). */
export function resolveDroidExecutable(args?: {
auth?: DetectedAuth[];
env?: NodeJS.ProcessEnv;
}): DroidExecutableResolution {
const env = args?.env ?? process.env;

const envPath = env.DROID_EXECUTABLE?.trim() || env.FACTORY_DROID_EXECUTABLE?.trim();
if (envPath) {
return { path: envPath, source: "path" };
}

const authPath = findDroidAuthPath(args?.auth);
if (authPath) {
return { path: authPath, source: "auth" };
}

const resolved = resolveExecutableFromKnownLocations("droid", env);
if (resolved) {
return {
path: resolved.path,
source: resolved.source === "path" ? "path" : "common-dir",
};
}

return { path: "droid", source: "fallback-command" };
}
Loading
Loading