Skip to content
Open
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
49 changes: 45 additions & 4 deletions backend/internal/adapters/agent/claudecode/claudecode.go
Original file line number Diff line number Diff line change
Expand Up @@ -74,13 +74,37 @@ func (p *Plugin) Manifest() adapters.Manifest {
}
}

// GetConfigSpec reports the agent-specific config keys. Claude Code exposes
// none yet.
// permissionConfigEnum lists the permission modes the "permissions" config key
// accepts. It mirrors the ports.PermissionMode constants so a project's stored
// config validates against the same vocabulary the launch command maps.
var permissionConfigEnum = []string{
string(ports.PermissionModeDefault),
string(ports.PermissionModeAcceptEdits),
string(ports.PermissionModeAuto),
string(ports.PermissionModeBypassPermissions),
}

// GetConfigSpec reports the per-project agent config keys Claude Code
// understands: a model override and a starting permission mode.
func (p *Plugin) GetConfigSpec(ctx context.Context) (ports.ConfigSpec, error) {
if err := ctx.Err(); err != nil {
return ports.ConfigSpec{}, err
}
return ports.ConfigSpec{}, nil
return ports.ConfigSpec{
Fields: []ports.ConfigField{
{
Key: "model",
Type: ports.ConfigFieldString,
Description: "Model override passed to `claude --model` (e.g. claude-opus-4-5).",
},
{
Key: "permissions",
Type: ports.ConfigFieldEnum,
Description: "Starting permission mode.",
Enum: permissionConfigEnum,
},
},
}, nil
}

// GetLaunchCommand builds the argv to start an interactive Claude Code
Expand All @@ -103,6 +127,12 @@ func (p *Plugin) GetConfigSpec(ctx context.Context) (ports.ConfigSpec, error) {
// The prompt is passed after `--` so a prompt beginning with "-" is not
// mistaken for a flag.
func (p *Plugin) GetLaunchCommand(ctx context.Context, cfg ports.LaunchConfig) (cmd []string, err error) {
// Defense-in-depth: the project service validates on write, but re-check
// here so a config written by any other path can't launch a bad command.
if err := cfg.Config.Validate(); err != nil {
return nil, fmt.Errorf("claude-code: %w", err)
}

binary, err := p.claudeBinary(ctx)
if err != nil {
return nil, err
Expand All @@ -112,7 +142,18 @@ func (p *Plugin) GetLaunchCommand(ctx context.Context, cfg ports.LaunchConfig) (
if cfg.SessionID != "" {
cmd = append(cmd, "--session-id", claudeSessionUUID(cfg.SessionID))
}
appendPermissionFlags(&cmd, cfg.Permissions)
// A project's configured permissions drive the starting mode; the explicit
// LaunchConfig.Permissions wins when set so a per-spawn override still takes
// precedence over the stored project default.
permissions := cfg.Permissions
if permissions == "" {
permissions = cfg.Config.Permissions
}
appendPermissionFlags(&cmd, permissions)

if model := strings.TrimSpace(cfg.Config.Model); model != "" {
cmd = append(cmd, "--model", model)
}

systemPrompt, err := resolveSystemPrompt(cfg)
if err != nil {
Expand Down
42 changes: 42 additions & 0 deletions backend/internal/adapters/agent/claudecode/claudecode_test.go
Original file line number Diff line number Diff line change
Expand Up @@ -422,6 +422,48 @@ func TestGetRestoreCommandFalseWithoutSessionID(t *testing.T) {
}
}

func TestGetLaunchCommandAppliesAgentConfig(t *testing.T) {
p := &Plugin{resolvedBinary: "claude"}
cmd, err := p.GetLaunchCommand(context.Background(), ports.LaunchConfig{
Config: ports.AgentConfig{
Model: "claude-opus-4-5",
Permissions: ports.PermissionModeAcceptEdits,
},
})
if err != nil {
t.Fatal(err)
}
if !containsSubsequence(cmd, []string{"--model", "claude-opus-4-5"}) {
t.Fatalf("command %#v missing --model flag", cmd)
}
if !containsSubsequence(cmd, []string{"--permission-mode", "acceptEdits"}) {
t.Fatalf("command %#v missing config-driven permission mode", cmd)
}
}

func TestGetLaunchCommandExplicitPermissionsOverrideConfig(t *testing.T) {
p := &Plugin{resolvedBinary: "claude"}
cmd, err := p.GetLaunchCommand(context.Background(), ports.LaunchConfig{
Permissions: ports.PermissionModeBypassPermissions,
Config: ports.AgentConfig{Permissions: ports.PermissionModeAcceptEdits},
})
if err != nil {
t.Fatal(err)
}
if !containsSubsequence(cmd, []string{"--permission-mode", "bypassPermissions"}) {
t.Fatalf("explicit Permissions should win; got %#v", cmd)
}
}

func TestGetLaunchCommandRejectsInvalidConfig(t *testing.T) {
p := &Plugin{resolvedBinary: "claude"}
if _, err := p.GetLaunchCommand(context.Background(), ports.LaunchConfig{
Config: ports.AgentConfig{Permissions: "yolo"},
}); err == nil {
t.Fatal("expected error for invalid permission mode")
}
}

func TestManifestID(t *testing.T) {
if got := New().Manifest().ID; got != "claude-code" {
t.Fatalf("manifest id = %q, want claude-code", got)
Expand Down
23 changes: 16 additions & 7 deletions backend/internal/adapters/workspace/gitworktree/workspace.go
Original file line number Diff line number Diff line change
Expand Up @@ -15,7 +15,9 @@ import (

const (
defaultGitBinary = "git"
defaultBranch = "main"
// defaultBranch is the base branch used when neither the per-project config
// nor the adapter options name one. It shares domain's single source of truth.
defaultBranch = domain.DefaultBranchName
)

// ErrUnsafePath is returned when a resolved worktree path escapes the managed
Expand Down Expand Up @@ -122,7 +124,7 @@ func (w *Workspace) Create(ctx context.Context, cfg ports.WorkspaceConfig) (port
if err != nil {
return ports.WorkspaceInfo{}, err
}
if err := w.addWorktree(ctx, repo, path, cfg.Branch); err != nil {
if err := w.addWorktree(ctx, repo, path, cfg.Branch, cfg.BaseBranch); err != nil {
return ports.WorkspaceInfo{}, err
}
return ports.WorkspaceInfo{Path: path, Branch: cfg.Branch, SessionID: cfg.SessionID, ProjectID: cfg.ProjectID}, nil
Expand Down Expand Up @@ -198,13 +200,13 @@ func (w *Workspace) Restore(ctx context.Context, cfg ports.WorkspaceConfig) (por
if err := w.validateBranch(ctx, repo, cfg.Branch); err != nil {
return ports.WorkspaceInfo{}, err
}
if err := w.addWorktree(ctx, repo, path, cfg.Branch); err != nil {
if err := w.addWorktree(ctx, repo, path, cfg.Branch, cfg.BaseBranch); err != nil {
return ports.WorkspaceInfo{}, err
}
return ports.WorkspaceInfo{Path: path, Branch: cfg.Branch, SessionID: cfg.SessionID, ProjectID: cfg.ProjectID}, nil
}

func (w *Workspace) addWorktree(ctx context.Context, repo, path, branch string) error {
func (w *Workspace) addWorktree(ctx context.Context, repo, path, branch, baseBranch string) error {
// Refuse early if the branch is already checked out in another worktree:
// `git worktree add` will fail, but its stderr leaks through as an opaque
// 500. A typed sentinel lets the HTTP layer surface a 409.
Expand Down Expand Up @@ -233,7 +235,7 @@ func (w *Workspace) addWorktree(ctx context.Context, repo, path, branch string)
// neither origin/<branch>, the default branch, nor any tag is reachable,
// the branch genuinely has no base — surface ErrBranchNotFetched so callers
// can suggest `git fetch`.
baseRef, err := w.resolveBaseRef(ctx, repo, branch)
baseRef, err := w.resolveBaseRef(ctx, repo, branch, baseBranch)
if err != nil {
if errors.Is(err, errNoBaseRef) {
return fmt.Errorf("%w: %q has no local head, no remote, and no tag — run `git fetch` then retry", ErrBranchNotFetched, branch)
Expand All @@ -257,8 +259,15 @@ func (w *Workspace) validateBranch(ctx context.Context, repo, branch string) err
// addWorktree translates it into ErrBranchNotFetched.
var errNoBaseRef = errors.New("gitworktree: no base ref found")

func (w *Workspace) resolveBaseRef(ctx context.Context, repo, branch string) (string, error) {
candidates := baseRefCandidates(branch, w.defaultBranch)
func (w *Workspace) resolveBaseRef(ctx context.Context, repo, branch, baseBranch string) (string, error) {
// A per-project base branch (cfg.BaseBranch) overrides the adapter default,
// so a project that branches off e.g. "develop" materialises worktrees from
// there. Empty falls back to the adapter's configured default.
defaultBranch := w.defaultBranch
if strings.TrimSpace(baseBranch) != "" {
defaultBranch = baseBranch
}
candidates := baseRefCandidates(branch, defaultBranch)
for _, ref := range candidates {
exists, err := w.refExists(ctx, repo, ref)
if err != nil {
Expand Down
6 changes: 6 additions & 0 deletions backend/internal/cli/client.go
Original file line number Diff line number Diff line change
Expand Up @@ -62,6 +62,12 @@ func (c *commandContext) patchJSON(ctx context.Context, path string, body, out a
return c.doJSON(ctx, http.MethodPatch, path, body, out)
}

// putJSON sends body as JSON to PUT /api/v1/<path> on the running daemon and
// decodes a 2xx response into out.
func (c *commandContext) putJSON(ctx context.Context, path string, body, out any) error {
return c.doJSON(ctx, http.MethodPut, path, body, out)
}

// deleteJSON sends DELETE /api/v1/<path> to the running daemon and decodes a
// 2xx response into out.
func (c *commandContext) deleteJSON(ctx context.Context, path string, out any) error {
Expand Down
5 changes: 5 additions & 0 deletions backend/internal/cli/dto_drift_e2e_test.go
Original file line number Diff line number Diff line change
Expand Up @@ -128,6 +128,11 @@ func (f *fakeProjectManager) Add(_ context.Context, in projectsvc.AddInput) (pro
return projectsvc.Project{ID: id, Path: in.Path}, nil
}

func (f *fakeProjectManager) SetConfig(_ context.Context, id domain.ProjectID, in projectsvc.SetConfigInput) (projectsvc.Project, error) {
cfg := in.Config
return projectsvc.Project{ID: id, Config: &cfg}, nil
}

func (f *fakeProjectManager) Remove(context.Context, domain.ProjectID) (projectsvc.RemoveResult, error) {
return projectsvc.RemoveResult{}, nil
}
Expand Down
Loading
Loading