diff --git a/README.md b/README.md index f7ade1b389e..42f24cd1a87 100644 --- a/README.md +++ b/README.md @@ -177,6 +177,8 @@ Open source. Self-hosted. No Paperclip account required. npx paperclipai onboard --yes ``` +If you already have Paperclip configured, rerunning `onboard` keeps the existing config in place. Use `paperclipai configure` to edit settings. + Or manually: ```bash diff --git a/cli/README.md b/cli/README.md new file mode 100644 index 00000000000..08f36ad49b3 --- /dev/null +++ b/cli/README.md @@ -0,0 +1,292 @@ +
+
+
+ Quickstart · + Docs · + GitHub · + Discord +
+ + + +| Works with |
+ OpenClaw |
+ Claude Code |
+ Codex |
+ Cursor |
+ Bash |
+ HTTP |
+
+🔌 Bring Your Own Agent+Any agent, any runtime, one org chart. If it can receive a heartbeat, it's hired. + |
+
+🎯 Goal Alignment+Every task traces back to the company mission. Agents know what to do and why. + |
+
+💓 Heartbeats+Agents wake on a schedule, check work, and act. Delegation flows up and down the org chart. + |
+
+💰 Cost Control+Monthly budgets per agent. When they hit the limit, they stop. No runaway costs. + |
+
+🏢 Multi-Company+One deployment, many companies. Complete data isolation. One control plane for your portfolio. + |
+
+🎫 Ticket System+Every conversation traced. Every decision explained. Full tool-call tracing and immutable audit log. + |
+
+🛡️ Governance+You're the board. Approve hires, override strategy, pause or terminate any agent — at any time. + |
+
+📊 Org Chart+Hierarchies, roles, reporting lines. Your agents have a boss, a title, and a job description. + |
+
+📱 Mobile Ready+Monitor and manage your autonomous businesses from anywhere. + |
+
+
+
+ Open source under MIT. Built for people who want to run companies, not babysit agents. +
diff --git a/cli/src/__tests__/onboard.test.ts b/cli/src/__tests__/onboard.test.ts new file mode 100644 index 00000000000..a5ffe44ad49 --- /dev/null +++ b/cli/src/__tests__/onboard.test.ts @@ -0,0 +1,105 @@ +import fs from "node:fs"; +import os from "node:os"; +import path from "node:path"; +import { afterEach, beforeEach, describe, expect, it } from "vitest"; +import { onboard } from "../commands/onboard.js"; +import type { PaperclipConfig } from "../config/schema.js"; + +const ORIGINAL_ENV = { ...process.env }; + +function createExistingConfigFixture() { + const root = fs.mkdtempSync(path.join(os.tmpdir(), "paperclip-onboard-")); + const runtimeRoot = path.join(root, "runtime"); + const configPath = path.join(root, ".paperclip", "config.json"); + const config: PaperclipConfig = { + $meta: { + version: 1, + updatedAt: "2026-03-29T00:00:00.000Z", + source: "configure", + }, + database: { + mode: "embedded-postgres", + embeddedPostgresDataDir: path.join(runtimeRoot, "db"), + embeddedPostgresPort: 54329, + backup: { + enabled: true, + intervalMinutes: 60, + retentionDays: 30, + dir: path.join(runtimeRoot, "backups"), + }, + }, + logging: { + mode: "file", + logDir: path.join(runtimeRoot, "logs"), + }, + server: { + deploymentMode: "local_trusted", + exposure: "private", + host: "127.0.0.1", + port: 3100, + allowedHostnames: [], + serveUi: true, + }, + auth: { + baseUrlMode: "auto", + disableSignUp: false, + }, + storage: { + provider: "local_disk", + localDisk: { + baseDir: path.join(runtimeRoot, "storage"), + }, + s3: { + bucket: "paperclip", + region: "us-east-1", + prefix: "", + forcePathStyle: false, + }, + }, + secrets: { + provider: "local_encrypted", + strictMode: false, + localEncrypted: { + keyFilePath: path.join(runtimeRoot, "secrets", "master.key"), + }, + }, + }; + + fs.mkdirSync(path.dirname(configPath), { recursive: true }); + fs.writeFileSync(configPath, `${JSON.stringify(config, null, 2)}\n`, { mode: 0o600 }); + + return { configPath, configText: fs.readFileSync(configPath, "utf8") }; +} + +describe("onboard", () => { + beforeEach(() => { + process.env = { ...ORIGINAL_ENV }; + delete process.env.PAPERCLIP_AGENT_JWT_SECRET; + delete process.env.PAPERCLIP_SECRETS_MASTER_KEY; + delete process.env.PAPERCLIP_SECRETS_MASTER_KEY_FILE; + }); + + afterEach(() => { + process.env = { ...ORIGINAL_ENV }; + }); + + it("preserves an existing config when rerun without flags", async () => { + const fixture = createExistingConfigFixture(); + + await onboard({ config: fixture.configPath }); + + expect(fs.readFileSync(fixture.configPath, "utf8")).toBe(fixture.configText); + expect(fs.existsSync(`${fixture.configPath}.backup`)).toBe(false); + expect(fs.existsSync(path.join(path.dirname(fixture.configPath), ".env"))).toBe(true); + }); + + it("preserves an existing config when rerun with --yes", async () => { + const fixture = createExistingConfigFixture(); + + await onboard({ config: fixture.configPath, yes: true, invokedByRun: true }); + + expect(fs.readFileSync(fixture.configPath, "utf8")).toBe(fixture.configText); + expect(fs.existsSync(`${fixture.configPath}.backup`)).toBe(false); + expect(fs.existsSync(path.join(path.dirname(fixture.configPath), ".env"))).toBe(true); + }); +}); diff --git a/cli/src/commands/onboard.ts b/cli/src/commands/onboard.ts index 523484f3c69..d470354f8d1 100644 --- a/cli/src/commands/onboard.ts +++ b/cli/src/commands/onboard.ts @@ -244,11 +244,12 @@ export async function onboard(opts: OnboardOptions): Promise{inviteError}
)} {inviteSnippet && ( -Loading...
; - if (error) return{error instanceof Error ? error.message : "Failed to load workspace"}
; - if (!workspace) return null; + const projectQuery = useQuery({ + queryKey: workspace ? [...queryKeys.projects.detail(workspace.projectId), workspace.companyId] : ["projects", "detail", "__pending__"], + queryFn: () => projectsApi.get(workspace!.projectId, workspace!.companyId), + enabled: Boolean(workspace?.projectId), + }); + const project = projectQuery.data ?? null; + + const sourceIssueQuery = useQuery({ + queryKey: workspace?.sourceIssueId ? queryKeys.issues.detail(workspace.sourceIssueId) : ["issues", "detail", "__none__"], + queryFn: () => issuesApi.get(workspace!.sourceIssueId!), + enabled: Boolean(workspace?.sourceIssueId), + }); + const sourceIssue = sourceIssueQuery.data ?? null; + + const derivedWorkspaceQuery = useQuery({ + queryKey: workspace?.derivedFromExecutionWorkspaceId + ? queryKeys.executionWorkspaces.detail(workspace.derivedFromExecutionWorkspaceId) + : ["execution-workspaces", "detail", "__none__"], + queryFn: () => executionWorkspacesApi.get(workspace!.derivedFromExecutionWorkspaceId!), + enabled: Boolean(workspace?.derivedFromExecutionWorkspaceId), + }); + const derivedWorkspace = derivedWorkspaceQuery.data ?? null; + const linkedIssuesQuery = useQuery({ + queryKey: workspace + ? queryKeys.issues.listByExecutionWorkspace(workspace.companyId, workspace.id) + : ["issues", "__execution-workspace__", "__none__"], + queryFn: () => issuesApi.list(workspace!.companyId, { executionWorkspaceId: workspace!.id }), + enabled: Boolean(workspace?.companyId), + }); + const linkedIssues = linkedIssuesQuery.data ?? []; + + const linkedProjectWorkspace = useMemo( + () => project?.workspaces.find((item) => item.id === workspace?.projectWorkspaceId) ?? null, + [project, workspace?.projectWorkspaceId], + ); + const inheritedRuntimeConfig = linkedProjectWorkspace?.runtimeConfig?.workspaceRuntime ?? null; + const effectiveRuntimeConfig = workspace?.config?.workspaceRuntime ?? inheritedRuntimeConfig; + const runtimeConfigSource = + workspace?.config?.workspaceRuntime + ? "execution_workspace" + : inheritedRuntimeConfig + ? "project_workspace" + : "none"; + + const initialState = useMemo(() => (workspace ? formStateFromWorkspace(workspace) : null), [workspace]); + const isDirty = Boolean(form && initialState && JSON.stringify(form) !== JSON.stringify(initialState)); + const projectRef = project ? projectRouteRef(project) : workspace?.projectId ?? ""; + + useEffect(() => { + if (!workspace?.companyId || workspace.companyId === selectedCompanyId) return; + setSelectedCompanyId(workspace.companyId, { source: "route_sync" }); + }, [workspace?.companyId, selectedCompanyId, setSelectedCompanyId]); + + useEffect(() => { + if (!workspace) return; + setForm(formStateFromWorkspace(workspace)); + setErrorMessage(null); + }, [workspace]); + + useEffect(() => { + if (!workspace) return; + const crumbs = [ + { label: "Projects", href: "/projects" }, + ...(project ? [{ label: project.name, href: `/projects/${projectRef}` }] : []), + ...(project ? [{ label: "Workspaces", href: `/projects/${projectRef}/workspaces` }] : []), + { label: workspace.name }, + ]; + setBreadcrumbs(crumbs); + }, [setBreadcrumbs, workspace, project, projectRef]); + + const updateWorkspace = useMutation({ + mutationFn: (patch: RecordLoading workspace…
; + if (workspaceQuery.error) { + return ( ++ {workspaceQuery.error instanceof Error ? workspaceQuery.error.message : "Failed to load workspace"} +
+ ); + } + if (!workspace || !form || !initialState) return null; + + const saveChanges = () => { + const validationError = validateForm(form); + if (validationError) { + setErrorMessage(validationError); + return; + } + + let patch: Record