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
181 changes: 181 additions & 0 deletions cli/cmd/init/agents.go
Original file line number Diff line number Diff line change
@@ -0,0 +1,181 @@
// Copyright AGNTCY Contributors (https://github.com/agntcy)
// SPDX-License-Identifier: Apache-2.0

//nolint:wrapcheck
package init

import (
"fmt"
"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.
`

// agentSelector picks a subset of the candidate agents for one artifact. It is
// 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
// (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) {
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, using the
// interactive checkbox prompt for per-agent selection.
func runAgentSetup(cmd *cobra.Command, opts *options) error {
return installAgents(cmd, agentcfg.ResolveEnv(), opts, promptMultiSelect)
}

// 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)
if err != nil {
return err
}

candidates, skipped := agentcfg.ResolveSelection(agentcfg.Registry(), env, chosen)
for _, id := range skipped {
presenter.Printf(cmd, "Skipping %s: not detected.\n", id)
}

if len(candidates) == 0 {
presenter.Printf(cmd, "No supported AI coding agents detected; skipping.\n")

return nil
}

arts, err := dirArtifacts()
if err != nil {
return err
}

// 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
}

apply(cmd, env, arts.SkillOnly(), candidates, "DIR skill")
apply(cmd, env, arts.MCPOnly(), candidates, "DIR MCP server")

return nil
}

// 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
}

apply(cmd, env, arts.SkillOnly(), skillAgents, "DIR skill")

mcpAgents, err := selectAgents(cmd, "Install the DIR MCP server into:", candidates)
if err != nil {
return err
}

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 {
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
}
177 changes: 177 additions & 0 deletions cli/cmd/init/agents_test.go
Original file line number Diff line number Diff line change
@@ -0,0 +1,177 @@
// Copyright AGNTCY Contributors (https://github.com/agntcy)
// SPDX-License-Identifier: Apache-2.0

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"
)

// 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}
}

// 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}, selectAll)
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}, selectAll)
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}}, selectAll)
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 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}, selectAll))

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")
}
8 changes: 8 additions & 0 deletions cli/cmd/init/options.go
Original file line number Diff line number Diff line change
Expand Up @@ -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"
)
Expand All @@ -14,6 +18,7 @@ type options struct {
assetDir string
yes bool
remove bool
agents []string
}

// addFlags registers the `dirctl init` flags on cmd.
Expand All @@ -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(), ", ")))
}
Loading
Loading