rebrand better-clawd and ship initial npm-ready release

This commit is contained in:
x1xhlol
2026-04-01 16:51:18 +02:00
Unverified
parent 420d4155ec
commit 407fa14d6f
109 changed files with 4155 additions and 1690 deletions

17
.gitignore vendored Normal file
View File

@@ -0,0 +1,17 @@
node_modules/
dist/
.env
.env.*
.npmrc
*.log
npm-debug.log*
bun-debug.log*
*.tgz
package-lock.json
*.tsbuildinfo
.DS_Store
Thumbs.db

BIN
README.md

Binary file not shown.

2
bin/better-clawd.js Normal file
View File

@@ -0,0 +1,2 @@
#!/usr/bin/env node
import '../dist/cli.mjs'

1451
bun.lock Normal file

File diff suppressed because it is too large Load Diff

123
package.json Normal file
View File

@@ -0,0 +1,123 @@
{
"name": "better-clawd",
"version": "0.1.0",
"description": "Claude Code, but better.",
"type": "module",
"bin": {
"better-clawd": "bin/better-clawd.js"
},
"files": [
"bin/",
"dist/cli.mjs",
"schemas/",
"README.md"
],
"scripts": {
"build": "bun run scripts/build.ts",
"start": "node dist/cli.mjs",
"typecheck": "tsc --noEmit",
"smoke": "bun run build && node dist/cli.mjs --version",
"prepack": "npm run build"
},
"dependencies": {
"@alcalzone/ansi-tokenize": "^0.3.0",
"@anthropic-ai/bedrock-sdk": "^0.26.0",
"@anthropic-ai/foundry-sdk": "^0.2.0",
"@anthropic-ai/sandbox-runtime": "^0.0.46",
"@anthropic-ai/sdk": "^0.81.0",
"@anthropic-ai/vertex-sdk": "^0.14.0",
"@commander-js/extra-typings": "^12.0.0",
"@growthbook/growthbook": "^1.3.0",
"@modelcontextprotocol/sdk": "^1.12.0",
"@opentelemetry/api": "^1.9.1",
"@opentelemetry/api-logs": "^0.214.0",
"@opentelemetry/core": "^2.6.1",
"@opentelemetry/exporter-logs-otlp-http": "^0.214.0",
"@opentelemetry/exporter-trace-otlp-grpc": "^0.57.0",
"@opentelemetry/resources": "^2.6.1",
"@opentelemetry/sdk-logs": "^0.214.0",
"@opentelemetry/sdk-metrics": "^2.6.1",
"@opentelemetry/sdk-trace-base": "^2.6.1",
"@opentelemetry/sdk-trace-node": "^2.6.1",
"@opentelemetry/semantic-conventions": "^1.40.0",
"ajv": "^8.17.0",
"auto-bind": "^5.0.1",
"axios": "^1.14.0",
"bidi-js": "^1.0.3",
"chalk": "^5.4.0",
"chokidar": "^4.0.0",
"cli-boxes": "^3.0.0",
"cli-highlight": "^2.1.0",
"code-excerpt": "^4.0.0",
"commander": "^12.0.0",
"diff": "^7.0.0",
"emoji-regex": "^10.4.0",
"env-paths": "^3.0.0",
"execa": "^9.5.0",
"fflate": "^0.8.2",
"figures": "^6.1.0",
"fuse.js": "^7.1.0",
"get-east-asian-width": "^1.3.0",
"google-auth-library": "^9.15.0",
"https-proxy-agent": "^7.0.6",
"ignore": "^7.0.0",
"indent-string": "^5.0.0",
"jsonc-parser": "^3.3.1",
"lodash-es": "^4.17.21",
"lru-cache": "^11.0.0",
"marked": "^15.0.0",
"p-map": "^7.0.3",
"picomatch": "^4.0.0",
"proper-lockfile": "^4.1.2",
"qrcode": "^1.5.4",
"react": "^19.2.4",
"react-compiler-runtime": "^1.0.0",
"react-reconciler": "^0.33.0",
"semver": "^7.6.3",
"shell-quote": "^1.8.2",
"signal-exit": "^4.1.0",
"stack-utils": "^2.0.6",
"strip-ansi": "^7.1.0",
"supports-hyperlinks": "^3.1.0",
"tree-kill": "^1.2.2",
"turndown": "^7.2.0",
"type-fest": "^4.30.0",
"undici": "^7.3.0",
"usehooks-ts": "^3.1.1",
"vscode-languageserver-protocol": "^3.17.5",
"wrap-ansi": "^9.0.0",
"ws": "^8.18.0",
"xss": "^1.0.15",
"yaml": "^2.7.0",
"zod": "^3.24.0"
},
"devDependencies": {
"@types/bun": "^1.2.0",
"@types/node": "^25.5.0",
"@types/react": "^19.2.14",
"typescript": "^5.7.0"
},
"engines": {
"node": ">=20.0.0"
},
"repository": {
"type": "git",
"url": "git+https://github.com/x1xhlol/better-clawd.git"
},
"homepage": "https://github.com/x1xhlol/better-clawd",
"bugs": {
"url": "https://github.com/x1xhlol/better-clawd/issues"
},
"keywords": [
"claude-code",
"openai",
"llm",
"cli",
"agent"
],
"license": "MIT",
"publishConfig": {
"access": "public"
},
"packageManager": "bun@1.2.15"
}

View File

@@ -0,0 +1,8 @@
{
"$schema": "https://json-schema.org/draft/2020-12/schema",
"$id": "https://raw.githubusercontent.com/x1xhlol/better-clawd/main/schemas/better-clawd-settings.schema.json",
"title": "Better-Clawd Settings",
"type": "object",
"description": "Placeholder schema for Better-Clawd settings. Runtime validation is enforced by the application.",
"additionalProperties": true
}

291
scripts/build.ts Normal file
View File

@@ -0,0 +1,291 @@
import { readFileSync } from 'fs'
const pkg = JSON.parse(readFileSync('./package.json', 'utf-8'))
const version = pkg.version
const result = await Bun.build({
entrypoints: ['./src/entrypoints/cli.tsx'],
outdir: './dist',
target: 'node',
format: 'esm',
splitting: false,
sourcemap: 'external',
minify: false,
naming: 'cli.mjs',
define: {
'MACRO.VERSION': JSON.stringify('99.0.0'),
'MACRO.DISPLAY_VERSION': JSON.stringify(version),
'MACRO.BUILD_TIME': JSON.stringify(new Date().toISOString()),
'MACRO.ISSUES_EXPLAINER': JSON.stringify(
'report the issue at https://github.com/x1xhlol/better-clawd/issues',
),
},
plugins: [
{
name: 'better-clawd-build-shim',
setup(build) {
const internalFeatureStubModules = new Map([
[
'../daemon/workerRegistry.js',
'export async function runDaemonWorker() { throw new Error("Daemon worker is unavailable in this Better-Clawd build."); }',
],
[
'../daemon/main.js',
'export async function daemonMain() { throw new Error("Daemon mode is unavailable in this Better-Clawd build."); }',
],
[
'../cli/bg.js',
`
export async function psHandler() { throw new Error("Background sessions are unavailable in this Better-Clawd build."); }
export async function logsHandler() { throw new Error("Background sessions are unavailable in this Better-Clawd build."); }
export async function attachHandler() { throw new Error("Background sessions are unavailable in this Better-Clawd build."); }
export async function killHandler() { throw new Error("Background sessions are unavailable in this Better-Clawd build."); }
export async function handleBgFlag() { throw new Error("Background sessions are unavailable in this Better-Clawd build."); }
`,
],
[
'../cli/handlers/templateJobs.js',
'export async function templatesMain() { throw new Error("Template jobs are unavailable in this Better-Clawd build."); }',
],
[
'../environment-runner/main.js',
'export async function environmentRunnerMain() { throw new Error("Environment runner is unavailable in this Better-Clawd build."); }',
],
[
'../self-hosted-runner/main.js',
'export async function selfHostedRunnerMain() { throw new Error("Self-hosted runner is unavailable in this Better-Clawd build."); }',
],
[
'./components/agents/SnapshotUpdateDialog.js',
'export function SnapshotUpdateDialog() { return null; }',
],
[
'./assistant/AssistantSessionChooser.js',
'export function AssistantSessionChooser() { return null; }',
],
[
'./commands/assistant/assistant.js',
"export function NewInstallWizard() { return null; } export async function computeDefaultInstallDir() { return '.better-clawd-assistant'; }",
],
[
'./commands/agents-platform/index.js',
'export default async function agentsPlatformCommand() { throw new Error("Agents platform is unavailable in this Better-Clawd build."); }',
],
[
'./tools/TungstenTool/TungstenTool.js',
"export const TungstenTool = { name: 'TungstenTool' };",
],
[
'../tools/TungstenTool/TungstenTool.js',
"export const TungstenTool = { name: 'TungstenTool' };",
],
[
'../../tools/TungstenTool/TungstenTool.js',
"export const TungstenTool = { name: 'TungstenTool' };",
],
[
'../tools/TungstenTool/TungstenLiveMonitor.js',
'export function TungstenLiveMonitor() { return null; }',
],
[
'./tools/REPLTool/REPLTool.js',
"export const REPLTool = { name: 'REPLTool' };",
],
[
'./tools/SuggestBackgroundPRTool/SuggestBackgroundPRTool.js',
"export const SuggestBackgroundPRTool = { name: 'SuggestBackgroundPRTool' };",
],
[
'./tools/VerifyPlanExecutionTool/VerifyPlanExecutionTool.js',
"export const VerifyPlanExecutionTool = { name: 'VerifyPlanExecutionTool' };",
],
] as const)
build.onResolve({ filter: /^bun:bundle$/ }, () => ({
path: 'bun:bundle',
namespace: 'bun-bundle-shim',
}))
build.onLoad(
{ filter: /.*/, namespace: 'bun-bundle-shim' },
() => ({
contents: `export function feature() { return false; }`,
loader: 'js',
}),
)
build.onResolve(
{
filter:
/^(\.\.\/(daemon\/workerRegistry|daemon\/main|cli\/bg|cli\/handlers\/templateJobs|environment-runner\/main|self-hosted-runner\/main)|\.\/(components\/agents\/SnapshotUpdateDialog|assistant\/AssistantSessionChooser|commands\/assistant\/assistant|commands\/agents-platform\/index|tools\/REPLTool\/REPLTool|tools\/SuggestBackgroundPRTool\/SuggestBackgroundPRTool|tools\/VerifyPlanExecutionTool\/VerifyPlanExecutionTool|tools\/TungstenTool\/TungstenTool)|\.\.\/tools\/TungstenTool\/Tungsten(LiveMonitor|Tool)|\.\.\/\.\.\/tools\/TungstenTool\/TungstenTool)\.js$/,
},
args => {
if (!internalFeatureStubModules.has(args.path)) return null
return {
path: args.path,
namespace: 'internal-feature-stub',
}
},
)
build.onLoad(
{ filter: /.*/, namespace: 'internal-feature-stub' },
args => ({
contents: internalFeatureStubModules.get(args.path) ?? 'export {}',
loader: 'js',
}),
)
build.onResolve({ filter: /^react\/compiler-runtime$/ }, () => ({
path: 'react/compiler-runtime',
namespace: 'react-compiler-shim',
}))
build.onLoad(
{ filter: /.*/, namespace: 'react-compiler-shim' },
() => ({
contents:
"export function c(size) { return new Array(size).fill(Symbol.for('react.memo_cache_sentinel')); }",
loader: 'js',
}),
)
for (const mod of [
'audio-capture-napi',
'audio-capture.node',
'image-processor-napi',
'modifiers-napi',
'url-handler-napi',
'color-diff-napi',
'sharp',
'@anthropic-ai/mcpb',
'@ant/claude-for-chrome-mcp',
'@ant/computer-use-mcp',
'@anthropic-ai/sandbox-runtime',
'asciichart',
'plist',
'cacache',
'fuse',
'code-excerpt',
'stack-utils',
]) {
build.onResolve({ filter: new RegExp(`^${mod}$`) }, () => ({
path: mod,
namespace: 'native-stub',
}))
}
build.onLoad(
{ filter: /.*/, namespace: 'native-stub' },
() => ({
contents: `
const noop = () => null;
const noopClass = class {};
const handler = {
get(_, prop) {
if (prop === '__esModule') return true;
if (prop === 'default') return new Proxy({}, handler);
if (prop === 'ExportResultCode') return { SUCCESS: 0, FAILED: 1 };
if (prop === 'resourceFromAttributes') return () => ({});
if (prop === 'SandboxRuntimeConfigSchema') return { parse: () => ({}) };
return noop;
}
};
const stub = new Proxy(noop, handler);
export default stub;
export const __stub = true;
export const SandboxViolationStore = null;
export const SandboxManager = new Proxy({}, { get: () => noop });
export const SandboxRuntimeConfigSchema = { parse: () => ({}) };
export const BROWSER_TOOLS = [];
export const getMcpConfigForManifest = noop;
export const ColorDiff = null;
export const ColorFile = null;
export const getSyntaxTheme = noop;
export const plot = noop;
export const createClaudeForChromeMcpServer = noop;
export const createComputerUseMcpServer = noop;
export const ExportResultCode = { SUCCESS: 0, FAILED: 1 };
export const resourceFromAttributes = noop;
export const Resource = noopClass;
export const SimpleSpanProcessor = noopClass;
export const BatchSpanProcessor = noopClass;
export const NodeTracerProvider = noopClass;
export const BasicTracerProvider = noopClass;
export const OTLPTraceExporter = noopClass;
export const OTLPLogExporter = noopClass;
export const OTLPMetricExporter = noopClass;
export const PrometheusExporter = noopClass;
export const LoggerProvider = noopClass;
export const SimpleLogRecordProcessor = noopClass;
export const BatchLogRecordProcessor = noopClass;
export const MeterProvider = noopClass;
export const PeriodicExportingMetricReader = noopClass;
export const trace = { getTracer: () => ({ startSpan: () => ({ end: noop, setAttribute: noop, setStatus: noop, recordException: noop }) }) };
export const context = { active: noop, with: (_, fn) => fn() };
export const SpanStatusCode = { OK: 0, ERROR: 1, UNSET: 2 };
export const ATTR_SERVICE_NAME = 'service.name';
export const ATTR_SERVICE_VERSION = 'service.version';
export const SEMRESATTRS_SERVICE_NAME = 'service.name';
export const SEMRESATTRS_SERVICE_VERSION = 'service.version';
export const AggregationTemporality = { CUMULATIVE: 0, DELTA: 1 };
export const DataPointType = { HISTOGRAM: 0, SUM: 1, GAUGE: 2 };
export const InstrumentType = { COUNTER: 0, HISTOGRAM: 1, UP_DOWN_COUNTER: 2 };
export const PushMetricExporter = noopClass;
export const SeverityNumber = {};
`,
loader: 'js',
}),
)
build.onResolve(
{ filter: /\.(md|txt)$/ },
args => ({
path: args.path,
namespace: 'text-stub',
}),
)
build.onLoad(
{ filter: /.*/, namespace: 'text-stub' },
() => ({
contents: `export default '';`,
loader: 'js',
}),
)
},
},
],
external: [
'@opentelemetry/api',
'@opentelemetry/api-logs',
'@opentelemetry/core',
'@opentelemetry/exporter-trace-otlp-grpc',
'@opentelemetry/exporter-trace-otlp-http',
'@opentelemetry/exporter-trace-otlp-proto',
'@opentelemetry/exporter-logs-otlp-http',
'@opentelemetry/exporter-logs-otlp-proto',
'@opentelemetry/exporter-logs-otlp-grpc',
'@opentelemetry/exporter-metrics-otlp-proto',
'@opentelemetry/exporter-metrics-otlp-grpc',
'@opentelemetry/exporter-metrics-otlp-http',
'@opentelemetry/exporter-prometheus',
'@opentelemetry/resources',
'@opentelemetry/sdk-trace-base',
'@opentelemetry/sdk-trace-node',
'@opentelemetry/sdk-logs',
'@opentelemetry/sdk-metrics',
'@opentelemetry/semantic-conventions',
'@aws-sdk/client-bedrock',
'@aws-sdk/client-bedrock-runtime',
'@aws-sdk/client-sts',
'@aws-sdk/credential-providers',
'@azure/identity',
'google-auth-library',
],
})
if (!result.success) {
console.error('Build failed:')
for (const log of result.logs) {
console.error(log)
}
process.exit(1)
}
console.log(`Built Better-Clawd v${version} -> dist/cli.mjs`)

View File

@@ -70,13 +70,13 @@ export async function isBridgeEnabledBlocking(): Promise<boolean> {
export async function getBridgeDisabledReason(): Promise<string | null> { export async function getBridgeDisabledReason(): Promise<string | null> {
if (feature('BRIDGE_MODE')) { if (feature('BRIDGE_MODE')) {
if (!isClaudeAISubscriber()) { if (!isClaudeAISubscriber()) {
return 'Remote Control requires a claude.ai subscription. Run `claude auth login` to sign in with your claude.ai account.' return 'Remote Control requires browser-based remote-session authentication. Run `/login` to sign in before using Remote Control.'
} }
if (!hasProfileScope()) { if (!hasProfileScope()) {
return 'Remote Control requires a full-scope login token. Long-lived tokens (from `claude setup-token` or CLAUDE_CODE_OAUTH_TOKEN) are limited to inference-only for security reasons. Run `claude auth login` to use Remote Control.' return 'Remote Control requires a full-scope browser login token. Long-lived tokens are limited to inference-only for security reasons. Run `/login` to use Remote Control.'
} }
if (!getOauthAccountInfo()?.organizationUuid) { if (!getOauthAccountInfo()?.organizationUuid) {
return 'Unable to determine your organization for Remote Control eligibility. Run `claude auth login` to refresh your account information.' return 'Unable to determine your organization for Remote Control eligibility. Run `/login` to refresh your account information.'
} }
if (!(await checkGate_CACHED_OR_BLOCKING('tengu_ccr_bridge'))) { if (!(await checkGate_CACHED_OR_BLOCKING('tengu_ccr_bridge'))) {
return 'Remote Control is not yet enabled for your account.' return 'Remote Control is not yet enabled for your account.'

View File

@@ -1918,12 +1918,12 @@ async function printHelp(): Promise<void> {
` `
: '' : ''
const help = ` const help = `
Remote Control - Connect your local environment to claude.ai/code Remote Control - Connect your local environment to a remote bridge
USAGE USAGE
claude remote-control [options] claude remote-control [options]
OPTIONS OPTIONS
--name <name> Name for the session (shown in claude.ai/code) --name <name> Name for the session shown by the remote bridge
${ ${
feature('KAIROS') feature('KAIROS')
? ` -c, --continue Resume the last session in this directory ? ` -c, --continue Resume the last session in this directory
@@ -1938,13 +1938,13 @@ ${
-h, --help Show this help -h, --help Show this help
${serverOptions} ${serverOptions}
DESCRIPTION DESCRIPTION
Remote Control allows you to control sessions on your local device from Remote Control allows you to control sessions on your local device from a
claude.ai/code (https://claude.ai/code). Run this command in the compatible remote bridge. Run this command in the directory you want to
directory you want to work in, then connect from the Claude app or web. work in, then connect from your configured bridge client.
${serverDescription} ${serverDescription}
NOTES NOTES
- You must be logged in with a Claude account that has a subscription - You must be logged in with an account that supports your configured bridge
- Run \`claude\` first in the directory to accept the workspace trust dialog - Run \`better-clawd\` first in the directory to accept the workspace trust dialog
${serverNote}` ${serverNote}`
// biome-ignore lint/suspicious/noConsole: intentional help output // biome-ignore lint/suspicious/noConsole: intentional help output
console.log(help) console.log(help)
@@ -2122,7 +2122,7 @@ export async function bridgeMain(args: string[]): Promise<void> {
}) })
// biome-ignore lint/suspicious/noConsole:: intentional console output // biome-ignore lint/suspicious/noConsole:: intentional console output
console.log( console.log(
'\nRemote Control lets you access this CLI session from the web (claude.ai/code)\nor the Claude app, so you can pick up where you left off on any device.\n\nYou can disconnect remote access anytime by running /remote-control again.\n', '\nRemote Control lets you access this CLI session from a compatible remote bridge, so you can pick up where you left off on another device.\n\nYou can disconnect remote access anytime by running /remote-control again.\n',
) )
const answer = await new Promise<string>(resolve => { const answer = await new Promise<string>(resolve => {
rl.question('Enable Remote Control? (y/n) ', resolve) rl.question('Enable Remote Control? (y/n) ', resolve)

View File

@@ -3,7 +3,7 @@ export const DEFAULT_SESSION_TIMEOUT_MS = 24 * 60 * 60 * 1000
/** Reusable login guidance appended to bridge auth errors. */ /** Reusable login guidance appended to bridge auth errors. */
export const BRIDGE_LOGIN_INSTRUCTION = export const BRIDGE_LOGIN_INSTRUCTION =
'Remote Control is only available with claude.ai subscriptions. Please use `/login` to sign in with your claude.ai account.' 'Remote Control is only available with browser-based remote-session authentication. Please use `/login` to sign in before using Remote Control.'
/** Full error printed when `claude remote-control` is run without auth. */ /** Full error printed when `claude remote-control` is run without auth. */
export const BRIDGE_LOGIN_ERROR = export const BRIDGE_LOGIN_ERROR =

21
src/cli/bg.ts Normal file
View File

@@ -0,0 +1,21 @@
import { unsupportedEntrypoint } from '../utils/unsupportedEntrypoint.js'
export async function psHandler(): Promise<never> {
return unsupportedEntrypoint('better-clawd ps')
}
export async function logsHandler(): Promise<never> {
return unsupportedEntrypoint('better-clawd logs')
}
export async function attachHandler(): Promise<never> {
return unsupportedEntrypoint('better-clawd attach')
}
export async function killHandler(): Promise<never> {
return unsupportedEntrypoint('better-clawd kill')
}
export async function handleBgFlag(): Promise<never> {
return unsupportedEntrypoint('better-clawd --bg')
}

View File

@@ -287,7 +287,7 @@ export async function authStatus(opts: {
} }
if (!loggedIn) { if (!loggedIn) {
process.stdout.write( process.stdout.write(
'Not logged in. Run claude auth login to authenticate.\n', 'Not logged in. Run better-clawd auth login to authenticate.\n',
) )
} }
} else { } else {

View File

@@ -83,7 +83,7 @@ export async function autoModeCritiqueHandler(options: {
process.stdout.write( process.stdout.write(
'No custom auto mode rules found.\n\n' + 'No custom auto mode rules found.\n\n' +
'Add rules to your settings file under autoMode.{allow, soft_deny, environment}.\n' + 'Add rules to your settings file under autoMode.{allow, soft_deny, environment}.\n' +
'Run `claude auto-mode defaults` to see the default rules for reference.\n', 'Run `better-clawd auto-mode defaults` to see the default rules for reference.\n',
) )
return return
} }

View File

@@ -0,0 +1,5 @@
import { unsupportedEntrypoint } from '../../utils/unsupportedEntrypoint.js'
export async function templatesMain(): Promise<never> {
return unsupportedEntrypoint('better-clawd new')
}

View File

@@ -1,422 +1,21 @@
import chalk from 'chalk' import chalk from 'chalk'
import { logEvent } from 'src/services/analytics/index.js' import { PRODUCT_NAME, PRODUCT_URL } from 'src/constants/product.js'
import {
getLatestVersion,
type InstallStatus,
installGlobalPackage,
} from 'src/utils/autoUpdater.js'
import { regenerateCompletionCache } from 'src/utils/completionCache.js'
import {
getGlobalConfig,
type InstallMethod,
saveGlobalConfig,
} from 'src/utils/config.js'
import { logForDebugging } from 'src/utils/debug.js'
import { getDoctorDiagnostic } from 'src/utils/doctorDiagnostic.js'
import { gracefulShutdown } from 'src/utils/gracefulShutdown.js' import { gracefulShutdown } from 'src/utils/gracefulShutdown.js'
import {
installOrUpdateClaudePackage,
localInstallationExists,
} from 'src/utils/localInstaller.js'
import {
installLatest as installLatestNative,
removeInstalledSymlink,
} from 'src/utils/nativeInstaller/index.js'
import { getPackageManager } from 'src/utils/nativeInstaller/packageManagers.js'
import { writeToStdout } from 'src/utils/process.js' import { writeToStdout } from 'src/utils/process.js'
import { gte } from 'src/utils/semver.js'
import { getInitialSettings } from 'src/utils/settings/settings.js'
export async function update() { export async function update() {
logEvent('tengu_update_check', {})
writeToStdout(`Current version: ${MACRO.VERSION}\n`) writeToStdout(`Current version: ${MACRO.VERSION}\n`)
const channel = getInitialSettings()?.autoUpdatesChannel ?? 'latest'
writeToStdout(`Checking for updates to ${channel} version...\n`)
logForDebugging('update: Starting update check')
// Run diagnostic to detect potential issues
logForDebugging('update: Running diagnostic')
const diagnostic = await getDoctorDiagnostic()
logForDebugging(`update: Installation type: ${diagnostic.installationType}`)
logForDebugging(
`update: Config install method: ${diagnostic.configInstallMethod}`,
)
// Check for multiple installations
if (diagnostic.multipleInstallations.length > 1) {
writeToStdout('\n') writeToStdout('\n')
writeToStdout(chalk.yellow('Warning: Multiple installations found') + '\n')
for (const install of diagnostic.multipleInstallations) {
const current =
diagnostic.installationType === install.type
? ' (currently running)'
: ''
writeToStdout(`- ${install.type} at ${install.path}${current}\n`)
}
}
// Display warnings if any exist
if (diagnostic.warnings.length > 0) {
writeToStdout('\n')
for (const warning of diagnostic.warnings) {
logForDebugging(`update: Warning detected: ${warning.issue}`)
// Don't skip PATH warnings - they're always relevant
// The user needs to know that 'which claude' points elsewhere
logForDebugging(`update: Showing warning: ${warning.issue}`)
writeToStdout(chalk.yellow(`Warning: ${warning.issue}\n`))
writeToStdout(chalk.bold(`Fix: ${warning.fix}\n`))
}
}
// Update config if installMethod is not set (but skip for package managers)
const config = getGlobalConfig()
if (
!config.installMethod &&
diagnostic.installationType !== 'package-manager'
) {
writeToStdout('\n')
writeToStdout('Updating configuration to track installation method...\n')
let detectedMethod: 'local' | 'native' | 'global' | 'unknown' = 'unknown'
// Map diagnostic installation type to config install method
switch (diagnostic.installationType) {
case 'npm-local':
detectedMethod = 'local'
break
case 'native':
detectedMethod = 'native'
break
case 'npm-global':
detectedMethod = 'global'
break
default:
detectedMethod = 'unknown'
}
saveGlobalConfig(current => ({
...current,
installMethod: detectedMethod,
}))
writeToStdout(`Installation method set to: ${detectedMethod}\n`)
}
// Check if running from development build
if (diagnostic.installationType === 'development') {
writeToStdout('\n')
writeToStdout(
chalk.yellow('Warning: Cannot update development build') + '\n',
)
await gracefulShutdown(1)
}
// Check if running from a package manager
if (diagnostic.installationType === 'package-manager') {
const packageManager = await getPackageManager()
writeToStdout('\n')
if (packageManager === 'homebrew') {
writeToStdout('Claude is managed by Homebrew.\n')
const latest = await getLatestVersion(channel)
if (latest && !gte(MACRO.VERSION, latest)) {
writeToStdout(`Update available: ${MACRO.VERSION}${latest}\n`)
writeToStdout('\n')
writeToStdout('To update, run:\n')
writeToStdout(chalk.bold(' brew upgrade claude-code') + '\n')
} else {
writeToStdout('Claude is up to date!\n')
}
} else if (packageManager === 'winget') {
writeToStdout('Claude is managed by winget.\n')
const latest = await getLatestVersion(channel)
if (latest && !gte(MACRO.VERSION, latest)) {
writeToStdout(`Update available: ${MACRO.VERSION}${latest}\n`)
writeToStdout('\n')
writeToStdout('To update, run:\n')
writeToStdout(
chalk.bold(' winget upgrade Anthropic.ClaudeCode') + '\n',
)
} else {
writeToStdout('Claude is up to date!\n')
}
} else if (packageManager === 'apk') {
writeToStdout('Claude is managed by apk.\n')
const latest = await getLatestVersion(channel)
if (latest && !gte(MACRO.VERSION, latest)) {
writeToStdout(`Update available: ${MACRO.VERSION}${latest}\n`)
writeToStdout('\n')
writeToStdout('To update, run:\n')
writeToStdout(chalk.bold(' apk upgrade claude-code') + '\n')
} else {
writeToStdout('Claude is up to date!\n')
}
} else {
// pacman, deb, and rpm don't get specific commands because they each have
// multiple frontends (pacman: yay/paru/makepkg, deb: apt/apt-get/aptitude/nala,
// rpm: dnf/yum/zypper)
writeToStdout('Claude is managed by a package manager.\n')
writeToStdout('Please use your package manager to update.\n')
}
await gracefulShutdown(0)
}
// Check for config/reality mismatch (skip for package-manager installs)
if (
config.installMethod &&
diagnostic.configInstallMethod !== 'not set' &&
diagnostic.installationType !== 'package-manager'
) {
const runningType = diagnostic.installationType
const configExpects = diagnostic.configInstallMethod
// Map installation types for comparison
const typeMapping: Record<string, string> = {
'npm-local': 'local',
'npm-global': 'global',
native: 'native',
development: 'development',
unknown: 'unknown',
}
const normalizedRunningType = typeMapping[runningType] || runningType
if (
normalizedRunningType !== configExpects &&
configExpects !== 'unknown'
) {
writeToStdout('\n')
writeToStdout(chalk.yellow('Warning: Configuration mismatch') + '\n')
writeToStdout(`Config expects: ${configExpects} installation\n`)
writeToStdout(`Currently running: ${runningType}\n`)
writeToStdout( writeToStdout(
chalk.yellow( chalk.yellow(
`Updating the ${runningType} installation you are currently using`, `${PRODUCT_NAME} no longer uses the upstream Anthropic auto-update service.\n`,
) + '\n', ),
)
// Update config to match reality
saveGlobalConfig(current => ({
...current,
installMethod: normalizedRunningType as InstallMethod,
}))
writeToStdout(
`Config updated to reflect current installation method: ${normalizedRunningType}\n`,
)
}
}
// Handle native installation updates first
if (diagnostic.installationType === 'native') {
logForDebugging(
'update: Detected native installation, using native updater',
)
try {
const result = await installLatestNative(channel, true)
// Handle lock contention gracefully
if (result.lockFailed) {
const pidInfo = result.lockHolderPid
? ` (PID ${result.lockHolderPid})`
: ''
writeToStdout(
chalk.yellow(
`Another Claude process${pidInfo} is currently running. Please try again in a moment.`,
) + '\n',
)
await gracefulShutdown(0)
}
if (!result.latestVersion) {
process.stderr.write('Failed to check for updates\n')
await gracefulShutdown(1)
}
if (result.latestVersion === MACRO.VERSION) {
writeToStdout(
chalk.green(`Claude Code is up to date (${MACRO.VERSION})`) + '\n',
)
} else {
writeToStdout(
chalk.green(
`Successfully updated from ${MACRO.VERSION} to version ${result.latestVersion}`,
) + '\n',
)
await regenerateCompletionCache()
}
await gracefulShutdown(0)
} catch (error) {
process.stderr.write('Error: Failed to install native update\n')
process.stderr.write(String(error) + '\n')
process.stderr.write('Try running "claude doctor" for diagnostics\n')
await gracefulShutdown(1)
}
}
// Fallback to existing JS/npm-based update logic
// Remove native installer symlink since we're not using native installation
// But only if user hasn't migrated to native installation
if (config.installMethod !== 'native') {
await removeInstalledSymlink()
}
logForDebugging('update: Checking npm registry for latest version')
logForDebugging(`update: Package URL: ${MACRO.PACKAGE_URL}`)
const npmTag = channel === 'stable' ? 'stable' : 'latest'
const npmCommand = `npm view ${MACRO.PACKAGE_URL}@${npmTag} version`
logForDebugging(`update: Running: ${npmCommand}`)
const latestVersion = await getLatestVersion(channel)
logForDebugging(
`update: Latest version from npm: ${latestVersion || 'FAILED'}`,
)
if (!latestVersion) {
logForDebugging('update: Failed to get latest version from npm registry')
process.stderr.write(chalk.red('Failed to check for updates') + '\n')
process.stderr.write('Unable to fetch latest version from npm registry\n')
process.stderr.write('\n')
process.stderr.write('Possible causes:\n')
process.stderr.write(' • Network connectivity issues\n')
process.stderr.write(' • npm registry is unreachable\n')
process.stderr.write(' • Corporate proxy/firewall blocking npm\n')
if (MACRO.PACKAGE_URL && !MACRO.PACKAGE_URL.startsWith('@anthropic')) {
process.stderr.write(
' • Internal/development build not published to npm\n',
)
}
process.stderr.write('\n')
process.stderr.write('Try:\n')
process.stderr.write(' • Check your internet connection\n')
process.stderr.write(' • Run with --debug flag for more details\n')
const packageName =
MACRO.PACKAGE_URL ||
(process.env.USER_TYPE === 'ant'
? '@anthropic-ai/claude-cli'
: '@anthropic-ai/claude-code')
process.stderr.write(
` • Manually check: npm view ${packageName} version\n`,
)
process.stderr.write(' • Check if you need to login: npm whoami\n')
await gracefulShutdown(1)
}
// Check if versions match exactly, including any build metadata (like SHA)
if (latestVersion === MACRO.VERSION) {
writeToStdout(
chalk.green(`Claude Code is up to date (${MACRO.VERSION})`) + '\n',
)
await gracefulShutdown(0)
}
writeToStdout(
`New version available: ${latestVersion} (current: ${MACRO.VERSION})\n`,
)
writeToStdout('Installing update...\n')
// Determine update method based on what's actually running
let useLocalUpdate = false
let updateMethodName = ''
switch (diagnostic.installationType) {
case 'npm-local':
useLocalUpdate = true
updateMethodName = 'local'
break
case 'npm-global':
useLocalUpdate = false
updateMethodName = 'global'
break
case 'unknown': {
// Fallback to detection if we can't determine installation type
const isLocal = await localInstallationExists()
useLocalUpdate = isLocal
updateMethodName = isLocal ? 'local' : 'global'
writeToStdout(
chalk.yellow('Warning: Could not determine installation type') + '\n',
) )
writeToStdout( writeToStdout(
`Attempting ${updateMethodName} update based on file detection...\n`, 'Install new builds manually from the project releases or by reinstalling from the Better-Clawd repository.\n',
) )
break writeToStdout('\n')
} writeToStdout(`Project: ${PRODUCT_URL}\n`)
default: writeToStdout(`Releases: ${PRODUCT_URL}/releases\n`)
process.stderr.write(
`Error: Cannot update ${diagnostic.installationType} installation\n`,
)
await gracefulShutdown(1)
}
writeToStdout(`Using ${updateMethodName} installation update method...\n`)
logForDebugging(`update: Update method determined: ${updateMethodName}`)
logForDebugging(`update: useLocalUpdate: ${useLocalUpdate}`)
let status: InstallStatus
if (useLocalUpdate) {
logForDebugging(
'update: Calling installOrUpdateClaudePackage() for local update',
)
status = await installOrUpdateClaudePackage(channel)
} else {
logForDebugging('update: Calling installGlobalPackage() for global update')
status = await installGlobalPackage()
}
logForDebugging(`update: Installation status: ${status}`)
switch (status) {
case 'success':
writeToStdout(
chalk.green(
`Successfully updated from ${MACRO.VERSION} to version ${latestVersion}`,
) + '\n',
)
await regenerateCompletionCache()
break
case 'no_permissions':
process.stderr.write(
'Error: Insufficient permissions to install update\n',
)
if (useLocalUpdate) {
process.stderr.write('Try manually updating with:\n')
process.stderr.write(
` cd ~/.claude/local && npm update ${MACRO.PACKAGE_URL}\n`,
)
} else {
process.stderr.write('Try running with sudo or fix npm permissions\n')
process.stderr.write(
'Or consider using native installation with: claude install\n',
)
}
await gracefulShutdown(1)
break
case 'install_failed':
process.stderr.write('Error: Failed to install update\n')
if (useLocalUpdate) {
process.stderr.write('Try manually updating with:\n')
process.stderr.write(
` cd ~/.claude/local && npm update ${MACRO.PACKAGE_URL}\n`,
)
} else {
process.stderr.write(
'Or consider using native installation with: claude install\n',
)
}
await gracefulShutdown(1)
break
case 'in_progress':
process.stderr.write(
'Error: Another instance is currently performing an update\n',
)
process.stderr.write('Please wait and try again later\n')
await gracefulShutdown(1)
break
}
await gracefulShutdown(0) await gracefulShutdown(0)
} }

View File

@@ -212,7 +212,7 @@ function ClaudeInChromeMenu(t0) {
} }
let t8; let t8;
if ($[23] !== isClaudeAISubscriber) { if ($[23] !== isClaudeAISubscriber) {
t8 = true && !isClaudeAISubscriber && <Text color="error">Claude in Chrome requires a claude.ai subscription.</Text>; t8 = true && !isClaudeAISubscriber && <Text color="error">Browser integration requires browser-based remote-session authentication.</Text>;
$[23] = isClaudeAISubscriber; $[23] = isClaudeAISubscriber;
$[24] = t8; $[24] = t8;
} else { } else {

View File

@@ -22,7 +22,7 @@ Usage notes:
\`\`\` \`\`\`
# CLAUDE.md # CLAUDE.md
This file provides guidance to Claude Code (claude.ai/code) when working with code in this repository. This file provides guidance to Better-Clawd 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 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.
@@ -125,7 +125,7 @@ Prefix the file with:
\`\`\` \`\`\`
# CLAUDE.md # CLAUDE.md
This file provides guidance to Claude Code (claude.ai/code) when working with code in this repository. This file provides guidance to Better-Clawd 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 CLAUDE.md already exists: read it, propose specific changes as diffs, and explain why each change improves it. Do not silently overwrite.

View File

@@ -26,14 +26,14 @@ export function InstallAppStep(t0) {
useKeybinding("confirm:yes", onSubmit, t1); useKeybinding("confirm:yes", onSubmit, t1);
let t2; let t2;
if ($[1] === Symbol.for("react.memo_cache_sentinel")) { if ($[1] === Symbol.for("react.memo_cache_sentinel")) {
t2 = <Box flexDirection="column" marginBottom={1}><Text bold={true}>Install the Claude GitHub App</Text></Box>; t2 = <Box flexDirection="column" marginBottom={1}><Text bold={true}>Install the required GitHub App</Text></Box>;
$[1] = t2; $[1] = t2;
} else { } else {
t2 = $[1]; t2 = $[1];
} }
let t3; let t3;
if ($[2] === Symbol.for("react.memo_cache_sentinel")) { if ($[2] === Symbol.for("react.memo_cache_sentinel")) {
t3 = <Box marginBottom={1}><Text>Opening browser to install the Claude GitHub App</Text></Box>; t3 = <Box marginBottom={1}><Text>Opening browser to install the required GitHub App</Text></Box>;
$[2] = t3; $[2] = t3;
} else { } else {
t3 = $[2]; t3 = $[2];

View File

@@ -59,7 +59,7 @@ export function SuccessStep(t0) {
} }
let t7; let t7;
if ($[11] !== skipWorkflow) { if ($[11] !== skipWorkflow) {
t7 = skipWorkflow ? <><Text>1. Install the Claude GitHub App if you haven't already</Text><Text>2. Your workflow file was kept unchanged</Text><Text>3. API key is configured and ready to use</Text></> : <><Text>1. A pre-filled PR page has been created</Text><Text>2. Install the Claude GitHub App if you haven't already</Text><Text>3. Merge the PR to enable Claude PR assistance</Text></>; t7 = skipWorkflow ? <><Text>1. Install the required GitHub App if you haven't already</Text><Text>2. Your workflow file was kept unchanged</Text><Text>3. API key is configured and ready to use</Text></> : <><Text>1. A pre-filled PR page has been created</Text><Text>2. Install the required GitHub App if you haven't already</Text><Text>3. Merge the PR to enable PR assistance</Text></>;
$[11] = skipWorkflow; $[11] = skipWorkflow;
$[12] = t7; $[12] = t7;
} else { } else {

View File

@@ -44,11 +44,11 @@ function getInstallationPath(): string {
const homeDir = homedir(); const homeDir = homedir();
if (isWindows) { if (isWindows) {
// Convert to Windows-style path // Convert to Windows-style path
const windowsPath = join(homeDir, '.local', 'bin', 'claude.exe'); const windowsPath = join(homeDir, '.local', 'bin', 'better-clawd.exe');
// Replace forward slashes with backslashes for Windows display // Replace forward slashes with backslashes for Windows display
return windowsPath.replace(/\//g, '\\'); return windowsPath.replace(/\//g, '\\');
} }
return '~/.local/bin/claude'; return '~/.local/bin/better-clawd';
} }
function SetupNotes(t0) { function SetupNotes(t0) {
const $ = _c(5); const $ = _c(5);
@@ -113,7 +113,7 @@ function Install({
// Check specifically for lock failure // Check specifically for lock failure
if (result.lockFailed) { if (result.lockFailed) {
throw new Error('Could not install - another process is currently installing Claude. Please try again in a moment.'); throw new Error('Could not install - another process is currently installing Better-Clawd. Please try again in a moment.');
} }
// If we couldn't get the version, there might be an issue // If we couldn't get the version, there might be an issue
@@ -210,12 +210,12 @@ function Install({
useEffect(() => { useEffect(() => {
if (state.type === 'success') { if (state.type === 'success') {
// Give success message time to render before exiting // Give success message time to render before exiting
setTimeout(onDone, 2000, 'Claude Code installation completed successfully', { setTimeout(onDone, 2000, 'Better-Clawd installation completed successfully', {
display: 'system' as const display: 'system' as const
}); });
} else if (state.type === 'error') { } else if (state.type === 'error') {
// Give error message time to render before exiting // Give error message time to render before exiting
setTimeout(onDone, 3000, 'Claude Code installation failed', { setTimeout(onDone, 3000, 'Better-Clawd installation failed', {
display: 'system' as const display: 'system' as const
}); });
} }
@@ -226,7 +226,7 @@ function Install({
{state.type === 'cleaning-npm' && <Text color="warning">Cleaning up old npm installations...</Text>} {state.type === 'cleaning-npm' && <Text color="warning">Cleaning up old npm installations...</Text>}
{state.type === 'installing' && <Text color="claude"> {state.type === 'installing' && <Text color="claude">
Installing Claude Code native build {state.version}... Installing Better-Clawd native build {state.version}...
</Text>} </Text>}
{state.type === 'setting-up' && <Text color="claude">Setting up launcher and shell integration...</Text>} {state.type === 'setting-up' && <Text color="claude">Setting up launcher and shell integration...</Text>}
@@ -237,7 +237,7 @@ function Install({
<Box> <Box>
<StatusIcon status="success" withSpace /> <StatusIcon status="success" withSpace />
<Text color="success" bold> <Text color="success" bold>
Claude Code successfully installed! Better-Clawd successfully installed!
</Text> </Text>
</Box> </Box>
<Box marginLeft={2} flexDirection="column" gap={1}> <Box marginLeft={2} flexDirection="column" gap={1}>
@@ -254,7 +254,7 @@ function Install({
<Box marginTop={1}> <Box marginTop={1}>
<Text dimColor>Next: Run </Text> <Text dimColor>Next: Run </Text>
<Text color="claude" bold> <Text color="claude" bold>
claude --help better-clawd --help
</Text> </Text>
<Text dimColor> to get started</Text> <Text dimColor> to get started</Text>
</Box> </Box>
@@ -279,7 +279,7 @@ function Install({
export const install = { export const install = {
type: 'local-jsx' as const, type: 'local-jsx' as const,
name: 'install', name: 'install',
description: 'Install Claude Code native build', description: 'Install Better-Clawd native build',
argumentHint: '[options]', argumentHint: '[options]',
async call(onDone: (result: string, options?: { async call(onDone: (result: string, options?: {
display?: CommandResultDisplay; display?: CommandResultDisplay;

View File

@@ -7,11 +7,13 @@ import type { LocalJSXCommandContext } from '../../commands.js';
import { ConfigurableShortcutHint } from '../../components/ConfigurableShortcutHint.js'; import { ConfigurableShortcutHint } from '../../components/ConfigurableShortcutHint.js';
import { ConsoleOAuthFlow } from '../../components/ConsoleOAuthFlow.js'; import { ConsoleOAuthFlow } from '../../components/ConsoleOAuthFlow.js';
import { Dialog } from '../../components/design-system/Dialog.js'; import { Dialog } from '../../components/design-system/Dialog.js';
import { OpenAILoginFlow } from '../../components/OpenAILoginFlow.js';
import { useMainLoopModel } from '../../hooks/useMainLoopModel.js'; import { useMainLoopModel } from '../../hooks/useMainLoopModel.js';
import { Text } from '../../ink.js'; import { Text } from '../../ink.js';
import { refreshGrowthBookAfterAuthChange } from '../../services/analytics/growthbook.js'; import { refreshGrowthBookAfterAuthChange } from '../../services/analytics/growthbook.js';
import { refreshPolicyLimits } from '../../services/policyLimits/index.js'; import { refreshPolicyLimits } from '../../services/policyLimits/index.js';
import { refreshRemoteManagedSettings } from '../../services/remoteManagedSettings/index.js'; import { refreshRemoteManagedSettings } from '../../services/remoteManagedSettings/index.js';
import { getConfiguredAuthProvider } from '../../utils/auth.js';
import type { LocalJSXCommandOnDone } from '../../types/command.js'; import type { LocalJSXCommandOnDone } from '../../types/command.js';
import { stripSignatureBlocks } from '../../utils/messages.js'; import { stripSignatureBlocks } from '../../utils/messages.js';
import { checkAndDisableAutoModeIfNeeded, checkAndDisableBypassPermissionsIfNeeded, resetAutoModeGateCheck, resetBypassPermissionsCheck } from '../../utils/permissions/bypassPermissionsKillswitch.js'; import { checkAndDisableAutoModeIfNeeded, checkAndDisableBypassPermissionsIfNeeded, resetAutoModeGateCheck, resetBypassPermissionsCheck } from '../../utils/permissions/bypassPermissionsKillswitch.js';
@@ -58,8 +60,9 @@ export async function call(onDone: LocalJSXCommandOnDone, context: LocalJSXComma
}} />; }} />;
} }
export function Login(props) { export function Login(props) {
const $ = _c(12); const $ = _c(15);
const mainLoopModel = useMainLoopModel(); const mainLoopModel = useMainLoopModel();
const authProvider = getConfiguredAuthProvider();
let t0; let t0;
if ($[0] !== mainLoopModel || $[1] !== props) { if ($[0] !== mainLoopModel || $[1] !== props) {
t0 = () => props.onDone(false, mainLoopModel); t0 = () => props.onDone(false, mainLoopModel);
@@ -79,22 +82,23 @@ export function Login(props) {
t1 = $[5]; t1 = $[5];
} }
let t2; let t2;
if ($[6] !== props.startingMessage || $[7] !== t1) { if ($[6] !== authProvider || $[7] !== props.startingMessage || $[8] !== t1) {
t2 = <ConsoleOAuthFlow onDone={t1} startingMessage={props.startingMessage} />; t2 = authProvider === 'openai' ? <OpenAILoginFlow onDone={t1} startingMessage={props.startingMessage} /> : <ConsoleOAuthFlow onDone={t1} startingMessage={props.startingMessage} />;
$[6] = props.startingMessage; $[6] = authProvider;
$[7] = t1; $[7] = props.startingMessage;
$[8] = t2; $[8] = t1;
$[9] = t2;
} else { } else {
t2 = $[8]; t2 = $[9];
} }
let t3; let t3;
if ($[9] !== t0 || $[10] !== t2) { if ($[10] !== t0 || $[11] !== t2) {
t3 = <Dialog title="Login" onCancel={t0} color="permission" inputGuide={_temp}>{t2}</Dialog>; t3 = <Dialog title="Login" onCancel={t0} color="permission" inputGuide={_temp}>{t2}</Dialog>;
$[9] = t0; $[10] = t0;
$[10] = t2; $[11] = t2;
$[11] = t3; $[12] = t3;
} else { } else {
t3 = $[11]; t3 = $[12];
} }
return t3; return t3;
} }

View File

@@ -241,7 +241,7 @@ export function ConsoleOAuthFlow({
state: 'success' state: 'success'
}); });
void sendNotification({ void sendNotification({
message: 'Claude Code login successful', message: 'Better-Clawd login successful',
notificationType: 'auth_success' notificationType: 'auth_success'
}, terminal); }, terminal);
} }
@@ -364,7 +364,7 @@ function OAuthStatusMessage(t0) {
switch (oauthStatus.state) { switch (oauthStatus.state) {
case "idle": case "idle":
{ {
const t1 = startingMessage ? startingMessage : "Claude Code can be used with your Claude subscription or billed based on API usage through your Console account."; const t1 = startingMessage ? startingMessage : "Better-Clawd can be used with your Anthropic subscription, OpenAI/Codex, OpenRouter, or billed API usage depending on the provider you choose.";
let t2; let t2;
if ($[0] !== t1) { if ($[0] !== t1) {
t2 = <Text bold={true}>{t1}</Text>; t2 = <Text bold={true}>{t1}</Text>;
@@ -460,7 +460,7 @@ function OAuthStatusMessage(t0) {
let t2; let t2;
let t3; let t3;
if ($[13] === Symbol.for("react.memo_cache_sentinel")) { if ($[13] === Symbol.for("react.memo_cache_sentinel")) {
t2 = <Text>Claude Code supports Amazon Bedrock, Microsoft Foundry, and Vertex AI. Set the required environment variables, then restart Claude Code.</Text>; t2 = <Text>Better-Clawd supports Amazon Bedrock, Microsoft Foundry, and Vertex AI. Set the required environment variables, then restart Better-Clawd.</Text>;
t3 = <Text>If you are part of an enterprise organization, contact your administrator for setup instructions.</Text>; t3 = <Text>If you are part of an enterprise organization, contact your administrator for setup instructions.</Text>;
$[13] = t2; $[13] = t2;
$[14] = t3; $[14] = t3;
@@ -554,7 +554,7 @@ function OAuthStatusMessage(t0) {
{ {
let t1; let t1;
if ($[37] === Symbol.for("react.memo_cache_sentinel")) { if ($[37] === Symbol.for("react.memo_cache_sentinel")) {
t1 = <Box flexDirection="column" gap={1}><Box><Spinner /><Text>Creating API key for Claude Code</Text></Box></Box>; t1 = <Box flexDirection="column" gap={1}><Box><Spinner /><Text>Creating API key for Better-Clawd</Text></Box></Box>;
$[37] = t1; $[37] = t1;
} else { } else {
t1 = $[37]; t1 = $[37];

View File

@@ -1,4 +1,3 @@
import axios from 'axios';
import { readFile, stat } from 'fs/promises'; import { readFile, stat } from 'fs/promises';
import * as React from 'react'; import * as React from 'react';
import { useCallback, useEffect, useState } from 'react'; import { useCallback, useEffect, useState } from 'react';
@@ -13,14 +12,12 @@ import { useKeybinding } from '../keybindings/useKeybinding.js';
import { queryHaiku } from '../services/api/claude.js'; import { queryHaiku } from '../services/api/claude.js';
import { startsWithApiErrorPrefix } from '../services/api/errors.js'; import { startsWithApiErrorPrefix } from '../services/api/errors.js';
import type { Message } from '../types/message.js'; import type { Message } from '../types/message.js';
import { checkAndRefreshOAuthTokenIfNeeded } from '../utils/auth.js';
import { openBrowser } from '../utils/browser.js'; import { openBrowser } from '../utils/browser.js';
import { PRODUCT_ISSUES_URL, PRODUCT_NAME } from '../constants/product.js';
import { logForDebugging } from '../utils/debug.js'; import { logForDebugging } from '../utils/debug.js';
import { env } from '../utils/env.js'; import { env } from '../utils/env.js';
import { type GitRepoState, getGitState, getIsGit } from '../utils/git.js'; import { type GitRepoState, getGitState, getIsGit } from '../utils/git.js';
import { getAuthHeaders, getUserAgent } from '../utils/http.js';
import { getInMemoryErrors, logError } from '../utils/log.js'; import { getInMemoryErrors, logError } from '../utils/log.js';
import { isEssentialTrafficOnly } from '../utils/privacyLevel.js';
import { extractTeammateTranscriptsFromTasks, getTranscriptPath, loadAllSubagentTranscriptsFromDisk, MAX_TRANSCRIPT_READ_BYTES } from '../utils/sessionStorage.js'; import { extractTeammateTranscriptsFromTasks, getTranscriptPath, loadAllSubagentTranscriptsFromDisk, MAX_TRANSCRIPT_READ_BYTES } from '../utils/sessionStorage.js';
import { jsonStringify } from '../utils/slowOperations.js'; import { jsonStringify } from '../utils/slowOperations.js';
import { asSystemPrompt } from '../utils/systemPromptType.js'; import { asSystemPrompt } from '../utils/systemPromptType.js';
@@ -32,7 +29,7 @@ import TextInput from './TextInput.js';
// This value was determined experimentally by testing the URL length limit // This value was determined experimentally by testing the URL length limit
const GITHUB_URL_LIMIT = 7250; const GITHUB_URL_LIMIT = 7250;
const GITHUB_ISSUES_REPO_URL = "external" === 'ant' ? 'https://github.com/anthropics/claude-cli-internal/issues' : 'https://github.com/anthropics/claude-code/issues'; const GITHUB_ISSUES_REPO_URL = PRODUCT_ISSUES_URL;
type Props = { type Props = {
abortSignal: AbortSignal; abortSignal: AbortSignal;
messages: Message[]; messages: Message[];
@@ -231,7 +228,6 @@ export function Feedback({
feedback_id: result.feedbackId as AnalyticsMetadata_I_VERIFIED_THIS_IS_NOT_CODE_OR_FILEPATHS, feedback_id: result.feedbackId as AnalyticsMetadata_I_VERIFIED_THIS_IS_NOT_CODE_OR_FILEPATHS,
last_assistant_message_id: lastAssistantMessageId as AnalyticsMetadata_I_VERIFIED_THIS_IS_NOT_CODE_OR_FILEPATHS last_assistant_message_id: lastAssistantMessageId as AnalyticsMetadata_I_VERIFIED_THIS_IS_NOT_CODE_OR_FILEPATHS
}); });
// 1P-only: freeform text approved for BQ. Join on feedback_id.
logEventTo1P('tengu_bug_report_description', { logEventTo1P('tengu_bug_report_description', {
feedback_id: result.feedbackId as AnalyticsMetadata_I_VERIFIED_THIS_IS_NOT_CODE_OR_FILEPATHS, feedback_id: result.feedbackId as AnalyticsMetadata_I_VERIFIED_THIS_IS_NOT_CODE_OR_FILEPATHS,
description: redactSensitiveInfo(description) as AnalyticsMetadata_I_VERIFIED_THIS_IS_NOT_CODE_OR_FILEPATHS description: redactSensitiveInfo(description) as AnalyticsMetadata_I_VERIFIED_THIS_IS_NOT_CODE_OR_FILEPATHS
@@ -239,11 +235,7 @@ export function Feedback({
} }
setStep('done'); setStep('done');
} else { } else {
if (result.isZdrOrg) { setError('Could not prepare the issue draft. Please try again later.');
setError('Feedback collection is not available for organizations with custom data retention policies.');
} else {
setError('Could not submit feedback. Please try again later.');
}
// Stay on userInput step so user can retry with their content preserved // Stay on userInput step so user can retry with their content preserved
setStep('userInput'); setStep('userInput');
} }
@@ -334,7 +326,7 @@ export function Feedback({
</Box>} </Box>}
{step === 'consent' && <Box flexDirection="column"> {step === 'consent' && <Box flexDirection="column">
<Text>This report will include:</Text> <Text>This issue draft will include:</Text>
<Box marginLeft={2} flexDirection="column"> <Box marginLeft={2} flexDirection="column">
<Text> <Text>
- Your feedback / bug description:{' '} - Your feedback / bug description:{' '}
@@ -360,24 +352,24 @@ export function Feedback({
</Box> </Box>
<Box marginTop={1}> <Box marginTop={1}>
<Text wrap="wrap" dimColor> <Text wrap="wrap" dimColor>
We will use your feedback to debug related issues or to improve{' '} Better-Clawd no longer uploads bug reports to an upstream service.
Claude Code&apos;s functionality (eg. to reduce the risk of bugs Press Enter to prepare a GitHub issue draft with the details shown
occurring in the future). above.
</Text> </Text>
</Box> </Box>
<Box marginTop={1}> <Box marginTop={1}>
<Text> <Text>
Press <Text bold>Enter</Text> to confirm and submit. Press <Text bold>Enter</Text> to continue.
</Text> </Text>
</Box> </Box>
</Box>} </Box>}
{step === 'submitting' && <Box flexDirection="row" gap={1}> {step === 'submitting' && <Box flexDirection="row" gap={1}>
<Text>Submitting report</Text> <Text>Preparing issue draft</Text>
</Box>} </Box>}
{step === 'done' && <Box flexDirection="column"> {step === 'done' && <Box flexDirection="column">
{error ? <Text color="error">{error}</Text> : <Text color="success">Thank you for your report!</Text>} {error ? <Text color="error">{error}</Text> : <Text color="success">Issue draft ready.</Text>}
{feedbackId && <Text dimColor>Feedback ID: {feedbackId}</Text>} {feedbackId && <Text dimColor>Feedback ID: {feedbackId}</Text>}
<Box marginTop={1}> <Box marginTop={1}>
<Text>Press </Text> <Text>Press </Text>
@@ -396,7 +388,7 @@ export function createGitHubIssueUrl(feedbackId: string, title: string, descript
}>): string { }>): string {
const sanitizedTitle = redactSensitiveInfo(title); const sanitizedTitle = redactSensitiveInfo(title);
const sanitizedDescription = redactSensitiveInfo(description); const sanitizedDescription = redactSensitiveInfo(description);
const bodyPrefix = `**Bug Description**\n${sanitizedDescription}\n\n` + `**Environment Info**\n` + `- Platform: ${env.platform}\n` + `- Terminal: ${env.terminal}\n` + `- Version: ${MACRO.VERSION || 'unknown'}\n` + `- Feedback ID: ${feedbackId}\n` + `\n**Errors**\n\`\`\`json\n`; const bodyPrefix = `**Bug Description**\n${sanitizedDescription}\n\n` + `**Environment Info**\n` + `- Product: ${PRODUCT_NAME}\n` + `- Platform: ${env.platform}\n` + `- Terminal: ${env.terminal}\n` + `- Version: ${MACRO.VERSION || 'unknown'}\n` + `- Feedback ID: ${feedbackId}\n` + `\n**Errors**\n\`\`\`json\n`;
const errorSuffix = `\n\`\`\`\n`; const errorSuffix = `\n\`\`\`\n`;
const errorsJson = jsonStringify(errors); const errorsJson = jsonStringify(errors);
const baseUrl = `${GITHUB_ISSUES_REPO_URL}/new?title=${encodeURIComponent(sanitizedTitle)}&labels=user-reported,bug&body=`; const baseUrl = `${GITHUB_ISSUES_REPO_URL}/new?title=${encodeURIComponent(sanitizedTitle)}&labels=user-reported,bug&body=`;
@@ -447,7 +439,7 @@ export function createGitHubIssueUrl(feedbackId: string, title: string, descript
async function generateTitle(description: string, abortSignal: AbortSignal): Promise<string> { async function generateTitle(description: string, abortSignal: AbortSignal): Promise<string> {
try { try {
const response = await queryHaiku({ const response = await queryHaiku({
systemPrompt: asSystemPrompt(['Generate a concise, technical issue title (max 80 chars) for a public GitHub issue based on this bug report for Claude Code.', 'Claude Code is an agentic coding CLI based on the Anthropic API.', 'The title should:', '- Include the type of issue [Bug] or [Feature Request] as the first thing in the title', '- Be concise, specific and descriptive of the actual problem', '- Use technical terminology appropriate for a software issue', '- For error messages, extract the key error (e.g., "Missing Tool Result Block" rather than the full message)', '- Be direct and clear for developers to understand the problem', '- If you cannot determine a clear issue, use "Bug Report: [brief description]"', '- Any LLM API errors are from the Anthropic API, not from any other model provider', 'Your response will be directly used as the title of the Github issue, and as such should not contain any other commentary or explaination', 'Examples of good titles include: "[Bug] Auto-Compact triggers to soon", "[Bug] Anthropic API Error: Missing Tool Result Block", "[Bug] Error: Invalid Model Name for Opus"']), systemPrompt: asSystemPrompt([`Generate a concise, technical issue title (max 80 chars) for a public GitHub issue based on this bug report for ${PRODUCT_NAME}.`, `${PRODUCT_NAME} is an agentic coding CLI with multiple model providers.`, 'The title should:', '- Include the type of issue [Bug] or [Feature Request] as the first thing in the title', '- Be concise, specific and descriptive of the actual problem', '- Use technical terminology appropriate for a software issue', '- For error messages, extract the key error (e.g., "Missing Tool Result Block" rather than the full message)', '- Be direct and clear for developers to understand the problem', '- If you cannot determine a clear issue, use "Bug Report: [brief description]"', 'Your response will be directly used as the title of the GitHub issue, and should not contain any extra commentary', 'Examples of good titles include: "[Bug] Auto-compact triggers too soon", "[Bug] Missing tool result block after retry", "[Bug] Invalid model name for GPT-5.4"']),
userPrompt: description, userPrompt: description,
signal: abortSignal, signal: abortSignal,
options: { options: {
@@ -520,69 +512,14 @@ async function submitFeedback(data: FeedbackData, signal?: AbortSignal): Promise
feedbackId?: string; feedbackId?: string;
isZdrOrg?: boolean; isZdrOrg?: boolean;
}> { }> {
if (isEssentialTrafficOnly()) {
return {
success: false
};
}
try { try {
// Ensure OAuth token is fresh before getting auth headers void signal;
// This prevents 401 errors from stale cached tokens void data;
await checkAndRefreshOAuthTokenIfNeeded();
const authResult = getAuthHeaders();
if (authResult.error) {
return {
success: false
};
}
const headers: Record<string, string> = {
'Content-Type': 'application/json',
'User-Agent': getUserAgent(),
...authResult.headers
};
const response = await axios.post('https://api.anthropic.com/api/claude_cli_feedback', {
content: jsonStringify(data)
}, {
headers,
timeout: 30000,
// 30 second timeout to prevent hanging
signal
});
if (response.status === 200) {
const result = response.data;
if (result?.feedback_id) {
return { return {
success: true, success: true,
feedbackId: result.feedback_id feedbackId: `better-clawd-${Date.now().toString(36)}`
};
}
sanitizeAndLogError(new Error('Failed to submit feedback: request did not return feedback_id'));
return {
success: false
};
}
sanitizeAndLogError(new Error('Failed to submit feedback:' + response.status));
return {
success: false
}; };
} catch (err) { } catch (err) {
// Handle cancellation/abort - don't log as error
if (axios.isCancel(err)) {
return {
success: false
};
}
if (axios.isAxiosError(err) && err.response?.status === 403) {
const errorData = err.response.data;
if (errorData?.error?.type === 'permission_error' && errorData?.error?.message?.includes('Custom data retention settings')) {
sanitizeAndLogError(new Error('Cannot submit feedback because custom data retention settings are enabled'));
return {
success: false,
isZdrOrg: true
};
}
}
// Use our safe error logging function to avoid leaking API keys
sanitizeAndLogError(err); sanitizeAndLogError(err);
return { return {
success: false success: false

View File

@@ -138,7 +138,7 @@ export function HelpV2(t0) {
const t5 = insideModal ? undefined : maxHeight; const t5 = insideModal ? undefined : maxHeight;
let t6; let t6;
if ($[31] !== tabs) { if ($[31] !== tabs) {
t6 = <Tabs title={false ? "/help" : `Claude Code v${MACRO.VERSION}`} color="professionalBlue" defaultTab="general">{tabs}</Tabs>; t6 = <Tabs title={false ? "/help" : `Better-Clawd v${MACRO.VERSION}`} color="professionalBlue" defaultTab="general">{tabs}</Tabs>;
$[31] = tabs; $[31] = tabs;
$[32] = t6; $[32] = t6;
} else { } else {

View File

@@ -88,7 +88,7 @@ export function CondensedLogo() {
} }
let t5; let t5;
if ($[8] === Symbol.for("react.memo_cache_sentinel")) { if ($[8] === Symbol.for("react.memo_cache_sentinel")) {
t5 = <Text bold={true}>Claude Code</Text>; t5 = <Text bold={true}>Better-Clawd</Text>;
$[8] = t5; $[8] = t5;
} else { } else {
t5 = $[8]; t5 = $[8];

View File

@@ -248,8 +248,8 @@ export function LogoV2() {
} }
const layoutMode = getLayoutMode(columns); const layoutMode = getLayoutMode(columns);
const userTheme = resolveThemeSetting(getGlobalConfig().theme); const userTheme = resolveThemeSetting(getGlobalConfig().theme);
const borderTitle = ` ${color("claude", userTheme)("Claude Code")} ${color("inactive", userTheme)(`v${version}`)} `; const borderTitle = ` ${color("claude", userTheme)("Better-Clawd")} ${color("inactive", userTheme)(`v${version}`)} `;
const compactBorderTitle = color("claude", userTheme)(" Claude Code "); const compactBorderTitle = color("claude", userTheme)(" Better-Clawd ");
if (layoutMode === "compact") { if (layoutMode === "compact") {
let welcomeMessage = formatWelcomeMessage(username); let welcomeMessage = formatWelcomeMessage(username);
if (stringWidth(welcomeMessage) > columns - 4) { if (stringWidth(welcomeMessage) > columns - 4) {

View File

@@ -9,7 +9,7 @@ export function WelcomeV2() {
if (env.terminal === "Apple_Terminal") { if (env.terminal === "Apple_Terminal") {
let t0; let t0;
if ($[0] !== theme) { if ($[0] !== theme) {
t0 = <AppleTerminalWelcomeV2 theme={theme} welcomeMessage="Welcome to Claude Code" />; t0 = <AppleTerminalWelcomeV2 theme={theme} welcomeMessage="Welcome to Better-Clawd" />;
$[0] = theme; $[0] = theme;
$[1] = t0; $[1] = t0;
} else { } else {
@@ -28,7 +28,7 @@ export function WelcomeV2() {
let t7; let t7;
let t8; let t8;
if ($[2] === Symbol.for("react.memo_cache_sentinel")) { if ($[2] === Symbol.for("react.memo_cache_sentinel")) {
t0 = <Text><Text color="claude">{"Welcome to Claude Code"} </Text><Text dimColor={true}>v{MACRO.VERSION} </Text></Text>; t0 = <Text><Text color="claude">{"Welcome to Better-Clawd"} </Text><Text dimColor={true}>v{MACRO.VERSION} </Text></Text>;
t1 = <Text>{"\u2026\u2026\u2026\u2026\u2026\u2026\u2026\u2026\u2026\u2026\u2026\u2026\u2026\u2026\u2026\u2026\u2026\u2026\u2026\u2026\u2026\u2026\u2026\u2026\u2026\u2026\u2026\u2026\u2026\u2026\u2026\u2026\u2026\u2026\u2026\u2026\u2026\u2026\u2026\u2026\u2026\u2026\u2026\u2026\u2026\u2026\u2026\u2026\u2026\u2026\u2026\u2026\u2026\u2026\u2026\u2026\u2026\u2026"}</Text>; t1 = <Text>{"\u2026\u2026\u2026\u2026\u2026\u2026\u2026\u2026\u2026\u2026\u2026\u2026\u2026\u2026\u2026\u2026\u2026\u2026\u2026\u2026\u2026\u2026\u2026\u2026\u2026\u2026\u2026\u2026\u2026\u2026\u2026\u2026\u2026\u2026\u2026\u2026\u2026\u2026\u2026\u2026\u2026\u2026\u2026\u2026\u2026\u2026\u2026\u2026\u2026\u2026\u2026\u2026\u2026\u2026\u2026\u2026\u2026\u2026"}</Text>;
t2 = <Text>{" "}</Text>; t2 = <Text>{" "}</Text>;
t3 = <Text>{" "}</Text>; t3 = <Text>{" "}</Text>;
@@ -113,7 +113,7 @@ export function WelcomeV2() {
let t5; let t5;
let t6; let t6;
if ($[18] === Symbol.for("react.memo_cache_sentinel")) { if ($[18] === Symbol.for("react.memo_cache_sentinel")) {
t0 = <Text><Text color="claude">{"Welcome to Claude Code"} </Text><Text dimColor={true}>v{MACRO.VERSION} </Text></Text>; t0 = <Text><Text color="claude">{"Welcome to Better-Clawd"} </Text><Text dimColor={true}>v{MACRO.VERSION} </Text></Text>;
t1 = <Text>{"\u2026\u2026\u2026\u2026\u2026\u2026\u2026\u2026\u2026\u2026\u2026\u2026\u2026\u2026\u2026\u2026\u2026\u2026\u2026\u2026\u2026\u2026\u2026\u2026\u2026\u2026\u2026\u2026\u2026\u2026\u2026\u2026\u2026\u2026\u2026\u2026\u2026\u2026\u2026\u2026\u2026\u2026\u2026\u2026\u2026\u2026\u2026\u2026\u2026\u2026\u2026\u2026\u2026\u2026\u2026\u2026\u2026\u2026"}</Text>; t1 = <Text>{"\u2026\u2026\u2026\u2026\u2026\u2026\u2026\u2026\u2026\u2026\u2026\u2026\u2026\u2026\u2026\u2026\u2026\u2026\u2026\u2026\u2026\u2026\u2026\u2026\u2026\u2026\u2026\u2026\u2026\u2026\u2026\u2026\u2026\u2026\u2026\u2026\u2026\u2026\u2026\u2026\u2026\u2026\u2026\u2026\u2026\u2026\u2026\u2026\u2026\u2026\u2026\u2026\u2026\u2026\u2026\u2026\u2026\u2026"}</Text>;
t2 = <Text>{" "}</Text>; t2 = <Text>{" "}</Text>;
t3 = <Text>{" * \u2588\u2588\u2588\u2588\u2588\u2593\u2593\u2591 "}</Text>; t3 = <Text>{" * \u2588\u2588\u2588\u2588\u2588\u2593\u2593\u2591 "}</Text>;

View File

@@ -282,10 +282,11 @@ const MAX_MESSAGES_TO_SHOW_IN_TRANSCRIPT_MODE = 30;
// of fibers, and per-frame write costs that push the process into a GC // of fibers, and per-frame write costs that push the process into a GC
// death spiral (observed: 59 GB RSS, 14k mmap/munmap/sec). Content dropped // death spiral (observed: 59 GB RSS, 14k mmap/munmap/sec). Content dropped
// from this slice has already been printed to terminal scrollback — users // from this slice has already been printed to terminal scrollback — users
// can still scroll up natively. VirtualMessageList (the default ant path) // can still scroll up natively. Better-Clawd's default external path is the
// bypasses this cap entirely. Headless one-shot renders (e.g. /export) // main-screen renderer (no VirtualMessageList), so keep the live window
// pass disableRenderCap to opt out — they have no scrollback and the // smaller to reduce typing-time diff/write work in long sessions. Headless
// memory concern doesn't apply to renderToString. // one-shot renders (e.g. /export) pass disableRenderCap to opt out — they
// have no scrollback and the memory concern doesn't apply to renderToString.
// //
// The slice boundary is tracked as a UUID anchor, not a count-derived // The slice boundary is tracked as a UUID anchor, not a count-derived
// index. Count-based slicing (slice(-200)) drops one message from the // index. Count-based slicing (slice(-200)) drops one message from the
@@ -302,9 +303,9 @@ const MAX_MESSAGES_TO_SHOW_IN_TRANSCRIPT_MODE = 30;
// as tool results stream in, changing which summary is first. When the // as tool results stream in, changing which summary is first. When the
// uuid vanishes, falling back to the stored index (clamped) keeps the // uuid vanishes, falling back to the stored index (clamped) keeps the
// slice roughly where it was instead of resetting to 0 — which would // slice roughly where it was instead of resetting to 0 — which would
// jump from ~200 rendered messages to the full history, orphaning // jump from ~120 rendered messages to the full history, orphaning
// in-progress badge snapshots in scrollback. // in-progress badge snapshots in scrollback.
const MAX_MESSAGES_WITHOUT_VIRTUALIZATION = 200; const MAX_MESSAGES_WITHOUT_VIRTUALIZATION = 120;
const MESSAGE_CAP_STEP = 50; const MESSAGE_CAP_STEP = 50;
export type SliceAnchor = { export type SliceAnchor = {
uuid: string; uuid: string;
@@ -500,7 +501,7 @@ const MessagesImpl = ({
// CC-724: drop attachment messages that AttachmentMessage renders as // CC-724: drop attachment messages that AttachmentMessage renders as
// null (hook_success, hook_additional_context, hook_cancelled, etc.) // null (hook_success, hook_additional_context, hook_cancelled, etc.)
// BEFORE counting/slicing so they don't inflate the "N messages" // BEFORE counting/slicing so they don't inflate the "N messages"
// count in ctrl-o or consume slots in the 200-message render cap. // count in ctrl-o or consume slots in the non-virtualized render cap.
.filter(msg_3 => !isNullRenderingAttachment(msg_3)).filter(_ => shouldShowUserMessage(_, isTranscriptMode)), syntheticStreamingToolUseMessages); .filter(msg_3 => !isNullRenderingAttachment(msg_3)).filter(_ => shouldShowUserMessage(_, isTranscriptMode)), syntheticStreamingToolUseMessages);
// Three-tier filtering. Transcript mode (ctrl+o screen) is truly unfiltered. // Three-tier filtering. Transcript mode (ctrl+o screen) is truly unfiltered.
// Brief-only: SendUserMessage + user input only. Default: drop redundant // Brief-only: SendUserMessage + user input only. Default: drop redundant
@@ -730,7 +731,8 @@ function expandKey(msg: RenderableMessage): string {
// Default React.memo does shallow comparison which fails when: // Default React.memo does shallow comparison which fails when:
// 1. onOpenRateLimitOptions callback is recreated (doesn't affect render output) // 1. onOpenRateLimitOptions callback is recreated (doesn't affect render output)
// 2. streamingToolUses array is recreated on every delta, but only contentBlock matters for rendering // 2. streamingToolUses array is recreated on every delta, but only contentBlock matters for rendering
// 3. streamingThinking changes on every delta - we DO want to re-render for this // 3. commands/tool queues get rebuilt with the same semantic contents
// 4. streamingThinking changes on every delta - we DO want to re-render for this
function setsEqual<T>(a: Set<T>, b: Set<T>): boolean { function setsEqual<T>(a: Set<T>, b: Set<T>): boolean {
if (a.size !== b.size) return false; if (a.size !== b.size) return false;
for (const item of a) { for (const item of a) {
@@ -738,6 +740,12 @@ function setsEqual<T>(a: Set<T>, b: Set<T>): boolean {
} }
return true; return true;
} }
function commandArraysEqual(a: Command[], b: Command[]): boolean {
return a.length === b.length && a.every((command, i) => command.name === b[i]?.name);
}
function toolUseConfirmQueuesEqual(a: ToolUseConfirm[], b: ToolUseConfirm[]): boolean {
return a.length === b.length && a.every((item, i) => item.toolUseID === b[i]?.toolUseID);
}
export const Messages = React.memo(MessagesImpl, (prev, next) => { export const Messages = React.memo(MessagesImpl, (prev, next) => {
const keys = Object.keys(prev) as (keyof typeof prev)[]; const keys = Object.keys(prev) as (keyof typeof prev)[];
for (const key of keys) { for (const key of keys) {
@@ -769,6 +777,16 @@ export const Messages = React.memo(MessagesImpl, (prev, next) => {
continue; continue;
} }
} }
if (key === 'commands') {
if (commandArraysEqual(prev.commands, next.commands)) {
continue;
}
}
if (key === 'toolUseConfirmQueue') {
if (toolUseConfirmQueuesEqual(prev.toolUseConfirmQueue, next.toolUseConfirmQueue)) {
continue;
}
}
// streamingThinking changes frequently - always re-render when it changes // streamingThinking changes frequently - always re-render when it changes
// (no special handling needed, default behavior is correct) // (no special handling needed, default behavior is correct)
return false; return false;

View File

@@ -0,0 +1,215 @@
import * as React from 'react'
import { useState } from 'react'
import { Box, Text } from '../ink.js'
import {
importOpenAIAuthFromCodexCache,
runCodexLogin,
saveOpenAIApiKey,
saveOpenAIAuthTokens,
} from '../utils/auth.js'
import { Select } from './CustomSelect/select.js'
import TextInput from './TextInput.js'
import { Spinner } from './Spinner.js'
type OpenAILoginFlowProps = {
onDone: () => void
startingMessage?: string
}
type LoginMode = 'menu' | 'api_key' | 'access_token'
export function OpenAILoginFlow({
onDone,
startingMessage,
}: OpenAILoginFlowProps): React.ReactNode {
const [mode, setMode] = useState<LoginMode>('menu')
const [isBusy, setIsBusy] = useState(false)
const [status, setStatus] = useState<string | null>(null)
const [inputValue, setInputValue] = useState('')
const [cursorOffset, setCursorOffset] = useState(0)
const menuOptions = [
{
label: (
<Text>
Use cached Codex login{' '}
<Text dimColor={true}>Import `~/.codex/auth.json`</Text>
{'\n'}
</Text>
),
value: 'import_cache',
},
{
label: (
<Text>
Sign in with Codex in browser{' '}
<Text dimColor={true}>Runs `codex login`</Text>
{'\n'}
</Text>
),
value: 'browser_login',
},
{
label: (
<Text>
Sign in with device code{' '}
<Text dimColor={true}>Runs `codex login --device-auth`</Text>
{'\n'}
</Text>
),
value: 'device_login',
},
{
label: (
<Text>
Paste OpenAI API key{' '}
<Text dimColor={true}>Usage-based billing</Text>
{'\n'}
</Text>
),
value: 'api_key',
},
{
label: (
<Text>
Paste Codex access token{' '}
<Text dimColor={true}>Manual fallback for ChatGPT auth</Text>
{'\n'}
</Text>
),
value: 'access_token',
},
] as const
async function handleMenuSelection(value: string): Promise<void> {
setStatus(null)
if (value === 'api_key') {
setInputValue('')
setCursorOffset(0)
setMode('api_key')
return
}
if (value === 'access_token') {
setInputValue('')
setCursorOffset(0)
setMode('access_token')
return
}
setIsBusy(true)
try {
if (value === 'import_cache') {
await importOpenAIAuthFromCodexCache()
} else if (value === 'browser_login') {
await runCodexLogin()
} else if (value === 'device_login') {
await runCodexLogin({ deviceAuth: true })
}
onDone()
} catch (error) {
setStatus(error instanceof Error ? error.message : String(error))
} finally {
setIsBusy(false)
}
}
async function handleSubmit(value: string): Promise<void> {
const trimmed = value.trim()
if (!trimmed) {
return
}
setIsBusy(true)
setStatus(null)
try {
if (mode === 'api_key') {
await saveOpenAIApiKey(trimmed)
} else {
saveOpenAIAuthTokens({ accessToken: trimmed })
}
onDone()
} catch (error) {
setStatus(error instanceof Error ? error.message : String(error))
} finally {
setIsBusy(false)
}
}
if (isBusy) {
return (
<Box flexDirection="column" gap={1}>
<Box>
<Spinner />
<Text>Configuring OpenAI login for Better-Clawd</Text>
</Box>
<Text dimColor={true}>
ChatGPT login uses Codex&apos;s shared auth cache and API-key login uses
your OpenAI Platform key.
</Text>
</Box>
)
}
if (mode === 'api_key' || mode === 'access_token') {
const prompt =
mode === 'api_key'
? 'Paste your OpenAI API key:'
: 'Paste your Codex access token:'
return (
<Box flexDirection="column" gap={1}>
<Text>
{mode === 'api_key'
? 'OpenAI API keys use standard platform billing.'
: 'Codex access tokens are cached by Codex after ChatGPT login.'}
</Text>
<Box>
<Text>{prompt}</Text>
<TextInput
value={inputValue}
onChange={setInputValue}
onSubmit={handleSubmit}
onExit={() => {
setMode('menu')
setInputValue('')
setCursorOffset(0)
}}
cursorOffset={cursorOffset}
onChangeCursorOffset={setCursorOffset}
columns={72}
mask="*"
/>
</Box>
{status ? <Text color="error">{status}</Text> : null}
<Text dimColor={true}>
Press <Text bold={true}>Enter</Text> to save, or <Text bold={true}>Esc</Text>{' '}
to cancel.
</Text>
</Box>
)
}
return (
<Box flexDirection="column" gap={1}>
<Text>
{startingMessage ??
'Better-Clawd can use OpenAI via ChatGPT-managed Codex login or with a standard OpenAI API key.'}
</Text>
<Text dimColor={true}>
Codex shares cached credentials between the CLI and IDE. If browser login
is unavailable, device-auth and auth-cache import are supported too.
</Text>
{status ? <Text color="error">{status}</Text> : null}
<Box>
<Select
options={menuOptions}
onChange={value => {
void handleMenuSelection(value)
}}
/>
</Box>
</Box>
)
}

View File

@@ -73,7 +73,7 @@ export function PackageManagerAutoUpdater(t0) {
if (!updateAvailable) { if (!updateAvailable) {
return null; return null;
} }
const updateCommand = packageManager === "homebrew" ? "brew upgrade claude-code" : packageManager === "winget" ? "winget upgrade Anthropic.ClaudeCode" : packageManager === "apk" ? "apk upgrade claude-code" : "your package manager update command"; const updateCommand = packageManager === "homebrew" ? "brew upgrade <better-clawd-package>" : packageManager === "winget" ? "winget upgrade <better-clawd-package>" : packageManager === "apk" ? "apk upgrade <better-clawd-package>" : "your package manager update command";
let t4; let t4;
if ($[3] !== verbose) { if ($[3] !== verbose) {
t4 = verbose && <Text dimColor={true} wrap="truncate">currentVersion: {MACRO.VERSION}</Text>; t4 = verbose && <Text dimColor={true} wrap="truncate">currentVersion: {MACRO.VERSION}</Text>;

View File

@@ -464,6 +464,8 @@ function PromptInput({
// immediately; the useEffect below clears the raw state so it doesn't // immediately; the useEffect below clears the raw state so it doesn't
// resurrect when the same pill reappears (new task starts → focus stolen). // resurrect when the same pill reappears (new task starts → focus stolen).
const rawFooterSelection = useAppState(s => s.footerSelection); const rawFooterSelection = useAppState(s => s.footerSelection);
const footerSelectionRef = useRef<FooterItem | null>(null);
footerSelectionRef.current = rawFooterSelection;
const footerItemSelected = rawFooterSelection && footerItems.includes(rawFooterSelection) ? rawFooterSelection : null; const footerItemSelected = rawFooterSelection && footerItems.includes(rawFooterSelection) ? rawFooterSelection : null;
useEffect(() => { useEffect(() => {
if (rawFooterSelection && !footerItemSelected) { if (rawFooterSelection && !footerItemSelected) {
@@ -473,6 +475,15 @@ function PromptInput({
}); });
} }
}, [rawFooterSelection, footerItemSelected, setAppState]); }, [rawFooterSelection, footerItemSelected, setAppState]);
const clearFooterSelectionIfNeeded = useCallback(() => {
if (footerSelectionRef.current === null) {
return;
}
setAppState(prev => prev.footerSelection === null ? prev : {
...prev,
footerSelection: null
});
}, [setAppState]);
const tasksSelected = footerItemSelected === 'tasks'; const tasksSelected = footerItemSelected === 'tasks';
const tmuxSelected = footerItemSelected === 'tmux'; const tmuxSelected = footerItemSelected === 'tmux';
const bagelSelected = footerItemSelected === 'bagel'; const bagelSelected = footerItemSelected === 'bagel';
@@ -892,13 +903,11 @@ function PromptInput({
pushToBuffer(input, cursorOffset, pastedContents); pushToBuffer(input, cursorOffset, pastedContents);
} }
// Deselect footer items when user types // Deselect footer items when user types, but skip the store write when
setAppState(prev => prev.footerSelection === null ? prev : { // nothing is selected so routine keystrokes stay inside the input subtree.
...prev, clearFooterSelectionIfNeeded();
footerSelection: null
});
trackAndSetInput(processedValue); trackAndSetInput(processedValue);
}, [trackAndSetInput, onModeChange, input, cursorOffset, pushToBuffer, pastedContents, dismissStashHint, setAppState]); }, [trackAndSetInput, onModeChange, input, cursorOffset, pushToBuffer, pastedContents, dismissStashHint, clearFooterSelectionIfNeeded]);
const { const {
resetHistory, resetHistory,
onHistoryUp, onHistoryUp,

View File

@@ -45,9 +45,9 @@ export function RemoteCallout({
<Box flexDirection="column" paddingX={2} paddingY={1}> <Box flexDirection="column" paddingX={2} paddingY={1}>
<Box marginBottom={1} flexDirection="column"> <Box marginBottom={1} flexDirection="column">
<Text> <Text>
Remote Control lets you access this CLI session from the web Remote Control lets you access this CLI session from a compatible
(claude.ai/code) or the Claude app, so you can pick up where you remote bridge so you can pick up where you left off on another
left off on any device. device.
</Text> </Text>
<Text> </Text> <Text> </Text>
<Text> <Text>

View File

@@ -18,7 +18,8 @@ import { Dialog } from './design-system/Dialog.js';
import { KeyboardShortcutHint } from './design-system/KeyboardShortcutHint.js'; import { KeyboardShortcutHint } from './design-system/KeyboardShortcutHint.js';
import { LoadingState } from './design-system/LoadingState.js'; import { LoadingState } from './design-system/LoadingState.js';
const DIALOG_TITLE = 'Select Remote Environment'; const DIALOG_TITLE = 'Select Remote Environment';
const SETUP_HINT = `Configure environments at: https://claude.ai/code`; const SETUP_HINT =
'Remote environments are not configured by Better-Clawd. Use your existing remote session provider setup.';
type Props = { type Props = {
onDone: (message?: string) => void; onDone: (message?: string) => void;
}; };

View File

@@ -1,9 +1,35 @@
export const PRODUCT_URL = 'https://claude.com/claude-code' export const PRODUCT_NAME = 'Better-Clawd'
export const LEGACY_PRODUCT_NAME = 'Claude Code'
export const PRODUCT_SLUG = 'better-clawd'
export const LEGACY_PRODUCT_SLUG = 'claude-code'
export const CLI_BINARY_NAME = 'better-clawd'
export const LEGACY_CLI_BINARY_NAME = 'claude'
export const PRODUCT_CONFIG_DIRNAME = '.better-clawd'
export const LEGACY_PRODUCT_CONFIG_DIRNAME = '.claude'
export const PRODUCT_CONFIG_ENV_VAR = 'BETTER_CLAWD_CONFIG_DIR'
export const LEGACY_PRODUCT_CONFIG_ENV_VAR = 'CLAUDE_CONFIG_DIR'
export const PRODUCT_URL = 'https://github.com/x1xhlol/better-clawd'
export const PRODUCT_ISSUES_URL =
'https://github.com/x1xhlol/better-clawd/issues'
export const PRODUCT_NOREPLY_EMAIL = 'noreply@better-clawd.invalid'
// Claude Code Remote session URLs // Anthropic web URLs are kept for the Anthropic provider and remote sessions.
export const CLAUDE_AI_BASE_URL = 'https://claude.ai' export const ANTHROPIC_APP_BASE_URL = 'https://claude.ai'
export const CLAUDE_AI_STAGING_BASE_URL = 'https://claude-ai.staging.ant.dev' export const ANTHROPIC_APP_STAGING_BASE_URL =
export const CLAUDE_AI_LOCAL_BASE_URL = 'http://localhost:4000' 'https://claude-ai.staging.ant.dev'
export const ANTHROPIC_APP_LOCAL_BASE_URL = 'http://localhost:4000'
// Backward-compatible aliases while the Anthropic provider is still supported.
export const CLAUDE_AI_BASE_URL = ANTHROPIC_APP_BASE_URL
export const CLAUDE_AI_STAGING_BASE_URL = ANTHROPIC_APP_STAGING_BASE_URL
export const CLAUDE_AI_LOCAL_BASE_URL = ANTHROPIC_APP_LOCAL_BASE_URL
export function getConfiguredProductConfigDir(): string | undefined {
return (
process.env[PRODUCT_CONFIG_ENV_VAR] ??
process.env[LEGACY_PRODUCT_CONFIG_ENV_VAR]
)
}
/** /**
* Determine if we're in a staging environment for remote sessions. * Determine if we're in a staging environment for remote sessions.
@@ -41,12 +67,12 @@ export function getClaudeAiBaseUrl(
ingressUrl?: string, ingressUrl?: string,
): string { ): string {
if (isRemoteSessionLocal(sessionId, ingressUrl)) { if (isRemoteSessionLocal(sessionId, ingressUrl)) {
return CLAUDE_AI_LOCAL_BASE_URL return ANTHROPIC_APP_LOCAL_BASE_URL
} }
if (isRemoteSessionStaging(sessionId, ingressUrl)) { if (isRemoteSessionStaging(sessionId, ingressUrl)) {
return CLAUDE_AI_STAGING_BASE_URL return ANTHROPIC_APP_STAGING_BASE_URL
} }
return CLAUDE_AI_BASE_URL return ANTHROPIC_APP_BASE_URL
} }
/** /**

View File

@@ -696,7 +696,7 @@ export async function computeSimpleEnvInfo(
: `The most recent Claude model family is Claude 4.5/4.6. Model IDs — Opus 4.6: '${CLAUDE_4_5_OR_4_6_MODEL_IDS.opus}', Sonnet 4.6: '${CLAUDE_4_5_OR_4_6_MODEL_IDS.sonnet}', Haiku 4.5: '${CLAUDE_4_5_OR_4_6_MODEL_IDS.haiku}'. When building AI applications, default to the latest and most capable Claude models.`, : `The most recent Claude model family is Claude 4.5/4.6. Model IDs — Opus 4.6: '${CLAUDE_4_5_OR_4_6_MODEL_IDS.opus}', Sonnet 4.6: '${CLAUDE_4_5_OR_4_6_MODEL_IDS.sonnet}', Haiku 4.5: '${CLAUDE_4_5_OR_4_6_MODEL_IDS.haiku}'. When building AI applications, default to the latest and most capable Claude models.`,
process.env.USER_TYPE === 'ant' && isUndercover() process.env.USER_TYPE === 'ant' && isUndercover()
? null ? null
: `Claude Code is available as a CLI in the terminal, desktop app (Mac/Windows), web app (claude.ai/code), and IDE extensions (VS Code, JetBrains).`, : `Better-Clawd is available as a terminal CLI and local desktop or IDE workflows. Prefer local tooling and repository-hosted flows when giving instructions.`,
process.env.USER_TYPE === 'ant' && isUndercover() process.env.USER_TYPE === 'ant' && isUndercover()
? null ? null
: `Fast mode for Claude Code uses the same ${FRONTIER_MODEL_NAME} model with faster output. It does NOT switch to a different model. It can be toggled with /fast.`, : `Fast mode for Claude Code uses the same ${FRONTIER_MODEL_NAME} model with faster output. It does NOT switch to a different model. It can be toggled with /fast.`,

5
src/daemon/main.ts Normal file
View File

@@ -0,0 +1,5 @@
import { unsupportedEntrypoint } from '../utils/unsupportedEntrypoint.js'
export async function daemonMain(): Promise<never> {
return unsupportedEntrypoint('better-clawd daemon')
}

View File

@@ -0,0 +1,7 @@
import { unsupportedEntrypoint } from '../utils/unsupportedEntrypoint.js'
export async function runDaemonWorker(kind?: string): Promise<never> {
return unsupportedEntrypoint(
kind ? `better-clawd --daemon-worker ${kind}` : 'better-clawd --daemon-worker',
)
}

View File

@@ -37,7 +37,7 @@ async function main(): Promise<void> {
if (args.length === 1 && (args[0] === '--version' || args[0] === '-v' || args[0] === '-V')) { if (args.length === 1 && (args[0] === '--version' || args[0] === '-v' || args[0] === '-V')) {
// MACRO.VERSION is inlined at build time // MACRO.VERSION is inlined at build time
// biome-ignore lint/suspicious/noConsole:: intentional console output // biome-ignore lint/suspicious/noConsole:: intentional console output
console.log(`${MACRO.VERSION} (Claude Code)`); console.log(`${MACRO.VERSION} (Better-Clawd)`);
return; return;
} }

View File

@@ -1,11 +1,8 @@
import { profileCheckpoint } from '../utils/startupProfiler.js' import { profileCheckpoint } from '../utils/startupProfiler.js'
import '../bootstrap/state.js' import '../bootstrap/state.js'
import '../utils/config.js' import '../utils/config.js'
import type { Attributes, MetricOptions } from '@opentelemetry/api'
import memoize from 'lodash-es/memoize.js' import memoize from 'lodash-es/memoize.js'
import { getIsNonInteractiveSession } from 'src/bootstrap/state.js' import { getIsNonInteractiveSession } from 'src/bootstrap/state.js'
import type { AttributedCounter } from '../bootstrap/state.js'
import { getSessionCounter, setMeter } from '../bootstrap/state.js'
import { shutdownLspServerManager } from '../services/lsp/manager.js' import { shutdownLspServerManager } from '../services/lsp/manager.js'
import { populateOAuthAccountInfoIfNeeded } from '../services/oauth/client.js' import { populateOAuthAccountInfoIfNeeded } from '../services/oauth/client.js'
import { import {
@@ -15,7 +12,6 @@ import {
import { import {
initializeRemoteManagedSettingsLoadingPromise, initializeRemoteManagedSettingsLoadingPromise,
isEligibleForRemoteManagedSettings, isEligibleForRemoteManagedSettings,
waitForRemoteManagedSettingsToLoad,
} from '../services/remoteManagedSettings/index.js' } from '../services/remoteManagedSettings/index.js'
import { preconnectAnthropicApi } from '../utils/apiPreconnect.js' import { preconnectAnthropicApi } from '../utils/apiPreconnect.js'
import { applyExtraCACertsFromConfig } from '../utils/caCertsConfig.js' import { applyExtraCACertsFromConfig } from '../utils/caCertsConfig.js'
@@ -26,34 +22,21 @@ import { detectCurrentRepository } from '../utils/detectRepository.js'
import { logForDiagnosticsNoPII } from '../utils/diagLogs.js' import { logForDiagnosticsNoPII } from '../utils/diagLogs.js'
import { initJetBrainsDetection } from '../utils/envDynamic.js' import { initJetBrainsDetection } from '../utils/envDynamic.js'
import { isEnvTruthy } from '../utils/envUtils.js' import { isEnvTruthy } from '../utils/envUtils.js'
import { ConfigParseError, errorMessage } from '../utils/errors.js' import { ConfigParseError } from '../utils/errors.js'
// showInvalidConfigDialog is dynamically imported in the error path to avoid loading React at init // showInvalidConfigDialog is dynamically imported in the error path to avoid loading React at init
import { import {
gracefulShutdownSync, gracefulShutdownSync,
setupGracefulShutdown, setupGracefulShutdown,
} from '../utils/gracefulShutdown.js' } from '../utils/gracefulShutdown.js'
import { import { applySafeConfigEnvironmentVariables } from '../utils/managedEnv.js'
applyConfigEnvironmentVariables,
applySafeConfigEnvironmentVariables,
} from '../utils/managedEnv.js'
import { configureGlobalMTLS } from '../utils/mtls.js' import { configureGlobalMTLS } from '../utils/mtls.js'
import { import {
ensureScratchpadDir, ensureScratchpadDir,
isScratchpadEnabled, isScratchpadEnabled,
} from '../utils/permissions/filesystem.js' } from '../utils/permissions/filesystem.js'
// initializeTelemetry is loaded lazily via import() in setMeterState() to defer
// ~400KB of OpenTelemetry + protobuf modules until telemetry is actually initialized.
// gRPC exporters (~700KB via @grpc/grpc-js) are further lazy-loaded within instrumentation.ts.
import { configureGlobalAgents } from '../utils/proxy.js' import { configureGlobalAgents } from '../utils/proxy.js'
import { isBetaTracingEnabled } from '../utils/telemetry/betaSessionTracing.js'
import { getTelemetryAttributes } from '../utils/telemetryAttributes.js'
import { setShellIfWindows } from '../utils/windowsPaths.js' import { setShellIfWindows } from '../utils/windowsPaths.js'
// initialize1PEventLogging is dynamically imported to defer OpenTelemetry sdk-logs/resources
// Track if telemetry has been initialized to prevent double initialization
let telemetryInitialized = false
export const init = memoize(async (): Promise<void> => { export const init = memoize(async (): Promise<void> => {
const initStartTime = Date.now() const initStartTime = Date.now()
logForDiagnosticsNoPII('info', 'init_started') logForDiagnosticsNoPII('info', 'init_started')
@@ -87,22 +70,7 @@ export const init = memoize(async (): Promise<void> => {
setupGracefulShutdown() setupGracefulShutdown()
profileCheckpoint('init_after_graceful_shutdown') profileCheckpoint('init_after_graceful_shutdown')
// Initialize 1P event logging (no security concerns, but deferred to avoid // Better-Clawd disables outbound telemetry, so the 1P event logger stays off.
// loading OpenTelemetry sdk-logs at startup). growthbook.js is already in
// the module cache by this point (firstPartyEventLogger imports it), so the
// second dynamic import adds no load cost.
void Promise.all([
import('../services/analytics/firstPartyEventLogger.js'),
import('../services/analytics/growthbook.js'),
]).then(([fp, gb]) => {
fp.initialize1PEventLogging()
// Rebuild the logger provider if tengu_1p_event_batch_config changes
// mid-session. Change detection (isEqual) is inside the handler so
// unchanged refreshes are no-ops.
gb.onGrowthBookRefresh(() => {
void fp.reinitialize1PEventLoggingIfConfigChanged()
})
})
profileCheckpoint('init_after_1p_event_logging') profileCheckpoint('init_after_1p_event_logging')
// Populate OAuth account info if it is not already cached in config. This is needed since the // Populate OAuth account info if it is not already cached in config. This is needed since the
@@ -245,96 +213,5 @@ export const init = memoize(async (): Promise<void> => {
* This should only be called once, after the trust dialog has been accepted. * This should only be called once, after the trust dialog has been accepted.
*/ */
export function initializeTelemetryAfterTrust(): void { export function initializeTelemetryAfterTrust(): void {
if (isEligibleForRemoteManagedSettings()) {
// For SDK/headless mode with beta tracing, initialize eagerly first
// to ensure the tracer is ready before the first query runs.
// The async path below will still run but doInitializeTelemetry() guards against double init.
if (getIsNonInteractiveSession() && isBetaTracingEnabled()) {
void doInitializeTelemetry().catch(error => {
logForDebugging(
`[3P telemetry] Eager telemetry init failed (beta tracing): ${errorMessage(error)}`,
{ level: 'error' },
)
})
}
logForDebugging(
'[3P telemetry] Waiting for remote managed settings before telemetry init',
)
void waitForRemoteManagedSettingsToLoad()
.then(async () => {
logForDebugging(
'[3P telemetry] Remote managed settings loaded, initializing telemetry',
)
// Re-apply env vars to pick up remote settings before initializing telemetry.
applyConfigEnvironmentVariables()
await doInitializeTelemetry()
})
.catch(error => {
logForDebugging(
`[3P telemetry] Telemetry init failed (remote settings path): ${errorMessage(error)}`,
{ level: 'error' },
)
})
} else {
void doInitializeTelemetry().catch(error => {
logForDebugging(
`[3P telemetry] Telemetry init failed: ${errorMessage(error)}`,
{ level: 'error' },
)
})
}
}
async function doInitializeTelemetry(): Promise<void> {
if (telemetryInitialized) {
// Already initialized, nothing to do
return return
}
// Set flag before init to prevent double initialization
telemetryInitialized = true
try {
await setMeterState()
} catch (error) {
// Reset flag on failure so subsequent calls can retry
telemetryInitialized = false
throw error
}
}
async function setMeterState(): Promise<void> {
// Lazy-load instrumentation to defer ~400KB of OpenTelemetry + protobuf
const { initializeTelemetry } = await import(
'../utils/telemetry/instrumentation.js'
)
// Initialize customer OTLP telemetry (metrics, logs, traces)
const meter = await initializeTelemetry()
if (meter) {
// Create factory function for attributed counters
const createAttributedCounter = (
name: string,
options: MetricOptions,
): AttributedCounter => {
const counter = meter?.createCounter(name, options)
return {
add(value: number, additionalAttributes: Attributes = {}) {
// Always fetch fresh telemetry attributes to ensure they're up to date
const currentAttributes = getTelemetryAttributes()
const mergedAttributes = {
...currentAttributes,
...additionalAttributes,
}
counter?.add(value, mergedAttributes)
},
}
}
setMeter(meter, createAttributedCounter)
// Increment session counter here because the startup telemetry path
// runs before this async initialization completes, so the counter
// would be null there.
getSessionCounter()?.add(1)
}
} }

View File

@@ -1341,7 +1341,7 @@ export const SDKRateLimitInfoSchema = lazySchema(() =>
isUsingOverage: z.boolean().optional(), isUsingOverage: z.boolean().optional(),
surpassedThreshold: z.number().optional(), surpassedThreshold: z.number().optional(),
}) })
.describe('Rate limit information for claude.ai subscription users.'), .describe('Rate limit information for browser-authenticated subscription users.'),
) )
export const SDKAssistantMessageSchema = lazySchema(() => export const SDKAssistantMessageSchema = lazySchema(() =>

View File

@@ -0,0 +1 @@
export type GeneratedSDKType = Record<string, unknown>

View File

@@ -0,0 +1,23 @@
export type EffortLevel = 'low' | 'medium' | 'high' | 'max'
export type AnyZodRawShape = Record<string, unknown>
export type InferShape<T> = T extends Record<string, unknown> ? T : never
export type Options = Record<string, unknown>
export type InternalOptions = Record<string, unknown>
export type Query = Record<string, unknown>
export type InternalQuery = Record<string, unknown>
export type ListSessionsOptions = Record<string, unknown>
export type GetSessionInfoOptions = Record<string, unknown>
export type GetSessionMessagesOptions = Record<string, unknown>
export type SessionMutationOptions = Record<string, unknown>
export type ForkSessionOptions = Record<string, unknown>
export type ForkSessionResult = Record<string, unknown>
export type McpSdkServerConfigWithInstance = Record<string, unknown>
export type SessionMessage = Record<string, unknown>
export type SDKSession = Record<string, unknown>
export type SDKSessionOptions = Record<string, unknown>
export type SDKSessionInfo = Record<string, unknown>
export type SDKUserMessage = Record<string, unknown>
export type SDKResultMessage = Record<string, unknown>
export type SdkMcpToolDefinition<T = Record<string, unknown>> = T

View File

@@ -0,0 +1 @@
export type SDKTool = Record<string, unknown>

View File

@@ -0,0 +1,5 @@
import { unsupportedEntrypoint } from '../utils/unsupportedEntrypoint.js'
export async function environmentRunnerMain(): Promise<never> {
return unsupportedEntrypoint('better-clawd environment-runner')
}

View File

@@ -2,7 +2,7 @@ import { isInBundledMode } from 'src/utils/bundledMode.js';
import { getCurrentInstallationType } from 'src/utils/doctorDiagnostic.js'; import { getCurrentInstallationType } from 'src/utils/doctorDiagnostic.js';
import { isEnvTruthy } from 'src/utils/envUtils.js'; import { isEnvTruthy } from 'src/utils/envUtils.js';
import { useStartupNotification } from './useStartupNotification.js'; import { useStartupNotification } from './useStartupNotification.js';
const NPM_DEPRECATION_MESSAGE = 'Claude Code has switched from npm to native installer. Run `claude install` or see https://docs.anthropic.com/en/docs/claude-code/getting-started for more options.'; const NPM_DEPRECATION_MESSAGE = 'Better-Clawd no longer recommends the legacy npm installer. Run `better-clawd install` or see the Better-Clawd repository for current installation options.';
export function useNpmDeprecationNotification() { export function useNpmDeprecationNotification() {
useStartupNotification(_temp); useStartupNotification(_temp);
} }

View File

@@ -24,7 +24,7 @@ async function _temp() {
if (true && !isClaudeAISubscriber()) { if (true && !isClaudeAISubscriber()) {
return { return {
key: "chrome-requires-subscription", key: "chrome-requires-subscription",
jsx: <Text color="error">Claude in Chrome requires a claude.ai subscription</Text>, jsx: <Text color="error">Browser integration requires browser-based remote-session authentication</Text>,
priority: "immediate", priority: "immediate",
timeoutMs: 5000 timeoutMs: 5000
}; };

1
src/ink/devtools.ts Normal file
View File

@@ -0,0 +1 @@
export {}

1
src/ink/global.d.ts vendored Normal file
View File

@@ -0,0 +1 @@
export {}

View File

@@ -4047,7 +4047,7 @@ async function run(): Promise<CommanderCommand> {
// Argv rewriting in main() should have consumed `ssh <host>` before // Argv rewriting in main() should have consumed `ssh <host>` before
// commander runs. Reaching here means host was missing or the // commander runs. Reaching here means host was missing or the
// rewrite predicate didn't match. // rewrite predicate didn't match.
process.stderr.write('Usage: claude ssh <user@host | ssh-config-alias> [dir]\n\n' + "Runs Claude Code on a remote Linux host. You don't need to install\n" + 'anything on the remote or run `claude auth login` there — the binary is\n' + 'deployed over SSH and API auth tunnels back through your local machine.\n'); process.stderr.write('Usage: better-clawd ssh <user@host | ssh-config-alias> [dir]\n\n' + "Runs Better-Clawd on a remote Linux host. You don't need to install\n" + 'anything on the remote or run `/login` there — the binary is\n' + 'deployed over SSH and API auth tunnels back through your local machine.\n');
process.exit(1); process.exit(1);
}); });
} }
@@ -4311,7 +4311,7 @@ async function run(): Promise<CommanderCommand> {
} }
} }
// Remote Control command — connect local environment to claude.ai/code. // Remote Control command — connect local environment to the configured bridge.
// The actual command is intercepted by the fast-path in cli.tsx before // The actual command is intercepted by the fast-path in cli.tsx before
// Commander.js runs, so this registration exists only for help output. // Commander.js runs, so this registration exists only for help output.
// Always hidden: isBridgeEnabled() at this point (before enableConfigs) // Always hidden: isBridgeEnabled() at this point (before enableConfigs)
@@ -4322,7 +4322,7 @@ async function run(): Promise<CommanderCommand> {
if (feature('BRIDGE_MODE')) { if (feature('BRIDGE_MODE')) {
program.command('remote-control', { program.command('remote-control', {
hidden: true hidden: true
}).alias('rc').description('Connect your local environment for remote-control sessions via claude.ai/code').action(async () => { }).alias('rc').description('Connect your local environment for remote-control sessions via the configured bridge').action(async () => {
// Unreachable — cli.tsx fast-path handles this command before main.tsx loads. // Unreachable — cli.tsx fast-path handles this command before main.tsx loads.
// If somehow reached, delegate to bridgeMain. // If somehow reached, delegate to bridgeMain.
const { const {

View File

@@ -292,6 +292,8 @@ import { createAttachmentMessage, getQueuedCommandAttachments } from '../utils/a
// creating a new [] literal on every render in remote mode, which would // creating a new [] literal on every render in remote mode, which would
// cause useEffect dependency changes and infinite re-render loops. // cause useEffect dependency changes and infinite re-render loops.
const EMPTY_MCP_CLIENTS: MCPServerConnection[] = []; const EMPTY_MCP_CLIENTS: MCPServerConnection[] = [];
const EMPTY_TOOL_USE_CONFIRM_QUEUE: ToolUseConfirm[] = [];
const EMPTY_IN_PROGRESS_TOOL_USE_IDS = new Set<string>();
// Stable stub for useAssistantHistory's non-KAIROS branch — avoids a new // Stable stub for useAssistantHistory's non-KAIROS branch — avoids a new
// function identity each render, which would break composedOnScroll's memo. // function identity each render, which would break composedOnScroll's memo.
@@ -980,6 +982,14 @@ export function REPL({
// True when user is actively typing — defers interrupt dialogs so keystrokes // True when user is actively typing — defers interrupt dialogs so keystrokes
// don't accidentally dismiss or answer a permission prompt the user hasn't read yet. // don't accidentally dismiss or answer a permission prompt the user hasn't read yet.
const [isPromptInputActive, setIsPromptInputActive] = React.useState(false); const [isPromptInputActive, setIsPromptInputActive] = React.useState(false);
const promptInputActiveRef = useRef(false);
const setPromptInputActive = useCallback((active: boolean) => {
if (promptInputActiveRef.current === active) {
return;
}
promptInputActiveRef.current = active;
setIsPromptInputActive(active);
}, []);
const [autoUpdaterResult, setAutoUpdaterResult] = useState<AutoUpdaterResult | null>(null); const [autoUpdaterResult, setAutoUpdaterResult] = useState<AutoUpdaterResult | null>(null);
useEffect(() => { useEffect(() => {
if (autoUpdaterResult?.notifications) { if (autoUpdaterResult?.notifications) {
@@ -1359,16 +1369,16 @@ export function REPL({
// block's `=== ''` guard — see the fresh value, not the stale render. // block's `=== ''` guard — see the fresh value, not the stale render.
inputValueRef.current = value; inputValueRef.current = value;
setInputValueRaw(value); setInputValueRaw(value);
setIsPromptInputActive(value.trim().length > 0); setPromptInputActive(value.trim().length > 0);
}, [setIsPromptInputActive, repinScroll, trySuggestBgPRIntercept]); }, [setPromptInputActive, repinScroll, trySuggestBgPRIntercept]);
// Schedule a timeout to stop suppressing dialogs after the user stops typing. // Schedule a timeout to stop suppressing dialogs after the user stops typing.
// Only manages the timeout — the immediate activation is handled by setInputValue above. // Only manages the timeout — the immediate activation is handled by setInputValue above.
useEffect(() => { useEffect(() => {
if (inputValue.trim().length === 0) return; if (inputValue.trim().length === 0) return;
const timer = setTimeout(setIsPromptInputActive, PROMPT_SUPPRESSION_MS, false); const timer = setTimeout(setPromptInputActive, PROMPT_SUPPRESSION_MS, false);
return () => clearTimeout(timer); return () => clearTimeout(timer);
}, [inputValue]); }, [inputValue, setPromptInputActive]);
const [inputMode, setInputMode] = useState<PromptInputMode>('prompt'); const [inputMode, setInputMode] = useState<PromptInputMode>('prompt');
const [stashedPrompt, setStashedPrompt] = useState<{ const [stashedPrompt, setStashedPrompt] = useState<{
text: string; text: string;
@@ -4399,7 +4409,7 @@ export function REPL({
// and transcript-mode are mutually exclusive (this early return), so // and transcript-mode are mutually exclusive (this early return), so
// only one ScrollBox is ever mounted at a time. // only one ScrollBox is ever mounted at a time.
const transcriptScrollRef = isFullscreenEnvEnabled() && !disableVirtualScroll && !dumpMode ? scrollRef : undefined; const transcriptScrollRef = isFullscreenEnvEnabled() && !disableVirtualScroll && !dumpMode ? scrollRef : undefined;
const transcriptMessagesElement = <Messages messages={transcriptMessages} tools={tools} commands={commands} verbose={true} toolJSX={null} toolUseConfirmQueue={[]} inProgressToolUseIDs={inProgressToolUseIDs} isMessageSelectorVisible={false} conversationId={conversationId} screen={screen} agentDefinitions={agentDefinitions} streamingToolUses={transcriptStreamingToolUses} showAllInTranscript={showAllInTranscript} onOpenRateLimitOptions={handleOpenRateLimitOptions} isLoading={isLoading} hidePastThinking={true} streamingThinking={streamingThinking} scrollRef={transcriptScrollRef} jumpRef={jumpRef} onSearchMatchesChange={onSearchMatchesChange} scanElement={scanElement} setPositions={setPositions} disableRenderCap={dumpMode} />; const transcriptMessagesElement = <Messages messages={transcriptMessages} tools={tools} commands={commands} verbose={true} toolJSX={null} toolUseConfirmQueue={EMPTY_TOOL_USE_CONFIRM_QUEUE} inProgressToolUseIDs={inProgressToolUseIDs} isMessageSelectorVisible={false} conversationId={conversationId} screen={screen} agentDefinitions={agentDefinitions} streamingToolUses={transcriptStreamingToolUses} showAllInTranscript={showAllInTranscript} onOpenRateLimitOptions={handleOpenRateLimitOptions} isLoading={isLoading} hidePastThinking={true} streamingThinking={streamingThinking} scrollRef={transcriptScrollRef} jumpRef={jumpRef} onSearchMatchesChange={onSearchMatchesChange} scanElement={scanElement} setPositions={setPositions} disableRenderCap={dumpMode} />;
const transcriptToolJSX = toolJSX && <Box flexDirection="column" width="100%"> const transcriptToolJSX = toolJSX && <Box flexDirection="column" width="100%">
{toolJSX.jsx} {toolJSX.jsx}
</Box>; </Box>;
@@ -4507,6 +4517,7 @@ export function REPL({
// When viewing an agent, never fall through to leader — empty until // When viewing an agent, never fall through to leader — empty until
// bootstrap/stream fills. Closes the see-leader-type-agent footgun. // bootstrap/stream fills. Closes the see-leader-type-agent footgun.
const displayedMessages = viewedAgentTask ? viewedAgentTask.messages ?? [] : usesSyncMessages ? messages : deferredMessages; const displayedMessages = viewedAgentTask ? viewedAgentTask.messages ?? [] : usesSyncMessages ? messages : deferredMessages;
const activeInProgressToolUseIDs = viewedTeammateTask ? viewedTeammateTask.inProgressToolUseIDs ?? EMPTY_IN_PROGRESS_TOOL_USE_IDS : inProgressToolUseIDs;
// Show the placeholder until the real user message appears in // Show the placeholder until the real user message appears in
// displayedMessages. userInputOnProcessing stays set for the whole turn // displayedMessages. userInputOnProcessing stays set for the whole turn
// (cleared in resetLoadingState); this length check hides it once // (cleared in resetLoadingState); this length check hides it once
@@ -4567,7 +4578,7 @@ export function REPL({
jumpToNew(scrollRef.current); jumpToNew(scrollRef.current);
}} scrollable={<> }} scrollable={<>
<TeammateViewHeader /> <TeammateViewHeader />
<Messages messages={displayedMessages} tools={tools} commands={commands} verbose={verbose} toolJSX={toolJSX} toolUseConfirmQueue={toolUseConfirmQueue} inProgressToolUseIDs={viewedTeammateTask ? viewedTeammateTask.inProgressToolUseIDs ?? new Set() : inProgressToolUseIDs} isMessageSelectorVisible={isMessageSelectorVisible} conversationId={conversationId} screen={screen} streamingToolUses={streamingToolUses} showAllInTranscript={showAllInTranscript} agentDefinitions={agentDefinitions} onOpenRateLimitOptions={handleOpenRateLimitOptions} isLoading={isLoading} streamingText={isLoading && !viewedAgentTask ? visibleStreamingText : null} isBriefOnly={viewedAgentTask ? false : isBriefOnly} unseenDivider={viewedAgentTask ? undefined : unseenDivider} scrollRef={isFullscreenEnvEnabled() ? scrollRef : undefined} trackStickyPrompt={isFullscreenEnvEnabled() ? true : undefined} cursor={cursor} setCursor={setCursor} cursorNavRef={cursorNavRef} /> <Messages messages={displayedMessages} tools={tools} commands={commands} verbose={verbose} toolJSX={toolJSX} toolUseConfirmQueue={toolUseConfirmQueue} inProgressToolUseIDs={activeInProgressToolUseIDs} isMessageSelectorVisible={isMessageSelectorVisible} conversationId={conversationId} screen={screen} streamingToolUses={streamingToolUses} showAllInTranscript={showAllInTranscript} agentDefinitions={agentDefinitions} onOpenRateLimitOptions={handleOpenRateLimitOptions} isLoading={isLoading} streamingText={isLoading && !viewedAgentTask ? visibleStreamingText : null} isBriefOnly={viewedAgentTask ? false : isBriefOnly} unseenDivider={viewedAgentTask ? undefined : unseenDivider} scrollRef={isFullscreenEnvEnabled() ? scrollRef : undefined} trackStickyPrompt={isFullscreenEnvEnabled() ? true : undefined} cursor={cursor} setCursor={setCursor} cursorNavRef={cursorNavRef} />
<AwsAuthStatusBox /> <AwsAuthStatusBox />
{/* Hide the processing placeholder while a modal is showing — {/* Hide the processing placeholder while a modal is showing —
it would sit at the last visible transcript row right above it would sit at the last visible transcript row right above

View File

@@ -0,0 +1,5 @@
import { unsupportedEntrypoint } from '../utils/unsupportedEntrypoint.js'
export async function selfHostedRunnerMain(): Promise<never> {
return unsupportedEntrypoint('better-clawd self-hosted-runner')
}

View File

@@ -1,237 +1,32 @@
import type { AnyValueMap, Logger, logs } from '@opentelemetry/api-logs'
import { resourceFromAttributes } from '@opentelemetry/resources'
import {
BatchLogRecordProcessor,
LoggerProvider,
} from '@opentelemetry/sdk-logs'
import {
ATTR_SERVICE_NAME,
ATTR_SERVICE_VERSION,
} from '@opentelemetry/semantic-conventions'
import { randomUUID } from 'crypto'
import { isEqual } from 'lodash-es'
import { getOrCreateUserID } from '../../utils/config.js'
import { logForDebugging } from '../../utils/debug.js'
import { logError } from '../../utils/log.js'
import { getPlatform, getWslVersion } from '../../utils/platform.js'
import { jsonStringify } from '../../utils/slowOperations.js'
import { profileCheckpoint } from '../../utils/startupProfiler.js'
import { getCoreUserData } from '../../utils/user.js'
import { isAnalyticsDisabled } from './config.js'
import { FirstPartyEventLoggingExporter } from './firstPartyEventLoggingExporter.js'
import type { GrowthBookUserAttributes } from './growthbook.js' import type { GrowthBookUserAttributes } from './growthbook.js'
import { getDynamicConfig_CACHED_MAY_BE_STALE } from './growthbook.js'
import { getEventMetadata } from './metadata.js'
import { isSinkKilled } from './sinkKillswitch.js'
/**
* Configuration for sampling individual event types.
* Each event name maps to an object containing sample_rate (0-1).
* Events not in the config are logged at 100% rate.
*/
export type EventSamplingConfig = { export type EventSamplingConfig = {
[eventName: string]: { [eventName: string]: {
sample_rate: number sample_rate: number
} }
} }
const EVENT_SAMPLING_CONFIG_NAME = 'tengu_event_sampling_config'
/**
* Get the event sampling configuration from GrowthBook.
* Uses cached value if available, updates cache in background.
*/
export function getEventSamplingConfig(): EventSamplingConfig { export function getEventSamplingConfig(): EventSamplingConfig {
return getDynamicConfig_CACHED_MAY_BE_STALE<EventSamplingConfig>( return {}
EVENT_SAMPLING_CONFIG_NAME,
{},
)
} }
/** export function shouldSampleEvent(_eventName: string): number | null {
* Determine if an event should be sampled based on its sample rate.
* Returns the sample rate if sampled, null if not sampled.
*
* @param eventName - Name of the event to check
* @returns The sample_rate if event should be logged, null if it should be dropped
*/
export function shouldSampleEvent(eventName: string): number | null {
const config = getEventSamplingConfig()
const eventConfig = config[eventName]
// If no config for this event, log at 100% rate (no sampling)
if (!eventConfig) {
return null return null
}
const sampleRate = eventConfig.sample_rate
// Validate sample rate is in valid range
if (typeof sampleRate !== 'number' || sampleRate < 0 || sampleRate > 1) {
return null
}
// Sample rate of 1 means log everything (no need to add metadata)
if (sampleRate >= 1) {
return null
}
// Sample rate of 0 means drop everything
if (sampleRate <= 0) {
return 0
}
// Randomly decide whether to sample this event
return Math.random() < sampleRate ? sampleRate : 0
} }
const BATCH_CONFIG_NAME = 'tengu_1p_event_batch_config'
type BatchConfig = {
scheduledDelayMillis?: number
maxExportBatchSize?: number
maxQueueSize?: number
skipAuth?: boolean
maxAttempts?: number
path?: string
baseUrl?: string
}
function getBatchConfig(): BatchConfig {
return getDynamicConfig_CACHED_MAY_BE_STALE<BatchConfig>(
BATCH_CONFIG_NAME,
{},
)
}
// Module-local state for event logging (not exposed globally)
let firstPartyEventLogger: ReturnType<typeof logs.getLogger> | null = null
let firstPartyEventLoggerProvider: LoggerProvider | null = null
// Last batch config used to construct the provider — used by
// reinitialize1PEventLoggingIfConfigChanged to decide whether a rebuild is
// needed when GrowthBook refreshes.
let lastBatchConfig: BatchConfig | null = null
/**
* Flush and shutdown the 1P event logger.
* This should be called as the final step before process exit to ensure
* all events (including late ones from API responses) are exported.
*/
export async function shutdown1PEventLogging(): Promise<void> { export async function shutdown1PEventLogging(): Promise<void> {
if (!firstPartyEventLoggerProvider) {
return return
}
try {
await firstPartyEventLoggerProvider.shutdown()
if (process.env.USER_TYPE === 'ant') {
logForDebugging('1P event logging: final shutdown complete')
}
} catch {
// Ignore shutdown errors
}
} }
/**
* Check if 1P event logging is enabled.
* Respects the same opt-outs as other analytics sinks:
* - Test environment
* - Third-party cloud providers (Bedrock/Vertex)
* - Global telemetry opt-outs
* - Non-essential traffic disabled
*
* Note: Unlike BigQuery metrics, event logging does NOT check organization-level
* metrics opt-out via API. It follows the same pattern as Statsig event logging.
*/
export function is1PEventLoggingEnabled(): boolean { export function is1PEventLoggingEnabled(): boolean {
// Respect standard analytics opt-outs return false
return !isAnalyticsDisabled()
} }
/**
* Log a 1st-party event for internal analytics (async version).
* Events are batched and exported to /api/event_logging/batch
*
* This enriches the event with core metadata (model, session, env context, etc.)
* at log time, similar to logEventToStatsig.
*
* @param eventName - Name of the event (e.g., 'tengu_api_query')
* @param metadata - Additional metadata for the event (intentionally no strings, to avoid accidentally logging code/filepaths)
*/
async function logEventTo1PAsync(
firstPartyEventLogger: Logger,
eventName: string,
metadata: Record<string, number | boolean | undefined> = {},
): Promise<void> {
try {
// Enrich with core metadata at log time (similar to Statsig pattern)
const coreMetadata = await getEventMetadata({
model: metadata.model,
betas: metadata.betas,
})
// Build attributes - OTel supports nested objects natively via AnyValueMap
// Cast through unknown since our nested objects are structurally compatible
// with AnyValue but TS doesn't recognize it due to missing index signatures
const attributes = {
event_name: eventName,
event_id: randomUUID(),
// Pass objects directly - no JSON serialization needed
core_metadata: coreMetadata,
user_metadata: getCoreUserData(true),
event_metadata: metadata,
} as unknown as AnyValueMap
// Add user_id if available
const userId = getOrCreateUserID()
if (userId) {
attributes.user_id = userId
}
// Debug logging when debug mode is enabled
if (process.env.USER_TYPE === 'ant') {
logForDebugging(
`[ANT-ONLY] 1P event: ${eventName} ${jsonStringify(metadata, null, 0)}`,
)
}
// Emit log record
firstPartyEventLogger.emit({
body: eventName,
attributes,
})
} catch (e) {
if (process.env.NODE_ENV === 'development') {
throw e
}
if (process.env.USER_TYPE === 'ant') {
logError(e as Error)
}
// swallow
}
}
/**
* Log a 1st-party event for internal analytics.
* Events are batched and exported to /api/event_logging/batch
*
* @param eventName - Name of the event (e.g., 'tengu_api_query')
* @param metadata - Additional metadata for the event (intentionally no strings, to avoid accidentally logging code/filepaths)
*/
export function logEventTo1P( export function logEventTo1P(
eventName: string, _eventName: string,
metadata: Record<string, number | boolean | undefined> = {}, _metadata: Record<string, number | boolean | undefined> = {},
): void { ): void {}
if (!is1PEventLoggingEnabled()) {
return
}
if (!firstPartyEventLogger || isSinkKilled('firstParty')) {
return
}
// Fire and forget - don't block on metadata enrichment
void logEventTo1PAsync(firstPartyEventLogger, eventName, metadata)
}
/**
* GrowthBook experiment event data for logging
*/
export type GrowthBookExperimentData = { export type GrowthBookExperimentData = {
experimentId: string experimentId: string
variationId: number variationId: number
@@ -239,211 +34,12 @@ export type GrowthBookExperimentData = {
experimentMetadata?: Record<string, unknown> experimentMetadata?: Record<string, unknown>
} }
// api.anthropic.com only serves the "production" GrowthBook environment
// (see starling/starling/cli/cli.py DEFAULT_ENVIRONMENTS). Staging and
// development environments are not exported to the prod API.
function getEnvironmentForGrowthBook(): string {
return 'production'
}
/**
* Log a GrowthBook experiment assignment event to 1P.
* Events are batched and exported to /api/event_logging/batch
*
* @param data - GrowthBook experiment assignment data
*/
export function logGrowthBookExperimentTo1P( export function logGrowthBookExperimentTo1P(
data: GrowthBookExperimentData, _data: GrowthBookExperimentData,
): void { ): void {}
if (!is1PEventLoggingEnabled()) {
return
}
if (!firstPartyEventLogger || isSinkKilled('firstParty')) { export function initialize1PEventLogging(): void {}
return
}
const userId = getOrCreateUserID()
const { accountUuid, organizationUuid } = getCoreUserData(true)
// Build attributes for GrowthbookExperimentEvent
const attributes = {
event_type: 'GrowthbookExperimentEvent',
event_id: randomUUID(),
experiment_id: data.experimentId,
variation_id: data.variationId,
...(userId && { device_id: userId }),
...(accountUuid && { account_uuid: accountUuid }),
...(organizationUuid && { organization_uuid: organizationUuid }),
...(data.userAttributes && {
session_id: data.userAttributes.sessionId,
user_attributes: jsonStringify(data.userAttributes),
}),
...(data.experimentMetadata && {
experiment_metadata: jsonStringify(data.experimentMetadata),
}),
environment: getEnvironmentForGrowthBook(),
}
if (process.env.USER_TYPE === 'ant') {
logForDebugging(
`[ANT-ONLY] 1P GrowthBook experiment: ${data.experimentId} variation=${data.variationId}`,
)
}
firstPartyEventLogger.emit({
body: 'growthbook_experiment',
attributes,
})
}
const DEFAULT_LOGS_EXPORT_INTERVAL_MS = 10000
const DEFAULT_MAX_EXPORT_BATCH_SIZE = 200
const DEFAULT_MAX_QUEUE_SIZE = 8192
/**
* Initialize 1P event logging infrastructure.
* This creates a separate LoggerProvider for internal event logging,
* independent of customer OTLP telemetry.
*
* This uses its own minimal resource configuration with just the attributes
* we need for internal analytics (service name, version, platform info).
*/
export function initialize1PEventLogging(): void {
profileCheckpoint('1p_event_logging_start')
const enabled = is1PEventLoggingEnabled()
if (!enabled) {
if (process.env.USER_TYPE === 'ant') {
logForDebugging('1P event logging not enabled')
}
return
}
// Fetch batch processor configuration from GrowthBook dynamic config
// Uses cached value if available, refreshes in background
const batchConfig = getBatchConfig()
lastBatchConfig = batchConfig
profileCheckpoint('1p_event_after_growthbook_config')
const scheduledDelayMillis =
batchConfig.scheduledDelayMillis ||
parseInt(
process.env.OTEL_LOGS_EXPORT_INTERVAL ||
DEFAULT_LOGS_EXPORT_INTERVAL_MS.toString(),
)
const maxExportBatchSize =
batchConfig.maxExportBatchSize || DEFAULT_MAX_EXPORT_BATCH_SIZE
const maxQueueSize = batchConfig.maxQueueSize || DEFAULT_MAX_QUEUE_SIZE
// Build our own resource for 1P event logging with minimal attributes
const platform = getPlatform()
const attributes: Record<string, string> = {
[ATTR_SERVICE_NAME]: 'claude-code',
[ATTR_SERVICE_VERSION]: MACRO.VERSION,
}
// Add WSL-specific attributes if running on WSL
if (platform === 'wsl') {
const wslVersion = getWslVersion()
if (wslVersion) {
attributes['wsl.version'] = wslVersion
}
}
const resource = resourceFromAttributes(attributes)
// Create a new LoggerProvider with the EventLoggingExporter
// NOTE: This is kept separate from customer telemetry logs to ensure
// internal events don't leak to customer endpoints and vice versa.
// We don't register this globally - it's only used for internal event logging.
const eventLoggingExporter = new FirstPartyEventLoggingExporter({
maxBatchSize: maxExportBatchSize,
skipAuth: batchConfig.skipAuth,
maxAttempts: batchConfig.maxAttempts,
path: batchConfig.path,
baseUrl: batchConfig.baseUrl,
isKilled: () => isSinkKilled('firstParty'),
})
firstPartyEventLoggerProvider = new LoggerProvider({
resource,
processors: [
new BatchLogRecordProcessor(eventLoggingExporter, {
scheduledDelayMillis,
maxExportBatchSize,
maxQueueSize,
}),
],
})
// Initialize event logger from our internal provider (NOT from global API)
// IMPORTANT: We must get the logger from our local provider, not logs.getLogger()
// because logs.getLogger() returns a logger from the global provider, which is
// separate and used for customer telemetry.
firstPartyEventLogger = firstPartyEventLoggerProvider.getLogger(
'com.anthropic.claude_code.events',
MACRO.VERSION,
)
}
/**
* Rebuild the 1P event logging pipeline if the batch config changed.
* Register this with onGrowthBookRefresh so long-running sessions pick up
* changes to batch size, delay, endpoint, etc.
*
* Event-loss safety:
* 1. Null the logger first — concurrent logEventTo1P() calls hit the
* !firstPartyEventLogger guard and bail during the swap window. This drops
* a handful of events but prevents emitting to a draining provider.
* 2. forceFlush() drains the old BatchLogRecordProcessor buffer to the
* exporter. Export failures go to disk at getCurrentBatchFilePath() which
* is keyed by module-level BATCH_UUID + sessionId — unchanged across
* reinit — so the NEW exporter's disk-backed retry picks them up.
* 3. Swap to new provider/logger; old provider shutdown runs in background
* (buffer already drained, just cleanup).
*/
export async function reinitialize1PEventLoggingIfConfigChanged(): Promise<void> { export async function reinitialize1PEventLoggingIfConfigChanged(): Promise<void> {
if (!is1PEventLoggingEnabled() || !firstPartyEventLoggerProvider) {
return return
}
const newConfig = getBatchConfig()
if (isEqual(newConfig, lastBatchConfig)) {
return
}
if (process.env.USER_TYPE === 'ant') {
logForDebugging(
`1P event logging: ${BATCH_CONFIG_NAME} changed, reinitializing`,
)
}
const oldProvider = firstPartyEventLoggerProvider
const oldLogger = firstPartyEventLogger
firstPartyEventLogger = null
try {
await oldProvider.forceFlush()
} catch {
// Export failures are already on disk; new exporter will retry them.
}
firstPartyEventLoggerProvider = null
try {
initialize1PEventLogging()
} catch (e) {
// Restore so the next GrowthBook refresh can retry. oldProvider was
// only forceFlush()'d, not shut down — it's still functional. Without
// this, both stay null and the !firstPartyEventLoggerProvider gate at
// the top makes recovery impossible.
firstPartyEventLoggerProvider = oldProvider
firstPartyEventLogger = oldLogger
logError(e)
return
}
void oldProvider.shutdown().catch(() => {})
} }

View File

@@ -1,114 +1,14 @@
/** import { attachAnalyticsSink } from './index.js'
* Analytics sink implementation
*
* This module contains the actual analytics routing logic and should be
* initialized during app startup. It routes events to Datadog and 1P event
* logging.
*
* Usage: Call initializeAnalyticsSink() during app startup to attach the sink.
*/
import { trackDatadogEvent } from './datadog.js'
import { logEventTo1P, shouldSampleEvent } from './firstPartyEventLogger.js'
import { checkStatsigFeatureGate_CACHED_MAY_BE_STALE } from './growthbook.js'
import { attachAnalyticsSink, stripProtoFields } from './index.js'
import { isSinkKilled } from './sinkKillswitch.js'
// Local type matching the logEvent metadata signature
type LogEventMetadata = { [key: string]: boolean | number | undefined } type LogEventMetadata = { [key: string]: boolean | number | undefined }
const DATADOG_GATE_NAME = 'tengu_log_datadog_events' function dropEvent(_eventName: string, _metadata: LogEventMetadata): void {}
// Module-level gate state - starts undefined, initialized during startup export function initializeAnalyticsGates(): void {}
let isDatadogGateEnabled: boolean | undefined = undefined
/**
* Check if Datadog tracking is enabled.
* Falls back to cached value from previous session if not yet initialized.
*/
function shouldTrackDatadog(): boolean {
if (isSinkKilled('datadog')) {
return false
}
if (isDatadogGateEnabled !== undefined) {
return isDatadogGateEnabled
}
// Fallback to cached value from previous session
try {
return checkStatsigFeatureGate_CACHED_MAY_BE_STALE(DATADOG_GATE_NAME)
} catch {
return false
}
}
/**
* Log an event (synchronous implementation)
*/
function logEventImpl(eventName: string, metadata: LogEventMetadata): void {
// Check if this event should be sampled
const sampleResult = shouldSampleEvent(eventName)
// If sample result is 0, the event was not selected for logging
if (sampleResult === 0) {
return
}
// If sample result is a positive number, add it to metadata
const metadataWithSampleRate =
sampleResult !== null
? { ...metadata, sample_rate: sampleResult }
: metadata
if (shouldTrackDatadog()) {
// Datadog is a general-access backend — strip _PROTO_* keys
// (unredacted PII-tagged values meant only for the 1P privileged column).
void trackDatadogEvent(eventName, stripProtoFields(metadataWithSampleRate))
}
// 1P receives the full payload including _PROTO_* — the exporter
// destructures and routes those keys to proto fields itself.
logEventTo1P(eventName, metadataWithSampleRate)
}
/**
* Log an event (asynchronous implementation)
*
* With Segment removed the two remaining sinks are fire-and-forget, so this
* just wraps the sync impl — kept to preserve the sink interface contract.
*/
function logEventAsyncImpl(
eventName: string,
metadata: LogEventMetadata,
): Promise<void> {
logEventImpl(eventName, metadata)
return Promise.resolve()
}
/**
* Initialize analytics gates during startup.
*
* Updates gate values from server. Early events use cached values from previous
* session to avoid data loss during initialization.
*
* Called from main.tsx during setupBackend().
*/
export function initializeAnalyticsGates(): void {
isDatadogGateEnabled =
checkStatsigFeatureGate_CACHED_MAY_BE_STALE(DATADOG_GATE_NAME)
}
/**
* Initialize the analytics sink.
*
* Call this during app startup to attach the analytics backend.
* Any events logged before this is called will be queued and drained.
*
* Idempotent: safe to call multiple times (subsequent calls are no-ops).
*/
export function initializeAnalyticsSink(): void { export function initializeAnalyticsSink(): void {
attachAnalyticsSink({ attachAnalyticsSink({
logEvent: logEventImpl, logEvent: dropEvent,
logEventAsync: logEventAsyncImpl, logEventAsync: async (_eventName, _metadata) => {},
}) })
} }

View File

@@ -6,7 +6,10 @@ import {
getAnthropicApiKey, getAnthropicApiKey,
getApiKeyFromApiKeyHelper, getApiKeyFromApiKeyHelper,
getClaudeAIOAuthTokens, getClaudeAIOAuthTokens,
getOpenAIApiKey,
getOpenRouterApiKey,
isClaudeAISubscriber, isClaudeAISubscriber,
refreshOpenAIAuthTokenIfNeeded,
refreshAndGetAwsCredentials, refreshAndGetAwsCredentials,
refreshGcpCredentialsIfNeeded, refreshGcpCredentialsIfNeeded,
} from 'src/utils/auth.js' } from 'src/utils/auth.js'
@@ -14,9 +17,12 @@ import { getUserAgent } from 'src/utils/http.js'
import { getSmallFastModel } from 'src/utils/model/model.js' import { getSmallFastModel } from 'src/utils/model/model.js'
import { import {
getAPIProvider, getAPIProvider,
getOpenAIBaseUrl,
getOpenRouterBaseUrl,
isFirstPartyAnthropicBaseUrl, isFirstPartyAnthropicBaseUrl,
} from 'src/utils/model/providers.js' } from 'src/utils/model/providers.js'
import { getProxyFetchOptions } from 'src/utils/proxy.js' import { getProxyFetchOptions } from 'src/utils/proxy.js'
import { OpenAIResponsesCompatClient } from './openaiCompat.js'
import { import {
getIsNonInteractiveSession, getIsNonInteractiveSession,
getSessionId, getSessionId,
@@ -98,6 +104,7 @@ export async function getAnthropicClient({
fetchOverride?: ClientOptions['fetch'] fetchOverride?: ClientOptions['fetch']
source?: string source?: string
}): Promise<Anthropic> { }): Promise<Anthropic> {
const provider = getAPIProvider()
const containerId = process.env.CLAUDE_CODE_CONTAINER_ID const containerId = process.env.CLAUDE_CODE_CONTAINER_ID
const remoteSessionId = process.env.CLAUDE_CODE_REMOTE_SESSION_ID const remoteSessionId = process.env.CLAUDE_CODE_REMOTE_SESSION_ID
const clientApp = process.env.CLAUDE_AGENT_SDK_CLIENT_APP const clientApp = process.env.CLAUDE_AGENT_SDK_CLIENT_APP
@@ -150,7 +157,7 @@ export async function getAnthropicClient({
fetch: resolvedFetch, fetch: resolvedFetch,
}), }),
} }
if (isEnvTruthy(process.env.CLAUDE_CODE_USE_BEDROCK)) { if (provider === 'bedrock') {
const { AnthropicBedrock } = await import('@anthropic-ai/bedrock-sdk') const { AnthropicBedrock } = await import('@anthropic-ai/bedrock-sdk')
// Use region override for small fast model if specified // Use region override for small fast model if specified
const awsRegion = const awsRegion =
@@ -188,7 +195,7 @@ export async function getAnthropicClient({
// we have always been lying about the return type - this doesn't support batching or models // we have always been lying about the return type - this doesn't support batching or models
return new AnthropicBedrock(bedrockArgs) as unknown as Anthropic return new AnthropicBedrock(bedrockArgs) as unknown as Anthropic
} }
if (isEnvTruthy(process.env.CLAUDE_CODE_USE_FOUNDRY)) { if (provider === 'foundry') {
const { AnthropicFoundry } = await import('@anthropic-ai/foundry-sdk') const { AnthropicFoundry } = await import('@anthropic-ai/foundry-sdk')
// Determine Azure AD token provider based on configuration // Determine Azure AD token provider based on configuration
// SDK reads ANTHROPIC_FOUNDRY_API_KEY by default // SDK reads ANTHROPIC_FOUNDRY_API_KEY by default
@@ -218,7 +225,7 @@ export async function getAnthropicClient({
// we have always been lying about the return type - this doesn't support batching or models // we have always been lying about the return type - this doesn't support batching or models
return new AnthropicFoundry(foundryArgs) as unknown as Anthropic return new AnthropicFoundry(foundryArgs) as unknown as Anthropic
} }
if (isEnvTruthy(process.env.CLAUDE_CODE_USE_VERTEX)) { if (provider === 'vertex') {
// Refresh GCP credentials if gcpAuthRefresh is configured and credentials are expired // Refresh GCP credentials if gcpAuthRefresh is configured and credentials are expired
// This is similar to how we handle AWS credential refresh for Bedrock // This is similar to how we handle AWS credential refresh for Bedrock
if (!isEnvTruthy(process.env.CLAUDE_CODE_SKIP_VERTEX_AUTH)) { if (!isEnvTruthy(process.env.CLAUDE_CODE_SKIP_VERTEX_AUTH)) {
@@ -297,7 +304,37 @@ export async function getAnthropicClient({
return new AnthropicVertex(vertexArgs) as unknown as Anthropic return new AnthropicVertex(vertexArgs) as unknown as Anthropic
} }
// Determine authentication method based on available tokens if (provider === 'openrouter') {
const clientConfig: ConstructorParameters<typeof Anthropic>[0] = {
apiKey: null,
authToken: apiKey || getOpenRouterApiKey(),
baseURL: getOpenRouterBaseUrl(),
...ARGS,
...(isDebugToStdErr() && { logger: createStderrLogger() }),
}
return new Anthropic(clientConfig)
}
if (provider === 'openai') {
await refreshOpenAIAuthTokenIfNeeded()
const openAIKey = apiKey || getOpenAIApiKey()
if (!openAIKey) {
throw new Error(
'OpenAI provider selected but no OpenAI API key or access token is configured.',
)
}
return new OpenAIResponsesCompatClient({
apiKey: openAIKey,
baseURL: getOpenAIBaseUrl(),
defaultHeaders,
fetchImpl: resolvedFetch,
timeoutMs: ARGS.timeout,
}) as unknown as Anthropic
}
// Determine authentication method based on available Anthropic tokens
const clientConfig: ConstructorParameters<typeof Anthropic>[0] = { const clientConfig: ConstructorParameters<typeof Anthropic>[0] = {
apiKey: isClaudeAISubscriber() ? null : apiKey || getAnthropicApiKey(), apiKey: isClaudeAISubscriber() ? null : apiKey || getAnthropicApiKey(),
authToken: isClaudeAISubscriber() authToken: isClaudeAISubscriber()

View File

@@ -343,13 +343,13 @@ export async function checkGroveForNonInteractive(): Promise<void> {
if (config === null || config.notice_is_grace_period) { if (config === null || config.notice_is_grace_period) {
// Grace period is still active - show informational message and continue // Grace period is still active - show informational message and continue
writeToStderr( writeToStderr(
'\nAn update to our Consumer Terms and Privacy Policy will take effect on October 8, 2025. Run `claude` to review the updated terms.\n\n', '\nAn update to our Consumer Terms and Privacy Policy will take effect on October 8, 2025. Run `better-clawd` to review the updated terms.\n\n',
) )
await markGroveNoticeViewed() await markGroveNoticeViewed()
} else { } else {
// Grace period has ended - show error message and exit // Grace period has ended - show error message and exit
writeToStderr( writeToStderr(
'\n[ACTION REQUIRED] An update to our Consumer Terms and Privacy Policy has taken effect on October 8, 2025. You must run `claude` to review the updated terms.\n\n', '\n[ACTION REQUIRED] An update to our Consumer Terms and Privacy Policy has taken effect on October 8, 2025. You must run `better-clawd` to review the updated terms.\n\n',
) )
await gracefulShutdown(1) await gracefulShutdown(1)
} }

View File

@@ -0,0 +1,495 @@
type AnthropicTool = {
name: string
description?: string
input_schema?: Record<string, unknown>
}
type AnthropicContentBlock =
| { type: 'text'; text: string }
| { type: 'tool_use'; id?: string; name: string; input?: unknown }
| { type: 'tool_result'; tool_use_id?: string; content?: unknown }
| { type: string; [key: string]: unknown }
type AnthropicMessage = {
role: 'user' | 'assistant'
content: string | AnthropicContentBlock[]
}
type AnthropicMessagesCreateParams = {
model: string
messages: AnthropicMessage[]
system?: string | Array<{ type?: string; text?: string }>
tools?: AnthropicTool[]
tool_choice?: { type?: string; name?: string } | null
max_tokens?: number
temperature?: number
stream?: boolean
}
type OpenAIResponseOutputItem =
| {
type: 'message'
role?: 'assistant'
content?: Array<{ type: string; text?: string }>
}
| {
type: 'function_call'
id?: string
call_id?: string
name: string
arguments?: string
}
| {
type: string
id?: string
call_id?: string
name?: string
arguments?: string
content?: Array<{ type: string; text?: string }>
summary?: Array<{ type: string; text?: string }>
}
type OpenAIResponse = {
id: string
model: string
output?: OpenAIResponseOutputItem[]
usage?: {
input_tokens?: number
output_tokens?: number
total_tokens?: number
}
}
type OpenAICompatOptions = {
apiKey: string
baseURL: string
defaultHeaders?: Record<string, string>
fetchImpl?: typeof fetch
timeoutMs: number
}
type StreamWithResponse = {
withResponse(): Promise<{
request_id: string
response: Response
data: OpenAICompatStream
}>
}
class OpenAICompatStream implements AsyncIterable<Record<string, unknown>> {
private readonly events: Record<string, unknown>[]
controller = {
abort: () => {
this.aborted = true
},
}
private aborted = false
constructor(events: Record<string, unknown>[]) {
this.events = events
}
async *[Symbol.asyncIterator](): AsyncIterator<Record<string, unknown>> {
for (const event of this.events) {
if (this.aborted) {
return
}
yield event
}
}
}
function normalizeOpenAIModel(model: string): string {
if (
model.startsWith('gpt-') ||
model.startsWith('o') ||
model.startsWith('codex')
) {
return model
}
return process.env.OPENAI_DEFAULT_MODEL || 'gpt-5.4'
}
function systemToInstructions(
system?: AnthropicMessagesCreateParams['system'],
): string | undefined {
if (!system) {
return undefined
}
if (typeof system === 'string') {
return system
}
return system
.map(block => ('text' in block && typeof block.text === 'string' ? block.text : ''))
.filter(Boolean)
.join('\n\n')
}
function stringifyToolOutput(content: unknown): string {
if (typeof content === 'string') {
return content
}
if (Array.isArray(content)) {
return content
.map(item => {
if (typeof item === 'string') {
return item
}
if (
item &&
typeof item === 'object' &&
'text' in item &&
typeof item.text === 'string'
) {
return item.text
}
return JSON.stringify(item)
})
.join('\n')
}
return JSON.stringify(content ?? '')
}
function anthropicMessagesToOpenAIInput(
messages: AnthropicMessage[],
): Array<Record<string, unknown>> {
const input: Array<Record<string, unknown>> = []
for (const message of messages) {
if (typeof message.content === 'string') {
input.push({ role: message.role, content: message.content })
continue
}
let bufferedText: string[] = []
const flushBufferedText = () => {
if (bufferedText.length === 0) {
return
}
input.push({
role: message.role,
content: bufferedText.join('\n'),
})
bufferedText = []
}
for (const block of message.content) {
if (block.type === 'text' && typeof block.text === 'string') {
bufferedText.push(block.text)
continue
}
flushBufferedText()
if (block.type === 'tool_use' && message.role === 'assistant') {
input.push({
type: 'function_call',
call_id: block.id ?? `call_${block.name}`,
name: block.name,
arguments: JSON.stringify(block.input ?? {}),
})
continue
}
if (block.type === 'tool_result' && message.role === 'user') {
input.push({
type: 'function_call_output',
call_id: block.tool_use_id ?? 'tool_call',
output: stringifyToolOutput(block.content),
})
continue
}
input.push({
role: message.role,
content: `[${block.type}] ${stringifyToolOutput(block)}`,
})
}
flushBufferedText()
}
return input
}
function anthropicToolsToOpenAI(
tools?: AnthropicTool[],
): Array<Record<string, unknown>> | undefined {
if (!tools || tools.length === 0) {
return undefined
}
return tools.map(tool => ({
type: 'function',
name: tool.name,
description: tool.description,
parameters: tool.input_schema ?? {
type: 'object',
properties: {},
additionalProperties: true,
},
strict: false,
}))
}
function anthropicToolChoiceToOpenAI(
toolChoice: AnthropicMessagesCreateParams['tool_choice'],
): string | Record<string, unknown> | undefined {
if (!toolChoice?.type) {
return undefined
}
if (toolChoice.type === 'auto' || toolChoice.type === 'none') {
return toolChoice.type
}
if (toolChoice.type === 'tool' && toolChoice.name) {
return {
type: 'function',
name: toolChoice.name,
}
}
return undefined
}
function extractAssistantText(item: OpenAIResponseOutputItem): string {
if ('content' in item && Array.isArray(item.content)) {
return item.content
.map(part => (typeof part.text === 'string' ? part.text : ''))
.join('')
}
if ('summary' in item && Array.isArray(item.summary)) {
return item.summary
.map(part => (typeof part.text === 'string' ? part.text : ''))
.join('')
}
return ''
}
function openAIOutputToAnthropicBlocks(
output: OpenAIResponseOutputItem[] = [],
): Array<Record<string, unknown>> {
const blocks: Array<Record<string, unknown>> = []
for (const item of output) {
if (item.type === 'message') {
const text = extractAssistantText(item)
if (text) {
blocks.push({ type: 'text', text })
}
continue
}
if (item.type === 'function_call') {
let parsedArguments: unknown = {}
try {
parsedArguments = item.arguments ? JSON.parse(item.arguments) : {}
} catch {
parsedArguments = item.arguments ?? {}
}
blocks.push({
type: 'tool_use',
id: item.call_id ?? item.id ?? `call_${item.name}`,
name: item.name,
input: parsedArguments,
})
continue
}
const text = extractAssistantText(item)
if (text) {
blocks.push({ type: 'text', text })
}
}
return blocks
}
function openAIResponseToAnthropicMessage(
response: OpenAIResponse,
model: string,
): Record<string, unknown> {
const blocks = openAIOutputToAnthropicBlocks(response.output)
const stopReason = blocks.some(block => block.type === 'tool_use')
? 'tool_use'
: 'end_turn'
return {
id: response.id,
type: 'message',
role: 'assistant',
model,
content: blocks,
stop_reason: stopReason,
stop_sequence: null,
usage: {
input_tokens: response.usage?.input_tokens ?? 0,
output_tokens: response.usage?.output_tokens ?? 0,
},
}
}
function openAIResponseToAnthropicEvents(
response: OpenAIResponse,
model: string,
): Record<string, unknown>[] {
const message = openAIResponseToAnthropicMessage(response, model)
const blocks = (message.content as Array<Record<string, unknown>>) ?? []
const events: Record<string, unknown>[] = [
{
type: 'message_start',
message,
},
]
blocks.forEach((block, index) => {
if (block.type === 'text') {
events.push({
type: 'content_block_start',
index,
content_block: { type: 'text', text: '' },
})
events.push({
type: 'content_block_delta',
index,
delta: {
type: 'text_delta',
text: block.text,
},
})
events.push({ type: 'content_block_stop', index })
return
}
if (block.type === 'tool_use') {
const rawInput =
typeof block.input === 'string'
? block.input
: JSON.stringify(block.input ?? {})
events.push({
type: 'content_block_start',
index,
content_block: {
type: 'tool_use',
id: block.id,
name: block.name,
input: '',
},
})
events.push({
type: 'content_block_delta',
index,
delta: {
type: 'input_json_delta',
partial_json: rawInput,
},
})
events.push({ type: 'content_block_stop', index })
}
})
events.push({
type: 'message_delta',
delta: {
stop_reason: message.stop_reason,
stop_sequence: null,
},
usage: {
output_tokens: response.usage?.output_tokens ?? 0,
},
})
events.push({ type: 'message_stop' })
return events
}
function buildOpenAIRequestBody(
params: AnthropicMessagesCreateParams,
): Record<string, unknown> {
return {
model: normalizeOpenAIModel(params.model),
input: anthropicMessagesToOpenAIInput(params.messages),
instructions: systemToInstructions(params.system),
tools: anthropicToolsToOpenAI(params.tools),
tool_choice: anthropicToolChoiceToOpenAI(params.tool_choice),
max_output_tokens: params.max_tokens,
temperature: params.temperature,
}
}
export class OpenAIResponsesCompatClient {
private readonly options: OpenAICompatOptions
beta = {
messages: {
create: (
params: AnthropicMessagesCreateParams,
requestOptions?: { signal?: AbortSignal },
): Promise<Record<string, unknown>> | StreamWithResponse => {
if (params.stream) {
return {
withResponse: async () => {
const response = await this.createResponse(params, requestOptions)
const stream = new OpenAICompatStream(
openAIResponseToAnthropicEvents(
response,
normalizeOpenAIModel(params.model),
),
)
return {
request_id: response.id,
response: new Response(JSON.stringify(response), {
status: 200,
headers: { 'content-type': 'application/json' },
}),
data: stream,
}
},
}
}
return this.createResponse(params, requestOptions).then(response =>
openAIResponseToAnthropicMessage(
response,
normalizeOpenAIModel(params.model),
),
)
},
},
}
constructor(options: OpenAICompatOptions) {
this.options = options
}
private async createResponse(
params: AnthropicMessagesCreateParams,
requestOptions?: { signal?: AbortSignal },
): Promise<OpenAIResponse> {
const controller = new AbortController()
const timeout = setTimeout(() => controller.abort(), this.options.timeoutMs)
requestOptions?.signal?.addEventListener('abort', () => controller.abort())
try {
const response = await (this.options.fetchImpl ?? globalThis.fetch)(
`${this.options.baseURL.replace(/\/$/, '')}/responses`,
{
method: 'POST',
signal: controller.signal,
headers: {
'content-type': 'application/json',
Authorization: `Bearer ${this.options.apiKey}`,
...this.options.defaultHeaders,
},
body: JSON.stringify(buildOpenAIRequestBody(params)),
},
)
if (!response.ok) {
throw new Error(
`OpenAI Responses API error ${response.status}: ${await response.text()}`,
)
}
return (await response.json()) as OpenAIResponse
} finally {
clearTimeout(timeout)
}
}
}

View File

@@ -0,0 +1,83 @@
export type CacheEditsBlock = {
type: 'cache_edits'
delete_tool_result_ids: string[]
}
export type PinnedCacheEdits = {
userMessageIndex: number
block: CacheEditsBlock
}
export type CachedMCState = {
registeredTools: Set<string>
deletedRefs: Set<string>
toolOrder: string[]
pinnedEdits: PinnedCacheEdits[]
}
export function isCachedMicrocompactEnabled(): boolean {
return false
}
export function isModelSupportedForCacheEditing(_model: string): boolean {
return false
}
export function getCachedMCConfig() {
return {
triggerThreshold: 0,
keepRecent: 0,
supportedModels: [] as string[],
}
}
export function createCachedMCState(): CachedMCState {
return {
registeredTools: new Set(),
deletedRefs: new Set(),
toolOrder: [],
pinnedEdits: [],
}
}
export function registerToolResult(
state: CachedMCState,
toolUseId: string,
): void {
if (state.registeredTools.has(toolUseId)) {
return
}
state.registeredTools.add(toolUseId)
state.toolOrder.push(toolUseId)
}
export function registerToolMessage(
_state: CachedMCState,
_groupIds: string[],
): void {}
export function getToolResultsToDelete(_state: CachedMCState): string[] {
return []
}
export function createCacheEditsBlock(
_state: CachedMCState,
toolIds: string[],
): CacheEditsBlock | null {
if (toolIds.length === 0) {
return null
}
return {
type: 'cache_edits',
delete_tool_result_ids: toolIds,
}
}
export function markToolsSentToAPI(_state: CachedMCState): void {}
export function resetCachedMCState(state: CachedMCState): void {
state.registeredTools.clear()
state.deletedRefs.clear()
state.toolOrder.length = 0
state.pinnedEdits.length = 0
}

View File

@@ -748,8 +748,9 @@ export async function compactConversation(
} }
} catch (error) { } catch (error) {
// Only show the error notification for manual /compact. // Only show the error notification for manual /compact.
// Auto-compact failures are retried on the next turn and the // Auto-compact failures are retried silently until the session-level
// notification is confusing when compaction eventually succeeds. // circuit breaker trips, and a user-facing notification here would be
// noisy for failures that recover on a later turn.
if (!isAutoCompact) { if (!isAutoCompact) {
addErrorNotificationIfNeeded(error, context) addErrorNotificationIfNeeded(error, context)
} }

View File

@@ -0,0 +1,25 @@
import type { Message, SystemMessage } from '../../types/message.js'
export type SnipCompactResult = {
messages: Message[]
tokensFreed: number
boundaryMessage?: SystemMessage
}
export function isSnipRuntimeEnabled(): boolean {
return false
}
export function isSnipMarkerMessage(_message: Message): boolean {
return false
}
export function snipCompactIfNeeded(
messages: Message[],
_options?: { force?: boolean },
): SnipCompactResult {
return {
messages,
tokensFreed: 0,
}
}

View File

@@ -0,0 +1,27 @@
type CollapseStats = {
collapsedSpans: number
stagedSpans: number
health: {
totalErrors: number
totalEmptySpawns: number
emptySpawnWarningEmitted: boolean
}
}
const EMPTY_STATS: CollapseStats = {
collapsedSpans: 0,
stagedSpans: 0,
health: {
totalErrors: 0,
totalEmptySpawns: 0,
emptySpawnWarningEmitted: false,
},
}
export function getStats(): CollapseStats {
return EMPTY_STATS
}
export function subscribe(_listener: () => void): () => void {
return () => {}
}

View File

@@ -96,7 +96,7 @@ function sanitizeConnectorName(name: string): string {
function formatConnectorsInfo(connectors: ConnectorInfo[]): string { function formatConnectorsInfo(connectors: ConnectorInfo[]): string {
if (connectors.length === 0) { if (connectors.length === 0) {
return 'No connected MCP connectors found. The user may need to connect servers at https://claude.ai/settings/connectors' return 'No connected MCP connectors found. The user may need to connect servers in their remote bridge settings.'
} }
const lines = ['Connected connectors (available for triggers):'] const lines = ['Connected connectors (available for triggers):']
for (const c of connectors) { for (const c of connectors) {
@@ -190,7 +190,7 @@ Use the \`${REMOTE_TRIGGER_TOOL_NAME}\` tool (load it first with \`ToolSearch se
- \`{action: "update", trigger_id: "...", body: {...}}\` — partial update - \`{action: "update", trigger_id: "...", body: {...}}\` — partial update
- \`{action: "run", trigger_id: "..."}\` — run a trigger now - \`{action: "run", trigger_id: "..."}\` — run a trigger now
You CANNOT delete triggers. If the user asks to delete, direct them to: https://claude.ai/code/scheduled You CANNOT delete triggers. If the user asks to delete, direct them to their remote scheduling UI.
## Create body shape ## Create body shape
@@ -227,13 +227,13 @@ Generate a fresh lowercase UUID for \`events[].data.uuid\` yourself.
## Available MCP Connectors ## Available MCP Connectors
These are the user's currently connected claude.ai MCP connectors: These are the user's currently connected remote MCP connectors:
${connectorsInfo} ${connectorsInfo}
When attaching connectors to a trigger, use the \`connector_uuid\` and \`name\` shown above (the name is already sanitized to only contain letters, numbers, hyphens, and underscores), and the connector's URL. The \`name\` field in \`mcp_connections\` must only contain \`[a-zA-Z0-9_-]\` — dots and spaces are NOT allowed. When attaching connectors to a trigger, use the \`connector_uuid\` and \`name\` shown above (the name is already sanitized to only contain letters, numbers, hyphens, and underscores), and the connector's URL. The \`name\` field in \`mcp_connections\` must only contain \`[a-zA-Z0-9_-]\` — dots and spaces are NOT allowed.
**Important:** Infer what services the agent needs from the user's description. For example, if they say "check Datadog and Slack me errors," the agent needs both Datadog and Slack connectors. Cross-reference against the list above and warn if any required service isn't connected. If a needed connector is missing, direct the user to https://claude.ai/settings/connectors to connect it first. **Important:** Infer what services the agent needs from the user's description. For example, if they say "check Datadog and Slack me errors," the agent needs both Datadog and Slack connectors. Cross-reference against the list above and warn if any required service isn't connected. If a needed connector is missing, direct the user to connect it in their remote bridge settings first.
## Environments ## Environments
@@ -287,9 +287,9 @@ Minimum interval is 1 hour. \`*/30 * * * *\` will be rejected.
- Explicit about what actions to take (open PRs, commit, just analyze, etc.) - Explicit about what actions to take (open PRs, commit, just analyze, etc.)
3. **Set the schedule** — Ask when and how often. The user's timezone is ${userTimezone}. When they say a time (e.g., "every morning at 9am"), assume they mean their local time and convert to UTC for the cron expression. Always confirm the conversion: "9am ${userTimezone} = Xam UTC." 3. **Set the schedule** — Ask when and how often. The user's timezone is ${userTimezone}. When they say a time (e.g., "every morning at 9am"), assume they mean their local time and convert to UTC for the cron expression. Always confirm the conversion: "9am ${userTimezone} = Xam UTC."
4. **Choose the model** — Default to \`claude-sonnet-4-6\`. Tell the user which model you're defaulting to and ask if they want a different one. 4. **Choose the model** — Default to \`claude-sonnet-4-6\`. Tell the user which model you're defaulting to and ask if they want a different one.
5. **Validate connections** — Infer what services the agent will need from the user's description. For example, if they say "check Datadog and Slack me errors," the agent needs both Datadog and Slack MCP connectors. Cross-reference with the connectors list above. If any are missing, warn the user and link them to https://claude.ai/settings/connectors to connect first.${gitRepoUrl ? ` The default git repo is already set to \`${gitRepoUrl}\`. Ask the user if this is the right repo or if they need a different one.` : ' Ask which git repos the remote agent needs cloned into its environment.'} 5. **Validate connections** — Infer what services the agent will need from the user's description. For example, if they say "check Datadog and Slack me errors," the agent needs both Datadog and Slack MCP connectors. Cross-reference with the connectors list above. If any are missing, warn the user and tell them to connect those services in their remote bridge settings first.${gitRepoUrl ? ` The default git repo is already set to \`${gitRepoUrl}\`. Ask the user if this is the right repo or if they need a different one.` : ' Ask which git repos the remote agent needs cloned into its environment.'}
6. **Review and confirm** — Show the full configuration before creating. Let them adjust. 6. **Review and confirm** — Show the full configuration before creating. Let them adjust.
7. **Create it** \u2014 Call \`${REMOTE_TRIGGER_TOOL_NAME}\` with \`action: "create"\` and show the result. The response includes the trigger ID. Always output a link at the end: \`https://claude.ai/code/scheduled/{TRIGGER_ID}\` 7. **Create it** \u2014 Call \`${REMOTE_TRIGGER_TOOL_NAME}\` with \`action: "create"\` and show the result. The response includes the trigger ID. Always output the trigger ID at the end.
### UPDATE a trigger: ### UPDATE a trigger:
@@ -311,13 +311,13 @@ Minimum interval is 1 hour. \`*/30 * * * *\` will be rejected.
## Important Notes ## Important Notes
- These are REMOTE agents — they run in Anthropic's cloud, not on the user's machine. They cannot access local files, local services, or local environment variables. - These are REMOTE agents — they run in a remote cloud environment, not on the user's machine. They cannot access local files, local services, or local environment variables.
- Always convert cron to human-readable when displaying - Always convert cron to human-readable when displaying
- Default to \`enabled: true\` unless user says otherwise - Default to \`enabled: true\` unless user says otherwise
- Accept GitHub URLs in any format (https://github.com/org/repo, org/repo, etc.) and normalize to the full HTTPS URL (without .git suffix) - Accept GitHub URLs in any format (https://github.com/org/repo, org/repo, etc.) and normalize to the full HTTPS URL (without .git suffix)
- The prompt is the most important part — spend time getting it right. The remote agent starts with zero context, so the prompt must be self-contained. - The prompt is the most important part — spend time getting it right. The remote agent starts with zero context, so the prompt must be self-contained.
- To delete a trigger, direct users to https://claude.ai/code/scheduled - To delete a trigger, direct users to their remote scheduling UI
${needsGitHubAccessReminder ? `- If the user's request seems to require GitHub repo access (e.g. cloning a repo, opening PRs, reading code), remind them that ${getFeatureValue_CACHED_MAY_BE_STALE('tengu_cobalt_lantern', false) ? "they should run /web-setup to connect their GitHub account (or install the Claude GitHub App on the repo as an alternative) — otherwise the remote agent won't be able to access it" : "they need the Claude GitHub App installed on the repo — otherwise the remote agent won't be able to access it"}.` : ''} ${needsGitHubAccessReminder ? `- If the user's request seems to require GitHub repo access (e.g. cloning a repo, opening PRs, reading code), remind them that ${getFeatureValue_CACHED_MAY_BE_STALE('tengu_cobalt_lantern', false) ? "they should run /web-setup to connect their GitHub account (or install the required GitHub app on the repo as an alternative) — otherwise the remote agent won't be able to access it" : "they need the required GitHub app installed on the repo — otherwise the remote agent won't be able to access it"}.` : ''}
${userArgs ? `\n## User Request\n\nThe user said: "${userArgs}"\n\nStart by understanding their intent and working through the appropriate workflow above.` : ''}` ${userArgs ? `\n## User Request\n\nThe user said: "${userArgs}"\n\nStart by understanding their intent and working through the appropriate workflow above.` : ''}`
} }
@@ -327,7 +327,7 @@ export function registerScheduleRemoteAgentsSkill(): void {
description: description:
'Create, update, list, or run scheduled remote agents (triggers) that execute on a cron schedule.', 'Create, update, list, or run scheduled remote agents (triggers) that execute on a cron schedule.',
whenToUse: whenToUse:
'When the user wants to schedule a recurring remote agent, set up automated tasks, create a cron job for Claude Code, or manage their scheduled agents/triggers.', 'When the user wants to schedule a recurring remote agent, set up automated tasks, create a cron job for Better-Clawd, or manage their scheduled agents/triggers.',
userInvocable: true, userInvocable: true,
isEnabled: () => isEnabled: () =>
getFeatureValue_CACHED_MAY_BE_STALE('tengu_surreal_dali', false) && getFeatureValue_CACHED_MAY_BE_STALE('tengu_surreal_dali', false) &&
@@ -338,7 +338,7 @@ export function registerScheduleRemoteAgentsSkill(): void {
return [ return [
{ {
type: 'text', type: 'text',
text: 'You need to authenticate with a claude.ai account first. API accounts are not supported. Run /login, then try /schedule again.', text: 'You need browser-based remote-session authentication first. API accounts are not supported. Run /login, then try /schedule again.',
}, },
] ]
} }
@@ -353,7 +353,7 @@ export function registerScheduleRemoteAgentsSkill(): void {
return [ return [
{ {
type: 'text', type: 'text',
text: "We're having trouble connecting with your remote claude.ai account to set up a scheduled task. Please try /schedule again in a few minutes.", text: "We're having trouble connecting to your remote scheduling account to set up a scheduled task. Please try /schedule again in a few minutes.",
}, },
] ]
} }
@@ -372,7 +372,7 @@ export function registerScheduleRemoteAgentsSkill(): void {
return [ return [
{ {
type: 'text', type: 'text',
text: 'No remote environments found, and we could not create one automatically. Visit https://claude.ai/code to set one up, then run /schedule again.', text: 'No remote environments found, and we could not create one automatically. Set one up in your remote bridge UI, then run /schedule again.',
}, },
] ]
} }
@@ -402,8 +402,8 @@ export function registerScheduleRemoteAgentsSkill(): void {
false, false,
) )
const msg = webSetupEnabled const msg = webSetupEnabled
? `GitHub not connected for ${repo.owner}/${repo.name} \u2014 run /web-setup to sync your GitHub credentials, or install the Claude GitHub App at https://claude.ai/code/onboarding?magic=github-app-setup.` ? `GitHub not connected for ${repo.owner}/${repo.name} \u2014 run /web-setup to sync your GitHub credentials, or install the required GitHub app for your remote bridge.`
: `Claude GitHub App not installed on ${repo.owner}/${repo.name} \u2014 install at https://claude.ai/code/onboarding?magic=github-app-setup if your trigger needs this repo.` : `The required GitHub app is not installed on ${repo.owner}/${repo.name}. Install it before using this repo in a remote trigger.`
setupNotes.push(msg) setupNotes.push(msg)
} }
} }
@@ -417,7 +417,7 @@ export function registerScheduleRemoteAgentsSkill(): void {
) )
if (connectors.length === 0) { if (connectors.length === 0) {
setupNotes.push( setupNotes.push(
`No MCP connectors — connect at https://claude.ai/settings/connectors if needed.`, `No MCP connectors — connect them in your remote bridge settings if needed.`,
) )
} }

View File

@@ -146,15 +146,15 @@ export async function checkRemoteAgentEligibility({
export function formatPreconditionError(error: BackgroundRemoteSessionPrecondition): string { export function formatPreconditionError(error: BackgroundRemoteSessionPrecondition): string {
switch (error.type) { switch (error.type) {
case 'not_logged_in': case 'not_logged_in':
return 'Please run /login and sign in with your Claude.ai account (not Console).'; return 'Please run /login and complete a browser-based sign-in before starting remote sessions.';
case 'no_remote_environment': case 'no_remote_environment':
return 'No cloud environment available. Set one up at https://claude.ai/code/onboarding?magic=env-setup'; return 'No remote environment is available. Better-Clawd does not provision cloud environments automatically.';
case 'not_in_git_repo': case 'not_in_git_repo':
return 'Background tasks require a git repository. Initialize git or run from a git repository.'; return 'Background tasks require a git repository. Initialize git or run from a git repository.';
case 'no_git_remote': case 'no_git_remote':
return 'Background tasks require a GitHub remote. Add one with `git remote add origin REPO_URL`.'; return 'Background tasks require a GitHub remote. Add one with `git remote add origin REPO_URL`.';
case 'github_app_not_installed': case 'github_app_not_installed':
return 'The Claude GitHub app must be installed on this repository first.\nhttps://github.com/apps/claude/installations/new'; return 'The required GitHub app must be installed on this repository first.';
case 'policy_blocked': case 'policy_blocked':
return "Remote sessions are disabled by your organization's policy. Contact your organization admin to enable them."; return "Remote sessions are disabled by your organization's policy. Contact your organization admin to enable them.";
} }

View File

@@ -80,7 +80,7 @@ export const RemoteTriggerTool = buildTool({
const accessToken = getClaudeAIOAuthTokens()?.accessToken const accessToken = getClaudeAIOAuthTokens()?.accessToken
if (!accessToken) { if (!accessToken) {
throw new Error( throw new Error(
'Not authenticated with a claude.ai account. Run /login and try again.', 'Not authenticated for remote scheduling. Run /login and try again.',
) )
} }
const orgUUID = await getOrganizationUUID() const orgUUID = await getOrganizationUUID()

View File

@@ -0,0 +1,3 @@
export function TungstenLiveMonitor(): null {
return null
}

View File

@@ -0,0 +1,3 @@
export const TungstenTool = {
name: 'TungstenTool',
}

View File

@@ -0,0 +1 @@
export const WORKFLOW_TOOL_NAME = 'Workflow'

View File

@@ -0,0 +1,17 @@
export type ConnectorTextBlock = {
type: 'connector_text'
text: string
}
export function isConnectorTextBlock(
value: unknown,
): value is ConnectorTextBlock {
return (
typeof value === 'object' &&
value !== null &&
'type' in value &&
(value as { type?: unknown }).type === 'connector_text' &&
'text' in value &&
typeof (value as { text?: unknown }).text === 'string'
)
}

View File

@@ -2,6 +2,8 @@ import { feature } from 'bun:bundle'
import { stat } from 'fs/promises' import { stat } from 'fs/promises'
import { getClientType } from '../bootstrap/state.js' import { getClientType } from '../bootstrap/state.js'
import { import {
PRODUCT_NAME,
PRODUCT_NOREPLY_EMAIL,
getRemoteSessionUrl, getRemoteSessionUrl,
isRemoteSessionLocal, isRemoteSessionLocal,
PRODUCT_URL, PRODUCT_URL,
@@ -69,15 +71,15 @@ export function getAttributionTexts(): AttributionTexts {
// @[MODEL LAUNCH]: Update the hardcoded fallback model name below (guards against codename leaks). // @[MODEL LAUNCH]: Update the hardcoded fallback model name below (guards against codename leaks).
// For internal repos, use the real model name. For external repos, // For internal repos, use the real model name. For external repos,
// fall back to "Claude Opus 4.6" for unrecognized models to avoid leaking codenames. // fall back to a generic Better-Clawd label for unrecognized models to avoid leaking codenames.
const model = getMainLoopModel() const model = getMainLoopModel()
const isKnownPublicModel = getPublicModelDisplayName(model) !== null const isKnownPublicModel = getPublicModelDisplayName(model) !== null
const modelName = const modelName =
isInternalModelRepoCached() || isKnownPublicModel isInternalModelRepoCached() || isKnownPublicModel
? getPublicModelName(model) ? getPublicModelName(model)
: 'Claude Opus 4.6' : `${PRODUCT_NAME} Assistant`
const defaultAttribution = `🤖 Generated with [Claude Code](${PRODUCT_URL})` const defaultAttribution = `🤖 Generated with [${PRODUCT_NAME}](${PRODUCT_URL})`
const defaultCommit = `Co-Authored-By: ${modelName} <noreply@anthropic.com>` const defaultCommit = `Co-Authored-By: ${modelName} <${PRODUCT_NOREPLY_EMAIL}>`
const settings = getInitialSettings() const settings = getInitialSettings()
@@ -282,12 +284,12 @@ async function getTranscriptStats(): Promise<{
} }
/** /**
* Get enhanced PR attribution text with Claude contribution stats. * Get enhanced PR attribution text with Better-Clawd contribution stats.
* *
* Format: "🤖 Generated with Claude Code (93% 3-shotted by claude-opus-4-5)" * Format: "🤖 Generated with Better-Clawd (93% 3-shotted by gpt-5.4)"
* *
* Rules: * Rules:
* - Shows Claude contribution percentage from commit attribution * - Shows assistant contribution percentage from commit attribution
* - Shows N-shotted where N is the prompt count (1-shotted, 2-shotted, etc.) * - Shows N-shotted where N is the prompt count (1-shotted, 2-shotted, etc.)
* - Shows short model name (e.g., claude-opus-4-5) * - Shows short model name (e.g., claude-opus-4-5)
* - Returns default attribution if stats can't be computed * - Returns default attribution if stats can't be computed
@@ -325,7 +327,7 @@ export async function getEnhancedPRAttribution(
return '' return ''
} }
const defaultAttribution = `🤖 Generated with [Claude Code](${PRODUCT_URL})` const defaultAttribution = `🤖 Generated with [${PRODUCT_NAME}](${PRODUCT_URL})`
// Get AppState first // Get AppState first
const appState = getAppState() const appState = getAppState()
@@ -366,12 +368,12 @@ export async function getEnhancedPRAttribution(
return defaultAttribution return defaultAttribution
} }
// Build the enhanced attribution: "🤖 Generated with Claude Code (93% 3-shotted by claude-opus-4-5, 2 memories recalled)" // Build the enhanced attribution: "🤖 Generated with Better-Clawd (93% 3-shotted by gpt-5.4, 2 memories recalled)"
const memSuffix = const memSuffix =
memoryAccessCount > 0 memoryAccessCount > 0
? `, ${memoryAccessCount} ${memoryAccessCount === 1 ? 'memory' : 'memories'} recalled` ? `, ${memoryAccessCount} ${memoryAccessCount === 1 ? 'memory' : 'memories'} recalled`
: '' : ''
const summary = `🤖 Generated with [Claude Code](${PRODUCT_URL}) (${claudePercent}% ${promptCount}-shotted by ${shortModelName}${memSuffix})` const summary = `🤖 Generated with [${PRODUCT_NAME}](${PRODUCT_URL}) (${claudePercent}% ${promptCount}-shotted by ${shortModelName}${memSuffix})`
// Append trailer lines for squash-merge survival. Only for allowlisted repos // Append trailer lines for squash-merge survival. Only for allowlisted repos
// (INTERNAL_MODEL_REPOS) and only in builds with COMMIT_ATTRIBUTION enabled — // (INTERNAL_MODEL_REPOS) and only in builds with COMMIT_ATTRIBUTION enabled —

View File

@@ -1,9 +1,14 @@
import chalk from 'chalk' import chalk from 'chalk'
import { exec } from 'child_process' import { exec } from 'child_process'
import { execa } from 'execa' import { execa } from 'execa'
import { mkdir, stat } from 'fs/promises' import { mkdir, readFile, stat } from 'fs/promises'
import memoize from 'lodash-es/memoize.js' import memoize from 'lodash-es/memoize.js'
import { homedir } from 'os'
import { join } from 'path' import { join } from 'path'
import {
PRODUCT_NAME,
PRODUCT_SLUG,
} from 'src/constants/product.js'
import { CLAUDE_AI_PROFILE_SCOPE } from 'src/constants/oauth.js' import { CLAUDE_AI_PROFILE_SCOPE } from 'src/constants/oauth.js'
import { import {
type AnalyticsMetadata_I_VERIFIED_THIS_IS_NOT_CODE_OR_FILEPATHS, type AnalyticsMetadata_I_VERIFIED_THIS_IS_NOT_CODE_OR_FILEPATHS,
@@ -67,6 +72,7 @@ import {
import { import {
clearKeychainCache, clearKeychainCache,
getMacOsKeychainStorageServiceName, getMacOsKeychainStorageServiceName,
getMacOsKeychainStorageServiceNames,
getUsername, getUsername,
} from './secureStorage/macOsKeychainHelpers.js' } from './secureStorage/macOsKeychainHelpers.js'
import { import {
@@ -101,6 +107,10 @@ export function isAnthropicAuthEnabled(): boolean {
// --bare: API-key-only, never OAuth. // --bare: API-key-only, never OAuth.
if (isBareMode()) return false if (isBareMode()) return false
if (getAPIProvider() !== 'firstParty') {
return false
}
// `claude ssh` remote: ANTHROPIC_UNIX_SOCKET tunnels API calls through a // `claude ssh` remote: ANTHROPIC_UNIX_SOCKET tunnels API calls through a
// local auth-injecting proxy. The launcher sets CLAUDE_CODE_OAUTH_TOKEN as a // local auth-injecting proxy. The launcher sets CLAUDE_CODE_OAUTH_TOKEN as a
// placeholder iff the local side is a subscriber (so the remote includes the // placeholder iff the local side is a subscriber (so the remote includes the
@@ -161,6 +171,10 @@ export function getAuthTokenSource() {
return { source: 'none' as const, hasToken: false } return { source: 'none' as const, hasToken: false }
} }
if (getAPIProvider() !== 'firstParty') {
return { source: 'none' as const, hasToken: false }
}
if (process.env.ANTHROPIC_AUTH_TOKEN && !isManagedOAuthContext()) { if (process.env.ANTHROPIC_AUTH_TOKEN && !isManagedOAuthContext()) {
return { source: 'ANTHROPIC_AUTH_TOKEN' as const, hasToken: true } return { source: 'ANTHROPIC_AUTH_TOKEN' as const, hasToken: true }
} }
@@ -211,6 +225,20 @@ export type ApiKeySource =
| '/login managed key' | '/login managed key'
| 'none' | 'none'
export type OpenAIApiKeySource =
| 'OPENAI_API_KEY'
| 'OPENAI_ACCESS_TOKEN'
| 'CODEX_ACCESS_TOKEN'
| '/login managed OpenAI auth'
| '/login managed OpenAI key'
| 'none'
export type OpenRouterApiKeySource =
| 'OPENROUTER_API_KEY'
| 'ANTHROPIC_AUTH_TOKEN'
| '/login managed OpenRouter key'
| 'none'
export function getAnthropicApiKey(): null | string { export function getAnthropicApiKey(): null | string {
const { key } = getAnthropicApiKeyWithSource() const { key } = getAnthropicApiKeyWithSource()
return key return key
@@ -223,6 +251,194 @@ export function hasAnthropicApiKeyAuth(): boolean {
return key !== null && source !== 'none' return key !== null && source !== 'none'
} }
export function getOpenAIApiKey(): null | string {
return getOpenAIApiKeyWithSource().key
}
export function getOpenAIApiKeyWithSource(): {
key: null | string
source: OpenAIApiKeySource
} {
if (process.env.OPENAI_API_KEY) {
return { key: process.env.OPENAI_API_KEY, source: 'OPENAI_API_KEY' }
}
if (process.env.OPENAI_ACCESS_TOKEN) {
return {
key: process.env.OPENAI_ACCESS_TOKEN,
source: 'OPENAI_ACCESS_TOKEN',
}
}
if (process.env.CODEX_ACCESS_TOKEN) {
return { key: process.env.CODEX_ACCESS_TOKEN, source: 'CODEX_ACCESS_TOKEN' }
}
const accessToken = getGlobalConfig().openAiAccessToken
if (accessToken) {
return { key: accessToken, source: '/login managed OpenAI auth' }
}
const key = getGlobalConfig().openAiApiKey
return key
? { key, source: '/login managed OpenAI key' }
: { key: null, source: 'none' }
}
export function getOpenRouterApiKey(): null | string {
return getOpenRouterApiKeyWithSource().key
}
export function getOpenRouterApiKeyWithSource(): {
key: null | string
source: OpenRouterApiKeySource
} {
if (process.env.OPENROUTER_API_KEY) {
return { key: process.env.OPENROUTER_API_KEY, source: 'OPENROUTER_API_KEY' }
}
if (process.env.ANTHROPIC_AUTH_TOKEN && getAPIProvider() === 'openrouter') {
return {
key: process.env.ANTHROPIC_AUTH_TOKEN,
source: 'ANTHROPIC_AUTH_TOKEN',
}
}
const key = getGlobalConfig().openRouterApiKey
return key
? { key, source: '/login managed OpenRouter key' }
: { key: null, source: 'none' }
}
export function getConfiguredAuthProvider():
| 'anthropic'
| 'openrouter'
| 'openai' {
const storedProvider = getGlobalConfig().authProvider
if (storedProvider) {
return storedProvider
}
const provider = getAPIProvider()
switch (provider) {
case 'openrouter':
return 'openrouter'
case 'openai':
return 'openai'
default:
return 'anthropic'
}
}
export type OpenAIAuthTokens = {
accessToken: string
refreshToken?: string | null
expiresAt?: number | null
workspaceId?: string | null
lastRefresh?: string | number | null
}
export function getOpenAIAuthTokens(): OpenAIAuthTokens | null {
const config = getGlobalConfig()
if (!config.openAiAccessToken) {
return null
}
return {
accessToken: config.openAiAccessToken,
refreshToken: config.openAiRefreshToken,
expiresAt: config.openAiTokenExpiresAt,
workspaceId: config.openAiWorkspaceId,
}
}
export function saveOpenAIAuthTokens(tokens: OpenAIAuthTokens): void {
saveGlobalConfig(current => ({
...current,
authProvider: 'openai',
openAiApiKey: undefined,
openAiAccessToken: tokens.accessToken,
openAiRefreshToken: tokens.refreshToken ?? undefined,
openAiTokenExpiresAt: tokens.expiresAt ?? undefined,
openAiWorkspaceId: tokens.workspaceId ?? undefined,
}))
}
function getCodexHomeDir(): string {
return process.env.CODEX_HOME || join(homedir(), '.codex')
}
export async function importOpenAIAuthFromCodexCache(): Promise<OpenAIAuthTokens> {
const authFilePath = join(getCodexHomeDir(), 'auth.json')
const raw = await readFile(authFilePath, 'utf8')
const parsed = jsonParse(raw) as {
auth_mode?: string
tokens?: {
access_token?: string
refresh_token?: string
expires_at?: number | string
}
workspace_id?: string
last_refresh?: string | number
}
const accessToken = parsed.tokens?.access_token
if (!accessToken) {
throw new Error(
`Codex auth cache at ${authFilePath} does not contain an access token.`,
)
}
const expiresAtRaw = parsed.tokens?.expires_at
const expiresAt =
typeof expiresAtRaw === 'number'
? expiresAtRaw
: typeof expiresAtRaw === 'string'
? Date.parse(expiresAtRaw)
: undefined
const tokens = {
accessToken,
refreshToken: parsed.tokens?.refresh_token,
expiresAt: Number.isFinite(expiresAt) ? expiresAt : undefined,
workspaceId: parsed.workspace_id,
lastRefresh: parsed.last_refresh,
} satisfies OpenAIAuthTokens
saveOpenAIAuthTokens(tokens)
return tokens
}
export async function runCodexLogin(opts?: {
deviceAuth?: boolean
}): Promise<OpenAIAuthTokens> {
const args = ['login']
if (opts?.deviceAuth) {
args.push('--device-auth')
}
const result = await execa('codex', args, {
stdio: 'inherit',
reject: false,
})
if (result.exitCode !== 0) {
throw new Error(`codex ${args.join(' ')} exited with code ${result.exitCode}`)
}
return importOpenAIAuthFromCodexCache()
}
export async function refreshOpenAIAuthTokenIfNeeded(): Promise<boolean> {
const tokens = getOpenAIAuthTokens()
if (!tokens?.expiresAt || Date.now() < tokens.expiresAt) {
return false
}
try {
await importOpenAIAuthFromCodexCache()
return true
} catch {
return false
}
}
export function getAnthropicApiKeyWithSource( export function getAnthropicApiKeyWithSource(
opts: { skipRetrievingKeyFromApiKeyHelper?: boolean } = {}, opts: { skipRetrievingKeyFromApiKeyHelper?: boolean } = {},
): { ): {
@@ -1063,14 +1279,15 @@ export const getApiKeyFromConfigOrMacOSKeychain = memoize(
} }
// Prefetch completed with no key — fall through to config, not keychain. // Prefetch completed with no key — fall through to config, not keychain.
} else { } else {
const storageServiceName = getMacOsKeychainStorageServiceName()
try { try {
for (const storageServiceName of getMacOsKeychainStorageServiceNames()) {
const result = execSyncWithDefaults_DEPRECATED( const result = execSyncWithDefaults_DEPRECATED(
`security find-generic-password -a $USER -w -s "${storageServiceName}"`, `security find-generic-password -a $USER -w -s "${storageServiceName}"`,
) )
if (result) { if (result) {
return { key: result, source: '/login managed key' } return { key: result, source: '/login managed key' }
} }
}
} catch (e) { } catch (e) {
logError(e) logError(e)
} }
@@ -1159,6 +1376,36 @@ export async function saveApiKey(apiKey: string): Promise<void> {
clearLegacyApiKeyPrefetch() clearLegacyApiKeyPrefetch()
} }
export async function saveOpenAIApiKey(apiKey: string): Promise<void> {
if (!isValidApiKey(apiKey)) {
throw new Error(
'Invalid API key format. API key must contain only alphanumeric characters, dashes, and underscores.',
)
}
saveGlobalConfig(current => ({
...current,
authProvider: 'openai',
openAiAccessToken: undefined,
openAiRefreshToken: undefined,
openAiTokenExpiresAt: undefined,
openAiWorkspaceId: undefined,
openAiApiKey: apiKey,
}))
}
export async function saveOpenRouterApiKey(apiKey: string): Promise<void> {
if (!isValidApiKey(apiKey)) {
throw new Error(
'Invalid API key format. API key must contain only alphanumeric characters, dashes, and underscores.',
)
}
saveGlobalConfig(current => ({
...current,
authProvider: 'openrouter',
openRouterApiKey: apiKey,
}))
}
export function isCustomApiKeyApproved(apiKey: string): boolean { export function isCustomApiKeyApproved(apiKey: string): boolean {
const config = getGlobalConfig() const config = getGlobalConfig()
const normalizedKey = normalizeApiKeyForConfig(apiKey) const normalizedKey = normalizeApiKeyForConfig(apiKey)
@@ -1182,6 +1429,24 @@ export async function removeApiKey(): Promise<void> {
clearLegacyApiKeyPrefetch() clearLegacyApiKeyPrefetch()
} }
export async function removeOpenAIApiKey(): Promise<void> {
saveGlobalConfig(current => ({
...current,
openAiApiKey: undefined,
openAiAccessToken: undefined,
openAiRefreshToken: undefined,
openAiTokenExpiresAt: undefined,
openAiWorkspaceId: undefined,
}))
}
export async function removeOpenRouterApiKey(): Promise<void> {
saveGlobalConfig(current => ({
...current,
openRouterApiKey: undefined,
}))
}
async function maybeRemoveApiKeyFromMacOSKeychain(): Promise<void> { async function maybeRemoveApiKeyFromMacOSKeychain(): Promise<void> {
try { try {
await maybeRemoveApiKeyFromMacOSKeychainThrows() await maybeRemoveApiKeyFromMacOSKeychainThrows()
@@ -1716,15 +1981,15 @@ export function getSubscriptionName(): string {
switch (subscriptionType) { switch (subscriptionType) {
case 'enterprise': case 'enterprise':
return 'Claude Enterprise' return 'Anthropic Enterprise'
case 'team': case 'team':
return 'Claude Team' return 'Anthropic Team'
case 'max': case 'max':
return 'Claude Max' return 'Anthropic Max'
case 'pro': case 'pro':
return 'Claude Pro' return 'Anthropic Pro'
default: default:
return 'Claude API' return `${PRODUCT_NAME} API`
} }
} }
@@ -1964,8 +2229,8 @@ export async function validateForceLoginOrg(): Promise<OrgValidationResult> {
`Unable to verify organization for the current authentication token.\n` + `Unable to verify organization for the current authentication token.\n` +
`This machine requires organization ${requiredOrgUuid} but the profile could not be fetched.\n` + `This machine requires organization ${requiredOrgUuid} but the profile could not be fetched.\n` +
`This may be a network error, or the token may lack the user:profile scope required for\n` + `This may be a network error, or the token may lack the user:profile scope required for\n` +
`verification (tokens from 'claude setup-token' do not include this scope).\n` + `verification (tokens from 'better-clawd setup-token' do not include this scope).\n` +
`Try again, or obtain a full-scope token via 'claude auth login'.`, `Try again, or obtain a full-scope token via 'better-clawd auth login'.`,
} }
} }
@@ -1995,7 +2260,7 @@ export async function validateForceLoginOrg(): Promise<OrgValidationResult> {
message: message:
`Your authentication token belongs to organization ${tokenOrgUuid},\n` + `Your authentication token belongs to organization ${tokenOrgUuid},\n` +
`but this machine requires organization ${requiredOrgUuid}.\n\n` + `but this machine requires organization ${requiredOrgUuid}.\n\n` +
`Please log in with the correct organization: claude auth login`, `Please log in with the correct organization: better-clawd auth login`,
} }
} }

View File

@@ -1,14 +1,19 @@
import { execa } from 'execa' import { execa } from 'execa'
import { getMacOsKeychainStorageServiceName } from 'src/utils/secureStorage/macOsKeychainHelpers.js' import { getMacOsKeychainStorageServiceNames } from 'src/utils/secureStorage/macOsKeychainHelpers.js'
export async function maybeRemoveApiKeyFromMacOSKeychainThrows(): Promise<void> { export async function maybeRemoveApiKeyFromMacOSKeychainThrows(): Promise<void> {
if (process.platform === 'darwin') { if (process.platform === 'darwin') {
const storageServiceName = getMacOsKeychainStorageServiceName() let deletedAny = false
for (const storageServiceName of getMacOsKeychainStorageServiceNames()) {
const result = await execa( const result = await execa(
`security delete-generic-password -a $USER -s "${storageServiceName}"`, `security delete-generic-password -a $USER -s "${storageServiceName}"`,
{ shell: true, reject: false }, { shell: true, reject: false },
) )
if (result.exitCode !== 0) { if (result.exitCode === 0) {
deletedAny = true
}
}
if (!deletedAny) {
throw new Error('Failed to delete keychain entry') throw new Error('Failed to delete keychain entry')
} }
} }

View File

@@ -68,34 +68,7 @@ export type MaxVersionConfig = {
* This approach keeps version comparison logic simple while maintaining traceability via the SHA. * This approach keeps version comparison logic simple while maintaining traceability via the SHA.
*/ */
export async function assertMinVersion(): Promise<void> { export async function assertMinVersion(): Promise<void> {
if (process.env.NODE_ENV === 'test') {
return return
}
try {
const versionConfig = await getDynamicConfig_BLOCKS_ON_INIT<{
minVersion: string
}>('tengu_version_config', { minVersion: '0.0.0' })
if (
versionConfig.minVersion &&
lt(MACRO.VERSION, versionConfig.minVersion)
) {
// biome-ignore lint/suspicious/noConsole:: intentional console output
console.error(`
It looks like your version of Claude Code (${MACRO.VERSION}) needs an update.
A newer version (${versionConfig.minVersion} or higher) is required to continue.
To update, please run:
claude update
This will ensure you have access to the latest features and improvements.
`)
gracefulShutdownSync(1)
}
} catch (error) {
logError(error as Error)
}
} }
/** /**
@@ -106,11 +79,7 @@ This will ensure you have access to the latest features and improvements.
* Returns undefined if no cap is configured. * Returns undefined if no cap is configured.
*/ */
export async function getMaxVersion(): Promise<string | undefined> { export async function getMaxVersion(): Promise<string | undefined> {
const config = await getMaxVersionConfig() return undefined
if (process.env.USER_TYPE === 'ant') {
return config.ant || undefined
}
return config.external || undefined
} }
/** /**
@@ -118,11 +87,7 @@ export async function getMaxVersion(): Promise<string | undefined> {
* Shown in the warning banner when the current version exceeds the max allowed version. * Shown in the warning banner when the current version exceeds the max allowed version.
*/ */
export async function getMaxVersionMessage(): Promise<string | undefined> { export async function getMaxVersionMessage(): Promise<string | undefined> {
const config = await getMaxVersionConfig() return undefined
if (process.env.USER_TYPE === 'ant') {
return config.ant_message || undefined
}
return config.external_message || undefined
} }
async function getMaxVersionConfig(): Promise<MaxVersionConfig> { async function getMaxVersionConfig(): Promise<MaxVersionConfig> {
@@ -319,28 +284,8 @@ export async function checkGlobalInstallPermissions(): Promise<{
export async function getLatestVersion( export async function getLatestVersion(
channel: ReleaseChannel, channel: ReleaseChannel,
): Promise<string | null> { ): Promise<string | null> {
const npmTag = channel === 'stable' ? 'stable' : 'latest' void channel
return MACRO.VERSION
// Run from home directory to avoid reading project-level .npmrc
// which could be maliciously crafted to redirect to an attacker's registry
const result = await execFileNoThrowWithCwd(
'npm',
['view', `${MACRO.PACKAGE_URL}@${npmTag}`, 'version', '--prefer-online'],
{ abortSignal: AbortSignal.timeout(5000), cwd: homedir() },
)
if (result.code !== 0) {
logForDebugging(`npm view failed with code ${result.code}`)
if (result.stderr) {
logForDebugging(`npm stderr: ${result.stderr.trim()}`)
} else {
logForDebugging('npm stderr: (empty)')
}
if (result.stdout) {
logForDebugging(`npm stdout: ${result.stdout.trim()}`)
}
return null
}
return result.stdout.trim()
} }
export type NpmDistTags = { export type NpmDistTags = {
@@ -384,16 +329,8 @@ export async function getNpmDistTags(): Promise<NpmDistTags> {
export async function getLatestVersionFromGcs( export async function getLatestVersionFromGcs(
channel: ReleaseChannel, channel: ReleaseChannel,
): Promise<string | null> { ): Promise<string | null> {
try { void channel
const response = await axios.get(`${GCS_BUCKET_URL}/${channel}`, { return MACRO.VERSION
timeout: 5000,
responseType: 'text',
})
return response.data.trim()
} catch (error) {
logForDebugging(`Failed to fetch ${channel} from GCS: ${error}`)
return null
}
} }
/** /**
@@ -456,80 +393,8 @@ export async function getVersionHistory(limit: number): Promise<string[]> {
export async function installGlobalPackage( export async function installGlobalPackage(
specificVersion?: string | null, specificVersion?: string | null,
): Promise<InstallStatus> { ): Promise<InstallStatus> {
if (!(await acquireLock())) { void specificVersion
logError(
new AutoUpdaterError('Another process is currently installing an update'),
)
// Log the lock contention
logEvent('tengu_auto_updater_lock_contention', {
pid: process.pid,
currentVersion:
MACRO.VERSION as AnalyticsMetadata_I_VERIFIED_THIS_IS_NOT_CODE_OR_FILEPATHS,
})
return 'in_progress'
}
try {
await removeClaudeAliasesFromShellConfigs()
// Check if we're using npm from Windows path in WSL
if (!env.isRunningWithBun() && env.isNpmFromWindowsPath()) {
logError(new Error('Windows NPM detected in WSL environment'))
logEvent('tengu_auto_updater_windows_npm_in_wsl', {
currentVersion:
MACRO.VERSION as AnalyticsMetadata_I_VERIFIED_THIS_IS_NOT_CODE_OR_FILEPATHS,
})
// biome-ignore lint/suspicious/noConsole:: intentional console output
console.error(`
Error: Windows NPM detected in WSL
You're running Claude Code in WSL but using the Windows NPM installation from /mnt/c/.
This configuration is not supported for updates.
To fix this issue:
1. Install Node.js within your Linux distribution: e.g. sudo apt install nodejs npm
2. Make sure Linux NPM is in your PATH before the Windows version
3. Try updating again with 'claude update'
`)
return 'install_failed' return 'install_failed'
}
const { hasPermissions } = await checkGlobalInstallPermissions()
if (!hasPermissions) {
return 'no_permissions'
}
// Use specific version if provided, otherwise use latest
const packageSpec = specificVersion
? `${MACRO.PACKAGE_URL}@${specificVersion}`
: MACRO.PACKAGE_URL
// Run from home directory to avoid reading project-level .npmrc/.bunfig.toml
// which could be maliciously crafted to redirect to an attacker's registry
const packageManager = env.isRunningWithBun() ? 'bun' : 'npm'
const installResult = await execFileNoThrowWithCwd(
packageManager,
['install', '-g', packageSpec],
{ cwd: homedir() },
)
if (installResult.code !== 0) {
const error = new AutoUpdaterError(
`Failed to install new version of claude: ${installResult.stdout} ${installResult.stderr}`,
)
logError(error)
return 'install_failed'
}
// Set installMethod to 'global' to track npm global installations
saveGlobalConfig(current => ({
...current,
installMethod: 'global',
}))
return 'success'
} finally {
// Ensure we always release the lock
await releaseLock()
}
} }
/** /**

View File

@@ -109,11 +109,11 @@ export function createChromeContext(
clientTypeId: 'claude-code', clientTypeId: 'claude-code',
onAuthenticationError: () => { onAuthenticationError: () => {
logger.warn( logger.warn(
'Authentication error occurred. Please ensure you are logged into the Claude browser extension with the same claude.ai account as Claude Code.', 'Authentication error occurred. Please ensure you are logged into the browser extension with the same account used by Better-Clawd.',
) )
}, },
onToolCallDisconnected: () => { onToolCallDisconnected: () => {
return `Browser extension is not connected. Please ensure the Claude browser extension is installed and running (${EXTENSION_DOWNLOAD_URL}), and that you are logged into claude.ai with the same account as Claude Code. If this is your first time connecting to Chrome, you may need to restart Chrome for the installation to take effect. If you continue to experience issues, please report a bug: ${BUG_REPORT_URL}` return `Browser extension is not connected. Please ensure the browser extension is installed and running (${EXTENSION_DOWNLOAD_URL}), and that you are logged in with the same account used by Better-Clawd. If this is your first time connecting to Chrome, you may need to restart Chrome for the installation to take effect. If you continue to experience issues, please report a bug: ${BUG_REPORT_URL}`
}, },
onExtensionPaired: (deviceId: string, name: string) => { onExtensionPaired: (deviceId: string, name: string) => {
saveGlobalConfig(config => { saveGlobalConfig(config => {

View File

@@ -1,6 +1,7 @@
import { createHash, randomUUID, type UUID } from 'crypto' import { createHash, randomUUID, type UUID } from 'crypto'
import { stat } from 'fs/promises' import { stat } from 'fs/promises'
import { isAbsolute, join, relative, sep } from 'path' import { isAbsolute, join, relative, sep } from 'path'
import { PRODUCT_NAME } from '../constants/product.js'
import { getOriginalCwd, getSessionId } from '../bootstrap/state.js' import { getOriginalCwd, getSessionId } from '../bootstrap/state.js'
import type { import type {
AttributionSnapshotMessage, AttributionSnapshotMessage,
@@ -164,11 +165,11 @@ export function sanitizeModelName(shortName: string): string {
if (shortName.includes('haiku-4-5')) return 'claude-haiku-4-5' if (shortName.includes('haiku-4-5')) return 'claude-haiku-4-5'
if (shortName.includes('haiku-3-5')) return 'claude-haiku-3-5' if (shortName.includes('haiku-3-5')) return 'claude-haiku-3-5'
// Unknown models get a generic name // Unknown models get a generic name
return 'claude' return PRODUCT_NAME.toLowerCase()
} }
/** /**
* Attribution state for tracking Claude's contributions to files. * Attribution state for tracking assistant contributions to files.
*/ */
export type AttributionState = { export type AttributionState = {
// File states keyed by relative path (from cwd) // File states keyed by relative path (from cwd)
@@ -192,7 +193,7 @@ export type AttributionState = {
} }
/** /**
* Summary of Claude's contribution for a commit. * Summary of assistant contribution for a commit.
*/ */
export type AttributionSummary = { export type AttributionSummary = {
claudePercent: number claudePercent: number

View File

@@ -221,7 +221,14 @@ export type GlobalConfig = {
approved?: string[] approved?: string[]
rejected?: string[] rejected?: string[]
} }
authProvider?: 'anthropic' | 'openrouter' | 'openai'
primaryApiKey?: string // Primary API key for the user when no environment variable is set, set via oauth (TODO: rename) primaryApiKey?: string // Primary API key for the user when no environment variable is set, set via oauth (TODO: rename)
openAiApiKey?: string
openAiAccessToken?: string
openAiRefreshToken?: string
openAiTokenExpiresAt?: number
openAiWorkspaceId?: string
openRouterApiKey?: string
hasAcknowledgedCostThreshold?: boolean hasAcknowledgedCostThreshold?: boolean
hasSeenUndercoverAutoNotice?: boolean // ant-only: whether the one-time auto-undercover explainer has been shown hasSeenUndercoverAutoNotice?: boolean // ant-only: whether the one-time auto-undercover explainer has been shown
hasSeenUltraplanTerms?: boolean // ant-only: whether the one-time CCR terms notice has been shown in the ultraplan launch dialog hasSeenUltraplanTerms?: boolean // ant-only: whether the one-time CCR terms notice has been shown in the ultraplan launch dialog

View File

@@ -21,6 +21,11 @@ import {
type AnalyticsMetadata_I_VERIFIED_THIS_IS_NOT_CODE_OR_FILEPATHS, type AnalyticsMetadata_I_VERIFIED_THIS_IS_NOT_CODE_OR_FILEPATHS,
logEvent, logEvent,
} from 'src/services/analytics/index.js' } from 'src/services/analytics/index.js'
import {
CLI_BINARY_NAME,
LEGACY_CLI_BINARY_NAME,
PRODUCT_NAME,
} from 'src/constants/product.js'
import { logForDebugging } from '../debug.js' import { logForDebugging } from '../debug.js'
import { getClaudeConfigHomeDir } from '../envUtils.js' import { getClaudeConfigHomeDir } from '../envUtils.js'
import { getErrnoCode } from '../errors.js' import { getErrnoCode } from '../errors.js'
@@ -30,10 +35,10 @@ import { which } from '../which.js'
import { getUserBinDir, getXDGDataHome } from '../xdg.js' import { getUserBinDir, getXDGDataHome } from '../xdg.js'
import { DEEP_LINK_PROTOCOL } from './parseDeepLink.js' import { DEEP_LINK_PROTOCOL } from './parseDeepLink.js'
export const MACOS_BUNDLE_ID = 'com.anthropic.claude-code-url-handler' export const MACOS_BUNDLE_ID = 'com.betterclawd.better-clawd-url-handler'
const APP_NAME = 'Claude Code URL Handler' const APP_NAME = 'Better-Clawd URL Handler'
const DESKTOP_FILE_NAME = 'claude-code-url-handler.desktop' const DESKTOP_FILE_NAME = 'better-clawd-url-handler.desktop'
const MACOS_APP_NAME = 'Claude Code URL Handler.app' const MACOS_APP_NAME = 'Better-Clawd URL Handler.app'
// Shared between register* (writes these paths/values) and // Shared between register* (writes these paths/values) and
// isProtocolHandlerCurrent (reads them back). Keep the writer and reader // isProtocolHandlerCurrent (reads them back). Keep the writer and reader
@@ -43,7 +48,7 @@ const MACOS_SYMLINK_PATH = path.join(
MACOS_APP_DIR, MACOS_APP_DIR,
'Contents', 'Contents',
'MacOS', 'MacOS',
'claude', CLI_BINARY_NAME,
) )
function linuxDesktopPath(): string { function linuxDesktopPath(): string {
return path.join(getXDGDataHome(), 'applications', DESKTOP_FILE_NAME) return path.join(getXDGDataHome(), 'applications', DESKTOP_FILE_NAME)
@@ -64,9 +69,9 @@ function windowsCommandValue(claudePath: string): string {
* Register the protocol handler on macOS. * Register the protocol handler on macOS.
* *
* Creates a .app bundle where the CFBundleExecutable is a symlink to the * Creates a .app bundle where the CFBundleExecutable is a symlink to the
* already-installed (and signed) `claude` binary. When macOS opens a * already-installed (and signed) CLI binary. When macOS opens a deep link,
* `claude-cli://` URL, it launches `claude` through this app bundle. * it launches Better-Clawd through this app bundle.
* Claude then uses the url-handler NAPI module to read the URL from the * Better-Clawd then uses the url-handler NAPI module to read the URL from the
* Apple Event and handles it normally. * Apple Event and handles it normally.
* *
* This approach avoids shipping a separate executable (which would need * This approach avoids shipping a separate executable (which would need
@@ -87,7 +92,7 @@ async function registerMacos(claudePath: string): Promise<void> {
await fs.mkdir(path.dirname(MACOS_SYMLINK_PATH), { recursive: true }) await fs.mkdir(path.dirname(MACOS_SYMLINK_PATH), { recursive: true })
// Info.plist — registers the URL scheme with claude as the executable // Info.plist — registers the URL scheme with Better-Clawd as the executable
const infoPlist = `<?xml version="1.0" encoding="UTF-8"?> const infoPlist = `<?xml version="1.0" encoding="UTF-8"?>
<!DOCTYPE plist PUBLIC "-//Apple//DTD PLIST 1.0//EN" "http://www.apple.com/DTDs/PropertyList-1.0.dtd"> <!DOCTYPE plist PUBLIC "-//Apple//DTD PLIST 1.0//EN" "http://www.apple.com/DTDs/PropertyList-1.0.dtd">
<plist version="1.0"> <plist version="1.0">
@@ -97,7 +102,7 @@ async function registerMacos(claudePath: string): Promise<void> {
<key>CFBundleName</key> <key>CFBundleName</key>
<string>${APP_NAME}</string> <string>${APP_NAME}</string>
<key>CFBundleExecutable</key> <key>CFBundleExecutable</key>
<string>claude</string> <string>${CLI_BINARY_NAME}</string>
<key>CFBundleVersion</key> <key>CFBundleVersion</key>
<string>1.0</string> <string>1.0</string>
<key>CFBundlePackageType</key> <key>CFBundlePackageType</key>
@@ -108,7 +113,7 @@ async function registerMacos(claudePath: string): Promise<void> {
<array> <array>
<dict> <dict>
<key>CFBundleURLName</key> <key>CFBundleURLName</key>
<string>Claude Code Deep Link</string> <string>Better-Clawd Deep Link</string>
<key>CFBundleURLSchemes</key> <key>CFBundleURLSchemes</key>
<array> <array>
<string>${DEEP_LINK_PROTOCOL}</string> <string>${DEEP_LINK_PROTOCOL}</string>
@@ -120,7 +125,7 @@ async function registerMacos(claudePath: string): Promise<void> {
await fs.writeFile(path.join(contentsDir, 'Info.plist'), infoPlist) await fs.writeFile(path.join(contentsDir, 'Info.plist'), infoPlist)
// Symlink to the already-signed claude binary — avoids a new executable // Symlink to the already-signed CLI binary — avoids a new executable
// that would need signing and endpoint-security allowlisting. // that would need signing and endpoint-security allowlisting.
// Written LAST among the throwing fs calls: isProtocolHandlerCurrent reads // Written LAST among the throwing fs calls: isProtocolHandlerCurrent reads
// this symlink, so it acts as the commit marker. If Info.plist write // this symlink, so it acts as the commit marker. If Info.plist write
@@ -146,7 +151,7 @@ async function registerLinux(claudePath: string): Promise<void> {
const desktopEntry = `[Desktop Entry] const desktopEntry = `[Desktop Entry]
Name=${APP_NAME} Name=${APP_NAME}
Comment=Handle ${DEEP_LINK_PROTOCOL}:// deep links for Claude Code Comment=Handle ${DEEP_LINK_PROTOCOL}:// deep links for Better-Clawd
${linuxExecLine(claudePath)} ${linuxExecLine(claudePath)}
Type=Application Type=Application
NoDisplay=true NoDisplay=true
@@ -233,25 +238,31 @@ export async function registerProtocolHandler(
} }
/** /**
* Resolve the claude binary path for protocol registration. Prefers the * Resolve the CLI binary path for protocol registration. Prefers the
* native installer's stable symlink (~/.local/bin/claude) which survives * native installer's stable symlink (~/.local/bin/better-clawd) which survives
* auto-updates; falls back to process.execPath when the symlink is absent * auto-updates; falls back to process.execPath when the symlink is absent
* (dev builds, non-native installs). * (dev builds, non-native installs).
*/ */
async function resolveClaudePath(): Promise<string> { async function resolveClaudePath(): Promise<string> {
const binaryName = process.platform === 'win32' ? 'claude.exe' : 'claude' const candidateNames = process.platform === 'win32'
? [`${CLI_BINARY_NAME}.exe`, `${LEGACY_CLI_BINARY_NAME}.exe`]
: [CLI_BINARY_NAME, LEGACY_CLI_BINARY_NAME]
for (const binaryName of candidateNames) {
const stablePath = path.join(getUserBinDir(), binaryName) const stablePath = path.join(getUserBinDir(), binaryName)
try { try {
await fs.realpath(stablePath) await fs.realpath(stablePath)
return stablePath return stablePath
} catch { } catch {
return process.execPath // Try the next compatibility alias.
} }
}
return process.execPath
} }
/** /**
* Check whether the OS-level protocol handler is already registered AND * Check whether the OS-level protocol handler is already registered AND
* points at the expected `claude` binary. Reads the registration artifact * points at the expected Better-Clawd binary. Reads the registration artifact
* directly (symlink target, .desktop Exec line, registry value) rather than * directly (symlink target, .desktop Exec line, registry value) rather than
* a cached flag in ~/.claude.json, so: * a cached flag in ~/.claude.json, so:
* - the check is per-machine (config can sync across machines; OS state can't) * - the check is per-machine (config can sync across machines; OS state can't)
@@ -290,7 +301,7 @@ export async function isProtocolHandlerCurrent(
} }
/** /**
* Auto-register the claude-cli:// deep link protocol handler when missing * Auto-register the Better-Clawd deep link protocol handler when missing
* or stale. Runs every session from backgroundHousekeeping (fire-and-forget), * or stale. Runs every session from backgroundHousekeeping (fire-and-forget),
* but the artifact check makes it a no-op after the first successful run * but the artifact check makes it a no-op after the first successful run
* unless the install path moves or the OS artifact is deleted. * unless the install path moves or the OS artifact is deleted.
@@ -328,7 +339,7 @@ export async function ensureDeepLinkProtocolRegistered(): Promise<void> {
try { try {
await registerProtocolHandler(claudePath) await registerProtocolHandler(claudePath)
logEvent('tengu_deep_link_registered', { success: true }) logEvent('tengu_deep_link_registered', { success: true })
logForDebugging('Auto-registered claude-cli:// deep link protocol handler') logForDebugging('Auto-registered Better-Clawd deep link protocol handler')
await fs.rm(failureMarkerPath, { force: true }).catch(() => {}) await fs.rm(failureMarkerPath, { force: true }).catch(() => {})
} catch (error) { } catch (error) {
const code = getErrnoCode(error) const code = getErrnoCode(error)

View File

@@ -435,14 +435,14 @@ async function detectConfigurationIssues(
if (type === 'npm-local' && config.installMethod !== 'local') { if (type === 'npm-local' && config.installMethod !== 'local') {
warnings.push({ warnings.push({
issue: `Running from local installation but config install method is '${config.installMethod}'`, issue: `Running from local installation but config install method is '${config.installMethod}'`,
fix: 'Consider using native installation: claude install', fix: 'Consider using native installation: better-clawd install',
}) })
} }
if (type === 'native' && config.installMethod !== 'native') { if (type === 'native' && config.installMethod !== 'native') {
warnings.push({ warnings.push({
issue: `Running native installation but config install method is '${config.installMethod}'`, issue: `Running native installation but config install method is '${config.installMethod}'`,
fix: 'Run claude install to update configuration', fix: 'Run better-clawd install to update configuration',
}) })
} }
} }
@@ -450,7 +450,7 @@ async function detectConfigurationIssues(
if (type === 'npm-global' && (await localInstallationExists())) { if (type === 'npm-global' && (await localInstallationExists())) {
warnings.push({ warnings.push({
issue: 'Local installation exists but not being used', issue: 'Local installation exists but not being used',
fix: 'Consider using native installation: claude install', fix: 'Consider using native installation: better-clawd install',
}) })
} }
@@ -469,13 +469,13 @@ async function detectConfigurationIssues(
// Alias exists but points to invalid target // Alias exists but points to invalid target
warnings.push({ warnings.push({
issue: 'Local installation not accessible', issue: 'Local installation not accessible',
fix: `Alias exists but points to invalid target: ${existingAlias}. Update alias: alias claude="~/.claude/local/claude"`, fix: `Alias exists but points to invalid target: ${existingAlias}. Update alias: alias better-clawd="~/.better-clawd/local/better-clawd"`,
}) })
} else { } else {
// No alias exists and not in PATH // No alias exists and not in PATH
warnings.push({ warnings.push({
issue: 'Local installation not accessible', issue: 'Local installation not accessible',
fix: 'Create alias: alias claude="~/.claude/local/claude"', fix: 'Create alias: alias better-clawd="~/.better-clawd/local/better-clawd"',
}) })
} }
} }
@@ -580,7 +580,7 @@ export async function getDoctorDiagnostic(): Promise<DiagnosticInfo> {
if (!hasUpdatePermissions && !getAutoUpdaterDisabledReason()) { if (!hasUpdatePermissions && !getAutoUpdaterDisabledReason()) {
warnings.push({ warnings.push({
issue: 'Insufficient permissions for auto-updates', issue: 'Insufficient permissions for auto-updates',
fix: 'Do one of: (1) Re-install node without sudo, or (2) Use `claude install` for native installation', fix: 'Do one of: (1) Re-install node without sudo, or (2) Use `better-clawd install` for native installation',
}) })
} }
} }

View File

@@ -1,6 +1,11 @@
import memoize from 'lodash-es/memoize.js' import memoize from 'lodash-es/memoize.js'
import { homedir } from 'os' import { homedir } from 'os'
import { join } from 'path' import { join } from 'path'
import {
getConfiguredProductConfigDir,
LEGACY_PRODUCT_SLUG,
PRODUCT_SLUG,
} from '../constants/product.js'
import { fileSuffixForOauthConfig } from '../constants/oauth.js' import { fileSuffixForOauthConfig } from '../constants/oauth.js'
import { isRunningWithBun } from './bundledMode.js' import { isRunningWithBun } from './bundledMode.js'
import { getClaudeConfigHomeDir, isEnvTruthy } from './envUtils.js' import { getClaudeConfigHomeDir, isEnvTruthy } from './envUtils.js'
@@ -12,17 +17,29 @@ type Platform = 'win32' | 'darwin' | 'linux'
// Config and data paths // Config and data paths
export const getGlobalClaudeFile = memoize((): string => { export const getGlobalClaudeFile = memoize((): string => {
const configHomeDir = getClaudeConfigHomeDir()
const configDirOverride = getConfiguredProductConfigDir()
// Legacy fallback for backwards compatibility // Legacy fallback for backwards compatibility
if ( const legacyConfigPath = join(configHomeDir, '.config.json')
getFsImplementation().existsSync( if (getFsImplementation().existsSync(legacyConfigPath)) {
join(getClaudeConfigHomeDir(), '.config.json'), return legacyConfigPath
)
) {
return join(getClaudeConfigHomeDir(), '.config.json')
} }
const filename = `.claude${fileSuffixForOauthConfig()}.json` const suffix = fileSuffixForOauthConfig()
return join(process.env.CLAUDE_CONFIG_DIR || homedir(), filename) const preferredFilename = `.${PRODUCT_SLUG}${suffix}.json`
const legacyFilename = `.${LEGACY_PRODUCT_SLUG}${suffix}.json`
const preferredPath = join(configDirOverride || homedir(), preferredFilename)
const legacyPath = join(configDirOverride || homedir(), legacyFilename)
if (getFsImplementation().existsSync(preferredPath)) {
return preferredPath
}
if (getFsImplementation().existsSync(legacyPath)) {
return legacyPath
}
return preferredPath
}) })
const hasInternetAccess = memoize(async (): Promise<boolean> => { const hasInternetAccess = memoize(async (): Promise<boolean> => {

View File

@@ -1,16 +1,35 @@
import memoize from 'lodash-es/memoize.js' import memoize from 'lodash-es/memoize.js'
import { existsSync } from 'fs'
import { homedir } from 'os' import { homedir } from 'os'
import { join } from 'path' import { join } from 'path'
import {
getConfiguredProductConfigDir,
LEGACY_PRODUCT_CONFIG_DIRNAME,
PRODUCT_CONFIG_DIRNAME,
} from '../constants/product.js'
// Memoized: 150+ callers, many on hot paths. Keyed off CLAUDE_CONFIG_DIR so // Memoized: 150+ callers, many on hot paths. Keyed off CLAUDE_CONFIG_DIR so
// tests that change the env var get a fresh value without explicit cache.clear. // tests that change the env var get a fresh value without explicit cache.clear.
export const getClaudeConfigHomeDir = memoize( export const getClaudeConfigHomeDir = memoize(
(): string => { (): string => {
return ( const configuredDir = getConfiguredProductConfigDir()
process.env.CLAUDE_CONFIG_DIR ?? join(homedir(), '.claude') if (configuredDir) {
).normalize('NFC') return configuredDir.normalize('NFC')
}
const betterClawdDir = join(homedir(), PRODUCT_CONFIG_DIRNAME)
const legacyClaudeDir = join(homedir(), LEGACY_PRODUCT_CONFIG_DIRNAME)
if (existsSync(betterClawdDir)) {
return betterClawdDir.normalize('NFC')
}
if (existsSync(legacyClaudeDir)) {
return legacyClaudeDir.normalize('NFC')
}
return betterClawdDir.normalize('NFC')
}, },
() => process.env.CLAUDE_CONFIG_DIR, () => getConfiguredProductConfigDir(),
) )
export function getTeamsDir(): string { export function getTeamsDir(): string {

View File

@@ -0,0 +1,20 @@
export const OUTPUTS_SUBDIR = 'outputs'
export const FILE_COUNT_LIMIT = 500
export const DEFAULT_UPLOAD_CONCURRENCY = 4
export type TurnStartTime = number
export type PersistedFile = {
filename: string
file_id: string
}
export type FailedPersistence = {
filename: string
error: string
}
export type FilesPersistedEventData = {
files: PersistedFile[]
failed: FailedPersistence[]
}

View File

@@ -154,10 +154,10 @@ export function generateHeatmap(
lines.push( lines.push(
' Less ' + ' Less ' +
[ [
claudeOrange('░'), clawdBlue('░'),
claudeOrange('▒'), clawdBlue('▒'),
claudeOrange('▓'), clawdBlue('▓'),
claudeOrange('█'), clawdBlue('█'),
].join(' ') + ].join(' ') +
' More', ' More',
) )
@@ -177,21 +177,21 @@ function getIntensity(
return 1 return 1
} }
// Claude orange color (hex #da7756) // Better-Clawd blue (hex #3b82f6)
const claudeOrange = chalk.hex('#da7756') const clawdBlue = chalk.hex('#3b82f6')
function getHeatmapChar(intensity: number): string { function getHeatmapChar(intensity: number): string {
switch (intensity) { switch (intensity) {
case 0: case 0:
return chalk.gray('·') return chalk.gray('·')
case 1: case 1:
return claudeOrange('░') return clawdBlue('░')
case 2: case 2:
return claudeOrange('▒') return clawdBlue('▒')
case 3: case 3:
return claudeOrange('▓') return clawdBlue('▓')
case 4: case 4:
return claudeOrange('█') return clawdBlue('█')
default: default:
return chalk.gray('·') return chalk.gray('·')
} }

View File

@@ -3,18 +3,23 @@
*/ */
import axios from 'axios' import axios from 'axios'
import {
PRODUCT_ISSUES_URL,
PRODUCT_SLUG,
} from '../constants/product.js'
import { OAUTH_BETA_HEADER } from '../constants/oauth.js' import { OAUTH_BETA_HEADER } from '../constants/oauth.js'
import { import {
getAnthropicApiKey, getAnthropicApiKey,
getClaudeAIOAuthTokens, getClaudeAIOAuthTokens,
getOpenAIApiKey,
getOpenRouterApiKey,
handleOAuth401Error, handleOAuth401Error,
isClaudeAISubscriber, isClaudeAISubscriber,
} from './auth.js' } from './auth.js'
import { getAPIProvider } from './model/providers.js'
import { getClaudeCodeUserAgent } from './userAgent.js' import { getClaudeCodeUserAgent } from './userAgent.js'
import { getWorkload } from './workloadContext.js' import { getWorkload } from './workloadContext.js'
// WARNING: We rely on `claude-cli` in the user agent for log filtering.
// Please do NOT change this without making sure that logging also gets updated!
export function getUserAgent(): string { export function getUserAgent(): string {
const agentSdkVersion = process.env.CLAUDE_AGENT_SDK_VERSION const agentSdkVersion = process.env.CLAUDE_AGENT_SDK_VERSION
? `, agent-sdk/${process.env.CLAUDE_AGENT_SDK_VERSION}` ? `, agent-sdk/${process.env.CLAUDE_AGENT_SDK_VERSION}`
@@ -31,7 +36,7 @@ export function getUserAgent(): string {
// so the read picks up the same setWorkload() value as getAttributionHeader. // so the read picks up the same setWorkload() value as getAttributionHeader.
const workload = getWorkload() const workload = getWorkload()
const workloadSuffix = workload ? `, workload/${workload}` : '' const workloadSuffix = workload ? `, workload/${workload}` : ''
return `claude-cli/${MACRO.VERSION} (${process.env.USER_TYPE}, ${process.env.CLAUDE_CODE_ENTRYPOINT ?? 'cli'}${agentSdkVersion}${clientApp}${workloadSuffix})` return `${PRODUCT_SLUG}-cli/${MACRO.VERSION} (${process.env.USER_TYPE}, ${process.env.CLAUDE_CODE_ENTRYPOINT ?? 'cli'}${agentSdkVersion}${clientApp}${workloadSuffix})`
} }
export function getMCPUserAgent(): string { export function getMCPUserAgent(): string {
@@ -46,15 +51,12 @@ export function getMCPUserAgent(): string {
parts.push(`client-app/${process.env.CLAUDE_AGENT_SDK_CLIENT_APP}`) parts.push(`client-app/${process.env.CLAUDE_AGENT_SDK_CLIENT_APP}`)
} }
const suffix = parts.length > 0 ? ` (${parts.join(', ')})` : '' const suffix = parts.length > 0 ? ` (${parts.join(', ')})` : ''
return `claude-code/${MACRO.VERSION}${suffix}` return `${PRODUCT_SLUG}/${MACRO.VERSION}${suffix}`
} }
// User-Agent for WebFetch requests to arbitrary sites. `Claude-User` is // User-Agent for WebFetch requests to arbitrary sites.
// Anthropic's publicly documented agent for user-initiated fetches (what site
// operators match in robots.txt); the claude-code suffix lets them distinguish
// local CLI traffic from claude.ai server-side fetches.
export function getWebFetchUserAgent(): string { export function getWebFetchUserAgent(): string {
return `Claude-User (${getClaudeCodeUserAgent()}; +https://support.anthropic.com/)` return `Better-Clawd-User (${getClaudeCodeUserAgent()}; +${PRODUCT_ISSUES_URL})`
} }
export type AuthHeaders = { export type AuthHeaders = {
@@ -67,6 +69,37 @@ export type AuthHeaders = {
* Returns either OAuth headers for Max/Pro users or API key headers for regular users * Returns either OAuth headers for Max/Pro users or API key headers for regular users
*/ */
export function getAuthHeaders(): AuthHeaders { export function getAuthHeaders(): AuthHeaders {
const provider = getAPIProvider()
if (provider === 'openai') {
const apiKey = getOpenAIApiKey()
if (!apiKey) {
return {
headers: {},
error: 'No OpenAI API key available',
}
}
return {
headers: {
Authorization: `Bearer ${apiKey}`,
},
}
}
if (provider === 'openrouter') {
const apiKey = getOpenRouterApiKey()
if (!apiKey) {
return {
headers: {},
error: 'No OpenRouter API key available',
}
}
return {
headers: {
Authorization: `Bearer ${apiKey}`,
},
}
}
if (isClaudeAISubscriber()) { if (isClaudeAISubscriber()) {
const oauthTokens = getClaudeAIOAuthTokens() const oauthTokens = getClaudeAIOAuthTokens()
if (!oauthTokens?.accessToken) { if (!oauthTokens?.accessToken) {

View File

@@ -4,6 +4,10 @@
import { access, chmod, writeFile } from 'fs/promises' import { access, chmod, writeFile } from 'fs/promises'
import { join } from 'path' import { join } from 'path'
import {
CLI_BINARY_NAME,
LEGACY_CLI_BINARY_NAME,
} from '../constants/product.js'
import { type ReleaseChannel, saveGlobalConfig } from './config.js' import { type ReleaseChannel, saveGlobalConfig } from './config.js'
import { getClaudeConfigHomeDir } from './envUtils.js' import { getClaudeConfigHomeDir } from './envUtils.js'
import { getErrnoCode } from './errors.js' import { getErrnoCode } from './errors.js'
@@ -20,7 +24,7 @@ function getLocalInstallDir(): string {
return join(getClaudeConfigHomeDir(), 'local') return join(getClaudeConfigHomeDir(), 'local')
} }
export function getLocalClaudePath(): string { export function getLocalClaudePath(): string {
return join(getLocalInstallDir(), 'claude') return join(getLocalInstallDir(), CLI_BINARY_NAME)
} }
/** /**
@@ -28,7 +32,10 @@ export function getLocalClaudePath(): string {
*/ */
export function isRunningFromLocalInstallation(): boolean { export function isRunningFromLocalInstallation(): boolean {
const execPath = process.argv[1] || '' const execPath = process.argv[1] || ''
return execPath.includes('/.claude/local/node_modules/') return (
execPath.includes('/.better-clawd/local/node_modules/') ||
execPath.includes('/.claude/local/node_modules/')
)
} }
/** /**
@@ -64,23 +71,26 @@ export async function ensureLocalPackageEnvironment(): Promise<boolean> {
await writeIfMissing( await writeIfMissing(
join(localInstallDir, 'package.json'), join(localInstallDir, 'package.json'),
jsonStringify( jsonStringify(
{ name: 'claude-local', version: '0.0.1', private: true }, { name: 'better-clawd-local', version: '0.0.1', private: true },
null, null,
2, 2,
), ),
) )
// Create the wrapper script if it doesn't exist // Create wrapper scripts for the canonical Better-Clawd launcher and the
const wrapperPath = join(localInstallDir, 'claude') // legacy `claude` alias.
for (const binaryName of [CLI_BINARY_NAME, LEGACY_CLI_BINARY_NAME]) {
const wrapperPath = join(localInstallDir, binaryName)
const created = await writeIfMissing( const created = await writeIfMissing(
wrapperPath, wrapperPath,
`#!/bin/sh\nexec "${localInstallDir}/node_modules/.bin/claude" "$@"`, `#!/bin/sh\nexec "${localInstallDir}/node_modules/.bin/${LEGACY_CLI_BINARY_NAME}" "$@"`,
0o755, 0o755,
) )
if (created) { if (created) {
// Mode in writeFile is masked by umask; chmod to ensure executable bit. // Mode in writeFile is masked by umask; chmod to ensure executable bit.
await chmod(wrapperPath, 0o755) await chmod(wrapperPath, 0o755)
} }
}
return true return true
} catch (error) { } catch (error) {
@@ -118,7 +128,7 @@ export async function installOrUpdateClaudePackage(
if (result.code !== 0) { if (result.code !== 0) {
const error = new Error( const error = new Error(
`Failed to install Claude CLI package: ${result.stderr}`, `Failed to install ${CLI_BINARY_NAME} package: ${result.stderr}`,
) )
logError(error) logError(error)
return result.code === 190 ? 'in_progress' : 'install_failed' return result.code === 190 ? 'in_progress' : 'install_failed'
@@ -143,7 +153,9 @@ export async function installOrUpdateClaudePackage(
*/ */
export async function localInstallationExists(): Promise<boolean> { export async function localInstallationExists(): Promise<boolean> {
try { try {
await access(join(getLocalInstallDir(), 'node_modules', '.bin', 'claude')) await access(
join(getLocalInstallDir(), 'node_modules', '.bin', LEGACY_CLI_BINARY_NAME),
)
return true return true
} catch { } catch {
return false return false

View File

@@ -34,6 +34,9 @@ export type ModelName = string
export type ModelSetting = ModelName | ModelAlias | null export type ModelSetting = ModelName | ModelAlias | null
export function getSmallFastModel(): ModelName { export function getSmallFastModel(): ModelName {
if (getAPIProvider() === 'openai') {
return process.env.OPENAI_SMALL_FAST_MODEL || getDefaultHaikuModel()
}
return process.env.ANTHROPIC_SMALL_FAST_MODEL || getDefaultHaikuModel() return process.env.ANTHROPIC_SMALL_FAST_MODEL || getDefaultHaikuModel()
} }

View File

@@ -43,6 +43,8 @@ export type ModelOption = {
} }
export function getDefaultOptionForUser(fastMode = false): ModelOption { export function getDefaultOptionForUser(fastMode = false): ModelOption {
const provider = getAPIProvider()
const isOpenAI = provider === 'openai'
if (process.env.USER_TYPE === 'ant') { if (process.env.USER_TYPE === 'ant') {
const currentModel = renderDefaultModelSetting( const currentModel = renderDefaultModelSetting(
getDefaultMainLoopModelSetting(), getDefaultMainLoopModelSetting(),
@@ -65,11 +67,13 @@ export function getDefaultOptionForUser(fastMode = false): ModelOption {
} }
// PAYG // PAYG
const is3P = getAPIProvider() !== 'firstParty' const is3P = provider !== 'firstParty'
return { return {
value: null, value: null,
label: 'Default (recommended)', label: 'Default (recommended)',
description: `Use the default model (currently ${renderDefaultModelSetting(getDefaultMainLoopModelSetting())})${is3P ? '' : ` · ${formatModelPricing(COST_TIER_3_15)}`}`, description: isOpenAI
? `Use the default OpenAI model (currently ${renderDefaultModelSetting(getDefaultMainLoopModelSetting())})`
: `Use the default model (currently ${renderDefaultModelSetting(getDefaultMainLoopModelSetting())})${is3P ? '' : ` · ${formatModelPricing(COST_TIER_3_15)}`}`,
} }
} }
@@ -94,7 +98,17 @@ function getCustomSonnetOption(): ModelOption | undefined {
// @[MODEL LAUNCH]: Update or add model option functions (getSonnetXXOption, getOpusXXOption, etc.) // @[MODEL LAUNCH]: Update or add model option functions (getSonnetXXOption, getOpusXXOption, etc.)
// with the new model's label and description. These appear in the /model picker. // with the new model's label and description. These appear in the /model picker.
function getSonnet46Option(): ModelOption { function getSonnet46Option(): ModelOption {
const is3P = getAPIProvider() !== 'firstParty' const provider = getAPIProvider()
const is3P = provider !== 'firstParty'
if (provider === 'openai') {
return {
value: getModelStrings().sonnet46,
label: 'GPT-5.4',
description: 'GPT-5.4 · Recommended for most coding tasks',
descriptionForModel:
'GPT-5.4 - recommended for most coding and agentic tasks on OpenAI',
}
}
return { return {
value: is3P ? getModelStrings().sonnet46 : 'sonnet', value: is3P ? getModelStrings().sonnet46 : 'sonnet',
label: 'Sonnet', label: 'Sonnet',
@@ -131,7 +145,17 @@ function getOpus41Option(): ModelOption {
} }
function getOpus46Option(fastMode = false): ModelOption { function getOpus46Option(fastMode = false): ModelOption {
const is3P = getAPIProvider() !== 'firstParty' const provider = getAPIProvider()
const is3P = provider !== 'firstParty'
if (provider === 'openai') {
return {
value: getModelStrings().opus46,
label: 'GPT-5.4',
description: 'GPT-5.4 · Most capable OpenAI coding model',
descriptionForModel:
'GPT-5.4 - most capable OpenAI model for complex coding work',
}
}
return { return {
value: is3P ? getModelStrings().opus46 : 'opus', value: is3P ? getModelStrings().opus46 : 'opus',
label: 'Opus', label: 'Opus',
@@ -141,7 +165,17 @@ function getOpus46Option(fastMode = false): ModelOption {
} }
export function getSonnet46_1MOption(): ModelOption { export function getSonnet46_1MOption(): ModelOption {
const is3P = getAPIProvider() !== 'firstParty' const provider = getAPIProvider()
const is3P = provider !== 'firstParty'
if (provider === 'openai') {
return {
value: getModelStrings().sonnet46,
label: 'GPT-5.4',
description: 'GPT-5.4 for long-running OpenAI sessions',
descriptionForModel:
'GPT-5.4 for long-running OpenAI sessions and large codebases',
}
}
return { return {
value: is3P ? getModelStrings().sonnet46 + '[1m]' : 'sonnet[1m]', value: is3P ? getModelStrings().sonnet46 + '[1m]' : 'sonnet[1m]',
label: 'Sonnet (1M context)', label: 'Sonnet (1M context)',
@@ -152,7 +186,17 @@ export function getSonnet46_1MOption(): ModelOption {
} }
export function getOpus46_1MOption(fastMode = false): ModelOption { export function getOpus46_1MOption(fastMode = false): ModelOption {
const is3P = getAPIProvider() !== 'firstParty' const provider = getAPIProvider()
const is3P = provider !== 'firstParty'
if (provider === 'openai') {
return {
value: getModelStrings().opus46,
label: 'GPT-5.4',
description: 'GPT-5.4 for long-running OpenAI sessions',
descriptionForModel:
'GPT-5.4 for long-running OpenAI sessions and large codebases',
}
}
return { return {
value: is3P ? getModelStrings().opus46 + '[1m]' : 'opus[1m]', value: is3P ? getModelStrings().opus46 + '[1m]' : 'opus[1m]',
label: 'Opus (1M context)', label: 'Opus (1M context)',
@@ -179,7 +223,17 @@ function getCustomHaikuOption(): ModelOption | undefined {
} }
function getHaiku45Option(): ModelOption { function getHaiku45Option(): ModelOption {
const is3P = getAPIProvider() !== 'firstParty' const provider = getAPIProvider()
const is3P = provider !== 'firstParty'
if (provider === 'openai') {
return {
value: getModelStrings().haiku45,
label: 'GPT-5.4 Mini',
description: 'GPT-5.4 Mini · Fastest OpenAI option for quick answers',
descriptionForModel:
'GPT-5.4 Mini - fastest OpenAI option for quick answers and lightweight tasks',
}
}
return { return {
value: 'haiku', value: 'haiku',
label: 'Haiku', label: 'Haiku',
@@ -190,7 +244,17 @@ function getHaiku45Option(): ModelOption {
} }
function getHaiku35Option(): ModelOption { function getHaiku35Option(): ModelOption {
const is3P = getAPIProvider() !== 'firstParty' const provider = getAPIProvider()
const is3P = provider !== 'firstParty'
if (provider === 'openai') {
return {
value: getModelStrings().haiku35,
label: 'GPT-5.4 Mini',
description: 'GPT-5.4 Mini for simple tasks',
descriptionForModel:
'GPT-5.4 Mini - lower latency OpenAI model for simple tasks',
}
}
return { return {
value: 'haiku', value: 'haiku',
label: 'Haiku', label: 'Haiku',

View File

@@ -23,6 +23,21 @@ export type ModelStrings = Record<ModelKey, string>
const MODEL_KEYS = Object.keys(ALL_MODEL_CONFIGS) as ModelKey[] const MODEL_KEYS = Object.keys(ALL_MODEL_CONFIGS) as ModelKey[]
function getBuiltinModelStrings(provider: APIProvider): ModelStrings { function getBuiltinModelStrings(provider: APIProvider): ModelStrings {
if (provider === 'openai') {
const out = getBuiltinModelStrings('firstParty') as Record<string, string>
out.haiku35 = process.env.OPENAI_HAIKU_MODEL || 'gpt-5.4-mini'
out.haiku45 = process.env.OPENAI_HAIKU_MODEL || 'gpt-5.4-mini'
out.sonnet37 = process.env.OPENAI_SONNET_MODEL || 'gpt-5.4'
out.sonnet40 = process.env.OPENAI_SONNET_MODEL || 'gpt-5.4'
out.sonnet45 = process.env.OPENAI_SONNET_MODEL || 'gpt-5.4'
out.sonnet46 = process.env.OPENAI_SONNET_MODEL || 'gpt-5.4'
out.opus40 = process.env.OPENAI_OPUS_MODEL || 'gpt-5.4'
out.opus41 = process.env.OPENAI_OPUS_MODEL || 'gpt-5.4'
out.opus45 = process.env.OPENAI_OPUS_MODEL || 'gpt-5.4'
out.opus46 = process.env.OPENAI_OPUS_MODEL || 'gpt-5.4'
return out as ModelStrings
}
const out = {} as ModelStrings const out = {} as ModelStrings
for (const key of MODEL_KEYS) { for (const key of MODEL_KEYS) {
out[key] = ALL_MODEL_CONFIGS[key][provider] out[key] = ALL_MODEL_CONFIGS[key][provider]

View File

@@ -1,15 +1,93 @@
import type { AnalyticsMetadata_I_VERIFIED_THIS_IS_NOT_CODE_OR_FILEPATHS } from '../../services/analytics/index.js' import type { AnalyticsMetadata_I_VERIFIED_THIS_IS_NOT_CODE_OR_FILEPATHS } from '../../services/analytics/index.js'
import { isEnvTruthy } from '../envUtils.js' import { isEnvTruthy } from '../envUtils.js'
export type APIProvider = 'firstParty' | 'bedrock' | 'vertex' | 'foundry' export type APIProvider =
| 'firstParty'
| 'openrouter'
| 'openai'
| 'bedrock'
| 'vertex'
| 'foundry'
function getExplicitProviderOverride(): APIProvider | null {
const rawProvider =
process.env.BETTER_CLAWD_API_PROVIDER ??
process.env.CLAUDE_CODE_API_PROVIDER
switch (rawProvider?.toLowerCase()) {
case 'anthropic':
case 'firstparty':
case 'first_party':
case 'first-party':
return 'firstParty'
case 'openrouter':
return 'openrouter'
case 'openai':
return 'openai'
case 'bedrock':
return 'bedrock'
case 'vertex':
return 'vertex'
case 'foundry':
return 'foundry'
default:
return null
}
}
export function isOpenRouterBaseUrl(baseUrl?: string | null): boolean {
if (!baseUrl) {
return false
}
try {
return new URL(baseUrl).host === 'openrouter.ai'
} catch {
return false
}
}
export function isOpenRouterConfigured(): boolean {
return (
getExplicitProviderOverride() === 'openrouter' ||
Boolean(process.env.OPENROUTER_API_KEY) ||
isOpenRouterBaseUrl(process.env.OPENROUTER_BASE_URL) ||
isOpenRouterBaseUrl(process.env.ANTHROPIC_BASE_URL)
)
}
export function isOpenAIConfigured(): boolean {
return (
getExplicitProviderOverride() === 'openai' ||
Boolean(process.env.OPENAI_API_KEY) ||
Boolean(process.env.OPENAI_BASE_URL) ||
Boolean(process.env.OPENAI_ACCESS_TOKEN) ||
Boolean(process.env.CODEX_ACCESS_TOKEN)
)
}
export function getOpenRouterBaseUrl(): string {
return process.env.OPENROUTER_BASE_URL ?? 'https://openrouter.ai/api/v1'
}
export function getOpenAIBaseUrl(): string {
return process.env.OPENAI_BASE_URL ?? 'https://api.openai.com/v1'
}
export function getAPIProvider(): APIProvider { export function getAPIProvider(): APIProvider {
const explicitProvider = getExplicitProviderOverride()
if (explicitProvider) {
return explicitProvider
}
return isEnvTruthy(process.env.CLAUDE_CODE_USE_BEDROCK) return isEnvTruthy(process.env.CLAUDE_CODE_USE_BEDROCK)
? 'bedrock' ? 'bedrock'
: isEnvTruthy(process.env.CLAUDE_CODE_USE_VERTEX) : isEnvTruthy(process.env.CLAUDE_CODE_USE_VERTEX)
? 'vertex' ? 'vertex'
: isEnvTruthy(process.env.CLAUDE_CODE_USE_FOUNDRY) : isEnvTruthy(process.env.CLAUDE_CODE_USE_FOUNDRY)
? 'foundry' ? 'foundry'
: isOpenAIConfigured()
? 'openai'
: isOpenRouterConfigured()
? 'openrouter'
: 'firstParty' : 'firstParty'
} }
@@ -17,6 +95,12 @@ export function getAPIProviderForStatsig(): AnalyticsMetadata_I_VERIFIED_THIS_IS
return getAPIProvider() as AnalyticsMetadata_I_VERIFIED_THIS_IS_NOT_CODE_OR_FILEPATHS return getAPIProvider() as AnalyticsMetadata_I_VERIFIED_THIS_IS_NOT_CODE_OR_FILEPATHS
} }
export function isAnthropicCompatibleProvider(
provider: APIProvider = getAPIProvider(),
): boolean {
return provider !== 'openai'
}
/** /**
* Check if ANTHROPIC_BASE_URL is a first-party Anthropic API URL. * Check if ANTHROPIC_BASE_URL is a first-party Anthropic API URL.
* Returns true if not set (default API) or points to api.anthropic.com * Returns true if not set (default API) or points to api.anthropic.com

View File

@@ -34,6 +34,12 @@ import {
type AnalyticsMetadata_I_VERIFIED_THIS_IS_NOT_CODE_OR_FILEPATHS, type AnalyticsMetadata_I_VERIFIED_THIS_IS_NOT_CODE_OR_FILEPATHS,
logEvent, logEvent,
} from 'src/services/analytics/index.js' } from 'src/services/analytics/index.js'
import {
CLI_BINARY_NAME,
LEGACY_CLI_BINARY_NAME,
PRODUCT_NAME,
PRODUCT_SLUG,
} from 'src/constants/product.js'
import { getMaxVersion, shouldSkipVersion } from '../autoUpdater.js' import { getMaxVersion, shouldSkipVersion } from '../autoUpdater.js'
import { registerCleanup } from '../cleanupRegistry.js' import { registerCleanup } from '../cleanupRegistry.js'
import { getGlobalConfig, saveGlobalConfig } from '../config.js' import { getGlobalConfig, saveGlobalConfig } from '../config.js'
@@ -109,24 +115,34 @@ export function getPlatform(): string {
} }
export function getBinaryName(platform: string): string { export function getBinaryName(platform: string): string {
return platform.startsWith('win32') ? 'claude.exe' : 'claude' return platform.startsWith('win32')
? `${LEGACY_CLI_BINARY_NAME}.exe`
: LEGACY_CLI_BINARY_NAME
}
export function getPreferredBinaryName(platform: string): string {
return platform.startsWith('win32')
? `${CLI_BINARY_NAME}.exe`
: CLI_BINARY_NAME
} }
function getBaseDirectories() { function getBaseDirectories() {
const platform = getPlatform() const platform = getPlatform()
const executableName = getBinaryName(platform) const executableName = getBinaryName(platform)
const preferredExecutableName = getPreferredBinaryName(platform)
return { return {
// Data directories (permanent storage) // Data directories (permanent storage)
versions: join(getXDGDataHome(), 'claude', 'versions'), versions: join(getXDGDataHome(), PRODUCT_SLUG, 'versions'),
// Cache directories (can be deleted) // Cache directories (can be deleted)
staging: join(getXDGCacheHome(), 'claude', 'staging'), staging: join(getXDGCacheHome(), PRODUCT_SLUG, 'staging'),
// State directories // State directories
locks: join(getXDGStateHome(), 'claude', 'locks'), locks: join(getXDGStateHome(), PRODUCT_SLUG, 'locks'),
// User bin // User bin. Keep the legacy `claude` alias during migration.
preferredExecutable: join(getUserBinDir(), preferredExecutableName),
executable: join(getUserBinDir(), executableName), executable: join(getUserBinDir(), executableName),
} }
} }
@@ -465,8 +481,11 @@ async function performVersionUpdate(
logForDebugging(`Version ${version} already installed, updating symlink`) logForDebugging(`Version ${version} already installed, updating symlink`)
} }
// Create direct symlink from ~/.local/bin/claude to the version binary // Create direct symlinks for the canonical Better-Clawd launcher and the
// legacy `claude` compatibility alias.
await removeDirectoryIfEmpty(getBaseDirectories().preferredExecutable)
await removeDirectoryIfEmpty(executablePath) await removeDirectoryIfEmpty(executablePath)
await updateSymlink(getBaseDirectories().preferredExecutable, installPath)
await updateSymlink(executablePath, installPath) await updateSymlink(executablePath, installPath)
// Verify the executable was actually created/updated // Verify the executable was actually created/updated
@@ -1458,7 +1477,7 @@ async function isNpmSymlink(executablePath: string): Promise<boolean> {
} }
/** /**
* Remove the claude symlink from the executable directory * Remove the Better-Clawd and legacy claude symlinks from the executable directory
* This is used when switching away from native installation * This is used when switching away from native installation
* Will only remove if it's a native binary symlink, not npm-managed JS files * Will only remove if it's a native binary symlink, not npm-managed JS files
*/ */
@@ -1467,21 +1486,29 @@ export async function removeInstalledSymlink(): Promise<void> {
try { try {
// Check if this is an npm-managed installation // Check if this is an npm-managed installation
if (await isNpmSymlink(dirs.executable)) { if (
(await isNpmSymlink(dirs.executable)) ||
(await isNpmSymlink(dirs.preferredExecutable))
) {
logForDebugging( logForDebugging(
`Skipping removal of ${dirs.executable} - appears to be npm-managed`, `Skipping removal of ${dirs.preferredExecutable} / ${dirs.executable} - appears to be npm-managed`,
) )
return return
} }
// It's a native binary symlink, safe to remove // They're native binary symlinks, safe to remove.
await unlink(dirs.executable) await Promise.allSettled([
logForDebugging(`Removed claude symlink at ${dirs.executable}`) unlink(dirs.preferredExecutable),
unlink(dirs.executable),
])
logForDebugging(
`Removed ${PRODUCT_NAME} symlinks at ${dirs.preferredExecutable} and ${dirs.executable}`,
)
} catch (error) { } catch (error) {
if (isENOENT(error)) { if (isENOENT(error)) {
return return
} }
logError(new Error(`Failed to remove claude symlink: ${error}`)) logError(new Error(`Failed to remove ${PRODUCT_NAME} symlinks: ${error}`))
} }
} }

View File

@@ -127,7 +127,7 @@ export function PreflightStep(t0) {
useEffect(t3, t4); useEffect(t3, t4);
let t5; let t5;
if ($[6] !== isChecking || $[7] !== result || $[8] !== showSpinner) { if ($[6] !== isChecking || $[7] !== result || $[8] !== showSpinner) {
t5 = isChecking && showSpinner ? <Box paddingLeft={1}><Spinner /><Text>Checking connectivity...</Text></Box> : !result?.success && !isChecking && <Box flexDirection="column" gap={1}><Text color="error">Unable to connect to Anthropic services</Text><Text color="error">{result?.error}</Text>{result?.sslHint ? <Box flexDirection="column" gap={1}><Text>{result.sslHint}</Text><Text color="suggestion">See https://code.claude.com/docs/en/network-config</Text></Box> : <Box flexDirection="column" gap={1}><Text>Please check your internet connection and network settings.</Text><Text>Note: Claude Code might not be available in your country. Check supported countries at{" "}<Text color="suggestion">https://anthropic.com/supported-countries</Text></Text></Box>}</Box>; t5 = isChecking && showSpinner ? <Box paddingLeft={1}><Spinner /><Text>Checking connectivity...</Text></Box> : !result?.success && !isChecking && <Box flexDirection="column" gap={1}><Text color="error">Unable to connect to the configured model provider</Text><Text color="error">{result?.error}</Text>{result?.sslHint ? <Box flexDirection="column" gap={1}><Text>{result.sslHint}</Text><Text color="suggestion">Review your network and proxy configuration for the selected provider.</Text></Box> : <Box flexDirection="column" gap={1}><Text>Please check your internet connection and network settings.</Text><Text>Verify that the currently selected provider is reachable from your environment.</Text></Box>}</Box>;
$[6] = isChecking; $[6] = isChecking;
$[7] = result; $[7] = result;
$[8] = showSpinner; $[8] = showSpinner;

View File

@@ -0,0 +1,3 @@
export function checkProtectedNamespace(): boolean {
return false
}

View File

@@ -26,6 +26,7 @@ import { isBareMode } from '../envUtils.js'
import { import {
CREDENTIALS_SERVICE_SUFFIX, CREDENTIALS_SERVICE_SUFFIX,
getMacOsKeychainStorageServiceName, getMacOsKeychainStorageServiceName,
getMacOsKeychainStorageServiceNames,
getUsername, getUsername,
primeKeychainCacheFromPrefetch, primeKeychainCacheFromPrefetch,
} from './macOsKeychainHelpers.js' } from './macOsKeychainHelpers.js'
@@ -72,20 +73,26 @@ export function startKeychainPrefetch(): void {
// Fire both subprocesses immediately (non-blocking). They run in parallel // Fire both subprocesses immediately (non-blocking). They run in parallel
// with each other AND with main.tsx imports. The await in Promise.all // with each other AND with main.tsx imports. The await in Promise.all
// happens later via ensureKeychainPrefetchCompleted(). // happens later via ensureKeychainPrefetchCompleted().
const oauthSpawn = spawnSecurity( const oauthSpawns = getMacOsKeychainStorageServiceNames(
getMacOsKeychainStorageServiceName(CREDENTIALS_SERVICE_SUFFIX), CREDENTIALS_SERVICE_SUFFIX,
) ).map(spawnSecurity)
const legacySpawn = spawnSecurity(getMacOsKeychainStorageServiceName()) const legacySpawns = getMacOsKeychainStorageServiceNames().map(spawnSecurity)
prefetchPromise = Promise.all([oauthSpawn, legacySpawn]).then( prefetchPromise = Promise.all([
([oauth, legacy]) => { Promise.all(oauthSpawns),
Promise.all(legacySpawns),
]).then(([oauthResults, legacyResults]) => {
const oauth = oauthResults.find(result => result.stdout) ?? oauthResults[0]
const legacy = legacyResults.find(result => result.stdout) ?? legacyResults[0]
if (!oauth || !legacy) {
return
}
// Timed-out prefetch: don't prime. Sync read/spawn will retry with its // Timed-out prefetch: don't prime. Sync read/spawn will retry with its
// own (longer) timeout. Priming null here would shadow a key that the // own (longer) timeout. Priming null here would shadow a key that the
// sync path might successfully fetch. // sync path might successfully fetch.
if (!oauth.timedOut) primeKeychainCacheFromPrefetch(oauth.stdout) if (!oauth.timedOut) primeKeychainCacheFromPrefetch(oauth.stdout)
if (!legacy.timedOut) legacyApiKeyPrefetch = { stdout: legacy.stdout } if (!legacy.timedOut) legacyApiKeyPrefetch = { stdout: legacy.stdout }
}, })
)
} }
/** /**

View File

@@ -17,6 +17,11 @@
import { createHash } from 'crypto' import { createHash } from 'crypto'
import { userInfo } from 'os' import { userInfo } from 'os'
import { getOauthConfig } from 'src/constants/oauth.js' import { getOauthConfig } from 'src/constants/oauth.js'
import {
getConfiguredProductConfigDir,
LEGACY_PRODUCT_NAME,
PRODUCT_NAME,
} from 'src/constants/product.js'
import { getClaudeConfigHomeDir } from '../envUtils.js' import { getClaudeConfigHomeDir } from '../envUtils.js'
import type { SecureStorageData } from './types.js' import type { SecureStorageData } from './types.js'
@@ -28,23 +33,35 @@ export const CREDENTIALS_SERVICE_SUFFIX = '-credentials'
export function getMacOsKeychainStorageServiceName( export function getMacOsKeychainStorageServiceName(
serviceSuffix: string = '', serviceSuffix: string = '',
opts: { legacy?: boolean } = {},
): string { ): string {
const configDir = getClaudeConfigHomeDir() const configDir = getClaudeConfigHomeDir()
const isDefaultDir = !process.env.CLAUDE_CONFIG_DIR const isDefaultDir = !getConfiguredProductConfigDir()
// Use a hash of the config dir path to create a unique but stable suffix // Use a hash of the config dir path to create a unique but stable suffix
// Only add suffix for non-default directories to maintain backwards compatibility // Only add suffix for non-default directories to maintain backwards compatibility
const dirHash = isDefaultDir const dirHash = isDefaultDir
? '' ? ''
: `-${createHash('sha256').update(configDir).digest('hex').substring(0, 8)}` : `-${createHash('sha256').update(configDir).digest('hex').substring(0, 8)}`
return `Claude Code${getOauthConfig().OAUTH_FILE_SUFFIX}${serviceSuffix}${dirHash}` const baseName = opts.legacy ? LEGACY_PRODUCT_NAME : PRODUCT_NAME
return `${baseName}${getOauthConfig().OAUTH_FILE_SUFFIX}${serviceSuffix}${dirHash}`
}
export function getMacOsKeychainStorageServiceNames(
serviceSuffix: string = '',
): string[] {
const names = [
getMacOsKeychainStorageServiceName(serviceSuffix),
getMacOsKeychainStorageServiceName(serviceSuffix, { legacy: true }),
]
return Array.from(new Set(names))
} }
export function getUsername(): string { export function getUsername(): string {
try { try {
return process.env.USER || userInfo().username return process.env.USER || userInfo().username
} catch { } catch {
return 'claude-code-user' return 'better-clawd-user'
} }
} }

View File

@@ -7,6 +7,7 @@ import {
CREDENTIALS_SERVICE_SUFFIX, CREDENTIALS_SERVICE_SUFFIX,
clearKeychainCache, clearKeychainCache,
getMacOsKeychainStorageServiceName, getMacOsKeychainStorageServiceName,
getMacOsKeychainStorageServiceNames,
getUsername, getUsername,
KEYCHAIN_CACHE_TTL_MS, KEYCHAIN_CACHE_TTL_MS,
keychainCacheState, keychainCacheState,
@@ -32,10 +33,10 @@ export const macOsKeychainStorage = {
} }
try { try {
const storageServiceName = getMacOsKeychainStorageServiceName(
CREDENTIALS_SERVICE_SUFFIX,
)
const username = getUsername() const username = getUsername()
for (const storageServiceName of getMacOsKeychainStorageServiceNames(
CREDENTIALS_SERVICE_SUFFIX,
)) {
const result = execSyncWithDefaults_DEPRECATED( const result = execSyncWithDefaults_DEPRECATED(
`security find-generic-password -a "${username}" -w -s "${storageServiceName}"`, `security find-generic-password -a "${username}" -w -s "${storageServiceName}"`,
) )
@@ -44,6 +45,7 @@ export const macOsKeychainStorage = {
keychainCacheState.cache = { data, cachedAt: Date.now() } keychainCacheState.cache = { data, cachedAt: Date.now() }
return data return data
} }
}
} catch (_e) { } catch (_e) {
// fall through // fall through
} }
@@ -161,14 +163,21 @@ export const macOsKeychainStorage = {
clearKeychainCache() clearKeychainCache()
try { try {
const storageServiceName = getMacOsKeychainStorageServiceName(
CREDENTIALS_SERVICE_SUFFIX,
)
const username = getUsername() const username = getUsername()
let deleted = false
for (const storageServiceName of getMacOsKeychainStorageServiceNames(
CREDENTIALS_SERVICE_SUFFIX,
)) {
try {
execSyncWithDefaults_DEPRECATED( execSyncWithDefaults_DEPRECATED(
`security delete-generic-password -a "${username}" -s "${storageServiceName}"`, `security delete-generic-password -a "${username}" -s "${storageServiceName}"`,
) )
return true deleted = true
} catch {
// Best-effort delete for legacy entries.
}
}
return deleted
} catch (_e) { } catch (_e) {
return false return false
} }
@@ -177,10 +186,10 @@ export const macOsKeychainStorage = {
async function doReadAsync(): Promise<SecureStorageData | null> { async function doReadAsync(): Promise<SecureStorageData | null> {
try { try {
const storageServiceName = getMacOsKeychainStorageServiceName(
CREDENTIALS_SERVICE_SUFFIX,
)
const username = getUsername() const username = getUsername()
for (const storageServiceName of getMacOsKeychainStorageServiceNames(
CREDENTIALS_SERVICE_SUFFIX,
)) {
const { stdout, code } = await execFileNoThrow( const { stdout, code } = await execFileNoThrow(
'security', 'security',
['find-generic-password', '-a', username, '-w', '-s', storageServiceName], ['find-generic-password', '-a', username, '-w', '-s', storageServiceName],
@@ -189,6 +198,7 @@ async function doReadAsync(): Promise<SecureStorageData | null> {
if (code === 0 && stdout) { if (code === 0 && stdout) {
return jsonParse(stdout.trim()) return jsonParse(stdout.trim())
} }
}
} catch (_e) { } catch (_e) {
// fall through // fall through
} }

View File

@@ -195,8 +195,7 @@ export const SOURCES = [
] as const satisfies readonly EditableSettingSource[] ] as const satisfies readonly EditableSettingSource[]
/** /**
* The JSON Schema URL for Claude Code settings * The JSON Schema URL for Better-Clawd settings.
* You can edit the contents at https://github.com/SchemaStore/schemastore/blob/master/src/schemas/json/claude-code-settings.json
*/ */
export const CLAUDE_CODE_SETTINGS_SCHEMA_URL = export const CLAUDE_CODE_SETTINGS_SCHEMA_URL =
'https://json.schemastore.org/claude-code-settings.json' 'https://raw.githubusercontent.com/x1xhlol/better-clawd/main/schemas/better-clawd-settings.schema.json'

View File

@@ -16,11 +16,11 @@ export const getManagedFilePath = memoize(function (): string {
switch (getPlatform()) { switch (getPlatform()) {
case 'macos': case 'macos':
return '/Library/Application Support/ClaudeCode' return '/Library/Application Support/BetterClawd'
case 'windows': case 'windows':
return 'C:\\Program Files\\ClaudeCode' return 'C:\\Program Files\\BetterClawd'
default: default:
return '/etc/claude-code' return '/etc/better-clawd'
} }
}) })

View File

@@ -8,8 +8,9 @@
import { homedir, userInfo } from 'os' import { homedir, userInfo } from 'os'
import { join } from 'path' import { join } from 'path'
/** macOS preference domain for Claude Code MDM profiles. */ /** macOS preference domains for Better-Clawd MDM profiles (new first, legacy fallback second). */
export const MACOS_PREFERENCE_DOMAIN = 'com.anthropic.claudecode' export const MACOS_PREFERENCE_DOMAIN = 'com.betterclawd.betterclawd'
export const LEGACY_MACOS_PREFERENCE_DOMAIN = 'com.anthropic.claudecode'
/** /**
* Windows registry key paths for Claude Code MDM policies. * Windows registry key paths for Claude Code MDM policies.
@@ -21,8 +22,12 @@ export const MACOS_PREFERENCE_DOMAIN = 'com.anthropic.claudecode'
* See: https://learn.microsoft.com/en-us/windows/win32/winprog64/shared-registry-keys * See: https://learn.microsoft.com/en-us/windows/win32/winprog64/shared-registry-keys
*/ */
export const WINDOWS_REGISTRY_KEY_PATH_HKLM = export const WINDOWS_REGISTRY_KEY_PATH_HKLM =
'HKLM\\SOFTWARE\\Policies\\ClaudeCode' 'HKLM\\SOFTWARE\\Policies\\BetterClawd'
export const WINDOWS_REGISTRY_KEY_PATH_HKCU = export const WINDOWS_REGISTRY_KEY_PATH_HKCU =
'HKCU\\SOFTWARE\\Policies\\BetterClawd'
export const LEGACY_WINDOWS_REGISTRY_KEY_PATH_HKLM =
'HKLM\\SOFTWARE\\Policies\\ClaudeCode'
export const LEGACY_WINDOWS_REGISTRY_KEY_PATH_HKCU =
'HKCU\\SOFTWARE\\Policies\\ClaudeCode' 'HKCU\\SOFTWARE\\Policies\\ClaudeCode'
/** Windows registry value name containing the JSON settings blob. */ /** Windows registry value name containing the JSON settings blob. */
@@ -51,31 +56,52 @@ export function getMacOSPlistPaths(): Array<{ path: string; label: string }> {
} }
const paths: Array<{ path: string; label: string }> = [] const paths: Array<{ path: string; label: string }> = []
const domains = [MACOS_PREFERENCE_DOMAIN, LEGACY_MACOS_PREFERENCE_DOMAIN]
if (username) { if (username) {
for (const domain of domains) {
paths.push({ paths.push({
path: `/Library/Managed Preferences/${username}/${MACOS_PREFERENCE_DOMAIN}.plist`, path: `/Library/Managed Preferences/${username}/${domain}.plist`,
label: 'per-user managed preferences', label:
domain === MACOS_PREFERENCE_DOMAIN
? 'per-user managed preferences'
: 'per-user managed preferences (legacy)',
})
}
}
for (const domain of domains) {
paths.push({
path: `/Library/Managed Preferences/${domain}.plist`,
label:
domain === MACOS_PREFERENCE_DOMAIN
? 'device-level managed preferences'
: 'device-level managed preferences (legacy)',
}) })
} }
paths.push({
path: `/Library/Managed Preferences/${MACOS_PREFERENCE_DOMAIN}.plist`,
label: 'device-level managed preferences',
})
// Allow user-writable preferences for local MDM testing in ant builds only. // Allow user-writable preferences for local MDM testing in ant builds only.
if (process.env.USER_TYPE === 'ant') { if (process.env.USER_TYPE === 'ant') {
for (const domain of domains) {
paths.push({ paths.push({
path: join( path: join(homedir(), 'Library', 'Preferences', `${domain}.plist`),
homedir(), label:
'Library', domain === MACOS_PREFERENCE_DOMAIN
'Preferences', ? 'user preferences (ant-only)'
`${MACOS_PREFERENCE_DOMAIN}.plist`, : 'user preferences (legacy, ant-only)',
),
label: 'user preferences (ant-only)',
}) })
} }
}
return paths return paths
} }
export function getWindowsRegistryKeyPaths(): {
hklm: string[]
hkcu: string[]
} {
return {
hklm: [WINDOWS_REGISTRY_KEY_PATH_HKLM, LEGACY_WINDOWS_REGISTRY_KEY_PATH_HKLM],
hkcu: [WINDOWS_REGISTRY_KEY_PATH_HKCU, LEGACY_WINDOWS_REGISTRY_KEY_PATH_HKCU],
}
}

View File

@@ -13,11 +13,10 @@ import { execFile } from 'child_process'
import { existsSync } from 'fs' import { existsSync } from 'fs'
import { import {
getMacOSPlistPaths, getMacOSPlistPaths,
getWindowsRegistryKeyPaths,
MDM_SUBPROCESS_TIMEOUT_MS, MDM_SUBPROCESS_TIMEOUT_MS,
PLUTIL_ARGS_PREFIX, PLUTIL_ARGS_PREFIX,
PLUTIL_PATH, PLUTIL_PATH,
WINDOWS_REGISTRY_KEY_PATH_HKCU,
WINDOWS_REGISTRY_KEY_PATH_HKLM,
WINDOWS_REGISTRY_VALUE_NAME, WINDOWS_REGISTRY_VALUE_NAME,
} from './constants.js' } from './constants.js'
@@ -88,24 +87,35 @@ export function fireRawRead(): Promise<RawReadResult> {
} }
if (process.platform === 'win32') { if (process.platform === 'win32') {
const [hklm, hkcu] = await Promise.all([ const registryKeys = getWindowsRegistryKeyPaths()
const [hklmResults, hkcuResults] = await Promise.all([
Promise.all(
registryKeys.hklm.map(keyPath =>
execFilePromise('reg', [ execFilePromise('reg', [
'query', 'query',
WINDOWS_REGISTRY_KEY_PATH_HKLM, keyPath,
'/v', '/v',
WINDOWS_REGISTRY_VALUE_NAME, WINDOWS_REGISTRY_VALUE_NAME,
]), ]),
),
),
Promise.all(
registryKeys.hkcu.map(keyPath =>
execFilePromise('reg', [ execFilePromise('reg', [
'query', 'query',
WINDOWS_REGISTRY_KEY_PATH_HKCU, keyPath,
'/v', '/v',
WINDOWS_REGISTRY_VALUE_NAME, WINDOWS_REGISTRY_VALUE_NAME,
]), ]),
),
),
]) ])
const hklm = hklmResults.find(result => result.code === 0)
const hkcu = hkcuResults.find(result => result.code === 0)
return { return {
plistStdouts: null, plistStdouts: null,
hklmStdout: hklm.code === 0 ? hklm.stdout : null, hklmStdout: hklm?.stdout ?? null,
hkcuStdout: hkcu.code === 0 ? hkcu.stdout : null, hkcuStdout: hkcu?.stdout ?? null,
} }
} }

View File

@@ -2,6 +2,10 @@ import { feature } from 'bun:bundle'
import mergeWith from 'lodash-es/mergeWith.js' import mergeWith from 'lodash-es/mergeWith.js'
import { dirname, join, resolve } from 'path' import { dirname, join, resolve } from 'path'
import { z } from 'zod/v4' import { z } from 'zod/v4'
import {
LEGACY_PRODUCT_CONFIG_DIRNAME,
PRODUCT_CONFIG_DIRNAME,
} from '../../constants/product.js'
import { import {
getFlagSettingsInline, getFlagSettingsInline,
getFlagSettingsPath, getFlagSettingsPath,
@@ -282,10 +286,15 @@ export function getSettingsFilePathForSource(
) )
case 'projectSettings': case 'projectSettings':
case 'localSettings': { case 'localSettings': {
return join( const root = getSettingsRootPathForSource(source)
getSettingsRootPathForSource(source), const candidates = getRelativeSettingsFilePathCandidatesForSource(source)
getRelativeSettingsFilePathForSource(source), for (const relativePath of candidates) {
) const absolutePath = join(root, relativePath)
if (getFsImplementation().existsSync(absolutePath)) {
return absolutePath
}
}
return join(root, candidates[0]!)
} }
case 'policySettings': case 'policySettings':
return getManagedSettingsFilePath() return getManagedSettingsFilePath()
@@ -298,11 +307,23 @@ export function getSettingsFilePathForSource(
export function getRelativeSettingsFilePathForSource( export function getRelativeSettingsFilePathForSource(
source: 'projectSettings' | 'localSettings', source: 'projectSettings' | 'localSettings',
): string { ): string {
return getRelativeSettingsFilePathCandidatesForSource(source)[0]!
}
function getRelativeSettingsFilePathCandidatesForSource(
source: 'projectSettings' | 'localSettings',
): string[] {
switch (source) { switch (source) {
case 'projectSettings': case 'projectSettings':
return join('.claude', 'settings.json') return [
join(PRODUCT_CONFIG_DIRNAME, 'settings.json'),
join(LEGACY_PRODUCT_CONFIG_DIRNAME, 'settings.json'),
]
case 'localSettings': case 'localSettings':
return join('.claude', 'settings.local.json') return [
join(PRODUCT_CONFIG_DIRNAME, 'settings.local.json'),
join(LEGACY_PRODUCT_CONFIG_DIRNAME, 'settings.local.json'),
]
} }
} }

View File

@@ -620,12 +620,25 @@ export const SettingsSchema = lazySchema(() =>
'these exact sources are blocked from being added as marketplaces. The check happens BEFORE ' + 'these exact sources are blocked from being added as marketplaces. The check happens BEFORE ' +
'downloading, so blocked sources never touch the filesystem.', 'downloading, so blocked sources never touch the filesystem.',
), ),
// Force a specific login method: 'claudeai' for Claude Pro/Max, 'console' for Console billing apiProvider: z
forceLoginMethod: z .enum([
.enum(['claudeai', 'console']) 'anthropic',
'openrouter',
'openai',
'bedrock',
'vertex',
'foundry',
])
.optional() .optional()
.describe( .describe(
'Force a specific login method: "claudeai" for Claude Pro/Max, "console" for Console billing', 'Preferred model provider for Better-Clawd sessions. Falls back to compatible legacy Claude/Anthropic environment variables when unset.',
),
// Force a specific login method: 'claudeai' for Claude Pro/Max, 'console' for Console billing
forceLoginMethod: z
.enum(['claudeai', 'console', 'openai', 'codex', 'openrouter'])
.optional()
.describe(
'Force a specific login method: "claudeai" for Anthropic subscriptions, "console" for Anthropic API billing, "openai"/"codex" for OpenAI auth, or "openrouter" for OpenRouter key auth.',
), ),
// Organization UUID to use for OAuth login (will be added as URL param to authorization URL) // Organization UUID to use for OAuth login (will be added as URL param to authorization URL)
forceLoginOrgUUID: z forceLoginOrgUUID: z

View File

@@ -242,6 +242,8 @@ export function buildAPIProviderProperties(): Property[] {
const properties: Property[] = []; const properties: Property[] = [];
if (apiProvider !== 'firstParty') { if (apiProvider !== 'firstParty') {
const providerLabel = { const providerLabel = {
openrouter: 'OpenRouter',
openai: 'OpenAI',
bedrock: 'AWS Bedrock', bedrock: 'AWS Bedrock',
vertex: 'Google Vertex AI', vertex: 'Google Vertex AI',
foundry: 'Microsoft Foundry' foundry: 'Microsoft Foundry'
@@ -259,6 +261,19 @@ export function buildAPIProviderProperties(): Property[] {
value: anthropicBaseUrl value: anthropicBaseUrl
}); });
} }
} else if (apiProvider === 'openrouter') {
properties.push({
label: 'OpenRouter base URL',
value:
process.env.OPENROUTER_BASE_URL ||
process.env.ANTHROPIC_BASE_URL ||
'https://openrouter.ai/api/v1'
});
} else if (apiProvider === 'openai') {
properties.push({
label: 'OpenAI base URL',
value: process.env.OPENAI_BASE_URL || 'https://api.openai.com/v1'
});
} else if (apiProvider === 'bedrock') { } else if (apiProvider === 'bedrock') {
const bedrockBaseUrl = process.env.BEDROCK_BASE_URL; const bedrockBaseUrl = process.env.BEDROCK_BASE_URL;
if (bedrockBaseUrl) { if (bedrockBaseUrl) {

View File

@@ -44,18 +44,11 @@ export class BigQueryMetricsExporter implements PushMetricExporter {
private isShutdown = false private isShutdown = false
constructor(options: { timeout?: number } = {}) { constructor(options: { timeout?: number } = {}) {
const defaultEndpoint = 'https://api.anthropic.com/api/claude_code/metrics' const configuredEndpoint =
process.env.BETTER_CLAWD_METRICS_ENDPOINT ??
process.env.CLAUDE_CODE_METRICS_ENDPOINT
if ( this.endpoint = configuredEndpoint ?? ''
process.env.USER_TYPE === 'ant' &&
process.env.ANT_CLAUDE_CODE_METRICS_ENDPOINT
) {
this.endpoint =
process.env.ANT_CLAUDE_CODE_METRICS_ENDPOINT +
'/api/claude_code/metrics'
} else {
this.endpoint = defaultEndpoint
}
this.timeout = options.timeout || 5000 this.timeout = options.timeout || 5000
} }
@@ -111,6 +104,14 @@ export class BigQueryMetricsExporter implements PushMetricExporter {
const payload = this.transformMetricsForInternal(metrics) const payload = this.transformMetricsForInternal(metrics)
if (!this.endpoint) {
logForDebugging(
'BigQuery metrics export disabled: no Better-Clawd metrics endpoint configured',
)
resultCallback({ code: ExportResultCode.SUCCESS })
return
}
const authResult = getAuthHeaders() const authResult = getAuthHeaders()
if (authResult.error) { if (authResult.error) {
logForDebugging(`Metrics export failed: ${authResult.error}`) logForDebugging(`Metrics export failed: ${authResult.error}`)
@@ -153,7 +154,7 @@ export class BigQueryMetricsExporter implements PushMetricExporter {
const attrs = metrics.resource.attributes const attrs = metrics.resource.attributes
const resourceAttributes: Record<string, string> = { const resourceAttributes: Record<string, string> = {
'service.name': (attrs['service.name'] as string) || 'claude-code', 'service.name': (attrs['service.name'] as string) || 'better-clawd',
'service.version': (attrs['service.version'] as string) || 'unknown', 'service.version': (attrs['service.version'] as string) || 'unknown',
'os.type': (attrs['os.type'] as string) || 'unknown', 'os.type': (attrs['os.type'] as string) || 'unknown',
'os.version': (attrs['os.version'] as string) || 'unknown', 'os.version': (attrs['os.version'] as string) || 'unknown',

Some files were not shown because too many files have changed in this diff Show More