@promptcellar/pc 0.5.2 → 0.5.4

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.
package/README.md CHANGED
@@ -1,6 +1,6 @@
1
- # PromptCellar CLI
1
+ # Prompt Cellar CLI
2
2
 
3
- Command-line tool for capturing, managing, and reusing AI prompts with [PromptCellar](https://prompts.weldedanvil.com).
3
+ Command-line tool for capturing, managing, and reusing AI prompts with [Prompt Cellar](https://prompts.weldedanvil.com).
4
4
 
5
5
  ## Installation
6
6
 
@@ -103,11 +103,16 @@ Configuration is stored in:
103
103
  - Linux: `~/.config/promptcellar-nodejs/`
104
104
  - Windows: `%APPDATA%/promptcellar-nodejs/`
105
105
 
106
+ Security flags and requirements:
107
+ - `PC_VAULT_KEY`: overrides the vault key used by the CLI.
108
+ - `PC_VAULT_KEY_FALLBACK=1`: enables file fallback when the keychain is locked.
109
+ - `apiUrl` and `accountUrl` must use HTTPS (`http://localhost` is allowed).
110
+
106
111
  ## API
107
112
 
108
- The CLI communicates with your PromptCellar instance. Get your API key from:
113
+ The CLI communicates with your Prompt Cellar instance. Get your API key from:
109
114
 
110
- Settings > API Keys in your PromptCellar dashboard.
115
+ Settings > API Keys in your Prompt Cellar dashboard.
111
116
 
112
117
  ## License
113
118
 
package/bin/pc.js CHANGED
@@ -16,12 +16,12 @@ const pkg = require('../package.json');
16
16
 
17
17
  program
18
18
  .name('pc')
19
- .description('PromptCellar CLI - sync prompts between your terminal and the cloud')
19
+ .description('Prompt Cellar CLI - sync prompts between your terminal and the cloud')
20
20
  .version(pkg.version);
21
21
 
22
22
  program
23
23
  .command('login')
24
- .description('Authenticate with PromptCellar via browser')
24
+ .description('Authenticate with Prompt Cellar via browser')
25
25
  .option('-u, --url <url>', 'API URL (for self-hosted instances)')
26
26
  .option('-a, --account-url <url>', 'Account URL (for self-hosted instances)')
27
27
  .option('-c, --client-id <id>', 'OIDC client ID (default: prompts-prod)')
@@ -1,7 +1,7 @@
1
1
  #!/usr/bin/env node
2
2
 
3
3
  /**
4
- * Codex CLI notify hook for capturing prompts to PromptCellar.
4
+ * Codex CLI notify hook for capturing prompts to Prompt Cellar.
5
5
  *
6
6
  * Codex calls this script with a JSON argument containing:
7
7
  * - type: event type (e.g., 'agent-turn-complete')
@@ -1,7 +1,7 @@
1
1
  #!/usr/bin/env node
2
2
 
3
3
  /**
4
- * Gemini CLI BeforeAgent hook for capturing prompts to PromptCellar.
4
+ * Gemini CLI BeforeAgent hook for capturing prompts to Prompt Cellar.
5
5
  *
6
6
  * Gemini hooks receive JSON via stdin with:
7
7
  * - hook_event_name: the hook event (e.g., 'BeforeAgent')
@@ -1,7 +1,7 @@
1
1
  #!/usr/bin/env node
2
2
 
3
3
  /**
4
- * Claude Code UserPromptSubmit hook for capturing prompts to PromptCellar.
4
+ * Claude Code UserPromptSubmit hook for capturing prompts to Prompt Cellar.
5
5
  *
6
6
  * This script is called by Claude Code on every user prompt submission.
7
7
  * UserPromptSubmit hooks receive JSON via stdin with:
package/package.json CHANGED
@@ -1,7 +1,7 @@
1
1
  {
2
2
  "name": "@promptcellar/pc",
3
- "version": "0.5.2",
4
- "description": "CLI for PromptCellar - sync prompts between your terminal and the cloud",
3
+ "version": "0.5.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",
7
7
  "bin": {
@@ -2,6 +2,7 @@ import { randomBytes } from 'crypto';
2
2
  import ora from 'ora';
3
3
  import chalk from 'chalk';
4
4
  import {
5
+ normalizeAndValidateUrl,
5
6
  setApiKey, setApiUrl, setAccountUrl,
6
7
  setVaultAvailable,
7
8
  isLoggedIn
@@ -123,6 +124,26 @@ export async function finalizeDeviceLogin({
123
124
  return exchangeTokenForApiKeyFn(apiUrl, approval.access_token, deviceName);
124
125
  }
125
126
 
127
+ export function persistLoginState({
128
+ apiUrl,
129
+ accountUrl,
130
+ apiKey,
131
+ vaultAvailable = true,
132
+ normalizeFn = normalizeAndValidateUrl,
133
+ setApiUrlFn = setApiUrl,
134
+ setAccountUrlFn = setAccountUrl,
135
+ setApiKeyFn = setApiKey,
136
+ setVaultAvailableFn = setVaultAvailable,
137
+ }) {
138
+ const normalizedApiUrl = normalizeFn(apiUrl);
139
+ const normalizedAccountUrl = normalizeFn(accountUrl);
140
+
141
+ setApiUrlFn(normalizedApiUrl);
142
+ setAccountUrlFn(normalizedAccountUrl);
143
+ setApiKeyFn(apiKey);
144
+ setVaultAvailableFn(vaultAvailable);
145
+ }
146
+
126
147
  function getDeviceName() {
127
148
  const os = process.platform;
128
149
  const hostname = process.env.HOSTNAME || process.env.COMPUTERNAME || 'CLI';
@@ -145,7 +166,7 @@ export async function login(options) {
145
166
  return;
146
167
  }
147
168
 
148
- console.log(chalk.bold('\nPromptCellar CLI Login\n'));
169
+ console.log(chalk.bold('\nPrompt Cellar CLI Login\n'));
149
170
 
150
171
  const spinner = ora('Connecting...').start();
151
172
 
@@ -181,10 +202,12 @@ export async function login(options) {
181
202
  });
182
203
 
183
204
  // Step 5: Store credentials
184
- setApiKey(result.api_key);
185
- setApiUrl(apiUrl);
186
- setAccountUrl(accountUrl);
187
- setVaultAvailable(true);
205
+ persistLoginState({
206
+ apiUrl,
207
+ accountUrl,
208
+ apiKey: result.api_key,
209
+ vaultAvailable: true,
210
+ });
188
211
 
189
212
  spinner.succeed(chalk.green('Logged in successfully!'));
190
213
  console.log(`\nWelcome, ${chalk.cyan(result.email)}!`);
@@ -133,7 +133,7 @@ function saveGeminiSettings(settings) {
133
133
  }
134
134
 
135
135
  export async function setup() {
136
- console.log(chalk.bold('\nPromptCellar CLI Setup\n'));
136
+ console.log(chalk.bold('\nPrompt Cellar CLI Setup\n'));
137
137
 
138
138
  const installedTools = detectInstalledTools();
139
139
 
@@ -178,7 +178,7 @@ export async function setup() {
178
178
  }
179
179
 
180
180
  console.log(chalk.green('\nSetup complete!'));
181
- console.log('Your prompts will be automatically captured to PromptCellar.\n');
181
+ console.log('Your prompts will be automatically captured to Prompt Cellar.\n');
182
182
  }
183
183
 
184
184
  async function setupClaudeCode() {
@@ -261,7 +261,7 @@ async function setupCodex() {
261
261
 
262
262
  // Remove existing notify line and comment
263
263
  config = config.split('\n')
264
- .filter(line => !line.includes('pc-codex-capture') && !line.includes('# PromptCellar capture hook'))
264
+ .filter(line => !line.includes('pc-codex-capture') && !line.includes('# Prompt Cellar capture hook'))
265
265
  .join('\n');
266
266
  }
267
267
 
@@ -278,10 +278,10 @@ async function setupCodex() {
278
278
  const insertPos = firstTableMatch.index;
279
279
  const before = config.slice(0, insertPos).trimEnd();
280
280
  const after = config.slice(insertPos);
281
- config = before + '\n\n# PromptCellar capture hook\n' + notifyLine + '\n\n' + after;
281
+ config = before + '\n\n# Prompt Cellar capture hook\n' + notifyLine + '\n\n' + after;
282
282
  } else {
283
283
  // No table sections — safe to append
284
- config = config.trimEnd() + '\n\n# PromptCellar capture hook\n' + notifyLine + '\n';
284
+ config = config.trimEnd() + '\n\n# Prompt Cellar capture hook\n' + notifyLine + '\n';
285
285
  }
286
286
  }
287
287
 
@@ -339,7 +339,7 @@ async function setupGemini() {
339
339
  }
340
340
 
341
341
  export async function unsetup() {
342
- console.log(chalk.bold('\nRemoving PromptCellar hooks...\n'));
342
+ console.log(chalk.bold('\nRemoving Prompt Cellar hooks...\n'));
343
343
 
344
344
  let removed = false;
345
345
 
@@ -366,7 +366,7 @@ export async function unsetup() {
366
366
  let codexConfig = getCodexConfig();
367
367
  if (isCodexHookInstalled(codexConfig)) {
368
368
  codexConfig = codexConfig.split('\n')
369
- .filter(line => !line.includes('pc-codex-capture') && !line.includes('# PromptCellar capture hook'))
369
+ .filter(line => !line.includes('pc-codex-capture') && !line.includes('# Prompt Cellar capture hook'))
370
370
  .join('\n');
371
371
  // Clean up extra blank lines left behind
372
372
  codexConfig = codexConfig.replace(/\n{3,}/g, '\n\n');
@@ -5,7 +5,7 @@ import { getGitContext } from '../lib/context.js';
5
5
  import { loadVaultKey } from '../lib/keychain.js';
6
6
 
7
7
  export async function status() {
8
- console.log(chalk.bold('\nPromptCellar CLI Status\n'));
8
+ console.log(chalk.bold('\nPrompt Cellar CLI Status\n'));
9
9
 
10
10
  // Auth status
11
11
  if (isLoggedIn()) {
package/src/index.js CHANGED
@@ -1,4 +1,4 @@
1
- // PromptCellar CLI - Main exports
1
+ // Prompt Cellar CLI - Main exports
2
2
 
3
3
  export { login } from './commands/login.js';
4
4
  export { logout } from './commands/logout.js';
package/src/lib/config.js CHANGED
@@ -50,8 +50,28 @@ export function getApiUrl() {
50
50
  return config.get('apiUrl');
51
51
  }
52
52
 
53
+ export function normalizeAndValidateUrl(rawUrl) {
54
+ if (typeof rawUrl !== 'string' || !rawUrl.trim()) {
55
+ throw new Error('URL is required');
56
+ }
57
+ let url;
58
+ try {
59
+ url = new URL(rawUrl);
60
+ } catch {
61
+ throw new Error('Invalid URL');
62
+ }
63
+ if (!['http:', 'https:'].includes(url.protocol)) {
64
+ throw new Error('URL must be http or https');
65
+ }
66
+ const isLocalhost = ['localhost', '127.0.0.1'].includes(url.hostname);
67
+ if (url.protocol !== 'https:' && !isLocalhost) {
68
+ throw new Error('URL must use https');
69
+ }
70
+ return url.origin;
71
+ }
72
+
53
73
  export function setApiUrl(url) {
54
- config.set('apiUrl', url);
74
+ config.set('apiUrl', normalizeAndValidateUrl(url));
55
75
  }
56
76
 
57
77
  export function getCaptureLevel() {
@@ -79,7 +99,7 @@ export function getAccountUrl() {
79
99
  }
80
100
 
81
101
  export function setAccountUrl(url) {
82
- config.set('accountUrl', url);
102
+ config.set('accountUrl', normalizeAndValidateUrl(url));
83
103
  }
84
104
 
85
105
  export function isVaultAvailable() {
@@ -9,6 +9,20 @@ function execGit(...args) {
9
9
  }
10
10
  }
11
11
 
12
+ export function sanitizeGitRemote(remote) {
13
+ if (!remote) return remote;
14
+ try {
15
+ const url = new URL(remote);
16
+ url.username = '';
17
+ url.password = '';
18
+ url.search = '';
19
+ url.hash = '';
20
+ return `${url.origin}${url.pathname}`;
21
+ } catch {
22
+ return remote.replace(/\/\/[^@]+@/, '//');
23
+ }
24
+ }
25
+
12
26
  export function getGitContext() {
13
27
  const level = getCaptureLevel();
14
28
 
@@ -28,7 +42,7 @@ export function getGitContext() {
28
42
 
29
43
  // Rich: add remote URL and commit hash
30
44
  if (level === 'rich' && topLevel) {
31
- context.git_remote_url = execGit('remote', 'get-url', 'origin');
45
+ context.git_remote_url = sanitizeGitRemote(execGit('remote', 'get-url', 'origin'));
32
46
  context.git_commit = execGit('rev-parse', 'HEAD');
33
47
  }
34
48
 
@@ -43,19 +43,24 @@ export async function storeVaultKey(keyB64url) {
43
43
  try {
44
44
  await keytar.setPassword(SERVICE, ACCOUNT, keyB64url);
45
45
  return;
46
- } catch {
47
- // Keychain unavailable use file fallback
46
+ } catch (err) {
47
+ if (process.env.PC_VAULT_KEY_FALLBACK !== '1') {
48
+ throw err;
49
+ }
48
50
  }
49
51
  writeFallback(keyB64url);
50
52
  }
51
53
 
52
54
  export async function loadVaultKey() {
55
+ const envVaultKey = process.env.PC_VAULT_KEY;
56
+ if (envVaultKey) return envVaultKey;
53
57
  try {
54
58
  const val = await keytar.getPassword(SERVICE, ACCOUNT);
55
59
  if (val) return val;
56
60
  } catch {
57
61
  // Keychain unavailable — try file fallback
58
62
  }
63
+ if (process.env.PC_VAULT_KEY_FALLBACK !== '1') return null;
59
64
  return readFallback();
60
65
  }
61
66
 
@@ -37,7 +37,7 @@ export function connect(tool, onPromptReceived) {
37
37
  });
38
38
 
39
39
  socket.on('registered', (data) => {
40
- console.log(`Connected to PromptCellar (session: ${data.session_id})`);
40
+ console.log(`Connected to Prompt Cellar (session: ${data.session_id})`);
41
41
  });
42
42
 
43
43
  socket.on('prompt_pushed', (data) => {
@@ -0,0 +1,29 @@
1
+ import { describe, it } from 'node:test';
2
+ import assert from 'node:assert/strict';
3
+ import { normalizeAndValidateUrl } from '../src/lib/config.js';
4
+
5
+ describe('normalizeAndValidateUrl', () => {
6
+ it('accepts https URLs', () => {
7
+ const url = normalizeAndValidateUrl('https://prompts.weldedanvil.com');
8
+ assert.equal(url, 'https://prompts.weldedanvil.com');
9
+ });
10
+
11
+ it('accepts localhost http URLs', () => {
12
+ const url = normalizeAndValidateUrl('http://localhost:3000');
13
+ assert.equal(url, 'http://localhost:3000');
14
+ });
15
+
16
+ it('rejects non-https for non-local hosts', () => {
17
+ assert.throws(
18
+ () => normalizeAndValidateUrl('http://example.com'),
19
+ /https/i,
20
+ );
21
+ });
22
+
23
+ it('rejects non-http schemes', () => {
24
+ assert.throws(
25
+ () => normalizeAndValidateUrl('ftp://example.com'),
26
+ /http/i,
27
+ );
28
+ });
29
+ });
@@ -0,0 +1,23 @@
1
+ import { describe, it } from 'node:test';
2
+ import assert from 'node:assert/strict';
3
+ import { sanitizeGitRemote } from '../src/lib/context.js';
4
+
5
+ describe('sanitizeGitRemote', () => {
6
+ it('strips credentials and query/fragment', () => {
7
+ const input = 'https://token:secret@github.com/org/repo.git?foo=bar#frag';
8
+ const output = sanitizeGitRemote(input);
9
+ assert.equal(output, 'https://github.com/org/repo.git');
10
+ });
11
+
12
+ it('removes userinfo from https URL', () => {
13
+ const input = 'https://token@github.com/org/repo.git';
14
+ const output = sanitizeGitRemote(input);
15
+ assert.equal(output, 'https://github.com/org/repo.git');
16
+ });
17
+
18
+ it('leaves scp-style remotes intact', () => {
19
+ const input = 'git@github.com:org/repo.git';
20
+ const output = sanitizeGitRemote(input);
21
+ assert.equal(output, input);
22
+ });
23
+ });
@@ -118,4 +118,32 @@ describe('device login helpers', () => {
118
118
  assert.equal(exchangeCalled, false);
119
119
  });
120
120
  });
121
+
122
+ describe('persistLoginState', () => {
123
+ it('does not persist when validation fails', () => {
124
+ const { persistLoginState } = login;
125
+ assert.equal(typeof persistLoginState, 'function', 'persistLoginState should be exported');
126
+
127
+ const calls = [];
128
+ const normalizeFn = () => {
129
+ throw new Error('invalid url');
130
+ };
131
+
132
+ assert.throws(
133
+ () => persistLoginState({
134
+ apiUrl: 'http://bad',
135
+ accountUrl: 'http://bad',
136
+ apiKey: 'api-key',
137
+ normalizeFn,
138
+ setApiUrlFn: () => calls.push('apiUrl'),
139
+ setAccountUrlFn: () => calls.push('accountUrl'),
140
+ setApiKeyFn: () => calls.push('apiKey'),
141
+ setVaultAvailableFn: () => calls.push('vaultAvailable'),
142
+ }),
143
+ /invalid url/i,
144
+ );
145
+
146
+ assert.deepEqual(calls, []);
147
+ });
148
+ });
121
149
  });
@@ -1,6 +1,14 @@
1
- import { describe, it } from 'node:test';
1
+ import { describe, it, mock } from 'node:test';
2
2
  import assert from 'node:assert/strict';
3
+ import keytar from 'keytar';
3
4
  import * as keychain from '../src/lib/keychain.js';
5
+ import { loadVaultKey, storeVaultKey } from '../src/lib/keychain.js';
6
+
7
+ const ORIGINAL_ENV = { ...process.env };
8
+
9
+ function resetEnv() {
10
+ process.env = { ...ORIGINAL_ENV };
11
+ }
4
12
 
5
13
  describe('requireVaultKey', () => {
6
14
  it('returns key when available', async () => {
@@ -38,3 +46,28 @@ describe('requireVaultKey', () => {
38
46
  assert.equal(key, null);
39
47
  });
40
48
  });
49
+
50
+ describe('vault key env handling', () => {
51
+ it('prefers PC_VAULT_KEY when set', async () => {
52
+ resetEnv();
53
+ process.env.PC_VAULT_KEY = 'env-key';
54
+ mock.method(keytar, 'getPassword', async () => {
55
+ throw new Error('should not call keytar');
56
+ });
57
+
58
+ const key = await loadVaultKey();
59
+ assert.equal(key, 'env-key');
60
+ });
61
+
62
+ it('throws when keytar fails and fallback disabled', async () => {
63
+ resetEnv();
64
+ mock.method(keytar, 'setPassword', async () => {
65
+ throw new Error('keytar locked');
66
+ });
67
+
68
+ await assert.rejects(
69
+ () => storeVaultKey('vault-key'),
70
+ /keytar locked/i,
71
+ );
72
+ });
73
+ });