diff --git a/app/src/components/AddProgramForm.tsx b/app/src/components/AddProgramForm.tsx
new file mode 100644
index 0000000..8f3427a
--- /dev/null
+++ b/app/src/components/AddProgramForm.tsx
@@ -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 (
+
+
+
Add program
+
+
+
+
+
+ {error && (
+
+ {error}
+
+ )}
+
+ {/* Editable path bar + live directory listing (or a pasted git URL). */}
+
+
+
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"
+ >
+
+
+
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 &&
… }
+
+
+ {!isGit && (
+
+ {browseError ? (
+
+ {(() => {
+ const m = browseError instanceof Error ? browseError.message : ""
+ try {
+ return JSON.parse(m).detail ?? "Cannot read directory"
+ } catch {
+ return m || "Cannot read directory"
+ }
+ })()}
+
+ ) : entries.length > 0 ? (
+ entries.map((e) => (
+
+ 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 ? (
+
+ ) : (
+
+ )}
+ {e.name}
+
+ 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 ? (
+
+ picked
+
+ ) : (
+ "pick"
+ )}
+
+
+ ))
+ ) : (
+
+ {filter ? "No matching sub-directories." : "No sub-directories."}
+
+ )}
+
+ )}
+
+
+ {target && (
+
+ Registering {target}
+ {isGit && " (cloned later via castle clone)"}
+
+ )}
+
+
setName(v.toLowerCase())}
+ mono
+ placeholder={suggestedName || "program-name"}
+ />
+ {nameError && {nameError}
}
+
+
+
+
+
+ Cancel
+
+
+ {adopt.isPending ? "Registering…" : "Register"}
+
+
+
+ )
+}
diff --git a/app/src/components/ProgramCard.tsx b/app/src/components/ProgramCard.tsx
index c38743a..8cbe475 100644
--- a/app/src/components/ProgramCard.tsx
+++ b/app/src/components/ProgramCard.tsx
@@ -45,25 +45,29 @@ export function ProgramCard({
+ {program.description && (
+ {program.description}
+ )}
+
{/* 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 ? (
-
+
{program.deployments.map((d) => (
- {d.name}
+ {d.name !== program.id && (
+ {d.name}
+ )}
))}
) : (
-
no deployment
+
no deployment
))}
-
- {program.description && (
-
{program.description}
- )}
)
}
diff --git a/app/src/pages/Programs.tsx b/app/src/pages/Programs.tsx
index f5c078a..7f07fa2 100644
--- a/app/src/pages/Programs.tsx
+++ b/app/src/pages/Programs.tsx
@@ -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 (
-
+
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"
+ >
+ Add program
+
+ }
+ />
+ {adding && (
+
+
p.id)}
+ onCancel={() => setAdding(false)}
+ />
+
+ )}
+
{isLoading ? (
Loading...
) : programs && programs.length > 0 ? (
diff --git a/app/src/services/api/hooks.ts b/app/src/services/api/hooks.ts
index b5db830..90d1f53 100644
--- a/app/src/services/api/hooks.ts
+++ b/app/src/services/api/hooks.ts
@@ -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(
+ `/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("/programs/adopt", body),
+ onSuccess: () => {
+ qc.invalidateQueries({ queryKey: ["programs"] })
+ qc.invalidateQueries({ queryKey: ["graph"] })
+ qc.invalidateQueries({ queryKey: ["repos"] })
+ },
+ })
+}
+
export function useRepos() {
return useQuery({
queryKey: ["repos"],
diff --git a/castle-api/src/castle_api/programs.py b/castle-api/src/castle_api/programs.py
index 5c1ad3a..b5182bc 100644
--- a/castle-api/src/castle_api/programs.py
+++ b/castle-api/src/castle_api/programs.py
@@ -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 `. 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
diff --git a/cli/src/castle_cli/commands/add.py b/cli/src/castle_cli/commands/add.py
index 0a66acd..427f186 100644
--- a/cli/src/castle_cli/commands/add.py
+++ b/cli/src/castle_cli/commands/add.py
@@ -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
diff --git a/core/src/castle_core/adopt.py b/core/src/castle_core/adopt.py
new file mode 100644
index 0000000..435a737
--- /dev/null
+++ b/core/src/castle_core/adopt.py
@@ -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),
+ )