Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
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
18 changes: 16 additions & 2 deletions control-plane/internal/cli/commands/install.go
Original file line number Diff line number Diff line change
Expand Up @@ -36,6 +36,7 @@ func (cmd *InstallCommand) GetDescription() string {
func (cmd *InstallCommand) BuildCobraCommand() *cobra.Command {
var force bool
var verbose bool
var path string

cobraCmd := &cobra.Command{
Use: "install <package-path>",
Expand All @@ -47,28 +48,40 @@ The package can be:
- A GitHub repository URL
- A package name from the AgentField registry

Use --path to install a package that lives in a subdirectory of the source, so a
single repository can ship more than one installable node. The subdirectory must
contain its own agentfield-package.yaml; that subtree becomes the package root.
--path is relative to the source root and may not escape it. It composes with an
@ref pin on a Git URL.

Examples:
agentfield install ./my-agent
agentfield install https://github.com/user/agent-repo
agentfield install https://github.com/user/agent-repo --path go
agentfield install https://github.com/user/agent-repo@v1.2.3 --path go
agentfield install agent-name`,
Args: cobra.ExactArgs(1),
RunE: func(cobraCmd *cobra.Command, args []string) error {
// Update output formatter with verbose setting
cmd.output.SetVerbose(verbose)
return cmd.execute(args[0], force, verbose)
return cmd.execute(args[0], force, verbose, path)
},
}

cobraCmd.Flags().BoolVarP(&force, "force", "f", false, "Force reinstall if package exists")
cobraCmd.Flags().BoolVarP(&verbose, "verbose", "v", false, "Verbose output")
cobraCmd.Flags().StringVar(&path, "path", "", "Install the package from this subdirectory of the source (relative to its root)")

return cobraCmd
}

// execute performs the actual installation
func (cmd *InstallCommand) execute(packagePath string, force, verbose bool) error {
func (cmd *InstallCommand) execute(packagePath string, force, verbose bool, path string) error {
cmd.output.PrintHeader("Installing AgentField Package")
cmd.output.PrintInfo(fmt.Sprintf("Package: %s", packagePath))
if path != "" {
cmd.output.PrintInfo(fmt.Sprintf("Subdirectory: %s", path))
}

if verbose {
cmd.output.PrintVerbose("Using new framework-based install command")
Expand All @@ -78,6 +91,7 @@ func (cmd *InstallCommand) execute(packagePath string, force, verbose bool) erro
options := domain.InstallOptions{
Force: force,
Verbose: verbose,
Path: path,
}

// Show progress
Expand Down
6 changes: 6 additions & 0 deletions control-plane/internal/core/domain/models.go
Original file line number Diff line number Diff line change
Expand Up @@ -66,6 +66,12 @@ type AgentFieldConfig struct {
type InstallOptions struct {
Force bool `json:"force"`
Verbose bool `json:"verbose"`
// Path optionally selects a subdirectory within the source (git repo or local
// directory) whose agentfield-package.yaml should be installed, letting one
// repository ship multiple installable nodes. Empty means the default
// root-first behavior. It applies only to the top-level source, never to
// recursively-installed node dependencies.
Path string `json:"path"`
}

// RunOptions represents options for running an agent
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -324,7 +324,7 @@ func TestPackageServiceAdditionalCoverage(t *testing.T) {
require.NoError(t, os.WriteFile(filepath.Join(home, "installed.yaml"), data, 0o644))

service := &DefaultPackageService{agentfieldHome: home}
require.NoError(t, service.installLocalPackage(sourcePath, true, false))
require.NoError(t, service.installLocalPackage(sourcePath, "", true, false))

updatedData, err := os.ReadFile(filepath.Join(home, "installed.yaml"))
require.NoError(t, err)
Expand All @@ -341,7 +341,7 @@ func TestPackageServiceAdditionalCoverage(t *testing.T) {
require.NoError(t, os.WriteFile(filepath.Join(home, "packages"), []byte("blocker"), 0o644))

service := &DefaultPackageService{agentfieldHome: home}
err := service.installLocalPackage(sourcePath, false, false)
err := service.installLocalPackage(sourcePath, "", false, false)
require.Error(t, err)
assert.Contains(t, err.Error(), "failed to copy package")
})
Expand Down
116 changes: 116 additions & 0 deletions control-plane/internal/core/services/install_path_test.go
Original file line number Diff line number Diff line change
@@ -0,0 +1,116 @@
package services

import (
"os"
"path/filepath"
"testing"

"github.com/Agent-Field/agentfield/control-plane/internal/core/domain"
"github.com/Agent-Field/agentfield/control-plane/internal/packages"
"github.com/stretchr/testify/assert"
"github.com/stretchr/testify/require"
"gopkg.in/yaml.v3"
)

// Validation contract for `af install <local-dir> --path <subdir>`:
// 1. --path <subdir> installs the node whose manifest is at <dir>/<subdir>.
// 2. No --path installs the root node (unchanged).
// 3. --path with no manifest there errors, naming the expected path; nothing
// is installed.
// 4. Absolute / escaping --path is rejected; nothing is installed.
// These mirror the git-path contract for a local source.

// writeNode writes a minimal, dependency-free Python node (so install does no
// venv/network work) at dir with the given package name.
func writeNode(t *testing.T, dir, name string) {
t.Helper()
require.NoError(t, os.MkdirAll(dir, 0o755))
manifest := "name: " + name + "\nversion: 1.0.0\nmain: main.py\n"
require.NoError(t, os.WriteFile(filepath.Join(dir, "agentfield-package.yaml"), []byte(manifest), 0o644))
require.NoError(t, os.WriteFile(filepath.Join(dir, "main.py"), []byte("print('ok')\n"), 0o644))
}

// installedNamesFromRegistry reads ~/.agentfield/installed.yaml and returns the
// set of installed package names (empty when the registry file is absent).
func installedNamesFromRegistry(t *testing.T, home string) map[string]bool {
t.Helper()
names := map[string]bool{}
data, err := os.ReadFile(filepath.Join(home, "installed.yaml"))
if os.IsNotExist(err) {
return names
}
require.NoError(t, err)
var reg packages.InstallationRegistry
require.NoError(t, yaml.Unmarshal(data, &reg))
for name := range reg.Installed {
names[name] = true
}
return names
}

func newLocalPackageService(t *testing.T, home string) *DefaultPackageService {
t.Helper()
return NewPackageService(newMockPackageRegistryStorage(), newMockFileSystemAdapter(), home).(*DefaultPackageService)
}

func TestInstallLocalPackage_PathSelectsSubdir(t *testing.T) {
home := t.TempDir()
src := filepath.Join(t.TempDir(), "repo")
writeNode(t, src, "root-node")
writeNode(t, filepath.Join(src, "sub"), "sub-node")

svc := newLocalPackageService(t, home)
require.NoError(t, svc.InstallPackage(src, domain.InstallOptions{Path: "sub"}))

names := installedNamesFromRegistry(t, home)
assert.True(t, names["sub-node"], "the --path subdir node should be installed")
assert.False(t, names["root-node"], "the root node must NOT be installed when --path selects a subdir")

// The installed package directory is keyed by the subdir manifest name and
// contains that subtree at its root.
pkgDir := filepath.Join(home, "packages", "sub-node")
if _, err := os.Stat(filepath.Join(pkgDir, "agentfield-package.yaml")); err != nil {
t.Fatalf("expected sub-node package copied to %s: %v", pkgDir, err)
}
}

func TestInstallLocalPackage_NoPathInstallsRoot(t *testing.T) {
home := t.TempDir()
src := filepath.Join(t.TempDir(), "repo")
writeNode(t, src, "root-node")
writeNode(t, filepath.Join(src, "sub"), "sub-node")

svc := newLocalPackageService(t, home)
require.NoError(t, svc.InstallPackage(src, domain.InstallOptions{}))

names := installedNamesFromRegistry(t, home)
assert.True(t, names["root-node"], "bare install must install the root node")
assert.False(t, names["sub-node"], "bare install must not reach into subdirectories")
}

func TestInstallLocalPackage_PathMissingManifest(t *testing.T) {
home := t.TempDir()
src := filepath.Join(t.TempDir(), "repo")
writeNode(t, src, "root-node")

svc := newLocalPackageService(t, home)
err := svc.InstallPackage(src, domain.InstallOptions{Path: "nope"})
require.Error(t, err)
assert.Contains(t, err.Error(), filepath.Join(src, "nope", "agentfield-package.yaml"))
assert.Empty(t, installedNamesFromRegistry(t, home), "a failed --path install must not mutate the registry")
}

func TestInstallLocalPackage_PathRejectsAbsoluteAndEscape(t *testing.T) {
for _, bad := range []string{"/etc", "../escape"} {
t.Run(bad, func(t *testing.T) {
home := t.TempDir()
src := filepath.Join(t.TempDir(), "repo")
writeNode(t, src, "root-node")

svc := newLocalPackageService(t, home)
err := svc.InstallPackage(src, domain.InstallOptions{Path: bad})
require.Error(t, err)
assert.Empty(t, installedNamesFromRegistry(t, home), "a rejected --path must not install anything")
})
}
}
26 changes: 22 additions & 4 deletions control-plane/internal/core/services/package_service.go
Original file line number Diff line number Diff line change
Expand Up @@ -47,7 +47,12 @@ func (ps *DefaultPackageService) InstallPackage(source string, options domain.In
return err
}

return ps.installNodeDependencies(before, options)
// The --path selector targets a subdirectory of THIS source only. Node
// dependencies are their own installable sources, so never carry the selector
// into recursive dependency installs.
depOptions := options
depOptions.Path = ""
return ps.installNodeDependencies(before, depOptions)
}

// installOne installs a single package from a git URL or local path.
Expand All @@ -57,12 +62,13 @@ func (ps *DefaultPackageService) installOne(source string, options domain.Instal
installer := &packages.GitInstaller{
AgentFieldHome: ps.agentfieldHome,
Verbose: options.Verbose,
Subdir: options.Path,
}
return installer.InstallFromGit(source, options.Force)
}

// Handle local package installation
return ps.installLocalPackage(source, options.Force, options.Verbose)
return ps.installLocalPackage(source, options.Path, options.Force, options.Verbose)
}

// installedNames returns the set of currently-installed package names.
Expand Down Expand Up @@ -134,8 +140,20 @@ func resolveNodeRef(ref string) (source string, name string) {
return ref, ""
}

// installLocalPackage installs a package from a local source path
func (ps *DefaultPackageService) installLocalPackage(sourcePath string, force bool, verbose bool) error {
// installLocalPackage installs a package from a local source path. When subdir is
// non-empty (the --path selector) the package root is resolved to
// <sourcePath>/<subdir>, which must contain an agentfield-package.yaml; that
// subdirectory is what gets validated, copied, and installed. Resolution happens
// before any copy or registry mutation, so a bad selector fails cleanly.
func (ps *DefaultPackageService) installLocalPackage(sourcePath string, subdir string, force bool, verbose bool) error {
if strings.TrimSpace(subdir) != "" {
resolved, err := packages.ResolvePackageSubdir(sourcePath, subdir)
if err != nil {
return err
}
sourcePath = resolved
}

// Get package name first for better messaging
metadata, err := ps.parsePackageMetadata(sourcePath)
if err != nil {
Expand Down
34 changes: 33 additions & 1 deletion control-plane/internal/packages/git.go
Original file line number Diff line number Diff line change
Expand Up @@ -26,6 +26,12 @@ type GitPackageInfo struct {
type GitInstaller struct {
AgentFieldHome string
Verbose bool
// Subdir optionally selects a package subdirectory within the cloned repo
// (the `--path` flag). Empty means the historical root-first walk. When set,
// the manifest MUST live at <clone>/<Subdir>/agentfield-package.yaml and that
// subdirectory becomes the package root that is copied and installed. It
// composes with an @ref pin on the URL, which is parsed independently.
Subdir string
}

// newSpinner creates a new spinner with the given message
Expand Down Expand Up @@ -106,6 +112,12 @@ func checkGitAvailable() error {

// InstallFromGit installs a package from any Git repository
func (gi *GitInstaller) InstallFromGit(gitURL string, force bool) error {
// Reject a malformed --path selector (absolute / escaping) up front, before
// any install work (clone, copy, registry mutation) happens.
if err := validateSubdirSelector(gi.Subdir); err != nil {
return err
}

// Check if Git is available
if err := checkGitAvailable(); err != nil {
return err
Expand Down Expand Up @@ -136,7 +148,7 @@ func (gi *GitInstaller) InstallFromGit(gitURL string, force bool) error {
spinner = gi.newSpinner("Validating package structure")
spinner.Start()

packagePath, err := gi.findPackageRoot(tempDir)
packagePath, err := gi.resolvePackageRoot(tempDir)
if err != nil {
spinner.Error("Invalid package structure")
return fmt.Errorf("invalid package structure: %w", err)
Expand Down Expand Up @@ -257,6 +269,26 @@ func (gi *GitInstaller) cloneRepository(info *GitPackageInfo) (string, error) {
return tempDir, nil
}

// resolvePackageRoot determines which directory of the cloned repository is the
// package to install. With no --path selector it defers to findPackageRoot's
// root-first walk (unchanged behavior). With a selector it resolves and validates
// <cloneDir>/<Subdir>, requiring the manifest to exist there, so one repo can ship
// multiple installable nodes selected explicitly.
func (gi *GitInstaller) resolvePackageRoot(cloneDir string) (string, error) {
if strings.TrimSpace(gi.Subdir) == "" {
return gi.findPackageRoot(cloneDir)
}
root, err := ResolvePackageSubdir(cloneDir, gi.Subdir)
if err != nil {
return "", err
}
// A selected subdir must still be a valid, startable agent node.
if err := ValidatePackage(root); err != nil {
return "", err
}
return root, nil
}

// findPackageRoot finds the root directory containing agentfield-package.yaml
func (gi *GitInstaller) findPackageRoot(cloneDir string) (string, error) {
var packageRoot string
Expand Down
Loading
Loading