Add library item rename with cascading reference updates

- Add POST /api/library/rename/:item_type/:name endpoint supporting
  skills, commands, rules, agents, tools, and workspace templates
- Implement dry_run mode to preview changes before applying
- Auto-update all cross-references in related configs and workspaces
- Add RenameDialog component with preview and apply workflow
- Integrate rename action into Skills, Commands, and Rules config pages
- Fix settings page to sync config before restarting OpenCode
- Clarify INSTALL.md dashboard deployment options (Vercel vs local)
- Add docs-site scaffolding (Nextra-based documentation)
This commit is contained in:
Thomas Marchand
2026-01-16 16:48:52 +00:00
parent 9daa328941
commit 58e10a5a95
42 changed files with 6076 additions and 76 deletions

View File

@@ -767,20 +767,40 @@ Note: Multi-user mode provides separate login credentials but does **not** provi
## 12) Dashboard Configuration
### 12.1 Web Dashboard
This guide installs the **backend** on your server. The dashboard (frontend) is separate and you have several options:
The web dashboard auto-detects the backend URL. For production, set the environment variable:
| Option | Best For | Setup |
|--------|----------|-------|
| **Vercel** | Production, always accessible | Deploy `dashboard/` to Vercel |
| **Local** | Development, quick testing | Run `bun dev` in dashboard folder |
| **iOS App** | Mobile access | Enter backend URL in app |
### 12.1 Web Dashboard (Vercel)
Deploy the `dashboard/` folder to [Vercel](https://vercel.com):
1. Connect your repo to Vercel
2. Set the root directory to `dashboard`
3. Add environment variable: `NEXT_PUBLIC_API_URL=https://agent.yourdomain.com`
4. Deploy
The dashboard will connect to your backend server.
### 12.2 Web Dashboard (Local)
Run the dashboard locally on your machine:
```bash
# When building/running the Next.js dashboard
NEXT_PUBLIC_API_URL=https://agent.yourdomain.com
cd dashboard
bun install
NEXT_PUBLIC_API_URL=https://agent.yourdomain.com bun dev
```
Or configure it at runtime via the Settings page.
Then open `http://localhost:3000`.
### 12.2 iOS App
### 12.3 iOS App
On first launch, the iOS app prompts for the server URL. Enter your production URL (e.g., `https://agent.yourdomain.com`).
On first launch, the iOS app prompts for the server URL. Enter your backend URL (e.g., `https://agent.yourdomain.com`).
To change later: **Menu (⋮) → Settings**

View File

@@ -16,11 +16,13 @@ import {
Save,
Trash2,
FileText,
Pencil,
} from 'lucide-react';
import { cn } from '@/lib/utils';
import { LibraryUnavailable } from '@/components/library-unavailable';
import { useLibrary } from '@/contexts/library-context';
import { ConfigCodeEditor } from '@/components/config-code-editor';
import { RenameDialog } from '@/components/rename-dialog';
export default function CommandsPage() {
const {
@@ -46,6 +48,7 @@ export default function CommandsPage() {
const [commandSaving, setCommandSaving] = useState(false);
const [loadingCommand, setLoadingCommand] = useState(false);
const [showNewCommandDialog, setShowNewCommandDialog] = useState(false);
const [showRenameDialog, setShowRenameDialog] = useState(false);
const [newCommandName, setNewCommandName] = useState('');
const [commitMessage, setCommitMessage] = useState('');
const [showCommitDialog, setShowCommitDialog] = useState(false);
@@ -159,6 +162,12 @@ Describe what this command does.
}
};
const handleRenameSuccess = async () => {
await refresh();
setSelectedCommand(null);
setCommandContent('');
};
if (loading) {
return (
<div className="flex items-center justify-center min-h-[calc(100vh-4rem)]">
@@ -304,9 +313,17 @@ Describe what this command does.
</div>
<div className="flex items-center gap-2">
{commandDirty && <span className="text-xs text-amber-400">Unsaved</span>}
<button
onClick={() => setShowRenameDialog(true)}
className="p-1.5 rounded-lg text-white/60 hover:text-white hover:bg-white/[0.06] transition-colors"
title="Rename Command"
>
<Pencil className="h-3.5 w-3.5" />
</button>
<button
onClick={handleCommandDelete}
className="p-1.5 rounded-lg text-red-400 hover:bg-red-500/10 transition-colors"
title="Delete Command"
>
<Trash2 className="h-3.5 w-3.5" />
</button>
@@ -416,6 +433,17 @@ Describe what this command does.
</div>
</div>
)}
{/* Rename Dialog */}
{selectedCommand && (
<RenameDialog
open={showRenameDialog}
onOpenChange={setShowRenameDialog}
itemType="command"
currentName={selectedCommand.name}
onSuccess={handleRenameSuccess}
/>
)}
</div>
);
}

View File

@@ -14,11 +14,13 @@ import {
Trash2,
X,
FileText,
Pencil,
} from 'lucide-react';
import { cn } from '@/lib/utils';
import { LibraryUnavailable } from '@/components/library-unavailable';
import { useLibrary } from '@/contexts/library-context';
import { ConfigCodeEditor } from '@/components/config-code-editor';
import { RenameDialog } from '@/components/rename-dialog';
export default function RulesPage() {
const {
@@ -44,6 +46,7 @@ export default function RulesPage() {
const [ruleSaving, setRuleSaving] = useState(false);
const [loadingRule, setLoadingRule] = useState(false);
const [showNewRuleDialog, setShowNewRuleDialog] = useState(false);
const [showRenameDialog, setShowRenameDialog] = useState(false);
const [newRuleName, setNewRuleName] = useState('');
const [commitMessage, setCommitMessage] = useState('');
const [showCommitDialog, setShowCommitDialog] = useState(false);
@@ -178,6 +181,12 @@ Describe what this rule does.
}
};
const handleRenameSuccess = async () => {
await refresh();
setSelectedRule(null);
setRuleContent('');
};
if (loading) {
return (
<div className="flex items-center justify-center min-h-[calc(100vh-4rem)]">
@@ -324,6 +333,13 @@ Describe what this rule does.
</div>
<div className="flex items-center gap-2">
{ruleDirty && <span className="text-xs text-amber-400">Unsaved</span>}
<button
onClick={() => setShowRenameDialog(true)}
className="p-1.5 rounded-lg text-white/60 hover:text-white hover:bg-white/[0.06] transition-colors"
title="Rename Rule"
>
<Pencil className="h-3.5 w-3.5" />
</button>
<button
onClick={handleRuleDelete}
className="p-1.5 rounded-lg text-red-400 hover:bg-red-500/10 transition-colors"
@@ -450,6 +466,17 @@ Your rule content here..."
</div>
</div>
)}
{/* Rename Dialog */}
{selectedRule && (
<RenameDialog
open={showRenameDialog}
onOpenChange={setShowRenameDialog}
itemType="rule"
currentName={selectedRule.name}
onSuccess={handleRenameSuccess}
/>
)}
</div>
);
}

View File

@@ -201,6 +201,8 @@ export default function SettingsPage() {
try {
setRestarting(true);
setError(null);
// Sync config before restarting
await sync();
await restartOpenCodeService();
setRestartSuccess(true);
setNeedsRestart(false);
@@ -328,47 +330,6 @@ export default function SettingsPage() {
</div>
)}
{/* Header */}
<div className="flex items-center justify-between">
<div>
<h1 className="text-xl font-semibold text-white">Configs</h1>
<p className="text-sm text-white/50 mt-1">
Configure OpenCode and OpenAgent settings
</p>
</div>
<div className="flex items-center gap-2">
<button
onClick={loadSettings}
disabled={loading}
className="flex items-center gap-2 px-3 py-1.5 text-sm text-white/70 hover:text-white bg-white/[0.04] hover:bg-white/[0.08] rounded-lg transition-colors disabled:opacity-50"
>
<RefreshCw className={cn('h-4 w-4', loading && 'animate-spin')} />
Reload
</button>
<button
onClick={handleRestart}
disabled={restarting}
className={cn(
'flex items-center gap-2 px-4 py-1.5 text-sm font-medium rounded-lg transition-colors',
needsRestart
? 'text-white bg-amber-500 hover:bg-amber-600'
: restartSuccess
? 'text-emerald-400 bg-emerald-500/10'
: 'text-white/70 hover:text-white bg-white/[0.04] hover:bg-white/[0.08]'
)}
>
{restarting ? (
<Loader className="h-4 w-4 animate-spin" />
) : restartSuccess ? (
<Check className="h-4 w-4" />
) : (
<RotateCcw className="h-4 w-4" />
)}
{restarting ? 'Restarting...' : restartSuccess ? 'Restarted!' : 'Restart OpenCode'}
</button>
</div>
</div>
{/* Error Display */}
{error && (
<div className="p-4 rounded-lg bg-red-500/10 border border-red-500/20 flex items-start gap-3">
@@ -481,6 +442,37 @@ export default function SettingsPage() {
)}
{saving ? 'Saving...' : saveSuccess ? 'Saved!' : 'Save'}
</button>
<button
onClick={loadSettings}
disabled={loading}
title="Reloads the source from disk"
className="flex items-center gap-2 px-3 py-1.5 text-sm text-white/70 hover:text-white bg-white/[0.04] hover:bg-white/[0.08] rounded-lg transition-colors disabled:opacity-50"
>
<RefreshCw className={cn('h-4 w-4', loading && 'animate-spin')} />
Reload
</button>
<button
onClick={handleRestart}
disabled={restarting}
title="Syncs config, and restarts OpenCode"
className={cn(
'flex items-center gap-2 px-4 py-1.5 text-sm font-medium rounded-lg transition-colors',
needsRestart
? 'text-white bg-amber-500 hover:bg-amber-600'
: restartSuccess
? 'text-emerald-400 bg-emerald-500/10'
: 'text-white/70 hover:text-white bg-white/[0.04] hover:bg-white/[0.08]'
)}
>
{restarting ? (
<Loader className="h-4 w-4 animate-spin" />
) : restartSuccess ? (
<Check className="h-4 w-4" />
) : (
<RotateCcw className="h-4 w-4" />
)}
{restarting ? 'Restarting...' : restartSuccess ? 'Restarted!' : 'Restart'}
</button>
</div>
</div>
@@ -520,25 +512,58 @@ export default function SettingsPage() {
<h2 className="text-lg font-medium text-white">OpenAgent Settings</h2>
<p className="text-sm text-white/50">Configure agent visibility in mission dialog</p>
</div>
<button
onClick={handleSaveOpenAgent}
disabled={savingOpenAgent || !isOpenAgentDirty}
className={cn(
'flex items-center gap-2 px-4 py-1.5 text-sm font-medium rounded-lg transition-colors',
isOpenAgentDirty
? 'text-white bg-indigo-500 hover:bg-indigo-600'
: 'text-white/40 bg-white/[0.04] cursor-not-allowed'
)}
>
{savingOpenAgent ? (
<Loader className="h-4 w-4 animate-spin" />
) : openAgentSaveSuccess ? (
<Check className="h-4 w-4 text-emerald-400" />
) : (
<Save className="h-4 w-4" />
)}
{savingOpenAgent ? 'Saving...' : openAgentSaveSuccess ? 'Saved!' : 'Save'}
</button>
<div className="flex items-center gap-2">
<button
onClick={handleSaveOpenAgent}
disabled={savingOpenAgent || !isOpenAgentDirty}
className={cn(
'flex items-center gap-2 px-4 py-1.5 text-sm font-medium rounded-lg transition-colors',
isOpenAgentDirty
? 'text-white bg-indigo-500 hover:bg-indigo-600'
: 'text-white/40 bg-white/[0.04] cursor-not-allowed'
)}
>
{savingOpenAgent ? (
<Loader className="h-4 w-4 animate-spin" />
) : openAgentSaveSuccess ? (
<Check className="h-4 w-4 text-emerald-400" />
) : (
<Save className="h-4 w-4" />
)}
{savingOpenAgent ? 'Saving...' : openAgentSaveSuccess ? 'Saved!' : 'Save'}
</button>
<button
onClick={loadSettings}
disabled={loading}
title="Reloads the source from disk"
className="flex items-center gap-2 px-3 py-1.5 text-sm text-white/70 hover:text-white bg-white/[0.04] hover:bg-white/[0.08] rounded-lg transition-colors disabled:opacity-50"
>
<RefreshCw className={cn('h-4 w-4', loading && 'animate-spin')} />
Reload
</button>
<button
onClick={handleRestart}
disabled={restarting}
title="Syncs config, and restarts OpenCode"
className={cn(
'flex items-center gap-2 px-4 py-1.5 text-sm font-medium rounded-lg transition-colors',
needsRestart
? 'text-white bg-amber-500 hover:bg-amber-600'
: restartSuccess
? 'text-emerald-400 bg-emerald-500/10'
: 'text-white/70 hover:text-white bg-white/[0.04] hover:bg-white/[0.08]'
)}
>
{restarting ? (
<Loader className="h-4 w-4 animate-spin" />
) : restartSuccess ? (
<Check className="h-4 w-4" />
) : (
<RotateCcw className="h-4 w-4" />
)}
{restarting ? 'Restarting...' : restartSuccess ? 'Restarted!' : 'Restart'}
</button>
</div>
</div>
{/* Agent Visibility */}

View File

@@ -29,11 +29,13 @@ import {
Download,
FileText,
ExternalLink,
Pencil,
} from 'lucide-react';
import { cn } from '@/lib/utils';
import { LibraryUnavailable } from '@/components/library-unavailable';
import { useLibrary } from '@/contexts/library-context';
import { ConfigCodeEditor } from '@/components/config-code-editor';
import { RenameDialog } from '@/components/rename-dialog';
// ─────────────────────────────────────────────────────────────────────────────
// Types
@@ -640,6 +642,7 @@ export default function SkillsPage() {
const [showImportDialog, setShowImportDialog] = useState(false);
const [showNewFileDialog, setShowNewFileDialog] = useState(false);
const [showCommitDialog, setShowCommitDialog] = useState(false);
const [showRenameDialog, setShowRenameDialog] = useState(false);
const [newSkillName, setNewSkillName] = useState('');
const [newSkillError, setNewSkillError] = useState<string | null>(null);
const [commitMessage, setCommitMessage] = useState('');
@@ -921,6 +924,17 @@ Describe what this skill does.
await loadSkill(skill.name);
};
const handleRenameSuccess = async () => {
await refresh();
// The skill was renamed, so we need to clear selection
// since the old name no longer exists
setSelectedSkill(null);
setSelectedFile(null);
setFileContent('');
setFrontmatter({});
setBodyContent('');
};
if (loading) {
return (
<div className="flex items-center justify-center min-h-[calc(100vh-4rem)]">
@@ -1104,13 +1118,22 @@ Describe what this skill does.
<div className="flex items-center gap-2">
{isDirty && <span className="text-xs text-amber-400">Unsaved</span>}
{selectedFile === 'SKILL.md' && (
<button
onClick={handleDeleteSkill}
className="p-1.5 rounded-lg text-red-400 hover:bg-red-500/10 transition-colors"
title="Delete Skill"
>
<Trash2 className="h-3.5 w-3.5" />
</button>
<>
<button
onClick={() => setShowRenameDialog(true)}
className="p-1.5 rounded-lg text-white/60 hover:text-white hover:bg-white/[0.06] transition-colors"
title="Rename Skill"
>
<Pencil className="h-3.5 w-3.5" />
</button>
<button
onClick={handleDeleteSkill}
className="p-1.5 rounded-lg text-red-400 hover:bg-red-500/10 transition-colors"
title="Delete Skill"
>
<Trash2 className="h-3.5 w-3.5" />
</button>
</>
)}
<button
onClick={handleSave}
@@ -1275,6 +1298,17 @@ Describe what this skill does.
skillName={selectedSkill.name}
/>
)}
{/* Rename Dialog */}
{selectedSkill && (
<RenameDialog
open={showRenameDialog}
onOpenChange={setShowRenameDialog}
itemType="skill"
currentName={selectedSkill.name}
onSuccess={handleRenameSuccess}
/>
)}
</div>
);
}

View File

@@ -0,0 +1,291 @@
"use client";
import { useState, useCallback, useEffect } from "react";
import { Loader, AlertCircle, FileEdit, ArrowRight, X } from "lucide-react";
import {
renameLibraryItem,
LibraryItemType,
RenameResult,
RenameChange,
} from "@/lib/api";
import { cn } from "@/lib/utils";
interface RenameDialogProps {
open: boolean;
onOpenChange: (open: boolean) => void;
itemType: LibraryItemType;
currentName: string;
onSuccess?: () => void;
}
const ITEM_TYPE_LABELS: Record<LibraryItemType, string> = {
skill: "Skill",
command: "Command",
rule: "Rule",
agent: "Agent",
tool: "Tool",
"workspace-template": "Workspace Template",
};
function ChangePreview({ changes }: { changes: RenameChange[] }) {
if (changes.length === 0) return null;
return (
<div className="mt-4 space-y-2">
<label className="text-xs text-white/60">
Changes to apply ({changes.length}):
</label>
<div className="max-h-48 overflow-y-auto rounded-lg border border-white/[0.08] bg-white/[0.02] p-2 text-xs font-mono space-y-1">
{changes.map((change, i) => (
<div key={i} className="flex items-center gap-2 text-white/80">
{change.type === "rename_file" && (
<>
<FileEdit className="h-3 w-3 text-blue-400 flex-shrink-0" />
<span className="text-white/50">{change.from}</span>
<ArrowRight className="h-3 w-3 flex-shrink-0" />
<span className="text-white">{change.to}</span>
</>
)}
{change.type === "update_reference" && (
<>
<FileEdit className="h-3 w-3 text-amber-400 flex-shrink-0" />
<span className="text-white/50">{change.file}</span>
<span className="text-white/40">({change.field})</span>
</>
)}
{change.type === "update_workspace" && (
<>
<FileEdit className="h-3 w-3 text-green-400 flex-shrink-0" />
<span className="text-white">
Workspace: {change.workspace_name}
</span>
<span className="text-white/40">({change.field})</span>
</>
)}
</div>
))}
</div>
</div>
);
}
export function RenameDialog({
open,
onOpenChange,
itemType,
currentName,
onSuccess,
}: RenameDialogProps) {
const [newName, setNewName] = useState(currentName);
const [loading, setLoading] = useState(false);
const [previewing, setPreviewing] = useState(false);
const [preview, setPreview] = useState<RenameResult | null>(null);
const [error, setError] = useState<string | null>(null);
const label = ITEM_TYPE_LABELS[itemType];
// Reset state when dialog opens/closes
useEffect(() => {
if (open) {
setNewName(currentName);
setPreview(null);
setError(null);
}
}, [open, currentName]);
// Handle escape key
useEffect(() => {
const handleKeyDown = (e: KeyboardEvent) => {
if (e.key === "Escape" && open) {
onOpenChange(false);
}
};
document.addEventListener("keydown", handleKeyDown);
return () => document.removeEventListener("keydown", handleKeyDown);
}, [open, onOpenChange]);
const handlePreview = useCallback(async () => {
if (!newName.trim() || newName === currentName) return;
setPreviewing(true);
setError(null);
setPreview(null);
try {
const result = await renameLibraryItem(
itemType,
currentName,
newName.trim(),
true // dry_run
);
setPreview(result);
if (!result.success) {
setError(result.error || "Preview failed");
}
} catch (e) {
setError(e instanceof Error ? e.message : "Failed to preview rename");
} finally {
setPreviewing(false);
}
}, [itemType, currentName, newName]);
const handleRename = useCallback(async () => {
if (!newName.trim() || newName === currentName) return;
setLoading(true);
setError(null);
try {
const result = await renameLibraryItem(
itemType,
currentName,
newName.trim(),
false // execute
);
if (result.success) {
onOpenChange(false);
onSuccess?.();
} else {
setError(result.error || "Rename failed");
}
} catch (e) {
setError(e instanceof Error ? e.message : "Failed to rename");
} finally {
setLoading(false);
}
}, [itemType, currentName, newName, onOpenChange, onSuccess]);
const isValid = newName.trim() && newName !== currentName;
if (!open) return null;
return (
<div className="fixed inset-0 z-50 flex items-center justify-center bg-black/50">
<div className="w-full max-w-md p-6 rounded-xl bg-[#1a1a1c] border border-white/[0.06]">
<div className="flex items-center justify-between mb-4">
<h3 className="text-lg font-medium text-white">Rename {label}</h3>
<button
onClick={() => onOpenChange(false)}
className="p-1 rounded hover:bg-white/[0.06] transition-colors"
>
<X className="h-4 w-4 text-white/60" />
</button>
</div>
<p className="text-sm text-white/60 mb-4">
Enter a new name for this {label.toLowerCase()}. All references will
be automatically updated.
</p>
<div className="space-y-4">
<div>
<label className="block text-sm text-white/60 mb-1.5">
Current name
</label>
<input
type="text"
value={currentName}
disabled
className="w-full px-4 py-2 rounded-lg bg-white/[0.02] border border-white/[0.06] text-white/50"
/>
</div>
<div>
<label className="block text-sm text-white/60 mb-1.5">
New name
</label>
<input
type="text"
value={newName}
onChange={(e) => {
setNewName(e.target.value);
setPreview(null);
setError(null);
}}
placeholder={`Enter new ${label.toLowerCase()} name`}
autoFocus
onKeyDown={(e) => {
if (e.key === "Enter" && isValid && !loading && !previewing) {
if (preview?.success) {
handleRename();
} else {
handlePreview();
}
}
}}
className="w-full px-4 py-2 rounded-lg bg-white/[0.04] border border-white/[0.08] text-white placeholder:text-white/30 focus:outline-none focus:border-indigo-500/50"
/>
</div>
{error && (
<div className="flex items-start gap-2 p-3 rounded-lg bg-red-500/10 border border-red-500/20 text-red-400 text-sm">
<AlertCircle className="h-4 w-4 mt-0.5 flex-shrink-0" />
<span>{error}</span>
</div>
)}
{preview?.success && <ChangePreview changes={preview.changes} />}
{preview?.warnings && preview.warnings.length > 0 && (
<div className="flex items-start gap-2 p-3 rounded-lg bg-amber-500/10 border border-amber-500/20 text-amber-400 text-sm">
<AlertCircle className="h-4 w-4 mt-0.5 flex-shrink-0" />
<span>{preview.warnings.join(", ")}</span>
</div>
)}
</div>
<div className="flex justify-end gap-2 mt-6">
<button
onClick={() => onOpenChange(false)}
className="px-4 py-2 text-sm text-white/60 hover:text-white transition-colors"
>
Cancel
</button>
{!preview?.success ? (
<button
onClick={handlePreview}
disabled={!isValid || previewing}
className={cn(
"flex items-center gap-2 px-4 py-2 text-sm font-medium rounded-lg transition-colors",
isValid && !previewing
? "text-white bg-indigo-500 hover:bg-indigo-600"
: "text-white/40 bg-white/[0.04] cursor-not-allowed"
)}
>
{previewing ? (
<>
<Loader className="h-4 w-4 animate-spin" />
Previewing...
</>
) : (
"Preview Changes"
)}
</button>
) : (
<button
onClick={handleRename}
disabled={!isValid || loading}
className={cn(
"flex items-center gap-2 px-4 py-2 text-sm font-medium rounded-lg transition-colors",
isValid && !loading
? "text-white bg-indigo-500 hover:bg-indigo-600"
: "text-white/40 bg-white/[0.04] cursor-not-allowed"
)}
>
{loading ? (
<>
<Loader className="h-4 w-4 animate-spin" />
Renaming...
</>
) : (
"Apply Rename"
)}
</button>
)}
</div>
</div>
</div>
);
}

View File

@@ -139,8 +139,8 @@ export function Sidebar() {
</div>
</div>
{/* Navigation */}
<nav className="flex flex-1 flex-col gap-1 p-3">
{/* Navigation - scrollable when content overflows */}
<nav className="flex-1 overflow-y-auto p-3 space-y-1">
{navigation.map((item) => {
const isCurrentPath = pathname === item.href;
const isChildActive = item.children?.some((child) => pathname === child.href);

View File

@@ -1807,6 +1807,59 @@ export async function renameWorkspaceTemplate(oldName: string, newName: string):
await deleteWorkspaceTemplate(oldName);
}
// ─────────────────────────────────────────────────────────────────────────────
// Library Rename
// ─────────────────────────────────────────────────────────────────────────────
export type LibraryItemType =
| "skill"
| "command"
| "rule"
| "agent"
| "tool"
| "workspace-template";
export interface RenameChange {
type: "rename_file" | "update_reference" | "update_workspace";
from?: string;
to?: string;
file?: string;
field?: string;
old_value?: string;
new_value?: string;
workspace_id?: string;
workspace_name?: string;
}
export interface RenameResult {
success: boolean;
changes: RenameChange[];
warnings: string[];
error?: string;
}
/**
* Rename a library item and update all references.
* Supports dry_run mode to preview changes before applying them.
*/
export async function renameLibraryItem(
itemType: LibraryItemType,
oldName: string,
newName: string,
dryRun: boolean = false
): Promise<RenameResult> {
const res = await apiFetch(
`/api/library/rename/${itemType}/${encodeURIComponent(oldName)}`,
{
method: "POST",
headers: { "Content-Type": "application/json" },
body: JSON.stringify({ new_name: newName, dry_run: dryRun }),
}
);
await ensureLibraryResponse(res, "Failed to rename item");
return res.json();
}
// ─────────────────────────────────────────────────────────────────────────────
// Library Migration
// ─────────────────────────────────────────────────────────────────────────────

31
docs-site/.gitignore vendored Normal file
View File

@@ -0,0 +1,31 @@
# dependencies
node_modules
.pnp
.pnp.js
# testing
coverage
# next.js
.next/
out/
build
# misc
.DS_Store
*.pem
# debug
npm-debug.log*
yarn-debug.log*
yarn-error.log*
# local env files
.env*.local
# vercel
.vercel
# typescript
*.tsbuildinfo
next-env.d.ts

View File

@@ -0,0 +1,919 @@
/*
* Open Agent Documentation Theme
* Overrides nextra-theme-docs to match Open Agent's design system
* Supports both light and dark modes - blue accent
*/
/* ============================================
BASE COLORS & VARIABLES
============================================ */
:root {
--nextra-primary-hue: 217deg; /* Blue */
--nextra-primary-saturation: 91%;
/* Premium easing curves */
--ease-out-expo: cubic-bezier(0.16, 1, 0.3, 1);
--ease-out-quart: cubic-bezier(0.25, 1, 0.5, 1);
--ease-spring: cubic-bezier(0.22, 1, 0.36, 1);
}
/* ============================================
LAYOUT & STRUCTURE
============================================ */
html,
body {
background-color: rgb(var(--background));
}
nav.nextra-nav-container {
background: rgba(255, 255, 255, 0.8);
backdrop-filter: blur(20px) saturate(180%);
-webkit-backdrop-filter: blur(20px) saturate(180%);
border-bottom: 1px solid rgba(0, 0, 0, 0.06);
}
.dark nav.nextra-nav-container {
background: rgba(12, 11, 10, 0.8);
border-bottom: 1px solid rgba(255, 248, 240, 0.06);
}
.nextra-sidebar-container {
background: rgba(249, 250, 251, 0.5);
backdrop-filter: blur(12px) saturate(150%);
-webkit-backdrop-filter: blur(12px) saturate(150%);
border-right: 1px solid rgba(0, 0, 0, 0.06);
}
.dark .nextra-sidebar-container {
background: rgba(24, 23, 22, 0.5);
border-right: 1px solid rgba(255, 248, 240, 0.06);
}
/* Sidebar ambient tint - blue */
.nextra-sidebar-container::before {
content: '';
position: absolute;
inset: 0;
background: linear-gradient(
180deg,
rgba(59, 130, 246, 0.02) 0%,
transparent 30%,
transparent 70%,
rgba(96, 165, 250, 0.015) 100%
);
pointer-events: none;
}
main.nextra-content {
background-color: rgb(var(--background));
}
.nextra-toc {
border-left: 1px solid rgba(0, 0, 0, 0.06);
}
.dark .nextra-toc {
border-left: 1px solid rgba(255, 248, 240, 0.06);
}
/* ============================================
TYPOGRAPHY - Enhanced with Geist
============================================ */
.nextra-content h1,
.nextra-content h2,
.nextra-content h3,
.nextra-content h4 {
color: rgb(var(--foreground));
font-weight: 600;
letter-spacing: -0.01em;
}
.nextra-content h1 {
font-size: 2rem;
letter-spacing: -0.025em;
}
.nextra-content h2 {
font-size: 1.5rem;
margin-top: 2.5rem;
padding-bottom: 0.5rem;
border-bottom: 1px solid rgba(0, 0, 0, 0.06);
}
.dark .nextra-content h2 {
border-bottom-color: rgba(255, 248, 240, 0.06);
}
/* Override any bright white borders from Nextra */
h2,
.nextra-content h2,
article h2 {
border-bottom-color: rgba(0, 0, 0, 0.06);
}
.dark h2,
.dark .nextra-content h2,
.dark article h2 {
border-bottom-color: rgba(255, 248, 240, 0.06);
}
.nextra-content h3 {
font-size: 1.25rem;
margin-top: 2rem;
}
.nextra-content p,
.nextra-content li {
color: rgb(var(--foreground-secondary));
line-height: 1.75;
}
.nextra-content a {
color: rgb(59, 130, 246);
text-decoration: none;
transition: color 150ms var(--ease-out-quart);
}
.nextra-content a:hover {
color: rgb(37, 99, 235);
}
.dark .nextra-content a:hover {
color: rgb(96, 165, 250);
}
.nextra-content strong {
color: rgb(var(--foreground));
font-weight: 600;
}
/* ============================================
NAVIGATION & SIDEBAR - Premium styling
============================================ */
.nextra-sidebar-container a,
aside a,
[class*="sidebar"] a,
nav a:not(.nextra-nav-container a) {
color: rgb(var(--foreground-secondary));
border-radius: 0.5rem;
transition: all 150ms var(--ease-out-quart);
padding: 0.5rem 0.75rem;
border: none;
border-left: none;
}
.nextra-sidebar-container a:hover,
aside a:hover,
[class*="sidebar"] a:hover {
color: rgb(var(--foreground));
background-color: rgba(0, 0, 0, 0.04);
}
.dark .nextra-sidebar-container a:hover,
.dark aside a:hover,
.dark [class*="sidebar"] a:hover {
background-color: rgba(255, 248, 240, 0.04);
}
/* Simplified active state - blue */
.nextra-sidebar-container a[data-active="true"],
.nextra-sidebar-container a.active,
aside a[data-active="true"],
aside a.active,
[class*="sidebar"] a[data-active="true"],
[class*="sidebar"] a.active,
li.active > a:only-child,
a[aria-current="page"] {
color: rgb(59, 130, 246);
background-color: rgba(59, 130, 246, 0.1);
border: none;
border-left: none;
margin-left: 0;
box-shadow: none;
border-radius: 0.5rem;
}
/* Prevent parent containers from inheriting active background */
.nextra-sidebar-container li.active,
.nextra-sidebar-container ul.active,
.nextra-sidebar-container div.active {
background-color: transparent;
}
.nextra-sidebar-container .nextra-menu-title,
aside .nextra-menu-title {
color: rgb(var(--foreground-muted));
font-size: 0.625rem;
text-transform: uppercase;
letter-spacing: 0.1em;
font-weight: 500;
}
/* Sidebar section separators - blue */
aside li:not(:has(a)):not(:has(button)),
.nextra-sidebar-container li:not(:has(a)):not(:has(button)),
aside ul > li:only-child:not(:has(a)),
[class*="sidebar"] li:not(:has(a)):not(:has(button)) {
color: rgb(59, 130, 246);
font-size: 0.6875rem;
font-weight: 600;
text-transform: uppercase;
letter-spacing: 0.08em;
padding: 1rem 0.75rem 0.5rem;
margin-top: 0.5rem;
opacity: 1;
}
/* First separator doesn't need top margin */
aside ul > li:first-child:not(:has(a)):not(:has(button)) {
margin-top: 0;
padding-top: 0.5rem;
}
/* ============================================
TABLE OF CONTENTS - Enhanced styling
============================================ */
.nextra-toc a {
font-size: 0.8125rem;
color: rgb(var(--foreground-muted));
padding: 0.375rem 0.75rem;
border-radius: 0.375rem;
transition: all 150ms var(--ease-out-quart);
display: block;
}
.nextra-toc a:hover {
color: rgb(var(--foreground-secondary));
background: rgba(0, 0, 0, 0.04);
}
.dark .nextra-toc a:hover {
background: rgba(255, 248, 240, 0.04);
}
.nextra-toc a[data-active="true"],
.nextra-toc a.active {
color: rgb(59, 130, 246);
background: rgba(59, 130, 246, 0.1);
}
.nextra-toc-title {
color: rgb(var(--foreground-muted));
font-size: 0.6875rem;
font-weight: 600;
text-transform: uppercase;
letter-spacing: 0.08em;
margin-bottom: 0.5rem;
}
/* ============================================
CODE BLOCKS - Premium styling - blue
============================================ */
.nextra-content code:not(pre code) {
background-color: rgba(0, 0, 0, 0.06);
color: rgb(37, 99, 235);
padding: 0.125rem 0.5rem;
border-radius: 0.375rem;
font-size: 0.875em;
font-family: var(--font-mono, ui-monospace, SFMono-Regular, "SF Mono", Menlo, Consolas, monospace);
}
.dark .nextra-content code:not(pre code) {
background-color: rgba(255, 248, 240, 0.06);
color: rgb(147, 197, 253);
}
pre {
background-color: rgba(249, 250, 251, 0.8);
border: 1px solid rgba(0, 0, 0, 0.06);
border-radius: 0.75rem;
backdrop-filter: blur(8px);
box-shadow:
0 2px 4px rgba(0, 0, 0, 0.04),
0 4px 12px rgba(0, 0, 0, 0.06);
}
.dark pre {
background-color: rgba(24, 23, 22, 0.8);
border: 1px solid rgba(255, 248, 240, 0.06);
box-shadow:
0 2px 4px rgba(0, 0, 0, 0.08),
0 4px 12px rgba(0, 0, 0, 0.12);
}
/* Top edge highlight on code blocks */
pre::before {
content: '';
position: absolute;
top: 0;
left: 0;
right: 0;
height: 1px;
background: linear-gradient(
90deg,
transparent 5%,
rgba(0, 0, 0, 0.04) 50%,
transparent 95%
);
pointer-events: none;
border-radius: 0.75rem 0.75rem 0 0;
}
.dark pre::before {
background: linear-gradient(
90deg,
transparent 5%,
rgba(255, 255, 255, 0.06) 50%,
transparent 95%
);
}
pre code {
font-family: var(--font-mono, ui-monospace, SFMono-Regular, "SF Mono", Menlo, Consolas, monospace);
font-size: 0.875rem;
line-height: 1.6;
}
.nextra-code-block figcaption {
background-color: rgba(0, 0, 0, 0.03);
border-bottom: 1px solid rgba(0, 0, 0, 0.06);
color: rgb(var(--foreground-muted));
font-size: 0.75rem;
font-family: var(--font-mono, ui-monospace, SFMono-Regular, "SF Mono", Menlo, Consolas, monospace);
}
.dark .nextra-code-block figcaption {
background-color: rgba(255, 248, 240, 0.03);
border-bottom: 1px solid rgba(255, 248, 240, 0.06);
}
/* Copy button */
.nextra-code-block button[aria-label*="Copy"] {
background: rgba(0, 0, 0, 0.04);
border: 1px solid rgba(0, 0, 0, 0.08);
border-radius: 0.375rem;
color: rgb(var(--foreground-muted));
transition: all 150ms var(--ease-out-quart);
}
.dark .nextra-code-block button[aria-label*="Copy"] {
background: rgba(255, 248, 240, 0.04);
border: 1px solid rgba(255, 248, 240, 0.08);
}
.nextra-code-block button[aria-label*="Copy"]:hover {
background: rgba(0, 0, 0, 0.08);
border-color: rgba(0, 0, 0, 0.12);
color: rgb(var(--foreground));
}
.dark .nextra-code-block button[aria-label*="Copy"]:hover {
background: rgba(255, 248, 240, 0.08);
border-color: rgba(255, 248, 240, 0.12);
}
/* ============================================
CALLOUTS / ADMONITIONS - Premium styling
============================================ */
.nextra-callout {
border-radius: 0.75rem;
border: 1px solid rgba(0, 0, 0, 0.06);
background-color: rgba(0, 0, 0, 0.02);
backdrop-filter: blur(8px);
position: relative;
overflow: hidden;
}
.dark .nextra-callout {
border: 1px solid rgba(255, 248, 240, 0.06);
background-color: rgba(255, 248, 240, 0.02);
}
/* Top edge highlight */
.nextra-callout::before {
content: '';
position: absolute;
top: 0;
left: 0;
right: 0;
height: 1px;
background: linear-gradient(
90deg,
transparent 5%,
rgba(0, 0, 0, 0.04) 50%,
transparent 95%
);
pointer-events: none;
}
.dark .nextra-callout::before {
background: linear-gradient(
90deg,
transparent 5%,
rgba(255, 255, 255, 0.06) 50%,
transparent 95%
);
}
.nextra-callout[data-type="info"] {
border-color: rgba(59, 130, 246, 0.3);
background-color: rgba(59, 130, 246, 0.05);
}
.nextra-callout[data-type="warning"] {
border-color: rgba(234, 179, 8, 0.3);
background-color: rgba(234, 179, 8, 0.05);
}
.nextra-callout[data-type="error"] {
border-color: rgba(248, 113, 113, 0.3);
background-color: rgba(248, 113, 113, 0.05);
}
/* ============================================
TABLES - Premium styling
============================================ */
.nextra-content table {
border-collapse: collapse;
width: 100%;
margin: 1.5rem 0;
border-radius: 0.75rem;
overflow: hidden;
border: 1px solid rgba(0, 0, 0, 0.06);
}
.dark .nextra-content table {
border: 1px solid rgba(255, 248, 240, 0.06);
}
.nextra-content th {
background-color: rgba(0, 0, 0, 0.04);
color: rgb(var(--foreground-secondary));
font-weight: 500;
font-size: 0.75rem;
text-transform: uppercase;
letter-spacing: 0.05em;
padding: 0.75rem 1rem;
text-align: left;
border-bottom: 1px solid rgba(0, 0, 0, 0.08);
}
.dark .nextra-content th {
background-color: rgba(255, 248, 240, 0.04);
border-bottom: 1px solid rgba(255, 248, 240, 0.08);
}
.nextra-content td {
color: rgb(var(--foreground-secondary));
padding: 0.75rem 1rem;
border-bottom: 1px solid rgba(0, 0, 0, 0.04);
}
.dark .nextra-content td {
border-bottom: 1px solid rgba(255, 248, 240, 0.04);
}
.nextra-content tr:hover td {
background-color: rgba(0, 0, 0, 0.02);
}
.dark .nextra-content tr:hover td {
background-color: rgba(255, 248, 240, 0.02);
}
.nextra-content tr:last-child td {
border-bottom: none;
}
/* ============================================
LISTS
============================================ */
.nextra-content ul,
.nextra-content ol {
padding-left: 1.5rem;
}
.nextra-content li {
margin: 0.5rem 0;
}
.nextra-content li::marker {
color: rgba(59, 130, 246, 0.6);
}
/* ============================================
BLOCKQUOTES - Premium styling - blue
============================================ */
.nextra-content blockquote {
border-left: 3px solid rgba(59, 130, 246, 0.4);
background-color: rgba(59, 130, 246, 0.03);
padding: 1rem 1.5rem;
margin: 1.5rem 0;
border-radius: 0 0.75rem 0.75rem 0;
position: relative;
}
.nextra-content blockquote::before {
content: '';
position: absolute;
top: 0;
left: 3px;
right: 0;
height: 1px;
background: linear-gradient(
90deg,
rgba(59, 130, 246, 0.2) 0%,
transparent 100%
);
pointer-events: none;
}
.nextra-content blockquote p {
color: rgb(var(--foreground-secondary));
font-style: italic;
}
/* ============================================
SEARCH - Premium styling - blue
============================================ */
.nextra-search input {
background-color: rgba(0, 0, 0, 0.04);
border: 1px solid rgba(0, 0, 0, 0.06);
border-radius: 0.5rem;
color: rgb(var(--foreground));
transition: all 150ms var(--ease-out-quart);
}
.dark .nextra-search input {
background-color: rgba(255, 248, 240, 0.04);
border: 1px solid rgba(255, 248, 240, 0.06);
}
.nextra-search input::placeholder {
color: rgb(var(--foreground-muted));
}
.nextra-search input:focus {
border-color: rgba(59, 130, 246, 0.5);
box-shadow: 0 0 0 2px rgba(59, 130, 246, 0.2);
outline: none;
}
.nextra-search-results {
background-color: rgb(255, 255, 255);
border: 1px solid rgba(0, 0, 0, 0.08);
border-radius: 0.75rem;
backdrop-filter: blur(20px);
box-shadow:
0 4px 8px rgba(0, 0, 0, 0.04),
0 8px 24px rgba(0, 0, 0, 0.08),
0 24px 48px rgba(0, 0, 0, 0.1);
}
.dark .nextra-search-results {
background-color: rgb(24, 23, 22);
border: 1px solid rgba(255, 248, 240, 0.08);
box-shadow:
0 4px 8px rgba(0, 0, 0, 0.08),
0 8px 24px rgba(0, 0, 0, 0.14),
0 24px 48px rgba(0, 0, 0, 0.18);
}
/* ============================================
BREADCRUMBS
============================================ */
.nextra-breadcrumb {
color: rgb(var(--foreground-muted));
}
.nextra-breadcrumb a {
color: rgb(var(--foreground-muted));
transition: color 150ms var(--ease-out-quart);
}
.nextra-breadcrumb a:hover {
color: rgb(var(--foreground-secondary));
}
/* ============================================
PAGINATION - Premium styling
============================================ */
.nextra-nav-link {
background-color: rgba(0, 0, 0, 0.02);
border: 1px solid rgba(0, 0, 0, 0.06);
border-radius: 0.75rem;
padding: 1rem;
transition: all 150ms var(--ease-out-quart);
position: relative;
overflow: hidden;
}
.dark .nextra-nav-link {
background-color: rgba(255, 248, 240, 0.02);
border: 1px solid rgba(255, 248, 240, 0.06);
}
/* Top edge highlight */
.nextra-nav-link::before {
content: '';
position: absolute;
top: 0;
left: 0;
right: 0;
height: 1px;
background: linear-gradient(
90deg,
transparent 5%,
rgba(0, 0, 0, 0.04) 50%,
transparent 95%
);
pointer-events: none;
}
.dark .nextra-nav-link::before {
background: linear-gradient(
90deg,
transparent 5%,
rgba(255, 255, 255, 0.04) 50%,
transparent 95%
);
}
.nextra-nav-link:hover {
background-color: rgba(0, 0, 0, 0.04);
border-color: rgba(0, 0, 0, 0.1);
box-shadow:
0 2px 4px rgba(0, 0, 0, 0.04),
0 4px 12px rgba(0, 0, 0, 0.06);
transform: translateY(-1px);
}
.dark .nextra-nav-link:hover {
background-color: rgba(255, 248, 240, 0.04);
border-color: rgba(255, 248, 240, 0.1);
box-shadow:
0 2px 4px rgba(0, 0, 0, 0.08),
0 4px 12px rgba(0, 0, 0, 0.12);
}
.nextra-nav-link span {
color: rgb(var(--foreground-muted));
}
.nextra-nav-link-title {
color: rgb(var(--foreground));
}
/* ============================================
THEME TOGGLE
============================================ */
.nextra-theme-toggle button {
background-color: rgba(0, 0, 0, 0.04);
border: 1px solid rgba(0, 0, 0, 0.06);
border-radius: 0.5rem;
transition: all 150ms var(--ease-out-quart);
}
.dark .nextra-theme-toggle button {
background-color: rgba(255, 248, 240, 0.04);
border: 1px solid rgba(255, 248, 240, 0.06);
}
.nextra-theme-toggle button:hover {
background-color: rgba(0, 0, 0, 0.08);
border-color: rgba(0, 0, 0, 0.1);
}
.dark .nextra-theme-toggle button:hover {
background-color: rgba(255, 248, 240, 0.08);
border-color: rgba(255, 248, 240, 0.1);
}
/* ============================================
SCROLLBARS - Premium styling
============================================ */
::-webkit-scrollbar {
width: 10px;
height: 10px;
}
::-webkit-scrollbar-track {
background: transparent;
}
::-webkit-scrollbar-thumb {
background: rgba(0, 0, 0, 0.08);
border-radius: 100px;
border: 2px solid transparent;
background-clip: padding-box;
}
::-webkit-scrollbar-thumb:hover {
background: rgba(0, 0, 0, 0.15);
border: 2px solid transparent;
background-clip: padding-box;
}
::-webkit-scrollbar-thumb:active {
background: rgba(59, 130, 246, 0.25);
border: 2px solid transparent;
background-clip: padding-box;
}
.dark ::-webkit-scrollbar-thumb {
background: rgba(255, 255, 255, 0.08);
}
.dark ::-webkit-scrollbar-thumb:hover {
background: rgba(255, 255, 255, 0.15);
}
::-webkit-scrollbar-corner {
background: transparent;
}
/* ============================================
MISC OVERRIDES
============================================ */
.nextra-content hr {
border-color: rgba(0, 0, 0, 0.06);
margin: 2rem 0;
}
.dark .nextra-content hr {
border-color: rgba(255, 248, 240, 0.06);
}
::selection {
background: rgba(59, 130, 246, 0.3);
}
*:focus-visible {
outline: none;
box-shadow: 0 0 0 2px rgba(59, 130, 246, 0.4);
}
/* Hide the Nextra footer - not needed */
footer {
display: none;
}
/* ============================================
AGGRESSIVE NEXTRA OVERRIDES
============================================ */
aside a,
aside button,
aside span,
nav:not(.nextra-nav-container) a,
[class*="sidebar"] a,
[class*="menu"] a,
[role="navigation"] a {
color: rgb(var(--foreground-secondary));
}
aside a[data-active="true"],
aside a[aria-current="page"],
aside a[aria-selected="true"],
aside li.active > a,
[class*="sidebar"] a[data-active="true"],
[class*="sidebar"] a.active {
color: rgb(59, 130, 246);
background-color: rgba(59, 130, 246, 0.1);
border: none;
border-left: none;
border-radius: 0.5rem;
}
/* Prevent folder/section containers from getting active background */
aside li.active,
aside div.active,
aside ul.active,
aside details.active,
aside summary.active,
[class*="sidebar"] li.active:not(:has(> a[data-active="true"])),
[class*="sidebar"] div.active,
[class*="sidebar"] ul.active {
background-color: transparent;
}
aside a:hover,
[class*="sidebar"] a:hover {
color: rgb(var(--foreground));
background-color: rgba(0, 0, 0, 0.04);
}
.dark aside a:hover,
.dark [class*="sidebar"] a:hover {
background-color: rgba(255, 248, 240, 0.04);
}
/* ============================================
MOBILE SIDEBAR FIXES
============================================ */
@media (max-width: 767px) {
.nextra-sidebar-container,
aside.nextra-sidebar-container,
[class*="sidebar-container"] {
display: none;
position: fixed;
top: 0;
left: 0;
bottom: 0;
z-index: 50;
width: 280px;
max-width: 80vw;
transform: translateX(-100%);
transition: transform 200ms var(--ease-out-expo);
}
.nextra-sidebar-container[data-open="true"],
aside[data-open="true"],
body.nextra-menu-active .nextra-sidebar-container,
body.nextra-menu-active aside.nextra-sidebar-container {
display: flex;
transform: translateX(0);
}
.nextra-content,
main.nextra-content,
article {
width: 100%;
max-width: 100%;
padding-left: 1rem;
padding-right: 1rem;
}
.nextra-toc,
nav.nextra-toc {
display: none;
}
body.nextra-menu-active::before {
content: "";
position: fixed;
top: 0;
left: 0;
right: 0;
bottom: 0;
background: rgba(0, 0, 0, 0.6);
backdrop-filter: blur(4px);
z-index: 40;
}
}
@media (max-width: 480px) {
.nextra-content h1 {
font-size: 1.5rem;
}
.nextra-content h2 {
font-size: 1.25rem;
}
.nextra-content h3 {
font-size: 1.1rem;
}
pre code {
font-size: 0.8rem;
}
.nextra-callout {
padding: 0.75rem;
}
}
/* Force all borders/separators to be subtle */
hr,
[class*="border"],
h2,
article h2,
.nextra-content h2 {
border-color: rgba(0, 0, 0, 0.06);
}
.dark hr,
.dark [class*="border"],
.dark h2,
.dark article h2,
.dark .nextra-content h2 {
border-color: rgba(255, 248, 240, 0.06);
}

View File

@@ -0,0 +1,50 @@
import type { ReactNode } from "react";
import { Layout, Navbar } from "nextra-theme-docs";
import { getPageMap } from "nextra/page-map";
import "nextra-theme-docs/style.css";
import "./docs.css";
// Custom logo component
function Logo() {
return (
<div style={{ display: "inline-flex", alignItems: "center", gap: 10 }}>
<span
style={{
fontWeight: 600,
fontSize: 16,
color: "rgb(var(--foreground))",
}}
>
Open Agent
</span>
</div>
);
}
export default async function DocsLayout({
children,
}: {
children: ReactNode;
}) {
const navbar = (
<Navbar
logo={<Logo />}
logoLink="/"
projectLink="https://github.com/Th0rgal/openagent"
/>
);
// Get the full page map
const pageMap = await getPageMap("/");
return (
<Layout
navbar={navbar}
editLink="Edit this page on GitHub"
docsRepositoryBase="https://github.com/Th0rgal/openagent/blob/main/docs-site"
sidebar={{ defaultMenuCollapseLevel: 1 }}
pageMap={pageMap}
footer={null}
>
{children}
</Layout>
);
}

View File

@@ -0,0 +1,39 @@
import type { Metadata } from "next";
import { generateStaticParamsFor, importPage } from "nextra/pages";
import type { ComponentType, ReactNode } from "react";
import { useMDXComponents as getMDXComponents } from "../../mdx-components";
export const generateStaticParams = generateStaticParamsFor("mdxPath");
type PageParams = { mdxPath: string[] };
type PageProps = { params: Promise<PageParams> };
export async function generateMetadata(props: PageProps): Promise<Metadata> {
const params = await props.params;
const { metadata } = await importPage(params.mdxPath);
return metadata as Metadata;
}
type WrapperProps = {
toc: unknown;
metadata: unknown;
sourceCode: unknown;
children: ReactNode;
};
const Wrapper = getMDXComponents().wrapper as ComponentType<WrapperProps>;
export default async function Page(props: PageProps) {
const params = await props.params;
const {
default: MDXContent,
toc,
metadata,
sourceCode,
} = await importPage(params.mdxPath);
return (
<Wrapper toc={toc} metadata={metadata} sourceCode={sourceCode}>
<MDXContent params={params} />
</Wrapper>
);
}

View File

@@ -0,0 +1,219 @@
import { NextRequest, NextResponse } from "next/server";
import { readFile, readdir, stat } from "fs/promises";
import { join } from "path";
// Content directory relative to project root
const CONTENT_DIR = join(process.cwd(), "content");
/**
* Strip frontmatter from MDX/markdown content
* Frontmatter is the YAML block between --- markers at the start
*/
function stripFrontmatter(content: string): string {
const frontmatterRegex = /^---\s*\n[\s\S]*?\n---\s*\n/;
return content.replace(frontmatterRegex, "").trim();
}
/**
* Extract frontmatter metadata from MDX content
*/
function extractFrontmatter(content: string): Record<string, string> {
const match = content.match(/^---\s*\n([\s\S]*?)\n---/);
if (!match) return {};
const frontmatter: Record<string, string> = {};
const lines = match[1].split("\n");
for (const line of lines) {
const [key, ...valueParts] = line.split(":");
if (key && valueParts.length) {
frontmatter[key.trim()] = valueParts.join(":").trim();
}
}
return frontmatter;
}
/**
* List all available documentation files
*/
async function listDocs(dir: string = CONTENT_DIR, prefix: string = ""): Promise<string[]> {
const entries = await readdir(dir, { withFileTypes: true });
const docs: string[] = [];
for (const entry of entries) {
// Skip meta files and hidden files
if (entry.name.startsWith("_") || entry.name.startsWith(".")) continue;
const fullPath = join(dir, entry.name);
const relativePath = prefix ? `${prefix}/${entry.name}` : entry.name;
if (entry.isDirectory()) {
docs.push(...(await listDocs(fullPath, relativePath)));
} else if (entry.name.endsWith(".mdx") || entry.name.endsWith(".md")) {
// Convert filename to URL path (remove extension)
const urlPath = relativePath.replace(/\.(mdx?|md)$/, "");
docs.push(urlPath);
}
}
return docs;
}
/**
* GET /api/docs/[...slug]
*
* Serves raw markdown content for AI agents and programmatic access.
*
* Special paths:
* - /api/docs/_index → List all available docs (JSON)
* - /api/docs/_all → All docs concatenated (Markdown)
*
* Examples:
* - /api/docs/index.md → Raw markdown for homepage
* - /api/docs/mission-api → Raw markdown (extension optional)
*/
export async function GET(
request: NextRequest,
{ params }: { params: Promise<{ slug: string[] }> }
) {
const { slug } = await params;
const path = slug.join("/");
// Special route: list all docs
if (path === "_index") {
try {
const docs = await listDocs();
return NextResponse.json({
description: "Open Agent Documentation Index",
docs: docs.map((doc) => ({
path: doc,
url: `/api/docs/${doc}`,
html_url: `/${doc === "index" ? "" : doc}`,
})),
});
} catch {
return NextResponse.json({ error: "Failed to list docs" }, { status: 500 });
}
}
// Special route: all docs concatenated
if (path === "_all") {
try {
const docs = await listDocs();
const contents: string[] = [
"# Open Agent - Complete Documentation",
"",
"> This file contains all documentation concatenated for AI agent consumption.",
"",
"---",
"",
];
for (const docPath of docs) {
const filePath = join(CONTENT_DIR, `${docPath}.mdx`);
try {
const content = await readFile(filePath, "utf-8");
const frontmatter = extractFrontmatter(content);
const markdown = stripFrontmatter(content);
contents.push(`# ${frontmatter.title || docPath}`);
contents.push("");
if (frontmatter.description) {
contents.push(`> ${frontmatter.description}`);
contents.push("");
}
contents.push(markdown);
contents.push("");
contents.push("---");
contents.push("");
} catch {
// Try .md extension as fallback
try {
const mdPath = join(CONTENT_DIR, `${docPath}.md`);
const content = await readFile(mdPath, "utf-8");
contents.push(stripFrontmatter(content));
contents.push("");
contents.push("---");
contents.push("");
} catch {
// Skip if file not found
}
}
}
return new NextResponse(contents.join("\n"), {
headers: {
"Content-Type": "text/markdown; charset=utf-8",
"Cache-Control": "public, max-age=3600",
"X-Content-Type-Options": "nosniff",
},
});
} catch {
return NextResponse.json({ error: "Failed to compile docs" }, { status: 500 });
}
}
// Regular doc request - normalize path
let normalizedPath = path.replace(/\.md$/, ""); // Strip .md extension if present
// Try to find the file
const possiblePaths = [
join(CONTENT_DIR, `${normalizedPath}.mdx`),
join(CONTENT_DIR, `${normalizedPath}.md`),
join(CONTENT_DIR, normalizedPath, "index.mdx"),
join(CONTENT_DIR, normalizedPath, "index.md"),
];
let content: string | null = null;
let foundPath: string | null = null;
for (const filePath of possiblePaths) {
try {
const stats = await stat(filePath);
if (stats.isFile()) {
content = await readFile(filePath, "utf-8");
foundPath = filePath;
break;
}
} catch {
// File doesn't exist, try next
}
}
if (!content || !foundPath) {
return NextResponse.json(
{
error: "Document not found",
path: normalizedPath,
suggestion: "Use /api/docs/_index to list available documents",
},
{ status: 404 }
);
}
// Extract metadata and strip frontmatter
const frontmatter = extractFrontmatter(content);
const markdown = stripFrontmatter(content);
// Build response with optional metadata header
const includeMetadata = request.nextUrl.searchParams.get("metadata") === "true";
let responseContent = markdown;
if (includeMetadata && (frontmatter.title || frontmatter.description)) {
const metaLines = [];
if (frontmatter.title) metaLines.push(`# ${frontmatter.title}`);
if (frontmatter.description) metaLines.push(`> ${frontmatter.description}`);
if (metaLines.length) {
responseContent = metaLines.join("\n") + "\n\n" + markdown;
}
}
return new NextResponse(responseContent, {
headers: {
"Content-Type": "text/markdown; charset=utf-8",
"Cache-Control": "public, max-age=3600",
"X-Content-Type-Options": "nosniff",
"X-Doc-Title": frontmatter.title || normalizedPath,
"X-Doc-Path": normalizedPath,
},
});
}

481
docs-site/app/globals.css Normal file
View File

@@ -0,0 +1,481 @@
@import "tailwindcss";
/*
* Open Agent Documentation
* Design System: Warm dark palette with blue accent
*/
/* =============================================================================
FONTS - Geist (matching desktop app)
============================================================================= */
@font-face {
font-family: "Geist";
src: url("/fonts/Geist-VariableFont_wght.ttf") format("truetype-variations");
font-weight: 300 700;
font-display: swap;
}
@font-face {
font-family: "Geist Mono";
src: url("/fonts/GeistMono-VariableFont_wght.ttf") format("truetype-variations");
font-weight: 300 700;
font-display: swap;
}
/* =============================================================================
DESIGN TOKENS
============================================================================= */
:root {
/* Typography */
--font-sans: "Geist", -apple-system, BlinkMacSystemFont, "Segoe UI", Roboto, sans-serif;
--font-mono: "Geist Mono", ui-monospace, SFMono-Regular, "SF Mono", Menlo, Consolas, monospace;
/* Premium easing curves */
--ease-out-expo: cubic-bezier(0.16, 1, 0.3, 1);
--ease-out-quart: cubic-bezier(0.25, 1, 0.5, 1);
--ease-spring: cubic-bezier(0.22, 1, 0.36, 1);
--ease-smooth: cubic-bezier(0.4, 0, 0.2, 1);
/* Light mode backgrounds */
--background: 255 255 255; /* #ffffff */
--background-elevated: 249 250 251; /* #f9fafb */
--background-tertiary: 243 244 246; /* #f3f4f6 */
/* Text hierarchy - light mode */
--foreground: 17 24 39; /* #111827 */
--foreground-secondary: 75 85 99; /* #4b5563 */
--foreground-tertiary: 107 114 128; /* #6b7280 */
--foreground-muted: 156 163 175; /* #9ca3af */
/* Borders - light mode */
--border: 0 0 0 / 0.06;
--border-elevated: 0 0 0 / 0.1;
--border-strong: 0 0 0 / 0.15;
/* Blue accent - matching dashboard */
--accent: 59 130 246; /* #3B82F6 - blue-500 */
--accent-hover: 37 99 235; /* #2563EB - blue-600 */
--accent-light: 96 165 250; /* #60A5FA - blue-400 */
--accent-foreground: 255 255 255;
/* Semantic colors */
--success: 34 197 94;
--warning: 234 179 8;
--error: 239 68 68;
--info: 59 130 246;
/* Glow colors - blue */
--glow-primary: rgba(59, 130, 246, 0.15);
--glow-secondary: rgba(96, 165, 250, 0.12);
/* Premium shadows (layered for depth) - lighter for light mode */
--shadow-sm:
0 1px 2px rgba(0, 0, 0, 0.05),
0 2px 4px rgba(0, 0, 0, 0.05);
--shadow-md:
0 2px 4px rgba(0, 0, 0, 0.04),
0 4px 12px rgba(0, 0, 0, 0.06),
0 8px 24px rgba(0, 0, 0, 0.06);
--shadow-lg:
0 4px 8px rgba(0, 0, 0, 0.04),
0 8px 24px rgba(0, 0, 0, 0.08),
0 24px 48px rgba(0, 0, 0, 0.1);
--shadow-xl:
0 8px 16px rgba(0, 0, 0, 0.06),
0 16px 32px rgba(0, 0, 0, 0.1),
0 32px 64px rgba(0, 0, 0, 0.12);
/* Button shadows */
--shadow-button:
0 1px 2px rgba(0, 0, 0, 0.1),
0 2px 4px rgba(0, 0, 0, 0.08);
--shadow-button-hover:
0 2px 4px rgba(0, 0, 0, 0.1),
0 4px 8px rgba(0, 0, 0, 0.08);
/* Ring for focus states */
--ring: 59 130 246 / 0.4;
}
/* Dark mode tokens */
.dark {
/* Warm dark backgrounds */
--background: 12 11 10; /* #0c0b0a */
--background-elevated: 24 23 22; /* #181716 */
--background-tertiary: 30 29 27; /* #1e1d1b */
/* Text hierarchy - warm whites */
--foreground: 245 240 235;
--foreground-secondary: 176 168 159;
--foreground-tertiary: 122 116 110;
--foreground-muted: 90 85 80;
/* Borders - subtle, warm */
--border: 255 248 240 / 0.06;
--border-elevated: 255 248 240 / 0.1;
--border-strong: 255 248 240 / 0.15;
/* Blue accent - slightly lighter for dark mode */
--accent-hover: 96 165 250; /* #60A5FA - blue-400 */
--accent-light: 147 197 253; /* #93C5FD - blue-300 */
/* Premium shadows (layered for depth) - darker for dark mode */
--shadow-sm:
0 1px 2px rgba(0, 0, 0, 0.2),
0 2px 4px rgba(0, 0, 0, 0.2);
--shadow-md:
0 2px 4px rgba(0, 0, 0, 0.15),
0 4px 12px rgba(0, 0, 0, 0.2),
0 8px 24px rgba(0, 0, 0, 0.2);
--shadow-lg:
0 4px 8px rgba(0, 0, 0, 0.15),
0 8px 24px rgba(0, 0, 0, 0.25),
0 24px 48px rgba(0, 0, 0, 0.3);
--shadow-xl:
0 8px 16px rgba(0, 0, 0, 0.2),
0 16px 32px rgba(0, 0, 0, 0.25),
0 32px 64px rgba(0, 0, 0, 0.3);
/* Button shadows */
--shadow-button:
0 1px 2px rgba(0, 0, 0, 0.3),
0 2px 4px rgba(0, 0, 0, 0.25),
inset 0 1px 0 rgba(255, 255, 255, 0.1);
--shadow-button-hover:
0 2px 4px rgba(0, 0, 0, 0.3),
0 4px 8px rgba(0, 0, 0, 0.25),
inset 0 1px 0 rgba(255, 255, 255, 0.1);
}
/* =============================================================================
BASE STYLES
============================================================================= */
html {
color-scheme: light;
background: rgb(var(--background));
overflow-x: hidden;
}
html.dark {
color-scheme: dark;
}
body {
font-family: var(--font-sans);
background: rgb(var(--background));
color: rgb(var(--foreground));
letter-spacing: -0.01em;
-webkit-font-smoothing: antialiased;
-moz-osx-font-smoothing: grayscale;
min-height: 100dvh;
}
/* Safari/iOS overscroll background fix */
html::before {
content: '';
position: fixed;
top: -100vh;
left: 0;
right: 0;
height: 300vh;
background: rgb(var(--background));
z-index: -9999;
pointer-events: none;
}
/* =============================================================================
PREMIUM SCROLLBAR
============================================================================= */
::-webkit-scrollbar {
width: 10px;
height: 10px;
}
::-webkit-scrollbar-track {
background: transparent;
}
::-webkit-scrollbar-thumb {
background: rgba(0, 0, 0, 0.08);
border-radius: 100px;
border: 2px solid transparent;
background-clip: padding-box;
}
::-webkit-scrollbar-thumb:hover {
background: rgba(0, 0, 0, 0.15);
border: 2px solid transparent;
background-clip: padding-box;
}
::-webkit-scrollbar-thumb:active {
background: rgba(59, 130, 246, 0.25);
border: 2px solid transparent;
background-clip: padding-box;
}
::-webkit-scrollbar-corner {
background: transparent;
}
/* Dark mode scrollbar */
.dark ::-webkit-scrollbar-thumb {
background: rgba(255, 255, 255, 0.08);
}
.dark ::-webkit-scrollbar-thumb:hover {
background: rgba(255, 255, 255, 0.15);
}
/* =============================================================================
GLASS EFFECTS
============================================================================= */
.glass {
background: rgb(var(--background-elevated) / 0.7);
backdrop-filter: blur(20px) saturate(180%);
-webkit-backdrop-filter: blur(20px) saturate(180%);
}
.glass-subtle {
background: rgb(var(--background-elevated) / 0.5);
backdrop-filter: blur(12px) saturate(150%);
-webkit-backdrop-filter: blur(12px) saturate(150%);
}
/* Glass with ambient tint - blue */
.glass-tinted {
background: rgb(var(--background-elevated) / 0.7);
backdrop-filter: blur(20px) saturate(180%);
-webkit-backdrop-filter: blur(20px) saturate(180%);
position: relative;
}
.glass-tinted::before {
content: '';
position: absolute;
inset: 0;
background: linear-gradient(
90deg,
rgba(59, 130, 246, 0.02) 0%,
transparent 50%,
rgba(96, 165, 250, 0.015) 100%
);
pointer-events: none;
border-radius: inherit;
}
/* =============================================================================
ELEVATION SURFACES
============================================================================= */
.surface-0 {
background: rgb(var(--background));
}
.surface-1 {
background: rgb(var(--background-elevated));
}
.surface-2 {
background: rgb(var(--background-tertiary));
}
/* =============================================================================
FOCUS STYLES
============================================================================= */
*:focus {
outline: none;
}
*:focus-visible {
outline: none;
}
button:focus-visible,
a:focus-visible,
[role="button"]:focus-visible {
outline: none;
box-shadow: 0 0 0 2px rgb(var(--accent) / 0.4);
}
input:focus-visible,
textarea:focus-visible,
select:focus-visible {
outline: none;
border-color: rgba(59, 130, 246, 0.5) !important;
box-shadow: 0 0 0 2px rgb(var(--accent) / 0.2);
}
/* =============================================================================
TYPOGRAPHY UTILITIES
============================================================================= */
.tabular-nums {
font-variant-numeric: tabular-nums;
}
.font-mono {
font-family: var(--font-mono);
}
/* =============================================================================
SELECTION
============================================================================= */
::selection {
background: rgb(var(--accent) / 0.3);
}
/* =============================================================================
ANIMATIONS
============================================================================= */
@keyframes fade-in {
from { opacity: 0; }
to { opacity: 1; }
}
@keyframes text-rotate {
0% {
opacity: 0;
transform: translateY(8px);
filter: blur(4px);
}
15% {
opacity: 1;
transform: translateY(0);
filter: blur(0);
}
85% {
opacity: 1;
transform: translateY(0);
filter: blur(0);
}
100% {
opacity: 0;
transform: translateY(-8px);
filter: blur(4px);
}
}
@keyframes slide-up {
from {
opacity: 0;
transform: translateY(4px);
}
to {
opacity: 1;
transform: translateY(0);
}
}
@keyframes scale-in {
from {
opacity: 0;
transform: scale(0.95);
}
to {
opacity: 1;
transform: scale(1);
}
}
.animate-fade-in {
animation: fade-in 150ms var(--ease-out-quart);
}
.animate-text-rotate {
animation: text-rotate 3s ease-in-out forwards;
}
.animate-slide-up {
animation: slide-up 200ms var(--ease-out-expo);
}
.animate-scale-in {
animation: scale-in 200ms var(--ease-spring);
}
/* =============================================================================
BACKGROUND MESH GRADIENT - blue
============================================================================= */
.bg-mesh-subtle {
background:
radial-gradient(ellipse at 20% 0%, rgba(59, 130, 246, 0.03) 0%, transparent 50%),
radial-gradient(ellipse at 80% 100%, rgba(96, 165, 250, 0.02) 0%, transparent 50%),
rgb(var(--background));
}
/* =============================================================================
CARD UTILITIES
============================================================================= */
.card {
background: rgb(var(--background-elevated));
border: 1px solid rgb(var(--border));
border-radius: 12px;
transition: all 200ms var(--ease-out-quart);
}
.card:hover {
border-color: rgb(var(--border-elevated));
box-shadow: var(--shadow-md);
}
/* Card with top edge highlight */
.card-highlight {
position: relative;
overflow: hidden;
}
.card-highlight::before {
content: '';
position: absolute;
top: 0;
left: 0;
right: 0;
height: 1px;
background: linear-gradient(
90deg,
transparent 5%,
rgba(255, 255, 255, 0.08) 50%,
transparent 95%
);
pointer-events: none;
}
/* =============================================================================
BUTTON UTILITIES
============================================================================= */
.btn-primary {
background: rgb(var(--accent));
color: white;
box-shadow: var(--shadow-button);
transition: all 150ms var(--ease-out-quart);
}
.btn-primary:hover {
background: rgb(var(--accent-hover));
box-shadow: var(--shadow-button-hover);
transform: translateY(-1px);
}
.btn-primary:active {
transform: scale(0.98) translateY(0);
box-shadow: var(--shadow-sm);
}
.btn-secondary {
background: rgb(var(--background-elevated));
border: 1px solid rgb(var(--border));
color: rgb(var(--foreground-secondary));
box-shadow: var(--shadow-sm);
transition: all 150ms var(--ease-out-quart);
}
.btn-secondary:hover {
border-color: rgb(var(--border-elevated));
color: rgb(var(--foreground));
box-shadow: var(--shadow-md);
transform: translateY(-1px);
}
.btn-secondary:active {
transform: scale(0.98) translateY(0);
box-shadow: none;
}

108
docs-site/app/layout.tsx Normal file
View File

@@ -0,0 +1,108 @@
import type { Metadata, Viewport } from "next";
import type { ReactNode } from "react";
import { Head } from "nextra/components";
import { ThemeProvider } from "@/components/theme-provider";
import "./globals.css";
export const viewport: Viewport = {
themeColor: [
{ media: "(prefers-color-scheme: light)", color: "#ffffff" },
{ media: "(prefers-color-scheme: dark)", color: "#0c0b0a" },
],
colorScheme: "light dark",
width: "device-width",
initialScale: 1,
viewportFit: "cover",
};
export const metadata: Metadata = {
metadataBase: new URL("https://openagent.thomas.md"),
title: {
default: "Open Agent | Managed Control Plane for AI Agents",
template: "%s | Open Agent",
},
description:
"Open-source managed control plane for OpenCode-based agents. Mission orchestration, workspace management, and library sync.",
applicationName: "Open Agent",
generator: "Next.js",
keywords: [
"ai agent",
"opencode",
"claude",
"automation",
"orchestration",
"workspace",
"mcp",
"model context protocol",
],
authors: [{ name: "Thomas Marchand", url: "https://thomas.md" }],
creator: "Thomas Marchand",
publisher: "Open Agent",
robots: {
index: true,
follow: true,
},
twitter: {
card: "summary_large_image",
title: "Open Agent",
description:
"Open-source managed control plane for OpenCode-based agents. Mission orchestration, workspace management, and library sync.",
creator: "@music_music_yo",
images: ["/og-image.png"],
},
openGraph: {
type: "website",
locale: "en_US",
url: "https://openagent.thomas.md",
siteName: "Open Agent",
title: "Open Agent",
description:
"Open-source managed control plane for OpenCode-based agents. Mission orchestration, workspace management, and library sync.",
images: [
{
url: "/og-image.png",
width: 1200,
height: 630,
alt: "Open Agent - Managed Control Plane for AI Agents",
},
],
},
icons: {
icon: "/favicon.svg",
apple: "/apple-touch-icon.png",
},
appleWebApp: {
capable: true,
statusBarStyle: "black-translucent",
title: "Open Agent",
},
other: {
"msapplication-TileColor": "#0c0b0a",
},
};
export default function RootLayout({
children,
}: {
children: ReactNode;
}) {
return (
<html lang="en" dir="ltr" suppressHydrationWarning>
<Head>
<meta
name="theme-color"
media="(prefers-color-scheme: light)"
content="#ffffff"
/>
<meta
name="theme-color"
media="(prefers-color-scheme: dark)"
content="#0c0b0a"
/>
</Head>
<body className="min-h-dvh bg-mesh-subtle">
<ThemeProvider>{children}</ThemeProvider>
</body>
</html>
);
}

1127
docs-site/bun.lock Normal file

File diff suppressed because it is too large Load Diff

View File

@@ -0,0 +1,16 @@
"use client";
import * as React from "react";
import { ThemeProvider as NextThemesProvider } from "next-themes";
export function ThemeProvider({
children,
}: {
children: React.ReactNode;
}) {
return (
<NextThemesProvider attribute="class" defaultTheme="system" enableSystem>
{children}
</NextThemesProvider>
);
}

View File

@@ -0,0 +1,17 @@
export default {
index: "Overview",
setup: "Setup",
"first-mission": "First Mission",
"-- Concepts": {
type: "separator",
title: "Concepts",
},
library: "Library",
workspaces: "Workspaces",
"-- Reference": {
type: "separator",
title: "Reference",
},
api: "API Reference",
desktop: "Desktop Automation",
};

363
docs-site/content/api.mdx Normal file
View File

@@ -0,0 +1,363 @@
---
title: API Reference
description: Complete API documentation for Open Agent
---
# API Reference
All endpoints require authentication via `Authorization: Bearer <token>` header.
Get a token by logging in:
```bash
curl -X POST "https://agent.yourdomain.com/api/auth/login" \
-H "Content-Type: application/json" \
-d '{"password": "your-password"}'
```
---
## Missions
Missions are agent tasks with conversation history.
### Create Mission
```http
POST /api/control/missions
```
```json
{
"title": "My Mission",
"workspace_id": "uuid",
"agent": "code-reviewer",
"model_override": "anthropic/claude-sonnet-4-20250514"
}
```
All fields optional. Returns `Mission` object.
### Load Mission
```http
POST /api/control/missions/:id/load
```
Loads a mission into the active control session. Required before sending messages.
### Send Message
```http
POST /api/control/message
```
```json
{
"content": "Your message here",
"agent": "optional-agent-override"
}
```
Returns `{"id": "uuid", "queued": false}`. If `queued: true`, another message is processing.
### Cancel
```http
POST /api/control/cancel # Current mission
POST /api/control/missions/:id/cancel # Specific mission
```
### Set Status
```http
POST /api/control/missions/:id/status
```
```json
{"status": "completed"}
```
Statuses: `pending`, `active`, `completed`, `failed`, `interrupted`
### Get Events
```http
GET /api/control/missions/:id/events?types=user_message,assistant_message&limit=100&offset=0
```
Returns stored events for replay/debugging.
### Stream Events (SSE)
```http
GET /api/control/stream
```
Server-Sent Events for real-time updates:
| Event | Description |
|-------|-------------|
| `status` | Control state changed |
| `user_message` | User message received |
| `assistant_message` | Agent response complete |
| `thinking` | Agent reasoning |
| `tool_call` | Tool invocation |
| `tool_result` | Tool output |
| `error` | Error occurred |
### Other Mission Endpoints
| Endpoint | Method | Description |
|----------|--------|-------------|
| `/api/control/missions` | GET | List all missions |
| `/api/control/missions/:id` | GET | Get mission details |
| `/api/control/missions/:id` | DELETE | Delete mission |
| `/api/control/missions/:id/resume` | POST | Resume interrupted mission |
| `/api/control/tree` | GET | Live agent tree |
### Mission Object
```json
{
"id": "uuid",
"status": "active",
"title": "My Mission",
"workspace_id": "uuid",
"workspace_name": "my-workspace",
"agent": "code-reviewer",
"model_override": null,
"created_at": "2025-01-13T10:00:00Z",
"updated_at": "2025-01-13T10:05:00Z"
}
```
---
## Workspaces
Workspaces are isolated environments for agent execution.
### List Workspaces
```http
GET /api/workspaces
```
### Create Workspace
```http
POST /api/workspaces
```
```json
{
"name": "my-workspace",
"workspace_type": "host",
"path": "/path/to/workspace",
"skills": ["skill-name"],
"tools": ["tool-name"],
"template": "template-name",
"distro": "ubuntu-noble",
"env_vars": {"KEY": "VALUE"},
"init_script": "#!/bin/bash\napt install -y nodejs"
}
```
| Field | Required | Description |
|-------|----------|-------------|
| `name` | Yes | Human-readable name |
| `workspace_type` | No | `host` or `chroot` (default: `host`) |
| `template` | No | Template name (forces `chroot`) |
| `distro` | No | `ubuntu-noble`, `ubuntu-jammy`, `debian-bookworm`, `arch-linux` |
| `skills` | No | Library skill names |
| `init_script` | No | Script to run on container build |
### Update Workspace
```http
PUT /api/workspaces/:id
```
### Delete Workspace
```http
DELETE /api/workspaces/:id
```
### Build Container
```http
POST /api/workspaces/:id/build
```
```json
{"distro": "ubuntu-noble", "rebuild": true}
```
Build runs in background. Poll status to check completion.
### Execute Command
```http
POST /api/workspaces/:id/exec
```
```json
{
"command": "ls -la",
"cwd": "subdirectory",
"timeout_secs": 60,
"env": {"MY_VAR": "value"}
}
```
Response:
```json
{
"exit_code": 0,
"stdout": "...",
"stderr": "",
"timed_out": false
}
```
### Sync Skills
```http
POST /api/workspaces/:id/sync
```
Syncs skills/tools from Library to `.opencode/` directory.
### Debug Endpoints
For troubleshooting container builds:
```http
GET /api/workspaces/:id/debug # Container state info
GET /api/workspaces/:id/init-log # Init script output
POST /api/workspaces/:id/rerun-init # Re-run init without rebuild
```
### Workspace Object
```json
{
"id": "uuid",
"name": "my-workspace",
"workspace_type": "chroot",
"path": "/path/to/workspace",
"status": "ready",
"error_message": null,
"skills": ["skill-1"],
"distro": "ubuntu-noble",
"created_at": "2025-01-13T10:00:00Z"
}
```
Status: `pending`, `building`, `ready`, `error`
---
## Library
Manage skills, commands, rules, agents, and templates.
### List Items
```http
GET /api/library/skill
GET /api/library/command
GET /api/library/rule
GET /api/library/agent
GET /api/library/mcp
GET /api/library/workspace-template
```
### Get Item
```http
GET /api/library/{type}/{name}
```
### Save Item
```http
PUT /api/library/{type}/{name}
```
### Delete Item
```http
DELETE /api/library/{type}/{name}
```
### Sync Library
```http
POST /api/library/sync
```
Pulls latest from git and updates workspaces.
---
## System
### Health Check
```http
GET /api/health
```
### System Stats
```http
GET /api/stats
```
Returns CPU, memory, disk, network usage.
### AI Providers
```http
GET /api/providers # List configured providers
POST /api/providers/:id/auth # Start OAuth flow
DELETE /api/providers/:id/auth # Remove auth
```
---
## Authentication
### Login
```http
POST /api/auth/login
```
```json
{"password": "your-password"}
```
Returns: `{"token": "jwt-token", "expires_at": "..."}`
### Multi-user Login
```http
POST /api/auth/login
```
```json
{"username": "alice", "password": "alice-password"}
```
### Verify Token
```http
GET /api/auth/verify
```
Returns `200` if valid, `401` if expired/invalid.

View File

@@ -0,0 +1,328 @@
---
title: Desktop Automation
description: Headless browser and GUI control for agents
---
# Desktop Automation
Run browsers and GUI applications in headless mode. The agent can take screenshots, click, type, and extract text, enabling web automation and visual tasks.
This is optional. Skip if you don't need browser control.
## Overview
The desktop automation stack consists of:
- **Xvfb**: Virtual framebuffer for headless X11
- **i3**: Minimal, deterministic window manager
- **xdotool**: Keyboard and mouse automation
- **scrot**: Screenshot capture
- **Chromium**: Web browser
- **AT-SPI2**: Accessibility tree extraction
- **Tesseract**: OCR fallback for text extraction
## Installation (Ubuntu/Debian)
```bash
# Update package list
apt update
# Install core X11 and window manager
apt install -y xvfb i3 x11-utils
# Install automation tools
apt install -y xdotool scrot imagemagick
# Install Chromium browser
apt install -y chromium chromium-sandbox
# Install accessibility tools (AT-SPI2)
apt install -y at-spi2-core libatspi2.0-0 python3-gi python3-gi-cairo gir1.2-atspi-2.0
# Install OCR
apt install -y tesseract-ocr
# Install fonts for proper rendering
apt install -y fonts-liberation fonts-dejavu-core
```
## i3 Configuration
Create a minimal, deterministic i3 config at `/root/.config/i3/config`:
```bash
mkdir -p /root/.config/i3
cat > /root/.config/i3/config << 'EOF'
# Open Agent i3 Config - Minimal and Deterministic
# No decorations, no animations, simple layout
# Use Super (Mod4) as modifier
set $mod Mod4
# Font for window titles (not shown due to no decorations)
font pango:DejaVu Sans Mono 10
# Remove window decorations
default_border none
default_floating_border none
# No gaps
gaps inner 0
gaps outer 0
# Focus follows mouse (predictable behavior)
focus_follows_mouse no
# Disable window titlebars completely
for_window [class=".*"] border pixel 0
# Make all windows float by default for easier positioning
# (comment out if you prefer tiling)
# for_window [class=".*"] floating enable
# Chromium-specific: maximize and remove sandbox issues
for_window [class="Chromium"] border pixel 0
for_window [class="chromium"] border pixel 0
# Keybindings (minimal set)
bindsym $mod+Return exec chromium --no-sandbox --disable-gpu
bindsym $mod+Shift+q kill
bindsym $mod+d exec dmenu_run
# Focus movement
bindsym $mod+h focus left
bindsym $mod+j focus down
bindsym $mod+k focus up
bindsym $mod+l focus right
# Exit i3
bindsym $mod+Shift+e exit
# Reload config
bindsym $mod+Shift+r reload
# Workspace setup (just workspace 1)
workspace 1 output primary
EOF
```
## Environment Variables
Add these to `/etc/open_agent/open_agent.env`:
```bash
# Enable desktop automation tools
DESKTOP_ENABLED=true
# Xvfb resolution (width x height)
DESKTOP_RESOLUTION=1920x1080
# Starting display number (will increment for concurrent sessions)
DESKTOP_DISPLAY_START=99
```
## Manual Testing
Test the setup manually before enabling for the agent:
```bash
# Start Xvfb on display :99
Xvfb :99 -screen 0 1920x1080x24 &
export DISPLAY=:99
# Start i3 window manager
i3 &
# Launch Chromium
chromium --no-sandbox --disable-gpu &
# Take a screenshot
sleep 2
scrot /tmp/test_screenshot.png
# Verify screenshot exists
ls -la /tmp/test_screenshot.png
# Test xdotool
xdotool getactivewindow
# Clean up
pkill -f "Xvfb :99"
```
## AT-SPI Accessibility Tree
Test accessibility tree extraction:
```bash
export DISPLAY=:99
export DBUS_SESSION_BUS_ADDRESS=unix:path=/tmp/dbus-session-$$
# Start dbus session (required for AT-SPI)
dbus-daemon --session --fork --address=$DBUS_SESSION_BUS_ADDRESS
# Python script to dump accessibility tree
python3 << 'EOF'
import gi
gi.require_version('Atspi', '2.0')
from gi.repository import Atspi
def print_tree(obj, indent=0):
try:
name = obj.get_name() or ""
role = obj.get_role_name()
if name or role != "unknown":
print(" " * indent + f"[{role}] {name}")
for i in range(obj.get_child_count()):
child = obj.get_child_at_index(i)
if child:
print_tree(child, indent + 1)
except Exception as e:
pass
desktop = Atspi.get_desktop(0)
for i in range(desktop.get_child_count()):
app = desktop.get_child_at_index(i)
if app:
print_tree(app)
EOF
```
## OCR with Tesseract
Test OCR on a screenshot:
```bash
# Take screenshot and run OCR
DISPLAY=:99 scrot /tmp/screen.png
tesseract /tmp/screen.png stdout
# With language hint
tesseract /tmp/screen.png stdout -l eng
```
## Troubleshooting
### Xvfb won't start
```bash
# Check if display is already in use
ls -la /tmp/.X*-lock
# Remove stale lock files
rm -f /tmp/.X99-lock /tmp/.X11-unix/X99
```
### Chromium sandbox issues
Always use `--no-sandbox` flag when running as root:
```bash
chromium --no-sandbox --disable-gpu
```
### xdotool can't find windows
```bash
# List all windows
xdotool search --name ""
# Ensure DISPLAY is set
echo $DISPLAY
```
### AT-SPI not working
```bash
# Ensure dbus is running
export $(dbus-launch)
# Enable AT-SPI for Chromium
chromium --force-renderer-accessibility --no-sandbox
```
### No fonts rendering
```bash
# Install additional fonts
apt install -y fonts-noto fonts-freefont-ttf
# Rebuild font cache
fc-cache -fv
```
## Security Considerations
- The agent runs with full system access
- Xvfb sessions are isolated per-task
- Sessions are cleaned up when tasks complete
- Chromium runs with `--no-sandbox` (required for root, but limits isolation)
- Consider running in a container for additional isolation
## Window Layout with i3-msg
The `desktop_i3_command` tool allows the agent to control window positioning using i3-msg.
### Creating a Multi-Window Layout
Example: Chrome on left, terminal with fastfetch top-right, calculator bottom-right:
```bash
# Start session
desktop_start_session
# Launch Chrome (takes left half by default in tiling mode)
i3-msg exec chromium --no-sandbox
# Prepare to split the right side horizontally
i3-msg split h
# Split right side vertically for stacked windows
i3-msg focus right
i3-msg split v
# Launch terminal with fastfetch (top-right)
i3-msg exec xterm -e fastfetch
# Launch calculator (bottom-right)
i3-msg exec xcalc
```
### Common i3-msg Commands
| Command | Description |
|---------|-------------|
| `exec <app>` | Launch an application |
| `split h` | Next window opens horizontally adjacent |
| `split v` | Next window opens vertically adjacent |
| `focus left/right/up/down` | Move focus to adjacent window |
| `move left/right/up/down` | Move focused window |
| `resize grow width 100 px` | Make window wider |
| `resize grow height 100 px` | Make window taller |
| `layout splitv/splith` | Change container layout |
| `fullscreen toggle` | Toggle fullscreen |
| `kill` | Close focused window |
### Pre-installed Applications
These are installed on the production server:
- `chromium --no-sandbox` - Web browser
- `xterm` - Terminal emulator
- `xcalc` - Calculator
- `fastfetch` - System info display
## Session Lifecycle
1. **Task starts**: Agent calls `desktop_start_session`
2. **Xvfb starts**: Virtual display created at `:99` (or next available)
3. **i3 starts**: Window manager provides predictable layout
4. **Browser launches**: Chromium opens (if requested)
5. **Agent works**: Screenshots, clicks, typing via desktop_* tools
6. **Task ends**: `desktop_stop_session` kills Xvfb and children
7. **Cleanup**: Any orphaned sessions killed on task failure
## Available Desktop Tools
| Tool | Description |
|------|-------------|
| `desktop_start_session` | Start Xvfb + i3 + optional Chromium |
| `desktop_stop_session` | Stop the desktop session |
| `desktop_screenshot` | Take screenshot (saves locally) |
| `desktop_type` | Send keyboard input (text or keys) |
| `desktop_click` | Mouse click at coordinates |
| `desktop_mouse_move` | Move mouse cursor |
| `desktop_scroll` | Scroll mouse wheel |
| `desktop_get_text` | Extract visible text (AT-SPI or OCR) |
| `desktop_i3_command` | Execute i3-msg commands for window control |

View File

@@ -0,0 +1,111 @@
---
title: First Mission
description: Run your first agent task and learn the basics
---
# Your First Mission
You've got Open Agent running. Now let's use it.
## Start a Mission
1. Open your dashboard at `https://agent.yourdomain.com`
2. Log in with your password
3. Click **New Mission** (or press `N`)
4. Type a simple task:
```
List the files in /root and tell me what you see.
```
5. Press Enter and watch
## What You'll See
The mission view shows:
- **Message stream**: Your messages and the agent's responses
- **Tool calls**: Every command the agent runs (with expandable output)
- **Thinking**: The agent's reasoning (if the model supports it)
- **Cost**: Running total of API usage
The agent will execute `ls -la /root`, read the output, and explain what it found.
## Try Something More Interesting
Here are good first tasks to understand capabilities:
**Code exploration:**
```
Clone https://github.com/Th0rgal/open-agent and summarize
what the main components do. Focus on src/api/.
```
**System administration:**
```
Check disk usage, memory, and running services on this server.
Highlight anything that looks unusual.
```
**File operations:**
```
Create a Python script in /tmp that fetches weather for Paris
and prints it. Use the wttr.in API. Then run it.
```
## Understanding the Output
### Tool Calls
Every action the agent takes appears as a tool call:
```
[Tool: bash]
Input: ls -la /root
Output: total 48
drwx------ 8 root root 4096 Jan 15 10:30 .
...
```
Click to expand and see full input/output.
### Mission Status
Missions have these states:
| Status | Meaning |
|--------|---------|
| `active` | Agent is working |
| `completed` | Agent finished successfully |
| `failed` | Something went wrong |
| `interrupted` | You cancelled it |
## Canceling a Mission
If the agent goes off track or you want to stop it:
1. Click **Cancel** in the top right
2. Or press `Esc`
The agent will stop after its current tool call completes.
## Workspaces
By default, missions run in the **host workspace**, directly on your server with full access.
For isolation, create a **container workspace**:
1. Go to **Workspaces** in the sidebar
2. Click **New Workspace**
3. Choose a template or configure manually
4. Select the workspace when starting a new mission
Container workspaces run in isolated Linux environments (systemd-nspawn). The agent can't affect your host system.
→ Learn more in [Workspaces](/workspaces)
## Next Steps
- **[Library](/library)**: Customize agent behavior with skills and rules
- **[Workspaces](/workspaces)**: Create isolated environments for different projects
- **[API Reference](/api)**: Integrate Open Agent into your own tools

100
docs-site/content/index.mdx Normal file
View File

@@ -0,0 +1,100 @@
---
title: Open Agent
description: Self-hosted control plane for autonomous AI agents
---
import { Callout } from 'nextra/components'
<Callout type="info">
**Are you an AI?** Check [/llms.txt](/llms.txt) or add `.md` to any page URL to get raw markdown and save tokens.
</Callout>
# Open Agent
Run AI coding agents on your own server. Start a task, close your laptop, check back tomorrow.
## The Problem
You're using Claude Code or Cursor. It's good. But:
- **Sessions time out.** That 4-hour refactor? Dead at 80%.
- **Your code goes somewhere.** Fine for side projects. Not fine for work.
- **You can't customize it.** Same generic behavior for every project.
Open Agent fixes all three.
## What It Actually Does
Open Agent is a control plane that runs on your server. You connect to it from a web dashboard, iOS app, or CLI. You tell an agent what to do. It works until it's done, whether that takes 10 minutes or 10 hours.
Your code stays on your machine. You define how the agent behaves through a git repo of skills, commands, and rules. Each project gets its own isolated container.
Under the hood, it runs [OpenCode](https://opencode.ai) for the actual agent work. Open Agent handles everything around it: starting tasks, streaming progress, managing environments, syncing configuration.
## When To Use This
**You have a task that takes hours.**
Point an agent at a GitHub issue before bed. Wake up to a PR. Review the diff, not the process.
**Your code can't leave your machines.**
Healthcare, finance, defense, or just "my company said no." Run local inference or route API calls through your own infrastructure.
**You want agents that know your codebase.**
Write skills that encode your team's patterns. "When touching the payments module, always run the fraud detection tests." The agent follows them.
**You run multiple projects with different needs.**
Container workspaces isolate everything. Your Node project doesn't see your Python project. Different tools, different rules, no cross-contamination.
## How It Works
```
┌─────────────────────────────────────────────────────────┐
│ Your Devices │
│ ┌─────────────┐ ┌─────────────┐ ┌─────────────────┐ │
│ │ Web Dashboard│ │ iOS App │ │ Claude Code CLI │ │
│ └──────┬──────┘ └──────┬──────┘ └────────┬────────┘ │
└─────────┼────────────────┼──────────────────┼───────────┘
│ │ │
└────────────────┼──────────────────┘
│ HTTPS
┌─────────────────────────────────────────────────────────┐
│ Your Server (~$30/mo) │
│ ┌─────────────────────────────────────────────────┐ │
│ │ Open Agent │ │
│ │ • Starts and monitors agent tasks │ │
│ │ • Manages isolated container workspaces │ │
│ │ • Syncs skills and rules from your git repo │ │
│ └──────────────────────┬──────────────────────────┘ │
│ │ │
│ ┌──────────────────────▼──────────────────────────┐ │
│ │ OpenCode │ │
│ │ • Runs the actual agent logic │ │
│ │ • Executes tools, writes code │ │
│ │ • Talks to Claude/GPT/local models │ │
│ └─────────────────────────────────────────────────┘ │
└─────────────────────────────────────────────────────────┘
```
Open Agent doesn't do AI. It manages the infrastructure around AI. OpenCode does the thinking. Open Agent makes sure it has somewhere to run, something to work on, and a way for you to watch.
**Note**: The setup guide installs the **backend** on your server. For the dashboard, you have options:
- **Vercel**: Host the Next.js dashboard for free
- **Local**: Run `bun dev` in the dashboard folder on your machine
- **iOS**: Download the app and enter your server URL
## Setup Takes 5 Minutes
If you have a server and a domain, tell Claude or Cursor:
> "Deploy Open Agent on my server at `1.2.3.4` with domain `agent.example.com`. Read INSTALL.md for the full guide."
It handles systemd, nginx, SSL, everything. Or [do it manually](/setup) if you prefer.
Don't have a server? Get one from [Hetzner](https://www.hetzner.com/) or DigitalOcean for ~$30/month.
## Next
1. **[Setup](/setup)** to get Open Agent running on your server
2. **[First Mission](/first-mission)** to run your first agent task
3. **[Library](/library)** to customize how your agents behave

View File

@@ -0,0 +1,204 @@
---
title: Library
description: Git-backed configuration for agent behavior
---
# Library
The Library is a git repository containing your agent's configuration. Everything that defines how your agent behaves lives here.
## Structure
```
library/
├── skill/ # Reusable capabilities
│ └── python-dev.md
├── command/ # Slash commands (/deploy, /test)
│ └── deploy.md
├── rule/ # Behavior constraints
│ └── no-production.md
├── agent/ # Agent personalities
│ └── code-reviewer.md
├── mcp/ # MCP server configs
│ └── playwright.json
└── workspace-template/ # Container blueprints
└── nodejs.json
```
## Why Git-Backed?
- **Version control**: Track changes to agent behavior like code
- **Review workflow**: PRs for configuration changes
- **Rollback**: Revert to working configs when something breaks
- **Sharing**: Fork and customize other people's setups
## Getting Started
Fork the [template library](https://github.com/Th0rgal/openagent-library-template) and customize it:
```bash
git clone https://github.com/Th0rgal/openagent-library-template my-library
cd my-library
git remote set-url origin git@github.com:you/your-library.git
git push -u origin main
```
Then set `LIBRARY_REMOTE` in your Open Agent config to point to your repo.
## Skills
Skills are markdown files that give the agent specialized knowledge and instructions for specific domains.
**Example: `skill/python-dev.md`**
```markdown
# Python Development
When working with Python code:
- Use type hints for all function signatures
- Prefer f-strings over .format() or %
- Use pathlib instead of os.path
- Run `ruff check` before committing
- Use pytest for testing, not unittest
Standard project structure:
├── src/
│ └── package_name/
├── tests/
├── pyproject.toml
└── README.md
```
Skills are synced to workspaces based on configuration. A workspace can have multiple skills.
## Commands
Commands are slash-triggered actions (like `/deploy` or `/test`).
**Example: `command/deploy.md`**
```markdown
# /deploy
Deploy the current project to production.
Steps:
1. Run tests: `pytest`
2. If tests pass, run: `./scripts/deploy.sh`
3. Verify deployment at the production URL
4. Report success or failure
```
Commands appear in the dashboard's command palette.
## Rules
Rules constrain agent behavior. They define things the agent should always or never do.
**Example: `rule/no-production.md`**
```markdown
# No Production Changes
NEVER:
- Modify files in /var/www/production
- Run commands with `--production` flag
- Access the production database directly
ALWAYS:
- Use staging/dev environments for testing
- Ask for confirmation before any production-adjacent operation
```
Rules are global and apply to all missions.
## Agents
Agents are personalities with specific behaviors and defaults.
**Example: `agent/code-reviewer.md`**
```markdown
# Code Reviewer
You are a senior code reviewer focused on:
- Security vulnerabilities
- Performance issues
- Code clarity and maintainability
- Test coverage
For each file, provide:
1. Summary of changes
2. Issues found (if any)
3. Suggestions for improvement
Be constructive. Praise good patterns. Flag serious issues clearly.
```
Select an agent when starting a mission.
## MCP Servers
MCP (Model Context Protocol) servers provide tools and resources to agents.
**Example: `mcp/playwright.json`**
```json
{
"name": "playwright",
"description": "Browser automation",
"command": "bunx",
"args": ["@anthropic/mcp-playwright"],
"env": {}
}
```
MCP servers run on the host and are available to all missions.
## Workspace Templates
Templates define pre-configured container environments.
**Example: `workspace-template/nodejs.json`**
```json
{
"name": "nodejs",
"description": "Node.js development environment",
"distro": "ubuntu-noble",
"skills": ["typescript-dev"],
"env_vars": {
"NODE_ENV": "development"
},
"init_script": "#!/bin/bash\napt update\napt install -y nodejs npm\nnpm install -g typescript"
}
```
Create workspaces from templates for consistent, reproducible environments.
## Scoping
| Type | Scope | Applied To |
|------|-------|------------|
| Skills | Per-workspace | Synced to `.opencode/skill/` |
| Commands | Global | Available everywhere |
| Rules | Global | All missions |
| Agents | Global | Selectable per-mission |
| MCPs | Global | Run on host |
| Templates | Per-workspace | Container creation |
## Syncing
Open Agent automatically syncs your Library:
1. Pulls latest changes from `LIBRARY_REMOTE`
2. Copies relevant files to workspace `.opencode/` directories
3. Updates OpenCode's global config
Force a sync via the dashboard: **Library → Sync**
## Next Steps
- **[Workspaces](/workspaces)**: Create isolated environments that use your skills
- **[API Reference](/api)**: Programmatic access to Library management

175
docs-site/content/setup.mdx Normal file
View File

@@ -0,0 +1,175 @@
---
title: Setup
description: Get Open Agent running on your server
---
# Setup
Choose your path based on comfort level. All paths end up with the same result.
## Quick Path: AI-Assisted (5 min)
Have an AI agent deploy for you. Open Claude, Cursor, or any coding assistant with terminal access and say:
```
Deploy Open Agent on my server.
- Server IP: YOUR_IP
- Domain: agent.yourdomain.com
- I have SSH access as root
Read INSTALL.md from the Open Agent repo for the full guide.
```
The AI will handle systemd services, nginx/Caddy, SSL certificates, and everything else.
**Requirements:**
- A dedicated server ([~$30/month from Hetzner](https://www.hetzner.com/), DigitalOcean, Vultr, etc.)
- Ubuntu 24.04 LTS
- A domain pointed to your server IP
- SSH key access to the server
## Standard Path: Manual (20 min)
If you prefer doing it yourself, here's the condensed version.
### 1. Install dependencies
```bash
apt update && apt install -y \
ca-certificates curl git jq unzip tar \
build-essential pkg-config libssl-dev \
systemd-container debootstrap
# Install Bun (for OpenCode plugins)
curl -fsSL https://bun.sh/install | bash
install -m 0755 /root/.bun/bin/bun /usr/local/bin/bun
# Install Rust
curl -fsSL https://sh.rustup.rs | sh -s -- -y
source /root/.cargo/env
```
### 2. Install OpenCode
```bash
curl -fsSL https://opencode.ai/install | bash -s -- --no-modify-path
install -m 0755 /root/.opencode/bin/opencode /usr/local/bin/opencode
```
### 3. Clone and build Open Agent
```bash
mkdir -p /opt/open_agent
cd /opt/open_agent
git clone https://github.com/Th0rgal/open-agent vaduz-v1
cd vaduz-v1
cargo build --bin open_agent --bin host-mcp --bin desktop-mcp
install -m 0755 target/debug/open_agent /usr/local/bin/open_agent
install -m 0755 target/debug/host-mcp /usr/local/bin/host-mcp
install -m 0755 target/debug/desktop-mcp /usr/local/bin/desktop-mcp
```
### 4. Configure
Create `/etc/open_agent/open_agent.env`:
```bash
mkdir -p /etc/open_agent
cat > /etc/open_agent/open_agent.env << 'EOF'
OPENCODE_BASE_URL=http://127.0.0.1:4096
OPENCODE_PERMISSIVE=true
HOST=0.0.0.0
PORT=3000
WORKING_DIR=/root
DEV_MODE=false
DASHBOARD_PASSWORD=change-me-to-something-strong
JWT_SECRET=change-me-to-a-long-random-string
EOF
```
### 5. Create systemd services
**OpenCode** (`/etc/systemd/system/opencode.service`):
```ini
[Unit]
Description=OpenCode Server
After=network.target
[Service]
Type=simple
ExecStart=/usr/local/bin/opencode serve --port 4096 --hostname 127.0.0.1
WorkingDirectory=/root
Restart=always
Environment=HOME=/root
[Install]
WantedBy=multi-user.target
```
**Open Agent** (`/etc/systemd/system/open_agent.service`):
```ini
[Unit]
Description=Open Agent
After=network-online.target
Wants=network-online.target
[Service]
Type=simple
EnvironmentFile=/etc/open_agent/open_agent.env
ExecStart=/usr/local/bin/open_agent
WorkingDirectory=/root
Restart=on-failure
[Install]
WantedBy=multi-user.target
```
### 6. Start services
```bash
systemctl daemon-reload
systemctl enable --now opencode.service
systemctl enable --now open_agent.service
```
### 7. Set up reverse proxy (Caddy)
```bash
apt install -y debian-keyring debian-archive-keyring apt-transport-https
curl -1sLf 'https://dl.cloudsmith.io/public/caddy/stable/gpg.key' | gpg --dearmor -o /usr/share/keyrings/caddy-stable-archive-keyring.gpg
curl -1sLf 'https://dl.cloudsmith.io/public/caddy/stable/debian.deb.txt' | tee /etc/apt/sources.list.d/caddy-stable.list
apt update && apt install caddy
echo 'agent.yourdomain.com {
reverse_proxy localhost:3000
}' > /etc/caddy/Caddyfile
systemctl enable --now caddy
```
### 8. Test
Open `https://agent.yourdomain.com` in your browser. Log in with your dashboard password.
## Accessing the Dashboard
This guide installs the **backend** on your server. The dashboard (frontend) is separate:
| Option | How | Best For |
|--------|-----|----------|
| **Vercel** | Deploy `dashboard/` folder to Vercel, set `NEXT_PUBLIC_API_URL` to your backend | Production, always accessible |
| **Local** | Run `bun dev` in `dashboard/` folder | Development, quick testing |
| **iOS App** | Enter your backend URL in the app settings | Mobile access |
The backend URL is your server domain (e.g., `https://agent.yourdomain.com`). All dashboard options connect to the same backend.
## Full Documentation
For advanced options (container workspaces, desktop automation, Tailscale exit nodes, multi-user auth), see the complete [INSTALL.md](https://github.com/Th0rgal/open-agent/blob/master/INSTALL.md) in the repository.
## Next: Your First Mission
Once you're logged into the dashboard, head to [First Mission](/first-mission) to run your first agent task.

View File

@@ -0,0 +1,217 @@
---
title: Workspaces
description: Isolated environments for agent tasks
---
# Workspaces
Workspaces are isolated environments where agents execute tasks. Each workspace has its own file system, installed software, and configuration.
## Types
### Host Workspace
The default workspace. Runs directly on your server.
- Full access to the host file system
- Uses globally installed tools
- No isolation (agent can affect system state)
- Zero setup required
Good for: trusted tasks, system administration, quick experiments.
### Container Workspace
Isolated Linux environment using systemd-nspawn.
- Separate file system (can't see host files)
- Own installed packages
- Destroyed and recreated cleanly
- Template-based setup
Good for: untrusted code, experiments, reproducible environments.
## Creating Workspaces
### Via Dashboard
1. Go to **Workspaces** in sidebar
2. Click **New Workspace**
3. Choose:
- **Host**: Direct execution on server
- **Container**: Isolated environment
4. If container:
- Select a **template** or configure manually
- Choose **distro** (Ubuntu Noble, Jammy, Debian, Arch)
- Add **skills** and **tools** from your Library
5. Click **Create**
For containers, click **Build** to create the environment (takes 1-3 minutes).
### Via API
```bash
curl -X POST "https://agent.yourdomain.com/api/workspaces" \
-H "Authorization: Bearer $TOKEN" \
-H "Content-Type: application/json" \
-d '{
"name": "python-project",
"workspace_type": "chroot",
"distro": "ubuntu-noble",
"skills": ["python-dev"],
"init_script": "#!/bin/bash\napt install -y python3 python3-pip"
}'
```
## Container Lifecycle
```
pending → building → ready → (in use) → (destroyed)
↑ ↓
└── rebuild ←───────┘
```
| Status | Meaning |
|--------|---------|
| `pending` | Created but not built |
| `building` | debootstrap + init script running |
| `ready` | Available for missions |
| `error` | Build failed (check logs) |
## Configuration
### Skills and Tools
Workspaces can have skills and tools from your Library:
```json
{
"skills": ["python-dev", "docker"],
"tools": ["custom-linter"]
}
```
These are synced to `.opencode/skill/` and `.opencode/tool/` inside the workspace.
### Environment Variables
Set environment variables available to all commands:
```json
{
"env_vars": {
"NODE_ENV": "development",
"DATABASE_URL": "postgres://localhost/dev"
}
}
```
### Init Script
For containers, the init script runs during build:
```bash
#!/bin/bash
apt update
apt install -y nodejs npm python3 python3-pip
pip3 install poetry
npm install -g typescript
```
## Templates
Templates are reusable workspace configurations stored in your Library.
**Create a template:**
```json
// library/workspace-template/fullstack.json
{
"name": "fullstack",
"description": "Node + Python development",
"distro": "ubuntu-noble",
"skills": ["typescript-dev", "python-dev"],
"env_vars": {
"NODE_ENV": "development"
},
"init_script": "#!/bin/bash\napt install -y nodejs npm python3 python3-pip"
}
```
**Use a template:**
```bash
curl -X POST "https://agent.yourdomain.com/api/workspaces" \
-H "Authorization: Bearer $TOKEN" \
-d '{"name": "my-project", "template": "fullstack"}'
```
## Executing Commands
Run commands in a workspace:
```bash
curl -X POST "https://agent.yourdomain.com/api/workspaces/$ID/exec" \
-H "Authorization: Bearer $TOKEN" \
-d '{"command": "python3 --version"}'
```
Response:
```json
{
"exit_code": 0,
"stdout": "Python 3.12.0\n",
"stderr": "",
"timed_out": false
}
```
## Debugging Container Builds
If a container build fails:
1. **Check status**:
```bash
GET /api/workspaces/$ID
# Look at error_message field
```
2. **View init log**:
```bash
GET /api/workspaces/$ID/init-log
# Returns stdout/stderr from init script
```
3. **Get debug info**:
```bash
GET /api/workspaces/$ID/debug
# Container state, directory structure, etc.
```
4. **Re-run init** (without full rebuild):
```bash
POST /api/workspaces/$ID/rerun-init
# Faster iteration on init script fixes
```
## Distros
| Distro | ID | Notes |
|--------|-----|-------|
| Ubuntu 24.04 | `ubuntu-noble` | Recommended |
| Ubuntu 22.04 | `ubuntu-jammy` | LTS |
| Debian 12 | `debian-bookworm` | Stable |
| Arch Linux | `arch-linux` | Rolling release |
## Best Practices
1. **Use containers for untrusted code.** Never run unknown code in host workspace.
2. **Create templates for repeated setups.** Don't rebuild the same environment.
3. **Keep init scripts idempotent.** Should work on re-run.
4. **Version your templates.** They're in your Library git repo.
## Next Steps
- **[API Reference](/api)**: Full workspace API documentation
- **[Desktop Automation](/desktop)**: Run browsers in workspaces

View File

@@ -0,0 +1,77 @@
import { useMDXComponents as getDocsMDXComponents } from "nextra-theme-docs";
import type { ReactNode } from "react";
const docsComponents = getDocsMDXComponents();
// Custom feature card component
function FeatureCard({
title,
children,
icon,
}: {
title: string;
children: ReactNode;
icon?: string;
}) {
return (
<div
style={{
padding: "1.25rem",
borderRadius: "0.75rem",
backgroundColor: "rgba(255, 248, 240, 0.02)",
border: "1px solid rgba(255, 248, 240, 0.06)",
marginBottom: "1rem",
}}
>
<div style={{ display: "flex", alignItems: "center", gap: "0.5rem", marginBottom: "0.5rem" }}>
{icon && <span style={{ fontSize: "1.25rem" }}>{icon}</span>}
<h4 style={{ margin: 0, fontSize: "0.875rem", fontWeight: 600, color: "rgb(245, 240, 235)" }}>
{title}
</h4>
</div>
<div style={{ color: "rgba(245, 240, 235, 0.6)", fontSize: "0.875rem" }}>{children}</div>
</div>
);
}
// Custom badge component with blue accent
function Badge({
children,
variant = "default",
}: {
children: ReactNode;
variant?: "default" | "success" | "warning" | "error";
}) {
const colors = {
default: { bg: "rgba(59, 130, 246, 0.1)", text: "rgb(59, 130, 246)" },
success: { bg: "rgba(124, 207, 155, 0.1)", text: "rgb(124, 207, 155)" },
warning: { bg: "rgba(244, 178, 127, 0.1)", text: "rgb(244, 178, 127)" },
error: { bg: "rgba(248, 113, 113, 0.1)", text: "rgb(248, 113, 113)" },
};
const { bg, text } = colors[variant];
return (
<span
style={{
display: "inline-flex",
padding: "0.25rem 0.625rem",
borderRadius: "0.375rem",
fontSize: "0.625rem",
fontWeight: 600,
textTransform: "uppercase",
letterSpacing: "0.05em",
backgroundColor: bg,
color: text,
}}
>
{children}
</span>
);
}
export const useMDXComponents = (components?: Record<string, unknown>) => ({
...docsComponents,
FeatureCard,
Badge,
...(components || {}),
});

19
docs-site/next.config.mjs Normal file
View File

@@ -0,0 +1,19 @@
import nextra from "nextra";
const withNextra = nextra({
latex: true,
search: {
codeblocks: false,
},
contentDirBasePath: "/",
});
export default withNextra({
reactStrictMode: true,
experimental: {
optimizeCss: false,
},
images: {
formats: ["image/avif", "image/webp"],
},
});

32
docs-site/package.json Normal file
View File

@@ -0,0 +1,32 @@
{
"name": "openagent-docs",
"version": "0.1.0",
"private": true,
"type": "module",
"description": "Open Agent documentation website",
"scripts": {
"dev": "next dev -p 3002",
"build": "next build",
"start": "next start -p 3002",
"lint": "eslint app components"
},
"dependencies": {
"@remixicon/react": "^4.7.0",
"@tailwindcss/postcss": "^4.1.0",
"clsx": "^2.1.1",
"next": "^16.0.7",
"next-themes": "^0.4.6",
"nextra": "^4.6.1",
"nextra-theme-docs": "^4.6.1",
"react": "^19.2.1",
"react-dom": "^19.2.1",
"tailwind-merge": "^2.6.0",
"tailwindcss": "^4.1.0"
},
"devDependencies": {
"@types/node": "^24.10.1",
"@types/react": "19.2.7",
"@types/react-dom": "^19.2.3",
"typescript": "^5.9.3"
}
}

View File

@@ -0,0 +1,5 @@
export default {
plugins: {
"@tailwindcss/postcss": {},
},
};

99
docs-site/proxy.ts Normal file
View File

@@ -0,0 +1,99 @@
import { NextRequest, NextResponse } from "next/server";
/**
* Known LLM/AI user agent patterns
* These agents get raw markdown automatically
*/
const LLM_USER_AGENTS = [
"ChatGPT-User", // OpenAI ChatGPT browsing
"GPTBot", // OpenAI crawler
"Claude-Web", // Anthropic (if they add browsing)
"ClaudeBot", // Anthropic crawler
"PerplexityBot", // Perplexity AI
"Applebot", // Apple Intelligence/Siri
"cohere-ai", // Cohere
"anthropic-ai", // Anthropic
"Google-Extended", // Google AI (Bard/Gemini)
"CCBot", // Common Crawl (used by many LLMs)
];
/**
* Check if user agent is an LLM/AI agent
*/
function isLLMUserAgent(userAgent: string | null): boolean {
if (!userAgent) return false;
return LLM_USER_AGENTS.some(bot =>
userAgent.toLowerCase().includes(bot.toLowerCase())
);
}
/**
* Proxy to serve raw markdown for AI agents
*
* Routes to raw markdown API when:
* 1. URL ends with .md extension (e.g., /setup.md)
* 2. Accept header includes text/markdown
* 3. Query param ?format=md is present
* 4. User-Agent is a known LLM (ChatGPT, GPTBot, PerplexityBot, etc.)
*
* This allows AI agents to fetch documentation as raw markdown
* while browsers get the rendered HTML version.
*
* Note: In Next.js 16+, middleware.ts was renamed to proxy.ts
* See: https://nextjs.org/docs/messages/middleware-to-proxy
*/
export function proxy(request: NextRequest) {
const { pathname } = request.nextUrl;
// Skip API routes, static files, and Next.js internals
if (
pathname.startsWith("/api/") ||
pathname.startsWith("/_next/") ||
pathname.startsWith("/static/") ||
pathname.includes(".") && !pathname.endsWith(".md") // Has extension but not .md
) {
return NextResponse.next();
}
const userAgent = request.headers.get("User-Agent");
// Check if this is a docs page request that wants markdown
const wantsMarkdown =
pathname.endsWith(".md") ||
request.headers.get("Accept")?.includes("text/markdown") ||
request.nextUrl.searchParams.get("format") === "md" ||
isLLMUserAgent(userAgent);
if (wantsMarkdown) {
// Normalize the path (remove .md extension if present)
let docPath = pathname.replace(/\.md$/, "");
// Handle root path
if (docPath === "" || docPath === "/") {
docPath = "/index";
}
// Preserve query params except format
const url = new URL(request.url);
url.pathname = `/api/docs${docPath}`;
url.searchParams.delete("format");
// Rewrite to the API route (internal redirect, URL doesn't change for client)
return NextResponse.rewrite(url);
}
return NextResponse.next();
}
// Only run proxy on relevant paths
export const config = {
matcher: [
/*
* Match all paths except:
* - API routes (already handled)
* - Static files with extensions (images, css, js, etc.)
* - Next.js internals
*/
"/((?!api|_next/static|_next/image|favicon.ico|.*\\.[^m][^d]$).*)",
],
};

Binary file not shown.

After

Width:  |  Height:  |  Size: 46 KiB

File diff suppressed because one or more lines are too long

After

Width:  |  Height:  |  Size: 20 KiB

View File

@@ -0,0 +1 @@
# Download Geist fonts from https://vercel.com/font/geist

Binary file not shown.

Binary file not shown.

87
docs-site/public/llms.txt Normal file
View File

@@ -0,0 +1,87 @@
# Open Agent Documentation
# For AI Agents and LLMs
# https://openagent.thomas.md
> Open Agent is a self-hosted control plane for autonomous AI agents.
> Isolated Linux workspaces, git-backed configuration, remote mission control.
## How to Access Documentation
All docs are available as raw markdown:
1. **Automatic**: If your User-Agent is a known LLM (ChatGPT, GPTBot, PerplexityBot, ClaudeBot, etc.), you automatically get raw markdown
2. **Add .md extension**: `/setup.md` returns raw markdown
3. **Accept header**: `Accept: text/markdown`
4. **Query param**: `/setup?format=md`
5. **API directly**: `/api/docs/setup`
Known LLM User-Agents that get markdown automatically:
ChatGPT-User, GPTBot, Claude-Web, ClaudeBot, PerplexityBot, Applebot, cohere-ai, anthropic-ai, Google-Extended, CCBot
## Documentation Index
### Getting Started
- /index.md - Overview: what Open Agent is and why
- /setup.md - Installation guide (AI-assisted and manual)
- /first-mission.md - Your first 10 minutes after setup
### Core Concepts
- /library.md - Git-backed config: skills, commands, rules, MCPs
- /workspaces.md - Isolated environments (host and containers)
### Reference
- /api.md - Complete API reference (missions, workspaces, library)
- /desktop.md - Headless browser and GUI automation (optional)
### Special Endpoints
- /api/docs/_index - JSON index of all documents
- /api/docs/_all - All documentation concatenated (for full context loading)
## Quick Reference
### Architecture
```
Your Devices (Dashboard, iOS, CLI)
▼ HTTPS
┌───────────────────────────┐
│ Your Server │
│ ┌─────────────────────┐ │
│ │ Open Agent │ │ ← Orchestration, workspaces, library
│ └──────────┬──────────┘ │
│ ▼ │
│ ┌─────────────────────┐ │
│ │ OpenCode │ │ ← Model inference, tool execution
│ └─────────────────────┘ │
└───────────────────────────┘
```
### Scoping Model
| Scope | Components |
|-------|------------|
| Global | Auth, providers, MCPs, agents, commands, rules |
| Per-Workspace | Skills, tools, plugins, installed software |
| Per-Mission | Agent selection, workspace, conversation |
### Key API Endpoints
```
POST /api/control/missions # Create mission
POST /api/control/missions/:id/load # Load mission
POST /api/control/message # Send message to agent
GET /api/control/stream # SSE event stream
POST /api/workspaces # Create workspace
POST /api/workspaces/:id/exec # Execute command
```
## For AI Agents Deploying Open Agent
1. Ask user for: server IP, domain, SSH access
2. Read `/api/docs/_all` for complete installation context
3. Or read `/setup.md` for condensed instructions
4. Full manual: INSTALL.md in the repository
## Source
- Repository: https://github.com/Th0rgal/open-agent
- Library Template: https://github.com/Th0rgal/openagent-library-template
- Docs: https://openagent.thomas.md

Binary file not shown.

After

Width:  |  Height:  |  Size: 59 KiB

View File

@@ -0,0 +1,9 @@
# Open Agent Documentation
# https://openagent.thomas.md
User-agent: *
Allow: /
# AI agent documentation index
# See /llms.txt for structured documentation access
Sitemap: https://openagent.thomas.md/sitemap.xml

34
docs-site/tsconfig.json Normal file
View File

@@ -0,0 +1,34 @@
{
"compilerOptions": {
"target": "ES2022",
"lib": ["dom", "dom.iterable", "es2022"],
"allowJs": false,
"skipLibCheck": true,
"strict": true,
"noEmit": true,
"esModuleInterop": true,
"module": "ESNext",
"moduleResolution": "Bundler",
"resolveJsonModule": true,
"isolatedModules": true,
"jsx": "react-jsx",
"incremental": true,
"plugins": [
{
"name": "next"
}
],
"baseUrl": ".",
"paths": {
"@/*": ["./*"]
}
},
"include": [
"next-env.d.ts",
"**/*.ts",
"**/*.tsx",
".next/types/**/*.ts",
".next/dev/types/**/*.ts"
],
"exclude": ["node_modules"]
}

View File

@@ -25,6 +25,7 @@ use std::sync::Arc;
use tokio::sync::RwLock;
use crate::library::{
rename::{ItemType, RenameResult},
Command, CommandSummary, GitAuthor, LibraryAgent, LibraryAgentSummary, LibraryStatus,
LibraryStore, LibraryTool, LibraryToolSummary, McpServer, MigrationReport, OpenAgentConfig,
Plugin, Rule, RuleSummary, Skill, SkillSummary, WorkspaceTemplate, WorkspaceTemplateSummary,
@@ -250,6 +251,8 @@ pub fn routes() -> Router<Arc<super::routes::AppState>> {
)
// Migration
.route("/migrate", post(migrate_library))
// Rename (works for all item types)
.route("/rename/:item_type/:name", post(rename_item))
// OpenCode Settings (oh-my-opencode.json)
.route("/opencode/settings", get(get_opencode_settings))
.route("/opencode/settings", put(save_opencode_settings))
@@ -293,6 +296,15 @@ pub struct SaveWorkspaceTemplateRequest {
pub init_script: Option<String>,
}
#[derive(Debug, Deserialize)]
pub struct RenameRequest {
/// The new name for the item.
pub new_name: String,
/// If true, return what would be changed without actually changing anything.
#[serde(default)]
pub dry_run: bool,
}
fn sanitize_skill_list(skills: Vec<String>) -> Vec<String> {
let mut seen = std::collections::HashSet::new();
let mut out = Vec::new();
@@ -1239,3 +1251,164 @@ fn extract_agent_names(agents: &serde_json::Value) -> Vec<String> {
}
Vec::new()
}
// ─────────────────────────────────────────────────────────────────────────────
// Rename
// ─────────────────────────────────────────────────────────────────────────────
/// POST /api/library/rename/:item_type/:name - Rename a library item.
/// Supports dry_run mode to preview changes before applying them.
async fn rename_item(
State(state): State<Arc<super::routes::AppState>>,
Path((item_type_str, name)): Path<(String, String)>,
headers: HeaderMap,
Json(req): Json<RenameRequest>,
) -> Result<Json<RenameResult>, (StatusCode, String)> {
// Parse item type
let item_type = match item_type_str.as_str() {
"skill" => ItemType::Skill,
"command" => ItemType::Command,
"rule" => ItemType::Rule,
"agent" => ItemType::Agent,
"tool" => ItemType::Tool,
"workspace-template" => ItemType::WorkspaceTemplate,
_ => {
return Err((
StatusCode::BAD_REQUEST,
format!(
"Invalid item type '{}'. Valid types: skill, command, rule, agent, tool, workspace-template",
item_type_str
),
))
}
};
let library = ensure_library(&state, &headers).await?;
// Perform rename (or dry run)
let result = library
.rename_item(item_type, &name, &req.new_name, req.dry_run)
.await
.map_err(|e| (StatusCode::INTERNAL_SERVER_ERROR, e.to_string()))?;
// If not dry run and successful, update workspace references
if !req.dry_run && result.success {
match item_type {
ItemType::Skill => {
// Update workspace skill lists
update_workspace_skill_references(&state, &name, &req.new_name).await;
// Sync skills to workspaces
sync_skill_to_workspaces(&state, library.as_ref(), &req.new_name).await;
}
ItemType::Tool => {
// Update workspace tool lists
update_workspace_tool_references(&state, &name, &req.new_name).await;
// Sync tools to workspaces
sync_tool_to_workspaces(&state, library.as_ref(), &req.new_name).await;
}
ItemType::WorkspaceTemplate => {
// Update workspace template references
update_workspace_template_references(&state, &name, &req.new_name).await;
}
_ => {}
}
}
if !result.success {
return Err((
StatusCode::BAD_REQUEST,
result.error.clone().unwrap_or_else(|| "Rename failed".to_string()),
));
}
Ok(Json(result))
}
/// Update workspace skill references when a skill is renamed.
async fn update_workspace_skill_references(
state: &super::routes::AppState,
old_name: &str,
new_name: &str,
) {
let workspaces = state.workspaces.list().await;
for workspace in workspaces {
if workspace.skills.contains(&old_name.to_string()) {
let mut updated_workspace = workspace.clone();
updated_workspace.skills = updated_workspace
.skills
.iter()
.map(|s| {
if s == old_name {
new_name.to_string()
} else {
s.clone()
}
})
.collect();
let workspace_name = workspace.name.clone();
if !state.workspaces.update(updated_workspace).await {
tracing::warn!(
workspace = %workspace_name,
"Failed to update workspace skill reference"
);
}
}
}
}
/// Update workspace tool references when a tool is renamed.
async fn update_workspace_tool_references(
state: &super::routes::AppState,
old_name: &str,
new_name: &str,
) {
let workspaces = state.workspaces.list().await;
for workspace in workspaces {
if workspace.tools.contains(&old_name.to_string()) {
let mut updated_workspace = workspace.clone();
updated_workspace.tools = updated_workspace
.tools
.iter()
.map(|t| {
if t == old_name {
new_name.to_string()
} else {
t.clone()
}
})
.collect();
let workspace_name = workspace.name.clone();
if !state.workspaces.update(updated_workspace).await {
tracing::warn!(
workspace = %workspace_name,
"Failed to update workspace tool reference"
);
}
}
}
}
/// Update workspace template references when a template is renamed.
async fn update_workspace_template_references(
state: &super::routes::AppState,
old_name: &str,
new_name: &str,
) {
let workspaces = state.workspaces.list().await;
for workspace in workspaces {
if workspace.template.as_deref() == Some(old_name) {
let mut updated_workspace = workspace.clone();
updated_workspace.template = Some(new_name.to_string());
let workspace_name = workspace.name.clone();
if !state.workspaces.update(updated_workspace).await {
tracing::warn!(
workspace = %workspace_name,
"Failed to update workspace template reference"
);
}
}
}
}

View File

@@ -13,6 +13,7 @@
pub mod env_crypto;
mod git;
pub mod rename;
pub mod types;
use anyhow::{Context, Result};

470
src/library/rename.rs Normal file
View File

@@ -0,0 +1,470 @@
//! Library item rename functionality with cascade reference updates.
//!
//! This module handles renaming library items (skills, commands, rules, agents, tools,
//! workspace templates) while automatically updating all cross-references.
use anyhow::{Context, Result};
use serde::{Deserialize, Serialize};
use std::path::Path;
use tokio::fs;
use super::types::{parse_frontmatter, OpenAgentConfig};
use super::LibraryStore;
// ─────────────────────────────────────────────────────────────────────────────
// Types
// ─────────────────────────────────────────────────────────────────────────────
/// The type of library item being renamed.
#[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize, Deserialize)]
#[serde(rename_all = "snake_case")]
pub enum ItemType {
Skill,
Command,
Rule,
Agent,
Tool,
WorkspaceTemplate,
}
impl ItemType {
pub fn as_str(&self) -> &'static str {
match self {
Self::Skill => "skill",
Self::Command => "command",
Self::Rule => "rule",
Self::Agent => "agent",
Self::Tool => "tool",
Self::WorkspaceTemplate => "workspace-template",
}
}
}
/// A single change that will be or was applied.
#[derive(Debug, Clone, Serialize, Deserialize)]
#[serde(tag = "type", rename_all = "snake_case")]
pub enum RenameChange {
/// Rename a file or directory.
RenameFile {
from: String,
to: String,
},
/// Update a reference in a file.
UpdateReference {
file: String,
field: String,
old_value: String,
new_value: String,
},
/// Update workspace skills/tools list (in memory, via workspace store).
UpdateWorkspace {
workspace_id: String,
workspace_name: String,
field: String,
},
}
/// Result of a rename operation.
#[derive(Debug, Clone, Serialize, Deserialize)]
pub struct RenameResult {
/// Whether the operation was successful.
pub success: bool,
/// Changes that were applied (or would be applied in dry_run mode).
pub changes: Vec<RenameChange>,
/// Any warnings encountered.
pub warnings: Vec<String>,
/// Error message if success is false.
#[serde(skip_serializing_if = "Option::is_none")]
pub error: Option<String>,
}
// ─────────────────────────────────────────────────────────────────────────────
// Reference Finding
// ─────────────────────────────────────────────────────────────────────────────
impl LibraryStore {
/// Find all references to an item in the library.
pub async fn find_references(&self, item_type: ItemType, name: &str) -> Result<Vec<RenameChange>> {
let mut refs = Vec::new();
match item_type {
ItemType::Skill => {
// Skills are referenced by:
// 1. workspace-template/*.json -> skills array
refs.extend(self.find_skill_refs_in_templates(name).await?);
}
ItemType::Rule => {
// Rules are referenced by:
// 1. agent/*.md -> rules array in frontmatter
refs.extend(self.find_rule_refs_in_agents(name).await?);
}
ItemType::Agent => {
// Agents are referenced by:
// 1. openagent/config.json -> hidden_agents, default_agent
refs.extend(self.find_agent_refs_in_config(name).await?);
}
ItemType::Command | ItemType::Tool | ItemType::WorkspaceTemplate => {
// These don't have direct cross-references in library files.
// Tools are referenced by workspaces (handled at API layer).
}
}
Ok(refs)
}
/// Find references to a skill in workspace templates.
async fn find_skill_refs_in_templates(&self, skill_name: &str) -> Result<Vec<RenameChange>> {
let mut refs = Vec::new();
let templates_dir = self.path.join("workspace-template");
if !templates_dir.exists() {
return Ok(refs);
}
let mut entries = fs::read_dir(&templates_dir).await?;
while let Some(entry) = entries.next_entry().await? {
let path = entry.path();
if path.extension().map(|e| e == "json").unwrap_or(false) {
if let Ok(content) = fs::read_to_string(&path).await {
if let Ok(template) = serde_json::from_str::<serde_json::Value>(&content) {
if let Some(skills) = template.get("skills").and_then(|s| s.as_array()) {
if skills.iter().any(|s| s.as_str() == Some(skill_name)) {
let rel_path = path
.strip_prefix(&self.path)
.unwrap_or(&path)
.to_string_lossy()
.to_string();
refs.push(RenameChange::UpdateReference {
file: rel_path,
field: "skills".to_string(),
old_value: skill_name.to_string(),
new_value: String::new(), // Will be filled in during rename
});
}
}
}
}
}
}
Ok(refs)
}
/// Find references to a rule in agent definitions.
async fn find_rule_refs_in_agents(&self, rule_name: &str) -> Result<Vec<RenameChange>> {
let mut refs = Vec::new();
let agents_dir = self.path.join("agent");
if !agents_dir.exists() {
return Ok(refs);
}
let mut entries = fs::read_dir(&agents_dir).await?;
while let Some(entry) = entries.next_entry().await? {
let path = entry.path();
if path.extension().map(|e| e == "md").unwrap_or(false) {
if let Ok(content) = fs::read_to_string(&path).await {
let (frontmatter, _) = parse_frontmatter(&content);
if let Some(fm) = frontmatter {
if let Some(rules) = fm.get("rules").and_then(|r| r.as_sequence()) {
if rules.iter().any(|r| r.as_str() == Some(rule_name)) {
let rel_path = path
.strip_prefix(&self.path)
.unwrap_or(&path)
.to_string_lossy()
.to_string();
refs.push(RenameChange::UpdateReference {
file: rel_path,
field: "rules".to_string(),
old_value: rule_name.to_string(),
new_value: String::new(),
});
}
}
}
}
}
}
Ok(refs)
}
/// Find references to an agent in openagent config.
async fn find_agent_refs_in_config(&self, agent_name: &str) -> Result<Vec<RenameChange>> {
let mut refs = Vec::new();
let config_path = self.path.join("openagent/config.json");
if !config_path.exists() {
return Ok(refs);
}
if let Ok(content) = fs::read_to_string(&config_path).await {
if let Ok(config) = serde_json::from_str::<OpenAgentConfig>(&content) {
if config.hidden_agents.contains(&agent_name.to_string()) {
refs.push(RenameChange::UpdateReference {
file: "openagent/config.json".to_string(),
field: "hidden_agents".to_string(),
old_value: agent_name.to_string(),
new_value: String::new(),
});
}
if config.default_agent.as_deref() == Some(agent_name) {
refs.push(RenameChange::UpdateReference {
file: "openagent/config.json".to_string(),
field: "default_agent".to_string(),
old_value: agent_name.to_string(),
new_value: String::new(),
});
}
}
}
Ok(refs)
}
/// Rename an item and update all references.
pub async fn rename_item(
&self,
item_type: ItemType,
old_name: &str,
new_name: &str,
dry_run: bool,
) -> Result<RenameResult> {
// Validate names
Self::validate_name(old_name)?;
Self::validate_name(new_name)?;
if old_name == new_name {
return Ok(RenameResult {
success: true,
changes: vec![],
warnings: vec!["Old and new names are identical".to_string()],
error: None,
});
}
// Check source exists
let (old_path, new_path) = self.get_item_paths(item_type, old_name, new_name);
if !old_path.exists() {
return Ok(RenameResult {
success: false,
changes: vec![],
warnings: vec![],
error: Some(format!("{} '{}' not found", item_type.as_str(), old_name)),
});
}
// Check target doesn't exist
if new_path.exists() {
return Ok(RenameResult {
success: false,
changes: vec![],
warnings: vec![],
error: Some(format!("{} '{}' already exists", item_type.as_str(), new_name)),
});
}
// Build change list
let mut changes = Vec::new();
let mut warnings = Vec::new();
// Add the rename operation
let old_rel = old_path
.strip_prefix(&self.path)
.unwrap_or(&old_path)
.to_string_lossy()
.to_string();
let new_rel = new_path
.strip_prefix(&self.path)
.unwrap_or(&new_path)
.to_string_lossy()
.to_string();
changes.push(RenameChange::RenameFile {
from: old_rel,
to: new_rel,
});
// Find and add reference updates
let refs = self.find_references(item_type, old_name).await?;
for mut ref_change in refs {
// Fill in the new_value
if let RenameChange::UpdateReference { new_value, .. } = &mut ref_change {
*new_value = new_name.to_string();
}
changes.push(ref_change);
}
if dry_run {
return Ok(RenameResult {
success: true,
changes,
warnings,
error: None,
});
}
// Execute the rename
if let Err(e) = self.execute_rename(item_type, old_name, new_name, &old_path, &new_path).await {
return Ok(RenameResult {
success: false,
changes: vec![],
warnings,
error: Some(format!("Failed to rename: {}", e)),
});
}
// Execute reference updates
for change in &changes {
if let RenameChange::UpdateReference { file, field, old_value, new_value } = change {
if let Err(e) = self.update_reference(file, field, old_value, new_value).await {
warnings.push(format!("Failed to update {}: {}", file, e));
}
}
}
Ok(RenameResult {
success: true,
changes,
warnings,
error: None,
})
}
/// Get the old and new paths for an item type.
fn get_item_paths(&self, item_type: ItemType, old_name: &str, new_name: &str) -> (std::path::PathBuf, std::path::PathBuf) {
match item_type {
ItemType::Skill => (
self.path.join("skill").join(old_name),
self.path.join("skill").join(new_name),
),
ItemType::Command => (
self.path.join("command").join(format!("{}.md", old_name)),
self.path.join("command").join(format!("{}.md", new_name)),
),
ItemType::Rule => (
self.path.join("rule").join(format!("{}.md", old_name)),
self.path.join("rule").join(format!("{}.md", new_name)),
),
ItemType::Agent => (
self.path.join("agent").join(format!("{}.md", old_name)),
self.path.join("agent").join(format!("{}.md", new_name)),
),
ItemType::Tool => (
self.path.join("tool").join(format!("{}.ts", old_name)),
self.path.join("tool").join(format!("{}.ts", new_name)),
),
ItemType::WorkspaceTemplate => (
self.path.join("workspace-template").join(format!("{}.json", old_name)),
self.path.join("workspace-template").join(format!("{}.json", new_name)),
),
}
}
/// Execute the actual rename operation.
async fn execute_rename(
&self,
item_type: ItemType,
_old_name: &str,
new_name: &str,
old_path: &Path,
new_path: &Path,
) -> Result<()> {
// For workspace templates, also update the internal "name" field
if item_type == ItemType::WorkspaceTemplate {
let content = fs::read_to_string(old_path).await?;
if let Ok(mut template) = serde_json::from_str::<serde_json::Value>(&content) {
if let Some(obj) = template.as_object_mut() {
obj.insert("name".to_string(), serde_json::Value::String(new_name.to_string()));
let updated = serde_json::to_string_pretty(&template)?;
fs::write(old_path, updated).await?;
}
}
}
// Perform the rename
fs::rename(old_path, new_path)
.await
.context("Failed to rename file/directory")?;
Ok(())
}
/// Update a reference in a file.
async fn update_reference(
&self,
file: &str,
field: &str,
old_value: &str,
new_value: &str,
) -> Result<()> {
let file_path = self.path.join(file);
if file.ends_with(".json") {
// JSON file (workspace template or openagent config)
let content = fs::read_to_string(&file_path).await?;
let mut data: serde_json::Value = serde_json::from_str(&content)?;
if field == "skills" || field == "hidden_agents" {
// Array field
if let Some(arr) = data.get_mut(field).and_then(|a| a.as_array_mut()) {
for item in arr.iter_mut() {
if item.as_str() == Some(old_value) {
*item = serde_json::Value::String(new_value.to_string());
}
}
}
} else if field == "default_agent" {
// String field
if data.get(field).and_then(|v| v.as_str()) == Some(old_value) {
data[field] = serde_json::Value::String(new_value.to_string());
}
}
let updated = serde_json::to_string_pretty(&data)?;
fs::write(&file_path, updated).await?;
} else if file.ends_with(".md") {
// Markdown file with YAML frontmatter (agent)
let content = fs::read_to_string(&file_path).await?;
let updated = self.update_frontmatter_array(&content, field, old_value, new_value)?;
fs::write(&file_path, updated).await?;
}
Ok(())
}
/// Update an array field in YAML frontmatter.
fn update_frontmatter_array(
&self,
content: &str,
field: &str,
old_value: &str,
new_value: &str,
) -> Result<String> {
if !content.starts_with("---") {
return Ok(content.to_string());
}
let rest = &content[3..];
if let Some(end_pos) = rest.find("\n---") {
let yaml_str = &rest[..end_pos];
let body = &rest[end_pos..];
// Parse and update YAML
if let Ok(mut yaml) = serde_yaml::from_str::<serde_yaml::Value>(yaml_str) {
if let Some(arr) = yaml.get_mut(field).and_then(|a| a.as_sequence_mut()) {
for item in arr.iter_mut() {
if item.as_str() == Some(old_value) {
*item = serde_yaml::Value::String(new_value.to_string());
}
}
}
let updated_yaml = serde_yaml::to_string(&yaml)?;
return Ok(format!("---\n{}\n---{}", updated_yaml.trim(), body));
}
}
Ok(content.to_string())
}
}