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

View File

@@ -70,13 +70,13 @@ export async function isBridgeEnabledBlocking(): Promise<boolean> {
export async function getBridgeDisabledReason(): Promise<string | null> {
if (feature('BRIDGE_MODE')) {
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()) {
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) {
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'))) {
return 'Remote Control is not yet enabled for your account.'

View File

@@ -1918,12 +1918,12 @@ async function printHelp(): Promise<void> {
`
: ''
const help = `
Remote Control - Connect your local environment to claude.ai/code
Remote Control - Connect your local environment to a remote bridge
USAGE
claude remote-control [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')
? ` -c, --continue Resume the last session in this directory
@@ -1938,13 +1938,13 @@ ${
-h, --help Show this help
${serverOptions}
DESCRIPTION
Remote Control allows you to control sessions on your local device from
claude.ai/code (https://claude.ai/code). Run this command in the
directory you want to work in, then connect from the Claude app or web.
Remote Control allows you to control sessions on your local device from a
compatible remote bridge. Run this command in the directory you want to
work in, then connect from your configured bridge client.
${serverDescription}
NOTES
- You must be logged in with a Claude account that has a subscription
- Run \`claude\` first in the directory to accept the workspace trust dialog
- You must be logged in with an account that supports your configured bridge
- Run \`better-clawd\` first in the directory to accept the workspace trust dialog
${serverNote}`
// biome-ignore lint/suspicious/noConsole: intentional help output
console.log(help)
@@ -2122,7 +2122,7 @@ export async function bridgeMain(args: string[]): Promise<void> {
})
// biome-ignore lint/suspicious/noConsole:: intentional console output
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 => {
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. */
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. */
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) {
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 {

View File

@@ -83,7 +83,7 @@ export async function autoModeCritiqueHandler(options: {
process.stdout.write(
'No custom auto mode rules found.\n\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
}

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 { logEvent } from 'src/services/analytics/index.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 { PRODUCT_NAME, PRODUCT_URL } from 'src/constants/product.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 { gte } from 'src/utils/semver.js'
import { getInitialSettings } from 'src/utils/settings/settings.js'
export async function update() {
logEvent('tengu_update_check', {})
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(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(
chalk.yellow(
`Updating the ${runningType} installation you are currently using`,
) + '\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('\n')
writeToStdout(
`New version available: ${latestVersion} (current: ${MACRO.VERSION})\n`,
chalk.yellow(
`${PRODUCT_NAME} no longer uses the upstream Anthropic auto-update service.\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(
`Attempting ${updateMethodName} update based on file detection...\n`,
)
break
}
default:
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
}
writeToStdout(
'Install new builds manually from the project releases or by reinstalling from the Better-Clawd repository.\n',
)
writeToStdout('\n')
writeToStdout(`Project: ${PRODUCT_URL}\n`)
writeToStdout(`Releases: ${PRODUCT_URL}/releases\n`)
await gracefulShutdown(0)
}

View File

@@ -212,7 +212,7 @@ function ClaudeInChromeMenu(t0) {
}
let t8;
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;
$[24] = t8;
} else {

View File

@@ -22,7 +22,7 @@ Usage notes:
\`\`\`
# 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.
@@ -125,7 +125,7 @@ Prefix the file with:
\`\`\`
# 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.

View File

@@ -26,14 +26,14 @@ export function InstallAppStep(t0) {
useKeybinding("confirm:yes", onSubmit, t1);
let t2;
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;
} else {
t2 = $[1];
}
let t3;
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;
} else {
t3 = $[2];

View File

@@ -59,7 +59,7 @@ export function SuccessStep(t0) {
}
let t7;
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;
$[12] = t7;
} else {

View File

@@ -44,11 +44,11 @@ function getInstallationPath(): string {
const homeDir = homedir();
if (isWindows) {
// 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
return windowsPath.replace(/\//g, '\\');
}
return '~/.local/bin/claude';
return '~/.local/bin/better-clawd';
}
function SetupNotes(t0) {
const $ = _c(5);
@@ -113,7 +113,7 @@ function Install({
// Check specifically for lock failure
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
@@ -210,12 +210,12 @@ function Install({
useEffect(() => {
if (state.type === 'success') {
// 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
});
} else if (state.type === 'error') {
// 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
});
}
@@ -226,7 +226,7 @@ function Install({
{state.type === 'cleaning-npm' && <Text color="warning">Cleaning up old npm installations...</Text>}
{state.type === 'installing' && <Text color="claude">
Installing Claude Code native build {state.version}...
Installing Better-Clawd native build {state.version}...
</Text>}
{state.type === 'setting-up' && <Text color="claude">Setting up launcher and shell integration...</Text>}
@@ -237,7 +237,7 @@ function Install({
<Box>
<StatusIcon status="success" withSpace />
<Text color="success" bold>
Claude Code successfully installed!
Better-Clawd successfully installed!
</Text>
</Box>
<Box marginLeft={2} flexDirection="column" gap={1}>
@@ -254,7 +254,7 @@ function Install({
<Box marginTop={1}>
<Text dimColor>Next: Run </Text>
<Text color="claude" bold>
claude --help
better-clawd --help
</Text>
<Text dimColor> to get started</Text>
</Box>
@@ -279,7 +279,7 @@ function Install({
export const install = {
type: 'local-jsx' as const,
name: 'install',
description: 'Install Claude Code native build',
description: 'Install Better-Clawd native build',
argumentHint: '[options]',
async call(onDone: (result: string, options?: {
display?: CommandResultDisplay;

View File

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

View File

@@ -241,7 +241,7 @@ export function ConsoleOAuthFlow({
state: 'success'
});
void sendNotification({
message: 'Claude Code login successful',
message: 'Better-Clawd login successful',
notificationType: 'auth_success'
}, terminal);
}
@@ -364,7 +364,7 @@ function OAuthStatusMessage(t0) {
switch (oauthStatus.state) {
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;
if ($[0] !== t1) {
t2 = <Text bold={true}>{t1}</Text>;
@@ -460,7 +460,7 @@ function OAuthStatusMessage(t0) {
let t2;
let t3;
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>;
$[13] = t2;
$[14] = t3;
@@ -554,7 +554,7 @@ function OAuthStatusMessage(t0) {
{
let t1;
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;
} else {
t1 = $[37];

View File

@@ -1,4 +1,3 @@
import axios from 'axios';
import { readFile, stat } from 'fs/promises';
import * as React 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 { startsWithApiErrorPrefix } from '../services/api/errors.js';
import type { Message } from '../types/message.js';
import { checkAndRefreshOAuthTokenIfNeeded } from '../utils/auth.js';
import { openBrowser } from '../utils/browser.js';
import { PRODUCT_ISSUES_URL, PRODUCT_NAME } from '../constants/product.js';
import { logForDebugging } from '../utils/debug.js';
import { env } from '../utils/env.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 { isEssentialTrafficOnly } from '../utils/privacyLevel.js';
import { extractTeammateTranscriptsFromTasks, getTranscriptPath, loadAllSubagentTranscriptsFromDisk, MAX_TRANSCRIPT_READ_BYTES } from '../utils/sessionStorage.js';
import { jsonStringify } from '../utils/slowOperations.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
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 = {
abortSignal: AbortSignal;
messages: Message[];
@@ -231,7 +228,6 @@ export function Feedback({
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
});
// 1P-only: freeform text approved for BQ. Join on feedback_id.
logEventTo1P('tengu_bug_report_description', {
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
@@ -239,11 +235,7 @@ export function Feedback({
}
setStep('done');
} else {
if (result.isZdrOrg) {
setError('Feedback collection is not available for organizations with custom data retention policies.');
} else {
setError('Could not submit feedback. Please try again later.');
}
setError('Could not prepare the issue draft. Please try again later.');
// Stay on userInput step so user can retry with their content preserved
setStep('userInput');
}
@@ -334,7 +326,7 @@ export function Feedback({
</Box>}
{step === 'consent' && <Box flexDirection="column">
<Text>This report will include:</Text>
<Text>This issue draft will include:</Text>
<Box marginLeft={2} flexDirection="column">
<Text>
- Your feedback / bug description:{' '}
@@ -360,24 +352,24 @@ export function Feedback({
</Box>
<Box marginTop={1}>
<Text wrap="wrap" dimColor>
We will use your feedback to debug related issues or to improve{' '}
Claude Code&apos;s functionality (eg. to reduce the risk of bugs
occurring in the future).
Better-Clawd no longer uploads bug reports to an upstream service.
Press Enter to prepare a GitHub issue draft with the details shown
above.
</Text>
</Box>
<Box marginTop={1}>
<Text>
Press <Text bold>Enter</Text> to confirm and submit.
Press <Text bold>Enter</Text> to continue.
</Text>
</Box>
</Box>}
{step === 'submitting' && <Box flexDirection="row" gap={1}>
<Text>Submitting report</Text>
<Text>Preparing issue draft</Text>
</Box>}
{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>}
<Box marginTop={1}>
<Text>Press </Text>
@@ -396,7 +388,7 @@ export function createGitHubIssueUrl(feedbackId: string, title: string, descript
}>): string {
const sanitizedTitle = redactSensitiveInfo(title);
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 errorsJson = jsonStringify(errors);
const baseUrl = `${GITHUB_ISSUES_REPO_URL}/new?title=${encodeURIComponent(sanitizedTitle)}&labels=user-reported,bug&body=`;
@@ -446,8 +438,8 @@ export function createGitHubIssueUrl(feedbackId: string, title: string, descript
}
async function generateTitle(description: string, abortSignal: AbortSignal): Promise<string> {
try {
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"']),
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 ${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,
signal: abortSignal,
options: {
@@ -520,69 +512,14 @@ async function submitFeedback(data: FeedbackData, signal?: AbortSignal): Promise
feedbackId?: string;
isZdrOrg?: boolean;
}> {
if (isEssentialTrafficOnly()) {
return {
success: false
};
}
try {
// Ensure OAuth token is fresh before getting auth headers
// This prevents 401 errors from stale cached tokens
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 {
success: true,
feedbackId: result.feedback_id
};
}
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));
void signal;
void data;
return {
success: false
success: true,
feedbackId: `better-clawd-${Date.now().toString(36)}`
};
} 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);
return {
success: false

View File

@@ -138,7 +138,7 @@ export function HelpV2(t0) {
const t5 = insideModal ? undefined : maxHeight;
let t6;
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;
$[32] = t6;
} else {

View File

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

View File

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

View File

@@ -9,7 +9,7 @@ export function WelcomeV2() {
if (env.terminal === "Apple_Terminal") {
let t0;
if ($[0] !== theme) {
t0 = <AppleTerminalWelcomeV2 theme={theme} welcomeMessage="Welcome to Claude Code" />;
t0 = <AppleTerminalWelcomeV2 theme={theme} welcomeMessage="Welcome to Better-Clawd" />;
$[0] = theme;
$[1] = t0;
} else {
@@ -28,7 +28,7 @@ export function WelcomeV2() {
let t7;
let t8;
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>;
t2 = <Text>{" "}</Text>;
t3 = <Text>{" "}</Text>;
@@ -113,7 +113,7 @@ export function WelcomeV2() {
let t5;
let t6;
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>;
t2 = <Text>{" "}</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
// death spiral (observed: 59 GB RSS, 14k mmap/munmap/sec). Content dropped
// from this slice has already been printed to terminal scrollback — users
// can still scroll up natively. VirtualMessageList (the default ant path)
// bypasses this cap entirely. Headless one-shot renders (e.g. /export)
// pass disableRenderCap to opt out — they have no scrollback and the
// memory concern doesn't apply to renderToString.
// can still scroll up natively. Better-Clawd's default external path is the
// main-screen renderer (no VirtualMessageList), so keep the live window
// smaller to reduce typing-time diff/write work in long sessions. Headless
// 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
// 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
// uuid vanishes, falling back to the stored index (clamped) keeps the
// 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.
const MAX_MESSAGES_WITHOUT_VIRTUALIZATION = 200;
const MAX_MESSAGES_WITHOUT_VIRTUALIZATION = 120;
const MESSAGE_CAP_STEP = 50;
export type SliceAnchor = {
uuid: string;
@@ -500,7 +501,7 @@ const MessagesImpl = ({
// CC-724: drop attachment messages that AttachmentMessage renders as
// null (hook_success, hook_additional_context, hook_cancelled, etc.)
// 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);
// Three-tier filtering. Transcript mode (ctrl+o screen) is truly unfiltered.
// 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:
// 1. onOpenRateLimitOptions callback is recreated (doesn't affect render output)
// 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 {
if (a.size !== b.size) return false;
for (const item of a) {
@@ -738,6 +740,12 @@ function setsEqual<T>(a: Set<T>, b: Set<T>): boolean {
}
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) => {
const keys = Object.keys(prev) as (keyof typeof prev)[];
for (const key of keys) {
@@ -769,6 +777,16 @@ export const Messages = React.memo(MessagesImpl, (prev, next) => {
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
// (no special handling needed, default behavior is correct)
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) {
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;
if ($[3] !== verbose) {
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
// resurrect when the same pill reappears (new task starts → focus stolen).
const rawFooterSelection = useAppState(s => s.footerSelection);
const footerSelectionRef = useRef<FooterItem | null>(null);
footerSelectionRef.current = rawFooterSelection;
const footerItemSelected = rawFooterSelection && footerItems.includes(rawFooterSelection) ? rawFooterSelection : null;
useEffect(() => {
if (rawFooterSelection && !footerItemSelected) {
@@ -473,6 +475,15 @@ function PromptInput({
});
}
}, [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 tmuxSelected = footerItemSelected === 'tmux';
const bagelSelected = footerItemSelected === 'bagel';
@@ -892,13 +903,11 @@ function PromptInput({
pushToBuffer(input, cursorOffset, pastedContents);
}
// Deselect footer items when user types
setAppState(prev => prev.footerSelection === null ? prev : {
...prev,
footerSelection: null
});
// Deselect footer items when user types, but skip the store write when
// nothing is selected so routine keystrokes stay inside the input subtree.
clearFooterSelectionIfNeeded();
trackAndSetInput(processedValue);
}, [trackAndSetInput, onModeChange, input, cursorOffset, pushToBuffer, pastedContents, dismissStashHint, setAppState]);
}, [trackAndSetInput, onModeChange, input, cursorOffset, pushToBuffer, pastedContents, dismissStashHint, clearFooterSelectionIfNeeded]);
const {
resetHistory,
onHistoryUp,

View File

@@ -45,9 +45,9 @@ export function RemoteCallout({
<Box flexDirection="column" paddingX={2} paddingY={1}>
<Box marginBottom={1} flexDirection="column">
<Text>
Remote Control lets you access this CLI session from the web
(claude.ai/code) or the Claude app, so you can pick up where you
left off on any device.
Remote Control lets you access this CLI session from a compatible
remote bridge so you can pick up where you left off on another
device.
</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 { LoadingState } from './design-system/LoadingState.js';
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 = {
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
export const CLAUDE_AI_BASE_URL = 'https://claude.ai'
export const CLAUDE_AI_STAGING_BASE_URL = 'https://claude-ai.staging.ant.dev'
export const CLAUDE_AI_LOCAL_BASE_URL = 'http://localhost:4000'
// Anthropic web URLs are kept for the Anthropic provider and remote sessions.
export const ANTHROPIC_APP_BASE_URL = 'https://claude.ai'
export const ANTHROPIC_APP_STAGING_BASE_URL =
'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.
@@ -41,12 +67,12 @@ export function getClaudeAiBaseUrl(
ingressUrl?: string,
): string {
if (isRemoteSessionLocal(sessionId, ingressUrl)) {
return CLAUDE_AI_LOCAL_BASE_URL
return ANTHROPIC_APP_LOCAL_BASE_URL
}
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.`,
process.env.USER_TYPE === 'ant' && isUndercover()
? 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()
? 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.`,

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')) {
// MACRO.VERSION is inlined at build time
// biome-ignore lint/suspicious/noConsole:: intentional console output
console.log(`${MACRO.VERSION} (Claude Code)`);
console.log(`${MACRO.VERSION} (Better-Clawd)`);
return;
}

View File

@@ -1,11 +1,8 @@
import { profileCheckpoint } from '../utils/startupProfiler.js'
import '../bootstrap/state.js'
import '../utils/config.js'
import type { Attributes, MetricOptions } from '@opentelemetry/api'
import memoize from 'lodash-es/memoize.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 { populateOAuthAccountInfoIfNeeded } from '../services/oauth/client.js'
import {
@@ -15,7 +12,6 @@ import {
import {
initializeRemoteManagedSettingsLoadingPromise,
isEligibleForRemoteManagedSettings,
waitForRemoteManagedSettingsToLoad,
} from '../services/remoteManagedSettings/index.js'
import { preconnectAnthropicApi } from '../utils/apiPreconnect.js'
import { applyExtraCACertsFromConfig } from '../utils/caCertsConfig.js'
@@ -26,34 +22,21 @@ import { detectCurrentRepository } from '../utils/detectRepository.js'
import { logForDiagnosticsNoPII } from '../utils/diagLogs.js'
import { initJetBrainsDetection } from '../utils/envDynamic.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
import {
gracefulShutdownSync,
setupGracefulShutdown,
} from '../utils/gracefulShutdown.js'
import {
applyConfigEnvironmentVariables,
applySafeConfigEnvironmentVariables,
} from '../utils/managedEnv.js'
import { applySafeConfigEnvironmentVariables } from '../utils/managedEnv.js'
import { configureGlobalMTLS } from '../utils/mtls.js'
import {
ensureScratchpadDir,
isScratchpadEnabled,
} 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 { isBetaTracingEnabled } from '../utils/telemetry/betaSessionTracing.js'
import { getTelemetryAttributes } from '../utils/telemetryAttributes.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> => {
const initStartTime = Date.now()
logForDiagnosticsNoPII('info', 'init_started')
@@ -87,22 +70,7 @@ export const init = memoize(async (): Promise<void> => {
setupGracefulShutdown()
profileCheckpoint('init_after_graceful_shutdown')
// Initialize 1P event logging (no security concerns, but deferred to avoid
// 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()
})
})
// Better-Clawd disables outbound telemetry, so the 1P event logger stays off.
profileCheckpoint('init_after_1p_event_logging')
// 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.
*/
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
}
// 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)
}
return
}

View File

@@ -1341,7 +1341,7 @@ export const SDKRateLimitInfoSchema = lazySchema(() =>
isUsingOverage: z.boolean().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(() =>

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 { isEnvTruthy } from 'src/utils/envUtils.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() {
useStartupNotification(_temp);
}

View File

@@ -24,7 +24,7 @@ async function _temp() {
if (true && !isClaudeAISubscriber()) {
return {
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",
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
// commander runs. Reaching here means host was missing or the
// 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);
});
}
@@ -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
// Commander.js runs, so this registration exists only for help output.
// Always hidden: isBridgeEnabled() at this point (before enableConfigs)
@@ -4322,7 +4322,7 @@ async function run(): Promise<CommanderCommand> {
if (feature('BRIDGE_MODE')) {
program.command('remote-control', {
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.
// If somehow reached, delegate to bridgeMain.
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
// cause useEffect dependency changes and infinite re-render loops.
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
// 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
// don't accidentally dismiss or answer a permission prompt the user hasn't read yet.
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);
useEffect(() => {
if (autoUpdaterResult?.notifications) {
@@ -1359,16 +1369,16 @@ export function REPL({
// block's `=== ''` guard — see the fresh value, not the stale render.
inputValueRef.current = value;
setInputValueRaw(value);
setIsPromptInputActive(value.trim().length > 0);
}, [setIsPromptInputActive, repinScroll, trySuggestBgPRIntercept]);
setPromptInputActive(value.trim().length > 0);
}, [setPromptInputActive, repinScroll, trySuggestBgPRIntercept]);
// Schedule a timeout to stop suppressing dialogs after the user stops typing.
// Only manages the timeout — the immediate activation is handled by setInputValue above.
useEffect(() => {
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);
}, [inputValue]);
}, [inputValue, setPromptInputActive]);
const [inputMode, setInputMode] = useState<PromptInputMode>('prompt');
const [stashedPrompt, setStashedPrompt] = useState<{
text: string;
@@ -4399,7 +4409,7 @@ export function REPL({
// and transcript-mode are mutually exclusive (this early return), so
// only one ScrollBox is ever mounted at a time.
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%">
{toolJSX.jsx}
</Box>;
@@ -4507,6 +4517,7 @@ export function REPL({
// When viewing an agent, never fall through to leader — empty until
// bootstrap/stream fills. Closes the see-leader-type-agent footgun.
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
// displayedMessages. userInputOnProcessing stays set for the whole turn
// (cleared in resetLoadingState); this length check hides it once
@@ -4567,7 +4578,7 @@ export function REPL({
jumpToNew(scrollRef.current);
}} scrollable={<>
<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 />
{/* Hide the processing placeholder while a modal is showing —
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 { 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 = {
[eventName: string]: {
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 {
return getDynamicConfig_CACHED_MAY_BE_STALE<EventSamplingConfig>(
EVENT_SAMPLING_CONFIG_NAME,
{},
)
return {}
}
/**
* 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
}
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
export function shouldSampleEvent(_eventName: string): number | null {
return null
}
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> {
if (!firstPartyEventLoggerProvider) {
return
}
try {
await firstPartyEventLoggerProvider.shutdown()
if (process.env.USER_TYPE === 'ant') {
logForDebugging('1P event logging: final shutdown complete')
}
} catch {
// Ignore shutdown errors
}
return
}
/**
* 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 {
// Respect standard analytics opt-outs
return !isAnalyticsDisabled()
return false
}
/**
* 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(
eventName: string,
metadata: Record<string, number | boolean | undefined> = {},
): void {
if (!is1PEventLoggingEnabled()) {
return
}
_eventName: string,
_metadata: Record<string, number | boolean | undefined> = {},
): void {}
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 = {
experimentId: string
variationId: number
@@ -239,211 +34,12 @@ export type GrowthBookExperimentData = {
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(
data: GrowthBookExperimentData,
): void {
if (!is1PEventLoggingEnabled()) {
return
}
_data: GrowthBookExperimentData,
): void {}
if (!firstPartyEventLogger || isSinkKilled('firstParty')) {
return
}
export function initialize1PEventLogging(): void {}
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> {
if (!is1PEventLoggingEnabled() || !firstPartyEventLoggerProvider) {
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(() => {})
return
}

View File

@@ -1,114 +1,14 @@
/**
* 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 { attachAnalyticsSink } from './index.js'
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 }
const DATADOG_GATE_NAME = 'tengu_log_datadog_events'
function dropEvent(_eventName: string, _metadata: LogEventMetadata): void {}
// Module-level gate state - starts undefined, initialized during startup
let isDatadogGateEnabled: boolean | undefined = undefined
export function initializeAnalyticsGates(): void {}
/**
* 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 {
attachAnalyticsSink({
logEvent: logEventImpl,
logEventAsync: logEventAsyncImpl,
logEvent: dropEvent,
logEventAsync: async (_eventName, _metadata) => {},
})
}

View File

@@ -6,7 +6,10 @@ import {
getAnthropicApiKey,
getApiKeyFromApiKeyHelper,
getClaudeAIOAuthTokens,
getOpenAIApiKey,
getOpenRouterApiKey,
isClaudeAISubscriber,
refreshOpenAIAuthTokenIfNeeded,
refreshAndGetAwsCredentials,
refreshGcpCredentialsIfNeeded,
} 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 {
getAPIProvider,
getOpenAIBaseUrl,
getOpenRouterBaseUrl,
isFirstPartyAnthropicBaseUrl,
} from 'src/utils/model/providers.js'
import { getProxyFetchOptions } from 'src/utils/proxy.js'
import { OpenAIResponsesCompatClient } from './openaiCompat.js'
import {
getIsNonInteractiveSession,
getSessionId,
@@ -98,6 +104,7 @@ export async function getAnthropicClient({
fetchOverride?: ClientOptions['fetch']
source?: string
}): Promise<Anthropic> {
const provider = getAPIProvider()
const containerId = process.env.CLAUDE_CODE_CONTAINER_ID
const remoteSessionId = process.env.CLAUDE_CODE_REMOTE_SESSION_ID
const clientApp = process.env.CLAUDE_AGENT_SDK_CLIENT_APP
@@ -150,7 +157,7 @@ export async function getAnthropicClient({
fetch: resolvedFetch,
}),
}
if (isEnvTruthy(process.env.CLAUDE_CODE_USE_BEDROCK)) {
if (provider === 'bedrock') {
const { AnthropicBedrock } = await import('@anthropic-ai/bedrock-sdk')
// Use region override for small fast model if specified
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
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')
// Determine Azure AD token provider based on configuration
// 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
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
// This is similar to how we handle AWS credential refresh for Bedrock
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
}
// 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] = {
apiKey: isClaudeAISubscriber() ? null : apiKey || getAnthropicApiKey(),
authToken: isClaudeAISubscriber()

View File

@@ -343,13 +343,13 @@ export async function checkGroveForNonInteractive(): Promise<void> {
if (config === null || config.notice_is_grace_period) {
// Grace period is still active - show informational message and continue
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()
} else {
// Grace period has ended - show error message and exit
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)
}

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) {
// Only show the error notification for manual /compact.
// Auto-compact failures are retried on the next turn and the
// notification is confusing when compaction eventually succeeds.
// Auto-compact failures are retried silently until the session-level
// circuit breaker trips, and a user-facing notification here would be
// noisy for failures that recover on a later turn.
if (!isAutoCompact) {
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 {
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):']
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: "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
@@ -227,13 +227,13 @@ Generate a fresh lowercase UUID for \`events[].data.uuid\` yourself.
## 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}
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
@@ -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.)
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.
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.
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:
@@ -311,13 +311,13 @@ Minimum interval is 1 hour. \`*/30 * * * *\` will be rejected.
## 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
- 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)
- 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
${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"}.` : ''}
- 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 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.` : ''}`
}
@@ -327,7 +327,7 @@ export function registerScheduleRemoteAgentsSkill(): void {
description:
'Create, update, list, or run scheduled remote agents (triggers) that execute on a cron schedule.',
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,
isEnabled: () =>
getFeatureValue_CACHED_MAY_BE_STALE('tengu_surreal_dali', false) &&
@@ -338,7 +338,7 @@ export function registerScheduleRemoteAgentsSkill(): void {
return [
{
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 [
{
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 [
{
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,
)
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.`
: `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.`
? `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.`
: `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)
}
}
@@ -417,7 +417,7 @@ export function registerScheduleRemoteAgentsSkill(): void {
)
if (connectors.length === 0) {
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 {
switch (error.type) {
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':
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':
return 'Background tasks require a git repository. Initialize git or run from a git repository.';
case 'no_git_remote':
return 'Background tasks require a GitHub remote. Add one with `git remote add origin REPO_URL`.';
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':
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
if (!accessToken) {
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()

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 { getClientType } from '../bootstrap/state.js'
import {
PRODUCT_NAME,
PRODUCT_NOREPLY_EMAIL,
getRemoteSessionUrl,
isRemoteSessionLocal,
PRODUCT_URL,
@@ -69,15 +71,15 @@ export function getAttributionTexts(): AttributionTexts {
// @[MODEL LAUNCH]: Update the hardcoded fallback model name below (guards against codename leaks).
// 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 isKnownPublicModel = getPublicModelDisplayName(model) !== null
const modelName =
isInternalModelRepoCached() || isKnownPublicModel
? getPublicModelName(model)
: 'Claude Opus 4.6'
const defaultAttribution = `🤖 Generated with [Claude Code](${PRODUCT_URL})`
const defaultCommit = `Co-Authored-By: ${modelName} <noreply@anthropic.com>`
: `${PRODUCT_NAME} Assistant`
const defaultAttribution = `🤖 Generated with [${PRODUCT_NAME}](${PRODUCT_URL})`
const defaultCommit = `Co-Authored-By: ${modelName} <${PRODUCT_NOREPLY_EMAIL}>`
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:
* - 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 short model name (e.g., claude-opus-4-5)
* - Returns default attribution if stats can't be computed
@@ -325,7 +327,7 @@ export async function getEnhancedPRAttribution(
return ''
}
const defaultAttribution = `🤖 Generated with [Claude Code](${PRODUCT_URL})`
const defaultAttribution = `🤖 Generated with [${PRODUCT_NAME}](${PRODUCT_URL})`
// Get AppState first
const appState = getAppState()
@@ -366,12 +368,12 @@ export async function getEnhancedPRAttribution(
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 =
memoryAccessCount > 0
? `, ${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
// (INTERNAL_MODEL_REPOS) and only in builds with COMMIT_ATTRIBUTION enabled —

View File

@@ -1,9 +1,14 @@
import chalk from 'chalk'
import { exec } from 'child_process'
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 { homedir } from 'os'
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 {
type AnalyticsMetadata_I_VERIFIED_THIS_IS_NOT_CODE_OR_FILEPATHS,
@@ -67,6 +72,7 @@ import {
import {
clearKeychainCache,
getMacOsKeychainStorageServiceName,
getMacOsKeychainStorageServiceNames,
getUsername,
} from './secureStorage/macOsKeychainHelpers.js'
import {
@@ -101,6 +107,10 @@ export function isAnthropicAuthEnabled(): boolean {
// --bare: API-key-only, never OAuth.
if (isBareMode()) return false
if (getAPIProvider() !== 'firstParty') {
return false
}
// `claude ssh` remote: ANTHROPIC_UNIX_SOCKET tunnels API calls through 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
@@ -161,6 +171,10 @@ export function getAuthTokenSource() {
return { source: 'none' as const, hasToken: false }
}
if (getAPIProvider() !== 'firstParty') {
return { source: 'none' as const, hasToken: false }
}
if (process.env.ANTHROPIC_AUTH_TOKEN && !isManagedOAuthContext()) {
return { source: 'ANTHROPIC_AUTH_TOKEN' as const, hasToken: true }
}
@@ -211,6 +225,20 @@ export type ApiKeySource =
| '/login managed key'
| '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 {
const { key } = getAnthropicApiKeyWithSource()
return key
@@ -223,6 +251,194 @@ export function hasAnthropicApiKeyAuth(): boolean {
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(
opts: { skipRetrievingKeyFromApiKeyHelper?: boolean } = {},
): {
@@ -1063,13 +1279,14 @@ export const getApiKeyFromConfigOrMacOSKeychain = memoize(
}
// Prefetch completed with no key — fall through to config, not keychain.
} else {
const storageServiceName = getMacOsKeychainStorageServiceName()
try {
const result = execSyncWithDefaults_DEPRECATED(
`security find-generic-password -a $USER -w -s "${storageServiceName}"`,
)
if (result) {
return { key: result, source: '/login managed key' }
for (const storageServiceName of getMacOsKeychainStorageServiceNames()) {
const result = execSyncWithDefaults_DEPRECATED(
`security find-generic-password -a $USER -w -s "${storageServiceName}"`,
)
if (result) {
return { key: result, source: '/login managed key' }
}
}
} catch (e) {
logError(e)
@@ -1159,6 +1376,36 @@ export async function saveApiKey(apiKey: string): Promise<void> {
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 {
const config = getGlobalConfig()
const normalizedKey = normalizeApiKeyForConfig(apiKey)
@@ -1182,6 +1429,24 @@ export async function removeApiKey(): Promise<void> {
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> {
try {
await maybeRemoveApiKeyFromMacOSKeychainThrows()
@@ -1716,15 +1981,15 @@ export function getSubscriptionName(): string {
switch (subscriptionType) {
case 'enterprise':
return 'Claude Enterprise'
return 'Anthropic Enterprise'
case 'team':
return 'Claude Team'
return 'Anthropic Team'
case 'max':
return 'Claude Max'
return 'Anthropic Max'
case 'pro':
return 'Claude Pro'
return 'Anthropic Pro'
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` +
`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` +
`verification (tokens from 'claude setup-token' do not include this scope).\n` +
`Try again, or obtain a full-scope token via 'claude auth login'.`,
`verification (tokens from 'better-clawd setup-token' do not include this scope).\n` +
`Try again, or obtain a full-scope token via 'better-clawd auth login'.`,
}
}
@@ -1995,7 +2260,7 @@ export async function validateForceLoginOrg(): Promise<OrgValidationResult> {
message:
`Your authentication token belongs to organization ${tokenOrgUuid},\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 { getMacOsKeychainStorageServiceName } from 'src/utils/secureStorage/macOsKeychainHelpers.js'
import { getMacOsKeychainStorageServiceNames } from 'src/utils/secureStorage/macOsKeychainHelpers.js'
export async function maybeRemoveApiKeyFromMacOSKeychainThrows(): Promise<void> {
if (process.platform === 'darwin') {
const storageServiceName = getMacOsKeychainStorageServiceName()
const result = await execa(
`security delete-generic-password -a $USER -s "${storageServiceName}"`,
{ shell: true, reject: false },
)
if (result.exitCode !== 0) {
let deletedAny = false
for (const storageServiceName of getMacOsKeychainStorageServiceNames()) {
const result = await execa(
`security delete-generic-password -a $USER -s "${storageServiceName}"`,
{ shell: true, reject: false },
)
if (result.exitCode === 0) {
deletedAny = true
}
}
if (!deletedAny) {
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.
*/
export async function assertMinVersion(): Promise<void> {
if (process.env.NODE_ENV === 'test') {
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)
}
return
}
/**
@@ -106,11 +79,7 @@ This will ensure you have access to the latest features and improvements.
* Returns undefined if no cap is configured.
*/
export async function getMaxVersion(): Promise<string | undefined> {
const config = await getMaxVersionConfig()
if (process.env.USER_TYPE === 'ant') {
return config.ant || undefined
}
return config.external || undefined
return 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.
*/
export async function getMaxVersionMessage(): Promise<string | undefined> {
const config = await getMaxVersionConfig()
if (process.env.USER_TYPE === 'ant') {
return config.ant_message || undefined
}
return config.external_message || undefined
return undefined
}
async function getMaxVersionConfig(): Promise<MaxVersionConfig> {
@@ -319,28 +284,8 @@ export async function checkGlobalInstallPermissions(): Promise<{
export async function getLatestVersion(
channel: ReleaseChannel,
): Promise<string | null> {
const npmTag = channel === 'stable' ? 'stable' : 'latest'
// 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()
void channel
return MACRO.VERSION
}
export type NpmDistTags = {
@@ -384,16 +329,8 @@ export async function getNpmDistTags(): Promise<NpmDistTags> {
export async function getLatestVersionFromGcs(
channel: ReleaseChannel,
): Promise<string | null> {
try {
const response = await axios.get(`${GCS_BUCKET_URL}/${channel}`, {
timeout: 5000,
responseType: 'text',
})
return response.data.trim()
} catch (error) {
logForDebugging(`Failed to fetch ${channel} from GCS: ${error}`)
return null
}
void channel
return MACRO.VERSION
}
/**
@@ -456,80 +393,8 @@ export async function getVersionHistory(limit: number): Promise<string[]> {
export async function installGlobalPackage(
specificVersion?: string | null,
): Promise<InstallStatus> {
if (!(await acquireLock())) {
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'
}
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()
}
void specificVersion
return 'install_failed'
}
/**

View File

@@ -109,11 +109,11 @@ export function createChromeContext(
clientTypeId: 'claude-code',
onAuthenticationError: () => {
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: () => {
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) => {
saveGlobalConfig(config => {

View File

@@ -1,6 +1,7 @@
import { createHash, randomUUID, type UUID } from 'crypto'
import { stat } from 'fs/promises'
import { isAbsolute, join, relative, sep } from 'path'
import { PRODUCT_NAME } from '../constants/product.js'
import { getOriginalCwd, getSessionId } from '../bootstrap/state.js'
import type {
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-3-5')) return 'claude-haiku-3-5'
// 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 = {
// 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 = {
claudePercent: number

View File

@@ -221,7 +221,14 @@ export type GlobalConfig = {
approved?: 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)
openAiApiKey?: string
openAiAccessToken?: string
openAiRefreshToken?: string
openAiTokenExpiresAt?: number
openAiWorkspaceId?: string
openRouterApiKey?: string
hasAcknowledgedCostThreshold?: boolean
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

View File

@@ -21,6 +21,11 @@ import {
type AnalyticsMetadata_I_VERIFIED_THIS_IS_NOT_CODE_OR_FILEPATHS,
logEvent,
} 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 { getClaudeConfigHomeDir } from '../envUtils.js'
import { getErrnoCode } from '../errors.js'
@@ -30,10 +35,10 @@ import { which } from '../which.js'
import { getUserBinDir, getXDGDataHome } from '../xdg.js'
import { DEEP_LINK_PROTOCOL } from './parseDeepLink.js'
export const MACOS_BUNDLE_ID = 'com.anthropic.claude-code-url-handler'
const APP_NAME = 'Claude Code URL Handler'
const DESKTOP_FILE_NAME = 'claude-code-url-handler.desktop'
const MACOS_APP_NAME = 'Claude Code URL Handler.app'
export const MACOS_BUNDLE_ID = 'com.betterclawd.better-clawd-url-handler'
const APP_NAME = 'Better-Clawd URL Handler'
const DESKTOP_FILE_NAME = 'better-clawd-url-handler.desktop'
const MACOS_APP_NAME = 'Better-Clawd URL Handler.app'
// Shared between register* (writes these paths/values) and
// isProtocolHandlerCurrent (reads them back). Keep the writer and reader
@@ -43,7 +48,7 @@ const MACOS_SYMLINK_PATH = path.join(
MACOS_APP_DIR,
'Contents',
'MacOS',
'claude',
CLI_BINARY_NAME,
)
function linuxDesktopPath(): string {
return path.join(getXDGDataHome(), 'applications', DESKTOP_FILE_NAME)
@@ -64,9 +69,9 @@ function windowsCommandValue(claudePath: string): string {
* Register the protocol handler on macOS.
*
* Creates a .app bundle where the CFBundleExecutable is a symlink to the
* already-installed (and signed) `claude` binary. When macOS opens a
* `claude-cli://` URL, it launches `claude` through this app bundle.
* Claude then uses the url-handler NAPI module to read the URL from the
* already-installed (and signed) CLI binary. When macOS opens a deep link,
* it launches Better-Clawd through this app bundle.
* Better-Clawd then uses the url-handler NAPI module to read the URL from the
* Apple Event and handles it normally.
*
* 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 })
// 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"?>
<!DOCTYPE plist PUBLIC "-//Apple//DTD PLIST 1.0//EN" "http://www.apple.com/DTDs/PropertyList-1.0.dtd">
<plist version="1.0">
@@ -97,7 +102,7 @@ async function registerMacos(claudePath: string): Promise<void> {
<key>CFBundleName</key>
<string>${APP_NAME}</string>
<key>CFBundleExecutable</key>
<string>claude</string>
<string>${CLI_BINARY_NAME}</string>
<key>CFBundleVersion</key>
<string>1.0</string>
<key>CFBundlePackageType</key>
@@ -108,7 +113,7 @@ async function registerMacos(claudePath: string): Promise<void> {
<array>
<dict>
<key>CFBundleURLName</key>
<string>Claude Code Deep Link</string>
<string>Better-Clawd Deep Link</string>
<key>CFBundleURLSchemes</key>
<array>
<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)
// 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.
// Written LAST among the throwing fs calls: isProtocolHandlerCurrent reads
// 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]
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)}
Type=Application
NoDisplay=true
@@ -233,25 +238,31 @@ export async function registerProtocolHandler(
}
/**
* Resolve the claude binary path for protocol registration. Prefers the
* native installer's stable symlink (~/.local/bin/claude) which survives
* Resolve the CLI binary path for protocol registration. Prefers the
* native installer's stable symlink (~/.local/bin/better-clawd) which survives
* auto-updates; falls back to process.execPath when the symlink is absent
* (dev builds, non-native installs).
*/
async function resolveClaudePath(): Promise<string> {
const binaryName = process.platform === 'win32' ? 'claude.exe' : 'claude'
const stablePath = path.join(getUserBinDir(), binaryName)
try {
await fs.realpath(stablePath)
return stablePath
} catch {
return process.execPath
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)
try {
await fs.realpath(stablePath)
return stablePath
} catch {
// Try the next compatibility alias.
}
}
return process.execPath
}
/**
* 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
* a cached flag in ~/.claude.json, so:
* - 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),
* 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.
@@ -328,7 +339,7 @@ export async function ensureDeepLinkProtocolRegistered(): Promise<void> {
try {
await registerProtocolHandler(claudePath)
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(() => {})
} catch (error) {
const code = getErrnoCode(error)

View File

@@ -435,14 +435,14 @@ async function detectConfigurationIssues(
if (type === 'npm-local' && config.installMethod !== 'local') {
warnings.push({
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') {
warnings.push({
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())) {
warnings.push({
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
warnings.push({
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 {
// No alias exists and not in PATH
warnings.push({
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()) {
warnings.push({
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 { homedir } from 'os'
import { join } from 'path'
import {
getConfiguredProductConfigDir,
LEGACY_PRODUCT_SLUG,
PRODUCT_SLUG,
} from '../constants/product.js'
import { fileSuffixForOauthConfig } from '../constants/oauth.js'
import { isRunningWithBun } from './bundledMode.js'
import { getClaudeConfigHomeDir, isEnvTruthy } from './envUtils.js'
@@ -12,17 +17,29 @@ type Platform = 'win32' | 'darwin' | 'linux'
// Config and data paths
export const getGlobalClaudeFile = memoize((): string => {
const configHomeDir = getClaudeConfigHomeDir()
const configDirOverride = getConfiguredProductConfigDir()
// Legacy fallback for backwards compatibility
if (
getFsImplementation().existsSync(
join(getClaudeConfigHomeDir(), '.config.json'),
)
) {
return join(getClaudeConfigHomeDir(), '.config.json')
const legacyConfigPath = join(configHomeDir, '.config.json')
if (getFsImplementation().existsSync(legacyConfigPath)) {
return legacyConfigPath
}
const filename = `.claude${fileSuffixForOauthConfig()}.json`
return join(process.env.CLAUDE_CONFIG_DIR || homedir(), filename)
const suffix = fileSuffixForOauthConfig()
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> => {

View File

@@ -1,16 +1,35 @@
import memoize from 'lodash-es/memoize.js'
import { existsSync } from 'fs'
import { homedir } from 'os'
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
// tests that change the env var get a fresh value without explicit cache.clear.
export const getClaudeConfigHomeDir = memoize(
(): string => {
return (
process.env.CLAUDE_CONFIG_DIR ?? join(homedir(), '.claude')
).normalize('NFC')
const configuredDir = getConfiguredProductConfigDir()
if (configuredDir) {
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 {

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

View File

@@ -3,18 +3,23 @@
*/
import axios from 'axios'
import {
PRODUCT_ISSUES_URL,
PRODUCT_SLUG,
} from '../constants/product.js'
import { OAUTH_BETA_HEADER } from '../constants/oauth.js'
import {
getAnthropicApiKey,
getClaudeAIOAuthTokens,
getOpenAIApiKey,
getOpenRouterApiKey,
handleOAuth401Error,
isClaudeAISubscriber,
} from './auth.js'
import { getAPIProvider } from './model/providers.js'
import { getClaudeCodeUserAgent } from './userAgent.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 {
const agentSdkVersion = 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.
const workload = getWorkload()
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 {
@@ -46,15 +51,12 @@ export function getMCPUserAgent(): string {
parts.push(`client-app/${process.env.CLAUDE_AGENT_SDK_CLIENT_APP}`)
}
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
// 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.
// User-Agent for WebFetch requests to arbitrary sites.
export function getWebFetchUserAgent(): string {
return `Claude-User (${getClaudeCodeUserAgent()}; +https://support.anthropic.com/)`
return `Better-Clawd-User (${getClaudeCodeUserAgent()}; +${PRODUCT_ISSUES_URL})`
}
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
*/
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()) {
const oauthTokens = getClaudeAIOAuthTokens()
if (!oauthTokens?.accessToken) {

View File

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

View File

@@ -34,6 +34,9 @@ export type ModelName = string
export type ModelSetting = ModelName | ModelAlias | null
export function getSmallFastModel(): ModelName {
if (getAPIProvider() === 'openai') {
return process.env.OPENAI_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 {
const provider = getAPIProvider()
const isOpenAI = provider === 'openai'
if (process.env.USER_TYPE === 'ant') {
const currentModel = renderDefaultModelSetting(
getDefaultMainLoopModelSetting(),
@@ -65,11 +67,13 @@ export function getDefaultOptionForUser(fastMode = false): ModelOption {
}
// PAYG
const is3P = getAPIProvider() !== 'firstParty'
const is3P = provider !== 'firstParty'
return {
value: null,
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.)
// with the new model's label and description. These appear in the /model picker.
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 {
value: is3P ? getModelStrings().sonnet46 : 'sonnet',
label: 'Sonnet',
@@ -131,7 +145,17 @@ function getOpus41Option(): 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 {
value: is3P ? getModelStrings().opus46 : 'opus',
label: 'Opus',
@@ -141,7 +165,17 @@ function getOpus46Option(fastMode = false): 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 {
value: is3P ? getModelStrings().sonnet46 + '[1m]' : 'sonnet[1m]',
label: 'Sonnet (1M context)',
@@ -152,7 +186,17 @@ export function getSonnet46_1MOption(): 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 {
value: is3P ? getModelStrings().opus46 + '[1m]' : 'opus[1m]',
label: 'Opus (1M context)',
@@ -179,7 +223,17 @@ function getCustomHaikuOption(): ModelOption | undefined {
}
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 {
value: 'haiku',
label: 'Haiku',
@@ -190,7 +244,17 @@ function getHaiku45Option(): 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 {
value: '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[]
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
for (const key of MODEL_KEYS) {
out[key] = ALL_MODEL_CONFIGS[key][provider]

View File

@@ -1,22 +1,106 @@
import type { AnalyticsMetadata_I_VERIFIED_THIS_IS_NOT_CODE_OR_FILEPATHS } from '../../services/analytics/index.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 {
const explicitProvider = getExplicitProviderOverride()
if (explicitProvider) {
return explicitProvider
}
return isEnvTruthy(process.env.CLAUDE_CODE_USE_BEDROCK)
? 'bedrock'
: isEnvTruthy(process.env.CLAUDE_CODE_USE_VERTEX)
? 'vertex'
: isEnvTruthy(process.env.CLAUDE_CODE_USE_FOUNDRY)
? 'foundry'
: 'firstParty'
: isOpenAIConfigured()
? 'openai'
: isOpenRouterConfigured()
? 'openrouter'
: 'firstParty'
}
export function getAPIProviderForStatsig(): 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.
* 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,
logEvent,
} 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 { registerCleanup } from '../cleanupRegistry.js'
import { getGlobalConfig, saveGlobalConfig } from '../config.js'
@@ -109,24 +115,34 @@ export function getPlatform(): 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() {
const platform = getPlatform()
const executableName = getBinaryName(platform)
const preferredExecutableName = getPreferredBinaryName(platform)
return {
// Data directories (permanent storage)
versions: join(getXDGDataHome(), 'claude', 'versions'),
versions: join(getXDGDataHome(), PRODUCT_SLUG, 'versions'),
// Cache directories (can be deleted)
staging: join(getXDGCacheHome(), 'claude', 'staging'),
staging: join(getXDGCacheHome(), PRODUCT_SLUG, 'staging'),
// 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),
}
}
@@ -465,8 +481,11 @@ async function performVersionUpdate(
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 updateSymlink(getBaseDirectories().preferredExecutable, installPath)
await updateSymlink(executablePath, installPath)
// 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
* 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 {
// Check if this is an npm-managed installation
if (await isNpmSymlink(dirs.executable)) {
if (
(await isNpmSymlink(dirs.executable)) ||
(await isNpmSymlink(dirs.preferredExecutable))
) {
logForDebugging(
`Skipping removal of ${dirs.executable} - appears to be npm-managed`,
`Skipping removal of ${dirs.preferredExecutable} / ${dirs.executable} - appears to be npm-managed`,
)
return
}
// It's a native binary symlink, safe to remove
await unlink(dirs.executable)
logForDebugging(`Removed claude symlink at ${dirs.executable}`)
// They're native binary symlinks, safe to remove.
await Promise.allSettled([
unlink(dirs.preferredExecutable),
unlink(dirs.executable),
])
logForDebugging(
`Removed ${PRODUCT_NAME} symlinks at ${dirs.preferredExecutable} and ${dirs.executable}`,
)
} catch (error) {
if (isENOENT(error)) {
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);
let t5;
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;
$[7] = result;
$[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 {
CREDENTIALS_SERVICE_SUFFIX,
getMacOsKeychainStorageServiceName,
getMacOsKeychainStorageServiceNames,
getUsername,
primeKeychainCacheFromPrefetch,
} from './macOsKeychainHelpers.js'
@@ -72,20 +73,26 @@ export function startKeychainPrefetch(): void {
// Fire both subprocesses immediately (non-blocking). They run in parallel
// with each other AND with main.tsx imports. The await in Promise.all
// happens later via ensureKeychainPrefetchCompleted().
const oauthSpawn = spawnSecurity(
getMacOsKeychainStorageServiceName(CREDENTIALS_SERVICE_SUFFIX),
)
const legacySpawn = spawnSecurity(getMacOsKeychainStorageServiceName())
const oauthSpawns = getMacOsKeychainStorageServiceNames(
CREDENTIALS_SERVICE_SUFFIX,
).map(spawnSecurity)
const legacySpawns = getMacOsKeychainStorageServiceNames().map(spawnSecurity)
prefetchPromise = Promise.all([oauthSpawn, legacySpawn]).then(
([oauth, legacy]) => {
prefetchPromise = Promise.all([
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
// own (longer) timeout. Priming null here would shadow a key that the
// sync path might successfully fetch.
if (!oauth.timedOut) primeKeychainCacheFromPrefetch(oauth.stdout)
if (!legacy.timedOut) legacyApiKeyPrefetch = { stdout: legacy.stdout }
},
)
})
}
/**

View File

@@ -17,6 +17,11 @@
import { createHash } from 'crypto'
import { userInfo } from 'os'
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 type { SecureStorageData } from './types.js'
@@ -28,23 +33,35 @@ export const CREDENTIALS_SERVICE_SUFFIX = '-credentials'
export function getMacOsKeychainStorageServiceName(
serviceSuffix: string = '',
opts: { legacy?: boolean } = {},
): string {
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
// Only add suffix for non-default directories to maintain backwards compatibility
const dirHash = isDefaultDir
? ''
: `-${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 {
try {
return process.env.USER || userInfo().username
} catch {
return 'claude-code-user'
return 'better-clawd-user'
}
}

View File

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

View File

@@ -195,8 +195,7 @@ export const SOURCES = [
] as const satisfies readonly EditableSettingSource[]
/**
* The JSON Schema URL for Claude Code settings
* You can edit the contents at https://github.com/SchemaStore/schemastore/blob/master/src/schemas/json/claude-code-settings.json
* The JSON Schema URL for Better-Clawd settings.
*/
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()) {
case 'macos':
return '/Library/Application Support/ClaudeCode'
return '/Library/Application Support/BetterClawd'
case 'windows':
return 'C:\\Program Files\\ClaudeCode'
return 'C:\\Program Files\\BetterClawd'
default:
return '/etc/claude-code'
return '/etc/better-clawd'
}
})

View File

@@ -8,8 +8,9 @@
import { homedir, userInfo } from 'os'
import { join } from 'path'
/** macOS preference domain for Claude Code MDM profiles. */
export const MACOS_PREFERENCE_DOMAIN = 'com.anthropic.claudecode'
/** macOS preference domains for Better-Clawd MDM profiles (new first, legacy fallback second). */
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.
@@ -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
*/
export const WINDOWS_REGISTRY_KEY_PATH_HKLM =
'HKLM\\SOFTWARE\\Policies\\ClaudeCode'
'HKLM\\SOFTWARE\\Policies\\BetterClawd'
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'
/** 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 domains = [MACOS_PREFERENCE_DOMAIN, LEGACY_MACOS_PREFERENCE_DOMAIN]
if (username) {
for (const domain of domains) {
paths.push({
path: `/Library/Managed Preferences/${username}/${domain}.plist`,
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/${username}/${MACOS_PREFERENCE_DOMAIN}.plist`,
label: 'per-user managed preferences',
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.
if (process.env.USER_TYPE === 'ant') {
paths.push({
path: join(
homedir(),
'Library',
'Preferences',
`${MACOS_PREFERENCE_DOMAIN}.plist`,
),
label: 'user preferences (ant-only)',
})
for (const domain of domains) {
paths.push({
path: join(homedir(), 'Library', 'Preferences', `${domain}.plist`),
label:
domain === MACOS_PREFERENCE_DOMAIN
? 'user preferences (ant-only)'
: 'user preferences (legacy, ant-only)',
})
}
}
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 {
getMacOSPlistPaths,
getWindowsRegistryKeyPaths,
MDM_SUBPROCESS_TIMEOUT_MS,
PLUTIL_ARGS_PREFIX,
PLUTIL_PATH,
WINDOWS_REGISTRY_KEY_PATH_HKCU,
WINDOWS_REGISTRY_KEY_PATH_HKLM,
WINDOWS_REGISTRY_VALUE_NAME,
} from './constants.js'
@@ -88,24 +87,35 @@ export function fireRawRead(): Promise<RawReadResult> {
}
if (process.platform === 'win32') {
const [hklm, hkcu] = await Promise.all([
execFilePromise('reg', [
'query',
WINDOWS_REGISTRY_KEY_PATH_HKLM,
'/v',
WINDOWS_REGISTRY_VALUE_NAME,
]),
execFilePromise('reg', [
'query',
WINDOWS_REGISTRY_KEY_PATH_HKCU,
'/v',
WINDOWS_REGISTRY_VALUE_NAME,
]),
const registryKeys = getWindowsRegistryKeyPaths()
const [hklmResults, hkcuResults] = await Promise.all([
Promise.all(
registryKeys.hklm.map(keyPath =>
execFilePromise('reg', [
'query',
keyPath,
'/v',
WINDOWS_REGISTRY_VALUE_NAME,
]),
),
),
Promise.all(
registryKeys.hkcu.map(keyPath =>
execFilePromise('reg', [
'query',
keyPath,
'/v',
WINDOWS_REGISTRY_VALUE_NAME,
]),
),
),
])
const hklm = hklmResults.find(result => result.code === 0)
const hkcu = hkcuResults.find(result => result.code === 0)
return {
plistStdouts: null,
hklmStdout: hklm.code === 0 ? hklm.stdout : null,
hkcuStdout: hkcu.code === 0 ? hkcu.stdout : null,
hklmStdout: hklm?.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 { dirname, join, resolve } from 'path'
import { z } from 'zod/v4'
import {
LEGACY_PRODUCT_CONFIG_DIRNAME,
PRODUCT_CONFIG_DIRNAME,
} from '../../constants/product.js'
import {
getFlagSettingsInline,
getFlagSettingsPath,
@@ -282,10 +286,15 @@ export function getSettingsFilePathForSource(
)
case 'projectSettings':
case 'localSettings': {
return join(
getSettingsRootPathForSource(source),
getRelativeSettingsFilePathForSource(source),
)
const root = getSettingsRootPathForSource(source)
const candidates = getRelativeSettingsFilePathCandidatesForSource(source)
for (const relativePath of candidates) {
const absolutePath = join(root, relativePath)
if (getFsImplementation().existsSync(absolutePath)) {
return absolutePath
}
}
return join(root, candidates[0]!)
}
case 'policySettings':
return getManagedSettingsFilePath()
@@ -298,11 +307,23 @@ export function getSettingsFilePathForSource(
export function getRelativeSettingsFilePathForSource(
source: 'projectSettings' | 'localSettings',
): string {
return getRelativeSettingsFilePathCandidatesForSource(source)[0]!
}
function getRelativeSettingsFilePathCandidatesForSource(
source: 'projectSettings' | 'localSettings',
): string[] {
switch (source) {
case 'projectSettings':
return join('.claude', 'settings.json')
return [
join(PRODUCT_CONFIG_DIRNAME, 'settings.json'),
join(LEGACY_PRODUCT_CONFIG_DIRNAME, 'settings.json'),
]
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 ' +
'downloading, so blocked sources never touch the filesystem.',
),
// Force a specific login method: 'claudeai' for Claude Pro/Max, 'console' for Console billing
forceLoginMethod: z
.enum(['claudeai', 'console'])
apiProvider: z
.enum([
'anthropic',
'openrouter',
'openai',
'bedrock',
'vertex',
'foundry',
])
.optional()
.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)
forceLoginOrgUUID: z

View File

@@ -242,6 +242,8 @@ export function buildAPIProviderProperties(): Property[] {
const properties: Property[] = [];
if (apiProvider !== 'firstParty') {
const providerLabel = {
openrouter: 'OpenRouter',
openai: 'OpenAI',
bedrock: 'AWS Bedrock',
vertex: 'Google Vertex AI',
foundry: 'Microsoft Foundry'
@@ -259,6 +261,19 @@ export function buildAPIProviderProperties(): Property[] {
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') {
const bedrockBaseUrl = process.env.BEDROCK_BASE_URL;
if (bedrockBaseUrl) {

View File

@@ -44,18 +44,11 @@ export class BigQueryMetricsExporter implements PushMetricExporter {
private isShutdown = false
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 (
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.endpoint = configuredEndpoint ?? ''
this.timeout = options.timeout || 5000
}
@@ -111,6 +104,14 @@ export class BigQueryMetricsExporter implements PushMetricExporter {
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()
if (authResult.error) {
logForDebugging(`Metrics export failed: ${authResult.error}`)
@@ -153,7 +154,7 @@ export class BigQueryMetricsExporter implements PushMetricExporter {
const attrs = metrics.resource.attributes
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',
'os.type': (attrs['os.type'] as string) || 'unknown',
'os.version': (attrs['os.version'] as string) || 'unknown',

View File

@@ -322,7 +322,10 @@ async function getOtlpTraceExporters() {
}
export function isTelemetryEnabled() {
return isEnvTruthy(process.env.CLAUDE_CODE_ENABLE_TELEMETRY)
return isEnvTruthy(
process.env.BETTER_CLAWD_ENABLE_TELEMETRY ??
process.env.CLAUDE_CODE_ENABLE_TELEMETRY,
)
}
function getBigQueryExportingReader() {

View File

@@ -438,7 +438,7 @@ export async function teleportResumeCodeSession(sessionId: string, onProgress?:
logEvent('tengu_teleport_resume_error', {
error_type: 'no_access_token' as AnalyticsMetadata_I_VERIFIED_THIS_IS_NOT_CODE_OR_FILEPATHS
});
throw new Error('Claude Code web sessions require authentication with a Claude.ai account. API key authentication is not sufficient. Please run /login to authenticate, or check your authentication status with /status.');
throw new Error('Remote web sessions require browser-based authentication. API key authentication is not sufficient. Please run /login, or check your authentication status with /status.');
}
// Get organization UUID
@@ -608,7 +608,7 @@ export async function teleportFromSessionsAPI(sessionId: string, orgUUID: string
logEvent('tengu_teleport_error_session_not_found_404', {
sessionId: sessionId as AnalyticsMetadata_I_VERIFIED_THIS_IS_NOT_CODE_OR_FILEPATHS
});
throw new TeleportOperationError(`${sessionId} not found.`, `${sessionId} not found.\n${chalk.dim('Run /status in Claude Code to check your account.')}`);
throw new TeleportOperationError(`${sessionId} not found.`, `${sessionId} not found.\n${chalk.dim('Run /status in Better-Clawd to check your account.')}`);
}
logError(err);
throw new Error(`Failed to fetch session from Sessions API: ${err.message}`);
@@ -1010,7 +1010,7 @@ export async function teleportToRemote(options: {
if (!bundle.success) {
logError(new Error(`Bundle upload failed: ${bundle.error}`));
// Only steer users to GitHub setup when there's a remote to clone from.
const setup = repoInfo ? '. Please setup GitHub on https://claude.ai/code' : '';
const setup = repoInfo ? '. Please configure a Git remote before retrying' : '';
let msg: string;
switch (bundle.failReason) {
case 'empty_repo':

View File

@@ -185,7 +185,7 @@ export async function prepareApiRequest(): Promise<{
const accessToken = getClaudeAIOAuthTokens()?.accessToken
if (accessToken === undefined) {
throw new Error(
'Claude Code web sessions require authentication with a Claude.ai account. API key authentication is not sufficient. Please run /login to authenticate, or check your authentication status with /status.',
'Remote web sessions require browser-based authentication. API key authentication is not sufficient. Please run /login, or check your authentication status with /status.',
)
}

View File

@@ -33,7 +33,7 @@ export async function fetchEnvironments(): Promise<EnvironmentResource[]> {
const accessToken = getClaudeAIOAuthTokens()?.accessToken
if (!accessToken) {
throw new Error(
'Claude Code web sessions require authentication with a Claude.ai account. API key authentication is not sufficient. Please run /login to authenticate, or check your authentication status with /status.',
'Remote web sessions require browser-based authentication. API key authentication is not sufficient. Please run /login, or check your authentication status with /status.',
)
}

View File

@@ -139,8 +139,7 @@ async function _bundleWithFallback(
return {
ok: false,
error:
'Repo is too large to bundle. Please setup GitHub on https://claude.ai/code',
error: 'Repo is too large to bundle. Please configure a Git remote instead.',
failReason: 'too_large',
}
}

View File

@@ -115,8 +115,8 @@ export type ThemeSetting = (typeof THEME_SETTINGS)[number]
const lightTheme: Theme = {
autoAccept: 'rgb(135,0,255)', // Electric violet
bashBorder: 'rgb(255,0,135)', // Vibrant pink
claude: 'rgb(215,119,87)', // Claude orange
claudeShimmer: 'rgb(245,149,117)', // Lighter claude orange for shimmer effect
claude: 'rgb(59,130,246)', // Better-Clawd blue
claudeShimmer: 'rgb(96,165,250)', // Lighter blue shimmer
claudeBlue_FOR_SYSTEM_SPINNER: 'rgb(87,105,247)', // Medium blue for system spinner
claudeBlueShimmer_FOR_SYSTEM_SPINNER: 'rgb(117,135,255)', // Lighter blue for system spinner shimmer
permission: 'rgb(87,105,247)', // Medium blue
@@ -158,7 +158,7 @@ const lightTheme: Theme = {
// Chrome colors
chromeYellow: 'rgb(251,188,4)', // Chrome yellow
// TUI V2 colors
clawd_body: 'rgb(215,119,87)',
clawd_body: 'rgb(59,130,246)',
clawd_background: 'rgb(0,0,0)',
userMessageBackground: 'rgb(240, 240, 240)', // Slightly darker grey for optimal contrast
userMessageBackgroundHover: 'rgb(252, 252, 252)', // ≥250 to quantize distinct from base at 256-color level
@@ -169,11 +169,11 @@ const lightTheme: Theme = {
memoryBackgroundColor: 'rgb(230, 245, 250)',
rate_limit_fill: 'rgb(87,105,247)', // Medium blue
rate_limit_empty: 'rgb(39,47,111)', // Dark blue
fastMode: 'rgb(255,106,0)', // Electric orange
fastModeShimmer: 'rgb(255,150,50)', // Lighter orange for shimmer
fastMode: 'rgb(37,99,235)', // Electric blue
fastModeShimmer: 'rgb(96,165,250)', // Lighter blue shimmer
// Brief/assistant mode
briefLabelYou: 'rgb(37,99,235)', // Blue
briefLabelClaude: 'rgb(215,119,87)', // Brand orange
briefLabelClaude: 'rgb(59,130,246)', // Brand blue
rainbow_red: 'rgb(235,95,87)',
rainbow_orange: 'rgb(245,139,87)',
rainbow_yellow: 'rgb(250,195,95)',
@@ -197,8 +197,8 @@ const lightTheme: Theme = {
const lightAnsiTheme: Theme = {
autoAccept: 'ansi:magenta',
bashBorder: 'ansi:magenta',
claude: 'ansi:redBright',
claudeShimmer: 'ansi:yellowBright',
claude: 'ansi:blueBright',
claudeShimmer: 'ansi:cyanBright',
claudeBlue_FOR_SYSTEM_SPINNER: 'ansi:blue',
claudeBlueShimmer_FOR_SYSTEM_SPINNER: 'ansi:blueBright',
permission: 'ansi:blue',
@@ -240,7 +240,7 @@ const lightAnsiTheme: Theme = {
// Chrome colors
chromeYellow: 'ansi:yellow', // Chrome yellow
// TUI V2 colors
clawd_body: 'ansi:redBright',
clawd_body: 'ansi:blueBright',
clawd_background: 'ansi:black',
userMessageBackground: 'ansi:white',
userMessageBackgroundHover: 'ansi:whiteBright',
@@ -251,10 +251,10 @@ const lightAnsiTheme: Theme = {
memoryBackgroundColor: 'ansi:white',
rate_limit_fill: 'ansi:yellow',
rate_limit_empty: 'ansi:black',
fastMode: 'ansi:red',
fastModeShimmer: 'ansi:redBright',
fastMode: 'ansi:blue',
fastModeShimmer: 'ansi:blueBright',
briefLabelYou: 'ansi:blue',
briefLabelClaude: 'ansi:redBright',
briefLabelClaude: 'ansi:blueBright',
rainbow_red: 'ansi:red',
rainbow_orange: 'ansi:redBright',
rainbow_yellow: 'ansi:yellow',
@@ -278,8 +278,8 @@ const lightAnsiTheme: Theme = {
const darkAnsiTheme: Theme = {
autoAccept: 'ansi:magentaBright',
bashBorder: 'ansi:magentaBright',
claude: 'ansi:redBright',
claudeShimmer: 'ansi:yellowBright',
claude: 'ansi:blueBright',
claudeShimmer: 'ansi:cyanBright',
claudeBlue_FOR_SYSTEM_SPINNER: 'ansi:blueBright',
claudeBlueShimmer_FOR_SYSTEM_SPINNER: 'ansi:blueBright',
permission: 'ansi:blueBright',
@@ -321,7 +321,7 @@ const darkAnsiTheme: Theme = {
// Chrome colors
chromeYellow: 'ansi:yellowBright', // Chrome yellow
// TUI V2 colors
clawd_body: 'ansi:redBright',
clawd_body: 'ansi:blueBright',
clawd_background: 'ansi:black',
userMessageBackground: 'ansi:blackBright',
userMessageBackgroundHover: 'ansi:white',
@@ -332,10 +332,10 @@ const darkAnsiTheme: Theme = {
memoryBackgroundColor: 'ansi:blackBright',
rate_limit_fill: 'ansi:yellow',
rate_limit_empty: 'ansi:white',
fastMode: 'ansi:redBright',
fastModeShimmer: 'ansi:redBright',
fastMode: 'ansi:blueBright',
fastModeShimmer: 'ansi:cyanBright',
briefLabelYou: 'ansi:blueBright',
briefLabelClaude: 'ansi:redBright',
briefLabelClaude: 'ansi:blueBright',
rainbow_red: 'ansi:red',
rainbow_orange: 'ansi:redBright',
rainbow_yellow: 'ansi:yellow',
@@ -359,8 +359,8 @@ const darkAnsiTheme: Theme = {
const lightDaltonizedTheme: Theme = {
autoAccept: 'rgb(135,0,255)', // Electric violet
bashBorder: 'rgb(0,102,204)', // Blue instead of pink
claude: 'rgb(255,153,51)', // Orange adjusted for deuteranopia
claudeShimmer: 'rgb(255,183,101)', // Lighter orange for shimmer effect
claude: 'rgb(51,102,255)', // Better-Clawd blue for deuteranopia
claudeShimmer: 'rgb(101,152,255)', // Lighter blue shimmer
claudeBlue_FOR_SYSTEM_SPINNER: 'rgb(51,102,255)', // Bright blue for system spinner
claudeBlueShimmer_FOR_SYSTEM_SPINNER: 'rgb(101,152,255)', // Lighter bright blue for system spinner shimmer
permission: 'rgb(51,102,255)', // Bright blue
@@ -402,7 +402,7 @@ const lightDaltonizedTheme: Theme = {
// Chrome colors
chromeYellow: 'rgb(251,188,4)', // Chrome yellow
// TUI V2 colors
clawd_body: 'rgb(215,119,87)',
clawd_body: 'rgb(51,102,255)',
clawd_background: 'rgb(0,0,0)',
userMessageBackground: 'rgb(220, 220, 220)', // Slightly darker grey for optimal contrast
userMessageBackgroundHover: 'rgb(232, 232, 232)', // ≥230 to quantize distinct from base at 256-color level
@@ -413,10 +413,10 @@ const lightDaltonizedTheme: Theme = {
memoryBackgroundColor: 'rgb(230, 245, 250)',
rate_limit_fill: 'rgb(51,102,255)', // Bright blue
rate_limit_empty: 'rgb(23,46,114)', // Dark blue
fastMode: 'rgb(255,106,0)', // Electric orange (color-blind safe)
fastModeShimmer: 'rgb(255,150,50)', // Lighter orange for shimmer
fastMode: 'rgb(0,102,204)', // Electric blue
fastModeShimmer: 'rgb(101,152,255)', // Lighter blue shimmer
briefLabelYou: 'rgb(37,99,235)', // Blue
briefLabelClaude: 'rgb(255,153,51)', // Orange adjusted for deuteranopia (matches claude)
briefLabelClaude: 'rgb(51,102,255)', // Brand blue
rainbow_red: 'rgb(235,95,87)',
rainbow_orange: 'rgb(245,139,87)',
rainbow_yellow: 'rgb(250,195,95)',
@@ -440,8 +440,8 @@ const lightDaltonizedTheme: Theme = {
const darkTheme: Theme = {
autoAccept: 'rgb(175,135,255)', // Electric violet
bashBorder: 'rgb(253,93,177)', // Bright pink
claude: 'rgb(215,119,87)', // Claude orange
claudeShimmer: 'rgb(235,159,127)', // Lighter claude orange for shimmer effect
claude: 'rgb(96,165,250)', // Better-Clawd blue
claudeShimmer: 'rgb(147,197,253)', // Lighter blue shimmer
claudeBlue_FOR_SYSTEM_SPINNER: 'rgb(147,165,255)', // Blue for system spinner
claudeBlueShimmer_FOR_SYSTEM_SPINNER: 'rgb(177,195,255)', // Lighter blue for system spinner shimmer
permission: 'rgb(177,185,249)', // Light blue-purple
@@ -483,7 +483,7 @@ const darkTheme: Theme = {
// Chrome colors
chromeYellow: 'rgb(251,188,4)', // Chrome yellow
// TUI V2 colors
clawd_body: 'rgb(215,119,87)',
clawd_body: 'rgb(96,165,250)',
clawd_background: 'rgb(0,0,0)',
userMessageBackground: 'rgb(55, 55, 55)', // Lighter grey for better visual contrast
userMessageBackgroundHover: 'rgb(70, 70, 70)',
@@ -494,10 +494,10 @@ const darkTheme: Theme = {
memoryBackgroundColor: 'rgb(55, 65, 70)',
rate_limit_fill: 'rgb(177,185,249)', // Light blue-purple
rate_limit_empty: 'rgb(80,83,112)', // Medium blue-purple
fastMode: 'rgb(255,120,20)', // Electric orange for dark bg
fastModeShimmer: 'rgb(255,165,70)', // Lighter orange for shimmer
fastMode: 'rgb(59,130,246)', // Electric blue for dark bg
fastModeShimmer: 'rgb(96,165,250)', // Lighter blue shimmer
briefLabelYou: 'rgb(122,180,232)', // Light blue
briefLabelClaude: 'rgb(215,119,87)', // Brand orange
briefLabelClaude: 'rgb(96,165,250)', // Brand blue
rainbow_red: 'rgb(235,95,87)',
rainbow_orange: 'rgb(245,139,87)',
rainbow_yellow: 'rgb(250,195,95)',
@@ -521,8 +521,8 @@ const darkTheme: Theme = {
const darkDaltonizedTheme: Theme = {
autoAccept: 'rgb(175,135,255)', // Electric violet
bashBorder: 'rgb(51,153,255)', // Bright blue
claude: 'rgb(255,153,51)', // Orange adjusted for deuteranopia
claudeShimmer: 'rgb(255,183,101)', // Lighter orange for shimmer effect
claude: 'rgb(153,204,255)', // Better-Clawd blue for deuteranopia
claudeShimmer: 'rgb(183,224,255)', // Lighter blue shimmer
claudeBlue_FOR_SYSTEM_SPINNER: 'rgb(153,204,255)', // Light blue for system spinner
claudeBlueShimmer_FOR_SYSTEM_SPINNER: 'rgb(183,224,255)', // Lighter blue for system spinner shimmer
permission: 'rgb(153,204,255)', // Light blue
@@ -564,7 +564,7 @@ const darkDaltonizedTheme: Theme = {
// Chrome colors
chromeYellow: 'rgb(251,188,4)', // Chrome yellow
// TUI V2 colors
clawd_body: 'rgb(215,119,87)',
clawd_body: 'rgb(153,204,255)',
clawd_background: 'rgb(0,0,0)',
userMessageBackground: 'rgb(55, 55, 55)', // Lighter grey for better visual contrast
userMessageBackgroundHover: 'rgb(70, 70, 70)',
@@ -575,10 +575,10 @@ const darkDaltonizedTheme: Theme = {
memoryBackgroundColor: 'rgb(55, 65, 70)',
rate_limit_fill: 'rgb(153,204,255)', // Light blue
rate_limit_empty: 'rgb(69,92,115)', // Dark blue
fastMode: 'rgb(255,120,20)', // Electric orange for dark bg (color-blind safe)
fastModeShimmer: 'rgb(255,165,70)', // Lighter orange for shimmer
fastMode: 'rgb(102,178,255)', // Electric blue for dark bg
fastModeShimmer: 'rgb(183,224,255)', // Lighter blue shimmer
briefLabelYou: 'rgb(122,180,232)', // Light blue
briefLabelClaude: 'rgb(255,153,51)', // Orange adjusted for deuteranopia (matches claude)
briefLabelClaude: 'rgb(153,204,255)', // Brand blue
rainbow_red: 'rgb(235,95,87)',
rainbow_orange: 'rgb(245,139,87)',
rainbow_yellow: 'rgb(250,195,95)',

View File

@@ -0,0 +1,7 @@
import { exitWithError } from './process.js'
export function unsupportedEntrypoint(commandName: string): never {
exitWithError(
`Error: \`${commandName}\` is not available in this Better-Clawd build.`,
)
}

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