@promptcellar/pc 0.5.5 → 0.7.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
@@ -0,0 +1,33 @@
1
+ #!/usr/bin/env node
2
+
3
+ /**
4
+ * Amazon Q Developer CLI hook for capturing prompts to Prompt Cellar.
5
+ *
6
+ * Amazon Q userPromptSubmit hooks receive JSON via stdin with
7
+ * prompt and session context.
8
+ */
9
+
10
+ import { readStdin, runCapture } from './lib/capture-pipeline.js';
11
+
12
+ async function main() {
13
+ try {
14
+ const input = await readStdin();
15
+ if (!input.trim()) process.exit(0);
16
+
17
+ const event = JSON.parse(input);
18
+ const prompt = event.prompt?.trim();
19
+ if (!prompt) process.exit(0);
20
+
21
+ await runCapture({
22
+ toolName: 'amazon-q',
23
+ prompt,
24
+ cwd: event.cwd,
25
+ sessionId: event.session_id,
26
+ });
27
+ } catch {
28
+ // Fail silently
29
+ }
30
+ process.exit(0);
31
+ }
32
+
33
+ main();
@@ -15,11 +15,8 @@
15
15
  * state tracking to avoid duplicates.
16
16
  */
17
17
 
18
- import { capturePrompt } from '../src/lib/api.js';
19
- import { getFullContext } from '../src/lib/context.js';
20
- import { isLoggedIn, isVaultAvailable } from '../src/lib/config.js';
18
+ import { checkAuth, getVaultKey, buildContext, buildContextFiles, submitCapture } from './lib/capture-pipeline.js';
21
19
  import { encryptPrompt } from '../src/lib/crypto.js';
22
- import { requireVaultKey } from '../src/lib/keychain.js';
23
20
  import { getLastCapturedIndex, saveLastCapturedIndex, cleanupStaleThreads } from '../src/lib/state.js';
24
21
 
25
22
  function extractContent(message) {
@@ -35,85 +32,57 @@ function extractContent(message) {
35
32
  }
36
33
 
37
34
  async function main() {
38
- // Codex passes JSON as first argument
39
35
  const jsonArg = process.argv[2];
40
-
41
- if (!jsonArg) {
42
- process.exit(0);
43
- }
44
-
45
- if (!isLoggedIn()) {
46
- process.exit(0);
47
- }
48
-
49
- if (!isVaultAvailable()) {
50
- process.exit(0);
51
- }
36
+ if (!jsonArg) process.exit(0);
37
+ if (!checkAuth()) process.exit(0);
52
38
 
53
39
  try {
54
40
  const event = JSON.parse(jsonArg);
55
-
56
- // Only capture on agent-turn-complete
57
- if (event.type !== 'agent-turn-complete') {
58
- process.exit(0);
59
- }
41
+ if (event.type !== 'agent-turn-complete') process.exit(0);
60
42
 
61
43
  const inputMessages = event['input-messages'] || [];
62
- if (inputMessages.length === 0) {
63
- process.exit(0);
64
- }
65
-
66
44
  const threadId = event['thread-id'];
67
- if (!threadId) {
68
- process.exit(0);
69
- }
45
+ if (!inputMessages.length || !threadId) process.exit(0);
70
46
 
71
- // Clean up stale threads periodically
72
47
  cleanupStaleThreads();
73
48
 
74
- // Get last captured index to avoid duplicates
75
49
  const lastIndex = getLastCapturedIndex(threadId);
76
50
  const newMessages = inputMessages.slice(lastIndex);
51
+ if (!newMessages.length) process.exit(0);
77
52
 
78
- if (newMessages.length === 0) {
79
- process.exit(0);
80
- }
53
+ const vaultKey = await getVaultKey();
54
+ if (!vaultKey) process.exit(0);
81
55
 
82
- const vaultKey = await requireVaultKey({ silent: true });
83
- if (!vaultKey) {
84
- process.exit(0);
56
+ const effectiveCwd = event.cwd || process.cwd();
57
+
58
+ // Encrypt context files once for all messages in this batch
59
+ let encrypted_context_files, context_files_iv;
60
+ const contextFileContents = buildContextFiles('codex', effectiveCwd);
61
+ if (contextFileContents) {
62
+ const enc = encryptPrompt(JSON.stringify(contextFileContents), vaultKey);
63
+ encrypted_context_files = enc.encrypted_content;
64
+ context_files_iv = enc.content_iv;
85
65
  }
86
66
 
87
- // Capture each new message
88
67
  for (const message of newMessages) {
89
68
  const content = extractContent(message);
90
- if (!content || !content.trim()) {
91
- continue;
92
- }
69
+ if (!content?.trim()) continue;
93
70
 
94
71
  const promptText = content.trim();
95
- const context = getFullContext('codex', null, {
96
- cwd: event.cwd || process.cwd(),
72
+ const context = buildContext('codex', {
73
+ cwd: effectiveCwd,
97
74
  sessionId: threadId,
98
75
  promptText,
99
76
  });
100
77
 
101
- const { encrypted_content, content_iv } = encryptPrompt(promptText, vaultKey);
102
-
103
- await capturePrompt({
104
- ...context,
105
- encrypted_content,
106
- content_iv
107
- });
78
+ await submitCapture({ context, prompt: promptText, vaultKey, encrypted_context_files, context_files_iv });
108
79
  }
109
80
 
110
- // Update state with new index
111
81
  saveLastCapturedIndex(threadId, inputMessages.length);
112
-
113
82
  } catch {
114
83
  // Fail silently to not disrupt Codex
115
- process.exit(0);
116
84
  }
85
+ process.exit(0);
117
86
  }
118
87
 
119
88
  main();
@@ -0,0 +1,33 @@
1
+ #!/usr/bin/env node
2
+
3
+ /**
4
+ * GitHub Copilot CLI hook for capturing prompts to Prompt Cellar.
5
+ *
6
+ * Copilot userPromptSubmitted hooks receive JSON via stdin with
7
+ * prompt and session context.
8
+ */
9
+
10
+ import { readStdin, runCapture } from './lib/capture-pipeline.js';
11
+
12
+ async function main() {
13
+ try {
14
+ const input = await readStdin();
15
+ if (!input.trim()) process.exit(0);
16
+
17
+ const event = JSON.parse(input);
18
+ const prompt = event.prompt?.trim();
19
+ if (!prompt) process.exit(0);
20
+
21
+ await runCapture({
22
+ toolName: 'copilot',
23
+ prompt,
24
+ cwd: event.cwd,
25
+ sessionId: event.session_id,
26
+ });
27
+ } catch {
28
+ // Fail silently
29
+ }
30
+ process.exit(0);
31
+ }
32
+
33
+ main();
@@ -0,0 +1,32 @@
1
+ #!/usr/bin/env node
2
+
3
+ /**
4
+ * Cursor IDE hook for capturing prompts to Prompt Cellar.
5
+ *
6
+ * Cursor hooks receive JSON via stdin with prompt and session context.
7
+ */
8
+
9
+ import { readStdin, runCapture } from './lib/capture-pipeline.js';
10
+
11
+ async function main() {
12
+ try {
13
+ const input = await readStdin();
14
+ if (!input.trim()) process.exit(0);
15
+
16
+ const event = JSON.parse(input);
17
+ const prompt = event.prompt?.trim();
18
+ if (!prompt) process.exit(0);
19
+
20
+ await runCapture({
21
+ toolName: 'cursor',
22
+ prompt,
23
+ cwd: event.cwd,
24
+ sessionId: event.session_id,
25
+ });
26
+ } catch {
27
+ // Fail silently
28
+ }
29
+ process.exit(0);
30
+ }
31
+
32
+ main();
@@ -15,87 +15,33 @@
15
15
  * Return {} for pass-through, or { decision: "block", reason: "..." } to block.
16
16
  */
17
17
 
18
- import { capturePrompt } from '../src/lib/api.js';
19
- import { getFullContext } from '../src/lib/context.js';
20
- import { isLoggedIn, isVaultAvailable } from '../src/lib/config.js';
21
- import { encryptPrompt } from '../src/lib/crypto.js';
22
- import { requireVaultKey } from '../src/lib/keychain.js';
23
-
24
- async function readStdin() {
25
- return new Promise((resolve) => {
26
- let data = '';
27
- process.stdin.setEncoding('utf8');
28
- process.stdin.on('data', chunk => data += chunk);
29
- process.stdin.on('end', () => resolve(data));
30
-
31
- // Timeout after 1 second if no input
32
- setTimeout(() => resolve(data), 1000);
33
- });
34
- }
18
+ import { readStdin, runCapture } from './lib/capture-pipeline.js';
35
19
 
36
20
  function respond() {
37
- // Gemini expects JSON output; empty object means pass-through
38
21
  console.log('{}');
39
22
  }
40
23
 
41
24
  async function main() {
42
25
  try {
43
26
  const input = await readStdin();
44
-
45
- if (!input.trim()) {
46
- respond();
47
- process.exit(0);
48
- }
49
-
50
- if (!isLoggedIn()) {
51
- respond();
52
- process.exit(0);
53
- }
54
-
55
- if (!isVaultAvailable()) {
56
- respond();
57
- process.exit(0);
58
- }
27
+ if (!input.trim()) { respond(); process.exit(0); }
59
28
 
60
29
  const event = JSON.parse(input);
30
+ const prompt = event.prompt?.trim();
31
+ if (!prompt) { respond(); process.exit(0); }
61
32
 
62
- // Extract the user's prompt
63
- if (!event.prompt) {
64
- respond();
65
- process.exit(0);
66
- }
67
-
68
- const content = event.prompt.trim();
69
- if (!content) {
70
- respond();
71
- process.exit(0);
72
- }
73
-
74
- // Build context
75
- const context = getFullContext('gemini', null, {
76
- cwd: event.cwd || process.cwd(),
33
+ await runCapture({
34
+ toolName: 'gemini',
35
+ prompt,
36
+ cwd: event.cwd,
77
37
  sessionId: event.session_id,
78
- promptText: content,
79
- });
80
-
81
- const vaultKey = await requireVaultKey({ silent: true });
82
- if (!vaultKey) {
83
- respond();
84
- process.exit(0);
85
- }
86
- const { encrypted_content, content_iv } = encryptPrompt(content, vaultKey);
87
-
88
- await capturePrompt({
89
- ...context,
90
- encrypted_content,
91
- content_iv
92
38
  });
93
39
 
94
40
  respond();
95
- } catch (error) {
96
- // Still respond successfully to not block Gemini
41
+ } catch {
97
42
  respond();
98
43
  }
44
+ process.exit(0);
99
45
  }
100
46
 
101
47
  main();
@@ -0,0 +1,81 @@
1
+ /**
2
+ * Shared capture pipeline for all hook scripts.
3
+ *
4
+ * Provides the common logic: login/vault validation, context building,
5
+ * encryption, and API capture. Each tool's hook script is a thin adapter
6
+ * that parses input and calls runCapture().
7
+ */
8
+
9
+ import { capturePrompt } from '../../src/lib/api.js';
10
+ import { getFullContext, collectContextFileContents } from '../../src/lib/context.js';
11
+ import { isLoggedIn, isVaultAvailable } from '../../src/lib/config.js';
12
+ import { encryptPrompt } from '../../src/lib/crypto.js';
13
+ import { requireVaultKey } from '../../src/lib/keychain.js';
14
+
15
+ export async function readStdin(timeoutMs = 1000) {
16
+ return new Promise((resolve) => {
17
+ let data = '';
18
+ const onData = chunk => data += chunk;
19
+ const onEnd = () => { cleanup(); resolve(data); };
20
+ const cleanup = () => {
21
+ process.stdin.removeListener('data', onData);
22
+ process.stdin.removeListener('end', onEnd);
23
+ };
24
+ process.stdin.setEncoding('utf8');
25
+ process.stdin.on('data', onData);
26
+ process.stdin.on('end', onEnd);
27
+ setTimeout(() => { cleanup(); resolve(data); }, timeoutMs);
28
+ });
29
+ }
30
+
31
+ export function checkAuth() {
32
+ return isLoggedIn() && isVaultAvailable();
33
+ }
34
+
35
+ export async function getVaultKey() {
36
+ return requireVaultKey({ silent: true });
37
+ }
38
+
39
+ export function buildContext(toolName, { cwd, sessionId, promptText }) {
40
+ return getFullContext(toolName, null, { cwd, sessionId, promptText });
41
+ }
42
+
43
+ export function buildContextFiles(toolName, cwd) {
44
+ return collectContextFileContents(toolName, cwd);
45
+ }
46
+
47
+ export async function submitCapture({ context, prompt, vaultKey, encrypted_context_files, context_files_iv }) {
48
+ const { encrypted_content, content_iv } = encryptPrompt(prompt, vaultKey);
49
+ await capturePrompt({
50
+ ...context,
51
+ encrypted_content,
52
+ content_iv,
53
+ encrypted_context_files,
54
+ context_files_iv,
55
+ });
56
+ }
57
+
58
+ export async function runCapture({ toolName, prompt, cwd, sessionId }) {
59
+ if (!checkAuth()) return;
60
+
61
+ const effectiveCwd = cwd || process.cwd();
62
+
63
+ const context = buildContext(toolName, {
64
+ cwd: effectiveCwd,
65
+ sessionId,
66
+ promptText: prompt,
67
+ });
68
+
69
+ const vaultKey = await getVaultKey();
70
+ if (!vaultKey) return;
71
+
72
+ let encrypted_context_files, context_files_iv;
73
+ const contextFileContents = buildContextFiles(toolName, effectiveCwd);
74
+ if (contextFileContents) {
75
+ const enc = encryptPrompt(JSON.stringify(contextFileContents), vaultKey);
76
+ encrypted_context_files = enc.encrypted_content;
77
+ context_files_iv = enc.content_iv;
78
+ }
79
+
80
+ await submitCapture({ context, prompt, vaultKey, encrypted_context_files, context_files_iv });
81
+ }
@@ -10,74 +10,27 @@
10
10
  * - cwd: working directory
11
11
  */
12
12
 
13
- import { capturePrompt } from '../src/lib/api.js';
14
- import { getFullContext } from '../src/lib/context.js';
15
- import { isLoggedIn, isVaultAvailable } from '../src/lib/config.js';
16
- import { encryptPrompt } from '../src/lib/crypto.js';
17
- import { requireVaultKey } from '../src/lib/keychain.js';
18
-
19
- async function readStdin() {
20
- return new Promise((resolve) => {
21
- let data = '';
22
- process.stdin.setEncoding('utf8');
23
- process.stdin.on('data', chunk => data += chunk);
24
- process.stdin.on('end', () => resolve(data));
25
-
26
- // Timeout after 1 second if no input
27
- setTimeout(() => resolve(data), 1000);
28
- });
29
- }
13
+ import { readStdin, runCapture } from './lib/capture-pipeline.js';
30
14
 
31
15
  async function main() {
32
16
  try {
33
17
  const input = await readStdin();
34
-
35
- if (!input.trim()) {
36
- process.exit(0);
37
- }
38
-
39
- if (!isLoggedIn()) {
40
- process.exit(0);
41
- }
42
-
43
- if (!isVaultAvailable()) {
44
- process.exit(0);
45
- }
18
+ if (!input.trim()) process.exit(0);
46
19
 
47
20
  const event = JSON.parse(input);
21
+ const prompt = event.prompt?.trim();
22
+ if (!prompt) process.exit(0);
48
23
 
49
- // UserPromptSubmit provides the prompt directly
50
- if (!event.prompt) {
51
- process.exit(0);
52
- }
53
-
54
- const promptContent = event.prompt.trim();
55
- if (!promptContent) {
56
- process.exit(0);
57
- }
58
-
59
- const context = getFullContext('claude-code', null, {
60
- cwd: event.cwd || process.cwd(),
24
+ await runCapture({
25
+ toolName: 'claude-code',
26
+ prompt,
27
+ cwd: event.cwd,
61
28
  sessionId: event.session_id,
62
- promptText: promptContent,
63
- });
64
-
65
- const vaultKey = await requireVaultKey({ silent: true });
66
- if (!vaultKey) {
67
- process.exit(0);
68
- }
69
- const { encrypted_content, content_iv } = encryptPrompt(promptContent, vaultKey);
70
-
71
- await capturePrompt({
72
- ...context,
73
- encrypted_content,
74
- content_iv
75
29
  });
76
-
77
30
  } catch {
78
31
  // Fail silently — stderr from hooks can cause issues
79
- process.exit(0);
80
32
  }
33
+ process.exit(0);
81
34
  }
82
35
 
83
36
  main();
@@ -0,0 +1,33 @@
1
+ #!/usr/bin/env node
2
+
3
+ /**
4
+ * Windsurf IDE (Codeium) hook for capturing prompts to Prompt Cellar.
5
+ *
6
+ * Windsurf pre_user_prompt hooks receive JSON via stdin with prompt
7
+ * and session context including trajectory_id and execution_id.
8
+ */
9
+
10
+ import { readStdin, runCapture } from './lib/capture-pipeline.js';
11
+
12
+ async function main() {
13
+ try {
14
+ const input = await readStdin();
15
+ if (!input.trim()) process.exit(0);
16
+
17
+ const event = JSON.parse(input);
18
+ const prompt = event.prompt?.trim();
19
+ if (!prompt) process.exit(0);
20
+
21
+ await runCapture({
22
+ toolName: 'windsurf',
23
+ prompt,
24
+ cwd: event.cwd,
25
+ sessionId: event.session_id || event.trajectory_id,
26
+ });
27
+ } catch {
28
+ // Fail silently
29
+ }
30
+ process.exit(0);
31
+ }
32
+
33
+ main();
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@promptcellar/pc",
3
- "version": "0.5.5",
3
+ "version": "0.7.0",
4
4
  "description": "CLI for Prompt Cellar - sync prompts between your terminal and the cloud",
5
5
  "type": "module",
6
6
  "main": "src/index.js",
@@ -8,7 +8,11 @@
8
8
  "pc": "bin/pc.js",
9
9
  "pc-capture": "hooks/prompt-capture.js",
10
10
  "pc-codex-capture": "hooks/codex-capture.js",
11
- "pc-gemini-capture": "hooks/gemini-capture.js"
11
+ "pc-gemini-capture": "hooks/gemini-capture.js",
12
+ "pc-cursor-capture": "hooks/cursor-capture.js",
13
+ "pc-windsurf-capture": "hooks/windsurf-capture.js",
14
+ "pc-copilot-capture": "hooks/copilot-capture.js",
15
+ "pc-amazonq-capture": "hooks/amazonq-capture.js"
12
16
  },
13
17
  "scripts": {
14
18
  "test": "node --test"
@@ -21,7 +25,11 @@
21
25
  "llm",
22
26
  "claude",
23
27
  "codex",
24
- "gemini"
28
+ "gemini",
29
+ "cursor",
30
+ "windsurf",
31
+ "copilot",
32
+ "amazon-q"
25
33
  ],
26
34
  "author": "Welded Anvil Technologies LLC",
27
35
  "license": "MIT",
@@ -1,6 +1,6 @@
1
1
  import inquirer from 'inquirer';
2
2
  import chalk from 'chalk';
3
- import { existsSync, readFileSync, writeFileSync, mkdirSync } from 'fs';
3
+ import { existsSync, readFileSync, writeFileSync, mkdirSync, unlinkSync } from 'fs';
4
4
  import { join, dirname } from 'path';
5
5
  import { homedir } from 'os';
6
6
  import { execFileSync } from 'child_process';
@@ -9,152 +9,138 @@ import { execFileSync } from 'child_process';
9
9
  const CLAUDE_SETTINGS_PATH = join(homedir(), '.claude', 'settings.json');
10
10
  const CODEX_CONFIG_PATH = join(homedir(), '.codex', 'config.toml');
11
11
  const GEMINI_SETTINGS_PATH = join(homedir(), '.gemini', 'settings.json');
12
+ const CURSOR_HOOKS_PATH = join(homedir(), '.cursor', 'hooks.json');
13
+ const WINDSURF_HOOKS_PATH = join(homedir(), '.codeium', 'windsurf', 'hooks.json');
14
+ const COPILOT_HOOKS_DIR = join(homedir(), '.copilot', 'hooks');
15
+ const COPILOT_HOOK_FILE = join(COPILOT_HOOKS_DIR, 'promptcellar-capture.json');
16
+ const AMAZONQ_AGENTS_DIR = join(homedir(), '.aws', 'amazonq', 'cli-agents');
17
+ const AMAZONQ_AGENT_FILE = join(AMAZONQ_AGENTS_DIR, 'promptcellar.json');
18
+
19
+ // Hook script names
20
+ const HOOK_SCRIPTS = {
21
+ claude: 'pc-capture',
22
+ codex: 'pc-codex-capture',
23
+ gemini: 'pc-gemini-capture',
24
+ cursor: 'pc-cursor-capture',
25
+ windsurf: 'pc-windsurf-capture',
26
+ copilot: 'pc-copilot-capture',
27
+ 'amazon-q': 'pc-amazonq-capture',
28
+ };
29
+
30
+ // --- Shared helpers ---
31
+
32
+ function readJsonConfig(path) {
33
+ if (existsSync(path)) {
34
+ try { return JSON.parse(readFileSync(path, 'utf8')); } catch { /* ignore */ }
35
+ }
36
+ return {};
37
+ }
12
38
 
13
- const HOOK_SCRIPT_NAME = 'pc-capture';
14
-
15
- // Detect which tools are installed
16
- function detectInstalledTools() {
17
- const tools = [];
18
-
19
- // Check for Claude Code
20
- try {
21
- execFileSync('which', ['claude'], { stdio: 'pipe' });
22
- tools.push('claude');
23
- } catch { /* not installed */ }
39
+ function writeConfigFile(filePath, content) {
40
+ const dir = dirname(filePath);
41
+ if (!existsSync(dir)) mkdirSync(dir, { recursive: true });
42
+ writeFileSync(filePath, typeof content === 'string' ? content : JSON.stringify(content, null, 2));
43
+ }
24
44
 
25
- // Check for Codex CLI
26
- try {
27
- execFileSync('which', ['codex'], { stdio: 'pipe' });
28
- tools.push('codex');
29
- } catch { /* not installed */ }
45
+ async function confirmReinstall() {
46
+ const { reinstall } = await inquirer.prompt([{
47
+ type: 'confirm',
48
+ name: 'reinstall',
49
+ message: 'Reinstall the hook?',
50
+ default: false
51
+ }]);
52
+ return reinstall;
53
+ }
30
54
 
31
- // Check for Gemini CLI
55
+ function isCommandAvailable(cmd) {
32
56
  try {
33
- execFileSync('which', ['gemini'], { stdio: 'pipe' });
34
- tools.push('gemini');
35
- } catch { /* not installed */ }
36
-
37
- return tools;
57
+ execFileSync('which', [cmd], { stdio: 'pipe' });
58
+ return true;
59
+ } catch {
60
+ return false;
61
+ }
38
62
  }
39
63
 
40
- // Claude Code settings (hooks are in settings.json under "hooks" key)
41
- function getClaudeSettings() {
42
- if (existsSync(CLAUDE_SETTINGS_PATH)) {
43
- try {
44
- return JSON.parse(readFileSync(CLAUDE_SETTINGS_PATH, 'utf8'));
45
- } catch {
46
- return {};
47
- }
48
- }
49
- return {};
64
+ function hasJsonHook(config, eventKey, scriptName) {
65
+ const matchers = config.hooks?.[eventKey] || [];
66
+ return matchers.some(m => m.hooks?.some(h => h.command?.includes(scriptName)));
50
67
  }
51
68
 
52
- function saveClaudeSettings(settings) {
53
- const dir = dirname(CLAUDE_SETTINGS_PATH);
54
- if (!existsSync(dir)) {
55
- mkdirSync(dir, { recursive: true });
69
+ function removeJsonHook(config, eventKey, scriptName) {
70
+ if (config.hooks?.[eventKey]) {
71
+ config.hooks[eventKey] = config.hooks[eventKey].filter(m =>
72
+ !m.hooks?.some(h => h.command?.includes(scriptName))
73
+ );
56
74
  }
57
- writeFileSync(CLAUDE_SETTINGS_PATH, JSON.stringify(settings, null, 2));
58
75
  }
59
76
 
60
- function isClaudeHookInstalled(settings) {
61
- const matchers = settings.hooks?.UserPromptSubmit || [];
62
- return matchers.some(matcher =>
63
- matcher.hooks?.some(hook => hook.command?.includes(HOOK_SCRIPT_NAME))
64
- );
77
+ function addJsonHook(config, eventKey, hookEntry) {
78
+ if (!config.hooks) config.hooks = {};
79
+ if (!config.hooks[eventKey]) config.hooks[eventKey] = [];
80
+ config.hooks[eventKey].push(hookEntry);
65
81
  }
66
82
 
67
- // Check for legacy Stop hook (pre-0.5.0)
68
- function hasLegacyClaudeHook(settings) {
69
- const stopMatchers = settings.hooks?.Stop || [];
70
- return stopMatchers.some(matcher =>
71
- matcher.hooks?.some(hook => hook.command?.includes(HOOK_SCRIPT_NAME))
72
- );
73
- }
83
+ // --- Tool detection ---
74
84
 
75
- // Remove legacy Stop hook if present
76
- function removeLegacyClaudeHook(settings) {
77
- if (settings.hooks?.Stop) {
78
- settings.hooks.Stop = settings.hooks.Stop.filter(matcher =>
79
- !matcher.hooks?.some(hook => hook.command?.includes(HOOK_SCRIPT_NAME))
80
- );
85
+ function detectInstalledTools() {
86
+ const tools = [];
87
+ const simpleBinaries = [
88
+ { binary: 'claude', key: 'claude' },
89
+ { binary: 'codex', key: 'codex' },
90
+ { binary: 'gemini', key: 'gemini' },
91
+ { binary: 'cursor', key: 'cursor' },
92
+ { binary: 'windsurf', key: 'windsurf' },
93
+ { binary: 'q', key: 'amazon-q' },
94
+ ];
95
+
96
+ for (const { binary, key } of simpleBinaries) {
97
+ if (isCommandAvailable(binary)) tools.push(key);
81
98
  }
82
- }
83
99
 
84
- // Codex config (TOML)
85
- function getCodexConfig() {
86
- if (existsSync(CODEX_CONFIG_PATH)) {
87
- try {
88
- return readFileSync(CODEX_CONFIG_PATH, 'utf8');
89
- } catch {
90
- return '';
91
- }
100
+ // Copilot: gh binary + ~/.copilot directory
101
+ if (isCommandAvailable('gh') && existsSync(join(homedir(), '.copilot'))) {
102
+ tools.push('copilot');
92
103
  }
93
- return '';
94
- }
95
104
 
96
- function isCodexHookInstalled(config) {
97
- return config.includes('pc-codex-capture');
105
+ return tools;
98
106
  }
99
107
 
100
- function saveCodexConfig(content) {
101
- const dir = dirname(CODEX_CONFIG_PATH);
102
- if (!existsSync(dir)) {
103
- mkdirSync(dir, { recursive: true });
104
- }
105
- writeFileSync(CODEX_CONFIG_PATH, content);
106
- }
108
+ // --- Codex (TOML — special case) ---
107
109
 
108
- // Gemini settings (JSON)
109
- function getGeminiSettings() {
110
- if (existsSync(GEMINI_SETTINGS_PATH)) {
111
- try {
112
- return JSON.parse(readFileSync(GEMINI_SETTINGS_PATH, 'utf8'));
113
- } catch {
114
- return {};
115
- }
110
+ function getCodexConfig() {
111
+ if (existsSync(CODEX_CONFIG_PATH)) {
112
+ try { return readFileSync(CODEX_CONFIG_PATH, 'utf8'); } catch { return ''; }
116
113
  }
117
- return {};
114
+ return '';
118
115
  }
119
116
 
120
- function isGeminiHookInstalled(settings) {
121
- const hooks = settings.hooks?.BeforeAgent || [];
122
- return hooks.some(h =>
123
- h.hooks?.some(hook => hook.command?.includes('pc-gemini-capture'))
124
- );
117
+ function isCodexHookInstalled(config) {
118
+ return config.includes(HOOK_SCRIPTS.codex);
125
119
  }
126
120
 
127
- function saveGeminiSettings(settings) {
128
- const dir = dirname(GEMINI_SETTINGS_PATH);
129
- if (!existsSync(dir)) {
130
- mkdirSync(dir, { recursive: true });
131
- }
132
- writeFileSync(GEMINI_SETTINGS_PATH, JSON.stringify(settings, null, 2));
133
- }
121
+ // --- Setup functions ---
134
122
 
135
123
  export async function setup() {
136
124
  console.log(chalk.bold('\nPrompt Cellar CLI Setup\n'));
137
125
 
138
126
  const installedTools = detectInstalledTools();
139
127
 
140
- const tools = [
141
- {
142
- name: `Claude Code${installedTools.includes('claude') ? chalk.green(' (detected)') : ''}`,
143
- value: 'claude',
144
- checked: installedTools.includes('claude')
145
- },
146
- {
147
- name: `Codex CLI${installedTools.includes('codex') ? chalk.green(' (detected)') : ''}`,
148
- value: 'codex',
149
- checked: installedTools.includes('codex')
150
- },
151
- {
152
- name: `Gemini CLI${installedTools.includes('gemini') ? chalk.green(' (detected)') : ''}`,
153
- value: 'gemini',
154
- checked: installedTools.includes('gemini')
155
- }
128
+ const toolDefs = [
129
+ { label: 'Claude Code', value: 'claude' },
130
+ { label: 'Codex CLI', value: 'codex' },
131
+ { label: 'Gemini CLI', value: 'gemini' },
132
+ { label: 'Cursor', value: 'cursor' },
133
+ { label: 'Windsurf', value: 'windsurf' },
134
+ { label: 'GitHub Copilot CLI', value: 'copilot' },
135
+ { label: 'Amazon Q Developer', value: 'amazon-q' },
156
136
  ];
157
137
 
138
+ const tools = toolDefs.map(t => ({
139
+ name: `${t.label}${installedTools.includes(t.value) ? chalk.green(' (detected)') : ''}`,
140
+ value: t.value,
141
+ checked: installedTools.includes(t.value),
142
+ }));
143
+
158
144
  const { selectedTools } = await inquirer.prompt([{
159
145
  type: 'checkbox',
160
146
  name: 'selectedTools',
@@ -167,14 +153,19 @@ export async function setup() {
167
153
  return;
168
154
  }
169
155
 
156
+ const setupFns = {
157
+ claude: setupClaudeCode,
158
+ codex: setupCodex,
159
+ gemini: setupGemini,
160
+ cursor: setupCursor,
161
+ windsurf: setupWindsurf,
162
+ copilot: setupCopilot,
163
+ 'amazon-q': setupAmazonQ,
164
+ };
165
+
170
166
  for (const tool of selectedTools) {
171
- if (tool === 'claude') {
172
- await setupClaudeCode();
173
- } else if (tool === 'codex') {
174
- await setupCodex();
175
- } else if (tool === 'gemini') {
176
- await setupGemini();
177
- }
167
+ const fn = setupFns[tool];
168
+ if (fn) await fn();
178
169
  }
179
170
 
180
171
  console.log(chalk.green('\nSetup complete!'));
@@ -184,59 +175,33 @@ export async function setup() {
184
175
  async function setupClaudeCode() {
185
176
  console.log(chalk.cyan('\nConfiguring Claude Code...'));
186
177
 
187
- const settings = getClaudeSettings();
188
- const hasLegacy = hasLegacyClaudeHook(settings);
189
- const hasCurrent = isClaudeHookInstalled(settings);
178
+ const settings = readJsonConfig(CLAUDE_SETTINGS_PATH);
179
+ const script = HOOK_SCRIPTS.claude;
180
+ const hasLegacy = hasJsonHook(settings, 'Stop', script);
181
+ const hasCurrent = hasJsonHook(settings, 'UserPromptSubmit', script);
190
182
 
191
183
  if (hasCurrent) {
192
184
  console.log(chalk.yellow(' Hook already installed.'));
193
-
194
- const { reinstall } = await inquirer.prompt([{
195
- type: 'confirm',
196
- name: 'reinstall',
197
- message: 'Reinstall the hook?',
198
- default: false
199
- }]);
200
-
201
- if (!reinstall) {
202
- // Still remove legacy hook if present
185
+ if (!await confirmReinstall()) {
203
186
  if (hasLegacy) {
204
- removeLegacyClaudeHook(settings);
205
- saveClaudeSettings(settings);
187
+ removeJsonHook(settings, 'Stop', script);
188
+ writeConfigFile(CLAUDE_SETTINGS_PATH, settings);
206
189
  console.log(chalk.green(' Removed legacy Stop hook.'));
207
190
  }
208
191
  return;
209
192
  }
210
-
211
- // Remove existing hook matchers that contain our hook
212
- settings.hooks.UserPromptSubmit = (settings.hooks.UserPromptSubmit || []).filter(matcher =>
213
- !matcher.hooks?.some(hook => hook.command?.includes(HOOK_SCRIPT_NAME))
214
- );
193
+ removeJsonHook(settings, 'UserPromptSubmit', script);
215
194
  } else if (hasLegacy) {
216
195
  console.log(chalk.yellow(' Migrating from legacy Stop hook to UserPromptSubmit...'));
217
196
  }
218
197
 
219
- // Always remove legacy Stop hook if present
220
- removeLegacyClaudeHook(settings);
221
-
222
- // Ensure hooks.UserPromptSubmit exists
223
- if (!settings.hooks) {
224
- settings.hooks = {};
225
- }
226
- if (!settings.hooks.UserPromptSubmit) {
227
- settings.hooks.UserPromptSubmit = [];
228
- }
229
-
230
- // Add the UserPromptSubmit hook
231
- settings.hooks.UserPromptSubmit.push({
198
+ removeJsonHook(settings, 'Stop', script);
199
+ addJsonHook(settings, 'UserPromptSubmit', {
232
200
  matcher: '*',
233
- hooks: [{
234
- type: 'command',
235
- command: 'pc-capture'
236
- }]
201
+ hooks: [{ type: 'command', command: script }]
237
202
  });
238
203
 
239
- saveClaudeSettings(settings);
204
+ writeConfigFile(CLAUDE_SETTINGS_PATH, settings);
240
205
  console.log(chalk.green(' Hook installed successfully.'));
241
206
  }
242
207
 
@@ -244,35 +209,22 @@ async function setupCodex() {
244
209
  console.log(chalk.cyan('\nConfiguring Codex CLI...'));
245
210
 
246
211
  let config = getCodexConfig();
212
+ const script = HOOK_SCRIPTS.codex;
247
213
 
248
214
  if (isCodexHookInstalled(config)) {
249
215
  console.log(chalk.yellow(' Hook already installed.'));
216
+ if (!await confirmReinstall()) return;
250
217
 
251
- const { reinstall } = await inquirer.prompt([{
252
- type: 'confirm',
253
- name: 'reinstall',
254
- message: 'Reinstall the hook?',
255
- default: false
256
- }]);
257
-
258
- if (!reinstall) {
259
- return;
260
- }
261
-
262
- // Remove existing notify line and comment
263
218
  config = config.split('\n')
264
- .filter(line => !line.includes('pc-codex-capture') && !line.includes('# Prompt Cellar capture hook'))
219
+ .filter(line => !line.includes(script) && !line.includes('# Prompt Cellar capture hook'))
265
220
  .join('\n');
266
221
  }
267
222
 
268
- // Add the notify hook at root level (before any [table] sections)
269
- const notifyLine = 'notify = ["pc-codex-capture"]';
223
+ const notifyLine = `notify = ["${script}"]`;
270
224
 
271
225
  if (config.match(/^notify\s*=/m)) {
272
- // Replace existing root-level notify
273
226
  config = config.replace(/^notify\s*=.*$/m, notifyLine);
274
227
  } else {
275
- // Insert at root level — before the first [table] section
276
228
  const firstTableMatch = config.match(/^\[/m);
277
229
  if (firstTableMatch) {
278
230
  const insertPos = firstTableMatch.index;
@@ -280,112 +232,174 @@ async function setupCodex() {
280
232
  const after = config.slice(insertPos);
281
233
  config = before + '\n\n# Prompt Cellar capture hook\n' + notifyLine + '\n\n' + after;
282
234
  } else {
283
- // No table sections — safe to append
284
235
  config = config.trimEnd() + '\n\n# Prompt Cellar capture hook\n' + notifyLine + '\n';
285
236
  }
286
237
  }
287
238
 
288
- saveCodexConfig(config);
239
+ writeConfigFile(CODEX_CONFIG_PATH, config);
289
240
  console.log(chalk.green(' Hook installed successfully.'));
290
241
  }
291
242
 
292
- async function setupGemini() {
293
- console.log(chalk.cyan('\nConfiguring Gemini CLI...'));
243
+ async function setupJsonHookTool({ label, configPath, eventKey, hookEntry, scriptName }) {
244
+ console.log(chalk.cyan(`\nConfiguring ${label}...`));
294
245
 
295
- const settings = getGeminiSettings();
246
+ const config = readJsonConfig(configPath);
296
247
 
297
- if (isGeminiHookInstalled(settings)) {
248
+ if (hasJsonHook(config, eventKey, scriptName)) {
298
249
  console.log(chalk.yellow(' Hook already installed.'));
250
+ if (!await confirmReinstall()) return;
251
+ removeJsonHook(config, eventKey, scriptName);
252
+ }
299
253
 
300
- const { reinstall } = await inquirer.prompt([{
301
- type: 'confirm',
302
- name: 'reinstall',
303
- message: 'Reinstall the hook?',
304
- default: false
305
- }]);
254
+ addJsonHook(config, eventKey, hookEntry);
255
+ writeConfigFile(configPath, config);
256
+ console.log(chalk.green(' Hook installed successfully.'));
257
+ }
306
258
 
307
- if (!reinstall) {
308
- return;
309
- }
259
+ async function setupGemini() {
260
+ return setupJsonHookTool({
261
+ label: 'Gemini CLI',
262
+ configPath: GEMINI_SETTINGS_PATH,
263
+ eventKey: 'BeforeAgent',
264
+ scriptName: HOOK_SCRIPTS.gemini,
265
+ hookEntry: {
266
+ matcher: '*',
267
+ hooks: [{
268
+ name: 'promptcellar-capture',
269
+ type: 'command',
270
+ command: HOOK_SCRIPTS.gemini,
271
+ timeout: 5000
272
+ }]
273
+ },
274
+ });
275
+ }
310
276
 
311
- // Remove existing hook
312
- if (settings.hooks?.BeforeAgent) {
313
- settings.hooks.BeforeAgent = settings.hooks.BeforeAgent.filter(h =>
314
- !h.hooks?.some(hook => hook.command?.includes('pc-gemini-capture'))
315
- );
316
- }
317
- }
277
+ async function setupCursor() {
278
+ return setupJsonHookTool({
279
+ label: 'Cursor',
280
+ configPath: CURSOR_HOOKS_PATH,
281
+ eventKey: 'UserPromptSubmit',
282
+ scriptName: HOOK_SCRIPTS.cursor,
283
+ hookEntry: {
284
+ matcher: '*',
285
+ hooks: [{ type: 'command', command: HOOK_SCRIPTS.cursor }]
286
+ },
287
+ });
288
+ }
318
289
 
319
- // Add the BeforeAgent hook
320
- if (!settings.hooks) {
321
- settings.hooks = {};
322
- }
323
- if (!settings.hooks.BeforeAgent) {
324
- settings.hooks.BeforeAgent = [];
290
+ async function setupWindsurf() {
291
+ return setupJsonHookTool({
292
+ label: 'Windsurf',
293
+ configPath: WINDSURF_HOOKS_PATH,
294
+ eventKey: 'pre_user_prompt',
295
+ scriptName: HOOK_SCRIPTS.windsurf,
296
+ hookEntry: {
297
+ matcher: '*',
298
+ hooks: [{ type: 'command', command: HOOK_SCRIPTS.windsurf }]
299
+ },
300
+ });
301
+ }
302
+
303
+ async function setupFileTool({ label, filePath, content }) {
304
+ console.log(chalk.cyan(`\nConfiguring ${label}...`));
305
+
306
+ if (existsSync(filePath)) {
307
+ console.log(chalk.yellow(' Hook already installed.'));
308
+ if (!await confirmReinstall()) return;
325
309
  }
326
310
 
327
- settings.hooks.BeforeAgent.push({
328
- matcher: '*',
329
- hooks: [{
311
+ writeConfigFile(filePath, content);
312
+ console.log(chalk.green(' Hook installed successfully.'));
313
+ }
314
+
315
+ async function setupCopilot() {
316
+ return setupFileTool({
317
+ label: 'GitHub Copilot CLI',
318
+ filePath: COPILOT_HOOK_FILE,
319
+ content: {
330
320
  name: 'promptcellar-capture',
331
- type: 'command',
332
- command: 'pc-gemini-capture',
321
+ event: 'userPromptSubmitted',
322
+ command: HOOK_SCRIPTS.copilot,
333
323
  timeout: 5000
334
- }]
324
+ },
335
325
  });
326
+ }
336
327
 
337
- saveGeminiSettings(settings);
338
- console.log(chalk.green(' Hook installed successfully.'));
328
+ async function setupAmazonQ() {
329
+ return setupFileTool({
330
+ label: 'Amazon Q Developer',
331
+ filePath: AMAZONQ_AGENT_FILE,
332
+ content: {
333
+ name: 'promptcellar',
334
+ hooks: {
335
+ userPromptSubmit: [{
336
+ command: HOOK_SCRIPTS['amazon-q'],
337
+ timeout_ms: 5000
338
+ }]
339
+ }
340
+ },
341
+ });
339
342
  }
340
343
 
344
+ // --- Unsetup ---
345
+
341
346
  export async function unsetup() {
342
347
  console.log(chalk.bold('\nRemoving Prompt Cellar hooks...\n'));
343
348
 
344
349
  let removed = false;
345
350
 
346
- // Remove Claude hook (both current and legacy)
347
- const claudeSettings = getClaudeSettings();
348
- const hasCurrent = isClaudeHookInstalled(claudeSettings);
349
- const hasLegacy = hasLegacyClaudeHook(claudeSettings);
350
-
351
- if (hasCurrent || hasLegacy) {
352
- if (hasCurrent) {
353
- claudeSettings.hooks.UserPromptSubmit = (claudeSettings.hooks.UserPromptSubmit || []).filter(matcher =>
354
- !matcher.hooks?.some(hook => hook.command?.includes(HOOK_SCRIPT_NAME))
355
- );
356
- }
357
- if (hasLegacy) {
358
- removeLegacyClaudeHook(claudeSettings);
359
- }
360
- saveClaudeSettings(claudeSettings);
351
+ // Claude (both current and legacy)
352
+ const claudeSettings = readJsonConfig(CLAUDE_SETTINGS_PATH);
353
+ const claudeScript = HOOK_SCRIPTS.claude;
354
+ if (hasJsonHook(claudeSettings, 'UserPromptSubmit', claudeScript) || hasJsonHook(claudeSettings, 'Stop', claudeScript)) {
355
+ removeJsonHook(claudeSettings, 'UserPromptSubmit', claudeScript);
356
+ removeJsonHook(claudeSettings, 'Stop', claudeScript);
357
+ writeConfigFile(CLAUDE_SETTINGS_PATH, claudeSettings);
361
358
  console.log(chalk.green(' Removed Claude Code hook.'));
362
359
  removed = true;
363
360
  }
364
361
 
365
- // Remove Codex hook
362
+ // Codex
366
363
  let codexConfig = getCodexConfig();
367
364
  if (isCodexHookInstalled(codexConfig)) {
368
365
  codexConfig = codexConfig.split('\n')
369
- .filter(line => !line.includes('pc-codex-capture') && !line.includes('# Prompt Cellar capture hook'))
370
- .join('\n');
371
- // Clean up extra blank lines left behind
372
- codexConfig = codexConfig.replace(/\n{3,}/g, '\n\n');
373
- saveCodexConfig(codexConfig);
366
+ .filter(line => !line.includes(HOOK_SCRIPTS.codex) && !line.includes('# Prompt Cellar capture hook'))
367
+ .join('\n')
368
+ .replace(/\n{3,}/g, '\n\n');
369
+ writeConfigFile(CODEX_CONFIG_PATH, codexConfig);
374
370
  console.log(chalk.green(' Removed Codex CLI hook.'));
375
371
  removed = true;
376
372
  }
377
373
 
378
- // Remove Gemini hook
379
- const geminiSettings = getGeminiSettings();
380
- if (isGeminiHookInstalled(geminiSettings)) {
381
- if (geminiSettings.hooks?.BeforeAgent) {
382
- geminiSettings.hooks.BeforeAgent = geminiSettings.hooks.BeforeAgent.filter(h =>
383
- !h.hooks?.some(hook => hook.command?.includes('pc-gemini-capture'))
384
- );
374
+ // JSON hook tools (Gemini, Cursor, Windsurf)
375
+ const jsonHookTools = [
376
+ { label: 'Gemini CLI', path: GEMINI_SETTINGS_PATH, eventKey: 'BeforeAgent', key: 'gemini' },
377
+ { label: 'Cursor', path: CURSOR_HOOKS_PATH, eventKey: 'UserPromptSubmit', key: 'cursor' },
378
+ { label: 'Windsurf', path: WINDSURF_HOOKS_PATH, eventKey: 'pre_user_prompt', key: 'windsurf' },
379
+ ];
380
+
381
+ for (const { label, path, eventKey, key } of jsonHookTools) {
382
+ const config = readJsonConfig(path);
383
+ if (hasJsonHook(config, eventKey, HOOK_SCRIPTS[key])) {
384
+ removeJsonHook(config, eventKey, HOOK_SCRIPTS[key]);
385
+ writeConfigFile(path, config);
386
+ console.log(chalk.green(` Removed ${label} hook.`));
387
+ removed = true;
388
+ }
389
+ }
390
+
391
+ // File-based tools (Copilot, Amazon Q)
392
+ const fileTools = [
393
+ { label: 'GitHub Copilot CLI', path: COPILOT_HOOK_FILE },
394
+ { label: 'Amazon Q Developer', path: AMAZONQ_AGENT_FILE },
395
+ ];
396
+
397
+ for (const { label, path } of fileTools) {
398
+ if (existsSync(path)) {
399
+ try { unlinkSync(path); } catch { /* already removed */ }
400
+ console.log(chalk.green(` Removed ${label} hook.`));
401
+ removed = true;
385
402
  }
386
- saveGeminiSettings(geminiSettings);
387
- console.log(chalk.green(' Removed Gemini CLI hook.'));
388
- removed = true;
389
403
  }
390
404
 
391
405
  if (!removed) {
@@ -2,6 +2,7 @@ import { getCaptureLevel, getSessionId } from './config.js';
2
2
  import {
3
3
  getGitContext as coreGitContext,
4
4
  getFullContext as coreFullContext,
5
+ collectContextFileContents as coreCollectContextFileContents,
5
6
  } from '@promptcellar/core/context';
6
7
 
7
8
  export { sanitizeGitRemote } from '@promptcellar/core/context';
@@ -21,4 +22,8 @@ export function getFullContext(tool, model, overrides = {}) {
21
22
  });
22
23
  }
23
24
 
24
- export default { getGitContext, getFullContext };
25
+ export function collectContextFileContents(tool, cwd) {
26
+ return coreCollectContextFileContents({ tool, cwd });
27
+ }
28
+
29
+ export default { getGitContext, getFullContext, collectContextFileContents };