SaylorTwift HF Staff commited on
Commit
fcfabd3
·
verified ·
1 Parent(s): c86de4e

Add files using upload-large-folder tool

Browse files
Files changed (50) hide show
  1. .claude-plugin/marketplace.json +150 -0
  2. .devcontainer/Dockerfile +91 -0
  3. .devcontainer/devcontainer.json +57 -0
  4. .devcontainer/init-firewall.sh +136 -0
  5. .gitattributes +2 -35
  6. .gitignore +2 -0
  7. .vscode/extensions.json +8 -0
  8. CHANGELOG.md +0 -0
  9. LICENSE.md +1 -0
  10. README.md +84 -0
  11. SECURITY.md +12 -0
  12. Script/run_devcontainer_claude_code.ps1 +152 -0
  13. feed.xml +0 -0
  14. mods/README.md +116 -0
  15. mods/diff/README.md +91 -0
  16. mods/sec-default/README.md +55 -0
  17. mods/telemetry/.claude-plugin/plugin.json +9 -0
  18. mods/telemetry/README.md +54 -0
  19. mods/telemetry/hooks/entries/batch-of.ts +34 -0
  20. mods/telemetry/hooks/entries/checked-fields.ts +30 -0
  21. mods/telemetry/hooks/entries/checked-mark.ts +55 -0
  22. mods/telemetry/hooks/entries/index.ts +25 -0
  23. mods/telemetry/hooks/entries/ingest-url.ts +9 -0
  24. mods/telemetry/hooks/environment/environment.ts +20 -0
  25. mods/telemetry/hooks/environment/index.ts +3 -0
  26. mods/telemetry/hooks/hooks.json +4 -0
  27. mods/telemetry/hooks/index.ts +8 -0
  28. mods/telemetry/hooks/is-analytics-off/index.ts +5 -0
  29. mods/telemetry/hooks/is-analytics-off/is-analytics-off.ts +34 -0
  30. mods/telemetry/hooks/is-analytics-off/is-env-set/is-env-set.ts +9 -0
  31. mods/telemetry/hooks/is-analytics-off/is-env-truthy/index.ts +3 -0
  32. mods/telemetry/hooks/is-analytics-off/is-env-truthy/is-env-truthy.ts +9 -0
  33. mods/telemetry/hooks/register.ts +44 -0
  34. mods/telemetry/hooks/telemetry-deps/index.ts +3 -0
  35. mods/telemetry/hooks/telemetry-deps/telemetry-deps.ts +35 -0
  36. mods/telemetry/hooks/telemetry-of/index.ts +3 -0
  37. mods/telemetry/hooks/telemetry-of/telemetry-of.ts +74 -0
  38. mods/telemetry/tests/register.test.ts +369 -0
  39. mods/telemetry/types/index.d.ts +116 -0
  40. mods/tsconfig.json +17 -0
  41. mods/types/claude-code.d.ts +0 -0
  42. plugins/README.md +77 -0
  43. scripts/auto-close-duplicates.ts +277 -0
  44. scripts/backfill-duplicate-comments.ts +213 -0
  45. scripts/comment-on-duplicates.sh +95 -0
  46. scripts/edit-issue-labels.sh +84 -0
  47. scripts/gh.sh +96 -0
  48. scripts/issue-lifecycle.ts +38 -0
  49. scripts/lifecycle-comment.ts +53 -0
  50. scripts/sweep.ts +168 -0
.claude-plugin/marketplace.json ADDED
@@ -0,0 +1,150 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ {
2
+ "$schema": "https://json.schemastore.org/claude-code-marketplace.json",
3
+ "name": "claude-code-plugins",
4
+ "version": "1.0.0",
5
+ "description": "Bundled plugins for Claude Code including Agent SDK development tools, PR review toolkit, and commit workflows",
6
+ "owner": {
7
+ "name": "Anthropic",
8
+ "email": "support@anthropic.com"
9
+ },
10
+ "plugins": [
11
+ {
12
+ "name": "agent-sdk-dev",
13
+ "description": "Development kit for working with the Claude Agent SDK",
14
+ "source": "./plugins/agent-sdk-dev",
15
+ "category": "development"
16
+ },
17
+ {
18
+ "name": "claude-opus-4-5-migration",
19
+ "description": "Migrate your code and prompts from Sonnet 4.x and Opus 4.1 to Opus 4.5.",
20
+ "version": "1.0.0",
21
+ "author": {
22
+ "name": "William Hu",
23
+ "email": "whu@anthropic.com"
24
+ },
25
+ "source": "./plugins/claude-opus-4-5-migration",
26
+ "category": "development"
27
+ },
28
+ {
29
+ "name": "code-review",
30
+ "description": "Automated code review for pull requests using multiple specialized agents with confidence-based scoring to filter false positives",
31
+ "version": "1.0.0",
32
+ "author": {
33
+ "name": "Boris Cherny",
34
+ "email": "boris@anthropic.com"
35
+ },
36
+ "source": "./plugins/code-review",
37
+ "category": "productivity"
38
+ },
39
+ {
40
+ "name": "commit-commands",
41
+ "description": "Commands for git commit workflows including commit, push, and PR creation",
42
+ "version": "1.0.0",
43
+ "author": {
44
+ "name": "Anthropic",
45
+ "email": "support@anthropic.com"
46
+ },
47
+ "source": "./plugins/commit-commands",
48
+ "category": "productivity"
49
+ },
50
+ {
51
+ "name": "explanatory-output-style",
52
+ "description": "Adds educational insights about implementation choices and codebase patterns (mimics the deprecated Explanatory output style)",
53
+ "version": "1.0.0",
54
+ "author": {
55
+ "name": "Dickson Tsai",
56
+ "email": "dickson@anthropic.com"
57
+ },
58
+ "source": "./plugins/explanatory-output-style",
59
+ "category": "learning"
60
+ },
61
+ {
62
+ "name": "feature-dev",
63
+ "description": "Comprehensive feature development workflow with specialized agents for codebase exploration, architecture design, and quality review",
64
+ "version": "1.0.0",
65
+ "author": {
66
+ "name": "Siddharth Bidasaria",
67
+ "email": "sbidasaria@anthropic.com"
68
+ },
69
+ "source": "./plugins/feature-dev",
70
+ "category": "development"
71
+ },
72
+ {
73
+ "name": "frontend-design",
74
+ "description": "Create distinctive, production-grade frontend interfaces with high design quality. Generates creative, polished code that avoids generic AI aesthetics.",
75
+ "version": "1.1.0",
76
+ "author": {
77
+ "name": "Prithvi Rajasekaran & Alexander Bricken",
78
+ "email": "prithvi@anthropic.com"
79
+ },
80
+ "source": "./plugins/frontend-design",
81
+ "category": "development"
82
+ },
83
+ {
84
+ "name": "hookify",
85
+ "description": "Easily create custom hooks to prevent unwanted behaviors by analyzing conversation patterns or from explicit instructions. Define rules via simple markdown files.",
86
+ "version": "0.1.0",
87
+ "author": {
88
+ "name": "Daisy Hollman",
89
+ "email": "daisy@anthropic.com"
90
+ },
91
+ "source": "./plugins/hookify",
92
+ "category": "productivity"
93
+ },
94
+ {
95
+ "name": "learning-output-style",
96
+ "description": "Interactive learning mode that requests meaningful code contributions at decision points (mimics the unshipped Learning output style)",
97
+ "version": "1.0.0",
98
+ "author": {
99
+ "name": "Boris Cherny",
100
+ "email": "boris@anthropic.com"
101
+ },
102
+ "source": "./plugins/learning-output-style",
103
+ "category": "learning"
104
+ },
105
+ {
106
+ "name": "plugin-dev",
107
+ "description": "Comprehensive toolkit for developing Claude Code plugins. Includes 7 expert skills covering hooks, MCP integration, commands, agents, and best practices. AI-assisted plugin creation and validation.",
108
+ "version": "0.1.0",
109
+ "author": {
110
+ "name": "Daisy Hollman",
111
+ "email": "daisy@anthropic.com"
112
+ },
113
+ "source": "./plugins/plugin-dev",
114
+ "category": "development"
115
+ },
116
+ {
117
+ "name": "pr-review-toolkit",
118
+ "description": "Comprehensive PR review agents specializing in comments, tests, error handling, type design, code quality, and code simplification",
119
+ "version": "1.0.0",
120
+ "author": {
121
+ "name": "Anthropic",
122
+ "email": "support@anthropic.com"
123
+ },
124
+ "source": "./plugins/pr-review-toolkit",
125
+ "category": "productivity"
126
+ },
127
+ {
128
+ "name": "ralph-wiggum",
129
+ "description": "Interactive self-referential AI loops for iterative development. Claude works on the same task repeatedly, seeing its previous work, until completion.",
130
+ "version": "1.0.0",
131
+ "author": {
132
+ "name": "Daisy Hollman",
133
+ "email": "daisy@anthropic.com"
134
+ },
135
+ "source": "./plugins/ralph-wiggum",
136
+ "category": "development"
137
+ },
138
+ {
139
+ "name": "security-guidance",
140
+ "description": "Security reminder hook that warns about potential security issues when editing files, including command injection, XSS, and unsafe code patterns",
141
+ "version": "1.0.0",
142
+ "author": {
143
+ "name": "David Dworken",
144
+ "email": "dworken@anthropic.com"
145
+ },
146
+ "source": "./plugins/security-guidance",
147
+ "category": "security"
148
+ }
149
+ ]
150
+ }
.devcontainer/Dockerfile ADDED
@@ -0,0 +1,91 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ FROM node:20
2
+
3
+ ARG TZ
4
+ ENV TZ="$TZ"
5
+
6
+ ARG CLAUDE_CODE_VERSION=latest
7
+
8
+ # Install basic development tools and iptables/ipset
9
+ RUN apt-get update && apt-get install -y --no-install-recommends \
10
+ less \
11
+ git \
12
+ procps \
13
+ sudo \
14
+ fzf \
15
+ zsh \
16
+ man-db \
17
+ unzip \
18
+ gnupg2 \
19
+ gh \
20
+ iptables \
21
+ ipset \
22
+ iproute2 \
23
+ dnsutils \
24
+ aggregate \
25
+ jq \
26
+ nano \
27
+ vim \
28
+ && apt-get clean && rm -rf /var/lib/apt/lists/*
29
+
30
+ # Ensure default node user has access to /usr/local/share
31
+ RUN mkdir -p /usr/local/share/npm-global && \
32
+ chown -R node:node /usr/local/share
33
+
34
+ ARG USERNAME=node
35
+
36
+ # Persist bash history.
37
+ RUN SNIPPET="export PROMPT_COMMAND='history -a' && export HISTFILE=/commandhistory/.bash_history" \
38
+ && mkdir /commandhistory \
39
+ && touch /commandhistory/.bash_history \
40
+ && chown -R $USERNAME /commandhistory
41
+
42
+ # Set `DEVCONTAINER` environment variable to help with orientation
43
+ ENV DEVCONTAINER=true
44
+
45
+ # Create workspace and config directories and set permissions
46
+ RUN mkdir -p /workspace /home/node/.claude && \
47
+ chown -R node:node /workspace /home/node/.claude
48
+
49
+ WORKDIR /workspace
50
+
51
+ ARG GIT_DELTA_VERSION=0.18.2
52
+ RUN ARCH=$(dpkg --print-architecture) && \
53
+ wget "https://github.com/dandavison/delta/releases/download/${GIT_DELTA_VERSION}/git-delta_${GIT_DELTA_VERSION}_${ARCH}.deb" && \
54
+ sudo dpkg -i "git-delta_${GIT_DELTA_VERSION}_${ARCH}.deb" && \
55
+ rm "git-delta_${GIT_DELTA_VERSION}_${ARCH}.deb"
56
+
57
+ # Set up non-root user
58
+ USER node
59
+
60
+ # Install global packages
61
+ ENV NPM_CONFIG_PREFIX=/usr/local/share/npm-global
62
+ ENV PATH=$PATH:/usr/local/share/npm-global/bin
63
+
64
+ # Set the default shell to zsh rather than sh
65
+ ENV SHELL=/bin/zsh
66
+
67
+ # Set the default editor and visual
68
+ ENV EDITOR=nano
69
+ ENV VISUAL=nano
70
+
71
+ # Default powerline10k theme
72
+ ARG ZSH_IN_DOCKER_VERSION=1.2.0
73
+ RUN sh -c "$(wget -O- https://github.com/deluan/zsh-in-docker/releases/download/v${ZSH_IN_DOCKER_VERSION}/zsh-in-docker.sh)" -- \
74
+ -p git \
75
+ -p fzf \
76
+ -a "source /usr/share/doc/fzf/examples/key-bindings.zsh" \
77
+ -a "source /usr/share/doc/fzf/examples/completion.zsh" \
78
+ -a "export PROMPT_COMMAND='history -a' && export HISTFILE=/commandhistory/.bash_history" \
79
+ -x
80
+
81
+ # Install Claude
82
+ RUN npm install -g @anthropic-ai/claude-code@${CLAUDE_CODE_VERSION}
83
+
84
+
85
+ # Copy and set up firewall script
86
+ COPY init-firewall.sh /usr/local/bin/
87
+ USER root
88
+ RUN chmod +x /usr/local/bin/init-firewall.sh && \
89
+ echo "node ALL=(root) NOPASSWD: /usr/local/bin/init-firewall.sh" > /etc/sudoers.d/node-firewall && \
90
+ chmod 0440 /etc/sudoers.d/node-firewall
91
+ USER node
.devcontainer/devcontainer.json ADDED
@@ -0,0 +1,57 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ {
2
+ "name": "Claude Code Sandbox",
3
+ "build": {
4
+ "dockerfile": "Dockerfile",
5
+ "args": {
6
+ "TZ": "${localEnv:TZ:America/Los_Angeles}",
7
+ "CLAUDE_CODE_VERSION": "latest",
8
+ "GIT_DELTA_VERSION": "0.18.2",
9
+ "ZSH_IN_DOCKER_VERSION": "1.2.0"
10
+ }
11
+ },
12
+ "runArgs": [
13
+ "--cap-add=NET_ADMIN",
14
+ "--cap-add=NET_RAW"
15
+ ],
16
+ "customizations": {
17
+ "vscode": {
18
+ "extensions": [
19
+ "anthropic.claude-code",
20
+ "dbaeumer.vscode-eslint",
21
+ "esbenp.prettier-vscode",
22
+ "eamodio.gitlens"
23
+ ],
24
+ "settings": {
25
+ "editor.formatOnSave": true,
26
+ "editor.defaultFormatter": "esbenp.prettier-vscode",
27
+ "editor.codeActionsOnSave": {
28
+ "source.fixAll.eslint": "explicit"
29
+ },
30
+ "terminal.integrated.defaultProfile.linux": "zsh",
31
+ "terminal.integrated.profiles.linux": {
32
+ "bash": {
33
+ "path": "bash",
34
+ "icon": "terminal-bash"
35
+ },
36
+ "zsh": {
37
+ "path": "zsh"
38
+ }
39
+ }
40
+ }
41
+ }
42
+ },
43
+ "remoteUser": "node",
44
+ "mounts": [
45
+ "source=claude-code-bashhistory-${devcontainerId},target=/commandhistory,type=volume",
46
+ "source=claude-code-config-${devcontainerId},target=/home/node/.claude,type=volume"
47
+ ],
48
+ "containerEnv": {
49
+ "NODE_OPTIONS": "--max-old-space-size=4096",
50
+ "CLAUDE_CONFIG_DIR": "/home/node/.claude",
51
+ "POWERLEVEL9K_DISABLE_GITSTATUS": "true"
52
+ },
53
+ "workspaceMount": "source=${localWorkspaceFolder},target=/workspace,type=bind,consistency=delegated",
54
+ "workspaceFolder": "/workspace",
55
+ "postStartCommand": "sudo /usr/local/bin/init-firewall.sh",
56
+ "waitFor": "postStartCommand"
57
+ }
.devcontainer/init-firewall.sh ADDED
@@ -0,0 +1,136 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ #!/bin/bash
2
+ set -euo pipefail # Exit on error, undefined vars, and pipeline failures
3
+ IFS=$'\n\t' # Stricter word splitting
4
+
5
+ # 1. Extract Docker DNS info BEFORE any flushing
6
+ DOCKER_DNS_RULES=$(iptables-save -t nat | grep "127\.0\.0\.11" || true)
7
+
8
+ # Flush existing rules and delete existing ipsets
9
+ iptables -F
10
+ iptables -X
11
+ iptables -t nat -F
12
+ iptables -t nat -X
13
+ iptables -t mangle -F
14
+ iptables -t mangle -X
15
+ ipset destroy allowed-domains 2>/dev/null || true
16
+
17
+ # 2. Selectively restore ONLY internal Docker DNS resolution
18
+ if [ -n "$DOCKER_DNS_RULES" ]; then
19
+ echo "Restoring Docker DNS rules..."
20
+ iptables -t nat -N DOCKER_OUTPUT 2>/dev/null || true
21
+ iptables -t nat -N DOCKER_POSTROUTING 2>/dev/null || true
22
+ echo "$DOCKER_DNS_RULES" | xargs -L 1 iptables -t nat
23
+ else
24
+ echo "No Docker DNS rules to restore"
25
+ fi
26
+
27
+ # First allow DNS and localhost before any restrictions
28
+ # Allow outbound DNS
29
+ iptables -A OUTPUT -p udp --dport 53 -j ACCEPT
30
+ # Allow inbound DNS responses
31
+ iptables -A INPUT -p udp --sport 53 -j ACCEPT
32
+ # Allow outbound SSH
33
+ iptables -A OUTPUT -p tcp --dport 22 -j ACCEPT
34
+ # Allow inbound SSH responses
35
+ iptables -A INPUT -p tcp --sport 22 -m state --state ESTABLISHED -j ACCEPT
36
+ # Allow localhost
37
+ iptables -A INPUT -i lo -j ACCEPT
38
+ iptables -A OUTPUT -o lo -j ACCEPT
39
+
40
+ # Create ipset with CIDR support
41
+ ipset create allowed-domains hash:net
42
+
43
+ # Fetch GitHub meta information and aggregate + add their IP ranges
44
+ echo "Fetching GitHub IP ranges..."
45
+ gh_ranges=$(curl -s https://api.github.com/meta)
46
+ if [ -z "$gh_ranges" ]; then
47
+ echo "ERROR: Failed to fetch GitHub IP ranges"
48
+ exit 1
49
+ fi
50
+
51
+ if ! echo "$gh_ranges" | jq -e '.web and .api and .git' >/dev/null; then
52
+ echo "ERROR: GitHub API response missing required fields"
53
+ exit 1
54
+ fi
55
+
56
+ echo "Processing GitHub IPs..."
57
+ while read -r cidr; do
58
+ if [[ ! "$cidr" =~ ^[0-9]{1,3}\.[0-9]{1,3}\.[0-9]{1,3}\.[0-9]{1,3}/[0-9]{1,2}$ ]]; then
59
+ echo "ERROR: Invalid CIDR range from GitHub meta: $cidr"
60
+ exit 1
61
+ fi
62
+ echo "Adding GitHub range $cidr"
63
+ ipset add allowed-domains "$cidr"
64
+ done < <(echo "$gh_ranges" | jq -r '(.web + .api + .git)[]' | aggregate -q)
65
+
66
+ # Resolve and add other allowed domains
67
+ for domain in \
68
+ "registry.npmjs.org" \
69
+ "api.anthropic.com" \
70
+ "sentry.io" \
71
+ "statsig.com" \
72
+ "marketplace.visualstudio.com" \
73
+ "vscode.blob.core.windows.net" \
74
+ "update.code.visualstudio.com"; do
75
+ echo "Resolving $domain..."
76
+ ips=$(dig +noall +answer A "$domain" | awk '$4 == "A" {print $5}')
77
+ if [ -z "$ips" ]; then
78
+ echo "ERROR: Failed to resolve $domain"
79
+ exit 1
80
+ fi
81
+
82
+ while read -r ip; do
83
+ if [[ ! "$ip" =~ ^[0-9]{1,3}\.[0-9]{1,3}\.[0-9]{1,3}\.[0-9]{1,3}$ ]]; then
84
+ echo "ERROR: Invalid IP from DNS for $domain: $ip"
85
+ exit 1
86
+ fi
87
+ echo "Adding $ip for $domain"
88
+ ipset add allowed-domains "$ip"
89
+ done < <(echo "$ips")
90
+ done
91
+
92
+ # Get host IP from default route
93
+ HOST_IP=$(ip route | grep default | cut -d" " -f3)
94
+ if [ -z "$HOST_IP" ]; then
95
+ echo "ERROR: Failed to detect host IP"
96
+ exit 1
97
+ fi
98
+
99
+ HOST_NETWORK=$(echo "$HOST_IP" | sed "s/\.[0-9]*$/.0\/24/")
100
+ echo "Host network detected as: $HOST_NETWORK"
101
+
102
+ # Set up remaining iptables rules
103
+ iptables -A INPUT -s "$HOST_NETWORK" -j ACCEPT
104
+ iptables -A OUTPUT -d "$HOST_NETWORK" -j ACCEPT
105
+
106
+ # Set default policies to DROP first
107
+ iptables -P INPUT DROP
108
+ iptables -P FORWARD DROP
109
+ iptables -P OUTPUT DROP
110
+
111
+ # First allow established connections for already approved traffic
112
+ iptables -A INPUT -m state --state ESTABLISHED,RELATED -j ACCEPT
113
+ iptables -A OUTPUT -m state --state ESTABLISHED,RELATED -j ACCEPT
114
+
115
+ # Then allow only specific outbound traffic to allowed domains
116
+ iptables -A OUTPUT -m set --match-set allowed-domains dst -j ACCEPT
117
+
118
+ # Explicitly REJECT all other outbound traffic for immediate feedback
119
+ iptables -A OUTPUT -j REJECT --reject-with icmp-admin-prohibited
120
+
121
+ echo "Firewall configuration complete"
122
+ echo "Verifying firewall rules..."
123
+ if curl --connect-timeout 5 https://example.com >/dev/null 2>&1; then
124
+ echo "ERROR: Firewall verification failed - was able to reach https://example.com"
125
+ exit 1
126
+ else
127
+ echo "Firewall verification passed - unable to reach https://example.com as expected"
128
+ fi
129
+
130
+ # Verify GitHub API access
131
+ if ! curl --connect-timeout 5 https://api.github.com/zen >/dev/null 2>&1; then
132
+ echo "ERROR: Firewall verification failed - unable to reach https://api.github.com"
133
+ exit 1
134
+ else
135
+ echo "Firewall verification passed - able to reach https://api.github.com as expected"
136
+ fi
.gitattributes CHANGED
@@ -1,35 +1,2 @@
1
- *.7z filter=lfs diff=lfs merge=lfs -text
2
- *.arrow filter=lfs diff=lfs merge=lfs -text
3
- *.bin filter=lfs diff=lfs merge=lfs -text
4
- *.bz2 filter=lfs diff=lfs merge=lfs -text
5
- *.ckpt filter=lfs diff=lfs merge=lfs -text
6
- *.ftz filter=lfs diff=lfs merge=lfs -text
7
- *.gz filter=lfs diff=lfs merge=lfs -text
8
- *.h5 filter=lfs diff=lfs merge=lfs -text
9
- *.joblib filter=lfs diff=lfs merge=lfs -text
10
- *.lfs.* filter=lfs diff=lfs merge=lfs -text
11
- *.mlmodel filter=lfs diff=lfs merge=lfs -text
12
- *.model filter=lfs diff=lfs merge=lfs -text
13
- *.msgpack filter=lfs diff=lfs merge=lfs -text
14
- *.npy filter=lfs diff=lfs merge=lfs -text
15
- *.npz filter=lfs diff=lfs merge=lfs -text
16
- *.onnx filter=lfs diff=lfs merge=lfs -text
17
- *.ot filter=lfs diff=lfs merge=lfs -text
18
- *.parquet filter=lfs diff=lfs merge=lfs -text
19
- *.pb filter=lfs diff=lfs merge=lfs -text
20
- *.pickle filter=lfs diff=lfs merge=lfs -text
21
- *.pkl filter=lfs diff=lfs merge=lfs -text
22
- *.pt filter=lfs diff=lfs merge=lfs -text
23
- *.pth filter=lfs diff=lfs merge=lfs -text
24
- *.rar filter=lfs diff=lfs merge=lfs -text
25
- *.safetensors filter=lfs diff=lfs merge=lfs -text
26
- saved_model/**/* filter=lfs diff=lfs merge=lfs -text
27
- *.tar.* filter=lfs diff=lfs merge=lfs -text
28
- *.tar filter=lfs diff=lfs merge=lfs -text
29
- *.tflite filter=lfs diff=lfs merge=lfs -text
30
- *.tgz filter=lfs diff=lfs merge=lfs -text
31
- *.wasm filter=lfs diff=lfs merge=lfs -text
32
- *.xz filter=lfs diff=lfs merge=lfs -text
33
- *.zip filter=lfs diff=lfs merge=lfs -text
34
- *.zst filter=lfs diff=lfs merge=lfs -text
35
- *tfevents* filter=lfs diff=lfs merge=lfs -text
 
1
+ * text=auto eol=lf
2
+ *.sh text eol=lf
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
.gitignore ADDED
@@ -0,0 +1,2 @@
 
 
 
1
+ .DS_Store
2
+
.vscode/extensions.json ADDED
@@ -0,0 +1,8 @@
 
 
 
 
 
 
 
 
 
1
+ {
2
+ "recommendations": [
3
+ "dbaeumer.vscode-eslint",
4
+ "esbenp.prettier-vscode",
5
+ "ms-vscode-remote.remote-containers",
6
+ "eamodio.gitlens"
7
+ ]
8
+ }
CHANGELOG.md ADDED
The diff for this file is too large to render. See raw diff
 
LICENSE.md ADDED
@@ -0,0 +1 @@
 
 
1
+ © Anthropic PBC. All rights reserved. Use is subject to Anthropic's [Commercial Terms of Service](https://www.anthropic.com/legal/commercial-terms).
README.md ADDED
@@ -0,0 +1,84 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ ---
2
+ license: other
3
+ license_name: anthropic-commercial-terms
4
+ license_link: https://www.anthropic.com/legal/commercial-terms
5
+ tags:
6
+ - agent
7
+ - coding-agent
8
+ - cli
9
+ ---
10
+
11
+ > **This is a source mirror — partial, by necessity.** This Hugging Face repository is not a model checkpoint — it's a read-only mirror of the [`anthropics/claude-code`](https://github.com/anthropics/claude-code) GitHub repository. Claude Code's core agent is closed-source and distributed as an npm package; this GitHub repo (and this mirror) contains only the public surrounding material — docs, examples, plugins, and scripts — not the agent engine itself. Licensed under Anthropic's [Commercial Terms of Service](https://www.anthropic.com/legal/commercial-terms), not an open-source license. File issues and contribute on GitHub, not here.
12
+
13
+ # Claude Code
14
+
15
+ ![](https://img.shields.io/badge/Node.js-18%2B-brightgreen?style=flat-square) [![npm]](https://www.npmjs.com/package/@anthropic-ai/claude-code)
16
+
17
+ [npm]: https://img.shields.io/npm/v/@anthropic-ai/claude-code.svg?style=flat-square
18
+
19
+ Claude Code is an agentic coding tool that lives in your terminal, understands your codebase, and helps you code faster by executing routine tasks, explaining complex code, and handling git workflows -- all through natural language commands. Use it in your terminal, IDE, or tag @claude on Github.
20
+
21
+ **Learn more in the [official documentation](https://code.claude.com/docs/en/overview)**.
22
+
23
+ <img src="./demo.gif" />
24
+
25
+ ## Get started
26
+ > [!NOTE]
27
+ > Installation via npm is deprecated. Use one of the recommended methods below.
28
+
29
+ For more installation options, uninstall steps, and troubleshooting, see the [setup documentation](https://code.claude.com/docs/en/setup).
30
+
31
+ 1. Install Claude Code:
32
+
33
+ **MacOS/Linux (Recommended):**
34
+ ```bash
35
+ curl -fsSL https://claude.ai/install.sh | bash
36
+ ```
37
+
38
+ **Homebrew (MacOS/Linux):**
39
+ ```bash
40
+ brew install --cask claude-code
41
+ ```
42
+
43
+ **Windows (Recommended):**
44
+ ```powershell
45
+ irm https://claude.ai/install.ps1 | iex
46
+ ```
47
+
48
+ **WinGet (Windows):**
49
+ ```powershell
50
+ winget install Anthropic.ClaudeCode
51
+ ```
52
+
53
+ **NPM (Deprecated):**
54
+ ```bash
55
+ npm install -g @anthropic-ai/claude-code
56
+ ```
57
+
58
+ 2. Navigate to your project directory and run `claude`.
59
+
60
+ ## Plugins
61
+
62
+ This repository includes several Claude Code plugins that extend functionality with custom commands and agents. See the [plugins directory](./plugins/README.md) for detailed documentation on available plugins.
63
+
64
+ ## Reporting Bugs
65
+
66
+ We welcome your feedback. Use the `/bug` command to report issues directly within Claude Code, or file a [GitHub issue](https://github.com/anthropics/claude-code/issues).
67
+
68
+ ## Connect on Discord
69
+
70
+ Join the [Claude Developers Discord](https://anthropic.com/discord) to connect with other developers using Claude Code. Get help, share feedback, and discuss your projects with the community.
71
+
72
+ ## Data collection, usage, and retention
73
+
74
+ When you use Claude Code, we collect feedback, which includes usage data (such as code acceptance or rejections), associated conversation data, and user feedback submitted via the `/bug` command.
75
+
76
+ ### How we use your data
77
+
78
+ See our [data usage policies](https://code.claude.com/docs/en/data-usage).
79
+
80
+ ### Privacy safeguards
81
+
82
+ We have implemented several safeguards to protect your data, including limited retention periods for sensitive information, restricted access to user session data, and clear policies against using feedback for model training.
83
+
84
+ For full details, please review our [Commercial Terms of Service](https://www.anthropic.com/legal/commercial-terms) and [Privacy Policy](https://www.anthropic.com/legal/privacy).
SECURITY.md ADDED
@@ -0,0 +1,12 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ # Security Policy
2
+ Thank you for helping us keep Claude Code secure!
3
+
4
+ ## Reporting Security Issues
5
+
6
+ The security of our systems and user data is Anthropic's top priority. We appreciate the work of security researchers acting in good faith in identifying and reporting potential vulnerabilities.
7
+
8
+ Our security program is managed on HackerOne and we ask that any validated vulnerability in this functionality be reported through their [submission form](https://hackerone.com/4f1f16ba-10d3-4d09-9ecc-c721aad90f24/embedded_submissions/new).
9
+
10
+ ## Anthropic Bug Bounty
11
+
12
+ Our Bug Bounty Program Guidelines are defined on our [HackerOne program page](https://hackerone.com/anthropic).
Script/run_devcontainer_claude_code.ps1 ADDED
@@ -0,0 +1,152 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ <#
2
+ .SYNOPSIS
3
+ Automates the setup and connection to a DevContainer environment using either Docker or Podman on Windows.
4
+
5
+ .DESCRIPTION
6
+ This script automates the process of initializing, starting, and connecting to a DevContainer
7
+ using either Docker or Podman as the container backend. It must be executed from the root
8
+ directory of your project and assumes the script is located in a 'Script' subdirectory.
9
+
10
+ .PARAMETER Backend
11
+ Specifies the container backend to use. Valid values are 'docker' or 'podman'.
12
+
13
+ .EXAMPLE
14
+ .\Script\run_devcontainer_claude_code.ps1 -Backend docker
15
+ Uses Docker as the container backend.
16
+
17
+ .EXAMPLE
18
+ .\Script\run_devcontainer_claude_code.ps1 -Backend podman
19
+ Uses Podman as the container backend.
20
+
21
+ .NOTES
22
+ Project Structure:
23
+ Project/
24
+ ├── .devcontainer/
25
+ └── Script/
26
+ └── run_devcontainer_claude_code.ps1
27
+ #>
28
+
29
+ [CmdletBinding()]
30
+ param(
31
+ [Parameter(Mandatory=$true)]
32
+ [ValidateSet('docker','podman')]
33
+ [string]$Backend
34
+ )
35
+
36
+ # Notify script start
37
+ Write-Host "--- DevContainer Startup & Connection Script ---"
38
+ Write-Host "Using backend: $($Backend)"
39
+
40
+ # --- Prerequisite Check ---
41
+ Write-Host "Checking for required commands..."
42
+ try {
43
+ if (-not (Get-Command $Backend -ErrorAction SilentlyContinue)) {
44
+ throw "Required command '$($Backend)' not found."
45
+ }
46
+ Write-Host "- $($Backend) command found."
47
+ if (-not (Get-Command devcontainer -ErrorAction SilentlyContinue)) {
48
+ throw "Required command 'devcontainer' not found."
49
+ }
50
+ Write-Host "- devcontainer command found."
51
+ }
52
+ catch {
53
+ Write-Error "A required command is not installed or not in your PATH. $($_.Exception.Message)"
54
+ Write-Error "Please ensure both '$Backend' and 'devcontainer' are installed and accessible in your system's PATH."
55
+ exit 1
56
+ }
57
+
58
+
59
+ # --- Backend-Specific Initialization ---
60
+ if ($Backend -eq 'podman') {
61
+ Write-Host "--- Podman Backend Initialization ---"
62
+
63
+ # --- Step 1a: Initialize Podman machine ---
64
+ Write-Host "Initializing Podman machine 'claudeVM'..."
65
+ try {
66
+ & podman machine init claudeVM
67
+ Write-Host "Podman machine 'claudeVM' initialized or already exists."
68
+ } catch {
69
+ Write-Error "Failed to initialize Podman machine: $($_.Exception.Message)"
70
+ exit 1 # Exit script on error
71
+ }
72
+
73
+ # --- Step 1b: Start Podman machine ---
74
+ Write-Host "Starting Podman machine 'claudeVM'..."
75
+ try {
76
+ & podman machine start claudeVM -q
77
+ Write-Host "Podman machine started or already running."
78
+ } catch {
79
+ Write-Error "Failed to start Podman machine: $($_.Exception.Message)"
80
+ exit 1
81
+ }
82
+
83
+ # --- Step 2: Set default connection ---
84
+ Write-Host "Setting default Podman connection to 'claudeVM'..."
85
+ try {
86
+ & podman system connection default claudeVM
87
+ Write-Host "Default connection set."
88
+ } catch {
89
+ Write-Warning "Failed to set default Podman connection (may be already set or machine issue): $($_.Exception.Message)"
90
+ }
91
+
92
+ } elseif ($Backend -eq 'docker') {
93
+ Write-Host "--- Docker Backend Initialization ---"
94
+
95
+ # --- Step 1 & 2: Check Docker Desktop ---
96
+ Write-Host "Checking if Docker Desktop is running and docker command is available..."
97
+ try {
98
+ docker info | Out-Null
99
+ Write-Host "Docker Desktop (daemon) is running."
100
+ } catch {
101
+ Write-Error "Docker Desktop is not running or docker command not found."
102
+ Write-Error "Please ensure Docker Desktop is running."
103
+ exit 1
104
+ }
105
+ }
106
+
107
+ # --- Step 3: Bring up DevContainer ---
108
+ Write-Host "Bringing up DevContainer in the current folder..."
109
+ try {
110
+ $arguments = @('up', '--workspace-folder', '.')
111
+ if ($Backend -eq 'podman') {
112
+ $arguments += '--docker-path', 'podman'
113
+ }
114
+ & devcontainer @arguments
115
+ Write-Host "DevContainer startup process completed."
116
+ } catch {
117
+ Write-Error "Failed to bring up DevContainer: $($_.Exception.Message)"
118
+ exit 1
119
+ }
120
+
121
+ # --- Step 4: Get DevContainer ID ---
122
+ Write-Host "Finding the DevContainer ID..."
123
+ $currentFolder = (Get-Location).Path
124
+
125
+ try {
126
+ $containerId = (& $Backend ps --filter "label=devcontainer.local_folder=$currentFolder" --format '{{.ID}}').Trim()
127
+ } catch {
128
+ $displayCommand = "$Backend ps --filter `"label=devcontainer.local_folder=$currentFolder`" --format '{{.ID}}'"
129
+ Write-Error "Failed to get container ID (Command: $displayCommand): $($_.Exception.Message)"
130
+ exit 1
131
+ }
132
+
133
+ if (-not $containerId) {
134
+ Write-Error "Could not find DevContainer ID for the current folder ('$currentFolder')."
135
+ Write-Error "Please check if 'devcontainer up' was successful and the container is running."
136
+ exit 1
137
+ }
138
+ Write-Host "Found container ID: $containerId"
139
+
140
+ # --- Step 5 & 6: Execute command and enter interactive shell inside container ---
141
+ Write-Host "Executing 'claude' command and then starting zsh session inside container $($containerId)..."
142
+ try {
143
+ & $Backend exec -it $containerId zsh -c 'claude; exec zsh'
144
+ Write-Host "Interactive session ended."
145
+ } catch {
146
+ $displayCommand = "$Backend exec -it $containerId zsh -c 'claude; exec zsh'"
147
+ Write-Error "Failed to execute command inside container (Command: $displayCommand): $($_.Exception.Message)"
148
+ exit 1
149
+ }
150
+
151
+ # Notify script completion
152
+ Write-Host "--- Script completed ---"
feed.xml ADDED
The diff for this file is too large to render. See raw diff
 
mods/README.md ADDED
@@ -0,0 +1,116 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ # Mods
2
+
3
+ A mod is a Claude Code plugin whose behaviour lives in a hooks module: one
4
+ `register(on, options)` entry that hooks the engine's events as functions
5
+ `($, e, next)`. These three ship inside Claude Code; this folder is their
6
+ source, published as it is built into the binary.
7
+
8
+ | Mod | What it does | Seated |
9
+ | --- | --- | --- |
10
+ | [`sec-default`](sec-default) | Keeps an organization's classic hooks, prompt content, managed settings and tool policy out of reach of the plugins a person installs; adds no policy of its own. | Outermost, on a machine with managed settings or for a Team or Enterprise organization, unless managed `prependPlugins` says otherwise |
11
+ | [`diff`](diff) | `/diff`: the session's uncommitted changes in a pane beside the transcript, file by file with their hunks, refreshed as Claude edits files and runs commands. | Built in |
12
+ | [`telemetry`](telemetry) | Adds `$.telemetry` (`log`, `mark`) in the `engine.create` fold so a plugin can record an event as a first-party analytics row; sends nothing wherever Claude Code's analytics are off. | Built in |
13
+
14
+ Each folder is a complete plugin: `.claude-plugin/plugin.json`, a
15
+ `hooks/hooks.json` naming the module, and TypeScript under `hooks/` typed
16
+ against the declarations `/plugin-types` writes (`import type … from
17
+ 'claude-code'`), kept here in `types/`. To read one running from source:
18
+
19
+ claude --plugin-dir mods/diff
20
+
21
+ ## Testing
22
+
23
+ A mod's tests are in its `tests/` folder, and run with
24
+
25
+ claude plugin test mods/diff
26
+
27
+ A test gets the engine's own `$` and a plugin's `on`. Each call on `$` is one
28
+ the engine makes, through every hook of the mod loaded as it ships. The hooks
29
+ the test registers with `on` sit beneath the mod, where the rest of the world
30
+ would be, and nothing is beneath them: a call they leave unanswered throws,
31
+ naming its event.
32
+
33
+ A test file is named for what it covers under `hooks/` (`register.test.ts`
34
+ beside `hooks/register.ts`, `git.test.ts` beside `hooks/git/`), and holds its
35
+ imports, the tier the mod loads in, and one `describe` titled with that name;
36
+ what several tests share sits under `tests/fixtures/`, one export a file.
37
+
38
+ ```ts
39
+ import { describe, expect, mock, test, tier } from 'claude-code/testing'
40
+
41
+ tier('builtin')
42
+
43
+ describe('register', () => {
44
+ test('outside a git repository /diff says so, opens nothing', async ($, on) => {
45
+ const opened: string[] = []
46
+ mock.clock(on)
47
+ on('session.start', ($, e) => ({ cwd: e.cwd }))
48
+ on('command.register', ($, e) => ({ value: { command: e.name } }))
49
+ on('process.run', () => ({
50
+ value: { exitCode: 128, stdout: '', stderr: 'fatal: not a git repository' },
51
+ }))
52
+ on('ui.open', ($, e, next) => {
53
+ opened.push(e.id)
54
+ return next(e)
55
+ })
56
+
57
+ await $.session.start({ surface: 'terminal', isInteractive: true, cwd: '/work' })
58
+ const { text } = await $.command.run({
59
+ command: 'diff',
60
+ args: '',
61
+ origin: { kind: 'composer' },
62
+ })
63
+
64
+ expect(text).toContain("isn't in a git repository")
65
+ expect(opened).toEqual([])
66
+ })
67
+ })
68
+ ```
69
+
70
+ The kit's `mock` answers the world beneath the mod from memory, noun by noun,
71
+ each member a plain function over `on` registering hooks where the test calls
72
+ it: `mock.env(on, variables)` for `$.env`, `mock.store(on, entries)` for
73
+ `$.store`, and `mock.clock(on)` for `$.clock`, whose `advance(ms)` resolves
74
+ every wait the mod asked for (`$.clock.sleep`, `after`, `every`) as the clock
75
+ crosses it. To see what a dispatch does before it answers, start it
76
+ unawaited, `await clock.settle()`, then look: the clock stays where it was.
77
+ `$.ui.press({ plugin, key })` presses a `Button` the test rendered, as a
78
+ click in the terminal does.
79
+
80
+ `tsc -p mods/tsconfig.json` typechecks every mod's hooks and tests against
81
+ `types/` and each mod's own `types/` contract.
82
+
83
+ ## Composing mods: noun contracts
84
+
85
+ A mod that adds a noun to `$` in the `engine.create` fold owns that noun's
86
+ types, and keeps them in one place: its `types/index.d.ts`, a declaration
87
+ file with no imports that exports the types the noun is made of, each named
88
+ for the noun, and declares the noun on `EngineInterface` in `claude-code`
89
+ (`telemetry/types/index.d.ts` exports `Telemetry`, `TelemetryLogEntry`,
90
+ `TelemetryMarkEntry` and the rest, and declares `$.telemetry`).
91
+
92
+ - The contract is the only declaration of the noun. The mod's own hooks
93
+ import its types from the folder (`import type { Telemetry } from
94
+ '../types'`), and the value its `engine.create` hook returns is checked
95
+ against `EngineInterface['telemetry']`, so the implementation cannot drift
96
+ from what callers read.
97
+ - A mod that calls another's noun reads the same file and never copies it:
98
+ `mods/tsconfig.json` includes `*/types/**/*.d.ts`, so `$.telemetry.log(…)`
99
+ in `diff` types against `telemetry`'s contract as it stands, and a helper
100
+ that must name one of its types imports it from that folder by path.
101
+ - A test of a mod that calls another's noun seats a provider for it, an inline
102
+ plugin whose `engine.create` hook adds the noun, and answers the calls the
103
+ way it answers the engine's: `on('telemetry.log', ($, e) => ({ value:
104
+ undefined }))` runs above the provider's own method, its `e` typed by the
105
+ contract. With no provider loaded the `$` build refuses the hook, naming the
106
+ noun nobody provides.
107
+
108
+ A plugin outside this repository that depends on a mod's noun points its
109
+ tsconfig `include` at that mod's `types/` folder for now; once the engine
110
+ writes the contracts of the plugins a session has installed, `/plugin-types`
111
+ will put them beside `claude-code.d.ts` and the include goes away.
112
+
113
+ Early access: hooks modules load only where function hooks are enabled, and
114
+ the API these mods are written against may change between releases without
115
+ notice. They are not listed in this repository's marketplace; the copies that
116
+ matter are the ones already in your Claude Code.
mods/diff/README.md ADDED
@@ -0,0 +1,91 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ # diff
2
+
3
+ The diff pane as a plugin: `/diff` opens the session's uncommitted changes
4
+ beside the transcript, one row per changed file and every file's hunks
5
+ beneath, and closes it again; each toggle leaves `Diff panel shown` or
6
+ `Diff panel hidden` in the transcript. The header, the file list and its
7
+ toggles stay put while the wheel moves the hunks under them three rows a
8
+ tick, or the list a file a tick while the wheel is over a list longer than
9
+ its eight rows (the plugin answers the pane's `ui.scroll` itself); a row's
10
+ click puts that file's hunks at the top; the list also scrolls under the
11
+ built-in's list keys (`ctrl+up`/`ctrl+down`, `opt+up`/`opt+down`), and
12
+ `ctrl+x b` moves the comparison base on, as the built-in's chord does: both
13
+ through Buttons that declare the engine's own actions. The pane refreshes
14
+ as Claude edits and runs shell commands, and while it is open it polls
15
+ the repository's HEAD so a commit or checkout made elsewhere shows too.
16
+ The first successful edit of a session opens the pane by itself where the
17
+ layout docks it beside the transcript (the fullscreen layout, which each
18
+ drawing's `viewport` says) and the terminal is wide enough (144 columns
19
+ when the person never chose, 110 when they kept it open before; a person
20
+ who closed it is left alone); where the surface does not say, nothing
21
+ opens by itself.
22
+
23
+ Under the fullscreen layout a terminal under 110 columns gets the
24
+ built-in's line asking for a wider one and nothing opens. Without that
25
+ layout (`CLAUDE_CODE_NO_FLICKER=0`, which `/diff` learns from the command's
26
+ `presentation`) the pane opens inline at any width, focused and as tall
27
+ as its content (the open's `rows`), in the built-in dialog's shape: the
28
+ title, the count, five file rows at a time round the selected one (`❯`,
29
+ where the focus ring starts; the plugin follows the ring's walk through
30
+ `ui.focus` and re-centres the rows as the built-in does), the key hints;
31
+ Enter shows that file's hunks alone,
32
+ Escape backs out to the list and then closes, leaving `Diff dialog
33
+ dismissed`; toasts are held while it is up. A file's ask button arms that
34
+ file: its hunks ride the next prompt as context, once.
35
+
36
+ The pane compares the working tree against HEAD, split at the session's
37
+ start (the default), against HEAD plainly, or against the merge-base with
38
+ the default branch; the base line under the header names a base other than
39
+ the session's, and the choice is kept per repository in the plugin's store.
40
+ A picker shows one earlier turn's edits instead of the working tree, read
41
+ from the session's messages. Files that changed before the session started
42
+ (by their timestamp, among the paths already dirty when the pane first
43
+ read the repository), and noise (lockfiles, generated and test files), are
44
+ listed apart and folded until asked for; a rename lists as git prints it.
45
+ Outside a git repository `/diff` says so and does nothing else.
46
+
47
+ Git runs when the built-in panel's would: nothing at the session's start;
48
+ one `git rev-parse`, in the directory the session started in, when `/diff`
49
+ or the first edit a pane has room to open on first needs the repository
50
+ (an answer of no repository is kept too, until `/clear` or `/resume`
51
+ forgets it); and the working tree is read only by a fetch for a pane that
52
+ is open, after an edit that landed or a shell command that ran. The one
53
+ read the built-in has no counterpart for is a `git status` at a pane's
54
+ first fetch, which stands in for the change time the built-in dates a
55
+ moved file by.
56
+
57
+ `hooks/register.ts` is the module; everything under `hooks/` is its parts.
58
+
59
+ ## What it hooks
60
+
61
+ | event | what the hook does |
62
+ | --- | --- |
63
+ | `session.start` | Binds the engine once and registers `/diff` (a session where another `/diff` is listed leaves the plugin idle); asks nothing of the repository, which `/diff` or the first edit pins when it comes. |
64
+ | `ui.render` of `PromptHint` | Reads the terminal's width and whether its layout docks a pane, which decide whether the first edit opens the pane. |
65
+ | `ui.render` of `Pane` | Draws the pane: docked, the header, base line, source picker, file list and toggles over the window of hunks; inline, the dialog. |
66
+ | `command.run` of `diff` | Pins the repository when none is, opens or closes the pane (focused and closing on Escape without the fullscreen layout), says which, and remembers the choice. |
67
+ | `ui.close` of the pane | Backs out of the dialog's detail view instead of closing; else remembers the person's close as `/diff`'s. |
68
+ | `ui.scroll` of the pane | Docked, moves the hunks under the pinned header and list (three rows a wheel tick, a page a page key), or the list when the wheel is over it, and keeps the engine's window still. |
69
+ | `ui.focus` in the pane | In the dialog's list, selects the file the ring lands on, re-centres the five rows on it, and lands the ring where that row now sits. |
70
+ | `command.run` of `clear`, `resume` | Closes the pane and forgets the session's state, the pinned repository with it. |
71
+ | `tool.call` of `Edit`, `Write`, `NotebookEdit` | After an edit that landed (not refused, not failed), refreshes an open pane; the session's first such edit opens it, pinning the repository then if the terminal has the room. |
72
+ | `tool.call` of `Bash`, `PowerShell` | After a command that was not refused, failed and interrupted ones too, refreshes an open pane. |
73
+ | `prompt.submit` | Adds the armed file's hunks to the prompt's context and disarms. |
74
+
75
+ ## What it calls on `$`
76
+
77
+ `clock.after`, `clock.every`, `clock.now`, `command.register`, `fs.list`,
78
+ `fs.read`, `fs.stat`, `process.run` (git, read-only), `session.messages`,
79
+ `store.get`, `store.set`, `telemetry.log`, `telemetry.mark`, `ui.close`,
80
+ `ui.invalidate`, `ui.log`, `ui.open`, `ui.resolve`, `ui.status`.
81
+
82
+ `$.telemetry` is the telemetry plugin's noun; where it is absent the rows
83
+ are dropped and nothing else changes.
84
+
85
+ ## Try it
86
+
87
+ ```sh
88
+ claude --plugin-dir /path/to/diff
89
+ ```
90
+
91
+ then `/diff` inside a git repository with a modified file.
mods/sec-default/README.md ADDED
@@ -0,0 +1,55 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ # sec-default
2
+
3
+ The security default for organizations. Function hooks give every plugin a
4
+ say on every event, in chain order, and the plugins a person installs sit
5
+ in the user tier, beneath the organization's prepend tier and above its
6
+ append tier. Some of what an organization sets today (its classic hooks,
7
+ its managed CLAUDE.md and rules, its settings, its MCP allowlist) was never
8
+ within a person's reach before function hooks; seated outermost, this
9
+ plugin keeps exactly those out of the user tier's reach and adds no policy
10
+ of its own. Everything else passes through untouched.
11
+
12
+ It has three moves and nothing else: continue past the user tier
13
+ (`next.to(e, "append")`), refuse a user-tier caller by name (`{ deny }`
14
+ when `next.origin.tier` is `user`), or pass (`next(e)`). A subject's
15
+ provenance is the event's pinned `e.provider`; policy is read through
16
+ `$.settings.read({ source: "policy" })`, one read serving a burst; both
17
+ fail closed, so an unreadable policy counts as a policy in force.
18
+
19
+ `hooks/register.ts` is the module; `hooks/policy/` reads the managed
20
+ settings it decides by.
21
+
22
+ ## The rows
23
+
24
+ | event | from the outermost seat |
25
+ | --- | --- |
26
+ | `classic.*` | Continue past the user tier: the organization's settings hooks see the engine's input and their answer stands. |
27
+ | `prompt.section`, `prompt.context`, `skill.prompt`, `attribution.text` | Continue past the user tier: managed CLAUDE.md, rules and policy skills reach the model as written. A person's plugins keep `prompt.submit` and its additive context. |
28
+ | `settings.read` | Continue past the user tier: no user hook rewrites what any caller reads as settings, this plugin's own policy reads included. |
29
+ | `tool.describe`, `command.describe`, `agent.offer`, `agent.spawn` | When the subject's pinned `e.provider.tier` is `prepend` or `append` (a policy-installed plugin, the managed folder, a policy MCP server), continue past the user tier; a subject provided by `user`, `builtin` or `core` passes. |
30
+ | `tool.register` | A caller in `prepend` or `append` continues past the user tier. A `user`-tier caller is refused by name while managed settings hold `allowedMcpServers` (set at all, empty included); otherwise it passes. |
31
+ | `tool.list` | The tools of the organization's managed MCP servers are listed as the organization's tiers listed them; every other tool as the user tier left it. With no policy to read, or a refusal from either listing, the organization's listing stands whole. |
32
+ | everything else | Passes: `prompt.submit`, `turn.*`, `tool.call`, `tool.check`, `command.run`, `command.register`, `session.*`, `ui.*`, `fs.*`, `http.fetch`, `process.run`, `store.*`, `clock.*`, `model.*`, `mcp.call`, `audio.*`, `agent.list`, `engine.create`. |
33
+
34
+ ## What it hooks
35
+
36
+ `classic.*`, `prompt.section`, `prompt.context`, `skill.prompt`,
37
+ `attribution.text`, `settings.read`, `tool.describe`, `command.describe`,
38
+ `agent.offer`, `agent.spawn`, `tool.register`, `tool.list`.
39
+
40
+ ## What it calls on `$`
41
+
42
+ `settings.read`. It continues to the `append` tier with `next.to`, which
43
+ only a plugin in a managed tier may do.
44
+
45
+ ## Where it is seated
46
+
47
+ The CLI seats it first in the prepend tier wherever hooks modules load on a
48
+ machine with managed settings or for a Team or Enterprise organization,
49
+ unless managed settings define `prependPlugins`: then that list is the
50
+ whole prepend tier, and the organization names `sec-default@builtin` in it
51
+ at the position it wants, e.g. `"prependPlugins": ["acme-guard@acme-tools",
52
+ "sec-default@builtin"]`, or leaves it out. It is a plugin folder like any
53
+ other, but its one move that matters, `next.to`, is refused outside a
54
+ managed tier, so loading it with `--plugin-dir` seats a plugin that can
55
+ only pass.
mods/telemetry/.claude-plugin/plugin.json ADDED
@@ -0,0 +1,9 @@
 
 
 
 
 
 
 
 
 
 
1
+ {
2
+ "name": "telemetry",
3
+ "version": "0.1.0",
4
+ "description": "Plugin analytics: adds $.telemetry in the engine.create fold, so a plugin logs an event or marks a feature's use as one first-party row per call, sent with the session's own credential.",
5
+ "author": {
6
+ "name": "Anthropic"
7
+ },
8
+ "types": "./types/index.d.ts"
9
+ }
mods/telemetry/README.md ADDED
@@ -0,0 +1,54 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ # telemetry
2
+
3
+ Plugin analytics as a plugin: one `engine.create` step adds `$.telemetry` to
4
+ the engine interface every plugin above it is handed, built over the
5
+ `$.session` and `$.http` nouns beneath. `$.telemetry.log({ event, props })`
6
+ sends one event as one first-party row, `tengu_plugin_<event>`;
7
+ `$.telemetry.mark({ feature, kind, reason?, props? })` marks one use of a
8
+ feature as the CLI's own feature events do, `tengu_feature_<kind>` with a
9
+ `feature_name` and the mark's properties beside it. Each call is one POST to
10
+ the event-logging ingest with the session's own credential
11
+ (`$.session.authorize()`, resolved at each call), one attempt, nothing
12
+ batched; a session with no first-party credential, or an ingest that
13
+ refuses, rejects the caller's promise.
14
+
15
+ It sends nothing wherever the CLI's own analytics are off: under
16
+ `DISABLE_TELEMETRY`, `CLAUDE_CODE_DISABLE_NONESSENTIAL_TRAFFIC` or
17
+ `DO_NOT_TRACK`, on any third-party provider (Bedrock, Vertex, Foundry and
18
+ kin), and on a deployment with its own OAuth URL. Each is read through
19
+ `$.env` at every call, rows go one after another, and the credential is
20
+ authorized afresh right before each POST, so a session that has since moved
21
+ to a third-party provider or a cloud gateway sends nothing more. The row's
22
+ `user_type` is `ant` when `USER_TYPE` says so, else `external`.
23
+
24
+ Nothing free-form reaches a row. An event name and every property key is a
25
+ snake_case token; a value is a finite number, a boolean, or a Choice (a
26
+ string named together with the list it is chosen from), under `log` and
27
+ `mark` alike; `mark` takes `ok`, `sad` or `bad`, with a `reason` required on
28
+ the last two and refused on the first. An entry that breaks a rule is
29
+ refused before anything is sent.
30
+
31
+ `hooks/register.ts` is the module; `types/index.d.ts` is the noun's contract,
32
+ the one declaration of `$.telemetry` that this mod's hooks, a mod calling the
33
+ noun and a test answering it all read.
34
+
35
+ ## What it hooks
36
+
37
+ `engine.create`: `{ ...await next(e), telemetry }`, so the noun is added and
38
+ nothing beneath is replaced.
39
+
40
+ ## What it calls on `$`
41
+
42
+ `session.authorize`, `session.id`, `session.model`, `http.fetch`, `env.get`
43
+ (the switches above and `USER_TYPE`, by literal name), each on the
44
+ interface the fold handed it.
45
+
46
+ ## Where it runs
47
+
48
+ This plugin is seated by the CLI itself, on internal builds whose own
49
+ analytics are on, and nowhere else: `session.authorize` exists only there,
50
+ and the rows it writes join tables only the CLI's own events reach. It is
51
+ not meant to be installed or loaded with `--plugin-dir`; the folder has a
52
+ manifest so it reads like every other plugin, not so it can stand alone. A
53
+ plugin that calls `$.telemetry` where this one is absent finds no such noun
54
+ and should treat that as "no analytics here".
mods/telemetry/hooks/entries/batch-of.ts ADDED
@@ -0,0 +1,34 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ import type { Fields } from './fields'
2
+ import type { RowSession } from './row-session'
3
+
4
+ /**
5
+ * The first-party event batch for one entry, shaped as the CLI's own
6
+ * event exporter shapes its batches.
7
+ *
8
+ * The ClaudeCodeInternalEvent JSON, one event per batch; the properties ride
9
+ * as base64 JSON in `additional_metadata`, the field the exporter uses;
10
+ * `user_type` is what the environment's USER_TYPE says of the build.
11
+ *
12
+ * @param fields the entry as checked
13
+ * @param session the session's id and model, and the build's user type
14
+ * @returns the batch's JSON text, ready to send to the exporter
15
+ */
16
+ export function batchOf(fields: Fields, session: RowSession) {
17
+ const metadata = JSON.stringify(fields.props)
18
+
19
+ return JSON.stringify({
20
+ events: [
21
+ {
22
+ event_type: 'ClaudeCodeInternalEvent',
23
+ event_data: {
24
+ event_name: fields.name,
25
+ client_timestamp: new Date().toISOString(),
26
+ session_id: session.sessionId,
27
+ model: session.model,
28
+ user_type: session.userType,
29
+ additional_metadata: btoa(metadata),
30
+ },
31
+ },
32
+ ],
33
+ })
34
+ }
mods/telemetry/hooks/entries/checked-fields.ts ADDED
@@ -0,0 +1,30 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ import { checkedProps } from './checked-props'
2
+ import type { Fields } from './fields'
3
+ import { fieldsOf } from './fields-of'
4
+ import { isRecord } from './is-record'
5
+ import { refusal } from './refusal'
6
+ import { TOKEN } from './token'
7
+
8
+ /**
9
+ * The row's fields from one entry, or a refusal naming the first thing
10
+ * wrong: the entry's shape, the event, then each property in turn.
11
+ *
12
+ * Nothing is sent from an entry refused here: the row reaches the ingest as
13
+ * written, so this check is the gate between the argument and the wire.
14
+ *
15
+ * @param entry the event's name and properties as the caller passed them
16
+ * @returns the entry's checked event name and properties, as `Fields`
17
+ */
18
+ export function checkedFields(entry: unknown): Fields {
19
+ if (!isRecord(entry)) {
20
+ throw refusal('takes one entry, { event, props? }')
21
+ }
22
+
23
+ const { event, props = {} } = entry
24
+
25
+ if (typeof event !== 'string' || !TOKEN.test(event)) {
26
+ throw refusal('takes an event name, a snake_case token')
27
+ }
28
+
29
+ return fieldsOf(event, checkedProps(props, 'log'))
30
+ }
mods/telemetry/hooks/entries/checked-mark.ts ADDED
@@ -0,0 +1,55 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ import { checkedProps } from './checked-props'
2
+ import type { Fields } from './fields'
3
+ import { isMarkKind } from './is-mark-kind'
4
+ import { isRecord } from './is-record'
5
+ import { markFieldsOf } from './mark-fields-of'
6
+ import { refusal } from './refusal'
7
+ import { TOKEN } from './token'
8
+
9
+ /**
10
+ * The row's fields from one mark, or a refusal naming the first thing
11
+ * wrong: the entry's shape, the feature, the kind, the reason, then props.
12
+ *
13
+ * Each property is checked in turn, as a log's are.
14
+ *
15
+ * @param entry the feature, kind, reason and properties as the caller passed
16
+ * them
17
+ * @returns the mark's row fields once the entry passes every check
18
+ */
19
+ export function checkedMark(entry: unknown): Fields {
20
+ if (!isRecord(entry)) {
21
+ throw refusal('takes one entry, { feature, kind, reason?, props? }', 'mark')
22
+ }
23
+
24
+ const { feature, kind, reason, props = {} } = entry
25
+
26
+ if (typeof feature !== 'string' || !TOKEN.test(feature)) {
27
+ throw refusal('takes a feature name, a snake_case token', 'mark')
28
+ }
29
+
30
+ if (!isMarkKind(kind)) {
31
+ throw refusal("kind: 'ok', 'sad' or 'bad'", 'mark')
32
+ }
33
+
34
+ if (kind === 'ok') {
35
+ if (reason !== undefined) {
36
+ throw refusal('reason: an ok mark carries none', 'mark')
37
+ }
38
+
39
+ return markFieldsOf({ kind, feature, props: checkedProps(props, 'mark') })
40
+ }
41
+
42
+ if (typeof reason !== 'string' || !TOKEN.test(reason)) {
43
+ throw refusal(
44
+ `reason: a ${kind} mark names why, a snake_case token`,
45
+ 'mark',
46
+ )
47
+ }
48
+
49
+ return markFieldsOf({
50
+ kind,
51
+ feature,
52
+ reason,
53
+ props: checkedProps(props, 'mark'),
54
+ })
55
+ }
mods/telemetry/hooks/entries/index.ts ADDED
@@ -0,0 +1,25 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ export * from './batch-of.js'
2
+ export * from './checked-fields.js'
3
+ export * from './checked-mark.js'
4
+ export * from './checked-props'
5
+ export * from './checked-value'
6
+ export * from './choice-token'
7
+ export * from './choices-limit'
8
+ export * from './core-event-prefix'
9
+ export * from './event-prefix'
10
+ export * from './feature-prefix'
11
+ export * from './fields'
12
+ export * from './fields-of'
13
+ export * from './ingest-url.js'
14
+ export * from './is-mark-kind'
15
+ export * from './is-record'
16
+ export * from './mark'
17
+ export * from './mark-fields-of'
18
+ export * from './mark-kinds'
19
+ export * from './method'
20
+ export * from './prop-limit'
21
+ export * from './refusal'
22
+ export * from './row-session'
23
+ export * from './token'
24
+
25
+ export * as default from '.'
mods/telemetry/hooks/entries/ingest-url.ts ADDED
@@ -0,0 +1,9 @@
 
 
 
 
 
 
 
 
 
 
1
+ /**
2
+ * The first-party event-logging ingest: the same address the CLI's own
3
+ * event exporter posts its batches to.
4
+ *
5
+ * No `$` reads the session's API base, so the production host is spelled
6
+ * here; a session on another base logs nothing, which the authorize gate
7
+ * already makes so.
8
+ */
9
+ export const INGEST_URL = 'https://api.anthropic.com/api/event_logging/v2/batch'
mods/telemetry/hooks/environment/environment.ts ADDED
@@ -0,0 +1,20 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ /**
2
+ * What the plugin reads of the environment before each row: the
3
+ * build's user type and every switch that turns the CLI's analytics off.
4
+ *
5
+ * Each field is the variable's value as `$.env.get` answers it, undefined
6
+ * when unset; analytics-off/ decides what they mean together.
7
+ */
8
+ export type Environment = {
9
+ readonly userType: string | undefined
10
+ readonly disableTelemetry: string | undefined
11
+ readonly disableNonessentialTraffic: string | undefined
12
+ readonly doNotTrack: string | undefined
13
+ readonly customOauthUrl: string | undefined
14
+ readonly useBedrock: string | undefined
15
+ readonly useVertex: string | undefined
16
+ readonly useFoundry: string | undefined
17
+ readonly useAnthropicAws: string | undefined
18
+ readonly useAnthropicGoogleCloud: string | undefined
19
+ readonly useMantle: string | undefined
20
+ }
mods/telemetry/hooks/environment/index.ts ADDED
@@ -0,0 +1,3 @@
 
 
 
 
1
+ export type * from './environment.js'
2
+
3
+ export * as default from '.'
mods/telemetry/hooks/hooks.json ADDED
@@ -0,0 +1,4 @@
 
 
 
 
 
1
+ {
2
+ "description": "Plugin analytics: an engine.create step adds $.telemetry (log, mark) over $.session, $.http and $.env beneath; sends nothing wherever the CLI's analytics are off",
3
+ "modules": ["./register.ts"]
4
+ }
mods/telemetry/hooks/index.ts ADDED
@@ -0,0 +1,8 @@
 
 
 
 
 
 
 
 
 
1
+ export * from './entries'
2
+ export * from './environment'
3
+ export * from './is-analytics-off'
4
+ export * from './register.js'
5
+ export * from './telemetry-deps'
6
+ export * from './telemetry-of'
7
+
8
+ export * as default from '.'
mods/telemetry/hooks/is-analytics-off/index.ts ADDED
@@ -0,0 +1,5 @@
 
 
 
 
 
 
1
+ export * from './is-analytics-off.js'
2
+ export * from './is-env-set'
3
+ export * from './is-env-truthy'
4
+
5
+ export * as default from '.'
mods/telemetry/hooks/is-analytics-off/is-analytics-off.ts ADDED
@@ -0,0 +1,34 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ import type { Environment } from '../environment'
2
+ import { isEnvSet } from './is-env-set'
3
+ import { isEnvTruthy } from './is-env-truthy'
4
+
5
+ /**
6
+ * Whether the CLI's own analytics would be off here, read off the
7
+ * environment alone: then the plugin sends nothing either.
8
+ *
9
+ * Off when DISABLE_TELEMETRY or the nonessential-traffic switch is set at
10
+ * all or DO_NOT_TRACK is on, on any third-party provider (no first-party
11
+ * credential rides there), and on a deployment with its own OAuth URL.
12
+ *
13
+ * @param environment the variables as read once for the session
14
+ * @returns true when no row may be sent
15
+ */
16
+ export function isAnalyticsOff(environment: Environment) {
17
+ const isPrivate =
18
+ isEnvSet(environment.disableTelemetry) ||
19
+ isEnvSet(environment.disableNonessentialTraffic) ||
20
+ isEnvTruthy(environment.doNotTrack)
21
+
22
+ const isThirdParty = [
23
+ environment.useBedrock,
24
+ environment.useVertex,
25
+ environment.useFoundry,
26
+ environment.useAnthropicAws,
27
+ environment.useAnthropicGoogleCloud,
28
+ environment.useMantle,
29
+ ].some(isEnvTruthy)
30
+
31
+ const isCustomDeployment = isEnvSet(environment.customOauthUrl?.trim())
32
+
33
+ return isPrivate || isThirdParty || isCustomDeployment
34
+ }
mods/telemetry/hooks/is-analytics-off/is-env-set/is-env-set.ts ADDED
@@ -0,0 +1,9 @@
 
 
 
 
 
 
 
 
 
 
1
+ /**
2
+ * Whether a variable is set to anything at all, as the CLI's privacy level
3
+ * reads DISABLE_TELEMETRY: any non-empty value counts, `0` included.
4
+ *
5
+ * @param value the variable as the environment holds it
6
+ * @returns true for a non-empty value
7
+ */
8
+ export const isEnvSet = (value: string | undefined) =>
9
+ value !== undefined && value !== ''
mods/telemetry/hooks/is-analytics-off/is-env-truthy/index.ts ADDED
@@ -0,0 +1,3 @@
 
 
 
 
1
+ export * from './is-env-truthy.js'
2
+
3
+ export * as default from '.'
mods/telemetry/hooks/is-analytics-off/is-env-truthy/is-env-truthy.ts ADDED
@@ -0,0 +1,9 @@
 
 
 
 
 
 
 
 
 
 
1
+ /**
2
+ * Whether a variable reads as on, as the CLI reads its boolean switches:
3
+ * `1`, `true`, `yes` or `on`, in any case, spaces around it ignored.
4
+ *
5
+ * @param value the variable as the environment holds it
6
+ * @returns true for one of the four spellings
7
+ */
8
+ export const isEnvTruthy = (value: string | undefined) =>
9
+ ['1', 'true', 'yes', 'on'].includes((value ?? '').trim().toLowerCase())
mods/telemetry/hooks/register.ts ADDED
@@ -0,0 +1,44 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ import type { EngineInterface, On } from 'claude-code'
2
+
3
+ import { telemetryOf } from './telemetry-of'
4
+
5
+ /**
6
+ * Registers the plugin's one hook: its engine.create step adds
7
+ * `$.telemetry` over the nouns beneath, the plugin's own `$` as core built.
8
+ *
9
+ * `log` runs after the fold, reaching `$.session` and `$.http` through the
10
+ * nouns beneath; each is one call on them.
11
+ *
12
+ * @param on the engine's registrar
13
+ */
14
+ export function register(on: On) {
15
+ on('engine.create', async ($, e, next) => {
16
+ const beneath = await next(e)
17
+
18
+ const telemetry: EngineInterface['telemetry'] = telemetryOf({
19
+ authorize: () => beneath.session.authorize(),
20
+ id: () => beneath.session.id(),
21
+ model: () => beneath.session.model(),
22
+ environment: async () => ({
23
+ userType: await beneath.env.get('USER_TYPE'),
24
+ disableTelemetry: await beneath.env.get('DISABLE_TELEMETRY'),
25
+ disableNonessentialTraffic: await beneath.env.get(
26
+ 'CLAUDE_CODE_DISABLE_NONESSENTIAL_TRAFFIC',
27
+ ),
28
+ doNotTrack: await beneath.env.get('DO_NOT_TRACK'),
29
+ customOauthUrl: await beneath.env.get('CLAUDE_CODE_CUSTOM_OAUTH_URL'),
30
+ useBedrock: await beneath.env.get('CLAUDE_CODE_USE_BEDROCK'),
31
+ useVertex: await beneath.env.get('CLAUDE_CODE_USE_VERTEX'),
32
+ useFoundry: await beneath.env.get('CLAUDE_CODE_USE_FOUNDRY'),
33
+ useAnthropicAws: await beneath.env.get('CLAUDE_CODE_USE_ANTHROPIC_AWS'),
34
+ useAnthropicGoogleCloud: await beneath.env.get(
35
+ 'CLAUDE_CODE_USE_ANTHROPIC_GOOGLE_CLOUD',
36
+ ),
37
+ useMantle: await beneath.env.get('CLAUDE_CODE_USE_MANTLE'),
38
+ }),
39
+ fetch: (url, init) => beneath.http.fetch(url, init),
40
+ })
41
+
42
+ return { ...beneath, telemetry }
43
+ })
44
+ }
mods/telemetry/hooks/telemetry-deps/index.ts ADDED
@@ -0,0 +1,3 @@
 
 
 
 
1
+ export type * from './telemetry-deps.js'
2
+
3
+ export * as default from '.'
mods/telemetry/hooks/telemetry-deps/telemetry-deps.ts ADDED
@@ -0,0 +1,35 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ import type { HttpInit, HttpResponse, SessionAuthorization } from 'claude-code'
2
+
3
+ import type { Environment } from '../environment'
4
+
5
+ /**
6
+ * What `telemetryOf` reaches on the nouns beneath: the session's authorize
7
+ * and reads, and one fetch. Each is a call on the plugin's own `$`.
8
+ */
9
+ export type TelemetryDeps = {
10
+ /**
11
+ * Resolves the session's held credential, or null.
12
+ */
13
+ authorize: () => Promise<SessionAuthorization>
14
+
15
+ /**
16
+ * The session's id, for the row.
17
+ */
18
+ id: () => Promise<string>
19
+
20
+ /**
21
+ * The session's model, for the row.
22
+ */
23
+ model: () => Promise<string>
24
+
25
+ /**
26
+ * Reads, at each call, what the row and the off switch need of the
27
+ * environment: the build's user type and every analytics-off variable.
28
+ */
29
+ environment: () => Promise<Environment>
30
+
31
+ /**
32
+ * Posts the batch to the ingest with the credential handle.
33
+ */
34
+ fetch: (url: string, init: HttpInit) => Promise<HttpResponse>
35
+ }
mods/telemetry/hooks/telemetry-of/index.ts ADDED
@@ -0,0 +1,3 @@
 
 
 
 
1
+ export * from './telemetry-of.js'
2
+
3
+ export * as default from '.'
mods/telemetry/hooks/telemetry-of/telemetry-of.ts ADDED
@@ -0,0 +1,74 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ import type { Telemetry } from '../../types'
2
+ import Entries from '../entries'
3
+ import { isAnalyticsOff } from '../is-analytics-off'
4
+ import type { TelemetryDeps } from '../telemetry-deps'
5
+
6
+ /**
7
+ * Builds `$.telemetry`: `log` and `mark` check the entry, read the environment
8
+ * and authorize afresh, build the first-party row and POST it to the ingest.
9
+ *
10
+ * One POST per call, rows one after another, nothing kept between them: a
11
+ * session that has moved to a third-party provider or a gateway, or turned
12
+ * analytics off, sends nothing more; no credential or a refusal rejects.
13
+ *
14
+ * @param deps the calls on the nouns beneath
15
+ * @returns the `$.telemetry` interface, `log` and `mark`
16
+ */
17
+ export function telemetryOf(deps: TelemetryDeps): Telemetry {
18
+ let queue: Promise<unknown> = Promise.resolve()
19
+
20
+ async function post(
21
+ fields: Entries.Fields,
22
+ method: Entries.Method,
23
+ ): Promise<void> {
24
+ const environment = await deps.environment()
25
+
26
+ if (isAnalyticsOff(environment)) {
27
+ return
28
+ }
29
+
30
+ const body = Entries.batchOf(fields, {
31
+ sessionId: await deps.id(),
32
+ model: await deps.model(),
33
+ userType: environment.userType === 'ant' ? 'ant' : 'external',
34
+ })
35
+
36
+ const auth = await deps.authorize()
37
+
38
+ if (!auth) {
39
+ throw Entries.refusal(
40
+ 'this session has no first-party credential to authorize',
41
+ method,
42
+ )
43
+ }
44
+
45
+ const response = await deps.fetch(Entries.INGEST_URL, {
46
+ method: 'POST',
47
+ headers: {
48
+ 'Content-Type': 'application/json',
49
+ 'x-service-name': 'claude-code',
50
+ },
51
+ auth: auth.handle,
52
+ body,
53
+ })
54
+
55
+ if (!response.ok) {
56
+ throw Entries.refusal(`the ingest answered ${response.status}`, method)
57
+ }
58
+ }
59
+
60
+ function queued(
61
+ fields: Entries.Fields,
62
+ method: Entries.Method,
63
+ ): Promise<void> {
64
+ const turn = queue.then(() => post(fields, method))
65
+ queue = turn.catch(() => undefined)
66
+
67
+ return turn
68
+ }
69
+
70
+ return {
71
+ log: async entry => queued(Entries.checkedFields(entry), 'log'),
72
+ mark: async entry => queued(Entries.checkedMark(entry), 'mark'),
73
+ }
74
+ }
mods/telemetry/tests/register.test.ts ADDED
@@ -0,0 +1,369 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ import type { Args } from 'claude-code'
2
+ import { describe, expect, mock, test, tier } from 'claude-code/testing'
3
+
4
+ import Fixtures from './fixtures'
5
+
6
+ tier('builtin')
7
+
8
+ describe('register', () => {
9
+ test(
10
+ 'a $.telemetry.log call from a plugin posts one first-party row',
11
+ { plugins: [Fixtures.recording] },
12
+ async ($, on) => {
13
+ mock.env(on, { USER_TYPE: 'ant' })
14
+
15
+ const posts = Fixtures.firstPartySession(on)
16
+
17
+ expect(
18
+ await $.command.run(Fixtures.record(Fixtures.surveyAnswer())),
19
+ ).toEqual({ text: 'sent' })
20
+
21
+ expect(
22
+ posts.map(post => [post.url, post.init?.method, post.init?.auth]),
23
+ ).toEqual([
24
+ [
25
+ 'https://api.anthropic.com/api/event_logging/v2/batch',
26
+ 'POST',
27
+ 'the-handle',
28
+ ],
29
+ ])
30
+
31
+ expect(posts.map(Fixtures.rowOf)).toEqual([
32
+ {
33
+ event_type: 'ClaudeCodeInternalEvent',
34
+ hasTimestamp: true,
35
+ event_name: 'tengu_plugin_survey_answered',
36
+ session_id: 'the-session',
37
+ model: 'the-model',
38
+ user_type: 'ant',
39
+ metadata: { answer: 2, page: 'ready', seen: true },
40
+ },
41
+ ])
42
+ },
43
+ )
44
+
45
+ test(
46
+ 'a row already named tengu_ is sent under its own name',
47
+ { plugins: [Fixtures.recording] },
48
+ async ($, on) => {
49
+ mock.env(on, {})
50
+
51
+ const posts = Fixtures.firstPartySession(on)
52
+
53
+ await $.command.run(
54
+ Fixtures.record({
55
+ ...Fixtures.surveyAnswer(),
56
+ event: 'tengu_repl_diff_panel_shown',
57
+ }),
58
+ )
59
+
60
+ expect(posts.map(Fixtures.batchOf)).toMatchObject([
61
+ {
62
+ events: [
63
+ {
64
+ event_data: {
65
+ event_name: 'tengu_repl_diff_panel_shown',
66
+ user_type: 'external',
67
+ },
68
+ },
69
+ ],
70
+ },
71
+ ])
72
+ },
73
+ )
74
+
75
+ test(
76
+ 'nothing is sent where any switch has analytics off',
77
+ { plugins: [Fixtures.recording] },
78
+ async ($, on) => {
79
+ let environment: Readonly<Record<string, string>> = {}
80
+
81
+ on('env.get', ($, e) => ({ value: environment[e.name] }))
82
+
83
+ const posts = Fixtures.firstPartySession(on)
84
+ const answers: (string | undefined)[] = []
85
+
86
+ for (const off of Fixtures.ANALYTICS_OFF_ENVIRONMENTS) {
87
+ environment = { USER_TYPE: 'ant', ...off }
88
+
89
+ answers.push(
90
+ (await $.command.run(Fixtures.record(Fixtures.surveyAnswer()))).text,
91
+ )
92
+ }
93
+
94
+ const withheld = posts.length
95
+
96
+ environment = { USER_TYPE: 'ant' }
97
+
98
+ await $.command.run(Fixtures.record(Fixtures.surveyAnswer()))
99
+
100
+ expect(answers).toEqual(
101
+ Fixtures.ANALYTICS_OFF_ENVIRONMENTS.map(() => 'sent'),
102
+ )
103
+
104
+ expect(withheld, 'nothing posted while any switch was off').toBe(0)
105
+ expect(posts, 'then one row once every switch is clear').toHaveLength(1)
106
+ },
107
+ )
108
+
109
+ test(
110
+ 'a third-party provider the host manages sends nothing too',
111
+ { plugins: [Fixtures.recording] },
112
+ async ($, on) => {
113
+ mock.env(on, {
114
+ CLAUDE_CODE_USE_BEDROCK: '1',
115
+ CLAUDE_CODE_PROVIDER_MANAGED_BY_HOST: '1',
116
+ })
117
+
118
+ const posts = Fixtures.firstPartySession(on)
119
+
120
+ const { text } = await $.command.run(
121
+ Fixtures.record(Fixtures.surveyAnswer()),
122
+ )
123
+
124
+ expect({ text, posts }).toEqual({ text: 'sent', posts: [] })
125
+ },
126
+ )
127
+
128
+ test(
129
+ 'a $.telemetry.mark call posts the feature row by its own name',
130
+ { plugins: [Fixtures.marking] },
131
+ async ($, on) => {
132
+ mock.env(on, { USER_TYPE: 'ant' })
133
+
134
+ const posts = Fixtures.firstPartySession(on)
135
+ const answers: (string | undefined)[] = []
136
+
137
+ for (const entry of [
138
+ { feature: 'learn_page', kind: 'ok' },
139
+ { feature: 'learn_page', kind: 'sad', reason: 'blocked' },
140
+ { feature: 'suggest_learning', kind: 'bad', reason: 'api_error' },
141
+ ]) {
142
+ answers.push((await $.command.run(Fixtures.mark(entry))).text)
143
+ }
144
+
145
+ expect(answers).toEqual(['sent', 'sent', 'sent'])
146
+
147
+ expect(
148
+ posts.map(Fixtures.rowOf),
149
+ 'the feature events by their own names, no plugin prefix',
150
+ ).toMatchObject([
151
+ {
152
+ event_name: 'tengu_feature_ok',
153
+ metadata: { feature_name: 'learn_page' },
154
+ },
155
+ {
156
+ event_name: 'tengu_feature_sad',
157
+ metadata: { feature_name: 'learn_page', error_code: 'blocked' },
158
+ },
159
+ {
160
+ event_name: 'tengu_feature_bad',
161
+ metadata: {
162
+ feature_name: 'suggest_learning',
163
+ error_code: 'api_error',
164
+ },
165
+ },
166
+ ])
167
+ },
168
+ )
169
+
170
+ test(
171
+ 'a mark with a bad kind or a wrong reason is refused, nothing sent',
172
+ { plugins: [Fixtures.marking] },
173
+ async ($, on) => {
174
+ mock.env(on, { USER_TYPE: 'ant' })
175
+
176
+ const posts = Fixtures.firstPartySession(on)
177
+
178
+ const refusalFor = async (entry: unknown) =>
179
+ (await $.command.run(Fixtures.mark(entry))).text
180
+
181
+ expect(
182
+ await refusalFor({ feature: 'learn_page', kind: 'meh' }),
183
+ ).toEndWith("$.telemetry.mark: kind: 'ok', 'sad' or 'bad'")
184
+
185
+ expect(
186
+ await refusalFor({ feature: 'learn_page', kind: 'bad' }),
187
+ ).toEndWith(
188
+ '$.telemetry.mark: reason: a bad mark names why, a snake_case token',
189
+ )
190
+
191
+ expect(
192
+ await refusalFor({ feature: 'learn_page', kind: 'ok', reason: 'x' }),
193
+ ).toEndWith('$.telemetry.mark: reason: an ok mark carries none')
194
+
195
+ expect(await refusalFor({ feature: 'Learn Page', kind: 'ok' })).toEndWith(
196
+ '$.telemetry.mark: takes a feature name, a snake_case token',
197
+ )
198
+
199
+ expect(await refusalFor('x')).toEndWith(
200
+ '$.telemetry.mark: takes one entry, { feature, kind, reason?, props? }',
201
+ )
202
+
203
+ expect(posts).toEqual([])
204
+ },
205
+ )
206
+
207
+ test(
208
+ 'a session with no first-party credential is refused, nothing sent',
209
+ { plugins: [Fixtures.recording] },
210
+ async ($, on) => {
211
+ mock.env(on, {})
212
+
213
+ const posts = Fixtures.firstPartySession(on, null)
214
+
215
+ const { text } = await $.command.run(
216
+ Fixtures.record(Fixtures.surveyAnswer()),
217
+ )
218
+
219
+ expect(text).toEndWith(
220
+ '$.telemetry.log: this session has no first-party credential to ' +
221
+ 'authorize',
222
+ )
223
+
224
+ expect(posts).toEqual([])
225
+ },
226
+ )
227
+
228
+ test(
229
+ "the ingest's refusal rejects the caller's promise",
230
+ { plugins: [Fixtures.recording] },
231
+ async ($, on) => {
232
+ mock.env(on, {})
233
+
234
+ const posts = Fixtures.firstPartySession(
235
+ on,
236
+ Fixtures.BEARER,
237
+ Fixtures.REFUSED,
238
+ )
239
+
240
+ const { text } = await $.command.run(
241
+ Fixtures.record(Fixtures.surveyAnswer()),
242
+ )
243
+
244
+ expect(text).toEndWith('$.telemetry.log: the ingest answered 500')
245
+ expect(posts).toHaveLength(1)
246
+ },
247
+ )
248
+
249
+ test(
250
+ 'free text and a malformed entry are refused, nothing sent',
251
+ { plugins: [Fixtures.recording] },
252
+ async ($, on) => {
253
+ mock.env(on, {})
254
+
255
+ const posts = Fixtures.firstPartySession(on)
256
+
257
+ const refusalFor = async (entry: unknown) =>
258
+ (
259
+ await $.command.run({
260
+ ...Fixtures.record(Fixtures.surveyAnswer()),
261
+ args: JSON.stringify(entry),
262
+ })
263
+ ).text
264
+
265
+ expect(
266
+ await refusalFor({ event: 'x', props: { note: 'hello world' } }),
267
+ ).toEndWith(
268
+ '$.telemetry.log: props.note: free text is refused; a string is a ' +
269
+ 'Choice, { value, of: [...] }',
270
+ )
271
+
272
+ expect(
273
+ await refusalFor({
274
+ event: 'x',
275
+ props: { page: { value: 'elsewhere', of: ['ready', 'later'] } },
276
+ }),
277
+ ).toEndWith(
278
+ '$.telemetry.log: props.page.value: one of the members of `of`',
279
+ )
280
+
281
+ expect(await refusalFor({ event: 'Survey' })).toEndWith(
282
+ '$.telemetry.log: takes an event name, a snake_case token',
283
+ )
284
+
285
+ expect(
286
+ await refusalFor({ event: 'x', props: { 'a path': 1 } }),
287
+ ).toEndWith('$.telemetry.log: props: every key is a snake_case token')
288
+
289
+ expect(await refusalFor('x')).toEndWith(
290
+ '$.telemetry.log: takes one entry, { event, props? }',
291
+ )
292
+
293
+ expect(posts).toEqual([])
294
+ },
295
+ )
296
+
297
+ test(
298
+ 'a number that is not finite is refused, nothing sent',
299
+ {
300
+ plugins: [
301
+ {
302
+ name: 'counting',
303
+ register(on) {
304
+ on('command.run', { command: 'count' }, $ =>
305
+ $.telemetry.log({ event: 'x', props: { n: Number.NaN } }).then(
306
+ () => ({ text: 'sent' }),
307
+ (error: unknown) => ({ text: String(error) }),
308
+ ),
309
+ )
310
+ },
311
+ },
312
+ ],
313
+ },
314
+ async ($, on) => {
315
+ mock.env(on, {})
316
+
317
+ const posts = Fixtures.firstPartySession(on)
318
+
319
+ const { text } = await $.command.run({
320
+ command: 'count',
321
+ args: '',
322
+ origin: { kind: 'composer' },
323
+ presentation: Fixtures.FULLSCREEN,
324
+ })
325
+
326
+ expect(text).toEndWith('$.telemetry.log: props.n: a number is finite')
327
+ expect(posts).toEqual([])
328
+ },
329
+ )
330
+
331
+ test(
332
+ 'a failed authorize is not memoized; a retry sends',
333
+ { plugins: [Fixtures.recording] },
334
+ async ($, on) => {
335
+ const posts: Args<'http.fetch'>[] = []
336
+
337
+ let authorizations = 0
338
+
339
+ mock.env(on, {})
340
+ on('session.id', () => ({ value: 'the-session' }))
341
+ on('session.model', () => ({ value: 'the-model' }))
342
+
343
+ on('session.authorize', () => {
344
+ authorizations += 1
345
+
346
+ const isFirstAuthorization = authorizations === 1
347
+
348
+ return isFirstAuthorization
349
+ ? { deny: 'transient' }
350
+ : { value: Fixtures.BEARER }
351
+ })
352
+
353
+ on('http.fetch', ($, e) => {
354
+ posts.push(e)
355
+
356
+ return { value: Fixtures.ACCEPTED }
357
+ })
358
+
359
+ const first = await $.command.run(Fixtures.record({ event: 'x' }))
360
+ const postsAfterFirst = posts.length
361
+ const second = await $.command.run(Fixtures.record({ event: 'x' }))
362
+
363
+ expect(first.text).toContain('transient')
364
+ expect(postsAfterFirst).toBe(0)
365
+ expect(second.text).toBe('sent')
366
+ expect(posts).toHaveLength(1)
367
+ },
368
+ )
369
+ })
mods/telemetry/types/index.d.ts ADDED
@@ -0,0 +1,116 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ /**
2
+ * The `$.telemetry` noun as every caller sees it: the one contract for the
3
+ * noun, its types exported here and the noun declared on `EngineInterface`.
4
+ *
5
+ * The telemetry mod adds the noun in the `engine.create` fold and checks its
6
+ * return against `EngineInterface['telemetry']`; its hooks import these types
7
+ * from this folder, a mod that calls the noun and a test that answers it read
8
+ * them by including it in their tsconfig, and the engine's repository imports
9
+ * the folder by path. Nothing here is imported, so it stands on its own.
10
+ */
11
+
12
+ /**
13
+ * A plugin's analytics, sent one event at a time through `$.telemetry`.
14
+ *
15
+ * Internal builds alone: the telemetry mod adds the noun in the
16
+ * `engine.create` fold, so a plugin on an external build, or one where the
17
+ * mod is off, finds no `$.telemetry` and its call throws.
18
+ */
19
+ export type Telemetry = {
20
+ /**
21
+ * Sends one event, `tengu_plugin_<event>`, as one first-party row;
22
+ * resolves once the ingest accepted it.
23
+ *
24
+ * The calling mod names itself in `event`; one already named `tengu_…` is
25
+ * sent as named. A value is a finite number, a boolean or a
26
+ * TelemetryChoice; free text is refused. One input, as every op on `$`
27
+ * takes.
28
+ *
29
+ * @param entry the event's name, a snake_case token, and its properties by
30
+ * snake_case key
31
+ * @example
32
+ * await $.telemetry.log({
33
+ * event: "suggest_learning_survey_answered",
34
+ * props: {
35
+ * answer: 2,
36
+ * page: { value: "ready", of: ["ready", "later"] },
37
+ * },
38
+ * })
39
+ */
40
+ log: (entry: TelemetryLogEntry) => Promise<void>
41
+
42
+ /**
43
+ * Marks one use of a feature as the CLI's own feature events do, one
44
+ * `tengu_feature_<kind>` row; resolves once the ingest accepted it.
45
+ *
46
+ * The row carries `feature_name`, `error_code` on sad or bad (`reason`,
47
+ * required there and refused on ok) and the entry's `props`, checked as
48
+ * `log`'s are; it joins the product-wide feature surface, so no prefix.
49
+ *
50
+ * @param entry the feature, how it went, why when not ok, and the row's
51
+ * properties by snake_case key
52
+ * @example
53
+ * await $.telemetry.mark({ feature: "learn_page", kind: "ok" })
54
+ * await $.telemetry.mark({
55
+ * feature: "learn_page",
56
+ * kind: "sad",
57
+ * reason: "blocked",
58
+ * })
59
+ */
60
+ mark: (entry: TelemetryMarkEntry) => Promise<void>
61
+ }
62
+
63
+ /**
64
+ * What `$.telemetry.log` takes: the event's name after the prefix, and its
65
+ * properties by snake_case key.
66
+ */
67
+ export type TelemetryLogEntry = {
68
+ event: string
69
+ props?: Readonly<Record<string, TelemetryProp>>
70
+ }
71
+
72
+ /**
73
+ * What `$.telemetry.mark` takes: the feature, how it went, why when not
74
+ * ok, and the properties the row carries beside them by snake_case key.
75
+ */
76
+ export type TelemetryMarkEntry = {
77
+ feature: string
78
+ kind: TelemetryMarkKind
79
+ reason?: string
80
+ props?: Readonly<Record<string, TelemetryProp>>
81
+ }
82
+
83
+ /**
84
+ * How a feature went, as the CLI's own feature events count it.
85
+ *
86
+ * `ok`: used, the person got what they asked. `sad`: degraded, a fallback
87
+ * or a partial, the person still got something. `bad`: failed, the person
88
+ * got nothing.
89
+ */
90
+ export type TelemetryMarkKind = 'ok' | 'sad' | 'bad'
91
+
92
+ /**
93
+ * A property's value: a finite number, a boolean, or a TelemetryChoice;
94
+ * never free text.
95
+ */
96
+ export type TelemetryProp = number | boolean | TelemetryChoice
97
+
98
+ /**
99
+ * A string property: the value and the list it is chosen from, declared
100
+ * beside it, so no free text reaches the row.
101
+ *
102
+ * Every member of `of` is a lowercase token of letters, digits, `_` and
103
+ * `-`, which may start with a digit, at most 32 of them; `value` is one of
104
+ * them.
105
+ */
106
+ export type TelemetryChoice = { value: string; of: readonly string[] }
107
+
108
+ declare module 'claude-code' {
109
+ interface EngineInterface {
110
+ /**
111
+ * A plugin's analytics, one first-party row per call; present only where
112
+ * the telemetry mod is seated (internal builds), absent everywhere else.
113
+ */
114
+ telemetry: Telemetry
115
+ }
116
+ }
mods/tsconfig.json ADDED
@@ -0,0 +1,17 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ {
2
+ "compilerOptions": {
3
+ "target": "es2023",
4
+ "lib": ["es2023"],
5
+ "types": [],
6
+ "module": "esnext",
7
+ "moduleResolution": "bundler",
8
+ "strict": true,
9
+ "noUncheckedIndexedAccess": true,
10
+ "noEmit": true,
11
+ "skipLibCheck": true,
12
+ "jsx": "react",
13
+ "jsxFactory": "h",
14
+ "jsxFragmentFactory": "Fragment"
15
+ },
16
+ "include": ["types", "*/types/**/*.d.ts", "*/hooks", "*/tests"]
17
+ }
mods/types/claude-code.d.ts ADDED
The diff for this file is too large to render. See raw diff
 
plugins/README.md ADDED
@@ -0,0 +1,77 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ # Claude Code Plugins
2
+
3
+ This directory contains some official Claude Code plugins that extend functionality through custom commands, agents, and workflows. These are examples of what's possible with the Claude Code plugin system—many more plugins are available through community marketplaces.
4
+
5
+ ## What are Claude Code Plugins?
6
+
7
+ Claude Code plugins are extensions that enhance Claude Code with custom slash commands, specialized agents, hooks, and MCP servers. Plugins can be shared across projects and teams, providing consistent tooling and workflows.
8
+
9
+ Learn more in the [official plugins documentation](https://docs.claude.com/en/docs/claude-code/plugins).
10
+
11
+ ## Plugins in This Directory
12
+
13
+ | Name | Description | Contents |
14
+ |------|-------------|----------|
15
+ | [agent-sdk-dev](./agent-sdk-dev/) | Development kit for working with the Claude Agent SDK | **Command:** `/new-sdk-app` - Interactive setup for new Agent SDK projects<br>**Agents:** `agent-sdk-verifier-py`, `agent-sdk-verifier-ts` - Validate SDK applications against best practices |
16
+ | [claude-opus-4-5-migration](./claude-opus-4-5-migration/) | Migrate code and prompts from Sonnet 4.x and Opus 4.1 to Opus 4.5 | **Skill:** `claude-opus-4-5-migration` - Automated migration of model strings, beta headers, and prompt adjustments |
17
+ | [code-review](./code-review/) | Automated PR code review using multiple specialized agents with confidence-based scoring to filter false positives | **Command:** `/code-review` - Automated PR review workflow<br>**Agents:** 5 parallel Sonnet agents for CLAUDE.md compliance, bug detection, historical context, PR history, and code comments |
18
+ | [commit-commands](./commit-commands/) | Git workflow automation for committing, pushing, and creating pull requests | **Commands:** `/commit`, `/commit-push-pr`, `/clean_gone` - Streamlined git operations |
19
+ | [explanatory-output-style](./explanatory-output-style/) | Adds educational insights about implementation choices and codebase patterns (mimics the deprecated Explanatory output style) | **Hook:** SessionStart - Injects educational context at the start of each session |
20
+ | [feature-dev](./feature-dev/) | Comprehensive feature development workflow with a structured 7-phase approach | **Command:** `/feature-dev` - Guided feature development workflow<br>**Agents:** `code-explorer`, `code-architect`, `code-reviewer` - For codebase analysis, architecture design, and quality review |
21
+ | [frontend-design](./frontend-design/) | Create distinctive, production-grade frontend interfaces that avoid generic AI aesthetics | **Skill:** `frontend-design` - Auto-invoked for frontend work, providing guidance on bold design choices, typography, animations, and visual details |
22
+ | [hookify](./hookify/) | Easily create custom hooks to prevent unwanted behaviors by analyzing conversation patterns or explicit instructions | **Commands:** `/hookify`, `/hookify:list`, `/hookify:configure`, `/hookify:help`<br>**Agent:** `conversation-analyzer` - Analyzes conversations for problematic behaviors<br>**Skill:** `writing-rules` - Guidance on hookify rule syntax |
23
+ | [learning-output-style](./learning-output-style/) | Interactive learning mode that requests meaningful code contributions at decision points (mimics the unshipped Learning output style) | **Hook:** SessionStart - Encourages users to write meaningful code (5-10 lines) at decision points while receiving educational insights |
24
+ | [plugin-dev](./plugin-dev/) | Comprehensive toolkit for developing Claude Code plugins with 7 expert skills and AI-assisted creation | **Command:** `/plugin-dev:create-plugin` - 8-phase guided workflow for building plugins<br>**Agents:** `agent-creator`, `plugin-validator`, `skill-reviewer`<br>**Skills:** Hook development, MCP integration, plugin structure, settings, commands, agents, and skill development |
25
+ | [pr-review-toolkit](./pr-review-toolkit/) | Comprehensive PR review agents specializing in comments, tests, error handling, type design, code quality, and code simplification | **Command:** `/pr-review-toolkit:review-pr` - Run with optional review aspects (comments, tests, errors, types, code, simplify, all)<br>**Agents:** `comment-analyzer`, `pr-test-analyzer`, `silent-failure-hunter`, `type-design-analyzer`, `code-reviewer`, `code-simplifier` |
26
+ | [ralph-wiggum](./ralph-wiggum/) | Interactive self-referential AI loops for iterative development. Claude works on the same task repeatedly until completion | **Commands:** `/ralph-loop`, `/cancel-ralph` - Start/stop autonomous iteration loops<br>**Hook:** Stop - Intercepts exit attempts to continue iteration |
27
+ | [security-guidance](./security-guidance/) | Security reminder hook that warns about potential security issues when editing files | **Hook:** PreToolUse - Monitors 9 security patterns including command injection, XSS, eval usage, dangerous HTML, pickle deserialization, and os.system calls |
28
+
29
+ ## Installation
30
+
31
+ These plugins are included in the Claude Code repository. To use them in your own projects:
32
+
33
+ 1. Install Claude Code globally:
34
+ ```bash
35
+ npm install -g @anthropic-ai/claude-code
36
+ ```
37
+
38
+ 2. Navigate to your project and run Claude Code:
39
+ ```bash
40
+ claude
41
+ ```
42
+
43
+ 3. Use the `/plugin` command to install plugins from marketplaces, or configure them in your project's `.claude/settings.json`.
44
+
45
+ For detailed plugin installation and configuration, see the [official documentation](https://docs.claude.com/en/docs/claude-code/plugins).
46
+
47
+ ## Plugin Structure
48
+
49
+ Each plugin follows the standard Claude Code plugin structure:
50
+
51
+ ```
52
+ plugin-name/
53
+ ├── .claude-plugin/
54
+ │ └── plugin.json # Plugin metadata
55
+ ├── commands/ # Slash commands (optional)
56
+ ├── agents/ # Specialized agents (optional)
57
+ ├── skills/ # Agent Skills (optional)
58
+ ├── hooks/ # Event handlers (optional)
59
+ ├── .mcp.json # External tool configuration (optional)
60
+ └── README.md # Plugin documentation
61
+ ```
62
+
63
+ ## Contributing
64
+
65
+ When adding new plugins to this directory:
66
+
67
+ 1. Follow the standard plugin structure
68
+ 2. Include a comprehensive README.md
69
+ 3. Add plugin metadata in `.claude-plugin/plugin.json`
70
+ 4. Document all commands and agents
71
+ 5. Provide usage examples
72
+
73
+ ## Learn More
74
+
75
+ - [Claude Code Documentation](https://docs.claude.com/en/docs/claude-code/overview)
76
+ - [Plugin System Documentation](https://docs.claude.com/en/docs/claude-code/plugins)
77
+ - [Agent SDK Documentation](https://docs.claude.com/en/api/agent-sdk/overview)
scripts/auto-close-duplicates.ts ADDED
@@ -0,0 +1,277 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ #!/usr/bin/env bun
2
+
3
+ declare global {
4
+ var process: {
5
+ env: Record<string, string | undefined>;
6
+ };
7
+ }
8
+
9
+ interface GitHubIssue {
10
+ number: number;
11
+ title: string;
12
+ user: { id: number };
13
+ created_at: string;
14
+ }
15
+
16
+ interface GitHubComment {
17
+ id: number;
18
+ body: string;
19
+ created_at: string;
20
+ user: { type: string; id: number };
21
+ }
22
+
23
+ interface GitHubReaction {
24
+ user: { id: number };
25
+ content: string;
26
+ }
27
+
28
+ async function githubRequest<T>(endpoint: string, token: string, method: string = 'GET', body?: any): Promise<T> {
29
+ const response = await fetch(`https://api.github.com${endpoint}`, {
30
+ method,
31
+ headers: {
32
+ Authorization: `Bearer ${token}`,
33
+ Accept: "application/vnd.github.v3+json",
34
+ "User-Agent": "auto-close-duplicates-script",
35
+ ...(body && { "Content-Type": "application/json" }),
36
+ },
37
+ ...(body && { body: JSON.stringify(body) }),
38
+ });
39
+
40
+ if (!response.ok) {
41
+ throw new Error(
42
+ `GitHub API request failed: ${response.status} ${response.statusText}`
43
+ );
44
+ }
45
+
46
+ return response.json();
47
+ }
48
+
49
+ function extractDuplicateIssueNumber(commentBody: string): number | null {
50
+ // Try to match #123 format first
51
+ let match = commentBody.match(/#(\d+)/);
52
+ if (match) {
53
+ return parseInt(match[1], 10);
54
+ }
55
+
56
+ // Try to match GitHub issue URL format: https://github.com/owner/repo/issues/123
57
+ match = commentBody.match(/github\.com\/[^\/]+\/[^\/]+\/issues\/(\d+)/);
58
+ if (match) {
59
+ return parseInt(match[1], 10);
60
+ }
61
+
62
+ return null;
63
+ }
64
+
65
+
66
+ async function closeIssueAsDuplicate(
67
+ owner: string,
68
+ repo: string,
69
+ issueNumber: number,
70
+ duplicateOfNumber: number,
71
+ token: string
72
+ ): Promise<void> {
73
+ await githubRequest(
74
+ `/repos/${owner}/${repo}/issues/${issueNumber}`,
75
+ token,
76
+ 'PATCH',
77
+ {
78
+ state: 'closed',
79
+ state_reason: 'duplicate',
80
+ labels: ['duplicate']
81
+ }
82
+ );
83
+
84
+ await githubRequest(
85
+ `/repos/${owner}/${repo}/issues/${issueNumber}/comments`,
86
+ token,
87
+ 'POST',
88
+ {
89
+ body: `This issue has been automatically closed as a duplicate of #${duplicateOfNumber}.
90
+
91
+ If this is incorrect, please re-open this issue or create a new one.
92
+
93
+ 🤖 Generated with [Claude Code](https://claude.ai/code)`
94
+ }
95
+ );
96
+
97
+ }
98
+
99
+ async function autoCloseDuplicates(): Promise<void> {
100
+ console.log("[DEBUG] Starting auto-close duplicates script");
101
+
102
+ const token = process.env.GITHUB_TOKEN;
103
+ if (!token) {
104
+ throw new Error("GITHUB_TOKEN environment variable is required");
105
+ }
106
+ console.log("[DEBUG] GitHub token found");
107
+
108
+ const owner = process.env.GITHUB_REPOSITORY_OWNER || "anthropics";
109
+ const repo = process.env.GITHUB_REPOSITORY_NAME || "claude-code";
110
+ console.log(`[DEBUG] Repository: ${owner}/${repo}`);
111
+
112
+ const threeDaysAgo = new Date();
113
+ threeDaysAgo.setDate(threeDaysAgo.getDate() - 3);
114
+ console.log(
115
+ `[DEBUG] Checking for duplicate comments older than: ${threeDaysAgo.toISOString()}`
116
+ );
117
+
118
+ console.log("[DEBUG] Fetching open issues created more than 3 days ago...");
119
+ const allIssues: GitHubIssue[] = [];
120
+ let page = 1;
121
+ const perPage = 100;
122
+
123
+ while (true) {
124
+ const pageIssues: GitHubIssue[] = await githubRequest(
125
+ `/repos/${owner}/${repo}/issues?state=open&per_page=${perPage}&page=${page}`,
126
+ token
127
+ );
128
+
129
+ if (pageIssues.length === 0) break;
130
+
131
+ // Filter for issues created more than 3 days ago
132
+ const oldEnoughIssues = pageIssues.filter(issue =>
133
+ new Date(issue.created_at) <= threeDaysAgo
134
+ );
135
+
136
+ allIssues.push(...oldEnoughIssues);
137
+ page++;
138
+
139
+ // Safety limit to avoid infinite loops
140
+ if (page > 20) break;
141
+ }
142
+
143
+ const issues = allIssues;
144
+ console.log(`[DEBUG] Found ${issues.length} open issues`);
145
+
146
+ let processedCount = 0;
147
+ let candidateCount = 0;
148
+
149
+ for (const issue of issues) {
150
+ processedCount++;
151
+ console.log(
152
+ `[DEBUG] Processing issue #${issue.number} (${processedCount}/${issues.length}): ${issue.title}`
153
+ );
154
+
155
+ console.log(`[DEBUG] Fetching comments for issue #${issue.number}...`);
156
+ const comments: GitHubComment[] = await githubRequest(
157
+ `/repos/${owner}/${repo}/issues/${issue.number}/comments`,
158
+ token
159
+ );
160
+ console.log(
161
+ `[DEBUG] Issue #${issue.number} has ${comments.length} comments`
162
+ );
163
+
164
+ const dupeComments = comments.filter(
165
+ (comment) =>
166
+ comment.body.includes("Found") &&
167
+ comment.body.includes("possible duplicate") &&
168
+ comment.user.type === "Bot"
169
+ );
170
+ console.log(
171
+ `[DEBUG] Issue #${issue.number} has ${dupeComments.length} duplicate detection comments`
172
+ );
173
+
174
+ if (dupeComments.length === 0) {
175
+ console.log(
176
+ `[DEBUG] Issue #${issue.number} - no duplicate comments found, skipping`
177
+ );
178
+ continue;
179
+ }
180
+
181
+ const lastDupeComment = dupeComments[dupeComments.length - 1];
182
+ const dupeCommentDate = new Date(lastDupeComment.created_at);
183
+ console.log(
184
+ `[DEBUG] Issue #${
185
+ issue.number
186
+ } - most recent duplicate comment from: ${dupeCommentDate.toISOString()}`
187
+ );
188
+
189
+ if (dupeCommentDate > threeDaysAgo) {
190
+ console.log(
191
+ `[DEBUG] Issue #${issue.number} - duplicate comment is too recent, skipping`
192
+ );
193
+ continue;
194
+ }
195
+ console.log(
196
+ `[DEBUG] Issue #${
197
+ issue.number
198
+ } - duplicate comment is old enough (${Math.floor(
199
+ (Date.now() - dupeCommentDate.getTime()) / (1000 * 60 * 60 * 24)
200
+ )} days)`
201
+ );
202
+
203
+ const commentsAfterDupe = comments.filter(
204
+ (comment) => new Date(comment.created_at) > dupeCommentDate
205
+ );
206
+ console.log(
207
+ `[DEBUG] Issue #${issue.number} - ${commentsAfterDupe.length} comments after duplicate detection`
208
+ );
209
+
210
+ if (commentsAfterDupe.length > 0) {
211
+ console.log(
212
+ `[DEBUG] Issue #${issue.number} - has activity after duplicate comment, skipping`
213
+ );
214
+ continue;
215
+ }
216
+
217
+ console.log(
218
+ `[DEBUG] Issue #${issue.number} - checking reactions on duplicate comment...`
219
+ );
220
+ const reactions: GitHubReaction[] = await githubRequest(
221
+ `/repos/${owner}/${repo}/issues/comments/${lastDupeComment.id}/reactions`,
222
+ token
223
+ );
224
+ console.log(
225
+ `[DEBUG] Issue #${issue.number} - duplicate comment has ${reactions.length} reactions`
226
+ );
227
+
228
+ const authorThumbsDown = reactions.some(
229
+ (reaction) =>
230
+ reaction.user.id === issue.user.id && reaction.content === "-1"
231
+ );
232
+ console.log(
233
+ `[DEBUG] Issue #${issue.number} - author thumbs down reaction: ${authorThumbsDown}`
234
+ );
235
+
236
+ if (authorThumbsDown) {
237
+ console.log(
238
+ `[DEBUG] Issue #${issue.number} - author disagreed with duplicate detection, skipping`
239
+ );
240
+ continue;
241
+ }
242
+
243
+ const duplicateIssueNumber = extractDuplicateIssueNumber(lastDupeComment.body);
244
+ if (!duplicateIssueNumber) {
245
+ console.log(
246
+ `[DEBUG] Issue #${issue.number} - could not extract duplicate issue number from comment, skipping`
247
+ );
248
+ continue;
249
+ }
250
+
251
+ candidateCount++;
252
+ const issueUrl = `https://github.com/${owner}/${repo}/issues/${issue.number}`;
253
+
254
+ try {
255
+ console.log(
256
+ `[INFO] Auto-closing issue #${issue.number} as duplicate of #${duplicateIssueNumber}: ${issueUrl}`
257
+ );
258
+ await closeIssueAsDuplicate(owner, repo, issue.number, duplicateIssueNumber, token);
259
+ console.log(
260
+ `[SUCCESS] Successfully closed issue #${issue.number} as duplicate of #${duplicateIssueNumber}`
261
+ );
262
+ } catch (error) {
263
+ console.error(
264
+ `[ERROR] Failed to close issue #${issue.number} as duplicate: ${error}`
265
+ );
266
+ }
267
+ }
268
+
269
+ console.log(
270
+ `[DEBUG] Script completed. Processed ${processedCount} issues, found ${candidateCount} candidates for auto-close`
271
+ );
272
+ }
273
+
274
+ autoCloseDuplicates().catch(console.error);
275
+
276
+ // Make it a module
277
+ export {};
scripts/backfill-duplicate-comments.ts ADDED
@@ -0,0 +1,213 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ #!/usr/bin/env bun
2
+
3
+ declare global {
4
+ var process: {
5
+ env: Record<string, string | undefined>;
6
+ };
7
+ }
8
+
9
+ interface GitHubIssue {
10
+ number: number;
11
+ title: string;
12
+ state: string;
13
+ state_reason?: string;
14
+ user: { id: number };
15
+ created_at: string;
16
+ closed_at?: string;
17
+ }
18
+
19
+ interface GitHubComment {
20
+ id: number;
21
+ body: string;
22
+ created_at: string;
23
+ user: { type: string; id: number };
24
+ }
25
+
26
+ async function githubRequest<T>(endpoint: string, token: string, method: string = 'GET', body?: any): Promise<T> {
27
+ const response = await fetch(`https://api.github.com${endpoint}`, {
28
+ method,
29
+ headers: {
30
+ Authorization: `Bearer ${token}`,
31
+ Accept: "application/vnd.github.v3+json",
32
+ "User-Agent": "backfill-duplicate-comments-script",
33
+ ...(body && { "Content-Type": "application/json" }),
34
+ },
35
+ ...(body && { body: JSON.stringify(body) }),
36
+ });
37
+
38
+ if (!response.ok) {
39
+ throw new Error(
40
+ `GitHub API request failed: ${response.status} ${response.statusText}`
41
+ );
42
+ }
43
+
44
+ return response.json();
45
+ }
46
+
47
+ async function triggerDedupeWorkflow(
48
+ owner: string,
49
+ repo: string,
50
+ issueNumber: number,
51
+ token: string,
52
+ dryRun: boolean = true
53
+ ): Promise<void> {
54
+ if (dryRun) {
55
+ console.log(`[DRY RUN] Would trigger dedupe workflow for issue #${issueNumber}`);
56
+ return;
57
+ }
58
+
59
+ await githubRequest(
60
+ `/repos/${owner}/${repo}/actions/workflows/claude-dedupe-issues.yml/dispatches`,
61
+ token,
62
+ 'POST',
63
+ {
64
+ ref: 'main',
65
+ inputs: {
66
+ issue_number: issueNumber.toString()
67
+ }
68
+ }
69
+ );
70
+ }
71
+
72
+ async function backfillDuplicateComments(): Promise<void> {
73
+ console.log("[DEBUG] Starting backfill duplicate comments script");
74
+
75
+ const token = process.env.GITHUB_TOKEN;
76
+ if (!token) {
77
+ throw new Error(`GITHUB_TOKEN environment variable is required
78
+
79
+ Usage:
80
+ GITHUB_TOKEN=your_token bun run scripts/backfill-duplicate-comments.ts
81
+
82
+ Environment Variables:
83
+ GITHUB_TOKEN - GitHub personal access token with repo and actions permissions (required)
84
+ DRY_RUN - Set to "false" to actually trigger workflows (default: true for safety)
85
+ MAX_ISSUE_NUMBER - Only process issues with numbers less than this value (default: 4050)`);
86
+ }
87
+ console.log("[DEBUG] GitHub token found");
88
+
89
+ const owner = "anthropics";
90
+ const repo = "claude-code";
91
+ const dryRun = process.env.DRY_RUN !== "false";
92
+ const maxIssueNumber = parseInt(process.env.MAX_ISSUE_NUMBER || "4050", 10);
93
+ const minIssueNumber = parseInt(process.env.MIN_ISSUE_NUMBER || "1", 10);
94
+
95
+ console.log(`[DEBUG] Repository: ${owner}/${repo}`);
96
+ console.log(`[DEBUG] Dry run mode: ${dryRun}`);
97
+ console.log(`[DEBUG] Looking at issues between #${minIssueNumber} and #${maxIssueNumber}`);
98
+
99
+ console.log(`[DEBUG] Fetching issues between #${minIssueNumber} and #${maxIssueNumber}...`);
100
+ const allIssues: GitHubIssue[] = [];
101
+ let page = 1;
102
+ const perPage = 100;
103
+
104
+ while (true) {
105
+ const pageIssues: GitHubIssue[] = await githubRequest(
106
+ `/repos/${owner}/${repo}/issues?state=all&per_page=${perPage}&page=${page}&sort=created&direction=desc`,
107
+ token
108
+ );
109
+
110
+ if (pageIssues.length === 0) break;
111
+
112
+ // Filter to only include issues within the specified range
113
+ const filteredIssues = pageIssues.filter(issue =>
114
+ issue.number >= minIssueNumber && issue.number < maxIssueNumber
115
+ );
116
+ allIssues.push(...filteredIssues);
117
+
118
+ // If the oldest issue in this page is still above our minimum, we need to continue
119
+ // but if the oldest issue is below our minimum, we can stop
120
+ const oldestIssueInPage = pageIssues[pageIssues.length - 1];
121
+ if (oldestIssueInPage && oldestIssueInPage.number >= maxIssueNumber) {
122
+ console.log(`[DEBUG] Oldest issue in page #${page} is #${oldestIssueInPage.number}, continuing...`);
123
+ } else if (oldestIssueInPage && oldestIssueInPage.number < minIssueNumber) {
124
+ console.log(`[DEBUG] Oldest issue in page #${page} is #${oldestIssueInPage.number}, below minimum, stopping`);
125
+ break;
126
+ } else if (filteredIssues.length === 0 && pageIssues.length > 0) {
127
+ console.log(`[DEBUG] No issues in page #${page} are in range #${minIssueNumber}-#${maxIssueNumber}, continuing...`);
128
+ }
129
+
130
+ page++;
131
+
132
+ // Safety limit to avoid infinite loops
133
+ if (page > 200) {
134
+ console.log("[DEBUG] Reached page limit, stopping pagination");
135
+ break;
136
+ }
137
+ }
138
+
139
+ console.log(`[DEBUG] Found ${allIssues.length} issues between #${minIssueNumber} and #${maxIssueNumber}`);
140
+
141
+ let processedCount = 0;
142
+ let candidateCount = 0;
143
+ let triggeredCount = 0;
144
+
145
+ for (const issue of allIssues) {
146
+ processedCount++;
147
+ console.log(
148
+ `[DEBUG] Processing issue #${issue.number} (${processedCount}/${allIssues.length}): ${issue.title}`
149
+ );
150
+
151
+ console.log(`[DEBUG] Fetching comments for issue #${issue.number}...`);
152
+ const comments: GitHubComment[] = await githubRequest(
153
+ `/repos/${owner}/${repo}/issues/${issue.number}/comments`,
154
+ token
155
+ );
156
+ console.log(
157
+ `[DEBUG] Issue #${issue.number} has ${comments.length} comments`
158
+ );
159
+
160
+ // Look for existing duplicate detection comments (from the dedupe bot)
161
+ const dupeDetectionComments = comments.filter(
162
+ (comment) =>
163
+ comment.body.includes("Found") &&
164
+ comment.body.includes("possible duplicate") &&
165
+ comment.user.type === "Bot"
166
+ );
167
+
168
+ console.log(
169
+ `[DEBUG] Issue #${issue.number} has ${dupeDetectionComments.length} duplicate detection comments`
170
+ );
171
+
172
+ // Skip if there's already a duplicate detection comment
173
+ if (dupeDetectionComments.length > 0) {
174
+ console.log(
175
+ `[DEBUG] Issue #${issue.number} already has duplicate detection comment, skipping`
176
+ );
177
+ continue;
178
+ }
179
+
180
+ candidateCount++;
181
+ const issueUrl = `https://github.com/${owner}/${repo}/issues/${issue.number}`;
182
+
183
+ try {
184
+ console.log(
185
+ `[INFO] ${dryRun ? '[DRY RUN] ' : ''}Triggering dedupe workflow for issue #${issue.number}: ${issueUrl}`
186
+ );
187
+ await triggerDedupeWorkflow(owner, repo, issue.number, token, dryRun);
188
+
189
+ if (!dryRun) {
190
+ console.log(
191
+ `[SUCCESS] Successfully triggered dedupe workflow for issue #${issue.number}`
192
+ );
193
+ }
194
+ triggeredCount++;
195
+ } catch (error) {
196
+ console.error(
197
+ `[ERROR] Failed to trigger workflow for issue #${issue.number}: ${error}`
198
+ );
199
+ }
200
+
201
+ // Add a delay between workflow triggers to avoid overwhelming the system
202
+ await new Promise(resolve => setTimeout(resolve, 1000));
203
+ }
204
+
205
+ console.log(
206
+ `[DEBUG] Script completed. Processed ${processedCount} issues, found ${candidateCount} candidates without duplicate comments, ${dryRun ? 'would trigger' : 'triggered'} ${triggeredCount} workflows`
207
+ );
208
+ }
209
+
210
+ backfillDuplicateComments().catch(console.error);
211
+
212
+ // Make it a module
213
+ export {};
scripts/comment-on-duplicates.sh ADDED
@@ -0,0 +1,95 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ #!/usr/bin/env bash
2
+ #
3
+ # Comments on a GitHub issue with a list of potential duplicates.
4
+ # Usage: ./comment-on-duplicates.sh --potential-duplicates 456 789 101
5
+ #
6
+ # The base issue number is read from the workflow event payload.
7
+ #
8
+
9
+ set -euo pipefail
10
+
11
+ REPO="anthropics/claude-code"
12
+
13
+ # Read from event payload so the issue number is bound to the triggering event.
14
+ # Falls back to workflow_dispatch inputs for manual runs.
15
+ BASE_ISSUE=$(jq -r '.issue.number // .inputs.issue_number // empty' "${GITHUB_EVENT_PATH:?GITHUB_EVENT_PATH not set}")
16
+ if ! [[ "$BASE_ISSUE" =~ ^[0-9]+$ ]]; then
17
+ echo "Error: no issue number in event payload" >&2
18
+ exit 1
19
+ fi
20
+
21
+ DUPLICATES=()
22
+
23
+ # Parse arguments
24
+ while [[ $# -gt 0 ]]; do
25
+ case $1 in
26
+ --potential-duplicates)
27
+ shift
28
+ while [[ $# -gt 0 && ! "$1" =~ ^-- ]]; do
29
+ DUPLICATES+=("$1")
30
+ shift
31
+ done
32
+ ;;
33
+ *)
34
+ echo "Error: unknown argument (only --potential-duplicates is accepted)" >&2
35
+ exit 1
36
+ ;;
37
+ esac
38
+ done
39
+
40
+ # Validate duplicates
41
+ if [[ ${#DUPLICATES[@]} -eq 0 ]]; then
42
+ echo "Error: --potential-duplicates requires at least one issue number" >&2
43
+ exit 1
44
+ fi
45
+
46
+ if [[ ${#DUPLICATES[@]} -gt 3 ]]; then
47
+ echo "Error: --potential-duplicates accepts at most 3 issues" >&2
48
+ exit 1
49
+ fi
50
+
51
+ for dup in "${DUPLICATES[@]}"; do
52
+ if ! [[ "$dup" =~ ^[0-9]+$ ]]; then
53
+ echo "Error: duplicate issue must be a number, got: $dup" >&2
54
+ exit 1
55
+ fi
56
+ done
57
+
58
+ # Validate that base issue exists
59
+ if ! gh issue view "$BASE_ISSUE" --repo "$REPO" &>/dev/null; then
60
+ echo "Error: issue #$BASE_ISSUE does not exist in $REPO" >&2
61
+ exit 1
62
+ fi
63
+
64
+ # Validate that all duplicate issues exist
65
+ for dup in "${DUPLICATES[@]}"; do
66
+ if ! gh issue view "$dup" --repo "$REPO" &>/dev/null; then
67
+ echo "Error: issue #$dup does not exist in $REPO" >&2
68
+ exit 1
69
+ fi
70
+ done
71
+
72
+ # Build comment body
73
+ COUNT=${#DUPLICATES[@]}
74
+ if [[ $COUNT -eq 1 ]]; then
75
+ HEADER="Found 1 possible duplicate issue:"
76
+ else
77
+ HEADER="Found $COUNT possible duplicate issues:"
78
+ fi
79
+
80
+ BODY="$HEADER"$'\n\n'
81
+ INDEX=1
82
+ for dup in "${DUPLICATES[@]}"; do
83
+ BODY+="$INDEX. https://github.com/$REPO/issues/$dup"$'\n'
84
+ ((INDEX++))
85
+ done
86
+
87
+ BODY+=$'\n'"This issue will be automatically closed as a duplicate in 3 days."$'\n\n'
88
+ BODY+="- If your issue is a duplicate, please close it and 👍 the existing issue instead"$'\n'
89
+ BODY+="- To prevent auto-closure, add a comment or 👎 this comment"$'\n\n'
90
+ BODY+="🤖 Generated with [Claude Code](https://claude.ai/code)"
91
+
92
+ # Post the comment
93
+ gh issue comment "$BASE_ISSUE" --repo "$REPO" --body "$BODY"
94
+
95
+ echo "Posted duplicate comment on issue #$BASE_ISSUE"
scripts/edit-issue-labels.sh ADDED
@@ -0,0 +1,84 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ #!/usr/bin/env bash
2
+ #
3
+ # Edits labels on a GitHub issue.
4
+ # Usage: ./edit-issue-labels.sh --add-label bug --add-label needs-triage --remove-label untriaged
5
+ #
6
+ # The issue number is read from the workflow event payload.
7
+ #
8
+
9
+ set -euo pipefail
10
+
11
+ # Read from event payload so the issue number is bound to the triggering event.
12
+ # Falls back to workflow_dispatch inputs for manual runs.
13
+ ISSUE=$(jq -r '.issue.number // .inputs.issue_number // empty' "${GITHUB_EVENT_PATH:?GITHUB_EVENT_PATH not set}")
14
+ if ! [[ "$ISSUE" =~ ^[0-9]+$ ]]; then
15
+ echo "Error: no issue number in event payload" >&2
16
+ exit 1
17
+ fi
18
+
19
+ ADD_LABELS=()
20
+ REMOVE_LABELS=()
21
+
22
+ # Parse arguments
23
+ while [[ $# -gt 0 ]]; do
24
+ case $1 in
25
+ --add-label)
26
+ ADD_LABELS+=("$2")
27
+ shift 2
28
+ ;;
29
+ --remove-label)
30
+ REMOVE_LABELS+=("$2")
31
+ shift 2
32
+ ;;
33
+ *)
34
+ echo "Error: unknown argument (only --add-label and --remove-label are accepted)" >&2
35
+ exit 1
36
+ ;;
37
+ esac
38
+ done
39
+
40
+ if [[ ${#ADD_LABELS[@]} -eq 0 && ${#REMOVE_LABELS[@]} -eq 0 ]]; then
41
+ exit 1
42
+ fi
43
+
44
+ # Fetch valid labels from the repo
45
+ VALID_LABELS=$(gh label list --limit 500 --json name --jq '.[].name')
46
+
47
+ # Filter to only labels that exist in the repo
48
+ FILTERED_ADD=()
49
+ for label in "${ADD_LABELS[@]}"; do
50
+ if echo "$VALID_LABELS" | grep -qxF "$label"; then
51
+ FILTERED_ADD+=("$label")
52
+ fi
53
+ done
54
+
55
+ FILTERED_REMOVE=()
56
+ for label in "${REMOVE_LABELS[@]}"; do
57
+ if echo "$VALID_LABELS" | grep -qxF "$label"; then
58
+ FILTERED_REMOVE+=("$label")
59
+ fi
60
+ done
61
+
62
+ if [[ ${#FILTERED_ADD[@]} -eq 0 && ${#FILTERED_REMOVE[@]} -eq 0 ]]; then
63
+ exit 0
64
+ fi
65
+
66
+ # Build gh command arguments
67
+ GH_ARGS=("issue" "edit" "$ISSUE")
68
+
69
+ for label in "${FILTERED_ADD[@]}"; do
70
+ GH_ARGS+=("--add-label" "$label")
71
+ done
72
+
73
+ for label in "${FILTERED_REMOVE[@]}"; do
74
+ GH_ARGS+=("--remove-label" "$label")
75
+ done
76
+
77
+ gh "${GH_ARGS[@]}"
78
+
79
+ if [[ ${#FILTERED_ADD[@]} -gt 0 ]]; then
80
+ echo "Added: ${FILTERED_ADD[*]}"
81
+ fi
82
+ if [[ ${#FILTERED_REMOVE[@]} -gt 0 ]]; then
83
+ echo "Removed: ${FILTERED_REMOVE[*]}"
84
+ fi
scripts/gh.sh ADDED
@@ -0,0 +1,96 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ #!/usr/bin/env bash
2
+ set -euo pipefail
3
+
4
+ # Wrapper around gh CLI that only allows specific subcommands and flags.
5
+ # All commands are scoped to the current repository via GH_REPO or GITHUB_REPOSITORY.
6
+ #
7
+ # Usage:
8
+ # ./scripts/gh.sh issue view 123
9
+ # ./scripts/gh.sh issue view 123 --comments
10
+ # ./scripts/gh.sh issue list --state open --limit 20
11
+ # ./scripts/gh.sh search issues "search query" --limit 10
12
+ # ./scripts/gh.sh label list --limit 100
13
+
14
+ export GH_HOST=github.com
15
+
16
+ REPO="${GH_REPO:-${GITHUB_REPOSITORY:-}}"
17
+ if [[ -z "$REPO" || "$REPO" == */*/* || "$REPO" != */* ]]; then
18
+ echo "Error: GH_REPO or GITHUB_REPOSITORY must be set to owner/repo format (e.g., GITHUB_REPOSITORY=anthropics/claude-code)" >&2
19
+ exit 1
20
+ fi
21
+ export GH_REPO="$REPO"
22
+
23
+ ALLOWED_FLAGS=(--comments --state --limit --label)
24
+ FLAGS_WITH_VALUES=(--state --limit --label)
25
+
26
+ SUB1="${1:-}"
27
+ SUB2="${2:-}"
28
+ CMD="$SUB1 $SUB2"
29
+ case "$CMD" in
30
+ "issue view"|"issue list"|"search issues"|"label list")
31
+ ;;
32
+ *)
33
+ echo "Error: only 'issue view', 'issue list', 'search issues', 'label list' are allowed (e.g., ./scripts/gh.sh issue view 123)" >&2
34
+ exit 1
35
+ ;;
36
+ esac
37
+
38
+ shift 2
39
+
40
+ # Separate flags from positional arguments
41
+ POSITIONAL=()
42
+ FLAGS=()
43
+ skip_next=false
44
+ for arg in "$@"; do
45
+ if [[ "$skip_next" == true ]]; then
46
+ FLAGS+=("$arg")
47
+ skip_next=false
48
+ elif [[ "$arg" == -* ]]; then
49
+ flag="${arg%%=*}"
50
+ matched=false
51
+ for allowed in "${ALLOWED_FLAGS[@]}"; do
52
+ if [[ "$flag" == "$allowed" ]]; then
53
+ matched=true
54
+ break
55
+ fi
56
+ done
57
+ if [[ "$matched" == false ]]; then
58
+ echo "Error: only --comments, --state, --limit, --label flags are allowed (e.g., ./scripts/gh.sh issue list --state open --limit 20)" >&2
59
+ exit 1
60
+ fi
61
+ FLAGS+=("$arg")
62
+ # If flag expects a value and isn't using = syntax, skip next arg
63
+ if [[ "$arg" != *=* ]]; then
64
+ for vflag in "${FLAGS_WITH_VALUES[@]}"; do
65
+ if [[ "$flag" == "$vflag" ]]; then
66
+ skip_next=true
67
+ break
68
+ fi
69
+ done
70
+ fi
71
+ else
72
+ POSITIONAL+=("$arg")
73
+ fi
74
+ done
75
+
76
+ if [[ "$CMD" == "search issues" ]]; then
77
+ QUERY="${POSITIONAL[0]:-}"
78
+ QUERY_LOWER=$(echo "$QUERY" | tr '[:upper:]' '[:lower:]')
79
+ if [[ "$QUERY_LOWER" == *"repo:"* || "$QUERY_LOWER" == *"org:"* || "$QUERY_LOWER" == *"user:"* ]]; then
80
+ echo "Error: search query must not contain repo:, org:, or user: qualifiers (e.g., ./scripts/gh.sh search issues \"bug report\" --limit 10)" >&2
81
+ exit 1
82
+ fi
83
+ gh "$SUB1" "$SUB2" "$QUERY" --repo "$REPO" "${FLAGS[@]}"
84
+ elif [[ "$CMD" == "issue view" ]]; then
85
+ if [[ ${#POSITIONAL[@]} -ne 1 ]] || ! [[ "${POSITIONAL[0]}" =~ ^[0-9]+$ ]]; then
86
+ echo "Error: issue view requires exactly one numeric issue number (e.g., ./scripts/gh.sh issue view 123)" >&2
87
+ exit 1
88
+ fi
89
+ gh "$SUB1" "$SUB2" "${POSITIONAL[0]}" "${FLAGS[@]}"
90
+ else
91
+ if [[ ${#POSITIONAL[@]} -ne 0 ]]; then
92
+ echo "Error: issue list and label list do not accept positional arguments (e.g., ./scripts/gh.sh issue list --state open, ./scripts/gh.sh label list --limit 100)" >&2
93
+ exit 1
94
+ fi
95
+ gh "$SUB1" "$SUB2" "${FLAGS[@]}"
96
+ fi
scripts/issue-lifecycle.ts ADDED
@@ -0,0 +1,38 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ // Single source of truth for issue lifecycle labels, timeouts, and messages.
2
+
3
+ export const lifecycle = [
4
+ {
5
+ label: "invalid",
6
+ days: 3,
7
+ reason: "this doesn't appear to be about Claude Code",
8
+ nudge: "This doesn't appear to be about [Claude Code](https://github.com/anthropics/claude-code). For general Anthropic support, visit [support.anthropic.com](https://support.anthropic.com).",
9
+ },
10
+ {
11
+ label: "needs-repro",
12
+ days: 7,
13
+ reason: "we still need reproduction steps to investigate",
14
+ nudge: "We weren't able to reproduce this. Could you provide steps to trigger the issue — what you ran, what happened, and what you expected?",
15
+ },
16
+ {
17
+ label: "needs-info",
18
+ days: 7,
19
+ reason: "we still need a bit more information to move forward",
20
+ nudge: "We need more information to continue investigating. Can you make sure to include your Claude Code version (`claude --version`), OS, and any error messages or logs?",
21
+ },
22
+ {
23
+ label: "stale",
24
+ days: 14,
25
+ reason: "inactive for too long",
26
+ nudge: "This issue has been automatically marked as stale due to inactivity.",
27
+ },
28
+ {
29
+ label: "autoclose",
30
+ days: 14,
31
+ reason: "inactive for too long",
32
+ nudge: "This issue has been marked for automatic closure.",
33
+ },
34
+ ] as const;
35
+
36
+ export type LifecycleLabel = (typeof lifecycle)[number]["label"];
37
+
38
+ export const STALE_UPVOTE_THRESHOLD = 10;
scripts/lifecycle-comment.ts ADDED
@@ -0,0 +1,53 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ #!/usr/bin/env bun
2
+
3
+ // Posts a comment when a lifecycle label is applied to an issue,
4
+ // giving the author a heads-up and a chance to respond before auto-close.
5
+
6
+ import { lifecycle } from "./issue-lifecycle.ts";
7
+
8
+ const DRY_RUN = process.argv.includes("--dry-run");
9
+ const token = process.env.GITHUB_TOKEN;
10
+ const repo = process.env.GITHUB_REPOSITORY; // owner/repo
11
+ const label = process.env.LABEL;
12
+ const issueNumber = process.env.ISSUE_NUMBER;
13
+
14
+ if (!DRY_RUN && !token) throw new Error("GITHUB_TOKEN required");
15
+ if (!repo) throw new Error("GITHUB_REPOSITORY required");
16
+ if (!label) throw new Error("LABEL required");
17
+ if (!issueNumber) throw new Error("ISSUE_NUMBER required");
18
+
19
+ const entry = lifecycle.find((l) => l.label === label);
20
+ if (!entry) {
21
+ console.log(`No lifecycle entry for label "${label}", skipping`);
22
+ process.exit(0);
23
+ }
24
+
25
+ const body = `${entry.nudge} This issue will be closed automatically if there's no activity within ${entry.days} days.`;
26
+
27
+ // --
28
+
29
+ if (DRY_RUN) {
30
+ console.log(`Would comment on #${issueNumber} for label "${label}":\n\n${body}`);
31
+ process.exit(0);
32
+ }
33
+
34
+ const response = await fetch(
35
+ `https://api.github.com/repos/${repo}/issues/${issueNumber}/comments`,
36
+ {
37
+ method: "POST",
38
+ headers: {
39
+ Authorization: `Bearer ${token}`,
40
+ Accept: "application/vnd.github.v3+json",
41
+ "Content-Type": "application/json",
42
+ "User-Agent": "lifecycle-comment",
43
+ },
44
+ body: JSON.stringify({ body }),
45
+ }
46
+ );
47
+
48
+ if (!response.ok) {
49
+ const text = await response.text();
50
+ throw new Error(`GitHub API ${response.status}: ${text}`);
51
+ }
52
+
53
+ console.log(`Commented on #${issueNumber} for label "${label}"`);
scripts/sweep.ts ADDED
@@ -0,0 +1,168 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ #!/usr/bin/env bun
2
+
3
+ import { lifecycle, STALE_UPVOTE_THRESHOLD } from "./issue-lifecycle.ts";
4
+
5
+ // --
6
+
7
+ const NEW_ISSUE = "https://github.com/anthropics/claude-code/issues/new/choose";
8
+ const DRY_RUN = process.argv.includes("--dry-run");
9
+
10
+ const CLOSE_MESSAGE = (reason: string) =>
11
+ `Closing for now — ${reason}. Please [open a new issue](${NEW_ISSUE}) if this is still relevant.`;
12
+
13
+ // --
14
+
15
+ async function githubRequest<T>(
16
+ endpoint: string,
17
+ method = "GET",
18
+ body?: unknown
19
+ ): Promise<T> {
20
+ const token = process.env.GITHUB_TOKEN;
21
+ if (!token) throw new Error("GITHUB_TOKEN required");
22
+
23
+ const response = await fetch(`https://api.github.com${endpoint}`, {
24
+ method,
25
+ headers: {
26
+ Authorization: `Bearer ${token}`,
27
+ Accept: "application/vnd.github.v3+json",
28
+ "User-Agent": "sweep",
29
+ ...(body && { "Content-Type": "application/json" }),
30
+ },
31
+ ...(body && { body: JSON.stringify(body) }),
32
+ });
33
+
34
+ if (!response.ok) {
35
+ if (response.status === 404) return {} as T;
36
+ const text = await response.text();
37
+ throw new Error(`GitHub API ${response.status}: ${text}`);
38
+ }
39
+
40
+ return response.json();
41
+ }
42
+
43
+ // --
44
+
45
+ async function markStale(owner: string, repo: string) {
46
+ const staleDays = lifecycle.find((l) => l.label === "stale")!.days;
47
+ const cutoff = new Date();
48
+ cutoff.setDate(cutoff.getDate() - staleDays);
49
+
50
+ let labeled = 0;
51
+
52
+ console.log(`\n=== marking stale (${staleDays}d inactive) ===`);
53
+
54
+ for (let page = 1; page <= 10; page++) {
55
+ const issues = await githubRequest<any[]>(
56
+ `/repos/${owner}/${repo}/issues?state=open&sort=updated&direction=asc&per_page=100&page=${page}`
57
+ );
58
+ if (issues.length === 0) break;
59
+
60
+ for (const issue of issues) {
61
+ if (issue.pull_request) continue;
62
+ if (issue.locked) continue;
63
+ if (issue.assignees?.length > 0) continue;
64
+
65
+ const updatedAt = new Date(issue.updated_at);
66
+ if (updatedAt > cutoff) return labeled;
67
+
68
+ const alreadyStale = issue.labels?.some(
69
+ (l: any) => l.name === "stale" || l.name === "autoclose"
70
+ );
71
+ if (alreadyStale) continue;
72
+
73
+ const thumbsUp = issue.reactions?.["+1"] ?? 0;
74
+ if (thumbsUp >= STALE_UPVOTE_THRESHOLD) continue;
75
+
76
+ const base = `/repos/${owner}/${repo}/issues/${issue.number}`;
77
+
78
+ if (DRY_RUN) {
79
+ const age = Math.floor((Date.now() - updatedAt.getTime()) / 86400000);
80
+ console.log(`#${issue.number}: would label stale (${age}d inactive) — ${issue.title}`);
81
+ } else {
82
+ await githubRequest(`${base}/labels`, "POST", { labels: ["stale"] });
83
+ console.log(`#${issue.number}: labeled stale — ${issue.title}`);
84
+ }
85
+ labeled++;
86
+ }
87
+ }
88
+
89
+ return labeled;
90
+ }
91
+
92
+ async function closeExpired(owner: string, repo: string) {
93
+ let closed = 0;
94
+
95
+ for (const { label, days, reason } of lifecycle) {
96
+ const cutoff = new Date();
97
+ cutoff.setDate(cutoff.getDate() - days);
98
+ console.log(`\n=== ${label} (${days}d timeout) ===`);
99
+
100
+ for (let page = 1; page <= 10; page++) {
101
+ const issues = await githubRequest<any[]>(
102
+ `/repos/${owner}/${repo}/issues?state=open&labels=${label}&sort=updated&direction=asc&per_page=100&page=${page}`
103
+ );
104
+ if (issues.length === 0) break;
105
+
106
+ for (const issue of issues) {
107
+ if (issue.pull_request) continue;
108
+ if (issue.locked) continue;
109
+
110
+ const thumbsUp = issue.reactions?.["+1"] ?? 0;
111
+ if (thumbsUp >= STALE_UPVOTE_THRESHOLD) continue;
112
+
113
+ const base = `/repos/${owner}/${repo}/issues/${issue.number}`;
114
+
115
+ const events = await githubRequest<any[]>(`${base}/events?per_page=100`);
116
+
117
+ const labeledAt = events
118
+ .filter((e) => e.event === "labeled" && e.label?.name === label)
119
+ .map((e) => new Date(e.created_at))
120
+ .pop();
121
+
122
+ if (!labeledAt || labeledAt > cutoff) continue;
123
+
124
+ // Skip if a non-bot user commented after the label was applied.
125
+ // The triage workflow should remove lifecycle labels on human
126
+ // activity, but check here too as a safety net.
127
+ const comments = await githubRequest<any[]>(
128
+ `${base}/comments?since=${labeledAt.toISOString()}&per_page=100`
129
+ );
130
+ const hasHumanComment = comments.some(
131
+ (c) => c.user && c.user.type !== "Bot"
132
+ );
133
+ if (hasHumanComment) {
134
+ console.log(
135
+ `#${issue.number}: skipping (human activity after ${label} label)`
136
+ );
137
+ continue;
138
+ }
139
+
140
+ if (DRY_RUN) {
141
+ const age = Math.floor((Date.now() - labeledAt.getTime()) / 86400000);
142
+ console.log(`#${issue.number}: would close (${label}, ${age}d old) — ${issue.title}`);
143
+ } else {
144
+ await githubRequest(`${base}/comments`, "POST", { body: CLOSE_MESSAGE(reason) });
145
+ await githubRequest(base, "PATCH", { state: "closed", state_reason: "not_planned" });
146
+ console.log(`#${issue.number}: closed (${label})`);
147
+ }
148
+ closed++;
149
+ }
150
+ }
151
+ }
152
+
153
+ return closed;
154
+ }
155
+
156
+ // --
157
+
158
+ const owner = process.env.GITHUB_REPOSITORY_OWNER;
159
+ const repo = process.env.GITHUB_REPOSITORY_NAME;
160
+ if (!owner || !repo)
161
+ throw new Error("GITHUB_REPOSITORY_OWNER and GITHUB_REPOSITORY_NAME required");
162
+
163
+ if (DRY_RUN) console.log("DRY RUN — no changes will be made\n");
164
+
165
+ const labeled = await markStale(owner, repo);
166
+ const closed = await closeExpired(owner, repo);
167
+
168
+ console.log(`\nDone: ${labeled} ${DRY_RUN ? "would be labeled" : "labeled"} stale, ${closed} ${DRY_RUN ? "would be closed" : "closed"}`);