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
29 changes: 28 additions & 1 deletion README.md
Original file line number Diff line number Diff line change
Expand Up @@ -399,7 +399,7 @@ Short aliases: `g:flat`, `v:stats`, `p:hooks`, `cfg:edit`. Multi-command: `view:

Config file: `~/.config/ccx/config.yaml` (bootstrap with `:config:edit`)

The config file contains three sections:
The config file contains these sections:

### Keybindings

Expand Down Expand Up @@ -430,6 +430,33 @@ preferences:
editor_input: true # prefer $EDITOR for live input (ctrl+e to toggle)
```

### Claude command template

Configure the local Claude command used by session resume/new-session, tmux windows,
plugin commands, and config/plugin test popups:

```yaml
claude:
command_template: "claude {{args}}"
```

`{{args}}` expands to the arguments supplied by ccx, such as `--resume <session-id>`
or `plugin install <id>`. If `{{args}}` is omitted, ccx appends its arguments at
the end. The template is parsed into argv and is not shell-evaluated for normal
process launches; tmux/script launches shell-quote the rendered argv.

Examples:

```yaml
claude:
command_template: "ccproxy -- claude {{args}}"
```

```yaml
claude:
command_template: "claude --model opus {{args}}"
```

### Number Key Shortcuts

Number keys `1-9` trigger commands based on the active view and split focus side.
Expand Down
159 changes: 159 additions & 0 deletions internal/claudecmd/claudecmd.go
Original file line number Diff line number Diff line change
@@ -0,0 +1,159 @@
package claudecmd

import (
"errors"
"fmt"
"os/exec"
"strings"
"unicode"
)

const (
DefaultTemplate = "claude {{args}}"
argsPlaceholder = "{{args}}"
)

type Config struct {
CommandTemplate string `yaml:"command_template,omitempty"`
}

func RenderArgv(cfg Config, args ...string) ([]string, error) {
template := strings.TrimSpace(cfg.CommandTemplate)
if template == "" {
template = DefaultTemplate
}

parts, err := splitTemplate(template)
if err != nil {
return nil, err
}
if len(parts) == 0 {
return nil, errors.New("claude command template is empty")
}

insertedArgs := false
argv := make([]string, 0, len(parts)+len(args))
for _, part := range parts {
if strings.Contains(part, argsPlaceholder) && part != argsPlaceholder {
return nil, fmt.Errorf("%s must be its own argument", argsPlaceholder)
}
if part == argsPlaceholder {
argv = append(argv, args...)
insertedArgs = true
continue
}
argv = append(argv, part)
}
if !insertedArgs {
argv = append(argv, args...)
}
if len(argv) == 0 || argv[0] == "" {
return nil, errors.New("claude command template has no executable")
}
return argv, nil
}

func Command(cfg Config, dir string, args ...string) (*exec.Cmd, error) {
argv, err := RenderArgv(cfg, args...)
if err != nil {
return nil, err
}
cmd := exec.Command(argv[0], argv[1:]...)
if dir != "" {
cmd.Dir = dir
}
return cmd, nil
}

func ShellCommand(cfg Config, dir string, args ...string) (string, error) {
argv, err := RenderArgv(cfg, args...)
if err != nil {
return "", err
}
quoted := make([]string, 0, len(argv))
for _, arg := range argv {
quoted = append(quoted, ShellQuote(arg))
}
cmd := strings.Join(quoted, " ")
if dir != "" {
cmd = "cd " + ShellQuote(dir) + " && " + cmd
}
return cmd, nil
}

func ShellQuote(s string) string {
if s == "" {
return "''"
}
return "'" + strings.ReplaceAll(s, "'", "'\\''") + "'"
}

func splitTemplate(input string) ([]string, error) {
var tokens []string
var b strings.Builder
inSingle := false
inDouble := false
escaped := false
hadQuotedOrEscaped := false

flush := func() {
if b.Len() == 0 && !hadQuotedOrEscaped {
return
}
tokens = append(tokens, b.String())
b.Reset()
hadQuotedOrEscaped = false
}

for _, r := range input {
if escaped {
b.WriteRune(r)
escaped = false
hadQuotedOrEscaped = true
continue
}
if r == '\\' {
escaped = true
continue
}
if inSingle {
if r == '\'' {
inSingle = false
hadQuotedOrEscaped = true
continue
}
b.WriteRune(r)
continue
}
if inDouble {
if r == '"' {
inDouble = false
hadQuotedOrEscaped = true
continue
}
b.WriteRune(r)
continue
}

switch {
case unicode.IsSpace(r):
flush()
case r == '\'':
inSingle = true
hadQuotedOrEscaped = true
case r == '"':
inDouble = true
hadQuotedOrEscaped = true
default:
b.WriteRune(r)
}
}
if escaped {
return nil, errors.New("claude command template ends with an unfinished escape")
}
if inSingle || inDouble {
return nil, errors.New("claude command template has an unterminated quote")
}
flush()
return tokens, nil
}
96 changes: 96 additions & 0 deletions internal/claudecmd/claudecmd_test.go
Original file line number Diff line number Diff line change
@@ -0,0 +1,96 @@
package claudecmd

import (
"reflect"
"strings"
"testing"
)

func TestRenderArgvDefault(t *testing.T) {
got, err := RenderArgv(Config{}, "--resume", "abc")
if err != nil {
t.Fatalf("RenderArgv failed: %v", err)
}
want := []string{"claude", "--resume", "abc"}
if !reflect.DeepEqual(got, want) {
t.Fatalf("got %#v, want %#v", got, want)
}
}

func TestRenderArgvWrapperTemplate(t *testing.T) {
got, err := RenderArgv(Config{CommandTemplate: "ccproxy -- claude {{args}}"}, "--resume", "abc")
if err != nil {
t.Fatalf("RenderArgv failed: %v", err)
}
want := []string{"ccproxy", "--", "claude", "--resume", "abc"}
if !reflect.DeepEqual(got, want) {
t.Fatalf("got %#v, want %#v", got, want)
}
}

func TestRenderArgvAppendsArgsWhenPlaceholderMissing(t *testing.T) {
got, err := RenderArgv(Config{CommandTemplate: "claude --model opus"}, "--resume", "abc")
if err != nil {
t.Fatalf("RenderArgv failed: %v", err)
}
want := []string{"claude", "--model", "opus", "--resume", "abc"}
if !reflect.DeepEqual(got, want) {
t.Fatalf("got %#v, want %#v", got, want)
}
}

func TestRenderArgvQuotedExecutable(t *testing.T) {
got, err := RenderArgv(Config{CommandTemplate: "'/opt/my wrapper/claude' --flag {{args}}"}, "plugin", "install", "foo/bar")
if err != nil {
t.Fatalf("RenderArgv failed: %v", err)
}
want := []string{"/opt/my wrapper/claude", "--flag", "plugin", "install", "foo/bar"}
if !reflect.DeepEqual(got, want) {
t.Fatalf("got %#v, want %#v", got, want)
}
}

func TestRenderArgvRejectsEmbeddedArgsPlaceholder(t *testing.T) {
_, err := RenderArgv(Config{CommandTemplate: "claude --wrapped={{args}}"}, "--resume", "abc")
if err == nil {
t.Fatal("expected error")
}
if !strings.Contains(err.Error(), "own argument") {
t.Fatalf("unexpected error: %v", err)
}
}

func TestRenderArgvRejectsUnterminatedQuote(t *testing.T) {
_, err := RenderArgv(Config{CommandTemplate: "claude 'unterminated"})
if err == nil {
t.Fatal("expected error")
}
}

func TestShellCommandQuotesArguments(t *testing.T) {
got, err := ShellCommand(Config{CommandTemplate: "ccproxy -- claude {{args}}"}, "/tmp/a dir", "--resume", "abc; touch /tmp/pwned")
if err != nil {
t.Fatalf("ShellCommand failed: %v", err)
}
want := "cd '/tmp/a dir' && 'ccproxy' '--' 'claude' '--resume' 'abc; touch /tmp/pwned'"
if got != want {
t.Fatalf("got %q, want %q", got, want)
}
}

func TestCommandSetsDirAndArgs(t *testing.T) {
cmd, err := Command(Config{CommandTemplate: "ccproxy -- claude {{args}}"}, "/tmp", "--resume", "abc")
if err != nil {
t.Fatalf("Command failed: %v", err)
}
if cmd.Args[0] != "ccproxy" {
t.Fatalf("Args[0] = %q", cmd.Args[0])
}
want := []string{"ccproxy", "--", "claude", "--resume", "abc"}
if !reflect.DeepEqual(cmd.Args, want) {
t.Fatalf("Args = %#v, want %#v", cmd.Args, want)
}
if cmd.Dir != "/tmp" {
t.Fatalf("Dir = %q", cmd.Dir)
}
}
3 changes: 2 additions & 1 deletion internal/cli/run_pick.go
Original file line number Diff line number Diff line change
Expand Up @@ -48,7 +48,7 @@ func RunPickSessionTUI(claudeDir, search string, multi bool) PickSessionExitCode
}

configPath := filepath.Join(os.Getenv("HOME"), ".config", "ccx", "config.yaml")
km, _, _, _ := tui.LoadCCXConfig(configPath)
km, _, _, _, cc := tui.LoadCCXConfig(configPath)

app := tui.NewApp(sessions, tui.Config{
ClaudeDir: claudeDir,
Expand All @@ -57,6 +57,7 @@ func RunPickSessionTUI(claudeDir, search string, multi bool) PickSessionExitCode
SearchQuery: search,
Keymap: km,
PickMode: true,
Claude: cc,
})

p := tea.NewProgram(app,
Expand Down
Loading
Loading