diff --git a/hooks/ponytail-config.js b/hooks/ponytail-config.js index 5d5e1262a..c2864e500 100644 --- a/hooks/ponytail-config.js +++ b/hooks/ponytail-config.js @@ -73,6 +73,10 @@ function getClaudeDir() { return process.env.CLAUDE_CONFIG_DIR || path.join(os.homedir(), '.claude'); } +function getQoderDir() { + return process.env.QODER_CONFIG_DIR || path.join(os.homedir(), '.qoder'); +} + function getDefaultMode() { // 1. Environment variable (highest priority) // ponytail: a default must be a runtime level (off/lite/full/ultra); review is @@ -159,6 +163,7 @@ module.exports = { getConfigDir, getConfigPath, getClaudeDir, + getQoderDir, getHideStatus, getQuietStartup, isShellSafe, diff --git a/hooks/ponytail-runtime.js b/hooks/ponytail-runtime.js index b4643eb7e..27e9ceee8 100644 --- a/hooks/ponytail-runtime.js +++ b/hooks/ponytail-runtime.js @@ -2,7 +2,7 @@ const fs = require('fs'); const path = require('path'); const os = require('os'); const { createHash } = require('crypto'); -const { getClaudeDir, getConfigDir } = require('./ponytail-config'); +const { getClaudeDir, getQoderDir } = require('./ponytail-config'); const STATE_FILE = '.ponytail-active'; @@ -37,7 +37,7 @@ if (isCodex) stateDir = process.env.PLUGIN_DATA; // COPILOT_PLUGIN_DATA is unset under VS Code Copilot, so fall back to // getClaudeDir() rather than building a path from undefined. if (isCopilot) stateDir = process.env.COPILOT_PLUGIN_DATA || getClaudeDir(); -if (isQoder) stateDir = path.join(os.homedir(), '.qoder'); +if (isQoder) stateDir = getQoderDir(); if (isCodeBuddy) stateDir = process.env.CODEBUDDY_CONFIG_DIR || path.join(os.homedir(), '.codebuddy'); if (isCursor) stateDir = path.join(os.homedir(), '.cursor'); diff --git a/scripts/uninstall.js b/scripts/uninstall.js index 92c7a74ee..d67e92fdc 100644 --- a/scripts/uninstall.js +++ b/scripts/uninstall.js @@ -9,7 +9,7 @@ const fs = require('fs'); const os = require('os'); const path = require('path'); -const { getConfigPath, getClaudeDir } = require('../hooks/ponytail-config'); +const { getConfigPath, getClaudeDir, getQoderDir } = require('../hooks/ponytail-config'); const cursorHooks = require('./cursor-hooks'); const STATUSLINE_SCRIPT = /(?:^|[\s"'\\/])ponytail-statusline\.(?:sh|ps1)(?=$|[\s"'])/; @@ -26,7 +26,7 @@ function removeIfExists(filePath, label) { removeIfExists(path.join(getClaudeDir(), '.ponytail-active'), 'mode flag'); removeIfExists(path.join(getClaudeDir(), '.ponytail-statusline-nudged'), 'statusline nudge flag'); removeIfExists(path.join(os.homedir(), '.cursor', '.ponytail-active'), 'Cursor mode flag'); -removeIfExists(path.join(os.homedir(), '.qoder', '.ponytail-active'), 'Qoder mode flag'); +removeIfExists(path.join(getQoderDir(), '.ponytail-active'), 'Qoder mode flag'); for (const dir of [path.join(getClaudeDir(), 'ponytail-modes'), path.join(os.homedir(), '.cursor', 'ponytail-modes'), path.join(process.env.CODEBUDDY_CONFIG_DIR || path.join(os.homedir(), '.codebuddy'), 'ponytail-modes')]) { if (fs.existsSync(dir)) { fs.rmSync(dir, { recursive: true }); diff --git a/tests/hooks.test.js b/tests/hooks.test.js index 974ea3092..45b1b089a 100644 --- a/tests/hooks.test.js +++ b/tests/hooks.test.js @@ -591,6 +591,19 @@ assert.match( /PONYTAIL MODE ACTIVE — level: full/, ); +// Qoder's QODER_CONFIG_DIR relocates its user state. Mode switches and +// subagent injection must use that location instead of creating ~/.qoder. +const qoderCustomDir = path.join(temp, 'qoder-custom-config'); +const qoderCustomEnv = { ...qoderEnv, QODER_CONFIG_DIR: qoderCustomDir }; +const qoderCustomState = path.join(qoderCustomDir, '.ponytail-active'); +result = run('ponytail-mode-tracker.js', qoderCustomEnv, JSON.stringify({ prompt: '/ponytail ultra' })); +assert.equal(result.status, 0, result.stderr); +assert.equal(fs.readFileSync(qoderCustomState, 'utf8'), 'ultra'); +assert.equal(fs.readFileSync(qoderState, 'utf8'), 'full', 'custom Qoder mode must not change ~/.qoder'); +result = run('ponytail-subagent.js', qoderCustomEnv); +assert.equal(result.status, 0, result.stderr); +assert.match(JSON.parse(result.stdout).hookSpecificOutput.additionalContext, /PONYTAIL MODE ACTIVE — level: ultra/); + // Bare `/ponytail` on Qoder is report-only: there's no SessionStart, so the // double-duty block below emits the full ruleset as the report. A second // confirmation here would push two JSON objects to stdout. The point is that diff --git a/tests/uninstall.test.js b/tests/uninstall.test.js index 20c0be54f..f4620ad66 100644 --- a/tests/uninstall.test.js +++ b/tests/uninstall.test.js @@ -32,6 +32,10 @@ fs.writeFileSync(flagPath, 'full'); const qoderFlagPath = path.join(home, '.qoder', '.ponytail-active'); fs.mkdirSync(path.dirname(qoderFlagPath), { recursive: true }); fs.writeFileSync(qoderFlagPath, 'ultra'); +const qoderCustomDir = path.join(temp, 'qoder-custom-config'); +const qoderCustomFlagPath = path.join(qoderCustomDir, '.ponytail-active'); +fs.mkdirSync(qoderCustomDir, { recursive: true }); +fs.writeFileSync(qoderCustomFlagPath, 'lite'); const nudgeFlagPath = path.join(claudeDir, '.ponytail-statusline-nudged'); fs.writeFileSync(nudgeFlagPath, ''); @@ -80,6 +84,7 @@ let result = runUninstall(env); assert.equal(result.status, 0, result.stderr); assert.equal(fs.existsSync(flagPath), false, 'mode flag must be removed'); assert.equal(fs.existsSync(qoderFlagPath), false, 'Qoder mode flag must be removed'); +assert.equal(fs.existsSync(qoderCustomFlagPath), true, 'default Qoder uninstall must not touch a separate custom config'); assert.equal(fs.existsSync(nudgeFlagPath), false, 'statusline nudge flag must be removed'); assert.equal(fs.existsSync(configPath), false, 'config file must be removed'); assert.equal(fs.existsSync(cursorFlagPath), false, 'Cursor mode flag must be removed'); @@ -141,6 +146,10 @@ assert.equal( 'a combined statusLine must keep the non-ponytail command', ); +result = runUninstall({ ...env, QODER_CONFIG_DIR: qoderCustomDir }); +assert.equal(result.status, 0, result.stderr); +assert.equal(fs.existsSync(qoderCustomFlagPath), false, 'Qoder custom config mode flag must be removed'); + // #434: a malformed settings.json must not crash the script mid-cleanup. It // can't be safely edited, so uninstall warns and leaves the file byte-for-byte // intact instead of throwing a SyntaxError after other state was already removed.