Add files using upload-large-folder tool
Browse files- .claude-plugin/marketplace.json +150 -0
- .devcontainer/Dockerfile +91 -0
- .devcontainer/devcontainer.json +57 -0
- .devcontainer/init-firewall.sh +136 -0
- .gitattributes +2 -35
- .gitignore +2 -0
- .vscode/extensions.json +8 -0
- CHANGELOG.md +0 -0
- LICENSE.md +1 -0
- README.md +84 -0
- SECURITY.md +12 -0
- Script/run_devcontainer_claude_code.ps1 +152 -0
- feed.xml +0 -0
- mods/README.md +116 -0
- mods/diff/README.md +91 -0
- mods/sec-default/README.md +55 -0
- mods/telemetry/.claude-plugin/plugin.json +9 -0
- mods/telemetry/README.md +54 -0
- mods/telemetry/hooks/entries/batch-of.ts +34 -0
- mods/telemetry/hooks/entries/checked-fields.ts +30 -0
- mods/telemetry/hooks/entries/checked-mark.ts +55 -0
- mods/telemetry/hooks/entries/index.ts +25 -0
- mods/telemetry/hooks/entries/ingest-url.ts +9 -0
- mods/telemetry/hooks/environment/environment.ts +20 -0
- mods/telemetry/hooks/environment/index.ts +3 -0
- mods/telemetry/hooks/hooks.json +4 -0
- mods/telemetry/hooks/index.ts +8 -0
- mods/telemetry/hooks/is-analytics-off/index.ts +5 -0
- mods/telemetry/hooks/is-analytics-off/is-analytics-off.ts +34 -0
- mods/telemetry/hooks/is-analytics-off/is-env-set/is-env-set.ts +9 -0
- mods/telemetry/hooks/is-analytics-off/is-env-truthy/index.ts +3 -0
- mods/telemetry/hooks/is-analytics-off/is-env-truthy/is-env-truthy.ts +9 -0
- mods/telemetry/hooks/register.ts +44 -0
- mods/telemetry/hooks/telemetry-deps/index.ts +3 -0
- mods/telemetry/hooks/telemetry-deps/telemetry-deps.ts +35 -0
- mods/telemetry/hooks/telemetry-of/index.ts +3 -0
- mods/telemetry/hooks/telemetry-of/telemetry-of.ts +74 -0
- mods/telemetry/tests/register.test.ts +369 -0
- mods/telemetry/types/index.d.ts +116 -0
- mods/tsconfig.json +17 -0
- mods/types/claude-code.d.ts +0 -0
- plugins/README.md +77 -0
- scripts/auto-close-duplicates.ts +277 -0
- scripts/backfill-duplicate-comments.ts +213 -0
- scripts/comment-on-duplicates.sh +95 -0
- scripts/edit-issue-labels.sh +84 -0
- scripts/gh.sh +96 -0
- scripts/issue-lifecycle.ts +38 -0
- scripts/lifecycle-comment.ts +53 -0
- 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 |
-
*
|
| 2 |
-
*.
|
| 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 |
+
 [![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"}`);
|