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
1 change: 1 addition & 0 deletions cmd/bootstrap.go
Original file line number Diff line number Diff line change
Expand Up @@ -195,6 +195,7 @@ func runBootstrap(cmd *cobra.Command, args []string) error {

// Initialize Service
svc := bootstrap.NewService(cwd, llmCfg)
svc.SetVersion(version)

// Prompt for repo selection in multi-repo workspaces.
// This must happen before the action loop because ActionInitProject may not
Expand Down
36 changes: 35 additions & 1 deletion cmd/root.go
Original file line number Diff line number Diff line change
Expand Up @@ -12,6 +12,7 @@ import (

"github.com/josephgoksu/TaskWing/internal/config"
"github.com/josephgoksu/TaskWing/internal/logger"
"github.com/josephgoksu/TaskWing/internal/migration"
"github.com/josephgoksu/TaskWing/internal/telemetry"
"github.com/josephgoksu/TaskWing/internal/ui"
"github.com/spf13/cobra"
Expand Down Expand Up @@ -55,7 +56,13 @@ var rootCmd = &cobra.Command{

Create a plan, execute tasks with your AI tool, and keep architecture context
persistent across sessions.`,
PersistentPreRunE: initTelemetry,
PersistentPreRunE: func(cmd *cobra.Command, args []string) error {
if err := initTelemetry(cmd, args); err != nil {
return err
}
maybeRunPostUpgradeMigration(cmd)
return nil
},
PersistentPostRunE: closeTelemetry,
Run: func(cmd *cobra.Command, args []string) {
if len(args) == 0 {
Expand Down Expand Up @@ -173,6 +180,33 @@ func GetVersion() string {
return version
}

// maybeRunPostUpgradeMigration runs a one-time migration when the CLI version
// changes (e.g., after brew upgrade). Skips commands that don't need project context.
func maybeRunPostUpgradeMigration(cmd *cobra.Command) {
// Skip the entire mcp subtree (and other commands that don't need migration)
for c := cmd; c != nil; c = c.Parent() {
n := c.Name()
if n == "version" || n == "help" || n == "mcp" {
return
}
}

cwd, err := os.Getwd()
if err != nil {
return
}

warnings, err := migration.CheckAndMigrate(cwd, version)
if err != nil {
// Migration errors are non-fatal
return
}

for _, w := range warnings {
fmt.Fprintf(os.Stderr, "⚠️ %s\n", w)
}
}

// initTelemetry initializes the telemetry client.
// It checks for:
// 1. --no-telemetry flag (disables for this command)
Expand Down
30 changes: 30 additions & 0 deletions internal/bootstrap/initializer.go
Original file line number Diff line number Diff line change
Expand Up @@ -16,6 +16,9 @@ import (
// Initializer handles the setup of TaskWing project structure and integrations.
type Initializer struct {
basePath string
// Version is the CLI version to stamp in .taskwing/version.
// If empty, no version file is written.
Version string
}

func NewInitializer(basePath string) *Initializer {
Expand Down Expand Up @@ -216,6 +219,13 @@ func (i *Initializer) createStructure(verbose bool) error {
fmt.Printf(" ✓ Created %s\n", dir)
}
}

// Track CLI version for post-upgrade migration detection
if i.Version != "" {
versionPath := filepath.Join(i.basePath, ".taskwing", "version")
_ = os.WriteFile(versionPath, []byte(i.Version), 0644)
}

return nil
}

Expand Down Expand Up @@ -248,6 +258,26 @@ var aiHelpers = func() map[string]aiHelperConfig {
return cfg
}()

// AIHelperInfo exposes read-only AI config fields needed by external packages.
type AIHelperInfo struct {
CommandsDir string
SingleFile bool
SingleFileName string
}

// AIHelperByName returns exported config info for the named AI, if it exists.
func AIHelperByName(name string) (AIHelperInfo, bool) {
cfg, ok := aiHelpers[name]
if !ok {
return AIHelperInfo{}, false
}
return AIHelperInfo{
CommandsDir: cfg.commandsDir,
SingleFile: cfg.singleFile,
SingleFileName: cfg.singleFileName,
}, true
}

// TaskWingManagedFile is the marker file name written to directories managed by TaskWing.
// This file indicates that TaskWing created and owns the directory, preventing false positives
// when users have similarly named directories for other purposes.
Expand Down
6 changes: 6 additions & 0 deletions internal/bootstrap/service.go
Original file line number Diff line number Diff line change
Expand Up @@ -40,6 +40,12 @@ func NewService(basePath string, llmCfg llm.Config) *Service {
}
}

// SetVersion sets the CLI version on the underlying initializer so that
// createStructure() stamps .taskwing/version for post-upgrade migration detection.
func (s *Service) SetVersion(v string) {
s.initializer.Version = v
}

// InitializeProject sets up the .taskwing directory structure and integrations.
func (s *Service) InitializeProject(verbose bool, selectedAIs []string) error {
return s.initializer.Run(verbose, selectedAIs)
Expand Down
133 changes: 133 additions & 0 deletions internal/migration/upgrade.go
Original file line number Diff line number Diff line change
@@ -0,0 +1,133 @@
package migration

import (
"encoding/json"
"fmt"
"os"
"path/filepath"
"strings"

"github.com/josephgoksu/TaskWing/internal/bootstrap"
"github.com/josephgoksu/TaskWing/internal/mcpcfg"
)

// CheckAndMigrate runs a post-upgrade migration if the CLI version has changed
// since the last run in this project. It silently regenerates local configs and
// returns warnings for issues that require manual intervention (e.g., global MCP).
//
// This is designed to be called from PersistentPreRunE and must be:
// - Sub-millisecond on the happy path (version matches)
// - Non-fatal on all error paths (never blocks user commands)
func CheckAndMigrate(projectDir, currentVersion string) (warnings []string, err error) {
taskwingDir := filepath.Join(projectDir, ".taskwing")
versionFile := filepath.Join(taskwingDir, "version")

// Not bootstrapped or inaccessible — nothing to migrate
if _, err := os.Stat(taskwingDir); err != nil {
return nil, nil
}

stored, err := os.ReadFile(versionFile)
if err != nil {
// Version file missing (pre-migration bootstrap). Write current and return.
if werr := os.WriteFile(versionFile, []byte(currentVersion), 0644); werr != nil {
fmt.Fprintf(os.Stderr, "⚠️ taskwing: could not write version stamp (%v); migration will re-run next time\n", werr)
}
return nil, nil
}

storedVersion := strings.TrimSpace(string(stored))

// Happy path: version matches — no-op
if storedVersion == currentVersion {
return nil, nil
}

// Skip dev builds to avoid constant re-runs during development
if currentVersion == "dev" {
return nil, nil
}

// --- Version mismatch: run migration ---

// 1. Silent local migration: regenerate slash commands for managed AIs
migrateLocalConfigs(projectDir)

// 2. Global MCP check: warn about legacy server names
warnings = checkGlobalMCPLegacy()

// 3. Write current version
if werr := os.WriteFile(versionFile, []byte(currentVersion), 0644); werr != nil {
fmt.Fprintf(os.Stderr, "⚠️ taskwing: could not write version stamp (%v); migration will re-run next time\n", werr)
}

return warnings, nil
}

// migrateLocalConfigs detects which AIs have managed markers and regenerates
// their slash commands (which internally prunes stale tw-* files).
func migrateLocalConfigs(projectDir string) {
for _, aiName := range bootstrap.ValidAINames() {
cfg, ok := bootstrap.AIHelperByName(aiName)
if !ok {
continue
}

// Check if this AI has a managed marker
if cfg.SingleFile {
// Single-file AIs (e.g., Copilot) embed the marker in file content.
// Check for the embedded marker before regenerating.
filePath := filepath.Join(projectDir, cfg.CommandsDir, cfg.SingleFileName)
content, err := os.ReadFile(filePath)
if err != nil || !strings.Contains(string(content), "<!-- TASKWING_MANAGED -->") {
continue
}
} else {
markerPath := filepath.Join(projectDir, cfg.CommandsDir, bootstrap.TaskWingManagedFile)
if _, err := os.Stat(markerPath); err != nil {
continue
}
}

// Regenerate (this prunes stale files and creates new ones)
initializer := bootstrap.NewInitializer(projectDir)
if err := initializer.CreateSlashCommands(aiName, false); err != nil {
fmt.Fprintf(os.Stderr, "⚠️ taskwing: could not regenerate %s commands: %v\n", aiName, err)
}
}
}

// checkGlobalMCPLegacy reads Claude's global MCP config and warns if legacy
// server names are present.
func checkGlobalMCPLegacy() []string {
home, err := os.UserHomeDir()
if err != nil {
return nil
}
configPath := filepath.Join(home, ".claude", "claude_desktop_config.json")
return checkGlobalMCPLegacyAt(configPath)
}

// checkGlobalMCPLegacyAt checks a specific config file path for legacy server names.
func checkGlobalMCPLegacyAt(configPath string) []string {
content, err := os.ReadFile(configPath)
if err != nil {
return nil
}

var config struct {
MCPServers map[string]json.RawMessage `json:"mcpServers"`
}
if err := json.Unmarshal(content, &config); err != nil {
return nil
}

var warnings []string
for name := range config.MCPServers {
if mcpcfg.IsLegacyServerName(name) {
warnings = append(warnings, fmt.Sprintf("Global MCP config has legacy server name %q. Run: taskwing doctor --fix --yes", name))
}
}

return warnings
}
Loading
Loading