@promptcellar/pc 0.4.0 → 0.5.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.
@@ -1,21 +1,25 @@
1
1
  #!/usr/bin/env node
2
2
 
3
3
  /**
4
- * Gemini CLI AfterAgent hook for capturing prompts to PromptCellar.
4
+ * Gemini CLI BeforeAgent hook for capturing prompts to PromptCellar.
5
5
  *
6
6
  * Gemini hooks receive JSON via stdin with:
7
- * - event_type: the hook event (e.g., 'AfterAgent')
7
+ * - hook_event_name: the hook event (e.g., 'BeforeAgent')
8
8
  * - prompt: the user's prompt
9
- * - prompt_response: the agent's response (for AfterAgent)
10
9
  * - session_id: session identifier
11
- * - working_directory: current working directory
10
+ * - cwd: working directory
11
+ * - transcript_path: path to session transcript
12
+ * - timestamp: event timestamp
12
13
  *
13
14
  * The hook must output valid JSON to stdout.
15
+ * Return {} for pass-through, or { decision: "block", reason: "..." } to block.
14
16
  */
15
17
 
16
18
  import { capturePrompt } from '../src/lib/api.js';
17
19
  import { getFullContext } from '../src/lib/context.js';
18
- import { isLoggedIn } from '../src/lib/config.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';
19
23
 
20
24
  async function readStdin() {
21
25
  return new Promise((resolve) => {
@@ -29,13 +33,9 @@ async function readStdin() {
29
33
  });
30
34
  }
31
35
 
32
- function respond(success = true) {
33
- // Gemini expects JSON output
34
- const response = {
35
- action: 'continue',
36
- success
37
- };
38
- console.log(JSON.stringify(response));
36
+ function respond() {
37
+ // Gemini expects JSON output; empty object means pass-through
38
+ console.log('{}');
39
39
  }
40
40
 
41
41
  async function main() {
@@ -43,26 +43,31 @@ async function main() {
43
43
  const input = await readStdin();
44
44
 
45
45
  if (!input.trim()) {
46
- respond(true);
46
+ respond();
47
47
  process.exit(0);
48
48
  }
49
49
 
50
50
  if (!isLoggedIn()) {
51
- respond(true);
51
+ respond();
52
+ process.exit(0);
53
+ }
54
+
55
+ if (!isVaultAvailable()) {
56
+ respond();
52
57
  process.exit(0);
53
58
  }
54
59
 
55
60
  const event = JSON.parse(input);
56
61
 
57
- // Only capture AfterAgent events with a prompt
58
- if (event.event_type !== 'AfterAgent' || !event.prompt) {
59
- respond(true);
62
+ // Extract the user's prompt
63
+ if (!event.prompt) {
64
+ respond();
60
65
  process.exit(0);
61
66
  }
62
67
 
63
68
  const content = event.prompt.trim();
64
69
  if (!content) {
65
- respond(true);
70
+ respond();
66
71
  process.exit(0);
67
72
  }
68
73
 
@@ -70,22 +75,30 @@ async function main() {
70
75
  const context = getFullContext('gemini');
71
76
 
72
77
  // Override with event data if available
73
- if (event.working_directory) {
74
- context.working_directory = event.working_directory;
78
+ if (event.cwd) {
79
+ context.working_directory = event.cwd;
75
80
  }
76
81
  if (event.session_id) {
77
82
  context.session_id = event.session_id;
78
83
  }
79
84
 
85
+ const vaultKey = await requireVaultKey({ silent: true });
86
+ if (!vaultKey) {
87
+ respond();
88
+ process.exit(0);
89
+ }
90
+ const { encrypted_content, content_iv } = encryptPrompt(content, vaultKey);
91
+
80
92
  await capturePrompt({
81
- content,
82
- ...context
93
+ ...context,
94
+ encrypted_content,
95
+ content_iv
83
96
  });
84
97
 
85
- respond(true);
98
+ respond();
86
99
  } catch (error) {
87
100
  // Still respond successfully to not block Gemini
88
- respond(true);
101
+ respond();
89
102
  }
90
103
  }
91
104
 
@@ -1,21 +1,20 @@
1
1
  #!/usr/bin/env node
2
2
 
3
3
  /**
4
- * Claude Code Stop hook for capturing prompts to PromptCellar.
4
+ * Claude Code UserPromptSubmit hook for capturing prompts to PromptCellar.
5
5
  *
6
- * This script is called by Claude Code when a session ends.
7
- * Claude Code Stop hooks receive JSON via stdin with:
8
- * - transcript_path: path to the session JSONL transcript
6
+ * This script is called by Claude Code on every user prompt submission.
7
+ * UserPromptSubmit hooks receive JSON via stdin with:
9
8
  * - session_id: session identifier
9
+ * - prompt: the user's prompt text
10
10
  * - cwd: working directory
11
- *
12
- * It reads the transcript file and extracts the initial user prompt to capture.
13
11
  */
14
12
 
15
- import { readFileSync, existsSync } from 'fs';
16
13
  import { capturePrompt } from '../src/lib/api.js';
17
14
  import { getFullContext } from '../src/lib/context.js';
18
- import { isLoggedIn } from '../src/lib/config.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';
19
18
 
20
19
  async function readStdin() {
21
20
  return new Promise((resolve) => {
@@ -41,38 +40,22 @@ async function main() {
41
40
  process.exit(0);
42
41
  }
43
42
 
44
- const event = JSON.parse(input);
45
- const transcriptPath = event.transcript_path;
46
-
47
- if (!transcriptPath || !existsSync(transcriptPath)) {
43
+ if (!isVaultAvailable()) {
48
44
  process.exit(0);
49
45
  }
50
46
 
51
- const content = readFileSync(transcriptPath, 'utf8');
52
- const lines = content.split('\n').filter(line => line.trim());
53
-
54
- // Parse JSONL format
55
- const messages = [];
56
- for (const line of lines) {
57
- try {
58
- const entry = JSON.parse(line);
59
- if (entry.type === 'human' && entry.message?.content) {
60
- messages.push({
61
- content: entry.message.content,
62
- timestamp: entry.timestamp
63
- });
64
- }
65
- } catch {
66
- // Skip invalid JSON lines
67
- }
47
+ const event = JSON.parse(input);
48
+
49
+ // UserPromptSubmit provides the prompt directly
50
+ if (!event.prompt) {
51
+ process.exit(0);
68
52
  }
69
53
 
70
- if (messages.length === 0) {
54
+ const promptContent = event.prompt.trim();
55
+ if (!promptContent) {
71
56
  process.exit(0);
72
57
  }
73
58
 
74
- // Capture the first user message (initial prompt)
75
- const initialPrompt = messages[0];
76
59
  const context = getFullContext('claude-code');
77
60
 
78
61
  // Override with event data if available
@@ -83,10 +66,16 @@ async function main() {
83
66
  context.session_id = event.session_id;
84
67
  }
85
68
 
69
+ const vaultKey = await requireVaultKey({ silent: true });
70
+ if (!vaultKey) {
71
+ process.exit(0);
72
+ }
73
+ const { encrypted_content, content_iv } = encryptPrompt(promptContent, vaultKey);
74
+
86
75
  await capturePrompt({
87
- content: initialPrompt.content,
88
76
  ...context,
89
- captured_at: initialPrompt.timestamp
77
+ encrypted_content,
78
+ content_iv
90
79
  });
91
80
 
92
81
  } catch {
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@promptcellar/pc",
3
- "version": "0.4.0",
3
+ "version": "0.5.0",
4
4
  "description": "CLI for PromptCellar - sync prompts between your terminal and the cloud",
5
5
  "type": "module",
6
6
  "main": "src/index.js",
@@ -11,7 +11,7 @@
11
11
  "pc-gemini-capture": "hooks/gemini-capture.js"
12
12
  },
13
13
  "scripts": {
14
- "test": "echo \"Error: no test specified\" && exit 1"
14
+ "test": "node --test"
15
15
  },
16
16
  "keywords": [
17
17
  "promptcellar",
@@ -39,6 +39,7 @@
39
39
  "chalk": "^5.3.0",
40
40
  "ora": "^8.0.0",
41
41
  "inquirer": "^9.2.0",
42
+ "keytar": "^7.9.0",
42
43
  "socket.io-client": "^4.6.0"
43
44
  }
44
45
  }
@@ -1,68 +1,132 @@
1
+ import { randomBytes } from 'crypto';
1
2
  import ora from 'ora';
2
3
  import chalk from 'chalk';
3
- import { setApiKey, setApiUrl, isLoggedIn } from '../lib/config.js';
4
+ import {
5
+ setApiKey, setApiUrl, setAccountUrl,
6
+ setVaultAvailable,
7
+ isLoggedIn
8
+ } from '../lib/config.js';
9
+ import { generateTransferKeyPair, decryptTransfer } from '../lib/device-transfer.js';
10
+ import { b64urlEncode } from '../lib/crypto.js';
11
+ import { storeVaultKey } from '../lib/keychain.js';
4
12
 
5
13
  const DEFAULT_API_URL = 'https://prompts.weldedanvil.com';
14
+ const DEFAULT_ACCOUNT_URL = 'https://account.weldedanvil.com';
15
+ let clientId = 'prompts-prod';
6
16
 
7
- async function initiateDeviceAuth(apiUrl) {
8
- const response = await fetch(`${apiUrl}/auth/device`, {
17
+ export function setClientId(id) { clientId = id; }
18
+
19
+ export async function requestDeviceCode(accountUrl, transferPublicKey) {
20
+ const payload = { client_id: clientId };
21
+ if (transferPublicKey) {
22
+ payload.transfer_public_key = transferPublicKey;
23
+ }
24
+
25
+ const response = await fetch(`${accountUrl}/device/code`, {
9
26
  method: 'POST',
10
27
  headers: { 'Content-Type': 'application/json' },
11
- body: JSON.stringify({ device_name: getDeviceName() })
28
+ body: JSON.stringify(payload),
12
29
  });
13
30
 
14
31
  if (!response.ok) {
15
- throw new Error('Failed to initiate device authentication');
32
+ const data = await response.json().catch(() => ({}));
33
+ throw new Error(data.error || 'Failed to request device code');
16
34
  }
17
35
 
18
36
  return response.json();
19
37
  }
20
38
 
21
- async function pollForApproval(apiUrl, deviceCode, interval, expiresIn) {
22
- const startTime = Date.now();
23
- const expiresAt = startTime + (expiresIn * 1000);
39
+ async function pollForApproval(accountUrl, deviceCode, expiresIn) {
40
+ const expiresAt = Date.now() + (expiresIn || 600) * 1000;
41
+ const interval = 5000;
24
42
 
25
43
  while (Date.now() < expiresAt) {
26
- await sleep(interval * 1000);
44
+ await sleep(interval);
27
45
 
28
- const response = await fetch(`${apiUrl}/auth/device/poll`, {
46
+ const response = await fetch(`${accountUrl}/device/poll`, {
29
47
  method: 'POST',
30
48
  headers: { 'Content-Type': 'application/json' },
31
- body: JSON.stringify({ device_code: deviceCode })
49
+ body: JSON.stringify({ device_code: deviceCode }),
32
50
  });
33
51
 
34
52
  const data = await response.json();
35
53
 
36
- if (response.ok && data.access_token) {
54
+ if (data.status === 'approved' && data.access_token) {
37
55
  return data;
38
56
  }
39
57
 
40
- if (data.error === 'authorization_pending') {
41
- continue;
42
- }
43
-
44
- if (data.error === 'expired_token') {
58
+ if (data.status === 'expired') {
45
59
  throw new Error('Authorization request expired. Please try again.');
46
60
  }
47
61
 
48
- if (data.error === 'access_denied') {
49
- throw new Error('Authorization denied.');
62
+ if (data.status === 'rejected') {
63
+ const error = data.error || 'unknown';
64
+ if (error.startsWith('vault_missing')) {
65
+ throw new Error(`Vault error: ${error}`);
66
+ }
67
+ if (error === 'missing_transfer_key') {
68
+ throw new Error('Transfer key missing. Please try again.');
69
+ }
70
+ throw new Error(`Authorization rejected: ${error}`);
50
71
  }
72
+
73
+ // status === 'pending' -- keep polling
51
74
  }
52
75
 
53
76
  throw new Error('Authorization request timed out. Please try again.');
54
77
  }
55
78
 
79
+ async function exchangeTokenForApiKey(apiUrl, jwt, deviceName) {
80
+ const response = await fetch(`${apiUrl}/api/v1/auth/device-token`, {
81
+ method: 'POST',
82
+ headers: {
83
+ 'Authorization': `Bearer ${jwt}`,
84
+ 'Content-Type': 'application/json',
85
+ },
86
+ body: JSON.stringify({ device_name: deviceName }),
87
+ });
88
+
89
+ if (!response.ok) {
90
+ const data = await response.json().catch(() => ({}));
91
+ throw new Error(data.error || 'Failed to exchange token');
92
+ }
93
+
94
+ return response.json();
95
+ }
96
+
97
+ export async function finalizeDeviceLogin({
98
+ approval,
99
+ privateKeyPem,
100
+ apiUrl,
101
+ deviceName,
102
+ decryptTransferFn = decryptTransfer,
103
+ storeVaultKeyFn = storeVaultKey,
104
+ exchangeTokenForApiKeyFn = exchangeTokenForApiKey,
105
+ }) {
106
+ if (!approval?.vault_transfer) {
107
+ throw new Error('Vault transfer missing. Please reauthorize.');
108
+ }
109
+
110
+ if (!approval?.access_token) {
111
+ throw new Error('Missing access token. Please reauthorize.');
112
+ }
113
+
114
+ let masterKeyB64;
115
+ if (approval.vault_transfer === 'cli_generate') {
116
+ // No browser extensions available — generate master key locally
117
+ masterKeyB64 = b64urlEncode(randomBytes(32));
118
+ } else {
119
+ masterKeyB64 = decryptTransferFn(approval.vault_transfer, privateKeyPem);
120
+ }
121
+ await storeVaultKeyFn(masterKeyB64);
122
+
123
+ return exchangeTokenForApiKeyFn(apiUrl, approval.access_token, deviceName);
124
+ }
125
+
56
126
  function getDeviceName() {
57
127
  const os = process.platform;
58
128
  const hostname = process.env.HOSTNAME || process.env.COMPUTERNAME || 'CLI';
59
-
60
- const osNames = {
61
- darwin: 'macOS',
62
- linux: 'Linux',
63
- win32: 'Windows'
64
- };
65
-
129
+ const osNames = { darwin: 'macOS', linux: 'Linux', win32: 'Windows' };
66
130
  return `${osNames[os] || os} - ${hostname}`;
67
131
  }
68
132
 
@@ -72,6 +136,8 @@ function sleep(ms) {
72
136
 
73
137
  export async function login(options) {
74
138
  const apiUrl = options?.url || DEFAULT_API_URL;
139
+ const accountUrl = options?.accountUrl || DEFAULT_ACCOUNT_URL;
140
+ if (options?.clientId) setClientId(options.clientId);
75
141
 
76
142
  if (isLoggedIn()) {
77
143
  console.log(chalk.yellow('Already logged in.'));
@@ -84,33 +150,51 @@ export async function login(options) {
84
150
  const spinner = ora('Connecting...').start();
85
151
 
86
152
  try {
87
- // Step 1: Request device code
88
- const authData = await initiateDeviceAuth(apiUrl);
153
+ // Step 1: Request device code from wa-auth
154
+ const { publicKeySpkiB64url, privateKeyPem } = await generateTransferKeyPair();
155
+ const authData = await requestDeviceCode(accountUrl, publicKeySpkiB64url);
89
156
  spinner.stop();
90
157
 
91
- // Step 2: Show login URL and code
92
- const verifyUrl = `${authData.verification_url}?code=${authData.user_code}`;
93
-
158
+ // Step 2: Show login URL
159
+ const code = authData.user_code;
160
+ const displayCode = code.length === 8
161
+ ? `${code.slice(0, 4)}-${code.slice(4)}`.toUpperCase()
162
+ : code.toUpperCase();
163
+ const verifyUrl = `${authData.verification_url}?code=${code}`;
94
164
  console.log('Open this URL in your browser to log in:\n');
95
165
  console.log(chalk.cyan.bold(` ${verifyUrl}\n`));
96
- console.log(chalk.dim(`Or go to ${authData.verification_url} and enter code: ${authData.user_code}\n`));
166
+ console.log(chalk.dim(`Or go to ${authData.verification_url}`));
167
+ console.log(chalk.dim(`and enter code: `) + chalk.white.bold(displayCode) + '\n');
97
168
 
98
169
  // Step 3: Poll for approval
99
170
  spinner.start('Waiting for you to authorize in browser...');
100
-
101
- const result = await pollForApproval(
171
+ const approval = await pollForApproval(accountUrl, authData.device_code, authData.expires_in);
172
+ spinner.text = 'Securing vault key...';
173
+
174
+ // Step 4: Decrypt vault transfer and store key before exchanging JWT
175
+ const deviceName = getDeviceName();
176
+ const result = await finalizeDeviceLogin({
177
+ approval,
178
+ privateKeyPem,
102
179
  apiUrl,
103
- authData.device_code,
104
- authData.interval,
105
- authData.expires_in
106
- );
180
+ deviceName,
181
+ });
107
182
 
108
- // Step 4: Save credentials
109
- setApiKey(result.access_token);
183
+ // Step 5: Store credentials
184
+ setApiKey(result.api_key);
110
185
  setApiUrl(apiUrl);
186
+ setAccountUrl(accountUrl);
187
+ setVaultAvailable(true);
111
188
 
112
189
  spinner.succeed(chalk.green('Logged in successfully!'));
113
- console.log(`\nWelcome, ${chalk.cyan(result.user_email)}!`);
190
+ console.log(`\nWelcome, ${chalk.cyan(result.email)}!`);
191
+
192
+ if (result.vault && result.vault.available) {
193
+ console.log(chalk.green('\nVault ready — prompts will be encrypted end-to-end.'));
194
+ } else {
195
+ console.log(chalk.dim('\nSet up your vault at: ' + `${apiUrl}/dashboard/settings`));
196
+ }
197
+
114
198
  console.log('\nRun ' + chalk.cyan('pc setup') + ' to configure auto-capture for your CLI tools.\n');
115
199
 
116
200
  } catch (error) {
@@ -1,5 +1,6 @@
1
1
  import chalk from 'chalk';
2
2
  import { clearApiKey, isLoggedIn } from '../lib/config.js';
3
+ import { clearVaultKey } from '../lib/keychain.js';
3
4
 
4
5
  export async function logout() {
5
6
  if (!isLoggedIn()) {
@@ -7,6 +8,11 @@ export async function logout() {
7
8
  return;
8
9
  }
9
10
 
11
+ try {
12
+ await clearVaultKey();
13
+ } catch (error) {
14
+ console.log(chalk.yellow('Warning: failed to clear vault key.'));
15
+ }
10
16
  clearApiKey();
11
17
  console.log(chalk.green('Logged out successfully.'));
12
18
  }
@@ -1,7 +1,6 @@
1
1
  import ora from 'ora';
2
2
  import chalk from 'chalk';
3
3
  import { getPrompt } from '../lib/api.js';
4
- import { connect, disconnect } from '../lib/websocket.js';
5
4
  import { isLoggedIn } from '../lib/config.js';
6
5
 
7
6
  export async function push(slug, options) {
@@ -14,42 +13,15 @@ export async function push(slug, options) {
14
13
 
15
14
  try {
16
15
  const prompt = await getPrompt(slug);
17
- spinner.text = 'Connecting to session...';
18
16
 
19
- const socket = connect(options.tool || 'claude');
20
-
21
- // Wait for connection
22
- await new Promise((resolve, reject) => {
23
- const timeout = setTimeout(() => {
24
- reject(new Error('Connection timeout'));
25
- }, 10000);
26
-
27
- socket.on('registered', () => {
28
- clearTimeout(timeout);
29
- resolve();
30
- });
31
-
32
- socket.on('error', (err) => {
33
- clearTimeout(timeout);
34
- reject(new Error(err.message));
35
- });
36
- });
37
-
38
- spinner.text = 'Pushing prompt...';
39
-
40
- // Push the prompt to ourselves (for now, just copy to clipboard simulation)
17
+ spinner.succeed(chalk.green('Prompt fetched!'));
41
18
  console.log('\n' + chalk.cyan('Prompt content:'));
42
19
  console.log(chalk.dim('─'.repeat(50)));
43
20
  console.log(prompt.content);
44
21
  console.log(chalk.dim('─'.repeat(50)));
45
-
46
- spinner.succeed(chalk.green('Prompt fetched!'));
47
22
  console.log('\nCopy the prompt above to use it in your session.');
48
-
49
- disconnect();
50
23
  } catch (error) {
51
24
  spinner.fail(chalk.red('Failed: ' + error.message));
52
- disconnect();
53
25
  }
54
26
  }
55
27
 
@@ -3,7 +3,9 @@ import ora from 'ora';
3
3
  import chalk from 'chalk';
4
4
  import { capturePrompt } from '../lib/api.js';
5
5
  import { getFullContext } from '../lib/context.js';
6
- import { isLoggedIn } from '../lib/config.js';
6
+ import { isLoggedIn, isVaultAvailable, getApiUrl } from '../lib/config.js';
7
+ import { encryptPrompt } from '../lib/crypto.js';
8
+ import { requireVaultKey } from '../lib/keychain.js';
7
9
 
8
10
  export async function save(options) {
9
11
  if (!isLoggedIn()) {
@@ -11,6 +13,12 @@ export async function save(options) {
11
13
  return;
12
14
  }
13
15
 
16
+ if (!isVaultAvailable()) {
17
+ console.log(chalk.red('Vault not set up.') + ' Prompt capture requires a vault.');
18
+ console.log('Set up your vault at: ' + chalk.cyan(`${getApiUrl()}/dashboard/settings`));
19
+ return;
20
+ }
21
+
14
22
  let content = options.message;
15
23
 
16
24
  if (!content) {
@@ -31,8 +39,19 @@ export async function save(options) {
31
39
  const spinner = ora('Saving prompt...').start();
32
40
 
33
41
  try {
42
+ let vaultKey;
43
+ try {
44
+ vaultKey = await requireVaultKey();
45
+ } catch (error) {
46
+ spinner.stop();
47
+ console.log(chalk.red(error.message));
48
+ return;
49
+ }
50
+ const { encrypted_content, content_iv } = encryptPrompt(content.trim(), vaultKey);
51
+
34
52
  const result = await capturePrompt({
35
- content: content.trim(),
53
+ encrypted_content,
54
+ content_iv,
36
55
  ...context
37
56
  });
38
57