Skip to content
Closed
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
22 changes: 22 additions & 0 deletions README.md
Original file line number Diff line number Diff line change
Expand Up @@ -72,6 +72,9 @@ Your `~/.claude` is the **default** profile — untouched, always there. New pro
~/.claude/ <-- always the active config (files get swapped in/out)
~/.claude-profiles/
├── state.json <-- which profile is active
├── scripts/ <-- profile startup scripts (optional)
│ ├── work.sh <-- runs when "work" profile is active
│ └── default.sh <-- fallback script
└── saved/
├── default/ <-- default profile backup
└── work/ <-- work profile config (restored to ~/.claude on switch)
Expand Down Expand Up @@ -140,6 +143,24 @@ claude-profiles use default # back to your original config

Each profile has its own `settings.json`, `mcp.json`, `CLAUDE.md`, `commands/`, and `hooks/`. Switching swaps them all atomically.

## Profile Scripts

Each profile can have a startup script that runs when the Notification hook fires. This lets you inject profile-specific context into Claude — environment info, project details, or setup checks.

```bash
# Create a script for the "work" profile
mkdir -p ~/.claude-profiles/scripts
cat > ~/.claude-profiles/scripts/work.sh << 'EOF'
#!/usr/bin/env bash
echo "Project: $(basename "$PWD")"
echo "Branch: $(git branch --show-current 2>/dev/null)"
echo "Node: $(node -v 2>/dev/null)"
EOF
chmod +x ~/.claude-profiles/scripts/work.sh
```

Scripts are stored in `~/.claude-profiles/scripts/<name>.sh`. If no profile-specific script exists, `default.sh` is used as a fallback. The `CLAUDE_PROFILE` environment variable is set to the active profile name when the script runs.

## Per-Project Auto-Switching

Add a `.claude-profile` file to any repo:
Expand Down Expand Up @@ -174,6 +195,7 @@ The install automatically:
- Adds `/profiles-*` slash commands to Claude Code
- Installs a shell hook in `.zshrc`/`.bashrc`/`config.fish` (auto-switch on `cd`)
- Registers a fast-execution hook (so `/profiles-list` responds in ~1s)
- Registers a Notification hook (loads profile startup scripts)
- Adds auto-approve permissions for `claude-profiles` commands

The uninstall removes all of the above. `~/.claude` is never modified.
Expand Down
76 changes: 5 additions & 71 deletions package-lock.json

Some generated files are not rendered by default. Learn more about how customized files appear on GitHub.

96 changes: 96 additions & 0 deletions src/core/scripts.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,96 @@
import { mkdir, readFile, writeFile } from 'node:fs/promises';
import { join } from 'node:path';
import { existsSync } from 'node:fs';
import { execFileSync } from 'node:child_process';
import { getProfilesBaseDir, loadState } from './state.js';

/**
* Profile scripts: shell scripts that run when a profile is loaded.
* Stored in ~/.claude-profiles/scripts/<name>.sh
* Loaded by the Notification hook to inject profile-specific context.
*/

export function getScriptsDir(baseDir: string): string {
return join(baseDir, 'scripts');
}

export function getProfileScriptPath(baseDir: string, profileName: string): string {
return join(getScriptsDir(baseDir), `${profileName}.sh`);
}

export function profileScriptExists(baseDir: string, profileName: string): boolean {
return existsSync(getProfileScriptPath(baseDir, profileName));
}

/**
* Run a profile's startup script and return its stdout.
* Falls back to default.sh if no profile-specific script exists.
* Returns empty string if no script is found.
*/
export function runProfileScript(baseDir: string, profileName: string): string {
const profileScript = getProfileScriptPath(baseDir, profileName);
const defaultScript = getProfileScriptPath(baseDir, 'default');

let scriptPath: string | null = null;
if (existsSync(profileScript)) {
scriptPath = profileScript;
} else if (existsSync(defaultScript)) {
scriptPath = defaultScript;
}

if (!scriptPath) return '';

try {
return execFileSync('bash', [scriptPath], {
encoding: 'utf-8',
timeout: 5000,
env: { ...process.env, CLAUDE_PROFILE: profileName },
}).trim();
} catch {
return '';
}
}

/**
* Resolve the active profile and run its script.
* Used by the Notification hook handler.
*/
export async function runActiveProfileScript(baseDir?: string): Promise<string> {
const dir = baseDir ?? getProfilesBaseDir();
const state = await loadState(dir);
return runProfileScript(dir, state.activeProfile);
}

/**
* Create a template profile script.
*/
export async function createProfileScript(baseDir: string, profileName: string): Promise<void> {
const scriptsDir = getScriptsDir(baseDir);
await mkdir(scriptsDir, { recursive: true });

const scriptPath = getProfileScriptPath(baseDir, profileName);
if (existsSync(scriptPath)) return; // Don't overwrite existing

const template = `#!/usr/bin/env bash
# Profile startup script for "${profileName}"
# This runs when the Notification hook fires while this profile is active.
# Output is injected as additional context for Claude.
#
# Available environment variable:
# CLAUDE_PROFILE — name of the active profile
#
# Examples:
# echo "Project: $(basename "$PWD")"
# echo "Branch: $(git branch --show-current 2>/dev/null)"
# echo "Node: $(node -v 2>/dev/null)"
exit 0
`;
await writeFile(scriptPath, template, { mode: 0o755 });
}

/**
* Ensure the scripts directory exists.
*/
export async function ensureScriptsDir(baseDir: string): Promise<void> {
await mkdir(getScriptsDir(baseDir), { recursive: true });
}
Loading