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:
34
INSTALL.md
34
INSTALL.md
@@ -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**
|
||||
|
||||
|
||||
@@ -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>
|
||||
);
|
||||
}
|
||||
|
||||
@@ -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>
|
||||
);
|
||||
}
|
||||
|
||||
@@ -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 */}
|
||||
|
||||
@@ -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>
|
||||
);
|
||||
}
|
||||
|
||||
291
dashboard/src/components/rename-dialog.tsx
Normal file
291
dashboard/src/components/rename-dialog.tsx
Normal 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>
|
||||
);
|
||||
}
|
||||
@@ -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);
|
||||
|
||||
@@ -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
31
docs-site/.gitignore
vendored
Normal 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
|
||||
919
docs-site/app/[[...mdxPath]]/docs.css
Normal file
919
docs-site/app/[[...mdxPath]]/docs.css
Normal 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);
|
||||
}
|
||||
50
docs-site/app/[[...mdxPath]]/layout.tsx
Normal file
50
docs-site/app/[[...mdxPath]]/layout.tsx
Normal 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>
|
||||
);
|
||||
}
|
||||
39
docs-site/app/[[...mdxPath]]/page.tsx
Normal file
39
docs-site/app/[[...mdxPath]]/page.tsx
Normal 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>
|
||||
);
|
||||
}
|
||||
219
docs-site/app/api/docs/[...slug]/route.ts
Normal file
219
docs-site/app/api/docs/[...slug]/route.ts
Normal 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
481
docs-site/app/globals.css
Normal 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
108
docs-site/app/layout.tsx
Normal 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
1127
docs-site/bun.lock
Normal file
File diff suppressed because it is too large
Load Diff
16
docs-site/components/theme-provider.tsx
Normal file
16
docs-site/components/theme-provider.tsx
Normal 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>
|
||||
);
|
||||
}
|
||||
17
docs-site/content/_meta.js
Normal file
17
docs-site/content/_meta.js
Normal 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
363
docs-site/content/api.mdx
Normal 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.
|
||||
328
docs-site/content/desktop.mdx
Normal file
328
docs-site/content/desktop.mdx
Normal 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 |
|
||||
111
docs-site/content/first-mission.mdx
Normal file
111
docs-site/content/first-mission.mdx
Normal 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
100
docs-site/content/index.mdx
Normal 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
|
||||
204
docs-site/content/library.mdx
Normal file
204
docs-site/content/library.mdx
Normal 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
175
docs-site/content/setup.mdx
Normal 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.
|
||||
217
docs-site/content/workspaces.mdx
Normal file
217
docs-site/content/workspaces.mdx
Normal 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
|
||||
77
docs-site/mdx-components.tsx
Normal file
77
docs-site/mdx-components.tsx
Normal 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
19
docs-site/next.config.mjs
Normal 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
32
docs-site/package.json
Normal 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"
|
||||
}
|
||||
}
|
||||
5
docs-site/postcss.config.mjs
Normal file
5
docs-site/postcss.config.mjs
Normal file
@@ -0,0 +1,5 @@
|
||||
export default {
|
||||
plugins: {
|
||||
"@tailwindcss/postcss": {},
|
||||
},
|
||||
};
|
||||
99
docs-site/proxy.ts
Normal file
99
docs-site/proxy.ts
Normal 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]$).*)",
|
||||
],
|
||||
};
|
||||
BIN
docs-site/public/apple-touch-icon.png
Normal file
BIN
docs-site/public/apple-touch-icon.png
Normal file
Binary file not shown.
|
After Width: | Height: | Size: 46 KiB |
10
docs-site/public/favicon.svg
Normal file
10
docs-site/public/favicon.svg
Normal file
File diff suppressed because one or more lines are too long
|
After Width: | Height: | Size: 20 KiB |
1
docs-site/public/fonts/.gitkeep
Normal file
1
docs-site/public/fonts/.gitkeep
Normal file
@@ -0,0 +1 @@
|
||||
# Download Geist fonts from https://vercel.com/font/geist
|
||||
BIN
docs-site/public/fonts/Geist-VariableFont_wght.ttf
Normal file
BIN
docs-site/public/fonts/Geist-VariableFont_wght.ttf
Normal file
Binary file not shown.
BIN
docs-site/public/fonts/GeistMono-VariableFont_wght.ttf
Normal file
BIN
docs-site/public/fonts/GeistMono-VariableFont_wght.ttf
Normal file
Binary file not shown.
87
docs-site/public/llms.txt
Normal file
87
docs-site/public/llms.txt
Normal 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
|
||||
BIN
docs-site/public/og-image.png
Normal file
BIN
docs-site/public/og-image.png
Normal file
Binary file not shown.
|
After Width: | Height: | Size: 59 KiB |
9
docs-site/public/robots.txt
Normal file
9
docs-site/public/robots.txt
Normal 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
34
docs-site/tsconfig.json
Normal 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"]
|
||||
}
|
||||
@@ -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"
|
||||
);
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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
470
src/library/rename.rs
Normal 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())
|
||||
}
|
||||
}
|
||||
Reference in New Issue
Block a user