Skip to content
Merged
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
8 changes: 7 additions & 1 deletion internal/cli/root.go
Original file line number Diff line number Diff line change
Expand Up @@ -136,9 +136,15 @@ func NewRootCmd() *cobra.Command {
}

cmd.PersistentPostRunE = func(cmd *cobra.Command, args []string) error {
if app := appctx.FromContext(cmd.Context()); app != nil {
app := appctx.FromContext(cmd.Context())
if app != nil {
app.Close()
}
if commands.RefreshSkillsIfVersionChanged() {
if app == nil || !app.IsMachineOutput() {
fmt.Fprintf(os.Stderr, "Agent skill updated to match CLI %s\n", version.Version)
}
}
return nil
}

Expand Down
43 changes: 42 additions & 1 deletion internal/commands/doctor.go
Original file line number Diff line number Diff line change
Expand Up @@ -200,6 +200,9 @@ func runDoctorChecks(ctx context.Context, app *appctx.App, verbose bool) []Check
}

// 12. AI Agent integration (for each detected agent)
if baselineSkillInstalled() {
checks = append(checks, checkSkillVersion())
}
for _, agent := range harness.DetectedAgents() {
if agent.Checks != nil {
for _, c := range agent.Checks() {
Expand Down Expand Up @@ -929,7 +932,7 @@ func buildDoctorBreadcrumbs(checks []Check) []output.Breadcrumb {
var breadcrumbs []output.Breadcrumb

for _, c := range checks {
if c.Status != "fail" {
if c.Status != "fail" && c.Status != "warn" {
continue
}

Expand All @@ -952,6 +955,12 @@ func buildDoctorBreadcrumbs(checks []Check) []output.Breadcrumb {
Cmd: "basecamp config show",
Description: "Review configuration",
})
case "Skill Version":
Comment thread
jeremy marked this conversation as resolved.
breadcrumbs = append(breadcrumbs, output.Breadcrumb{
Action: "install",
Cmd: "basecamp skill install",
Description: "Update installed skill",
})
Comment thread
jeremy marked this conversation as resolved.
}
}

Expand Down Expand Up @@ -1042,6 +1051,38 @@ func renderDoctorStyled(w io.Writer, result *DoctorResult) {
fmt.Fprintln(w)
}

// checkSkillVersion reports whether the installed skill matches the current CLI version.
func checkSkillVersion() Check {
check := Check{
Name: "Skill Version",
}
Comment thread
jeremy marked this conversation as resolved.

installed := installedSkillVersion()

if installed == "" {
check.Status = "pass"
check.Message = "Installed (version not tracked)"
return check
}

if version.IsDev() {
check.Status = "pass"
check.Message = fmt.Sprintf("Installed (%s, dev build)", installed)
return check
}

if installed == version.Version {
check.Status = "pass"
check.Message = fmt.Sprintf("Up to date (%s)", installed)
return check
}

check.Status = "warn"
check.Message = fmt.Sprintf("Stale (installed: %s, current: %s)", installed, version.Version)
check.Hint = "Run: basecamp skill install"
return check
}

// 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
82 changes: 82 additions & 0 deletions internal/commands/doctor_test.go
Original file line number Diff line number Diff line change
Expand Up @@ -565,6 +565,88 @@ func TestCheckClaudeIntegrationIncludesSkillLink(t *testing.T) {
}
}

func TestCheckSkillVersion_UpToDate(t *testing.T) {
home := t.TempDir()
t.Setenv("HOME", home)

dir := filepath.Join(home, ".agents", "skills", "basecamp")
require.NoError(t, os.MkdirAll(dir, 0o755))
require.NoError(t, os.WriteFile(filepath.Join(dir, "SKILL.md"), []byte("skill"), 0o644))
require.NoError(t, os.WriteFile(filepath.Join(dir, ".installed-version"), []byte(version.Version), 0o644))

check := checkSkillVersion()
assert.Equal(t, "pass", check.Status)
if version.IsDev() {
assert.Contains(t, check.Message, "dev build")
} else {
assert.Contains(t, check.Message, "Up to date")
}
}

func TestCheckSkillVersion_Stale(t *testing.T) {
home := t.TempDir()
t.Setenv("HOME", home)

origVersion := version.Version
version.Version = "2.0.0"
defer func() { version.Version = origVersion }()

dir := filepath.Join(home, ".agents", "skills", "basecamp")
require.NoError(t, os.MkdirAll(dir, 0o755))
require.NoError(t, os.WriteFile(filepath.Join(dir, "SKILL.md"), []byte("skill"), 0o644))
require.NoError(t, os.WriteFile(filepath.Join(dir, ".installed-version"), []byte("1.0.0"), 0o644))

check := checkSkillVersion()
assert.Equal(t, "warn", check.Status)
assert.Contains(t, check.Message, "Stale")
assert.Contains(t, check.Message, "1.0.0")
assert.Contains(t, check.Message, "2.0.0")
assert.Contains(t, check.Hint, "basecamp skill install")
}

func TestCheckSkillVersion_Untracked(t *testing.T) {
home := t.TempDir()
t.Setenv("HOME", home)

dir := filepath.Join(home, ".agents", "skills", "basecamp")
require.NoError(t, os.MkdirAll(dir, 0o755))
require.NoError(t, os.WriteFile(filepath.Join(dir, "SKILL.md"), []byte("skill"), 0o644))
// No .installed-version file

check := checkSkillVersion()
assert.Equal(t, "pass", check.Status)
assert.Contains(t, check.Message, "version not tracked")
}

func TestCheckSkillVersion_DevBuild(t *testing.T) {
home := t.TempDir()
t.Setenv("HOME", home)

origVersion := version.Version
version.Version = "dev"
defer func() { version.Version = origVersion }()

dir := filepath.Join(home, ".agents", "skills", "basecamp")
require.NoError(t, os.MkdirAll(dir, 0o755))
require.NoError(t, os.WriteFile(filepath.Join(dir, "SKILL.md"), []byte("skill"), 0o644))
require.NoError(t, os.WriteFile(filepath.Join(dir, ".installed-version"), []byte("1.0.0"), 0o644))

check := checkSkillVersion()
assert.Equal(t, "pass", check.Status)
assert.Contains(t, check.Message, "dev build")
assert.Contains(t, check.Message, "1.0.0")
}

func TestBuildDoctorBreadcrumbs_SkillVersionWarn(t *testing.T) {
checks := []Check{
{Name: "Skill Version", Status: "warn"},
}

breadcrumbs := buildDoctorBreadcrumbs(checks)
require.Len(t, breadcrumbs, 1)
assert.Equal(t, "basecamp skill install", breadcrumbs[0].Cmd)
}

func TestCheckLegacyInstall_SkipsKeyringWhenNoKeyring(t *testing.T) {
t.Setenv("BASECAMP_NO_KEYRING", "1")
t.Setenv("XDG_CACHE_HOME", t.TempDir())
Expand Down
55 changes: 55 additions & 0 deletions internal/commands/skill.go
Original file line number Diff line number Diff line change
Expand Up @@ -9,13 +9,16 @@ import (
"github.com/spf13/cobra"

"github.com/basecamp/basecamp-cli/internal/appctx"
"github.com/basecamp/basecamp-cli/internal/config"
"github.com/basecamp/basecamp-cli/internal/harness"
"github.com/basecamp/basecamp-cli/internal/output"
"github.com/basecamp/basecamp-cli/internal/tui"
"github.com/basecamp/basecamp-cli/internal/version"
"github.com/basecamp/basecamp-cli/skills"
)

const skillFilename = "SKILL.md"
const installedVersionFile = ".installed-version"

// skillLocation represents a predefined skill installation target.
type skillLocation struct {
Expand Down Expand Up @@ -125,6 +128,9 @@ func installSkillFiles() (string, error) {
return "", fmt.Errorf("writing skill file: %w", err)
}

// Best-effort: stamp installed version
_ = os.WriteFile(filepath.Join(skillDir, installedVersionFile), []byte(version.Version), 0o644) //nolint:gosec // G306: not a secret

Comment thread
jeremy marked this conversation as resolved.
return skillFile, nil
}

Expand Down Expand Up @@ -208,6 +214,8 @@ func runSkillWizard(cmd *cobra.Command, app *appctx.App) error {
result["notice"] = fmt.Sprintf("could not write to %s: %v", canonicalFile, wErr)
}
}
// Best-effort: stamp installed version in canonical location
_ = os.WriteFile(filepath.Join(canonicalDir, installedVersionFile), []byte(version.Version), 0o644) //nolint:gosec // G306: not a secret
}

return app.OK(result,
Expand Down Expand Up @@ -293,6 +301,53 @@ func linkSkillToClaude() (string, string, error) {
return symlinkPath, notice, nil
}

// installedSkillVersion reads the .installed-version file from the baseline
// skill directory. Returns "" if absent or unreadable.
func installedSkillVersion() string {
home, err := os.UserHomeDir()
if err != nil {
return ""
}
data, err := os.ReadFile(filepath.Join(home, ".agents", "skills", "basecamp", installedVersionFile))
if err != nil {
return ""
}
return strings.TrimSpace(string(data))
}

// RefreshSkillsIfVersionChanged checks the CLI version sentinel and silently
// refreshes installed skills when the version has changed. Returns true if
// skills were refreshed.
func RefreshSkillsIfVersionChanged() bool {
if version.Version == "dev" {
Comment thread
jeremy marked this conversation as resolved.
return false
}

sentinelPath := filepath.Join(config.GlobalConfigDir(), ".last-run-version")

data, err := os.ReadFile(sentinelPath)
if err == nil && strings.TrimSpace(string(data)) == version.Version {
return false
}

refreshed := false
needsRefresh := baselineSkillInstalled()
if needsRefresh {
if _, err := installSkillFiles(); err == nil {
refreshed = true
}
}

// Update sentinel only when no refresh was needed or it succeeded.
// On transient failure, leave the sentinel stale so the next run retries.
if !needsRefresh || refreshed {
_ = os.MkdirAll(filepath.Dir(sentinelPath), 0o755) //nolint:gosec // G301: config dir
_ = os.WriteFile(sentinelPath, []byte(version.Version), 0o644) //nolint:gosec // G306: not a secret
}

return refreshed
}

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