feat: add claudectx run for session-scoped profile launch (closes #21)#22
Conversation
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>
There was a problem hiding this comment.
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
runcommand 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.
| opts.ProfileName = a | ||
| } |
There was a problem hiding this comment.
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.
| opts.ProfileName = a | |
| } | |
| opts.ProfileName = a | |
| continue | |
| } | |
| return RunOptions{}, fmt.Errorf( | |
| "unexpected extra argument %q; use '--' to pass arguments to Claude", | |
| a, | |
| ) |
| claudeArgs = append(claudeArgs, "--settings", settingsPath) | ||
|
|
||
| // --append-system-prompt-file only when CLAUDE.md is non-empty. | ||
| if strings.TrimSpace(prof.ClaudeMD) != "" { |
There was a problem hiding this comment.
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.
| 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) | |
| } |
|
|
||
| // 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 | ||
| } |
There was a problem hiding this comment.
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.
| // 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 | |
| } |
| 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) |
There was a problem hiding this comment.
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.
| 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") |
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 toclaudectx <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.jsonand the current profile tracker, so the last switch always wins.claudectx runsolves this cleanly using Claude Code's documented session-scoped CLI flags.What's new
Global state guarantee:
~/.claude/settings.json,~/.claude/CLAUDE.md,~/.claude.json,.claudectx-current, and.claudectx-previousare never touched byrun.Implementation
--settings <profile/settings.json>--append-system-prompt-file <profile/CLAUDE.md>--mcp-configtemp file +--strict-mcp-configKey implementation decisions backed by empirical testing:
--settingsoverrides global scalar settings (model, env, permission mode); permission arrays accumulate — a profile cannot narrow permissions already granted globally--append-system-prompt-fileis session-scoped and additive; global~/.claude/CLAUDE.mdremains active alongside profile instructionsenvblocks are copied to the generated wrapper; omitting them causes servers requiring env vars (e.g.KUBECONFIG) to crash on connect--mcp-configpath) without creating temp files or exec-ingNew files:
cmd/run.go—ParseRunArgs,RunProfile,RunOptions,RunResultcmd/run_test.go— 25 testscmd/run_parse_test.go— 10 parser testsinternal/mcpconfig/claudeformat_test.go— 7 tests forSaveClaudeMCPConfigModified:
internal/mcpconfig/mcpconfig.go—SaveClaudeMCPConfig(Claude--mcp-configwrapper format, distinct from raw profile storage)internal/paths/paths.go—RunTempDir()for~/.claude/.claudectx-run/main.go—case "run"routing, updated help text with Switch vs Run notesREADME.md— Quick Start, Session Launcher section, Switch vs Run table, How It Works tree, concurrent session examplesdocs/IMPLEMENTATION_PLAN.md— implemented features sectionTest plan
cmd/run_test.go,cmd/run_parse_test.go,internal/mcpconfig/claudeformat_test.gogo test ./...) — all 12 packages pass--settings,--append-system-prompt-file,--mcp-config,--strict-mcp-configflagssettings.json,CLAUDE.md,claude.json, current/previous trackers all untouched after a dry-run callKnown caveats (documented in README and help text)
/configchanges write to the normal user config, not the profile'ssettings.json--isolatedmode (to exclude global user settings entirely) is deferred until--setting-sources+--settingsprecedence is verified🤖 Generated with Claude Code