app: Add-program flow with server-side filesystem picker

Register an existing repo as a program from the /programs page — the web
equivalent of `castle program add`. An inline form with an editable,
autocompleting path bar browses the *server's* filesystem (a browser's own
file dialog only sees the client machine) and also accepts a git URL.

- core: extract the adopt logic (target parsing, stack/command detection,
  ProgramSpec build) into castle_core.adopt so the CLI and API share it; the
  CLI's add.py now delegates to it.
- castle-api: GET /fs/browse (directory listing, per-entry stat errors skipped
  so one unreadable child can't 403 the dir) and POST /programs/adopt.
- app: AddProgramForm + Add-program button on the Programs page; useBrowse /
  useAdoptProgram hooks.

Also tidy ProgramCard: deployments render below the description, and the
deployment name label is hidden when it matches the program title.
This commit is contained in:
2026-07-07 20:51:59 -07:00
parent 9a8e16143b
commit a9f1e5b099
7 changed files with 585 additions and 110 deletions

View File

@@ -0,0 +1,216 @@
import { useState } from "react"
import { useNavigate } from "react-router-dom"
import { X, Folder, ArrowUp, Check, GitBranch } from "lucide-react"
import { useBrowse, useAdoptProgram } from "@/services/api/hooks"
import { TextField } from "./detail/fields"
const IS_GIT_URL = /^(https?:\/\/|git@|ssh:\/\/)|\.git$/
/** Split the typed path into the directory to browse and the trailing partial
* segment to filter by — the autocomplete engine. Typing within a directory only
* changes the filter (client-side, no refetch); crossing a "/" browses the new dir.
* "/data/repos/wid" -> browse "/data/repos", filter "wid"
* "/data/repos/" -> browse "/data/repos", filter ""
* "wid" -> browse the default repos dir, filter "wid" */
function splitPath(raw: string): { dir: string | null; filter: string } {
if (raw === "") return { dir: null, filter: "" }
if (raw.endsWith("/")) return { dir: raw.length > 1 ? raw.slice(0, -1) : "/", filter: "" }
const idx = raw.lastIndexOf("/")
if (idx === -1) return { dir: null, filter: raw }
if (idx === 0) return { dir: "/", filter: raw.slice(1) }
return { dir: raw.slice(0, idx), filter: raw.slice(idx + 1) }
}
/** Adopt an existing repo as a program (the web `castle program add`). Programs
* live on the *server's* filesystem, so the picker browses the server's dirs: type
* a path (autocompletes as you go), navigate the listing, or paste a git URL. */
export function AddProgramForm({
existingNames,
onCancel,
}: {
existingNames: string[]
onCancel: () => void
}) {
const navigate = useNavigate()
const adopt = useAdoptProgram()
// The path bar is the single source of truth — its value is the adopt target.
// Empty browses the server's repos dir (surfaced as the placeholder), so the
// listing is populated from the first render without seeding the field.
const [input, setInput] = useState("")
const [name, setName] = useState("")
const [description, setDescription] = useState("")
const [error, setError] = useState("")
const target = input.trim()
const isGit = IS_GIT_URL.test(target)
const { dir, filter } = splitPath(target)
const { data: browse, isFetching, error: browseError } = useBrowse(dir, !isGit)
const entries = (browse?.entries ?? []).filter((e) =>
filter ? e.name.toLowerCase().startsWith(filter.toLowerCase()) : true,
)
const suggestedName = target
? target.replace(/\/+$/, "").split("/").pop()!.replace(/\.git$/, "")
: ""
const effectiveName = name.trim() || suggestedName
const nameError =
name && !/^[a-z0-9][a-z0-9-]*$/.test(name)
? "lowercase letters, numbers, hyphens"
: effectiveName && existingNames.includes(effectiveName)
? "already exists"
: ""
const submit = async () => {
if (!target || nameError) return
setError("")
try {
const res = await adopt.mutateAsync({
target,
name: name.trim() || undefined,
description: description.trim() || undefined,
})
navigate(`/programs/${res.program}`)
} catch (e: unknown) {
let msg = e instanceof Error ? e.message : String(e)
try {
msg = JSON.parse((e as Error).message).detail ?? msg
} catch {
/* keep msg */
}
setError(msg)
}
}
return (
<div className="bg-[var(--card)] border border-[var(--primary)] rounded-lg p-5 space-y-4">
<div className="flex items-center justify-between">
<h3 className="font-semibold">Add program</h3>
<button onClick={onCancel} className="text-[var(--muted)] hover:text-[var(--foreground)]">
<X size={16} />
</button>
</div>
{error && (
<div className="text-sm text-red-400 bg-red-900/20 border border-red-800 rounded px-3 py-1.5">
{error}
</div>
)}
{/* Editable path bar + live directory listing (or a pasted git URL). */}
<div className="border border-[var(--border)] rounded">
<div className="flex items-center gap-2 px-2 py-1.5 border-b border-[var(--border)] bg-black/20">
<button
onClick={() => browse?.parent && setInput(browse.parent + "/")}
disabled={isGit || !browse?.parent}
title="Up one level"
className="text-[var(--muted)] hover:text-[var(--foreground)] disabled:opacity-30 shrink-0"
>
<ArrowUp size={14} />
</button>
<input
value={input}
onChange={(e) => setInput(e.target.value)}
autoFocus
spellCheck={false}
placeholder={browse?.path ? `${browse.path}/ (or a git URL)` : "/data/repos/… or a git URL"}
className="flex-1 min-w-0 bg-transparent text-xs font-mono text-[var(--foreground)] focus:outline-none py-1"
/>
{isFetching && !isGit && <span className="text-[10px] text-[var(--muted)] shrink-0"></span>}
</div>
{!isGit && (
<div className="max-h-56 overflow-y-auto">
{browseError ? (
<p className="text-xs text-red-400 px-3 py-3">
{(() => {
const m = browseError instanceof Error ? browseError.message : ""
try {
return JSON.parse(m).detail ?? "Cannot read directory"
} catch {
return m || "Cannot read directory"
}
})()}
</p>
) : entries.length > 0 ? (
entries.map((e) => (
<div
key={e.path}
className="flex items-center gap-2 px-3 py-1.5 text-sm border-b border-[var(--border)]/50 last:border-0"
>
<button
onClick={() => setInput(e.path + "/")}
className="flex items-center gap-2 min-w-0 flex-1 text-left hover:text-[var(--primary)]"
title="Open"
>
{e.is_git ? (
<GitBranch size={13} className="shrink-0 text-[var(--primary)]" />
) : (
<Folder size={13} className="shrink-0 text-[var(--muted)]" />
)}
<span className="truncate font-mono">{e.name}</span>
</button>
<button
onClick={() => setInput(e.path)}
className={`shrink-0 px-2 py-0.5 text-xs rounded border transition-colors ${
e.is_program
? "border-[var(--primary)] text-[var(--primary)] hover:bg-[var(--primary)] hover:text-white"
: "border-[var(--border)] text-[var(--muted)] hover:text-[var(--foreground)]"
}`}
>
{target === e.path ? (
<span className="flex items-center gap-1">
<Check size={11} /> picked
</span>
) : (
"pick"
)}
</button>
</div>
))
) : (
<p className="text-xs text-[var(--muted)] px-3 py-3">
{filter ? "No matching sub-directories." : "No sub-directories."}
</p>
)}
</div>
)}
</div>
{target && (
<p className="text-xs text-[var(--muted)]">
Registering <span className="font-mono text-[var(--foreground)]">{target}</span>
{isGit && " (cloned later via castle clone)"}
</p>
)}
<TextField
label="Name"
value={name}
onChange={(v) => setName(v.toLowerCase())}
mono
placeholder={suggestedName || "program-name"}
/>
{nameError && <p className="text-xs text-red-400 -mt-2 ml-28 sm:ml-36">{nameError}</p>}
<TextField label="Description" value={description} onChange={setDescription} />
<div className="flex justify-end gap-2 pt-2">
<button
onClick={onCancel}
className="px-3 py-1.5 text-sm rounded border border-[var(--border)] text-[var(--muted)] hover:text-[var(--foreground)]"
>
Cancel
</button>
<button
onClick={submit}
disabled={!target || !!nameError || adopt.isPending}
className="px-4 py-1.5 text-sm rounded bg-green-700 hover:bg-green-600 text-white transition-colors disabled:opacity-40"
>
{adopt.isPending ? "Registering…" : "Register"}
</button>
</div>
</div>
)
}

View File

@@ -45,25 +45,29 @@ export function ProgramCard({
<StackBadge stack={program.stack} />
</div>
{program.description && (
<p className="text-sm text-[var(--muted)] mb-2">{program.description}</p>
)}
{/* A program has no kind of its own — show its deployments (name · kind).
When a deployment shares the program's name (the common 1:1 case) the
name is redundant with the card title, so show just the kind badge.
Suppressed on kind-specific lenses (e.g. Tools) which already scope to one. */}
{showDeployments &&
(program.deployments.length > 0 ? (
<div className="flex flex-col gap-1 mb-2">
<div className="flex flex-col gap-1">
{program.deployments.map((d) => (
<div key={d.name} className="flex items-center gap-1.5 text-xs">
<span className="font-mono text-[var(--muted)]">{d.name}</span>
{d.name !== program.id && (
<span className="font-mono text-[var(--muted)]">{d.name}</span>
)}
<KindBadge kind={d.kind} />
</div>
))}
</div>
) : (
<p className="text-xs text-[var(--muted)] italic mb-2">no deployment</p>
<p className="text-xs text-[var(--muted)] italic">no deployment</p>
))}
{program.description && (
<p className="text-sm text-[var(--muted)]">{program.description}</p>
)}
</div>
)
}

View File

@@ -1,17 +1,41 @@
import { useState } from "react"
import { Plus } from "lucide-react"
import { usePrograms } from "@/services/api/hooks"
import { ProgramList } from "@/components/ProgramList"
import { MonorepoBanner } from "@/components/MonorepoBanner"
import { PageHeader } from "@/components/PageHeader"
import { AddProgramForm } from "@/components/AddProgramForm"
export function Programs() {
const { data: programs, isLoading } = usePrograms()
const [adding, setAdding] = useState(false)
return (
<div className="max-w-6xl mx-auto px-6 py-8">
<PageHeader title="Programs" subtitle="Software catalog" />
<PageHeader
title="Programs"
subtitle="Software catalog"
actions={
<button
onClick={() => setAdding((a) => !a)}
className="flex items-center gap-1 px-3 py-1.5 text-sm rounded border border-[var(--border)] text-[var(--muted)] hover:text-[var(--foreground)] hover:border-[var(--primary)] transition-colors"
>
<Plus size={14} /> Add program
</button>
}
/>
<MonorepoBanner />
{adding && (
<div className="mb-6 max-w-2xl">
<AddProgramForm
existingNames={(programs ?? []).map((p) => p.id)}
onCancel={() => setAdding(false)}
/>
</div>
)}
{isLoading ? (
<p className="text-[var(--muted)]">Loading...</p>
) : programs && programs.length > 0 ? (

View File

@@ -385,6 +385,56 @@ export function useProgramSync() {
})
}
// Server-side directory browser for the "Add program" flow. Programs live on the
// server's filesystem, so the picker browses the server's dirs (the browser's own
// file dialog only sees the client machine). `path` null => the repos dir.
export interface BrowseEntry {
name: string
path: string
is_program: boolean
is_git: boolean
}
export interface BrowseResult {
path: string
parent: string | null
repos_dir: string
entries: BrowseEntry[]
}
export function useBrowse(path: string | null, enabled = true) {
return useQuery({
queryKey: ["browse", path ?? "@repos"],
queryFn: () =>
apiClient.get<BrowseResult>(
`/fs/browse${path ? `?path=${encodeURIComponent(path)}` : ""}`,
),
enabled,
})
}
export interface AdoptResult {
ok: boolean
program: string
source: string
stack: string | null
repo: string | null
commands: string[]
is_git_url: boolean
}
// Adopt an existing repo as a program (the web `castle program add`). Refreshes
// the catalog + the derived graph/repo views.
export function useAdoptProgram() {
const qc = useQueryClient()
return useMutation({
mutationFn: (body: { target: string; name?: string; description?: string }) =>
apiClient.post<AdoptResult>("/programs/adopt", body),
onSuccess: () => {
qc.invalidateQueries({ queryKey: ["programs"] })
qc.invalidateQueries({ queryKey: ["graph"] })
qc.invalidateQueries({ queryKey: ["repos"] })
},
})
}
export function useRepos() {
return useQuery({
queryKey: ["repos"],

View File

@@ -3,10 +3,19 @@
from __future__ import annotations
from dataclasses import asdict
from pathlib import Path
from fastapi import APIRouter, HTTPException, status
from pydantic import BaseModel
from castle_core import git
from castle_core.adopt import (
AdoptError,
build_adopted_program,
is_git_url,
looks_like_program,
)
from castle_core.config import write_program_file
from castle_core.stacks import available_actions, available_stacks, run_action
from castle_api import stream
@@ -22,6 +31,103 @@ def list_stacks() -> list[str]:
return available_stacks()
# ---------------------------------------------------------------------------
# Filesystem browse + adopt — powers the dashboard's "Add program" flow, the
# web equivalent of `castle program add <path|git-url>`. Programs live on the
# server's filesystem, so the picker browses the *server's* dirs (a browser's
# native file dialog only sees the client machine).
# ---------------------------------------------------------------------------
@programs_router.get("/fs/browse")
def browse_filesystem(path: str | None = None) -> dict:
"""List sub-directories of ``path`` (default: the repos dir) so the dashboard
can browse to a program on the server. Directories only; hidden dirs skipped.
Each entry is flagged when it looks adoptable (a project manifest or git repo).
"""
config = get_config()
base = Path(path).expanduser() if path else config.repos_dir
try:
base = base.resolve()
except OSError as e:
raise HTTPException(status_code=400, detail=f"Invalid path: {e}")
if not base.exists():
raise HTTPException(status_code=404, detail=f"Path does not exist: {base}")
if not base.is_dir():
raise HTTPException(status_code=400, detail=f"Not a directory: {base}")
try:
children = sorted(base.iterdir(), key=lambda p: p.name.lower())
except PermissionError:
raise HTTPException(status_code=403, detail=f"Permission denied: {base}")
entries: list[dict] = []
for child in children:
if child.name.startswith("."):
continue
# A single unreadable child (can't stat/traverse) shouldn't sink the whole
# listing — skip it rather than 403 the directory.
try:
if not child.is_dir():
continue
entries.append(
{
"name": child.name,
"path": str(child),
"is_program": looks_like_program(child),
"is_git": (child / ".git").exists(),
}
)
except OSError:
continue
parent = str(base.parent) if base.parent != base else None
return {
"path": str(base),
"parent": parent,
"repos_dir": str(config.repos_dir),
"entries": entries,
}
class AdoptRequest(BaseModel):
target: str # a local server path or a git URL
name: str | None = None
description: str = ""
@programs_router.post("/programs/adopt")
def adopt_program(request: AdoptRequest) -> dict:
"""Adopt an existing repo as a program (the web `castle program add`).
``target`` is a local server path or a git URL. Writes just the new program's
file; declaring a deployment (service/job/tool/static) stays a separate step.
"""
target = request.target.strip()
if not target:
raise HTTPException(status_code=422, detail="A path or git URL is required.")
config = get_config()
try:
adopted = build_adopted_program(
config, target, name=request.name, description=request.description
)
except AdoptError as e:
raise HTTPException(status_code=422, detail=str(e))
config.programs[adopted.name] = adopted.spec
write_program_file(config, adopted.name)
return {
"ok": True,
"program": adopted.name,
"source": adopted.source,
"stack": adopted.stack,
"repo": adopted.repo,
"commands": adopted.commands,
"is_git_url": is_git_url(target),
}
# ---------------------------------------------------------------------------
# Git sync — pull a program's source working copy up to date (pull only; no
# build/apply/restart — converge stays an explicit, separate step). Declared

View File

@@ -3,125 +3,43 @@
`castle program create` makes new code from a stack. `castle program add` adopts code that
already exists — a local path, or a git URL to clone. It detects sensible dev
verb commands so a non-castle project becomes usable without writing them by hand.
The adopt logic itself lives in ``castle_core.adopt`` so the CLI and the API
(`POST /programs/adopt`, the dashboard's "Add program") behave identically.
"""
from __future__ import annotations
import argparse
import tomllib
from pathlib import Path
from castle_core.adopt import AdoptError, build_adopted_program
from castle_cli.config import load_config, save_config
from castle_cli.manifest import BuildSpec, CommandsSpec, ProgramSpec
def _is_git_url(s: str) -> bool:
return s.startswith(("http://", "https://", "git@", "ssh://")) or s.endswith(".git")
def _detect(src: Path) -> tuple[str | None, dict[str, list[list[str]]]]:
"""Detect (stack, commands) for a source dir.
Returns a stack name when the project fits a known one (so it inherits those
defaults), otherwise an explicit commands map. `add` adopts source only; the
deployment (and thus kind) is declared separately, so no kind is inferred here.
"""
commands: dict[str, list[list[str]]] = {}
pyproject = src / "pyproject.toml"
if pyproject.exists():
try:
data = tomllib.loads(pyproject.read_text())
except (OSError, tomllib.TOMLDecodeError):
data = {}
deps = " ".join(data.get("project", {}).get("dependencies", []))
stack = "python-fastapi" if ("fastapi" in deps or "uvicorn" in deps) else "python-cli"
return stack, commands
if (src / "Cargo.toml").exists():
commands = {
"build": [["cargo", "build", "--release"]],
"test": [["cargo", "test"]],
"lint": [["cargo", "clippy"]],
"run": [["cargo", "run"]],
}
return None, commands
if (src / "package.json").exists():
commands = {
"build": [["pnpm", "build"]],
"test": [["pnpm", "test"]],
"lint": [["pnpm", "lint"]],
}
return None, commands
if (src / "Makefile").exists() or (src / "makefile").exists():
commands = {"build": [["make"]], "test": [["make", "test"]]}
return None, commands
return None, commands
def run_add(args: argparse.Namespace) -> int:
"""Adopt an existing repo as a program."""
config = load_config()
target = args.target
repo_url: str | None = None
source: str | None = None
if _is_git_url(target):
repo_url = target
name = args.name or Path(target.rstrip("/")).name.removesuffix(".git")
# Default local clone location; cloned later via `castle clone`.
source = str(config.repos_dir / name)
src_path = Path(source)
else:
src_path = Path(target).expanduser().resolve()
if not src_path.exists():
print(f"Error: path does not exist: {src_path}")
return 1
source = str(src_path)
name = args.name or src_path.name
if name in config.programs or config.deployments_named(name):
print(f"Error: '{name}' already exists in castle.yaml")
try:
adopted = build_adopted_program(
config, args.target, name=args.name, description=args.description
)
except AdoptError as e:
print(f"Error: {e}")
return 1
# Detect verbs from the working copy if we have one on disk. `kind` is derived
# from a deployment, not stored on the program — so `castle add` adopts the
# source only; declare a deployment separately (castle service/job create).
stack: str | None = None
detected_commands: dict[str, list[list[str]]] = {}
if src_path.exists():
stack, detected_commands = _detect(src_path)
prog = ProgramSpec(
id=name,
description=args.description or f"Adopted from {target}",
source=source,
stack=stack,
repo=repo_url,
)
# `build` is declared via BuildSpec; every other verb via CommandsSpec.
if detected_commands:
build_cmds = detected_commands.pop("build", None)
if build_cmds:
prog.build = BuildSpec(commands=build_cmds)
if detected_commands:
prog.commands = CommandsSpec.model_validate(detected_commands)
config.programs[name] = prog
config.programs[adopted.name] = adopted.spec
save_config(config)
print(f"Adopted '{name}' as a program.")
print(f" source: {source}")
if repo_url:
print(f" repo: {repo_url} (run 'castle clone {name}' to fetch it)")
if stack:
print(f" stack: {stack} (verbs inherited from stack defaults)")
elif detected_commands:
print(f" commands detected: {', '.join(sorted(detected_commands))}")
print(f"Adopted '{adopted.name}' as a program.")
print(f" source: {adopted.source}")
if adopted.repo:
print(f" repo: {adopted.repo} (run 'castle clone {adopted.name}' to fetch it)")
if adopted.stack:
print(f" stack: {adopted.stack} (verbs inherited from stack defaults)")
elif adopted.commands:
print(f" commands detected: {', '.join(adopted.commands)}")
else:
print(" no stack/commands detected — declare verbs in castle.yaml as needed")
return 0

View File

@@ -0,0 +1,157 @@
"""Adopt an existing repo as a program — shared by the CLI (`castle program add`)
and the API (`POST /programs/adopt`).
`create` scaffolds new code from a stack; `add` adopts code that already exists —
a local path, or a git URL to clone later. Keeping the target parsing, stack /
command sniffing, and ProgramSpec build here means both front-ends behave
identically (the logic used to live only in the CLI).
"""
from __future__ import annotations
import tomllib
from dataclasses import dataclass, field
from pathlib import Path
from castle_core.config import CastleConfig
from castle_core.manifest import BuildSpec, CommandsSpec, ProgramSpec
class AdoptError(ValueError):
"""A program can't be adopted (bad path, or the name already exists)."""
def is_git_url(s: str) -> bool:
return s.startswith(("http://", "https://", "git@", "ssh://")) or s.endswith(".git")
def looks_like_program(src: Path) -> bool:
"""Whether a directory holds something castle can adopt (a project manifest or
a git repo). Used to flag candidates in the filesystem browser."""
return (
(src / ".git").exists()
or (src / "pyproject.toml").exists()
or (src / "Cargo.toml").exists()
or (src / "package.json").exists()
or (src / "Makefile").exists()
or (src / "makefile").exists()
)
def detect_stack_commands(src: Path) -> tuple[str | None, dict[str, list[list[str]]]]:
"""Detect (stack, commands) for a source dir.
Returns a stack name when the project fits a known one (so it inherits those
defaults), otherwise an explicit commands map. Adoption takes source only; the
deployment (and thus kind) is declared separately, so no kind is inferred here.
"""
commands: dict[str, list[list[str]]] = {}
pyproject = src / "pyproject.toml"
if pyproject.exists():
try:
data = tomllib.loads(pyproject.read_text())
except (OSError, tomllib.TOMLDecodeError):
data = {}
deps = " ".join(data.get("project", {}).get("dependencies", []))
stack = "python-fastapi" if ("fastapi" in deps or "uvicorn" in deps) else "python-cli"
return stack, commands
if (src / "Cargo.toml").exists():
commands = {
"build": [["cargo", "build", "--release"]],
"test": [["cargo", "test"]],
"lint": [["cargo", "clippy"]],
"run": [["cargo", "run"]],
}
return None, commands
if (src / "package.json").exists():
commands = {
"build": [["pnpm", "build"]],
"test": [["pnpm", "test"]],
"lint": [["pnpm", "lint"]],
}
return None, commands
if (src / "Makefile").exists() or (src / "makefile").exists():
commands = {"build": [["make"]], "test": [["make", "test"]]}
return None, commands
return None, commands
@dataclass
class Adopted:
"""The result of building a program spec from an adopt target — the caller
persists it (``config.programs[name] = spec``; then save/write)."""
name: str
spec: ProgramSpec
source: str
stack: str | None = None
repo: str | None = None
commands: list[str] = field(default_factory=list)
def build_adopted_program(
config: CastleConfig,
target: str,
name: str | None = None,
description: str = "",
) -> Adopted:
"""Build (but do not save) a ProgramSpec adopting ``target``.
``target`` is a local path or a git URL. Raises :class:`AdoptError` if a local
path doesn't exist or the resolved name already exists in the config. Does not
mutate ``config`` — the caller assigns ``config.programs[name]`` and persists.
"""
repo_url: str | None = None
if is_git_url(target):
repo_url = target
name = name or Path(target.rstrip("/")).name.removesuffix(".git")
# Default local clone location; cloned later via `castle clone`.
source = str(config.repos_dir / name)
src_path = Path(source)
else:
src_path = Path(target).expanduser().resolve()
if not src_path.exists():
raise AdoptError(f"path does not exist: {src_path}")
source = str(src_path)
name = name or src_path.name
if name in config.programs or config.deployments_named(name):
raise AdoptError(f"'{name}' already exists in castle.yaml")
# Detect verbs from the working copy if we have one on disk. `kind` is derived
# from a deployment, not stored on the program — so adoption takes source only;
# declare a deployment separately (castle service/job create).
stack: str | None = None
detected: dict[str, list[list[str]]] = {}
if src_path.exists():
stack, detected = detect_stack_commands(src_path)
spec = ProgramSpec(
id=name,
description=description or f"Adopted from {target}",
source=source,
stack=stack,
repo=repo_url,
)
# `build` is declared via BuildSpec; every other verb via CommandsSpec.
if detected:
build_cmds = detected.pop("build", None)
if build_cmds:
spec.build = BuildSpec(commands=build_cmds)
if detected:
spec.commands = CommandsSpec.model_validate(detected)
return Adopted(
name=name,
spec=spec,
source=source,
stack=stack,
repo=repo_url,
commands=sorted(detected),
)