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
38 changes: 19 additions & 19 deletions .claude/rules/package-organization.md
Original file line number Diff line number Diff line change
Expand Up @@ -2,35 +2,35 @@

## Principles

- Organize by **domain** (config, state, git, registry) not by layer (handlers, services)
- Organize by **domain** (config, registry, git, userconfig) not by layer (handlers, services)
- Each package has a **single clear responsibility**
- Package names: short, lowercase, noun describing what it contains
- No circular dependencies — extract shared types if needed

## Package Responsibilities

| Package | Responsibility |
|--------------|--------------------------------------------------------------------|
| `config/` | Load `fr8.json` / `conductor.json` with fallback chain |
| `state/` | Workspace state CRUD in `.git/fr8.json` with file locking |
| `git/` | All git operations via `os/exec` — no go-git |
| `port/` | Sequential port block allocation |
| `names/` | Adjective-city name generation |
| `filesync/` | `.worktreeinclude` glob matching and file copy |
| `env/` | Build `FR8_*` and `CONDUCTOR_*` environment variables |
| `workspace/` | Workspace resolution: CWD -> registry -> explicit |
| `registry/` | Global repo registry CRUD with file locking |
| `opener/` | Workspace opener config management |
| `tmux/` | Thin wrapper around tmux CLI for background sessions |
| `tui/` | Bubble Tea dashboard TUI |
| `exitcode/` | Exit code constants, `ExitError` type, error classification |
| `jsonout/` | JSON output mode — `Write()`, `WriteError()`, `Conciser` interface |
| `mcp/` | MCP server for tool integrations |
| Package | Responsibility |
|---------------|--------------------------------------------------------------------------|
| `config/` | Load `fr8.json` / `conductor.json` with fallback chain |
| `git/` | All git operations via `os/exec` — no go-git |
| `port/` | Sequential port block allocation |
| `names/` | Adjective-city name generation |
| `filesync/` | `.worktreeinclude` glob matching and file copy |
| `env/` | Build `FR8_*` and `CONDUCTOR_*` environment variables |
| `workspace/` | Workspace resolution: CWD -> registry -> explicit |
| `registry/` | Unified repo + workspace state CRUD (`~/.local/state/fr8/repos.json`) |
| `userconfig/` | User preferences: openers, future settings (`~/.config/fr8/config.json`) |
| `opener/` | Opener execution (`Run()` only) |
| `tmux/` | Thin wrapper around tmux CLI for background sessions |
| `tui/` | Bubble Tea dashboard TUI |
| `exitcode/` | Exit code constants, `ExitError` type, error classification |
| `jsonout/` | JSON output mode — `Write()`, `WriteError()`, `Conciser` interface |
| `mcp/` | MCP server for tool integrations |

## Key Patterns

- **Git operations**: always go through `internal/git/` — never shell out directly from `cmd/`
- **State/registry CRUD**: go through their respective packages with file locking
- **Registry/userconfig CRUD**: go through their respective packages with file locking
- **File locking**: `.lock` file + `syscall.Flock(LOCK_EX)` + defer unlock + defer remove
- **Config fallback**: `fr8.json` -> `conductor.json` -> defaults
- **Env vars**: `internal/env/` sets both `FR8_*` and `CONDUCTOR_*` (backwards compat)
Expand Down
10 changes: 6 additions & 4 deletions CLAUDE.md
Original file line number Diff line number Diff line change
Expand Up @@ -40,15 +40,15 @@ cmd/ # Cobra command definitions (one file per comma
resolve.go # Shared resolveWorkspace() helper (local → global fallback)
internal/
config/config.go # Load fr8.json / conductor.json
state/state.go # Workspace state CRUD (.git/fr8.json)
git/git.go # Shell out to git (worktree, branch, status)
port/port.go # Sequential port block allocation
names/{names,words}.go # Adjective-city name generation
filesync/filesync.go # .worktreeinclude glob + copy
env/env.go # Build FR8_* and CONDUCTOR_* env vars
workspace/resolve.go # Resolve workspace by name, CWD, or global registry
registry/registry.go # Global repo registry (~/.config/fr8/repos.json)
opener/opener.go # Workspace opener config (~/.config/fr8/openers.json)
registry/registry.go # Unified repo + workspace state (~/.local/state/fr8/repos.json)
userconfig/userconfig.go # User preferences: openers (~/.config/fr8/config.json)
opener/opener.go # Opener execution (Run only)
tmux/tmux.go # Thin wrapper around tmux CLI for background sessions
tui/ # Bubble Tea dashboard TUI
create_workspace.go # TUI view for creating workspaces
Expand Down Expand Up @@ -79,4 +79,6 @@ Key architectural notes:

- `createWorkspace()` in `cmd/new.go` is the shared creation function used by both CLI and TUI dashboard
- Background process management uses tmux sessions named `fr8/<repo>/<workspace>`; graceful degradation when tmux is not installed
- Workspace openers are stored at `~/.config/fr8/openers.json`; TUI picker shown when multiple workspaces are configured
- State storage: `~/.local/state/fr8/repos.json` (unified repo + workspace registry); `~/.config/fr8/config.json` (user preferences like openers)
- Env var overrides: `FR8_STATE_DIR` for state, `FR8_CONFIG_DIR` for config
- Auto-migration from old format (`~/.config/fr8/repos.json` + `.git/fr8.json`) runs on first command
26 changes: 16 additions & 10 deletions cmd/archive.go
Original file line number Diff line number Diff line change
Expand Up @@ -10,7 +10,7 @@ import (
"github.com/protocollar/fr8/internal/exitcode"
"github.com/protocollar/fr8/internal/git"
"github.com/protocollar/fr8/internal/jsonout"
"github.com/protocollar/fr8/internal/state"
"github.com/protocollar/fr8/internal/registry"
"github.com/protocollar/fr8/internal/tmux"
)

Expand Down Expand Up @@ -43,7 +43,7 @@ func runArchive(cmd *cobra.Command, args []string) error {
name = args[0]
}

ws, rootPath, commonDir, err := resolveWorkspace(name)
ws, rootPath, err := resolveWorkspace(name)
if err != nil {
if archiveIfExists {
if jsonout.Enabled {
Expand All @@ -61,11 +61,6 @@ func runArchive(cmd *cobra.Command, args []string) error {
return fmt.Errorf("loading config: %w", err)
}

st, err := state.Load(commonDir)
if err != nil {
return fmt.Errorf("loading state: %w", err)
}

// Dry run: just report what would happen
if archiveDryRun {
dirty, _ := git.HasUncommittedChanges(ws.Path)
Expand Down Expand Up @@ -170,9 +165,20 @@ func runArchive(cmd *cobra.Command, args []string) error {
}

// Update state
_ = st.Remove(ws.Name)
if err := st.Save(commonDir); err != nil {
return fmt.Errorf("saving state: %w", err)
regPath, err := registry.DefaultPath()
if err != nil {
return fmt.Errorf("finding state path: %w", err)
}
reg, err := registry.Load(regPath)
if err != nil {
return fmt.Errorf("loading registry: %w", err)
}
repo := reg.FindByPath(rootPath)
if repo != nil {
_ = repo.RemoveWorkspace(ws.Name)
if err := reg.Save(regPath); err != nil {
return fmt.Errorf("saving state: %w", err)
}
}

if jsonout.Enabled {
Expand Down
2 changes: 1 addition & 1 deletion cmd/attach.go
Original file line number Diff line number Diff line change
Expand Up @@ -40,7 +40,7 @@ func runAttach(cmd *cobra.Command, args []string) error {
name = args[0]
}

ws, rootPath, _, err := resolveWorkspace(name)
ws, rootPath, err := resolveWorkspace(name)
if err != nil {
return err
}
Expand Down
6 changes: 3 additions & 3 deletions cmd/browser.go
Original file line number Diff line number Diff line change
Expand Up @@ -6,7 +6,7 @@ import (
"github.com/spf13/cobra"
"github.com/protocollar/fr8/internal/jsonout"
"github.com/protocollar/fr8/internal/port"
"github.com/protocollar/fr8/internal/state"
"github.com/protocollar/fr8/internal/registry"
)

func init() {
Expand All @@ -27,15 +27,15 @@ func runBrowser(cmd *cobra.Command, args []string) error {
name = args[0]
}

ws, _, _, err := resolveWorkspace(name)
ws, _, err := resolveWorkspace(name)
if err != nil {
return err
}

return openWorkspaceBrowser(ws)
}

func openWorkspaceBrowser(ws *state.Workspace) error {
func openWorkspaceBrowser(ws *registry.Workspace) error {
listening := !port.IsFree(ws.Port)
url := fmt.Sprintf("http://localhost:%d", ws.Port)

Expand Down
2 changes: 1 addition & 1 deletion cmd/cd.go
Original file line number Diff line number Diff line change
Expand Up @@ -30,7 +30,7 @@ func runCd(cmd *cobra.Command, args []string) error {
name = args[0]
}

ws, _, _, err := resolveWorkspace(name)
ws, _, err := resolveWorkspace(name)
if err != nil {
return err
}
Expand Down
49 changes: 16 additions & 33 deletions cmd/completion.go
Original file line number Diff line number Diff line change
Expand Up @@ -6,7 +6,6 @@ import (
"github.com/spf13/cobra"
"github.com/protocollar/fr8/internal/git"
"github.com/protocollar/fr8/internal/registry"
"github.com/protocollar/fr8/internal/state"
)

func init() {
Expand Down Expand Up @@ -53,45 +52,29 @@ func workspaceNameCompletion(cmd *cobra.Command, args []string, toComplete strin
return nil, cobra.ShellCompDirectiveNoFileComp
}

commonDir, err := git.CommonDir(cwd)
if err != nil {
// Not inside a git repo — search all registered repos
return allRegistryWorkspaceNames(), cobra.ShellCompDirectiveNoFileComp
}

st, err := state.Load(commonDir)
if err != nil {
return nil, cobra.ShellCompDirectiveNoFileComp
}

return st.Names(), cobra.ShellCompDirectiveNoFileComp
}

// allRegistryWorkspaceNames returns workspace names from all registered repos.
func allRegistryWorkspaceNames() []string {
regPath, err := registry.DefaultPath()
if err != nil {
return nil
return nil, cobra.ShellCompDirectiveNoFileComp
}

reg, err := registry.Load(regPath)
if err != nil {
return nil
return nil, cobra.ShellCompDirectiveNoFileComp
}

var names []string
for _, repo := range reg.Repos {
commonDir, err := git.CommonDir(repo.Path)
if err != nil {
continue
}

st, err := state.Load(commonDir)
if err != nil {
continue
// Try CWD match
repo := reg.FindRepoByWorkspacePath(cwd)
if repo == nil {
if git.IsInsideWorkTree(cwd) {
rootPath, err := git.RootWorktreePath(cwd)
if err == nil {
repo = reg.FindByPath(rootPath)
}
}

names = append(names, st.Names()...)
}
return names
if repo != nil {
return repo.WorkspaceNames(), cobra.ShellCompDirectiveNoFileComp
}

// Fall back to all registered workspace names
return reg.AllWorkspaceNames(), cobra.ShellCompDirectiveNoFileComp
}
9 changes: 3 additions & 6 deletions cmd/config.go
Original file line number Diff line number Diff line change
Expand Up @@ -13,6 +13,7 @@ import (
"github.com/protocollar/fr8/internal/exitcode"
"github.com/protocollar/fr8/internal/git"
"github.com/protocollar/fr8/internal/jsonout"
"github.com/protocollar/fr8/internal/registry"
)

var doctorFix bool
Expand All @@ -23,7 +24,7 @@ func init() {
configCmd.AddCommand(configDoctorCmd)
configCmd.AddCommand(configValidateCmd) // alias
configCmd.AddCommand(configOpenCmd)
rootCmd.AddCommand(configCmd)
rootCmd.AddCommand(configCmd)
}

var configCmd = &cobra.Command{
Expand Down Expand Up @@ -62,11 +63,7 @@ var configOpenCmd = &cobra.Command{

// configDir returns the fr8 global config directory (~/.config/fr8).
func configDir() (string, error) {
home, err := os.UserHomeDir()
if err != nil {
return "", fmt.Errorf("finding home directory: %w", err)
}
return filepath.Join(home, ".config", "fr8"), nil
return registry.ConfigDir()
}

func runConfigOpen(cmd *cobra.Command, args []string) error {
Expand Down
10 changes: 3 additions & 7 deletions cmd/dashboard.go
Original file line number Diff line number Diff line change
Expand Up @@ -87,15 +87,11 @@ func runDashboard(cmd *cobra.Command, args []string) error {

if result.OpenWorkspace != nil {
ws := result.OpenWorkspace
openerPath, err := opener.DefaultPath()
cfg, _, err := loadUserConfig()
if err != nil {
return err
}
openers, err := opener.Load(openerPath)
if err != nil {
return fmt.Errorf("loading openers: %w", err)
}
o := opener.Find(openers, result.OpenerName)
o := cfg.FindOpener(result.OpenerName)
if o == nil {
return fmt.Errorf("opener %q not found", result.OpenerName)
}
Expand All @@ -108,7 +104,7 @@ func runDashboard(cmd *cobra.Command, args []string) error {
}

if result.CreateRequested {
ws, err := createWorkspace(result.RootPath, result.CommonDir, result.CreateName, "", false, true, false)
ws, err := createWorkspace(result.RootPath, result.CreateName, "", false, true, false)
if err != nil {
fmt.Fprintf(os.Stderr, "Error creating workspace: %v\n", err)
} else {
Expand Down
2 changes: 1 addition & 1 deletion cmd/env.go
Original file line number Diff line number Diff line change
Expand Up @@ -29,7 +29,7 @@ func runEnv(cmd *cobra.Command, args []string) error {
name = args[0]
}

ws, rootPath, _, err := resolveWorkspace(name)
ws, rootPath, err := resolveWorkspace(name)
if err != nil {
return err
}
Expand Down
2 changes: 1 addition & 1 deletion cmd/exec.go
Original file line number Diff line number Diff line change
Expand Up @@ -64,7 +64,7 @@ func runExec(cmd *cobra.Command, args []string) error {
return fmt.Errorf("no command specified after --")
}

ws, rootPath, _, err := resolveWorkspace(wsName)
ws, rootPath, err := resolveWorkspace(wsName)
if err != nil {
return err
}
Expand Down
Loading