@promptcellar/pc 0.5.3 → 0.5.5

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.
@@ -84,13 +84,6 @@ async function main() {
84
84
  process.exit(0);
85
85
  }
86
86
 
87
- // Build base context once
88
- const baseContext = getFullContext('codex');
89
- if (event.cwd) {
90
- baseContext.working_directory = event.cwd;
91
- }
92
- baseContext.session_id = threadId;
93
-
94
87
  // Capture each new message
95
88
  for (const message of newMessages) {
96
89
  const content = extractContent(message);
@@ -98,10 +91,17 @@ async function main() {
98
91
  continue;
99
92
  }
100
93
 
101
- const { encrypted_content, content_iv } = encryptPrompt(content.trim(), vaultKey);
94
+ const promptText = content.trim();
95
+ const context = getFullContext('codex', null, {
96
+ cwd: event.cwd || process.cwd(),
97
+ sessionId: threadId,
98
+ promptText,
99
+ });
100
+
101
+ const { encrypted_content, content_iv } = encryptPrompt(promptText, vaultKey);
102
102
 
103
103
  await capturePrompt({
104
- ...baseContext,
104
+ ...context,
105
105
  encrypted_content,
106
106
  content_iv
107
107
  });
@@ -72,15 +72,11 @@ async function main() {
72
72
  }
73
73
 
74
74
  // Build context
75
- const context = getFullContext('gemini');
76
-
77
- // Override with event data if available
78
- if (event.cwd) {
79
- context.working_directory = event.cwd;
80
- }
81
- if (event.session_id) {
82
- context.session_id = event.session_id;
83
- }
75
+ const context = getFullContext('gemini', null, {
76
+ cwd: event.cwd || process.cwd(),
77
+ sessionId: event.session_id,
78
+ promptText: content,
79
+ });
84
80
 
85
81
  const vaultKey = await requireVaultKey({ silent: true });
86
82
  if (!vaultKey) {
@@ -56,15 +56,11 @@ async function main() {
56
56
  process.exit(0);
57
57
  }
58
58
 
59
- const context = getFullContext('claude-code');
60
-
61
- // Override with event data if available
62
- if (event.cwd) {
63
- context.working_directory = event.cwd;
64
- }
65
- if (event.session_id) {
66
- context.session_id = event.session_id;
67
- }
59
+ const context = getFullContext('claude-code', null, {
60
+ cwd: event.cwd || process.cwd(),
61
+ sessionId: event.session_id,
62
+ promptText: promptContent,
63
+ });
68
64
 
69
65
  const vaultKey = await requireVaultKey({ silent: true });
70
66
  if (!vaultKey) {
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@promptcellar/pc",
3
- "version": "0.5.3",
3
+ "version": "0.5.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",
@@ -34,6 +34,7 @@
34
34
  "node": ">=18.0.0"
35
35
  },
36
36
  "dependencies": {
37
+ "@promptcellar/core": "*",
37
38
  "commander": "^12.0.0",
38
39
  "conf": "^12.0.0",
39
40
  "chalk": "^5.3.0",
@@ -1,126 +1,45 @@
1
- import { randomBytes } from 'crypto';
2
1
  import ora from 'ora';
3
2
  import chalk from 'chalk';
4
3
  import {
4
+ normalizeAndValidateUrl,
5
5
  setApiKey, setApiUrl, setAccountUrl,
6
6
  setVaultAvailable,
7
7
  isLoggedIn
8
8
  } from '../lib/config.js';
9
9
  import { generateTransferKeyPair, decryptTransfer } from '../lib/device-transfer.js';
10
- import { b64urlEncode } from '../lib/crypto.js';
11
10
  import { storeVaultKey } from '../lib/keychain.js';
11
+ import {
12
+ setClientId,
13
+ requestDeviceCode,
14
+ pollForApproval,
15
+ exchangeTokenForApiKey,
16
+ finalizeDeviceLogin,
17
+ } from '@promptcellar/core/auth';
18
+
19
+ // Re-export auth functions so existing CLI tests can import from this module
20
+ export { setClientId, requestDeviceCode, finalizeDeviceLogin };
12
21
 
13
22
  const DEFAULT_API_URL = 'https://prompts.weldedanvil.com';
14
23
  const DEFAULT_ACCOUNT_URL = 'https://account.weldedanvil.com';
15
- let clientId = 'prompts-prod';
16
-
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`, {
26
- method: 'POST',
27
- headers: { 'Content-Type': 'application/json' },
28
- body: JSON.stringify(payload),
29
- });
30
-
31
- if (!response.ok) {
32
- const data = await response.json().catch(() => ({}));
33
- throw new Error(data.error || 'Failed to request device code');
34
- }
35
-
36
- return response.json();
37
- }
38
-
39
- async function pollForApproval(accountUrl, deviceCode, expiresIn) {
40
- const expiresAt = Date.now() + (expiresIn || 600) * 1000;
41
- const interval = 5000;
42
-
43
- while (Date.now() < expiresAt) {
44
- await sleep(interval);
45
-
46
- const response = await fetch(`${accountUrl}/device/poll`, {
47
- method: 'POST',
48
- headers: { 'Content-Type': 'application/json' },
49
- body: JSON.stringify({ device_code: deviceCode }),
50
- });
51
-
52
- const data = await response.json();
53
-
54
- if (data.status === 'approved' && data.access_token) {
55
- return data;
56
- }
57
-
58
- if (data.status === 'expired') {
59
- throw new Error('Authorization request expired. Please try again.');
60
- }
61
24
 
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}`);
71
- }
72
-
73
- // status === 'pending' -- keep polling
74
- }
75
-
76
- throw new Error('Authorization request timed out. Please try again.');
77
- }
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,
25
+ export function persistLoginState({
100
26
  apiUrl,
101
- deviceName,
102
- decryptTransferFn = decryptTransfer,
103
- storeVaultKeyFn = storeVaultKey,
104
- exchangeTokenForApiKeyFn = exchangeTokenForApiKey,
27
+ accountUrl,
28
+ apiKey,
29
+ vaultAvailable = true,
30
+ normalizeFn = normalizeAndValidateUrl,
31
+ setApiUrlFn = setApiUrl,
32
+ setAccountUrlFn = setAccountUrl,
33
+ setApiKeyFn = setApiKey,
34
+ setVaultAvailableFn = setVaultAvailable,
105
35
  }) {
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);
36
+ const normalizedApiUrl = normalizeFn(apiUrl);
37
+ const normalizedAccountUrl = normalizeFn(accountUrl);
122
38
 
123
- return exchangeTokenForApiKeyFn(apiUrl, approval.access_token, deviceName);
39
+ setApiUrlFn(normalizedApiUrl);
40
+ setAccountUrlFn(normalizedAccountUrl);
41
+ setApiKeyFn(apiKey);
42
+ setVaultAvailableFn(vaultAvailable);
124
43
  }
125
44
 
126
45
  function getDeviceName() {
@@ -130,10 +49,6 @@ function getDeviceName() {
130
49
  return `${osNames[os] || os} - ${hostname}`;
131
50
  }
132
51
 
133
- function sleep(ms) {
134
- return new Promise(resolve => setTimeout(resolve, ms));
135
- }
136
-
137
52
  export async function login(options) {
138
53
  const apiUrl = options?.url || DEFAULT_API_URL;
139
54
  const accountUrl = options?.accountUrl || DEFAULT_ACCOUNT_URL;
@@ -178,13 +93,17 @@ export async function login(options) {
178
93
  privateKeyPem,
179
94
  apiUrl,
180
95
  deviceName,
96
+ decryptTransferFn: decryptTransfer,
97
+ storeVaultKeyFn: storeVaultKey,
181
98
  });
182
99
 
183
100
  // Step 5: Store credentials
184
- setApiKey(result.api_key);
185
- setApiUrl(apiUrl);
186
- setAccountUrl(accountUrl);
187
- setVaultAvailable(true);
101
+ persistLoginState({
102
+ apiUrl,
103
+ accountUrl,
104
+ apiKey: result.api_key,
105
+ vaultAvailable: true,
106
+ });
188
107
 
189
108
  spinner.succeed(chalk.green('Logged in successfully!'));
190
109
  console.log(`\nWelcome, ${chalk.cyan(result.email)}!`);
@@ -34,7 +34,10 @@ export async function save(options) {
34
34
  content = promptContent;
35
35
  }
36
36
 
37
- const context = getFullContext(options.tool || 'manual', options.model);
37
+ const trimmedContent = content.trim();
38
+ const context = getFullContext(options.tool || 'manual', options.model, {
39
+ promptText: trimmedContent,
40
+ });
38
41
 
39
42
  const spinner = ora('Saving prompt...').start();
40
43
 
@@ -47,7 +50,7 @@ export async function save(options) {
47
50
  console.log(chalk.red(error.message));
48
51
  return;
49
52
  }
50
- const { encrypted_content, content_iv } = encryptPrompt(content.trim(), vaultKey);
53
+ const { encrypted_content, content_iv } = encryptPrompt(trimmedContent, vaultKey);
51
54
 
52
55
  const result = await capturePrompt({
53
56
  encrypted_content,
package/src/lib/api.js CHANGED
@@ -1,31 +1,31 @@
1
1
  import { getApiKey, getApiUrl } from './config.js';
2
-
3
- async function request(endpoint, options = {}) {
2
+ import {
3
+ capturePrompt as coreCapturePrompt,
4
+ listCaptured as coreListCaptured,
5
+ getPrompt as coreGetPrompt,
6
+ healthCheck as coreHealthCheck,
7
+ } from '@promptcellar/core/api';
8
+
9
+ function requireAuth() {
4
10
  const apiKey = getApiKey();
5
11
  const apiUrl = getApiUrl();
12
+ if (!apiKey) throw new Error('Not logged in. Run: pc login');
13
+ return { apiKey, apiUrl };
14
+ }
6
15
 
7
- if (!apiKey) {
8
- throw new Error('Not logged in. Run: pc login');
9
- }
10
-
11
- const url = `${apiUrl}${endpoint}`;
12
- const headers = {
13
- 'Authorization': `Bearer ${apiKey}`,
14
- 'Content-Type': 'application/json',
15
- ...options.headers
16
- };
17
-
18
- const response = await fetch(url, {
19
- ...options,
20
- headers
21
- });
16
+ export async function capturePrompt(data) {
17
+ const { apiUrl, apiKey } = requireAuth();
18
+ return coreCapturePrompt(apiUrl, apiKey, data);
19
+ }
22
20
 
23
- if (!response.ok) {
24
- const data = await response.json().catch(() => ({}));
25
- throw new Error(data.error || `HTTP ${response.status}`);
26
- }
21
+ export async function listCaptured(options) {
22
+ const { apiUrl, apiKey } = requireAuth();
23
+ return coreListCaptured(apiUrl, apiKey, options);
24
+ }
27
25
 
28
- return response.json();
26
+ export async function getPrompt(slug) {
27
+ const { apiUrl, apiKey } = requireAuth();
28
+ return coreGetPrompt(apiUrl, apiKey, slug);
29
29
  }
30
30
 
31
31
  export async function testConnection() {
@@ -37,35 +37,16 @@ export async function testConnection() {
37
37
  }
38
38
 
39
39
  try {
40
- const response = await fetch(`${apiUrl}/health`);
41
- if (response.ok) {
42
- return { success: true };
43
- }
44
- return { success: false, error: 'Server not responding' };
40
+ await coreHealthCheck(apiUrl);
41
+ return { success: true };
45
42
  } catch (error) {
46
43
  return { success: false, error: error.message };
47
44
  }
48
45
  }
49
46
 
50
- export async function capturePrompt(data) {
51
- return request('/api/v1/capture', {
52
- method: 'POST',
53
- body: JSON.stringify(data)
54
- });
55
- }
56
-
57
- export async function listCaptured(options = {}) {
58
- const params = new URLSearchParams();
59
- if (options.page) params.set('page', options.page);
60
- if (options.tool) params.set('tool', options.tool);
61
- if (options.starred) params.set('starred', 'true');
62
-
63
- const query = params.toString();
64
- return request(`/api/v1/captured${query ? '?' + query : ''}`);
65
- }
66
-
67
- export async function getPrompt(slug) {
68
- return request(`/api/v1/prompts/${slug}`);
47
+ export async function healthCheck() {
48
+ const apiUrl = getApiUrl();
49
+ return coreHealthCheck(apiUrl);
69
50
  }
70
51
 
71
- export default { testConnection, capturePrompt, listCaptured, getPrompt };
52
+ export default { testConnection, capturePrompt, listCaptured, getPrompt, healthCheck };
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() {
@@ -1,69 +1,24 @@
1
- import { execFileSync } from 'child_process';
2
1
  import { getCaptureLevel, getSessionId } from './config.js';
2
+ import {
3
+ getGitContext as coreGitContext,
4
+ getFullContext as coreFullContext,
5
+ } from '@promptcellar/core/context';
3
6
 
4
- function execGit(...args) {
5
- try {
6
- return execFileSync('git', args, { encoding: 'utf8', stdio: ['pipe', 'pipe', 'pipe'] }).trim();
7
- } catch {
8
- return null;
9
- }
10
- }
7
+ export { sanitizeGitRemote } from '@promptcellar/core/context';
11
8
 
12
9
  export function getGitContext() {
13
- const level = getCaptureLevel();
14
-
15
- // Minimal: no git context
16
- if (level === 'minimal') {
17
- return {};
18
- }
19
-
20
- const context = {};
21
-
22
- // Standard: repo name and branch
23
- const topLevel = execGit('rev-parse', '--show-toplevel');
24
- if (topLevel) {
25
- context.git_repo = topLevel.split('/').pop();
26
- context.git_branch = execGit('rev-parse', '--abbrev-ref', 'HEAD');
27
- }
28
-
29
- // Rich: add remote URL and commit hash
30
- if (level === 'rich' && topLevel) {
31
- context.git_remote_url = execGit('remote', 'get-url', 'origin');
32
- context.git_commit = execGit('rev-parse', 'HEAD');
33
- }
34
-
35
- return context;
10
+ return coreGitContext(process.cwd(), getCaptureLevel());
36
11
  }
37
12
 
38
- export function getFullContext(tool, model) {
39
- const level = getCaptureLevel();
40
- const sessionId = getSessionId();
41
-
42
- const context = {
13
+ export function getFullContext(tool, model, overrides = {}) {
14
+ return coreFullContext({
43
15
  tool,
44
- captured_at: new Date().toISOString()
45
- };
46
-
47
- // Minimal: just tool and timestamp
48
- if (level === 'minimal') {
49
- return context;
50
- }
51
-
52
- // Standard: add working directory and model
53
- context.working_directory = process.cwd();
54
- if (model) {
55
- context.model = model;
56
- }
57
-
58
- // Add git context
59
- Object.assign(context, getGitContext());
60
-
61
- // Rich: add session ID
62
- if (level === 'rich') {
63
- context.session_id = sessionId;
64
- }
65
-
66
- return context;
16
+ model,
17
+ captureLevel: getCaptureLevel(),
18
+ sessionId: getSessionId(),
19
+ cwd: process.cwd(),
20
+ ...overrides,
21
+ });
67
22
  }
68
23
 
69
24
  export default { getGitContext, getFullContext };
package/src/lib/crypto.js CHANGED
@@ -1,39 +1 @@
1
- import { createCipheriv, randomBytes } from 'crypto';
2
-
3
- function b64urlEncode(buffer) {
4
- return Buffer.from(buffer)
5
- .toString('base64')
6
- .replace(/\+/g, '-')
7
- .replace(/\//g, '_')
8
- .replace(/=+$/, '');
9
- }
10
-
11
- function b64urlDecode(str) {
12
- let base64 = str.replace(/-/g, '+').replace(/_/g, '/');
13
- while (base64.length % 4) base64 += '=';
14
- return Buffer.from(base64, 'base64');
15
- }
16
-
17
- export function encryptPrompt(plaintext, keyBase64url) {
18
- const key = b64urlDecode(keyBase64url);
19
- if (key.length !== 32) {
20
- throw new Error('Invalid vault key length');
21
- }
22
-
23
- const iv = randomBytes(12);
24
- const cipher = createCipheriv('aes-256-gcm', key, iv);
25
-
26
- const encoded = Buffer.from(plaintext, 'utf8');
27
- const encrypted = Buffer.concat([cipher.update(encoded), cipher.final()]);
28
- const authTag = cipher.getAuthTag();
29
-
30
- // AES-GCM ciphertext = encrypted + authTag (WebCrypto format)
31
- const ciphertext = Buffer.concat([encrypted, authTag]);
32
-
33
- return {
34
- encrypted_content: b64urlEncode(ciphertext),
35
- content_iv: b64urlEncode(iv),
36
- };
37
- }
38
-
39
- export { b64urlEncode, b64urlDecode };
1
+ export { encryptPrompt, decryptPrompt, b64urlEncode, b64urlDecode } from '@promptcellar/core/crypto';
@@ -1,43 +1 @@
1
- import { generateKeyPair, publicEncrypt, privateDecrypt, constants } from 'crypto';
2
- import { promisify } from 'util';
3
- import { b64urlEncode, b64urlDecode } from './crypto.js';
4
-
5
- const generateKeyPairAsync = promisify(generateKeyPair);
6
-
7
- /**
8
- * Generate an RSA-OAEP 2048 keypair for device-transfer envelope encryption.
9
- * Returns { publicKeySpkiB64url, privateKeyPem }.
10
- */
11
- export async function generateTransferKeyPair() {
12
- const { publicKey, privateKey } = await generateKeyPairAsync('rsa', {
13
- modulusLength: 2048,
14
- publicKeyEncoding: { type: 'spki', format: 'der' },
15
- privateKeyEncoding: { type: 'pkcs8', format: 'pem' },
16
- });
17
-
18
- return {
19
- publicKeySpkiB64url: b64urlEncode(publicKey),
20
- privateKeyPem: privateKey,
21
- };
22
- }
23
-
24
- /**
25
- * Decrypt a transfer payload encrypted with RSA-OAEP + SHA-256.
26
- * @param {string} ciphertextB64url - base64url-encoded ciphertext
27
- * @param {string} privateKeyPem - PEM-encoded PKCS#8 private key
28
- * @returns {string} base64url-encoded master key
29
- */
30
- export function decryptTransfer(ciphertextB64url, privateKeyPem) {
31
- const ciphertext = b64urlDecode(ciphertextB64url);
32
-
33
- const plaintext = privateDecrypt(
34
- {
35
- key: privateKeyPem,
36
- oaepHash: 'sha256',
37
- padding: constants.RSA_PKCS1_OAEP_PADDING,
38
- },
39
- ciphertext,
40
- );
41
-
42
- return b64urlEncode(plaintext);
43
- }
1
+ export { generateTransferKeyPair, decryptTransfer } from '@promptcellar/core/auth';
@@ -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
 
@@ -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
+ });