diff --git a/README.md b/README.md index 667cd20..f4bebc1 100644 --- a/README.md +++ b/README.md @@ -132,10 +132,12 @@ approvals, sandbox limits, and audit records keep build and live operations inside explicit boundaries. ```bash -# Scaffold a small app that consumes a BackAI deployment. +# A standalone app that calls a running BackAI. Works in any directory. af-stack init my-ai-product -# Or customize a full fork and give it to your coding agent. +# Or brand a full fork and hand it to your coding agent. These run inside +# a clone of this repo; that clone is where the four surfaces below live. +git clone https://github.com/Agent-Field/backai acme-ai && cd acme-ai af-stack init --name "Acme AI" --color "#2563EB" --logo ./logo.png af-stack agent new researcher ``` diff --git a/docs/cli-distribution.md b/docs/cli-distribution.md index c091a26..c314397 100644 --- a/docs/cli-distribution.md +++ b/docs/cli-distribution.md @@ -108,7 +108,7 @@ Every command below exists in the current binary (see [`services/cli/cmd/af-stack/main.go`](../services/cli/cmd/af-stack/main.go)). ```bash -# Fork bootstrap + dev loop +# Fork bootstrap + dev loop (run inside a clone of this repo) af-stack init --name "DocuChat" --color "#0A66C2" --logo ./logo.png af-stack dev --detach af-stack mode personal|saas # auth+billing off ⇄ multi-tenant SaaS diff --git a/docs/dx/README.md b/docs/dx/README.md index 3cbc33a..976710a 100644 --- a/docs/dx/README.md +++ b/docs/dx/README.md @@ -7,13 +7,17 @@ source — where this hub and older prose disagree, this hub wins. ## The golden path (CLI-first) ```bash -af-stack init my-app # scaffold a fork / new project +git clone https://github.com/Agent-Field/backai my-app && cd my-app +af-stack init --name "My App" # brand the fork: brand.yaml, logos, default agent af-stack dev # preflight ports + docker compose up # … edit one of the four surfaces (below) … af-stack deploy helm # ship it (helm | fly | railway | render) ``` -Four commands, one loop: **init → dev → edit → deploy**. See +Four commands, one loop: **init → dev → edit → deploy**, all inside the +clone. `af-stack init ` with a positional name is a different thing: +it scaffolds a small standalone app that *calls* a running BackAI, in any +directory, and has no surfaces to brand or extend. See [run.md](run.md) for what `af-stack dev` actually brings up and [build-app.md](build-app.md) for the surfaces you edit. diff --git a/docs/theming.md b/docs/theming.md index 1cf55e0..b124eb7 100644 --- a/docs/theming.md +++ b/docs/theming.md @@ -1,7 +1,7 @@ # Theming and Branding BackAI branding starts in root [`brand.yaml`](../brand.yaml). For a -new fork, prefer the CLI: +new fork, prefer the CLI, run inside your clone of the repo: ```bash af-stack init --name "DocuChat" --color "#0A66C2" --logo ./logo.png diff --git a/services/cli/cmd/af-stack/main.go b/services/cli/cmd/af-stack/main.go index 6c3359f..d5de837 100644 --- a/services/cli/cmd/af-stack/main.go +++ b/services/cli/cmd/af-stack/main.go @@ -9,7 +9,7 @@ // Currently shipped subcommands: // // af-stack init my-app Scaffold a new app on the stack -// af-stack init --brand --name "DocuChat" Re-theme a fork (power-user path) +// af-stack init --name "DocuChat" Re-theme a fork (run inside a clone) // af-stack dev Start local compose dev loop // af-stack agent new Scaffold an AgentField agent // af-stack module new Scaffold a workload module diff --git a/services/cli/internal/checkout/checkout.go b/services/cli/internal/checkout/checkout.go new file mode 100644 index 0000000..6316b7c --- /dev/null +++ b/services/cli/internal/checkout/checkout.go @@ -0,0 +1,103 @@ +// Package checkout locates the BackAI checkout — a clone of the repository — +// that in-tree commands (`init --name`, `dev`, `agent|module|plugin new`, +// `deploy`) operate on, and explains clearly when there is none. +// +// The most common way to end up outside a checkout is to scaffold a +// standalone app with `af-stack init `, cd into it, and then run a +// fork command there. That directory calls a running BackAI; it has no +// apps/ tree to brand or add agents to. The error says so. +package checkout + +import ( + "fmt" + "os" + "path/filepath" + "strings" +) + +// RepoURL is the upstream the error message tells users to clone. +const RepoURL = "https://github.com/Agent-Field/backai" + +// NotFoundError is returned when no checkout encloses the start directory. +type NotFoundError struct { + // Dir is the directory the search started from. + Dir string + // ScaffoldedApp is Dir or the nearest ancestor that looks like an app + // written by `af-stack init `, or "" when there is none. + ScaffoldedApp string +} + +func (e *NotFoundError) Error() string { + var b strings.Builder + fmt.Fprintf(&b, "must run from inside a BackAI checkout — a clone of %s (a directory containing apps/dashboard and apps/customer-app); %s is not one.", RepoURL, e.Dir) + if e.ScaffoldedApp != "" { + fmt.Fprintf(&b, "\n %s is a standalone app created by `af-stack init `: it calls a running BackAI and has no fork surfaces to brand or extend.", e.ScaffoldedApp) + } + fmt.Fprintf(&b, "\n To brand a fork or add agents, modules, or plugins: git clone %s my-fork && cd my-fork", RepoURL) + fmt.Fprintf(&b, "\n To scaffold a standalone app instead: af-stack init (works in any directory)") + return b.String() +} + +// Find walks up from the working directory to the nearest checkout root. +func Find() (string, error) { + wd, err := os.Getwd() + if err != nil { + return "", err + } + return FindFrom(wd) +} + +// FindFrom is Find starting at dir instead of the working directory. +func FindFrom(dir string) (string, error) { + for d := dir; ; { + if IsRoot(d) { + return d, nil + } + next := filepath.Dir(d) + if next == d { + break + } + d = next + } + return "", &NotFoundError{Dir: dir, ScaffoldedApp: scaffoldedAppAbove(dir)} +} + +// IsRoot reports whether dir is the root of a checkout: the workspace +// package.json plus both apps the platform ships. +func IsRoot(dir string) bool { + return exists(filepath.Join(dir, "package.json")) && + exists(filepath.Join(dir, "apps", "dashboard")) && + exists(filepath.Join(dir, "apps", "customer-app")) +} + +// scaffoldedAppAbove returns dir or the nearest ancestor that looks like an +// app written by `af-stack init `, or "" when there is none. +func scaffoldedAppAbove(dir string) string { + for d := dir; ; { + if looksScaffolded(d) { + return d + } + next := filepath.Dir(d) + if next == d { + return "" + } + d = next + } +} + +// looksScaffolded matches what initcmd's scaffolds write: package.json, +// CLAUDE.md, and an .env.example that points the app at AF_STACK_URL (or +// VITE_AF_STACK_URL for the saas template). +func looksScaffolded(dir string) bool { + if !exists(filepath.Join(dir, "package.json")) || !exists(filepath.Join(dir, "CLAUDE.md")) { + return false + } + // #nosec G304 -- a marker file under a directory the user is already running in. + env, err := os.ReadFile(filepath.Join(dir, ".env.example")) + return err == nil && strings.Contains(string(env), "AF_STACK_URL=") +} + +func exists(path string) bool { + _, err := os.Stat(path) + return err == nil +} diff --git a/services/cli/internal/checkout/checkout_test.go b/services/cli/internal/checkout/checkout_test.go new file mode 100644 index 0000000..12df408 --- /dev/null +++ b/services/cli/internal/checkout/checkout_test.go @@ -0,0 +1,141 @@ +package checkout + +import ( + "errors" + "os" + "path/filepath" + "strings" + "testing" +) + +func write(t *testing.T, root, rel, contents string) { + t.Helper() + path := filepath.Join(root, filepath.FromSlash(rel)) + if err := os.MkdirAll(filepath.Dir(path), 0o755); err != nil { + t.Fatal(err) + } + if err := os.WriteFile(path, []byte(contents), 0o644); err != nil { + t.Fatal(err) + } +} + +func fakeCheckout(t *testing.T) string { + t.Helper() + root := t.TempDir() + write(t, root, "package.json", "{}") + write(t, root, "apps/dashboard/.keep", "") + write(t, root, "apps/customer-app/.keep", "") + return root +} + +func fakeScaffoldedApp(t *testing.T, env string) string { + t.Helper() + app := filepath.Join(t.TempDir(), "my-ai-product") + write(t, app, "package.json", `{"name":"my-ai-product"}`) + write(t, app, "CLAUDE.md", "# my-ai-product — an app on the AF Stack backend") + write(t, app, ".env.example", env) + write(t, app, "src/index.mjs", "") + return app +} + +// Contract: inside a clone, or any subdirectory of it, the root is found +// and commands behave exactly as before. +func TestFindFromInsideCheckoutSubdir(t *testing.T) { + root := fakeCheckout(t) + deep := filepath.Join(root, "apps", "backend", "agents") + if err := os.MkdirAll(deep, 0o755); err != nil { + t.Fatal(err) + } + got, err := FindFrom(deep) + if err != nil { + t.Fatalf("FindFrom(subdir) error: %v", err) + } + if got != root { + t.Fatalf("FindFrom(subdir) = %q, want %q", got, root) + } +} + +// Contract: outside a checkout the error names the directory, says what a +// checkout is, gives the clone command, and offers the standalone scaffold. +func TestFindFromOutsideCheckoutExplains(t *testing.T) { + dir := t.TempDir() + _, err := FindFrom(dir) + var nf *NotFoundError + if !errors.As(err, &nf) { + t.Fatalf("want *NotFoundError, got %T: %v", err, err) + } + if nf.ScaffoldedApp != "" { + t.Fatalf("empty dir should not look scaffolded, got %q", nf.ScaffoldedApp) + } + msg := err.Error() + for _, want := range []string{ + dir, + "apps/dashboard and apps/customer-app", + "git clone " + RepoURL, + "af-stack init ", + } { + if !strings.Contains(msg, want) { + t.Errorf("error message missing %q:\n%s", want, msg) + } + } + if strings.Contains(msg, "standalone app created by") { + t.Errorf("empty dir must not be described as a scaffolded app:\n%s", msg) + } +} + +// Contract: inside an app written by `af-stack init ` the error says +// this is a standalone app, not a fork — for both scaffold templates. +func TestFindFromInsideScaffoldedAppSaysSo(t *testing.T) { + for name, env := range map[string]string{ + "node": "AF_STACK_URL=http://localhost:8080\n", + "saas": "VITE_AF_STACK_URL=http://localhost:8080\n", + } { + t.Run(name, func(t *testing.T) { + app := fakeScaffoldedApp(t, env) + _, err := FindFrom(filepath.Join(app, "src")) + var nf *NotFoundError + if !errors.As(err, &nf) { + t.Fatalf("want *NotFoundError, got %T: %v", err, err) + } + if nf.ScaffoldedApp != app { + t.Fatalf("ScaffoldedApp = %q, want %q", nf.ScaffoldedApp, app) + } + msg := err.Error() + if !strings.Contains(msg, app+" is a standalone app created by `af-stack init `") { + t.Errorf("error message should identify the scaffolded app:\n%s", msg) + } + if !strings.Contains(msg, "git clone "+RepoURL) { + t.Errorf("error message should still give the clone command:\n%s", msg) + } + }) + } +} + +// Contract: a checkout that happens to carry the scaffold markers too is +// still a checkout. +func TestCheckoutWinsOverScaffoldMarkers(t *testing.T) { + root := fakeCheckout(t) + write(t, root, "CLAUDE.md", "@AGENTS.md") + write(t, root, ".env.example", "AF_STACK_URL=http://localhost:8080\n") + got, err := FindFrom(root) + if err != nil { + t.Fatalf("unexpected error: %v", err) + } + if got != root { + t.Fatalf("FindFrom = %q, want %q", got, root) + } +} + +// package.json alone (any Node project) is not a scaffolded app. +func TestPlainNodeProjectIsNotScaffolded(t *testing.T) { + dir := t.TempDir() + write(t, dir, "package.json", "{}") + _, err := FindFrom(dir) + var nf *NotFoundError + if !errors.As(err, &nf) { + t.Fatalf("want *NotFoundError, got %T", err) + } + if nf.ScaffoldedApp != "" { + t.Fatalf("plain node project reported as scaffolded: %q", nf.ScaffoldedApp) + } +} diff --git a/services/cli/internal/initcmd/brand_generator_test.go b/services/cli/internal/initcmd/brand_generator_test.go new file mode 100644 index 0000000..fad8bb7 --- /dev/null +++ b/services/cli/internal/initcmd/brand_generator_test.go @@ -0,0 +1,86 @@ +package initcmd + +import ( + "bytes" + "errors" + "strings" + "testing" +) + +func minimalFork(t *testing.T) string { + t.Helper() + root := t.TempDir() + write(t, root, "package.json", "{}") + write(t, root, "apps/dashboard/.keep", "") + write(t, root, "apps/customer-app/.keep", "") + write(t, root, "brand.yaml", "schema_version: 1\nname: af-stack\ncodename: af-stack\ndisplay_name: AF Stack\n") + return root +} + +func stubPnpm(t *testing.T, present bool) { + t.Helper() + old := pnpmOnPath + pnpmOnPath = func() bool { return present } + t.Cleanup(func() { pnpmOnPath = old }) +} + +// Contract: on a fresh clone (no node_modules yet) `init --name` must not +// run the brand generator at all — running it prints a Node stack trace as +// the first thing the user sees — and must say in one line what to run. +func TestRunSkipsBrandGeneratorWithoutNodeDeps(t *testing.T) { + root := minimalFork(t) + defer chdir(t, root)() + stubPnpm(t, true) + defer stubGenerator(t, func(string) error { + t.Fatal("generator must not run when node_modules is missing") + return nil + })() + + var out, errOut bytes.Buffer + if err := Run([]string{"--name", "Acme AI"}, strings.NewReader(""), &out, &errOut); err != nil { + t.Fatalf("init: %v", err) + } + if got := errOut.String(); !strings.Contains(got, "Node deps are not installed yet") || !strings.Contains(got, "pnpm install && pnpm run generate:brand") { + t.Errorf("stderr should carry a one-line hint, got:\n%s", got) + } + if got := errOut.String(); strings.Contains(got, "generate brand:") || strings.Count(got, "\n") > 1 { + t.Errorf("stderr should be a single line, got:\n%s", got) + } + if !strings.Contains(out.String(), "brand CSS/modules NOT regenerated") { + t.Errorf("stdout should say the assets were not regenerated, got:\n%s", out.String()) + } +} + +// Contract: with deps present, a genuine generator failure is still +// reported — trimmed to its tail, which is where the actual error is. +func TestRunReportsTrimmedGeneratorFailure(t *testing.T) { + root := minimalFork(t) + write(t, root, "node_modules/.keep", "") + defer chdir(t, root)() + stubPnpm(t, true) + defer stubGenerator(t, func(string) error { + return errors.New("init: generate brand: exit status 1\n" + lastLines(strings.Repeat("noise\n", 30)+"Error: real cause\n", 8)) + })() + + var out, errOut bytes.Buffer + if err := Run([]string{"--name", "Acme AI"}, strings.NewReader(""), &out, &errOut); err != nil { + t.Fatalf("init: %v", err) + } + got := errOut.String() + if !strings.Contains(got, "Error: real cause") { + t.Errorf("stderr should carry the generator's actual error, got:\n%s", got) + } + if strings.Count(got, "noise") > 8 { + t.Errorf("stderr should be trimmed to the tail, got %d noise lines", strings.Count(got, "noise")) + } +} + +func TestLastLinesKeepsTailOnly(t *testing.T) { + in := "a\nb\nc\nd\ne\n" + if got := lastLines(in, 2); got != "…\nd\ne" { + t.Fatalf("lastLines = %q", got) + } + if got := lastLines(in, 10); got != "a\nb\nc\nd\ne" { + t.Fatalf("lastLines (no trim) = %q", got) + } +} diff --git a/services/cli/internal/initcmd/checkout_error_test.go b/services/cli/internal/initcmd/checkout_error_test.go new file mode 100644 index 0000000..84a64bf --- /dev/null +++ b/services/cli/internal/initcmd/checkout_error_test.go @@ -0,0 +1,71 @@ +package initcmd + +import ( + "bytes" + "os" + "path/filepath" + "strings" + "testing" + + "github.com/Agent-Field/backai/services/cli/internal/checkout" +) + +func chdirTemp(t *testing.T, dir string) { + t.Helper() + old, err := os.Getwd() + if err != nil { + t.Fatal(err) + } + if err := os.Chdir(dir); err != nil { + t.Fatal(err) + } + t.Cleanup(func() { + if err := os.Chdir(old); err != nil { + t.Fatal(err) + } + }) +} + +// Contract: the flag form of init, run outside a clone, fails with a +// message that says what a checkout is, how to clone one, and that the +// positional form scaffolds a standalone app instead. +func TestRunOutsideCheckoutExplainsHowToGetOne(t *testing.T) { + chdirTemp(t, t.TempDir()) + var out, errOut bytes.Buffer + err := Run([]string{"--name", "Acme AI", "--color", "#2563EB"}, strings.NewReader(""), &out, &errOut) + if err == nil { + t.Fatal("expected an error outside a checkout") + } + msg := err.Error() + for _, want := range []string{"init: must run from inside a BackAI checkout", "git clone " + checkout.RepoURL, "af-stack init "} { + if !strings.Contains(msg, want) { + t.Errorf("error missing %q:\n%s", want, msg) + } + } +} + +// Contract: run inside an app that `af-stack init ` scaffolded (the +// README sequence a user actually follows), the error names that app and +// says it is standalone, not a fork. +func TestRunInsideScaffoldedAppSaysSo(t *testing.T) { + parent := t.TempDir() + chdirTemp(t, parent) + var out, errOut bytes.Buffer + if err := Run([]string{"my-ai-product"}, strings.NewReader(""), &out, &errOut); err != nil { + t.Fatalf("scaffold: %v", err) + } + app := filepath.Join(parent, "my-ai-product") + chdirTemp(t, app) + + err := Run([]string{"--name", "Acme AI"}, strings.NewReader(""), &out, &errOut) + if err == nil { + t.Fatal("expected an error inside a scaffolded app") + } + msg := err.Error() + if !strings.Contains(msg, "is a standalone app created by `af-stack init `") { + t.Errorf("error should identify the scaffolded app:\n%s", msg) + } + if !strings.Contains(msg, "git clone "+checkout.RepoURL) { + t.Errorf("error should give the clone command:\n%s", msg) + } +} diff --git a/services/cli/internal/initcmd/init.go b/services/cli/internal/initcmd/init.go index 2ab9f06..238f277 100644 --- a/services/cli/internal/initcmd/init.go +++ b/services/cli/internal/initcmd/init.go @@ -23,6 +23,8 @@ import ( "unicode" "gopkg.in/yaml.v3" + + "github.com/Agent-Field/backai/services/cli/internal/checkout" ) type brandFile struct { @@ -131,9 +133,9 @@ func Run(args []string, stdin io.Reader, stdout, stderr io.Writer) error { } primaryColor = strings.ToUpper(primaryColor) - root, err := findRepoRoot() + root, err := checkout.Find() if err != nil { - return err + return fmt.Errorf("init: %w", err) } brand, err := readBrand(filepath.Join(root, "brand.yaml")) @@ -181,16 +183,28 @@ func Run(args []string, stdin io.Reader, stdout, stderr io.Writer) error { if err := updateDefaultAgentName(root, slug); err != nil { return err } - brandRegenerated := true - if err := runBrandGenerator(root); err != nil { - // Brand CSS/modules are also regenerated at build time, so a missing - // pnpm / node_modules (e.g. a fresh clone that hasn't run `pnpm - // install` yet) must NOT abort the scaffold: brand.yaml — the source - // of truth — is already written above. Warn and carry on so the - // hero flow (`init --template coding-agent`) still scaffolds. - brandRegenerated = false - fmt.Fprintf(stderr, "warning: skipped brand asset regeneration (%v)\n", err) - fmt.Fprintf(stderr, " run `pnpm install && pnpm run generate:brand` once Node deps are present\n") + // Brand CSS/modules are also regenerated at build time, so a missing + // pnpm / node_modules (e.g. a fresh clone that hasn't run `pnpm install` + // yet) must NOT abort the scaffold: brand.yaml — the source of truth — + // is already written above. Warn and carry on so the hero flow + // (`init --template coding-agent`) still scaffolds. + // + // Don't even try when the deps are not there: on a fresh clone that is + // the common case, and running the generator anyway prints a Node + // stack trace as the first thing the user sees. + brandRegenerated := false + switch { + case !exists(filepath.Join(root, "node_modules")): + fmt.Fprintf(stderr, "warning: brand CSS/modules not regenerated — Node deps are not installed yet; run `pnpm install && pnpm run generate:brand`\n") + case !pnpmOnPath(): + fmt.Fprintf(stderr, "warning: brand CSS/modules not regenerated — pnpm is not on PATH; run `pnpm install && pnpm run generate:brand`\n") + default: + if err := runBrandGenerator(root); err != nil { + fmt.Fprintf(stderr, "warning: skipped brand asset regeneration (%v)\n", err) + fmt.Fprintf(stderr, " run `pnpm run generate:brand` to see the full output\n") + } else { + brandRegenerated = true + } } var templateSummary string @@ -239,25 +253,6 @@ func prompt(stdin io.Reader, stdout io.Writer, label string) (string, error) { return strings.TrimSpace(line), nil } -func findRepoRoot() (string, error) { - wd, err := os.Getwd() - if err != nil { - return "", err - } - for { - if exists(filepath.Join(wd, "package.json")) && - exists(filepath.Join(wd, "apps", "dashboard")) && - exists(filepath.Join(wd, "apps", "customer-app")) { - return wd, nil - } - next := filepath.Dir(wd) - if next == wd { - return "", errors.New("init: must run from inside an AF Stack checkout") - } - wd = next - } -} - func readBrand(path string) (brandFile, error) { brand := brandFile{ SchemaVersion: 1, @@ -411,6 +406,11 @@ func exists(path string) bool { return err == nil } +var pnpmOnPath = func() bool { + _, err := exec.LookPath("pnpm") + return err == nil +} + var runBrandGenerator = func(root string) error { cmd := exec.Command("pnpm", "run", "generate:brand") cmd.Dir = root @@ -418,7 +418,17 @@ var runBrandGenerator = func(root string) error { cmd.Stdout = &output cmd.Stderr = &output if err := cmd.Run(); err != nil { - return fmt.Errorf("init: generate brand: %w\n%s", err, output.String()) + return fmt.Errorf("init: generate brand: %w\n%s", err, lastLines(output.String(), 8)) } return nil } + +// lastLines keeps a failure readable: the tail of a tool's output carries +// the actual error, the head is usually a stack trace. +func lastLines(s string, n int) string { + lines := strings.Split(strings.TrimRight(s, "\n"), "\n") + if len(lines) > n { + lines = append([]string{"…"}, lines[len(lines)-n:]...) + } + return strings.Join(lines, "\n") +} diff --git a/services/cli/internal/initcmd/init_test.go b/services/cli/internal/initcmd/init_test.go index b6cbb53..47a201a 100644 --- a/services/cli/internal/initcmd/init_test.go +++ b/services/cli/internal/initcmd/init_test.go @@ -55,9 +55,15 @@ surfaces: t.Fatal(err) } + // Deps present, so the brand generator is expected to run. + write(t, root, "node_modules/.keep", "") + stubPnpm(t, true) + generatorRan := false + restoreCwd := chdir(t, root) defer restoreCwd() restoreGenerator := stubGenerator(t, func(gotRoot string) error { + generatorRan = true wantRoot, err := filepath.EvalSymlinks(root) if err != nil { t.Fatal(err) @@ -105,6 +111,12 @@ surfaces: if !strings.Contains(stdout.String(), "default agent node_id set to docuchat") { t.Fatalf("unexpected stdout:\n%s", stdout.String()) } + if !generatorRan { + t.Fatal("brand generator should run when node_modules and pnpm are present") + } + if !strings.Contains(stdout.String(), "brand CSS/modules regenerated") { + t.Fatalf("stdout should report regeneration:\n%s", stdout.String()) + } } func TestRunRejectsInvalidColor(t *testing.T) { diff --git a/services/cli/internal/project/checkout_error_test.go b/services/cli/internal/project/checkout_error_test.go new file mode 100644 index 0000000..6ef5f68 --- /dev/null +++ b/services/cli/internal/project/checkout_error_test.go @@ -0,0 +1,68 @@ +package project + +import ( + "bytes" + "context" + "strings" + "testing" + + "github.com/Agent-Field/backai/services/cli/internal/checkout" +) + +// Contract: the fork scaffolds and dev, run outside a clone, fail with a +// message that says what a checkout is, how to clone one, and that +// `af-stack init ` scaffolds a standalone app instead. +func TestForkCommandsOutsideCheckoutExplain(t *testing.T) { + restore := chdir(t, t.TempDir()) + defer restore() + + cases := map[string]func() error{ + "agent new": func() error { + var out, errOut bytes.Buffer + return RunAgent([]string{"new", "researcher"}, &out, &errOut) + }, + "module new": func() error { + var out, errOut bytes.Buffer + return RunModule([]string{"new", "billing"}, &out, &errOut) + }, + "plugin new": func() error { + var out, errOut bytes.Buffer + return RunPlugin([]string{"new", "billing"}, &out, &errOut) + }, + "dev": func() error { + var out, errOut bytes.Buffer + return RunDev(context.Background(), []string{"--no-preflight"}, &out, &errOut) + }, + } + for name, run := range cases { + t.Run(name, func(t *testing.T) { + err := run() + if err == nil { + t.Fatal("expected an error outside a checkout") + } + msg := err.Error() + for _, want := range []string{"must run from inside a BackAI checkout", "git clone " + checkout.RepoURL, "af-stack init "} { + if !strings.Contains(msg, want) { + t.Errorf("error missing %q:\n%s", want, msg) + } + } + }) + } +} + +// Contract: inside a clone — including a subdirectory of it — the +// scaffolds still resolve the root and behave as before. +func TestForkCommandsFromCheckoutSubdir(t *testing.T) { + root := fakeRepo(t) + write(t, root, "apps/backend/.keep", "") + restore := chdir(t, root+"/apps/backend") + defer restore() + + var out, errOut bytes.Buffer + if err := RunAgent([]string{"new", "researcher"}, &out, &errOut); err != nil { + t.Fatalf("agent new from a subdir: %v", err) + } + if got := read(t, root, "apps/backend/agents/researcher/main.py"); !strings.Contains(got, "researcher") { + t.Fatalf("agent scaffold not written at the checkout root; got:\n%s", got) + } +} diff --git a/services/cli/internal/project/project.go b/services/cli/internal/project/project.go index 37a6461..231aefb 100644 --- a/services/cli/internal/project/project.go +++ b/services/cli/internal/project/project.go @@ -24,6 +24,7 @@ import ( "github.com/jackc/pgx/v5" "golang.org/x/crypto/bcrypt" + "github.com/Agent-Field/backai/services/cli/internal/checkout" "github.com/Agent-Field/backai/services/cli/internal/client" "github.com/Agent-Field/backai/services/cli/internal/output" "github.com/Agent-Field/backai/services/cli/internal/validate" @@ -49,7 +50,7 @@ func RunDev(ctx context.Context, args []string, stdout, stderr io.Writer) error if err := fs.Parse(args); err != nil { return err } - root, err := findRepoRoot() + root, err := checkout.Find() if err != nil { return err } @@ -274,7 +275,7 @@ func RunDeploy(ctx context.Context, args []string, stdout, stderr io.Writer) err if target == "" { return errors.New("deploy: target is required") } - root, err := findRepoRoot() + root, err := checkout.Find() if err != nil { return err } @@ -408,7 +409,7 @@ func runAgentNew(args []string, stdout, _ io.Writer) error { if err != nil { return err } - root, err := findRepoRoot() + root, err := checkout.Find() if err != nil { return err } @@ -434,7 +435,7 @@ func runModuleNew(args []string, stdout, _ io.Writer) error { if err != nil { return err } - root, err := findRepoRoot() + root, err := checkout.Find() if err != nil { return err } @@ -459,7 +460,7 @@ func runPluginNew(args []string, stdout, _ io.Writer) error { if err != nil { return err } - root, err := findRepoRoot() + root, err := checkout.Find() if err != nil { return err } @@ -699,25 +700,6 @@ func writeFiles(root string, files map[string]string) error { return nil } -func findRepoRoot() (string, error) { - wd, err := os.Getwd() - if err != nil { - return "", err - } - for { - if exists(filepath.Join(wd, "package.json")) && - exists(filepath.Join(wd, "apps", "dashboard")) && - exists(filepath.Join(wd, "apps", "customer-app")) { - return wd, nil - } - next := filepath.Dir(wd) - if next == wd { - return "", errors.New("must run from inside an AF Stack checkout") - } - wd = next - } -} - func exists(path string) bool { _, err := os.Stat(path) return err == nil diff --git a/skills/af-stack/SKILL.md b/skills/af-stack/SKILL.md index 57f8bfd..abcab7f 100644 --- a/skills/af-stack/SKILL.md +++ b/skills/af-stack/SKILL.md @@ -11,29 +11,33 @@ platform for AI products. Architecture is Supabase-shape (Postgres + auth forkable — the repo IS the product. AgentField is the AI runtime that ships at one of the layers, peer to LiteLLM and Postgres. -**The primary path is the CLI.** `af-stack init --template ` scaffolds a -branded, batteries-included app; `af-stack dev` runs the whole stack locally; -the `deploy/` targets ship it. Your job is to help the user build on top of -that scaffold — never to rebuild what the platform already gives them. +**The primary path is the CLI inside a clone of the repo.** `af-stack init +--name --template ` brands the clone and drops in a batteries-included +app; `af-stack dev` runs the whole stack locally; the `deploy/` targets ship +it. Your job is to help the user build on top of that — never to rebuild what +the platform already gives them. -Working directly in a raw fork of the repo (clone → brand → edit in-tree) is -the **fallback** for deep platform customization; reach for it only when the -CLI scaffold + the four edit surfaces below don't cover the need. +Editing platform code outside the four surfaces (`services/`, `packages/`) is +the **fallback** for deep customization; reach for it only when the branded +clone + the four edit surfaces below don't cover the need. ## Start here — the CLI (primary path) ```bash -af-stack init acme-coder --template coding-agent # scaffold a branded app -cd acme-coder -af-stack dev # whole backend + apps up -af-stack mcp add github --transport stdio \ # register tool servers +git clone https://github.com/Agent-Field/backai acme-coder && cd acme-coder +af-stack init --name "Acme Coder" --template coding-agent # brand + a real coding agent +af-stack dev # whole backend + apps up +af-stack mcp add github --transport stdio \ # register tool servers --command "uvx mcp-server-github" --env GITHUB_TOKEN=secret:github_token # edit the four surfaces below, then ship via deploy/ (Helm/Fly/Railway/Render/compose) ``` -`af-stack init` writes the app under your cwd (a coding agent, customer-app, -multi-tenancy ON, a GH_TOKEN secret slot). Everything after is editing the four -surfaces. Prefer these commands over hand-copying files. +`af-stack init --template coding-agent` brands the checkout and adds a real +coding agent (multi-tenancy ON, a GH_TOKEN secret slot). Everything after is +editing the four surfaces. Prefer these commands over hand-copying files. +`af-stack init ` with a positional name is different: it scaffolds a +small standalone app that calls a running BackAI, in any directory, with no +surfaces to brand or extend. ## Read these first