diff --git a/api/routers/repos.py b/api/routers/repos.py index ca22271..5000d94 100644 --- a/api/routers/repos.py +++ b/api/routers/repos.py @@ -1,5 +1,7 @@ import asyncio import json +import os +import re import socket import subprocess import sys @@ -34,6 +36,8 @@ from api.schemas.managed_repo import ( PendingInterfaceChange, RepoCreate, RepoDispatch, + RepoOnboardRequest, + RepoOnboardResult, RepoPathRegister, RepoRead, RepoScopeHealth, @@ -90,6 +94,77 @@ async def register_repo( return repo +@router.post("/onboard", response_model=RepoOnboardResult) +async def onboard_repo(body: RepoOnboardRequest) -> RepoOnboardResult: + """Run the local repo onboarding script for an accessible working copy. + + The dashboard uses this for the "Add Repo" action. The path must be visible + from the State Hub host, either as a local checkout or through an ops-bridge + mounted/exposed working copy. Keep the API agent-profile based so future + native coding agents can gain their own profiles without changing callers. + """ + project_path = Path(body.project_path).expanduser() + if not project_path.exists() or not project_path.is_dir(): + raise HTTPException( + status_code=400, + detail=f"project_path is not an accessible directory: {body.project_path}", + ) + if not (project_path / ".git").exists(): + raise HTTPException( + status_code=400, + detail=f"project_path does not look like a git working copy: {body.project_path}", + ) + + script = Path(__file__).parent.parent.parent / "scripts" / "register_project.sh" + cmd = ["bash", str(script), body.domain_slug, str(project_path)] + if body.agent_profile == "codex": + cmd.append("--codex") + if body.additional: + cmd.append("--additional") + + env = { + **os.environ, + "API_BASE": settings.api_base, + "CUSTODIAN_SKIP_SBOM_PROMPT": "true", + } + result = await asyncio.to_thread( + subprocess.run, + cmd, + cwd=str(script.parent.parent), + env=env, + stdin=subprocess.DEVNULL, + capture_output=True, + text=True, + timeout=180, + ) + stdout = result.stdout or "" + stderr = result.stderr or "" + if result.returncode != 0: + raise HTTPException( + status_code=500, + detail={ + "message": "Repo onboarding failed.", + "command": cmd, + "stdout": stdout, + "stderr": stderr, + }, + ) + + repo_slug = None + match = re.search(r"Repo slug:\s+([a-z0-9][a-z0-9-]*)", stdout) + if match: + repo_slug = match.group(1) + + return RepoOnboardResult( + ok=True, + repo_slug=repo_slug, + agent_profile=body.agent_profile, + command=cmd, + stdout=stdout, + stderr=stderr, + ) + + @router.get("/by-fingerprint", response_model=list[RepoRead]) async def get_repo_by_fingerprint( hash: str, diff --git a/api/schemas/managed_repo.py b/api/schemas/managed_repo.py index 1189061..9486ec6 100644 --- a/api/schemas/managed_repo.py +++ b/api/schemas/managed_repo.py @@ -1,6 +1,6 @@ import uuid from datetime import date, datetime -from typing import Any +from typing import Any, Literal from pydantic import BaseModel, ConfigDict, Field @@ -32,6 +32,23 @@ class RepoPathRegister(BaseModel): path: str +class RepoOnboardRequest(BaseModel): + """Start scripted onboarding for a working copy that is visible to State Hub.""" + domain_slug: str + project_path: str + agent_profile: Literal["claude-code", "codex"] = "codex" + additional: bool = False + + +class RepoOnboardResult(BaseModel): + ok: bool + repo_slug: str | None = None + agent_profile: str + command: list[str] + stdout: str = "" + stderr: str = "" + + class RepoRead(BaseModel): model_config = ConfigDict(from_attributes=True) id: uuid.UUID diff --git a/dashboard/src/repos.md b/dashboard/src/repos.md index 8ef63fd..25f23e9 100644 --- a/dashboard/src/repos.md +++ b/dashboard/src/repos.md @@ -303,37 +303,125 @@ const _h2onboard = [...document.querySelectorAll("#observablehq-main h2")] if (_h2onboard) { _h2onboard.style.position = "relative"; withDocHelp(_h2onboard, "/docs/repo-integration"); } ``` +```js +const onboardId = `onboard-${Math.random().toString(36).slice(2)}`; +const onboardDomainOptions = [...domains] + .sort((a, b) => a.slug.localeCompare(b.slug)) + .map(d => html``); + +const onboardForm = html`
+
+
+

Add Repo

+

Register an accessible git working copy and write the starter files for the selected agent profile.

+
+ +
+
+ + + + +
+

Paste the absolute path when the checkout is on the State Hub host or exposed via ops-bridge.

+

+
`; + +display(onboardForm); + +{ + const button = onboardForm.querySelector(".onboard-primary"); + const status = onboardForm.querySelector(".onboard-status"); + const pathInput = onboardForm.querySelector(".onboard-path"); + + button.addEventListener("click", async () => { + const body = { + domain_slug: onboardForm.querySelector(".onboard-domain").value, + project_path: pathInput.value.trim(), + agent_profile: onboardForm.querySelector(".onboard-agent").value, + additional: onboardForm.querySelector(".onboard-additional").checked, + }; + if (!body.project_path) { + status.textContent = "Enter an absolute path that the State Hub API can access."; + pathInput.focus(); + return; + } + + button.disabled = true; + status.textContent = "Onboarding started..."; + try { + const response = await fetch(`${API}/repos/onboard`, { + method: "POST", + headers: {"Content-Type": "application/json"}, + body: JSON.stringify(body), + }); + const payload = await response.json().catch(() => ({})); + if (!response.ok) { + const detail = payload.detail ?? payload; + throw new Error(typeof detail === "string" ? detail : JSON.stringify(detail, null, 2)); + } + status.textContent = [ + `Onboarding complete${payload.repo_slug ? `: ${payload.repo_slug}` : ""}`, + "", + payload.stdout?.trim() ?? "", + payload.stderr?.trim() ? `\nWarnings:\n${payload.stderr.trim()}` : "", + ].join("\n").trim(); + } catch (error) { + status.textContent = `Onboarding failed:\n${error.message}`; + } finally { + button.disabled = false; + } + }); +} +``` + ```js display(html`
1
- Clone the repo locally + Make the working copy accessible
git clone <remote-url> /path/to/repo
+

The path must be visible from the State Hub API host, either as a local checkout or through an ops-bridge-exposed path.

2
- Register from the repo root -
cd /path/to/repo
-custodian register-project --domain <slug>
-

The custodian writes CLAUDE.custodian.md, registers the repo, and creates 4 onboarding tasks in the domain's topic.

+ Choose an agent profile +
Codex       -> AGENTS.md + SCOPE.md
+Claude Code -> CLAUDE.md + .claude/rules/
+

The API keeps this as an agent profile so future native coding agents can get their own onboarding templates without changing the repo model.

3
- Open the repo in Claude Code and run /init -
cd /path/to/repo && claude
-

Once Claude starts, run /init to trigger the integration. The repo agent reads CLAUDE.custodian.md, picks up the onboarding tasks, and integrates autonomously.

+ Run onboarding +
scripts/register_project.sh <domain> /path/to/repo --codex
+

Use the Add Repo form above for the automatic path. The script verifies the domain, writes agent instructions, registers the repo, records host_paths for this machine, and logs a progress event.

4
- Monitor here -

The ⚙ integrating badge clears when the repo agent completes all 4 onboarding tasks and closes the workstream.

+ Start the agent and monitor here +

Open the repo in the chosen coding agent, complete the generated TODO stubs, then use the Repositories and DoI views to track integration gaps.

`); @@ -372,6 +460,18 @@ custodian register-project --domain <slug> .chip-integrating { background: #ede9fe; color: #5b21b6; } .row-integrating { background: #faf5ff; } +.onboard-action { margin-top: 0.5rem; margin-bottom: 1rem; border: 1px solid var(--theme-foreground-faint, #ddd); border-radius: 8px; padding: 1rem; background: var(--theme-background); } +.onboard-action-head { display: flex; align-items: flex-start; justify-content: space-between; gap: 1rem; margin-bottom: 0.9rem; } +.onboard-action h3 { margin: 0 0 0.2rem; font-size: 1rem; } +.onboard-action p { margin: 0; color: #6b7280; font-size: 0.85rem; } +.onboard-grid { display: grid; grid-template-columns: minmax(120px, 0.8fr) minmax(180px, 1fr) minmax(260px, 2fr); gap: 0.8rem; align-items: end; } +.onboard-grid label { display: flex; flex-direction: column; gap: 0.25rem; font-size: 0.78rem; font-weight: 600; color: #4b5563; } +.onboard-grid input[type="text"], .onboard-grid select { min-height: 2.1rem; border: 1px solid var(--theme-foreground-faint, #d1d5db); border-radius: 4px; padding: 0.35rem 0.5rem; background: var(--theme-background); color: var(--theme-foreground); font: inherit; } +.onboard-check { flex-direction: row !important; align-items: center; grid-column: 1 / -1; font-weight: 500 !important; } +.onboard-primary { border: 0; border-radius: 4px; padding: 0.45rem 0.8rem; background: var(--theme-foreground-focus, #111827); color: var(--theme-background, white); font-weight: 700; cursor: pointer; white-space: nowrap; } +.onboard-primary:disabled { opacity: 0.6; cursor: progress; } +.onboard-path-note { margin-top: 0.65rem !important; font-size: 0.82rem !important; } +.onboard-status { min-height: 0; max-height: 18rem; overflow: auto; margin: 0.75rem 0 0; padding: 0.5rem 0.65rem; background: var(--theme-background-alt); border-radius: 4px; font-size: 0.78rem; white-space: pre-wrap; } .onboard-panel { display: flex; flex-direction: column; gap: 0; margin-top: 0.5rem; background: var(--theme-background-alt); border-radius: 8px; overflow: hidden; } .onboard-step { display: flex; gap: 1rem; align-items: flex-start; padding: 0.9rem 1.1rem; border-bottom: 1px solid var(--theme-foreground-faint, #eee); } .onboard-step:last-child { border-bottom: none; } @@ -387,4 +487,9 @@ custodian register-project --domain <slug> vertical-align: middle; } @keyframes doi-spin { to { transform: rotate(360deg); } } +@media (max-width: 760px) { + .onboard-action-head { flex-direction: column; } + .onboard-primary { width: 100%; } + .onboard-grid { grid-template-columns: 1fr; } +} diff --git a/scripts/register_project.sh b/scripts/register_project.sh index cd163b2..6329024 100755 --- a/scripts/register_project.sh +++ b/scripts/register_project.sh @@ -306,7 +306,11 @@ fi # ── Optional: SBOM ingest ───────────────────────────────────────────────────── if [[ "$ADDITIONAL" != "true" ]]; then echo "" - read -r -p "==> Run SBOM ingest now? [y/N] " INGEST_NOW Run SBOM ingest now? [y/N] " INGEST_NOW