From a491f76909868707db4442bf0da22a8bdac5a2f2 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Andr=C3=A1s=20J=C3=A1ky?= Date: Fri, 10 Jul 2026 22:28:05 +0200 Subject: [PATCH 1/3] feat(cli): add MCP server & skills step to dirctl init MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Signed-off-by: András Jáky --- cli/cmd/init/agents.go | 210 ++++++++++++++++++ cli/cmd/init/agents_test.go | 92 ++++++++ cli/cmd/init/options.go | 8 + cli/cmd/init/run.go | 13 +- cli/cmd/install/batch.go | 11 +- cli/cmd/install/batch_test.go | 12 +- cli/cmd/install/flags.go | 53 +---- cli/cmd/install/flags_test.go | 34 --- cli/cmd/install/install.go | 18 +- cli/cmd/install/uninstall.go | 5 +- cli/internal/agentcfg/selection.go | 58 +++++ cli/internal/agentcfg/selection_test.go | 40 ++++ .../agentinstall}/apply.go | 10 +- .../agentinstall}/apply_test.go | 38 ++-- .../agentinstall}/derive.go | 30 +-- .../agentinstall}/derive_test.go | 14 +- .../agentinstall}/testdata/a2a.json | 0 .../agentinstall}/testdata/bare.json | 0 .../agentinstall}/testdata/mcp.json | 0 .../agentinstall}/testdata/multi.json | 0 .../agentinstall}/testdata/skill.json | 0 21 files changed, 497 insertions(+), 149 deletions(-) create mode 100644 cli/cmd/init/agents.go create mode 100644 cli/cmd/init/agents_test.go delete mode 100644 cli/cmd/install/flags_test.go create mode 100644 cli/internal/agentcfg/selection.go create mode 100644 cli/internal/agentcfg/selection_test.go rename cli/{cmd/install => internal/agentinstall}/apply.go (88%) rename cli/{cmd/install => internal/agentinstall}/apply_test.go (92%) rename cli/{cmd/install => internal/agentinstall}/derive.go (87%) rename cli/{cmd/install => internal/agentinstall}/derive_test.go (92%) rename cli/{cmd/install => internal/agentinstall}/testdata/a2a.json (100%) rename cli/{cmd/install => internal/agentinstall}/testdata/bare.json (100%) rename cli/{cmd/install => internal/agentinstall}/testdata/mcp.json (100%) rename cli/{cmd/install => internal/agentinstall}/testdata/multi.json (100%) rename cli/{cmd/install => internal/agentinstall}/testdata/skill.json (100%) diff --git a/cli/cmd/init/agents.go b/cli/cmd/init/agents.go new file mode 100644 index 000000000..a4afb32f1 --- /dev/null +++ b/cli/cmd/init/agents.go @@ -0,0 +1,210 @@ +// Copyright AGNTCY Contributors (https://github.com/agntcy) +// SPDX-License-Identifier: Apache-2.0 + +//nolint:wrapcheck +package init + +import ( + "bufio" + "fmt" + "strings" + "time" + + "github.com/agntcy/dir/cli/internal/agentcfg" + "github.com/agntcy/dir/cli/internal/agentinstall" + "github.com/agntcy/dir/cli/presenter" + "github.com/agntcy/dir/server/skill" + "github.com/spf13/cobra" +) + +const agentStepIntro = ` +Step 3 — Directory MCP server & skills +Wire this Directory into your AI coding agents: an MCP server entry (so an agent +can push, search, and pull records) plus the DIR skill (a usage guide). Content +is built in — no Directory connection is made. Writes are idempotent and atomic. +` + +// dirArtifacts builds the built-in DIR record locally and derives its +// installable artifacts (skill + MCP server). No Directory round-trip. +func dirArtifacts() (agentinstall.Artifacts, error) { + rec, err := skill.BuildRecord(time.Now().UTC()) + if err != nil { + return agentinstall.Artifacts{}, fmt.Errorf("build DIR record: %w", err) + } + + arts, err := agentinstall.DeriveArtifacts(rec) + if err != nil { + return agentinstall.Artifacts{}, fmt.Errorf("derive DIR artifacts: %w", err) + } + + return arts, nil +} + +// runAgentSetup runs Step 3 against the resolved ambient environment. +func runAgentSetup(cmd *cobra.Command, opts *options) error { + return installAgents(cmd, agentcfg.ResolveEnv(), opts) +} + +// installAgents is the testable core of Step 3: detect + select agents, then +// place the built-in DIR MCP server & skill via the shared agentinstall engine. +func installAgents(cmd *cobra.Command, env agentcfg.Env, opts *options) error { + presenter.Printf(cmd, "%s", agentStepIntro) + + chosen, err := agentcfg.ParseSelection(opts.agents) + if err != nil { + return err + } + + selected, skipped := agentcfg.ResolveSelection(agentcfg.Registry(), env, chosen) + for _, id := range skipped { + presenter.Printf(cmd, "Skipping %s: not detected.\n", id) + } + + if len(selected) == 0 { + presenter.Printf(cmd, "No supported AI coding agents detected; skipping.\n") + + return nil + } + + arts, err := dirArtifacts() + if err != nil { + return err + } + + presenter.Printf(cmd, "Detected agents:\n") + + for _, a := range selected { + presenter.Printf(cmd, " • %s\n", a.Name) + } + + if !opts.yes { + // Opt-out, but never unattended: a non-TTY run without --yes skips. + if !isInteractive(cmd) { + presenter.Printf(cmd, "Skipping MCP server & skill setup (non-interactive). Pass --yes to install.\n") + + return nil + } + + proceed, narrowed, err := promptAgentSelection(cmd, selected) + if err != nil { + return err + } + + if !proceed { + presenter.Printf(cmd, "Skipped. No changes made.\n") + + return nil + } + + selected = narrowed + } + + plan := agentinstall.Install(env, arts, selected, true) + presenter.Printf(cmd, "%s", agentcfg.FormatPlan(plan)) + + if len(plan) == 0 { + return nil + } + + outcomes := agentinstall.Install(env, arts, selected, false) + presenter.Printf(cmd, "%s", agentcfg.FormatSummary(outcomes, false)) + + return nil +} + +// removeAgents strips the built-in DIR MCP server & skill from detected agents. +// It mirrors installAgents' selection and TTY/--yes gating. +func removeAgents(cmd *cobra.Command, env agentcfg.Env, opts *options) error { + chosen, err := agentcfg.ParseSelection(opts.agents) + if err != nil { + return err + } + + selected, _ := agentcfg.ResolveSelection(agentcfg.Registry(), env, chosen) + if len(selected) == 0 { + return nil + } + + arts, err := dirArtifacts() + if err != nil { + return err + } + + plan := agentinstall.Uninstall(env, arts, selected, true) + if len(plan) == 0 { + return nil + } + + presenter.Printf(cmd, "\nThe DIR MCP server & skill will be removed from:\n") + presenter.Printf(cmd, "%s", agentcfg.FormatPlan(plan)) + + if !opts.yes { + // Defensive, and mirrors installAgents' gating: in the wizard, runRemove + // already confirms before calling us, so a non-interactive run aborts + // there first. This guard only matters if removeAgents is ever called + // standalone — it must never act unattended. + if !isInteractive(cmd) { + return nil + } + + ok, err := confirm(cmd, "Remove the DIR MCP server & skill from these agents?", false) + if err != nil { + return err + } + + if !ok { + return nil + } + } + + outcomes := agentinstall.Uninstall(env, arts, selected, false) + presenter.Printf(cmd, "%s", agentcfg.FormatSummary(outcomes, false)) + + return nil +} + +// promptAgentSelection confirms installing into all detected agents, or narrows +// to a typed comma-separated subset of agent IDs. An empty line (Enter) accepts +// all; EOF with no input returns proceed=false so nothing is assumed. +// +//nolint:unparam +func promptAgentSelection(cmd *cobra.Command, detected []agentcfg.Agent) (bool, []agentcfg.Agent, error) { + presenter.Printf(cmd, "\nConfigure these agents? [Y/n], or type a comma-separated subset of IDs: ") + + reader := bufio.NewReader(cmd.InOrStdin()) + + line, err := reader.ReadString('\n') + if err != nil && line == "" { + return false, nil, nil + } + + answer := strings.TrimSpace(line) + + switch strings.ToLower(answer) { + case "", "y", "yes": + return true, detected, nil + case "n", "no": + return false, nil, nil + } + + chosen, err := agentcfg.ParseSelection(strings.Split(answer, ",")) + if err != nil { + return false, nil, err + } + + var narrowed []agentcfg.Agent + + for _, a := range detected { + if chosen[a.ID] { + narrowed = append(narrowed, a) + } + } + + if len(narrowed) == 0 { + presenter.Printf(cmd, "None of the typed IDs match a detected agent; skipping.\n") + + return false, nil, nil + } + + return true, narrowed, nil +} diff --git a/cli/cmd/init/agents_test.go b/cli/cmd/init/agents_test.go new file mode 100644 index 000000000..809b7dfff --- /dev/null +++ b/cli/cmd/init/agents_test.go @@ -0,0 +1,92 @@ +// Copyright AGNTCY Contributors (https://github.com/agntcy) +// SPDX-License-Identifier: Apache-2.0 + +package init + +import ( + "os" + "path/filepath" + "testing" + + "github.com/agntcy/dir/cli/internal/agentcfg" + "github.com/stretchr/testify/assert" + "github.com/stretchr/testify/require" +) + +// claudeEnv builds a temp environment with a ~/.claude marker so the +// claude-code agent is detected, and returns the matching agentcfg.Env. +func claudeEnv(t *testing.T) agentcfg.Env { + t.Helper() + home := t.TempDir() + require.NoError(t, os.MkdirAll(filepath.Join(home, ".claude"), 0o755)) + + return agentcfg.Env{Home: home, GOOS: "linux", Cwd: home} +} + +func TestInstallAgentsWritesMCPAndSkill(t *testing.T) { + env := claudeEnv(t) + cmd, out := newTestCmd("") + + err := installAgents(cmd, env, &options{agents: []string{agentcfg.AllAgents}, yes: true}) + require.NoError(t, err) + + // MCP entry landed in ~/.claude.json. + raw, err := os.ReadFile(filepath.Join(env.Home, ".claude.json")) + require.NoError(t, err) + assert.Contains(t, string(raw), `"agntcy-dir"`, "MCP server should be keyed by the translator-normalized name") + assert.NotContains(t, string(raw), "agntcy-dir-mcp", "the -mcp suffix must be stripped by normalization") + + // Skill folder was created under ~/.claude/skills. + entries, err := os.ReadDir(filepath.Join(env.Home, ".claude", "skills")) + require.NoError(t, err) + assert.NotEmpty(t, entries) + + assert.Contains(t, out.String(), "Step 3") +} + +func TestInstallAgentsNoAgentsDetected(t *testing.T) { + env := agentcfg.Env{Home: t.TempDir(), GOOS: "linux", Cwd: t.TempDir()} + cmd, out := newTestCmd("") + + err := installAgents(cmd, env, &options{agents: []string{agentcfg.AllAgents}, yes: true}) + require.NoError(t, err) + assert.Contains(t, out.String(), "No supported AI coding agents detected") +} + +func TestInstallAgentsNonInteractiveWithoutYesSkips(t *testing.T) { + env := claudeEnv(t) + cmd, out := newTestCmd("") // non-TTY stdin (bytes reader), no --yes + + err := installAgents(cmd, env, &options{agents: []string{agentcfg.AllAgents}}) + require.NoError(t, err) + + _, statErr := os.Stat(filepath.Join(env.Home, ".claude.json")) + assert.True(t, os.IsNotExist(statErr), "must not write config unattended") + assert.Contains(t, out.String(), "non-interactive") +} + +func TestRemoveAgentsUninstallsMCPAndSkill(t *testing.T) { + env := claudeEnv(t) + + // Install first so there is something to remove. + installCmd, _ := newTestCmd("") + require.NoError(t, installAgents(installCmd, env, &options{agents: []string{agentcfg.AllAgents}, yes: true})) + + before, err := os.ReadFile(filepath.Join(env.Home, ".claude.json")) + require.NoError(t, err) + require.Contains(t, string(before), `"agntcy-dir"`, "MCP server should be present (normalized key) before removal") + + // Now remove. + rmCmd, out := newTestCmd("") + require.NoError(t, removeAgents(rmCmd, env, &options{agents: []string{agentcfg.AllAgents}, yes: true})) + + after, err := os.ReadFile(filepath.Join(env.Home, ".claude.json")) + require.NoError(t, err) + // NOTE: the brief's literal assertion checks NotContains "agntcy-dir-mcp", but + // the written key is the normalized "agntcy-dir" (the "-mcp" suffix is stripped + // by the oasf-sdk translator; see TestInstallAgentsWritesMCPAndSkill). Asserting + // against "agntcy-dir-mcp" would trivially pass even if removal did nothing, so + // we assert against the actual key instead, proving the entry is truly gone. + assert.NotContains(t, string(after), `"agntcy-dir"`, "MCP server entry should be removed") + assert.Contains(t, out.String(), "removed") +} diff --git a/cli/cmd/init/options.go b/cli/cmd/init/options.go index 7a20c97e7..187709b7a 100644 --- a/cli/cmd/init/options.go +++ b/cli/cmd/init/options.go @@ -4,6 +4,10 @@ package init import ( + "fmt" + "strings" + + "github.com/agntcy/dir/cli/internal/agentcfg" extractor "github.com/agntcy/dir/cli/internal/extractor" "github.com/spf13/cobra" ) @@ -14,6 +18,7 @@ type options struct { assetDir string yes bool remove bool + agents []string } // addFlags registers the `dirctl init` flags on cmd. @@ -27,4 +32,7 @@ func addFlags(cmd *cobra.Command, opts *options) { "Skip prompts and proceed non-interactively (provisions ~89 MB unattended)") flags.BoolVar(&opts.remove, "remove", false, "Remove the provisioned extractor assets and clear the saved config") + flags.StringSliceVar(&opts.agents, "agents", []string{agentcfg.AllAgents}, + fmt.Sprintf("Agents to configure in the MCP server & skills step: %q (default, all detected) or a comma-separated list (%s)", + agentcfg.AllAgents, strings.Join(agentcfg.AgentIDs(), ", "))) } diff --git a/cli/cmd/init/run.go b/cli/cmd/init/run.go index 8bbdf291d..a3356f7b9 100644 --- a/cli/cmd/init/run.go +++ b/cli/cmd/init/run.go @@ -11,6 +11,7 @@ import ( "os" "strings" + "github.com/agntcy/dir/cli/internal/agentcfg" extractor "github.com/agntcy/dir/cli/internal/extractor" "github.com/agntcy/dir/cli/presenter" clientconfig "github.com/agntcy/dir/client/config" @@ -35,7 +36,7 @@ of AI agent records described in OASF. This wizard sets up your local environment. Steps: 1. Configure a local client context 2. Provision the OASF taxonomy extractor - (more steps — MCP server & skills — coming soon) + 3. Configure the Directory MCP server & skills in your AI agents `) } @@ -51,7 +52,11 @@ func run(cmd *cobra.Command, opts *options) error { return err } - return runProvision(cmd, opts) + if err := runProvision(cmd, opts); err != nil { + return err + } + + return runAgentSetup(cmd, opts) } // configFromOpts builds the engine config from flags, resolving defaults. @@ -230,6 +235,10 @@ func runRemove(cmd *cobra.Command, opts *options) error { presenter.Printf(cmd, "Removed extractor assets at %s and cleared saved config.\n", cfg.AssetDir) + if err := removeAgents(cmd, agentcfg.ResolveEnv(), opts); err != nil { + return err + } + return nil } diff --git a/cli/cmd/install/batch.go b/cli/cmd/install/batch.go index 018a632fb..0232fa5f7 100644 --- a/cli/cmd/install/batch.go +++ b/cli/cmd/install/batch.go @@ -12,6 +12,7 @@ import ( searchv1 "github.com/agntcy/dir/api/search/v1" "github.com/agntcy/dir/cli/cmd/search" "github.com/agntcy/dir/cli/internal/agentcfg" + "github.com/agntcy/dir/cli/internal/agentinstall" "github.com/agntcy/dir/cli/presenter" ctxUtils "github.com/agntcy/dir/cli/util/context" "github.com/agntcy/dir/cli/util/records" @@ -98,10 +99,10 @@ func formatSkippedSummary(skipped []skippedRecord) string { type installTarget struct { label string - arts artifacts + arts agentinstall.Artifacts } -type recordApplyFn func(env agentcfg.Env, arts artifacts, agents []agentcfg.Agent, dryRun bool) []agentcfg.Outcome +type recordApplyFn func(env agentcfg.Env, arts agentinstall.Artifacts, agents []agentcfg.Agent, dryRun bool) []agentcfg.Outcome func buildTaggedOutcomes( env agentcfg.Env, @@ -143,7 +144,7 @@ func buildBatchTargets(cmd *cobra.Command, recs []*corev1.Record) ([]installTarg for _, record := range recs { label := getRecordLabel(record) - arts, err := deriveArtifacts(record) + arts, err := agentinstall.DeriveArtifacts(record) if err != nil { skipped = append(skipped, skippedRecord{label: label, reason: err.Error()}) presenter.Printf(cmd, "Warning: skipping %s: %s\n", label, err.Error()) @@ -242,10 +243,10 @@ func runBatch(cmd *cobra.Command, apply recordApplyFn, confirmFn func(*cobra.Com // runBatchInstall searches for records and installs each into the selected agents. func runBatchInstall(cmd *cobra.Command) error { - return runBatch(cmd, runInstall, confirmBatchChanges) + return runBatch(cmd, agentinstall.Install, confirmBatchChanges) } // runBatchUninstall searches for records and removes each from the selected agents. func runBatchUninstall(cmd *cobra.Command) error { - return runBatch(cmd, runUninstall, confirmBatchUninstall) + return runBatch(cmd, agentinstall.Uninstall, confirmBatchUninstall) } diff --git a/cli/cmd/install/batch_test.go b/cli/cmd/install/batch_test.go index e790ed23c..86ae88aa0 100644 --- a/cli/cmd/install/batch_test.go +++ b/cli/cmd/install/batch_test.go @@ -8,6 +8,7 @@ import ( oasfv1alpha1 "buf.build/gen/go/agntcy/oasf/protocolbuffers/go/agntcy/oasf/types/v1alpha1" corev1 "github.com/agntcy/dir/api/core/v1" + "github.com/agntcy/dir/cli/internal/agentinstall" "github.com/stretchr/testify/require" ) @@ -96,7 +97,16 @@ func TestBatchInstallSkipsUnsuitableRecords(t *testing.T) { defer func() { opts = orig }() - _, err := deriveArtifacts(loadRecord(t, "bare.json")) + // Bare record: no modules, so DeriveArtifacts must reject it. Built inline + // rather than via loadRecord/testdata, which moved with the derive/apply + // logic into the agentinstall package. + bare := corev1.New(&oasfv1alpha1.Record{ + Name: "bare", + Version: "1.0.0", + Description: "A record with no modules", + }) + + _, err := agentinstall.DeriveArtifacts(bare) require.Error(t, err) require.Contains(t, err.Error(), "no installable") } diff --git a/cli/cmd/install/flags.go b/cli/cmd/install/flags.go index 26572da78..fcac47f59 100644 --- a/cli/cmd/install/flags.go +++ b/cli/cmd/install/flags.go @@ -12,18 +12,15 @@ import ( "github.com/spf13/cobra" ) -// allAgents is the --agents sentinel selecting every detected agent. -const allAgents = "all" - // addSelectionFlags registers the shared install/uninstall flags: which agents to // target (--agents), which artifacts (--mcp/--skill), and the dry-run/confirm // flags. func addSelectionFlags(cmd *cobra.Command, opts *options) { flags := cmd.PersistentFlags() - flags.StringSliceVar(&opts.agents, "agents", []string{allAgents}, + flags.StringSliceVar(&opts.agents, "agents", []string{agentcfg.AllAgents}, fmt.Sprintf("Agents to target: %q (default, all detected) or a comma-separated list of agent IDs (%s)", - allAgents, strings.Join(agentIDs(), ", "))) + agentcfg.AllAgents, strings.Join(agentcfg.AgentIDs(), ", "))) flags.BoolVar(&opts.dryRun, "dry-run", false, "Preview changes without writing") flags.BoolVarP(&opts.yes, "yes", "y", false, "Skip the confirmation prompt") } @@ -36,49 +33,3 @@ func addBatchFlags(cmd *cobra.Command, opts *options) { search.RegisterPersistentFilterFlags(cmd, &opts.filters) } - -// agentIDs returns the known agent IDs from the registry, for flag help and -// validation. -func agentIDs() []string { - agents := agentcfg.Registry() - ids := make([]string, 0, len(agents)) - - for _, a := range agents { - ids = append(ids, a.ID) - } - - return ids -} - -// resolveChosen validates the --agents values and returns the chosen agent-ID -// set. An empty result means "all detected agents". It errors on an unknown ID -// or on combining "all" with specific IDs. -func resolveChosen(agents []string) (map[string]bool, error) { - valid := map[string]bool{} - for _, id := range agentIDs() { - valid[id] = true - } - - chosen := map[string]bool{} - allMode := false - - for _, raw := range agents { - id := strings.TrimSpace(raw) - - switch { - case id == allAgents: - allMode = true - case valid[id]: - chosen[id] = true - default: - return nil, fmt.Errorf("unknown agent %q; valid values: %s, %s", - id, allAgents, strings.Join(agentIDs(), ", ")) - } - } - - if allMode && len(chosen) > 0 { - return nil, fmt.Errorf("--agents: %q cannot be combined with specific agent IDs", allAgents) - } - - return chosen, nil -} diff --git a/cli/cmd/install/flags_test.go b/cli/cmd/install/flags_test.go deleted file mode 100644 index 8af3c61bb..000000000 --- a/cli/cmd/install/flags_test.go +++ /dev/null @@ -1,34 +0,0 @@ -// Copyright AGNTCY Contributors (https://github.com/agntcy) -// SPDX-License-Identifier: Apache-2.0 - -package install - -import ( - "testing" - - "github.com/stretchr/testify/require" -) - -func TestResolveChosenAllMeansEmpty(t *testing.T) { - chosen, err := resolveChosen([]string{"all"}) - require.NoError(t, err) - require.Empty(t, chosen, `"all" resolves to an empty set (all detected)`) -} - -func TestResolveChosenExplicitList(t *testing.T) { - chosen, err := resolveChosen([]string{"claude-code", "cursor"}) - require.NoError(t, err) - require.Equal(t, map[string]bool{"claude-code": true, "cursor": true}, chosen) -} - -func TestResolveChosenUnknownAgentErrors(t *testing.T) { - _, err := resolveChosen([]string{"cusror"}) - require.Error(t, err) - require.Contains(t, err.Error(), "unknown agent") -} - -func TestResolveChosenAllCombinedWithSpecificErrors(t *testing.T) { - _, err := resolveChosen([]string{"all", "cursor"}) - require.Error(t, err) - require.Contains(t, err.Error(), "cannot be combined") -} diff --git a/cli/cmd/install/install.go b/cli/cmd/install/install.go index 2afa7e66a..8c576f4f0 100644 --- a/cli/cmd/install/install.go +++ b/cli/cmd/install/install.go @@ -1,6 +1,7 @@ // Copyright AGNTCY Contributors (https://github.com/agntcy) // SPDX-License-Identifier: Apache-2.0 +//nolint:wrapcheck package install import ( @@ -12,6 +13,7 @@ import ( corev1 "github.com/agntcy/dir/api/core/v1" "github.com/agntcy/dir/cli/cmd/search" "github.com/agntcy/dir/cli/internal/agentcfg" + "github.com/agntcy/dir/cli/internal/agentinstall" "github.com/agntcy/dir/cli/presenter" ctxUtils "github.com/agntcy/dir/cli/util/context" "github.com/agntcy/dir/cli/util/reference" @@ -87,7 +89,7 @@ func init() { // agents to act on, printing a note for any explicitly-requested agent that is // not detected (never installed for undetected agents). func selectAgents(cmd *cobra.Command, env agentcfg.Env) ([]agentcfg.Agent, error) { - chosen, err := resolveChosen(opts.agents) + chosen, err := agentcfg.ParseSelection(opts.agents) if err != nil { return nil, err } @@ -102,23 +104,23 @@ func selectAgents(cmd *cobra.Command, env agentcfg.Env) ([]agentcfg.Agent, error } // pullAndDerive resolves the ref, pulls the record, and derives its artifacts. -func pullAndDerive(cmd *cobra.Command, input string) (artifacts, error) { +func pullAndDerive(cmd *cobra.Command, input string) (agentinstall.Artifacts, error) { c, ok := ctxUtils.GetClientFromContext(cmd.Context()) if !ok { - return artifacts{}, errors.New("failed to get client from context") + return agentinstall.Artifacts{}, errors.New("failed to get client from context") } cid, err := reference.ResolveToCID(cmd.Context(), c, input) if err != nil { - return artifacts{}, fmt.Errorf("resolve reference: %w", err) + return agentinstall.Artifacts{}, fmt.Errorf("resolve reference: %w", err) } record, err := c.Pull(cmd.Context(), &corev1.RecordRef{Cid: cid}) if err != nil { - return artifacts{}, fmt.Errorf("failed to pull record: %w", err) + return agentinstall.Artifacts{}, fmt.Errorf("failed to pull record: %w", err) } - return deriveArtifacts(record) + return agentinstall.DeriveArtifacts(record) } // runInstallCmd is the shared body for the parent's bare-positional form and the @@ -136,7 +138,7 @@ func runInstallCmd(cmd *cobra.Command, input string) error { return err } - plan := runInstall(env, arts, selected, true) + plan := agentinstall.Install(env, arts, selected, true) presenter.Printf(cmd, "%s", agentcfg.FormatPlan(plan)) if len(plan) == 0 { @@ -156,7 +158,7 @@ func runInstallCmd(cmd *cobra.Command, input string) error { } } - outcomes := runInstall(env, arts, selected, opts.dryRun) + outcomes := agentinstall.Install(env, arts, selected, opts.dryRun) presenter.Printf(cmd, "%s", agentcfg.FormatSummary(outcomes, opts.dryRun)) return nil diff --git a/cli/cmd/install/uninstall.go b/cli/cmd/install/uninstall.go index 430bfafbd..c7e406f6a 100644 --- a/cli/cmd/install/uninstall.go +++ b/cli/cmd/install/uninstall.go @@ -6,6 +6,7 @@ package install import ( "github.com/agntcy/dir/cli/cmd/search" "github.com/agntcy/dir/cli/internal/agentcfg" + "github.com/agntcy/dir/cli/internal/agentinstall" "github.com/agntcy/dir/cli/presenter" "github.com/spf13/cobra" ) @@ -95,7 +96,7 @@ func runUninstallCmd(cmd *cobra.Command, input string) error { return err } - plan := runUninstall(env, arts, selected, true) + plan := agentinstall.Uninstall(env, arts, selected, true) presenter.Printf(cmd, "%s", agentcfg.FormatPlan(plan)) if len(plan) == 0 { @@ -115,7 +116,7 @@ func runUninstallCmd(cmd *cobra.Command, input string) error { } } - outcomes := runUninstall(env, arts, selected, opts.dryRun) + outcomes := agentinstall.Uninstall(env, arts, selected, opts.dryRun) presenter.Printf(cmd, "%s", agentcfg.FormatSummary(outcomes, opts.dryRun)) return nil diff --git a/cli/internal/agentcfg/selection.go b/cli/internal/agentcfg/selection.go new file mode 100644 index 000000000..7ea2d4374 --- /dev/null +++ b/cli/internal/agentcfg/selection.go @@ -0,0 +1,58 @@ +// Copyright AGNTCY Contributors (https://github.com/agntcy) +// SPDX-License-Identifier: Apache-2.0 + +package agentcfg + +import ( + "fmt" + "strings" +) + +// AllAgents is the --agents sentinel selecting every detected agent. +const AllAgents = "all" + +// AgentIDs returns the known agent IDs from the registry, for flag help and +// validation. +func AgentIDs() []string { + agents := Registry() + ids := make([]string, 0, len(agents)) + + for _, a := range agents { + ids = append(ids, a.ID) + } + + return ids +} + +// ParseSelection validates raw --agents values and returns the chosen agent-ID +// set. An empty result means "all detected agents". It errors on an unknown ID +// or on combining AllAgents with specific IDs. +func ParseSelection(values []string) (map[string]bool, error) { + valid := map[string]bool{} + for _, id := range AgentIDs() { + valid[id] = true + } + + chosen := map[string]bool{} + allMode := false + + for _, raw := range values { + id := strings.TrimSpace(raw) + + switch { + case id == AllAgents: + allMode = true + case valid[id]: + chosen[id] = true + default: + return nil, fmt.Errorf("unknown agent %q; valid values: %s, %s", + id, AllAgents, strings.Join(AgentIDs(), ", ")) + } + } + + if allMode && len(chosen) > 0 { + return nil, fmt.Errorf("--agents: %q cannot be combined with specific agent IDs", AllAgents) + } + + return chosen, nil +} diff --git a/cli/internal/agentcfg/selection_test.go b/cli/internal/agentcfg/selection_test.go new file mode 100644 index 000000000..8cab80254 --- /dev/null +++ b/cli/internal/agentcfg/selection_test.go @@ -0,0 +1,40 @@ +// Copyright AGNTCY Contributors (https://github.com/agntcy) +// SPDX-License-Identifier: Apache-2.0 + +package agentcfg + +import ( + "testing" + + "github.com/stretchr/testify/assert" + "github.com/stretchr/testify/require" +) + +func TestParseSelectionAllIsEmptyMap(t *testing.T) { + chosen, err := ParseSelection([]string{AllAgents}) + require.NoError(t, err) + assert.Empty(t, chosen) +} + +func TestParseSelectionExplicitList(t *testing.T) { + chosen, err := ParseSelection([]string{"claude-code", " cursor "}) + require.NoError(t, err) + assert.True(t, chosen["claude-code"]) + assert.True(t, chosen["cursor"]) + assert.Len(t, chosen, 2) +} + +func TestParseSelectionUnknownID(t *testing.T) { + _, err := ParseSelection([]string{"nope"}) + require.Error(t, err) + assert.Contains(t, err.Error(), "unknown agent") +} + +func TestParseSelectionAllWithIDConflicts(t *testing.T) { + _, err := ParseSelection([]string{AllAgents, "cursor"}) + require.Error(t, err) +} + +func TestAgentIDsMatchRegistry(t *testing.T) { + assert.Len(t, AgentIDs(), len(Registry())) +} diff --git a/cli/cmd/install/apply.go b/cli/internal/agentinstall/apply.go similarity index 88% rename from cli/cmd/install/apply.go rename to cli/internal/agentinstall/apply.go index 757a259b7..0acedd2b5 100644 --- a/cli/cmd/install/apply.go +++ b/cli/internal/agentinstall/apply.go @@ -1,7 +1,7 @@ // Copyright AGNTCY Contributors (https://github.com/agntcy) // SPDX-License-Identifier: Apache-2.0 -package install +package agentinstall import ( "maps" @@ -11,9 +11,9 @@ import ( const skillBundleFolderOnlyReason = "skill bundle requires a multi-file skills directory; this agent only supports single instruction files" -// runInstall applies the record's artifacts to the selected agents, one outcome +// Install applies the record's artifacts to the selected agents, one outcome // per touched artifact. Errors on one agent never abort the rest. -func runInstall(env agentcfg.Env, arts artifacts, agents []agentcfg.Agent, dryRun bool) []agentcfg.Outcome { +func Install(env agentcfg.Env, arts Artifacts, agents []agentcfg.Agent, dryRun bool) []agentcfg.Outcome { var outcomes []agentcfg.Outcome seenSkill := map[string]bool{} @@ -59,8 +59,8 @@ func runInstall(env agentcfg.Env, arts artifacts, agents []agentcfg.Agent, dryRu return outcomes } -// runUninstall removes the record's artifacts from the selected agents. -func runUninstall(env agentcfg.Env, arts artifacts, agents []agentcfg.Agent, dryRun bool) []agentcfg.Outcome { +// Uninstall removes the record's artifacts from the selected agents. +func Uninstall(env agentcfg.Env, arts Artifacts, agents []agentcfg.Agent, dryRun bool) []agentcfg.Outcome { var outcomes []agentcfg.Outcome seenSkill := map[string]bool{} diff --git a/cli/cmd/install/apply_test.go b/cli/internal/agentinstall/apply_test.go similarity index 92% rename from cli/cmd/install/apply_test.go rename to cli/internal/agentinstall/apply_test.go index 0001ab6d5..3c195e1a5 100644 --- a/cli/cmd/install/apply_test.go +++ b/cli/internal/agentinstall/apply_test.go @@ -1,7 +1,7 @@ // Copyright AGNTCY Contributors (https://github.com/agntcy) // SPDX-License-Identifier: Apache-2.0 -package install +package agentinstall import ( "archive/tar" @@ -34,7 +34,7 @@ func TestRunInstallWritesMCPEntryIdempotently(t *testing.T) { require.NotNil(t, target) - arts := artifacts{ + arts := Artifacts{ slug: "code-review", mcpServers: []mcpServer{{ name: "code-review", @@ -44,7 +44,7 @@ func TestRunInstallWritesMCPEntryIdempotently(t *testing.T) { agent := agentcfg.Agent{Name: "Claude Code", MCP: target} agents := []agentcfg.Agent{agent} - first := runInstall(env, arts, agents, false) + first := Install(env, arts, agents, false) require.Len(t, first, 1) require.Equal(t, agentcfg.ActionAdded, first[0].Action) @@ -56,7 +56,7 @@ func TestRunInstallWritesMCPEntryIdempotently(t *testing.T) { _, present := codec.GetNested(m, "mcpServers", "code-review") require.True(t, present) - second := runInstall(env, arts, agents, false) + second := Install(env, arts, agents, false) require.Len(t, second, 1) require.Equal(t, agentcfg.ActionUnchanged, second[0].Action) } @@ -76,14 +76,14 @@ func TestRunInstallWritesSkill(t *testing.T) { require.NotNil(t, target) - arts := artifacts{ + arts := Artifacts{ slug: "code-review", skill: "---\nname: code-review\ndescription: x\n---\n\nbody\n", } agent := agentcfg.Agent{Name: "Claude Code", Skill: target} agents := []agentcfg.Agent{agent} - outcomes := runInstall(env, arts, agents, false) + outcomes := Install(env, arts, agents, false) require.Len(t, outcomes, 1) require.Equal(t, agentcfg.ActionAdded, outcomes[0].Action) @@ -137,7 +137,7 @@ func TestRunUninstallRemovesMCPEntryAndPreservesSibling(t *testing.T) { require.NoError(t, os.MkdirAll(filepath.Dir(path), 0o755)) require.NoError(t, os.WriteFile(path, initialJSON, 0o600)) - arts := artifacts{ + arts := Artifacts{ slug: "agntcy-dir", mcpServers: []mcpServer{{ name: "agntcy-dir-mcp", @@ -145,7 +145,7 @@ func TestRunUninstallRemovesMCPEntryAndPreservesSibling(t *testing.T) { }}, } agent := agentcfg.Agent{Name: "Claude Code", MCP: mcpTarget} - outcomes := runUninstall(env, arts, []agentcfg.Agent{agent}, false) + outcomes := Uninstall(env, arts, []agentcfg.Agent{agent}, false) require.Len(t, outcomes, 1) require.Equal(t, agentcfg.ActionRemoved, outcomes[0].Action) @@ -171,17 +171,17 @@ func TestRunUninstallRemovesSkill(t *testing.T) { require.NotNil(t, skillTarget) - arts := artifacts{ + arts := Artifacts{ slug: "code-review", skill: "---\nname: code-review\ndescription: x\n---\n\nbody\n", } agent := agentcfg.Agent{Name: "Claude Code", Skill: skillTarget} // Install first. - runInstall(env, arts, []agentcfg.Agent{agent}, false) + Install(env, arts, []agentcfg.Agent{agent}, false) // Uninstall. - outcomes := runUninstall(env, arts, []agentcfg.Agent{agent}, false) + outcomes := Uninstall(env, arts, []agentcfg.Agent{agent}, false) require.Len(t, outcomes, 1) require.Equal(t, agentcfg.ActionRemoved, outcomes[0].Action) } @@ -206,7 +206,7 @@ func TestRunInstallDedupesSharedSkillPath(t *testing.T) { require.NotNil(t, claudeCode) require.NotNil(t, claudeDesktop) - arts := artifacts{ + arts := Artifacts{ slug: "code-review", skill: "---\nname: code-review\ndescription: x\n---\n\nbody\n", } @@ -215,7 +215,7 @@ func TestRunInstallDedupesSharedSkillPath(t *testing.T) { {Name: "Claude Desktop", Skill: claudeDesktop}, } - outcomes := runInstall(env, arts, agents, false) + outcomes := Install(env, arts, agents, false) // Both agents resolve to the same skills path, so dedupeSkill collapses the // shared target to a single skill outcome. @@ -237,13 +237,13 @@ func TestRunInstallWritesSkillBundle(t *testing.T) { require.NotNil(t, target) - arts := artifacts{ + arts := Artifacts{ slug: "summarize", skill: "---\nname: summarize\ndescription: x\n---\n\nbody\n", skillBundle: skillBundleArchiveBytes(t), } agent := agentcfg.Agent{Name: "Claude Code", Skill: target} - outcomes := runInstall(env, arts, []agentcfg.Agent{agent}, false) + outcomes := Install(env, arts, []agentcfg.Agent{agent}, false) require.Len(t, outcomes, 1) require.Equal(t, agentcfg.ActionAdded, outcomes[0].Action) @@ -279,7 +279,7 @@ func TestRunInstallExtractsSkillBundleToFolderAgents(t *testing.T) { require.NotNil(t, cursor) require.NotNil(t, vscode) - arts := artifacts{ + arts := Artifacts{ slug: "summarize", skill: "---\nname: summarize\ndescription: x\n---\n\nbody\n", skillBundle: skillBundleArchiveBytes(t), @@ -290,7 +290,7 @@ func TestRunInstallExtractsSkillBundleToFolderAgents(t *testing.T) { {Name: "VS Code (Copilot)", Skill: vscode}, } - outcomes := runInstall(env, arts, agents, true) + outcomes := Install(env, arts, agents, true) require.Len(t, outcomes, 3) byAgent := map[string]agentcfg.Outcome{} @@ -317,12 +317,12 @@ func TestRunInstallSkipsSkillBundleForNonFolderAgents(t *testing.T) { require.NotNil(t, continueAgent) - arts := artifacts{ + arts := Artifacts{ slug: "summarize", skill: "---\nname: summarize\ndescription: x\n---\n\nbody\n", skillBundle: skillBundleArchiveBytes(t), } - outcomes := runInstall(env, arts, []agentcfg.Agent{{Name: "Continue", Skill: continueAgent}}, true) + outcomes := Install(env, arts, []agentcfg.Agent{{Name: "Continue", Skill: continueAgent}}, true) require.Len(t, outcomes, 1) require.Equal(t, agentcfg.ActionSkipped, outcomes[0].Action) require.Equal(t, skillBundleFolderOnlyReason, outcomes[0].Reason) diff --git a/cli/cmd/install/derive.go b/cli/internal/agentinstall/derive.go similarity index 87% rename from cli/cmd/install/derive.go rename to cli/internal/agentinstall/derive.go index 2e59ed078..304e8a97b 100644 --- a/cli/cmd/install/derive.go +++ b/cli/internal/agentinstall/derive.go @@ -1,7 +1,7 @@ // Copyright AGNTCY Contributors (https://github.com/agntcy) // SPDX-License-Identifier: Apache-2.0 -package install +package agentinstall import ( "fmt" @@ -24,43 +24,43 @@ type mcpServer struct { entry map[string]any } -// artifacts is the set of installable artifacts derived from a record's modules. -type artifacts struct { +// Artifacts is the set of installable artifacts derived from a record's modules. +type Artifacts struct { slug string // sanitized record name; skill folder/file + block marker id skill string // canonical SKILL.md content for single-file skill targets skillBundle []byte // gzip skill bundle when the record stores application/agent-skills+gzip mcpServers []mcpServer } -func (a artifacts) hasSkill() bool { +func (a Artifacts) hasSkill() bool { return a.skill != "" || a.hasSkillBundle() } -func (a artifacts) hasSkillBundle() bool { +func (a Artifacts) hasSkillBundle() bool { return len(a.skillBundle) > 0 } -// deriveArtifacts inspects the record's OASF modules and builds the installable +// DeriveArtifacts inspects the record's OASF modules and builds the installable // artifact set. A record with only integration/a2a, or with no installable // module, returns an error. -func deriveArtifacts(record *corev1.Record) (artifacts, error) { +func DeriveArtifacts(record *corev1.Record) (Artifacts, error) { data := record.GetData() if data == nil { - return artifacts{}, fmt.Errorf("record contains no data") + return Artifacts{}, fmt.Errorf("record contains no data") } - arts := artifacts{slug: sanitizeSlug(record.GetName())} + arts := Artifacts{slug: sanitizeSlug(record.GetName())} if ok, _ := recordutil.GetModule(data, translator.AgentSkillsModuleName); ok { if err := deriveSkillArtifacts(record, &arts); err != nil { - return artifacts{}, err + return Artifacts{}, err } } if ok, _ := recordutil.GetModule(data, translator.MCPModuleName); ok { cfg, err := translator.RecordToGHCopilot(data) if err != nil { - return artifacts{}, fmt.Errorf("translate MCP module: %w", err) + return Artifacts{}, fmt.Errorf("translate MCP module: %w", err) } // Sort server names so the derived order (and thus plan/summary output) is @@ -82,25 +82,25 @@ func deriveArtifacts(record *corev1.Record) (artifacts, error) { if !arts.hasSkill() && len(arts.mcpServers) == 0 { if ok, _ := recordutil.GetModule(data, translator.A2AModuleName); ok { - return artifacts{}, fmt.Errorf( + return Artifacts{}, fmt.Errorf( "record carries an A2A AgentCard (%s), which cannot be installed into agent configs; use `dirctl export` instead", translator.A2AModuleName) } - return artifacts{}, fmt.Errorf( + return Artifacts{}, fmt.Errorf( "record has no installable module (found: %s); installable modules are %s and %s", strings.Join(moduleNames(data), ", "), translator.AgentSkillsModuleName, translator.MCPModuleName) } if arts.slug == "" { - return artifacts{}, fmt.Errorf("record has no name; cannot derive an install identity") + return Artifacts{}, fmt.Errorf("record has no name; cannot derive an install identity") } return arts, nil } -func deriveSkillArtifacts(record *corev1.Record, arts *artifacts) error { +func deriveSkillArtifacts(record *corev1.Record, arts *Artifacts) error { data := record.GetData() mediaType := agentSkillsArtifactMediaType(data) diff --git a/cli/cmd/install/derive_test.go b/cli/internal/agentinstall/derive_test.go similarity index 92% rename from cli/cmd/install/derive_test.go rename to cli/internal/agentinstall/derive_test.go index bb89e8221..582a95f3a 100644 --- a/cli/cmd/install/derive_test.go +++ b/cli/internal/agentinstall/derive_test.go @@ -1,7 +1,7 @@ // Copyright AGNTCY Contributors (https://github.com/agntcy) // SPDX-License-Identifier: Apache-2.0 -package install +package agentinstall import ( "archive/tar" @@ -33,7 +33,7 @@ func loadRecord(t *testing.T, name string) *corev1.Record { } func TestDeriveSkillOnly(t *testing.T) { - arts, err := deriveArtifacts(loadRecord(t, "skill.json")) + arts, err := DeriveArtifacts(loadRecord(t, "skill.json")) require.NoError(t, err) require.Equal(t, "code-review", arts.slug) require.NotEmpty(t, arts.skill) @@ -41,7 +41,7 @@ func TestDeriveSkillOnly(t *testing.T) { } func TestDeriveMCPOnly(t *testing.T) { - arts, err := deriveArtifacts(loadRecord(t, "mcp.json")) + arts, err := DeriveArtifacts(loadRecord(t, "mcp.json")) require.NoError(t, err) require.Equal(t, "io.example-code-review-server", arts.slug) require.Empty(t, arts.skill) @@ -55,14 +55,14 @@ func TestDeriveMCPOnly(t *testing.T) { } func TestDeriveMulti(t *testing.T) { - arts, err := deriveArtifacts(loadRecord(t, "multi.json")) + arts, err := DeriveArtifacts(loadRecord(t, "multi.json")) require.NoError(t, err) require.NotEmpty(t, arts.skill) require.NotEmpty(t, arts.mcpServers) } func TestDeriveSkillBundle(t *testing.T) { - arts, err := deriveArtifacts(newSkillBundleRecord(t)) + arts, err := DeriveArtifacts(newSkillBundleRecord(t)) require.NoError(t, err) require.NotEmpty(t, arts.skillBundle) require.NotEmpty(t, arts.skill) @@ -111,13 +111,13 @@ description: Summarize content. } func TestDeriveA2AErrors(t *testing.T) { - _, err := deriveArtifacts(loadRecord(t, "a2a.json")) + _, err := DeriveArtifacts(loadRecord(t, "a2a.json")) require.Error(t, err) require.Contains(t, err.Error(), "dirctl export") } func TestDeriveBareErrors(t *testing.T) { - _, err := deriveArtifacts(loadRecord(t, "bare.json")) + _, err := DeriveArtifacts(loadRecord(t, "bare.json")) require.Error(t, err) require.Contains(t, strings.ToLower(err.Error()), "no installable") } diff --git a/cli/cmd/install/testdata/a2a.json b/cli/internal/agentinstall/testdata/a2a.json similarity index 100% rename from cli/cmd/install/testdata/a2a.json rename to cli/internal/agentinstall/testdata/a2a.json diff --git a/cli/cmd/install/testdata/bare.json b/cli/internal/agentinstall/testdata/bare.json similarity index 100% rename from cli/cmd/install/testdata/bare.json rename to cli/internal/agentinstall/testdata/bare.json diff --git a/cli/cmd/install/testdata/mcp.json b/cli/internal/agentinstall/testdata/mcp.json similarity index 100% rename from cli/cmd/install/testdata/mcp.json rename to cli/internal/agentinstall/testdata/mcp.json diff --git a/cli/cmd/install/testdata/multi.json b/cli/internal/agentinstall/testdata/multi.json similarity index 100% rename from cli/cmd/install/testdata/multi.json rename to cli/internal/agentinstall/testdata/multi.json diff --git a/cli/cmd/install/testdata/skill.json b/cli/internal/agentinstall/testdata/skill.json similarity index 100% rename from cli/cmd/install/testdata/skill.json rename to cli/internal/agentinstall/testdata/skill.json From 0ac547afed89aea87b9564918140523c12cc4d20 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Andr=C3=A1s=20J=C3=A1ky?= Date: Fri, 10 Jul 2026 22:57:42 +0200 Subject: [PATCH 2/3] feat(cli): split dirctl init into per-artifact agent selection MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Signed-off-by: András Jáky --- cli/cmd/init/agents.go | 130 +++++++---------- cli/cmd/init/agents_test.go | 93 ++++++++++++- cli/cmd/init/select.go | 208 ++++++++++++++++++++++++++++ cli/internal/agentinstall/filter.go | 24 ++++ 4 files changed, 371 insertions(+), 84 deletions(-) create mode 100644 cli/cmd/init/select.go create mode 100644 cli/internal/agentinstall/filter.go diff --git a/cli/cmd/init/agents.go b/cli/cmd/init/agents.go index a4afb32f1..0162a9482 100644 --- a/cli/cmd/init/agents.go +++ b/cli/cmd/init/agents.go @@ -5,9 +5,7 @@ package init import ( - "bufio" "fmt" - "strings" "time" "github.com/agntcy/dir/cli/internal/agentcfg" @@ -24,6 +22,16 @@ can push, search, and pull records) plus the DIR skill (a usage guide). Content is built in — no Directory connection is made. Writes are idempotent and atomic. ` +// agentSelector picks a subset of the candidate agents for one artifact. It is +// injected so the interactive huh multi-select (production) can be swapped for a +// deterministic fake in tests. Returning an empty slice means "install nowhere". +type agentSelector func(cmd *cobra.Command, title string, candidates []agentcfg.Agent) ([]agentcfg.Agent, error) + +// interactiveCheck reports whether the command may prompt. It is a package var +// (defaulting to isInteractive) so tests can drive the interactive branch without +// a real TTY. +var interactiveCheck = isInteractive + // dirArtifacts builds the built-in DIR record locally and derives its // installable artifacts (skill + MCP server). No Directory round-trip. func dirArtifacts() (agentinstall.Artifacts, error) { @@ -40,14 +48,17 @@ func dirArtifacts() (agentinstall.Artifacts, error) { return arts, nil } -// runAgentSetup runs Step 3 against the resolved ambient environment. +// runAgentSetup runs Step 3 against the resolved ambient environment, using the +// interactive checkbox prompt for per-agent selection. func runAgentSetup(cmd *cobra.Command, opts *options) error { - return installAgents(cmd, agentcfg.ResolveEnv(), opts) + return installAgents(cmd, agentcfg.ResolveEnv(), opts, promptMultiSelect) } -// installAgents is the testable core of Step 3: detect + select agents, then -// place the built-in DIR MCP server & skill via the shared agentinstall engine. -func installAgents(cmd *cobra.Command, env agentcfg.Env, opts *options) error { +// installAgents is the testable core of Step 3: detect the candidate agents, +// then (interactively) ask which of them get the DIR skill and, separately, +// which get the DIR MCP server — installing each artifact via the shared +// agentinstall engine. selectAgents is the per-artifact chooser (injected). +func installAgents(cmd *cobra.Command, env agentcfg.Env, opts *options, selectAgents agentSelector) error { presenter.Printf(cmd, "%s", agentStepIntro) chosen, err := agentcfg.ParseSelection(opts.agents) @@ -55,12 +66,12 @@ func installAgents(cmd *cobra.Command, env agentcfg.Env, opts *options) error { return err } - selected, skipped := agentcfg.ResolveSelection(agentcfg.Registry(), env, chosen) + candidates, skipped := agentcfg.ResolveSelection(agentcfg.Registry(), env, chosen) for _, id := range skipped { presenter.Printf(cmd, "Skipping %s: not detected.\n", id) } - if len(selected) == 0 { + if len(candidates) == 0 { presenter.Printf(cmd, "No supported AI coding agents detected; skipping.\n") return nil @@ -71,47 +82,52 @@ func installAgents(cmd *cobra.Command, env agentcfg.Env, opts *options) error { return err } - presenter.Printf(cmd, "Detected agents:\n") - - for _, a := range selected { - presenter.Printf(cmd, " • %s\n", a.Name) - } - - if !opts.yes { - // Opt-out, but never unattended: a non-TTY run without --yes skips. - if !isInteractive(cmd) { + // Non-interactive: never prompt. With --yes, install both artifacts into + // every candidate; without it, skip rather than act unattended. + if opts.yes || !interactiveCheck(cmd) { + if !opts.yes { presenter.Printf(cmd, "Skipping MCP server & skill setup (non-interactive). Pass --yes to install.\n") return nil } - proceed, narrowed, err := promptAgentSelection(cmd, selected) - if err != nil { - return err - } - - if !proceed { - presenter.Printf(cmd, "Skipped. No changes made.\n") + apply(cmd, env, arts.SkillOnly(), candidates, "DIR skill") + apply(cmd, env, arts.MCPOnly(), candidates, "DIR MCP server") - return nil - } + return nil + } - selected = narrowed + // Interactive: one prompt per artifact, each pre-selecting all candidates. + skillAgents, err := selectAgents(cmd, "Install the DIR skill into:", candidates) + if err != nil { + return err } - plan := agentinstall.Install(env, arts, selected, true) - presenter.Printf(cmd, "%s", agentcfg.FormatPlan(plan)) + apply(cmd, env, arts.SkillOnly(), skillAgents, "DIR skill") - if len(plan) == 0 { - return nil + mcpAgents, err := selectAgents(cmd, "Install the DIR MCP server into:", candidates) + if err != nil { + return err } - outcomes := agentinstall.Install(env, arts, selected, false) - presenter.Printf(cmd, "%s", agentcfg.FormatSummary(outcomes, false)) + apply(cmd, env, arts.MCPOnly(), mcpAgents, "DIR MCP server") return nil } +// apply installs one artifact set into the chosen agents and prints the summary. +// An empty selection is a no-op with a short note (the user deselected everyone). +func apply(cmd *cobra.Command, env agentcfg.Env, arts agentinstall.Artifacts, agents []agentcfg.Agent, label string) { + if len(agents) == 0 { + presenter.Printf(cmd, "%s: no agents selected; skipping.\n", label) + + return + } + + outcomes := agentinstall.Install(env, arts, agents, false) + presenter.Printf(cmd, "%s", agentcfg.FormatSummary(outcomes, false)) +} + // removeAgents strips the built-in DIR MCP server & skill from detected agents. // It mirrors installAgents' selection and TTY/--yes gating. func removeAgents(cmd *cobra.Command, env agentcfg.Env, opts *options) error { @@ -162,49 +178,3 @@ func removeAgents(cmd *cobra.Command, env agentcfg.Env, opts *options) error { return nil } - -// promptAgentSelection confirms installing into all detected agents, or narrows -// to a typed comma-separated subset of agent IDs. An empty line (Enter) accepts -// all; EOF with no input returns proceed=false so nothing is assumed. -// -//nolint:unparam -func promptAgentSelection(cmd *cobra.Command, detected []agentcfg.Agent) (bool, []agentcfg.Agent, error) { - presenter.Printf(cmd, "\nConfigure these agents? [Y/n], or type a comma-separated subset of IDs: ") - - reader := bufio.NewReader(cmd.InOrStdin()) - - line, err := reader.ReadString('\n') - if err != nil && line == "" { - return false, nil, nil - } - - answer := strings.TrimSpace(line) - - switch strings.ToLower(answer) { - case "", "y", "yes": - return true, detected, nil - case "n", "no": - return false, nil, nil - } - - chosen, err := agentcfg.ParseSelection(strings.Split(answer, ",")) - if err != nil { - return false, nil, err - } - - var narrowed []agentcfg.Agent - - for _, a := range detected { - if chosen[a.ID] { - narrowed = append(narrowed, a) - } - } - - if len(narrowed) == 0 { - presenter.Printf(cmd, "None of the typed IDs match a detected agent; skipping.\n") - - return false, nil, nil - } - - return true, narrowed, nil -} diff --git a/cli/cmd/init/agents_test.go b/cli/cmd/init/agents_test.go index 809b7dfff..bb4b284cc 100644 --- a/cli/cmd/init/agents_test.go +++ b/cli/cmd/init/agents_test.go @@ -4,11 +4,14 @@ package init import ( + "bufio" "os" "path/filepath" + "strings" "testing" "github.com/agntcy/dir/cli/internal/agentcfg" + "github.com/spf13/cobra" "github.com/stretchr/testify/assert" "github.com/stretchr/testify/require" ) @@ -23,11 +26,17 @@ func claudeEnv(t *testing.T) agentcfg.Env { return agentcfg.Env{Home: home, GOOS: "linux", Cwd: home} } +// selectAll is a fake agentSelector that keeps every candidate — the non-prompt +// default used where the test does not care about the selection UI. +func selectAll(_ *cobra.Command, _ string, candidates []agentcfg.Agent) ([]agentcfg.Agent, error) { + return candidates, nil +} + func TestInstallAgentsWritesMCPAndSkill(t *testing.T) { env := claudeEnv(t) cmd, out := newTestCmd("") - err := installAgents(cmd, env, &options{agents: []string{agentcfg.AllAgents}, yes: true}) + err := installAgents(cmd, env, &options{agents: []string{agentcfg.AllAgents}, yes: true}, selectAll) require.NoError(t, err) // MCP entry landed in ~/.claude.json. @@ -48,7 +57,7 @@ func TestInstallAgentsNoAgentsDetected(t *testing.T) { env := agentcfg.Env{Home: t.TempDir(), GOOS: "linux", Cwd: t.TempDir()} cmd, out := newTestCmd("") - err := installAgents(cmd, env, &options{agents: []string{agentcfg.AllAgents}, yes: true}) + err := installAgents(cmd, env, &options{agents: []string{agentcfg.AllAgents}, yes: true}, selectAll) require.NoError(t, err) assert.Contains(t, out.String(), "No supported AI coding agents detected") } @@ -57,7 +66,7 @@ func TestInstallAgentsNonInteractiveWithoutYesSkips(t *testing.T) { env := claudeEnv(t) cmd, out := newTestCmd("") // non-TTY stdin (bytes reader), no --yes - err := installAgents(cmd, env, &options{agents: []string{agentcfg.AllAgents}}) + err := installAgents(cmd, env, &options{agents: []string{agentcfg.AllAgents}}, selectAll) require.NoError(t, err) _, statErr := os.Stat(filepath.Join(env.Home, ".claude.json")) @@ -65,12 +74,88 @@ func TestInstallAgentsNonInteractiveWithoutYesSkips(t *testing.T) { assert.Contains(t, out.String(), "non-interactive") } +func TestInstallAgentsInteractivePerArtifactSelection(t *testing.T) { + env := claudeEnv(t) + + // Drive the interactive branch without a real TTY. + prev := interactiveCheck + interactiveCheck = func(*cobra.Command) bool { return true } + + t.Cleanup(func() { interactiveCheck = prev }) + + // Skill goes to all detected agents; the MCP server goes to none — proving + // the two prompts select independently. + selector := func(_ *cobra.Command, title string, candidates []agentcfg.Agent) ([]agentcfg.Agent, error) { + if strings.Contains(title, "MCP") { + return nil, nil + } + + return candidates, nil + } + + cmd, out := newTestCmd("") + require.NoError(t, installAgents(cmd, env, &options{agents: []string{agentcfg.AllAgents}}, selector)) + + // Skill folder created… + entries, err := os.ReadDir(filepath.Join(env.Home, ".claude", "skills")) + require.NoError(t, err) + assert.NotEmpty(t, entries) + + // …but no MCP entry written, since the MCP prompt selected nothing. + _, statErr := os.Stat(filepath.Join(env.Home, ".claude.json")) + assert.True(t, os.IsNotExist(statErr), "MCP config must not be written when MCP deselected") + + assert.Contains(t, out.String(), "no agents selected") +} + +func TestDecodeKey(t *testing.T) { + cases := map[string]key{ + " ": keyToggle, + "\r": keyConfirm, + "\n": keyConfirm, + "j": keyDown, + "k": keyUp, + "q": keyAbort, + "\x03": keyAbort, // Ctrl-C + "\x1b[A": keyUp, // up arrow + "\x1b[B": keyDown, // down arrow + "\x1b[C": keyNone, // right arrow (unhandled) + "x": keyNone, + } + + for input, want := range cases { + got, err := decodeKey(bufio.NewReader(strings.NewReader(input))) + require.NoError(t, err, "input %q", input) + assert.Equal(t, want, got, "input %q", input) + } +} + +func TestSelectState(t *testing.T) { + s := newSelectState([]string{"A", "B", "C"}) + + // All checked by default. + assert.Equal(t, []int{0, 1, 2}, s.checkedIndexes()) + + // Up at the top is a no-op; down moves the cursor, clamped at the end. + s.apply(keyUp) + assert.Equal(t, 0, s.cursor) + s.apply(keyDown) // -> 1 + s.apply(keyToggle) + assert.Equal(t, []int{0, 2}, s.checkedIndexes(), "toggled B off") + + s.apply(keyDown) // -> 2 + s.apply(keyDown) // clamp at last + assert.Equal(t, 2, s.cursor) + s.apply(keyToggle) + assert.Equal(t, []int{0}, s.checkedIndexes(), "toggled C off too") +} + func TestRemoveAgentsUninstallsMCPAndSkill(t *testing.T) { env := claudeEnv(t) // Install first so there is something to remove. installCmd, _ := newTestCmd("") - require.NoError(t, installAgents(installCmd, env, &options{agents: []string{agentcfg.AllAgents}, yes: true})) + require.NoError(t, installAgents(installCmd, env, &options{agents: []string{agentcfg.AllAgents}, yes: true}, selectAll)) before, err := os.ReadFile(filepath.Join(env.Home, ".claude.json")) require.NoError(t, err) diff --git a/cli/cmd/init/select.go b/cli/cmd/init/select.go new file mode 100644 index 000000000..2ae47b398 --- /dev/null +++ b/cli/cmd/init/select.go @@ -0,0 +1,208 @@ +// Copyright AGNTCY Contributors (https://github.com/agntcy) +// SPDX-License-Identifier: Apache-2.0 + +package init + +import ( + "bufio" + "fmt" + "io" + "os" + + "github.com/agntcy/dir/cli/internal/agentcfg" + "github.com/spf13/cobra" + "golang.org/x/term" +) + +// Control bytes read from the terminal in raw mode. +const ( + byteCtrlC = 0x03 + byteEsc = 0x1b +) + +// key is a decoded keypress from the interactive selector. +type key int + +const ( + keyNone key = iota + keyUp + keyDown + keyToggle // space + keyConfirm // enter + keyAbort // q / esc / ctrl-c +) + +// selectState is the mutable state of the checkbox list: which line the cursor +// is on and which entries are checked. +type selectState struct { + names []string + checked []bool + cursor int +} + +func newSelectState(names []string) *selectState { + checked := make([]bool, len(names)) + for i := range checked { + checked[i] = true // all selected by default + } + + return &selectState{names: names, checked: checked} +} + +// apply mutates the state for one keypress (movement or toggle). Enter/abort are +// handled by the caller and are no-ops here. +func (s *selectState) apply(k key) { + switch k { + case keyUp: + if s.cursor > 0 { + s.cursor-- + } + case keyDown: + if s.cursor < len(s.names)-1 { + s.cursor++ + } + case keyToggle: + s.checked[s.cursor] = !s.checked[s.cursor] + case keyNone, keyConfirm, keyAbort: + // no state change + } +} + +// checkedIndexes returns the indexes of the checked entries, in list order. +func (s *selectState) checkedIndexes() []int { + var out []int + + for i, on := range s.checked { + if on { + out = append(out, i) + } + } + + return out +} + +// decodeKey reads one logical keypress, translating arrow-key escape sequences +// and vim-style j/k into movement. Unknown bytes decode to keyNone. +func decodeKey(r *bufio.Reader) (key, error) { + b, err := r.ReadByte() + if err != nil { + return keyNone, err //nolint:wrapcheck + } + + switch b { + case '\r', '\n': + return keyConfirm, nil + case ' ': + return keyToggle, nil + case 'k': + return keyUp, nil + case 'j': + return keyDown, nil + case 'q', byteCtrlC: // q or Ctrl-C + return keyAbort, nil + case byteEsc: // ESC: bare escape aborts; ESC-[-A/B is an arrow key + if r.Buffered() == 0 { + return keyAbort, nil + } + + if b2, _ := r.ReadByte(); b2 != '[' { + return keyNone, nil + } + + switch b3, _ := r.ReadByte(); b3 { + case 'A': + return keyUp, nil + case 'B': + return keyDown, nil + default: + return keyNone, nil + } + default: + return keyNone, nil + } +} + +// promptMultiSelect renders a minimal interactive checkbox list — arrow keys (or +// j/k) move, space toggles, enter confirms, q/esc skips — with every candidate +// checked by default. It uses raw terminal mode via golang.org/x/term (already a +// dependency); no extra package. When stdin is not a terminal it selects all +// candidates (callers gate on interactivity before reaching here). +func promptMultiSelect(cmd *cobra.Command, title string, candidates []agentcfg.Agent) ([]agentcfg.Agent, error) { + in, ok := cmd.InOrStdin().(*os.File) + if !ok || !term.IsTerminal(int(in.Fd())) { //nolint:gosec // G115: fd fits an int. + return candidates, nil + } + + names := make([]string, len(candidates)) + for i, a := range candidates { + names[i] = a.Name + } + + state := newSelectState(names) + out := cmd.OutOrStdout() + + restore, err := term.MakeRaw(int(in.Fd())) //nolint:gosec // G115: fd fits an int. + if err != nil { + return nil, fmt.Errorf("enter raw mode: %w", err) + } + + defer func() { _ = term.Restore(int(in.Fd()), restore) }() //nolint:gosec // G115: fd fits an int. + + fmt.Fprintf(out, "%s (↑/↓ move · space toggles · enter confirms · q skips)\r\n", title) + render(out, state, true) + + reader := bufio.NewReader(in) + + for { + k, err := decodeKey(reader) + if err != nil { + return nil, err + } + + switch k { + case keyConfirm: + fmt.Fprint(out, "\r\n") + + return pick(candidates, state.checkedIndexes()), nil + case keyAbort: + fmt.Fprint(out, "\r\n") + + return nil, nil + case keyNone, keyUp, keyDown, keyToggle: + state.apply(k) + render(out, state, false) + } + } +} + +// render draws the checkbox list. On redraws (first=false) it moves the cursor +// up over the previous rendering first, so the list updates in place. +func render(w io.Writer, s *selectState, first bool) { + if !first { + fmt.Fprintf(w, "\x1b[%dA", len(s.names)) // cursor up to the first row + } + + for i, name := range s.names { + pointer := " " + if i == s.cursor { + pointer = ">" + } + + box := " " + if s.checked[i] { + box = "x" + } + + fmt.Fprintf(w, "\r\x1b[K%s [%s] %s\r\n", pointer, box, name) + } +} + +// pick returns the candidates at the given indexes, in order. +func pick(candidates []agentcfg.Agent, indexes []int) []agentcfg.Agent { + var out []agentcfg.Agent + for _, i := range indexes { + out = append(out, candidates[i]) + } + + return out +} diff --git a/cli/internal/agentinstall/filter.go b/cli/internal/agentinstall/filter.go new file mode 100644 index 000000000..9629e5167 --- /dev/null +++ b/cli/internal/agentinstall/filter.go @@ -0,0 +1,24 @@ +// Copyright AGNTCY Contributors (https://github.com/agntcy) +// SPDX-License-Identifier: Apache-2.0 + +package agentinstall + +// SkillOnly returns a copy of the artifacts carrying only the skill (SKILL.md or +// bundle), with no MCP servers. Used to place the skill independently of the MCP +// server entry — e.g. `dirctl init` prompts for each separately. +func (a Artifacts) SkillOnly() Artifacts { + return Artifacts{ + slug: a.slug, + skill: a.skill, + skillBundle: a.skillBundle, + } +} + +// MCPOnly returns a copy of the artifacts carrying only the MCP servers, with no +// skill. The counterpart to SkillOnly. +func (a Artifacts) MCPOnly() Artifacts { + return Artifacts{ + slug: a.slug, + mcpServers: a.mcpServers, + } +} From 2ebc4d96f585af75bd2acd188cbaad3ce89c724b Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Andr=C3=A1s=20J=C3=A1ky?= Date: Sat, 11 Jul 2026 00:30:48 +0200 Subject: [PATCH 3/3] fix(cli): propagate ESC-decode errors and correct stale selector comment MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Signed-off-by: András Jáky --- cli/cmd/init/agents.go | 5 +++-- cli/cmd/init/select.go | 45 +++++++++++++++++++++++++++++------------- 2 files changed, 34 insertions(+), 16 deletions(-) diff --git a/cli/cmd/init/agents.go b/cli/cmd/init/agents.go index 0162a9482..294574f16 100644 --- a/cli/cmd/init/agents.go +++ b/cli/cmd/init/agents.go @@ -23,8 +23,9 @@ is built in — no Directory connection is made. Writes are idempotent and atomi ` // agentSelector picks a subset of the candidate agents for one artifact. It is -// injected so the interactive huh multi-select (production) can be swapped for a -// deterministic fake in tests. Returning an empty slice means "install nowhere". +// injected so the interactive checkbox selector (promptMultiSelect, production) +// can be swapped for a deterministic fake in tests. Returning an empty slice +// means "install nowhere". type agentSelector func(cmd *cobra.Command, title string, candidates []agentcfg.Agent) ([]agentcfg.Agent, error) // interactiveCheck reports whether the command may prompt. It is a package var diff --git a/cli/cmd/init/select.go b/cli/cmd/init/select.go index 2ae47b398..eae8b1fa5 100644 --- a/cli/cmd/init/select.go +++ b/cli/cmd/init/select.go @@ -101,22 +101,39 @@ func decodeKey(r *bufio.Reader) (key, error) { case 'q', byteCtrlC: // q or Ctrl-C return keyAbort, nil case byteEsc: // ESC: bare escape aborts; ESC-[-A/B is an arrow key - if r.Buffered() == 0 { - return keyAbort, nil - } + return decodeEscape(r) + default: + return keyNone, nil + } +} - if b2, _ := r.ReadByte(); b2 != '[' { - return keyNone, nil - } +// decodeEscape decodes the bytes following an ESC (0x1b). A bare ESC (nothing +// buffered) aborts; ESC-[-A / ESC-[-B are the up / down arrows. Read errors +// mid-sequence are propagated rather than silently decoded as keyNone. +func decodeEscape(r *bufio.Reader) (key, error) { + if r.Buffered() == 0 { + return keyAbort, nil + } - switch b3, _ := r.ReadByte(); b3 { - case 'A': - return keyUp, nil - case 'B': - return keyDown, nil - default: - return keyNone, nil - } + b2, err := r.ReadByte() + if err != nil { + return keyNone, err //nolint:wrapcheck + } + + if b2 != '[' { + return keyNone, nil + } + + b3, err := r.ReadByte() + if err != nil { + return keyNone, err //nolint:wrapcheck + } + + switch b3 { + case 'A': + return keyUp, nil + case 'B': + return keyDown, nil default: return keyNone, nil }