Skip to content
Open
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
212 changes: 191 additions & 21 deletions src/index.tsx
Original file line number Diff line number Diff line change
@@ -1,4 +1,7 @@
#!/usr/bin/env node
import * as os from 'node:os';
import * as fsSync from 'node:fs';
import * as path from 'node:path';
import { render } from 'ink';
import { Command } from 'commander';
import chalk from 'chalk';
Expand Down Expand Up @@ -181,6 +184,12 @@ const main = async () => {
'--config-url <url>',
'fetch configuration from a URL instead of local config.json'
)
.option(
'--install-hook',
'install a SessionStart hook so patches auto-reapply after CC updates'
)
.option('--remove-hook', 'remove the auto-reapply SessionStart hook')
.option('-q, --quiet', 'suppress output (for use with --apply in hooks)')
.action(async () => {
// This action handles the default case (no subcommand).
// All the --flag handling lives here so that Commander's subcommand
Expand Down Expand Up @@ -224,6 +233,20 @@ const main = async () => {
return;
}

// Handle --install-hook / --remove-hook flags
if (options.installHook && options.removeHook) {
console.error(
chalk.red(
'Error: Cannot use --install-hook and --remove-hook together.'
)
);
process.exit(1);
}
if (options.installHook || options.removeHook) {
await handleHookMode(!!options.installHook);
return;
Comment on lines +236 to +247
Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

⚠️ Potential issue | 🟠 Major

Reject mixed command modes instead of silently picking one.

This branch short-circuits the rest of the default action, so --remove-hook --apply / --install-hook --patches ... return success after only managing the hook, while --install-hook --list-patches never reaches this block because list mode returned earlier. Please make hook management mutually exclusive with the other top-level modes instead of ignoring part of the CLI input.

💡 Proposed guard
       if (options.installHook && options.removeHook) {
         console.error(
           chalk.red(
             'Error: Cannot use --install-hook and --remove-hook together.'
           )
         );
         process.exit(1);
       }
+      if (
+        (options.installHook || options.removeHook) &&
+        (options.apply ||
+          options.restore ||
+          options.revert ||
+          options.listPatches ||
+          options.listSystemPrompts !== undefined ||
+          options.configUrl ||
+          options.patches)
+      ) {
+        console.error(
+          chalk.red(
+            'Error: --install-hook/--remove-hook cannot be combined with other command modes.'
+          )
+        );
+        process.exit(1);
+      }
       if (options.installHook || options.removeHook) {
         await handleHookMode(!!options.installHook);
         return;
       }
🤖 Prompt for AI Agents
Verify each finding against the current code and only fix it if needed.

In `@src/index.tsx` around lines 236 - 247, Reject combined hook and other
top-level modes: instead of short-circuiting, check that when
options.installHook or options.removeHook is set no other top-level action flags
(e.g. options.apply, options.patches, options.listPatches or any other mutually
exclusive CLI mode flags used elsewhere) are also present; if they are, print an
error (similar to the existing message) and exit non‑zero. Only call await
handleHookMode(!!options.installHook) and return when install/removeHook is the
sole top-level mode; otherwise fail fast. Use the existing symbols
options.installHook, options.removeHook and handleHookMode to locate and
implement the guard.

}
Comment thread
coderabbitai[bot] marked this conversation as resolved.

// Handle --apply flag for non-interactive mode
if (options.apply) {
// Parse patch filter if provided
Expand All @@ -232,7 +255,7 @@ const main = async () => {
.split(',')
.map((id: string) => id.trim())
: null;
await handleApplyMode(patchFilter, options.configUrl);
await handleApplyMode(patchFilter, options.configUrl, !!options.quiet);
return;
}

Expand Down Expand Up @@ -334,6 +357,148 @@ const main = async () => {
program.parse();
};

/**
* Handles --install-hook / --remove-hook flags.
* Installs or removes a Claude Code SessionStart hook that runs
* `tweakcc --apply --quiet` after CC auto-updates.
*/
async function handleHookMode(install: boolean): Promise<void> {
const home = os.homedir();
const settingsPath = path.join(home, '.claude', 'settings.json');

// Read existing settings
let settings: Record<string, unknown> = {};
try {
const raw = fsSync.readFileSync(settingsPath, 'utf8');
const parsed: unknown = JSON.parse(raw);
if (parsed == null || Array.isArray(parsed) || typeof parsed !== 'object') {
console.error(
chalk.red(`Error: ${settingsPath} must contain a JSON object.`)
);
process.exit(1);
}
settings = parsed as Record<string, unknown>;
} catch (error) {
const err = error as NodeJS.ErrnoException;
if (err.code === 'ENOENT') {
// File doesn't exist — start fresh
} else if (error instanceof SyntaxError) {
console.error(
chalk.red(
`Error: ${settingsPath} contains invalid JSON. Please fix it manually.`
)
);
process.exit(1);
} else {
console.error(
chalk.red(
`Error reading ${settingsPath}: ${error instanceof Error ? error.message : String(error)}`
)
);
process.exit(1);
}
}
Comment on lines +371 to +400
Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

⚠️ Potential issue | 🟠 Major

Do not overwrite Claude settings when JSON parsing fails.

At Line 366-Line 368, invalid JSON is treated the same as a missing file. In install mode, this can replace an existing settings.json with a new minimal object and drop unrelated user settings.

Suggested fix
-  } catch {
-    // File doesn't exist or isn't valid JSON — start fresh
+  } catch (error) {
+    const err = error as NodeJS.ErrnoException;
+    if (err.code !== 'ENOENT') {
+      console.error(chalk.red(`Error reading ${settingsPath}: ${err.message}`));
+      process.exit(1);
+    }
   }

Also applies to: 405-410

🤖 Prompt for AI Agents
Verify each finding against the current code and only fix it if needed.

In `@src/index.tsx` around lines 363 - 368, The try/catch around reading/parsing
settings (fsSync.readFileSync(settingsPath) and JSON.parse) currently treats
parse errors the same as a missing file and can overwrite user settings; change
the catch to distinguish a missing file (ENOENT) from a JSON parse error: if the
file is missing, initialize settings to an empty object; if JSON.parse throws,
log the parse error and leave the existing settings variable untouched (do not
replace it with a new minimal object or write out a new settings.json). Apply
the same fix to the other read/parse block mentioned (the block around lines
405-410) so parse failures never overwrite existing settings.


const hooks =
settings.hooks != null &&
typeof settings.hooks === 'object' &&
!Array.isArray(settings.hooks)
? (settings.hooks as Record<string, unknown>)
: {};
Comment thread
coderabbitai[bot] marked this conversation as resolved.
const rawSessionStart = hooks.SessionStart;
const sessionStart = Array.isArray(rawSessionStart)
? (rawSessionStart as Array<{
matcher?: string;
hooks?: Array<{ type?: string; command?: string; timeout?: number }>;
}>)
: [];

const hookCommand = 'tweakcc --apply --quiet';

const existingIdx = sessionStart.findIndex(entry =>
entry.hooks?.some(h => h.command === hookCommand)
);

if (install) {
if (existingIdx !== -1) {
console.log(chalk.green('Auto-reapply hook is already installed.'));
console.log(chalk.dim(`Location: ${settingsPath}`));
process.exit(0);
}

sessionStart.push({
matcher: '',
hooks: [
{
type: 'command',
command: hookCommand,
timeout: 30,
},
],
});

hooks.SessionStart = sessionStart;
settings.hooks = hooks;

fsSync.mkdirSync(path.dirname(settingsPath), { recursive: true });
fsSync.writeFileSync(
settingsPath,
JSON.stringify(settings, null, 2) + '\n',
'utf8'
);
Comment on lines +443 to +448
Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

⚠️ Potential issue | 🟠 Major

Handle write-side failures before exiting.

mkdirSync / writeFileSync can still fail on EACCES, EPERM, disk-full, or a read-only file. Right now that turns --install-hook / --remove-hook into an uncaught exception path. Please funnel both write sites through one guarded helper so the CLI prints a clear write error instead of a stack trace.

💡 Proposed helper
+function writeClaudeSettings(
+  settingsPath: string,
+  settings: Record<string, unknown>
+): void {
+  try {
+    fsSync.mkdirSync(path.dirname(settingsPath), { recursive: true });
+    fsSync.writeFileSync(
+      settingsPath,
+      JSON.stringify(settings, null, 2) + '\n',
+      'utf8'
+    );
+  } catch (error) {
+    console.error(
+      chalk.red(
+        `Error writing ${settingsPath}: ${error instanceof Error ? error.message : String(error)}`
+      )
+    );
+    process.exit(1);
+  }
+}
+
 ...
-    fsSync.mkdirSync(path.dirname(settingsPath), { recursive: true });
-    fsSync.writeFileSync(
-      settingsPath,
-      JSON.stringify(settings, null, 2) + '\n',
-      'utf8'
-    );
+    writeClaudeSettings(settingsPath, settings);
 ...
-    fsSync.writeFileSync(
-      settingsPath,
-      JSON.stringify(settings, null, 2) + '\n',
-      'utf8'
-    );
+    writeClaudeSettings(settingsPath, settings);

Also applies to: 489-492

🤖 Prompt for AI Agents
Verify each finding against the current code and only fix it if needed.

In `@src/index.tsx` around lines 443 - 448, Wrap the synchronous directory
creation and file write operations into a single guarded helper (e.g.,
ensureDirAndWriteSync or safeWriteSettings) that takes settingsPath and
contents, calls fsSync.mkdirSync(path.dirname(settingsPath), { recursive: true
}) and fsSync.writeFileSync(settingsPath, ...), and catches any exceptions
(EACCES/EPERM/ENOSPC/etc.); on error, call the CLI logger/console.error with a
clear message including the path and error.message and exit with non-zero status
instead of letting the exception bubble. Replace the two existing write sites
that use fsSync.mkdirSync/fsSync.writeFileSync (the block around settingsPath
and the similar block at the other location) to call this helper so both
--install-hook / --remove-hook paths use the guarded writer.


console.log(
chalk.green('Auto-reapply hook installed in Claude Code settings.')
);
console.log(
chalk.dim(
'After CC updates, patches will be reapplied automatically on next session start.'
)
);
console.log(chalk.dim(`Location: ${settingsPath}`));
console.log(chalk.dim('To remove: tweakcc --remove-hook'));
} else {
if (existingIdx === -1) {
console.log(chalk.yellow('No auto-reapply hook found to remove.'));
process.exit(0);
}

const entry = sessionStart[existingIdx];
const hookIdx =
entry.hooks?.findIndex(h => h.command === hookCommand) ?? -1;
if (hookIdx !== -1 && entry.hooks) {
entry.hooks.splice(hookIdx, 1);
if (entry.hooks.length === 0) {
sessionStart.splice(existingIdx, 1);
}
} else {
sessionStart.splice(existingIdx, 1);
}

if (sessionStart.length === 0) {
delete hooks.SessionStart;
} else {
hooks.SessionStart = sessionStart;
Comment thread
coderabbitai[bot] marked this conversation as resolved.
}
if (Object.keys(hooks).length === 0) {
delete settings.hooks;
} else {
settings.hooks = hooks;
}

fsSync.writeFileSync(
settingsPath,
JSON.stringify(settings, null, 2) + '\n',
'utf8'
);

console.log(chalk.green('Auto-reapply hook removed.'));
console.log(chalk.dim(`Location: ${settingsPath}`));
}

process.exit(0);
}

/**
* Handles the --apply flag for non-interactive mode.
* All errors in detection will throw with detailed messages.
Expand All @@ -342,24 +507,27 @@ const main = async () => {
*/
async function handleApplyMode(
patchFilter: string[] | null,
configUrl?: string
configUrl?: string,
quiet: boolean = false
): Promise<void> {
console.log('Applying saved customizations to Claude Code...');
const log = quiet ? () => {} : console.log.bind(console);

log('Applying saved customizations to Claude Code...');

// Read the configuration (from URL or local file)
let config;
if (configUrl) {
console.log(`Fetching configuration from: ${configUrl}`);
log(`Fetching configuration from: ${configUrl}`);
try {
config = await fetchConfigFromUrl(configUrl);
console.log('Configuration fetched successfully.');
log('Configuration fetched successfully.');
} catch (error) {
const message = error instanceof Error ? error.message : String(error);
console.error(chalk.red(`Error: ${message}`));
process.exit(1);
}
} else {
console.log(`Configuration saved at: ${CONFIG_FILE}`);
log(`Configuration saved at: ${CONFIG_FILE}`);
config = await readConfigFile();
}

Expand All @@ -383,37 +551,39 @@ async function handleApplyMode(
const { ccInstInfo } = result.startupCheckInfo;

if (ccInstInfo.nativeInstallationPath) {
console.log(
log(
`Found Claude Code (native installation): ${ccInstInfo.nativeInstallationPath}`
);
} else {
console.log(`Found Claude Code at: ${ccInstInfo.cliPath}`);
log(`Found Claude Code at: ${ccInstInfo.cliPath}`);
}
console.log(`Version: ${ccInstInfo.version}`);
log(`Version: ${ccInstInfo.version}`);

// Preload strings file for system prompts
console.log('Loading system prompts...');
log('Loading system prompts...');
const preloadResult = await preloadStringsFile(ccInstInfo.version);
if (!preloadResult.success) {
console.log(chalk.red('\n✖ Error downloading system prompts:'));
console.log(chalk.red(` ${preloadResult.errorMessage}`));
console.log(
log(chalk.red('\n✖ Error downloading system prompts:'));
log(chalk.red(` ${preloadResult.errorMessage}`));
log(
chalk.yellow(
'\n⚠ System prompts not available - skipping system prompt customizations'
)
);
}

// Apply the customizations
console.log('Applying customizations...');
log('Applying customizations...');
const { results } = await applyCustomization(
config,
ccInstInfo,
patchFilter
);

// Print patch results
printPatchResults(results, patchFilter);
if (!quiet) {
printPatchResults(results, patchFilter);
}

// Check if any patches failed
const hasFailures = results.some(r => r.failed);
Expand All @@ -422,28 +592,28 @@ async function handleApplyMode(
);

if (hasFailures) {
console.log(chalk.yellow('Customizations applied with some failures.'));
console.log(
log(chalk.yellow('Customizations applied with some failures.'));
log(
chalk.dim(
'These patching errors do not affect your system prompt patches.'
)
);
if (hasSystemPromptChanges) {
console.log(
log(
chalk.dim(
'Your system prompt customizations were still applied successfully.'
)
);
}
console.log(
log(
chalk.dim(
'Please open an issue on https://github.com/Piebald-AI/tweakcc/issues/new reporting these patching errors.'
)
);
} else {
console.log(chalk.green('Customizations applied successfully!'));
log(chalk.green('Customizations applied successfully!'));
}
console.log(
log(
chalk.dim(
'Run with --restore/--revert to revert Claude Code to its original state.'
)
Expand Down