Skip to content
35 changes: 17 additions & 18 deletions control-plane/internal/cli/coverage_gap_additional_test.go
Original file line number Diff line number Diff line change
Expand Up @@ -219,10 +219,10 @@ func TestCLIExitHelper(t *testing.T) {
_ = verifyVC(filepath.Join(t.TempDir(), "missing.json"), VerifyOptions{OutputFormat: "json"})
case "output-json-invalid":
_ = outputJSON(VCVerificationResult{
Valid: false,
Type: "credential",
Valid: false,
Type: "credential",
FormatValid: false,
Message: "bad",
Message: "bad",
})
case "output-pretty-invalid":
_ = outputPretty(VCVerificationResult{
Expand Down Expand Up @@ -607,9 +607,9 @@ func TestEnhancedWorkflowVerificationBranches(t *testing.T) {
t.Run("validate vc structure covers required fields", func(t *testing.T) {
verifier := NewEnhancedVCVerifier(nil, false)
cases := []struct {
name string
doc types.VCDocument
want string
name string
doc types.VCDocument
want string
}{
{name: "missing context", doc: types.VCDocument{}, want: "missing @context"},
{name: "missing type", doc: types.VCDocument{Context: []string{"ctx"}}, want: "missing type"},
Expand Down Expand Up @@ -669,17 +669,17 @@ func signedWorkflowVCForTest(t *testing.T, issuer string, componentIDs []string)
require.NoError(t, err)

return types.WorkflowVC{
WorkflowID: "wf-1",
ComponentVCs: componentIDs,
Status: "completed",
VCDocument: raw,
}, DIDResolutionInfo{
DID: issuer,
Method: "key",
PublicKeyJWK: map[string]interface{}{
"x": base64.RawURLEncoding.EncodeToString(publicKey),
},
}
WorkflowID: "wf-1",
ComponentVCs: componentIDs,
Status: "completed",
VCDocument: raw,
}, DIDResolutionInfo{
DID: issuer,
Method: "key",
PublicKeyJWK: map[string]interface{}{
"x": base64.RawURLEncoding.EncodeToString(publicKey),
},
}
}

func TestPrepareConfigFixtureShape(t *testing.T) {
Expand Down Expand Up @@ -721,7 +721,6 @@ func withStreamingStdin(t *testing.T, chunks []string, fn func()) {
<-done
}


func TestProxyErrorOutputIncludesHint(t *testing.T) {
output, err := runCLITestHelper(t, "proxy-http-error-with-default-error")
require.Error(t, err)
Expand Down
36 changes: 18 additions & 18 deletions control-plane/internal/cli/doctor.go
Original file line number Diff line number Diff line change
Expand Up @@ -19,15 +19,15 @@ import (
// Coding agents (and skills like agentfield) call this once to learn
// what's actually available in the environment instead of probing manually.
type DoctorReport struct {
OS string `json:"os"`
Arch string `json:"arch"`
Python ToolStatus `json:"python"`
Node ToolStatus `json:"node"`
Docker ToolStatus `json:"docker"`
OS string `json:"os"`
Arch string `json:"arch"`
Python ToolStatus `json:"python"`
Node ToolStatus `json:"node"`
Docker ToolStatus `json:"docker"`
HarnessProviders map[string]ToolStatus `json:"harness_providers"`
ProviderKeys map[string]ProviderKey `json:"provider_keys"`
ControlPlane ControlPlaneStatus `json:"control_plane"`
Recommendation Recommendation `json:"recommendation"`
ProviderKeys map[string]ProviderKey `json:"provider_keys"`
ControlPlane ControlPlaneStatus `json:"control_plane"`
Recommendation Recommendation `json:"recommendation"`
}

// ToolStatus describes whether a CLI is available and, if so, where.
Expand All @@ -47,21 +47,21 @@ type ProviderKey struct {
// ControlPlaneStatus reports whether a local control plane is reachable
// and whether the Docker image is locally available.
type ControlPlaneStatus struct {
URL string `json:"url"`
Reachable bool `json:"reachable"`
HealthStatus string `json:"health_status,omitempty"`
DockerImageName string `json:"docker_image_name"`
DockerImageLocal bool `json:"docker_image_local"`
URL string `json:"url"`
Reachable bool `json:"reachable"`
HealthStatus string `json:"health_status,omitempty"`
DockerImageName string `json:"docker_image_name"`
DockerImageLocal bool `json:"docker_image_local"`
}

// Recommendation tells the caller (a skill or a coding agent) what to default to,
// based on what's actually present in the environment.
type Recommendation struct {
Provider string `json:"provider"` // "openrouter" / "openai" / "anthropic" / "google" / "none"
AIModel string `json:"ai_model"` // suggested LiteLLM-style model string
HarnessUsable bool `json:"harness_usable"` // true only if at least one provider CLI is on PATH
HarnessProviders []string `json:"harness_providers"` // available provider CLI names
Notes []string `json:"notes"` // human-readable suggestions
Provider string `json:"provider"` // "openrouter" / "openai" / "anthropic" / "google" / "none"
AIModel string `json:"ai_model"` // suggested LiteLLM-style model string
HarnessUsable bool `json:"harness_usable"` // true only if at least one provider CLI is on PATH
HarnessProviders []string `json:"harness_providers"` // available provider CLI names
Notes []string `json:"notes"` // human-readable suggestions
}

// providerEnvVars maps provider name -> env var. Order matters for the recommendation.
Expand Down
8 changes: 4 additions & 4 deletions control-plane/internal/cli/framework/command_test.go
Original file line number Diff line number Diff line change
Expand Up @@ -59,12 +59,12 @@ func TestCommandRegistryRegisterAndGetCommands(t *testing.T) {

func TestCommandRegistryBuildCobraCommands(t *testing.T) {
tests := []struct {
name string
commands []Command
wantUses []string
name string
commands []Command
wantUses []string
}{
{
name: "empty registry",
name: "empty registry",
wantUses: nil,
},
{
Expand Down
28 changes: 14 additions & 14 deletions control-plane/internal/cli/framework/output_test.go
Original file line number Diff line number Diff line change
Expand Up @@ -60,33 +60,33 @@ func TestOutputFormatterPrintMethods(t *testing.T) {
wantOut string
}{
{
name: "success",
print: func(o *OutputFormatter) { o.PrintSuccess("done") },
name: "success",
print: func(o *OutputFormatter) { o.PrintSuccess("done") },
wantOut: "✅ done\n",
},
{
name: "error",
print: func(o *OutputFormatter) { o.PrintError("failed") },
name: "error",
print: func(o *OutputFormatter) { o.PrintError("failed") },
wantOut: "❌ failed\n",
},
{
name: "info",
print: func(o *OutputFormatter) { o.PrintInfo("details") },
name: "info",
print: func(o *OutputFormatter) { o.PrintInfo("details") },
wantOut: "ℹ️ details\n",
},
{
name: "warning",
print: func(o *OutputFormatter) { o.PrintWarning("careful") },
name: "warning",
print: func(o *OutputFormatter) { o.PrintWarning("careful") },
wantOut: "⚠️ careful\n",
},
{
name: "header",
print: func(o *OutputFormatter) { o.PrintHeader("title") },
name: "header",
print: func(o *OutputFormatter) { o.PrintHeader("title") },
wantOut: "\n🧠 title\n",
},
{
name: "progress",
print: func(o *OutputFormatter) { o.PrintProgress("working") },
name: "progress",
print: func(o *OutputFormatter) { o.PrintProgress("working") },
wantOut: "⏳ working\n",
},
{
Expand All @@ -98,8 +98,8 @@ func TestOutputFormatterPrintMethods(t *testing.T) {
wantOut: "🔍 trace\n",
},
{
name: "verbose disabled",
print: func(o *OutputFormatter) { o.PrintVerbose("hidden") },
name: "verbose disabled",
print: func(o *OutputFormatter) { o.PrintVerbose("hidden") },
wantOut: "",
},
}
Expand Down
1 change: 1 addition & 0 deletions control-plane/internal/cli/root.go
Original file line number Diff line number Diff line change
Expand Up @@ -112,6 +112,7 @@ AI Agent? Run "af agent help" for structured JSON output optimized for programma

// Add remaining old commands (not yet migrated)
RootCmd.AddCommand(NewUninstallCommand())
RootCmd.AddCommand(NewSecretsCommand())
RootCmd.AddCommand(NewListCommand())
RootCmd.AddCommand(NewStopCommand())
RootCmd.AddCommand(NewLogsCommand())
Expand Down
152 changes: 152 additions & 0 deletions control-plane/internal/cli/secrets.go
Original file line number Diff line number Diff line change
@@ -0,0 +1,152 @@
package cli

import (
"bufio"
"fmt"
"os"
"strings"

"github.com/Agent-Field/agentfield/control-plane/internal/packages"
"github.com/spf13/cobra"
"golang.org/x/term"
)

// NewSecretsCommand returns the `af secrets` command tree for managing the
// encrypted secret store used by agent nodes.
func NewSecretsCommand() *cobra.Command {
cmd := &cobra.Command{
Use: "secrets",
Short: "Manage encrypted secrets for agent nodes",
Long: `Manage the encrypted secret store under ~/.agentfield.

Secrets are stored encrypted at rest (AES-256-GCM) and are only ever decrypted
into an agent node's process environment at start time. Global secrets are
shared across all nodes; node-scoped secrets override the global value for a
single node.`,
}

cmd.AddCommand(newSecretsSetCommand())
cmd.AddCommand(newSecretsListCommand())
cmd.AddCommand(newSecretsRemoveCommand())
return cmd
}

func openSecretStore() (*packages.SecretStore, error) {
return packages.NewSecretStore(getAgentFieldHomeDir())
}

func newSecretsSetCommand() *cobra.Command {
var node string
cmd := &cobra.Command{
Use: "set KEY [VALUE]",
Short: "Store a secret (prompts hidden if VALUE is omitted)",
Args: cobra.RangeArgs(1, 2),
RunE: func(cmd *cobra.Command, args []string) error {
key := args[0]
var value string
if len(args) == 2 {
value = args[1]
} else {
v, err := readHiddenValue(fmt.Sprintf("Enter value for %s", key))
if err != nil {
return err
}
value = v
}
if strings.TrimSpace(value) == "" {
return fmt.Errorf("value must not be empty")
}

store, err := openSecretStore()
if err != nil {
return err
}
scope := packages.GlobalScope
if node != "" {
scope = node
}
if err := store.Set(scope, key, value); err != nil {
return err
}
PrintSuccess(fmt.Sprintf("Stored %s in %s scope", key, scope))
return nil
},
}
cmd.Flags().StringVar(&node, "node", "", "store as a node-scoped secret instead of global")
return cmd
}

func newSecretsListCommand() *cobra.Command {
cmd := &cobra.Command{
Use: "ls",
Aliases: []string{"list"},
Short: "List stored secrets (values masked)",
Args: cobra.NoArgs,
RunE: func(cmd *cobra.Command, args []string) error {
store, err := openSecretStore()
if err != nil {
return err
}
refs, err := store.ListAll()
if err != nil {
return err
}
if len(refs) == 0 {
PrintInfo("No secrets stored yet. Add one with: af secrets set KEY")
return nil
}
fmt.Printf("%-30s %s\n", "KEY", "SCOPE")
for _, ref := range refs {
fmt.Printf("%-30s %s\n", ref.Key, ref.Scope)
}
return nil
},
}
return cmd
}

func newSecretsRemoveCommand() *cobra.Command {
var node string
cmd := &cobra.Command{
Use: "rm KEY",
Aliases: []string{"remove", "delete"},
Short: "Remove a stored secret",
Args: cobra.ExactArgs(1),
RunE: func(cmd *cobra.Command, args []string) error {
store, err := openSecretStore()
if err != nil {
return err
}
scope := packages.GlobalScope
if node != "" {
scope = node
}
if err := store.Delete(scope, args[0]); err != nil {
return err
}
PrintSuccess(fmt.Sprintf("Removed %s from %s scope", args[0], scope))
return nil
},
}
cmd.Flags().StringVar(&node, "node", "", "remove from a node scope instead of global")
return cmd
}

// readHiddenValue reads a single line without echo when stdin is a terminal,
// falling back to plain line input otherwise.
func readHiddenValue(prompt string) (string, error) {
fmt.Printf("%s: ", prompt)
if term.IsTerminal(int(os.Stdin.Fd())) {
data, err := term.ReadPassword(int(os.Stdin.Fd()))
fmt.Println()
if err != nil {
return "", fmt.Errorf("failed to read value: %w", err)
}
return strings.TrimSpace(string(data)), nil
}
line, err := bufio.NewReader(os.Stdin).ReadString('\n')
if err != nil && line == "" {
return "", fmt.Errorf("failed to read value: %w", err)
}
return strings.TrimSpace(line), nil
}
14 changes: 7 additions & 7 deletions control-plane/internal/cli/skill.go
Original file line number Diff line number Diff line change
Expand Up @@ -60,13 +60,13 @@ Examples:

func newSkillInstallCommand() *cobra.Command {
var (
skillName string
version string
targets []string
allDetected bool
allTargets bool
force bool
dryRun bool
skillName string
version string
targets []string
allDetected bool
allTargets bool
force bool
dryRun bool
nonInteractive bool
)

Expand Down
Loading
Loading