Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
Show all changes
23 commits
Select commit Hold shift + click to select a range
7014e82
docs: add builder delivery routing
AbirAbbas Jul 23, 2026
ad53f16
docs: add usage skill builder fallback
AbirAbbas Jul 23, 2026
53652cf
fix(skill): quote agentfield frontmatter description
AbirAbbas Jul 23, 2026
7fb00a7
fix(skills): harden agentfield-use frontmatter
AbirAbbas Jul 23, 2026
7e9ac13
feat: make generated language scaffolds installable
AbirAbbas Jul 23, 2026
cad2ac6
fix(skill): clarify builder coverage evidence
AbirAbbas Jul 23, 2026
400bbfc
fix(skillkit): sync agentfield skill embeds
AbirAbbas Jul 23, 2026
40bdd06
feat(skillkit): reconcile obsolete alias installs
AbirAbbas Jul 23, 2026
bdbf95d
fix(skill): make builder fallback handoff explicit
AbirAbbas Jul 23, 2026
c62b02e
fix(skillkit): reconcile aliases before update validation
AbirAbbas Jul 23, 2026
7361847
test(packages): cover managed TypeScript installs
AbirAbbas Jul 23, 2026
d3ad97a
test(skillkit): strengthen alias reconciliation coverage
AbirAbbas Jul 23, 2026
84e7a45
test(skillkit): verify reconciliation runs once per operation
AbirAbbas Jul 23, 2026
647a397
test(skillkit): cover reconciliation failure seams
AbirAbbas Jul 23, 2026
a0a713c
test(templates): preserve punctuated scaffold metadata
AbirAbbas Jul 23, 2026
634e124
fix(skill): clarify fallback authorization guard
AbirAbbas Jul 23, 2026
01f78f4
fix(templates): quote generated TypeScript package name
AbirAbbas Jul 23, 2026
edbab5c
test(templates): require explicit empty scaffold environment
AbirAbbas Jul 23, 2026
e33ecc7
test(templates): enforce one scaffold manifest per language
AbirAbbas Jul 23, 2026
a9804bb
Align skill catalog mirrors
AbirAbbas Jul 23, 2026
2ad9106
test(skillkit): strengthen catalog routing contracts
AbirAbbas Jul 23, 2026
34aaa5e
test: verify integrated repository contracts
AbirAbbas Jul 23, 2026
315f1b3
refactor(skills): split personal-agent flow into its own agentfield-p…
AbirAbbas Jul 23, 2026
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
100 changes: 100 additions & 0 deletions control-plane/internal/cli/generated_scaffold_test.go
Original file line number Diff line number Diff line change
@@ -0,0 +1,100 @@
package cli_test

import (
"bytes"
"encoding/json"
"os"
"path/filepath"
"testing"

"github.com/Agent-Field/agentfield/control-plane/internal/packages"
"github.com/Agent-Field/agentfield/control-plane/internal/templates"
)

func TestGeneratedScaffoldsHaveInstallableV1Manifests(t *testing.T) {
if packages.CurrentConfigVersion != 1 {
t.Fatalf("CurrentConfigVersion = %d, want 1", packages.CurrentConfigVersion)
}

data := templates.TemplateData{
ProjectName: "Project: punctuation & more",
NodeID: "representative-node",
AuthorName: "O'Reilly: Jane \"JJ\"",
AgentPort: 8123,
}
wantStarts := map[string]string{
"python": "python main.py",
"go": "go run .",
"typescript": "npm run start",
}

for language, wantStart := range wantStarts {
t.Run(language, func(t *testing.T) {
root := t.TempDir()
files, err := templates.GetTemplateFiles(language)
if err != nil {
t.Fatal(err)
}
manifestCount := 0
for templatePath, destination := range files {
if destination == "agentfield-package.yaml" {
manifestCount++
}
tmpl, err := templates.GetTemplate(templatePath)
if err != nil {
t.Fatal(err)
}
var rendered bytes.Buffer
if err := tmpl.Execute(&rendered, data); err != nil {
t.Fatal(err)
}
path := filepath.Join(root, destination)
if err := os.MkdirAll(filepath.Dir(path), 0o755); err != nil {
t.Fatal(err)
}
if err := os.WriteFile(path, rendered.Bytes(), 0o644); err != nil {
t.Fatal(err)
}
}
if manifestCount != 1 {
t.Fatalf("rendered %d root manifests, want 1", manifestCount)
}

metadata, err := packages.ParsePackageMetadata(root)
if err != nil {
t.Fatalf("ParsePackageMetadata: %v", err)
}
if metadata.ConfigVersionNumber() != 1 || metadata.Name != data.NodeID || metadata.Version != "1.0.0" || metadata.Language != language || metadata.Author != data.AuthorName {
t.Fatalf("unexpected manifest metadata: %#v", metadata)
}
if metadata.AgentNode.NodeID != data.NodeID || metadata.AgentNode.DefaultPort != data.AgentPort || metadata.Entrypoint.Start != wantStart || metadata.HealthcheckPath() != "/health" {
t.Fatalf("unexpected executable metadata: %#v", metadata)
}
if len(metadata.UserEnvironment.Required) != 0 {
t.Fatalf("required environment = %#v, want empty", metadata.UserEnvironment.Required)
}

if language == "typescript" {
var packageJSON struct {
Scripts map[string]string `json:"scripts"`
}
contents, err := os.ReadFile(filepath.Join(root, "package.json"))
if err != nil {
t.Fatal(err)
}
if err := json.Unmarshal(contents, &packageJSON); err != nil {
t.Fatalf("parse package.json: %v", err)
}
wantScripts := map[string]string{"start": "tsx main.ts", "dev": "tsx main.ts", "lint": "tsc --noEmit"}
if len(packageJSON.Scripts) != len(wantScripts) {
t.Fatalf("scripts = %#v, want %#v", packageJSON.Scripts, wantScripts)
}
for name, want := range wantScripts {
if packageJSON.Scripts[name] != want {
t.Fatalf("scripts[%q] = %q, want %q", name, packageJSON.Scripts[name], want)
}
}
}
})
}
}
125 changes: 125 additions & 0 deletions control-plane/internal/packages/install_deps_test.go
Original file line number Diff line number Diff line change
Expand Up @@ -3,6 +3,7 @@ package packages
import (
"os"
"path/filepath"
"runtime"
"strings"
"testing"
)
Expand All @@ -29,6 +30,130 @@ func fakePythonOnPath(t *testing.T) {
t.Setenv("PATH", binDir+string(os.PathListSeparator)+os.Getenv("PATH"))
}

func fakeNPMOnPath(t *testing.T, exitCode int) string {
t.Helper()
binDir := t.TempDir()
record := filepath.Join(t.TempDir(), "npm-record")
t.Setenv("FAKE_NPM_RECORD", record)
if runtime.GOOS == "windows" {
script := "@echo off\r\necho %CD%> \"%FAKE_NPM_RECORD%\"\r\necho %*>> \"%FAKE_NPM_RECORD%\"\r\n"
if exitCode != 0 {
script += "echo npm failed 1>&2\r\nexit /b " + string(rune('0'+exitCode)) + "\r\n"
}
if err := os.WriteFile(filepath.Join(binDir, "npm.cmd"), []byte(script), 0o755); err != nil {
t.Fatal(err)
}
} else {
script := "#!/bin/sh\nprintf '%s\\n' \"$PWD\" \"$@\" > \"$FAKE_NPM_RECORD\"\n"
if exitCode != 0 {
script += "echo npm failed >&2\nexit " + string(rune('0'+exitCode)) + "\n"
}
if err := os.WriteFile(filepath.Join(binDir, "npm"), []byte(script), 0o755); err != nil {
t.Fatal(err)
}
}
t.Setenv("PATH", binDir+string(os.PathListSeparator)+os.Getenv("PATH"))
return record
}

func TestInstallDependencies_TypeScript(t *testing.T) {
pkg := t.TempDir()
if err := os.WriteFile(filepath.Join(pkg, "package.json"), []byte(`{"name":"demo"}`), 0o644); err != nil {
t.Fatal(err)
}
record := fakeNPMOnPath(t, 0)
if err := InstallDependencies(pkg, &PackageMetadata{Language: " TypeScript "}); err != nil {
t.Fatalf("InstallDependencies: %v", err)
}
got, err := os.ReadFile(record)
if err != nil {
t.Fatal(err)
}
if !strings.Contains(string(got), pkg) || !strings.Contains(string(got), "install") {
t.Fatalf("npm invocation record = %q, want package root and install", got)
}
if _, err := os.Stat(filepath.Join(pkg, "venv")); !os.IsNotExist(err) {
t.Fatalf("TypeScript install created Python venv")
}
}

// The public installer must run npm only after it has copied the source into
// AgentField's managed package root. This keeps a newly installed scaffold
// runnable without modifying the author's source tree.
func TestPackageInstallerInstallPackage_TypeScriptInstallsManagedCopy(t *testing.T) {
source := t.TempDir()
manifest := `config_version: v1
name: typescript-managed-copy
version: 1.0.0
language: typescript
entrypoint:
start: npm run start
`
if err := os.WriteFile(filepath.Join(source, "agentfield-package.yaml"), []byte(manifest), 0o644); err != nil {
t.Fatal(err)
}
if err := os.WriteFile(filepath.Join(source, "package.json"), []byte(`{"name":"demo"}`), 0o644); err != nil {
t.Fatal(err)
}
record := fakeNPMOnPath(t, 0)
home := t.TempDir()
if err := (&PackageInstaller{AgentFieldHome: home}).InstallPackage(source, false); err != nil {
t.Fatalf("InstallPackage: %v", err)
}
managed := filepath.Join(home, "packages", "typescript-managed-copy")
got, err := os.ReadFile(record)
if err != nil {
t.Fatal(err)
}
if !strings.Contains(string(got), managed) || !strings.Contains(string(got), "install") {
t.Fatalf("npm invocation record = %q, want npm install in managed package root %q", got, managed)
}
if _, err := os.Stat(filepath.Join(managed, "venv")); !os.IsNotExist(err) {
t.Fatalf("managed TypeScript install created Python venv")
}
}

func TestInstallTypeScriptDependenciesFailures(t *testing.T) {
t.Run("missing package json", func(t *testing.T) {
pkg := t.TempDir()
err := InstallDependencies(pkg, &PackageMetadata{Language: "typescript"})
if err == nil || !strings.Contains(err.Error(), "package.json not found") {
t.Fatalf("error = %v, want actionable missing package.json", err)
}
if _, statErr := os.Stat(filepath.Join(pkg, "venv")); !os.IsNotExist(statErr) {
t.Fatalf("missing package.json fell back to Python venv")
}
})
t.Run("missing npm", func(t *testing.T) {
pkg := t.TempDir()
if err := os.WriteFile(filepath.Join(pkg, "package.json"), []byte(`{}`), 0o644); err != nil {
t.Fatal(err)
}
t.Setenv("PATH", t.TempDir())
err := InstallDependencies(pkg, &PackageMetadata{Language: "typescript"})
if err == nil || !strings.Contains(err.Error(), "npm executable not found") {
t.Fatalf("error = %v, want actionable missing npm", err)
}
if _, statErr := os.Stat(filepath.Join(pkg, "venv")); !os.IsNotExist(statErr) {
t.Fatalf("missing npm fell back to Python venv")
}
})
t.Run("npm failure includes output", func(t *testing.T) {
pkg := t.TempDir()
if err := os.WriteFile(filepath.Join(pkg, "package.json"), []byte(`{}`), 0o644); err != nil {
t.Fatal(err)
}
fakeNPMOnPath(t, 2)
err := InstallDependencies(pkg, &PackageMetadata{Language: "typescript"})
if err == nil || !strings.Contains(err.Error(), "npm install") || !strings.Contains(err.Error(), "npm failed") {
t.Fatalf("error = %v, want npm operation and output", err)
}
if _, statErr := os.Stat(filepath.Join(pkg, "venv")); !os.IsNotExist(statErr) {
t.Fatalf("TypeScript npm failure created Python venv")
}
})
}

// Contract: a pyproject.toml package gets a venv and is installed via
// `pip install .`, even without a requirements.txt.
func TestInstallPythonDependencies_Pyproject(t *testing.T) {
Expand Down
39 changes: 36 additions & 3 deletions control-plane/internal/packages/installer.go
Original file line number Diff line number Diff line change
Expand Up @@ -596,6 +596,11 @@ func (m *PackageMetadata) IsGo() bool {
return strings.EqualFold(strings.TrimSpace(m.Language), "go")
}

// IsTypeScript reports whether this node is a TypeScript node.
func (m *PackageMetadata) IsTypeScript() bool {
return strings.EqualFold(strings.TrimSpace(m.Language), "typescript")
}

// StartCommand returns the tokens used to launch the node. It prefers the
// manifest entrypoint.start; otherwise it falls back to a language-appropriate
// default: "go run ." for a Go node, "python <main>" (default main.py) for a
Expand Down Expand Up @@ -832,16 +837,44 @@ func (pi *PackageInstaller) installDependencies(packagePath string, metadata *Pa
}

// InstallDependencies resolves and installs a node's dependencies for its
// implementation language: it builds the Go binary for a Go node, or provisions
// the Python venv + pip installs for a Python node. It is the single entry point
// shared by the CLI installer and the package service so both stay in lockstep.
// implementation language. It is the single entry point shared by the CLI
// installer and the package service so both stay in lockstep.
func InstallDependencies(packagePath string, metadata *PackageMetadata) error {
if metadata.IsGo() {
return InstallGoDependencies(packagePath, metadata)
}
if metadata.IsTypeScript() {
return InstallTypeScriptDependencies(packagePath, metadata.Dependencies.System)
}
return InstallPythonDependencies(packagePath, metadata.Dependencies.Python, metadata.Dependencies.System)
}

// InstallTypeScriptDependencies installs package.json dependencies with npm in
// the package root. TypeScript dependencies remain declared by package.json;
// manifest system dependencies are reported for manual installation.
func InstallTypeScriptDependencies(packagePath string, systemDeps []string) error {
if !fileExistsAt(packagePath, "package.json") {
return fmt.Errorf("cannot install TypeScript dependencies for package %s: package.json not found", packagePath)
}

npmPath, err := exec.LookPath("npm")
if err != nil {
return fmt.Errorf("cannot install TypeScript dependencies for package %s: npm executable not found on PATH: %w", packagePath, err)
}

cmd := exec.Command(npmPath, "install")
cmd.Dir = packagePath
if output, err := cmd.CombinedOutput(); err != nil {
return fmt.Errorf("failed to run npm install for TypeScript package %s: %w\nOutput: %s", packagePath, err, output)
}

for _, dep := range systemDeps {
fmt.Printf("System dependency required: %s (please install manually)\n", dep)
}

return nil
}

// InstallPythonDependencies sets up a per-package virtual environment and
// installs the node's Python dependencies. A venv is created when the package
// has a requirements.txt, a pyproject.toml, or manifest-declared Python deps.
Expand Down
17 changes: 14 additions & 3 deletions control-plane/internal/skillkit/catalog.go
Original file line number Diff line number Diff line change
Expand Up @@ -30,18 +30,29 @@ var Catalog = []Skill{
{
Name: "agentfield",
Aliases: []string{"agentfield-multi-reasoner-builder"},
Version: "0.5.0",
Description: "Design and ship a multi-agent system on AgentField. Derive the orchestration from the problem: decompose by cognitive jobs, place each slot on the autonomy spectrum, assign a verification rung, choose the dynamism rung with budgets. Composite intelligence, deep dynamic call graphs, live SDK docs from agentfield.ai, async-first smoke tests.",
Version: "0.5.1",
Description: "Design and ship a multi-agent system on AgentField. Derive the orchestration from the problem: decompose by cognitive jobs, place each slot on the autonomy spectrum, assign a verification rung, choose the dynamism rung with budgets. Composite intelligence, deep dynamic call graphs, live SDK docs from agentfield.ai, async-first smoke tests. For an agent installed on this machine via af/Desktop, use agentfield-personal instead.",
EmbedRoot: "skill_data/agentfield",
EntryFile: "SKILL.md",
Trigger: `When the user asks you to architect or build a multi-agent system on
AgentField (composite-intelligence backends, multi-reasoner pipelines,
financial reviewer / clinical triage / research agent / etc.), you MUST
read this skill first`,
},
{
Name: "agentfield-personal",
Version: "0.1.0",
Description: "Build and install a personal AI agent on this machine's AgentField: real source in ~/agentfield-agents, an agentfield-package.yaml manifest, af install + af run, verified control-plane registration and a live reasoner call, visible in AgentField Desktop with a keys form and auto-start toggle. For a standalone project repository with Docker Compose, use the agentfield skill instead.",
EmbedRoot: "skill_data/agentfield-personal",
EntryFile: "SKILL.md",
Trigger: `When the user asks you to build an agent that lives on this machine —
installed through af, managed in AgentField Desktop, available as a
persistent local capability rather than a project repository — you MUST
read this skill first`,
},
{
Name: "agentfield-use",
Version: "0.3.0",
Version: "0.4.0",
Description: "Discover and call agents already running on a local AgentField control plane. Health check, capability discovery, ranked reasoner search (af agent search), concurrent sync/async execution, load-aware pacing (meta.load), in-flight visibility (af ps / executions/active), wedged-run triage (cancel-tree), sessions, and the af CLI ops (run/stop/logs/secrets) that keep installed agents answering.",
EmbedRoot: "skill_data/agentfield-use",
EntryFile: "SKILL.md",
Expand Down
Loading
Loading