Skip to content
Closed
Show file tree
Hide file tree
Changes from 6 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
124 changes: 119 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,77 @@
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 (result.status !== 0) continue;
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 +778,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 +807,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 +851,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 +880,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