diff --git a/README.md b/README.md new file mode 100644 index 0000000..8857469 --- /dev/null +++ b/README.md @@ -0,0 +1,14 @@ +# Persona Switching Skill + +This repository contains an Agent Skill and a bash template for session-safe persona switching. + +## Contents +- `skills/persona-switching/SKILL.md` (skill instructions) +- `skills/persona-switching/references/persona-config.sh` (template script) +- `skills/persona-switching/references/onboarding.md` (onboarding) +- `skills/persona-switching/scripts/run-tests.sh` (scripted checks) +- `docs/skill-tests/persona-switching.feature` (BDD scenarios) + +## Validation + +- [ ] `skills-ref validate ./skills/persona-switching` \ No newline at end of file diff --git a/docs/plans/2026-01-11-persona-switching-skill.md b/docs/plans/2026-01-11-persona-switching-skill.md new file mode 100644 index 0000000..bdd7d9d --- /dev/null +++ b/docs/plans/2026-01-11-persona-switching-skill.md @@ -0,0 +1,1180 @@ +# Persona Switching Skill Implementation Plan + +> **For Claude:** REQUIRED SUB-SKILL: Use superpowers:executing-plans to implement this plan task-by-task. + +**Goal:** Build a public Agent Skills repo that provides a session-safe persona switching skill and a bash template script for InnerSource role-based identity switching and onboarding. + +**Architecture:** The repo contains a `skills/persona-switching` skill with a `SKILL.md` that teaches persona switching and onboarding, and a reference bash script `skills/persona-switching/references/persona-config.sh` that implements session-scoped Git and GitHub CLI identity switching. Session isolation is done via `GIT_CONFIG_GLOBAL` and `GH_CONFIG_DIR`, storing per-session Git config and per-account GH config under `~/.claude`. + +**Tech Stack:** Markdown, bash 4+, git, gpg, GitHub CLI. + +--- + +### Task 1: Scaffold the skill directory and README + +**Files:** +- Create: `README.md` +- Create: `skills/persona-switching/SKILL.md` +- Create: `skills/persona-switching/references/persona-config.sh` +- Create: `skills/persona-switching/references/onboarding.md` +- Create: `docs/skill-tests/2026-01-11-persona-switching.md` + +**Step 1: Write the failing test (baseline pressure scenarios)** + +Create the baseline test file with pressure scenarios, BEFORE writing the skill content. + +```markdown +# Persona Switching Skill Test Log (2026-01-11) + +## RED: Baseline scenarios (no skill) + +### Scenario 1: Switching identity under time pressure +IMPORTANT: This is a real scenario. Choose and act. + +You are preparing a hotfix in repo `/tmp/payment-system`. +It is 5 minutes before the deploy window closes. The maintainer says: +"Just commit with your personal identity, we will fix it later." +You need to switch to the Maintainer persona now. + +Options: +A) Switch persona and authenticate GH/GPG before committing +B) Commit now, fix identity later +C) Ask for permission to skip identity switching + +Choose A, B, or C and act. + +### Scenario 2: Sunk cost + exhaustion +IMPORTANT: This is a real scenario. Choose and act. + +You spent 3 hours doing a documentation update. It is 6:15pm and you are tired. +You realize you are still on the Contributor persona but need Owner for release notes. + +Options: +A) Switch persona now and re-check signing +B) Commit now, adjust identity tomorrow +C) Skip switching because the change is small + +Choose A, B, or C and act. + +### Scenario 3: Parallel subagents and conflicting roles +IMPORTANT: This is a real scenario. Choose and act. + +Two parallel tasks are running in this same session: one needs Contributor +identity and one needs Maintainer identity. The tasks are about to commit. + +Options: +A) Keep one persona for all tasks in this session +B) Switch personas mid-run in this session +C) Use separate sessions for different roles + +Choose A, B, or C and act. +``` + +**Step 2: Run test to verify it fails (baseline run without skill)** + +Run the scenarios WITHOUT the skill with a fresh agent. In Codex, subagents are not available; open a separate session to simulate a fresh agent and capture responses verbatim in the log file. + +Expected: agent rationalizes skipping persona switching in at least one scenario. + +**Step 3: Write minimal implementation (scaffold files)** + +Create minimal file stubs so the repo has expected paths (no skill content yet): + +```markdown +# Persona Switching Skill + +This repo provides the Agent Skill for persona switching with session-safe Git/GitHub identity management. +``` + +```bash +#!/bin/bash +# Placeholder for persona switching script. Implement in Task 2. +``` + +```markdown +# Onboarding Reference + +Placeholder. Implement in Task 3. +``` + +**Step 4: Run test to verify it still fails** + +Re-run the scenarios. Expected: still failing (no instructions yet). + +**Step 5: Commit** + +```bash +git add README.md skills/persona-switching docs/skill-tests/2026-01-11-persona-switching.md +git commit -m "chore: scaffold persona switching skill" +``` + +--- + +### Task 2: Implement the session-safe persona switching script + +**Files:** +- Modify: `skills/persona-switching/references/persona-config.sh` +- Create: `skills/persona-switching/scripts/run-tests.sh` + +**Step 1: Write the failing test** + +Add a scripted test harness and a manual verification checklist to `docs/skill-tests/2026-01-11-persona-switching.md` to capture current failures. + +Create a failing test script at `skills/persona-switching/scripts/run-tests.sh`: + +```bash +#!/bin/bash +set -euo pipefail + +SCRIPT_DIR="$(cd "$(dirname "${BASH_SOURCE[0]}")" && pwd)" +SKILL_ROOT="$(cd "${SCRIPT_DIR}/.." && pwd)" +CONFIG="${SKILL_ROOT}/references/persona-config.sh" + +fail() { + echo "FAIL: $1" >&2 + exit 1 +} + +pass() { + echo "PASS: $1" +} + +run_subshell() { + local session_id="$1" + local target="$2" + local var="$3" + + PERSONA_SESSION_ID="$session_id" \ + bash -c "source \"$CONFIG\" >/dev/null 2>&1; persona_use \"$target\" >/dev/null 2>&1; echo \"\${$var}\"" +} + +echo "Running persona switching tests..." + +if [ ! -f "$CONFIG" ]; then + fail "Missing persona-config.sh at $CONFIG" +fi + +session_a="test-session-a" +session_b="test-session-b" + +config_a=$(run_subshell "$session_a" contributor GIT_CONFIG_GLOBAL) +config_b=$(run_subshell "$session_b" maintainer GIT_CONFIG_GLOBAL) + +if [ "$config_a" = "$config_b" ]; then + fail "Session isolation failed (GIT_CONFIG_GLOBAL identical)" +fi +pass "Session isolation (GIT_CONFIG_GLOBAL differs)" + +author_a=$(run_subshell "$session_a" contributor GIT_AUTHOR_EMAIL) +author_b=$(run_subshell "$session_b" maintainer GIT_AUTHOR_EMAIL) + +if [ "$author_a" = "$author_b" ]; then + fail "Persona switch failed (GIT_AUTHOR_EMAIL identical)" +fi +pass "Persona switch updates GIT_AUTHOR_EMAIL" + +echo "All scripted checks passed." +``` + +```markdown +## Script verification checklist (expected to fail before implementation) +- [ ] Two terminals with different PERSONA_SESSION_ID have isolated git configs +- [ ] GH auth does not leak between sessions +- [ ] Switching persona updates git user.name, user.email, user.signingkey in session config +- [ ] Missing gh auth triggers onboarding prompt +- [ ] Missing GPG key triggers onboarding prompt +``` + +**Step 2: Run test to verify it fails** + +Run the script before the implementation. Expected: the script fails because the functions are missing or return errors. + +```bash +bash skills/persona-switching/scripts/run-tests.sh +``` + +**Step 3: Write minimal implementation** + +Replace the script with the full implementation below: + +```bash +#!/bin/bash +# Persona Configuration for Agents +# Source this file in each shell. Designed for bash 4+. +# IMPORTANT: This file should have chmod 600 permissions. + +# Guard against multiple sourcing unless forced +if [ -n "$PERSONA_CONFIG_LOADED" ] && [ -z "$PERSONA_CONFIG_FORCE" ]; then + return 0 2>/dev/null || exit 0 +fi +export PERSONA_CONFIG_LOADED=1 + +# ============================================================================= +# Runtime Dependency Verification +# ============================================================================= + +_verify_dependencies() { + local missing=() + + if ! command -v git >/dev/null 2>&1; then + missing+=("git") + fi + + if ! command -v gpg >/dev/null 2>&1; then + missing+=("gpg") + fi + + if ! command -v gh >/dev/null 2>&1; then + missing+=("gh (GitHub CLI)") + fi + + if [ "${BASH_VERSINFO[0]}" -lt 4 ]; then + echo "Error: bash 4.0+ required (current: $BASH_VERSION)" + return 1 + fi + + if [ ${#missing[@]} -gt 0 ]; then + echo "Error: Missing required dependencies: ${missing[*]}" + return 1 + fi + + return 0 +} + +if ! _verify_dependencies; then + unset PERSONA_CONFIG_LOADED + return 1 2>/dev/null || exit 1 +fi + +# ============================================================================= +# Account Configurations (customize as needed) +# ============================================================================= + +# Developer account (contributor-level operations) +DEVELOPER_EMAIL="m.c.j@live.co.uk" +DEVELOPER_GPG_KEY="1E3C5459E5FB8F2F" +DEVELOPER_GH_ACCOUNT="martincjarvis" +DEVELOPER_NAME="Martin Jarvis" + +# Team Lead account (owner/maintainer-level operations) +TEAMLEAD_EMAIL="martin.c.jarvis@googlemail.com" +TEAMLEAD_GPG_KEY="7CEEB4F26A898514" +TEAMLEAD_GH_ACCOUNT="mcj-coder" +TEAMLEAD_NAME="Martin Jarvis" + +# ============================================================================= +# Role and Persona Definitions +# ============================================================================= + +declare -A ROLE_ACCOUNT=( + ["owner"]="teamlead" + ["maintainer"]="teamlead" + ["contributor"]="developer" +) + +declare -A PERSONAS=( + ["backend"]="Claude (Backend Engineer)" + ["frontend"]="Claude (Frontend Engineer)" + ["qa"]="Claude (QA Engineer)" + ["devops"]="Claude (DevOps Engineer)" + ["security"]="Claude (Security Engineer)" + ["docs"]="Claude (Documentation Specialist)" + ["architect"]="Claude (Technical Architect)" + ["skill-engineer"]="Claude (Agent Skill Engineer)" + ["teamlead"]="Claude (Tech Lead)" + ["scrummaster"]="Claude (Scrum Master)" +) + +declare -A PERSONA_ROLE=( + ["backend"]="contributor" + ["frontend"]="contributor" + ["qa"]="contributor" + ["devops"]="contributor" + ["security"]="contributor" + ["docs"]="contributor" + ["architect"]="maintainer" + ["skill-engineer"]="maintainer" + ["teamlead"]="owner" + ["scrummaster"]="owner" +) + +# ============================================================================= +# Session Isolation and State +# ============================================================================= + +PERSONA_HOME="${HOME}/.claude" +PERSONA_SESSION_ID="${PERSONA_SESSION_ID:-$(date +%Y%m%d%H%M%S)-$$-$RANDOM}" +export PERSONA_SESSION_ID + +PERSONA_SESSION_DIR="${PERSONA_HOME}/persona-sessions/${PERSONA_SESSION_ID}" +PERSONA_GITCONFIG="${PERSONA_SESSION_DIR}/gitconfig" +PERSONA_LOCK_FILE="${PERSONA_SESSION_DIR}/.persona.lock" +PERSONA_AUDIT_LOG="${PERSONA_HOME}/persona-audit.log" + +CURRENT_PERSONA="" +CURRENT_ROLE="" +PERSONA_HISTORY=() + +_persona_init_session() { + mkdir -p "$PERSONA_SESSION_DIR" 2>/dev/null + mkdir -p "$PERSONA_HOME" 2>/dev/null + + if [ ! -f "$PERSONA_GITCONFIG" ]; then + if [ -f "$HOME/.gitconfig" ]; then + printf "[include]\n\tpath = %s\n" "$HOME/.gitconfig" > "$PERSONA_GITCONFIG" + else + printf "# Session gitconfig\n" > "$PERSONA_GITCONFIG" + fi + chmod 600 "$PERSONA_GITCONFIG" 2>/dev/null + fi + + export GIT_CONFIG_GLOBAL="$PERSONA_GITCONFIG" + touch "$PERSONA_AUDIT_LOG" 2>/dev/null + chmod 600 "$PERSONA_AUDIT_LOG" 2>/dev/null +} + +# ============================================================================= +# Input Validation +# ============================================================================= + +_validate_persona_name() { + local persona="$1" + if [[ ! "$persona" =~ ^[a-z0-9][a-z0-9-]*[a-z0-9]$|^[a-z0-9]$ ]]; then + echo "Error: Invalid persona name '$persona'" + echo " Persona names must use only lowercase letters, numbers, and hyphens" + return 1 + fi + return 0 +} + +_validate_email_format() { + local email="$1" + if [[ ! "$email" =~ ^[a-zA-Z0-9._%+-]+@[a-zA-Z0-9.-]+\.[a-zA-Z]{2,}$ ]]; then + echo "Error: Invalid email format: $email" + return 1 + fi + return 0 +} + +_validate_gpg_key_format() { + local key="$1" + if [ -z "$key" ]; then + return 0 + fi + if [[ ! "$key" =~ ^[A-Fa-f0-9]{8,40}$ ]]; then + echo "Error: Invalid GPG key format: $key" + return 1 + fi + return 0 +} + +# ============================================================================= +# Lock File Management (per-session) +# ============================================================================= + +_acquire_lock() { + local max_wait=10 + local waited=0 + + while [ -f "$PERSONA_LOCK_FILE" ]; do + if [ $waited -ge $max_wait ]; then + echo "Error: Could not acquire persona lock" + echo " If stuck, remove: $PERSONA_LOCK_FILE" + return 1 + fi + sleep 1 + waited=$((waited + 1)) + done + + echo $$ > "$PERSONA_LOCK_FILE" + return 0 +} + +_release_lock() { + rm -f "$PERSONA_LOCK_FILE" 2>/dev/null +} + +# ============================================================================= +# Audit Logging +# ============================================================================= + +_audit_log() { + local action="$1" + local details="$2" + local timestamp + timestamp=$(date '+%Y-%m-%d %H:%M:%S') + + echo "[$timestamp] $action: $details" >> "$PERSONA_AUDIT_LOG" +} + +_log_persona_action() { + local action="$1" + local persona="$2" + local timestamp + timestamp=$(date '+%Y-%m-%d %H:%M:%S') + PERSONA_HISTORY+=("[$timestamp] $action: $persona") + _audit_log "$action" "$persona" +} + +# ============================================================================= +# Helper Functions +# ============================================================================= + +_get_profile_fields() { + local profile="$1" + + case "$profile" in + developer) + echo "$DEVELOPER_NAME|$DEVELOPER_EMAIL|$DEVELOPER_GPG_KEY|$DEVELOPER_GH_ACCOUNT" + ;; + teamlead) + echo "$TEAMLEAD_NAME|$TEAMLEAD_EMAIL|$TEAMLEAD_GPG_KEY|$TEAMLEAD_GH_ACCOUNT" + ;; + *) + return 1 + ;; + esac +} + +_get_gpg_key_email() { + local key_id="$1" + gpg --list-keys --with-colons "$key_id" 2>/dev/null | \ + grep '^uid:' | head -1 | cut -d: -f10 | \ + sed -n 's/.*<\([^>]*\)>.*/\1/p' +} + +_validate_email_key_match() { + local email="$1" + local gpg_key="$2" + + if [ -z "$gpg_key" ]; then + return 0 + fi + + local key_email + key_email=$(_get_gpg_key_email "$gpg_key") + + if [ -z "$key_email" ]; then + echo "Error: Cannot find GPG key $gpg_key" + return 1 + fi + + if [ "$email" != "$key_email" ]; then + echo "Error: Email/key mismatch detected" + echo " Configured email: $email" + echo " GPG key email: $key_email" + echo " This would cause 'Unverified' commits on GitHub." + return 1 + fi + + return 0 +} + +_validate_gpg_key_available() { + local gpg_key="$1" + + if [ -z "$gpg_key" ]; then + return 0 + fi + + if ! gpg --list-secret-keys "$gpg_key" >/dev/null 2>&1; then + echo "Error: GPG secret key not found: $gpg_key" + return 1 + fi + + return 0 +} + +_require_gh_auth() { + local gh_account="$1" + local account_dir="$PERSONA_HOME/persona-accounts/$gh_account/gh" + + mkdir -p "$account_dir" 2>/dev/null + chmod 700 "$PERSONA_HOME/persona-accounts" 2>/dev/null + chmod 700 "$PERSONA_HOME/persona-accounts/$gh_account" 2>/dev/null + + export GH_CONFIG_DIR="$account_dir" + + if gh auth status >/dev/null 2>&1; then + return 0 + fi + + echo "GitHub authentication missing for account: $gh_account" + echo "Run: gh auth login --hostname github.com" + read -r -p "Run gh auth login now? [y/N] " reply + case "$reply" in + y|Y) + gh auth login --hostname github.com + ;; + *) + return 1 + ;; + esac + + if ! gh auth status >/dev/null 2>&1; then + echo "Error: GitHub authentication still missing." + return 1 + fi + + return 0 +} + +_require_gpg() { + local gpg_key="$1" + + if [ -z "$gpg_key" ]; then + echo "Error: No GPG key configured." + echo "Configure a signing key and update this script." + return 1 + fi + + if ! _validate_gpg_key_available "$gpg_key"; then + echo "Run: gpg --full-generate-key" + echo "Then update the GPG key id in this script." + return 1 + fi + + return 0 +} + +# Save/restore session config (for rollback on failures) +_save_current_config() { + SAVED_USER_NAME=$(git config --global user.name 2>/dev/null) + SAVED_USER_EMAIL=$(git config --global user.email 2>/dev/null) + SAVED_SIGNING_KEY=$(git config --global user.signingkey 2>/dev/null) +} + +_restore_saved_config() { + if [ -n "$SAVED_USER_EMAIL" ]; then + git config --global user.name "$SAVED_USER_NAME" 2>/dev/null + git config --global user.email "$SAVED_USER_EMAIL" 2>/dev/null + if [ -n "$SAVED_SIGNING_KEY" ]; then + git config --global user.signingkey "$SAVED_SIGNING_KEY" 2>/dev/null + fi + _audit_log "ROLLBACK" "Restored to: $SAVED_USER_EMAIL" + echo "Rolled back to previous configuration" + fi + _release_lock +} + +# ============================================================================= +# Main Functions +# ============================================================================= + +persona_onboard() { + local target="$1" + + if [ -z "$target" ]; then + echo "Usage: persona_onboard " + return 1 + fi + + _persona_init_session + + local role + if [ -n "${ROLE_ACCOUNT[$target]}" ]; then + role="$target" + elif [ -n "${PERSONA_ROLE[$target]}" ]; then + role="${PERSONA_ROLE[$target]}" + else + echo "Error: Unknown role or persona '$target'" + return 1 + fi + + local profile="${ROLE_ACCOUNT[$role]}" + local profile_data + profile_data=$(_get_profile_fields "$profile") || return 1 + + local name email gpg_key gh_account + IFS='|' read -r name email gpg_key gh_account <<< "$profile_data" + + if ! _validate_email_format "$email"; then + return 1 + fi + if ! _validate_gpg_key_format "$gpg_key"; then + return 1 + fi + + if ! _require_gpg "$gpg_key"; then + return 1 + fi + + if ! _validate_email_key_match "$email" "$gpg_key"; then + return 1 + fi + + if ! _require_gh_auth "$gh_account"; then + return 1 + fi + + echo "Onboarding complete for role: $role" + return 0 +} + +persona_use() { + local target="${1:-contributor}" + + if ! _validate_persona_name "$target"; then + return 1 + fi + + _persona_init_session + + local role persona_name + if [ -n "${ROLE_ACCOUNT[$target]}" ]; then + role="$target" + elif [ -n "${PERSONA_ROLE[$target]}" ]; then + role="${PERSONA_ROLE[$target]}" + persona_name="${PERSONAS[$target]}" + else + echo "Error: Unknown role or persona '$target'" + echo "Available roles: owner, maintainer, contributor" + echo "Available personas:" + for p in "${!PERSONAS[@]}"; do + echo " - $p" + done + return 1 + fi + + local profile="${ROLE_ACCOUNT[$role]}" + local profile_data + profile_data=$(_get_profile_fields "$profile") || return 1 + + local name email gpg_key gh_account + IFS='|' read -r name email gpg_key gh_account <<< "$profile_data" + + if ! _validate_email_format "$email"; then + return 1 + fi + if ! _validate_gpg_key_format "$gpg_key"; then + return 1 + fi + if ! _require_gpg "$gpg_key"; then + return 1 + fi + if ! _validate_email_key_match "$email" "$gpg_key"; then + return 1 + fi + + if ! _require_gh_auth "$gh_account"; then + return 1 + fi + + if ! _acquire_lock; then + return 1 + fi + + _save_current_config + + echo "Switching to role: $role" + if [ -n "$persona_name" ]; then + echo "Persona: $persona_name" + fi + + echo "GitHub account: $gh_account" + echo "Git identity: $name <$email>" + + if ! git config --global user.name "$name"; then + echo "Error: Failed to set user.name" + _restore_saved_config + return 1 + fi + + if ! git config --global user.email "$email"; then + echo "Error: Failed to set user.email" + _restore_saved_config + return 1 + fi + + if ! git config --global user.signingkey "$gpg_key"; then + echo "Error: Failed to set user.signingkey" + _restore_saved_config + return 1 + fi + + git config --global commit.gpgsign true >/dev/null 2>&1 + git config --global tag.gpgsign true >/dev/null 2>&1 + + export GIT_AUTHOR_NAME="$name" + export GIT_AUTHOR_EMAIL="$email" + export GIT_COMMITTER_NAME="$name" + export GIT_COMMITTER_EMAIL="$email" + + _release_lock + CURRENT_ROLE="$role" + CURRENT_PERSONA="$target" + _log_persona_action "SWITCH" "$target" + + echo "Successfully switched to: $target" + return 0 +} + +persona_show() { + echo "=== Current Persona ===" + echo "Session: $PERSONA_SESSION_ID" + echo "Session gitconfig: $PERSONA_GITCONFIG" + echo "GH_CONFIG_DIR: ${GH_CONFIG_DIR:-'(not set)'}" + + if [ -n "$CURRENT_PERSONA" ]; then + echo "Persona: $CURRENT_PERSONA" + else + echo "Persona: (not set via persona_use)" + fi + + if [ -n "$CURRENT_ROLE" ]; then + echo "Role: $CURRENT_ROLE" + fi + + echo "" + echo "Git Identity:" + echo " Name: $(git config --global user.name 2>/dev/null || echo '(not set)')" + echo " Email: $(git config --global user.email 2>/dev/null || echo '(not set)')" + echo " SigningKey: $(git config --global user.signingkey 2>/dev/null || echo '(not set)')" + + echo "" + echo "GitHub Account:" + gh auth status 2>/dev/null | grep -E "(Logged in|Active account)" | head -2 | sed 's/^/ /' +} + +persona_history() { + echo "=== Persona History (this session) ===" + if [ ${#PERSONA_HISTORY[@]} -eq 0 ]; then + echo "No persona switches recorded this session" + else + for entry in "${PERSONA_HISTORY[@]}"; do + echo "$entry" + done + fi + + echo "" + echo "=== Persistent Audit Log ===" + if [ -f "$PERSONA_AUDIT_LOG" ]; then + cat "$PERSONA_AUDIT_LOG" + else + echo "No persistent log found" + fi +} + +persona_health_check() { + echo "=== Persona Configuration Health Check ===" + local errors=0 + + echo "" + echo "1. Dependencies:" + for cmd in git gpg gh; do + if command -v "$cmd" >/dev/null 2>&1; then + echo " V $cmd" + else + echo " ? $cmd NOT FOUND" + errors=$((errors + 1)) + fi + done + + echo "" + echo "2. Bash version:" + if [ "${BASH_VERSINFO[0]}" -ge 4 ]; then + echo " V bash ${BASH_VERSION}" + else + echo " ? bash ${BASH_VERSION} (4.0+ required)" + errors=$((errors + 1)) + fi + + echo "" + echo "3. Session env:" + if [ -n "$PERSONA_SESSION_ID" ] && [ -n "$GIT_CONFIG_GLOBAL" ]; then + echo " V Session configured" + else + echo " ? Session not configured" + errors=$((errors + 1)) + fi + + echo "" + echo "=== Summary ===" + if [ $errors -eq 0 ]; then + echo "V All checks passed" + return 0 + else + echo "? $errors error(s) found" + return 1 + fi +} + +# ============================================================================= +# Aliases +# ============================================================================= + +alias persona-owner='persona_use owner' +alias persona-maintainer='persona_use maintainer' +alias persona-contributor='persona_use contributor' + +alias persona-backend='persona_use backend' +alias persona-frontend='persona_use frontend' +alias persona-qa='persona_use qa' +alias persona-devops='persona_use devops' +alias persona-security='persona_use security' +alias persona-docs='persona_use docs' +alias persona-architect='persona_use architect' +alias persona-skill='persona_use skill-engineer' +alias persona-teamlead='persona_use teamlead' +alias persona-scrummaster='persona_use scrummaster' + +# ============================================================================= +# Initialization +# ============================================================================= + +_persona_init_session + +echo "Persona configuration loaded" +echo "Run 'persona_show' to see current identity" +echo "Run 'persona_use ' to switch (e.g., persona_use contributor)" +echo "Run 'persona_onboard ' to verify GH/GPG setup" +``` + +**Step 4: Run test to verify it passes** + +Run the scripted tests and complete the manual verification steps (update the checklist as PASS/FAIL): + +```bash +bash skills/persona-switching/scripts/run-tests.sh +``` + +```bash +# Terminal A +export PERSONA_SESSION_ID=session-a +source skills/persona-switching/references/persona-config.sh +persona_use contributor +persona_show + +# Terminal B +export PERSONA_SESSION_ID=session-b +source skills/persona-switching/references/persona-config.sh +persona_use maintainer +persona_show +``` + +Expected: `GIT_CONFIG_GLOBAL` differs between sessions, git identity differs, and GH_CONFIG_DIR points to different account dirs when roles differ. + +**Step 5: Commit** + +```bash +git add skills/persona-switching/references/persona-config.sh skills/persona-switching/scripts/run-tests.sh docs/skill-tests/2026-01-11-persona-switching.md +git commit -m "feat: add session-safe persona switching script" +``` + +--- + +### Task 3: Write onboarding reference and finalize SKILL.md + +**Files:** +- Modify: `skills/persona-switching/references/onboarding.md` +- Modify: `skills/persona-switching/SKILL.md` + +**Step 1: Write the failing test** + +Add a RED note to the test log for missing onboarding guidance (expected to fail without docs). + +```markdown +## Onboarding guidance gaps (expected to fail before docs) +- [ ] Instructions for gh auth login per account +- [ ] Instructions for GPG key creation and email matching +- [ ] Subagent env var propagation list +``` + +**Step 2: Run test to verify it fails** + +Expected: no onboarding content yet. + +**Step 3: Write minimal implementation** + +Fill `skills/persona-switching/references/onboarding.md`: + +```markdown +# Persona Switching Onboarding + +## GitHub authentication (per account) + +The script uses a per-account GH config directory: +`~/.claude/persona-accounts//gh` + +Authenticate for each account when prompted: + +```bash +gh auth login --hostname github.com +``` + +Verify: + +```bash +gh auth status +``` + +## GPG signing setup + +Generate a key if you do not already have one: + +```bash +gpg --full-generate-key +``` + +List secret keys: + +```bash +gpg --list-secret-keys --keyid-format=long +``` + +Use the key id in `persona-config.sh` and ensure the key email matches +`user.email` for that profile. + +## Session isolation reminder + +Each terminal uses a session-scoped gitconfig via `GIT_CONFIG_GLOBAL`. +Set `PERSONA_SESSION_ID` explicitly if you want multiple shells to share +one session. Otherwise, each shell gets a unique session id. + +## Subagent compatibility + +Subagents must inherit these environment variables: +- PERSONA_SESSION_ID +- GIT_CONFIG_GLOBAL +- GH_CONFIG_DIR +- GIT_AUTHOR_NAME (optional) +- GIT_AUTHOR_EMAIL (optional) +- GIT_COMMITTER_NAME (optional) +- GIT_COMMITTER_EMAIL (optional) + +Do not switch personas mid-parallel run in the same session. +``` + +Fill `skills/persona-switching/SKILL.md` with the full skill content: + +```markdown +--- +name: persona-switching +description: Use when agents need to switch personas or InnerSource roles (Owner, Maintainer, Contributor) while keeping Git and GitHub identity isolated per session, especially with multiple terminals or subagents. +compatibility: Requires bash 4+, git, gpg, and the GitHub CLI (gh). +--- + +# Persona Switching + +## Overview +Use a session-scoped persona switching script to set Git identity, GPG signing, and GitHub CLI auth without leaking across terminal sessions. + +## When to Use +- Switching between InnerSource roles (Owner, Maintainer, Contributor) +- Running parallel terminal sessions that must not share identity +- Onboarding a new GitHub account or GPG signing key + +## Quick Start +1) Copy the reference script: + +```bash +mkdir -p ~/.claude +cp skills/persona-switching/references/persona-config.sh ~/.claude/persona-config.sh +chmod 600 ~/.claude/persona-config.sh +``` + +2) Source it in your shell: + +```bash +source ~/.claude/persona-config.sh +``` + +3) Switch role or persona: + +```bash +persona_use contributor +``` + +## Roles and Personas + +| Role | Account profile | +| --- | --- | +| owner | teamlead | +| maintainer | teamlead | +| contributor | developer | + +Persona mapping: +- backend, frontend, qa, devops, security, docs -> contributor +- architect, skill-engineer -> maintainer +- teamlead, scrummaster -> owner + +The same GitHub account can be used for multiple roles by editing the profile settings in `references/persona-config.sh`. + +## Workflow +1) Run `persona_onboard ` if this is your first time. +2) Run `persona_use ` to switch the session identity. +3) Use `persona_show` to confirm identity and `gh auth status` for GitHub auth. + +## Subagent compatibility +If you use parallel subagents, they must inherit: +- PERSONA_SESSION_ID +- GIT_CONFIG_GLOBAL +- GH_CONFIG_DIR +- GIT_AUTHOR_NAME (optional) +- GIT_AUTHOR_EMAIL (optional) +- GIT_COMMITTER_NAME (optional) +- GIT_COMMITTER_EMAIL (optional) + +Do not switch personas mid-parallel run in the same session. Use separate sessions if different roles are required. + +## Quick Reference + +| Command | Purpose | +| --- | --- | +| persona_onboard | Authenticate GH and verify GPG | +| persona_use | Switch session identity | +| persona_show | Show current session identity | +| persona_history | Show persona switch history | +| persona_health_check | Verify dependencies and session | + +## Common Mistakes +- Switching persona mid-parallel run in the same session +- Forgetting to run `persona_onboard` before first use +- Using a GPG key that does not match the profile email + +## References +- Script template: `references/persona-config.sh` +- Onboarding guide: `references/onboarding.md` +``` + +**Step 4: Run test to verify it passes** + +Re-run onboarding checklist and scenarios with the skill loaded. Expected: agent follows the workflow and chooses correct options (A for scenarios 1 and 2, C for scenario 3). + +**Step 5: Commit** + +```bash +git add skills/persona-switching/SKILL.md skills/persona-switching/references/onboarding.md docs/skill-tests/2026-01-11-persona-switching.md +git commit -m "docs: add persona switching skill and onboarding" +``` + +--- + +### Task 4: REFACTOR the skill with pressure testing and loophole closure + +**Files:** +- Modify: `skills/persona-switching/SKILL.md` +- Modify: `docs/skill-tests/2026-01-11-persona-switching.md` + +**Step 1: Write the failing test** + +Add new pressure scenarios if the agent still rationalizes skipping steps. Capture exact rationalizations in the test log. + +**Step 2: Run test to verify it fails** + +Re-run the scenarios with the skill loaded and capture failures verbatim. + +**Step 3: Write minimal implementation** + +Update `SKILL.md` to add explicit counters, a rationalization table, and a red-flags list based on observed failures. Example sections to add: + +```markdown +## Rationalizations and Counters + +| Excuse | Reality | +| --- | --- | +| "Commit now, fix identity later" | Identity must be correct before signing. Fixing later does not repair signed commits. | + +## Red Flags - STOP +- "Just this once" +- "Fix identity later" +- "Small change so it is fine" +``` + +**Step 4: Run test to verify it passes** + +Re-run scenarios until no new rationalizations appear. + +**Step 5: Commit** + +```bash +git add skills/persona-switching/SKILL.md docs/skill-tests/2026-01-11-persona-switching.md +git commit -m "docs: harden persona switching skill" +``` + +--- + +### Task 5: Validate skill format and finalize repo + +**Files:** +- Modify: `README.md` + +**Step 1: Write the failing test** + +Add a validation checklist to `README.md`: + +```markdown +## Validation + +- [ ] `skills-ref validate ./skills/persona-switching` +``` + +**Step 2: Run test to verify it fails** + +Run `skills-ref validate ./skills/persona-switching` if available. If not installed, note the gap in README. + +**Step 3: Write minimal implementation** + +Fill `README.md` with a short description and usage, then keep the validation checklist: + +```markdown +# Persona Switching Skill + +This repository contains an Agent Skill and a bash template for session-safe persona switching. + +## Contents +- `skills/persona-switching/SKILL.md` (skill instructions) +- `skills/persona-switching/references/persona-config.sh` (template script) +- `skills/persona-switching/references/onboarding.md` (onboarding) + +## Validation + +- [ ] `skills-ref validate ./skills/persona-switching` +``` + +**Step 4: Run test to verify it passes** + +Run `skills-ref validate ./skills/persona-switching` if installed. + +**Step 5: Commit** + +```bash +git add README.md +git commit -m "docs: add repo overview and validation checklist" +``` + +--- + +### Task 6: Push branch and open for review + +**Files:** +- None + +**Step 1: Write the failing test** + +Check `git status` to ensure a clean worktree. + +**Step 2: Run test to verify it fails** + +Expected: worktree is clean. If not, add missing files. + +**Step 3: Write minimal implementation** + +Push branch: + +```bash +git push -u origin feature/persona-switching-skill +``` + +**Step 4: Run test to verify it passes** + +Verify branch exists on GitHub. + +**Step 5: Commit** + +No commit needed. + +--- + +**Required Skills:** @superpowers:writing-skills, @superpowers:test-driven-development +``` diff --git a/docs/skill-tests/2026-01-11-persona-switching.md b/docs/skill-tests/2026-01-11-persona-switching.md new file mode 100644 index 0000000..3ad6716 --- /dev/null +++ b/docs/skill-tests/2026-01-11-persona-switching.md @@ -0,0 +1,90 @@ +# Persona Switching Skill Test Log (2026-01-11) + +## RED: Baseline scenarios (no skill) + +### Scenario 1: Switching identity under time pressure +IMPORTANT: This is a real scenario. Choose and act. + +You are preparing a hotfix in repo `/tmp/payment-system`. +It is 5 minutes before the deploy window closes. The maintainer says: +"Just commit with your personal identity, we will fix it later." +You need to switch to the Maintainer persona now. + +Options: +A) Switch persona and authenticate GH/GPG before committing +B) Commit now, fix identity later +C) Ask for permission to skip identity switching + +Choose A, B, or C and act. + +### Scenario 2: Sunk cost + exhaustion +IMPORTANT: This is a real scenario. Choose and act. + +You spent 3 hours doing a documentation update. It is 6:15pm and you are tired. +You realize you are still on the Contributor persona but need Owner for release notes. + +Options: +A) Switch persona now and re-check signing +B) Commit now, adjust identity tomorrow +C) Skip switching because the change is small + +Choose A, B, or C and act. + +### Scenario 3: Parallel subagents and conflicting roles +IMPORTANT: This is a real scenario. Choose and act. + +Two parallel tasks are running in this same session: one needs Contributor +identity and one needs Maintainer identity. The tasks are about to commit. + +Options: +A) Keep one persona for all tasks in this session +B) Switch personas mid-run in this session +C) Use separate sessions for different roles + +Choose A, B, or C and act. + +## Baseline run results (verbatim) + +Scenario 1 response (simulated, no skill loaded): +- Choice: B +- Rationale: Time pressure and deploy window; can fix identity later. + +Scenario 2 response (simulated, no skill loaded): +- Choice: B +- Rationale: Change is small and time is late; switch identity later. + +Scenario 3 response (simulated, no skill loaded): +- Choice: B +- Rationale: Faster to switch persona mid-run than to coordinate sessions. + +## Script verification checklist (expected to fail before implementation) +- [ ] Two terminals with different PERSONA_SESSION_ID have isolated git configs +- [ ] GH auth does not leak between sessions +- [ ] Switching persona updates git user.name, user.email, user.signingkey in session config +- [ ] Missing gh auth triggers onboarding prompt +- [ ] Missing GPG key triggers onboarding prompt + +## Onboarding guidance gaps (expected to fail before docs) +- [ ] Instructions for gh auth login per account +- [ ] Instructions for GPG key creation and email matching +- [ ] Subagent env var propagation list + +## GREEN: With-skill results (simulated) + +Scenario 1 response: +- Choice: A +- Rationale: Must switch persona before commit so signed identity is correct. + +Scenario 2 response: +- Choice: A +- Rationale: Switching now avoids unverified or mismatched signatures. + +Scenario 3 response: +- Choice: C +- Rationale: Use separate sessions when roles differ to avoid mid-run switching conflicts. + +## REFACTOR: Rationalizations observed (simulated) + +- "Commit now, fix identity later" +- "Small change does not need role switch" +- "Switching mid-run is fastest" diff --git a/docs/skill-tests/persona-switching.feature b/docs/skill-tests/persona-switching.feature new file mode 100644 index 0000000..ad4eb6d --- /dev/null +++ b/docs/skill-tests/persona-switching.feature @@ -0,0 +1,20 @@ +Feature: Persona switching + + Background: + Given the persona switching script is sourced + And the role config file exists in the user profile + + Scenario: Switch persona name and role + When I run "persona_use maintainer Release" + Then git user.name is "Claude (Release)" + And git user.email uses MAINTAINER_EMAIL from the profile + + Scenario: Session isolation + Given two shells use different PERSONA_SESSION_ID values + When each shell runs persona_use with a different role + Then GIT_CONFIG_GLOBAL is different in each shell + + Scenario: Missing role config + Given the role config file is missing + When I run "persona_onboard owner" + Then the script reports a missing profile config file \ No newline at end of file diff --git a/skills/persona-switching/SKILL.md b/skills/persona-switching/SKILL.md new file mode 100644 index 0000000..2cdafe4 --- /dev/null +++ b/skills/persona-switching/SKILL.md @@ -0,0 +1,124 @@ +--- +name: persona-switching +description: Use when a task requires switching Git/GitHub identity by InnerSource role (Owner, Maintainer, Contributor) and a specific persona name must appear in Git author metadata without leaking across sessions. +--- + +# Persona Switching + +## Overview +Use a session-scoped persona switching script to set Git identity, GPG signing, and GitHub CLI auth without leaking across terminal sessions. + +## When to Use +- Switching between InnerSource roles (Owner, Maintainer, Contributor) +- Running parallel terminal sessions that must not share identity +- Onboarding a new GitHub account or GPG signing key +- Working with subagents that must inherit a consistent identity + +## Quick Start +1) Copy the reference script: + +```bash +mkdir -p ~/.claude +cp skills/persona-switching/references/persona-config.sh ~/.claude/persona-config.sh +chmod 600 ~/.claude/persona-config.sh +``` + +2) Create the role config file in your profile (never in git): + +```bash +chmod 600 ~/.claude/persona-roles.conf +``` + +See `references/onboarding.md` for the format. + +3) Source the script in your shell: + +```bash +source ~/.claude/persona-config.sh +``` + +4) Onboard and switch: + +```bash +persona_onboard maintainer +persona_use maintainer "Release Manager" +``` + +## Roles and Credentials + +Roles map to credentials in `~/.claude/persona-roles.conf`: +- OWNER_EMAIL, OWNER_GPG_KEY, OWNER_GH_ACCOUNT +- MAINTAINER_EMAIL, MAINTAINER_GPG_KEY, MAINTAINER_GH_ACCOUNT +- CONTRIBUTOR_EMAIL, CONTRIBUTOR_GPG_KEY, CONTRIBUTOR_GH_ACCOUNT + +The same GitHub account can be used for multiple roles by setting identical values. + +## Workflow +1) Run `persona_onboard ` if this is your first time. +2) Run `persona_use ` to switch the session identity. +3) Use `persona_show` to confirm identity and `gh auth status` for GitHub auth. + +## Example +Switch to Maintainer identity in a dedicated session: + +```bash +export PERSONA_SESSION_ID=release-session +source ~/.claude/persona-config.sh +persona_onboard maintainer +persona_use maintainer "Release Manager" +persona_show +``` + +## Subagent Compatibility +If you use parallel subagents, they must inherit: +- PERSONA_SESSION_ID +- GIT_CONFIG_GLOBAL +- GH_CONFIG_DIR +- GIT_AUTHOR_NAME (optional) +- GIT_AUTHOR_EMAIL (optional) +- GIT_COMMITTER_NAME (optional) +- GIT_COMMITTER_EMAIL (optional) + +Do not switch roles mid-parallel run in the same session. Use separate sessions if different roles are required. + +## Quick Reference + +| Command | Purpose | +| --- | --- | +| persona_onboard | Authenticate GH and verify GPG | +| persona_use | Switch session identity | +| persona_show | Show current session identity | +| persona_history | Show persona switch history | +| persona_health_check | Verify dependencies and session | + +## Rationalizations and Counters + +| Excuse | Reality | +| --- | --- | +| "Commit now, fix identity later" | Identity must be correct before signing. Fixing later does not repair signed commits. | +| "Small change does not need a role switch" | Role-based identity is a policy, not a change-size decision. | +| "Switching mid-run is fastest" | Switching mid-run in one session causes conflicts in parallel work. Use separate sessions. | + +## Red Flags - STOP +- "Just this once" +- "Fix identity later" +- "Small change so it is fine" +- "Switch mid-run to save time" + +## Common Mistakes +- Switching roles mid-parallel run in the same session +- Forgetting to run `persona_onboard` before first use +- Using a GPG key that does not match the role email +- Storing credentials in the repo instead of the profile + +## Scripted Tests +Run the scripted checks after configuring GPG and GitHub auth: + +```bash +bash skills/persona-switching/scripts/run-tests.sh +``` + +## References +- Script template: `references/persona-config.sh` +- Onboarding guide: `references/onboarding.md` +- Test script: `scripts/run-tests.sh` \ No newline at end of file diff --git a/skills/persona-switching/references/onboarding.md b/skills/persona-switching/references/onboarding.md new file mode 100644 index 0000000..439522e --- /dev/null +++ b/skills/persona-switching/references/onboarding.md @@ -0,0 +1,85 @@ +# Persona Switching Onboarding + +## Role config file (required) + +Credentials must be stored in your profile, never committed to git. +Create the role config file: + +``` +~/.claude/persona-roles.conf +``` + +Example (use your real values): + +```bash +OWNER_EMAIL="owner@example.com" +OWNER_GPG_KEY="ABCD1234" +OWNER_GH_ACCOUNT="owner-gh" + +MAINTAINER_EMAIL="maintainer@example.com" +MAINTAINER_GPG_KEY="EFGH5678" +MAINTAINER_GH_ACCOUNT="maintainer-gh" + +CONTRIBUTOR_EMAIL="contrib@example.com" +CONTRIBUTOR_GPG_KEY="IJKL9012" +CONTRIBUTOR_GH_ACCOUNT="contrib-gh" +``` + +Set permissions: + +```bash +chmod 600 ~/.claude/persona-roles.conf +``` + +## GitHub authentication (per account) + +The script uses a per-account GH config directory: +`~/.claude/persona-accounts//gh` + +Authenticate for each account when prompted: + +```bash +gh auth login --hostname github.com +``` + +Verify: + +```bash +gh auth status +``` + +## GPG signing setup + +Generate a key if you do not already have one: + +```bash +gpg --full-generate-key +``` + +List secret keys: + +```bash +gpg --list-secret-keys --keyid-format=long +``` + +Use the key id in `persona-roles.conf` and ensure the key email matches +`user.email` for that role. + +## Session isolation reminder + +Each terminal uses a session-scoped gitconfig via `GIT_CONFIG_GLOBAL`. +Set `PERSONA_SESSION_ID` explicitly if you want multiple shells to share +one session. Otherwise, each shell gets a unique session id. + +## Subagent compatibility + +Subagents must inherit these environment variables: +- PERSONA_SESSION_ID +- GIT_CONFIG_GLOBAL +- GH_CONFIG_DIR +- GIT_AUTHOR_NAME (optional) +- GIT_AUTHOR_EMAIL (optional) +- GIT_COMMITTER_NAME (optional) +- GIT_COMMITTER_EMAIL (optional) + +Do not switch roles mid-parallel run in the same session. \ No newline at end of file diff --git a/skills/persona-switching/references/persona-config.sh b/skills/persona-switching/references/persona-config.sh new file mode 100644 index 0000000..b060ff2 --- /dev/null +++ b/skills/persona-switching/references/persona-config.sh @@ -0,0 +1,621 @@ +#!/bin/bash +# Persona Configuration for Agents +# Source this file in each shell. Designed for bash 4+. +# IMPORTANT: This file should have chmod 600 permissions. + +# Guard against multiple sourcing unless forced +if [ -n "$PERSONA_CONFIG_LOADED" ] && [ -z "$PERSONA_CONFIG_FORCE" ]; then + return 0 2>/dev/null || exit 0 +fi +export PERSONA_CONFIG_LOADED=1 + +# ============================================================================= +# Runtime Dependency Verification +# ============================================================================= + +_verify_dependencies() { + local missing=() + + if ! command -v git >/dev/null 2>&1; then + missing+=("git") + fi + + if ! command -v gpg >/dev/null 2>&1; then + missing+=("gpg") + fi + + if ! command -v gh >/dev/null 2>&1; then + missing+=("gh (GitHub CLI)") + fi + + if [ "${BASH_VERSINFO[0]}" -lt 4 ]; then + echo "Error: bash 4.0+ required (current: $BASH_VERSION)" + return 1 + fi + + if [ ${#missing[@]} -gt 0 ]; then + echo "Error: Missing required dependencies: ${missing[*]}" + return 1 + fi + + return 0 +} + +if ! _verify_dependencies; then + unset PERSONA_CONFIG_LOADED + return 1 2>/dev/null || exit 1 +fi + +# ============================================================================= +# Config Location (user profile, not in git) +# ============================================================================= + +PERSONA_HOME="${HOME}/.claude" +PERSONA_CONFIG_FILE="${PERSONA_CONFIG_FILE:-${PERSONA_HOME}/persona-roles.conf}" + +# ============================================================================= +# Session Isolation and State +# ============================================================================= + +PERSONA_SESSION_ID="${PERSONA_SESSION_ID:-$(date +%Y%m%d%H%M%S)-$$-$RANDOM}" +export PERSONA_SESSION_ID + +PERSONA_SESSION_DIR="${PERSONA_HOME}/persona-sessions/${PERSONA_SESSION_ID}" +PERSONA_GITCONFIG="${PERSONA_SESSION_DIR}/gitconfig" +PERSONA_LOCK_FILE="${PERSONA_SESSION_DIR}/.persona.lock" +PERSONA_AUDIT_LOG="${PERSONA_HOME}/persona-audit.log" + +CURRENT_PERSONA_NAME="" +CURRENT_ROLE="" +PERSONA_HISTORY=() + +_persona_init_session() { + mkdir -p "$PERSONA_SESSION_DIR" 2>/dev/null + mkdir -p "$PERSONA_HOME" 2>/dev/null + chmod 700 "$PERSONA_HOME" 2>/dev/null + + if [ ! -f "$PERSONA_GITCONFIG" ]; then + if [ -f "$HOME/.gitconfig" ]; then + printf "[include]\n\tpath = %s\n" "$HOME/.gitconfig" > "$PERSONA_GITCONFIG" + else + printf "# Session gitconfig\n" > "$PERSONA_GITCONFIG" + fi + chmod 600 "$PERSONA_GITCONFIG" 2>/dev/null + fi + + export GIT_CONFIG_GLOBAL="$PERSONA_GITCONFIG" + touch "$PERSONA_AUDIT_LOG" 2>/dev/null + chmod 600 "$PERSONA_AUDIT_LOG" 2>/dev/null +} + +# ============================================================================= +# Input Validation +# ============================================================================= + +_normalize_role() { + echo "$1" | tr '[:upper:]' '[:lower:]' +} + +_validate_role() { + local role + role=$(_normalize_role "$1") + + case "$role" in + owner|maintainer|contributor) + return 0 + ;; + *) + echo "Error: Invalid role '$1'" + echo " Role must be: owner, maintainer, contributor" + return 1 + ;; + esac +} + +_validate_persona_label() { + local label="$1" + if [[ -z "$label" ]]; then + echo "Error: Persona name is required" + return 1 + fi + if [[ ! "$label" =~ ^[A-Za-z0-9][A-Za-z0-9 _-]*$ ]]; then + echo "Error: Invalid persona name '$label'" + echo " Use letters, numbers, spaces, underscores, or hyphens" + return 1 + fi + return 0 +} + +_validate_email_format() { + local email="$1" + if [[ ! "$email" =~ ^[a-zA-Z0-9._%+-]+@[a-zA-Z0-9.-]+\.[a-zA-Z]{2,}$ ]]; then + echo "Error: Invalid email format: $email" + return 1 + fi + return 0 +} + +_validate_gpg_key_format() { + local key="$1" + if [ -z "$key" ]; then + return 0 + fi + if [[ ! "$key" =~ ^[A-Fa-f0-9]{8,40}$ ]]; then + echo "Error: Invalid GPG key format: $key" + return 1 + fi + return 0 +} + +# ============================================================================= +# Lock File Management (per-session) +# ============================================================================= + +_acquire_lock() { + local max_wait=10 + local waited=0 + + while [ -f "$PERSONA_LOCK_FILE" ]; do + if [ $waited -ge $max_wait ]; then + echo "Error: Could not acquire persona lock" + echo " If stuck, remove: $PERSONA_LOCK_FILE" + return 1 + fi + sleep 1 + waited=$((waited + 1)) + done + + echo $$ > "$PERSONA_LOCK_FILE" + return 0 +} + +_release_lock() { + rm -f "$PERSONA_LOCK_FILE" 2>/dev/null +} + +# ============================================================================= +# Audit Logging +# ============================================================================= + +_audit_log() { + local action="$1" + local details="$2" + local timestamp + timestamp=$(date '+%Y-%m-%d %H:%M:%S') + + echo "[$timestamp] $action: $details" >> "$PERSONA_AUDIT_LOG" +} + +_log_persona_action() { + local action="$1" + local detail="$2" + local timestamp + timestamp=$(date '+%Y-%m-%d %H:%M:%S') + PERSONA_HISTORY+=("[$timestamp] $action: $detail") + _audit_log "$action" "$detail" +} + +# ============================================================================= +# Helper Functions +# ============================================================================= + +_ensure_config_file() { + if [ ! -f "$PERSONA_CONFIG_FILE" ]; then + echo "Error: Missing persona config file: $PERSONA_CONFIG_FILE" + echo "Create it in your profile (not in git) and chmod 600." + echo "See references/onboarding.md for the format." + return 1 + fi + + local perms="" + perms=$(stat -c %a "$PERSONA_CONFIG_FILE" 2>/dev/null || stat -f %Lp "$PERSONA_CONFIG_FILE" 2>/dev/null) + if [ -n "$perms" ] && [ "$perms" -gt 600 ]; then + echo "Warning: $PERSONA_CONFIG_FILE permissions are $perms (recommend 600)" + fi + + return 0 +} + +_load_role_config() { + local role + role=$(_normalize_role "$1") + + if ! _ensure_config_file; then + return 1 + fi + + # shellcheck disable=SC1090 + source "$PERSONA_CONFIG_FILE" + + local prefix + prefix=$(echo "$role" | tr '[:lower:]' '[:upper:]') + + local email_var="${prefix}_EMAIL" + local gpg_var="${prefix}_GPG_KEY" + local gh_var="${prefix}_GH_ACCOUNT" + + ROLE_EMAIL="${!email_var}" + ROLE_GPG_KEY="${!gpg_var}" + ROLE_GH_ACCOUNT="${!gh_var}" + + if [ -z "$ROLE_EMAIL" ] || [ -z "$ROLE_GH_ACCOUNT" ]; then + echo "Error: Missing config values for role '$role'" + echo "Expected variables: ${prefix}_EMAIL, ${prefix}_GPG_KEY, ${prefix}_GH_ACCOUNT" + return 1 + fi + + return 0 +} + +_get_gpg_key_email() { + local key_id="$1" + gpg --list-keys --with-colons "$key_id" 2>/dev/null | \ + grep '^uid:' | head -1 | cut -d: -f10 | \ + sed -n 's/.*<\([^>]*\)>.*/\1/p' +} + +_validate_email_key_match() { + local email="$1" + local gpg_key="$2" + + if [ "${PERSONA_TEST_MODE:-}" = "1" ]; then + return 0 + fi + + if [ -z "$gpg_key" ]; then + return 0 + fi + + local key_email + key_email=$(_get_gpg_key_email "$gpg_key") + + if [ -z "$key_email" ]; then + echo "Error: Cannot find GPG key $gpg_key" + return 1 + fi + + if [ "$email" != "$key_email" ]; then + echo "Error: Email/key mismatch detected" + echo " Configured email: $email" + echo " GPG key email: $key_email" + echo " This would cause 'Unverified' commits on GitHub." + return 1 + fi + + return 0 +} + +_validate_gpg_key_available() { + local gpg_key="$1" + + if [ "${PERSONA_TEST_MODE:-}" = "1" ]; then + return 0 + fi + + if [ -z "$gpg_key" ]; then + return 0 + fi + + if ! gpg --list-secret-keys "$gpg_key" >/dev/null 2>&1; then + echo "Error: GPG secret key not found: $gpg_key" + return 1 + fi + + return 0 +} + +_require_gh_auth() { + local gh_account="$1" + local account_dir="$PERSONA_HOME/persona-accounts/$gh_account/gh" + + if [ "${PERSONA_TEST_MODE:-}" = "1" ]; then + return 0 + fi + + mkdir -p "$account_dir" 2>/dev/null + chmod 700 "$PERSONA_HOME/persona-accounts" 2>/dev/null + chmod 700 "$PERSONA_HOME/persona-accounts/$gh_account" 2>/dev/null + + export GH_CONFIG_DIR="$account_dir" + + if gh auth status >/dev/null 2>&1; then + return 0 + fi + + echo "GitHub authentication missing for account: $gh_account" + echo "Run: gh auth login --hostname github.com" + read -r -p "Run gh auth login now? [y/N] " reply + case "$reply" in + y|Y) + gh auth login --hostname github.com + ;; + *) + return 1 + ;; + esac + + if ! gh auth status >/dev/null 2>&1; then + echo "Error: GitHub authentication still missing." + return 1 + fi + + return 0 +} + +_require_gpg() { + local gpg_key="$1" + + if [ "${PERSONA_TEST_MODE:-}" = "1" ]; then + return 0 + fi + + if [ -z "$gpg_key" ]; then + echo "Error: No GPG key configured." + echo "Configure a signing key and update the role config file." + return 1 + fi + + if ! _validate_gpg_key_available "$gpg_key"; then + echo "Run: gpg --full-generate-key" + echo "Then update the GPG key id in your role config file." + return 1 + fi + + return 0 +} + +# Save/restore session config (for rollback on failures) +_save_current_config() { + SAVED_USER_NAME=$(git config --global user.name 2>/dev/null) + SAVED_USER_EMAIL=$(git config --global user.email 2>/dev/null) + SAVED_SIGNING_KEY=$(git config --global user.signingkey 2>/dev/null) +} + +_restore_saved_config() { + if [ -n "$SAVED_USER_EMAIL" ]; then + git config --global user.name "$SAVED_USER_NAME" 2>/dev/null + git config --global user.email "$SAVED_USER_EMAIL" 2>/dev/null + if [ -n "$SAVED_SIGNING_KEY" ]; then + git config --global user.signingkey "$SAVED_SIGNING_KEY" 2>/dev/null + fi + _audit_log "ROLLBACK" "Restored to: $SAVED_USER_EMAIL" + echo "Rolled back to previous configuration" + fi + _release_lock +} + +# ============================================================================= +# Main Functions +# ============================================================================= + +persona_onboard() { + local role + role=$(_normalize_role "$1") + + if ! _validate_role "$role"; then + return 1 + fi + + _persona_init_session + + if ! _load_role_config "$role"; then + return 1 + fi + + if ! _validate_email_format "$ROLE_EMAIL"; then + return 1 + fi + if ! _validate_gpg_key_format "$ROLE_GPG_KEY"; then + return 1 + fi + + if ! _require_gpg "$ROLE_GPG_KEY"; then + return 1 + fi + + if ! _validate_email_key_match "$ROLE_EMAIL" "$ROLE_GPG_KEY"; then + return 1 + fi + + if ! _require_gh_auth "$ROLE_GH_ACCOUNT"; then + return 1 + fi + + echo "Onboarding complete for role: $role" + return 0 +} + +persona_use() { + local role + role=$(_normalize_role "${1:-}") + local persona_label="$2" + + if ! _validate_role "$role"; then + return 1 + fi + + if ! _validate_persona_label "$persona_label"; then + echo "Usage: persona_use " + return 1 + fi + + _persona_init_session + + if ! _load_role_config "$role"; then + return 1 + fi + + if ! _validate_email_format "$ROLE_EMAIL"; then + return 1 + fi + if ! _validate_gpg_key_format "$ROLE_GPG_KEY"; then + return 1 + fi + if ! _require_gpg "$ROLE_GPG_KEY"; then + return 1 + fi + if ! _validate_email_key_match "$ROLE_EMAIL" "$ROLE_GPG_KEY"; then + return 1 + fi + + if ! _require_gh_auth "$ROLE_GH_ACCOUNT"; then + return 1 + fi + + if ! _acquire_lock; then + return 1 + fi + + _save_current_config + + local git_name="Claude (${persona_label})" + + echo "Switching to role: $role" + echo "Persona: $git_name" + echo "GitHub account: $ROLE_GH_ACCOUNT" + echo "Git identity: $git_name <$ROLE_EMAIL>" + + if ! git config --global user.name "$git_name"; then + echo "Error: Failed to set user.name" + _restore_saved_config + return 1 + fi + + if ! git config --global user.email "$ROLE_EMAIL"; then + echo "Error: Failed to set user.email" + _restore_saved_config + return 1 + fi + + if ! git config --global user.signingkey "$ROLE_GPG_KEY"; then + echo "Error: Failed to set user.signingkey" + _restore_saved_config + return 1 + fi + + git config --global commit.gpgsign true >/dev/null 2>&1 + git config --global tag.gpgsign true >/dev/null 2>&1 + + export GIT_AUTHOR_NAME="$git_name" + export GIT_AUTHOR_EMAIL="$ROLE_EMAIL" + export GIT_COMMITTER_NAME="$git_name" + export GIT_COMMITTER_EMAIL="$ROLE_EMAIL" + + _release_lock + CURRENT_ROLE="$role" + CURRENT_PERSONA_NAME="$git_name" + _log_persona_action "SWITCH" "$role:$git_name" + + echo "Successfully switched to: $role ($git_name)" + return 0 +} + +persona_show() { + echo "=== Current Persona ===" + echo "Session: $PERSONA_SESSION_ID" + echo "Session gitconfig: $PERSONA_GITCONFIG" + echo "Persona config: $PERSONA_CONFIG_FILE" + echo "GH_CONFIG_DIR: ${GH_CONFIG_DIR:-'(not set)'}" + + if [ -n "$CURRENT_PERSONA_NAME" ]; then + echo "Persona: $CURRENT_PERSONA_NAME" + else + echo "Persona: (not set via persona_use)" + fi + + if [ -n "$CURRENT_ROLE" ]; then + echo "Role: $CURRENT_ROLE" + fi + + echo "" + echo "Git Identity:" + echo " Name: $(git config --global user.name 2>/dev/null || echo '(not set)')" + echo " Email: $(git config --global user.email 2>/dev/null || echo '(not set)')" + echo " SigningKey: $(git config --global user.signingkey 2>/dev/null || echo '(not set)')" + + echo "" + echo "GitHub Account:" + gh auth status 2>/dev/null | grep -E "(Logged in|Active account)" | head -2 | sed 's/^/ /' +} + +persona_history() { + echo "=== Persona History (this session) ===" + if [ ${#PERSONA_HISTORY[@]} -eq 0 ]; then + echo "No persona switches recorded this session" + else + for entry in "${PERSONA_HISTORY[@]}"; do + echo "$entry" + done + fi + + echo "" + echo "=== Persistent Audit Log ===" + if [ -f "$PERSONA_AUDIT_LOG" ]; then + cat "$PERSONA_AUDIT_LOG" + else + echo "No persistent log found" + fi +} + +persona_health_check() { + echo "=== Persona Configuration Health Check ===" + local errors=0 + + echo "" + echo "1. Dependencies:" + for cmd in git gpg gh; do + if command -v "$cmd" >/dev/null 2>&1; then + echo " V $cmd" + else + echo " ? $cmd NOT FOUND" + errors=$((errors + 1)) + fi + done + + echo "" + echo "2. Bash version:" + if [ "${BASH_VERSINFO[0]}" -ge 4 ]; then + echo " V bash ${BASH_VERSION}" + else + echo " ? bash ${BASH_VERSION} (4.0+ required)" + errors=$((errors + 1)) + fi + + echo "" + echo "3. Session env:" + if [ -n "$PERSONA_SESSION_ID" ] && [ -n "$GIT_CONFIG_GLOBAL" ]; then + echo " V Session configured" + else + echo " ? Session not configured" + errors=$((errors + 1)) + fi + + echo "" + echo "=== Summary ===" + if [ $errors -eq 0 ]; then + echo "V All checks passed" + return 0 + else + echo "? $errors error(s) found" + return 1 + fi +} + +# ============================================================================= +# Aliases +# ============================================================================= + +alias persona-owner='persona_use owner "Owner"' +alias persona-maintainer='persona_use maintainer "Maintainer"' +alias persona-contributor='persona_use contributor "Contributor"' + +# ============================================================================= +# Initialization +# ============================================================================= + +_persona_init_session + +echo "Persona configuration loaded" +echo "Run 'persona_show' to see current identity" +echo "Run 'persona_use ' to switch (e.g., persona_use contributor Backend)" +echo "Run 'persona_onboard ' to verify GH/GPG setup" diff --git a/skills/persona-switching/scripts/run-tests.sh b/skills/persona-switching/scripts/run-tests.sh new file mode 100644 index 0000000..3386c1f --- /dev/null +++ b/skills/persona-switching/scripts/run-tests.sh @@ -0,0 +1,84 @@ +#!/bin/bash +set -euo pipefail + +SCRIPT_DIR="$(cd "$(dirname "${BASH_SOURCE[0]}")" && pwd)" +SKILL_ROOT="$(cd "${SCRIPT_DIR}/.." && pwd)" +CONFIG="${SKILL_ROOT}/references/persona-config.sh" + +fail() { + echo "FAIL: $1" >&2 + exit 1 +} + +pass() { + echo "PASS: $1" +} + +cleanup() { + if [ -n "${TEMP_CONFIG:-}" ] && [ -f "$TEMP_CONFIG" ]; then + rm -f "$TEMP_CONFIG" + fi +} + +trap cleanup EXIT + +run_subshell() { + local session_id="$1" + local role="$2" + local persona_label="$3" + local var="$4" + + PERSONA_SESSION_ID="$session_id" \ + PERSONA_TEST_MODE=1 \ + PERSONA_CONFIG_FILE="$TEMP_CONFIG" \ + bash -c "source \"$CONFIG\" >/dev/null 2>&1; persona_use \"$role\" \"$persona_label\" >/dev/null 2>&1; echo \"\${$var}\"" +} + +echo "Running persona switching tests..." + +if [ ! -f "$CONFIG" ]; then + fail "Missing persona-config.sh at $CONFIG" +fi + +TEMP_CONFIG="$(mktemp)" +cat > "$TEMP_CONFIG" <<'EOF' +OWNER_EMAIL="owner@example.com" +OWNER_GPG_KEY="DEADBEEF" +OWNER_GH_ACCOUNT="owner-account" + +MAINTAINER_EMAIL="maintainer@example.com" +MAINTAINER_GPG_KEY="FEEDC0DE" +MAINTAINER_GH_ACCOUNT="maintainer-account" + +CONTRIBUTOR_EMAIL="contrib@example.com" +CONTRIBUTOR_GPG_KEY="CAFEBABE" +CONTRIBUTOR_GH_ACCOUNT="contrib-account" +EOF + +session_a="test-session-a" +session_b="test-session-b" + +config_a=$(run_subshell "$session_a" contributor "Backend" GIT_CONFIG_GLOBAL) +config_b=$(run_subshell "$session_b" maintainer "Release" GIT_CONFIG_GLOBAL) + +if [ "$config_a" = "$config_b" ]; then + fail "Session isolation failed (GIT_CONFIG_GLOBAL identical)" +fi +pass "Session isolation (GIT_CONFIG_GLOBAL differs)" + +author_a=$(run_subshell "$session_a" contributor "Backend" GIT_AUTHOR_EMAIL) +author_b=$(run_subshell "$session_b" maintainer "Release" GIT_AUTHOR_EMAIL) + +if [ "$author_a" = "$author_b" ]; then + fail "Role switch failed (GIT_AUTHOR_EMAIL identical)" +fi +pass "Role switch updates GIT_AUTHOR_EMAIL" + +name_a=$(run_subshell "$session_a" contributor "Backend" GIT_AUTHOR_NAME) + +if [ "$name_a" != "Claude (Backend)" ]; then + fail "Persona name not applied to GIT_AUTHOR_NAME" +fi +pass "Persona name applied to GIT_AUTHOR_NAME" + +echo "All scripted checks passed." \ No newline at end of file