diff --git a/README.md b/README.md index de8383e..2183ab4 100644 --- a/README.md +++ b/README.md @@ -45,11 +45,20 @@ pip install cli-mem pip install "cli-mem[ai]" ``` -Then activate the shell hook: +Then activate the shell hook for your shell: ```bash +# zsh echo 'eval "$(mem init zsh)"' >> ~/.zshrc source ~/.zshrc + +# bash +echo 'eval "$(mem init bash)"' >> ~/.bashrc +source ~/.bashrc + +# fish +echo 'mem init fish | source' >> ~/.config/fish/config.fish +source ~/.config/fish/config.fish ``` That's it. Every command you type is now silently captured with full context (directory, git repo, exit code, duration). @@ -142,6 +151,8 @@ mem export k8s # copy JSON to clipboard mem export k8s --format markdown # copy as markdown mem export k8s --stdout # print instead of clipboard +mem import # import from clipboard (auto-detect format + group name) +mem import -g renamed # import from clipboard with custom group name mem import runbook.json -g ops # import from file (auto-detects format) mem import runbook.md -g ops # markdown works too ``` @@ -248,7 +259,7 @@ mem stats # top commands, repos, totals mem stats --json # machine-readable stats mem forget "API_KEY=sk-..." # permanently delete matching commands mem forget "password" --yes # skip confirmation -mem init zsh # print shell hook code +mem init zsh # print shell hook code (also: bash, fish) ``` --- @@ -350,7 +361,7 @@ brew uninstall mem # or: pip uninstall cli-mem rm -rf ~/.mem # remove all captured data ``` -Remove the `eval "$(mem init zsh)"` line from `~/.zshrc`. +Remove the shell hook line from your shell config (`~/.zshrc`, `~/.bashrc`, or `~/.config/fish/config.fish`). --- diff --git a/command-groups/databases/postgres-debug.json b/command-groups/databases/postgres-debug.json new file mode 100644 index 0000000..a053467 --- /dev/null +++ b/command-groups/databases/postgres-debug.json @@ -0,0 +1,31 @@ +{ + "postgres-debug": { + "description": "PostgreSQL troubleshooting -- connections, locks, slow queries", + "commands": [ + { + "cmd": "psql -h $PGHOST -U $PGUSER -d $PGDATABASE -c \"SELECT count(*), state FROM pg_stat_activity GROUP BY state ORDER BY count DESC;\"", + "comment": "connection count by state -- idle in transaction is a red flag" + }, + { + "cmd": "psql -h $PGHOST -U $PGUSER -d $PGDATABASE -c \"SELECT pid, now() - pg_stat_activity.query_start AS duration, query FROM pg_stat_activity WHERE state = 'active' AND query NOT LIKE '%pg_stat_activity%' ORDER BY duration DESC LIMIT 10;\"", + "comment": "currently running queries sorted by duration" + }, + { + "cmd": "psql -h $PGHOST -U $PGUSER -d $PGDATABASE -c \"SELECT blocked.pid AS blocked_pid, blocked.query AS blocked_query, blocking.pid AS blocking_pid, blocking.query AS blocking_query FROM pg_stat_activity blocked JOIN pg_locks bl ON bl.pid = blocked.pid JOIN pg_locks bk ON bk.locktype = bl.locktype AND bk.relation = bl.relation AND bk.pid != bl.pid JOIN pg_stat_activity blocking ON bk.pid = blocking.pid WHERE NOT bl.granted;\"", + "comment": "blocking lock chains -- who's waiting on whom" + }, + { + "cmd": "psql -h $PGHOST -U $PGUSER -d $PGDATABASE -c \"SELECT schemaname, relname, n_tup_ins, n_tup_upd, n_tup_del, n_live_tup, n_dead_tup, last_autovacuum FROM pg_stat_user_tables ORDER BY n_dead_tup DESC LIMIT 10;\"", + "comment": "tables with most dead tuples -- needs vacuum" + }, + { + "cmd": "psql -h $PGHOST -U $PGUSER -d $PGDATABASE -c \"SELECT relname, pg_size_pretty(pg_total_relation_size(relid)) AS total, pg_size_pretty(pg_indexes_size(relid)) AS indexes FROM pg_catalog.pg_statio_user_tables ORDER BY pg_total_relation_size(relid) DESC LIMIT 10;\"", + "comment": "largest tables with index size -- where's the disk going" + }, + { + "cmd": "psql -h $PGHOST -U $PGUSER -d $PGDATABASE -c \"SELECT indexrelname, idx_scan, pg_size_pretty(pg_relation_size(indexrelid)) AS size FROM pg_stat_user_indexes WHERE idx_scan = 0 ORDER BY pg_relation_size(indexrelid) DESC LIMIT 10;\"", + "comment": "unused indexes -- wasting disk and slowing writes" + } + ] + } +} diff --git a/command-groups/databases/redis-ops.json b/command-groups/databases/redis-ops.json new file mode 100644 index 0000000..2c80da1 --- /dev/null +++ b/command-groups/databases/redis-ops.json @@ -0,0 +1,31 @@ +{ + "redis-ops": { + "description": "Redis operational checks -- memory, slow queries, key analysis", + "commands": [ + { + "cmd": "redis-cli -h $REDIS_HOST -a $REDIS_PASSWORD INFO memory | grep -E 'used_memory_human|maxmemory_human|mem_fragmentation_ratio'", + "comment": "memory usage and fragmentation -- ratio > 1.5 means wasted RAM" + }, + { + "cmd": "redis-cli -h $REDIS_HOST -a $REDIS_PASSWORD INFO clients | grep -E 'connected_clients|blocked_clients'", + "comment": "client connections -- blocked_clients > 0 means something is stuck" + }, + { + "cmd": "redis-cli -h $REDIS_HOST -a $REDIS_PASSWORD SLOWLOG GET 10", + "comment": "recent slow commands -- KEYS * in production is the usual suspect" + }, + { + "cmd": "redis-cli -h $REDIS_HOST -a $REDIS_PASSWORD INFO keyspace", + "comment": "key count and expiry stats per database" + }, + { + "cmd": "redis-cli -h $REDIS_HOST -a $REDIS_PASSWORD --bigkeys --no-auth-warning 2>/dev/null", + "comment": "find the largest keys -- memory hogs that might need restructuring" + }, + { + "cmd": "redis-cli -h $REDIS_HOST -a $REDIS_PASSWORD INFO replication | grep -E 'role|connected_slaves|master_link_status'", + "comment": "replication health -- master_link_status should be 'up'" + } + ] + } +} diff --git a/command-groups/devops/docker-cleanup.json b/command-groups/devops/docker-cleanup.json new file mode 100644 index 0000000..022e93d --- /dev/null +++ b/command-groups/devops/docker-cleanup.json @@ -0,0 +1,31 @@ +{ + "docker-cleanup": { + "description": "Docker system maintenance -- recover disk space without nuking active workloads", + "commands": [ + { + "cmd": "docker system df", + "comment": "disk usage breakdown by images, containers, volumes" + }, + { + "cmd": "docker ps -a --filter status=exited --format 'table {{.ID}}\t{{.Names}}\t{{.Status}}\t{{.Size}}'", + "comment": "stopped containers still eating disk" + }, + { + "cmd": "docker images --format 'table {{.Repository}}\t{{.Tag}}\t{{.Size}}\t{{.CreatedSince}}' | sort -k3 -h -r | head -20", + "comment": "largest images -- candidates for cleanup" + }, + { + "cmd": "docker images -f dangling=true -q | xargs -r docker rmi", + "comment": "remove dangling images (untagged layers from rebuilds)" + }, + { + "cmd": "docker volume ls -f dangling=true -q | xargs -r docker volume rm", + "comment": "remove orphaned volumes -- check before running" + }, + { + "cmd": "docker builder prune --filter until=72h -f", + "comment": "clear build cache older than 3 days" + } + ] + } +} diff --git a/command-groups/devops/incident-response.json b/command-groups/devops/incident-response.json new file mode 100644 index 0000000..80fe7b2 --- /dev/null +++ b/command-groups/devops/incident-response.json @@ -0,0 +1,31 @@ +{ + "incident-response": { + "description": "First 5 minutes of a production incident -- gather signal fast", + "commands": [ + { + "cmd": "kubectl get pods -n $NAMESPACE | grep -vE 'Running|Completed'", + "comment": "unhealthy pods right now" + }, + { + "cmd": "kubectl logs -l app=$SERVICE -n $NAMESPACE --tail=50 --since=5m | grep -iE 'error|fatal|panic|exception'", + "comment": "recent errors across all pods of the service" + }, + { + "cmd": "kubectl top pods -n $NAMESPACE --sort-by=cpu | head -5", + "comment": "CPU spikes -- runaway processes or hot loops" + }, + { + "cmd": "kubectl rollout history deployment/$SERVICE -n $NAMESPACE | tail -5", + "comment": "recent deploys -- was something just shipped?" + }, + { + "cmd": "kubectl get endpoints $SERVICE -n $NAMESPACE -o yaml | grep -c 'ip:'", + "comment": "how many pods are actually receiving traffic" + }, + { + "cmd": "kubectl describe ingress -n $NAMESPACE | grep -A2 $SERVICE", + "comment": "ingress rules -- is traffic even reaching the right service?" + } + ] + } +} diff --git a/command-groups/devops/k8s-debug.json b/command-groups/devops/k8s-debug.json new file mode 100644 index 0000000..fe45b52 --- /dev/null +++ b/command-groups/devops/k8s-debug.json @@ -0,0 +1,35 @@ +{ + "k8s-debug": { + "description": "Kubernetes troubleshooting -- from symptom to root cause", + "commands": [ + { + "cmd": "kubectl get pods -n $NAMESPACE --field-selector status.phase!=Running", + "comment": "pods not in Running state -- the starting point" + }, + { + "cmd": "kubectl describe pod $POD -n $NAMESPACE | tail -30", + "comment": "events section -- scheduling failures, image pulls, OOMs" + }, + { + "cmd": "kubectl logs $POD -n $NAMESPACE --tail=100 --previous", + "comment": "logs from the previous crashed container" + }, + { + "cmd": "kubectl top pods -n $NAMESPACE --sort-by=memory | head -10", + "comment": "memory hogs -- approaching limits means OOMKill incoming" + }, + { + "cmd": "kubectl get events -n $NAMESPACE --sort-by='.lastTimestamp' | tail -20", + "comment": "recent cluster events -- often reveals the actual problem" + }, + { + "cmd": "kubectl get hpa -n $NAMESPACE", + "comment": "autoscaler status -- is it maxed out or thrashing?" + }, + { + "cmd": "kubectl exec -it $POD -n $NAMESPACE -- sh -c 'wget -qO- http://localhost:$PORT/health || curl -sf http://localhost:$PORT/health'", + "comment": "health check from inside the pod -- bypasses ingress/service" + } + ] + } +} diff --git a/command-groups/git/cleanup.json b/command-groups/git/cleanup.json new file mode 100644 index 0000000..b933966 --- /dev/null +++ b/command-groups/git/cleanup.json @@ -0,0 +1,31 @@ +{ + "git-cleanup": { + "description": "Branch and repo cleanup -- keep the repo lean", + "commands": [ + { + "cmd": "git branch --merged main | grep -v 'main\\|master\\|\\*' | xargs -r git branch -d", + "comment": "delete local branches already merged to main" + }, + { + "cmd": "git remote prune origin --dry-run", + "comment": "preview stale remote-tracking branches (remove --dry-run to prune)" + }, + { + "cmd": "git branch -vv | grep ': gone]' | awk '{print $1}'", + "comment": "local branches whose remote was deleted -- safe to remove" + }, + { + "cmd": "git reflog expire --expire=30.days --all && git gc --prune=now --aggressive", + "comment": "aggressive gc -- reclaims space from rewritten history" + }, + { + "cmd": "git stash list", + "comment": "forgotten stashes -- review before they become mysteries" + }, + { + "cmd": "git log --oneline --all --graph --decorate | head -30", + "comment": "visual branch topology -- understand what needs merging" + } + ] + } +} diff --git a/command-groups/git/forensics.json b/command-groups/git/forensics.json new file mode 100644 index 0000000..a1be4d0 --- /dev/null +++ b/command-groups/git/forensics.json @@ -0,0 +1,35 @@ +{ + "git-forensics": { + "description": "Git archaeology -- find secrets, trace bugs, understand history", + "commands": [ + { + "cmd": "git log --all --oneline -S '$SEARCH_STRING' -- '*.py' '*.js' '*.go' '*.yaml' '*.yml' '*.json' '*.env'", + "comment": "find every commit that added or removed a string (pickaxe search)" + }, + { + "cmd": "git log --all --diff-filter=D --name-only --pretty=format:'%h %s' | head -40", + "comment": "deleted files -- credentials often get 'removed' but live in history" + }, + { + "cmd": "git log --all --oneline --grep='$SEARCH_STRING' -i", + "comment": "search commit messages -- find when a fix or feature was introduced" + }, + { + "cmd": "git log --format='%H' --all | xargs git grep -l '$SEARCH_STRING' 2>/dev/null | sort -u", + "comment": "brute-force search across all history -- finds buried secrets" + }, + { + "cmd": "git rev-list --objects --all | git cat-file --batch-check='%(objecttype) %(objectname) %(objectsize) %(rest)' | awk '/^blob/ {print $3,$4}' | sort -rn | head -20", + "comment": "largest blobs ever committed -- binaries, data dumps, vendor dirs" + }, + { + "cmd": "git shortlog -sn --all --no-merges | head -10", + "comment": "top contributors by commit count -- who knows this code best" + }, + { + "cmd": "git log --oneline --all --after='$AFTER_DATE' --before='$BEFORE_DATE' --format='%h %ai %an: %s'", + "comment": "timeline of changes in a date range -- incident correlation" + } + ] + } +} diff --git a/command-groups/networking/dns-debug.json b/command-groups/networking/dns-debug.json new file mode 100644 index 0000000..63b129e --- /dev/null +++ b/command-groups/networking/dns-debug.json @@ -0,0 +1,35 @@ +{ + "dns-debug": { + "description": "DNS troubleshooting -- when resolution is the problem", + "commands": [ + { + "cmd": "dig $DOMAIN A +noall +answer && dig $DOMAIN AAAA +noall +answer", + "comment": "A and AAAA records -- what IPs does this resolve to?" + }, + { + "cmd": "dig $DOMAIN NS +short", + "comment": "authoritative nameservers -- wrong NS = wrong answers" + }, + { + "cmd": "dig @8.8.8.8 $DOMAIN A +short && dig @1.1.1.1 $DOMAIN A +short", + "comment": "compare Google vs Cloudflare DNS -- catches propagation issues" + }, + { + "cmd": "dig $DOMAIN SOA +short", + "comment": "SOA record -- serial number reveals if zone was updated" + }, + { + "cmd": "dig $DOMAIN ANY +noall +answer 2>/dev/null || echo 'ANY queries blocked (RFC 8482)'", + "comment": "all records at once -- some resolvers block this" + }, + { + "cmd": "dig +trace +nodnssec $DOMAIN", + "comment": "walk the full delegation chain from root -- finds broken links" + }, + { + "cmd": "dig $DOMAIN CAA +short", + "comment": "CAA records -- which CAs can issue certs for this domain" + } + ] + } +} diff --git a/command-groups/networking/network-debug.json b/command-groups/networking/network-debug.json new file mode 100644 index 0000000..e0a3a36 --- /dev/null +++ b/command-groups/networking/network-debug.json @@ -0,0 +1,31 @@ +{ + "network-debug": { + "description": "Network connectivity troubleshooting -- layer by layer", + "commands": [ + { + "cmd": "curl -sS -o /dev/null -w 'dns: %{time_namelookup}s\\nconnect: %{time_connect}s\\ntls: %{time_appconnect}s\\nttfb: %{time_starttransfer}s\\ntotal: %{time_total}s\\ncode: %{http_code}\\n' https://$HOST", + "comment": "request timing breakdown -- pinpoints where latency lives" + }, + { + "cmd": "dig +trace $HOST", + "comment": "full DNS resolution path -- find where delegation breaks" + }, + { + "cmd": "traceroute -n -w 2 -q 1 $HOST", + "comment": "network path -- identify the hop where packets die" + }, + { + "cmd": "nc -zv $HOST $PORT -w 5 2>&1", + "comment": "TCP port reachability -- is the port open or firewalled?" + }, + { + "cmd": "curl -sS --resolve $HOST:443:$IP https://$HOST -o /dev/null -w '%{http_code}\\n'", + "comment": "bypass DNS and hit a specific IP -- test individual backends" + }, + { + "cmd": "mtr -n -c 10 --report $HOST", + "comment": "combined ping/traceroute -- shows packet loss per hop" + } + ] + } +} diff --git a/command-groups/performance/system-profile.json b/command-groups/performance/system-profile.json new file mode 100644 index 0000000..a715c2b --- /dev/null +++ b/command-groups/performance/system-profile.json @@ -0,0 +1,35 @@ +{ + "system-profile": { + "description": "System performance profiling -- find the bottleneck", + "commands": [ + { + "cmd": "uptime && sysctl -n hw.ncpu 2>/dev/null || nproc", + "comment": "load average vs CPU count -- load > ncpu means saturation" + }, + { + "cmd": "top -l 1 -n 10 -o cpu -stats pid,command,cpu,mem,time 2>/dev/null || top -bn1 | head -17", + "comment": "top processes by CPU right now" + }, + { + "cmd": "vm_stat 2>/dev/null | awk '/Pages (free|active|inactive|speculative|wired)/' || free -h", + "comment": "memory pressure -- pageouts mean real trouble" + }, + { + "cmd": "df -h / /tmp /var 2>/dev/null | grep -v '^Filesystem'", + "comment": "disk space on critical mounts -- 90%+ needs attention" + }, + { + "cmd": "iostat -d 2 3 2>/dev/null || echo 'iostat not available'", + "comment": "disk I/O throughput -- high wait% means disk-bound" + }, + { + "cmd": "lsof -iTCP -sTCP:LISTEN -n -P | sort -t: -k2 -n", + "comment": "all listening TCP ports -- what's actually running" + }, + { + "cmd": "netstat -an | awk '/^tcp/ {print $6}' | sort | uniq -c | sort -rn", + "comment": "TCP connection states -- too many TIME_WAIT or CLOSE_WAIT is a leak" + } + ] + } +} diff --git a/command-groups/security/headers-audit.json b/command-groups/security/headers-audit.json new file mode 100644 index 0000000..da0289d --- /dev/null +++ b/command-groups/security/headers-audit.json @@ -0,0 +1,31 @@ +{ + "headers-audit": { + "description": "HTTP security headers audit -- check what's missing before it's a finding", + "commands": [ + { + "cmd": "curl -sI https://$DOMAIN | grep -iE 'strict-transport|content-security|x-frame|x-content-type|referrer-policy|permissions-policy'", + "comment": "check which security headers are present" + }, + { + "cmd": "curl -sI https://$DOMAIN | grep -i 'set-cookie' | grep -ivE 'secure|httponly|samesite'", + "comment": "cookies missing Secure, HttpOnly, or SameSite flags" + }, + { + "cmd": "curl -sI https://$DOMAIN | grep -iE 'server:|x-powered-by:|x-aspnet|x-generator'", + "comment": "technology disclosure headers that should be stripped" + }, + { + "cmd": "curl -sI https://$DOMAIN | grep -i 'access-control-allow'", + "comment": "CORS policy -- overly permissive origins are a classic vuln" + }, + { + "cmd": "curl -s -o /dev/null -w '%{http_code}' -X OPTIONS https://$DOMAIN", + "comment": "OPTIONS method response -- should be restricted" + }, + { + "cmd": "curl -s -o /dev/null -w '%{http_code}' -X TRACE https://$DOMAIN", + "comment": "TRACE method -- must return 405, otherwise XST risk" + } + ] + } +} diff --git a/command-groups/security/recon.json b/command-groups/security/recon.json new file mode 100644 index 0000000..29b80bb --- /dev/null +++ b/command-groups/security/recon.json @@ -0,0 +1,35 @@ +{ + "recon": { + "description": "Passive recon workflow for bug bounty -- no active scanning", + "commands": [ + { + "cmd": "curl -s 'https://crt.sh/?q=%25.$DOMAIN&output=json' | jq -r '.[].name_value' | sort -u", + "comment": "subdomain discovery via certificate transparency" + }, + { + "cmd": "dig $DOMAIN A +short && dig $DOMAIN MX +short && dig $DOMAIN TXT +short", + "comment": "DNS records -- reveals stack, email provider, SPF/DMARC" + }, + { + "cmd": "curl -sIL https://$DOMAIN", + "comment": "HTTP headers + redirect chain -- technology fingerprinting" + }, + { + "cmd": "whois $DOMAIN | grep -E 'Registrar:|Creation Date:|Expir|Name Server:'", + "comment": "domain registration info" + }, + { + "cmd": "curl -s 'https://web.archive.org/cdx/search/cdx?url=*.$DOMAIN&output=text&fl=original&collapse=urlkey' | sort -u | head -50", + "comment": "historical URLs from Wayback Machine -- deleted endpoints" + }, + { + "cmd": "curl -s https://$DOMAIN/robots.txt", + "comment": "disallowed paths -- often the most interesting ones" + }, + { + "cmd": "curl -s 'https://api.shodan.io/dns/resolve?hostnames=$DOMAIN&key=$SHODAN_API_KEY'", + "comment": "IP resolution + exposure check via Shodan" + } + ] + } +} diff --git a/command-groups/security/ssl-audit.json b/command-groups/security/ssl-audit.json new file mode 100644 index 0000000..d0a784d --- /dev/null +++ b/command-groups/security/ssl-audit.json @@ -0,0 +1,31 @@ +{ + "ssl-audit": { + "description": "TLS/SSL certificate and configuration audit", + "commands": [ + { + "cmd": "echo | openssl s_client -connect $DOMAIN:443 -servername $DOMAIN 2>/dev/null | openssl x509 -noout -dates -subject -issuer", + "comment": "certificate details -- expiry, issuer, subject" + }, + { + "cmd": "echo | openssl s_client -connect $DOMAIN:443 -servername $DOMAIN 2>/dev/null | openssl x509 -noout -ext subjectAltName", + "comment": "SAN entries -- reveals other domains on the same cert" + }, + { + "cmd": "echo | openssl s_client -connect $DOMAIN:443 -servername $DOMAIN 2>/dev/null | grep -E 'Protocol|Cipher'", + "comment": "negotiated protocol version and cipher suite" + }, + { + "cmd": "echo | openssl s_client -connect $DOMAIN:443 -servername $DOMAIN -tls1 2>&1 | grep -q 'handshake failure' && echo 'TLS 1.0: disabled (good)' || echo 'TLS 1.0: ENABLED (bad)'", + "comment": "verify TLS 1.0 is disabled" + }, + { + "cmd": "echo | openssl s_client -connect $DOMAIN:443 -servername $DOMAIN -tls1_1 2>&1 | grep -q 'handshake failure' && echo 'TLS 1.1: disabled (good)' || echo 'TLS 1.1: ENABLED (bad)'", + "comment": "verify TLS 1.1 is disabled" + }, + { + "cmd": "curl -s 'https://crt.sh/?q=%25.$DOMAIN&output=json' | jq -r '.[].not_after' | sort -u | head -5", + "comment": "upcoming cert expirations across all issued certs" + } + ] + } +} diff --git a/hooks/mem.bash b/hooks/mem.bash new file mode 100644 index 0000000..454b31d --- /dev/null +++ b/hooks/mem.bash @@ -0,0 +1,42 @@ +# mem shell hook for bash — install with: eval "$(mem init bash)" +# +# How it works: +# - DEBUG trap fires before each simple command. We use _mem_capturing +# as a guard so only the first simple command in a pipeline is recorded. +# - PROMPT_COMMAND runs after each command completes, capturing exit code +# and duration, then calling mem _capture in the background. +# - `& disown` backgrounds the capture and suppresses job notifications. +# +# Why $BASH_COMMAND instead of `history 1`: +# `history 1` returns the last *persisted* history entry, not the current +# command. With HISTCONTROL=ignorespace, HISTIGNORE, or disabled history, +# it silently returns a stale previous command — corrupting mem's data. +# $BASH_COMMAND is always the actual command being executed. The trade-off +# is that for pipelines (a | b | c) we capture only the first simple +# command, but correctness matters more than completeness. + +_mem_cmd="" +_mem_start=0 +_mem_capturing="" + +_mem_debug_trap() { + if [[ -z "$_mem_capturing" && -n "$BASH_COMMAND" ]]; then + _mem_capturing=1 + _mem_cmd="$BASH_COMMAND" + _mem_start=$SECONDS + fi +} + +_mem_prompt_cmd() { + local exit_code=$? + _mem_capturing="" + if [[ -n "$_mem_cmd" ]]; then + local duration=$(( (SECONDS - _mem_start) * 1000 )) + mem _capture "$_mem_cmd" "$PWD" "$exit_code" "$duration" 2>/dev/null & + disown 2>/dev/null + _mem_cmd="" + fi +} + +trap '_mem_debug_trap' DEBUG +PROMPT_COMMAND="_mem_prompt_cmd${PROMPT_COMMAND:+;$PROMPT_COMMAND}" diff --git a/hooks/mem.fish b/hooks/mem.fish new file mode 100644 index 0000000..ed22734 --- /dev/null +++ b/hooks/mem.fish @@ -0,0 +1,13 @@ +# mem shell hook for fish — install with: mem init fish | source +# +# How it works: +# - fish_postexec fires after each command with the full command line in $argv. +# - $status gives the exit code, $CMD_DURATION gives duration in milliseconds. +# - `& disown` backgrounds the capture so it never blocks the prompt. + +function _mem_postexec --on-event fish_postexec + set -l exit_code $status + # $CMD_DURATION is set by fish automatically (milliseconds) + command mem _capture "$argv" "$PWD" "$exit_code" "$CMD_DURATION" 2>/dev/null & + disown 2>/dev/null +end diff --git a/src/mem/cli.py b/src/mem/cli.py index a77f0d4..dab5c23 100644 --- a/src/mem/cli.py +++ b/src/mem/cli.py @@ -162,29 +162,66 @@ def capture_cmd(command: str, dir: str, exit_code: int, duration_ms: int) -> Non @click.argument("shell") def init(shell: str) -> None: """Print shell hook code for automatic command capture.""" - supported = {"zsh"} - future = {"bash", "fish"} - - if shell not in supported and shell not in future: + supported = {"zsh", "bash", "fish"} + fallback_hooks = { + "zsh": _ZSH_HOOK, + "bash": _BASH_HOOK, + "fish": _FISH_HOOK, + } + + if shell not in supported: click.echo( f'Error: unsupported shell "{shell}". Supported: zsh, bash, fish', err=True ) sys.exit(1) - if shell in future: - click.echo( - f"Error: {shell} support coming in v1.5. Currently supported: zsh", err=True - ) - sys.exit(1) - # Print hook code to stdout hook_path = Path(__file__).parent.parent.parent / "hooks" / f"mem.{shell}" if hook_path.exists(): click.echo(hook_path.read_text()) else: # Fallback: inline the hook if the file isn't found (e.g., installed via pip) - click.echo(_ZSH_HOOK) + click.echo(fallback_hooks[shell]) + + +_BASH_HOOK = """# mem shell hook for bash + +_mem_cmd="" +_mem_start=0 +_mem_capturing="" + +_mem_debug_trap() { + if [[ -z "$_mem_capturing" && -n "$BASH_COMMAND" ]]; then + _mem_capturing=1 + _mem_cmd="$BASH_COMMAND" + _mem_start=$SECONDS + fi +} + +_mem_prompt_cmd() { + local exit_code=$? + _mem_capturing="" + if [[ -n "$_mem_cmd" ]]; then + local duration=$(( (SECONDS - _mem_start) * 1000 )) + mem _capture "$_mem_cmd" "$PWD" "$exit_code" "$duration" 2>/dev/null & + disown 2>/dev/null + _mem_cmd="" + fi +} + +trap '_mem_debug_trap' DEBUG +PROMPT_COMMAND="_mem_prompt_cmd${PROMPT_COMMAND:+;$PROMPT_COMMAND}" +""" + +_FISH_HOOK = """# mem shell hook for fish +function _mem_postexec --on-event fish_postexec + set -l exit_code $status + # $CMD_DURATION is set by fish automatically (milliseconds) + command mem _capture "$argv" "$PWD" "$exit_code" "$CMD_DURATION" 2>/dev/null & + disown 2>/dev/null +end +""" _ZSH_HOOK = """# mem shell hook @@ -831,6 +868,41 @@ def run( console.print() +def _read_from_clipboard() -> str | None: + """Read text from system clipboard. Returns None if unavailable or empty.""" + import shutil + import subprocess as sp + + try: + # macOS + if shutil.which("pbpaste"): + result = sp.run(["pbpaste"], capture_output=True, text=True, timeout=5) + if result.returncode == 0 and result.stdout.strip(): + return result.stdout + # Linux (X11) + if shutil.which("xclip"): + result = sp.run( + ["xclip", "-selection", "clipboard", "-o"], + capture_output=True, + text=True, + timeout=5, + ) + if result.returncode == 0 and result.stdout.strip(): + return result.stdout + if shutil.which("xsel"): + result = sp.run( + ["xsel", "--clipboard", "--output"], + capture_output=True, + text=True, + timeout=5, + ) + if result.returncode == 0 and result.stdout.strip(): + return result.stdout + except (sp.CalledProcessError, sp.TimeoutExpired): + return None + return None + + def _copy_to_clipboard(text: str) -> bool: """Copy text to system clipboard. Returns True on success.""" import shutil @@ -905,8 +977,15 @@ def export(group_name: str, fmt: str, global_flag: bool, use_stdout: bool) -> No @cli.command(name="import") -@click.argument("file", type=click.Path(exists=True)) -@click.option("--group", "-g", "group_name", required=True, help="Target group name") +@click.argument("file", type=click.Path(exists=True), required=False, default=None) +@click.option( + "--group", + "-g", + "group_name", + required=False, + default=None, + help="Target group name", +) @click.option( "--format", "-f", @@ -916,33 +995,98 @@ def export(group_name: str, fmt: str, global_flag: bool, use_stdout: bool) -> No help="Input format (auto-detected from extension if omitted)", ) @click.option("--global", "global_flag", is_flag=True, help="Import to global scope") -def import_cmd(file: str, group_name: str, fmt: str | None, global_flag: bool) -> None: - """Import a group from a file.""" +def import_cmd( + file: str | None, group_name: str | None, fmt: str | None, global_flag: bool +) -> None: + """Import a group from a file or clipboard. + + When FILE is omitted, reads from the system clipboard and auto-detects + the format and group name. Use -g to override the detected group name. + """ from mem import groups, storage - from mem.models import Group + from mem.models import Group, GroupCommand - groups.validate_group_name(group_name) - scope_path = groups.resolve_scope(global_flag) - data = groups._load_group_file(scope_path) + detected_name: str | None = None - file_path = Path(file) + if file is None: + # --- Clipboard import mode --- + content = _read_from_clipboard() + if content is None: + raise click.ClickException( + "Clipboard is empty or unavailable.\n" + "Usage: mem import (read from clipboard)\n" + " mem import runbook.json -g ops (read from file)" + ) - # Auto-detect format from extension if not specified - if fmt is None: - ext = file_path.suffix.lower() - if ext == ".json": - fmt = "json" - elif ext in (".md", ".markdown"): - fmt = "markdown" - else: + # Try JSON first, then markdown + commands: list[GroupCommand] | None = None + try: + detected_name, commands = groups.import_from_json_str(content) + except click.ClickException: + pass + + if commands is None: + try: + detected_name, commands = groups.import_from_markdown_str(content) + except click.ClickException: + pass + + if not commands: raise click.ClickException( - f"Cannot detect format from extension '{ext}'. Use --format to specify." + "No group data found in clipboard.\n" + "Expected JSON (from mem export) or markdown with a command table.\n" + "Try: mem export mygroup then mem import" ) - if fmt == "json": - commands = groups.import_from_json(file_path) + # Resolve group name: --group flag > auto-detected > prompt + if group_name is None: + group_name = detected_name + if group_name is None: + if _is_interactive(): + group_name = click.prompt("Group name") + else: + raise click.ClickException( + "Could not detect group name. Use -g to specify one." + ) + + groups.validate_group_name(group_name) + + # Confirm before importing + if _is_interactive(): + err_console.print( + f"Found {len(commands)} commands. Import to group '{group_name}'?" + ) + if not click.confirm("Proceed?", default=True, err=True): + return else: - commands = groups.import_from_markdown(file_path) + # --- File import mode --- + if group_name is None: + raise click.ClickException( + "Group name required for file import. Use -g to specify one." + ) + groups.validate_group_name(group_name) + + file_path = Path(file) + + # Auto-detect format from extension if not specified + if fmt is None: + ext = file_path.suffix.lower() + if ext == ".json": + fmt = "json" + elif ext in (".md", ".markdown"): + fmt = "markdown" + else: + raise click.ClickException( + f"Cannot detect format from extension '{ext}'. Use --format to specify." + ) + + if fmt == "json": + commands = groups.import_from_json(file_path) + else: + commands = groups.import_from_markdown(file_path) + + scope_path = groups.resolve_scope(global_flag) + data = groups._load_group_file(scope_path) if group_name in data.groups: choice = click.prompt( diff --git a/src/mem/groups.py b/src/mem/groups.py index 2da0408..e0dd5f7 100644 --- a/src/mem/groups.py +++ b/src/mem/groups.py @@ -239,28 +239,36 @@ def export_json(group_name: str, group: Group) -> str: ) -def import_from_json(file_path: Path) -> list[GroupCommand]: - """Parse a JSON file and extract commands. +def import_from_json_str(content: str) -> tuple[str | None, list[GroupCommand]]: + """Parse a JSON string and extract commands. Accepts two formats: the export format ({"name": {"commands": [...]}}) or a flat commands list ({"commands": [...]}). + + Returns (detected_group_name, commands). The group name is extracted + from the top-level key in export format, or None for flat format. """ try: - data = json.loads(file_path.read_text(encoding="utf-8")) + data = json.loads(content) except json.JSONDecodeError as e: - raise click.ClickException(f"Invalid JSON in {file_path}: {e}") + raise click.ClickException(f"Invalid JSON: {e}") if not isinstance(data, dict): raise click.ClickException("Expected a JSON object.") + group_name: str | None = None + # Handle {"commands": [...]} format if "commands" in data: cmds = data["commands"] else: # Handle {"group_name": {"commands": [...]}} export format + first_key = next(iter(data.keys()), None) first_value = next(iter(data.values()), {}) if isinstance(first_value, dict): cmds = first_value.get("commands", []) + if first_key and cmds and GROUP_NAME_PATTERN.match(first_key): + group_name = first_key else: raise click.ClickException("Cannot parse group structure from JSON.") @@ -268,41 +276,55 @@ def import_from_json(file_path: Path) -> list[GroupCommand]: raise click.ClickException("Expected 'commands' to be a list.") try: - return [GroupCommand(cmd=c["cmd"], comment=c.get("comment")) for c in cmds] + commands = [GroupCommand(cmd=c["cmd"], comment=c.get("comment")) for c in cmds] except (KeyError, TypeError) as e: - raise click.ClickException(f"Malformed command entry in {file_path}: {e}") + raise click.ClickException(f"Malformed command entry: {e}") + return group_name, commands -def import_from_markdown(file_path: Path) -> list[GroupCommand]: - """Parse a markdown file and extract commands from a table. + +def import_from_markdown_str(content: str) -> tuple[str | None, list[GroupCommand]]: + """Parse a markdown string and extract commands from a table. Expects a table with | Command | Description | columns where commands are wrapped in backticks. + + Returns (detected_group_name, commands). The group name is extracted + from the first ## heading, or None if not found. """ - text = file_path.read_text(encoding="utf-8") commands: list[GroupCommand] = [] in_table = False + group_name: str | None = None + + for line in content.splitlines(): + stripped = line.strip() + + # Detect group name from ## heading + if group_name is None and stripped.startswith("## "): + group_name = stripped[3:].strip().lower().replace(" ", "-") + # Validate it looks like a group name + if not GROUP_NAME_PATTERN.match(group_name): + group_name = None + continue - for line in text.splitlines(): - line = line.strip() - if not line.startswith("|"): - if line: + if not stripped.startswith("|"): + if stripped: in_table = False continue # Skip header separator (|---|---|) - if re.match(r"^\|[-\s|]+\|$", line): + if re.match(r"^\|[-\s|]+\|$", stripped): in_table = True continue # Skip header row (| Command | Description |) if not in_table: - if "Command" in line and "Description" in line: + if "Command" in stripped and "Description" in stripped: continue continue # Parse table row - cells = [c.strip() for c in line.split("|")[1:-1]] + cells = [c.strip() for c in stripped.split("|")[1:-1]] if len(cells) >= 2: cmd_cell = cells[0] comment_cell = cells[1] if len(cells) > 1 else "" @@ -316,6 +338,24 @@ def import_from_markdown(file_path: Path) -> list[GroupCommand]: if not commands: raise click.ClickException("No commands found in markdown table.") + return group_name, commands + + +def import_from_json(file_path: Path) -> list[GroupCommand]: + """Parse a JSON file and extract commands. + + Delegates to import_from_json_str for the actual parsing. + """ + _, commands = import_from_json_str(file_path.read_text(encoding="utf-8")) + return commands + + +def import_from_markdown(file_path: Path) -> list[GroupCommand]: + """Parse a markdown file and extract commands from a table. + + Delegates to import_from_markdown_str for the actual parsing. + """ + _, commands = import_from_markdown_str(file_path.read_text(encoding="utf-8")) return commands diff --git a/tests/conftest.py b/tests/conftest.py index b495462..4c9f7df 100644 --- a/tests/conftest.py +++ b/tests/conftest.py @@ -21,9 +21,7 @@ def tmp_mem_dir(tmp_path: Path, monkeypatch: pytest.MonkeyPatch) -> Path: monkeypatch.setattr( storage, "GROUPS_GLOBAL_FILE", tmp_path / "groups" / "_global.json" ) - monkeypatch.setattr( - storage, "SYNC_COUNTER_FILE", tmp_path / ".sync_counter" - ) + monkeypatch.setattr(storage, "SYNC_COUNTER_FILE", tmp_path / ".sync_counter") monkeypatch.setattr(storage, "VARS_FILE", tmp_path / "vars.json") return tmp_path diff --git a/tests/test_groups.py b/tests/test_groups.py index d3eb9a7..9fda646 100644 --- a/tests/test_groups.py +++ b/tests/test_groups.py @@ -1925,7 +1925,9 @@ def test_env_var_assignment(self): from mem.variables import _extract_value_from_syntax cmd = "GITHUB_TOKEN=ghp_abc123 gh pr create" - assert _extract_value_from_syntax("GITHUB_TOKEN=ghp_abc123", cmd) == "ghp_abc123" + assert ( + _extract_value_from_syntax("GITHUB_TOKEN=ghp_abc123", cmd) == "ghp_abc123" + ) def test_plain_value_unchanged(self): from mem.variables import _extract_value_from_syntax @@ -1965,3 +1967,309 @@ def test_password_not_hostname(self): from mem.variables import _looks_like_hostname assert _looks_like_hostname("S3cretP@ssw0rd") is False + + +# --------------------------------------------------------------------------- +# String-based import functions +# --------------------------------------------------------------------------- + + +class TestImportFromJsonStr: + """Tests for import_from_json_str — parses JSON content and detects group name.""" + + def test_export_format_detects_name(self): + content = json.dumps( + { + "k8s": { + "description": "kubernetes", + "commands": [ + {"cmd": "kubectl get pods", "comment": "list pods"}, + {"cmd": "kubectl get svc"}, + ], + } + } + ) + name, cmds = groups.import_from_json_str(content) + assert name == "k8s" + assert len(cmds) == 2 + assert cmds[0].cmd == "kubectl get pods" + assert cmds[0].comment == "list pods" + + def test_flat_format_no_name(self): + content = json.dumps({"commands": [{"cmd": "ls"}, {"cmd": "pwd"}]}) + name, cmds = groups.import_from_json_str(content) + assert name is None + assert len(cmds) == 2 + + def test_invalid_json_raises(self): + with pytest.raises(click.ClickException, match="Invalid JSON"): + groups.import_from_json_str("NOT JSON") + + def test_wrong_structure_raises(self): + with pytest.raises(click.ClickException, match="Expected a JSON object"): + groups.import_from_json_str("[1,2,3]") + + +class TestImportFromMarkdownStr: + """Tests for import_from_markdown_str — parses markdown and detects group name.""" + + def test_detects_group_name_from_heading(self): + md = ( + "## deploy\n" + "> Start services\n\n" + "| Command | Description |\n" + "|---|---|\n" + "| `docker compose up -d` | start |\n" + ) + name, cmds = groups.import_from_markdown_str(md) + assert name == "deploy" + assert len(cmds) == 1 + assert cmds[0].cmd == "docker compose up -d" + + def test_heading_with_spaces_becomes_kebab(self): + md = ( + "## my deploy\n\n" + "| Command | Description |\n" + "|---|---|\n" + "| `echo 1` | test |\n" + ) + name, cmds = groups.import_from_markdown_str(md) + assert name == "my-deploy" + assert len(cmds) == 1 + + def test_invalid_heading_ignored(self): + md = ( + "## Invalid Name!!\n\n" + "| Command | Description |\n" + "|---|---|\n" + "| `echo 1` | test |\n" + ) + name, cmds = groups.import_from_markdown_str(md) + assert name is None + assert len(cmds) == 1 + + def test_no_table_raises(self): + with pytest.raises(click.ClickException, match="No commands found"): + groups.import_from_markdown_str("# Just text\nNo table here.") + + +class TestImportFromJsonDelegation: + """Ensure the file-based function still works via delegation.""" + + def test_file_based_still_works(self, tmp_path: Path): + content = json.dumps({"ops": {"commands": [{"cmd": "echo 1"}]}}) + f = tmp_path / "test.json" + f.write_text(content, encoding="utf-8") + cmds = groups.import_from_json(f) + assert len(cmds) == 1 + assert cmds[0].cmd == "echo 1" + + +class TestImportFromMarkdownDelegation: + """Ensure the file-based function still works via delegation.""" + + def test_file_based_still_works(self, tmp_path: Path): + md = ( + "## test\n\n" + "| Command | Description |\n" + "|---|---|\n" + "| `echo hello` | greeting |\n" + ) + f = tmp_path / "test.md" + f.write_text(md, encoding="utf-8") + cmds = groups.import_from_markdown(f) + assert len(cmds) == 1 + assert cmds[0].cmd == "echo hello" + + +# --------------------------------------------------------------------------- +# Clipboard import CLI tests +# --------------------------------------------------------------------------- + + +class TestClipboardImportCLI: + """Tests for 'mem import' reading from clipboard.""" + + def test_clipboard_json_with_auto_detect(self, tmp_mem_dir: Path): + clipboard_content = json.dumps( + { + "k8s": { + "commands": [ + {"cmd": "kubectl get pods", "comment": "list pods"}, + {"cmd": "kubectl get svc"}, + ], + } + } + ) + + runner = CliRunner() + with ( + patch("mem.cli._read_from_clipboard", return_value=clipboard_content), + patch("mem.cli._is_interactive", return_value=True), + ): + result = runner.invoke( + cli, + ["import", "--global"], + input="y\n", + ) + assert result.exit_code == 0 + assert "Found 2 commands" in result.output + assert "k8s" in result.output + + data = storage.read_group_file(storage.GROUPS_GLOBAL_FILE) + assert "k8s" in data.groups + assert len(data.groups["k8s"].commands) == 2 + + def test_clipboard_markdown_detected(self, tmp_mem_dir: Path): + md = ( + "## deploy\n\n" + "| Command | Description |\n" + "|---|---|\n" + "| `docker compose up -d` | start |\n" + "| `docker compose logs -f` | logs |\n" + ) + + runner = CliRunner() + with ( + patch("mem.cli._read_from_clipboard", return_value=md), + patch("mem.cli._is_interactive", return_value=True), + ): + result = runner.invoke( + cli, + ["import", "--global"], + input="y\n", + ) + assert result.exit_code == 0 + data = storage.read_group_file(storage.GROUPS_GLOBAL_FILE) + assert "deploy" in data.groups + + def test_clipboard_with_group_override(self, tmp_mem_dir: Path): + clipboard_content = json.dumps( + {"k8s": {"commands": [{"cmd": "kubectl get pods"}]}} + ) + + runner = CliRunner() + with ( + patch("mem.cli._read_from_clipboard", return_value=clipboard_content), + patch("mem.cli._is_interactive", return_value=True), + ): + result = runner.invoke( + cli, + ["import", "-g", "renamed", "--global"], + input="y\n", + ) + assert result.exit_code == 0 + data = storage.read_group_file(storage.GROUPS_GLOBAL_FILE) + assert "renamed" in data.groups + assert "k8s" not in data.groups + + def test_clipboard_empty_errors(self, tmp_mem_dir: Path): + runner = CliRunner() + with patch("mem.cli._read_from_clipboard", return_value=None): + result = runner.invoke(cli, ["import", "--global"]) + assert result.exit_code != 0 + assert "Clipboard is empty" in result.output + + def test_clipboard_unparseable_errors(self, tmp_mem_dir: Path): + runner = CliRunner() + with patch("mem.cli._read_from_clipboard", return_value="just some text"): + result = runner.invoke(cli, ["import", "--global"]) + assert result.exit_code != 0 + assert "No group data found" in result.output + + def test_clipboard_no_name_prompts(self, tmp_mem_dir: Path): + clipboard_content = json.dumps({"commands": [{"cmd": "echo 1"}]}) + + runner = CliRunner() + with ( + patch("mem.cli._read_from_clipboard", return_value=clipboard_content), + patch("mem.cli._is_interactive", return_value=True), + ): + result = runner.invoke( + cli, + ["import", "--global"], + input="my-group\ny\n", + ) + assert result.exit_code == 0 + data = storage.read_group_file(storage.GROUPS_GLOBAL_FILE) + assert "my-group" in data.groups + + def test_clipboard_declined_aborts(self, tmp_mem_dir: Path): + clipboard_content = json.dumps( + {"k8s": {"commands": [{"cmd": "kubectl get pods"}]}} + ) + + runner = CliRunner() + with ( + patch("mem.cli._read_from_clipboard", return_value=clipboard_content), + patch("mem.cli._is_interactive", return_value=True), + ): + result = runner.invoke( + cli, + ["import", "--global"], + input="n\n", + ) + assert result.exit_code == 0 + data = storage.read_group_file(storage.GROUPS_GLOBAL_FILE) + assert "k8s" not in data.groups + + def test_clipboard_non_interactive_skips_confirm(self, tmp_mem_dir: Path): + """In non-interactive mode (e.g., piped), skip confirmation and import directly.""" + clipboard_content = json.dumps( + {"k8s": {"commands": [{"cmd": "kubectl get pods"}]}} + ) + + runner = CliRunner() + with ( + patch("mem.cli._read_from_clipboard", return_value=clipboard_content), + patch("mem.cli._is_interactive", return_value=False), + ): + result = runner.invoke(cli, ["import", "--global"]) + assert result.exit_code == 0 + data = storage.read_group_file(storage.GROUPS_GLOBAL_FILE) + assert "k8s" in data.groups + + def test_file_import_still_requires_group(self, tmp_mem_dir: Path, tmp_path: Path): + f = tmp_path / "data.json" + f.write_text(json.dumps({"commands": [{"cmd": "echo 1"}]}), encoding="utf-8") + + runner = CliRunner() + result = runner.invoke(cli, ["import", str(f), "--global"]) + assert result.exit_code != 0 + assert "Group name required" in result.output + + +# --------------------------------------------------------------------------- +# Shell hook init tests +# --------------------------------------------------------------------------- + + +class TestInitShellHooks: + """Tests for mem init with bash and fish support.""" + + def test_init_bash(self): + runner = CliRunner() + result = runner.invoke(cli, ["init", "bash"]) + assert result.exit_code == 0 + assert "_mem_debug_trap" in result.output + assert "PROMPT_COMMAND" in result.output + assert "mem _capture" in result.output + + def test_init_fish(self): + runner = CliRunner() + result = runner.invoke(cli, ["init", "fish"]) + assert result.exit_code == 0 + assert "_mem_postexec" in result.output + assert "fish_postexec" in result.output + assert "CMD_DURATION" in result.output + + def test_init_zsh_still_works(self): + runner = CliRunner() + result = runner.invoke(cli, ["init", "zsh"]) + assert result.exit_code == 0 + assert "_mem_preexec" in result.output + + def test_init_unsupported_shell(self): + runner = CliRunner() + result = runner.invoke(cli, ["init", "powershell"]) + assert result.exit_code != 0 diff --git a/tests/test_patterns.py b/tests/test_patterns.py index 0fbe5b4..16aabdd 100644 --- a/tests/test_patterns.py +++ b/tests/test_patterns.py @@ -583,9 +583,7 @@ async def _counting_respond(prompt: str, generating=None): patch("apple_fm_sdk.LanguageModelSession", return_value=mock_session), ): commands2 = commands + ["git push"] - await patterns.extract_patterns_for_tool( - "git", commands2, already_done - ) + await patterns.extract_patterns_for_tool("git", commands2, already_done) assert call_count == 1 # Only "git push" is new