Skip to content
22 changes: 15 additions & 7 deletions internal/commands/auth.go
Original file line number Diff line number Diff line change
Expand Up @@ -281,7 +281,7 @@ func buildLoginCmd(use string) *cobra.Command {
}
}

printClaudeNudge(w, r)
printAgentNudge(w, r)

return nil
},
Expand Down Expand Up @@ -320,11 +320,19 @@ func buildLogoutCmd(use string) *cobra.Command {
}
}

// printClaudeNudge prints a hint about Claude Code plugin setup after login.
func printClaudeNudge(w io.Writer, r *output.Renderer) {
if harness.IsPluginNeeded() {
fmt.Fprintln(w)
fmt.Fprintln(w, r.Muted.Render(" Claude Code detected. Connect it to Basecamp:"))
fmt.Fprintln(w, r.Data.Render(" basecamp setup claude"))
// printAgentNudge prints a hint about coding agent setup after login.
func printAgentNudge(w io.Writer, r *output.Renderer) {
for _, agent := range harness.DetectedAgents() {
if agent.Checks == nil {
continue
}
for _, c := range agent.Checks() {
if c.Status != "pass" {
fmt.Fprintln(w)
fmt.Fprintln(w, r.Muted.Render(fmt.Sprintf(" %s detected. Connect it to Basecamp:", agent.Name)))
fmt.Fprintln(w, r.Data.Render(fmt.Sprintf(" basecamp setup %s", agent.ID)))
return // one nudge is enough
}
}
}
}
33 changes: 12 additions & 21 deletions internal/commands/doctor.go
Original file line number Diff line number Diff line change
Expand Up @@ -199,9 +199,18 @@ func runDoctorChecks(ctx context.Context, app *appctx.App, verbose bool) []Check
checks = append(checks, *legacyCheck)
}

// 12. AI Agent integration (only when Claude Code is detected)
if harness.DetectClaude() {
checks = append(checks, checkClaudeIntegration()...)
// 12. AI Agent integration (for each detected agent)
for _, agent := range harness.DetectedAgents() {
if agent.Checks != nil {
for _, c := range agent.Checks() {
checks = append(checks, Check{
Name: c.Name,
Status: c.Status,
Message: c.Message,
Hint: c.Hint,
})
}
}
}

return checks
Expand Down Expand Up @@ -1025,24 +1034,6 @@ func renderDoctorStyled(w io.Writer, result *DoctorResult) {
fmt.Fprintln(w)
}

// checkClaudeIntegration runs Claude Code-specific health checks.
func checkClaudeIntegration() []Check {
var checks []Check

// Plugin installed?
pluginCheck := harness.CheckClaudePlugin()
if pluginCheck != nil {
checks = append(checks, Check{
Name: pluginCheck.Name,
Status: pluginCheck.Status,
Message: pluginCheck.Message,
Hint: pluginCheck.Hint,
})
}

return checks
}

// checkLegacyInstall detects stale bcq artifacts and suggests migration.
// Returns nil if no legacy artifacts are found (to avoid noisy output).
func checkLegacyInstall() *Check {
Expand Down
50 changes: 46 additions & 4 deletions internal/commands/doctor_test.go
Original file line number Diff line number Diff line change
Expand Up @@ -16,6 +16,7 @@ import (
"github.com/basecamp/basecamp-cli/internal/appctx"
"github.com/basecamp/basecamp-cli/internal/auth"
"github.com/basecamp/basecamp-cli/internal/config"
"github.com/basecamp/basecamp-cli/internal/harness"
"github.com/basecamp/basecamp-cli/internal/names"
"github.com/basecamp/basecamp-cli/internal/output"
"github.com/basecamp/basecamp-cli/internal/version"
Expand Down Expand Up @@ -513,16 +514,57 @@ func TestCheckLegacyInstall_NilWhenAlreadyMigrated(t *testing.T) {
}

func TestCheckClaudeIntegration(t *testing.T) {
// checkClaudeIntegration calls harness.CheckClaudePlugin which reads
// ~/.claude/plugins/installed_plugins.json. In test environments there's
// no ~/.claude directory, so the plugin check should return "fail".
checks := checkClaudeIntegration()
// Claude registers via init() in the harness package. Its Checks function
// calls harness.CheckClaudePlugin which reads the plugin file.
agent := harness.FindAgent("claude")
require.NotNil(t, agent, "claude agent should be registered")
require.NotNil(t, agent.Checks)

checks := agent.Checks()
require.NotEmpty(t, checks, "should return at least one check")
assert.Equal(t, "Claude Code Plugin", checks[0].Name)
// Status depends on environment — in CI there's no ~/.claude so it'll be "fail"
assert.Contains(t, []string{"pass", "fail", "warn"}, checks[0].Status)
}

func TestCheckClaudeIntegrationIncludesSkillLink(t *testing.T) {
home := t.TempDir()
t.Setenv("HOME", home)
t.Setenv("PATH", home) // no claude binary

// Create ~/.claude so the skill link check is included
require.NoError(t, os.MkdirAll(filepath.Join(home, ".claude"), 0o755))

agent := harness.FindAgent("claude")
require.NotNil(t, agent)

checks := agent.Checks()
require.True(t, len(checks) >= 2, "expected at least plugin + skill checks, got %d", len(checks))

// Find the skill check
var skillCheck *harness.StatusCheck
for _, c := range checks {
if c.Name == "Claude Code Skill" {
skillCheck = c
break
}
}
require.NotNil(t, skillCheck, "expected Claude Code Skill check")
assert.Equal(t, "fail", skillCheck.Status, "skill link should fail when not present")

// Now create the skill link and verify it passes
skillDir := filepath.Join(home, ".claude", "skills", "basecamp")
require.NoError(t, os.MkdirAll(skillDir, 0o755))
require.NoError(t, os.WriteFile(filepath.Join(skillDir, "SKILL.md"), []byte("test"), 0o644))

Comment thread
jeremy marked this conversation as resolved.
checks = agent.Checks()
for _, c := range checks {
if c.Name == "Claude Code Skill" {
assert.Equal(t, "pass", c.Status, "skill link should pass when present")
}
}
}

func TestCheckLegacyInstall_SkipsKeyringWhenNoKeyring(t *testing.T) {
t.Setenv("BASECAMP_NO_KEYRING", "1")
t.Setenv("XDG_CACHE_HOME", t.TempDir())
Expand Down
18 changes: 14 additions & 4 deletions internal/commands/quickstart.go
Original file line number Diff line number Diff line change
Expand Up @@ -130,10 +130,20 @@ func runQuickStart(cmd *cobra.Command, args []string) error {
Action: "authenticate", Cmd: "basecamp auth login", Description: "Login",
})
}
if harness.IsPluginNeeded() {
breadcrumbs = append(breadcrumbs, output.Breadcrumb{
Action: "setup_claude", Cmd: "basecamp setup claude", Description: "Connect Claude to Basecamp",
})
for _, agent := range harness.DetectedAgents() {
if agent.Checks == nil {
continue
}
for _, c := range agent.Checks() {
if c.Status != "pass" {
breadcrumbs = append(breadcrumbs, output.Breadcrumb{
Action: "setup_" + agent.ID,
Cmd: "basecamp setup " + agent.ID,
Description: "Connect " + agent.Name + " to Basecamp",
})
break
}
}
}

return app.OK(json.RawMessage(data),
Expand Down
117 changes: 75 additions & 42 deletions internal/commands/skill.go
Original file line number Diff line number Diff line change
Expand Up @@ -8,6 +8,7 @@ import (
"github.com/spf13/cobra"

"github.com/basecamp/basecamp-cli/internal/appctx"
"github.com/basecamp/basecamp-cli/internal/harness"
"github.com/basecamp/basecamp-cli/internal/output"
"github.com/basecamp/basecamp-cli/skills"
)
Expand All @@ -34,69 +35,101 @@ func NewSkillCmd() *cobra.Command {
func newSkillInstallCmd() *cobra.Command {
return &cobra.Command{
Use: "install",
Short: "Install the basecamp skill globally for Claude",
Long: "Copies the embedded SKILL.md to ~/.agents/skills/basecamp/ and creates a symlink in ~/.claude/skills/basecamp.",
Short: "Install the basecamp agent skill",
Long: "Copies the embedded SKILL.md to ~/.agents/skills/basecamp/ and creates a symlink in ~/.claude/skills/basecamp (if Claude Code is detected).",
RunE: func(cmd *cobra.Command, args []string) error {
app := appctx.FromContext(cmd.Context())

home, err := os.UserHomeDir()
skillPath, err := installSkillFiles()
if err != nil {
return fmt.Errorf("getting home directory: %w", err)
return err
}

skillDir := filepath.Join(home, ".agents", "skills", "basecamp")
skillFile := filepath.Join(skillDir, "SKILL.md")
symlinkDir := filepath.Join(home, ".claude", "skills")
symlinkPath := filepath.Join(symlinkDir, "basecamp")

data, err := skills.FS.ReadFile("basecamp/SKILL.md")
if err != nil {
return fmt.Errorf("reading embedded skill: %w", err)
}

if err := os.MkdirAll(skillDir, 0o755); err != nil { //nolint:gosec // G301: Skill files are not secrets
return fmt.Errorf("creating skill directory: %w", err)
}
if err := os.WriteFile(skillFile, data, 0o644); err != nil { //nolint:gosec // G306: Skill files are not secrets
return fmt.Errorf("writing skill file: %w", err)
}

if err := os.MkdirAll(symlinkDir, 0o755); err != nil { //nolint:gosec // G301: Skill files are not secrets
return fmt.Errorf("creating symlink directory: %w", err)
result := map[string]any{
"skill_path": skillPath,
}

// Remove existing entry at symlink path (idempotent)
_ = os.Remove(symlinkPath)

symlinkTarget := filepath.Join("..", "..", ".agents", "skills", "basecamp")
notice := ""
if err := os.Symlink(symlinkTarget, symlinkPath); err != nil {
// Fallback: copy skill files directly
notice = fmt.Sprintf("symlink failed (%v), copied files instead", err)
if copyErr := copySkillFiles(skillDir, symlinkPath); copyErr != nil {
return fmt.Errorf("creating symlink: %w (copy fallback also failed: %w)", err, copyErr)
// Only create the Claude symlink if Claude is actually installed
if harness.DetectClaude() {
symlinkPath, notice, linkErr := linkSkillToClaude()
if linkErr != nil {
return linkErr
}
result["symlink_path"] = symlinkPath
if notice != "" {
result["notice"] = notice
}
}

result := map[string]any{
"skill_path": skillFile,
"symlink_path": symlinkPath,
}
if notice != "" {
result["notice"] = notice
}

summary := "Basecamp skill installed"
if app != nil {
return app.OK(result, output.WithSummary(summary))
}
// Fallback if app context not available (shouldn't happen in practice)
fmt.Fprintf(cmd.OutOrStdout(), "Installed skill to %s\n", skillFile)
fmt.Fprintf(cmd.OutOrStdout(), "Installed skill to %s\n", skillPath)
return nil
},
}
}

// installSkillFiles writes the embedded SKILL.md to ~/.agents/skills/basecamp/
// and returns the path to the installed file.
func installSkillFiles() (string, error) {
home, err := os.UserHomeDir()
if err != nil {
return "", fmt.Errorf("getting home directory: %w", err)
}

skillDir := filepath.Join(home, ".agents", "skills", "basecamp")
skillFile := filepath.Join(skillDir, "SKILL.md")

data, err := skills.FS.ReadFile("basecamp/SKILL.md")
if err != nil {
return "", fmt.Errorf("reading embedded skill: %w", err)
}

if err := os.MkdirAll(skillDir, 0o755); err != nil { //nolint:gosec // G301: Skill files are not secrets
return "", fmt.Errorf("creating skill directory: %w", err)
}
if err := os.WriteFile(skillFile, data, 0o644); err != nil { //nolint:gosec // G306: Skill files are not secrets
return "", fmt.Errorf("writing skill file: %w", err)
}

return skillFile, nil
}

// linkSkillToClaude creates a symlink at ~/.claude/skills/basecamp pointing to
// the baseline skill directory. Returns (symlinkPath, notice, error).
func linkSkillToClaude() (string, string, error) {
home, err := os.UserHomeDir()
if err != nil {
return "", "", fmt.Errorf("getting home directory: %w", err)
}

skillDir := filepath.Join(home, ".agents", "skills", "basecamp")
symlinkDir := filepath.Join(home, ".claude", "skills")
symlinkPath := filepath.Join(symlinkDir, "basecamp")

if err := os.MkdirAll(symlinkDir, 0o755); err != nil { //nolint:gosec // G301: Skill files are not secrets
return "", "", fmt.Errorf("creating symlink directory: %w", err)
}

// Remove existing entry at symlink path (idempotent)
_ = os.Remove(symlinkPath)

symlinkTarget := filepath.Join("..", "..", ".agents", "skills", "basecamp")
notice := ""
if err := os.Symlink(symlinkTarget, symlinkPath); err != nil {
// Fallback: copy skill files directly
notice = fmt.Sprintf("symlink failed (%v), copied files instead", err)
if copyErr := copySkillFiles(skillDir, symlinkPath); copyErr != nil {
return "", "", fmt.Errorf("creating symlink: %w (copy fallback also failed: %w)", err, copyErr)
}
}

return symlinkPath, notice, nil
}

func copySkillFiles(src, dst string) error {
if err := os.MkdirAll(dst, 0o755); err != nil { //nolint:gosec // G301: Skill files are not secrets
return err
Expand Down
Loading
Loading