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

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
70 changes: 69 additions & 1 deletion packages/server/src/routes/settings.ts
Original file line number Diff line number Diff line change
@@ -1,7 +1,9 @@
import fs from 'fs';
import os from 'os';
import path from 'path';
import { Hono } from 'hono';
import { Settings } from '@tinyclaw/core';
import { SETTINGS_FILE, getSettings } from '@tinyclaw/core';
import { SETTINGS_FILE, TINYCLAW_HOME, getSettings, ensureAgentDirectory, copyDirSync, SCRIPT_DIR } from '@tinyclaw/core';
import { log } from '@tinyclaw/core';

/** Read, mutate, and persist settings.json atomically. */
Expand All @@ -14,6 +16,17 @@ export function mutateSettings(fn: (settings: Settings) => void): Settings {

const app = new Hono();

function expandHomePath(input?: string): string | undefined {
if (!input) return input;
const home = process.env.HOME || os.homedir();
if (!home) return input;
if (input === '~') return home;
if (input.startsWith('~/')) return path.join(home, input.slice(2));
if (input === '$HOME') return home;
if (input.startsWith('$HOME/')) return path.join(home, input.slice(6));
return input;
}

// GET /api/settings
app.get('/api/settings', (c) => {
return c.json(getSettings());
Expand All @@ -29,4 +42,59 @@ app.put('/api/settings', async (c) => {
return c.json({ ok: true, settings: merged });
});

// POST /api/setup — run initial setup (write settings + create directories)
app.post('/api/setup', async (c) => {
const settings = (await c.req.json()) as Settings;

if (settings.workspace?.path) {
settings.workspace.path = expandHomePath(settings.workspace.path);
}
if (settings.agents) {
for (const agent of Object.values(settings.agents)) {
if (agent.working_directory) {
agent.working_directory = expandHomePath(agent.working_directory) || agent.working_directory;
}
}
}

// Write settings.json
fs.mkdirSync(path.dirname(SETTINGS_FILE), { recursive: true });
fs.writeFileSync(SETTINGS_FILE, JSON.stringify(settings, null, 2) + '\n');
log('INFO', '[API] Setup: settings.json written');

// Create TINYCLAW_HOME directories
fs.mkdirSync(path.join(TINYCLAW_HOME, 'logs'), { recursive: true });
fs.mkdirSync(path.join(TINYCLAW_HOME, 'files'), { recursive: true });

// Copy template files into TINYCLAW_HOME
const templateItems = ['.claude', 'heartbeat.md', 'AGENTS.md'];
for (const item of templateItems) {
const srcPath = path.join(SCRIPT_DIR, item);
const destPath = path.join(TINYCLAW_HOME, item);
if (fs.existsSync(srcPath)) {
if (fs.statSync(srcPath).isDirectory()) {
copyDirSync(srcPath, destPath);
} else {
fs.copyFileSync(srcPath, destPath);
}
}
}
Comment on lines +70 to +81
Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Template files unconditionally overwritten on re-run

The "Run Setup" button on the settings page can trigger this endpoint again on an already-configured system. Since there's no existence check on destPath, files like .claude (which often holds custom Claude configuration) and AGENTS.md will be silently overwritten with the bundled templates, discarding any user modifications.

Consider guarding the copy with an !fs.existsSync(destPath) check, or at least documenting this destructive behaviour:

if (fs.existsSync(srcPath) && !fs.existsSync(destPath)) {
    if (fs.statSync(srcPath).isDirectory()) {
        copyDirSync(srcPath, destPath);
    } else {
        fs.copyFileSync(srcPath, destPath);
    }
}


// Create workspace directory
const workspacePath = settings.workspace?.path;
if (workspacePath) {
fs.mkdirSync(workspacePath, { recursive: true });
}

// Create agent directories
if (settings.agents) {
for (const agent of Object.values(settings.agents)) {
ensureAgentDirectory(agent.working_directory);
}
}

log('INFO', '[API] Setup complete');
return c.json({ ok: true, settings });
});
Comment on lines +46 to +98
Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Filesystem errors surface as unhandled exceptions

The POST /api/setup handler performs several fs.* operations (mkdirSync, copyFileSync, etc.) with no surrounding try-catch. If any of these fail — due to permission denied, disk full, or a missing parent directory — Hono will propagate the exception as an unhandled 500, and the client's apiFetch will only see "Internal Server Error" rather than a descriptive message.

Wrapping the side-effectful block in a try-catch lets you return a structured error that the UI can display to the user:

try {
    // ... all fs.mkdirSync / copyFileSync / ensureAgentDirectory calls ...
} catch (err) {
    const msg = err instanceof Error ? err.message : String(err);
    log('ERROR', `[API] Setup failed: ${msg}`);
    return c.json({ ok: false, error: msg }, 500);
}


export default app;
3 changes: 1 addition & 2 deletions tinyoffice/package.json
Original file line number Diff line number Diff line change
@@ -1,7 +1,6 @@
{
"name": "mission-control",
"name": "tinyoffice",
"version": "0.1.0",
"private": true,
"scripts": {
"dev": "next dev --webpack",
"build": "next build",
Expand Down
9 changes: 2 additions & 7 deletions tinyoffice/src/app/layout.tsx
Original file line number Diff line number Diff line change
@@ -1,6 +1,6 @@
import type { Metadata } from "next";
import "./globals.css";
import { Sidebar } from "@/components/sidebar";
import { AppShell } from "@/components/app-shell";

export const metadata: Metadata = {
title: "TinyClaw Mission Control",
Expand All @@ -15,12 +15,7 @@ export default function RootLayout({
return (
<html lang="en" className="dark">
<body className="antialiased">
<div className="flex h-screen overflow-hidden">
<Sidebar />
<main className="flex-1 overflow-y-auto">
{children}
</main>
</div>
<AppShell>{children}</AppShell>
</body>
</html>
);
Expand Down
200 changes: 128 additions & 72 deletions tinyoffice/src/app/settings/page.tsx
Original file line number Diff line number Diff line change
@@ -1,5 +1,6 @@
"use client";

import Link from "next/link";
import { useState, useEffect } from "react";
import { getSettings, updateSettings, type Settings } from "@/lib/api";
import { Card, CardContent, CardHeader, CardTitle, CardDescription } from "@/components/ui/card";
Expand All @@ -16,6 +17,7 @@ import {
MessageSquare,
Cpu,
FolderOpen,
Wand2,
} from "lucide-react";

export default function SettingsPage() {
Expand All @@ -25,16 +27,22 @@ export default function SettingsPage() {
const [saving, setSaving] = useState(false);
const [status, setStatus] = useState<"idle" | "saved" | "error">("idle");
const [errorMsg, setErrorMsg] = useState("");
const [needsSetup, setNeedsSetup] = useState(false);

useEffect(() => {
getSettings()
.then((s) => {
setSettings(s);
setRawJson(JSON.stringify(s, null, 2));
// Show setup wizard if settings are effectively empty
const isEmpty = !s || (Object.keys(s).length === 0) ||
(!s.channels?.enabled?.length && !s.agents && !s.models?.provider);
setNeedsSetup(isEmpty);
})
.catch((err) => {
setErrorMsg(err.message);
setStatus("error");
setNeedsSetup(true);
})
.finally(() => setLoading(false));
}, []);
Expand All @@ -57,6 +65,43 @@ export default function SettingsPage() {
}
};

if (loading) {
return (
<div className="p-8">
<div className="flex items-center gap-2 text-sm text-muted-foreground">
<div className="h-3 w-3 animate-spin border-2 border-primary border-t-transparent" />
Loading settings...
</div>
</div>
);
}

if (needsSetup) {
return (
<div className="p-8 max-w-2xl mx-auto space-y-6">
<div>
<h1 className="text-2xl font-bold tracking-tight flex items-center gap-2">
<Wand2 className="h-5 w-5 text-primary" />
Setup Required
</h1>
<p className="text-sm text-muted-foreground mt-1">
Run the setup wizard to create your initial configuration.
</p>
</div>
<Card>
<CardContent className="pt-6 space-y-3">
<p className="text-sm text-muted-foreground">
Once setup is complete, you can return here to edit raw settings.
</p>
<Button asChild>
<Link href="/setup">Run Setup</Link>
</Button>
</CardContent>
</Card>
</div>
);
}

return (
<div className="p-8 space-y-8">
<div className="flex items-center justify-between">
Expand All @@ -82,6 +127,15 @@ export default function SettingsPage() {
{errorMsg}
</span>
)}
<Button
variant="outline"
asChild
>
<Link href="/setup" className="inline-flex items-center gap-2">
<Wand2 className="h-4 w-4" />
<span>Run Setup</span>
</Link>
</Button>
<Button onClick={handleSave} disabled={saving || loading}>
{saving ? (
<Loader2 className="h-4 w-4 animate-spin" />
Expand All @@ -93,81 +147,83 @@ export default function SettingsPage() {
</div>
</div>

{loading ? (
<div className="flex items-center gap-2 text-sm text-muted-foreground">
<div className="h-3 w-3 animate-spin border-2 border-primary border-t-transparent" />
Loading settings...
{settings && (
<div className="grid grid-cols-1 gap-4 md:grid-cols-2 xl:grid-cols-4">
<OverviewCard
icon={<FolderOpen className="h-4 w-4 text-muted-foreground" />}
title="Workspace"
value={settings.workspace?.name || settings.workspace?.path || "Default"}
/>
<OverviewCard
icon={<Cpu className="h-4 w-4 text-muted-foreground" />}
title="Default Provider"
value={settings.models?.provider || "anthropic"}
/>
<OverviewCard
icon={<Wifi className="h-4 w-4 text-muted-foreground" />}
title="Channels"
value={settings.channels?.enabled?.join(", ") || "None"}
/>
<OverviewCard
icon={<MessageSquare className="h-4 w-4 text-muted-foreground" />}
title="Heartbeat"
value={settings.monitoring?.heartbeat_interval ? `${settings.monitoring.heartbeat_interval}s` : "Disabled"}
/>
</div>
) : (
<>
{settings && (
<div className="grid grid-cols-1 gap-4 md:grid-cols-2 xl:grid-cols-4">
<OverviewCard
icon={<FolderOpen className="h-4 w-4 text-muted-foreground" />}
title="Workspace"
value={settings.workspace?.name || settings.workspace?.path || "Default"}
/>
<OverviewCard
icon={<Cpu className="h-4 w-4 text-muted-foreground" />}
title="Default Provider"
value={settings.models?.provider || "anthropic"}
/>
<OverviewCard
icon={<Wifi className="h-4 w-4 text-muted-foreground" />}
title="Channels"
value={settings.channels?.enabled?.join(", ") || "None"}
/>
<OverviewCard
icon={<MessageSquare className="h-4 w-4 text-muted-foreground" />}
title="Heartbeat"
value={settings.monitoring?.heartbeat_interval ? `${settings.monitoring.heartbeat_interval}s` : "Disabled"}
/>
</div>
)}
)}

<Card>
<CardHeader>
<CardTitle className="text-sm flex items-center gap-2">
Configuration (settings.json)
<Badge variant="outline" className="text-[10px]">JSON</Badge>
</CardTitle>
<CardDescription>
Edit the raw configuration. Changes take effect on next message processing cycle.
</CardDescription>
</CardHeader>
<CardContent>
<Textarea
value={rawJson}
onChange={(e) => setRawJson(e.target.value)}
rows={30}
className="font-mono text-xs leading-relaxed"
spellCheck={false}
/>
</CardContent>
</Card>
<Card>
<CardHeader>
<CardTitle className="text-sm flex items-center gap-2">
Configuration (settings.json)
<Badge variant="outline" className="text-[10px]">JSON</Badge>
</CardTitle>
<CardDescription>
Edit the raw configuration. Changes take effect on next message processing cycle.
</CardDescription>
</CardHeader>
<CardContent>
<Textarea
value={rawJson}
onChange={(e) => setRawJson(e.target.value)}
rows={30}
className="font-mono text-xs leading-relaxed"
spellCheck={false}
/>
</CardContent>
</Card>

<Card>
<CardHeader>
<CardTitle className="text-sm">API Endpoints</CardTitle>
</CardHeader>
<CardContent className="space-y-3">
<div className="grid grid-cols-1 gap-3 text-sm md:grid-cols-2">
<ApiEndpoint method="POST" path="/api/message" desc="Send a message to the queue" />
<ApiEndpoint method="GET" path="/api/agents" desc="List all agents" />
<ApiEndpoint method="GET" path="/api/teams" desc="List all teams" />
<ApiEndpoint method="GET" path="/api/settings" desc="Get current settings" />
<ApiEndpoint method="PUT" path="/api/settings" desc="Update settings" />
<ApiEndpoint method="GET" path="/api/queue/status" desc="Queue status" />
<ApiEndpoint method="GET" path="/api/responses" desc="Recent responses" />
<ApiEndpoint method="GET" path="/api/events/stream" desc="SSE event stream" />
<ApiEndpoint method="GET" path="/api/events" desc="Recent events (polling)" />
<ApiEndpoint method="GET" path="/api/logs" desc="Queue processor logs" />
<ApiEndpoint method="GET" path="/api/chats" desc="Chat histories" />
</div>
</CardContent>
</Card>
</>
)}
<Card>
<CardHeader>
<CardTitle className="text-sm">API Endpoints</CardTitle>
</CardHeader>
<CardContent className="space-y-3">
<div className="grid grid-cols-1 gap-3 text-sm md:grid-cols-2">
<ApiEndpoint method="POST" path="/api/message" desc="Send a message to the queue" />
<ApiEndpoint method="GET" path="/api/agents" desc="List all agents" />
<ApiEndpoint method="GET" path="/api/teams" desc="List all teams" />
<ApiEndpoint method="GET" path="/api/settings" desc="Get current settings" />
<ApiEndpoint method="PUT" path="/api/settings" desc="Update settings" />
<ApiEndpoint method="GET" path="/api/queue/status" desc="Queue status" />
<ApiEndpoint method="GET" path="/api/responses" desc="Recent responses" />
<ApiEndpoint method="GET" path="/api/events/stream" desc="SSE event stream" />
<ApiEndpoint method="GET" path="/api/events" desc="Recent events (polling)" />
<ApiEndpoint method="GET" path="/api/logs" desc="Queue processor logs" />
<ApiEndpoint method="GET" path="/api/chats" desc="Chat histories" />
</div>
</CardContent>
</Card>
</div>
);
}

// ── Helpers ───────────────────────────────────────────────────────────────

function ReviewItem({ label, value }: { label: string; value: string }) {
return (
<div>
<span className="text-[10px] font-medium text-muted-foreground uppercase tracking-wider">{label}</span>
<p className="text-sm font-medium">{value}</p>
</div>
);
}
Expand Down
Loading