diff --git a/.changeset/auto-00d8d1c8b7d4b9d5.md b/.changeset/auto-00d8d1c8b7d4b9d5.md new file mode 100644 index 0000000..73b5b68 --- /dev/null +++ b/.changeset/auto-00d8d1c8b7d4b9d5.md @@ -0,0 +1,5 @@ +--- +"claude-auto": patch +--- + +- Removed unused firstSetupRequired field from hook state, simplifying the configuration interface diff --git a/.changeset/auto-03859f8c212486b9.md b/.changeset/auto-03859f8c212486b9.md new file mode 100644 index 0000000..23feead --- /dev/null +++ b/.changeset/auto-03859f8c212486b9.md @@ -0,0 +1,5 @@ +--- +"claude-auto": patch +--- + +- Planned upcoming work to wrap the init hint message as a directive diff --git a/.changeset/auto-07d0cbbbc5cd960b.md b/.changeset/auto-07d0cbbbc5cd960b.md new file mode 100644 index 0000000..0fb6db8 --- /dev/null +++ b/.changeset/auto-07d0cbbbc5cd960b.md @@ -0,0 +1,5 @@ +--- +"claude-auto": patch +--- + +- Simplified the initialization hint message to a plain one-line reminder for clearer, less intrusive guidance diff --git a/.changeset/auto-0aeb92648e0f883a.md b/.changeset/auto-0aeb92648e0f883a.md new file mode 100644 index 0000000..f56e5b3 --- /dev/null +++ b/.changeset/auto-0aeb92648e0f883a.md @@ -0,0 +1,5 @@ +--- +"claude-auto": patch +--- + +- Planned upcoming fix for skill-name handling diff --git a/.changeset/auto-103b214fd7e5f524.md b/.changeset/auto-103b214fd7e5f524.md new file mode 100644 index 0000000..bec9641 --- /dev/null +++ b/.changeset/auto-103b214fd7e5f524.md @@ -0,0 +1,5 @@ +--- +"claude-auto": patch +--- + +- Fixed init hint message not appearing in sessions by ensuring Claude surfaces it to the user diff --git a/.changeset/auto-114006803163210a.md b/.changeset/auto-114006803163210a.md new file mode 100644 index 0000000..d41bf60 --- /dev/null +++ b/.changeset/auto-114006803163210a.md @@ -0,0 +1,5 @@ +--- +"claude-auto": patch +--- + +- Updated internal planning documentation to reflect completed work diff --git a/.changeset/auto-13e2896eba0dd5dc.md b/.changeset/auto-13e2896eba0dd5dc.md new file mode 100644 index 0000000..594858a --- /dev/null +++ b/.changeset/auto-13e2896eba0dd5dc.md @@ -0,0 +1,5 @@ +--- +"claude-auto": patch +--- + +- Renamed skills to use consistent `/claude-auto-*` naming pattern (claude-auto-init and claude-auto-config) diff --git a/.changeset/auto-18be352e0fd346e0.md b/.changeset/auto-18be352e0fd346e0.md new file mode 100644 index 0000000..ec7a236 --- /dev/null +++ b/.changeset/auto-18be352e0fd346e0.md @@ -0,0 +1,6 @@ +--- +"claude-auto": minor +--- + +- Added `/claude-auto init` skill that sets up claude-auto in a project with a guided initialization flow +- Included esbuild bundling configuration for the new init skill entry point diff --git a/.changeset/auto-2eda8b072fdf990a.md b/.changeset/auto-2eda8b072fdf990a.md new file mode 100644 index 0000000..cff95b4 --- /dev/null +++ b/.changeset/auto-2eda8b072fdf990a.md @@ -0,0 +1,5 @@ +--- +"claude-auto": minor +--- + +- Loggers become silent no-ops when the auto directory does not exist, preventing errors in unconfigured environments diff --git a/.changeset/auto-527d22f7c1158c63.md b/.changeset/auto-527d22f7c1158c63.md new file mode 100644 index 0000000..41203e8 --- /dev/null +++ b/.changeset/auto-527d22f7c1158c63.md @@ -0,0 +1,9 @@ +--- +"claude-auto": minor +--- + +- Switched to plugin-only mode, removing the legacy npx/CLI installation system entirely +- Added plugin marketplace support for easier installation via Claude Code's plugin system +- Added runtime configuration skill for managing validators and reminders with overrides +- Fixed path resolution and commit validation settings when running as a plugin +- Rewrote all documentation for the new plugin-only workflow diff --git a/.changeset/auto-5a4164dedf5b798c.md b/.changeset/auto-5a4164dedf5b798c.md new file mode 100644 index 0000000..1a7bed2 --- /dev/null +++ b/.changeset/auto-5a4164dedf5b798c.md @@ -0,0 +1,6 @@ +--- +"claude-auto": minor +--- + +- Refreshed init output with emojis for better readability +- Removed interrupt directive from init messaging diff --git a/.changeset/auto-5c66880199819e15.md b/.changeset/auto-5c66880199819e15.md new file mode 100644 index 0000000..a9c1ab2 --- /dev/null +++ b/.changeset/auto-5c66880199819e15.md @@ -0,0 +1,5 @@ +--- +"claude-auto": patch +--- + +- Updated internal ketchup plan to reflect completed burst 7.4 diff --git a/.changeset/auto-608cf294b022200a.md b/.changeset/auto-608cf294b022200a.md new file mode 100644 index 0000000..c7d3859 --- /dev/null +++ b/.changeset/auto-608cf294b022200a.md @@ -0,0 +1,5 @@ +--- +"claude-auto": patch +--- + +- Planned simplification of the session-start init hint message diff --git a/.changeset/auto-6cd909f9c1020e67.md b/.changeset/auto-6cd909f9c1020e67.md new file mode 100644 index 0000000..38d7ad4 --- /dev/null +++ b/.changeset/auto-6cd909f9c1020e67.md @@ -0,0 +1,5 @@ +--- +"claude-auto": patch +--- + +- Improved hook state initialization to avoid creating unnecessary directories diff --git a/.changeset/auto-72b5e9ed34f04950.md b/.changeset/auto-72b5e9ed34f04950.md new file mode 100644 index 0000000..e13c3bc --- /dev/null +++ b/.changeset/auto-72b5e9ed34f04950.md @@ -0,0 +1,5 @@ +--- +"claude-auto": patch +--- + +- Marked burst 7.1 as complete in the ketchup plan diff --git a/.changeset/auto-76da04a23593ce0e.md b/.changeset/auto-76da04a23593ce0e.md new file mode 100644 index 0000000..e636e3e --- /dev/null +++ b/.changeset/auto-76da04a23593ce0e.md @@ -0,0 +1,5 @@ +--- +"claude-auto": patch +--- + +- Updated internal planning notes to reflect completed work diff --git a/.changeset/auto-7ee4b538ca7b1e61.md b/.changeset/auto-7ee4b538ca7b1e61.md new file mode 100644 index 0000000..dfe1466 --- /dev/null +++ b/.changeset/auto-7ee4b538ca7b1e61.md @@ -0,0 +1,5 @@ +--- +"claude-auto": patch +--- + +- Fixed incorrect skill name in initialization hint so users see the correct `/claude-auto-init` command diff --git a/.changeset/auto-8a3650020678282b.md b/.changeset/auto-8a3650020678282b.md new file mode 100644 index 0000000..b555d52 --- /dev/null +++ b/.changeset/auto-8a3650020678282b.md @@ -0,0 +1,6 @@ +--- +"claude-auto": minor +--- + +- Added `initClaudeAuto` function that sets up the `.claude-auto` directory with default configuration +- Automatically detects and updates `.gitignore` to exclude generated files diff --git a/.changeset/auto-8abcc38b44c748f9.md b/.changeset/auto-8abcc38b44c748f9.md new file mode 100644 index 0000000..ff64699 --- /dev/null +++ b/.changeset/auto-8abcc38b44c748f9.md @@ -0,0 +1,5 @@ +--- +"claude-auto": patch +--- + +- Changed the initialization hint message to be a directive, so Claude now actively mentions the hint to the user in its first response instead of silently absorbing it diff --git a/.changeset/auto-8d572fc9287f961a.md b/.changeset/auto-8d572fc9287f961a.md new file mode 100644 index 0000000..76cbba0 --- /dev/null +++ b/.changeset/auto-8d572fc9287f961a.md @@ -0,0 +1,5 @@ +--- +"claude-auto": minor +--- + +- Pre-tool-use and auto-continue hooks now return early when the auto directory is missing, avoiding errors in unconfigured projects diff --git a/.changeset/auto-9f0c051f2b2f0c71.md b/.changeset/auto-9f0c051f2b2f0c71.md new file mode 100644 index 0000000..ff2d198 --- /dev/null +++ b/.changeset/auto-9f0c051f2b2f0c71.md @@ -0,0 +1,8 @@ +--- +"claude-auto": patch +--- + +- Updated all documentation to reflect the new opt-in activation model and renamed skills +- Renamed `/claude-auto:config` to `/claude-auto-config` across all docs +- Added references to `/claude-auto-init` for opt-in repository activation +- Updated installation and getting-started guides to reflect the new plugin workflow diff --git a/.changeset/auto-a276c3aa9bb22e96.md b/.changeset/auto-a276c3aa9bb22e96.md new file mode 100644 index 0000000..23cc910 --- /dev/null +++ b/.changeset/auto-a276c3aa9bb22e96.md @@ -0,0 +1,5 @@ +--- +"claude-auto": patch +--- + +- Planned upcoming improvement to wrap the init configuration tip in a directive for better visibility diff --git a/.changeset/auto-a569e12b1fcfbf53.md b/.changeset/auto-a569e12b1fcfbf53.md new file mode 100644 index 0000000..5b8dce7 --- /dev/null +++ b/.changeset/auto-a569e12b1fcfbf53.md @@ -0,0 +1,5 @@ +--- +"claude-auto": patch +--- + +- Fixed init config tip so Claude reliably surfaces it to users diff --git a/.changeset/auto-b5b2b91dae49881e.md b/.changeset/auto-b5b2b91dae49881e.md new file mode 100644 index 0000000..be8110b --- /dev/null +++ b/.changeset/auto-b5b2b91dae49881e.md @@ -0,0 +1,5 @@ +--- +"claude-auto": patch +--- + +- Updated ketchup plan to reflect completed bursts diff --git a/.changeset/auto-c36b4f31bff1dd98.md b/.changeset/auto-c36b4f31bff1dd98.md new file mode 100644 index 0000000..a4172e6 --- /dev/null +++ b/.changeset/auto-c36b4f31bff1dd98.md @@ -0,0 +1,6 @@ +--- +"claude-auto": minor +--- + +- Simplified initial setup by removing the first-setup-required flag +- Hook state now returns sensible defaults when the auto directory doesn't exist yet, preventing errors on fresh installations diff --git a/.changeset/auto-cc43d2e6e4f7dee7.md b/.changeset/auto-cc43d2e6e4f7dee7.md new file mode 100644 index 0000000..3c3153b --- /dev/null +++ b/.changeset/auto-cc43d2e6e4f7dee7.md @@ -0,0 +1,5 @@ +--- +"claude-auto": patch +--- + +- Planned non-interrupting init message burst diff --git a/.changeset/auto-d2443bac99df8c22.md b/.changeset/auto-d2443bac99df8c22.md new file mode 100644 index 0000000..f3f5862 --- /dev/null +++ b/.changeset/auto-d2443bac99df8c22.md @@ -0,0 +1,5 @@ +--- +"claude-auto": patch +--- + +- Planned emoji-decorated styling for the initialization hint message diff --git a/.changeset/auto-da5d347ee9523406.md b/.changeset/auto-da5d347ee9523406.md new file mode 100644 index 0000000..ef0a694 --- /dev/null +++ b/.changeset/auto-da5d347ee9523406.md @@ -0,0 +1,5 @@ +--- +"claude-auto": minor +--- + +- Added a prompt after initialization that asks users if they want to review or customize their configuration diff --git a/.changeset/auto-de781e018e6b3e35.md b/.changeset/auto-de781e018e6b3e35.md new file mode 100644 index 0000000..e804ba4 --- /dev/null +++ b/.changeset/auto-de781e018e6b3e35.md @@ -0,0 +1,5 @@ +--- +"claude-auto": minor +--- + +- Added emojis to the initialization hint message for better visibility diff --git a/.changeset/auto-ecd9b8349c13aa41.md b/.changeset/auto-ecd9b8349c13aa41.md new file mode 100644 index 0000000..199c0ae --- /dev/null +++ b/.changeset/auto-ecd9b8349c13aa41.md @@ -0,0 +1,5 @@ +--- +"claude-auto": minor +--- + +- Added human-readable formatting for init command output diff --git a/.changeset/auto-ed0bb5e5f8ebb378.md b/.changeset/auto-ed0bb5e5f8ebb378.md new file mode 100644 index 0000000..9ad0b5f --- /dev/null +++ b/.changeset/auto-ed0bb5e5f8ebb378.md @@ -0,0 +1,6 @@ +--- +"claude-auto": minor +--- + +- Replaced blocking first-setup flow with a non-blocking hint message shown on session start when the project is not yet initialized +- Hooks now silently return early in uninitialized repos instead of interrupting the user diff --git a/.changeset/auto-f0f2f04512b4ac0b.md b/.changeset/auto-f0f2f04512b4ac0b.md new file mode 100644 index 0000000..fb74a38 --- /dev/null +++ b/.changeset/auto-f0f2f04512b4ac0b.md @@ -0,0 +1,5 @@ +--- +"claude-auto": minor +--- + +- Write and update operations now silently skip when the auto directory is missing, preventing errors in unconfigured projects diff --git a/CLAUDE.md b/CLAUDE.md index a3eac91..e72cf51 100644 --- a/CLAUDE.md +++ b/CLAUDE.md @@ -71,7 +71,7 @@ Both are markdown files with YAML frontmatter. Validators gate commits (ACK/NACK ### Installation Model -Claude Auto runs as a Claude Code plugin. Install via `/plugin marketplace add BeOnAuto/auto-plugins` or `claude --plugin-dir /path/to/claude-auto`. The plugin provides validators, reminders, and hook scripts. Projects can add local overrides in `.claude-auto/`. +Claude Auto runs as a Claude Code plugin. Install via `/plugin marketplace add BeOnAuto/auto-plugins` or `claude --plugin-dir /path/to/claude-auto`. The plugin is opt-in per repository: hooks are inactive until the user runs `/claude-auto-init`, which creates `.claude-auto/` with default config. Without initialization, session-start shows a non-blocking hint. The plugin provides validators, reminders, and hook scripts. Projects can add local overrides in `.claude-auto/`. ## Coding Patterns diff --git a/README.md b/README.md index 9ad87cf..51619e8 100644 --- a/README.md +++ b/README.md @@ -40,7 +40,7 @@ Inside any Claude Code session: claude --plugin-dir /path/to/claude-auto ``` -Claude Code sets `CLAUDE_PLUGIN_ROOT` and `CLAUDE_PLUGIN_DATA` automatically. Validators and reminders load from the plugin package, with optional project-local overrides from `.claude-auto/`. State and logs go to the project's `.claude-auto/` directory. +Claude Code sets `CLAUDE_PLUGIN_ROOT` and `CLAUDE_PLUGIN_DATA` automatically. Run `/claude-auto-init` inside a session to activate per-project configuration, validators, and logging. ## Quick Start @@ -53,11 +53,13 @@ Claude Code sets `CLAUDE_PLUGIN_ROOT` and `CLAUDE_PLUGIN_DATA` automatically. Va claude --plugin-dir /path/to/claude-auto ``` -After installation, Claude Auto automatically: +After installation, Claude will mention that claude-auto is available. To activate it in a project: -- Injects hooks that validate every commit against your criteria -- Creates reminders that inject your guidelines into prompts -- Sets up file protection via deny-lists +``` +/claude-auto-init +``` + +This creates `.claude-auto/` with default configuration. You can add it to `.gitignore` for personal use, or commit it for the whole team. **Next steps:** @@ -121,10 +123,10 @@ Higher `priority` = appears first. Project-local files are loaded alongside plug Toggle validators and reminders without editing files: ```bash -/claude-auto:config show -/claude-auto:config validators disable no-comments -/claude-auto:config reminders priority my-reminder 200 -/claude-auto:config reminders add my-rule --hook UserPromptSubmit --priority 50 --content "Always use early returns" +/claude-auto-config show +/claude-auto-config validators disable no-comments +/claude-auto-config reminders priority my-reminder 200 +/claude-auto-config reminders add my-rule --hook UserPromptSubmit --priority 50 --content "Always use early returns" ``` --- diff --git a/dist/bundle/scripts/auto-continue.js b/dist/bundle/scripts/auto-continue.js index e4ed63c..2e780bd 100755 --- a/dist/bundle/scripts/auto-continue.js +++ b/dist/bundle/scripts/auto-continue.js @@ -48,6 +48,9 @@ function matchesFilter(hookName, message) { return includes.some((pattern) => searchText.includes(pattern)); } function activityLog(autoDir, sessionId, hookName, message) { + if (!import_node_fs.default.existsSync(autoDir)) { + return; + } if (!matchesFilter(hookName, message)) { return; } @@ -76,6 +79,9 @@ function sanitizeForFilename(hookName) { return hookName.replace(/[^a-zA-Z0-9-]/g, "-").toLowerCase(); } function writeHookLog(autoDir, entry) { + if (!fs2.existsSync(autoDir)) { + return; + } const logsDir = path2.join(autoDir, "logs", "hooks"); if (!fs2.existsSync(logsDir)) { fs2.mkdirSync(logsDir, { recursive: true }); @@ -125,6 +131,9 @@ function writeHookLog(autoDir, entry) { `); } +// src/hooks/auto-continue.ts +var import_node_fs2 = require("node:fs"); + // src/hook-state.ts var fs3 = __toESM(require("node:fs")); var path3 = __toESM(require("node:path")); @@ -156,14 +165,13 @@ var DEFAULT_HOOK_STATE = { } }; function createHookState(autoDir) { - if (!fs3.existsSync(autoDir)) { - fs3.mkdirSync(autoDir, { recursive: true }); - } const stateFile = path3.join(autoDir, ".claude.hooks.json"); function read() { + if (!fs3.existsSync(autoDir)) { + return { ...DEFAULT_HOOK_STATE }; + } if (!fs3.existsSync(stateFile)) { - const isPluginMode = !!process.env.CLAUDE_PLUGIN_ROOT; - const initialState = isPluginMode ? { ...DEFAULT_HOOK_STATE, firstSetupRequired: true } : { ...DEFAULT_HOOK_STATE }; + const initialState = { ...DEFAULT_HOOK_STATE }; fs3.writeFileSync(stateFile, `${JSON.stringify(initialState, null, 2)} `); return JSON.parse(JSON.stringify(initialState)); @@ -171,7 +179,6 @@ function createHookState(autoDir) { const content = fs3.readFileSync(stateFile, "utf-8"); const partial = JSON.parse(content); return { - ...partial.firstSetupRequired !== void 0 ? { firstSetupRequired: partial.firstSetupRequired } : {}, autoContinue: { ...DEFAULT_HOOK_STATE.autoContinue, ...partial.autoContinue }, validateCommit: { ...DEFAULT_HOOK_STATE.validateCommit, ...partial.validateCommit }, denyList: { ...DEFAULT_HOOK_STATE.denyList, ...partial.denyList }, @@ -184,10 +191,16 @@ function createHookState(autoDir) { }; } function write(state) { + if (!fs3.existsSync(autoDir)) { + return; + } fs3.writeFileSync(stateFile, `${JSON.stringify(state, null, 2)} `); } function update(updates) { + if (!fs3.existsSync(autoDir)) { + return { ...DEFAULT_HOOK_STATE }; + } const current = read(); const newState = { ...current, @@ -218,6 +231,9 @@ function createHookState(autoDir) { // src/hooks/auto-continue.ts function handleStop(autoDir, input2) { + if (!(0, import_node_fs2.existsSync)(autoDir)) { + return { decision: "allow", reason: "auto-continue disabled" }; + } const stateManager = createHookState(autoDir); const state = stateManager.read(); const { mode, skipModes } = state.autoContinue; @@ -279,9 +295,11 @@ function logPluginDiagnostics(hookName, paths) { if (isDebug) { console.error(message); } - const logsDir = path5.join(paths.autoDir, "logs"); - fs4.mkdirSync(logsDir, { recursive: true }); - fs4.appendFileSync(path5.join(logsDir, "plugin-debug.log"), message); + if (fs4.existsSync(paths.autoDir)) { + const logsDir = path5.join(paths.autoDir, "logs"); + fs4.mkdirSync(logsDir, { recursive: true }); + fs4.appendFileSync(path5.join(logsDir, "plugin-debug.log"), message); + } } // scripts/auto-continue.ts diff --git a/dist/bundle/scripts/config.js b/dist/bundle/scripts/config.js index aebbf1d..764a50e 100755 --- a/dist/bundle/scripts/config.js +++ b/dist/bundle/scripts/config.js @@ -3547,14 +3547,13 @@ var DEFAULT_HOOK_STATE = { } }; function createHookState(autoDir) { - if (!fs.existsSync(autoDir)) { - fs.mkdirSync(autoDir, { recursive: true }); - } const stateFile = path.join(autoDir, ".claude.hooks.json"); function read() { + if (!fs.existsSync(autoDir)) { + return { ...DEFAULT_HOOK_STATE }; + } if (!fs.existsSync(stateFile)) { - const isPluginMode = !!process.env.CLAUDE_PLUGIN_ROOT; - const initialState = isPluginMode ? { ...DEFAULT_HOOK_STATE, firstSetupRequired: true } : { ...DEFAULT_HOOK_STATE }; + const initialState = { ...DEFAULT_HOOK_STATE }; fs.writeFileSync(stateFile, `${JSON.stringify(initialState, null, 2)} `); return JSON.parse(JSON.stringify(initialState)); @@ -3562,7 +3561,6 @@ function createHookState(autoDir) { const content = fs.readFileSync(stateFile, "utf-8"); const partial = JSON.parse(content); return { - ...partial.firstSetupRequired !== void 0 ? { firstSetupRequired: partial.firstSetupRequired } : {}, autoContinue: { ...DEFAULT_HOOK_STATE.autoContinue, ...partial.autoContinue }, validateCommit: { ...DEFAULT_HOOK_STATE.validateCommit, ...partial.validateCommit }, denyList: { ...DEFAULT_HOOK_STATE.denyList, ...partial.denyList }, @@ -3575,10 +3573,16 @@ function createHookState(autoDir) { }; } function write(state) { + if (!fs.existsSync(autoDir)) { + return; + } fs.writeFileSync(stateFile, `${JSON.stringify(state, null, 2)} `); } function update(updates) { + if (!fs.existsSync(autoDir)) { + return { ...DEFAULT_HOOK_STATE }; + } const current = read(); const newState = { ...current, @@ -3827,7 +3831,7 @@ function derivePluginRoot() { var args = process.argv.slice(2); var subcommand = args[0]; function usage() { - return `Usage: /claude-auto:config [args] + return `Usage: /claude-auto-config [args] Subcommands: show Show all current configuration diff --git a/dist/bundle/scripts/init.js b/dist/bundle/scripts/init.js new file mode 100755 index 0000000..2c59a94 --- /dev/null +++ b/dist/bundle/scripts/init.js @@ -0,0 +1,103 @@ +#!/usr/bin/env npx tsx +"use strict"; +var __create = Object.create; +var __defProp = Object.defineProperty; +var __getOwnPropDesc = Object.getOwnPropertyDescriptor; +var __getOwnPropNames = Object.getOwnPropertyNames; +var __getProtoOf = Object.getPrototypeOf; +var __hasOwnProp = Object.prototype.hasOwnProperty; +var __copyProps = (to, from, except, desc) => { + if (from && typeof from === "object" || typeof from === "function") { + for (let key of __getOwnPropNames(from)) + if (!__hasOwnProp.call(to, key) && key !== except) + __defProp(to, key, { get: () => from[key], enumerable: !(desc = __getOwnPropDesc(from, key)) || desc.enumerable }); + } + return to; +}; +var __toESM = (mod, isNodeMode, target) => (target = mod != null ? __create(__getProtoOf(mod)) : {}, __copyProps( + // If the importer is in node compatibility mode or this is not an ESM + // file that has been converted to a CommonJS file using a Babel- + // compatible transform (i.e. "__esModule" has not been set), then set + // "default" to the CommonJS "module.exports" for node compatibility. + isNodeMode || !mod || !mod.__esModule ? __defProp(target, "default", { value: mod, enumerable: true }) : target, + mod +)); + +// src/init.ts +var fs = __toESM(require("node:fs")); +var path = __toESM(require("node:path")); + +// src/hook-state.ts +var DEFAULT_HOOK_STATE = { + autoContinue: { + mode: "smart", + maxIterations: 0, + skipModes: ["plan"] + }, + validateCommit: { + mode: "strict", + batchCount: 3 + }, + denyList: { + enabled: true, + extraPatterns: [] + }, + promptReminder: { + enabled: true + }, + subagentHooks: { + validateCommitOnExplore: false, + validateCommitOnWork: true, + validateCommitOnUnknown: true + }, + overrides: { + validators: {}, + reminders: {} + } +}; + +// src/init.ts +function initClaudeAuto(projectRoot) { + const autoDir = path.join(projectRoot, ".claude-auto"); + if (fs.existsSync(autoDir)) { + return { created: false, autoDir, gitignoreAdvice: checkGitignoreAdvice(projectRoot) }; + } + fs.mkdirSync(autoDir, { recursive: true }); + const stateFile = path.join(autoDir, ".claude.hooks.json"); + fs.writeFileSync(stateFile, `${JSON.stringify(DEFAULT_HOOK_STATE, null, 2)} +`); + return { created: true, autoDir, gitignoreAdvice: checkGitignoreAdvice(projectRoot) }; +} +function formatInitResult(result2) { + const lines = []; + if (result2.created) { + lines.push(`\u2705 Initialized claude-auto at ${result2.autoDir}`); + lines.push("\u{1F3AF} Default configuration written to .claude-auto/.claude.hooks.json"); + if (result2.gitignoreAdvice) { + lines.push(""); + lines.push("\u{1F4CC} Note: .claude-auto is not in your .gitignore."); + lines.push(" If this is for personal use only, consider adding it:"); + lines.push(' echo ".claude-auto" >> .gitignore'); + } + lines.push(""); + lines.push( + `On your next reply, mention once (then proceed with the user's request): "Reminder: Defaults are active \u2014 run /claude-auto-config show anytime to review or customize."` + ); + } else { + lines.push(`\u2705 claude-auto is already initialized at ${result2.autoDir}`); + } + return lines.join("\n"); +} +function checkGitignoreAdvice(projectRoot) { + const gitignorePath = path.join(projectRoot, ".gitignore"); + if (!fs.existsSync(gitignorePath)) { + return true; + } + const content = fs.readFileSync(gitignorePath, "utf-8"); + const lines = content.split("\n").map((l) => l.trim()); + return !lines.some((line) => line === ".claude-auto" || line === ".claude-auto/"); +} + +// scripts/init.ts +var result = initClaudeAuto(process.cwd()); +console.log(formatInitResult(result)); diff --git a/dist/bundle/scripts/pre-tool-use.js b/dist/bundle/scripts/pre-tool-use.js index f49fb8f..04f42cd 100755 --- a/dist/bundle/scripts/pre-tool-use.js +++ b/dist/bundle/scripts/pre-tool-use.js @@ -6150,7 +6150,7 @@ var require_parse3 = __commonJS({ var require_gray_matter = __commonJS({ "node_modules/.pnpm/gray-matter@4.0.3/node_modules/gray-matter/index.js"(exports2, module2) { "use strict"; - var fs10 = require("fs"); + var fs11 = require("fs"); var sections = require_section_matter(); var defaults = require_defaults(); var stringify = require_stringify2(); @@ -6234,7 +6234,7 @@ var require_gray_matter = __commonJS({ return stringify(file, data, options2); }; matter3.read = function(filepath, options2) { - const str2 = fs10.readFileSync(filepath, "utf8"); + const str2 = fs11.readFileSync(filepath, "utf8"); const file = matter3(str2, options2); file.path = filepath; return file; @@ -6263,7 +6263,7 @@ var require_gray_matter = __commonJS({ }); // scripts/pre-tool-use.ts -var fs9 = __toESM(require("node:fs")); +var fs10 = __toESM(require("node:fs")); // src/activity-logger.ts var import_node_fs = __toESM(require("node:fs")); @@ -6287,6 +6287,9 @@ function matchesFilter(hookName, message) { return includes.some((pattern) => searchText.includes(pattern)); } function activityLog(autoDir, sessionId, hookName, message) { + if (!import_node_fs.default.existsSync(autoDir)) { + return; + } if (!matchesFilter(hookName, message)) { return; } @@ -6509,6 +6512,9 @@ function sanitizeForFilename(hookName) { return hookName.replace(/[^a-zA-Z0-9-]/g, "-").toLowerCase(); } function writeHookLog(autoDir, entry) { + if (!fs2.existsSync(autoDir)) { + return; + } const logsDir = path2.join(autoDir, "logs", "hooks"); if (!fs2.existsSync(logsDir)) { fs2.mkdirSync(logsDir, { recursive: true }); @@ -6558,10 +6564,16 @@ function writeHookLog(autoDir, entry) { `); } +// src/hooks/pre-tool-use.ts +var fs8 = __toESM(require("node:fs")); + // src/debug-logger.ts var import_node_fs2 = __toESM(require("node:fs")); var import_node_path2 = __toESM(require("node:path")); function debugLog(autoDir, hookName, message) { + if (!import_node_fs2.default.existsSync(autoDir)) { + return; + } const debug = process.env.DEBUG; if (!debug || !debug.includes("claude-auto")) { return; @@ -6628,14 +6640,13 @@ var DEFAULT_HOOK_STATE = { } }; function createHookState(autoDir) { - if (!fs5.existsSync(autoDir)) { - fs5.mkdirSync(autoDir, { recursive: true }); - } const stateFile = path5.join(autoDir, ".claude.hooks.json"); function read() { + if (!fs5.existsSync(autoDir)) { + return { ...DEFAULT_HOOK_STATE }; + } if (!fs5.existsSync(stateFile)) { - const isPluginMode = !!process.env.CLAUDE_PLUGIN_ROOT; - const initialState = isPluginMode ? { ...DEFAULT_HOOK_STATE, firstSetupRequired: true } : { ...DEFAULT_HOOK_STATE }; + const initialState = { ...DEFAULT_HOOK_STATE }; fs5.writeFileSync(stateFile, `${JSON.stringify(initialState, null, 2)} `); return JSON.parse(JSON.stringify(initialState)); @@ -6643,7 +6654,6 @@ function createHookState(autoDir) { const content = fs5.readFileSync(stateFile, "utf-8"); const partial = JSON.parse(content); return { - ...partial.firstSetupRequired !== void 0 ? { firstSetupRequired: partial.firstSetupRequired } : {}, autoContinue: { ...DEFAULT_HOOK_STATE.autoContinue, ...partial.autoContinue }, validateCommit: { ...DEFAULT_HOOK_STATE.validateCommit, ...partial.validateCommit }, denyList: { ...DEFAULT_HOOK_STATE.denyList, ...partial.denyList }, @@ -6656,10 +6666,16 @@ function createHookState(autoDir) { }; } function write(state) { + if (!fs5.existsSync(autoDir)) { + return; + } fs5.writeFileSync(stateFile, `${JSON.stringify(state, null, 2)} `); } function update(updates) { + if (!fs5.existsSync(autoDir)) { + return { ...DEFAULT_HOOK_STATE }; + } const current = read(); const newState = { ...current, @@ -6784,6 +6800,14 @@ function loadValidators(dirs, overrides) { // src/hooks/pre-tool-use.ts async function handlePreToolUse(paths, sessionId, toolInput, options2 = {}) { + if (!fs8.existsSync(paths.autoDir)) { + return { + hookSpecificOutput: { + hookEventName: "PreToolUse", + permissionDecision: "allow" + } + }; + } const command = toolInput.command; if (command && isCommitCommand(command)) { const gitCwd = options2.cwd ?? process.cwd(); @@ -6899,7 +6923,7 @@ async function resolvePathsFromEnv(explicitPluginRoot) { } // src/plugin-debug.ts -var fs8 = __toESM(require("node:fs")); +var fs9 = __toESM(require("node:fs")); var path9 = __toESM(require("node:path")); function logPluginDiagnostics(hookName, paths) { const isPluginMode = !!process.env.CLAUDE_PLUGIN_ROOT; @@ -6923,13 +6947,15 @@ function logPluginDiagnostics(hookName, paths) { if (isDebug) { console.error(message); } - const logsDir = path9.join(paths.autoDir, "logs"); - fs8.mkdirSync(logsDir, { recursive: true }); - fs8.appendFileSync(path9.join(logsDir, "plugin-debug.log"), message); + if (fs9.existsSync(paths.autoDir)) { + const logsDir = path9.join(paths.autoDir, "logs"); + fs9.mkdirSync(logsDir, { recursive: true }); + fs9.appendFileSync(path9.join(logsDir, "plugin-debug.log"), message); + } } // scripts/pre-tool-use.ts -var input = parseHookInput(fs9.readFileSync(0, "utf-8")); +var input = parseHookInput(fs10.readFileSync(0, "utf-8")); var startTime = Date.now(); (async () => { const paths = await resolvePathsFromEnv(); diff --git a/dist/bundle/scripts/session-start.js b/dist/bundle/scripts/session-start.js index b54851f..c8351ea 100755 --- a/dist/bundle/scripts/session-start.js +++ b/dist/bundle/scripts/session-start.js @@ -3397,7 +3397,7 @@ var require_parse = __commonJS({ var require_gray_matter = __commonJS({ "node_modules/.pnpm/gray-matter@4.0.3/node_modules/gray-matter/index.js"(exports2, module2) { "use strict"; - var fs8 = require("fs"); + var fs9 = require("fs"); var sections = require_section_matter(); var defaults = require_defaults(); var stringify = require_stringify(); @@ -3481,7 +3481,7 @@ var require_gray_matter = __commonJS({ return stringify(file, data, options2); }; matter2.read = function(filepath, options2) { - const str2 = fs8.readFileSync(filepath, "utf8"); + const str2 = fs9.readFileSync(filepath, "utf8"); const file = matter2(str2, options2); file.path = filepath; return file; @@ -3510,7 +3510,7 @@ var require_gray_matter = __commonJS({ }); // scripts/session-start.ts -var fs7 = __toESM(require("node:fs")); +var fs8 = __toESM(require("node:fs")); // src/activity-logger.ts var import_node_fs = __toESM(require("node:fs")); @@ -3534,6 +3534,9 @@ function matchesFilter(hookName, message) { return includes.some((pattern) => searchText.includes(pattern)); } function activityLog(autoDir, sessionId, hookName, message) { + if (!import_node_fs.default.existsSync(autoDir)) { + return; + } if (!matchesFilter(hookName, message)) { return; } @@ -3567,6 +3570,9 @@ function sanitizeForFilename(hookName) { return hookName.replace(/[^a-zA-Z0-9-]/g, "-").toLowerCase(); } function writeHookLog(autoDir, entry) { + if (!fs2.existsSync(autoDir)) { + return; + } const logsDir = path2.join(autoDir, "logs", "hooks"); if (!fs2.existsSync(logsDir)) { fs2.mkdirSync(logsDir, { recursive: true }); @@ -3616,10 +3622,16 @@ function writeHookLog(autoDir, entry) { `); } +// src/hooks/session-start.ts +var fs6 = __toESM(require("node:fs")); + // src/debug-logger.ts var import_node_fs2 = __toESM(require("node:fs")); var import_node_path2 = __toESM(require("node:path")); function debugLog(autoDir, hookName, message) { + if (!import_node_fs2.default.existsSync(autoDir)) { + return; + } const debug = process.env.DEBUG; if (!debug || !debug.includes("claude-auto")) { return; @@ -3666,14 +3678,13 @@ var DEFAULT_HOOK_STATE = { } }; function createHookState(autoDir) { - if (!fs4.existsSync(autoDir)) { - fs4.mkdirSync(autoDir, { recursive: true }); - } const stateFile = path4.join(autoDir, ".claude.hooks.json"); function read() { + if (!fs4.existsSync(autoDir)) { + return { ...DEFAULT_HOOK_STATE }; + } if (!fs4.existsSync(stateFile)) { - const isPluginMode = !!process.env.CLAUDE_PLUGIN_ROOT; - const initialState = isPluginMode ? { ...DEFAULT_HOOK_STATE, firstSetupRequired: true } : { ...DEFAULT_HOOK_STATE }; + const initialState = { ...DEFAULT_HOOK_STATE }; fs4.writeFileSync(stateFile, `${JSON.stringify(initialState, null, 2)} `); return JSON.parse(JSON.stringify(initialState)); @@ -3681,7 +3692,6 @@ function createHookState(autoDir) { const content = fs4.readFileSync(stateFile, "utf-8"); const partial = JSON.parse(content); return { - ...partial.firstSetupRequired !== void 0 ? { firstSetupRequired: partial.firstSetupRequired } : {}, autoContinue: { ...DEFAULT_HOOK_STATE.autoContinue, ...partial.autoContinue }, validateCommit: { ...DEFAULT_HOOK_STATE.validateCommit, ...partial.validateCommit }, denyList: { ...DEFAULT_HOOK_STATE.denyList, ...partial.denyList }, @@ -3694,10 +3704,16 @@ function createHookState(autoDir) { }; } function write(state) { + if (!fs4.existsSync(autoDir)) { + return; + } fs4.writeFileSync(stateFile, `${JSON.stringify(state, null, 2)} `); } function update(updates) { + if (!fs4.existsSync(autoDir)) { + return { ...DEFAULT_HOOK_STATE }; + } const current = read(); const newState = { ...current, @@ -3784,8 +3800,24 @@ function loadReminders(dirs, context, overrides) { return sortByPriority(matched); } +// src/welcome-message.ts +var INIT_HINT_MESSAGE = `On your first reply, mention once (then proceed with the user's request): "Reminder: Use /claude-auto-init to enable https://claude.on.auto in this folder"`; + // src/hooks/session-start.ts async function handleSessionStart(paths, sessionId = "", agentType) { + if (!fs6.existsSync(paths.autoDir)) { + return { + hookSpecificOutput: { + hookEventName: "SessionStart", + additionalContext: INIT_HINT_MESSAGE + }, + diagnostics: { + resolvedPaths: paths, + reminderFiles: [], + matchedReminders: [] + } + }; + } const reminderFiles = paths.remindersDirs.flatMap((dir) => scanReminders(dir)); if (agentType === "validator") { activityLog(paths.autoDir, sessionId, "session-start", "skipped reminders for validator session"); @@ -3841,7 +3873,7 @@ async function resolvePathsFromEnv(explicitPluginRoot) { } // src/plugin-debug.ts -var fs6 = __toESM(require("node:fs")); +var fs7 = __toESM(require("node:fs")); var path7 = __toESM(require("node:path")); function logPluginDiagnostics(hookName, paths) { const isPluginMode = !!process.env.CLAUDE_PLUGIN_ROOT; @@ -3865,13 +3897,15 @@ function logPluginDiagnostics(hookName, paths) { if (isDebug) { console.error(message); } - const logsDir = path7.join(paths.autoDir, "logs"); - fs6.mkdirSync(logsDir, { recursive: true }); - fs6.appendFileSync(path7.join(logsDir, "plugin-debug.log"), message); + if (fs7.existsSync(paths.autoDir)) { + const logsDir = path7.join(paths.autoDir, "logs"); + fs7.mkdirSync(logsDir, { recursive: true }); + fs7.appendFileSync(path7.join(logsDir, "plugin-debug.log"), message); + } } // scripts/session-start.ts -var input = parseHookInput(fs7.readFileSync(0, "utf-8")); +var input = parseHookInput(fs8.readFileSync(0, "utf-8")); var startTime = Date.now(); (async () => { const paths = await resolvePathsFromEnv(); diff --git a/dist/bundle/scripts/user-prompt-submit.js b/dist/bundle/scripts/user-prompt-submit.js index 37594e7..2d8b2ad 100755 --- a/dist/bundle/scripts/user-prompt-submit.js +++ b/dist/bundle/scripts/user-prompt-submit.js @@ -3397,7 +3397,7 @@ var require_parse = __commonJS({ var require_gray_matter = __commonJS({ "node_modules/.pnpm/gray-matter@4.0.3/node_modules/gray-matter/index.js"(exports2, module2) { "use strict"; - var fs8 = require("fs"); + var fs9 = require("fs"); var sections = require_section_matter(); var defaults = require_defaults(); var stringify = require_stringify(); @@ -3481,7 +3481,7 @@ var require_gray_matter = __commonJS({ return stringify(file, data, options2); }; matter2.read = function(filepath, options2) { - const str2 = fs8.readFileSync(filepath, "utf8"); + const str2 = fs9.readFileSync(filepath, "utf8"); const file = matter2(str2, options2); file.path = filepath; return file; @@ -3510,7 +3510,7 @@ var require_gray_matter = __commonJS({ }); // scripts/user-prompt-submit.ts -var fs7 = __toESM(require("node:fs")); +var fs8 = __toESM(require("node:fs")); // src/activity-logger.ts var import_node_fs = __toESM(require("node:fs")); @@ -3534,6 +3534,9 @@ function matchesFilter(hookName, message) { return includes.some((pattern) => searchText.includes(pattern)); } function activityLog(autoDir, sessionId, hookName, message) { + if (!import_node_fs.default.existsSync(autoDir)) { + return; + } if (!matchesFilter(hookName, message)) { return; } @@ -3567,6 +3570,9 @@ function sanitizeForFilename(hookName) { return hookName.replace(/[^a-zA-Z0-9-]/g, "-").toLowerCase(); } function writeHookLog(autoDir, entry) { + if (!fs2.existsSync(autoDir)) { + return; + } const logsDir = path2.join(autoDir, "logs", "hooks"); if (!fs2.existsSync(logsDir)) { fs2.mkdirSync(logsDir, { recursive: true }); @@ -3616,10 +3622,16 @@ function writeHookLog(autoDir, entry) { `); } +// src/hooks/user-prompt-submit.ts +var fs6 = __toESM(require("node:fs")); + // src/debug-logger.ts var import_node_fs2 = __toESM(require("node:fs")); var import_node_path2 = __toESM(require("node:path")); function debugLog(autoDir, hookName, message) { + if (!import_node_fs2.default.existsSync(autoDir)) { + return; + } const debug = process.env.DEBUG; if (!debug || !debug.includes("claude-auto")) { return; @@ -3666,14 +3678,13 @@ var DEFAULT_HOOK_STATE = { } }; function createHookState(autoDir) { - if (!fs4.existsSync(autoDir)) { - fs4.mkdirSync(autoDir, { recursive: true }); - } const stateFile = path4.join(autoDir, ".claude.hooks.json"); function read() { + if (!fs4.existsSync(autoDir)) { + return { ...DEFAULT_HOOK_STATE }; + } if (!fs4.existsSync(stateFile)) { - const isPluginMode = !!process.env.CLAUDE_PLUGIN_ROOT; - const initialState = isPluginMode ? { ...DEFAULT_HOOK_STATE, firstSetupRequired: true } : { ...DEFAULT_HOOK_STATE }; + const initialState = { ...DEFAULT_HOOK_STATE }; fs4.writeFileSync(stateFile, `${JSON.stringify(initialState, null, 2)} `); return JSON.parse(JSON.stringify(initialState)); @@ -3681,7 +3692,6 @@ function createHookState(autoDir) { const content = fs4.readFileSync(stateFile, "utf-8"); const partial = JSON.parse(content); return { - ...partial.firstSetupRequired !== void 0 ? { firstSetupRequired: partial.firstSetupRequired } : {}, autoContinue: { ...DEFAULT_HOOK_STATE.autoContinue, ...partial.autoContinue }, validateCommit: { ...DEFAULT_HOOK_STATE.validateCommit, ...partial.validateCommit }, denyList: { ...DEFAULT_HOOK_STATE.denyList, ...partial.denyList }, @@ -3694,10 +3704,16 @@ function createHookState(autoDir) { }; } function write(state) { + if (!fs4.existsSync(autoDir)) { + return; + } fs4.writeFileSync(stateFile, `${JSON.stringify(state, null, 2)} `); } function update(updates) { + if (!fs4.existsSync(autoDir)) { + return { ...DEFAULT_HOOK_STATE }; + } const current = read(); const newState = { ...current, @@ -3793,36 +3809,9 @@ function isValidatorSession(prompt) { return hasDiff && hasCommitMessage && hasFiles; } -// src/welcome-message.ts -var FIRST_SETUP_MESSAGE = `CRITICAL INSTRUCTION \u2014 DO THIS BEFORE ANYTHING ELSE: - -The claude-auto plugin has just been installed in this project for the first time. You MUST guide the user through setup before doing any work. - -DO NOT proceed with the user's request yet. Instead: - -1. Greet the user and explain that claude-auto is now active -2. Briefly explain what it does: - - Commit validation (17 validators enforce TDD, coverage, code quality) - - Reminders injected into prompts (TCR workflow, testing rules) - - File protection via deny-lists - - Auto-continue to keep working until the plan is done -3. Run /claude-auto:config show to display the current configuration -4. Ask the user if they want to customize anything, or proceed with defaults -5. ONLY AFTER the user confirms, proceed with their original request - -The default workflow follows the Ketchup Technique: -- Create ketchup-plan.md with TODO/DONE sections before coding -- One failing test \u2192 minimal passing code \u2192 commit (TCR) -- 100% test coverage, no exceptions - -This setup runs once. After this, normal reminders will guide the workflow.`; - // src/hooks/user-prompt-submit.ts async function handleUserPromptSubmit(paths, sessionId, prompt) { - const reminderFiles = paths.remindersDirs.flatMap((dir) => scanReminders(dir)); - if (isValidatorSession(prompt)) { - activityLog(paths.autoDir, sessionId, "user-prompt-submit", "skipped reminders for validator session"); - debugLog(paths.autoDir, "user-prompt-submit", "skipped reminders for validator session"); + if (!fs6.existsSync(paths.autoDir)) { return { hookSpecificOutput: { hookEventName: "UserPromptSubmit", @@ -3830,23 +3819,19 @@ async function handleUserPromptSubmit(paths, sessionId, prompt) { }, diagnostics: { resolvedPaths: paths, - reminderFiles, + reminderFiles: [], matchedReminders: [] } }; } - const stateManager = createHookState(paths.autoDir); - const state = stateManager.read(); - if (state.firstSetupRequired) { - const updated = stateManager.read(); - delete updated.firstSetupRequired; - stateManager.write(updated); - activityLog(paths.autoDir, sessionId, "user-prompt-submit", "first-setup directive injected"); - debugLog(paths.autoDir, "user-prompt-submit", "first-setup directive injected"); + const reminderFiles = paths.remindersDirs.flatMap((dir) => scanReminders(dir)); + if (isValidatorSession(prompt)) { + activityLog(paths.autoDir, sessionId, "user-prompt-submit", "skipped reminders for validator session"); + debugLog(paths.autoDir, "user-prompt-submit", "skipped reminders for validator session"); return { hookSpecificOutput: { hookEventName: "UserPromptSubmit", - additionalContext: FIRST_SETUP_MESSAGE + additionalContext: "" }, diagnostics: { resolvedPaths: paths, @@ -3855,6 +3840,7 @@ async function handleUserPromptSubmit(paths, sessionId, prompt) { } }; } + const state = createHookState(paths.autoDir).read(); const reminders = loadReminders(paths.remindersDirs, { hook: "UserPromptSubmit" }, state.overrides.reminders); const reminderContent = reminders.map((r) => r.content).join("\n\n"); activityLog( @@ -3903,7 +3889,7 @@ async function resolvePathsFromEnv(explicitPluginRoot) { } // src/plugin-debug.ts -var fs6 = __toESM(require("node:fs")); +var fs7 = __toESM(require("node:fs")); var path7 = __toESM(require("node:path")); function logPluginDiagnostics(hookName, paths) { const isPluginMode = !!process.env.CLAUDE_PLUGIN_ROOT; @@ -3927,13 +3913,15 @@ function logPluginDiagnostics(hookName, paths) { if (isDebug) { console.error(message); } - const logsDir = path7.join(paths.autoDir, "logs"); - fs6.mkdirSync(logsDir, { recursive: true }); - fs6.appendFileSync(path7.join(logsDir, "plugin-debug.log"), message); + if (fs7.existsSync(paths.autoDir)) { + const logsDir = path7.join(paths.autoDir, "logs"); + fs7.mkdirSync(logsDir, { recursive: true }); + fs7.appendFileSync(path7.join(logsDir, "plugin-debug.log"), message); + } } // scripts/user-prompt-submit.ts -var input = parseHookInput(fs7.readFileSync(0, "utf-8")); +var input = parseHookInput(fs8.readFileSync(0, "utf-8")); var startTime = Date.now(); (async () => { const paths = await resolvePathsFromEnv(); diff --git a/docs/configuration.md b/docs/configuration.md index 33dc275..208b47c 100644 --- a/docs/configuration.md +++ b/docs/configuration.md @@ -139,13 +139,13 @@ Patterns use [micromatch](https://github.com/micromatch/micromatch) glob syntax. ## Managing Configuration -Configuration is managed via the `/claude-auto:config` skill from within a Claude Code session: +Configuration is managed via the `/claude-auto-config` skill from within a Claude Code session: ``` -/claude-auto:config show # View current configuration -/claude-auto:config set # Update a setting -/claude-auto:config validators # List active validators -/claude-auto:config reminders # List active reminders +/claude-auto-config show # View current configuration +/claude-auto-config set # Update a setting +/claude-auto-config validators # List active validators +/claude-auto-config reminders # List active reminders ``` --- diff --git a/docs/getting-started.md b/docs/getting-started.md index 1ff0287..62c66a3 100644 --- a/docs/getting-started.md +++ b/docs/getting-started.md @@ -34,21 +34,23 @@ Or for local development: claude --plugin-dir /path/to/claude-auto ``` -The plugin automatically: - -- Creates reminders that inject your guidelines -- Sets up the supervisor that ACKs or NACKs changes -- Initializes hook state with sensible defaults +Claude will mention that claude-auto is available but not yet active. --- -## Step 2: Verify Your Installation +## Step 2: Activate in Your Project + +``` +/claude-auto-init +``` + +This creates `.claude-auto/` with default configuration. Then verify: ``` -/claude-auto:config show +/claude-auto-config show ``` -Everything looks good? Claude Auto is active. The Quality Loop is running. You can now let it run and check back on outcomes. +Claude Auto is now active with commit validation, reminders, deny-lists, and auto-continue. --- @@ -171,5 +173,5 @@ See the [transformation story](/origin-story#the-transformation) for the complet Having issues? See the [Configuration Guide](/configuration#troubleshooting) for common problems and solutions, or run: ``` -/claude-auto:config show +/claude-auto-config show ``` diff --git a/docs/installation.md b/docs/installation.md index a6704d8..23484ed 100644 --- a/docs/installation.md +++ b/docs/installation.md @@ -25,20 +25,28 @@ claude --plugin-dir /path/to/claude-auto --- -## What Gets Created +## Activating in a Project -After the plugin activates, the following structure is created in your project: +After installing the plugin, Claude will mention that claude-auto is available. To activate it: + +``` +/claude-auto-init +``` + +This creates the following structure in your project: ``` your-project/ ├── .claude-auto/ -│ ├── reminders/ # Context injection files (*.md) -│ ├── validators/ # Commit validation rules (*.md) │ ├── .claude.hooks.json # Hook behavior state +│ ├── reminders/ # Custom context injection files (*.md) +│ ├── validators/ # Custom commit validation rules (*.md) │ └── logs/ │ └── activity.log # Activity log ``` +You can add `.claude-auto` to `.gitignore` for personal use, or commit it for the whole team. + See the [Reminders Guide](/reminders-guide) and [Validators Guide](/validators-guide) for the complete list of built-in reminders and validators. --- @@ -48,20 +56,20 @@ See the [Reminders Guide](/reminders-guide) and [Validators Guide](/validators-g After installing the plugin, use the config skill to check the current state: ``` -/claude-auto:config show +/claude-auto-config show ``` --- ## Configuration -All configuration is managed via the `/claude-auto:config` skill: +All configuration is managed via the `/claude-auto-config` skill: ``` -/claude-auto:config show # View current configuration -/claude-auto:config set # Update a setting -/claude-auto:config validators # List active validators -/claude-auto:config reminders # List active reminders +/claude-auto-config show # View current configuration +/claude-auto-config set # Update a setting +/claude-auto-config validators # List active validators +/claude-auto-config reminders # List active reminders ``` Configuration is stored in `.claude-auto/.claude.hooks.json`. @@ -126,6 +134,6 @@ After installation: If you run into issues: -1. Run `/claude-auto:config show` to check configuration state +1. Run `/claude-auto-config show` to check configuration state 2. Check `.claude-auto/logs/` for detailed error messages 3. Report persistent issues at [GitHub Issues](https://github.com/BeOnAuto/claude-auto/issues) diff --git a/docs/reminders-guide.md b/docs/reminders-guide.md index 3546175..5c9ccf4 100644 --- a/docs/reminders-guide.md +++ b/docs/reminders-guide.md @@ -391,7 +391,7 @@ Commit project reminders (not personal preferences): See which reminders are active from within a Claude Code session: ``` -/claude-auto:config reminders +/claude-auto-config reminders ``` ### Test Reminder Loading diff --git a/docs/validators-guide.md b/docs/validators-guide.md index b22e524..e730004 100644 --- a/docs/validators-guide.md +++ b/docs/validators-guide.md @@ -367,7 +367,7 @@ Consider NACK only if performance is critical for this change. From within a Claude Code session: ``` -/claude-auto:config validators +/claude-auto-config validators ``` ### Temporarily Disable diff --git a/ketchup-plan.md b/ketchup-plan.md index 63e5713..05884e5 100644 --- a/ketchup-plan.md +++ b/ketchup-plan.md @@ -1,23 +1,33 @@ -# Ketchup Plan: Remove Legacy NPX Installation +# Ketchup Plan: Make claude-auto opt-in per repository ## TODO ## DONE -- [x] Burst 1: Delete src/cli/ directory (all 26 files) -- [x] Burst 2: Delete bin/cli.ts -- [x] Burst 3: Delete templates/ directory (settings.json, settings.local.json) -- [x] Burst 4: Delete src/settings-merger.ts and src/settings-merger.test.ts -- [x] Burst 5: Delete src/settings-template.test.ts -- [x] Burst 6: Delete src/e2e.test.ts -- [x] Burst 7: Delete src/linker.ts and src/linker.test.ts -- [x] Burst 8: Delete src/gitignore-manager.ts and src/gitignore-manager.test.ts -- [x] Burst 9: Delete src/state-manager.ts and src/state-manager.test.ts -- [x] Burst 10: Delete src/root-finder.ts and src/root-finder.test.ts -- [x] Burst 11: Remove legacy fallback from path-resolver.ts, remove config-loader.ts, update tests -- [x] Burst 12: Clean up index.ts barrel exports -- [x] Burst 13: Remove commander/cosmiconfig/yaml deps, bin entry, legacy scripts from package.json -- [x] Burst 14: Update README.md and CLAUDE.md -- [x] Burst 15: Fix resolvePathsFromEnv for skills context (CLAUDE_PLUGIN_ROOT only) -- [x] Burst 16: Add explicit pluginRoot parameter to resolvePathsFromEnv, use in config.ts -- [x] Burst 17: Update all docs, delete npm-package-test.yml, delete install-local-spec.md, clean stale dist +- [x] Burst 7.4: Wrap init config tip in directive so Claude surfaces it (45f3a8a) +- [x] Burst 7.3: Wrap `INIT_HINT_MESSAGE` in a directive so Claude surfaces it on first reply (f388145) +- [x] Burst 7.2: Fix skill name in `INIT_HINT_MESSAGE` to `/claude-auto-init` (0832f02) +- [x] Burst 7.1: Simplify `INIT_HINT_MESSAGE` to plain one-line reminder (8783851) +- [x] Burst 6.1: `formatInitResult` uses emojis and does not instruct Claude to ask the user +- [x] Burst 6.2: `INIT_HINT_MESSAGE` uses emojis for visibility + +- [x] Burst 1.1: `createHookState` does not create autoDir (6fe15c2) +- [x] Burst 1.2: `read()` returns defaults when autoDir missing (7ee52cd) +- [x] Burst 1.3: `write()` is no-op when autoDir missing (c70de21) +- [x] Burst 1.4: `update()` returns defaults when autoDir missing (c70de21) +- [x] Burst 1.5: Remove `firstSetupRequired` from initial state creation (7ee52cd) +- [x] Burst 2.1: `activityLog` no-op when autoDir missing (e33d77f) +- [x] Burst 2.2: `debugLog` no-op when autoDir missing (e33d77f) +- [x] Burst 2.3: `writeHookLog` no-op when autoDir missing (e33d77f) +- [x] Burst 2.4: `logPluginDiagnostics` no file write when autoDir missing (e33d77f) +- [x] Burst 3.1: `INIT_HINT_MESSAGE` constant (86fac7f) +- [x] Burst 3.2: `handleSessionStart` returns only hint when autoDir missing (86fac7f) +- [x] Burst 3.3: `handlePreToolUse` allows everything when autoDir missing (62efee8) +- [x] Burst 3.4: `handleUserPromptSubmit` returns empty when autoDir missing (86fac7f) +- [x] Burst 3.5: `handleStop` returns stop when autoDir missing (62efee8) +- [x] Burst 4.1: Remove `firstSetupRequired` block from user-prompt-submit (86fac7f) +- [x] Burst 4.2: Remove `FIRST_SETUP_MESSAGE` (86fac7f) +- [x] Burst 5.1: `initClaudeAuto` creates `.claude-auto/` with default state (43244eb) +- [x] Burst 5.2: Returns `created: false` when already initialized (43244eb) +- [x] Burst 5.3: Detects `.gitignore` status for `.claude-auto` (43244eb) +- [x] Burst 5.4: Script entry point + SKILL.md (7526a57) diff --git a/package.json b/package.json index fff7ac7..ebe0b35 100644 --- a/package.json +++ b/package.json @@ -19,7 +19,7 @@ "types": "dist/src/index.d.ts", "scripts": { "build": "tsc && pnpm build:bundle", - "build:bundle": "esbuild scripts/session-start.ts scripts/pre-tool-use.ts scripts/user-prompt-submit.ts scripts/auto-continue.ts scripts/config.ts --bundle --platform=node --target=node18 --format=cjs --outdir=dist/bundle/scripts --external:typescript", + "build:bundle": "esbuild scripts/session-start.ts scripts/pre-tool-use.ts scripts/user-prompt-submit.ts scripts/auto-continue.ts scripts/config.ts scripts/init.ts --bundle --platform=node --target=node18 --format=cjs --outdir=dist/bundle/scripts --external:typescript", "test": "vitest run", "test:watch": "vitest", "type-check": "tsc --noEmit", diff --git a/scripts/config.ts b/scripts/config.ts index 3f6db51..f92f51e 100644 --- a/scripts/config.ts +++ b/scripts/config.ts @@ -24,7 +24,7 @@ const args = process.argv.slice(2); const subcommand = args[0]; function usage(): string { - return `Usage: /claude-auto:config [args] + return `Usage: /claude-auto-config [args] Subcommands: show Show all current configuration diff --git a/scripts/init.ts b/scripts/init.ts new file mode 100644 index 0000000..e35226b --- /dev/null +++ b/scripts/init.ts @@ -0,0 +1,6 @@ +#!/usr/bin/env npx tsx + +import { formatInitResult, initClaudeAuto } from '../src/init.js'; + +const result = initClaudeAuto(process.cwd()); +console.log(formatInitResult(result)); diff --git a/skills/config-claude-auto/SKILL.md b/skills/config-claude-auto/SKILL.md index 646f28a..03a00de 100644 --- a/skills/config-claude-auto/SKILL.md +++ b/skills/config-claude-auto/SKILL.md @@ -1,5 +1,5 @@ --- -name: config-claude-auto +name: claude-auto-config description: Manage claude-auto configuration — toggle validators, reminders, set hook options user-invocable: true argument-hint: show | set | validators [enable|disable|reset] | reminders [enable|disable|priority|reset|add] diff --git a/skills/init-claude-auto/SKILL.md b/skills/init-claude-auto/SKILL.md new file mode 100644 index 0000000..0be68aa --- /dev/null +++ b/skills/init-claude-auto/SKILL.md @@ -0,0 +1,7 @@ +--- +name: claude-auto-init +description: Initialize claude-auto in the current repository +user-invocable: true +--- + +!`node "${CLAUDE_PLUGIN_ROOT}/dist/bundle/scripts/init.js"` diff --git a/src/activity-logger.test.ts b/src/activity-logger.test.ts index 332190f..ae05df2 100644 --- a/src/activity-logger.test.ts +++ b/src/activity-logger.test.ts @@ -22,6 +22,14 @@ describe('activity-logger', () => { } }); + it('does not write or create directories when autoDir does not exist', () => { + const nonExistentDir = path.join(tempDir, 'not-created'); + + activityLog(nonExistentDir, 'session-123', 'test-hook', 'test message'); + + expect(fs.existsSync(nonExistentDir)).toBe(false); + }); + it('writes to .claude-auto/logs/activity.log', () => { const autoDir = path.join(tempDir, '.claude-auto'); fs.mkdirSync(autoDir, { recursive: true }); diff --git a/src/activity-logger.ts b/src/activity-logger.ts index 4c582a7..eda4b25 100644 --- a/src/activity-logger.ts +++ b/src/activity-logger.ts @@ -25,6 +25,10 @@ function matchesFilter(hookName: string, message: string): boolean { } export function activityLog(autoDir: string, sessionId: string, hookName: string, message: string): void { + if (!fs.existsSync(autoDir)) { + return; + } + if (!matchesFilter(hookName, message)) { return; } diff --git a/src/debug-logger.test.ts b/src/debug-logger.test.ts index 325c598..7a68662 100644 --- a/src/debug-logger.test.ts +++ b/src/debug-logger.test.ts @@ -21,6 +21,15 @@ describe('debug-logger', () => { } }); + it('does not write or create directories when autoDir does not exist', () => { + process.env.DEBUG = 'claude-auto'; + const nonExistentDir = path.join(tempDir, 'not-created'); + + debugLog(nonExistentDir, 'test-hook', 'test message'); + + expect(fs.existsSync(nonExistentDir)).toBe(false); + }); + it('writes to .claude-auto/logs/claude-auto/debug.log when DEBUG=claude-auto', () => { process.env.DEBUG = 'claude-auto'; const autoDir = path.join(tempDir, '.claude-auto'); diff --git a/src/debug-logger.ts b/src/debug-logger.ts index 0e46d8d..e4f92da 100644 --- a/src/debug-logger.ts +++ b/src/debug-logger.ts @@ -2,6 +2,10 @@ import fs from 'node:fs'; import path from 'node:path'; export function debugLog(autoDir: string, hookName: string, message: string): void { + if (!fs.existsSync(autoDir)) { + return; + } + const debug = process.env.DEBUG; if (!debug || !debug.includes('claude-auto')) { return; diff --git a/src/hook-logger.test.ts b/src/hook-logger.test.ts index 39e6136..96ad714 100644 --- a/src/hook-logger.test.ts +++ b/src/hook-logger.test.ts @@ -20,6 +20,19 @@ describe('hook-logger', () => { fs.rmSync(tempDir, { recursive: true, force: true }); }); + it('does not write or create directories when autoDir does not exist', () => { + const nonExistentDir = path.join(tempDir, 'not-created'); + + writeHookLog(nonExistentDir, { + hookName: 'session-start', + timestamp: '2026-01-28T12:00:00.000Z', + input: {}, + output: {}, + }); + + expect(fs.existsSync(nonExistentDir)).toBe(false); + }); + it('creates log file in .claude-auto/logs/hooks/ named after hook', () => { writeHookLog(autoDir, { hookName: 'session-start', diff --git a/src/hook-logger.ts b/src/hook-logger.ts index 3ad19db..166154f 100644 --- a/src/hook-logger.ts +++ b/src/hook-logger.ts @@ -18,6 +18,10 @@ function sanitizeForFilename(hookName: string): string { } export function writeHookLog(autoDir: string, entry: HookLogEntry): void { + if (!fs.existsSync(autoDir)) { + return; + } + const logsDir = path.join(autoDir, 'logs', 'hooks'); if (!fs.existsSync(logsDir)) { fs.mkdirSync(logsDir, { recursive: true }); diff --git a/src/hook-state.test.ts b/src/hook-state.test.ts index 3c89c04..406c665 100644 --- a/src/hook-state.test.ts +++ b/src/hook-state.test.ts @@ -2,7 +2,7 @@ import * as fs from 'node:fs'; import * as os from 'node:os'; import * as path from 'node:path'; -import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest'; +import { afterEach, beforeEach, describe, expect, it } from 'vitest'; import { createHookState, DEFAULT_HOOK_STATE, type HookState } from './hook-state.js'; @@ -21,31 +21,29 @@ describe('hook-state', () => { }); describe('createHookState', () => { - it('exists returns false before read and true after read', () => { - const hookState = createHookState(autoDir); - - expect(hookState.exists()).toBe(false); - - hookState.read(); - - expect(hookState.exists()).toBe(true); + it('does not create autoDir when it does not exist', () => { + const nonExistentDir = path.join(tempDir, 'not-created'); + createHookState(nonExistentDir); + expect(fs.existsSync(nonExistentDir)).toBe(false); }); - it('sets firstSetupRequired when created in plugin mode', () => { - vi.stubEnv('CLAUDE_PLUGIN_ROOT', '/plugins/claude-auto'); - const hookState = createHookState(autoDir); + it('read returns DEFAULT_HOOK_STATE when autoDir does not exist', () => { + const nonExistentDir = path.join(tempDir, 'not-created'); + const hookState = createHookState(nonExistentDir); const state = hookState.read(); - expect(state.firstSetupRequired).toBe(true); - vi.unstubAllEnvs(); + expect(state).toEqual(DEFAULT_HOOK_STATE); + expect(fs.existsSync(nonExistentDir)).toBe(false); }); - it('does not set firstSetupRequired when created in legacy mode', () => { - delete process.env.CLAUDE_PLUGIN_ROOT; + it('exists returns false before read and true after read', () => { const hookState = createHookState(autoDir); - const state = hookState.read(); - expect(state.firstSetupRequired).toBeUndefined(); + expect(hookState.exists()).toBe(false); + + hookState.read(); + + expect(hookState.exists()).toBe(true); }); it('creates state file with defaults when not exists', () => { @@ -119,6 +117,15 @@ describe('hook-state', () => { }); describe('write', () => { + it('is a no-op when autoDir does not exist', () => { + const nonExistentDir = path.join(tempDir, 'not-created'); + const hookState = createHookState(nonExistentDir); + + hookState.write({ ...DEFAULT_HOOK_STATE, autoContinue: { ...DEFAULT_HOOK_STATE.autoContinue, mode: 'off' } }); + + expect(fs.existsSync(nonExistentDir)).toBe(false); + }); + it('writes state to .claude-auto/.claude.hooks.json', () => { const hookState = createHookState(autoDir); const newState: HookState = { @@ -146,6 +153,16 @@ describe('hook-state', () => { }); describe('update', () => { + it('returns defaults and does not create files when autoDir does not exist', () => { + const nonExistentDir = path.join(tempDir, 'not-created'); + const hookState = createHookState(nonExistentDir); + + const result = hookState.update({ autoContinue: { mode: 'off', skipModes: [], maxIterations: 0 } }); + + expect(result).toEqual(DEFAULT_HOOK_STATE); + expect(fs.existsSync(nonExistentDir)).toBe(false); + }); + it('updates specific fields and preserves others', () => { const hookState = createHookState(autoDir); diff --git a/src/hook-state.ts b/src/hook-state.ts index 9e58a0d..c4f2867 100644 --- a/src/hook-state.ts +++ b/src/hook-state.ts @@ -46,7 +46,6 @@ export interface OverridesState { } export interface HookState { - firstSetupRequired?: boolean; autoContinue: AutoContinueState; validateCommit: ValidateCommitState; denyList: DenyListState; @@ -91,17 +90,15 @@ export interface HookStateManager { } export function createHookState(autoDir: string): HookStateManager { - if (!fs.existsSync(autoDir)) { - fs.mkdirSync(autoDir, { recursive: true }); - } const stateFile = path.join(autoDir, '.claude.hooks.json'); function read(): HookState { + if (!fs.existsSync(autoDir)) { + return { ...DEFAULT_HOOK_STATE }; + } + if (!fs.existsSync(stateFile)) { - const isPluginMode = !!process.env.CLAUDE_PLUGIN_ROOT; - const initialState = isPluginMode - ? { ...DEFAULT_HOOK_STATE, firstSetupRequired: true } - : { ...DEFAULT_HOOK_STATE }; + const initialState = { ...DEFAULT_HOOK_STATE }; fs.writeFileSync(stateFile, `${JSON.stringify(initialState, null, 2)}\n`); return JSON.parse(JSON.stringify(initialState)) as HookState; } @@ -110,7 +107,6 @@ export function createHookState(autoDir: string): HookStateManager { const partial = JSON.parse(content) as Partial; return { - ...(partial.firstSetupRequired !== undefined ? { firstSetupRequired: partial.firstSetupRequired } : {}), autoContinue: { ...DEFAULT_HOOK_STATE.autoContinue, ...partial.autoContinue }, validateCommit: { ...DEFAULT_HOOK_STATE.validateCommit, ...partial.validateCommit }, denyList: { ...DEFAULT_HOOK_STATE.denyList, ...partial.denyList }, @@ -124,10 +120,16 @@ export function createHookState(autoDir: string): HookStateManager { } function write(state: HookState): void { + if (!fs.existsSync(autoDir)) { + return; + } fs.writeFileSync(stateFile, `${JSON.stringify(state, null, 2)}\n`); } function update(updates: Partial): HookState { + if (!fs.existsSync(autoDir)) { + return { ...DEFAULT_HOOK_STATE }; + } const current = read(); const newState: HookState = { ...current, diff --git a/src/hooks/auto-continue.test.ts b/src/hooks/auto-continue.test.ts index 140d4c7..6c22603 100644 --- a/src/hooks/auto-continue.test.ts +++ b/src/hooks/auto-continue.test.ts @@ -155,6 +155,16 @@ describe('auto-continue hook', () => { }); describe('handleStop', () => { + it('returns allow (stop) when autoDir does not exist', () => { + const nonExistentDir = path.join(tempDir, 'not-created'); + const input: StopHookInput = { session_id: 'test-session' }; + + const result = handleStop(nonExistentDir, input); + + expect(result).toEqual({ decision: 'allow', reason: 'auto-continue disabled' }); + expect(fs.existsSync(nonExistentDir)).toBe(false); + }); + it('returns allow when mode is off', () => { const autoDir = path.join(tempDir, '.claude-auto'); fs.mkdirSync(autoDir, { recursive: true }); diff --git a/src/hooks/auto-continue.ts b/src/hooks/auto-continue.ts index 948cae1..1b375eb 100644 --- a/src/hooks/auto-continue.ts +++ b/src/hooks/auto-continue.ts @@ -1,4 +1,4 @@ -import { readFileSync } from 'node:fs'; +import { existsSync, readFileSync } from 'node:fs'; import type { ClueCollectorResult } from '../clue-collector.js'; import { createHookState } from '../hook-state.js'; @@ -76,6 +76,10 @@ Respond JSON only: {"decision":"CONTINUE","reason":"..."} or {"decision":"STOP", } export function handleStop(autoDir: string, input: StopHookInput): StopHookResult { + if (!existsSync(autoDir)) { + return { decision: 'allow', reason: 'auto-continue disabled' }; + } + const stateManager = createHookState(autoDir); const state = stateManager.read(); const { mode, skipModes } = state.autoContinue; diff --git a/src/hooks/pre-tool-use.test.ts b/src/hooks/pre-tool-use.test.ts index 0188af3..fdcd99f 100644 --- a/src/hooks/pre-tool-use.test.ts +++ b/src/hooks/pre-tool-use.test.ts @@ -41,6 +41,20 @@ describe('pre-tool-use hook', () => { } }); + it('allows everything when autoDir does not exist', async () => { + const nonExistentPaths = { ...resolvedPaths, autoDir: path.join(tempDir, 'not-created') }; + const toolInput = { command: 'git commit -m "test"' }; + + const result = await handlePreToolUse(nonExistentPaths, 'session-1', toolInput); + + expect(result).toEqual({ + hookSpecificOutput: { + hookEventName: 'PreToolUse', + permissionDecision: 'allow', + }, + }); + }); + it('blocks tool use when path matches deny pattern', async () => { fs.writeFileSync(path.join(claudeDir, 'deny-list.project.txt'), '*.secret\n'); const toolInput = { file_path: '/project/config.secret' }; diff --git a/src/hooks/pre-tool-use.ts b/src/hooks/pre-tool-use.ts index 65c7c5b..71a453e 100644 --- a/src/hooks/pre-tool-use.ts +++ b/src/hooks/pre-tool-use.ts @@ -1,3 +1,5 @@ +import * as fs from 'node:fs'; + import { activityLog } from '../activity-logger.js'; import { type Executor, @@ -36,6 +38,15 @@ export async function handlePreToolUse( toolInput: ToolInput, options: PreToolUseOptions = {}, ): Promise { + if (!fs.existsSync(paths.autoDir)) { + return { + hookSpecificOutput: { + hookEventName: 'PreToolUse', + permissionDecision: 'allow', + }, + }; + } + const command = toolInput.command as string | undefined; if (command && isCommitCommand(command)) { diff --git a/src/hooks/session-start.test.ts b/src/hooks/session-start.test.ts index a73c6ed..d66b882 100644 --- a/src/hooks/session-start.test.ts +++ b/src/hooks/session-start.test.ts @@ -6,6 +6,7 @@ import { afterEach, beforeEach, describe, expect, it } from 'vitest'; import { DEFAULT_HOOK_STATE } from '../hook-state.js'; import type { ResolvedPaths } from '../path-resolver.js'; +import { INIT_HINT_MESSAGE } from '../welcome-message.js'; const DEFAULT_AUTO_DIR = '.claude-auto'; @@ -44,6 +45,15 @@ describe('session-start hook', () => { } }); + it('returns init hint when autoDir does not exist', async () => { + const nonExistentPaths = { ...resolvedPaths, autoDir: path.join(tempDir, 'not-created') }; + + const result = await handleSessionStart(nonExistentPaths, 'session-1'); + + expect(result.hookSpecificOutput.additionalContext).toBe(INIT_HINT_MESSAGE); + expect(result.diagnostics.matchedReminders).toEqual([]); + }); + it('outputs filtered reminders content for SessionStart hook', async () => { const remindersDir = path.join(autoDir, 'reminders'); fs.mkdirSync(remindersDir, { recursive: true }); diff --git a/src/hooks/session-start.ts b/src/hooks/session-start.ts index 541fd1c..04d31b5 100644 --- a/src/hooks/session-start.ts +++ b/src/hooks/session-start.ts @@ -1,8 +1,11 @@ +import * as fs from 'node:fs'; + import { activityLog } from '../activity-logger.js'; import { debugLog } from '../debug-logger.js'; import { createHookState } from '../hook-state.js'; import type { ResolvedPaths } from '../path-resolver.js'; import { loadReminders, scanReminders } from '../reminder-loader.js'; +import { INIT_HINT_MESSAGE } from '../welcome-message.js'; type HookResult = { hookSpecificOutput: { @@ -22,6 +25,20 @@ export async function handleSessionStart( sessionId: string = '', agentType?: string, ): Promise { + if (!fs.existsSync(paths.autoDir)) { + return { + hookSpecificOutput: { + hookEventName: 'SessionStart', + additionalContext: INIT_HINT_MESSAGE, + }, + diagnostics: { + resolvedPaths: paths, + reminderFiles: [], + matchedReminders: [], + }, + }; + } + const reminderFiles = paths.remindersDirs.flatMap((dir) => scanReminders(dir)); if (agentType === 'validator') { diff --git a/src/hooks/user-prompt-submit.test.ts b/src/hooks/user-prompt-submit.test.ts index 915c1b3..6b68487 100644 --- a/src/hooks/user-prompt-submit.test.ts +++ b/src/hooks/user-prompt-submit.test.ts @@ -5,7 +5,6 @@ import * as path from 'node:path'; import { afterEach, beforeEach, describe, expect, it } from 'vitest'; import type { ResolvedPaths } from '../path-resolver.js'; -import { FIRST_SETUP_MESSAGE } from '../welcome-message.js'; const DEFAULT_AUTO_DIR = '.claude-auto'; @@ -123,14 +122,13 @@ Remember to follow coding standards.`, expect(content).toContain('injected 1 reminder'); }); - it('injects first-setup directive when firstSetupRequired is true', async () => { - fs.writeFileSync(path.join(autoDir, '.claude.hooks.json'), JSON.stringify({ firstSetupRequired: true })); + it('returns empty when autoDir does not exist', async () => { + const nonExistentPaths = { ...resolvedPaths, autoDir: path.join(tempDir, 'not-created') }; - const result = await handleUserPromptSubmit(resolvedPaths, 'setup-session', 'do something'); + const result = await handleUserPromptSubmit(nonExistentPaths, 'session-1'); - expect(result.hookSpecificOutput.additionalContext).toBe(FIRST_SETUP_MESSAGE); - const state = JSON.parse(fs.readFileSync(path.join(autoDir, '.claude.hooks.json'), 'utf-8')); - expect(state.firstSetupRequired).toBeUndefined(); + expect(result.hookSpecificOutput.additionalContext).toBe(''); + expect(result.diagnostics.matchedReminders).toEqual([]); }); it('skips reminders for validator subagent sessions', async () => { diff --git a/src/hooks/user-prompt-submit.ts b/src/hooks/user-prompt-submit.ts index 20a9c91..4e47341 100644 --- a/src/hooks/user-prompt-submit.ts +++ b/src/hooks/user-prompt-submit.ts @@ -1,10 +1,11 @@ +import * as fs from 'node:fs'; + import { activityLog } from '../activity-logger.js'; import { debugLog } from '../debug-logger.js'; import { createHookState } from '../hook-state.js'; import type { ResolvedPaths } from '../path-resolver.js'; import { loadReminders, scanReminders } from '../reminder-loader.js'; import { isValidatorSession } from '../validator-session.js'; -import { FIRST_SETUP_MESSAGE } from '../welcome-message.js'; type HookResult = { hookSpecificOutput: { @@ -24,12 +25,7 @@ export async function handleUserPromptSubmit( sessionId: string, prompt?: string, ): Promise { - const reminderFiles = paths.remindersDirs.flatMap((dir) => scanReminders(dir)); - - if (isValidatorSession(prompt)) { - activityLog(paths.autoDir, sessionId, 'user-prompt-submit', 'skipped reminders for validator session'); - debugLog(paths.autoDir, 'user-prompt-submit', 'skipped reminders for validator session'); - + if (!fs.existsSync(paths.autoDir)) { return { hookSpecificOutput: { hookEventName: 'UserPromptSubmit', @@ -37,26 +33,22 @@ export async function handleUserPromptSubmit( }, diagnostics: { resolvedPaths: paths, - reminderFiles, + reminderFiles: [], matchedReminders: [], }, }; } - const stateManager = createHookState(paths.autoDir); - const state = stateManager.read(); + const reminderFiles = paths.remindersDirs.flatMap((dir) => scanReminders(dir)); - if (state.firstSetupRequired) { - const updated = stateManager.read(); - delete updated.firstSetupRequired; - stateManager.write(updated); - activityLog(paths.autoDir, sessionId, 'user-prompt-submit', 'first-setup directive injected'); - debugLog(paths.autoDir, 'user-prompt-submit', 'first-setup directive injected'); + if (isValidatorSession(prompt)) { + activityLog(paths.autoDir, sessionId, 'user-prompt-submit', 'skipped reminders for validator session'); + debugLog(paths.autoDir, 'user-prompt-submit', 'skipped reminders for validator session'); return { hookSpecificOutput: { hookEventName: 'UserPromptSubmit', - additionalContext: FIRST_SETUP_MESSAGE, + additionalContext: '', }, diagnostics: { resolvedPaths: paths, @@ -66,6 +58,8 @@ export async function handleUserPromptSubmit( }; } + const state = createHookState(paths.autoDir).read(); + const reminders = loadReminders(paths.remindersDirs, { hook: 'UserPromptSubmit' }, state.overrides.reminders); const reminderContent = reminders.map((r) => r.content).join('\n\n'); diff --git a/src/init.test.ts b/src/init.test.ts new file mode 100644 index 0000000..28d6cff --- /dev/null +++ b/src/init.test.ts @@ -0,0 +1,106 @@ +import { execSync } from 'node:child_process'; +import * as fs from 'node:fs'; +import * as os from 'node:os'; +import * as path from 'node:path'; + +import { afterEach, beforeEach, describe, expect, it } from 'vitest'; + +import { DEFAULT_HOOK_STATE } from './hook-state.js'; +import { formatInitResult, initClaudeAuto } from './init.js'; + +describe('initClaudeAuto', () => { + let tempDir: string; + + beforeEach(() => { + tempDir = fs.mkdtempSync(path.join(os.tmpdir(), 'claude-auto-init-')); + }); + + afterEach(() => { + fs.rmSync(tempDir, { recursive: true, force: true }); + }); + + it('creates .claude-auto directory with default state', () => { + const result = initClaudeAuto(tempDir); + const autoDir = path.join(tempDir, '.claude-auto'); + + expect(result).toEqual({ created: true, autoDir, gitignoreAdvice: true }); + + const stateFile = path.join(autoDir, '.claude.hooks.json'); + expect(JSON.parse(fs.readFileSync(stateFile, 'utf-8'))).toEqual(DEFAULT_HOOK_STATE); + }); + + it('returns created false when already initialized and preserves existing state', () => { + const autoDir = path.join(tempDir, '.claude-auto'); + fs.mkdirSync(autoDir, { recursive: true }); + const existingState = { autoContinue: { mode: 'off' } }; + fs.writeFileSync(path.join(autoDir, '.claude.hooks.json'), JSON.stringify(existingState)); + + const result = initClaudeAuto(tempDir); + + expect(result).toEqual({ created: false, autoDir, gitignoreAdvice: true }); + expect(JSON.parse(fs.readFileSync(path.join(autoDir, '.claude.hooks.json'), 'utf-8'))).toEqual(existingState); + }); + + it('returns gitignoreAdvice true when .claude-auto not in .gitignore', () => { + fs.writeFileSync(path.join(tempDir, '.gitignore'), 'node_modules\n'); + + const result = initClaudeAuto(tempDir); + + expect(result).toEqual({ + created: true, + autoDir: path.join(tempDir, '.claude-auto'), + gitignoreAdvice: true, + }); + }); + + it('returns gitignoreAdvice false when .claude-auto is in .gitignore', () => { + fs.writeFileSync(path.join(tempDir, '.gitignore'), 'node_modules\n.claude-auto\n'); + + const result = initClaudeAuto(tempDir); + + expect(result).toEqual({ + created: true, + autoDir: path.join(tempDir, '.claude-auto'), + gitignoreAdvice: false, + }); + }); +}); + +describe('formatInitResult', () => { + it('formats newly created result with directive-wrapped config reminder', () => { + const output = formatInitResult({ created: true, autoDir: '/project/.claude-auto', gitignoreAdvice: true }); + + expect(output).toContain('Initialized claude-auto at /project/.claude-auto'); + expect(output).toContain('.gitignore'); + expect(output).toContain('On your next reply, mention once'); + expect(output).toContain('Reminder: Defaults are active — run /claude-auto-config show'); + expect(output).not.toMatch(/ask the user/i); + }); + + it('formats already initialized result without config prompt', () => { + const output = formatInitResult({ created: false, autoDir: '/project/.claude-auto', gitignoreAdvice: false }); + + expect(output).toContain('already initialized'); + expect(output).not.toContain('/claude-auto-config'); + }); +}); + +describe('scripts/init.ts', () => { + let tempDir: string; + + beforeEach(() => { + tempDir = fs.mkdtempSync(path.join(os.tmpdir(), 'claude-auto-script-init-')); + }); + + afterEach(() => { + fs.rmSync(tempDir, { recursive: true, force: true }); + }); + + it('creates .claude-auto and prints initialization message', () => { + const scriptPath = path.resolve(__dirname, '..', 'scripts', 'init.ts'); + const output = execSync(`npx tsx ${scriptPath}`, { cwd: tempDir, encoding: 'utf-8' }); + + expect(output).toContain('Initialized claude-auto'); + expect(fs.existsSync(path.join(tempDir, '.claude-auto', '.claude.hooks.json'))).toBe(true); + }); +}); diff --git a/src/init.ts b/src/init.ts new file mode 100644 index 0000000..25a9b9f --- /dev/null +++ b/src/init.ts @@ -0,0 +1,62 @@ +import * as fs from 'node:fs'; +import * as path from 'node:path'; + +import { DEFAULT_HOOK_STATE } from './hook-state.js'; + +export interface InitResult { + created: boolean; + autoDir: string; + gitignoreAdvice: boolean; +} + +export function initClaudeAuto(projectRoot: string): InitResult { + const autoDir = path.join(projectRoot, '.claude-auto'); + + if (fs.existsSync(autoDir)) { + return { created: false, autoDir, gitignoreAdvice: checkGitignoreAdvice(projectRoot) }; + } + + fs.mkdirSync(autoDir, { recursive: true }); + + const stateFile = path.join(autoDir, '.claude.hooks.json'); + fs.writeFileSync(stateFile, `${JSON.stringify(DEFAULT_HOOK_STATE, null, 2)}\n`); + + return { created: true, autoDir, gitignoreAdvice: checkGitignoreAdvice(projectRoot) }; +} + +export function formatInitResult(result: InitResult): string { + const lines: string[] = []; + + if (result.created) { + lines.push(`✅ Initialized claude-auto at ${result.autoDir}`); + lines.push('🎯 Default configuration written to .claude-auto/.claude.hooks.json'); + + if (result.gitignoreAdvice) { + lines.push(''); + lines.push('📌 Note: .claude-auto is not in your .gitignore.'); + lines.push(' If this is for personal use only, consider adding it:'); + lines.push(' echo ".claude-auto" >> .gitignore'); + } + + lines.push(''); + lines.push( + 'On your next reply, mention once (then proceed with the user\'s request): "Reminder: Defaults are active — run /claude-auto-config show anytime to review or customize."', + ); + } else { + lines.push(`✅ claude-auto is already initialized at ${result.autoDir}`); + } + + return lines.join('\n'); +} + +function checkGitignoreAdvice(projectRoot: string): boolean { + const gitignorePath = path.join(projectRoot, '.gitignore'); + + if (!fs.existsSync(gitignorePath)) { + return true; + } + + const content = fs.readFileSync(gitignorePath, 'utf-8'); + const lines = content.split('\n').map((l) => l.trim()); + return !lines.some((line) => line === '.claude-auto' || line === '.claude-auto/'); +} diff --git a/src/plugin-debug.test.ts b/src/plugin-debug.test.ts index e394894..38414e4 100644 --- a/src/plugin-debug.test.ts +++ b/src/plugin-debug.test.ts @@ -18,6 +18,21 @@ describe('logPluginDiagnostics', () => { fs.rmSync(tempDir, { recursive: true, force: true }); }); + it('does not write log file when autoDir does not exist', () => { + vi.stubEnv('CLAUDE_PLUGIN_ROOT', '/plugins/claude-auto'); + const nonExistentDir = path.join(tempDir, 'not-created'); + + logPluginDiagnostics('SessionStart', { + projectRoot: '/project', + claudeDir: '/project/.claude', + autoDir: nonExistentDir, + validatorsDirs: ['/plugins/claude-auto/validators'], + remindersDirs: ['/plugins/claude-auto/reminders'], + }); + + expect(fs.existsSync(nonExistentDir)).toBe(false); + }); + it('writes to file in plugin mode', () => { vi.stubEnv('CLAUDE_PLUGIN_ROOT', '/plugins/claude-auto'); vi.stubEnv('CLAUDE_PLUGIN_DATA', '/data/claude-auto'); diff --git a/src/plugin-debug.ts b/src/plugin-debug.ts index 94b366c..4e97f25 100644 --- a/src/plugin-debug.ts +++ b/src/plugin-debug.ts @@ -29,7 +29,9 @@ export function logPluginDiagnostics(hookName: string, paths: ResolvedPaths): vo console.error(message); } - const logsDir = path.join(paths.autoDir, 'logs'); - fs.mkdirSync(logsDir, { recursive: true }); - fs.appendFileSync(path.join(logsDir, 'plugin-debug.log'), message); + if (fs.existsSync(paths.autoDir)) { + const logsDir = path.join(paths.autoDir, 'logs'); + fs.mkdirSync(logsDir, { recursive: true }); + fs.appendFileSync(path.join(logsDir, 'plugin-debug.log'), message); + } } diff --git a/src/welcome-message.test.ts b/src/welcome-message.test.ts index c6d21ae..e6d79b4 100644 --- a/src/welcome-message.test.ts +++ b/src/welcome-message.test.ts @@ -1,14 +1,11 @@ import { describe, expect, it } from 'vitest'; -import { FIRST_SETUP_MESSAGE } from './welcome-message.js'; +import { INIT_HINT_MESSAGE } from './welcome-message.js'; -describe('FIRST_SETUP_MESSAGE', () => { - it('contains setup directive with config skill reference', () => { - expect(FIRST_SETUP_MESSAGE).toContain('/claude-auto:config show'); - expect(FIRST_SETUP_MESSAGE).toContain('CRITICAL'); - }); - - it('instructs to not proceed with user request', () => { - expect(FIRST_SETUP_MESSAGE).toContain('DO NOT proceed'); +describe('INIT_HINT_MESSAGE', () => { + it('instructs Claude to surface the reminder once on first reply', () => { + expect(INIT_HINT_MESSAGE).toBe( + 'On your first reply, mention once (then proceed with the user\'s request): "Reminder: Use /claude-auto-init to enable https://claude.on.auto in this folder"', + ); }); }); diff --git a/src/welcome-message.ts b/src/welcome-message.ts index 6fa12c0..c79d178 100644 --- a/src/welcome-message.ts +++ b/src/welcome-message.ts @@ -1,22 +1,2 @@ -export const FIRST_SETUP_MESSAGE = `CRITICAL INSTRUCTION — DO THIS BEFORE ANYTHING ELSE: - -The claude-auto plugin has just been installed in this project for the first time. You MUST guide the user through setup before doing any work. - -DO NOT proceed with the user's request yet. Instead: - -1. Greet the user and explain that claude-auto is now active -2. Briefly explain what it does: - - Commit validation (17 validators enforce TDD, coverage, code quality) - - Reminders injected into prompts (TCR workflow, testing rules) - - File protection via deny-lists - - Auto-continue to keep working until the plan is done -3. Run /claude-auto:config show to display the current configuration -4. Ask the user if they want to customize anything, or proceed with defaults -5. ONLY AFTER the user confirms, proceed with their original request - -The default workflow follows the Ketchup Technique: -- Create ketchup-plan.md with TODO/DONE sections before coding -- One failing test → minimal passing code → commit (TCR) -- 100% test coverage, no exceptions - -This setup runs once. After this, normal reminders will guide the workflow.`; +export const INIT_HINT_MESSAGE = + 'On your first reply, mention once (then proceed with the user\'s request): "Reminder: Use /claude-auto-init to enable https://claude.on.auto in this folder"';