Skip to content
103 changes: 70 additions & 33 deletions internal/cli/init.go
Original file line number Diff line number Diff line change
Expand Up @@ -11,6 +11,7 @@ import (
)

var initTemplate string
var initForce bool

var initCmd = &cobra.Command{
Use: "init",
Expand All @@ -26,6 +27,7 @@ This command sets up:

func init() {
initCmd.Flags().StringVarP(&initTemplate, "template", "t", "default", "template name to create")
initCmd.Flags().BoolVarP(&initForce, "force", "f", false, "overwrite existing template")
rootCmd.AddCommand(initCmd)
}

Expand All @@ -36,54 +38,89 @@ func runInit(cmd *cobra.Command, args []string) error {
}

wispDir := filepath.Join(cwd, ".wisp")
templateDir := filepath.Join(wispDir, "templates", initTemplate)

// Check if .wisp already exists
if _, err := os.Stat(wispDir); err == nil {
return fmt.Errorf(".wisp directory already exists")
}
wispExists := dirExists(wispDir)
templateExists := dirExists(templateDir)

// Handle three cases:
// 1. .wisp/ doesn't exist -> full initialization
// 2. .wisp/ exists, template doesn't exist -> create only template
// 3. .wisp/ exists, template exists -> error unless --force

// Create directory structure
dirs := []string{
wispDir,
filepath.Join(wispDir, "sessions"),
filepath.Join(wispDir, "templates", initTemplate),
if wispExists && templateExists && !initForce {
return fmt.Errorf("template %q already exists (use --force to overwrite)", initTemplate)
}

for _, dir := range dirs {
if err := os.MkdirAll(dir, 0755); err != nil {
return fmt.Errorf("failed to create directory %s: %w", dir, err)
if !wispExists {
// Full initialization
dirs := []string{
wispDir,
filepath.Join(wispDir, "sessions"),
templateDir,
}
}

// Write config.yaml
if err := writeConfigYAML(wispDir); err != nil {
return err
}
for _, dir := range dirs {
if err := os.MkdirAll(dir, 0755); err != nil {
return fmt.Errorf("failed to create directory %s: %w", dir, err)
}
}

// Write settings.json
if err := writeSettingsJSON(wispDir); err != nil {
return err
}
// Write config.yaml
if err := writeConfigYAML(wispDir); err != nil {
return err
}

// Write .sprite.env placeholder
if err := writeSpriteEnv(wispDir); err != nil {
return err
}
// Write settings.json
if err := writeSettingsJSON(wispDir); err != nil {
return err
}

// Write .gitignore
if err := writeGitignore(wispDir); err != nil {
return err
}
// Write .sprite.env placeholder
if err := writeSpriteEnv(wispDir); err != nil {
return err
}

// Write .gitignore
if err := writeGitignore(wispDir); err != nil {
return err
}

// Write template files
if err := writeTemplateFiles(wispDir, initTemplate); err != nil {
return err
}

fmt.Printf("Initialized .wisp/ directory with %q template\n", initTemplate)
} else {
// Template-only creation (wisp already initialized)
if err := os.MkdirAll(templateDir, 0755); err != nil {
return fmt.Errorf("failed to create template directory %s: %w", templateDir, err)
}

if err := writeTemplateFiles(wispDir, initTemplate); err != nil {
return err
}

// Write template files
if err := writeTemplateFiles(wispDir, initTemplate); err != nil {
return err
if templateExists && initForce {
fmt.Printf("Overwrote template %q\n", initTemplate)
} else {
fmt.Printf("Created template %q\n", initTemplate)
}
}

fmt.Printf("Initialized .wisp/ directory with %q template\n", initTemplate)
return nil
}

// dirExists checks if a directory exists
func dirExists(path string) bool {
info, err := os.Stat(path)
if err != nil {
return false
}
return info.IsDir()
}

func writeConfigYAML(wispDir string) error {
content := `# Wisp configuration
# See https://github.com/thruflo/wisp for documentation
Expand Down
156 changes: 149 additions & 7 deletions internal/cli/init_test.go
Original file line number Diff line number Diff line change
Expand Up @@ -21,8 +21,9 @@ func TestInitCommand(t *testing.T) {
err = os.Chdir(tmpDir)
require.NoError(t, err)

// Reset template flag to default
// Reset flags to default
initTemplate = "default"
initForce = false

// Run init command
err = runInit(initCmd, []string{})
Expand Down Expand Up @@ -129,8 +130,9 @@ func TestInitCommandWithCustomTemplate(t *testing.T) {
err = os.Chdir(tmpDir)
require.NoError(t, err)

// Set custom template name
// Set custom template name and reset force flag
initTemplate = "custom"
initForce = false

err = runInit(initCmd, []string{})
require.NoError(t, err)
Expand All @@ -141,7 +143,7 @@ func TestInitCommandWithCustomTemplate(t *testing.T) {
assertFileExists(t, filepath.Join(templateDir, "context.md"))
}

func TestInitCommandFailsIfExists(t *testing.T) {
func TestInitCommandFailsIfTemplateExists(t *testing.T) {
tmpDir := t.TempDir()
originalDir, err := os.Getwd()
require.NoError(t, err)
Expand All @@ -150,17 +152,21 @@ func TestInitCommandFailsIfExists(t *testing.T) {
err = os.Chdir(tmpDir)
require.NoError(t, err)

// Create .wisp directory first
err = os.Mkdir(filepath.Join(tmpDir, ".wisp"), 0755)
// Create .wisp directory and template directory first
wispDir := filepath.Join(tmpDir, ".wisp")
templateDir := filepath.Join(wispDir, "templates", "default")
err = os.MkdirAll(templateDir, 0755)
require.NoError(t, err)

// Reset template flag
// Reset flags
initTemplate = "default"
initForce = false

// Init should fail
// Init should fail when template already exists
err = runInit(initCmd, []string{})
assert.Error(t, err)
assert.Contains(t, err.Error(), "already exists")
assert.Contains(t, err.Error(), "--force")
}

func assertDirExists(t *testing.T, path string) {
Expand All @@ -176,3 +182,139 @@ func assertFileExists(t *testing.T, path string) {
require.NoError(t, err, "file should exist: %s", path)
assert.False(t, info.IsDir(), "should be a file: %s", path)
}

func TestInitTemplateOnlyCreation(t *testing.T) {
tmpDir := t.TempDir()
originalDir, err := os.Getwd()
require.NoError(t, err)
defer os.Chdir(originalDir)

err = os.Chdir(tmpDir)
require.NoError(t, err)

// First, do a full init with default template
initTemplate = "default"
initForce = false
err = runInit(initCmd, []string{})
require.NoError(t, err)

wispDir := filepath.Join(tmpDir, ".wisp")

// Record config.yaml modification time to verify it's not rewritten
configPath := filepath.Join(wispDir, "config.yaml")
configInfo, err := os.Stat(configPath)
require.NoError(t, err)
configModTime := configInfo.ModTime()

// Now init with a new template name (should create only the template)
initTemplate = "website"
initForce = false
err = runInit(initCmd, []string{})
require.NoError(t, err)

// Verify new template directory was created
newTemplateDir := filepath.Join(wispDir, "templates", "website")
assertDirExists(t, newTemplateDir)
assertFileExists(t, filepath.Join(newTemplateDir, "context.md"))

// Verify config.yaml was NOT rewritten (same modification time)
configInfoAfter, err := os.Stat(configPath)
require.NoError(t, err)
assert.Equal(t, configModTime, configInfoAfter.ModTime(), "config.yaml should not be modified during template-only creation")

// Verify original template still exists
assertDirExists(t, filepath.Join(wispDir, "templates", "default"))
}

func TestInitForceOverwritesTemplate(t *testing.T) {
tmpDir := t.TempDir()
originalDir, err := os.Getwd()
require.NoError(t, err)
defer os.Chdir(originalDir)

err = os.Chdir(tmpDir)
require.NoError(t, err)

// First, do a full init with default template
initTemplate = "default"
initForce = false
err = runInit(initCmd, []string{})
require.NoError(t, err)

wispDir := filepath.Join(tmpDir, ".wisp")
templateDir := filepath.Join(wispDir, "templates", "default")

// Modify an existing template file to verify it gets overwritten
contextPath := filepath.Join(templateDir, "context.md")
originalContent, err := os.ReadFile(contextPath)
require.NoError(t, err)
err = os.WriteFile(contextPath, []byte("modified content"), 0644)
require.NoError(t, err)

// Try to init with default template again without --force (should fail)
initTemplate = "default"
initForce = false
err = runInit(initCmd, []string{})
assert.Error(t, err)
assert.Contains(t, err.Error(), "already exists")

// Verify content was NOT overwritten
content, err := os.ReadFile(contextPath)
require.NoError(t, err)
assert.Equal(t, "modified content", string(content))

// Now init with --force (should succeed and overwrite)
initTemplate = "default"
initForce = true
err = runInit(initCmd, []string{})
require.NoError(t, err)

// Verify content WAS overwritten with original template content
content, err = os.ReadFile(contextPath)
require.NoError(t, err)
assert.Equal(t, string(originalContent), string(content))
}

func TestInitTemplateOnlyHasCorrectContent(t *testing.T) {
tmpDir := t.TempDir()
originalDir, err := os.Getwd()
require.NoError(t, err)
defer os.Chdir(originalDir)

err = os.Chdir(tmpDir)
require.NoError(t, err)

// Create minimal .wisp structure without templates
wispDir := filepath.Join(tmpDir, ".wisp")
err = os.MkdirAll(wispDir, 0755)
require.NoError(t, err)

// Init with a template (template-only creation since .wisp exists)
initTemplate = "backend"
initForce = false
err = runInit(initCmd, []string{})
require.NoError(t, err)

// Verify all expected template files exist with proper content
templateDir := filepath.Join(wispDir, "templates", "backend")
expectedTemplates := []string{
"context.md",
"create-tasks.md",
"update-tasks.md",
"review-tasks.md",
"iterate.md",
"generate-pr.md",
}

for _, tmpl := range expectedTemplates {
path := filepath.Join(templateDir, tmpl)
assertFileExists(t, path)

content, err := os.ReadFile(path)
require.NoError(t, err)
assert.NotEmpty(t, content, "template %s should have content", tmpl)

// Verify content is meaningful (not just empty or placeholder)
assert.True(t, len(content) > 50, "template %s should have substantial content", tmpl)
}
}
18 changes: 11 additions & 7 deletions internal/cli/resume.go
Original file line number Diff line number Diff line change
Expand Up @@ -273,7 +273,7 @@ func setupSpriteForResume(

// Clone primary repo (token embedded in URL for auth)
fmt.Printf("Cloning %s...\n", session.Repo)
if err := CloneRepo(ctx, client, session.SpriteName, session.Repo, repoPath, githubToken); err != nil {
if err := CloneRepo(ctx, client, session.SpriteName, session.Repo, repoPath, githubToken, ""); err != nil {
return "", fmt.Errorf("failed to clone repo: %w", err)
}

Expand All @@ -289,18 +289,22 @@ func setupSpriteForResume(
return "", fmt.Errorf("failed to copy spec file: %w", err)
}

// Clone sibling repos
// Clone sibling repos (with optional ref checkout)
for _, sibling := range session.Siblings {
siblingParts := strings.Split(sibling, "/")
siblingParts := strings.Split(sibling.Repo, "/")
if len(siblingParts) != 2 {
return "", fmt.Errorf("invalid sibling repo format %q, expected org/repo", sibling)
return "", fmt.Errorf("invalid sibling repo format %q, expected org/repo", sibling.Repo)
}
siblingOrg, siblingRepo := siblingParts[0], siblingParts[1]
siblingPath := filepath.Join(sprite.ReposDir, siblingOrg, siblingRepo)

fmt.Printf("Cloning sibling %s...\n", sibling)
if err := CloneRepo(ctx, client, session.SpriteName, sibling, siblingPath, githubToken); err != nil {
return "", fmt.Errorf("failed to clone sibling %s: %w", sibling, err)
if sibling.Ref != "" {
fmt.Printf("Cloning sibling %s@%s...\n", sibling.Repo, sibling.Ref)
} else {
fmt.Printf("Cloning sibling %s...\n", sibling.Repo)
}
if err := CloneRepo(ctx, client, session.SpriteName, sibling.Repo, siblingPath, githubToken, sibling.Ref); err != nil {
return "", fmt.Errorf("failed to clone sibling %s: %w", sibling.Repo, err)
}
}

Expand Down
4 changes: 2 additions & 2 deletions internal/cli/resume_test.go
Original file line number Diff line number Diff line change
Expand Up @@ -40,7 +40,7 @@ func TestResumeCommand_SessionExists(t *testing.T) {
session := &config.Session{
Repo: "test-org/test-repo",
Spec: "docs/rfc.md",
Siblings: []string{"test-org/sibling-repo"},
Siblings: []config.SiblingRepo{{Repo: "test-org/sibling-repo"}},
Checkpoint: "checkpoint-123",
Branch: "wisp/test-feature",
SpriteName: "wisp-abc123",
Expand All @@ -60,7 +60,7 @@ func TestResumeCommand_SessionExists(t *testing.T) {
assert.Equal(t, session.SpriteName, loaded.SpriteName)
assert.Equal(t, session.Checkpoint, loaded.Checkpoint)
assert.Len(t, loaded.Siblings, 1)
assert.Equal(t, "test-org/sibling-repo", loaded.Siblings[0])
assert.Equal(t, "test-org/sibling-repo", loaded.Siblings[0].Repo)
}

func TestResumeCommand_SessionStatusUpdate(t *testing.T) {
Expand Down
Loading