mirror of
https://github.com/rowboatlabs/rowboat.git
synced 2026-06-09 19:45:17 +02:00
feat: add code mode settings tab with agent install/auth checks
This commit is contained in:
parent
530d807fab
commit
ad1b949262
10 changed files with 497 additions and 23 deletions
|
|
@ -30,6 +30,9 @@ import { listGatewayModels } from '@x/core/dist/models/gateway.js';
|
|||
import type { IModelConfigRepo } from '@x/core/dist/models/repo.js';
|
||||
import type { IOAuthRepo } from '@x/core/dist/auth/repo.js';
|
||||
import { IGranolaConfigRepo } from '@x/core/dist/knowledge/granola/repo.js';
|
||||
import { ICodeModeConfigRepo } from '@x/core/dist/code-mode/repo.js';
|
||||
import { checkCodeModeAgentStatus } from '@x/core/dist/code-mode/status.js';
|
||||
import { invalidateCopilotInstructionsCache } from '@x/core/dist/application/assistant/instructions.js';
|
||||
import { triggerSync as triggerGranolaSync } from '@x/core/dist/knowledge/granola/sync.js';
|
||||
import { ISlackConfigRepo } from '@x/core/dist/slack/repo.js';
|
||||
import { isOnboardingComplete, markOnboardingComplete } from '@x/core/dist/config/note_creation_config.js';
|
||||
|
|
@ -600,6 +603,20 @@ export function setupIpcHandlers() {
|
|||
const config = await repo.getConfig();
|
||||
return { enabled: config.enabled };
|
||||
},
|
||||
'codeMode:getConfig': async () => {
|
||||
const repo = container.resolve<ICodeModeConfigRepo>('codeModeConfigRepo');
|
||||
const config = await repo.getConfig();
|
||||
return { enabled: config.enabled };
|
||||
},
|
||||
'codeMode:setConfig': async (_event, args) => {
|
||||
const repo = container.resolve<ICodeModeConfigRepo>('codeModeConfigRepo');
|
||||
await repo.setConfig({ enabled: args.enabled });
|
||||
invalidateCopilotInstructionsCache();
|
||||
return { success: true };
|
||||
},
|
||||
'codeMode:checkAgentStatus': async () => {
|
||||
return await checkCodeModeAgentStatus();
|
||||
},
|
||||
'granola:setConfig': async (_event, args) => {
|
||||
const repo = container.resolve<IGranolaConfigRepo>('granolaConfigRepo');
|
||||
await repo.setConfig({ enabled: args.enabled });
|
||||
|
|
|
|||
|
|
@ -177,6 +177,7 @@ function ChatInputInner({
|
|||
const [workDir, setWorkDir] = useState<string | null>(null)
|
||||
const [codingAgent, setCodingAgent] = useState<'claude' | 'codex'>('claude')
|
||||
const [codeModeEnabled, setCodeModeEnabled] = useState(false)
|
||||
const [codeModeFeatureEnabled, setCodeModeFeatureEnabled] = useState(false)
|
||||
|
||||
// When a run exists, freeze the dropdown to the run's resolved model+provider.
|
||||
useEffect(() => {
|
||||
|
|
@ -259,6 +260,25 @@ function ChatInputInner({
|
|||
return () => window.removeEventListener('models-config-changed', handler)
|
||||
}, [loadModelConfig])
|
||||
|
||||
// Load the global code-mode feature flag (from settings) and stay in sync.
|
||||
useEffect(() => {
|
||||
const load = () => {
|
||||
window.ipc.invoke('codeMode:getConfig', null)
|
||||
.then((r) => setCodeModeFeatureEnabled(r.enabled))
|
||||
.catch(() => setCodeModeFeatureEnabled(false))
|
||||
}
|
||||
load()
|
||||
window.addEventListener('code-mode-config-changed', load)
|
||||
return () => window.removeEventListener('code-mode-config-changed', load)
|
||||
}, [])
|
||||
|
||||
// If the feature is turned off in settings, also turn off any per-conversation chip.
|
||||
useEffect(() => {
|
||||
if (!codeModeFeatureEnabled && codeModeEnabled) {
|
||||
setCodeModeEnabled(false)
|
||||
}
|
||||
}, [codeModeFeatureEnabled, codeModeEnabled])
|
||||
|
||||
// Listen for coding-agent runs that were triggered without the explicit code-mode
|
||||
// toggle. App.tsx dispatches this when it sees an acpx executeCommand fire. We
|
||||
// flip the pill on with the detected agent so the UI reflects what's happening.
|
||||
|
|
@ -712,7 +732,7 @@ function ChatInputInner({
|
|||
</Tooltip>
|
||||
)
|
||||
)}
|
||||
{codeModeEnabled ? (
|
||||
{codeModeFeatureEnabled && (codeModeEnabled ? (
|
||||
<div className="flex h-7 shrink-0 items-center rounded-full bg-secondary text-xs font-medium text-foreground">
|
||||
<Tooltip>
|
||||
<TooltipTrigger asChild>
|
||||
|
|
@ -757,7 +777,7 @@ function ChatInputInner({
|
|||
</TooltipTrigger>
|
||||
<TooltipContent side="top">Use a coding agent (Claude Code or Codex)</TooltipContent>
|
||||
</Tooltip>
|
||||
)}
|
||||
))}
|
||||
<div className="flex-1" />
|
||||
{lockedModel ? (
|
||||
<span
|
||||
|
|
|
|||
|
|
@ -2,7 +2,7 @@
|
|||
|
||||
import * as React from "react"
|
||||
import { useState, useEffect, useCallback, useMemo } from "react"
|
||||
import { Server, Key, Shield, Palette, Monitor, Sun, Moon, Loader2, CheckCircle2, Plus, X, Wrench, Search, ChevronRight, Link2, Tags, Mail, BookOpen, User, Plug, HelpCircle, MessageCircle, Bug } from "lucide-react"
|
||||
import { Server, Key, Shield, Palette, Monitor, Sun, Moon, Loader2, CheckCircle2, Plus, X, Wrench, Search, ChevronRight, Link2, Tags, Mail, BookOpen, User, Plug, HelpCircle, MessageCircle, Bug, Terminal, AlertTriangle, RefreshCw } from "lucide-react"
|
||||
|
||||
import {
|
||||
Dialog,
|
||||
|
|
@ -26,7 +26,7 @@ import { toast } from "sonner"
|
|||
import { AccountSettings } from "@/components/settings/account-settings"
|
||||
import { ConnectedAccountsSettings } from "@/components/settings/connected-accounts-settings"
|
||||
|
||||
type ConfigTab = "account" | "connections" | "models" | "mcp" | "security" | "appearance" | "note-tagging" | "help"
|
||||
type ConfigTab = "account" | "connections" | "models" | "mcp" | "security" | "code-mode" | "appearance" | "note-tagging" | "help"
|
||||
|
||||
interface TabConfig {
|
||||
id: ConfigTab
|
||||
|
|
@ -70,6 +70,12 @@ const tabs: TabConfig[] = [
|
|||
path: "config/security.json",
|
||||
description: "Configure allowed shell commands",
|
||||
},
|
||||
{
|
||||
id: "code-mode",
|
||||
label: "Code Mode",
|
||||
icon: Terminal,
|
||||
description: "Delegate coding tasks to Claude Code or Codex",
|
||||
},
|
||||
{
|
||||
id: "appearance",
|
||||
label: "Appearance",
|
||||
|
|
@ -1648,6 +1654,189 @@ function NoteTaggingSettings({ dialogOpen }: { dialogOpen: boolean }) {
|
|||
)
|
||||
}
|
||||
|
||||
// --- Code Mode Settings ---
|
||||
|
||||
type AgentStatus = { installed: boolean; signedIn: boolean }
|
||||
type CodeModeAgentStatus = { claude: AgentStatus; codex: AgentStatus }
|
||||
|
||||
function AgentStatusRow({
|
||||
name,
|
||||
installLink,
|
||||
status,
|
||||
}: {
|
||||
name: string
|
||||
installLink: string
|
||||
status: AgentStatus | null
|
||||
}) {
|
||||
const ready = status?.installed && status?.signedIn
|
||||
return (
|
||||
<div className="rounded-md border px-3 py-2.5 flex items-center gap-3">
|
||||
<Terminal className="size-4 text-muted-foreground shrink-0" />
|
||||
<div className="flex-1 min-w-0">
|
||||
<div className="text-sm font-medium">{name}</div>
|
||||
<div className="text-xs text-muted-foreground mt-0.5 flex items-center gap-3">
|
||||
<span className={cn("inline-flex items-center gap-1", status?.installed ? "text-green-600" : "text-muted-foreground")}>
|
||||
{status?.installed ? <CheckCircle2 className="size-3" /> : <X className="size-3" />}
|
||||
Installed
|
||||
</span>
|
||||
<span className={cn("inline-flex items-center gap-1", status?.signedIn ? "text-green-600" : "text-muted-foreground")}>
|
||||
{status?.signedIn ? <CheckCircle2 className="size-3" /> : <X className="size-3" />}
|
||||
Signed in
|
||||
</span>
|
||||
</div>
|
||||
</div>
|
||||
{ready ? (
|
||||
<span className="rounded-full bg-green-500/10 px-2 py-0.5 text-[10px] font-medium leading-none text-green-600">
|
||||
Ready
|
||||
</span>
|
||||
) : (
|
||||
<a
|
||||
href={installLink}
|
||||
target="_blank"
|
||||
rel="noopener noreferrer"
|
||||
className="text-xs text-primary hover:underline shrink-0"
|
||||
>
|
||||
Install & sign in
|
||||
</a>
|
||||
)}
|
||||
</div>
|
||||
)
|
||||
}
|
||||
|
||||
function CodeModeSettings({ dialogOpen }: { dialogOpen: boolean }) {
|
||||
const [enabled, setEnabled] = useState(false)
|
||||
const [loading, setLoading] = useState(true)
|
||||
const [saving, setSaving] = useState(false)
|
||||
const [status, setStatus] = useState<CodeModeAgentStatus | null>(null)
|
||||
const [statusLoading, setStatusLoading] = useState(false)
|
||||
|
||||
const loadStatus = useCallback(async () => {
|
||||
setStatusLoading(true)
|
||||
try {
|
||||
const result = await window.ipc.invoke("codeMode:checkAgentStatus", null)
|
||||
setStatus(result)
|
||||
} catch {
|
||||
setStatus(null)
|
||||
} finally {
|
||||
setStatusLoading(false)
|
||||
}
|
||||
}, [])
|
||||
|
||||
useEffect(() => {
|
||||
if (!dialogOpen) return
|
||||
let cancelled = false
|
||||
async function load() {
|
||||
setLoading(true)
|
||||
try {
|
||||
const result = await window.ipc.invoke("codeMode:getConfig", null)
|
||||
if (!cancelled) setEnabled(result.enabled)
|
||||
} catch {
|
||||
if (!cancelled) setEnabled(false)
|
||||
} finally {
|
||||
if (!cancelled) setLoading(false)
|
||||
}
|
||||
}
|
||||
load()
|
||||
loadStatus()
|
||||
return () => { cancelled = true }
|
||||
}, [dialogOpen, loadStatus])
|
||||
|
||||
const handleToggle = useCallback(async (next: boolean) => {
|
||||
setSaving(true)
|
||||
setEnabled(next)
|
||||
try {
|
||||
await window.ipc.invoke("codeMode:setConfig", { enabled: next })
|
||||
window.dispatchEvent(new Event("code-mode-config-changed"))
|
||||
toast.success(next ? "Code mode enabled" : "Code mode disabled")
|
||||
} catch {
|
||||
setEnabled(!next)
|
||||
toast.error("Failed to update code mode")
|
||||
} finally {
|
||||
setSaving(false)
|
||||
}
|
||||
}, [])
|
||||
|
||||
const anyReady = status?.claude.installed && status?.claude.signedIn
|
||||
|| status?.codex.installed && status?.codex.signedIn
|
||||
|
||||
if (loading) {
|
||||
return (
|
||||
<div className="h-full flex items-center justify-center text-muted-foreground text-sm">
|
||||
<Loader2 className="size-4 animate-spin mr-2" />
|
||||
Loading...
|
||||
</div>
|
||||
)
|
||||
}
|
||||
|
||||
return (
|
||||
<div className="space-y-5">
|
||||
<div className="space-y-2 text-sm text-muted-foreground leading-relaxed">
|
||||
<p>
|
||||
<strong className="text-foreground">Code mode</strong> lets the assistant delegate coding tasks
|
||||
to <strong className="text-foreground">Claude Code</strong> or <strong className="text-foreground">Codex</strong> running
|
||||
on your machine. Pick the agent inline from the composer; the assistant calls it via
|
||||
<code className="mx-1 rounded bg-muted px-1 py-0.5 text-[11px]">acpx</code>
|
||||
and streams results back into chat.
|
||||
</p>
|
||||
<p>
|
||||
Requires an active <strong className="text-foreground">Claude Code</strong> subscription or
|
||||
a <strong className="text-foreground">ChatGPT/Codex</strong> subscription. You can have one or both.
|
||||
</p>
|
||||
</div>
|
||||
|
||||
<div className="space-y-2">
|
||||
<div className="flex items-center justify-between">
|
||||
<span className="text-xs font-medium text-muted-foreground uppercase tracking-wider">Agent status</span>
|
||||
<button
|
||||
onClick={() => { void loadStatus() }}
|
||||
disabled={statusLoading}
|
||||
className="inline-flex items-center gap-1 text-xs text-muted-foreground hover:text-foreground transition-colors"
|
||||
>
|
||||
{statusLoading ? <Loader2 className="size-3 animate-spin" /> : <RefreshCw className="size-3" />}
|
||||
Re-check
|
||||
</button>
|
||||
</div>
|
||||
<div className="space-y-2">
|
||||
<AgentStatusRow
|
||||
name="Claude Code"
|
||||
installLink="https://claude.ai/code"
|
||||
status={status?.claude ?? null}
|
||||
/>
|
||||
<AgentStatusRow
|
||||
name="Codex"
|
||||
installLink="https://developers.openai.com/codex/cli"
|
||||
status={status?.codex ?? null}
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div className="rounded-md border px-3 py-3 flex items-start gap-3">
|
||||
<div className="flex-1 min-w-0">
|
||||
<div className="text-sm font-medium">Enable code mode</div>
|
||||
<div className="text-xs text-muted-foreground mt-0.5">
|
||||
Shows the code mode chip in the composer and lets the assistant delegate to your installed agents.
|
||||
</div>
|
||||
</div>
|
||||
<Switch
|
||||
checked={enabled}
|
||||
onCheckedChange={handleToggle}
|
||||
disabled={saving}
|
||||
/>
|
||||
</div>
|
||||
|
||||
{enabled && status && !anyReady && (
|
||||
<div className="rounded-md border border-amber-500/40 bg-amber-50/60 dark:bg-amber-950/20 px-3 py-2.5 flex items-start gap-2 text-xs">
|
||||
<AlertTriangle className="size-4 text-amber-600 dark:text-amber-500 shrink-0 mt-0.5" />
|
||||
<div className="text-amber-900 dark:text-amber-200">
|
||||
Neither Claude Code nor Codex is ready. Install at least one and sign in with a subscription
|
||||
account, then click Re-check.
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
)
|
||||
}
|
||||
|
||||
// --- Main Settings Dialog ---
|
||||
|
||||
export function SettingsDialog({ children, defaultTab = "account", open: controlledOpen, onOpenChange }: SettingsDialogProps) {
|
||||
|
|
@ -1695,7 +1884,7 @@ export function SettingsDialog({ children, defaultTab = "account", open: control
|
|||
}
|
||||
|
||||
const loadConfig = useCallback(async (tab: ConfigTab) => {
|
||||
if (tab === "appearance" || tab === "models" || tab === "note-tagging" || tab === "account" || tab === "connections" || tab === "help") return
|
||||
if (tab === "appearance" || tab === "models" || tab === "note-tagging" || tab === "account" || tab === "connections" || tab === "help" || tab === "code-mode") return
|
||||
const tabConfig = tabs.find((t) => t.id === tab)!
|
||||
if (!tabConfig.path) return
|
||||
setLoading(true)
|
||||
|
|
@ -1803,7 +1992,7 @@ export function SettingsDialog({ children, defaultTab = "account", open: control
|
|||
</div>
|
||||
|
||||
{/* Content */}
|
||||
<div className={cn("flex-1 p-4 min-h-0", (activeTab === "models" || activeTab === "connections" || activeTab === "account") ? "overflow-y-auto" : activeTab === "note-tagging" ? "overflow-hidden flex flex-col" : "overflow-hidden")}>
|
||||
<div className={cn("flex-1 p-4 min-h-0", (activeTab === "models" || activeTab === "connections" || activeTab === "account" || activeTab === "code-mode") ? "overflow-y-auto" : activeTab === "note-tagging" ? "overflow-hidden flex flex-col" : "overflow-hidden")}>
|
||||
{activeTab === "account" ? (
|
||||
<AccountSettings dialogOpen={open} />
|
||||
) : activeTab === "connections" ? (
|
||||
|
|
@ -1828,6 +2017,8 @@ export function SettingsDialog({ children, defaultTab = "account", open: control
|
|||
<AppearanceSettings />
|
||||
) : activeTab === "help" ? (
|
||||
<HelpSettings />
|
||||
) : activeTab === "code-mode" ? (
|
||||
<CodeModeSettings dialogOpen={open} />
|
||||
) : loading ? (
|
||||
<div className="h-full flex items-center justify-center text-muted-foreground text-sm">
|
||||
Loading...
|
||||
|
|
|
|||
|
|
@ -3,6 +3,8 @@ import { getRuntimeContext, getRuntimeContextPrompt } from "./runtime-context.js
|
|||
import { composioAccountsRepo } from "../../composio/repo.js";
|
||||
import { isConfigured as isComposioConfigured } from "../../composio/client.js";
|
||||
import { CURATED_TOOLKITS } from "@x/shared/dist/composio.js";
|
||||
import container from "../../di/container.js";
|
||||
import type { ICodeModeConfigRepo } from "../../code-mode/repo.js";
|
||||
|
||||
const runtimeContextPrompt = getRuntimeContextPrompt(getRuntimeContext());
|
||||
|
||||
|
|
@ -29,7 +31,7 @@ Load the \`composio-integration\` skill when the user asks to interact with any
|
|||
`;
|
||||
}
|
||||
|
||||
function buildStaticInstructions(composioEnabled: boolean, catalog: string): string {
|
||||
function buildStaticInstructions(composioEnabled: boolean, catalog: string, codeModeEnabled: boolean = true): string {
|
||||
// Conditionally include Composio-related instruction sections
|
||||
const emailDraftSuffix = composioEnabled
|
||||
? ` Do NOT load this skill for reading, fetching, or checking emails — use the \`composio-integration\` skill for that instead.`
|
||||
|
|
@ -80,7 +82,9 @@ ${thirdPartyBlock}**Meeting Prep:** When users ask you to prepare for a meeting,
|
|||
|
||||
**Document Collaboration:** When users ask you to work on a document, collaborate on writing, create a new document, edit/refine existing notes, or say things like "let's work on [X]", "help me write [X]", "create a doc for [X]", or "let's draft [X]", you MUST load the \`doc-collab\` skill first. **This applies even for small one-off edits** — the skill carries the canonical *terse-and-scannable* writing style for the knowledge base, and that style applies whether you're authoring a fresh note or fixing a single section. Load it before writing anything into a note.
|
||||
|
||||
**Code with Agents:** When users ask you to write code, build a project, create a script, fix a bug, or do any software development task — **including simple things like "create a .c file" or "write a hello-world in Python"** — your FIRST action MUST be \`loadSkill('code-with-agents')\`. Do NOT reach for \`executeCommand\` (PowerShell / bash / shell) or any workspace file tool to do code work yourself before loading this skill. The skill decides whether to delegate to Claude Code / Codex (via acpx) or hand control back to you, and it presents the user a one-click choice when needed. Paths outside the Rowboat workspace root (e.g. \`G:/...\`, \`~/projects/...\`) are NORMAL for coding tasks — do NOT raise "outside workspace" concerns or fall back to your own tools.
|
||||
${codeModeEnabled
|
||||
? `**Code with Agents:** When users ask you to write code, build a project, create a script, fix a bug, or do any software development task — **including simple things like "create a .c file" or "write a hello-world in Python"** — your FIRST action MUST be \`loadSkill('code-with-agents')\`. Do NOT reach for \`executeCommand\` (PowerShell / bash / shell) or any workspace file tool to do code work yourself before loading this skill. The skill decides whether to delegate to Claude Code / Codex (via acpx) or hand control back to you, and it presents the user a one-click choice when needed. Paths outside the Rowboat workspace root (e.g. \`G:/...\`, \`~/projects/...\`) are NORMAL for coding tasks — do NOT raise "outside workspace" concerns or fall back to your own tools.`
|
||||
: `**Code with Agents (disabled):** Code mode is currently OFF in the user's settings. Do NOT load \`code-with-agents\` and do NOT call acpx. Handle coding requests yourself with your normal tools if you can. After answering, add a final line letting the user know they can delegate coding to Claude Code or Codex by enabling Code Mode in Settings → Code Mode.`}
|
||||
|
||||
**App Control:** When users ask you to open notes, show the bases or graph view, filter or search notes, or manage saved views, load the \`app-navigation\` skill first. It provides structured guidance for navigating the app UI and controlling the knowledge base view.
|
||||
|
||||
|
|
@ -312,30 +316,29 @@ Never output raw file paths in plain text when they could be wrapped in a filepa
|
|||
/** Keep backward-compatible export for any external consumers */
|
||||
export const CopilotInstructions = buildStaticInstructions(true, skillCatalog);
|
||||
|
||||
/**
|
||||
* Cached Composio instructions. Invalidated by calling invalidateCopilotInstructionsCache().
|
||||
*/
|
||||
let cachedInstructions: string | null = null;
|
||||
|
||||
/**
|
||||
* Invalidate the cached instructions so the next buildCopilotInstructions() call
|
||||
* regenerates the Composio section. Call this after connecting/disconnecting a toolkit.
|
||||
*/
|
||||
export function invalidateCopilotInstructionsCache(): void {
|
||||
cachedInstructions = null;
|
||||
}
|
||||
|
||||
/**
|
||||
* Build full copilot instructions with dynamic Composio tools section.
|
||||
* Results are cached and reused until invalidated via invalidateCopilotInstructionsCache().
|
||||
*/
|
||||
export async function buildCopilotInstructions(): Promise<string> {
|
||||
if (cachedInstructions !== null) return cachedInstructions;
|
||||
const composioEnabled = await isComposioConfigured();
|
||||
const catalog = composioEnabled
|
||||
? skillCatalog
|
||||
: buildSkillCatalog({ excludeIds: ['composio-integration'] });
|
||||
const baseInstructions = buildStaticInstructions(composioEnabled, catalog);
|
||||
let codeModeEnabled = false;
|
||||
try {
|
||||
const repo = container.resolve<ICodeModeConfigRepo>('codeModeConfigRepo');
|
||||
codeModeEnabled = (await repo.getConfig()).enabled;
|
||||
} catch {
|
||||
// repo unavailable — default to disabled
|
||||
}
|
||||
const excludeIds: string[] = [];
|
||||
if (!composioEnabled) excludeIds.push('composio-integration');
|
||||
if (!codeModeEnabled) excludeIds.push('code-with-agents');
|
||||
const catalog = excludeIds.length > 0
|
||||
? buildSkillCatalog({ excludeIds })
|
||||
: skillCatalog;
|
||||
const baseInstructions = buildStaticInstructions(composioEnabled, catalog, codeModeEnabled);
|
||||
const composioPrompt = await getComposioToolsPrompt();
|
||||
cachedInstructions = composioPrompt
|
||||
? baseInstructions + '\n' + composioPrompt
|
||||
|
|
|
|||
3
apps/x/packages/core/src/code-mode/index.ts
Normal file
3
apps/x/packages/core/src/code-mode/index.ts
Normal file
|
|
@ -0,0 +1,3 @@
|
|||
export { CodeModeConfig, CodeModeAgentStatus, AgentStatus } from './types.js';
|
||||
export { FSCodeModeConfigRepo, type ICodeModeConfigRepo } from './repo.js';
|
||||
export { checkCodeModeAgentStatus } from './status.js';
|
||||
42
apps/x/packages/core/src/code-mode/repo.ts
Normal file
42
apps/x/packages/core/src/code-mode/repo.ts
Normal file
|
|
@ -0,0 +1,42 @@
|
|||
import fs from 'fs/promises';
|
||||
import path from 'path';
|
||||
import { WorkDir } from '../config/config.js';
|
||||
import { CodeModeConfig } from './types.js';
|
||||
|
||||
export interface ICodeModeConfigRepo {
|
||||
getConfig(): Promise<CodeModeConfig>;
|
||||
setConfig(config: CodeModeConfig): Promise<void>;
|
||||
}
|
||||
|
||||
export class FSCodeModeConfigRepo implements ICodeModeConfigRepo {
|
||||
private readonly configPath = path.join(WorkDir, 'config', 'code-mode.json');
|
||||
private readonly defaultConfig: CodeModeConfig = { enabled: false };
|
||||
|
||||
constructor() {
|
||||
this.ensureConfigFile();
|
||||
}
|
||||
|
||||
private async ensureConfigFile(): Promise<void> {
|
||||
try {
|
||||
await fs.access(this.configPath);
|
||||
} catch {
|
||||
await fs.mkdir(path.dirname(this.configPath), { recursive: true });
|
||||
await fs.writeFile(this.configPath, JSON.stringify(this.defaultConfig, null, 2));
|
||||
}
|
||||
}
|
||||
|
||||
async getConfig(): Promise<CodeModeConfig> {
|
||||
try {
|
||||
const content = await fs.readFile(this.configPath, 'utf8');
|
||||
return CodeModeConfig.parse(JSON.parse(content));
|
||||
} catch {
|
||||
return this.defaultConfig;
|
||||
}
|
||||
}
|
||||
|
||||
async setConfig(config: CodeModeConfig): Promise<void> {
|
||||
const validated = CodeModeConfig.parse(config);
|
||||
await fs.mkdir(path.dirname(this.configPath), { recursive: true });
|
||||
await fs.writeFile(this.configPath, JSON.stringify(validated, null, 2));
|
||||
}
|
||||
}
|
||||
157
apps/x/packages/core/src/code-mode/status.ts
Normal file
157
apps/x/packages/core/src/code-mode/status.ts
Normal file
|
|
@ -0,0 +1,157 @@
|
|||
import { exec } from 'child_process';
|
||||
import { promisify } from 'util';
|
||||
import os from 'os';
|
||||
import path from 'path';
|
||||
import fs from 'fs/promises';
|
||||
import { existsSync } from 'fs';
|
||||
import { CodeModeAgentStatus } from './types.js';
|
||||
|
||||
const execAsync = promisify(exec);
|
||||
|
||||
// Where claude.cmd / codex.cmd typically live when installed via npm/pnpm/yarn.
|
||||
// We scan these directly because Electron's spawned shell sometimes doesn't
|
||||
// inherit the user's full PATH (especially on macOS GUI launches, and even on
|
||||
// Windows when global npm prefix isn't propagated to system PATH).
|
||||
function commonInstallPaths(binary: string): string[] {
|
||||
const home = os.homedir();
|
||||
if (process.platform === 'win32') {
|
||||
const appData = process.env.APPDATA || path.join(home, 'AppData', 'Roaming');
|
||||
const localAppData = process.env.LOCALAPPDATA || path.join(home, 'AppData', 'Local');
|
||||
const programFiles = process.env.ProgramFiles || 'C:\\Program Files';
|
||||
return [
|
||||
path.join(appData, 'npm', `${binary}.cmd`),
|
||||
path.join(appData, 'npm', `${binary}.exe`),
|
||||
path.join(localAppData, 'npm', `${binary}.cmd`),
|
||||
path.join(localAppData, 'pnpm', `${binary}.cmd`),
|
||||
path.join(home, 'AppData', 'Roaming', 'pnpm', `${binary}.cmd`),
|
||||
path.join(programFiles, 'nodejs', `${binary}.cmd`),
|
||||
path.join(home, '.volta', 'bin', `${binary}.cmd`),
|
||||
];
|
||||
}
|
||||
return [
|
||||
'/usr/local/bin',
|
||||
'/opt/homebrew/bin', // Apple Silicon Homebrew
|
||||
'/usr/bin',
|
||||
path.join(home, '.npm-global', 'bin'),
|
||||
path.join(home, '.local', 'bin'),
|
||||
path.join(home, '.volta', 'bin'),
|
||||
path.join(home, '.nvm', 'versions', 'node'), // partial; nvm has versioned subdirs
|
||||
path.join(home, 'bin'),
|
||||
].map(dir => path.join(dir, binary));
|
||||
}
|
||||
|
||||
async function probeShell(binary: string): Promise<boolean> {
|
||||
try {
|
||||
if (process.platform === 'win32') {
|
||||
const { stdout } = await execAsync(`where ${binary}`, { timeout: 5000 });
|
||||
return stdout.trim().length > 0;
|
||||
}
|
||||
// Login shell so ~/.zprofile / ~/.bashrc PATH additions are visible —
|
||||
// essential for Homebrew, nvm, asdf, volta installs on macOS GUI launches.
|
||||
const { stdout } = await execAsync(`/bin/sh -lc 'command -v ${binary}'`, { timeout: 5000 });
|
||||
return stdout.trim().length > 0;
|
||||
} catch {
|
||||
return false;
|
||||
}
|
||||
}
|
||||
|
||||
async function isInstalled(binary: string): Promise<boolean> {
|
||||
if (await probeShell(binary)) return true;
|
||||
// Fallback: scan well-known install locations directly.
|
||||
for (const candidate of commonInstallPaths(binary)) {
|
||||
if (existsSync(candidate)) return true;
|
||||
}
|
||||
return false;
|
||||
}
|
||||
|
||||
function decodeJwtPayload(token: string): Record<string, unknown> | null {
|
||||
try {
|
||||
const parts = token.split('.');
|
||||
if (parts.length < 2) return null;
|
||||
const padded = parts[1].replace(/-/g, '+').replace(/_/g, '/');
|
||||
const pad = padded.length % 4 === 0 ? '' : '='.repeat(4 - (padded.length % 4));
|
||||
const json = Buffer.from(padded + pad, 'base64').toString('utf-8');
|
||||
const parsed = JSON.parse(json);
|
||||
return typeof parsed === 'object' && parsed !== null ? parsed as Record<string, unknown> : null;
|
||||
} catch {
|
||||
return null;
|
||||
}
|
||||
}
|
||||
|
||||
// Validates Claude Code auth: ~/.claude/.credentials.json (or ~/.config fallback).
|
||||
// Considered signed in if any of: valid API key, unexpired access token, or
|
||||
// presence of a refresh token (which can mint a new access token transparently).
|
||||
async function checkClaudeSignedIn(): Promise<boolean> {
|
||||
const home = os.homedir();
|
||||
const candidates = [
|
||||
path.join(home, '.claude', '.credentials.json'),
|
||||
path.join(home, '.config', 'claude', '.credentials.json'),
|
||||
];
|
||||
for (const full of candidates) {
|
||||
try {
|
||||
const raw = await fs.readFile(full, 'utf-8');
|
||||
const parsed = JSON.parse(raw) as Record<string, unknown>;
|
||||
|
||||
const oauth = parsed.claudeAiOauth as Record<string, unknown> | undefined;
|
||||
if (oauth) {
|
||||
const access = typeof oauth.accessToken === 'string' ? oauth.accessToken : '';
|
||||
const refresh = typeof oauth.refreshToken === 'string' ? oauth.refreshToken : '';
|
||||
if (refresh.length > 0) return true;
|
||||
if (access.length > 0) {
|
||||
if (typeof oauth.expiresAt === 'number' && oauth.expiresAt > 0 && oauth.expiresAt < Date.now()) {
|
||||
return false;
|
||||
}
|
||||
return true;
|
||||
}
|
||||
}
|
||||
|
||||
if (typeof parsed.apiKey === 'string' && parsed.apiKey.length > 10) return true;
|
||||
if (typeof parsed.accessToken === 'string' && parsed.accessToken.length > 10) return true;
|
||||
} catch {
|
||||
// try next candidate
|
||||
}
|
||||
}
|
||||
return false;
|
||||
}
|
||||
|
||||
// Validates Codex auth at ~/.codex/auth.json on all platforms.
|
||||
// Considered signed in if API key set, or a refresh_token / access_token
|
||||
// exists. id_token expiry is intentionally NOT used as a rejection signal —
|
||||
// id_tokens are short-lived (~1h) but refresh_tokens persist for weeks.
|
||||
async function checkCodexSignedIn(): Promise<boolean> {
|
||||
const home = os.homedir();
|
||||
const full = path.join(home, '.codex', 'auth.json');
|
||||
try {
|
||||
const raw = await fs.readFile(full, 'utf-8');
|
||||
const parsed = JSON.parse(raw) as Record<string, unknown>;
|
||||
|
||||
if (typeof parsed.OPENAI_API_KEY === 'string' && parsed.OPENAI_API_KEY.length > 10) return true;
|
||||
|
||||
const tokens = parsed.tokens as Record<string, unknown> | undefined;
|
||||
if (tokens) {
|
||||
const refresh = typeof tokens.refresh_token === 'string' ? tokens.refresh_token : '';
|
||||
const access = typeof tokens.access_token === 'string' ? tokens.access_token : '';
|
||||
const id = typeof tokens.id_token === 'string' ? tokens.id_token : '';
|
||||
if (refresh.length > 0 || access.length > 0 || id.length > 0) return true;
|
||||
}
|
||||
} catch {
|
||||
// file missing or unreadable
|
||||
}
|
||||
return false;
|
||||
}
|
||||
|
||||
// Exported for diagnostics — silenced unused-var warning by re-export only.
|
||||
export { decodeJwtPayload };
|
||||
|
||||
export async function checkCodeModeAgentStatus(): Promise<CodeModeAgentStatus> {
|
||||
const [claudeInstalled, codexInstalled, claudeSignedIn, codexSignedIn] = await Promise.all([
|
||||
isInstalled('claude'),
|
||||
isInstalled('codex'),
|
||||
checkClaudeSignedIn(),
|
||||
checkCodexSignedIn(),
|
||||
]);
|
||||
return {
|
||||
claude: { installed: claudeInstalled, signedIn: claudeSignedIn },
|
||||
codex: { installed: codexInstalled, signedIn: codexSignedIn },
|
||||
};
|
||||
}
|
||||
18
apps/x/packages/core/src/code-mode/types.ts
Normal file
18
apps/x/packages/core/src/code-mode/types.ts
Normal file
|
|
@ -0,0 +1,18 @@
|
|||
import z from "zod";
|
||||
|
||||
export const CodeModeConfig = z.object({
|
||||
enabled: z.boolean(),
|
||||
});
|
||||
export type CodeModeConfig = z.infer<typeof CodeModeConfig>;
|
||||
|
||||
export const AgentStatus = z.object({
|
||||
installed: z.boolean(),
|
||||
signedIn: z.boolean(),
|
||||
});
|
||||
export type AgentStatus = z.infer<typeof AgentStatus>;
|
||||
|
||||
export const CodeModeAgentStatus = z.object({
|
||||
claude: AgentStatus,
|
||||
codex: AgentStatus,
|
||||
});
|
||||
export type CodeModeAgentStatus = z.infer<typeof CodeModeAgentStatus>;
|
||||
|
|
@ -11,6 +11,7 @@ import { IAgentRuntime, AgentRuntime } from "../agents/runtime.js";
|
|||
import { FSOAuthRepo, IOAuthRepo } from "../auth/repo.js";
|
||||
import { FSClientRegistrationRepo, IClientRegistrationRepo } from "../auth/client-repo.js";
|
||||
import { FSGranolaConfigRepo, IGranolaConfigRepo } from "../knowledge/granola/repo.js";
|
||||
import { FSCodeModeConfigRepo, ICodeModeConfigRepo } from "../code-mode/repo.js";
|
||||
import { IAbortRegistry, InMemoryAbortRegistry } from "../runs/abort-registry.js";
|
||||
import { FSAgentScheduleRepo, IAgentScheduleRepo } from "../agent-schedule/repo.js";
|
||||
import { FSAgentScheduleStateRepo, IAgentScheduleStateRepo } from "../agent-schedule/state-repo.js";
|
||||
|
|
@ -38,6 +39,7 @@ container.register({
|
|||
oauthRepo: asClass<IOAuthRepo>(FSOAuthRepo).singleton(),
|
||||
clientRegistrationRepo: asClass<IClientRegistrationRepo>(FSClientRegistrationRepo).singleton(),
|
||||
granolaConfigRepo: asClass<IGranolaConfigRepo>(FSGranolaConfigRepo).singleton(),
|
||||
codeModeConfigRepo: asClass<ICodeModeConfigRepo>(FSCodeModeConfigRepo).singleton(),
|
||||
agentScheduleRepo: asClass<IAgentScheduleRepo>(FSAgentScheduleRepo).singleton(),
|
||||
agentScheduleStateRepo: asClass<IAgentScheduleStateRepo>(FSAgentScheduleStateRepo).singleton(),
|
||||
slackConfigRepo: asClass<ISlackConfigRepo>(FSSlackConfigRepo).singleton(),
|
||||
|
|
|
|||
|
|
@ -416,6 +416,27 @@ const ipcSchemas = {
|
|||
enabled: z.boolean(),
|
||||
}),
|
||||
},
|
||||
'codeMode:getConfig': {
|
||||
req: z.null(),
|
||||
res: z.object({
|
||||
enabled: z.boolean(),
|
||||
}),
|
||||
},
|
||||
'codeMode:setConfig': {
|
||||
req: z.object({
|
||||
enabled: z.boolean(),
|
||||
}),
|
||||
res: z.object({
|
||||
success: z.literal(true),
|
||||
}),
|
||||
},
|
||||
'codeMode:checkAgentStatus': {
|
||||
req: z.null(),
|
||||
res: z.object({
|
||||
claude: z.object({ installed: z.boolean(), signedIn: z.boolean() }),
|
||||
codex: z.object({ installed: z.boolean(), signedIn: z.boolean() }),
|
||||
}),
|
||||
},
|
||||
'granola:setConfig': {
|
||||
req: z.object({
|
||||
enabled: z.boolean(),
|
||||
|
|
|
|||
Loading…
Add table
Add a link
Reference in a new issue