Compare commits
59 Commits
fix/repl-a
...
fix/386-wi
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
1137b9a037 | ||
|
|
54e6df58eb | ||
|
|
7f432fe87d | ||
|
|
7350a798cb | ||
|
|
5ef79546e9 | ||
|
|
daa3aa27a0 | ||
|
|
5ff34283c4 | ||
|
|
d1a2df2f69 | ||
|
|
ba1b9913aa | ||
|
|
0d27ca596a | ||
|
|
8fc40ee8c4 | ||
|
|
2f162af60c | ||
|
|
9e84d2fddc | ||
|
|
75d2543854 | ||
|
|
01acc4c10e | ||
|
|
e4cf810e14 | ||
|
|
0951c8bc59 | ||
|
|
4c3118e071 | ||
|
|
80a2f1414c | ||
|
|
462a985d7e | ||
|
|
ef881b247f | ||
|
|
a0bdab24c0 | ||
|
|
cdc92d16e4 | ||
|
|
fbf3385395 | ||
|
|
ea335aeddc | ||
|
|
280c9732f5 | ||
|
|
08be5181ab | ||
|
|
b4725c19e0 | ||
|
|
3c2e80a1ae | ||
|
|
c3c60b7bab | ||
|
|
27e6505bfd | ||
|
|
cdbe016e6f | ||
|
|
bd4daa3ee7 | ||
|
|
5be5387096 | ||
|
|
897ef2002e | ||
|
|
ab3c46a591 | ||
|
|
03dff274a1 | ||
|
|
bffd43056f | ||
|
|
c52245fc0a | ||
|
|
365bd3102d | ||
|
|
3df635c24d | ||
|
|
2031c67d46 | ||
|
|
694c242865 | ||
|
|
fb221baa21 | ||
|
|
e5c9a6f629 | ||
|
|
70cfa61582 | ||
|
|
afed73fa5a | ||
|
|
c735233f92 | ||
|
|
8ce09ae743 | ||
|
|
931ee96f5a | ||
|
|
c1e5e363cd | ||
|
|
b0d796e5c3 | ||
|
|
6987a54a71 | ||
|
|
fb32e3f829 | ||
|
|
59ab2701f7 | ||
|
|
7668abaed0 | ||
|
|
36d1c45954 | ||
|
|
116cc8e6bd | ||
|
|
19c00e67ed |
41
.github/ISSUE_TEMPLATE/bug_report.md
vendored
Normal file
41
.github/ISSUE_TEMPLATE/bug_report.md
vendored
Normal file
@@ -0,0 +1,41 @@
|
||||
---
|
||||
name: Bug report
|
||||
about: Report a reproducible problem in OpenClaude
|
||||
title: ""
|
||||
labels: ""
|
||||
assignees: ""
|
||||
---
|
||||
|
||||
## Summary
|
||||
|
||||
What is broken?
|
||||
|
||||
## Steps to Reproduce
|
||||
|
||||
1.
|
||||
2.
|
||||
3.
|
||||
|
||||
## Expected Behavior
|
||||
|
||||
What should have happened?
|
||||
|
||||
## Actual Behavior
|
||||
|
||||
What happened instead?
|
||||
|
||||
## Environment
|
||||
|
||||
- OpenClaude version:
|
||||
- OS:
|
||||
- Terminal:
|
||||
- Provider:
|
||||
- Model:
|
||||
|
||||
## Logs / Screenshots
|
||||
|
||||
Paste the exact error output or attach screenshots if useful.
|
||||
|
||||
## Additional Context
|
||||
|
||||
Anything else maintainers should know?
|
||||
5
.github/ISSUE_TEMPLATE/config.yml
vendored
Normal file
5
.github/ISSUE_TEMPLATE/config.yml
vendored
Normal file
@@ -0,0 +1,5 @@
|
||||
blank_issues_enabled: true
|
||||
contact_links:
|
||||
- name: OpenClaude Discussions
|
||||
url: https://github.com/Gitlawb/openclaude/discussions
|
||||
about: Use Discussions for setup help, questions, ideas, and community conversation.
|
||||
27
.github/ISSUE_TEMPLATE/feature_request.md
vendored
Normal file
27
.github/ISSUE_TEMPLATE/feature_request.md
vendored
Normal file
@@ -0,0 +1,27 @@
|
||||
---
|
||||
name: Feature request
|
||||
about: Suggest an improvement or new capability for OpenClaude
|
||||
title: ""
|
||||
labels: ""
|
||||
assignees: ""
|
||||
---
|
||||
|
||||
## Summary
|
||||
|
||||
What would you like OpenClaude to do?
|
||||
|
||||
## Problem
|
||||
|
||||
What problem does this solve for you?
|
||||
|
||||
## Proposed Direction
|
||||
|
||||
Describe the smallest useful version of the feature if possible.
|
||||
|
||||
## Alternatives Considered
|
||||
|
||||
What are you doing today instead?
|
||||
|
||||
## Additional Context
|
||||
|
||||
Examples, screenshots, related projects, or prior art.
|
||||
21
.github/pull_request_template.md
vendored
Normal file
21
.github/pull_request_template.md
vendored
Normal file
@@ -0,0 +1,21 @@
|
||||
## Summary
|
||||
|
||||
- what changed
|
||||
- why it changed
|
||||
|
||||
## Impact
|
||||
|
||||
- user-facing impact:
|
||||
- developer/maintainer impact:
|
||||
|
||||
## Testing
|
||||
|
||||
- [ ] `bun run build`
|
||||
- [ ] `bun run smoke`
|
||||
- [ ] focused tests:
|
||||
|
||||
## Notes
|
||||
|
||||
- provider/model path tested:
|
||||
- screenshots attached (if UI changed):
|
||||
- follow-up work or known limitations:
|
||||
7
.github/workflows/pr-checks.yml
vendored
7
.github/workflows/pr-checks.yml
vendored
@@ -16,6 +16,8 @@ jobs:
|
||||
steps:
|
||||
- name: Check out repository
|
||||
uses: actions/checkout@11bd71901bbe5b1630ceea73d27597364c9af683 # v4.2.2
|
||||
with:
|
||||
fetch-depth: 0
|
||||
|
||||
- name: Set up Node.js
|
||||
uses: actions/setup-node@49933ea5288caeca8642d1e84afbd3f7d6820020 # v4.4.0
|
||||
@@ -33,6 +35,11 @@ jobs:
|
||||
- name: Smoke check
|
||||
run: bun run smoke
|
||||
|
||||
- name: Full unit test suite
|
||||
run: bun test --max-concurrency=1
|
||||
|
||||
- name: Suspicious PR intent scan
|
||||
run: bun run security:pr-scan -- --base ${{ github.event.pull_request.base.sha || 'origin/main' }}
|
||||
- name: Provider tests
|
||||
run: bun run test:provider
|
||||
|
||||
|
||||
1
.gitignore
vendored
1
.gitignore
vendored
@@ -6,3 +6,4 @@ dist/
|
||||
!.env.example
|
||||
.openclaude-profile.json
|
||||
reports/
|
||||
coverage/
|
||||
|
||||
126
CODE_OF_CONDUCT.md
Normal file
126
CODE_OF_CONDUCT.md
Normal file
@@ -0,0 +1,126 @@
|
||||
# Contributor Covenant Code of Conduct
|
||||
|
||||
## Our Pledge
|
||||
|
||||
We as members, contributors, and maintainers pledge to make participation in
|
||||
our community a harassment-free experience for everyone, regardless of age,
|
||||
body size, visible or invisible disability, ethnicity, sex characteristics,
|
||||
gender identity and expression, level of experience, education, socio-economic
|
||||
status, nationality, personal appearance, race, religion, or sexual identity
|
||||
and orientation.
|
||||
|
||||
We pledge to act and interact in ways that contribute to an open, welcoming,
|
||||
diverse, inclusive, and healthy community.
|
||||
|
||||
## Our Standards
|
||||
|
||||
Examples of behavior that contributes to a positive environment for our
|
||||
community include:
|
||||
|
||||
- Demonstrating empathy and kindness toward other people
|
||||
- Being respectful of differing opinions, viewpoints, and experiences
|
||||
- Giving and gracefully accepting constructive feedback
|
||||
- Accepting responsibility and apologizing to those affected by our mistakes,
|
||||
and learning from the experience
|
||||
- Focusing on what is best not just for us as individuals, but for the
|
||||
overall community
|
||||
|
||||
Examples of unacceptable behavior include:
|
||||
|
||||
- The use of sexualized language or imagery, and sexual attention or
|
||||
advances of any kind
|
||||
- Trolling, insulting or derogatory comments, and personal or political attacks
|
||||
- Public or private harassment
|
||||
- Publishing others' private information, such as a physical or email
|
||||
address, without their explicit permission
|
||||
- Other conduct which could reasonably be considered inappropriate in a
|
||||
professional setting
|
||||
|
||||
## Enforcement Responsibilities
|
||||
|
||||
Community leaders are responsible for clarifying and enforcing our standards of
|
||||
acceptable behavior and will take appropriate and fair corrective action in
|
||||
response to any behavior that they deem inappropriate, threatening, offensive,
|
||||
or harmful.
|
||||
|
||||
Community leaders have the right and responsibility to remove, edit, or reject
|
||||
comments, commits, code, wiki edits, issues, and other contributions that are
|
||||
not aligned to this Code of Conduct, and will communicate reasons for
|
||||
moderation decisions when appropriate.
|
||||
|
||||
## Scope
|
||||
|
||||
This Code of Conduct applies within all community spaces, and also applies when
|
||||
an individual is officially representing the community in public spaces.
|
||||
Examples of representing our community include using an official email address,
|
||||
posting via an official social media account, or acting as an appointed
|
||||
representative at an online or offline event.
|
||||
|
||||
## Enforcement
|
||||
|
||||
Instances of abusive, harassing, or otherwise unacceptable behavior may be
|
||||
reported to the project maintainers through the repository maintainers or
|
||||
security/community contact paths available in the repository.
|
||||
|
||||
All complaints will be reviewed and investigated promptly and fairly.
|
||||
|
||||
All community leaders are obligated to respect the privacy and security of the
|
||||
reporter of any incident.
|
||||
|
||||
## Enforcement Guidelines
|
||||
|
||||
Community leaders will follow these Community Impact Guidelines in determining
|
||||
the consequences for any action they deem in violation of this Code of Conduct:
|
||||
|
||||
### 1. Correction
|
||||
|
||||
**Community Impact**: Use of inappropriate language or other behavior deemed
|
||||
unprofessional or unwelcome in the community.
|
||||
|
||||
**Consequence**: A private, written warning from community leaders, providing
|
||||
clarity around the nature of the violation and an explanation of why the
|
||||
behavior was inappropriate. A public apology may be requested.
|
||||
|
||||
### 2. Warning
|
||||
|
||||
**Community Impact**: A violation through a single incident or series
|
||||
of actions.
|
||||
|
||||
**Consequence**: A warning with consequences for continued behavior. No
|
||||
interaction with the people involved, including unsolicited interaction with
|
||||
those enforcing the Code of Conduct, for a specified period of time. This
|
||||
includes avoiding interactions in community spaces as well as external channels
|
||||
like social media. Violating these terms may lead to a temporary or permanent
|
||||
ban.
|
||||
|
||||
### 3. Temporary Ban
|
||||
|
||||
**Community Impact**: A serious violation of community standards, including
|
||||
sustained inappropriate behavior.
|
||||
|
||||
**Consequence**: A temporary ban from any sort of interaction or public
|
||||
communication with the community for a specified period of time. No public or
|
||||
private interaction with the people involved, including unsolicited interaction
|
||||
with those enforcing the Code of Conduct, is allowed during this period.
|
||||
Violating these terms may lead to a permanent ban.
|
||||
|
||||
### 4. Permanent Ban
|
||||
|
||||
**Community Impact**: Demonstrating a pattern of violation of community
|
||||
standards, including sustained inappropriate behavior, harassment of an
|
||||
individual, or aggression toward or disparagement of classes of individuals.
|
||||
|
||||
**Consequence**: A permanent ban from any sort of public interaction within
|
||||
the community.
|
||||
|
||||
## Attribution
|
||||
|
||||
This Code of Conduct is adapted from the [Contributor Covenant](https://www.contributor-covenant.org/),
|
||||
version 2.1, available at
|
||||
[https://www.contributor-covenant.org/version/2/1/code_of_conduct.html](https://www.contributor-covenant.org/version/2/1/code_of_conduct.html).
|
||||
|
||||
Community Impact Guidelines were inspired by
|
||||
[Mozilla's code of conduct enforcement ladder](https://github.com/mozilla/diversity).
|
||||
|
||||
For answers to common questions about this code of conduct, see the FAQ at
|
||||
[https://www.contributor-covenant.org/faq](https://www.contributor-covenant.org/faq).
|
||||
119
CONTRIBUTING.md
Normal file
119
CONTRIBUTING.md
Normal file
@@ -0,0 +1,119 @@
|
||||
# Contributing to OpenClaude
|
||||
|
||||
Thanks for contributing.
|
||||
|
||||
OpenClaude is a fast-moving open-source coding-agent CLI with support for multiple providers, local backends, MCP, and a terminal-first workflow. The best contributions here are focused, well-tested, and easy to review.
|
||||
|
||||
## Before You Start
|
||||
|
||||
- Search existing [issues](https://github.com/Gitlawb/openclaude/issues) and [discussions](https://github.com/Gitlawb/openclaude/discussions) before opening a new thread.
|
||||
- Use issues for confirmed bugs and actionable feature work.
|
||||
- Use discussions for setup help, ideas, and general community conversation.
|
||||
- For larger changes, open an issue first so the scope is clear before implementation.
|
||||
- For security reports, follow [SECURITY.md](SECURITY.md).
|
||||
|
||||
## Local Setup
|
||||
|
||||
Install dependencies:
|
||||
|
||||
```bash
|
||||
bun install
|
||||
```
|
||||
|
||||
Build the CLI:
|
||||
|
||||
```bash
|
||||
bun run build
|
||||
```
|
||||
|
||||
Smoke test:
|
||||
|
||||
```bash
|
||||
bun run smoke
|
||||
```
|
||||
|
||||
Run the app locally:
|
||||
|
||||
```bash
|
||||
bun run dev
|
||||
```
|
||||
|
||||
If you are working on provider setup or saved profiles, useful commands include:
|
||||
|
||||
```bash
|
||||
bun run profile:init
|
||||
bun run dev:profile
|
||||
```
|
||||
|
||||
## Development Workflow
|
||||
|
||||
- Keep PRs focused on one problem or feature.
|
||||
- Avoid mixing unrelated cleanup into the same change.
|
||||
- Preserve existing repo patterns unless the change is intentionally refactoring them.
|
||||
- Add or update tests when the change affects behavior.
|
||||
- Update docs when setup, commands, or user-facing behavior changes.
|
||||
|
||||
## Validation
|
||||
|
||||
At minimum, run the most relevant checks for your change.
|
||||
|
||||
Common checks:
|
||||
|
||||
```bash
|
||||
bun run build
|
||||
bun run smoke
|
||||
```
|
||||
|
||||
Focused tests:
|
||||
|
||||
```bash
|
||||
bun test ./path/to/test-file.test.ts
|
||||
```
|
||||
|
||||
When working on provider/runtime setup, this can also help:
|
||||
|
||||
```bash
|
||||
bun run doctor:runtime
|
||||
```
|
||||
|
||||
## Pull Requests
|
||||
|
||||
Good PRs usually include:
|
||||
|
||||
- a short explanation of what changed
|
||||
- why it changed
|
||||
- the user or developer impact
|
||||
- the exact checks you ran
|
||||
|
||||
If the PR touches UI, terminal presentation, or the VS Code extension, include screenshots when useful.
|
||||
|
||||
If the PR changes provider behavior, mention which provider path was tested.
|
||||
|
||||
## Code Style
|
||||
|
||||
- Follow the existing code style in the touched files.
|
||||
- Prefer small, readable changes over broad rewrites.
|
||||
- Do not reformat unrelated files just because they are nearby.
|
||||
- Keep comments useful and concise.
|
||||
|
||||
## Provider Changes
|
||||
|
||||
OpenClaude supports multiple provider paths. If you change provider logic:
|
||||
|
||||
- be explicit about which providers are affected
|
||||
- avoid breaking third-party providers while fixing first-party behavior
|
||||
- test the exact provider/model path you changed when possible
|
||||
- call out any limitations or follow-up work in the PR description
|
||||
|
||||
## Community
|
||||
|
||||
Please be respectful and constructive with other contributors.
|
||||
|
||||
Maintainers may ask for:
|
||||
|
||||
- narrower scope
|
||||
- focused follow-up PRs
|
||||
- stronger validation
|
||||
- docs updates for behavior changes
|
||||
|
||||
That is normal and helps keep the project reviewable as it grows.
|
||||
29
LICENSE
Normal file
29
LICENSE
Normal file
@@ -0,0 +1,29 @@
|
||||
NOTICE
|
||||
|
||||
This repository contains code derived from Anthropic's Claude Code CLI.
|
||||
|
||||
The original Claude Code source is proprietary software:
|
||||
Copyright (c) Anthropic PBC. All rights reserved.
|
||||
Subject to Anthropic's Commercial Terms of Service.
|
||||
|
||||
Modifications and additions by OpenClaude contributors are offered under
|
||||
the MIT License where legally permissible:
|
||||
|
||||
MIT License
|
||||
Copyright (c) 2026 OpenClaude contributors (modifications only)
|
||||
|
||||
Permission is hereby granted, free of charge, to any person obtaining
|
||||
a copy of the modifications made by OpenClaude contributors, to deal
|
||||
in those modifications without restriction, including without limitation
|
||||
the rights to use, copy, modify, merge, publish, distribute, sublicense,
|
||||
and/or sell copies, subject to the following conditions:
|
||||
|
||||
The above copyright notice and this permission notice shall be included
|
||||
in all copies or substantial portions of the modifications.
|
||||
|
||||
THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND.
|
||||
|
||||
The underlying derived code remains subject to Anthropic's copyright.
|
||||
This project does not have Anthropic's authorization to distribute
|
||||
their proprietary source. Users and contributors should evaluate their
|
||||
own legal position.
|
||||
184
README.md
184
README.md
@@ -1,17 +1,24 @@
|
||||
# OpenClaude
|
||||
|
||||
OpenClaude is an open-source coding-agent CLI that works with more than one model provider.
|
||||
OpenClaude is an open-source coding-agent CLI for cloud and local model providers.
|
||||
|
||||
Use OpenAI-compatible APIs, Gemini, GitHub Models, Codex, Ollama, Atomic Chat, and other supported backends while keeping the same terminal-first workflow: prompts, tools, agents, MCP, slash commands, and streaming output.
|
||||
Use OpenAI-compatible APIs, Gemini, GitHub Models, Codex, Ollama, Atomic Chat, and other supported backends while keeping one terminal-first workflow: prompts, tools, agents, MCP, slash commands, and streaming output.
|
||||
|
||||
[](https://github.com/Gitlawb/openclaude/actions/workflows/pr-checks.yml)
|
||||
[](https://github.com/Gitlawb/openclaude/tags)
|
||||
[](https://github.com/Gitlawb/openclaude/discussions)
|
||||
[](SECURITY.md)
|
||||
[](LICENSE)
|
||||
|
||||
[Quick Start](#quick-start) | [Setup Guides](#setup-guides) | [Providers](#supported-providers) | [Source Build](#source-build-and-local-development) | [VS Code Extension](#vs-code-extension) | [Community](#community)
|
||||
|
||||
## Why OpenClaude
|
||||
|
||||
- Use one CLI across cloud and local model providers
|
||||
- Use one CLI across cloud APIs and local model backends
|
||||
- Save provider profiles inside the app with `/provider`
|
||||
- Run locally with Ollama or Atomic Chat
|
||||
- Keep core coding-agent workflows: bash, file tools, grep, glob, agents, tasks, MCP, and web tools
|
||||
|
||||
---
|
||||
- Run with OpenAI-compatible services, Gemini, GitHub Models, Codex, Ollama, Atomic Chat, and other supported providers
|
||||
- Keep coding-agent workflows in one place: bash, file tools, grep, glob, agents, tasks, MCP, and web tools
|
||||
- Use the bundled VS Code extension for launch integration and theme support
|
||||
|
||||
## Quick Start
|
||||
|
||||
@@ -21,7 +28,7 @@ Use OpenAI-compatible APIs, Gemini, GitHub Models, Codex, Ollama, Atomic Chat, a
|
||||
npm install -g @gitlawb/openclaude
|
||||
```
|
||||
|
||||
If the npm install path later reports `ripgrep not found`, install ripgrep system-wide and confirm `rg --version` works in the same terminal before starting OpenClaude.
|
||||
If the install later reports `ripgrep not found`, install ripgrep system-wide and confirm `rg --version` works in the same terminal before starting OpenClaude.
|
||||
|
||||
### Start
|
||||
|
||||
@@ -31,8 +38,8 @@ openclaude
|
||||
|
||||
Inside OpenClaude:
|
||||
|
||||
- run `/provider` for guided setup of OpenAI-compatible, Gemini, Ollama, or Codex profiles
|
||||
- run `/onboard-github` for GitHub Models setup
|
||||
- run `/provider` for guided provider setup and saved profiles
|
||||
- run `/onboard-github` for GitHub Models onboarding
|
||||
|
||||
### Fastest OpenAI setup
|
||||
|
||||
@@ -78,8 +85,6 @@ $env:OPENAI_MODEL="qwen2.5-coder:7b"
|
||||
openclaude
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
## Setup Guides
|
||||
|
||||
Beginner-friendly guides:
|
||||
@@ -93,38 +98,26 @@ Advanced and source-build guides:
|
||||
- [Advanced Setup](docs/advanced-setup.md)
|
||||
- [Android Install](ANDROID_INSTALL.md)
|
||||
|
||||
---
|
||||
|
||||
## Supported Providers
|
||||
|
||||
| Provider | Setup Path | Notes |
|
||||
| --- | --- | --- |
|
||||
| OpenAI-compatible | `/provider` or env vars | Works with OpenAI, OpenRouter, DeepSeek, Groq, Mistral, LM Studio, and compatible local `/v1` servers |
|
||||
| Gemini | `/provider` or env vars | Google Gemini support through the runtime provider layer |
|
||||
| OpenAI-compatible | `/provider` or env vars | Works with OpenAI, OpenRouter, DeepSeek, Groq, Mistral, LM Studio, and other compatible `/v1` servers |
|
||||
| Gemini | `/provider` or env vars | Supports API key, access token, or local ADC workflow on current `main` |
|
||||
| GitHub Models | `/onboard-github` | Interactive onboarding with saved credentials |
|
||||
| Codex | `/provider` | Uses existing Codex credentials when available |
|
||||
| Ollama | `/provider` or env vars | Local inference with no API key |
|
||||
| Atomic Chat | advanced setup | Local Apple Silicon backend |
|
||||
| Bedrock / Vertex / Foundry | env vars | Additional provider integrations for supported environments |
|
||||
|
||||
---
|
||||
|
||||
## What Works
|
||||
|
||||
- Tool-driven coding workflows
|
||||
Bash, file read/write/edit, grep, glob, agents, tasks, MCP, and slash commands
|
||||
- Streaming responses
|
||||
Real-time token output and tool progress
|
||||
- Tool calling
|
||||
Multi-step tool loops with model calls, tool execution, and follow-up responses
|
||||
- Images
|
||||
URL and base64 image inputs for providers that support vision
|
||||
- Provider profiles
|
||||
Guided setup plus saved `.openclaude-profile.json` support
|
||||
- Local and remote model backends
|
||||
Cloud APIs, local servers, and Apple Silicon local inference
|
||||
|
||||
---
|
||||
- **Tool-driven coding workflows**: Bash, file read/write/edit, grep, glob, agents, tasks, MCP, and slash commands
|
||||
- **Streaming responses**: Real-time token output and tool progress
|
||||
- **Tool calling**: Multi-step tool loops with model calls, tool execution, and follow-up responses
|
||||
- **Images**: URL and base64 image inputs for providers that support vision
|
||||
- **Provider profiles**: Guided setup plus saved `.openclaude-profile.json` support
|
||||
- **Local and remote model backends**: Cloud APIs, local servers, and Apple Silicon local inference
|
||||
|
||||
## Provider Notes
|
||||
|
||||
@@ -137,13 +130,49 @@ OpenClaude supports multiple providers, but behavior is not identical across all
|
||||
|
||||
For best results, use models with strong tool/function calling support.
|
||||
|
||||
---
|
||||
## Agent Routing
|
||||
|
||||
OpenClaude can route different agents to different models through settings-based routing. This is useful for cost optimization or splitting work by model strength.
|
||||
|
||||
Add to `~/.claude/settings.json`:
|
||||
|
||||
```json
|
||||
{
|
||||
"agentModels": {
|
||||
"deepseek-chat": {
|
||||
"base_url": "https://api.deepseek.com/v1",
|
||||
"api_key": "sk-your-key"
|
||||
},
|
||||
"gpt-4o": {
|
||||
"base_url": "https://api.openai.com/v1",
|
||||
"api_key": "sk-your-key"
|
||||
}
|
||||
},
|
||||
"agentRouting": {
|
||||
"Explore": "deepseek-chat",
|
||||
"Plan": "gpt-4o",
|
||||
"general-purpose": "gpt-4o",
|
||||
"frontend-dev": "deepseek-chat",
|
||||
"default": "gpt-4o"
|
||||
}
|
||||
}
|
||||
```
|
||||
|
||||
When no routing match is found, the global provider remains the fallback.
|
||||
|
||||
> **Note:** `api_key` values in `settings.json` are stored in plaintext. Keep this file private and do not commit it to version control.
|
||||
|
||||
## Web Search and Fetch
|
||||
|
||||
`WebFetch` works out of the box.
|
||||
By default, `WebSearch` works on non-Anthropic models using DuckDuckGo. This gives GPT-4o, DeepSeek, Gemini, Ollama, and other OpenAI-compatible providers a free web search path out of the box.
|
||||
|
||||
`WebSearch` and richer JS-aware fetching work best with a Firecrawl API key:
|
||||
> **Note:** DuckDuckGo fallback works by scraping search results and may be rate-limited, blocked, or subject to DuckDuckGo's Terms of Service. If you want a more reliable supported option, configure Firecrawl.
|
||||
|
||||
For Anthropic-native backends and Codex responses, OpenClaude keeps the native provider web search behavior.
|
||||
|
||||
`WebFetch` works, but its basic HTTP plus HTML-to-markdown path can still fail on JavaScript-rendered sites or sites that block plain HTTP requests.
|
||||
|
||||
Set a [Firecrawl](https://firecrawl.dev) API key if you want Firecrawl-powered search/fetch behavior:
|
||||
|
||||
```bash
|
||||
export FIRECRAWL_API_KEY=your-key-here
|
||||
@@ -151,14 +180,12 @@ export FIRECRAWL_API_KEY=your-key-here
|
||||
|
||||
With Firecrawl enabled:
|
||||
|
||||
- `WebSearch` is available across more provider setups
|
||||
- `WebFetch` can handle JavaScript-rendered pages more reliably
|
||||
- `WebSearch` can use Firecrawl's search API while DuckDuckGo remains the default free path for non-Claude models
|
||||
- `WebFetch` uses Firecrawl's scrape endpoint instead of raw HTTP, handling JS-rendered pages correctly
|
||||
|
||||
Firecrawl is optional. Without it, OpenClaude falls back to the built-in behavior.
|
||||
Free tier at [firecrawl.dev](https://firecrawl.dev) includes 500 credits. The key is optional.
|
||||
|
||||
---
|
||||
|
||||
## Source Build
|
||||
## Source Build And Local Development
|
||||
|
||||
```bash
|
||||
bun install
|
||||
@@ -169,22 +196,78 @@ node dist/cli.mjs
|
||||
Helpful commands:
|
||||
|
||||
- `bun run dev`
|
||||
- `bun test`
|
||||
- `bun run test:coverage`
|
||||
- `bun run security:pr-scan -- --base origin/main`
|
||||
- `bun run smoke`
|
||||
- `bun run doctor:runtime`
|
||||
- `bun run verify:privacy`
|
||||
- focused `bun test ...` runs for the areas you touch
|
||||
|
||||
---
|
||||
## Testing And Coverage
|
||||
|
||||
OpenClaude uses Bun's built-in test runner for unit tests.
|
||||
|
||||
Run the full unit suite:
|
||||
|
||||
```bash
|
||||
bun test
|
||||
```
|
||||
|
||||
Generate unit test coverage:
|
||||
|
||||
```bash
|
||||
bun run test:coverage
|
||||
```
|
||||
|
||||
Open the visual coverage report:
|
||||
|
||||
```bash
|
||||
open coverage/index.html
|
||||
```
|
||||
|
||||
If you already have `coverage/lcov.info` and only want to rebuild the UI:
|
||||
|
||||
```bash
|
||||
bun run test:coverage:ui
|
||||
```
|
||||
|
||||
Use focused test runs when you only touch one area:
|
||||
|
||||
- `bun run test:provider`
|
||||
- `bun run test:provider-recommendation`
|
||||
- `bun test path/to/file.test.ts`
|
||||
|
||||
Recommended contributor validation before opening a PR:
|
||||
|
||||
- `bun run build`
|
||||
- `bun run smoke`
|
||||
- `bun run test:coverage` for broader unit coverage when your change affects shared runtime or provider logic
|
||||
- focused `bun test ...` runs for the files and flows you changed
|
||||
|
||||
Coverage output is written to `coverage/lcov.info`, and OpenClaude also generates a git-activity-style heatmap at `coverage/index.html`.
|
||||
## Repository Structure
|
||||
|
||||
- `src/` - core CLI/runtime
|
||||
- `scripts/` - build, verification, and maintenance scripts
|
||||
- `docs/` - setup, contributor, and project documentation
|
||||
- `python/` - standalone Python helpers and their tests
|
||||
- `vscode-extension/openclaude-vscode/` - VS Code extension
|
||||
- `.github/` - repo automation, templates, and CI configuration
|
||||
- `bin/` - CLI launcher entrypoints
|
||||
|
||||
## VS Code Extension
|
||||
|
||||
The repo includes a VS Code extension in [`vscode-extension/openclaude-vscode`](vscode-extension/openclaude-vscode) for OpenClaude launch integration and theme support.
|
||||
|
||||
---
|
||||
The repo includes a VS Code extension in [`vscode-extension/openclaude-vscode`](vscode-extension/openclaude-vscode) for OpenClaude launch integration, provider-aware control-center UI, and theme support.
|
||||
|
||||
## Security
|
||||
|
||||
If you believe you found a security issue, see [SECURITY.md](SECURITY.md).
|
||||
|
||||
---
|
||||
## Community
|
||||
|
||||
- Use [GitHub Discussions](https://github.com/Gitlawb/openclaude/discussions) for Q&A, ideas, and community conversation
|
||||
- Use [GitHub Issues](https://github.com/Gitlawb/openclaude/issues) for confirmed bugs and actionable feature work
|
||||
|
||||
## Contributing
|
||||
|
||||
@@ -193,19 +276,16 @@ Contributions are welcome.
|
||||
For larger changes, open an issue first so the scope is clear before implementation. Helpful validation commands include:
|
||||
|
||||
- `bun run build`
|
||||
- `bun run test:coverage`
|
||||
- `bun run smoke`
|
||||
- focused `bun test ...` runs for touched areas
|
||||
|
||||
---
|
||||
|
||||
## Disclaimer
|
||||
|
||||
OpenClaude is an independent community project and is not affiliated with, endorsed by, or sponsored by Anthropic.
|
||||
|
||||
"Claude" and "Claude Code" are trademarks of Anthropic.
|
||||
|
||||
---
|
||||
OpenClaude originated from the Claude Code codebase and has since been substantially modified to support multiple providers and open use. "Claude" and "Claude Code" are trademarks of Anthropic PBC. See [LICENSE](LICENSE) for details.
|
||||
|
||||
## License
|
||||
|
||||
MIT
|
||||
See [LICENSE](LICENSE).
|
||||
|
||||
@@ -1,7 +1,13 @@
|
||||
import { join } from 'path'
|
||||
import { join, win32 } from 'path'
|
||||
import { pathToFileURL } from 'url'
|
||||
|
||||
export function getDistImportSpecifier(baseDir) {
|
||||
const distPath = join(baseDir, '..', 'dist', 'cli.mjs')
|
||||
if (/^[A-Za-z]:\\/.test(baseDir)) {
|
||||
const distPath = win32.join(baseDir, '..', 'dist', 'cli.mjs')
|
||||
return `file:///${distPath.replace(/\\/g, '/')}`
|
||||
}
|
||||
|
||||
const joinImpl = join
|
||||
const distPath = joinImpl(baseDir, '..', 'dist', 'cli.mjs')
|
||||
return pathToFileURL(distPath).href
|
||||
}
|
||||
|
||||
71
bun.lock
71
bun.lock
@@ -36,7 +36,9 @@
|
||||
"cli-highlight": "2.1.11",
|
||||
"code-excerpt": "4.0.0",
|
||||
"commander": "12.1.0",
|
||||
"cross-spawn": "7.0.6",
|
||||
"diff": "8.0.3",
|
||||
"duck-duck-scrape": "^2.2.7",
|
||||
"emoji-regex": "10.6.0",
|
||||
"env-paths": "3.0.0",
|
||||
"execa": "9.6.1",
|
||||
@@ -60,6 +62,7 @@
|
||||
"react-compiler-runtime": "1.0.0",
|
||||
"react-reconciler": "0.33.0",
|
||||
"semver": "7.7.4",
|
||||
"sharp": "^0.34.5",
|
||||
"shell-quote": "1.8.3",
|
||||
"signal-exit": "4.1.0",
|
||||
"stack-utils": "2.0.6",
|
||||
@@ -176,6 +179,8 @@
|
||||
|
||||
"@commander-js/extra-typings": ["@commander-js/extra-typings@12.1.0", "", { "peerDependencies": { "commander": "~12.1.0" } }, "sha512-wf/lwQvWAA0goIghcb91dQYpkLBcyhOhQNqG/VgWhnKzgt+UOMvra7EX/2fv70arm5RW+PUHoQHHDa6/p77Eqg=="],
|
||||
|
||||
"@emnapi/runtime": ["@emnapi/runtime@1.9.2", "", { "dependencies": { "tslib": "^2.4.0" } }, "sha512-3U4+MIWHImeyu1wnmVygh5WlgfYDtyf0k8AbLhMFxOipihf6nrWC4syIm/SwEeec0mNSafiiNnMJwbza/Is6Lw=="],
|
||||
|
||||
"@growthbook/growthbook": ["@growthbook/growthbook@1.6.5", "", { "dependencies": { "dom-mutator": "^0.6.0" } }, "sha512-mUaMsgeUTpRIUOTn33EUXHRK6j7pxBjwqH4WpQyq+pukjd1AIzWlEa6w7i6bInJUcweGgP2beXZmaP6b6UPn7A=="],
|
||||
|
||||
"@grpc/grpc-js": ["@grpc/grpc-js@1.14.3", "", { "dependencies": { "@grpc/proto-loader": "^0.8.0", "@js-sdsl/ordered-map": "^4.4.2" } }, "sha512-Iq8QQQ/7X3Sac15oB6p0FmUg/klxQvXLeileoqrTRGJYLV+/9tubbr9ipz0GKHjmXVsgFPo/+W+2cA8eNcR+XA=="],
|
||||
@@ -184,6 +189,56 @@
|
||||
|
||||
"@hono/node-server": ["@hono/node-server@1.19.12", "", { "peerDependencies": { "hono": "^4" } }, "sha512-txsUW4SQ1iilgE0l9/e9VQWmELXifEFvmdA1j6WFh/aFPj99hIntrSsq/if0UWyGVkmrRPKA1wCeP+UCr1B9Uw=="],
|
||||
|
||||
"@img/colour": ["@img/colour@1.1.0", "", {}, "sha512-Td76q7j57o/tLVdgS746cYARfSyxk8iEfRxewL9h4OMzYhbW4TAcppl0mT4eyqXddh6L/jwoM75mo7ixa/pCeQ=="],
|
||||
|
||||
"@img/sharp-darwin-arm64": ["@img/sharp-darwin-arm64@0.34.5", "", { "optionalDependencies": { "@img/sharp-libvips-darwin-arm64": "1.2.4" }, "os": "darwin", "cpu": "arm64" }, "sha512-imtQ3WMJXbMY4fxb/Ndp6HBTNVtWCUI0WdobyheGf5+ad6xX8VIDO8u2xE4qc/fr08CKG/7dDseFtn6M6g/r3w=="],
|
||||
|
||||
"@img/sharp-darwin-x64": ["@img/sharp-darwin-x64@0.34.5", "", { "optionalDependencies": { "@img/sharp-libvips-darwin-x64": "1.2.4" }, "os": "darwin", "cpu": "x64" }, "sha512-YNEFAF/4KQ/PeW0N+r+aVVsoIY0/qxxikF2SWdp+NRkmMB7y9LBZAVqQ4yhGCm/H3H270OSykqmQMKLBhBJDEw=="],
|
||||
|
||||
"@img/sharp-libvips-darwin-arm64": ["@img/sharp-libvips-darwin-arm64@1.2.4", "", { "os": "darwin", "cpu": "arm64" }, "sha512-zqjjo7RatFfFoP0MkQ51jfuFZBnVE2pRiaydKJ1G/rHZvnsrHAOcQALIi9sA5co5xenQdTugCvtb1cuf78Vf4g=="],
|
||||
|
||||
"@img/sharp-libvips-darwin-x64": ["@img/sharp-libvips-darwin-x64@1.2.4", "", { "os": "darwin", "cpu": "x64" }, "sha512-1IOd5xfVhlGwX+zXv2N93k0yMONvUlANylbJw1eTah8K/Jtpi15KC+WSiaX/nBmbm2HxRM1gZ0nSdjSsrZbGKg=="],
|
||||
|
||||
"@img/sharp-libvips-linux-arm": ["@img/sharp-libvips-linux-arm@1.2.4", "", { "os": "linux", "cpu": "arm" }, "sha512-bFI7xcKFELdiNCVov8e44Ia4u2byA+l3XtsAj+Q8tfCwO6BQ8iDojYdvoPMqsKDkuoOo+X6HZA0s0q11ANMQ8A=="],
|
||||
|
||||
"@img/sharp-libvips-linux-arm64": ["@img/sharp-libvips-linux-arm64@1.2.4", "", { "os": "linux", "cpu": "arm64" }, "sha512-excjX8DfsIcJ10x1Kzr4RcWe1edC9PquDRRPx3YVCvQv+U5p7Yin2s32ftzikXojb1PIFc/9Mt28/y+iRklkrw=="],
|
||||
|
||||
"@img/sharp-libvips-linux-ppc64": ["@img/sharp-libvips-linux-ppc64@1.2.4", "", { "os": "linux", "cpu": "ppc64" }, "sha512-FMuvGijLDYG6lW+b/UvyilUWu5Ayu+3r2d1S8notiGCIyYU/76eig1UfMmkZ7vwgOrzKzlQbFSuQfgm7GYUPpA=="],
|
||||
|
||||
"@img/sharp-libvips-linux-riscv64": ["@img/sharp-libvips-linux-riscv64@1.2.4", "", { "os": "linux", "cpu": "none" }, "sha512-oVDbcR4zUC0ce82teubSm+x6ETixtKZBh/qbREIOcI3cULzDyb18Sr/Wcyx7NRQeQzOiHTNbZFF1UwPS2scyGA=="],
|
||||
|
||||
"@img/sharp-libvips-linux-s390x": ["@img/sharp-libvips-linux-s390x@1.2.4", "", { "os": "linux", "cpu": "s390x" }, "sha512-qmp9VrzgPgMoGZyPvrQHqk02uyjA0/QrTO26Tqk6l4ZV0MPWIW6LTkqOIov+J1yEu7MbFQaDpwdwJKhbJvuRxQ=="],
|
||||
|
||||
"@img/sharp-libvips-linux-x64": ["@img/sharp-libvips-linux-x64@1.2.4", "", { "os": "linux", "cpu": "x64" }, "sha512-tJxiiLsmHc9Ax1bz3oaOYBURTXGIRDODBqhveVHonrHJ9/+k89qbLl0bcJns+e4t4rvaNBxaEZsFtSfAdquPrw=="],
|
||||
|
||||
"@img/sharp-libvips-linuxmusl-arm64": ["@img/sharp-libvips-linuxmusl-arm64@1.2.4", "", { "os": "linux", "cpu": "arm64" }, "sha512-FVQHuwx1IIuNow9QAbYUzJ+En8KcVm9Lk5+uGUQJHaZmMECZmOlix9HnH7n1TRkXMS0pGxIJokIVB9SuqZGGXw=="],
|
||||
|
||||
"@img/sharp-libvips-linuxmusl-x64": ["@img/sharp-libvips-linuxmusl-x64@1.2.4", "", { "os": "linux", "cpu": "x64" }, "sha512-+LpyBk7L44ZIXwz/VYfglaX/okxezESc6UxDSoyo2Ks6Jxc4Y7sGjpgU9s4PMgqgjj1gZCylTieNamqA1MF7Dg=="],
|
||||
|
||||
"@img/sharp-linux-arm": ["@img/sharp-linux-arm@0.34.5", "", { "optionalDependencies": { "@img/sharp-libvips-linux-arm": "1.2.4" }, "os": "linux", "cpu": "arm" }, "sha512-9dLqsvwtg1uuXBGZKsxem9595+ujv0sJ6Vi8wcTANSFpwV/GONat5eCkzQo/1O6zRIkh0m/8+5BjrRr7jDUSZw=="],
|
||||
|
||||
"@img/sharp-linux-arm64": ["@img/sharp-linux-arm64@0.34.5", "", { "optionalDependencies": { "@img/sharp-libvips-linux-arm64": "1.2.4" }, "os": "linux", "cpu": "arm64" }, "sha512-bKQzaJRY/bkPOXyKx5EVup7qkaojECG6NLYswgktOZjaXecSAeCWiZwwiFf3/Y+O1HrauiE3FVsGxFg8c24rZg=="],
|
||||
|
||||
"@img/sharp-linux-ppc64": ["@img/sharp-linux-ppc64@0.34.5", "", { "optionalDependencies": { "@img/sharp-libvips-linux-ppc64": "1.2.4" }, "os": "linux", "cpu": "ppc64" }, "sha512-7zznwNaqW6YtsfrGGDA6BRkISKAAE1Jo0QdpNYXNMHu2+0dTrPflTLNkpc8l7MUP5M16ZJcUvysVWWrMefZquA=="],
|
||||
|
||||
"@img/sharp-linux-riscv64": ["@img/sharp-linux-riscv64@0.34.5", "", { "optionalDependencies": { "@img/sharp-libvips-linux-riscv64": "1.2.4" }, "os": "linux", "cpu": "none" }, "sha512-51gJuLPTKa7piYPaVs8GmByo7/U7/7TZOq+cnXJIHZKavIRHAP77e3N2HEl3dgiqdD/w0yUfiJnII77PuDDFdw=="],
|
||||
|
||||
"@img/sharp-linux-s390x": ["@img/sharp-linux-s390x@0.34.5", "", { "optionalDependencies": { "@img/sharp-libvips-linux-s390x": "1.2.4" }, "os": "linux", "cpu": "s390x" }, "sha512-nQtCk0PdKfho3eC5MrbQoigJ2gd1CgddUMkabUj+rBevs8tZ2cULOx46E7oyX+04WGfABgIwmMC0VqieTiR4jg=="],
|
||||
|
||||
"@img/sharp-linux-x64": ["@img/sharp-linux-x64@0.34.5", "", { "optionalDependencies": { "@img/sharp-libvips-linux-x64": "1.2.4" }, "os": "linux", "cpu": "x64" }, "sha512-MEzd8HPKxVxVenwAa+JRPwEC7QFjoPWuS5NZnBt6B3pu7EG2Ge0id1oLHZpPJdn3OQK+BQDiw9zStiHBTJQQQQ=="],
|
||||
|
||||
"@img/sharp-linuxmusl-arm64": ["@img/sharp-linuxmusl-arm64@0.34.5", "", { "optionalDependencies": { "@img/sharp-libvips-linuxmusl-arm64": "1.2.4" }, "os": "linux", "cpu": "arm64" }, "sha512-fprJR6GtRsMt6Kyfq44IsChVZeGN97gTD331weR1ex1c1rypDEABN6Tm2xa1wE6lYb5DdEnk03NZPqA7Id21yg=="],
|
||||
|
||||
"@img/sharp-linuxmusl-x64": ["@img/sharp-linuxmusl-x64@0.34.5", "", { "optionalDependencies": { "@img/sharp-libvips-linuxmusl-x64": "1.2.4" }, "os": "linux", "cpu": "x64" }, "sha512-Jg8wNT1MUzIvhBFxViqrEhWDGzqymo3sV7z7ZsaWbZNDLXRJZoRGrjulp60YYtV4wfY8VIKcWidjojlLcWrd8Q=="],
|
||||
|
||||
"@img/sharp-wasm32": ["@img/sharp-wasm32@0.34.5", "", { "dependencies": { "@emnapi/runtime": "^1.7.0" }, "cpu": "none" }, "sha512-OdWTEiVkY2PHwqkbBI8frFxQQFekHaSSkUIJkwzclWZe64O1X4UlUjqqqLaPbUpMOQk6FBu/HtlGXNblIs0huw=="],
|
||||
|
||||
"@img/sharp-win32-arm64": ["@img/sharp-win32-arm64@0.34.5", "", { "os": "win32", "cpu": "arm64" }, "sha512-WQ3AgWCWYSb2yt+IG8mnC6Jdk9Whs7O0gxphblsLvdhSpSTtmu69ZG1Gkb6NuvxsNACwiPV6cNSZNzt0KPsw7g=="],
|
||||
|
||||
"@img/sharp-win32-ia32": ["@img/sharp-win32-ia32@0.34.5", "", { "os": "win32", "cpu": "ia32" }, "sha512-FV9m/7NmeCmSHDD5j4+4pNI8Cp3aW+JvLoXcTUo0IqyjSfAZJ8dIUmijx1qaJsIiU+Hosw6xM5KijAWRJCSgNg=="],
|
||||
|
||||
"@img/sharp-win32-x64": ["@img/sharp-win32-x64@0.34.5", "", { "os": "win32", "cpu": "x64" }, "sha512-+29YMsqY2/9eFEiW93eqWnuLcWcufowXewwSNIT6UwZdUUCrM3oFjMWH/Z6/TMmb4hlFenmfAVbpWeup2jryCw=="],
|
||||
|
||||
"@js-sdsl/ordered-map": ["@js-sdsl/ordered-map@4.4.2", "", {}, "sha512-iUKgm52T8HOE/makSxjqoWhe95ZJA1/G1sYsGev2JDKUSS14KAgg1LHb+Ba+IPow0xflbnSkOsZcO08C7w1gYw=="],
|
||||
|
||||
"@mendable/firecrawl-js": ["@mendable/firecrawl-js@4.18.1", "", { "dependencies": { "axios": "1.14.0", "firecrawl": "4.16.0", "typescript-event-target": "^1.1.1", "zod": "^3.23.8", "zod-to-json-schema": "^3.23.0" } }, "sha512-NfmJv+xcHoZthj8I3NP/8KAgO8EWcvOcTvCAvszxqs7/6sCs1CRss6Tum6RycZNSwJkr5RzQossN89IlixRfng=="],
|
||||
@@ -436,12 +491,16 @@
|
||||
|
||||
"depd": ["depd@2.0.0", "", {}, "sha512-g7nH6P6dyDioJogAAGprGpCtVImJhpPk/roCzdb3fIh61/s/nPsfR6onyMwkCAR/OlC3yBC0lESvUoQEAssIrw=="],
|
||||
|
||||
"detect-libc": ["detect-libc@2.1.2", "", {}, "sha512-Btj2BOOO83o3WyH59e8MgXsxEQVcarkUOpEYrubB0urwnN10yQ364rsiByU11nZlqWYZm05i/of7io4mzihBtQ=="],
|
||||
|
||||
"diff": ["diff@8.0.3", "", {}, "sha512-qejHi7bcSD4hQAZE0tNAawRK1ZtafHDmMTMkrrIGgSLl7hTnQHmKCeB45xAcbfTqK2zowkM3j3bHt/4b/ARbYQ=="],
|
||||
|
||||
"dijkstrajs": ["dijkstrajs@1.0.3", "", {}, "sha512-qiSlmBq9+BCdCA/L46dw8Uy93mloxsPSbwnm5yrKn2vMPiy8KyAskTF6zuV/j5BMsmOGZDPs7KjU+mjb670kfA=="],
|
||||
|
||||
"dom-mutator": ["dom-mutator@0.6.0", "", {}, "sha512-iCt9o0aYfXMUkz/43ZOAUFQYotjGB+GNbYJiJdz4TgXkyToXbbRy5S6FbTp72lRBtfpUMwEc1KmpFEU4CZeoNg=="],
|
||||
|
||||
"duck-duck-scrape": ["duck-duck-scrape@2.2.7", "", { "dependencies": { "html-entities": "^2.3.3", "needle": "^3.2.0" } }, "sha512-BEcglwnfx5puJl90KQfX+Q2q5vCguqyMpZcSRPBWk8OY55qWwV93+E+7DbIkrGDW4qkqPfUvtOUdi0lXz6lEMQ=="],
|
||||
|
||||
"dunder-proto": ["dunder-proto@1.0.1", "", { "dependencies": { "call-bind-apply-helpers": "^1.0.1", "es-errors": "^1.3.0", "gopd": "^1.2.0" } }, "sha512-KIN/nDJBQRcXw0MLVhZE9iQHmG68qAVIBg9CqmUYjmQIhgij9U5MFvrqkUL5FbtyyzZuOeOt0zdeRe4UY7ct+A=="],
|
||||
|
||||
"ecdsa-sig-formatter": ["ecdsa-sig-formatter@1.0.11", "", { "dependencies": { "safe-buffer": "^5.0.1" } }, "sha512-nagl3RYrbNv6kQkeJIpt6NJZy8twLB/2vtz6yN9Z4vRKHN4/QZJIEbqohALSgwKdnksuY3k5Addp5lg8sVoVcQ=="],
|
||||
@@ -548,6 +607,8 @@
|
||||
|
||||
"hono": ["hono@4.12.9", "", {}, "sha512-wy3T8Zm2bsEvxKZM5w21VdHDDcwVS1yUFFY6i8UobSsKfFceT7TOwhbhfKsDyx7tYQlmRM5FLpIuYvNFyjctiA=="],
|
||||
|
||||
"html-entities": ["html-entities@2.6.0", "", {}, "sha512-kig+rMn/QOVRvr7c86gQ8lWXq+Hkv6CbAH1hLu+RG338StTpE8Z0b44SDVaqVu7HGKf27frdmUYEs9hTUX/cLQ=="],
|
||||
|
||||
"http-errors": ["http-errors@2.0.1", "", { "dependencies": { "depd": "~2.0.0", "inherits": "~2.0.4", "setprototypeof": "~1.2.0", "statuses": "~2.0.2", "toidentifier": "~1.0.1" } }, "sha512-4FbRdAX+bSdmo4AUFuS0WNiPz8NgFt+r8ThgNWmlrjQjt1Q7ZR9+zTlce2859x4KSXrwIsaeTqDoKQmtP8pLmQ=="],
|
||||
|
||||
"https-proxy-agent": ["https-proxy-agent@7.0.6", "", { "dependencies": { "agent-base": "^7.1.2", "debug": "4" } }, "sha512-vK9P5/iUfdl95AI+JVyUuIcVtd4ofvtrOr3HNtM2yxC9bnMbEdp3x01OhQNnjb8IJYi38VlTE3mBXwcfvywuSw=="],
|
||||
@@ -622,6 +683,8 @@
|
||||
|
||||
"mz": ["mz@2.7.0", "", { "dependencies": { "any-promise": "^1.0.0", "object-assign": "^4.0.1", "thenify-all": "^1.0.0" } }, "sha512-z81GNO7nnYMEhrGh9LeymoE4+Yr0Wn5McHIZMK5cfQCl+NDX08sCZgUc9/6MHni9IWuFLm1Z3HTCXu2z9fN62Q=="],
|
||||
|
||||
"needle": ["needle@3.5.0", "", { "dependencies": { "iconv-lite": "^0.6.3", "sax": "^1.2.4" }, "bin": { "needle": "bin/needle" } }, "sha512-jaQyPKKk2YokHrEg+vFDYxXIHTCBgiZwSHOoVx/8V3GIBS8/VN6NdVRmg8q1ERtPkMvmOvebsgga4sAj5hls/w=="],
|
||||
|
||||
"negotiator": ["negotiator@1.0.0", "", {}, "sha512-8Ofs/AUQh8MaEcrlq5xOX0CQ9ypTF5dl78mjlMNfOK08fzpgTHQRQPBxcPlEtIw0yRpws+Zo/3r+5WRby7u3Gg=="],
|
||||
|
||||
"node-fetch": ["node-fetch@2.7.0", "", { "dependencies": { "whatwg-url": "^5.0.0" }, "peerDependencies": { "encoding": "^0.1.0" }, "optionalPeers": ["encoding"] }, "sha512-c4FRfUm/dbcWZ7U+1Wq0AwCyFL+3nt2bEw05wfxSz+DWpWsitgmSgYmy2dQdWyKC1694ELPqMs/YzUSNozLt8A=="],
|
||||
@@ -706,6 +769,8 @@
|
||||
|
||||
"safer-buffer": ["safer-buffer@2.1.2", "", {}, "sha512-YZo3K82SD7Riyi0E1EQPojLz7kpepnSQI9IyPbHHg1XXXevb5dJI7tpyN2ADxGcQbHG7vcyRHk0cbwqcQriUtg=="],
|
||||
|
||||
"sax": ["sax@1.6.0", "", {}, "sha512-6R3J5M4AcbtLUdZmRv2SygeVaM7IhrLXu9BmnOGmmACak8fiUtOsYNWUS4uK7upbmHIBbLBeFeI//477BKLBzA=="],
|
||||
|
||||
"scheduler": ["scheduler@0.27.0", "", {}, "sha512-eNv+WrVbKu1f3vbYJT/xtiF5syA5HPIMtf9IgY/nKg0sWqzAUEvqY/xm7OcZc/qafLx/iO9FgOmeSAp4v5ti/Q=="],
|
||||
|
||||
"semver": ["semver@7.7.4", "", { "bin": { "semver": "bin/semver.js" } }, "sha512-vFKC2IEtQnVhpT78h1Yp8wzwrf8CM+MzKMHGJZfBtzhZNycRFnXsHk6E5TxIkkMsgNS7mdX3AGB7x2QM2di4lA=="],
|
||||
@@ -718,6 +783,8 @@
|
||||
|
||||
"setprototypeof": ["setprototypeof@1.2.0", "", {}, "sha512-E5LDX7Wrp85Kil5bhZv46j8jOeboKq5JMmYM3gVGdGH8xFpPWXUMsNrlODCrkoxMEeNi/XZIwuRvY4XNwYMJpw=="],
|
||||
|
||||
"sharp": ["sharp@0.34.5", "", { "dependencies": { "@img/colour": "^1.0.0", "detect-libc": "^2.1.2", "semver": "^7.7.3" }, "optionalDependencies": { "@img/sharp-darwin-arm64": "0.34.5", "@img/sharp-darwin-x64": "0.34.5", "@img/sharp-libvips-darwin-arm64": "1.2.4", "@img/sharp-libvips-darwin-x64": "1.2.4", "@img/sharp-libvips-linux-arm": "1.2.4", "@img/sharp-libvips-linux-arm64": "1.2.4", "@img/sharp-libvips-linux-ppc64": "1.2.4", "@img/sharp-libvips-linux-riscv64": "1.2.4", "@img/sharp-libvips-linux-s390x": "1.2.4", "@img/sharp-libvips-linux-x64": "1.2.4", "@img/sharp-libvips-linuxmusl-arm64": "1.2.4", "@img/sharp-libvips-linuxmusl-x64": "1.2.4", "@img/sharp-linux-arm": "0.34.5", "@img/sharp-linux-arm64": "0.34.5", "@img/sharp-linux-ppc64": "0.34.5", "@img/sharp-linux-riscv64": "0.34.5", "@img/sharp-linux-s390x": "0.34.5", "@img/sharp-linux-x64": "0.34.5", "@img/sharp-linuxmusl-arm64": "0.34.5", "@img/sharp-linuxmusl-x64": "0.34.5", "@img/sharp-wasm32": "0.34.5", "@img/sharp-win32-arm64": "0.34.5", "@img/sharp-win32-ia32": "0.34.5", "@img/sharp-win32-x64": "0.34.5" } }, "sha512-Ou9I5Ft9WNcCbXrU9cMgPBcCK8LiwLqcbywW3t4oDV37n1pzpuNLsYiAV8eODnjbtQlSDwZ2cUEeQz4E54Hltg=="],
|
||||
|
||||
"shebang-command": ["shebang-command@2.0.0", "", { "dependencies": { "shebang-regex": "^3.0.0" } }, "sha512-kHxr2zZpYtdmrN1qDjrrX/Z1rR1kG8Dx+gkpK1G4eXmvXswmcE1hTWBWYUzlraYw1/yZp6YuDY77YtvbN0dmDA=="],
|
||||
|
||||
"shebang-regex": ["shebang-regex@3.0.0", "", {}, "sha512-7++dFhtcx3353uBaq8DDR4NuxBetBzC7ZQOhmTQInHEd6bSrXdiEyzCvG07Z44UYdLShWUyXt5M/yhz8ekcb1A=="],
|
||||
@@ -1016,6 +1083,8 @@
|
||||
|
||||
"@aws-sdk/xml-builder/tslib": ["tslib@2.8.1", "", {}, "sha512-oJFu94HQb+KVduSUQL7wnpmqnfmLsOA/nAh6b6EH0wCEoK0/mPeXU6c3wKDV83MkOuHPRHtSXKKU99IBazS/2w=="],
|
||||
|
||||
"@emnapi/runtime/tslib": ["tslib@2.8.1", "", {}, "sha512-oJFu94HQb+KVduSUQL7wnpmqnfmLsOA/nAh6b6EH0wCEoK0/mPeXU6c3wKDV83MkOuHPRHtSXKKU99IBazS/2w=="],
|
||||
|
||||
"@grpc/proto-loader/yargs": ["yargs@17.7.2", "", { "dependencies": { "cliui": "^8.0.1", "escalade": "^3.1.1", "get-caller-file": "^2.0.5", "require-directory": "^2.1.1", "string-width": "^4.2.3", "y18n": "^5.0.5", "yargs-parser": "^21.1.1" } }, "sha512-7dSzzRQ++CKnNI/krKnYRV7JKKPUXMEh61soaHKg9mrWEhzFWhFnxPxGl+69cD1Ou63C13NUPCnmIcrvqCuM6w=="],
|
||||
|
||||
"@opentelemetry/exporter-trace-otlp-grpc/@opentelemetry/core": ["@opentelemetry/core@1.30.1", "", { "dependencies": { "@opentelemetry/semantic-conventions": "1.28.0" }, "peerDependencies": { "@opentelemetry/api": ">=1.0.0 <1.10.0" } }, "sha512-OOCM2C/QIURhJMuKaekP3TRBxBKxG/TWWA0TL2J6nXUtDnuCtccy49LUJF8xPFXMX+0LMcxFpCo8M9cGY1W6rQ=="],
|
||||
@@ -1246,6 +1315,8 @@
|
||||
|
||||
"gaxios/is-stream": ["is-stream@2.0.1", "", {}, "sha512-hFoiJiTl63nn+kstHGBtewWSKnQLpyb155KHheA1l39uvtO9nWIop1p3udqPcUd/xbF1VLMO4n7OI6p7RbngDg=="],
|
||||
|
||||
"needle/iconv-lite": ["iconv-lite@0.6.3", "", { "dependencies": { "safer-buffer": ">= 2.1.2 < 3.0.0" } }, "sha512-4fCk79wshMdzMp2rH06qWrJE4iolqLhCUH+OiuIgU++RB0+94NlDL81atO7GX55uUKueo0txHNtvEyI6D7WdMw=="],
|
||||
|
||||
"npm-run-path/path-key": ["path-key@4.0.0", "", {}, "sha512-haREypq7xkM7ErfgIyA0z+Bj4AGKlMSdlQE2jvJo6huWD1EdkKYV+G/T4nq0YEF2vgTT8kqMFKo1uHn950r4SQ=="],
|
||||
|
||||
"parse5-htmlparser2-tree-adapter/parse5": ["parse5@6.0.1", "", {}, "sha512-Ofn/CTFzRGTTxwpNEs9PP93gXShHcTq255nzRYSKe8AkVpZY7e1fpmTfOyoIvjP5HG7Z2ZM7VS9PPhQGW2pOpw=="],
|
||||
|
||||
11
package.json
11
package.json
@@ -31,9 +31,15 @@
|
||||
"dev:fast": "bun run profile:fast && bun run dev:ollama:fast",
|
||||
"dev:code": "bun run profile:code && bun run dev:profile",
|
||||
"start": "node dist/cli.mjs",
|
||||
"test": "bun test",
|
||||
"test:coverage": "bun test --coverage --coverage-reporter=lcov --coverage-dir=coverage --max-concurrency=1 && bun run scripts/render-coverage-heatmap.ts",
|
||||
"test:coverage:ui": "bun run scripts/render-coverage-heatmap.ts",
|
||||
"security:pr-scan": "bun run scripts/pr-intent-scan.ts",
|
||||
"test:provider-recommendation": "bun test src/utils/providerRecommendation.test.ts src/utils/providerProfile.test.ts",
|
||||
"typecheck": "tsc --noEmit",
|
||||
"smoke": "bun run build && node dist/cli.mjs --version",
|
||||
"verify:privacy": "bun run scripts/verify-no-phone-home.ts",
|
||||
"build:verified": "bun run build && bun run verify:privacy",
|
||||
"test:provider": "bun test src/services/api/*.test.ts src/utils/context.test.ts",
|
||||
"doctor:runtime": "bun run scripts/system-check.ts",
|
||||
"doctor:runtime:json": "bun run scripts/system-check.ts --json",
|
||||
@@ -74,7 +80,9 @@
|
||||
"cli-highlight": "2.1.11",
|
||||
"code-excerpt": "4.0.0",
|
||||
"commander": "12.1.0",
|
||||
"cross-spawn": "7.0.6",
|
||||
"diff": "8.0.3",
|
||||
"duck-duck-scrape": "^2.2.7",
|
||||
"emoji-regex": "10.6.0",
|
||||
"env-paths": "3.0.0",
|
||||
"execa": "9.6.1",
|
||||
@@ -98,6 +106,7 @@
|
||||
"react-compiler-runtime": "1.0.0",
|
||||
"react-reconciler": "0.33.0",
|
||||
"semver": "7.7.4",
|
||||
"sharp": "^0.34.5",
|
||||
"shell-quote": "1.8.3",
|
||||
"signal-exit": "4.1.0",
|
||||
"stack-utils": "2.0.6",
|
||||
@@ -138,7 +147,7 @@
|
||||
"ollama",
|
||||
"gemini"
|
||||
],
|
||||
"license": "MIT",
|
||||
"license": "SEE LICENSE FILE",
|
||||
"publishConfig": {
|
||||
"access": "public"
|
||||
}
|
||||
|
||||
1
python/__init__.py
Normal file
1
python/__init__.py
Normal file
@@ -0,0 +1 @@
|
||||
# Python helper package for standalone provider-side utilities.
|
||||
1
python/tests/__init__.py
Normal file
1
python/tests/__init__.py
Normal file
@@ -0,0 +1 @@
|
||||
# Pytest package marker for the Python helper test suite.
|
||||
5
python/tests/conftest.py
Normal file
5
python/tests/conftest.py
Normal file
@@ -0,0 +1,5 @@
|
||||
from pathlib import Path
|
||||
import sys
|
||||
|
||||
# Make the sibling `python/` helper modules importable from this test package.
|
||||
sys.path.insert(0, str(Path(__file__).resolve().parents[1]))
|
||||
@@ -1,6 +1,6 @@
|
||||
"""
|
||||
test_atomic_chat_provider.py
|
||||
Run: pytest test_atomic_chat_provider.py -v
|
||||
Run: pytest python/tests/test_atomic_chat_provider.py -v
|
||||
"""
|
||||
|
||||
import pytest
|
||||
@@ -1,6 +1,6 @@
|
||||
"""
|
||||
test_ollama_provider.py
|
||||
Run: pytest test_ollama_provider.py -v
|
||||
Run: pytest python/tests/test_ollama_provider.py -v
|
||||
"""
|
||||
|
||||
import pytest
|
||||
@@ -13,25 +13,31 @@ from ollama_provider import (
|
||||
check_ollama_running,
|
||||
)
|
||||
|
||||
|
||||
def test_normalize_strips_prefix():
|
||||
assert normalize_ollama_model("ollama/llama3:8b") == "llama3:8b"
|
||||
|
||||
|
||||
def test_normalize_no_prefix():
|
||||
assert normalize_ollama_model("codellama:34b") == "codellama:34b"
|
||||
|
||||
|
||||
def test_normalize_empty():
|
||||
assert normalize_ollama_model("") == ""
|
||||
|
||||
|
||||
def test_converts_string_content():
|
||||
messages = [{"role": "user", "content": "Hello!"}]
|
||||
result = anthropic_to_ollama_messages(messages)
|
||||
assert result == [{"role": "user", "content": "Hello!"}]
|
||||
|
||||
|
||||
def test_converts_text_block_list():
|
||||
messages = [{"role": "user", "content": [{"type": "text", "text": "What is Python?"}]}]
|
||||
result = anthropic_to_ollama_messages(messages)
|
||||
assert result[0]["content"] == "What is Python?"
|
||||
|
||||
|
||||
def test_converts_image_block_to_placeholder():
|
||||
messages = [{"role": "user", "content": [{"type": "image", "source": {}}, {"type": "text", "text": "Describe this"}]}]
|
||||
result = anthropic_to_ollama_messages(messages)
|
||||
@@ -68,6 +74,7 @@ def test_converts_multi_turn():
|
||||
assert len(result) == 3
|
||||
assert result[1]["role"] == "assistant"
|
||||
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_ollama_running_true():
|
||||
mock_response = MagicMock()
|
||||
@@ -77,6 +84,7 @@ async def test_ollama_running_true():
|
||||
result = await check_ollama_running()
|
||||
assert result is True
|
||||
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_ollama_running_false_on_exception():
|
||||
with patch("ollama_provider.httpx.AsyncClient") as MockClient:
|
||||
@@ -84,6 +92,7 @@ async def test_ollama_running_false_on_exception():
|
||||
result = await check_ollama_running()
|
||||
assert result is False
|
||||
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_list_models_returns_names():
|
||||
mock_response = MagicMock()
|
||||
@@ -95,6 +104,7 @@ async def test_list_models_returns_names():
|
||||
models = await list_ollama_models()
|
||||
assert "llama3:8b" in models
|
||||
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_ollama_chat_returns_anthropic_format():
|
||||
mock_response = MagicMock()
|
||||
@@ -115,9 +125,11 @@ async def test_ollama_chat_returns_anthropic_format():
|
||||
assert result["role"] == "assistant"
|
||||
assert "42" in result["content"][0]["text"]
|
||||
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_ollama_chat_prepends_system():
|
||||
captured = {}
|
||||
|
||||
async def mock_post(url, json=None, **kwargs):
|
||||
captured.update(json or {})
|
||||
m = MagicMock()
|
||||
@@ -134,7 +146,7 @@ async def test_ollama_chat_prepends_system():
|
||||
await ollama_chat(
|
||||
model="llama3:8b",
|
||||
messages=[{"role": "user", "content": "Hi"}],
|
||||
system="Be helpful."
|
||||
system="Be helpful.",
|
||||
)
|
||||
assert captured["messages"][0]["role"] == "system"
|
||||
assert "helpful" in captured["messages"][0]["content"]
|
||||
@@ -2,7 +2,7 @@
|
||||
test_smart_router.py
|
||||
--------------------
|
||||
Tests for the SmartRouter.
|
||||
Run: pytest test_smart_router.py -v
|
||||
Run: pytest python/tests/test_smart_router.py -v
|
||||
"""
|
||||
|
||||
import pytest
|
||||
@@ -18,6 +18,7 @@ from smart_router import SmartRouter, Provider
|
||||
def fake_api_key(monkeypatch):
|
||||
monkeypatch.setenv("FAKE_KEY", "test-key")
|
||||
|
||||
|
||||
def make_provider(name, healthy=True, configured=True,
|
||||
latency=100.0, cost=0.002, errors=0, requests=0):
|
||||
p = Provider(
|
||||
@@ -33,7 +34,7 @@ def make_provider(name, healthy=True, configured=True,
|
||||
p.error_count = errors
|
||||
p.request_count = requests
|
||||
if not configured:
|
||||
p.api_key_env = "" # makes is_configured False for non-ollama
|
||||
p.api_key_env = "" # makes is_configured False for non-local providers
|
||||
return p
|
||||
|
||||
|
||||
111
scripts/build.ts
111
scripts/build.ts
@@ -3,7 +3,7 @@
|
||||
* distributable JS file using Bun's bundler.
|
||||
*
|
||||
* Handles:
|
||||
* - bun:bundle feature() flags → all false (disables internal-only features)
|
||||
* - bun:bundle feature() flags for the open build
|
||||
* - MACRO.* globals → inlined version/build-time constants
|
||||
* - src/ path aliases
|
||||
*/
|
||||
@@ -14,8 +14,9 @@ import { noTelemetryPlugin } from './no-telemetry-plugin'
|
||||
const pkg = JSON.parse(readFileSync('./package.json', 'utf-8'))
|
||||
const version = pkg.version
|
||||
|
||||
// Feature flags — all disabled for the open build.
|
||||
// These gate Anthropic-internal features (voice, proactive, kairos, etc.)
|
||||
// Feature flags for the open build.
|
||||
// Most Anthropic-internal features stay off; open-build features can be
|
||||
// selectively enabled here when their full source exists in the mirror.
|
||||
const featureFlags: Record<string, boolean> = {
|
||||
VOICE_MODE: false,
|
||||
PROACTIVE: false,
|
||||
@@ -37,7 +38,7 @@ const featureFlags: Record<string, boolean> = {
|
||||
TRANSCRIPT_CLASSIFIER: false,
|
||||
WEB_BROWSER_TOOL: false,
|
||||
MESSAGE_ACTIONS: false,
|
||||
BUDDY: false,
|
||||
BUDDY: true,
|
||||
CHICAGO_MCP: false,
|
||||
COWORKER_TYPE_TELEMETRY: false,
|
||||
}
|
||||
@@ -110,7 +111,7 @@ export async function handleBgFlag() { throw new Error("Background sessions are
|
||||
build.onLoad(
|
||||
{ filter: /.*/, namespace: 'bun-bundle-shim' },
|
||||
() => ({
|
||||
contents: `export function feature(name) { return false; }`,
|
||||
contents: `const featureFlags = ${JSON.stringify(featureFlags)};\nexport function feature(name) { return featureFlags[name] ?? false; }`,
|
||||
loader: 'js',
|
||||
}),
|
||||
)
|
||||
@@ -158,7 +159,6 @@ export async function handleBgFlag() { throw new Error("Background sessions are
|
||||
'modifiers-napi',
|
||||
'url-handler-napi',
|
||||
'color-diff-napi',
|
||||
'sharp',
|
||||
'@anthropic-ai/mcpb',
|
||||
'@ant/claude-for-chrome-mcp',
|
||||
'@anthropic-ai/sandbox-runtime',
|
||||
@@ -251,6 +251,103 @@ export const SeverityNumber = {};
|
||||
loader: 'js',
|
||||
}),
|
||||
)
|
||||
|
||||
// Pre-scan: find all missing modules that need stubbing
|
||||
// (Bun's onResolve corrupts module graph even when returning null,
|
||||
// so we use exact-match resolvers instead of catch-all patterns)
|
||||
const fs = require('fs')
|
||||
const pathMod = require('path')
|
||||
const srcDir = pathMod.resolve(__dirname, '..', 'src')
|
||||
const missingModules = new Set<string>()
|
||||
const missingModuleExports = new Map<string, Set<string>>()
|
||||
|
||||
// Known missing external packages
|
||||
for (const pkg of [
|
||||
'@ant/computer-use-mcp',
|
||||
'@ant/computer-use-mcp/sentinelApps',
|
||||
'@ant/computer-use-mcp/types',
|
||||
'@ant/computer-use-swift',
|
||||
'@ant/computer-use-input',
|
||||
]) {
|
||||
missingModules.add(pkg)
|
||||
}
|
||||
|
||||
// Scan source to find imports that can't resolve
|
||||
function scanForMissingImports() {
|
||||
function walk(dir: string) {
|
||||
for (const ent of fs.readdirSync(dir, { withFileTypes: true })) {
|
||||
const full = pathMod.join(dir, ent.name)
|
||||
if (ent.isDirectory()) { walk(full); continue }
|
||||
if (!/\.(ts|tsx)$/.test(ent.name)) continue
|
||||
const code: string = fs.readFileSync(full, 'utf-8')
|
||||
// Collect all imports
|
||||
for (const m of code.matchAll(/import\s+(?:\{([^}]*)\}|(\w+))?\s*(?:,\s*\{([^}]*)\})?\s*from\s+['"](.*?)['"]/g)) {
|
||||
const specifier = m[4]
|
||||
const namedPart = m[1] || m[3] || ''
|
||||
const names = namedPart.split(',')
|
||||
.map((s: string) => s.trim().replace(/^type\s+/, ''))
|
||||
.filter((s: string) => s && !s.startsWith('type '))
|
||||
|
||||
// Check src/tasks/ non-relative imports
|
||||
if (specifier.startsWith('src/tasks/')) {
|
||||
const resolved = pathMod.resolve(__dirname, '..', specifier)
|
||||
const candidates = [
|
||||
resolved,
|
||||
`${resolved}.ts`, `${resolved}.tsx`,
|
||||
resolved.replace(/\.js$/, '.ts'), resolved.replace(/\.js$/, '.tsx'),
|
||||
pathMod.join(resolved, 'index.ts'), pathMod.join(resolved, 'index.tsx'),
|
||||
]
|
||||
if (!candidates.some((c: string) => fs.existsSync(c))) {
|
||||
missingModules.add(specifier)
|
||||
}
|
||||
}
|
||||
// Check relative .js imports
|
||||
else if (specifier.endsWith('.js') && (specifier.startsWith('./') || specifier.startsWith('../'))) {
|
||||
const dir2 = pathMod.dirname(full)
|
||||
const resolved = pathMod.resolve(dir2, specifier)
|
||||
const tsVariant = resolved.replace(/\.js$/, '.ts')
|
||||
const tsxVariant = resolved.replace(/\.js$/, '.tsx')
|
||||
if (!fs.existsSync(resolved) && !fs.existsSync(tsVariant) && !fs.existsSync(tsxVariant)) {
|
||||
missingModules.add(specifier)
|
||||
}
|
||||
}
|
||||
|
||||
// Track named exports for missing modules
|
||||
if (names.length > 0) {
|
||||
if (!missingModuleExports.has(specifier)) missingModuleExports.set(specifier, new Set())
|
||||
for (const n of names) missingModuleExports.get(specifier)!.add(n)
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
walk(srcDir)
|
||||
}
|
||||
scanForMissingImports()
|
||||
|
||||
// Register exact-match resolvers for each missing module
|
||||
for (const mod of missingModules) {
|
||||
const escaped = mod.replace(/[.*+?^${}()|[\]\\]/g, '\\$&')
|
||||
build.onResolve({ filter: new RegExp(`^${escaped}$`) }, () => ({
|
||||
path: mod,
|
||||
namespace: 'missing-module-stub',
|
||||
}))
|
||||
}
|
||||
|
||||
build.onLoad(
|
||||
{ filter: /.*/, namespace: 'missing-module-stub' },
|
||||
(args) => {
|
||||
const names = missingModuleExports.get(args.path) ?? new Set()
|
||||
const exports = [...names].map(n => `export const ${n} = noop;`).join('\n')
|
||||
return {
|
||||
contents: `
|
||||
const noop = () => null;
|
||||
export default noop;
|
||||
${exports}
|
||||
`,
|
||||
loader: 'js',
|
||||
}
|
||||
},
|
||||
)
|
||||
},
|
||||
},
|
||||
],
|
||||
@@ -275,6 +372,8 @@ export const SeverityNumber = {};
|
||||
'@opentelemetry/sdk-logs',
|
||||
'@opentelemetry/sdk-metrics',
|
||||
'@opentelemetry/semantic-conventions',
|
||||
// Native image processing
|
||||
'sharp',
|
||||
// Cloud provider SDKs
|
||||
'@aws-sdk/client-bedrock',
|
||||
'@aws-sdk/client-bedrock-runtime',
|
||||
|
||||
@@ -196,6 +196,67 @@ export function classifyFetchError() { return 'disabled'; }
|
||||
|
||||
'components/FeedbackSurvey/submitTranscriptShare': `
|
||||
export async function submitTranscriptShare() { return { success: false }; }
|
||||
`,
|
||||
|
||||
// ─── Internal employee logging (not needed in the external build) ─────
|
||||
|
||||
'services/internalLogging': `
|
||||
export async function logPermissionContextForAnts() {}
|
||||
export const getContainerId = async () => null;
|
||||
`,
|
||||
|
||||
// ─── Deleted Anthropic-internal modules ───────────────────────────────
|
||||
|
||||
'services/api/dumpPrompts': `
|
||||
export function createDumpPromptsFetch() { return undefined; }
|
||||
export function getDumpPromptsPath() { return ''; }
|
||||
export function getLastApiRequests() { return []; }
|
||||
export function clearApiRequestCache() {}
|
||||
export function clearDumpState() {}
|
||||
export function clearAllDumpState() {}
|
||||
export function addApiRequestToCache() {}
|
||||
`,
|
||||
|
||||
'utils/undercover': `
|
||||
export function isUndercover() { return false; }
|
||||
export function getUndercoverInstructions() { return ''; }
|
||||
export function shouldShowUndercoverAutoNotice() { return false; }
|
||||
`,
|
||||
|
||||
'types/generated/events_mono/claude_code/v1/claude_code_internal_event': `
|
||||
export const ClaudeCodeInternalEvent = {
|
||||
fromJSON: value => value,
|
||||
toJSON: value => value,
|
||||
create: value => value ?? {},
|
||||
fromPartial: value => value ?? {},
|
||||
};
|
||||
`,
|
||||
|
||||
'types/generated/events_mono/growthbook/v1/growthbook_experiment_event': `
|
||||
export const GrowthbookExperimentEvent = {
|
||||
fromJSON: value => value,
|
||||
toJSON: value => value,
|
||||
create: value => value ?? {},
|
||||
fromPartial: value => value ?? {},
|
||||
};
|
||||
`,
|
||||
|
||||
'types/generated/events_mono/common/v1/auth': `
|
||||
export const PublicApiAuth = {
|
||||
fromJSON: value => value,
|
||||
toJSON: value => value,
|
||||
create: value => value ?? {},
|
||||
fromPartial: value => value ?? {},
|
||||
};
|
||||
`,
|
||||
|
||||
'types/generated/google/protobuf/timestamp': `
|
||||
export const Timestamp = {
|
||||
fromJSON: value => value,
|
||||
toJSON: value => value,
|
||||
create: value => value ?? {},
|
||||
fromPartial: value => value ?? {},
|
||||
};
|
||||
`,
|
||||
}
|
||||
|
||||
|
||||
136
scripts/pr-intent-scan.test.ts
Normal file
136
scripts/pr-intent-scan.test.ts
Normal file
@@ -0,0 +1,136 @@
|
||||
import { describe, expect, test } from 'bun:test'
|
||||
|
||||
import { scanAddedLines, type DiffLine } from './pr-intent-scan.ts'
|
||||
|
||||
function line(content: string, overrides: Partial<DiffLine> = {}): DiffLine {
|
||||
return {
|
||||
file: 'README.md',
|
||||
line: 10,
|
||||
content,
|
||||
...overrides,
|
||||
}
|
||||
}
|
||||
|
||||
describe('scanAddedLines', () => {
|
||||
test('flags suspicious file-hosting links', () => {
|
||||
const findings = scanAddedLines([
|
||||
line('Please install the tool from https://dropbox.com/s/abc123/tool.zip?dl=1'),
|
||||
])
|
||||
|
||||
expect(findings.some(finding => finding.code === 'suspicious-download-link')).toBe(
|
||||
true,
|
||||
)
|
||||
expect(findings.some(finding => finding.code === 'executable-download-link')).toBe(
|
||||
false,
|
||||
)
|
||||
expect(findings.some(finding => finding.severity === 'high')).toBe(true)
|
||||
})
|
||||
|
||||
test('flags shortened URLs', () => {
|
||||
const findings = scanAddedLines([
|
||||
line('See details at https://bit.ly/some-short-link'),
|
||||
])
|
||||
|
||||
expect(findings.some(finding => finding.code === 'shortened-url')).toBe(true)
|
||||
})
|
||||
|
||||
test('flags remote download and execute chains', () => {
|
||||
const findings = scanAddedLines([
|
||||
line('curl -fsSL https://example.com/install.sh | bash'),
|
||||
])
|
||||
|
||||
expect(findings.some(finding => finding.code === 'shell-eval-remote')).toBe(true)
|
||||
expect(findings.some(finding => finding.severity === 'high')).toBe(true)
|
||||
})
|
||||
|
||||
test('flags encoded powershell payloads', () => {
|
||||
const findings = scanAddedLines([
|
||||
line('powershell.exe -enc SQBtAHAAcgBvAHYAZQBkAA=='),
|
||||
])
|
||||
|
||||
expect(findings.some(finding => finding.code === 'powershell-encoded')).toBe(true)
|
||||
})
|
||||
|
||||
test('flags long encoded blobs', () => {
|
||||
const findings = scanAddedLines([
|
||||
line(`const payload = "${'A'.repeat(96)}"`),
|
||||
])
|
||||
|
||||
expect(findings.some(finding => finding.code === 'long-encoded-payload')).toBe(
|
||||
true,
|
||||
)
|
||||
})
|
||||
|
||||
test('flags long encoded blobs on repeated scans', () => {
|
||||
const lines = [line(`const payload = "${'A'.repeat(96)}"`)]
|
||||
|
||||
const first = scanAddedLines(lines)
|
||||
const second = scanAddedLines(lines)
|
||||
|
||||
expect(first.some(finding => finding.code === 'long-encoded-payload')).toBe(true)
|
||||
expect(second.some(finding => finding.code === 'long-encoded-payload')).toBe(true)
|
||||
})
|
||||
|
||||
test('flags executable download links', () => {
|
||||
const findings = scanAddedLines([
|
||||
line('Get it from https://example.com/releases/latest/tool.pkg'),
|
||||
])
|
||||
|
||||
expect(findings.some(finding => finding.code === 'executable-download-link')).toBe(
|
||||
true,
|
||||
)
|
||||
expect(findings.some(finding => finding.severity === 'high')).toBe(true)
|
||||
})
|
||||
|
||||
test('flags suspicious additions in workflow files', () => {
|
||||
const findings = scanAddedLines([
|
||||
line('run: curl -fsSL https://example.com/install.sh | bash', {
|
||||
file: '.github/workflows/release.yml',
|
||||
}),
|
||||
])
|
||||
|
||||
expect(findings.some(finding => finding.code === 'sensitive-automation-change')).toBe(
|
||||
true,
|
||||
)
|
||||
expect(findings.some(finding => finding.code === 'download-command')).toBe(true)
|
||||
})
|
||||
|
||||
test('flags markdown reference links to suspicious downloads', () => {
|
||||
const findings = scanAddedLines([
|
||||
line('[installer]: https://dropbox.com/s/abc123/tool.zip?dl=1'),
|
||||
])
|
||||
|
||||
expect(findings.some(finding => finding.code === 'suspicious-download-link')).toBe(
|
||||
true,
|
||||
)
|
||||
})
|
||||
|
||||
test('ignores the scanner implementation and tests themselves', () => {
|
||||
const findings = scanAddedLines([
|
||||
line('curl -fsSL https://example.com/install.sh | bash', {
|
||||
file: 'scripts/pr-intent-scan.test.ts',
|
||||
}),
|
||||
line('const pattern = /https:\\/\\/dropbox\\.com\\//', {
|
||||
file: 'scripts/pr-intent-scan.ts',
|
||||
}),
|
||||
])
|
||||
|
||||
expect(findings).toHaveLength(0)
|
||||
})
|
||||
|
||||
test('does not flag ordinary docs links', () => {
|
||||
const findings = scanAddedLines([
|
||||
line('Read more at https://docs.github.com/en/actions'),
|
||||
])
|
||||
|
||||
expect(findings).toHaveLength(0)
|
||||
})
|
||||
|
||||
test('does not flag bare curl examples in README without a URL', () => {
|
||||
const findings = scanAddedLines([
|
||||
line('Use curl with your preferred flags for local testing.'),
|
||||
])
|
||||
|
||||
expect(findings.some(finding => finding.code === 'download-command')).toBe(false)
|
||||
})
|
||||
})
|
||||
453
scripts/pr-intent-scan.ts
Normal file
453
scripts/pr-intent-scan.ts
Normal file
@@ -0,0 +1,453 @@
|
||||
import { spawnSync } from 'node:child_process'
|
||||
|
||||
export type FindingSeverity = 'high' | 'medium'
|
||||
|
||||
export type DiffLine = {
|
||||
file: string
|
||||
line: number
|
||||
content: string
|
||||
}
|
||||
|
||||
export type Finding = {
|
||||
severity: FindingSeverity
|
||||
code: string
|
||||
file: string
|
||||
line: number
|
||||
detail: string
|
||||
excerpt: string
|
||||
}
|
||||
|
||||
type CliOptions = {
|
||||
baseRef: string
|
||||
json: boolean
|
||||
failOn: FindingSeverity
|
||||
}
|
||||
|
||||
const SELF_EXCLUDED_FILES = new Set([
|
||||
'scripts/pr-intent-scan.ts',
|
||||
'scripts/pr-intent-scan.test.ts',
|
||||
])
|
||||
|
||||
const SHORTENER_DOMAINS = [
|
||||
'bit.ly',
|
||||
'tinyurl.com',
|
||||
'goo.gl',
|
||||
't.co',
|
||||
'is.gd',
|
||||
'rb.gy',
|
||||
'cutt.ly',
|
||||
]
|
||||
|
||||
const SUSPICIOUS_DOWNLOAD_DOMAINS = [
|
||||
'dropbox.com',
|
||||
'dl.dropboxusercontent.com',
|
||||
'drive.google.com',
|
||||
'docs.google.com',
|
||||
'mega.nz',
|
||||
'mediafire.com',
|
||||
'transfer.sh',
|
||||
'anonfiles.com',
|
||||
'catbox.moe',
|
||||
]
|
||||
|
||||
const URL_REGEX = /\bhttps?:\/\/[^\s)>"']+/gi
|
||||
const LONG_BASE64_REGEX = /\b(?:[A-Za-z0-9+/]{80,}={0,2}|[A-Za-z0-9_-]{80,})\b/
|
||||
const EXECUTABLE_PATH_REGEX =
|
||||
/\.(?:sh|bash|zsh|ps1|exe|msi|pkg|deb|rpm|zip|tar|tgz|gz|xz|dmg|appimage)(?:$|[?#])/i
|
||||
const SENSITIVE_PATH_REGEX =
|
||||
/^(?:\.github\/workflows\/|scripts\/|bin\/|install(?:\/|\.|$)|.*(?:Dockerfile|docker-compose|compose\.ya?ml)$)/i
|
||||
|
||||
function parseOptions(argv: string[]): CliOptions {
|
||||
const options: CliOptions = {
|
||||
baseRef: 'origin/main',
|
||||
json: false,
|
||||
failOn: 'high',
|
||||
}
|
||||
|
||||
for (let index = 0; index < argv.length; index++) {
|
||||
const arg = argv[index]
|
||||
if (arg === '--json') {
|
||||
options.json = true
|
||||
continue
|
||||
}
|
||||
if (arg === '--base') {
|
||||
const next = argv[index + 1]
|
||||
if (next && !next.startsWith('--')) {
|
||||
options.baseRef = next
|
||||
index++
|
||||
}
|
||||
continue
|
||||
}
|
||||
if (arg === '--fail-on') {
|
||||
const next = argv[index + 1]
|
||||
if (next === 'high' || next === 'medium') {
|
||||
options.failOn = next
|
||||
index++
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
return options
|
||||
}
|
||||
|
||||
function trimExcerpt(content: string): string {
|
||||
const compact = content.trim().replace(/\s+/g, ' ')
|
||||
return compact.length > 140 ? `${compact.slice(0, 137)}...` : compact
|
||||
}
|
||||
|
||||
function uniqueFindings(findings: Finding[]): Finding[] {
|
||||
const seen = new Set<string>()
|
||||
return findings.filter(finding => {
|
||||
const key = `${finding.code}:${finding.file}:${finding.line}:${finding.detail}`
|
||||
if (seen.has(key)) {
|
||||
return false
|
||||
}
|
||||
seen.add(key)
|
||||
return true
|
||||
})
|
||||
}
|
||||
|
||||
function parseAddedLines(diffText: string): DiffLine[] {
|
||||
const lines = diffText.split('\n')
|
||||
const added: DiffLine[] = []
|
||||
let currentFile: string | null = null
|
||||
let currentLine = 0
|
||||
|
||||
for (const rawLine of lines) {
|
||||
if (rawLine.startsWith('+++ b/')) {
|
||||
currentFile = rawLine.slice('+++ b/'.length)
|
||||
continue
|
||||
}
|
||||
|
||||
if (rawLine.startsWith('@@')) {
|
||||
const match = /\+(\d+)(?:,(\d+))?/.exec(rawLine)
|
||||
if (match) {
|
||||
currentLine = Number(match[1])
|
||||
}
|
||||
continue
|
||||
}
|
||||
|
||||
if (!currentFile) {
|
||||
continue
|
||||
}
|
||||
|
||||
if (rawLine.startsWith('+') && !rawLine.startsWith('+++')) {
|
||||
added.push({
|
||||
file: currentFile,
|
||||
line: currentLine,
|
||||
content: rawLine.slice(1),
|
||||
})
|
||||
currentLine += 1
|
||||
continue
|
||||
}
|
||||
|
||||
if (rawLine.startsWith('-') && !rawLine.startsWith('---')) {
|
||||
continue
|
||||
}
|
||||
|
||||
if (!rawLine.startsWith('\\')) {
|
||||
currentLine += 1
|
||||
}
|
||||
}
|
||||
|
||||
return added
|
||||
}
|
||||
|
||||
function tryParseUrl(value: string): URL | null {
|
||||
try {
|
||||
return new URL(value)
|
||||
} catch {
|
||||
return null
|
||||
}
|
||||
}
|
||||
|
||||
function hostMatches(hostname: string, domain: string): boolean {
|
||||
return hostname === domain || hostname.endsWith(`.${domain}`)
|
||||
}
|
||||
|
||||
function hasSuspiciousDownloadIndicators(url: URL): boolean {
|
||||
const combined = `${url.pathname}${url.search}`.toLowerCase()
|
||||
return (
|
||||
combined.includes('dl=1') ||
|
||||
combined.includes('raw=1') ||
|
||||
combined.includes('export=download') ||
|
||||
combined.includes('/download') ||
|
||||
combined.includes('/uc?export=download')
|
||||
)
|
||||
}
|
||||
|
||||
function findUrlFindings(line: DiffLine): Finding[] {
|
||||
const findings: Finding[] = []
|
||||
const matches = line.content.match(URL_REGEX) ?? []
|
||||
|
||||
for (const match of matches) {
|
||||
const parsed = tryParseUrl(match)
|
||||
if (!parsed) continue
|
||||
|
||||
const hostname = parsed.hostname.toLowerCase()
|
||||
|
||||
for (const domain of SHORTENER_DOMAINS) {
|
||||
if (hostMatches(hostname, domain)) {
|
||||
findings.push({
|
||||
severity: 'medium',
|
||||
code: 'shortened-url',
|
||||
file: line.file,
|
||||
line: line.line,
|
||||
detail: `Added shortened URL: ${hostname}`,
|
||||
excerpt: trimExcerpt(line.content),
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
const isSuspiciousHost = SUSPICIOUS_DOWNLOAD_DOMAINS.some(domain =>
|
||||
hostMatches(hostname, domain),
|
||||
)
|
||||
const isExecutableDownload = EXECUTABLE_PATH_REGEX.test(
|
||||
`${parsed.pathname}${parsed.search}`,
|
||||
)
|
||||
|
||||
if (isSuspiciousHost) {
|
||||
findings.push({
|
||||
severity:
|
||||
hasSuspiciousDownloadIndicators(parsed) || isExecutableDownload
|
||||
? 'high'
|
||||
: 'medium',
|
||||
code: 'suspicious-download-link',
|
||||
file: line.file,
|
||||
line: line.line,
|
||||
detail: `Added external file-hosting link: ${hostname}`,
|
||||
excerpt: trimExcerpt(line.content),
|
||||
})
|
||||
} else if (isExecutableDownload) {
|
||||
findings.push({
|
||||
severity: 'high',
|
||||
code: 'executable-download-link',
|
||||
file: line.file,
|
||||
line: line.line,
|
||||
detail: `Added direct link to executable or archive payload: ${hostname}`,
|
||||
excerpt: trimExcerpt(line.content),
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
return findings
|
||||
}
|
||||
|
||||
function findSensitivePathFindings(line: DiffLine): Finding[] {
|
||||
if (!SENSITIVE_PATH_REGEX.test(line.file)) {
|
||||
return []
|
||||
}
|
||||
|
||||
const lower = line.content.toLowerCase()
|
||||
|
||||
if (
|
||||
/\b(curl|wget|invoke-webrequest|iwr|powershell|bash|sh|chmod\s+\+x)\b/i.test(
|
||||
line.content,
|
||||
) ||
|
||||
URL_REGEX.test(line.content) ||
|
||||
lower.includes('download')
|
||||
) {
|
||||
return [
|
||||
{
|
||||
severity: 'medium',
|
||||
code: 'sensitive-automation-change',
|
||||
file: line.file,
|
||||
line: line.line,
|
||||
detail:
|
||||
'Added network, execution, or download-related content in a sensitive automation file',
|
||||
excerpt: trimExcerpt(line.content),
|
||||
},
|
||||
]
|
||||
}
|
||||
|
||||
return []
|
||||
}
|
||||
|
||||
function findCommandFindings(line: DiffLine): Finding[] {
|
||||
const findings: Finding[] = []
|
||||
const lower = line.content.toLowerCase()
|
||||
|
||||
const highPatterns: Array<[string, RegExp, string]> = [
|
||||
[
|
||||
'download-exec-chain',
|
||||
/\b(curl|wget|invoke-webrequest|iwr)\b.*(\|\s*(sh|bash|zsh)|;\s*chmod\s+\+x|&&\s*\.\.?\/|>\s*\/tmp\/)/i,
|
||||
'Added remote download followed by execution or staging',
|
||||
],
|
||||
[
|
||||
'powershell-encoded',
|
||||
/\bpowershell(?:\.exe)?\b.*(?:-enc|-encodedcommand)\b/i,
|
||||
'Added encoded PowerShell invocation',
|
||||
],
|
||||
[
|
||||
'shell-eval-remote',
|
||||
/\b(curl|wget)\b.*\|\s*(sh|bash|zsh)\b/i,
|
||||
'Added shell pipe from remote content into interpreter',
|
||||
],
|
||||
[
|
||||
'binary-lolbin',
|
||||
/\b(mshta|rundll32|regsvr32|certutil)\b/i,
|
||||
'Added living-off-the-land binary often used for payload staging',
|
||||
],
|
||||
[
|
||||
'invoke-expression',
|
||||
/\b(iex|invoke-expression)\b/i,
|
||||
'Added PowerShell expression execution',
|
||||
],
|
||||
]
|
||||
|
||||
const mediumPatterns: Array<[string, RegExp, string]> = [
|
||||
[
|
||||
'download-command',
|
||||
/\b(curl|wget|invoke-webrequest|iwr)\b.*https?:\/\//i,
|
||||
'Added command that downloads remote content',
|
||||
],
|
||||
[
|
||||
'archive-extract-exec',
|
||||
/\b(unzip|tar|7z)\b.*(&&|;).*\b(chmod|node|python|bash|sh)\b/i,
|
||||
'Added archive extraction followed by execution',
|
||||
],
|
||||
[
|
||||
'base64-decode',
|
||||
/\b(base64\s+-d|openssl\s+base64\s+-d|python .*b64decode)\b/i,
|
||||
'Added explicit payload decode step',
|
||||
],
|
||||
]
|
||||
|
||||
for (const [code, pattern, detail] of highPatterns) {
|
||||
if (pattern.test(line.content)) {
|
||||
findings.push({
|
||||
severity: 'high',
|
||||
code,
|
||||
file: line.file,
|
||||
line: line.line,
|
||||
detail,
|
||||
excerpt: trimExcerpt(line.content),
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
for (const [code, pattern, detail] of mediumPatterns) {
|
||||
if (code === 'download-command' && !SENSITIVE_PATH_REGEX.test(line.file)) {
|
||||
continue
|
||||
}
|
||||
if (pattern.test(line.content)) {
|
||||
findings.push({
|
||||
severity: 'medium',
|
||||
code,
|
||||
file: line.file,
|
||||
line: line.line,
|
||||
detail,
|
||||
excerpt: trimExcerpt(line.content),
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
if (LONG_BASE64_REGEX.test(line.content) && !lower.includes('sha256') && !lower.includes('sha512')) {
|
||||
findings.push({
|
||||
severity: 'medium',
|
||||
code: 'long-encoded-payload',
|
||||
file: line.file,
|
||||
line: line.line,
|
||||
detail: 'Added long encoded blob or token-like payload',
|
||||
excerpt: trimExcerpt(line.content),
|
||||
})
|
||||
}
|
||||
|
||||
return findings
|
||||
}
|
||||
|
||||
export function scanAddedLines(lines: DiffLine[]): Finding[] {
|
||||
const findings = lines
|
||||
.filter(line => !SELF_EXCLUDED_FILES.has(line.file))
|
||||
.flatMap(line => [
|
||||
...findUrlFindings(line),
|
||||
...findCommandFindings(line),
|
||||
...findSensitivePathFindings(line),
|
||||
])
|
||||
return uniqueFindings(findings)
|
||||
}
|
||||
|
||||
export function getGitDiff(baseRef: string): string {
|
||||
const mergeBase = spawnSync('git', ['merge-base', baseRef, 'HEAD'], {
|
||||
encoding: 'utf8',
|
||||
})
|
||||
|
||||
if (mergeBase.status !== 0) {
|
||||
throw new Error(
|
||||
`Could not determine merge-base with ${baseRef}: ${mergeBase.stderr.trim() || mergeBase.stdout.trim()}`,
|
||||
)
|
||||
}
|
||||
|
||||
const base = mergeBase.stdout.trim()
|
||||
const diff = spawnSync(
|
||||
'git',
|
||||
['diff', '--unified=0', '--no-ext-diff', `${base}...HEAD`],
|
||||
{ encoding: 'utf8' },
|
||||
)
|
||||
|
||||
if (diff.status !== 0) {
|
||||
throw new Error(`git diff failed: ${diff.stderr.trim() || diff.stdout.trim()}`)
|
||||
}
|
||||
|
||||
return diff.stdout
|
||||
}
|
||||
|
||||
function shouldFail(findings: Finding[], failOn: FindingSeverity): boolean {
|
||||
if (failOn === 'medium') {
|
||||
return findings.length > 0
|
||||
}
|
||||
return findings.some(finding => finding.severity === 'high')
|
||||
}
|
||||
|
||||
function renderText(findings: Finding[]): string {
|
||||
if (findings.length === 0) {
|
||||
return 'PR intent scan: no suspicious additions found.'
|
||||
}
|
||||
|
||||
const high = findings.filter(f => f.severity === 'high')
|
||||
const medium = findings.filter(f => f.severity === 'medium')
|
||||
const lines = [
|
||||
`PR intent scan: ${findings.length} finding(s)`,
|
||||
`- high: ${high.length}`,
|
||||
`- medium: ${medium.length}`,
|
||||
'',
|
||||
]
|
||||
|
||||
for (const finding of findings) {
|
||||
lines.push(
|
||||
`[${finding.severity.toUpperCase()}] ${finding.file}:${finding.line} ${finding.detail}`,
|
||||
)
|
||||
lines.push(` ${finding.excerpt}`)
|
||||
}
|
||||
|
||||
return lines.join('\n')
|
||||
}
|
||||
|
||||
export function run(options: CliOptions): number {
|
||||
const diff = getGitDiff(options.baseRef)
|
||||
const addedLines = parseAddedLines(diff)
|
||||
const findings = scanAddedLines(addedLines)
|
||||
|
||||
if (options.json) {
|
||||
process.stdout.write(
|
||||
`${JSON.stringify(
|
||||
{
|
||||
baseRef: options.baseRef,
|
||||
addedLines: addedLines.length,
|
||||
findings,
|
||||
},
|
||||
null,
|
||||
2,
|
||||
)}\n`,
|
||||
)
|
||||
} else {
|
||||
process.stdout.write(`${renderText(findings)}\n`)
|
||||
}
|
||||
|
||||
return shouldFail(findings, options.failOn) ? 1 : 0
|
||||
}
|
||||
|
||||
if (import.meta.main) {
|
||||
const options = parseOptions(process.argv.slice(2))
|
||||
process.exitCode = run(options)
|
||||
}
|
||||
@@ -120,19 +120,18 @@ function applyFastFlags(env: NodeJS.ProcessEnv): NodeJS.ProcessEnv {
|
||||
return env
|
||||
}
|
||||
|
||||
function printSummary(profile: ProviderProfile, env: NodeJS.ProcessEnv): void {
|
||||
function printSummary(profile: ProviderProfile): void {
|
||||
console.log(`Launching profile: ${profile}`)
|
||||
if (profile === 'gemini') {
|
||||
console.log(`GEMINI_MODEL=${env.GEMINI_MODEL}`)
|
||||
console.log('Using configured Gemini provider settings.')
|
||||
} else if (profile === 'codex') {
|
||||
console.log(`OPENAI_BASE_URL=${env.OPENAI_BASE_URL}`)
|
||||
console.log(`OPENAI_MODEL=${env.OPENAI_MODEL}`)
|
||||
console.log('Using configured Codex/OpenAI-compatible provider settings.')
|
||||
} else if (profile === 'atomic-chat') {
|
||||
console.log(`OPENAI_BASE_URL=${env.OPENAI_BASE_URL}`)
|
||||
console.log(`OPENAI_MODEL=${env.OPENAI_MODEL}`)
|
||||
console.log('Using configured Atomic Chat provider settings.')
|
||||
} else if (profile === 'ollama') {
|
||||
console.log('Using configured Ollama provider settings.')
|
||||
} else {
|
||||
console.log(`OPENAI_BASE_URL=${env.OPENAI_BASE_URL}`)
|
||||
console.log(`OPENAI_MODEL=${env.OPENAI_MODEL}`)
|
||||
console.log('Using configured OpenAI-compatible provider settings.')
|
||||
}
|
||||
}
|
||||
|
||||
@@ -227,7 +226,7 @@ async function main(): Promise<void> {
|
||||
}
|
||||
}
|
||||
|
||||
printSummary(profile, env)
|
||||
printSummary(profile)
|
||||
|
||||
const doctorCode = await runProcess('bun', ['run', 'scripts/system-check.ts'], env)
|
||||
if (doctorCode !== 0) {
|
||||
|
||||
393
scripts/render-coverage-heatmap.ts
Normal file
393
scripts/render-coverage-heatmap.ts
Normal file
@@ -0,0 +1,393 @@
|
||||
import { mkdir, readFile, writeFile } from 'fs/promises'
|
||||
import { dirname, resolve } from 'path'
|
||||
|
||||
type FileCoverage = {
|
||||
path: string
|
||||
found: number
|
||||
hit: number
|
||||
chunks: number[]
|
||||
}
|
||||
|
||||
type DirectoryCoverage = {
|
||||
path: string
|
||||
found: number
|
||||
hit: number
|
||||
}
|
||||
|
||||
const LCOV_PATH = resolve(process.cwd(), 'coverage/lcov.info')
|
||||
const HTML_PATH = resolve(process.cwd(), 'coverage/index.html')
|
||||
const CHUNK_COUNT = 20
|
||||
|
||||
function escapeHtml(value: string): string {
|
||||
return value
|
||||
.replaceAll('&', '&')
|
||||
.replaceAll('<', '<')
|
||||
.replaceAll('>', '>')
|
||||
.replaceAll('"', '"')
|
||||
}
|
||||
|
||||
function bucketColor(ratio: number): string {
|
||||
if (ratio >= 0.9) return '#166534'
|
||||
if (ratio >= 0.75) return '#15803d'
|
||||
if (ratio >= 0.5) return '#65a30d'
|
||||
if (ratio > 0) return '#a3a3a3'
|
||||
return '#262626'
|
||||
}
|
||||
|
||||
function coverageLabel(ratio: number): string {
|
||||
return `${Math.round(ratio * 100)}%`
|
||||
}
|
||||
|
||||
function coverageRatio(found: number, hit: number): number {
|
||||
return found === 0 ? 0 : hit / found
|
||||
}
|
||||
|
||||
function bucketGlyph(ratio: number): string {
|
||||
if (ratio >= 0.9) return '█'
|
||||
if (ratio >= 0.75) return '▓'
|
||||
if (ratio >= 0.5) return '▒'
|
||||
if (ratio > 0) return '░'
|
||||
return '·'
|
||||
}
|
||||
|
||||
function terminalBar(chunks: number[]): string {
|
||||
return chunks.map(bucketGlyph).join('')
|
||||
}
|
||||
|
||||
function summarizeDirectories(files: FileCoverage[]): DirectoryCoverage[] {
|
||||
const dirs = new Map<string, DirectoryCoverage>()
|
||||
|
||||
for (const file of files) {
|
||||
const dir =
|
||||
file.path.includes('/') ? file.path.slice(0, file.path.lastIndexOf('/')) : '.'
|
||||
const current = dirs.get(dir) ?? { path: dir, found: 0, hit: 0 }
|
||||
current.found += file.found
|
||||
current.hit += file.hit
|
||||
dirs.set(dir, current)
|
||||
}
|
||||
|
||||
return [...dirs.values()].sort((a, b) => {
|
||||
const left = coverageRatio(a.found, a.hit)
|
||||
const right = coverageRatio(b.found, b.hit)
|
||||
if (right !== left) return right - left
|
||||
return b.found - a.found
|
||||
})
|
||||
}
|
||||
|
||||
function buildTerminalReport(files: FileCoverage[]): string {
|
||||
const totalFound = files.reduce((sum, file) => sum + file.found, 0)
|
||||
const totalHit = files.reduce((sum, file) => sum + file.hit, 0)
|
||||
const totalRatio = coverageRatio(totalFound, totalHit)
|
||||
const overallChunks = new Array(CHUNK_COUNT).fill(totalRatio)
|
||||
const topDirectories = summarizeDirectories(files)
|
||||
.filter(dir => dir.found > 0)
|
||||
.slice(0, 8)
|
||||
const lowestFiles = [...files]
|
||||
.filter(file => file.found >= 20)
|
||||
.sort((a, b) => {
|
||||
const left = coverageRatio(a.found, a.hit)
|
||||
const right = coverageRatio(b.found, b.hit)
|
||||
if (left !== right) return left - right
|
||||
return b.found - a.found
|
||||
})
|
||||
.slice(0, 10)
|
||||
|
||||
const lines = [
|
||||
'',
|
||||
'Coverage Activity',
|
||||
`${terminalBar(overallChunks)} ${coverageLabel(totalRatio)} ${totalHit}/${totalFound} lines ${files.length} files`,
|
||||
'',
|
||||
'Top Directories',
|
||||
]
|
||||
|
||||
for (const dir of topDirectories) {
|
||||
const ratio = coverageRatio(dir.found, dir.hit)
|
||||
lines.push(
|
||||
`${terminalBar(new Array(12).fill(ratio))} ${coverageLabel(ratio).padStart(4)} ${String(dir.hit).padStart(5)}/${String(dir.found).padEnd(5)} ${dir.path}`,
|
||||
)
|
||||
}
|
||||
|
||||
lines.push('', 'Lowest Coverage Files')
|
||||
|
||||
for (const file of lowestFiles) {
|
||||
const ratio = coverageRatio(file.found, file.hit)
|
||||
lines.push(
|
||||
`${terminalBar(file.chunks).padEnd(CHUNK_COUNT)} ${coverageLabel(ratio).padStart(4)} ${String(file.hit).padStart(5)}/${String(file.found).padEnd(5)} ${file.path}`,
|
||||
)
|
||||
}
|
||||
|
||||
lines.push('', `HTML report: ${HTML_PATH}`)
|
||||
return lines.join('\n')
|
||||
}
|
||||
|
||||
function parseLcov(content: string): FileCoverage[] {
|
||||
const files: FileCoverage[] = []
|
||||
const sections = content.split('end_of_record')
|
||||
|
||||
for (const rawSection of sections) {
|
||||
const section = rawSection.trim()
|
||||
if (!section) continue
|
||||
|
||||
const lines = section.split('\n')
|
||||
let filePath = ''
|
||||
const lineHits = new Map<number, number>()
|
||||
|
||||
for (const line of lines) {
|
||||
if (line.startsWith('SF:')) {
|
||||
filePath = line.slice(3).trim()
|
||||
} else if (line.startsWith('DA:')) {
|
||||
const [lineNumberText, hitText] = line.slice(3).split(',')
|
||||
const lineNumber = Number(lineNumberText)
|
||||
const hits = Number(hitText)
|
||||
if (Number.isFinite(lineNumber) && Number.isFinite(hits)) {
|
||||
lineHits.set(lineNumber, hits)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
if (!filePath || lineHits.size === 0) continue
|
||||
|
||||
const ordered = [...lineHits.entries()].sort((a, b) => a[0] - b[0])
|
||||
const found = ordered.length
|
||||
const hit = ordered.filter(([, hits]) => hits > 0).length
|
||||
const chunkSize = Math.max(1, Math.ceil(found / CHUNK_COUNT))
|
||||
const chunks: number[] = []
|
||||
|
||||
for (let index = 0; index < found; index += chunkSize) {
|
||||
const slice = ordered.slice(index, index + chunkSize)
|
||||
const covered = slice.filter(([, hits]) => hits > 0).length
|
||||
chunks.push(slice.length === 0 ? 0 : covered / slice.length)
|
||||
}
|
||||
|
||||
while (chunks.length < CHUNK_COUNT) {
|
||||
chunks.push(0)
|
||||
}
|
||||
|
||||
files.push({
|
||||
path: filePath,
|
||||
found,
|
||||
hit,
|
||||
chunks: chunks.slice(0, CHUNK_COUNT),
|
||||
})
|
||||
}
|
||||
|
||||
return files.sort((a, b) => {
|
||||
const left = a.found === 0 ? 0 : a.hit / a.found
|
||||
const right = b.found === 0 ? 0 : b.hit / b.found
|
||||
if (right !== left) return right - left
|
||||
return a.path.localeCompare(b.path)
|
||||
})
|
||||
}
|
||||
|
||||
function buildHtml(files: FileCoverage[]): string {
|
||||
const totalFound = files.reduce((sum, file) => sum + file.found, 0)
|
||||
const totalHit = files.reduce((sum, file) => sum + file.hit, 0)
|
||||
const totalRatio = totalFound === 0 ? 0 : totalHit / totalFound
|
||||
|
||||
const cards = [
|
||||
['Files', String(files.length)],
|
||||
['Covered Lines', `${totalHit}/${totalFound}`],
|
||||
['Line Coverage', coverageLabel(totalRatio)],
|
||||
]
|
||||
|
||||
const rows = files
|
||||
.map(file => {
|
||||
const ratio = file.found === 0 ? 0 : file.hit / file.found
|
||||
const squares = file.chunks
|
||||
.map(
|
||||
(chunk, index) =>
|
||||
`<span class="cell" title="Chunk ${index + 1}: ${coverageLabel(chunk)}" style="background:${bucketColor(chunk)}"></span>`,
|
||||
)
|
||||
.join('')
|
||||
|
||||
return `
|
||||
<tr>
|
||||
<td class="file">${escapeHtml(file.path)}</td>
|
||||
<td class="percent">${coverageLabel(ratio)}</td>
|
||||
<td class="lines">${file.hit}/${file.found}</td>
|
||||
<td class="heatmap">${squares}</td>
|
||||
</tr>
|
||||
`
|
||||
})
|
||||
.join('')
|
||||
|
||||
const summary = cards
|
||||
.map(
|
||||
([label, value]) => `
|
||||
<div class="card">
|
||||
<div class="card-label">${escapeHtml(label)}</div>
|
||||
<div class="card-value">${escapeHtml(value)}</div>
|
||||
</div>
|
||||
`,
|
||||
)
|
||||
.join('')
|
||||
|
||||
return `<!doctype html>
|
||||
<html lang="en">
|
||||
<head>
|
||||
<meta charset="utf-8" />
|
||||
<meta name="viewport" content="width=device-width, initial-scale=1" />
|
||||
<title>OpenClaude Coverage</title>
|
||||
<style>
|
||||
:root {
|
||||
color-scheme: dark;
|
||||
--bg: #09090b;
|
||||
--panel: #111113;
|
||||
--panel-2: #18181b;
|
||||
--border: #27272a;
|
||||
--text: #fafafa;
|
||||
--muted: #a1a1aa;
|
||||
}
|
||||
* { box-sizing: border-box; }
|
||||
body {
|
||||
margin: 0;
|
||||
background: linear-gradient(180deg, #09090b 0%, #0f0f12 100%);
|
||||
color: var(--text);
|
||||
font: 14px/1.4 ui-monospace, SFMono-Regular, Menlo, monospace;
|
||||
}
|
||||
main {
|
||||
max-width: 1440px;
|
||||
margin: 0 auto;
|
||||
padding: 32px 24px 48px;
|
||||
}
|
||||
h1 {
|
||||
margin: 0 0 8px;
|
||||
font-size: 32px;
|
||||
letter-spacing: -0.04em;
|
||||
}
|
||||
p {
|
||||
margin: 0;
|
||||
color: var(--muted);
|
||||
}
|
||||
.summary {
|
||||
display: grid;
|
||||
grid-template-columns: repeat(3, minmax(0, 1fr));
|
||||
gap: 12px;
|
||||
margin: 24px 0;
|
||||
}
|
||||
.card {
|
||||
background: rgba(24, 24, 27, 0.92);
|
||||
border: 1px solid var(--border);
|
||||
border-radius: 16px;
|
||||
padding: 16px 18px;
|
||||
}
|
||||
.card-label {
|
||||
color: var(--muted);
|
||||
margin-bottom: 8px;
|
||||
}
|
||||
.card-value {
|
||||
font-size: 28px;
|
||||
font-weight: 700;
|
||||
}
|
||||
.table-wrap {
|
||||
background: rgba(17, 17, 19, 0.94);
|
||||
border: 1px solid var(--border);
|
||||
border-radius: 18px;
|
||||
overflow: hidden;
|
||||
}
|
||||
table {
|
||||
width: 100%;
|
||||
border-collapse: collapse;
|
||||
}
|
||||
thead th {
|
||||
text-align: left;
|
||||
color: var(--muted);
|
||||
font-weight: 500;
|
||||
background: rgba(24, 24, 27, 0.95);
|
||||
border-bottom: 1px solid var(--border);
|
||||
}
|
||||
th, td {
|
||||
padding: 12px 16px;
|
||||
vertical-align: middle;
|
||||
}
|
||||
tbody tr + tr td {
|
||||
border-top: 1px solid rgba(39, 39, 42, 0.65);
|
||||
}
|
||||
.file {
|
||||
width: 48%;
|
||||
word-break: break-all;
|
||||
}
|
||||
.percent, .lines {
|
||||
white-space: nowrap;
|
||||
}
|
||||
.heatmap {
|
||||
width: 32%;
|
||||
min-width: 280px;
|
||||
}
|
||||
.cell {
|
||||
display: inline-block;
|
||||
width: 12px;
|
||||
height: 12px;
|
||||
margin-right: 4px;
|
||||
border-radius: 3px;
|
||||
border: 1px solid rgba(255,255,255,0.05);
|
||||
}
|
||||
.legend {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 10px;
|
||||
margin-top: 16px;
|
||||
color: var(--muted);
|
||||
}
|
||||
.legend-scale {
|
||||
display: flex;
|
||||
gap: 4px;
|
||||
}
|
||||
@media (max-width: 900px) {
|
||||
.summary {
|
||||
grid-template-columns: 1fr;
|
||||
}
|
||||
.heatmap {
|
||||
min-width: 220px;
|
||||
}
|
||||
th, td {
|
||||
padding: 10px 12px;
|
||||
}
|
||||
}
|
||||
</style>
|
||||
</head>
|
||||
<body>
|
||||
<main>
|
||||
<h1>Coverage Activity</h1>
|
||||
<p>Git-style heatmap generated from coverage/lcov.info</p>
|
||||
<section class="summary">${summary}</section>
|
||||
<section class="table-wrap">
|
||||
<table>
|
||||
<thead>
|
||||
<tr>
|
||||
<th>File</th>
|
||||
<th>Coverage</th>
|
||||
<th>Lines</th>
|
||||
<th>Activity</th>
|
||||
</tr>
|
||||
</thead>
|
||||
<tbody>${rows}</tbody>
|
||||
</table>
|
||||
</section>
|
||||
<div class="legend">
|
||||
<span>Less</span>
|
||||
<div class="legend-scale">
|
||||
<span class="cell" style="background:#262626"></span>
|
||||
<span class="cell" style="background:#a3a3a3"></span>
|
||||
<span class="cell" style="background:#65a30d"></span>
|
||||
<span class="cell" style="background:#15803d"></span>
|
||||
<span class="cell" style="background:#166534"></span>
|
||||
</div>
|
||||
<span>More</span>
|
||||
</div>
|
||||
</main>
|
||||
</body>
|
||||
</html>`
|
||||
}
|
||||
|
||||
async function main() {
|
||||
const content = await readFile(LCOV_PATH, 'utf8')
|
||||
const files = parseLcov(content)
|
||||
const html = buildHtml(files)
|
||||
await mkdir(dirname(HTML_PATH), { recursive: true })
|
||||
await writeFile(HTML_PATH, html, 'utf8')
|
||||
console.log(buildTerminalReport(files))
|
||||
console.log(`coverage heatmap written to ${HTML_PATH}`)
|
||||
}
|
||||
|
||||
await main()
|
||||
42
scripts/system-check.test.ts
Normal file
42
scripts/system-check.test.ts
Normal file
@@ -0,0 +1,42 @@
|
||||
import { describe, expect, test } from 'bun:test'
|
||||
|
||||
import { formatReachabilityFailureDetail } from './system-check.ts'
|
||||
|
||||
describe('formatReachabilityFailureDetail', () => {
|
||||
test('returns generic failure detail for non-codex transport', () => {
|
||||
const detail = formatReachabilityFailureDetail(
|
||||
'https://api.openai.com/v1/models',
|
||||
429,
|
||||
'{"error":"rate_limit"}',
|
||||
{
|
||||
transport: 'chat_completions',
|
||||
requestedModel: 'gpt-4o',
|
||||
resolvedModel: 'gpt-4o',
|
||||
},
|
||||
)
|
||||
|
||||
expect(detail).toBe(
|
||||
'Unexpected status 429 from https://api.openai.com/v1/models. Body: {"error":"rate_limit"}',
|
||||
)
|
||||
})
|
||||
|
||||
test('adds alias/entitlement hint for codex model support 400s', () => {
|
||||
const detail = formatReachabilityFailureDetail(
|
||||
'https://chatgpt.com/backend-api/codex/responses',
|
||||
400,
|
||||
'{"detail":"The \\"gpt-5.3-codex-spark\\" model is not supported when using Codex with a ChatGPT account."}',
|
||||
{
|
||||
transport: 'codex_responses',
|
||||
requestedModel: 'codexspark',
|
||||
resolvedModel: 'gpt-5.3-codex-spark',
|
||||
},
|
||||
)
|
||||
|
||||
expect(detail).toContain(
|
||||
'model alias "codexspark" resolved to "gpt-5.3-codex-spark"',
|
||||
)
|
||||
expect(detail).toContain(
|
||||
'Try "codexplan" or another entitled Codex model.',
|
||||
)
|
||||
})
|
||||
})
|
||||
@@ -58,6 +58,31 @@ function parseOptions(argv: string[]): CliOptions {
|
||||
return options
|
||||
}
|
||||
|
||||
export function formatReachabilityFailureDetail(
|
||||
endpoint: string,
|
||||
status: number,
|
||||
responseBody: string,
|
||||
request: {
|
||||
transport: string
|
||||
requestedModel: string
|
||||
resolvedModel: string
|
||||
},
|
||||
): string {
|
||||
const compactBody = responseBody.trim().replace(/\s+/g, ' ').slice(0, 240)
|
||||
const base = `Unexpected status ${status} from ${endpoint}.`
|
||||
const bodySuffix = compactBody ? ` Body: ${compactBody}` : ''
|
||||
|
||||
if (request.transport !== 'codex_responses' || status !== 400) {
|
||||
return `${base}${bodySuffix}`
|
||||
}
|
||||
|
||||
if (!/not supported.*chatgpt account/i.test(responseBody)) {
|
||||
return `${base}${bodySuffix}`
|
||||
}
|
||||
|
||||
return `${base}${bodySuffix} Hint: model alias "${request.requestedModel}" resolved to "${request.resolvedModel}", which this ChatGPT account does not currently allow. Try "codexplan" or another entitled Codex model.`
|
||||
}
|
||||
|
||||
function checkNodeVersion(): CheckResult {
|
||||
const raw = process.versions.node
|
||||
const major = Number(raw.split('.')[0] ?? '0')
|
||||
@@ -284,6 +309,7 @@ async function checkBaseUrlReachability(): Promise<CheckResult> {
|
||||
headers['chatgpt-account-id'] = credentials.accountId
|
||||
}
|
||||
headers['Content-Type'] = 'application/json'
|
||||
headers.originator = 'openclaude'
|
||||
method = 'POST'
|
||||
body = JSON.stringify({
|
||||
model: request.resolvedModel,
|
||||
@@ -315,7 +341,17 @@ async function checkBaseUrlReachability(): Promise<CheckResult> {
|
||||
return pass('Provider reachability', `Reached ${endpoint} (status ${response.status}).`)
|
||||
}
|
||||
|
||||
return fail('Provider reachability', `Unexpected status ${response.status} from ${endpoint}.`)
|
||||
const responseBody = await response.text().catch(() => '')
|
||||
const detail = formatReachabilityFailureDetail(
|
||||
endpoint,
|
||||
response.status,
|
||||
responseBody,
|
||||
request,
|
||||
)
|
||||
return fail(
|
||||
'Provider reachability',
|
||||
detail,
|
||||
)
|
||||
} catch (error) {
|
||||
const message = error instanceof Error ? error.message : String(error)
|
||||
return fail('Provider reachability', `Failed to reach ${endpoint}: ${message}`)
|
||||
@@ -504,6 +540,8 @@ async function main(): Promise<void> {
|
||||
}
|
||||
}
|
||||
|
||||
await main()
|
||||
if (import.meta.main) {
|
||||
await main()
|
||||
}
|
||||
|
||||
export {}
|
||||
|
||||
50
scripts/verify-no-phone-home.sh
Normal file
50
scripts/verify-no-phone-home.sh
Normal file
@@ -0,0 +1,50 @@
|
||||
#!/bin/bash
|
||||
|
||||
set -euo pipefail
|
||||
|
||||
DIST="dist/cli.mjs"
|
||||
|
||||
if [ ! -f "$DIST" ]; then
|
||||
echo "ERROR: $DIST not found. Run 'bun run build' first."
|
||||
exit 1
|
||||
fi
|
||||
|
||||
EXIT=0
|
||||
|
||||
BANNED=(
|
||||
"datadoghq.com"
|
||||
"api/event_logging/batch"
|
||||
"api/claude_code/metrics"
|
||||
"getKubernetesNamespace"
|
||||
"/var/run/secrets/kubernetes"
|
||||
"/proc/self/mountinfo"
|
||||
"tengu_internal_record_permission_context"
|
||||
"anthropic-serve"
|
||||
"infra.ant.dev"
|
||||
"claude-code-feedback"
|
||||
"C07VBSHV7EV"
|
||||
)
|
||||
|
||||
echo "Checking $DIST for banned patterns..."
|
||||
echo ""
|
||||
|
||||
for pattern in "${BANNED[@]}"; do
|
||||
COUNT=$(grep -F -c "$pattern" "$DIST" 2>/dev/null || true)
|
||||
COUNT=${COUNT:-0}
|
||||
if [ "$COUNT" -gt 0 ]; then
|
||||
echo " FAIL: '$pattern' found ($COUNT occurrences)"
|
||||
EXIT=1
|
||||
else
|
||||
echo " PASS: '$pattern' not found"
|
||||
fi
|
||||
done
|
||||
|
||||
echo ""
|
||||
|
||||
if [ "$EXIT" -eq 0 ]; then
|
||||
echo "✓ All checks passed — no banned patterns in build output"
|
||||
else
|
||||
echo "✗ FAILED — banned patterns found in build output"
|
||||
fi
|
||||
|
||||
exit $EXIT
|
||||
47
scripts/verify-no-phone-home.ts
Normal file
47
scripts/verify-no-phone-home.ts
Normal file
@@ -0,0 +1,47 @@
|
||||
import { existsSync, readFileSync } from 'node:fs'
|
||||
|
||||
const DIST = 'dist/cli.mjs'
|
||||
const BANNED_PATTERNS = [
|
||||
'datadoghq.com',
|
||||
'api/event_logging/batch',
|
||||
'api/claude_code/metrics',
|
||||
'getKubernetesNamespace',
|
||||
'/var/run/secrets/kubernetes',
|
||||
'/proc/self/mountinfo',
|
||||
'tengu_internal_record_permission_context',
|
||||
'anthropic-serve',
|
||||
'infra.ant.dev',
|
||||
'claude-code-feedback',
|
||||
'C07VBSHV7EV',
|
||||
] as const
|
||||
|
||||
if (!existsSync(DIST)) {
|
||||
console.error(`ERROR: ${DIST} not found. Run 'bun run build' first.`)
|
||||
process.exit(1)
|
||||
}
|
||||
|
||||
const contents = readFileSync(DIST, 'utf8')
|
||||
let exitCode = 0
|
||||
|
||||
console.log(`Checking ${DIST} for banned patterns...`)
|
||||
console.log('')
|
||||
|
||||
for (const pattern of BANNED_PATTERNS) {
|
||||
const count = contents.split(pattern).length - 1
|
||||
if (count > 0) {
|
||||
console.log(` FAIL: '${pattern}' found (${count} occurrences)`)
|
||||
exitCode = 1
|
||||
} else {
|
||||
console.log(` PASS: '${pattern}' not found`)
|
||||
}
|
||||
}
|
||||
|
||||
console.log('')
|
||||
|
||||
if (exitCode === 0) {
|
||||
console.log('✓ All checks passed — no banned patterns in build output')
|
||||
} else {
|
||||
console.log('✗ FAILED — banned patterns found in build output')
|
||||
}
|
||||
|
||||
process.exit(exitCode)
|
||||
@@ -46,6 +46,7 @@ import type { AttributionState } from './utils/commitAttribution.js'
|
||||
import { getGlobalConfig } from './utils/config.js'
|
||||
import { getCwd } from './utils/cwd.js'
|
||||
import { isBareMode, isEnvTruthy } from './utils/envUtils.js'
|
||||
import { logForDebugging } from './utils/debug.js'
|
||||
import { getFastModeState } from './utils/fastMode.js'
|
||||
import {
|
||||
type FileHistoryState,
|
||||
@@ -695,9 +696,11 @@ export class QueryEngine {
|
||||
// progress are now recorded inline (their switch cases below), but
|
||||
// this flush still matters for the preservedSegment tail walk.
|
||||
// If the SDK subprocess restarts before then (claude-desktop kills
|
||||
// between turns), tailUuid points to a never-written message →
|
||||
// applyPreservedSegmentRelinks fails its tail→head walk → returns
|
||||
// without pruning → resume loads full pre-compact history.
|
||||
// between turns), tailUuid can point to a never-written message. In
|
||||
// that case strip preservedSegment before transcript persistence so
|
||||
// resume falls back to ordinary boundary pruning instead of relying on
|
||||
// broken relink metadata.
|
||||
let transcriptMessage = message
|
||||
if (
|
||||
persistSession &&
|
||||
message.type === 'system' &&
|
||||
@@ -710,10 +713,21 @@ export class QueryEngine {
|
||||
)
|
||||
if (tailIdx !== -1) {
|
||||
await recordTranscript(this.mutableMessages.slice(0, tailIdx + 1))
|
||||
} else {
|
||||
transcriptMessage = {
|
||||
...message,
|
||||
compactMetadata: {
|
||||
...message.compactMetadata,
|
||||
preservedSegment: undefined,
|
||||
},
|
||||
}
|
||||
logForDebugging(
|
||||
`[QueryEngine] stripped preservedSegment before transcript write; missing tail ${tailUuid}`,
|
||||
)
|
||||
}
|
||||
}
|
||||
}
|
||||
messages.push(message)
|
||||
messages.push(transcriptMessage)
|
||||
if (persistSession) {
|
||||
// Fire-and-forget for assistant messages. claude.ts yields one
|
||||
// assistant message per content block, then mutates the last
|
||||
|
||||
@@ -176,6 +176,8 @@ export type ToolUseContext = {
|
||||
querySource?: QuerySource
|
||||
/** Optional callback to get the latest tools (e.g., after MCP servers connect mid-query) */
|
||||
refreshTools?: () => Tools
|
||||
/** Per-agent provider override from agentRouting config */
|
||||
providerOverride?: { model: string; baseURL: string; apiKey: string }
|
||||
}
|
||||
abortController: AbortController
|
||||
readFileState: FileStateCache
|
||||
|
||||
@@ -112,7 +112,7 @@ type State = {
|
||||
agentColorIndex: number
|
||||
// Last API request for bug reports
|
||||
lastAPIRequest: Omit<BetaMessageStreamParams, 'messages'> | null
|
||||
// Messages from the last API request (ant-only; reference, not clone).
|
||||
// Messages from the last API request (internal-only; reference, not clone).
|
||||
// Captures the exact post-compaction, CLAUDE.md-injected message set sent
|
||||
// to the API so /share's serialized_conversation.json reflects reality.
|
||||
lastAPIRequestMessages: BetaMessageStreamParams['messages'] | null
|
||||
@@ -185,7 +185,7 @@ type State = {
|
||||
agentId: string | null
|
||||
}
|
||||
>
|
||||
// Track slow operations for dev bar display (ant-only)
|
||||
// Track slow operations for dev bar display (internal-only)
|
||||
slowOperations: Array<{
|
||||
operation: string
|
||||
durationMs: number
|
||||
@@ -1756,3 +1756,12 @@ export function setPromptId(id: string | null): void {
|
||||
STATE.promptId = id
|
||||
}
|
||||
|
||||
// Stub for feature-gated REPL bridge (not available in open build)
|
||||
export function isReplBridgeActive(): boolean {
|
||||
return false
|
||||
}
|
||||
|
||||
export function getReplBridgeHandle(): null {
|
||||
return null
|
||||
}
|
||||
|
||||
|
||||
@@ -1,11 +1,11 @@
|
||||
/**
|
||||
* Shared bridge auth/URL resolution. Consolidates the ant-only
|
||||
* Shared bridge auth/URL resolution. Consolidates the internal-only
|
||||
* CLAUDE_BRIDGE_* dev overrides that were previously copy-pasted across
|
||||
* a dozen files — inboundAttachments, BriefTool/upload, bridgeMain,
|
||||
* initReplBridge, remoteBridgeCore, daemon workers, /rename,
|
||||
* /remote-control.
|
||||
*
|
||||
* Two layers: *Override() returns the ant-only env var (or undefined);
|
||||
* Two layers: *Override() returns the internal-only env var (or undefined);
|
||||
* the non-Override versions fall through to the real OAuth store/config.
|
||||
* Callers that compose with a different auth source (e.g. daemon workers
|
||||
* using IPC auth) use the Override getters directly.
|
||||
|
||||
@@ -174,7 +174,7 @@ export function checkBridgeMinVersion(): string | null {
|
||||
|
||||
/**
|
||||
* Default for remoteControlAtStartup when the user hasn't explicitly set it.
|
||||
* When the CCR_AUTO_CONNECT build flag is present (ant-only) and the
|
||||
* When the CCR_AUTO_CONNECT build flag is present (internal-only) and the
|
||||
* tengu_cobalt_harbor GrowthBook gate is on, all sessions connect to CCR by
|
||||
* default — the user can still opt out by setting remoteControlAtStartup=false
|
||||
* in config (explicit settings always win over this default).
|
||||
|
||||
@@ -1520,7 +1520,7 @@ export async function runBridgeLoop(
|
||||
// Skip when the loop exited fatally (env expired, auth failed, give-up) —
|
||||
// resume is impossible in those cases and the message would contradict the
|
||||
// error already printed.
|
||||
// feature('KAIROS') gate: --session-id is ant-only; without the gate,
|
||||
// feature('KAIROS') gate: --session-id is internal-only; without the gate,
|
||||
// revert to the pre-PR behavior (archive + deregister on every shutdown).
|
||||
if (
|
||||
feature('KAIROS') &&
|
||||
@@ -1888,7 +1888,7 @@ export function parseArgs(args: string[]): ParsedArgs {
|
||||
|
||||
async function printHelp(): Promise<void> {
|
||||
// Use EXTERNAL_PERMISSION_MODES for help text — internal modes (bubble)
|
||||
// are ant-only and auto is feature-gated; they're still accepted by validation.
|
||||
// are internal-only and auto is feature-gated; they're still accepted by validation.
|
||||
const { EXTERNAL_PERMISSION_MODES } = await import('../types/permissions.js')
|
||||
const modes = EXTERNAL_PERMISSION_MODES.join(', ')
|
||||
const showServer = await isMultiSessionSpawnEnabled()
|
||||
@@ -2356,7 +2356,7 @@ export async function bridgeMain(args: string[]): Promise<void> {
|
||||
// environment_id and reuse that for registration (idempotent on the
|
||||
// backend). Left undefined otherwise — the backend rejects
|
||||
// client-generated UUIDs and will allocate a fresh environment.
|
||||
// feature('KAIROS') gate: --session-id is ant-only; parseArgs already
|
||||
// feature('KAIROS') gate: --session-id is internal-only; parseArgs already
|
||||
// rejects the flag when the gate is off, so resumeSessionId is always
|
||||
// undefined here in external builds — this guard is for tree-shaking.
|
||||
let reuseEnvironmentId: string | undefined
|
||||
|
||||
@@ -223,7 +223,7 @@ export function createBridgeLogger(options: {
|
||||
|
||||
if (process.env.USER_TYPE === 'ant' && debugLogPath) {
|
||||
writeStatus(
|
||||
`${chalk.yellow('[ANT-ONLY] Logs:')} ${chalk.dim(debugLogPath)}\n`,
|
||||
`${chalk.yellow('[internal] Logs:')} ${chalk.dim(debugLogPath)}\n`,
|
||||
)
|
||||
}
|
||||
writeStatus(`${indicatorColor(indicator)} ${stateText}${suffix}\n`)
|
||||
|
||||
@@ -161,7 +161,7 @@ export async function initReplBridge(
|
||||
return null
|
||||
}
|
||||
|
||||
// When CLAUDE_BRIDGE_OAUTH_TOKEN is set (ant-only local dev), the bridge
|
||||
// When CLAUDE_BRIDGE_OAUTH_TOKEN is set (internal-only local dev), the bridge
|
||||
// uses that token directly via getBridgeAccessToken() — keychain state is
|
||||
// irrelevant. Skip 2b/2c to preserve that decoupling: an expired keychain
|
||||
// token shouldn't block a bridge connection that doesn't use it.
|
||||
|
||||
@@ -1,4 +1,4 @@
|
||||
// biome-ignore-all assist/source/organizeImports: ANT-ONLY import markers must not be reordered
|
||||
// biome-ignore-all assist/source/organizeImports: internal-only import markers must not be reordered
|
||||
/**
|
||||
* Env-less Remote Control bridge core.
|
||||
*
|
||||
|
||||
@@ -1,4 +1,4 @@
|
||||
// biome-ignore-all assist/source/organizeImports: ANT-ONLY import markers must not be reordered
|
||||
// biome-ignore-all assist/source/organizeImports: internal-only import markers must not be reordered
|
||||
import { randomUUID } from 'crypto'
|
||||
import {
|
||||
createBridgeApiClient,
|
||||
|
||||
@@ -17,7 +17,7 @@ import { jsonStringify } from '../utils/slowOperations.js'
|
||||
*
|
||||
* Bridge sessions have SecurityTier=ELEVATED on the server (CCR v2).
|
||||
* The server gates ConnectBridgeWorker on its own flag
|
||||
* (sessions_elevated_auth_enforcement in Anthropic Main); this CLI-side
|
||||
* (sessions_elevated_auth_enforcement in the server-side main deployment); this CLI-side
|
||||
* flag controls whether the CLI sends X-Trusted-Device-Token at all.
|
||||
* Two flags so rollout can be staged: flip CLI-side first (headers
|
||||
* start flowing, server still no-ops), then flip server-side.
|
||||
|
||||
File diff suppressed because one or more lines are too long
3
src/buddy/feature.ts
Normal file
3
src/buddy/feature.ts
Normal file
@@ -0,0 +1,3 @@
|
||||
export function isBuddyEnabled(): boolean {
|
||||
return true
|
||||
}
|
||||
65
src/buddy/observer.ts
Normal file
65
src/buddy/observer.ts
Normal file
@@ -0,0 +1,65 @@
|
||||
import type { Message } from '../types/message.js'
|
||||
import { getGlobalConfig } from '../utils/config.js'
|
||||
import { getUserMessageText } from '../utils/messages.js'
|
||||
import { getCompanion } from './companion.js'
|
||||
|
||||
const DIRECT_REPLIES = [
|
||||
'I am observing.',
|
||||
'I am helping from the corner.',
|
||||
'I saw that.',
|
||||
'Still here.',
|
||||
'Watching closely.',
|
||||
] as const
|
||||
|
||||
const PET_REPLIES = [
|
||||
'happy chirp',
|
||||
'tiny victory dance',
|
||||
'quietly approves',
|
||||
'wiggles with joy',
|
||||
'looks pleased',
|
||||
] as const
|
||||
|
||||
function hashString(s: string): number {
|
||||
let h = 2166136261
|
||||
for (let i = 0; i < s.length; i++) {
|
||||
h ^= s.charCodeAt(i)
|
||||
h = Math.imul(h, 16777619)
|
||||
}
|
||||
return h >>> 0
|
||||
}
|
||||
|
||||
function pickDeterministic<T>(items: readonly T[], seed: string): T {
|
||||
return items[hashString(seed) % items.length]!
|
||||
}
|
||||
|
||||
export async function fireCompanionObserver(
|
||||
messages: Message[],
|
||||
onReaction: (reaction: string | undefined) => void,
|
||||
): Promise<void> {
|
||||
const companion = getCompanion()
|
||||
if (!companion || getGlobalConfig().companionMuted) return
|
||||
|
||||
const lastUser = [...messages].reverse().find(msg => msg.type === 'user')
|
||||
if (!lastUser) return
|
||||
|
||||
const text = getUserMessageText(lastUser)?.trim()
|
||||
if (!text) return
|
||||
|
||||
const lower = text.toLowerCase()
|
||||
const companionName = companion.name.toLowerCase()
|
||||
|
||||
if (lower.includes('/buddy')) {
|
||||
onReaction(pickDeterministic(PET_REPLIES, text + companion.name))
|
||||
return
|
||||
}
|
||||
|
||||
if (
|
||||
lower.includes(companionName) ||
|
||||
lower.includes('buddy') ||
|
||||
lower.includes('companion')
|
||||
) {
|
||||
onReaction(
|
||||
`${companion.name}: ${pickDeterministic(DIRECT_REPLIES, text + companion.personality)}`,
|
||||
)
|
||||
}
|
||||
}
|
||||
@@ -1,8 +1,8 @@
|
||||
import { feature } from 'bun:bundle'
|
||||
import type { Message } from '../types/message.js'
|
||||
import type { Attachment } from '../utils/attachments.js'
|
||||
import { getGlobalConfig } from '../utils/config.js'
|
||||
import { getCompanion } from './companion.js'
|
||||
import { isBuddyEnabled } from './feature.js'
|
||||
|
||||
export function companionIntroText(name: string, species: string): string {
|
||||
return `# Companion
|
||||
@@ -15,7 +15,7 @@ When the user addresses ${name} directly (by name), its bubble will answer. Your
|
||||
export function getCompanionIntroAttachment(
|
||||
messages: Message[] | undefined,
|
||||
): Attachment[] {
|
||||
if (!feature('BUDDY')) return []
|
||||
if (!isBuddyEnabled()) return []
|
||||
const companion = getCompanion()
|
||||
if (!companion || getGlobalConfig().companionMuted) return []
|
||||
|
||||
|
||||
File diff suppressed because one or more lines are too long
@@ -116,7 +116,6 @@ export async function autoModeCritiqueHandler(options: {
|
||||
querySource: 'auto_mode_critique',
|
||||
model,
|
||||
system: CRITIQUE_SYSTEM_PROMPT,
|
||||
skipSystemPromptPrefix: true,
|
||||
max_tokens: 4096,
|
||||
messages: [
|
||||
{
|
||||
|
||||
File diff suppressed because one or more lines are too long
File diff suppressed because one or more lines are too long
@@ -1,4 +1,4 @@
|
||||
// biome-ignore-all assist/source/organizeImports: ANT-ONLY import markers must not be reordered
|
||||
// biome-ignore-all assist/source/organizeImports: internal-only import markers must not be reordered
|
||||
import { feature } from 'bun:bundle'
|
||||
import { readFile, stat } from 'fs/promises'
|
||||
import { dirname } from 'path'
|
||||
@@ -2829,7 +2829,7 @@ function runHeadlessStreaming(
|
||||
|
||||
if (message.type === 'control_request') {
|
||||
if (message.request.subtype === 'interrupt') {
|
||||
// Track escapes for attribution (ant-only feature)
|
||||
// Track escapes for attribution (internal-only feature)
|
||||
if (feature('COMMIT_ATTRIBUTION')) {
|
||||
setAppState(prev => ({
|
||||
...prev,
|
||||
@@ -3765,7 +3765,7 @@ function runHeadlessStreaming(
|
||||
...getSettingsWithSources(),
|
||||
applied: {
|
||||
model,
|
||||
// Numeric effort (ant-only) → null; SDK schema is string-level only.
|
||||
// Numeric effort (internal-only) → null; SDK schema is string-level only.
|
||||
effort: typeof effort === 'string' ? effort : null,
|
||||
},
|
||||
})
|
||||
@@ -5025,7 +5025,7 @@ async function loadInitialMessages(
|
||||
}
|
||||
|
||||
// Handle resume in print mode (accepts session ID or URL)
|
||||
// URLs are [ANT-ONLY]
|
||||
// URLs are [internal-only]
|
||||
if (options.resume) {
|
||||
try {
|
||||
logEvent('tengu_resume_print', {})
|
||||
|
||||
@@ -30,7 +30,7 @@ import { getInitialSettings } from 'src/utils/settings/settings.js'
|
||||
|
||||
export async function update() {
|
||||
// Block updates for third-party providers. The update mechanism downloads
|
||||
// from Anthropic's distribution bucket, which would silently replace the
|
||||
// from the first-party distribution bucket, which would silently replace the
|
||||
// OpenClaude build (with the OpenAI shim) with the upstream Claude Code
|
||||
// binary (without it).
|
||||
if (getAPIProvider() !== 'firstParty') {
|
||||
|
||||
@@ -1,4 +1,4 @@
|
||||
// biome-ignore-all assist/source/organizeImports: ANT-ONLY import markers must not be reordered
|
||||
// biome-ignore-all assist/source/organizeImports: internal-only import markers must not be reordered
|
||||
import addDir from './commands/add-dir/index.js'
|
||||
import autofixPr from './commands/autofix-pr/index.js'
|
||||
import backfillSessions from './commands/backfill-sessions/index.js'
|
||||
@@ -17,6 +17,7 @@ import config from './commands/config/index.js'
|
||||
import { context, contextNonInteractive } from './commands/context/index.js'
|
||||
import cost from './commands/cost/index.js'
|
||||
import diff from './commands/diff/index.js'
|
||||
import dream from './commands/dream/index.js'
|
||||
import ctx_viz from './commands/ctx_viz/index.js'
|
||||
import doctor from './commands/doctor/index.js'
|
||||
import onboardGithub from './commands/onboard-github/index.js'
|
||||
@@ -58,6 +59,7 @@ import usage from './commands/usage/index.js'
|
||||
import theme from './commands/theme/index.js'
|
||||
import vim from './commands/vim/index.js'
|
||||
import { feature } from 'bun:bundle'
|
||||
import { isBuddyEnabled } from './buddy/feature.js'
|
||||
// Dead code elimination: conditional imports
|
||||
/* eslint-disable @typescript-eslint/no-require-imports */
|
||||
const proactive =
|
||||
@@ -116,7 +118,7 @@ const forkCmd = feature('FORK_SUBAGENT')
|
||||
require('./commands/fork/index.js') as typeof import('./commands/fork/index.js')
|
||||
).default
|
||||
: null
|
||||
const buddy = feature('BUDDY')
|
||||
const buddy = isBuddyEnabled()
|
||||
? (
|
||||
require('./commands/buddy/index.js') as typeof import('./commands/buddy/index.js')
|
||||
).default
|
||||
@@ -274,6 +276,7 @@ const COMMANDS = memoize((): Command[] => [
|
||||
contextNonInteractive,
|
||||
cost,
|
||||
diff,
|
||||
dream,
|
||||
doctor,
|
||||
effort,
|
||||
exit,
|
||||
|
||||
File diff suppressed because one or more lines are too long
@@ -9,4 +9,3 @@ export async function call(onDone: LocalJSXCommandOnDone, context: ToolUseContex
|
||||
const tools = getTools(permissionContext);
|
||||
return <AgentsMenu tools={tools} onExit={onDone} />;
|
||||
}
|
||||
//# sourceMappingURL=data:application/json;charset=utf-8;base64,eyJ2ZXJzaW9uIjozLCJuYW1lcyI6WyJSZWFjdCIsIkFnZW50c01lbnUiLCJUb29sVXNlQ29udGV4dCIsImdldFRvb2xzIiwiTG9jYWxKU1hDb21tYW5kT25Eb25lIiwiY2FsbCIsIm9uRG9uZSIsImNvbnRleHQiLCJQcm9taXNlIiwiUmVhY3ROb2RlIiwiYXBwU3RhdGUiLCJnZXRBcHBTdGF0ZSIsInBlcm1pc3Npb25Db250ZXh0IiwidG9vbFBlcm1pc3Npb25Db250ZXh0IiwidG9vbHMiXSwic291cmNlcyI6WyJhZ2VudHMudHN4Il0sInNvdXJjZXNDb250ZW50IjpbImltcG9ydCAqIGFzIFJlYWN0IGZyb20gJ3JlYWN0J1xuaW1wb3J0IHsgQWdlbnRzTWVudSB9IGZyb20gJy4uLy4uL2NvbXBvbmVudHMvYWdlbnRzL0FnZW50c01lbnUuanMnXG5pbXBvcnQgdHlwZSB7IFRvb2xVc2VDb250ZXh0IH0gZnJvbSAnLi4vLi4vVG9vbC5qcydcbmltcG9ydCB7IGdldFRvb2xzIH0gZnJvbSAnLi4vLi4vdG9vbHMuanMnXG5pbXBvcnQgdHlwZSB7IExvY2FsSlNYQ29tbWFuZE9uRG9uZSB9IGZyb20gJy4uLy4uL3R5cGVzL2NvbW1hbmQuanMnXG5cbmV4cG9ydCBhc3luYyBmdW5jdGlvbiBjYWxsKFxuICBvbkRvbmU6IExvY2FsSlNYQ29tbWFuZE9uRG9uZSxcbiAgY29udGV4dDogVG9vbFVzZUNvbnRleHQsXG4pOiBQcm9taXNlPFJlYWN0LlJlYWN0Tm9kZT4ge1xuICBjb25zdCBhcHBTdGF0ZSA9IGNvbnRleHQuZ2V0QXBwU3RhdGUoKVxuICBjb25zdCBwZXJtaXNzaW9uQ29udGV4dCA9IGFwcFN0YXRlLnRvb2xQZXJtaXNzaW9uQ29udGV4dFxuICBjb25zdCB0b29scyA9IGdldFRvb2xzKHBlcm1pc3Npb25Db250ZXh0KVxuXG4gIHJldHVybiA8QWdlbnRzTWVudSB0b29scz17dG9vbHN9IG9uRXhpdD17b25Eb25lfSAvPlxufVxuIl0sIm1hcHBpbmdzIjoiQUFBQSxPQUFPLEtBQUtBLEtBQUssTUFBTSxPQUFPO0FBQzlCLFNBQVNDLFVBQVUsUUFBUSx1Q0FBdUM7QUFDbEUsY0FBY0MsY0FBYyxRQUFRLGVBQWU7QUFDbkQsU0FBU0MsUUFBUSxRQUFRLGdCQUFnQjtBQUN6QyxjQUFjQyxxQkFBcUIsUUFBUSx3QkFBd0I7QUFFbkUsT0FBTyxlQUFlQyxJQUFJQSxDQUN4QkMsTUFBTSxFQUFFRixxQkFBcUIsRUFDN0JHLE9BQU8sRUFBRUwsY0FBYyxDQUN4QixFQUFFTSxPQUFPLENBQUNSLEtBQUssQ0FBQ1MsU0FBUyxDQUFDLENBQUM7RUFDMUIsTUFBTUMsUUFBUSxHQUFHSCxPQUFPLENBQUNJLFdBQVcsQ0FBQyxDQUFDO0VBQ3RDLE1BQU1DLGlCQUFpQixHQUFHRixRQUFRLENBQUNHLHFCQUFxQjtFQUN4RCxNQUFNQyxLQUFLLEdBQUdYLFFBQVEsQ0FBQ1MsaUJBQWlCLENBQUM7RUFFekMsT0FBTyxDQUFDLFVBQVUsQ0FBQyxLQUFLLENBQUMsQ0FBQ0UsS0FBSyxDQUFDLENBQUMsTUFBTSxDQUFDLENBQUNSLE1BQU0sQ0FBQyxHQUFHO0FBQ3JEIiwiaWdub3JlTGlzdCI6W119
|
||||
File diff suppressed because one or more lines are too long
File diff suppressed because one or more lines are too long
185
src/commands/buddy/buddy.tsx
Normal file
185
src/commands/buddy/buddy.tsx
Normal file
@@ -0,0 +1,185 @@
|
||||
import type { LocalJSXCommandContext, LocalJSXCommandOnDone } from '../../types/command.js'
|
||||
import { getGlobalConfig, saveGlobalConfig } from '../../utils/config.js'
|
||||
import { companionUserId, getCompanion, rollWithSeed } from '../../buddy/companion.js'
|
||||
import type { StoredCompanion } from '../../buddy/types.js'
|
||||
import { COMMON_HELP_ARGS, COMMON_INFO_ARGS } from '../../constants/xml.js'
|
||||
|
||||
const NAME_PREFIXES = [
|
||||
'Byte',
|
||||
'Echo',
|
||||
'Glint',
|
||||
'Miso',
|
||||
'Nova',
|
||||
'Pixel',
|
||||
'Rune',
|
||||
'Static',
|
||||
'Vector',
|
||||
'Whisk',
|
||||
] as const
|
||||
|
||||
const NAME_SUFFIXES = [
|
||||
'bean',
|
||||
'bit',
|
||||
'bud',
|
||||
'dot',
|
||||
'ling',
|
||||
'loop',
|
||||
'moss',
|
||||
'patch',
|
||||
'puff',
|
||||
'spark',
|
||||
] as const
|
||||
|
||||
const PERSONALITIES = [
|
||||
'Curious and quietly encouraging',
|
||||
'A patient little watcher with strong debugging instincts',
|
||||
'Playful, observant, and suspicious of flaky tests',
|
||||
'Calm under pressure and fond of clean diffs',
|
||||
'A tiny terminal gremlin who likes successful builds',
|
||||
] as const
|
||||
|
||||
const PET_REACTIONS = [
|
||||
'leans into the headpat',
|
||||
'does a proud little bounce',
|
||||
'emits a content beep',
|
||||
'looks delighted',
|
||||
'wiggles happily',
|
||||
] as const
|
||||
|
||||
function hashString(s: string): number {
|
||||
let h = 2166136261
|
||||
for (let i = 0; i < s.length; i++) {
|
||||
h ^= s.charCodeAt(i)
|
||||
h = Math.imul(h, 16777619)
|
||||
}
|
||||
return h >>> 0
|
||||
}
|
||||
|
||||
function pickDeterministic<T>(items: readonly T[], seed: string): T {
|
||||
return items[hashString(seed) % items.length]!
|
||||
}
|
||||
|
||||
function titleCase(s: string): string {
|
||||
return s.charAt(0).toUpperCase() + s.slice(1)
|
||||
}
|
||||
|
||||
function createStoredCompanion(): StoredCompanion {
|
||||
const userId = companionUserId()
|
||||
const { bones } = rollWithSeed(`${userId}:buddy`)
|
||||
const prefix = pickDeterministic(NAME_PREFIXES, `${userId}:prefix`)
|
||||
const suffix = pickDeterministic(NAME_SUFFIXES, `${userId}:suffix`)
|
||||
const personality = pickDeterministic(PERSONALITIES, `${userId}:personality`)
|
||||
|
||||
return {
|
||||
name: `${prefix}${suffix}`,
|
||||
personality: `${personality}.`,
|
||||
hatchedAt: Date.now(),
|
||||
}
|
||||
}
|
||||
|
||||
function setCompanionReaction(
|
||||
context: LocalJSXCommandContext,
|
||||
reaction: string | undefined,
|
||||
pet = false,
|
||||
): void {
|
||||
context.setAppState(prev => ({
|
||||
...prev,
|
||||
companionReaction: reaction,
|
||||
companionPetAt: pet ? Date.now() : prev.companionPetAt,
|
||||
}))
|
||||
}
|
||||
|
||||
function showHelp(onDone: LocalJSXCommandOnDone): void {
|
||||
onDone(
|
||||
'Usage: /buddy [status|mute|unmute]\n\nRun /buddy with no args to hatch your companion the first time, then pet it on later runs.',
|
||||
{ display: 'system' },
|
||||
)
|
||||
}
|
||||
|
||||
export async function call(
|
||||
onDone: LocalJSXCommandOnDone,
|
||||
context: LocalJSXCommandContext,
|
||||
args?: string,
|
||||
): Promise<null> {
|
||||
const arg = args?.trim().toLowerCase() ?? ''
|
||||
|
||||
if (COMMON_HELP_ARGS.includes(arg) || arg === '') {
|
||||
const existing = getCompanion()
|
||||
if (arg !== '' || existing) {
|
||||
if (arg !== '') {
|
||||
showHelp(onDone)
|
||||
return null
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
if (COMMON_HELP_ARGS.includes(arg)) {
|
||||
showHelp(onDone)
|
||||
return null
|
||||
}
|
||||
|
||||
if (COMMON_INFO_ARGS.includes(arg) || arg === 'status') {
|
||||
const companion = getCompanion()
|
||||
if (!companion) {
|
||||
onDone('No buddy hatched yet. Run /buddy to hatch one.', {
|
||||
display: 'system',
|
||||
})
|
||||
return null
|
||||
}
|
||||
onDone(
|
||||
`${companion.name} is your ${titleCase(companion.rarity)} ${companion.species}. ${companion.personality}`,
|
||||
{ display: 'system' },
|
||||
)
|
||||
return null
|
||||
}
|
||||
|
||||
if (arg === 'mute' || arg === 'unmute') {
|
||||
const muted = arg === 'mute'
|
||||
saveGlobalConfig(current => ({
|
||||
...current,
|
||||
companionMuted: muted,
|
||||
}))
|
||||
if (muted) {
|
||||
setCompanionReaction(context, undefined)
|
||||
}
|
||||
onDone(`Buddy ${muted ? 'muted' : 'unmuted'}.`, { display: 'system' })
|
||||
return null
|
||||
}
|
||||
|
||||
if (arg !== '') {
|
||||
showHelp(onDone)
|
||||
return null
|
||||
}
|
||||
|
||||
let companion = getCompanion()
|
||||
if (!companion) {
|
||||
const stored = createStoredCompanion()
|
||||
saveGlobalConfig(current => ({
|
||||
...current,
|
||||
companion: stored,
|
||||
companionMuted: false,
|
||||
}))
|
||||
companion = {
|
||||
...rollWithSeed(`${companionUserId()}:buddy`).bones,
|
||||
...stored,
|
||||
}
|
||||
setCompanionReaction(
|
||||
context,
|
||||
`${companion.name} the ${companion.species} has hatched.`,
|
||||
true,
|
||||
)
|
||||
onDone(
|
||||
`${companion.name} the ${companion.species} is now your buddy. Run /buddy again to pet them.`,
|
||||
{ display: 'system' },
|
||||
)
|
||||
return null
|
||||
}
|
||||
|
||||
const reaction = `${companion.name} ${pickDeterministic(
|
||||
PET_REACTIONS,
|
||||
`${Date.now()}:${companion.name}`,
|
||||
)}`
|
||||
setCompanionReaction(context, reaction, true)
|
||||
onDone(undefined, { display: 'skip' })
|
||||
return null
|
||||
}
|
||||
12
src/commands/buddy/index.ts
Normal file
12
src/commands/buddy/index.ts
Normal file
@@ -0,0 +1,12 @@
|
||||
import type { Command } from '../../commands.js'
|
||||
|
||||
const buddy = {
|
||||
type: 'local-jsx',
|
||||
name: 'buddy',
|
||||
description: 'Hatch, pet, and manage your Open Claude companion',
|
||||
immediate: true,
|
||||
argumentHint: '[status|mute|unmute|help]',
|
||||
load: () => import('./buddy.js'),
|
||||
} satisfies Command
|
||||
|
||||
export default buddy
|
||||
File diff suppressed because one or more lines are too long
@@ -4,4 +4,3 @@ import type { LocalJSXCommandCall } from '../../types/command.js';
|
||||
export const call: LocalJSXCommandCall = async (onDone, context) => {
|
||||
return <Settings onClose={onDone} context={context} defaultTab="Config" />;
|
||||
};
|
||||
//# sourceMappingURL=data:application/json;charset=utf-8;base64,eyJ2ZXJzaW9uIjozLCJuYW1lcyI6WyJSZWFjdCIsIlNldHRpbmdzIiwiTG9jYWxKU1hDb21tYW5kQ2FsbCIsImNhbGwiLCJvbkRvbmUiLCJjb250ZXh0Il0sInNvdXJjZXMiOlsiY29uZmlnLnRzeCJdLCJzb3VyY2VzQ29udGVudCI6WyJpbXBvcnQgKiBhcyBSZWFjdCBmcm9tICdyZWFjdCdcbmltcG9ydCB7IFNldHRpbmdzIH0gZnJvbSAnLi4vLi4vY29tcG9uZW50cy9TZXR0aW5ncy9TZXR0aW5ncy5qcydcbmltcG9ydCB0eXBlIHsgTG9jYWxKU1hDb21tYW5kQ2FsbCB9IGZyb20gJy4uLy4uL3R5cGVzL2NvbW1hbmQuanMnXG5cbmV4cG9ydCBjb25zdCBjYWxsOiBMb2NhbEpTWENvbW1hbmRDYWxsID0gYXN5bmMgKG9uRG9uZSwgY29udGV4dCkgPT4ge1xuICByZXR1cm4gPFNldHRpbmdzIG9uQ2xvc2U9e29uRG9uZX0gY29udGV4dD17Y29udGV4dH0gZGVmYXVsdFRhYj1cIkNvbmZpZ1wiIC8+XG59XG4iXSwibWFwcGluZ3MiOiJBQUFBLE9BQU8sS0FBS0EsS0FBSyxNQUFNLE9BQU87QUFDOUIsU0FBU0MsUUFBUSxRQUFRLHVDQUF1QztBQUNoRSxjQUFjQyxtQkFBbUIsUUFBUSx3QkFBd0I7QUFFakUsT0FBTyxNQUFNQyxJQUFJLEVBQUVELG1CQUFtQixHQUFHLE1BQUFDLENBQU9DLE1BQU0sRUFBRUMsT0FBTyxLQUFLO0VBQ2xFLE9BQU8sQ0FBQyxRQUFRLENBQUMsT0FBTyxDQUFDLENBQUNELE1BQU0sQ0FBQyxDQUFDLE9BQU8sQ0FBQyxDQUFDQyxPQUFPLENBQUMsQ0FBQyxVQUFVLENBQUMsUUFBUSxHQUFHO0FBQzVFLENBQUMiLCJpZ25vcmVMaXN0IjpbXX0=
|
||||
@@ -199,13 +199,13 @@ function formatContextAsMarkdownTable(data: ContextData): string {
|
||||
output += `\n`
|
||||
}
|
||||
|
||||
// System tools (ant-only)
|
||||
// System tools (internal-only)
|
||||
if (
|
||||
systemTools &&
|
||||
systemTools.length > 0 &&
|
||||
process.env.USER_TYPE === 'ant'
|
||||
) {
|
||||
output += `### [ANT-ONLY] System Tools\n\n`
|
||||
output += `### [internal] System Tools\n\n`
|
||||
output += `| Tool | Tokens |\n`
|
||||
output += `|------|--------|\n`
|
||||
for (const tool of systemTools) {
|
||||
@@ -214,13 +214,13 @@ function formatContextAsMarkdownTable(data: ContextData): string {
|
||||
output += `\n`
|
||||
}
|
||||
|
||||
// System prompt sections (ant-only)
|
||||
// System prompt sections (internal-only)
|
||||
if (
|
||||
systemPromptSections &&
|
||||
systemPromptSections.length > 0 &&
|
||||
process.env.USER_TYPE === 'ant'
|
||||
) {
|
||||
output += `### [ANT-ONLY] System Prompt Sections\n\n`
|
||||
output += `### [internal] System Prompt Sections\n\n`
|
||||
output += `| Section | Tokens |\n`
|
||||
output += `|---------|--------|\n`
|
||||
for (const section of systemPromptSections) {
|
||||
@@ -288,9 +288,9 @@ function formatContextAsMarkdownTable(data: ContextData): string {
|
||||
output += `\n`
|
||||
}
|
||||
|
||||
// Message breakdown (ant-only)
|
||||
// Message breakdown (internal-only)
|
||||
if (messageBreakdown && process.env.USER_TYPE === 'ant') {
|
||||
output += `### [ANT-ONLY] Message Breakdown\n\n`
|
||||
output += `### [internal] Message Breakdown\n\n`
|
||||
output += `| Category | Tokens |\n`
|
||||
output += `|----------|--------|\n`
|
||||
output += `| Tool calls | ${formatTokens(messageBreakdown.toolCallTokens)} |\n`
|
||||
|
||||
File diff suppressed because one or more lines are too long
File diff suppressed because one or more lines are too long
@@ -16,7 +16,7 @@ export const call: LocalCommandCall = async () => {
|
||||
}
|
||||
|
||||
if (process.env.USER_TYPE === 'ant') {
|
||||
value += `\n\n[ANT-ONLY] Showing cost anyway:\n ${formatTotalCost()}`
|
||||
value += `\n\n[internal-only] Showing cost anyway:\n ${formatTotalCost()}`
|
||||
}
|
||||
return { type: 'text', value }
|
||||
}
|
||||
|
||||
@@ -6,4 +6,3 @@ export async function call(onDone: (result?: string, options?: {
|
||||
}) => void): Promise<React.ReactNode> {
|
||||
return <DesktopHandoff onDone={onDone} />;
|
||||
}
|
||||
//# sourceMappingURL=data:application/json;charset=utf-8;base64,eyJ2ZXJzaW9uIjozLCJuYW1lcyI6WyJSZWFjdCIsIkNvbW1hbmRSZXN1bHREaXNwbGF5IiwiRGVza3RvcEhhbmRvZmYiLCJjYWxsIiwib25Eb25lIiwicmVzdWx0Iiwib3B0aW9ucyIsImRpc3BsYXkiLCJQcm9taXNlIiwiUmVhY3ROb2RlIl0sInNvdXJjZXMiOlsiZGVza3RvcC50c3giXSwic291cmNlc0NvbnRlbnQiOlsiaW1wb3J0IFJlYWN0IGZyb20gJ3JlYWN0J1xuaW1wb3J0IHR5cGUgeyBDb21tYW5kUmVzdWx0RGlzcGxheSB9IGZyb20gJy4uLy4uL2NvbW1hbmRzLmpzJ1xuaW1wb3J0IHsgRGVza3RvcEhhbmRvZmYgfSBmcm9tICcuLi8uLi9jb21wb25lbnRzL0Rlc2t0b3BIYW5kb2ZmLmpzJ1xuXG5leHBvcnQgYXN5bmMgZnVuY3Rpb24gY2FsbChcbiAgb25Eb25lOiAoXG4gICAgcmVzdWx0Pzogc3RyaW5nLFxuICAgIG9wdGlvbnM/OiB7IGRpc3BsYXk/OiBDb21tYW5kUmVzdWx0RGlzcGxheSB9LFxuICApID0+IHZvaWQsXG4pOiBQcm9taXNlPFJlYWN0LlJlYWN0Tm9kZT4ge1xuICByZXR1cm4gPERlc2t0b3BIYW5kb2ZmIG9uRG9uZT17b25Eb25lfSAvPlxufVxuIl0sIm1hcHBpbmdzIjoiQUFBQSxPQUFPQSxLQUFLLE1BQU0sT0FBTztBQUN6QixjQUFjQyxvQkFBb0IsUUFBUSxtQkFBbUI7QUFDN0QsU0FBU0MsY0FBYyxRQUFRLG9DQUFvQztBQUVuRSxPQUFPLGVBQWVDLElBQUlBLENBQ3hCQyxNQUFNLEVBQUUsQ0FDTkMsTUFBZSxDQUFSLEVBQUUsTUFBTSxFQUNmQyxPQUE0QyxDQUFwQyxFQUFFO0VBQUVDLE9BQU8sQ0FBQyxFQUFFTixvQkFBb0I7QUFBQyxDQUFDLEVBQzVDLEdBQUcsSUFBSSxDQUNWLEVBQUVPLE9BQU8sQ0FBQ1IsS0FBSyxDQUFDUyxTQUFTLENBQUMsQ0FBQztFQUMxQixPQUFPLENBQUMsY0FBYyxDQUFDLE1BQU0sQ0FBQyxDQUFDTCxNQUFNLENBQUMsR0FBRztBQUMzQyIsImlnbm9yZUxpc3QiOltdfQ==
|
||||
@@ -6,4 +6,3 @@ export const call: LocalJSXCommandCall = async (onDone, context) => {
|
||||
} = await import('../../components/diff/DiffDialog.js');
|
||||
return <DiffDialog messages={context.messages} onDone={onDone} />;
|
||||
};
|
||||
//# sourceMappingURL=data:application/json;charset=utf-8;base64,eyJ2ZXJzaW9uIjozLCJuYW1lcyI6WyJSZWFjdCIsIkxvY2FsSlNYQ29tbWFuZENhbGwiLCJjYWxsIiwib25Eb25lIiwiY29udGV4dCIsIkRpZmZEaWFsb2ciLCJtZXNzYWdlcyJdLCJzb3VyY2VzIjpbImRpZmYudHN4Il0sInNvdXJjZXNDb250ZW50IjpbImltcG9ydCAqIGFzIFJlYWN0IGZyb20gJ3JlYWN0J1xuaW1wb3J0IHR5cGUgeyBMb2NhbEpTWENvbW1hbmRDYWxsIH0gZnJvbSAnLi4vLi4vdHlwZXMvY29tbWFuZC5qcydcblxuZXhwb3J0IGNvbnN0IGNhbGw6IExvY2FsSlNYQ29tbWFuZENhbGwgPSBhc3luYyAob25Eb25lLCBjb250ZXh0KSA9PiB7XG4gIGNvbnN0IHsgRGlmZkRpYWxvZyB9ID0gYXdhaXQgaW1wb3J0KCcuLi8uLi9jb21wb25lbnRzL2RpZmYvRGlmZkRpYWxvZy5qcycpXG4gIHJldHVybiA8RGlmZkRpYWxvZyBtZXNzYWdlcz17Y29udGV4dC5tZXNzYWdlc30gb25Eb25lPXtvbkRvbmV9IC8+XG59XG4iXSwibWFwcGluZ3MiOiJBQUFBLE9BQU8sS0FBS0EsS0FBSyxNQUFNLE9BQU87QUFDOUIsY0FBY0MsbUJBQW1CLFFBQVEsd0JBQXdCO0FBRWpFLE9BQU8sTUFBTUMsSUFBSSxFQUFFRCxtQkFBbUIsR0FBRyxNQUFBQyxDQUFPQyxNQUFNLEVBQUVDLE9BQU8sS0FBSztFQUNsRSxNQUFNO0lBQUVDO0VBQVcsQ0FBQyxHQUFHLE1BQU0sTUFBTSxDQUFDLHFDQUFxQyxDQUFDO0VBQzFFLE9BQU8sQ0FBQyxVQUFVLENBQUMsUUFBUSxDQUFDLENBQUNELE9BQU8sQ0FBQ0UsUUFBUSxDQUFDLENBQUMsTUFBTSxDQUFDLENBQUNILE1BQU0sQ0FBQyxHQUFHO0FBQ25FLENBQUMiLCJpZ25vcmVMaXN0IjpbXX0=
|
||||
@@ -4,4 +4,3 @@ import type { LocalJSXCommandCall } from '../../types/command.js';
|
||||
export const call: LocalJSXCommandCall = (onDone, _context, _args) => {
|
||||
return Promise.resolve(<Doctor onDone={onDone} />);
|
||||
};
|
||||
//# sourceMappingURL=data:application/json;charset=utf-8;base64,eyJ2ZXJzaW9uIjozLCJuYW1lcyI6WyJSZWFjdCIsIkRvY3RvciIsIkxvY2FsSlNYQ29tbWFuZENhbGwiLCJjYWxsIiwib25Eb25lIiwiX2NvbnRleHQiLCJfYXJncyIsIlByb21pc2UiLCJyZXNvbHZlIl0sInNvdXJjZXMiOlsiZG9jdG9yLnRzeCJdLCJzb3VyY2VzQ29udGVudCI6WyJpbXBvcnQgUmVhY3QgZnJvbSAncmVhY3QnXG5pbXBvcnQgeyBEb2N0b3IgfSBmcm9tICcuLi8uLi9zY3JlZW5zL0RvY3Rvci5qcydcbmltcG9ydCB0eXBlIHsgTG9jYWxKU1hDb21tYW5kQ2FsbCB9IGZyb20gJy4uLy4uL3R5cGVzL2NvbW1hbmQuanMnXG5cbmV4cG9ydCBjb25zdCBjYWxsOiBMb2NhbEpTWENvbW1hbmRDYWxsID0gKG9uRG9uZSwgX2NvbnRleHQsIF9hcmdzKSA9PiB7XG4gIHJldHVybiBQcm9taXNlLnJlc29sdmUoPERvY3RvciBvbkRvbmU9e29uRG9uZX0gLz4pXG59XG4iXSwibWFwcGluZ3MiOiJBQUFBLE9BQU9BLEtBQUssTUFBTSxPQUFPO0FBQ3pCLFNBQVNDLE1BQU0sUUFBUSx5QkFBeUI7QUFDaEQsY0FBY0MsbUJBQW1CLFFBQVEsd0JBQXdCO0FBRWpFLE9BQU8sTUFBTUMsSUFBSSxFQUFFRCxtQkFBbUIsR0FBR0MsQ0FBQ0MsTUFBTSxFQUFFQyxRQUFRLEVBQUVDLEtBQUssS0FBSztFQUNwRSxPQUFPQyxPQUFPLENBQUNDLE9BQU8sQ0FBQyxDQUFDLE1BQU0sQ0FBQyxNQUFNLENBQUMsQ0FBQ0osTUFBTSxDQUFDLEdBQUcsQ0FBQztBQUNwRCxDQUFDIiwiaWdub3JlTGlzdCI6W119
|
||||
68
src/commands/dream/dream.ts
Normal file
68
src/commands/dream/dream.ts
Normal file
@@ -0,0 +1,68 @@
|
||||
import type { ContentBlockParam } from '@anthropic-ai/sdk/resources/messages.mjs'
|
||||
import type { Command } from '../../commands.js'
|
||||
import { isAutoMemoryEnabled, getAutoMemPath } from '../../memdir/paths.js'
|
||||
import { getProjectDir } from '../../utils/sessionStorage.js'
|
||||
import { getOriginalCwd, getSessionId } from '../../bootstrap/state.js'
|
||||
import { buildConsolidationPrompt } from '../../services/autoDream/consolidationPrompt.js'
|
||||
import {
|
||||
readLastConsolidatedAt,
|
||||
listSessionsTouchedSince,
|
||||
recordConsolidation,
|
||||
} from '../../services/autoDream/consolidationLock.js'
|
||||
|
||||
const command = {
|
||||
type: 'prompt',
|
||||
name: 'dream',
|
||||
description:
|
||||
'Run memory consolidation — synthesize recent sessions into durable memories',
|
||||
isEnabled: () => isAutoMemoryEnabled(),
|
||||
progressMessage: 'consolidating memories',
|
||||
contentLength: 0,
|
||||
source: 'builtin',
|
||||
async getPromptForCommand(): Promise<ContentBlockParam[]> {
|
||||
const memoryRoot = getAutoMemPath()
|
||||
const transcriptDir = getProjectDir(getOriginalCwd())
|
||||
|
||||
let lastAt: number
|
||||
try {
|
||||
lastAt = await readLastConsolidatedAt()
|
||||
} catch {
|
||||
lastAt = 0
|
||||
}
|
||||
|
||||
let sessionIds: string[]
|
||||
try {
|
||||
sessionIds = await listSessionsTouchedSince(lastAt)
|
||||
} catch {
|
||||
sessionIds = []
|
||||
}
|
||||
|
||||
const currentSession = getSessionId()
|
||||
sessionIds = sessionIds.filter(id => id !== currentSession)
|
||||
|
||||
if (sessionIds.length === 0) {
|
||||
sessionIds = [currentSession]
|
||||
}
|
||||
|
||||
const hoursSince =
|
||||
lastAt > 0
|
||||
? `${((Date.now() - lastAt) / 3_600_000).toFixed(1)}h ago`
|
||||
: 'never'
|
||||
|
||||
const extra = `
|
||||
**Manually triggered by user via /dream.**
|
||||
|
||||
Sessions since last consolidation (${sessionIds.length}, last run: ${hoursSince}):
|
||||
${sessionIds.map(id => `- ${id}`).join('\n')}`
|
||||
|
||||
const prompt = buildConsolidationPrompt(memoryRoot, transcriptDir, extra)
|
||||
|
||||
// Record consolidation timestamp programmatically so auto-dream
|
||||
// knows when the last manual run happened.
|
||||
await recordConsolidation()
|
||||
|
||||
return [{ type: 'text', text: prompt }]
|
||||
},
|
||||
} satisfies Command
|
||||
|
||||
export default command
|
||||
1
src/commands/dream/index.ts
Normal file
1
src/commands/dream/index.ts
Normal file
@@ -0,0 +1 @@
|
||||
export { default } from './dream.js'
|
||||
File diff suppressed because one or more lines are too long
@@ -30,4 +30,3 @@ export async function call(onDone: LocalJSXCommandOnDone): Promise<React.ReactNo
|
||||
await gracefulShutdown(0, 'prompt_input_exit');
|
||||
return null;
|
||||
}
|
||||
//# sourceMappingURL=data:application/json;charset=utf-8;base64,eyJ2ZXJzaW9uIjozLCJuYW1lcyI6WyJmZWF0dXJlIiwic3Bhd25TeW5jIiwic2FtcGxlIiwiUmVhY3QiLCJFeGl0RmxvdyIsIkxvY2FsSlNYQ29tbWFuZE9uRG9uZSIsImlzQmdTZXNzaW9uIiwiZ3JhY2VmdWxTaHV0ZG93biIsImdldEN1cnJlbnRXb3JrdHJlZVNlc3Npb24iLCJHT09EQllFX01FU1NBR0VTIiwiZ2V0UmFuZG9tR29vZGJ5ZU1lc3NhZ2UiLCJjYWxsIiwib25Eb25lIiwiUHJvbWlzZSIsIlJlYWN0Tm9kZSIsInN0ZGlvIiwic2hvd1dvcmt0cmVlIl0sInNvdXJjZXMiOlsiZXhpdC50c3giXSwic291cmNlc0NvbnRlbnQiOlsiaW1wb3J0IHsgZmVhdHVyZSB9IGZyb20gJ2J1bjpidW5kbGUnXG5pbXBvcnQgeyBzcGF3blN5bmMgfSBmcm9tICdjaGlsZF9wcm9jZXNzJ1xuaW1wb3J0IHNhbXBsZSBmcm9tICdsb2Rhc2gtZXMvc2FtcGxlLmpzJ1xuaW1wb3J0ICogYXMgUmVhY3QgZnJvbSAncmVhY3QnXG5pbXBvcnQgeyBFeGl0RmxvdyB9IGZyb20gJy4uLy4uL2NvbXBvbmVudHMvRXhpdEZsb3cuanMnXG5pbXBvcnQgdHlwZSB7IExvY2FsSlNYQ29tbWFuZE9uRG9uZSB9IGZyb20gJy4uLy4uL3R5cGVzL2NvbW1hbmQuanMnXG5pbXBvcnQgeyBpc0JnU2Vzc2lvbiB9IGZyb20gJy4uLy4uL3V0aWxzL2NvbmN1cnJlbnRTZXNzaW9ucy5qcydcbmltcG9ydCB7IGdyYWNlZnVsU2h1dGRvd24gfSBmcm9tICcuLi8uLi91dGlscy9ncmFjZWZ1bFNodXRkb3duLmpzJ1xuaW1wb3J0IHsgZ2V0Q3VycmVudFdvcmt0cmVlU2Vzc2lvbiB9IGZyb20gJy4uLy4uL3V0aWxzL3dvcmt0cmVlLmpzJ1xuXG5jb25zdCBHT09EQllFX01FU1NBR0VTID0gWydHb29kYnllIScsICdTZWUgeWEhJywgJ0J5ZSEnLCAnQ2F0Y2ggeW91IGxhdGVyISddXG5cbmZ1bmN0aW9uIGdldFJhbmRvbUdvb2RieWVNZXNzYWdlKCk6IHN0cmluZyB7XG4gIHJldHVybiBzYW1wbGUoR09PREJZRV9NRVNTQUdFUykgPz8gJ0dvb2RieWUhJ1xufVxuXG5leHBvcnQgYXN5bmMgZnVuY3Rpb24gY2FsbChcbiAgb25Eb25lOiBMb2NhbEpTWENvbW1hbmRPbkRvbmUsXG4pOiBQcm9taXNlPFJlYWN0LlJlYWN0Tm9kZT4ge1xuICAvLyBJbnNpZGUgYSBgY2xhdWRlIC0tYmdgIHRtdXggc2Vzc2lvbjogZGV0YWNoIGluc3RlYWQgb2Yga2lsbC4gVGhlIFJFUExcbiAgLy8ga2VlcHMgcnVubmluZzsgYGNsYXVkZSBhdHRhY2hgIGNhbiByZWNvbm5lY3QuIENvdmVycyAvZXhpdCwgL3F1aXQsXG4gIC8vIGN0cmwrYywgY3RybCtkIOKAlCBhbGwgZnVubmVsIHRocm91Z2ggaGVyZSB2aWEgUkVQTCdzIGhhbmRsZUV4aXQuXG4gIGlmIChmZWF0dXJlKCdCR19TRVNTSU9OUycpICYmIGlzQmdTZXNzaW9uKCkpIHtcbiAgICBvbkRvbmUoKVxuICAgIHNwYXduU3luYygndG11eCcsIFsnZGV0YWNoLWNsaWVudCddLCB7IHN0ZGlvOiAnaWdub3JlJyB9KVxuICAgIHJldHVybiBudWxsXG4gIH1cblxuICBjb25zdCBzaG93V29ya3RyZWUgPSBnZXRDdXJyZW50V29ya3RyZWVTZXNzaW9uKCkgIT09IG51bGxcblxuICBpZiAoc2hvd1dvcmt0cmVlKSB7XG4gICAgcmV0dXJuIChcbiAgICAgIDxFeGl0Rmxvd1xuICAgICAgICBzaG93V29ya3RyZWU9e3Nob3dXb3JrdHJlZX1cbiAgICAgICAgb25Eb25lPXtvbkRvbmV9XG4gICAgICAgIG9uQ2FuY2VsPXsoKSA9PiBvbkRvbmUoKX1cbiAgICAgIC8+XG4gICAgKVxuICB9XG5cbiAgb25Eb25lKGdldFJhbmRvbUdvb2RieWVNZXNzYWdlKCkpXG4gIGF3YWl0IGdyYWNlZnVsU2h1dGRvd24oMCwgJ3Byb21wdF9pbnB1dF9leGl0JylcbiAgcmV0dXJuIG51bGxcbn1cbiJdLCJtYXBwaW5ncyI6IkFBQUEsU0FBU0EsT0FBTyxRQUFRLFlBQVk7QUFDcEMsU0FBU0MsU0FBUyxRQUFRLGVBQWU7QUFDekMsT0FBT0MsTUFBTSxNQUFNLHFCQUFxQjtBQUN4QyxPQUFPLEtBQUtDLEtBQUssTUFBTSxPQUFPO0FBQzlCLFNBQVNDLFFBQVEsUUFBUSw4QkFBOEI7QUFDdkQsY0FBY0MscUJBQXFCLFFBQVEsd0JBQXdCO0FBQ25FLFNBQVNDLFdBQVcsUUFBUSxtQ0FBbUM7QUFDL0QsU0FBU0MsZ0JBQWdCLFFBQVEsaUNBQWlDO0FBQ2xFLFNBQVNDLHlCQUF5QixRQUFRLHlCQUF5QjtBQUVuRSxNQUFNQyxnQkFBZ0IsR0FBRyxDQUFDLFVBQVUsRUFBRSxTQUFTLEVBQUUsTUFBTSxFQUFFLGtCQUFrQixDQUFDO0FBRTVFLFNBQVNDLHVCQUF1QkEsQ0FBQSxDQUFFLEVBQUUsTUFBTSxDQUFDO0VBQ3pDLE9BQU9SLE1BQU0sQ0FBQ08sZ0JBQWdCLENBQUMsSUFBSSxVQUFVO0FBQy9DO0FBRUEsT0FBTyxlQUFlRSxJQUFJQSxDQUN4QkMsTUFBTSxFQUFFUCxxQkFBcUIsQ0FDOUIsRUFBRVEsT0FBTyxDQUFDVixLQUFLLENBQUNXLFNBQVMsQ0FBQyxDQUFDO0VBQzFCO0VBQ0E7RUFDQTtFQUNBLElBQUlkLE9BQU8sQ0FBQyxhQUFhLENBQUMsSUFBSU0sV0FBVyxDQUFDLENBQUMsRUFBRTtJQUMzQ00sTUFBTSxDQUFDLENBQUM7SUFDUlgsU0FBUyxDQUFDLE1BQU0sRUFBRSxDQUFDLGVBQWUsQ0FBQyxFQUFFO01BQUVjLEtBQUssRUFBRTtJQUFTLENBQUMsQ0FBQztJQUN6RCxPQUFPLElBQUk7RUFDYjtFQUVBLE1BQU1DLFlBQVksR0FBR1IseUJBQXlCLENBQUMsQ0FBQyxLQUFLLElBQUk7RUFFekQsSUFBSVEsWUFBWSxFQUFFO0lBQ2hCLE9BQ0UsQ0FBQyxRQUFRLENBQ1AsWUFBWSxDQUFDLENBQUNBLFlBQVksQ0FBQyxDQUMzQixNQUFNLENBQUMsQ0FBQ0osTUFBTSxDQUFDLENBQ2YsUUFBUSxDQUFDLENBQUMsTUFBTUEsTUFBTSxDQUFDLENBQUMsQ0FBQyxHQUN6QjtFQUVOO0VBRUFBLE1BQU0sQ0FBQ0YsdUJBQXVCLENBQUMsQ0FBQyxDQUFDO0VBQ2pDLE1BQU1ILGdCQUFnQixDQUFDLENBQUMsRUFBRSxtQkFBbUIsQ0FBQztFQUM5QyxPQUFPLElBQUk7QUFDYiIsImlnbm9yZUxpc3QiOltdfQ==
|
||||
File diff suppressed because one or more lines are too long
@@ -14,4 +14,3 @@ export async function call(onDone: LocalJSXCommandOnDone, context: LocalJSXComma
|
||||
onDone(success ? 'Login successful' : 'Login interrupted');
|
||||
}} />;
|
||||
}
|
||||
//# sourceMappingURL=data:application/json;charset=utf-8;base64,eyJ2ZXJzaW9uIjozLCJuYW1lcyI6WyJSZWFjdCIsIkxvY2FsSlNYQ29tbWFuZENvbnRleHQiLCJMb2NhbEpTWENvbW1hbmRPbkRvbmUiLCJMb2dpbiIsInJ1bkV4dHJhVXNhZ2UiLCJjYWxsIiwib25Eb25lIiwiY29udGV4dCIsIlByb21pc2UiLCJSZWFjdE5vZGUiLCJyZXN1bHQiLCJ0eXBlIiwidmFsdWUiLCJzdWNjZXNzIiwib25DaGFuZ2VBUElLZXkiXSwic291cmNlcyI6WyJleHRyYS11c2FnZS50c3giXSwic291cmNlc0NvbnRlbnQiOlsiaW1wb3J0IFJlYWN0IGZyb20gJ3JlYWN0J1xuaW1wb3J0IHR5cGUgeyBMb2NhbEpTWENvbW1hbmRDb250ZXh0IH0gZnJvbSAnLi4vLi4vY29tbWFuZHMuanMnXG5pbXBvcnQgdHlwZSB7IExvY2FsSlNYQ29tbWFuZE9uRG9uZSB9IGZyb20gJy4uLy4uL3R5cGVzL2NvbW1hbmQuanMnXG5pbXBvcnQgeyBMb2dpbiB9IGZyb20gJy4uL2xvZ2luL2xvZ2luLmpzJ1xuaW1wb3J0IHsgcnVuRXh0cmFVc2FnZSB9IGZyb20gJy4vZXh0cmEtdXNhZ2UtY29yZS5qcydcblxuZXhwb3J0IGFzeW5jIGZ1bmN0aW9uIGNhbGwoXG4gIG9uRG9uZTogTG9jYWxKU1hDb21tYW5kT25Eb25lLFxuICBjb250ZXh0OiBMb2NhbEpTWENvbW1hbmRDb250ZXh0LFxuKTogUHJvbWlzZTxSZWFjdC5SZWFjdE5vZGUgfCBudWxsPiB7XG4gIGNvbnN0IHJlc3VsdCA9IGF3YWl0IHJ1bkV4dHJhVXNhZ2UoKVxuXG4gIGlmIChyZXN1bHQudHlwZSA9PT0gJ21lc3NhZ2UnKSB7XG4gICAgb25Eb25lKHJlc3VsdC52YWx1ZSlcbiAgICByZXR1cm4gbnVsbFxuICB9XG5cbiAgcmV0dXJuIChcbiAgICA8TG9naW5cbiAgICAgIHN0YXJ0aW5nTWVzc2FnZT17XG4gICAgICAgICdTdGFydGluZyBuZXcgbG9naW4gZm9sbG93aW5nIC9leHRyYS11c2FnZS4gRXhpdCB3aXRoIEN0cmwtQyB0byB1c2UgZXhpc3RpbmcgYWNjb3VudC4nXG4gICAgICB9XG4gICAgICBvbkRvbmU9e3N1Y2Nlc3MgPT4ge1xuICAgICAgICBjb250ZXh0Lm9uQ2hhbmdlQVBJS2V5KClcbiAgICAgICAgb25Eb25lKHN1Y2Nlc3MgPyAnTG9naW4gc3VjY2Vzc2Z1bCcgOiAnTG9naW4gaW50ZXJydXB0ZWQnKVxuICAgICAgfX1cbiAgICAvPlxuICApXG59XG4iXSwibWFwcGluZ3MiOiJBQUFBLE9BQU9BLEtBQUssTUFBTSxPQUFPO0FBQ3pCLGNBQWNDLHNCQUFzQixRQUFRLG1CQUFtQjtBQUMvRCxjQUFjQyxxQkFBcUIsUUFBUSx3QkFBd0I7QUFDbkUsU0FBU0MsS0FBSyxRQUFRLG1CQUFtQjtBQUN6QyxTQUFTQyxhQUFhLFFBQVEsdUJBQXVCO0FBRXJELE9BQU8sZUFBZUMsSUFBSUEsQ0FDeEJDLE1BQU0sRUFBRUoscUJBQXFCLEVBQzdCSyxPQUFPLEVBQUVOLHNCQUFzQixDQUNoQyxFQUFFTyxPQUFPLENBQUNSLEtBQUssQ0FBQ1MsU0FBUyxHQUFHLElBQUksQ0FBQyxDQUFDO0VBQ2pDLE1BQU1DLE1BQU0sR0FBRyxNQUFNTixhQUFhLENBQUMsQ0FBQztFQUVwQyxJQUFJTSxNQUFNLENBQUNDLElBQUksS0FBSyxTQUFTLEVBQUU7SUFDN0JMLE1BQU0sQ0FBQ0ksTUFBTSxDQUFDRSxLQUFLLENBQUM7SUFDcEIsT0FBTyxJQUFJO0VBQ2I7RUFFQSxPQUNFLENBQUMsS0FBSyxDQUNKLGVBQWUsQ0FBQyxDQUNkLHNGQUNGLENBQUMsQ0FDRCxNQUFNLENBQUMsQ0FBQ0MsT0FBTyxJQUFJO0lBQ2pCTixPQUFPLENBQUNPLGNBQWMsQ0FBQyxDQUFDO0lBQ3hCUixNQUFNLENBQUNPLE9BQU8sR0FBRyxrQkFBa0IsR0FBRyxtQkFBbUIsQ0FBQztFQUM1RCxDQUFDLENBQUMsR0FDRjtBQUVOIiwiaWdub3JlTGlzdCI6W119
|
||||
File diff suppressed because one or more lines are too long
@@ -22,4 +22,3 @@ export async function call(onDone: LocalJSXCommandOnDone, context: LocalJSXComma
|
||||
const initialDescription = args || '';
|
||||
return renderFeedbackComponent(onDone, context.abortController.signal, context.messages, initialDescription);
|
||||
}
|
||||
//# sourceMappingURL=data:application/json;charset=utf-8;base64,eyJ2ZXJzaW9uIjozLCJuYW1lcyI6WyJSZWFjdCIsIkNvbW1hbmRSZXN1bHREaXNwbGF5IiwiTG9jYWxKU1hDb21tYW5kQ29udGV4dCIsIkZlZWRiYWNrIiwiTG9jYWxKU1hDb21tYW5kT25Eb25lIiwiTWVzc2FnZSIsInJlbmRlckZlZWRiYWNrQ29tcG9uZW50Iiwib25Eb25lIiwicmVzdWx0Iiwib3B0aW9ucyIsImRpc3BsYXkiLCJhYm9ydFNpZ25hbCIsIkFib3J0U2lnbmFsIiwibWVzc2FnZXMiLCJpbml0aWFsRGVzY3JpcHRpb24iLCJiYWNrZ3JvdW5kVGFza3MiLCJ0YXNrSWQiLCJ0eXBlIiwiaWRlbnRpdHkiLCJhZ2VudElkIiwiUmVhY3ROb2RlIiwiY2FsbCIsImNvbnRleHQiLCJhcmdzIiwiUHJvbWlzZSIsImFib3J0Q29udHJvbGxlciIsInNpZ25hbCJdLCJzb3VyY2VzIjpbImZlZWRiYWNrLnRzeCJdLCJzb3VyY2VzQ29udGVudCI6WyJpbXBvcnQgKiBhcyBSZWFjdCBmcm9tICdyZWFjdCdcbmltcG9ydCB0eXBlIHtcbiAgQ29tbWFuZFJlc3VsdERpc3BsYXksXG4gIExvY2FsSlNYQ29tbWFuZENvbnRleHQsXG59IGZyb20gJy4uLy4uL2NvbW1hbmRzLmpzJ1xuaW1wb3J0IHsgRmVlZGJhY2sgfSBmcm9tICcuLi8uLi9jb21wb25lbnRzL0ZlZWRiYWNrLmpzJ1xuaW1wb3J0IHR5cGUgeyBMb2NhbEpTWENvbW1hbmRPbkRvbmUgfSBmcm9tICcuLi8uLi90eXBlcy9jb21tYW5kLmpzJ1xuaW1wb3J0IHR5cGUgeyBNZXNzYWdlIH0gZnJvbSAnLi4vLi4vdHlwZXMvbWVzc2FnZS5qcydcblxuLy8gU2hhcmVkIGZ1bmN0aW9uIHRvIHJlbmRlciB0aGUgRmVlZGJhY2sgY29tcG9uZW50XG5leHBvcnQgZnVuY3Rpb24gcmVuZGVyRmVlZGJhY2tDb21wb25lbnQoXG4gIG9uRG9uZTogKFxuICAgIHJlc3VsdD86IHN0cmluZyxcbiAgICBvcHRpb25zPzogeyBkaXNwbGF5PzogQ29tbWFuZFJlc3VsdERpc3BsYXkgfSxcbiAgKSA9PiB2b2lkLFxuICBhYm9ydFNpZ25hbDogQWJvcnRTaWduYWwsXG4gIG1lc3NhZ2VzOiBNZXNzYWdlW10sXG4gIGluaXRpYWxEZXNjcmlwdGlvbjogc3RyaW5nID0gJycsXG4gIGJhY2tncm91bmRUYXNrczoge1xuICAgIFt0YXNrSWQ6IHN0cmluZ106IHtcbiAgICAgIHR5cGU6IHN0cmluZ1xuICAgICAgaWRlbnRpdHk/OiB7IGFnZW50SWQ6IHN0cmluZyB9XG4gICAgICBtZXNzYWdlcz86IE1lc3NhZ2VbXVxuICAgIH1cbiAgfSA9IHt9LFxuKTogUmVhY3QuUmVhY3ROb2RlIHtcbiAgcmV0dXJuIChcbiAgICA8RmVlZGJhY2tcbiAgICAgIGFib3J0U2lnbmFsPXthYm9ydFNpZ25hbH1cbiAgICAgIG1lc3NhZ2VzPXttZXNzYWdlc31cbiAgICAgIGluaXRpYWxEZXNjcmlwdGlvbj17aW5pdGlhbERlc2NyaXB0aW9ufVxuICAgICAgb25Eb25lPXtvbkRvbmV9XG4gICAgICBiYWNrZ3JvdW5kVGFza3M9e2JhY2tncm91bmRUYXNrc31cbiAgICAvPlxuICApXG59XG5cbmV4cG9ydCBhc3luYyBmdW5jdGlvbiBjYWxsKFxuICBvbkRvbmU6IExvY2FsSlNYQ29tbWFuZE9uRG9uZSxcbiAgY29udGV4dDogTG9jYWxKU1hDb21tYW5kQ29udGV4dCxcbiAgYXJncz86IHN0cmluZyxcbik6IFByb21pc2U8UmVhY3QuUmVhY3ROb2RlPiB7XG4gIGNvbnN0IGluaXRpYWxEZXNjcmlwdGlvbiA9IGFyZ3MgfHwgJydcbiAgcmV0dXJuIHJlbmRlckZlZWRiYWNrQ29tcG9uZW50KFxuICAgIG9uRG9uZSxcbiAgICBjb250ZXh0LmFib3J0Q29udHJvbGxlci5zaWduYWwsXG4gICAgY29udGV4dC5tZXNzYWdlcyxcbiAgICBpbml0aWFsRGVzY3JpcHRpb24sXG4gIClcbn1cbiJdLCJtYXBwaW5ncyI6IkFBQUEsT0FBTyxLQUFLQSxLQUFLLE1BQU0sT0FBTztBQUM5QixjQUNFQyxvQkFBb0IsRUFDcEJDLHNCQUFzQixRQUNqQixtQkFBbUI7QUFDMUIsU0FBU0MsUUFBUSxRQUFRLDhCQUE4QjtBQUN2RCxjQUFjQyxxQkFBcUIsUUFBUSx3QkFBd0I7QUFDbkUsY0FBY0MsT0FBTyxRQUFRLHdCQUF3Qjs7QUFFckQ7QUFDQSxPQUFPLFNBQVNDLHVCQUF1QkEsQ0FDckNDLE1BQU0sRUFBRSxDQUNOQyxNQUFlLENBQVIsRUFBRSxNQUFNLEVBQ2ZDLE9BQTRDLENBQXBDLEVBQUU7RUFBRUMsT0FBTyxDQUFDLEVBQUVULG9CQUFvQjtBQUFDLENBQUMsRUFDNUMsR0FBRyxJQUFJLEVBQ1RVLFdBQVcsRUFBRUMsV0FBVyxFQUN4QkMsUUFBUSxFQUFFUixPQUFPLEVBQUUsRUFDbkJTLGtCQUFrQixFQUFFLE1BQU0sR0FBRyxFQUFFLEVBQy9CQyxlQUFlLEVBQUU7RUFDZixDQUFDQyxNQUFNLEVBQUUsTUFBTSxDQUFDLEVBQUU7SUFDaEJDLElBQUksRUFBRSxNQUFNO0lBQ1pDLFFBQVEsQ0FBQyxFQUFFO01BQUVDLE9BQU8sRUFBRSxNQUFNO0lBQUMsQ0FBQztJQUM5Qk4sUUFBUSxDQUFDLEVBQUVSLE9BQU8sRUFBRTtFQUN0QixDQUFDO0FBQ0gsQ0FBQyxHQUFHLENBQUMsQ0FBQyxDQUNQLEVBQUVMLEtBQUssQ0FBQ29CLFNBQVMsQ0FBQztFQUNqQixPQUNFLENBQUMsUUFBUSxDQUNQLFdBQVcsQ0FBQyxDQUFDVCxXQUFXLENBQUMsQ0FDekIsUUFBUSxDQUFDLENBQUNFLFFBQVEsQ0FBQyxDQUNuQixrQkFBa0IsQ0FBQyxDQUFDQyxrQkFBa0IsQ0FBQyxDQUN2QyxNQUFNLENBQUMsQ0FBQ1AsTUFBTSxDQUFDLENBQ2YsZUFBZSxDQUFDLENBQUNRLGVBQWUsQ0FBQyxHQUNqQztBQUVOO0FBRUEsT0FBTyxlQUFlTSxJQUFJQSxDQUN4QmQsTUFBTSxFQUFFSCxxQkFBcUIsRUFDN0JrQixPQUFPLEVBQUVwQixzQkFBc0IsRUFDL0JxQixJQUFhLENBQVIsRUFBRSxNQUFNLENBQ2QsRUFBRUMsT0FBTyxDQUFDeEIsS0FBSyxDQUFDb0IsU0FBUyxDQUFDLENBQUM7RUFDMUIsTUFBTU4sa0JBQWtCLEdBQUdTLElBQUksSUFBSSxFQUFFO0VBQ3JDLE9BQU9qQix1QkFBdUIsQ0FDNUJDLE1BQU0sRUFDTmUsT0FBTyxDQUFDRyxlQUFlLENBQUNDLE1BQU0sRUFDOUJKLE9BQU8sQ0FBQ1QsUUFBUSxFQUNoQkMsa0JBQ0YsQ0FBQztBQUNIIiwiaWdub3JlTGlzdCI6W119
|
||||
@@ -8,4 +8,3 @@ export const call: LocalJSXCommandCall = async (onDone, {
|
||||
}) => {
|
||||
return <HelpV2 commands={commands} onClose={onDone} />;
|
||||
};
|
||||
//# sourceMappingURL=data:application/json;charset=utf-8;base64,eyJ2ZXJzaW9uIjozLCJuYW1lcyI6WyJSZWFjdCIsIkhlbHBWMiIsIkxvY2FsSlNYQ29tbWFuZENhbGwiLCJjYWxsIiwib25Eb25lIiwib3B0aW9ucyIsImNvbW1hbmRzIl0sInNvdXJjZXMiOlsiaGVscC50c3giXSwic291cmNlc0NvbnRlbnQiOlsiaW1wb3J0ICogYXMgUmVhY3QgZnJvbSAncmVhY3QnXG5pbXBvcnQgeyBIZWxwVjIgfSBmcm9tICcuLi8uLi9jb21wb25lbnRzL0hlbHBWMi9IZWxwVjIuanMnXG5pbXBvcnQgdHlwZSB7IExvY2FsSlNYQ29tbWFuZENhbGwgfSBmcm9tICcuLi8uLi90eXBlcy9jb21tYW5kLmpzJ1xuXG5leHBvcnQgY29uc3QgY2FsbDogTG9jYWxKU1hDb21tYW5kQ2FsbCA9IGFzeW5jIChcbiAgb25Eb25lLFxuICB7IG9wdGlvbnM6IHsgY29tbWFuZHMgfSB9LFxuKSA9PiB7XG4gIHJldHVybiA8SGVscFYyIGNvbW1hbmRzPXtjb21tYW5kc30gb25DbG9zZT17b25Eb25lfSAvPlxufVxuIl0sIm1hcHBpbmdzIjoiQUFBQSxPQUFPLEtBQUtBLEtBQUssTUFBTSxPQUFPO0FBQzlCLFNBQVNDLE1BQU0sUUFBUSxtQ0FBbUM7QUFDMUQsY0FBY0MsbUJBQW1CLFFBQVEsd0JBQXdCO0FBRWpFLE9BQU8sTUFBTUMsSUFBSSxFQUFFRCxtQkFBbUIsR0FBRyxNQUFBQyxDQUN2Q0MsTUFBTSxFQUNOO0VBQUVDLE9BQU8sRUFBRTtJQUFFQztFQUFTO0FBQUUsQ0FBQyxLQUN0QjtFQUNILE9BQU8sQ0FBQyxNQUFNLENBQUMsUUFBUSxDQUFDLENBQUNBLFFBQVEsQ0FBQyxDQUFDLE9BQU8sQ0FBQyxDQUFDRixNQUFNLENBQUMsR0FBRztBQUN4RCxDQUFDIiwiaWdub3JlTGlzdCI6W119
|
||||
@@ -10,4 +10,3 @@ export const call: LocalJSXCommandCall = async (onDone, context) => {
|
||||
const toolNames = getTools(permissionContext).map(tool => tool.name);
|
||||
return <HooksConfigMenu toolNames={toolNames} onExit={onDone} />;
|
||||
};
|
||||
//# sourceMappingURL=data:application/json;charset=utf-8;base64,eyJ2ZXJzaW9uIjozLCJuYW1lcyI6WyJSZWFjdCIsIkhvb2tzQ29uZmlnTWVudSIsImxvZ0V2ZW50IiwiZ2V0VG9vbHMiLCJMb2NhbEpTWENvbW1hbmRDYWxsIiwiY2FsbCIsIm9uRG9uZSIsImNvbnRleHQiLCJhcHBTdGF0ZSIsImdldEFwcFN0YXRlIiwicGVybWlzc2lvbkNvbnRleHQiLCJ0b29sUGVybWlzc2lvbkNvbnRleHQiLCJ0b29sTmFtZXMiLCJtYXAiLCJ0b29sIiwibmFtZSJdLCJzb3VyY2VzIjpbImhvb2tzLnRzeCJdLCJzb3VyY2VzQ29udGVudCI6WyJpbXBvcnQgKiBhcyBSZWFjdCBmcm9tICdyZWFjdCdcbmltcG9ydCB7IEhvb2tzQ29uZmlnTWVudSB9IGZyb20gJy4uLy4uL2NvbXBvbmVudHMvaG9va3MvSG9va3NDb25maWdNZW51LmpzJ1xuaW1wb3J0IHsgbG9nRXZlbnQgfSBmcm9tICcuLi8uLi9zZXJ2aWNlcy9hbmFseXRpY3MvaW5kZXguanMnXG5pbXBvcnQgeyBnZXRUb29scyB9IGZyb20gJy4uLy4uL3Rvb2xzLmpzJ1xuaW1wb3J0IHR5cGUgeyBMb2NhbEpTWENvbW1hbmRDYWxsIH0gZnJvbSAnLi4vLi4vdHlwZXMvY29tbWFuZC5qcydcblxuZXhwb3J0IGNvbnN0IGNhbGw6IExvY2FsSlNYQ29tbWFuZENhbGwgPSBhc3luYyAob25Eb25lLCBjb250ZXh0KSA9PiB7XG4gIGxvZ0V2ZW50KCd0ZW5ndV9ob29rc19jb21tYW5kJywge30pXG4gIGNvbnN0IGFwcFN0YXRlID0gY29udGV4dC5nZXRBcHBTdGF0ZSgpXG4gIGNvbnN0IHBlcm1pc3Npb25Db250ZXh0ID0gYXBwU3RhdGUudG9vbFBlcm1pc3Npb25Db250ZXh0XG4gIGNvbnN0IHRvb2xOYW1lcyA9IGdldFRvb2xzKHBlcm1pc3Npb25Db250ZXh0KS5tYXAodG9vbCA9PiB0b29sLm5hbWUpXG4gIHJldHVybiA8SG9va3NDb25maWdNZW51IHRvb2xOYW1lcz17dG9vbE5hbWVzfSBvbkV4aXQ9e29uRG9uZX0gLz5cbn1cbiJdLCJtYXBwaW5ncyI6IkFBQUEsT0FBTyxLQUFLQSxLQUFLLE1BQU0sT0FBTztBQUM5QixTQUFTQyxlQUFlLFFBQVEsMkNBQTJDO0FBQzNFLFNBQVNDLFFBQVEsUUFBUSxtQ0FBbUM7QUFDNUQsU0FBU0MsUUFBUSxRQUFRLGdCQUFnQjtBQUN6QyxjQUFjQyxtQkFBbUIsUUFBUSx3QkFBd0I7QUFFakUsT0FBTyxNQUFNQyxJQUFJLEVBQUVELG1CQUFtQixHQUFHLE1BQUFDLENBQU9DLE1BQU0sRUFBRUMsT0FBTyxLQUFLO0VBQ2xFTCxRQUFRLENBQUMscUJBQXFCLEVBQUUsQ0FBQyxDQUFDLENBQUM7RUFDbkMsTUFBTU0sUUFBUSxHQUFHRCxPQUFPLENBQUNFLFdBQVcsQ0FBQyxDQUFDO0VBQ3RDLE1BQU1DLGlCQUFpQixHQUFHRixRQUFRLENBQUNHLHFCQUFxQjtFQUN4RCxNQUFNQyxTQUFTLEdBQUdULFFBQVEsQ0FBQ08saUJBQWlCLENBQUMsQ0FBQ0csR0FBRyxDQUFDQyxJQUFJLElBQUlBLElBQUksQ0FBQ0MsSUFBSSxDQUFDO0VBQ3BFLE9BQU8sQ0FBQyxlQUFlLENBQUMsU0FBUyxDQUFDLENBQUNILFNBQVMsQ0FBQyxDQUFDLE1BQU0sQ0FBQyxDQUFDTixNQUFNLENBQUMsR0FBRztBQUNsRSxDQUFDIiwiaWdub3JlTGlzdCI6W119
|
||||
File diff suppressed because one or more lines are too long
@@ -70,7 +70,7 @@ If the user chose personal CLAUDE.local.md or both: ask about them, not the code
|
||||
- Only if Phase 2 found multiple git worktrees: ask whether their worktrees are nested inside the main repo (e.g., \`.claude/worktrees/<name>/\`) or siblings/external (e.g., \`../myrepo-feature/\`). If nested, the upward file walk finds the main repo's CLAUDE.local.md automatically — no special handling needed. If sibling/external, the personal content should live in a home-directory file (e.g., \`~/.claude/<project-name>-instructions.md\`) and each worktree gets a one-line CLAUDE.local.md stub that imports it: \`@~/.claude/<project-name>-instructions.md\`. Never put this import in the project CLAUDE.md — that would check a personal reference into the team-shared file.
|
||||
- Any communication preferences? (e.g., "be terse", "always explain tradeoffs", "don't summarize at the end")
|
||||
|
||||
**Synthesize a proposal from Phase 2 findings** — e.g., format-on-edit if a formatter exists, a \`/verify\` skill if tests exist, a CLAUDE.md note for anything from the gap-fill answers that's a guideline rather than a workflow. For each, pick the artifact type that fits, **constrained by the Phase 1 skills+hooks choice**:
|
||||
**Synthesize a proposal from Phase 2 findings** — e.g., format-on-edit if a formatter exists, a project verification workflow if tests exist, a CLAUDE.md note for anything from the gap-fill answers that's a guideline rather than a workflow. For each, pick the artifact type that fits, **constrained by the Phase 1 skills+hooks choice**:
|
||||
|
||||
- **Hook** (stricter) — deterministic shell command on a tool event; Claude can't skip it. Fits mechanical, fast, per-edit steps: formatting, linting, running a quick test on the changed file.
|
||||
- **Skill** (on-demand) — you or Claude invoke \`/skill-name\` when you want it. Fits workflows that don't belong on every edit: deep verification, session reports, deploys.
|
||||
@@ -85,7 +85,7 @@ If the user chose personal CLAUDE.local.md or both: ask about them, not the code
|
||||
- **Keep previews compact — the preview box truncates with no scrolling.** One line per item, no blank lines between items, no header. Example preview content:
|
||||
|
||||
• **Format-on-edit hook** (automatic) — \`ruff format <file>\` via PostToolUse
|
||||
• **/verify skill** (on-demand) — \`make lint && make typecheck && make test\`
|
||||
• **Verification workflow** (on-demand) — \`make lint && make typecheck && make test\`
|
||||
• **CLAUDE.md note** (guideline) — "run lint/typecheck/test before marking done"
|
||||
|
||||
- Option labels stay short ("Looks good", "Drop the hook", "Drop the skill") — the tool auto-adds an "Other" free-text option, so don't add your own catch-all.
|
||||
@@ -157,7 +157,7 @@ Skills add capabilities Claude can use on demand without bloating every session.
|
||||
|
||||
**First, consume \`skill\` entries from the Phase 3 preference queue.** Each queued skill preference becomes a SKILL.md tailored to what the user described. For each:
|
||||
- Name it from the preference (e.g., "verify-deep", "session-report", "deploy-sandbox")
|
||||
- Write the body using the user's own words from the interview plus whatever Phase 2 found (test commands, report format, deploy target). If the preference maps to an existing bundled skill (e.g., \`/verify\`), write a project skill that adds the user's specific constraints on top — tell the user the bundled one still exists and theirs is additive.
|
||||
- Write the body using the user's own words from the interview plus whatever Phase 2 found (test commands, report format, deploy target). If the preference maps to an existing project workflow, write a project skill that captures the user's specific constraints on top.
|
||||
- Ask a quick follow-up if the preference is underspecified (e.g., "which test command should verify-deep run?")
|
||||
|
||||
**Then suggest additional skills** beyond the queue when you find:
|
||||
|
||||
@@ -2187,7 +2187,7 @@ function generateHtmlReport(
|
||||
`
|
||||
: ''
|
||||
|
||||
// Build Team Feedback section (collapsible, ant-only)
|
||||
// Build Team Feedback section (collapsible, internal-only)
|
||||
const ccImprovements =
|
||||
process.env.USER_TYPE === 'ant'
|
||||
? insights.cc_team_improvements?.improvements || []
|
||||
@@ -2804,7 +2804,7 @@ export async function generateUsageReport(options?: {
|
||||
}> {
|
||||
let remoteStats: { hosts: RemoteHostInfo[]; totalCopied: number } | undefined
|
||||
|
||||
// Optionally collect data from remote hosts first (ant-only)
|
||||
// Optionally collect data from remote hosts first (internal-only)
|
||||
if (process.env.USER_TYPE === 'ant' && options?.collectRemote) {
|
||||
const destDir = join(getClaudeConfigHomeDir(), 'projects')
|
||||
const { hosts, totalCopied } = await collectAllRemoteHostData(destDir)
|
||||
@@ -3072,33 +3072,6 @@ const usageReport: Command = {
|
||||
let reportUrl = `file://${htmlPath}`
|
||||
let uploadHint = ''
|
||||
|
||||
if (process.env.USER_TYPE === 'ant') {
|
||||
// Try to upload to S3
|
||||
const timestamp = new Date()
|
||||
.toISOString()
|
||||
.replace(/[-:]/g, '')
|
||||
.replace('T', '_')
|
||||
.slice(0, 15)
|
||||
const username = process.env.SAFEUSER || process.env.USER || 'unknown'
|
||||
const filename = `${username}_insights_${timestamp}.html`
|
||||
const s3Path = `s3://anthropic-serve/atamkin/cc-user-reports/${filename}`
|
||||
const s3Url = `https://s3-frontend.infra.ant.dev/anthropic-serve/atamkin/cc-user-reports/${filename}`
|
||||
|
||||
reportUrl = s3Url
|
||||
try {
|
||||
execFileSync('ff', ['cp', htmlPath, s3Path], {
|
||||
timeout: 60000,
|
||||
stdio: 'pipe', // Suppress output
|
||||
})
|
||||
} catch {
|
||||
// Upload failed - fall back to local file and show upload command
|
||||
reportUrl = `file://${htmlPath}`
|
||||
uploadHint = `\nAutomatic upload failed. Are you on the boron namespace? Try \`use-bo\` and ensure you've run \`sso\`.
|
||||
To share, run: ff cp ${htmlPath} ${s3Path}
|
||||
Then access at: ${s3Url}`
|
||||
}
|
||||
}
|
||||
|
||||
// Build header with stats
|
||||
const sessionLabel =
|
||||
data.total_sessions_scanned &&
|
||||
@@ -3112,7 +3085,7 @@ Then access at: ${s3Url}`
|
||||
`${data.git_commits} commits`,
|
||||
].join(' · ')
|
||||
|
||||
// Build remote host info (ant-only)
|
||||
// Build remote host info (internal-only)
|
||||
let remoteInfo = ''
|
||||
if (process.env.USER_TYPE === 'ant') {
|
||||
if (remoteStats && remoteStats.totalCopied > 0) {
|
||||
|
||||
File diff suppressed because one or more lines are too long
File diff suppressed because one or more lines are too long
@@ -12,4 +12,3 @@ export function CheckGitHubStep() {
|
||||
}
|
||||
return t0;
|
||||
}
|
||||
//# sourceMappingURL=data:application/json;charset=utf-8;base64,eyJ2ZXJzaW9uIjozLCJuYW1lcyI6WyJSZWFjdCIsIlRleHQiLCJDaGVja0dpdEh1YlN0ZXAiLCIkIiwiX2MiLCJ0MCIsIlN5bWJvbCIsImZvciJdLCJzb3VyY2VzIjpbIkNoZWNrR2l0SHViU3RlcC50c3giXSwic291cmNlc0NvbnRlbnQiOlsiaW1wb3J0IFJlYWN0IGZyb20gJ3JlYWN0J1xuaW1wb3J0IHsgVGV4dCB9IGZyb20gJy4uLy4uL2luay5qcydcblxuZXhwb3J0IGZ1bmN0aW9uIENoZWNrR2l0SHViU3RlcCgpIHtcbiAgcmV0dXJuIDxUZXh0PkNoZWNraW5nIEdpdEh1YiBDTEkgaW5zdGFsbGF0aW9u4oCmPC9UZXh0PlxufVxuIl0sIm1hcHBpbmdzIjoiO0FBQUEsT0FBT0EsS0FBSyxNQUFNLE9BQU87QUFDekIsU0FBU0MsSUFBSSxRQUFRLGNBQWM7QUFFbkMsT0FBTyxTQUFBQyxnQkFBQTtFQUFBLE1BQUFDLENBQUEsR0FBQUMsRUFBQTtFQUFBLElBQUFDLEVBQUE7RUFBQSxJQUFBRixDQUFBLFFBQUFHLE1BQUEsQ0FBQUMsR0FBQTtJQUNFRixFQUFBLElBQUMsSUFBSSxDQUFDLGlDQUFpQyxFQUF0QyxJQUFJLENBQXlDO0lBQUFGLENBQUEsTUFBQUUsRUFBQTtFQUFBO0lBQUFBLEVBQUEsR0FBQUYsQ0FBQTtFQUFBO0VBQUEsT0FBOUNFLEVBQThDO0FBQUEiLCJpZ25vcmVMaXN0IjpbXX0=
|
||||
File diff suppressed because one or more lines are too long
File diff suppressed because one or more lines are too long
File diff suppressed because one or more lines are too long
File diff suppressed because one or more lines are too long
File diff suppressed because one or more lines are too long
File diff suppressed because one or more lines are too long
File diff suppressed because one or more lines are too long
File diff suppressed because one or more lines are too long
File diff suppressed because one or more lines are too long
@@ -33,4 +33,16 @@ test('rejects malformed or non-GitHub URLs', () => {
|
||||
assert.equal(extractGitHubRepoSlug('https://gitlab.com/Gitlawb/openclaude'), null)
|
||||
assert.equal(extractGitHubRepoSlug('https://github.com/Gitlawb'), null)
|
||||
assert.equal(extractGitHubRepoSlug('not actually github.com/Gitlawb/openclaude'), null)
|
||||
assert.equal(
|
||||
extractGitHubRepoSlug('https://evil.example/?next=github.com/Gitlawb/openclaude'),
|
||||
null,
|
||||
)
|
||||
assert.equal(
|
||||
extractGitHubRepoSlug('https://github.com.evil.example/Gitlawb/openclaude'),
|
||||
null,
|
||||
)
|
||||
assert.equal(
|
||||
extractGitHubRepoSlug('https://example.com/github.com/Gitlawb/openclaude'),
|
||||
null,
|
||||
)
|
||||
})
|
||||
|
||||
@@ -1,12 +1,24 @@
|
||||
export function extractGitHubRepoSlug(value: string): string | null {
|
||||
const trimmed = value.trim()
|
||||
|
||||
if (/^[a-z][a-z0-9+.-]*:\/\//i.test(trimmed) && !trimmed.includes('github.com')) {
|
||||
return null
|
||||
const slugMatch = trimmed.match(
|
||||
/^(?<owner>[^/:\s]+)\/(?<repo>[^/\s]+?)(?:\.git)?\/?$/i,
|
||||
)
|
||||
if (slugMatch?.groups?.owner && slugMatch.groups.repo) {
|
||||
return `${slugMatch.groups.owner}/${slugMatch.groups.repo}`.replace(
|
||||
/\.git$/i,
|
||||
'',
|
||||
)
|
||||
}
|
||||
|
||||
if (!trimmed.includes('github.com')) {
|
||||
return trimmed
|
||||
const shorthandUrlMatch = trimmed.match(
|
||||
/^(?:https?:\/\/)?(?:www\.)?github\.com\/(?<owner>[^/:\s]+)\/(?<repo>[^/\s]+?)(?:\.git)?\/?$/i,
|
||||
)
|
||||
if (shorthandUrlMatch?.groups?.owner && shorthandUrlMatch.groups.repo) {
|
||||
return `${shorthandUrlMatch.groups.owner}/${shorthandUrlMatch.groups.repo}`.replace(
|
||||
/\.git$/i,
|
||||
'',
|
||||
)
|
||||
}
|
||||
|
||||
const sshMatch = trimmed.match(
|
||||
@@ -16,6 +28,10 @@ export function extractGitHubRepoSlug(value: string): string | null {
|
||||
return `${sshMatch.groups.owner}/${sshMatch.groups.repo}`
|
||||
}
|
||||
|
||||
if (/^[a-z][a-z0-9+.-]*:\/\//i.test(trimmed)) {
|
||||
return null
|
||||
}
|
||||
|
||||
try {
|
||||
const parsed = new URL(trimmed)
|
||||
const hostname = parsed.hostname.toLowerCase()
|
||||
|
||||
File diff suppressed because one or more lines are too long
File diff suppressed because one or more lines are too long
File diff suppressed because one or more lines are too long
File diff suppressed because one or more lines are too long
Some files were not shown because too many files have changed in this diff Show More
Reference in New Issue
Block a user