Skip to content

feat: add claudectx run for session-scoped profile launch (closes #21)#22

Merged
foxj77 merged 2 commits into
mainfrom
feature/run-command
Apr 29, 2026
Merged

feat: add claudectx run for session-scoped profile launch (closes #21)#22
foxj77 merged 2 commits into
mainfrom
feature/run-command

Conversation

@foxj77

@foxj77 foxj77 commented Apr 28, 2026

Copy link
Copy Markdown
Owner

Summary

Adds claudectx run <profile> [-- <claude args...>] to launch a Claude Code session using a named profile without modifying any global config state. This is the session-scoped complement to claudectx <profile> (persistent global switch).

Closes #21.

Motivation

Users want to run work and personal (or multiple client) Claude sessions concurrently in separate terminals. The existing switch model can't support this — it mutates ~/.claude/settings.json and the current profile tracker, so the last switch always wins. claudectx run solves this cleanly using Claude Code's documented session-scoped CLI flags.

What's new

claudectx run work                          # launch Claude with 'work' profile
claudectx run work -- --model opus          # pass extra Claude flags
claudectx run review -- -p "Review this"   # non-interactive
claudectx run work --dry-run               # print the generated command, no exec

Global state guarantee: ~/.claude/settings.json, ~/.claude/CLAUDE.md, ~/.claude.json, .claudectx-current, and .claudectx-previous are never touched by run.

Implementation

Profile component Mechanism
Settings (model, env, permissions) --settings <profile/settings.json>
CLAUDE.md instructions --append-system-prompt-file <profile/CLAUDE.md>
MCP servers Generated --mcp-config temp file + --strict-mcp-config

Key implementation decisions backed by empirical testing:

  • --settings overrides global scalar settings (model, env, permission mode); permission arrays accumulate — a profile cannot narrow permissions already granted globally
  • --append-system-prompt-file is session-scoped and additive; global ~/.claude/CLAUDE.md remains active alongside profile instructions
  • MCP config is read at Claude startup only — temp file deletion after exit is safe
  • MCP env blocks are copied to the generated wrapper; omitting them causes servers requiring env vars (e.g. KUBECONFIG) to crash on connect
  • Dry-run computes the full would-be command (including --mcp-config path) without creating temp files or exec-ing

New files:

  • cmd/run.goParseRunArgs, RunProfile, RunOptions, RunResult
  • cmd/run_test.go — 25 tests
  • cmd/run_parse_test.go — 10 parser tests
  • internal/mcpconfig/claudeformat_test.go — 7 tests for SaveClaudeMCPConfig

Modified:

  • internal/mcpconfig/mcpconfig.goSaveClaudeMCPConfig (Claude --mcp-config wrapper format, distinct from raw profile storage)
  • internal/paths/paths.goRunTempDir() for ~/.claude/.claudectx-run/
  • main.gocase "run" routing, updated help text with Switch vs Run notes
  • README.md — Quick Start, Session Launcher section, Switch vs Run table, How It Works tree, concurrent session examples
  • docs/IMPLEMENTATION_PLAN.md — implemented features section

Test plan

  • 42 new tests passing across cmd/run_test.go, cmd/run_parse_test.go, internal/mcpconfig/claudeformat_test.go
  • Full suite green (go test ./...) — all 12 packages pass
  • Dry-run smoke tested against a real profile: generates correct --settings, --append-system-prompt-file, --mcp-config, --strict-mcp-config flags
  • Global state immutability verified: settings.json, CLAUDE.md, claude.json, current/previous trackers all untouched after a dry-run call

Known caveats (documented in README and help text)

  • In-session /config changes write to the normal user config, not the profile's settings.json
  • Session history is not profile-scoped
  • Auth is not switched — uses whatever Claude Code login is currently active
  • --isolated mode (to exclude global user settings entirely) is deferred until --setting-sources + --settings precedence is verified

🤖 Generated with Claude Code

foxj77 and others added 2 commits April 28, 2026 22:09
Adds `claudectx run <profile> [-- <claude args...>]` to launch a Claude
Code session using a named profile without mutating global config state.
Unlike `claudectx <profile>` (persistent global switch), `run` leaves
~/.claude/settings.json, ~/.claude/CLAUDE.md, ~/.claude.json, and the
current/previous profile trackers entirely untouched.

Implementation:
- cmd/run.go: ParseRunArgs, RunProfile, RunOptions, RunResult
- internal/mcpconfig: SaveClaudeMCPConfig writes the --mcp-config wrapper
  format ({"mcpServers":{...}}) distinct from raw profile storage
- internal/paths: RunTempDir for ~/.claude/.claudectx-run/
- main.go: case "run" wiring and updated help text with switch vs run notes

Key behaviours confirmed by empirical investigation:
- --settings overrides global ~/.claude/settings.json scalar values;
  permission arrays accumulate (documented in help)
- --append-system-prompt-file is session-scoped and additive; global
  CLAUDE.md remains active alongside profile instructions
- MCP config is read at startup only; temp file cleanup after exit is safe
- MCP env blocks are preserved in the generated wrapper or servers
  requiring env vars (e.g. KUBECONFIG) will crash on connect
- Dry-run prints the full generated command including the would-be
  --mcp-config path without creating any temp files or exec-ing claude

42 tests across cmd/run_test.go, cmd/run_parse_test.go, and
internal/mcpconfig/claudeformat_test.go; full suite green.

Docs: docs/issue21.md (proposal), docs/issue21-review.md (review with
empirical findings), docs/issue18.md + docs/issue18-review.md (skill
switching proposal and review).

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
- README: add run to Quick Start, new Session Launcher section with
  caveats table, Switch vs Run comparison table, updated How It Works
  tree showing .claudectx-run/ temp dir, concurrent sessions examples
  in Real-World section, run row in comparison table, bump version badge
- docs/IMPLEMENTATION_PLAN.md: add implemented features section
  documenting run command design decisions, empirical findings, and
  file inventory

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
Copilot AI review requested due to automatic review settings April 28, 2026 21:24

Copilot AI left a comment

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Pull request overview

Adds a new claudectx run <profile> [-- <claude args...>] command to launch a Claude Code session using a named profile without mutating global config, enabling concurrent sessions in different terminals (closes #21).

Changes:

  • Add run command routing, argument parsing, and session launcher implementation (dry-run + pass-through args).
  • Add Claude-compatible MCP config writer (SaveClaudeMCPConfig) and a new temp directory path helper for generated MCP config files.
  • Add substantial unit test coverage for run behavior + MCP wrapper formatting, and update README + implementation docs.

Reviewed changes

Copilot reviewed 13 out of 13 changed files in this pull request and generated 4 comments.

Show a summary per file
File Description
main.go Adds run command routing plus help/examples and “Switch vs Run” notes.
cmd/run.go Implements ParseRunArgs, RunProfile, and execClaude for session-scoped launches.
cmd/run_test.go Adds run behavior tests (immutability, generated args, dry-run, MCP behavior).
cmd/run_parse_test.go Adds parser-focused tests for claudectx run argument handling.
internal/paths/paths.go Adds RunTempDir() for ~/.claude/.claudectx-run/ base path.
internal/mcpconfig/mcpconfig.go Adds SaveClaudeMCPConfig() wrapper format for Claude’s --mcp-config.
internal/mcpconfig/claudeformat_test.go Adds tests validating Claude MCP wrapper output (including env/args preservation).
README.md Documents run, caveats, concurrent sessions examples, and Switch vs Run comparison.
docs/IMPLEMENTATION_PLAN.md Documents claudectx run as implemented feature with design notes.
docs/issue21.md Adds detailed design/proposal doc for issue #21.
docs/issue21-review.md Adds review notes + empirical findings for flags/precedence and MCP cleanup.
docs/issue18.md Adds proposal doc for issue #18 (skills switching).
docs/issue18-review.md Adds review notes for issue #18 proposal.

💡 Add Copilot custom instructions for smarter, more guided reviews. Learn how to get started.

Comment thread cmd/run.go
Comment on lines +74 to +75
opts.ProfileName = a
}

Copilot AI Apr 28, 2026

Copy link

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

ParseRunArgs currently sets ProfileName from the first non-flag token but silently ignores any additional positional tokens before the "--" separator (e.g. claudectx run work extra will run profile "work" and drop "extra"). This should return an error for unexpected extra args (with a hint to use -- for Claude args) to avoid surprising behavior.

Suggested change
opts.ProfileName = a
}
opts.ProfileName = a
continue
}
return RunOptions{}, fmt.Errorf(
"unexpected extra argument %q; use '--' to pass arguments to Claude",
a,
)

Copilot uses AI. Check for mistakes.
Comment thread cmd/run.go
claudeArgs = append(claudeArgs, "--settings", settingsPath)

// --append-system-prompt-file only when CLAUDE.md is non-empty.
if strings.TrimSpace(prof.ClaudeMD) != "" {

Copilot AI Apr 28, 2026

Copy link

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

RunProfile validates settings but does not validate prof.ClaudeMD content before passing --append-system-prompt-file. cmd/switch.go calls validator.ValidateClaudeMD, so run should do the same (at least when non-empty) to keep behavior consistent and fail early on oversized/invalid content.

Suggested change
if strings.TrimSpace(prof.ClaudeMD) != "" {
if strings.TrimSpace(prof.ClaudeMD) != "" {
if err := validator.ValidateClaudeMD(prof.ClaudeMD); err != nil {
return result, fmt.Errorf("profile CLAUDE.md invalid: %w", err)
}

Copilot uses AI. Check for mistakes.
Comment thread cmd/run.go
Comment on lines +215 to +231

// createRunTempDir creates a unique temp directory under ~/.claude/.claudectx-run/.
func createRunTempDir() (string, error) {
base, err := paths.RunTempDir()
if err != nil {
return "", err
}
if err := os.MkdirAll(base, 0700); err != nil {
return "", fmt.Errorf("failed to create run temp base dir: %w", err)
}
name := fmt.Sprintf("run-%d-%d", time.Now().UnixNano(), os.Getpid())
dir := filepath.Join(base, name)
if err := os.Mkdir(dir, 0700); err != nil {
return "", fmt.Errorf("failed to create run temp dir: %w", err)
}
return dir, nil
}

Copilot AI Apr 28, 2026

Copy link

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

createRunTempDir is unused in this file. Consider removing it or using it from the MCP temp-file block to avoid dead code and duplicated temp-dir creation logic.

Suggested change
// createRunTempDir creates a unique temp directory under ~/.claude/.claudectx-run/.
func createRunTempDir() (string, error) {
base, err := paths.RunTempDir()
if err != nil {
return "", err
}
if err := os.MkdirAll(base, 0700); err != nil {
return "", fmt.Errorf("failed to create run temp base dir: %w", err)
}
name := fmt.Sprintf("run-%d-%d", time.Now().UnixNano(), os.Getpid())
dir := filepath.Join(base, name)
if err := os.Mkdir(dir, 0700); err != nil {
return "", fmt.Errorf("failed to create run temp dir: %w", err)
}
return dir, nil
}

Copilot uses AI. Check for mistakes.
Comment thread cmd/run_test.go
Comment on lines +548 to +562
func TestRunProfile_ProfileWithNoSettingsFileStillRunsWithDefaultArgs(t *testing.T) {
s, _ := setupRunTest(t)

// Save a profile, then remove its settings.json to simulate a bare profile dir
p := profile.NewProfile("bare")
saveProfile(t, s, p)

profileDir, _ := paths.ProfileDir("bare")
_ = os.Remove(filepath.Join(profileDir, "settings.json"))

// Should still produce args (settings flag will point to a non-existent file;
// that is Claude's problem, not claudectx's — the profile is technically valid)
_, err := RunProfile(s, RunOptions{ProfileName: "bare", DryRun: true})
if err != nil {
t.Logf("note: RunProfile returned error for profile with missing settings.json: %v", err)

Copilot AI Apr 28, 2026

Copy link

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

This test doesn't assert any behavior and its comment is inaccurate: Store.Exists requires settings.json to exist, so removing it makes the profile non-existent and RunProfile should error. Consider either (1) asserting the expected error, or (2) rewriting the test to cover a real supported scenario.

Suggested change
func TestRunProfile_ProfileWithNoSettingsFileStillRunsWithDefaultArgs(t *testing.T) {
s, _ := setupRunTest(t)
// Save a profile, then remove its settings.json to simulate a bare profile dir
p := profile.NewProfile("bare")
saveProfile(t, s, p)
profileDir, _ := paths.ProfileDir("bare")
_ = os.Remove(filepath.Join(profileDir, "settings.json"))
// Should still produce args (settings flag will point to a non-existent file;
// that is Claude's problem, not claudectx's — the profile is technically valid)
_, err := RunProfile(s, RunOptions{ProfileName: "bare", DryRun: true})
if err != nil {
t.Logf("note: RunProfile returned error for profile with missing settings.json: %v", err)
func TestRunProfile_ProfileWithNoSettingsFileReturnsError(t *testing.T) {
s, _ := setupRunTest(t)
// Save a profile, then remove its settings.json. Without settings.json the
// store should no longer consider the profile to exist, so RunProfile should
// return an error.
p := profile.NewProfile("bare")
saveProfile(t, s, p)
profileDir, _ := paths.ProfileDir("bare")
_ = os.Remove(filepath.Join(profileDir, "settings.json"))
_, err := RunProfile(s, RunOptions{ProfileName: "bare", DryRun: true})
if err == nil {
t.Fatal("expected error for profile with missing settings.json")

Copilot uses AI. Check for mistakes.
@foxj77 foxj77 merged commit 1503147 into main Apr 29, 2026
7 checks passed
@foxj77 foxj77 deleted the feature/run-command branch April 29, 2026 19:33
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

[RFE] Allow having multiple profiles enabled at the same time

2 participants