Compare commits

..

18 Commits

Author SHA1 Message Date
github-actions[bot]
d2a057c6f1 chore(main): release 0.2.2 (#631)
Co-authored-by: github-actions[bot] <41898282+github-actions[bot]@users.noreply.github.com>
2026-04-13 01:03:57 +08:00
Jeevan Mohan Pawar
08cc6f3287 fix(read/edit): make compact line prefix unambiguous for tab-indented files (#613) 2026-04-13 01:00:33 +08:00
Kevin Codex
84fcc7f7e0 ci: publish npm in release workflow (#630) 2026-04-13 01:00:07 +08:00
github-actions[bot]
ad11414def chore(main): release 0.2.1 (#629)
Co-authored-by: github-actions[bot] <41898282+github-actions[bot]@users.noreply.github.com>
2026-04-13 00:41:31 +08:00
Jeevan Mohan Pawar
9419e8a4a2 fix(provider): add recovery guidance for missing OpenAI API key (#616) 2026-04-13 00:37:04 +08:00
Kevin Codex
41a86d05fa ci: publish from release events (#628) 2026-04-13 00:33:43 +08:00
Kevin Codex
fa4b6a96c0 Fix/manual publish current release (#627)
* ci: keep manual publish path for current release

* ci: fix trusted publishing metadata
2026-04-13 00:23:00 +08:00
Kevin Codex
d03d77b110 ci: keep manual publish path for current release (#626) 2026-04-13 00:18:43 +08:00
Kevin Codex
15de1d6190 Fix/release please invalid input (#624)
* ci: remove invalid release-please input

* ci: add npm publish debug diagnostics

* ci: allow manual publish of existing release tags
2026-04-12 23:59:19 +08:00
Kevin Codex
812facf024 Fix/release please invalid input (#622)
* ci: remove invalid release-please input

* ci: add npm publish debug diagnostics
2026-04-12 23:33:22 +08:00
Kevin Codex
2e39d2607a Fix/release please invalid input (#620)
* ci: remove invalid release-please input

* ci: add npm publish debug diagnostics
2026-04-12 23:24:39 +08:00
github-actions[bot]
a3633ac094 chore(main): release 0.2.0 (#617)
Co-authored-by: github-actions[bot] <41898282+github-actions[bot]@users.noreply.github.com>
2026-04-12 22:43:06 +08:00
Kevin Codex
3cefe2297d ci: remove invalid release-please input (#618) 2026-04-12 22:40:38 +08:00
Kevin Codex
40ac164501 ci: add secure automated release workflow (#615)
* ci: add secure automated release workflow

* ci: fix release-please action pin
2026-04-12 21:57:00 +08:00
ZhaoXiaoLuo
b3f3dc4e66 Prefer AGENTS.md over CLAUDE.md for project instructions (#439)
* Prefer AGENTS.md over CLAUDE.md for project instructions

* fix: preserve CLAUDE.md fallback behavior

* fix: isolate onboarding tests and preserve legacy init

* fix: restore full fsOperations exports in test mock and align compact cwd

* Fix onboarding test isolation and init migration guidance

* Tighten init prompt coverage and onboarding copy

* Handle nested project instruction paths consistently

* Fix NEW_INIT feature gate for Bun build

---------

Co-authored-by: 赵小落 <zhaoxiaoluo@zhaoxiaoluodeMac-mini.local>
Co-authored-by: zhaomo01 <zhaomo01@baidu.com>
2026-04-12 21:31:33 +08:00
Nourrisse Florian
2e0e14d713 fix: add LiteLLM-style aliases for GitHub Copilot context windows (#606)
The OPENAI_CONTEXT_WINDOWS/OPENAI_MAX_OUTPUT_TOKENS tables only contained
the `github:copilot:<model>` namespaced form used when talking directly to
Copilot via /onboard-github. When OpenClaude is pointed at a LiteLLM proxy
(which routes Copilot using the standard `github_copilot/<model>` convention),
the lookup missed and fell back to the conservative 8k default — causing the
compaction loop to fire repeatedly on every tick and blocking requests
before they left the client with repeated "not in context window table"
warnings on stderr.

Mirror the 11 active Copilot models with LiteLLM-style keys in both tables.
No behavior change for users of /onboard-github since namespaced entries
remain untouched and `lookupByKey` picks exact matches first.
2026-04-12 21:10:17 +08:00
euxaristia
a02c44143b fix(web-search): close SSRF bypasses in custom provider hostname guard (#610)
The previous `isPrivateHostname` used a list of regexes against
`URL.hostname`. Several literal-address forms slipped past it:

- IPv4-mapped IPv6 `[::ffff:127.0.0.1]` (WHATWG URL normalizes to
  `[::ffff:7f00:1]`, which no regex matched) — lets callers reach
  loopback and other private v4 via an IPv6 literal.
- ULA `fc00::/7` (e.g. `[fc00::1]`) — not covered.
- Link-local `fe80::/10` (e.g. `[fe80::1]`) — not covered.
- IPv4 `169.254.0.0/16` (cloud metadata, including 169.254.169.254),
  `100.64.0.0/10` (CGNAT), and the full `0.0.0.0/8` — not covered.
- The IPv6 regex `/^\[::1?\]$/` also required brackets, but `URL.hostname`
  returns bracketed form anyway, so this part happened to work.

WHATWG `new URL(...)` already normalizes short-form / numeric / hex /
octal IPv4 to dotted-quad before we see it, so those cases were in fact
handled — the remaining gaps were IPv6 and a few missing v4 ranges.

Replace the regex list with:
- a dotted-quad IPv4 parser + int range check covering 0/8, 10/8,
  100.64/10, 127/8, 169.254/16, 172.16/12, 192.168/16;
- a small IPv6 parser (handles `::` compression and embedded v4 suffix)
  + a byte-range check covering `::`, `::1`, IPv4-mapped (recursing
  into the v4 classifier), IPv4-compatible, `fc00::/7`, `fe80::/10`,
  and `fec0::/10`.

Export `isPrivateHostname` and add unit tests covering every bypass
listed above plus public-address negatives.

Co-authored-by: Claude Opus 4.6 <noreply@anthropic.com>
2026-04-12 21:09:46 +08:00
euxaristia
7817fe88bd fix(web-search): stop leaking abort listeners in custom provider retry (#611)
`fetchWithRetry` created a fresh `AbortController` per attempt and did:

    signal?.addEventListener('abort', () => controller.abort(), { once: true })

The listener was never removed. Consequences:

- On retry, a second listener was attached to the caller's signal,
  each closing over a different controller.
- After a successful fetch, the listener remained on the caller's
  signal indefinitely, referencing a controller whose work was done.
  For a long-lived caller signal this is a slow leak.
- The `{ once: true }` only helps if the signal actually fires — on
  non-aborted signals the listener stays attached forever.

Replace the manual controller + timer + listener dance with
`AbortSignal.any([signal, AbortSignal.timeout(ms)])`, which the
codebase already uses elsewhere (see src/services/mcp/xaa.ts). This:

- has no user-code listener to leak,
- gives each attempt a fresh independent timeout,
- cleanly distinguishes caller-initiated abort from timeout via
  `signal.aborted` vs `timeoutSignal.aborted` before rewriting the
  error as "Custom search timed out after Ns".

Also resets `lastStatus` per attempt so a 5xx on attempt 0 can't leak
into attempt 1's retry decision, and collapses the two redundant
retry branches (`lastStatus >= 500` and `lastStatus === undefined`)
into one.

Co-authored-by: Claude Opus 4.6 <noreply@anthropic.com>
2026-04-12 19:37:08 +08:00
32 changed files with 1095 additions and 163 deletions

88
.github/workflows/release.yml vendored Normal file
View File

@@ -0,0 +1,88 @@
name: Auto Release
on:
push:
branches:
- main
concurrency:
group: auto-release-${{ github.ref }}
cancel-in-progress: false
jobs:
release-please:
name: Release Please
runs-on: ubuntu-latest
permissions:
contents: write
pull-requests: write
outputs:
release_created: ${{ steps.release.outputs.release_created }}
tag_name: ${{ steps.release.outputs.tag_name }}
version: ${{ steps.release.outputs.version }}
steps:
- name: Run release-please
id: release
uses: googleapis/release-please-action@16a9c90856f42705d54a6fda1823352bdc62cf38
with:
token: ${{ secrets.GITHUB_TOKEN }}
release-type: node
publish-npm:
name: Publish to npm
needs: release-please
if: ${{ needs.release-please.outputs.release_created == 'true' }}
runs-on: ubuntu-latest
environment: release
permissions:
contents: read
id-token: write
steps:
- name: Checkout release tag
uses: actions/checkout@34e114876b0b11c390a56381ad16ebd13914f8d5
with:
ref: ${{ needs.release-please.outputs.tag_name }}
fetch-depth: 0
- name: Set up Node.js
uses: actions/setup-node@49933ea5288caeca8642d1e84afbd3f7d6820020
with:
node-version: 24
registry-url: https://registry.npmjs.org
- name: Set up Bun
uses: oven-sh/setup-bun@0c5077e51419868618aeaa5fe8019c62421857d6
with:
bun-version: 1.3.11
- name: Install dependencies
run: bun install --frozen-lockfile
- name: Run unit tests
run: bun test --max-concurrency=1
- name: Smoke test
run: bun run smoke
- name: Build
run: bun run build
- name: Dry-run package
run: npm pack --dry-run
- name: Clear token auth for trusted publishing
run: |
unset NODE_AUTH_TOKEN
echo "NODE_AUTH_TOKEN=" >> "$GITHUB_ENV"
- name: Publish to npm
run: npm publish --access public --provenance
- name: Release summary
run: |
{
echo "## Released ${{ needs.release-please.outputs.tag_name }}"
echo
echo "- npm: https://www.npmjs.com/package/@gitlawb/openclaude"
echo "- GitHub: https://github.com/Gitlawb/openclaude/releases/tag/${{ needs.release-please.outputs.tag_name }}"
} >> "$GITHUB_STEP_SUMMARY"

View File

@@ -0,0 +1,3 @@
{
".": "0.2.2"
}

61
CHANGELOG.md Normal file
View File

@@ -0,0 +1,61 @@
# Changelog
## [0.2.2](https://github.com/Gitlawb/openclaude/compare/v0.2.1...v0.2.2) (2026-04-12)
### Bug Fixes
* **read/edit:** make compact line prefix unambiguous for tab-indented files ([#613](https://github.com/Gitlawb/openclaude/issues/613)) ([08cc6f3](https://github.com/Gitlawb/openclaude/commit/08cc6f328711cd93ce9fa53351266c29a0b0a341))
## [0.2.1](https://github.com/Gitlawb/openclaude/compare/v0.2.0...v0.2.1) (2026-04-12)
### Bug Fixes
* **provider:** add recovery guidance for missing OpenAI API key ([#616](https://github.com/Gitlawb/openclaude/issues/616)) ([9419e8a](https://github.com/Gitlawb/openclaude/commit/9419e8a4a21b3771d9ddb10f7072e0a8c5b5b631))
## [0.2.0](https://github.com/Gitlawb/openclaude/compare/v0.1.8...v0.2.0) (2026-04-12)
### Features
* add /cache-probe diagnostic command ([#580](https://github.com/Gitlawb/openclaude/issues/580)) ([9ccaa7a](https://github.com/Gitlawb/openclaude/commit/9ccaa7a6759b6991f4a566b4118c06e68a2398fe)), closes [#515](https://github.com/Gitlawb/openclaude/issues/515)
* add auto-fix service — auto-lint and test after AI file edits ([#508](https://github.com/Gitlawb/openclaude/issues/508)) ([c385047](https://github.com/Gitlawb/openclaude/commit/c385047abba4366866f4c87bfb5e0b0bd4dcbb9d))
* Add Gemini support with thought_signature fix ([#404](https://github.com/Gitlawb/openclaude/issues/404)) ([5012c16](https://github.com/Gitlawb/openclaude/commit/5012c160c9a2dff9418e7ee19dc9a4d29ef2b024))
* add headless gRPC server for external agent integration ([#278](https://github.com/Gitlawb/openclaude/issues/278)) ([26eef92](https://github.com/Gitlawb/openclaude/commit/26eef92fe72e9c3958d61435b8d3571e12bf2b74))
* add wiki mvp commands ([#532](https://github.com/Gitlawb/openclaude/issues/532)) ([c328fdf](https://github.com/Gitlawb/openclaude/commit/c328fdf9e2fe59ad101b049301298ce9ff24caca))
* GitHub provider lifecycle and onboarding hardening ([#351](https://github.com/Gitlawb/openclaude/issues/351)) ([ff7d499](https://github.com/Gitlawb/openclaude/commit/ff7d49990de515825ddbe4099f3a39b944b61370))
### Bug Fixes
* add File polyfill for Node &lt; 20 to prevent startup deadlock with proxy ([#442](https://github.com/Gitlawb/openclaude/issues/442)) ([85aa8b0](https://github.com/Gitlawb/openclaude/commit/85aa8b0985c8f3cb8801efa5141114a0ab0f6a83))
* add GitHub Copilot model context windows and output limits ([#576](https://github.com/Gitlawb/openclaude/issues/576)) ([a7f5982](https://github.com/Gitlawb/openclaude/commit/a7f5982f6438ab0ddc3f0daae31ea68ac7ac206c)), closes [#515](https://github.com/Gitlawb/openclaude/issues/515)
* add LiteLLM-style aliases for GitHub Copilot context windows ([#606](https://github.com/Gitlawb/openclaude/issues/606)) ([2e0e14d](https://github.com/Gitlawb/openclaude/commit/2e0e14d71313e0e501efaa9e55c6c56f2742fb10))
* add store:false to Chat Completions and /responses fallback ([#578](https://github.com/Gitlawb/openclaude/issues/578)) ([8aaa4f2](https://github.com/Gitlawb/openclaude/commit/8aaa4f22ac5b942d82aa9cad54af30d56034515a))
* address code scanning alerts ([#434](https://github.com/Gitlawb/openclaude/issues/434)) ([e365cb4](https://github.com/Gitlawb/openclaude/commit/e365cb4010becabacd7cbccb4c3e59ea23a41e90))
* avoid sync github credential reads in provider manager ([#428](https://github.com/Gitlawb/openclaude/issues/428)) ([aff2bd8](https://github.com/Gitlawb/openclaude/commit/aff2bd87e4f2821992f74fb95481c505d0ba5d5d))
* convert dragged file paths to [@mentions](https://github.com/mentions) for attachment ([#382](https://github.com/Gitlawb/openclaude/issues/382)) ([112df59](https://github.com/Gitlawb/openclaude/commit/112df5911791ea71ee9efbb98ea59c5ded1ea161))
* custom web search — WEB_URL_TEMPLATE not recognized, timeout too short, silent native fallback ([#537](https://github.com/Gitlawb/openclaude/issues/537)) ([32fbd0c](https://github.com/Gitlawb/openclaude/commit/32fbd0c7b4168b32dcb13a5b69342e2727269201))
* defer startup checks and suppress recommendation dialogs during startup window (issue [#363](https://github.com/Gitlawb/openclaude/issues/363)) ([#504](https://github.com/Gitlawb/openclaude/issues/504)) ([2caf2fd](https://github.com/Gitlawb/openclaude/commit/2caf2fd982af1ec845c50152ad9d28d1a597f82f))
* display selected model in startup screen instead of hardcoded sonnet 4.6 ([#587](https://github.com/Gitlawb/openclaude/issues/587)) ([b126e38](https://github.com/Gitlawb/openclaude/commit/b126e38b1affddd2de83fcc3ba26f2e44b42a509))
* handle missing skill parameter in SkillTool ([#485](https://github.com/Gitlawb/openclaude/issues/485)) ([f9ce81b](https://github.com/Gitlawb/openclaude/commit/f9ce81bfb384e909353813fb6f6760cadd508ae7))
* include MCP tool results in microcompact to reduce token waste ([#348](https://github.com/Gitlawb/openclaude/issues/348)) ([52d33a8](https://github.com/Gitlawb/openclaude/commit/52d33a87a047b943aedaaaf772cd48636c263509))
* **ink:** restore host prop updates in React 19 reconciler ([#589](https://github.com/Gitlawb/openclaude/issues/589)) ([6e94dd9](https://github.com/Gitlawb/openclaude/commit/6e94dd913688b2d6433a9abe62a245c5f031b776))
* let saved provider profiles win on restart ([#513](https://github.com/Gitlawb/openclaude/issues/513)) ([cb8f8b7](https://github.com/Gitlawb/openclaude/commit/cb8f8b7ac2e3e74516ee219a3a48156db7c6ed78))
* normalize malformed Bash tool arguments from OpenAI-compatible providers ([#385](https://github.com/Gitlawb/openclaude/issues/385)) ([b4bd95b](https://github.com/Gitlawb/openclaude/commit/b4bd95b47715c9896240d708c106777507fd26ec))
* preserve only originally-required properties in strict tool schemas ([#471](https://github.com/Gitlawb/openclaude/issues/471)) ([ccaa193](https://github.com/Gitlawb/openclaude/commit/ccaa193eec5761f0972ffb58eb3189a81a9244b0))
* preserve unicode in Windows clipboard fallback ([#388](https://github.com/Gitlawb/openclaude/issues/388)) ([c193497](https://github.com/Gitlawb/openclaude/commit/c1934974aaf64db460cc850a044bd13cc744cce7))
* rebrand prompt identity to openclaude ([#496](https://github.com/Gitlawb/openclaude/issues/496)) ([598651f](https://github.com/Gitlawb/openclaude/commit/598651f42389ce76311ec00e8a9c701c939ead27))
* replace isDeepStrictEqual with navigation-aware options comparison ([#507](https://github.com/Gitlawb/openclaude/issues/507)) ([537c469](https://github.com/Gitlawb/openclaude/commit/537c469c3a2f7cb0eed05fa2f54dca57b6bc273f)), closes [#472](https://github.com/Gitlawb/openclaude/issues/472)
* report cache reads in streaming and correct cost calculation ([#577](https://github.com/Gitlawb/openclaude/issues/577)) ([f4ac709](https://github.com/Gitlawb/openclaude/commit/f4ac709fa6eda732bf45204fcab625ba6c5674b9))
* restore default context window for unknown 3p models ([#494](https://github.com/Gitlawb/openclaude/issues/494)) ([69ea1f1](https://github.com/Gitlawb/openclaude/commit/69ea1f1e4a99e9436215d8cb391a116a64442b94))
* restore Grep and Glob reliability on OpenAI paths ([#461](https://github.com/Gitlawb/openclaude/issues/461)) ([600c01f](https://github.com/Gitlawb/openclaude/commit/600c01faf761a080a2c7dede872ddbe05a132f23))
* restore Ollama auto-detect in first-run setup ([#561](https://github.com/Gitlawb/openclaude/issues/561)) ([68c2968](https://github.com/Gitlawb/openclaude/commit/68c296833dcef54ce44cb18b24357230b5204dbc))
* scrub canonical Anthropic headers from 3P shim requests ([#499](https://github.com/Gitlawb/openclaude/issues/499)) ([07621a6](https://github.com/Gitlawb/openclaude/commit/07621a6f8d0918170281869a47b5dbff90e71594))
* strip Anthropic params from 3P resume paths ([#479](https://github.com/Gitlawb/openclaude/issues/479)) ([4975cfc](https://github.com/Gitlawb/openclaude/commit/4975cfc2e0ddbe34aa4e8e3f52ee5eba07fbe465))
* suppress startup dialogs when input is buffered ([#423](https://github.com/Gitlawb/openclaude/issues/423)) ([8ece290](https://github.com/Gitlawb/openclaude/commit/8ece2900872dadd157e798ef501ddf126dac66c4))
* **tui:** restore prompt rendering on startup ([#498](https://github.com/Gitlawb/openclaude/issues/498)) ([e30ad17](https://github.com/Gitlawb/openclaude/commit/e30ad17ae0056787273be2caafd6cf5340b6ab57))
* update theme preview on focus change ([#562](https://github.com/Gitlawb/openclaude/issues/562)) ([6924718](https://github.com/Gitlawb/openclaude/commit/692471850fc789ee0797190089272407f9a4d953))
* **web-search:** close SSRF bypasses in custom provider hostname guard ([#610](https://github.com/Gitlawb/openclaude/issues/610)) ([a02c441](https://github.com/Gitlawb/openclaude/commit/a02c44143b257fbee7f38f1b93873cc0ea68a1f9))
* WebSearch providers + MCPTool bugs ([#593](https://github.com/Gitlawb/openclaude/issues/593)) ([91e4cfb](https://github.com/Gitlawb/openclaude/commit/91e4cfb15b62c04615834fd3c417fe38b4feb914))

View File

@@ -1,6 +1,6 @@
{
"name": "@gitlawb/openclaude",
"version": "0.1.8",
"version": "0.2.2",
"description": "Claude Code opened to any LLM — OpenAI, Gemini, DeepSeek, Ollama, and 200+ models",
"type": "module",
"bin": {
@@ -140,7 +140,7 @@
},
"repository": {
"type": "git",
"url": "https://gitlawb.com/z6MkqDnb7Siv3Cwj7pGJq4T5EsUisECqR8KpnDLwcaZq5TPr/openclaude"
"url": "https://github.com/Gitlawb/openclaude.git"
},
"keywords": [
"claude-code",

View File

@@ -0,0 +1,11 @@
{
"$schema": "https://raw.githubusercontent.com/googleapis/release-please/main/schemas/config.json",
"packages": {
".": {
"release-type": "node",
"package-name": "@gitlawb/openclaude",
"bump-minor-pre-major": true,
"include-v-in-tag": true
}
}
}

View File

@@ -45,7 +45,7 @@ function getPromptContent(
<!-- CHANGELOG:END -->`
let slackStep = `
5. After creating/updating the PR, check if the user's CLAUDE.md mentions posting to Slack channels. If it does, use ToolSearch to search for "slack send message" tools. If ToolSearch finds a Slack tool, ask the user if they'd like you to post the PR URL to the relevant Slack channel. Only post if the user confirms. If ToolSearch returns no results or errors, skip this step silently—do not mention the failure, do not attempt workarounds, and do not try alternative approaches.`
5. After creating/updating the PR, check if the user's AGENTS.md or CLAUDE.md mentions posting to Slack channels. If it does, use ToolSearch to search for "slack send message" tools. If ToolSearch finds a Slack tool, ask the user if they'd like you to post the PR URL to the relevant Slack channel. Only post if the user confirms. If ToolSearch returns no results or errors, skip this step silently—do not mention the failure, do not attempt workarounds, and do not try alternative approaches.`
if (process.env.USER_TYPE === 'ant' && isUndercover()) {
prefix = getUndercoverInstructions() + '\n'
reviewerArg = ''

43
src/commands/init.test.ts Normal file
View File

@@ -0,0 +1,43 @@
import { afterEach, expect, mock, test } from 'bun:test'
const originalClaudeCodeNewInit = process.env.CLAUDE_CODE_NEW_INIT
async function importInitCommand() {
return (await import(`./init.ts?ts=${Date.now()}-${Math.random()}`)).default
}
afterEach(() => {
mock.restore()
if (originalClaudeCodeNewInit === undefined) {
delete process.env.CLAUDE_CODE_NEW_INIT
} else {
process.env.CLAUDE_CODE_NEW_INIT = originalClaudeCodeNewInit
}
})
test('NEW_INIT prompt preserves existing root CLAUDE.md by default', async () => {
process.env.CLAUDE_CODE_NEW_INIT = '1'
mock.module('../projectOnboardingState.js', () => ({
maybeMarkProjectOnboardingComplete: () => {},
}))
mock.module('./initMode.js', () => ({
isNewInitEnabled: () => true,
}))
const command = await importInitCommand()
const blocks = await command.getPromptForCommand()
expect(blocks).toHaveLength(1)
expect(blocks[0]?.type).toBe('text')
expect(String(blocks[0]?.text)).toContain(
'checked-in root `CLAUDE.md` and does NOT already have a root `AGENTS.md`',
)
expect(String(blocks[0]?.text)).toContain(
'do NOT silently create a second root instruction file',
)
expect(String(blocks[0]?.text)).toContain(
'update the existing root `CLAUDE.md` in place by default',
)
})

View File

@@ -1,7 +1,6 @@
import { feature } from 'bun:bundle'
import type { Command } from '../commands.js'
import { maybeMarkProjectOnboardingComplete } from '../projectOnboardingState.js'
import { isEnvTruthy } from '../utils/envUtils.js'
import { isNewInitEnabled } from './initMode.js'
const OLD_INIT_PROMPT = `Please analyze this codebase and create a CLAUDE.md file, which will be given to future instances of Claude Code to operate in this repository.
@@ -25,19 +24,19 @@ Usage notes:
This file provides guidance to Claude Code (claude.ai/code) when working with code in this repository.
\`\`\``
const NEW_INIT_PROMPT = `Set up a minimal CLAUDE.md (and optionally skills and hooks) for this repo. CLAUDE.md is loaded into every Claude Code session, so it must be concise — only include what Claude would get wrong without it.
const NEW_INIT_PROMPT = `Set up a minimal AGENTS.md (and optionally CLAUDE.local.md, skills, and hooks) for this repo. The root project instruction file is loaded into every Claude Code session, so it must be concise — only include what Claude would get wrong without it.
## Phase 1: Ask what to set up
Use AskUserQuestion to find out what the user wants:
- "Which CLAUDE.md files should /init set up?"
Options: "Project CLAUDE.md" | "Personal CLAUDE.local.md" | "Both project + personal"
- "Which instruction files should /init set up?"
Options: "Project AGENTS.md" | "Personal CLAUDE.local.md" | "Both project + personal"
Description for project: "Team-shared instructions checked into source control — architecture, coding standards, common workflows."
Description for personal: "Your private preferences for this project (gitignored, not shared) — your role, sandbox URLs, preferred test data, workflow quirks."
- "Also set up skills and hooks?"
Options: "Skills + hooks" | "Skills only" | "Hooks only" | "Neither, just CLAUDE.md"
Options: "Skills + hooks" | "Skills only" | "Hooks only" | "Neither, just the instruction file(s)"
Description for skills: "On-demand capabilities you or Claude invoke with \`/skill-name\` — good for repeatable workflows and reference knowledge."
Description for hooks: "Deterministic shell commands that run on tool events (e.g., format after every edit). Claude can't skip them."
@@ -59,24 +58,24 @@ Note what you could NOT figure out from code alone — these become interview qu
## Phase 3: Fill in the gaps
Use AskUserQuestion to gather what you still need to write good CLAUDE.md files and skills. Ask only things the code can't answer.
Use AskUserQuestion to gather what you still need to write good instruction files and skills. Ask only things the code can't answer.
If the user chose project CLAUDE.md or both: ask about codebase practices — non-obvious commands, gotchas, branch/PR conventions, required env setup, testing quirks. Skip things already in README or obvious from manifest files. Do not mark any options as "recommended" — this is about how their team works, not best practices.
If the user chose project AGENTS.md or both: ask about codebase practices — non-obvious commands, gotchas, branch/PR conventions, required env setup, testing quirks. Skip things already in README or obvious from manifest files. Do not mark any options as "recommended" — this is about how their team works, not best practices.
If the user chose personal CLAUDE.local.md or both: ask about them, not the codebase. Do not mark any options as "recommended" — this is about their personal preferences, not best practices. Examples of questions:
- What's their role on the team? (e.g., "backend engineer", "data scientist", "new hire onboarding")
- How familiar are they with this codebase and its languages/frameworks? (so Claude can calibrate explanation depth)
- Do they have personal sandbox URLs, test accounts, API key paths, or local setup details Claude should know?
- 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.
- 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 AGENTS.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 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**:
**Synthesize a proposal from Phase 2 findings** — e.g., format-on-edit if a formatter exists, a project verification workflow if tests exist, an AGENTS.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.
- **CLAUDE.md note** (looser) — influences Claude's behavior but not enforced. Fits communication/thinking preferences: "plan before coding", "be terse", "explain tradeoffs".
- **AGENTS.md note** (looser) — influences Claude's behavior but not enforced. Fits communication/thinking preferences: "plan before coding", "be terse", "explain tradeoffs".
**Respect Phase 1's skills+hooks choice as a hard filter**: if the user picked "Skills only", downgrade any hook you'd suggest to a skill or a CLAUDE.md note. If "Hooks only", downgrade skills to hooks (where mechanically possible) or notes. If "Neither", everything becomes a CLAUDE.md note. Never propose an artifact type the user didn't opt into.
**Respect Phase 1's skills+hooks choice as a hard filter**: if the user picked "Skills only", downgrade any hook you'd suggest to a skill or an AGENTS.md note. If "Hooks only", downgrade skills to hooks (where mechanically possible) or notes. If "Neither", everything becomes an AGENTS.md note. Never propose an artifact type the user didn't opt into.
**Show the proposal via AskUserQuestion's \`preview\` field, not as a separate text message** — the dialog overlays your output, so preceding text is hidden. The \`preview\` field renders markdown in a side-panel (like plan mode); the \`question\` field is plain-text-only. Structure it as:
@@ -86,17 +85,19 @@ If the user chose personal CLAUDE.local.md or both: ask about them, not the code
• **Format-on-edit hook** (automatic) — \`ruff format <file>\` via PostToolUse
• **Verification workflow** (on-demand) — \`make lint && make typecheck && make test\`
• **CLAUDE.md note** (guideline) — "run lint/typecheck/test before marking done"
• **AGENTS.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.
**Build the preference queue** from the accepted proposal. Each entry: {type: hook|skill|note, description, target file, any Phase-2-sourced details like the actual test/format command}. Phases 4-7 consume this queue.
## Phase 4: Write CLAUDE.md (if user chose project or both)
## Phase 4: Write AGENTS.md (if user chose project or both)
Write a minimal CLAUDE.md at the project root. Every line must pass this test: "Would removing this cause Claude to make mistakes?" If no, cut it.
Write a minimal AGENTS.md at the project root. Every line must pass this test: "Would removing this cause Claude to make mistakes?" If no, cut it.
**Consume \`note\` entries from the Phase 3 preference queue whose target is CLAUDE.md** (team-level notes) — add each as a concise line in the most relevant section. These are the behaviors the user wants Claude to follow but didn't need guaranteed (e.g., "propose a plan before implementing", "explain the tradeoffs when refactoring"). Leave personal-targeted notes for Phase 5.
If the repo already has a checked-in root \`CLAUDE.md\` and does NOT already have a root \`AGENTS.md\`, do NOT silently create a second root instruction file. In that case, update the existing root \`CLAUDE.md\` in place by default. Only create or migrate to root \`AGENTS.md\` if the user explicitly asks to migrate.
**Consume \`note\` entries from the Phase 3 preference queue whose target is AGENTS.md** (team-level notes) — add each as a concise line in the most relevant section. These are the behaviors the user wants Claude to follow but didn't need guaranteed (e.g., "propose a plan before implementing", "explain the tradeoffs when refactoring"). Leave personal-targeted notes for Phase 5.
Include:
- Build/test/lint commands Claude can't guess (non-standard scripts, flags, or sequences)
@@ -111,7 +112,7 @@ Exclude:
- File-by-file structure or component lists (Claude can discover these by reading the codebase)
- Standard language conventions Claude already knows
- Generic advice ("write clean code", "handle errors")
- Detailed API docs or long references — use \`@path/to/import\` syntax instead (e.g., \`@docs/api-reference.md\`) to inline content on demand without bloating CLAUDE.md
- Detailed API docs or long references — use \`@path/to/import\` syntax instead (e.g., \`@docs/api-reference.md\`) to inline content on demand without bloating AGENTS.md
- Information that changes frequently — reference the source with \`@path/to/import\` so Claude always reads the current version
- Long tutorials or walkthroughs (move to a separate file and reference with \`@path/to/import\`, or put in a skill)
- Commands obvious from manifest files (e.g., standard "npm test", "cargo test", "pytest")
@@ -123,20 +124,20 @@ Do not repeat yourself and do not make up sections like "Common Development Task
Prefix the file with:
\`\`\`
# CLAUDE.md
# AGENTS.md
This file provides guidance to Claude Code (claude.ai/code) when working with code in this repository.
\`\`\`
If CLAUDE.md already exists: read it, propose specific changes as diffs, and explain why each change improves it. Do not silently overwrite.
If AGENTS.md already exists: read it, propose specific changes as diffs, and explain why each change improves it. Do not silently overwrite.
For projects with multiple concerns, suggest organizing instructions into \`.claude/rules/\` as separate focused files (e.g., \`code-style.md\`, \`testing.md\`, \`security.md\`). These are loaded automatically alongside CLAUDE.md and can be scoped to specific file paths using \`paths\` frontmatter.
For projects with multiple concerns, suggest organizing instructions into \`.claude/rules/\` as separate focused files (e.g., \`code-style.md\`, \`testing.md\`, \`security.md\`). These are loaded automatically alongside AGENTS.md and can be scoped to specific file paths using \`paths\` frontmatter.
For projects with distinct subdirectories (monorepos, multi-module projects, etc.): mention that subdirectory CLAUDE.md files can be added for module-specific instructions (they're loaded automatically when Claude works in those directories). Offer to create them if the user wants.
For projects with distinct subdirectories (monorepos, multi-module projects, etc.): mention that subdirectory AGENTS.md files can be added for module-specific instructions (they're loaded automatically when Claude works in those directories). Offer to create them if the user wants.
## Phase 5: Write CLAUDE.local.md (if user chose personal or both)
Write a minimal CLAUDE.local.md at the project root. This file is automatically loaded alongside CLAUDE.md. After creating it, add \`CLAUDE.local.md\` to the project's .gitignore so it stays private.
Write a minimal CLAUDE.local.md at the project root. This file is automatically loaded alongside AGENTS.md. After creating it, add \`CLAUDE.local.md\` to the project's .gitignore so it stays private.
**Consume \`note\` entries from the Phase 3 preference queue whose target is CLAUDE.local.md** (personal-level notes) — add each as a concise line. If the user chose personal-only in Phase 1, this is the sole consumer of note entries.
@@ -147,7 +148,7 @@ Include:
Keep it short — only include what would make Claude's responses noticeably better for this user.
If Phase 2 found multiple git worktrees and the user confirmed they use sibling/external worktrees (not nested inside the main repo): the upward file walk won't find a single CLAUDE.local.md from all worktrees. Write the actual personal content to \`~/.claude/<project-name>-instructions.md\` and make CLAUDE.local.md a one-line stub that imports it: \`@~/.claude/<project-name>-instructions.md\`. The user can copy this one-line stub to each sibling worktree. Never put this import in the project CLAUDE.md. If worktrees are nested inside the main repo (e.g., \`.claude/worktrees/\`), no special handling is needed — the main repo's CLAUDE.local.md is found automatically.
If Phase 2 found multiple git worktrees and the user confirmed they use sibling/external worktrees (not nested inside the main repo): the upward file walk won't find a single CLAUDE.local.md from all worktrees. Write the actual personal content to \`~/.claude/<project-name>-instructions.md\` and make CLAUDE.local.md a one-line stub that imports it: \`@~/.claude/<project-name>-instructions.md\`. The user can copy this one-line stub to each sibling worktree. Never put this import in the project AGENTS.md. If worktrees are nested inside the main repo (e.g., \`.claude/worktrees/\`), no special handling is needed — the main repo's CLAUDE.local.md is found automatically.
If CLAUDE.local.md already exists: read it, propose specific additions, and do not silently overwrite.
@@ -183,7 +184,7 @@ Both the user (\`/<skill-name>\`) and Claude can invoke skills by default. For w
## Phase 7: Suggest additional optimizations
Tell the user you're going to suggest a few additional optimizations now that CLAUDE.md and skills (if chosen) are in place.
Tell the user you're going to suggest a few additional optimizations now that AGENTS.md and skills (if chosen) are in place.
Check the environment and ask about each gap you find (use AskUserQuestion):
@@ -195,7 +196,7 @@ Check the environment and ask about each gap you find (use AskUserQuestion):
For each hook preference (from the queue or the formatter fallback):
1. Target file: default based on the Phase 1 CLAUDE.md choice — project → \`.claude/settings.json\` (team-shared, committed); personal → \`.claude/settings.local.json\`. Only ask if the user chose "both" in Phase 1 or the preference is ambiguous. Ask once for all hooks, not per-hook.
1. Target file: default based on the Phase 1 instruction-file choice — project → \`.claude/settings.json\` (team-shared, committed); personal → \`.claude/settings.local.json\`. Only ask if the user chose "both" in Phase 1 or the preference is ambiguous. Ask once for all hooks, not per-hook.
2. Pick the event and matcher from the preference:
- "after every edit" → \`PostToolUse\` with matcher \`Write|Edit\`
@@ -227,11 +228,9 @@ const command = {
type: 'prompt',
name: 'init',
get description() {
return feature('NEW_INIT') &&
(process.env.USER_TYPE === 'ant' ||
isEnvTruthy(process.env.CLAUDE_CODE_NEW_INIT))
? 'Initialize new CLAUDE.md file(s) and optional skills/hooks with codebase documentation'
: 'Initialize a new CLAUDE.md file with codebase documentation'
return isNewInitEnabled()
? 'Initialize new project instruction file(s) and optional skills/hooks with codebase documentation'
: 'Initialize a new project instruction file with codebase documentation'
},
contentLength: 0, // Dynamic content
progressMessage: 'analyzing your codebase',
@@ -242,12 +241,7 @@ const command = {
return [
{
type: 'text',
text:
feature('NEW_INIT') &&
(process.env.USER_TYPE === 'ant' ||
isEnvTruthy(process.env.CLAUDE_CODE_NEW_INIT))
? NEW_INIT_PROMPT
: OLD_INIT_PROMPT,
text: isNewInitEnabled() ? NEW_INIT_PROMPT : OLD_INIT_PROMPT,
},
]
},

13
src/commands/initMode.ts Normal file
View File

@@ -0,0 +1,13 @@
import { feature } from 'bun:bundle'
import { isEnvTruthy } from '../utils/envUtils.js'
export function isNewInitEnabled(): boolean {
if (feature('NEW_INIT')) {
return (
process.env.USER_TYPE === 'ant' ||
isEnvTruthy(process.env.CLAUDE_CODE_NEW_INIT)
)
}
return false
}

View File

@@ -1,3 +1,4 @@
import { c as _c } from "react-compiler-runtime";
import { feature } from 'bun:bundle';
import React, { createContext, useContext, useEffect, useMemo, useState } from 'react';
import useStdin from '../../ink/hooks/use-stdin.js';
@@ -119,8 +120,21 @@ export function ThemeProvider({
* accepts any ThemeSetting (including 'auto').
*/
export function useTheme() {
const { currentTheme, setThemeSetting } = useContext(ThemeContext);
return [currentTheme, setThemeSetting] as const;
const $ = _c(3);
const {
currentTheme,
setThemeSetting
} = useContext(ThemeContext);
let t0;
if ($[0] !== currentTheme || $[1] !== setThemeSetting) {
t0 = [currentTheme, setThemeSetting];
$[0] = currentTheme;
$[1] = setThemeSetting;
$[2] = t0;
} else {
t0 = $[2];
}
return t0;
}
/**
@@ -131,10 +145,25 @@ export function useThemeSetting() {
return useContext(ThemeContext).themeSetting;
}
export function usePreviewTheme() {
const { setPreviewTheme, savePreview, cancelPreview } = useContext(ThemeContext);
return {
const $ = _c(4);
const {
setPreviewTheme,
savePreview,
cancelPreview,
};
cancelPreview
} = useContext(ThemeContext);
let t0;
if ($[0] !== cancelPreview || $[1] !== savePreview || $[2] !== setPreviewTheme) {
t0 = {
setPreviewTheme,
savePreview,
cancelPreview
};
$[0] = cancelPreview;
$[1] = savePreview;
$[2] = setPreviewTheme;
$[3] = t0;
} else {
t0 = $[3];
}
return t0;
}

View File

@@ -2,7 +2,7 @@ import { c as _c } from "react-compiler-runtime";
import { feature } from 'bun:bundle';
import chalk from 'chalk';
import { mkdir } from 'fs/promises';
import { join } from 'path';
import { basename, join } from 'path';
import * as React from 'react';
import { use, useEffect, useState } from 'react';
import { getOriginalCwd } from '../../bootstrap/state.js';
@@ -24,6 +24,7 @@ import { projectIsInGitRepo } from '../../utils/memory/versions.js';
import { updateSettingsForSource } from '../../utils/settings/settings.js';
import { Select } from '../CustomSelect/index.js';
import { ListItem } from '../design-system/ListItem.js';
import { getProjectMemoryPathForSelector } from './memoryFileSelectorPaths.js';
/* eslint-disable @typescript-eslint/no-require-imports */
const teamMemPaths = feature('TEAMMEM') ? require('../../memdir/teamMemPaths.js') as typeof import('../../memdir/teamMemPaths.js') : null;
@@ -48,8 +49,10 @@ export function MemoryFileSelector(t0) {
onCancel
} = t0;
const existingMemoryFiles = use(getMemoryFiles());
const originalCwd = getOriginalCwd();
const userMemoryPath = join(getClaudeConfigHomeDir(), "CLAUDE.md");
const projectMemoryPath = join(getOriginalCwd(), "CLAUDE.md");
const projectMemoryPath = getProjectMemoryPathForSelector(existingMemoryFiles, originalCwd);
const projectMemoryFileName = basename(projectMemoryPath);
const hasUserMemory = existingMemoryFiles.some(f => f.path === userMemoryPath);
const hasProjectMemory = existingMemoryFiles.some(f_0 => f_0.path === projectMemoryPath);
const allMemoryFiles = [...existingMemoryFiles.filter(_temp).map(_temp2), ...(hasUserMemory ? [] : [{
@@ -85,12 +88,12 @@ export function MemoryFileSelector(t0) {
}
}
let description;
const isGit = projectIsInGitRepo(getOriginalCwd());
const isGit = projectIsInGitRepo(originalCwd);
if (file.type === "User" && !file.isNested) {
description = "Saved in ~/.claude/CLAUDE.md";
} else {
if (file.type === "Project" && !file.isNested && file.path === projectMemoryPath) {
description = `${isGit ? "Checked in at" : "Saved in"} ./CLAUDE.md`;
description = `${isGit ? "Checked in at" : "Saved in"} ./${projectMemoryFileName}`;
} else {
if (file.parent) {
description = "@-imported";

View File

@@ -0,0 +1,69 @@
import { describe, expect, test } from 'bun:test'
import { join } from 'node:path'
import type { MemoryFileInfo } from '../../utils/claudemd.js'
import { getProjectMemoryPathForSelector } from './memoryFileSelectorPaths.js'
function projectFile(path: string): MemoryFileInfo {
return {
path,
type: 'Project',
content: '',
}
}
describe('getProjectMemoryPathForSelector', () => {
test('uses the loaded repo-level AGENTS.md from a nested cwd', () => {
const repoDir = '/repo'
const nestedDir = join(repoDir, 'packages', 'app')
expect(
getProjectMemoryPathForSelector(
[projectFile(join(repoDir, 'AGENTS.md'))],
nestedDir,
),
).toBe(join(repoDir, 'AGENTS.md'))
})
test('uses the loaded repo-level CLAUDE.md fallback from a nested cwd', () => {
const repoDir = '/repo'
const nestedDir = join(repoDir, 'packages', 'app')
expect(
getProjectMemoryPathForSelector(
[projectFile(join(repoDir, 'CLAUDE.md'))],
nestedDir,
),
).toBe(join(repoDir, 'CLAUDE.md'))
})
test('prefers the closest loaded ancestor instruction file', () => {
const repoDir = '/repo'
const nestedProjectDir = join(repoDir, 'packages', 'app')
expect(
getProjectMemoryPathForSelector(
[
projectFile(join(repoDir, 'AGENTS.md')),
projectFile(join(nestedProjectDir, 'CLAUDE.md')),
],
join(nestedProjectDir, 'src'),
),
).toBe(join(nestedProjectDir, 'CLAUDE.md'))
})
test('defaults to a new AGENTS.md in the current cwd when no project file is loaded', () => {
expect(getProjectMemoryPathForSelector([], '/repo/packages/app')).toBe(
'/repo/packages/app/AGENTS.md',
)
})
test('ignores loaded project instruction files outside the current cwd ancestry', () => {
expect(
getProjectMemoryPathForSelector(
[projectFile('/other-worktree/AGENTS.md')],
'/repo/packages/app',
),
).toBe('/repo/packages/app/AGENTS.md')
})
})

View File

@@ -0,0 +1,34 @@
import { basename, join } from 'path'
import type { MemoryFileInfo } from '../../utils/claudemd.js'
import {
findProjectInstructionFilePathInAncestors,
isProjectInstructionFileName,
PRIMARY_PROJECT_INSTRUCTION_FILE,
} from '../../utils/projectInstructions.js'
function isLoadedProjectInstructionFile(file: MemoryFileInfo): boolean {
return (
file.type === 'Project' &&
file.parent === undefined &&
isProjectInstructionFileName(basename(file.path))
)
}
export function getProjectMemoryPathForSelector(
existingMemoryFiles: MemoryFileInfo[],
cwd: string,
): string {
const loadedProjectInstructionPaths = new Set(
existingMemoryFiles
.filter(isLoadedProjectInstructionFile)
.map(file => file.path),
)
return (
findProjectInstructionFilePathInAncestors(
cwd,
path => loadedProjectInstructionPaths.has(path),
) ?? join(cwd, PRIMARY_PROJECT_INSTRUCTION_FILE)
)
}

View File

@@ -0,0 +1,62 @@
import { afterEach, describe, expect, test } from 'bun:test'
import { mkdir, mkdtemp, rm, writeFile } from 'node:fs/promises'
import { tmpdir } from 'node:os'
import { join } from 'node:path'
import {
getSteps,
isProjectOnboardingComplete,
} from './projectOnboardingSteps.js'
import { runWithCwdOverride } from './utils/cwd.js'
let tempDir: string | undefined
afterEach(async () => {
if (tempDir) {
await rm(tempDir, { recursive: true, force: true })
tempDir = undefined
}
})
describe('project onboarding completion', () => {
test('is incomplete when neither AGENTS.md nor CLAUDE.md exists', async () => {
tempDir = await mkdtemp(join(tmpdir(), 'project-onboarding-'))
await runWithCwdOverride(tempDir, async () => {
expect(isProjectOnboardingComplete()).toBe(false)
expect(getSteps()[1]?.text).toContain('/init')
expect(getSteps()[1]?.text).toContain('AGENTS.md')
expect(getSteps()[1]?.text).toContain('CLAUDE.md')
})
})
test('is complete when only CLAUDE.md exists', async () => {
tempDir = await mkdtemp(join(tmpdir(), 'project-onboarding-'))
await writeFile(join(tempDir, 'CLAUDE.md'), '# CLAUDE.md\n')
await runWithCwdOverride(tempDir, async () => {
expect(isProjectOnboardingComplete()).toBe(true)
})
})
test('is complete when only AGENTS.md exists', async () => {
tempDir = await mkdtemp(join(tmpdir(), 'project-onboarding-'))
await writeFile(join(tempDir, 'AGENTS.md'), '# AGENTS.md\n')
await runWithCwdOverride(tempDir, async () => {
expect(isProjectOnboardingComplete()).toBe(true)
})
})
test('is complete from a nested cwd when repo instructions exist in an ancestor directory', async () => {
tempDir = await mkdtemp(join(tmpdir(), 'project-onboarding-'))
const nestedDir = join(tempDir, 'packages', 'app')
await writeFile(join(tempDir, 'AGENTS.md'), '# AGENTS.md\n')
await mkdir(nestedDir, { recursive: true })
await writeFile(join(nestedDir, 'index.ts'), 'export {}\n')
await runWithCwdOverride(nestedDir, async () => {
expect(isProjectOnboardingComplete()).toBe(true)
})
})
})

View File

@@ -1,50 +1,14 @@
import memoize from 'lodash-es/memoize.js'
import { join } from 'path'
import {
getCurrentProjectConfig,
saveCurrentProjectConfig,
} from './utils/config.js'
import { getCwd } from './utils/cwd.js'
import { isDirEmpty } from './utils/file.js'
import { getFsImplementation } from './utils/fsOperations.js'
export type Step = {
key: string
text: string
isComplete: boolean
isCompletable: boolean
isEnabled: boolean
}
export function getSteps(): Step[] {
const hasClaudeMd = getFsImplementation().existsSync(
join(getCwd(), 'CLAUDE.md'),
)
const isWorkspaceDirEmpty = isDirEmpty(getCwd())
return [
{
key: 'workspace',
text: 'Ask Claude to create a new app or clone a repository',
isComplete: false,
isCompletable: true,
isEnabled: isWorkspaceDirEmpty,
},
{
key: 'claudemd',
text: 'Run /init to create a CLAUDE.md file with instructions for Claude',
isComplete: hasClaudeMd,
isCompletable: true,
isEnabled: !isWorkspaceDirEmpty,
},
]
}
export function isProjectOnboardingComplete(): boolean {
return getSteps()
.filter(({ isCompletable, isEnabled }) => isCompletable && isEnabled)
.every(({ isComplete }) => isComplete)
}
export {
getSteps,
isProjectOnboardingComplete,
type Step,
} from './projectOnboardingSteps.js'
import { isProjectOnboardingComplete } from './projectOnboardingSteps.js'
export function maybeMarkProjectOnboardingComplete(): void {
// Short-circuit on cached config — isProjectOnboardingComplete() hits

View File

@@ -0,0 +1,44 @@
import { getCwd } from './utils/cwd.js'
import { isDirEmpty } from './utils/file.js'
import { getFsImplementation } from './utils/fsOperations.js'
import { findProjectInstructionFilePathInAncestors } from './utils/projectInstructions.js'
export type Step = {
key: string
text: string
isComplete: boolean
isCompletable: boolean
isEnabled: boolean
}
export function getSteps(): Step[] {
const hasRepoInstructions =
findProjectInstructionFilePathInAncestors(
getCwd(),
getFsImplementation().existsSync,
) !== null
const isWorkspaceDirEmpty = isDirEmpty(getCwd())
return [
{
key: 'workspace',
text: 'Ask Claude to create a new app or clone a repository',
isComplete: false,
isCompletable: true,
isEnabled: isWorkspaceDirEmpty,
},
{
key: 'claudemd',
text: 'Set up repo instructions (/init creates AGENTS.md or updates existing CLAUDE.md; either file counts)',
isComplete: hasRepoInstructions,
isCompletable: true,
isEnabled: !isWorkspaceDirEmpty,
},
]
}
export function isProjectOnboardingComplete(): boolean {
return getSteps()
.filter(({ isCompletable, isEnabled }) => isCompletable && isEnabled)
.every(({ isComplete }) => isComplete)
}

View File

@@ -9,7 +9,10 @@ const sessionTranscriptModule = feature('KAIROS')
import { APIUserAbortError } from '@anthropic-ai/sdk'
import { markPostCompaction } from 'src/bootstrap/state.js'
import { getInvokedSkillsForAgent } from '../../bootstrap/state.js'
import {
getInvokedSkillsForAgent,
getOriginalCwd,
} from '../../bootstrap/state.js'
import type { QuerySource } from '../../constants/querySource.js'
import type { CanUseToolFn } from '../../hooks/useCanUseTool.js'
import type { Tool, ToolUseContext } from '../../Tool.js'
@@ -68,6 +71,7 @@ import {
} from '../../utils/messages.js'
import { expandPath } from '../../utils/path.js'
import { getPlan, getPlanFilePath } from '../../utils/plans.js'
import { getProjectInstructionFilePaths } from '../../utils/projectInstructions.js'
import {
isSessionActivityTrackingActive,
sendSessionActivitySignal,
@@ -1689,8 +1693,13 @@ function shouldExcludeFromPostCompactRestore(
// and to also match child directory memory files (.claude/rules/*.md, etc.)
try {
const normalizedMemoryPaths = new Set(
MEMORY_TYPE_VALUES.map(type => expandPath(getMemoryPath(type))),
MEMORY_TYPE_VALUES.filter(type => type !== 'Project').map(type =>
expandPath(getMemoryPath(type)),
),
)
for (const path of getProjectInstructionFilePaths(getOriginalCwd())) {
normalizedMemoryPaths.add(expandPath(path))
}
if (normalizedMemoryPaths.has(normalizedFilename)) {
return true

View File

@@ -525,7 +525,10 @@ export const FileEditTool = buildTool({
})
// 7. Log events
if (absoluteFilePath.endsWith(`${sep}CLAUDE.md`)) {
if (
absoluteFilePath.endsWith(`${sep}AGENTS.md`) ||
absoluteFilePath.endsWith(`${sep}CLAUDE.md`)
) {
logEvent('tengu_write_claudemd', {})
}
countLinesChanged(patch)

View File

@@ -11,7 +11,7 @@ export function getEditToolDescription(): string {
function getDefaultEditDescription(): string {
const prefixFormat = isCompactLinePrefixEnabled()
? 'line number + tab'
? 'line number + arrow'
: 'spaces + line number + arrow'
const minimalUniquenessHint =
process.env.USER_TYPE === 'ant'

View File

@@ -336,8 +336,11 @@ export const FileWriteTool = buildTool({
limit: undefined,
})
// Log when writing to CLAUDE.md
if (fullFilePath.endsWith(`${sep}CLAUDE.md`)) {
// Log when writing to the root project instruction file
if (
fullFilePath.endsWith(`${sep}AGENTS.md`) ||
fullFilePath.endsWith(`${sep}CLAUDE.md`)
) {
logEvent('tengu_write_claudemd', {})
}

View File

@@ -1,5 +1,5 @@
import { describe, expect, test, beforeEach, afterEach } from 'bun:test'
import { extractHits, customProvider } from './custom.js'
import { extractHits, customProvider, isPrivateHostname } from './custom.js'
// ---------------------------------------------------------------------------
// extractHits — flexible response parsing
@@ -175,3 +175,94 @@ describe('buildAuthHeadersForPreset direct assertions', () => {
expect(buildAuthHeadersForPreset({ urlTemplate: '', queryParam: 'q', authHeader: 'Authorization' })).toEqual({})
})
})
// ---------------------------------------------------------------------------
// isPrivateHostname — SSRF guard
// ---------------------------------------------------------------------------
// Helper: route through new URL() the way validateUrl() does, so we exercise
// the same normalized hostname that production code sees.
const hostOf = (url: string) => new URL(url).hostname
describe('isPrivateHostname — IPv4', () => {
test('blocks localhost', () => {
expect(isPrivateHostname('localhost')).toBe(true)
expect(isPrivateHostname('LOCALHOST')).toBe(true)
})
test('blocks 127.0.0.0/8 loopback including short/numeric/hex/octal forms (via URL normalization)', () => {
expect(isPrivateHostname(hostOf('http://127.0.0.1/'))).toBe(true)
expect(isPrivateHostname(hostOf('http://127.1/'))).toBe(true)
expect(isPrivateHostname(hostOf('http://2130706433/'))).toBe(true)
expect(isPrivateHostname(hostOf('http://0x7f000001/'))).toBe(true)
expect(isPrivateHostname(hostOf('http://0177.0.0.1/'))).toBe(true)
})
test('blocks RFC1918 ranges', () => {
expect(isPrivateHostname('10.0.0.1')).toBe(true)
expect(isPrivateHostname('172.16.0.1')).toBe(true)
expect(isPrivateHostname('172.31.255.255')).toBe(true)
expect(isPrivateHostname('192.168.1.1')).toBe(true)
})
test('blocks 169.254.0.0/16 link-local (AWS/GCP metadata)', () => {
expect(isPrivateHostname('169.254.169.254')).toBe(true)
})
test('blocks 100.64.0.0/10 CGNAT', () => {
expect(isPrivateHostname('100.64.0.1')).toBe(true)
expect(isPrivateHostname('100.127.255.255')).toBe(true)
})
test('blocks 0.0.0.0/8', () => {
expect(isPrivateHostname('0.0.0.0')).toBe(true)
expect(isPrivateHostname('0.1.2.3')).toBe(true)
})
test('allows public IPv4', () => {
expect(isPrivateHostname('8.8.8.8')).toBe(false)
expect(isPrivateHostname('172.15.0.1')).toBe(false) // just outside 172.16/12
expect(isPrivateHostname('172.32.0.1')).toBe(false)
expect(isPrivateHostname('100.63.255.255')).toBe(false) // just outside CGNAT
expect(isPrivateHostname('100.128.0.0')).toBe(false)
})
test('allows regular hostnames', () => {
expect(isPrivateHostname('example.com')).toBe(false)
expect(isPrivateHostname('api.search.brave.com')).toBe(false)
})
})
describe('isPrivateHostname — IPv6', () => {
test('blocks ::1 loopback and :: unspecified', () => {
expect(isPrivateHostname(hostOf('http://[::1]/'))).toBe(true)
expect(isPrivateHostname(hostOf('http://[::]/'))).toBe(true)
})
test('blocks IPv4-mapped IPv6 pointing at private v4 (the previous bypass)', () => {
// WHATWG URL normalizes [::ffff:127.0.0.1] → [::ffff:7f00:1]; must still block.
expect(isPrivateHostname(hostOf('http://[::ffff:127.0.0.1]/'))).toBe(true)
expect(isPrivateHostname(hostOf('http://[::ffff:7f00:1]/'))).toBe(true)
expect(isPrivateHostname(hostOf('http://[::ffff:169.254.169.254]/'))).toBe(true)
expect(isPrivateHostname(hostOf('http://[::ffff:10.0.0.1]/'))).toBe(true)
})
test('blocks ULA fc00::/7', () => {
expect(isPrivateHostname(hostOf('http://[fc00::1]/'))).toBe(true)
expect(isPrivateHostname(hostOf('http://[fd12:3456:789a::1]/'))).toBe(true)
})
test('blocks link-local fe80::/10', () => {
expect(isPrivateHostname(hostOf('http://[fe80::1]/'))).toBe(true)
expect(isPrivateHostname(hostOf('http://[febf::1]/'))).toBe(true)
})
test('allows public IPv6', () => {
expect(isPrivateHostname(hostOf('http://[2001:4860:4860::8888]/'))).toBe(false)
expect(isPrivateHostname(hostOf('http://[2606:4700:4700::1111]/'))).toBe(false)
})
test('malformed IPv6 is not classified as private (URL parser rejects it upstream)', () => {
expect(isPrivateHostname('not:an:ipv6')).toBe(false)
})
})

View File

@@ -137,26 +137,147 @@ const SAFE_HEADER_NAMES = new Set([
])
/**
* Private / reserved IP ranges that should not be reachable from a
* search adapter (SSRF mitigation).
* Private / reserved address check for SSRF mitigation.
*
* This is a hostname-level check. DNS resolution to private IPs is
* NOT blocked here (that would require resolving before fetch, which
* Node fetch does not expose). This guard blocks obvious cases.
* Operates on the hostname produced by WHATWG `new URL(...)`, which already
* normalizes short-form, numeric, hex, and octal IPv4 to dotted-quad
* (e.g. `127.1`, `2130706433`, `0x7f000001`, `0177.0.0.1` → `127.0.0.1`),
* and which preserves IPv6 in bracketed compressed form
* (e.g. `[::ffff:127.0.0.1]` → `[::ffff:7f00:1]`).
*
* DNS resolution to private IPs is NOT blocked here — resolving before
* fetch is not exposed by Node's fetch. This guard blocks literal-address
* bypasses, which is what the original regex was trying (and failing) to do.
*/
const BLOCKED_HOSTNAME_PATTERNS = [
/^localhost$/i,
/^127\.\d+\.\d+\.\d+$/,
/^10\.\d+\.\d+\.\d+$/,
/^172\.(1[6-9]|2\d|3[01])\.\d+\.\d+$/,
/^192\.168\.\d+\.\d+$/,
/^0\.0\.0\.0$/,
/^\[::1?\]$/i, // [::1] or [::]
/^0x[0-9a-f]+$/i, // hex-encoded IPs
]
function isPrivateHostname(hostname: string): boolean {
return BLOCKED_HOSTNAME_PATTERNS.some(re => re.test(hostname))
function ipv4DottedToInt(ip: string): number | null {
const parts = ip.split('.')
if (parts.length !== 4) return null
let n = 0
for (const p of parts) {
if (!/^\d+$/.test(p)) return null
const x = Number(p)
if (!Number.isInteger(x) || x < 0 || x > 255) return null
n = n * 256 + x
}
return n >>> 0
}
function isPrivateIPv4Int(n: number): boolean {
const a = (n >>> 24) & 0xff
const b = (n >>> 16) & 0xff
// 0.0.0.0/8 "this network"
if (a === 0) return true
// 10.0.0.0/8
if (a === 10) return true
// 100.64.0.0/10 CGNAT
if (a === 100 && (b & 0xc0) === 0x40) return true
// 127.0.0.0/8 loopback
if (a === 127) return true
// 169.254.0.0/16 link-local
if (a === 169 && b === 254) return true
// 172.16.0.0/12
if (a === 172 && (b & 0xf0) === 0x10) return true
// 192.168.0.0/16
if (a === 192 && b === 168) return true
return false
}
/**
* Parse an IPv6 address (without brackets, zone id optional) to 16 bytes.
* Returns null on malformed input. Handles `::` compression and embedded
* IPv4 suffix (e.g. `::ffff:127.0.0.1`).
*/
function parseIPv6(input: string): Uint8Array | null {
let s = input.split('%')[0] ?? ''
if (s === '') return null
// Split off trailing embedded IPv4 if present
let trailingV4: [number, number, number, number] | null = null
const v4m = s.match(/^(.*:)(\d+\.\d+\.\d+\.\d+)$/)
if (v4m) {
const n = ipv4DottedToInt(v4m[2]!)
if (n === null) return null
trailingV4 = [(n >>> 24) & 0xff, (n >>> 16) & 0xff, (n >>> 8) & 0xff, n & 0xff]
s = v4m[1]!.replace(/:$/, '')
if (s === '') s = '::' // e.g. input was "::1.2.3.4"
}
const halves = s.split('::')
if (halves.length > 2) return null
const left = halves[0] ? halves[0]!.split(':') : []
const right = halves.length === 2 && halves[1] ? halves[1]!.split(':') : []
const groupsNeeded = 8 - (trailingV4 ? 2 : 0)
if (halves.length === 1 && left.length !== groupsNeeded) return null
if (halves.length === 2 && left.length + right.length > groupsNeeded) return null
const fill = halves.length === 2 ? groupsNeeded - left.length - right.length : 0
const groups = [...left, ...Array(fill).fill('0'), ...right]
const bytes = new Uint8Array(16)
for (let i = 0; i < groups.length; i++) {
const g = groups[i]!
if (!/^[0-9a-f]{1,4}$/i.test(g)) return null
const v = parseInt(g, 16)
bytes[i * 2] = (v >>> 8) & 0xff
bytes[i * 2 + 1] = v & 0xff
}
if (trailingV4) {
const off = groups.length * 2
bytes[off] = trailingV4[0]
bytes[off + 1] = trailingV4[1]
bytes[off + 2] = trailingV4[2]
bytes[off + 3] = trailingV4[3]
}
return bytes
}
function isPrivateIPv6(bytes: Uint8Array): boolean {
// ::1 loopback
let allZeroExceptLast = true
for (let i = 0; i < 15; i++) if (bytes[i] !== 0) { allZeroExceptLast = false; break }
if (allZeroExceptLast && bytes[15] === 1) return true
// :: unspecified
if (bytes.every(v => v === 0)) return true
// IPv4-mapped ::ffff:a.b.c.d
let isV4Mapped = true
for (let i = 0; i < 10; i++) if (bytes[i] !== 0) { isV4Mapped = false; break }
if (isV4Mapped && bytes[10] === 0xff && bytes[11] === 0xff) {
const n = ((bytes[12]! << 24) | (bytes[13]! << 16) | (bytes[14]! << 8) | bytes[15]!) >>> 0
return isPrivateIPv4Int(n)
}
// IPv4-compatible (deprecated) ::a.b.c.d — treat as private if embedded v4 is
let isV4Compat = true
for (let i = 0; i < 12; i++) if (bytes[i] !== 0) { isV4Compat = false; break }
if (isV4Compat) {
const n = ((bytes[12]! << 24) | (bytes[13]! << 16) | (bytes[14]! << 8) | bytes[15]!) >>> 0
if (n !== 0 && n !== 1) return isPrivateIPv4Int(n)
}
// ULA fc00::/7
if ((bytes[0]! & 0xfe) === 0xfc) return true
// Link-local fe80::/10
if (bytes[0] === 0xfe && (bytes[1]! & 0xc0) === 0x80) return true
// Site-local (deprecated) fec0::/10
if (bytes[0] === 0xfe && (bytes[1]! & 0xc0) === 0xc0) return true
return false
}
export function isPrivateHostname(hostname: string): boolean {
if (/^localhost$/i.test(hostname)) return true
// URL.hostname wraps IPv6 literals in brackets; strip for parsing.
const unwrapped = hostname.startsWith('[') && hostname.endsWith(']')
? hostname.slice(1, -1)
: hostname
// IPv4 dotted-quad (WHATWG URL normalizes short/numeric/hex/octal to this).
const v4 = ipv4DottedToInt(unwrapped)
if (v4 !== null) return isPrivateIPv4Int(v4)
// IPv6
if (unwrapped.includes(':')) {
const bytes = parseIPv6(unwrapped)
if (bytes) return isPrivateIPv6(bytes)
}
return false
}
/**
@@ -406,20 +527,18 @@ async function fetchWithRetry(url: string, init: RequestInit, signal?: AbortSign
let lastStatus: number | undefined
for (let attempt = 0; attempt < 2; attempt++) {
// Create a timeout that races with the external signal
const controller = new AbortController()
const timer = setTimeout(() => controller.abort(), timeoutMs)
// If the external signal is already aborted, forward it
if (signal?.aborted) {
controller.abort()
} else {
signal?.addEventListener('abort', () => controller.abort(), { once: true })
}
// Compose timeout with caller signal via AbortSignal.any so each attempt
// has a fresh timeout and we don't leak an abort listener on `signal`
// (the previous implementation added one per attempt and never removed
// it, and the listener kept a reference to a stale AbortController).
const timeoutSignal = AbortSignal.timeout(timeoutMs)
const combined = signal
? AbortSignal.any([signal, timeoutSignal])
: timeoutSignal
lastStatus = undefined
try {
const res = await fetch(url, { ...init, signal: controller.signal })
clearTimeout(timer)
const res = await fetch(url, { ...init, signal: combined })
if (!res.ok) {
lastStatus = res.status
@@ -427,26 +546,20 @@ async function fetchWithRetry(url: string, init: RequestInit, signal?: AbortSign
}
return await res.json()
} catch (err) {
clearTimeout(timer)
lastErr = err instanceof Error ? err : new Error(String(err))
// AbortError from timeout
if (lastErr.name === 'AbortError' && !signal?.aborted) {
// Caller-initiated abort wins — propagate without retry or rewrite.
if (signal?.aborted) throw lastErr
// Timeout (TimeoutError on Bun/Node, or AbortError with timeoutSignal aborted).
if (timeoutSignal.aborted) {
throw new Error(`Custom search timed out after ${timeoutSec}s`)
}
// Retry on 5xx or network errors only
if (attempt === 0) {
if (lastStatus !== undefined && lastStatus >= 500) {
await new Promise(r => setTimeout(r, 500))
continue
}
if (lastStatus === undefined) {
// Network error — retry
await new Promise(r => setTimeout(r, 500))
continue
}
// 4xx — don't retry
// Retry once on 5xx or network errors; do not retry 4xx.
if (attempt === 0 && (lastStatus === undefined || lastStatus >= 500)) {
await new Promise(r => setTimeout(r, 500))
continue
}
throw lastErr
}

View File

@@ -3,7 +3,7 @@
*
* 1. Managed memory (eg. /etc/claude-code/CLAUDE.md) - Global instructions for all users
* 2. User memory (~/.claude/CLAUDE.md) - Private global instructions for all projects
* 3. Project memory (CLAUDE.md, .claude/CLAUDE.md, and .claude/rules/*.md in project roots) - Instructions checked into the codebase
* 3. Project memory (AGENTS.md or fallback CLAUDE.md, plus .claude/CLAUDE.md and .claude/rules/*.md in project roots) - Instructions checked into the codebase
* 4. Local memory (CLAUDE.local.md in project roots) - Private project-specific instructions
*
* Files are loaded in reverse order of priority, i.e. the latest files are highest priority
@@ -13,7 +13,8 @@
* - User memory is loaded from the user's home directory
* - Project and Local files are discovered by traversing from the current directory up to root
* - Files closer to the current directory have higher priority (loaded later)
* - CLAUDE.md, .claude/CLAUDE.md, and all .md files in .claude/rules/ are checked in each directory for Project memory
* - AGENTS.md is preferred for root project instructions; CLAUDE.md is only used when AGENTS.md is absent
* - .claude/CLAUDE.md and all .md files in .claude/rules/ are checked in each directory for Project memory
*
* Memory @include directive:
* - Memory files can include other files using @ notation
@@ -75,6 +76,10 @@ import {
import type { MemoryType } from './memory/types.js'
import { expandPath } from './path.js'
import { pathInWorkingPath } from './permissions/filesystem.js'
import {
getProjectInstructionFilePath,
isProjectInstructionFileName,
} from './projectInstructions.js'
import { isSettingSourceEnabled } from './settings/constants.js'
import { getInitialSettings } from './settings/settings.js'
@@ -868,7 +873,7 @@ export const getMemoryFiles = memoize(
// When running from a git worktree nested inside its main repo (e.g.,
// .claude/worktrees/<name>/ from `claude -w`), the upward walk passes
// through both the worktree root and the main repo root. Both contain
// checked-in files like CLAUDE.md and .claude/rules/*.md, so the same
// checked-in files like AGENTS.md/CLAUDE.md and .claude/rules/*.md, so the same
// content gets loaded twice. Skip Project-type (checked-in) files from
// directories above the worktree but within the main repo — the worktree
// already has its own checkout. CLAUDE.local.md is gitignored so it only
@@ -892,9 +897,12 @@ export const getMemoryFiles = memoize(
pathInWorkingPath(dir, canonicalRoot) &&
!pathInWorkingPath(dir, gitRoot)
// Try reading CLAUDE.md (Project) - only if projectSettings is enabled
// Try reading the root project instruction file (AGENTS.md first, otherwise CLAUDE.md)
if (isSettingSourceEnabled('projectSettings') && !skipProject) {
const projectPath = join(dir, 'CLAUDE.md')
const projectPath = getProjectInstructionFilePath(
dir,
getFsImplementation().existsSync,
)
result.push(
...(await processMemoryFile(
projectPath,
@@ -942,15 +950,18 @@ export const getMemoryFiles = memoize(
}
}
// Process CLAUDE.md from additional directories (--add-dir) if env var is enabled
// Process root project instruction files from additional directories (--add-dir) if env var is enabled
// This is controlled by CLAUDE_CODE_ADDITIONAL_DIRECTORIES_CLAUDE_MD and defaults to off
// Note: we don't check isSettingSourceEnabled('projectSettings') here because --add-dir
// is an explicit user action and the SDK defaults settingSources to [] when not specified
if (isEnvTruthy(process.env.CLAUDE_CODE_ADDITIONAL_DIRECTORIES_CLAUDE_MD)) {
const additionalDirs = getAdditionalDirectoriesForClaudeMd()
for (const dir of additionalDirs) {
// Try reading CLAUDE.md from the additional directory
const projectPath = join(dir, 'CLAUDE.md')
// Try reading the root project instruction file from the additional directory
const projectPath = getProjectInstructionFilePath(
dir,
getFsImplementation().existsSync,
)
result.push(
...(await processMemoryFile(
projectPath,
@@ -1248,7 +1259,7 @@ export async function getManagedAndUserConditionalRules(
/**
* Gets memory files for a single nested directory (between CWD and target).
* Loads CLAUDE.md, unconditional rules, and conditional rules for that directory.
* Loads the root project instruction file, unconditional rules, and conditional rules for that directory.
*
* @param dir The directory to process
* @param targetPath The target file path (for conditional rule matching)
@@ -1262,9 +1273,12 @@ export async function getMemoryFilesForNestedDirectory(
): Promise<MemoryFileInfo[]> {
const result: MemoryFileInfo[] = []
// Process project memory files (CLAUDE.md and .claude/CLAUDE.md)
// Process project memory files (AGENTS.md first, otherwise CLAUDE.md, plus .claude/CLAUDE.md)
if (isSettingSourceEnabled('projectSettings')) {
const projectPath = join(dir, 'CLAUDE.md')
const projectPath = getProjectInstructionFilePath(
dir,
getFsImplementation().existsSync,
)
result.push(
...(await processMemoryFile(
projectPath,
@@ -1439,13 +1453,13 @@ export async function shouldShowClaudeMdExternalIncludesWarning(): Promise<boole
}
/**
* Check if a file path is a memory file (CLAUDE.md, CLAUDE.local.md, or .claude/rules/*.md)
* Check if a file path is a memory file (AGENTS.md, CLAUDE.md, CLAUDE.local.md, or .claude/rules/*.md)
*/
export function isMemoryFilePath(filePath: string): boolean {
const name = basename(filePath)
// CLAUDE.md or CLAUDE.local.md anywhere
if (name === 'CLAUDE.md' || name === 'CLAUDE.local.md') {
// Root instruction files or CLAUDE.local.md anywhere
if (isProjectInstructionFileName(name) || name === 'CLAUDE.local.md') {
return true
}

View File

@@ -31,6 +31,7 @@ import { normalizePathForConfigKey } from './path.js'
import { getEssentialTrafficOnlyReason } from './privacyLevel.js'
import { getManagedFilePath } from './settings/managedPath.js'
import type { ThemeSetting } from './theme.js'
import { PRIMARY_PROJECT_INSTRUCTION_FILE } from './projectInstructions.js'
/* eslint-disable @typescript-eslint/no-require-imports */
const teamMemPaths = feature('TEAMMEM')
@@ -1823,7 +1824,7 @@ export function getMemoryPath(memoryType: MemoryType): string {
case 'Local':
return join(cwd, 'CLAUDE.local.md')
case 'Project':
return join(cwd, 'CLAUDE.md')
return join(cwd, PRIMARY_PROJECT_INSTRUCTION_FILE)
case 'Managed':
return join(getManagedFilePath(), 'CLAUDE.md')
case 'AutoMem':

51
src/utils/file.test.ts Normal file
View File

@@ -0,0 +1,51 @@
import { afterEach, describe, expect, mock, test } from 'bun:test'
async function importFileModuleWithKillswitchEnabled(
killswitchEnabled: boolean,
) {
mock.module('../services/analytics/growthbook.js', () => ({
getFeatureValue_CACHED_MAY_BE_STALE: () => killswitchEnabled,
}))
return import(`./file.js?ts=${Date.now()}-${Math.random()}`)
}
afterEach(() => {
mock.restore()
})
describe('addLineNumbers', () => {
test('uses unambiguous arrow compact prefix and preserves leading tabs', async () => {
const { addLineNumbers } = await importFileModuleWithKillswitchEnabled(false)
const result = addLineNumbers({
content: '\tfirst\n\t\tsecond',
startLine: 41,
})
expect(result).toBe('41→\tfirst\n42→\t\tsecond')
})
test('keeps padded arrow format when compact mode is disabled', async () => {
const { addLineNumbers } = await importFileModuleWithKillswitchEnabled(true)
const result = addLineNumbers({
content: 'alpha\nbeta',
startLine: 1,
})
expect(result).toBe(' 1→alpha\n 2→beta')
})
})
describe('stripLineNumberPrefix', () => {
test('strips compact arrow, padded arrow, and legacy tab prefixes', async () => {
const { stripLineNumberPrefix } = await importFileModuleWithKillswitchEnabled(
false,
)
expect(stripLineNumberPrefix('41→\tfirst')).toBe('\tfirst')
expect(stripLineNumberPrefix(' 2→beta')).toBe('beta')
expect(stripLineNumberPrefix('7\t\tlegacy-tab')).toBe('\tlegacy-tab')
})
})

View File

@@ -267,7 +267,7 @@ export async function suggestPathUnderCwd(
}
/**
* Whether to use the compact line-number prefix format (`N\t` instead of
* Whether to use the compact line-number prefix format (`N` instead of
* ` N→`). The padded-arrow format costs 9 bytes/line overhead; at
* 1.35B Read calls × 132 lines avg this is 2.18% of fleet uncached input
* (bq-queries/read_line_prefix_overhead_verify.sql).
@@ -303,7 +303,7 @@ export function addLineNumbers({
if (isCompactLinePrefixEnabled()) {
return lines
.map((line, index) => `${index + startLine}\t${line}`)
.map((line, index) => `${index + startLine}${line}`)
.join('\n')
}

View File

@@ -18,6 +18,7 @@ const OPENAI_CONTEXT_WINDOWS: Record<string, number> = {
// Claude
'github:copilot:claude-sonnet-4': 216_000,
'github:copilot:claude-haiku-4': 200_000,
'github:copilot:claude-haiku-4.5': 144_000,
'github:copilot:claude-sonnet-4.5': 200_000,
'github:copilot:claude-sonnet-4.6': 200_000,
'github:copilot:claude-opus-4': 200_000,
@@ -46,6 +47,25 @@ const OPENAI_CONTEXT_WINDOWS: Record<string, number> = {
// Grok
'github:copilot:grok-code-fast-1': 256_000,
// LiteLLM format — when OpenClaude talks to a LiteLLM proxy, Copilot models
// keep their "<provider>/<model>" naming convention (standard LiteLLM routing)
// instead of the "github:copilot:<model>" namespaced form used by /onboard-github.
// Entries below cover the aliases currently exposed by LiteLLM's github_copilot
// provider — this is a curated subset, not an exhaustive mirror of the
// namespaced entries above. Values are sourced from copilotModels.ts to stay
// consistent with the /onboard-github path.
'github_copilot/claude-sonnet-4.6': 200_000,
'github_copilot/claude-opus-4.6': 200_000,
'github_copilot/claude-haiku-4.5': 144_000,
'github_copilot/gpt-4.1': 128_000,
'github_copilot/gpt-4o': 128_000,
'github_copilot/gpt-5-mini': 264_000,
'github_copilot/gpt-5.4': 400_000,
'github_copilot/gpt-5.4-mini': 400_000,
'github_copilot/gemini-2.5-pro': 128_000,
'github_copilot/gemini-3-flash': 128_000,
'github_copilot/grok-code-fast-1': 256_000,
// NOTE: bare Claude model names (e.g. 'claude-sonnet-4') are intentionally
// omitted. Different OpenAI-compatible providers may impose different context
// limits for the same model name, so we cannot safely hardcode values here.
@@ -127,6 +147,7 @@ const OPENAI_MAX_OUTPUT_TOKENS: Record<string, number> = {
// Claude
'github:copilot:claude-sonnet-4': 16_000,
'github:copilot:claude-haiku-4': 64_000,
'github:copilot:claude-haiku-4.5': 32_768,
'github:copilot:claude-sonnet-4.5': 32_000,
'github:copilot:claude-sonnet-4.6': 32_000,
'github:copilot:claude-opus-4': 32_000,
@@ -155,6 +176,19 @@ const OPENAI_MAX_OUTPUT_TOKENS: Record<string, number> = {
// Grok
'github:copilot:grok-code-fast-1': 64_000,
// LiteLLM format — see note on context windows above.
'github_copilot/claude-sonnet-4.6': 32_000,
'github_copilot/claude-opus-4.6': 32_000,
'github_copilot/claude-haiku-4.5': 32_768,
'github_copilot/gpt-4.1': 16_384,
'github_copilot/gpt-4o': 4_096,
'github_copilot/gpt-5-mini': 64_000,
'github_copilot/gpt-5.4': 128_000,
'github_copilot/gpt-5.4-mini': 128_000,
'github_copilot/gemini-2.5-pro': 64_000,
'github_copilot/gemini-3-flash': 64_000,
'github_copilot/grok-code-fast-1': 64_000,
// NOTE: bare Claude model names omitted — see context windows comment above.
// OpenAI

View File

@@ -0,0 +1,105 @@
import { describe, expect, test } from 'bun:test'
import { join } from 'node:path'
import {
findProjectInstructionFilePathInAncestors,
FALLBACK_PROJECT_INSTRUCTION_FILE,
getProjectInstructionFilePath,
getProjectInstructionFilePaths,
hasProjectInstructionFile,
isProjectInstructionFileName,
PRIMARY_PROJECT_INSTRUCTION_FILE,
} from './projectInstructions.js'
describe('projectInstructions', () => {
test('prefers AGENTS.md over CLAUDE.md for root project instructions', () => {
const dir = '/repo'
const existingPaths = new Set([
join(dir, PRIMARY_PROJECT_INSTRUCTION_FILE),
join(dir, FALLBACK_PROJECT_INSTRUCTION_FILE),
])
const filePath = getProjectInstructionFilePath(
dir,
path => existingPaths.has(path),
)
expect(filePath).toBe(join(dir, PRIMARY_PROJECT_INSTRUCTION_FILE))
})
test('falls back to CLAUDE.md when AGENTS.md is absent', () => {
const dir = '/repo'
const existingPaths = new Set([join(dir, FALLBACK_PROJECT_INSTRUCTION_FILE)])
const filePath = getProjectInstructionFilePath(
dir,
path => existingPaths.has(path),
)
expect(filePath).toBe(join(dir, FALLBACK_PROJECT_INSTRUCTION_FILE))
})
test('returns both candidate root instruction paths', () => {
const dir = '/repo'
expect(getProjectInstructionFilePaths(dir)).toEqual([
join(dir, PRIMARY_PROJECT_INSTRUCTION_FILE),
join(dir, FALLBACK_PROJECT_INSTRUCTION_FILE),
])
})
test('detects whether a repo instruction file exists', () => {
const dir = '/repo'
const existingPaths = new Set([join(dir, PRIMARY_PROJECT_INSTRUCTION_FILE)])
expect(hasProjectInstructionFile(dir, path => existingPaths.has(path))).toBe(
true,
)
expect(hasProjectInstructionFile(dir, () => false)).toBe(false)
})
test('recognizes AGENTS.md as a root instruction filename', () => {
expect(isProjectInstructionFileName(PRIMARY_PROJECT_INSTRUCTION_FILE)).toBe(
true,
)
expect(isProjectInstructionFileName(FALLBACK_PROJECT_INSTRUCTION_FILE)).toBe(
true,
)
expect(isProjectInstructionFileName('README.md')).toBe(false)
})
test('finds repo instructions in ancestor directories', () => {
const repoDir = '/repo'
const nestedDir = join(repoDir, 'packages', 'app')
const existingPaths = new Set([join(repoDir, PRIMARY_PROJECT_INSTRUCTION_FILE)])
expect(
findProjectInstructionFilePathInAncestors(
nestedDir,
path => existingPaths.has(path),
),
).toBe(join(repoDir, PRIMARY_PROJECT_INSTRUCTION_FILE))
})
test('prefers the closest ancestor project instruction file', () => {
const repoDir = '/repo'
const nestedProjectDir = join(repoDir, 'packages', 'app')
const existingPaths = new Set([
join(repoDir, PRIMARY_PROJECT_INSTRUCTION_FILE),
join(nestedProjectDir, FALLBACK_PROJECT_INSTRUCTION_FILE),
])
expect(
findProjectInstructionFilePathInAncestors(
join(nestedProjectDir, 'src'),
path => existingPaths.has(path),
),
).toBe(join(nestedProjectDir, FALLBACK_PROJECT_INSTRUCTION_FILE))
})
test('returns null when no ancestor repo instruction file exists', () => {
expect(
findProjectInstructionFilePathInAncestors('/repo/packages/app', () => false),
).toBeNull()
})
})

View File

@@ -0,0 +1,55 @@
import { dirname, join } from 'path'
export const PRIMARY_PROJECT_INSTRUCTION_FILE = 'AGENTS.md'
export const FALLBACK_PROJECT_INSTRUCTION_FILE = 'CLAUDE.md'
export function getProjectInstructionFilePaths(dir: string): string[] {
return [
join(dir, PRIMARY_PROJECT_INSTRUCTION_FILE),
join(dir, FALLBACK_PROJECT_INSTRUCTION_FILE),
]
}
export function getProjectInstructionFilePath(
dir: string,
existsSync: (path: string) => boolean,
): string {
const [primaryPath, fallbackPath] = getProjectInstructionFilePaths(dir)
return existsSync(primaryPath)
? primaryPath
: fallbackPath
}
export function hasProjectInstructionFile(
dir: string,
existsSync: (path: string) => boolean,
): boolean {
return getProjectInstructionFilePaths(dir).some(path => existsSync(path))
}
export function findProjectInstructionFilePathInAncestors(
startDir: string,
existsSync: (path: string) => boolean,
): string | null {
let currentDir = startDir
while (true) {
if (hasProjectInstructionFile(currentDir, existsSync)) {
return getProjectInstructionFilePath(currentDir, existsSync)
}
const parentDir = dirname(currentDir)
if (parentDir === currentDir) {
return null
}
currentDir = parentDir
}
}
export function isProjectInstructionFileName(name: string): boolean {
return (
name === PRIMARY_PROJECT_INSTRUCTION_FILE ||
name === FALLBACK_PROJECT_INSTRUCTION_FILE
)
}

View File

@@ -3,6 +3,9 @@ import { afterEach, expect, test } from 'bun:test'
import { getProviderValidationError } from './providerValidation.ts'
const originalEnv = {
CLAUDE_CODE_USE_OPENAI: process.env.CLAUDE_CODE_USE_OPENAI,
OPENAI_API_KEY: process.env.OPENAI_API_KEY,
OPENAI_BASE_URL: process.env.OPENAI_BASE_URL,
CLAUDE_CODE_USE_GEMINI: process.env.CLAUDE_CODE_USE_GEMINI,
GEMINI_API_KEY: process.env.GEMINI_API_KEY,
GOOGLE_API_KEY: process.env.GOOGLE_API_KEY,
@@ -20,6 +23,9 @@ function restoreEnv(key: string, value: string | undefined): void {
}
afterEach(() => {
restoreEnv('CLAUDE_CODE_USE_OPENAI', originalEnv.CLAUDE_CODE_USE_OPENAI)
restoreEnv('OPENAI_API_KEY', originalEnv.OPENAI_API_KEY)
restoreEnv('OPENAI_BASE_URL', originalEnv.OPENAI_BASE_URL)
restoreEnv('CLAUDE_CODE_USE_GEMINI', originalEnv.CLAUDE_CODE_USE_GEMINI)
restoreEnv('GEMINI_API_KEY', originalEnv.GEMINI_API_KEY)
restoreEnv('GOOGLE_API_KEY', originalEnv.GOOGLE_API_KEY)
@@ -71,3 +77,19 @@ test('still errors when no Gemini credential source is available', async () => {
'GEMINI_API_KEY, GOOGLE_API_KEY, GEMINI_ACCESS_TOKEN, or Google ADC credentials are required when CLAUDE_CODE_USE_GEMINI=1.',
)
})
test('openai missing key error includes recovery guidance and config locations', async () => {
process.env.CLAUDE_CODE_USE_OPENAI = '1'
process.env.OPENAI_BASE_URL = 'https://api.openai.com/v1'
delete process.env.OPENAI_API_KEY
const message = await getProviderValidationError(process.env)
expect(message).toContain(
'OPENAI_API_KEY is required when CLAUDE_CODE_USE_OPENAI=1 and OPENAI_BASE_URL is not local.',
)
expect(message).toContain(
'set CLAUDE_CODE_USE_OPENAI=0 in your shell environment',
)
expect(message).toContain('Saved startup settings can come from')
expect(message).toContain('.openclaude-profile.json')
})

View File

@@ -1,14 +1,16 @@
import { resolve } from 'node:path'
import {
getGithubEndpointType,
isLocalProviderUrl,
resolveCodexApiCredentials,
resolveProviderRequest,
} from '../services/api/providerConfig.js'
import { getGlobalClaudeFile } from './env.js'
import {
type GeminiResolvedCredential,
resolveGeminiCredential,
} from './geminiAuth.js'
import { redactSecretValueForDisplay } from './providerProfile.js'
import { PROFILE_FILE_NAME, redactSecretValueForDisplay } from './providerProfile.js'
function isEnvTruthy(value: string | undefined): boolean {
if (!value) return false
@@ -61,6 +63,17 @@ function checkGithubTokenStatus(
return 'valid'
}
function getOpenAIMissingKeyMessage(): string {
const globalConfigPath = getGlobalClaudeFile()
const profilePath = resolve(process.cwd(), PROFILE_FILE_NAME)
return [
'OPENAI_API_KEY is required when CLAUDE_CODE_USE_OPENAI=1 and OPENAI_BASE_URL is not local.',
`To recover, run /provider and switch provider, or set CLAUDE_CODE_USE_OPENAI=0 in your shell environment.`,
`Saved startup settings can come from ${globalConfigPath} or ${profilePath}.`,
].join('\n')
}
export async function getProviderValidationError(
env: NodeJS.ProcessEnv = process.env,
options?: {
@@ -137,7 +150,7 @@ export async function getProviderValidationError(
if (useGithub && hasGithubToken) {
return null
}
return 'OPENAI_API_KEY is required when CLAUDE_CODE_USE_OPENAI=1 and OPENAI_BASE_URL is not local.'
return getOpenAIMissingKeyMessage()
}
return null

View File

@@ -1082,10 +1082,10 @@ export const SettingsSchema = lazySchema(() =>
.array(z.string())
.optional()
.describe(
'Glob patterns or absolute paths of CLAUDE.md files to exclude from loading. ' +
'Glob patterns or absolute paths of AGENTS.md/CLAUDE.md files to exclude from loading. ' +
'Patterns are matched against absolute file paths using picomatch. ' +
'Only applies to User, Project, and Local memory types (Managed/policy files cannot be excluded). ' +
'Examples: "/home/user/monorepo/CLAUDE.md", "**/code/CLAUDE.md", "**/some-dir/.claude/rules/**"',
'Examples: "/home/user/monorepo/AGENTS.md", "**/code/CLAUDE.md", "**/some-dir/.claude/rules/**"',
),
pluginTrustMessage: z
.string()