System prompts (#115)

This commit is contained in:
bl-ue
2025-10-22 09:13:37 -06:00
committed by GitHub
parent fc777e79a3
commit f0b53eba57
18 changed files with 3857 additions and 55 deletions
+4
View File
@@ -56,13 +56,17 @@
"vitest": "^3.2.4"
},
"dependencies": {
"@babel/parser": "^7.28.4",
"@types/figlet": "^1.7.0",
"chalk": "^5.5.0",
"commander": "^14.0.0",
"figlet": "^1.8.2",
"globby": "^14.1.0",
"gray-matter": "^4.0.3",
"ink": "^6.1.0",
"ink-link": "^4.1.0",
"ollama": "^0.6.0",
"openai": "^6.3.0",
"react": "^19.1.1"
}
}
+144
View File
@@ -8,6 +8,9 @@ importers:
.:
dependencies:
'@babel/parser':
specifier: ^7.28.4
version: 7.28.4
'@types/figlet':
specifier: ^1.7.0
version: 1.7.0
@@ -23,12 +26,21 @@ importers:
globby:
specifier: ^14.1.0
version: 14.1.0
gray-matter:
specifier: ^4.0.3
version: 4.0.3
ink:
specifier: ^6.1.0
version: 6.1.0(@types/[email protected])([email protected])([email protected])
ink-link:
specifier: ^4.1.0
version: 4.1.0([email protected](@types/[email protected])([email protected])([email protected]))
ollama:
specifier: ^0.6.0
version: 0.6.0
openai:
specifier: ^6.3.0
version: 6.3.0([email protected])
react:
specifier: ^19.1.1
version: 19.1.1
@@ -82,6 +94,23 @@ packages:
resolution: {integrity: sha512-3yWxPTq3UQ/FY9p1ErPxIyfT64elWaMvM9lIHnaqpyft63tkxodF5aUElYHrdisWve5cETkh1+KBw1yJuW0aRw==}
engines: {node: '>=14.13.1'}
'@babel/[email protected]':
resolution: {integrity: sha512-qMlSxKbpRlAridDExk92nSobyDdpPijUq2DW6oDnUqd0iOGxmQjyqhMIihI9+zv4LPyZdRje2cavWPbCbWm3eA==}
engines: {node: '>=6.9.0'}
'@babel/[email protected]':
resolution: {integrity: sha512-D2hP9eA+Sqx1kBZgzxZh0y1trbuU+JoDkiEwqhQ36nodYqJwyEIhPSdMNd7lOm/4io72luTPWH20Yda0xOuUow==}
engines: {node: '>=6.9.0'}
'@babel/[email protected]':
resolution: {integrity: sha512-yZbBqeM6TkpP9du/I2pUZnJsRMGGvOuIrhjzC1AwHwW+6he4mni6Bp/m8ijn0iOuZuPI2BfkCoSRunpyjnrQKg==}
engines: {node: '>=6.0.0'}
hasBin: true
'@babel/[email protected]':
resolution: {integrity: sha512-bkFqkLhh3pMBUQQkpVgWDWq/lqzc2678eUyDlTBhRqhCHFguYYGM0Efga7tYk4TogG/3x0EEl66/OQ+WGbWB/Q==}
engines: {node: '>=6.9.0'}
'@emnapi/[email protected]':
resolution: {integrity: sha512-XsLw1dEOpkSX/WucdqUhPWP7hDxSvZiY+fsUC14h+FtQ2Ifni4znbBt8punRX+Uj2JG/uDb8nEHVKvrVlvdZ5Q==}
@@ -668,6 +697,9 @@ packages:
resolution: {integrity: sha512-BGcItUBWSMRgOCe+SVZJ+S7yTRG0eGt9cXAHev72yuGcY23hnLA7Bky5L/xLyPINoSN95geovfBkqoTlNZYa7w==}
engines: {node: '>=14'}
[email protected]:
resolution: {integrity: sha512-o5Roy6tNG4SL/FOkCAN6RzjiakZS25RLYFrcMttJqbdd8BWrnA+fGz57iN5Pb06pvBGvl5gQ0B48dJlslXvoTg==}
[email protected]:
resolution: {integrity: sha512-8+9WqebbFzpX9OR+Wa6O29asIogeRMzcGtAINdpMHHyAg10f05aSFVBbcEqGf/PXw1EjAZ+q2/bEBg3DvurK3Q==}
@@ -943,6 +975,11 @@ packages:
resolution: {integrity: sha512-j6PAQ2uUr79PZhBjP5C5fhl8e39FmRnOjsD5lGnWrFU8i2G776tBK7+nP8KuQUTTyAZUwfQqXAgrVH5MbH9CYQ==}
engines: {node: ^18.18.0 || ^20.9.0 || >=21.1.0}
[email protected]:
resolution: {integrity: sha512-eGuFFw7Upda+g4p+QHvnW0RyTX/SVeJBDM/gCtMARO0cLuT2HcEKnTPvhjV6aGeqrCB/sbNop0Kszm0jsaWU4A==}
engines: {node: '>=4'}
hasBin: true
[email protected]:
resolution: {integrity: sha512-ca9pw9fomFcKPvFLXhBKUK90ZvGibiGOvRJNbjljY7s7uq/5YO4BOzcYtJqExdx99rF6aAcnRxHmcUHcz6sQsg==}
engines: {node: '>=0.10'}
@@ -966,6 +1003,10 @@ packages:
resolution: {integrity: sha512-JhFGDVJ7tmDJItKhYgJCGLOWjuK9vPxiXoUFLwLDc99NlmklilbiQJwoctZtt13+xMw91MCk/REan6MWHqDjyA==}
engines: {node: '>=12.0.0'}
[email protected]:
resolution: {integrity: sha512-zCnTtlxNoAiDc3gqY2aYAWFx7XWWiasuF2K8Me5WbN8otHKTUKBwjPtNpRs/rbUZm7KxWAaNj7P1a/p52GbVug==}
engines: {node: '>=0.10.0'}
[email protected]:
resolution: {integrity: sha512-f3qQ9oQy9j2AhBe/H9VC91wLmKBCCU/gDOnKNAYG5hswO7BLKj09Hc5HYNz9cGI++xlpDCIgDaitVs03ATR84Q==}
@@ -1083,6 +1124,10 @@ packages:
[email protected]:
resolution: {integrity: sha512-EtKwoO6kxCL9WO5xipiHTZlSzBm7WLT627TqC/uVRd0HKmq8NXyebnNYxDoBi7wt8eTWrUrKXCOVaFq9x1kgag==}
[email protected]:
resolution: {integrity: sha512-5v6yZd4JK3eMI3FqqCouswVqwugaA9r4dNZB1wwcmrD02QkV5H0y7XBQW8QwQqEaZY1pM9aqORSORhJRdNK44Q==}
engines: {node: '>=6.0'}
[email protected]:
resolution: {integrity: sha512-R3pbpkcIqv2Pm3dUwgjclDRVmWpTJW2DcMzcIhEXEx1oh/CEMObMm3KLmRJOdvhM7o4uQBnwr8pzRK2sJWIqfg==}
engines: {node: '>= 0.4'}
@@ -1185,6 +1230,10 @@ packages:
resolution: {integrity: sha512-PwwhEakHVKTdRNVOw+/Gyh0+MzlCl4R6qKvkhuvLtPMggI1WAHt9sOwZxQLSGpUaDnrdyDsomoRgNnCfKNSXXg==}
engines: {node: '>= 0.4'}
[email protected]:
resolution: {integrity: sha512-5BMULNob1vgFX6EjQw5izWDxrecWK9AM72rugNr0TFldMOi0fj6Jk+zeKIt0xGj4cEfQIJth4w3OKWOJ4f+AFw==}
engines: {node: '>=0.10.0'}
[email protected]:
resolution: {integrity: sha512-SbKbANkN603Vi4jEZv49LeVJMn4yGwsbzZworEoyEiutsN3nJYdbO36zfhGJ6QEDpOZIFkDtnq5JRxmvl3jsoQ==}
engines: {node: '>=0.10.0'}
@@ -1282,6 +1331,10 @@ packages:
[email protected]:
resolution: {integrity: sha512-mxa9E9ITFOt0ban3j6L5MpjwegGz6lBQmM1IJkWeBZGcMxto50+eWdjC/52xDbS2vy0k7vIMK0Fe2wfL9OQSpQ==}
[email protected]:
resolution: {integrity: sha512-okMH7OXXJ7YrN9Ok3/SXrnu4iX9yOk+25nqX4imS2npuvTYDmo/QEZoqwZkYaIDk3jVvBOTOIEgEhaLOynBS9g==}
hasBin: true
[email protected]:
resolution: {integrity: sha512-wpxZs9NoxZaJESJGIZTyDEaYpl0FKSA+FB9aJiyemKhMwkxQg63h4T1KJgUGHpTqPDNRcmmYLugrRjJlBtWvRA==}
hasBin: true
@@ -1302,6 +1355,10 @@ packages:
[email protected]:
resolution: {integrity: sha512-oxVHkHR/EJf2CNXnWxRLW6mg7JyCCUcG0DtEGmL2ctUo1PNTin1PUil+r/+4r5MpVgC/fn1kjsx7mjSujKqIpw==}
[email protected]:
resolution: {integrity: sha512-dcS1ul+9tmeD95T+x28/ehLgd9mENa3LsvDTtzm3vyBEO7RPptvAD+t44WVXaUjTBRcrpFeFlC8WCruUR456hw==}
engines: {node: '>=0.10.0'}
[email protected]:
resolution: {integrity: sha512-+bT2uH4E5LGE7h/n3evcS/sQlJXCpIp6ym8OWJ5eV6+67Dsql/LaaT7qJBAt2rzfoa/5QBGBhxDix1dMt2kQKQ==}
engines: {node: '>= 0.8.0'}
@@ -1449,10 +1506,25 @@ packages:
resolution: {integrity: sha512-gXah6aZrcUxjWg2zR2MwouP2eHlCBzdV4pygudehaKXSGW4v2AsRQUK+lwwXhii6KFZcunEnmSUoYp5CXibxtA==}
engines: {node: '>= 0.4'}
[email protected]:
resolution: {integrity: sha512-FHjdU2Ok5x2HZsxPui/MBJZ5J+HzmxoWYa/p9wk736eT+uAhS8nvIICar5YgwlG5MFNjDR6UA5F3RSKq+JseOA==}
[email protected]:
resolution: {integrity: sha512-kbpaSSGJTWdAY5KPVeMOKXSrPtr8C8C7wodJbcsd51jRnmD+GZu8Y0VoU6Dm5Z4vWr0Ig/1NKuWRKf7j5aaYSg==}
engines: {node: '>=6'}
[email protected]:
resolution: {integrity: sha512-E6vOGtZvdcb4yXQ5jXvDlUG599OhIkb/GjBLZXS+qk0HF+PJReIldEc9hM8Ft81vn+N6dRdFRb7BZNK8bbvXrw==}
hasBin: true
peerDependencies:
ws: ^8.18.0
zod: ^3.25 || ^4.0
peerDependenciesMeta:
ws:
optional: true
zod:
optional: true
[email protected]:
resolution: {integrity: sha512-6IpQ7mKUxRcZNLIObR0hz7lxsapSSIYNZJwXPGeF0mTVqGKFIXj1DQcMoT22S3ROcLyY/rz0PWaWZ9ayWmad9g==}
engines: {node: '>= 0.8.0'}
@@ -1656,6 +1728,10 @@ packages:
[email protected]:
resolution: {integrity: sha512-NlHwttCI/l5gCPR3D1nNXtWABUmBwvZpEQiD4IXSbIDq8BzLIK/7Ir5gTFSGZDUu37K5cMNp0hFtzO38sC7gWA==}
[email protected]:
resolution: {integrity: sha512-vfD3pmTzGpufjScBh50YHKzEu2lxBWhVEHsNGoEXmCmn2hKGfeNLYMzCJpe8cD7gqX7TJluOVpBkAequ6dgMmA==}
engines: {node: '>=4'}
[email protected]:
resolution: {integrity: sha512-BR7VvDCVHO+q2xBEWskxS6DJE1qRnb7DxzUrogb71CWoSficBxYsiAGd+Kl0mmq/MprG9yArRkyrQxTO6XjMzA==}
hasBin: true
@@ -1727,6 +1803,9 @@ packages:
resolution: {integrity: sha512-UXWMKhLOwVKb728IUtQPXxfYU+usdybtUrK/8uGE8CQMvrhOpwvzDBwj0QhSL7MQc7vIsISBG8VQ8+IDQxpfQA==}
engines: {node: '>=0.10.0'}
[email protected]:
resolution: {integrity: sha512-D9cPgkvLlV3t3IzL0D0YLvGA9Ahk4PcvVwUbN0dSGr1aP0Nrt4AEnTUbuGvquEC0mA64Gqt1fzirlRs5ibXx8g==}
[email protected]:
resolution: {integrity: sha512-XlkWvfIm6RmsWtNJx+uqtKLS8eqFbxUg0ZzLXqY0caEy9l7hruX8IpiDnjsLavoBgqCCR71TqWO8MaXYheJ3RQ==}
engines: {node: '>=10'}
@@ -1768,6 +1847,10 @@ packages:
resolution: {integrity: sha512-iq6eVVI64nQQTRYq2KtEg2d2uU7LElhTJwsH4YzIHZshxlgZms/wIc4VoDQTlG/IvVIrBKG06CrZnp0qv7hkcQ==}
engines: {node: '>=12'}
[email protected]:
resolution: {integrity: sha512-uCC2VHvQRYu+lMh4My/sFNmF2klFymLX1wHJeXnbEJERpV/ZsVuonzerjfrGpIGF7LBVa1O7i9kjiWvJiFck8g==}
engines: {node: '>=0.10.0'}
[email protected]:
resolution: {integrity: sha512-6fPc+R4ihwqP6N/aIv2f1gMH8lOVtWQHoqC4yK6oSDVVocumAsfCqjkXnqiYMhmMwS/mEHLp7Vehlt3ql6lEig==}
engines: {node: '>=8'}
@@ -1958,6 +2041,9 @@ packages:
jsdom:
optional: true
[email protected]:
resolution: {integrity: sha512-EqhiFU6daOA8kpjOWTL0olhVOF3i7OrFzSYiGsEMB8GcXS+RrzauAERX65xMeNWVqxA6HXH2m69Z9LaKKdisfg==}
[email protected]:
resolution: {integrity: sha512-TbX3mj8n0odCBFVlY8AxkqcHASw3L60jIuF8jFP78az3C2YhmGvqbHBpAjTRH2/xqYunrJ9g1jSyjCjpoWzIAA==}
engines: {node: '>= 0.4'}
@@ -2034,6 +2120,19 @@ snapshots:
ansi-styles: 6.2.1
is-fullwidth-code-point: 4.0.0
'@babel/[email protected]': {}
'@babel/[email protected]': {}
'@babel/[email protected]':
dependencies:
'@babel/types': 7.28.4
'@babel/[email protected]':
dependencies:
'@babel/helper-string-parser': 7.27.1
'@babel/helper-validator-identifier': 7.27.1
'@emnapi/[email protected]':
dependencies:
'@emnapi/wasi-threads': 1.0.4
@@ -2519,6 +2618,10 @@ snapshots:
[email protected]: {}
[email protected]:
dependencies:
sprintf-js: 1.0.3
[email protected]: {}
[email protected]:
@@ -2945,6 +3048,8 @@ snapshots:
acorn-jsx: 5.3.2([email protected])
eslint-visitor-keys: 4.2.1
[email protected]: {}
[email protected]:
dependencies:
estraverse: 5.3.0
@@ -2963,6 +3068,10 @@ snapshots:
[email protected]: {}
[email protected]:
dependencies:
is-extendable: 0.1.1
[email protected]: {}
[email protected]:
@@ -3087,6 +3196,13 @@ snapshots:
[email protected]: {}
[email protected]:
dependencies:
js-yaml: 3.14.1
kind-of: 6.0.3
section-matter: 1.0.0
strip-bom-string: 1.0.0
[email protected]: {}
[email protected]: {}
@@ -3208,6 +3324,8 @@ snapshots:
call-bound: 1.0.4
has-tostringtag: 1.0.2
[email protected]: {}
[email protected]: {}
[email protected]:
@@ -3300,6 +3418,11 @@ snapshots:
[email protected]: {}
[email protected]:
dependencies:
argparse: 1.0.10
esprima: 4.0.1
[email protected]:
dependencies:
argparse: 2.0.1
@@ -3321,6 +3444,8 @@ snapshots:
dependencies:
json-buffer: 3.0.1
[email protected]: {}
[email protected]:
dependencies:
prelude-ls: 1.2.1
@@ -3448,10 +3573,18 @@ snapshots:
define-properties: 1.2.1
es-object-atoms: 1.1.1
[email protected]:
dependencies:
whatwg-fetch: 3.6.20
[email protected]:
dependencies:
mimic-fn: 2.1.0
[email protected]([email protected]):
optionalDependencies:
ws: 8.18.3
[email protected]:
dependencies:
deep-is: 0.1.4
@@ -3671,6 +3804,11 @@ snapshots:
[email protected]: {}
[email protected]:
dependencies:
extend-shallow: 2.0.1
kind-of: 6.0.3
[email protected]: {}
[email protected]: {}
@@ -3752,6 +3890,8 @@ snapshots:
[email protected]: {}
[email protected]: {}
[email protected]:
dependencies:
escape-string-regexp: 2.0.0
@@ -3819,6 +3959,8 @@ snapshots:
dependencies:
ansi-regex: 6.1.0
[email protected]: {}
[email protected]: {}
[email protected]:
@@ -4019,6 +4161,8 @@ snapshots:
- tsx
- yaml
[email protected]: {}
[email protected]:
dependencies:
is-bigint: 1.1.0
+13 -9
View File
@@ -14,13 +14,14 @@ import {
Settings,
StartupCheckInfo,
TweakccConfig,
SYSTEM_PROMPTS_DIR,
} from './utils/types.js';
import {
readConfigFile,
restoreClijsFromBackup,
updateConfigFile,
} from './utils/config.js';
import { revealFileInExplorer } from './utils/misc.js';
import { openInExplorer, revealFileInExplorer } from './utils/misc.js';
import { applyCustomization } from './utils/patches/index.js';
export const SettingsContext = createContext({
@@ -113,14 +114,6 @@ Please reapply your changes below.`,
const handleMainSubmit = (item: MainMenuItem) => {
setNotification(null);
switch (item) {
case MainMenuItem.THEMES:
case MainMenuItem.LAUNCH_TEXT:
case MainMenuItem.THINKING_VERBS:
case MainMenuItem.THINKING_STYLE:
case MainMenuItem.USER_MESSAGE_DISPLAY:
case MainMenuItem.INPUT_BOX:
setCurrentView(item);
break;
case MainMenuItem.APPLY_CHANGES:
if (startupCheckInfo.ccInstInfo) {
setNotification({
@@ -138,6 +131,17 @@ Please reapply your changes below.`,
);
}
break;
case MainMenuItem.THEMES:
case MainMenuItem.LAUNCH_TEXT:
case MainMenuItem.THINKING_VERBS:
case MainMenuItem.THINKING_STYLE:
case MainMenuItem.USER_MESSAGE_DISPLAY:
case MainMenuItem.INPUT_BOX:
setCurrentView(item);
break;
case MainMenuItem.VIEW_SYSTEM_PROMPTS:
openInExplorer(SYSTEM_PROMPTS_DIR);
break;
case MainMenuItem.RESTORE_ORIGINAL:
if (startupCheckInfo.ccInstInfo) {
restoreClijsFromBackup(startupCheckInfo.ccInstInfo).then(() => {
+4
View File
@@ -40,6 +40,10 @@ const baseMenuItems: SelectItem[] = [
name: MainMenuItem.INPUT_BOX,
desc: 'Customize the input box appearance (e.g., remove border)',
},
{
name: MainMenuItem.VIEW_SYSTEM_PROMPTS,
desc: 'Opens the system prompts directory where you can customize Claude Code\'s system prompts',
},
];
// prettier-ignore
+36 -44
View File
@@ -29,53 +29,45 @@ const main = async () => {
console.log('Applying saved customizations to Claude Code...');
console.log(`Configuration saved at: ${CONFIG_FILE}`);
try {
// Read the saved configuration
const config = await readConfigFile();
// Read the saved configuration
const config = await readConfigFile();
if (!config.settings || Object.keys(config.settings).length === 0) {
console.error('No saved customizations found in ' + CONFIG_FILE);
process.exit(1);
}
// Find Claude Code installation
const startupCheckInfo = await startupCheck();
if (!startupCheckInfo || !startupCheckInfo.ccInstInfo) {
console.error(`Cannot find Claude Code's cli.js`);
console.error('Searched at the following locations:');
CLIJS_SEARCH_PATH_INFO.forEach(info => {
if (info.isGlob) {
if (info.expandedPaths.length === 0) {
console.error(` - ${info.pattern} (no matches)`);
} else {
console.error(` - ${info.pattern}`);
info.expandedPaths.forEach(path => {
console.error(` - ${path}`);
});
}
} else {
console.error(` - ${info.pattern}`);
}
});
process.exit(1);
}
console.log(
`Found Claude Code at: ${startupCheckInfo.ccInstInfo.cliPath}`
);
console.log(`Version: ${startupCheckInfo.ccInstInfo.version}`);
// Apply the customizations
console.log('Applying customizations...');
await applyCustomization(config, startupCheckInfo.ccInstInfo);
console.log('Customizations applied successfully!');
process.exit(0);
} catch (error) {
console.error('Unexpected error:');
console.error(error instanceof Error ? error.message : String(error));
if (!config.settings || Object.keys(config.settings).length === 0) {
console.error('No saved customizations found in ' + CONFIG_FILE);
process.exit(1);
}
// Find Claude Code installation
const startupCheckInfo = await startupCheck();
if (!startupCheckInfo || !startupCheckInfo.ccInstInfo) {
console.error(`Cannot find Claude Code's cli.js`);
console.error('Searched at the following locations:');
CLIJS_SEARCH_PATH_INFO.forEach(info => {
if (info.isGlob) {
if (info.expandedPaths.length === 0) {
console.error(` - ${info.pattern} (no matches)`);
} else {
console.error(` - ${info.pattern}`);
info.expandedPaths.forEach(path => {
console.error(` - ${path}`);
});
}
} else {
console.error(` - ${info.pattern}`);
}
});
process.exit(1);
}
console.log(`Found Claude Code at: ${startupCheckInfo.ccInstInfo.cliPath}`);
console.log(`Version: ${startupCheckInfo.ccInstInfo.version}`);
// Apply the customizations
console.log('Applying customizations...');
await applyCustomization(config, startupCheckInfo.ccInstInfo);
console.log('Customizations applied successfully!');
process.exit(0);
}
const startupCheckInfo = await startupCheck();
+13 -1
View File
@@ -11,6 +11,7 @@ import fs from 'node:fs/promises';
import type { Stats } from 'node:fs';
import path from 'node:path';
import * as misc from './misc.js';
import * as systemPromptHashIndex from './systemPromptHashIndex.js';
vi.mock('node:fs/promises');
@@ -35,6 +36,11 @@ describe('config.ts', () => {
vi.clearAllMocks();
vi.spyOn(console, 'error').mockImplementation(() => {});
vi.spyOn(console, 'clear').mockImplementation(() => {});
// Mock hasUnappliedSystemPromptChanges to always return false by default
vi.spyOn(
systemPromptHashIndex,
'hasUnappliedSystemPromptChanges'
).mockResolvedValue(false);
});
describe('ensureConfigDir', () => {
@@ -87,11 +93,17 @@ describe('config.ts', () => {
describe('restoreClijsFromBackup', () => {
it('should copy the backup file and update the config', async () => {
// Mock the clearAllAppliedHashes function to avoid file system operations
vi.spyOn(
systemPromptHashIndex,
'clearAllAppliedHashes'
).mockResolvedValue(undefined);
// Mock reading the backup file
const readFileSpy = vi
.spyOn(fs, 'readFile')
.mockResolvedValueOnce(Buffer.from('backup content')) // Reading backup file
.mockRejectedValue(createEnoent()); // Reading config file
.mockRejectedValue(createEnoent()); // Reading config file and others
// Mock file operations for the helper function
vi.spyOn(fs, 'stat').mockRejectedValue(createEnoent()); // File doesn't exist
+24
View File
@@ -8,6 +8,7 @@ import {
CONFIG_FILE,
DEFAULT_SETTINGS,
StartupCheckInfo,
SYSTEM_PROMPTS_DIR,
Theme,
ThinkingVerbsConfig,
TweakccConfig,
@@ -17,9 +18,15 @@ import {
isDebug,
replaceFileBreakingHardLinks,
} from './misc.js';
import { syncSystemPrompts, displaySyncResults } from './promptSync.js';
import {
hasUnappliedSystemPromptChanges,
clearAllAppliedHashes,
} from './systemPromptHashIndex.js';
export const ensureConfigDir = async (): Promise<void> => {
await fs.mkdir(CONFIG_DIR, { recursive: true });
await fs.mkdir(SYSTEM_PROMPTS_DIR, { recursive: true });
};
let lastConfig: TweakccConfig = {
@@ -121,6 +128,14 @@ export const readConfigFile = async (): Promise<TweakccConfig> => {
DEFAULT_SETTINGS.userMessageDisplay;
}
// Check if system prompts have been modified since they were last applied
// If so, mark changesApplied as false to show the "*Apply customizations" indicator
const hasSystemPromptChanges =
await hasUnappliedSystemPromptChanges(SYSTEM_PROMPTS_DIR);
if (hasSystemPromptChanges) {
readConfig.changesApplied = false;
}
lastConfig = readConfig;
return readConfig;
} catch (error) {
@@ -181,6 +196,9 @@ export const restoreClijsFromBackup = async (
'restore'
);
// Clear all applied hashes since we're restoring to defaults
await clearAllAppliedHashes();
await updateConfigFile(config => {
config.changesApplied = false;
});
@@ -291,6 +309,12 @@ export async function startupCheck(): Promise<StartupCheckInfo | null> {
return null;
}
// Sync system prompts with the current CC version
if (ccInstInfo.version) {
const syncSummary = await syncSystemPrompts(ccInstInfo.version);
displaySyncResults(syncSummary);
}
const realVersion = ccInstInfo.version;
const backedUpVersion = config.ccVersion;
+60
View File
@@ -0,0 +1,60 @@
import type { StringsFile } from './promptSync.js';
/**
* Downloads the strings file for a given CC version from GitHub
* @param version - Version string in format "X.Y.Z" (e.g., "0.17.0")
* @returns Promise that resolves to the parsed JSON content
*/
export async function downloadStringsFile(
version: string
): Promise<StringsFile> {
// Construct the GitHub raw URL
//const url = `https://raw.githubusercontent.com/Piebald-AI/tweakcc/refs/heads/main/data/prompts/prompts-${version}.json`;
const url = `https://raw.githubusercontent.com/Piebald-AI/tweakcc/refs/heads/strings-tmp/strings-${version}.json`;
try {
// Fetch the file from GitHub
const response = await fetch(url);
if (!response.ok) {
throw new Error(
`Failed to download strings file: ${response.status} ${response.statusText}`
);
}
// Parse JSON directly
const jsonData = (await response.json()) as StringsFile;
return jsonData;
} catch (error) {
if (error instanceof Error) {
throw new Error(
`Error downloading strings file for version ${version}: ${error.message}`
);
}
throw error;
}
}
/**
* Downloads strings files for multiple versions
* @param versions - Array of version strings
* @returns Promise that resolves to a map of version to parsed JSON content
*/
export async function downloadMultipleStringsFiles(
versions: string[]
): Promise<Map<string, StringsFile>> {
const results = new Map<string, StringsFile>();
for (const version of versions) {
try {
const data = await downloadStringsFile(version);
results.set(version, data);
} catch (error) {
console.error(`Failed to download version ${version}:`, error);
// Continue with other versions
}
}
return results;
}
+25
View File
@@ -80,6 +80,31 @@ export function getColorKeys(theme: Theme): string[] {
return Object.keys(theme.colors);
}
export function openInExplorer(filePath: string) {
if (process.platform === 'win32') {
child_process
.spawn('explorer', [filePath], {
detached: true,
stdio: 'ignore',
})
.unref();
} else if (process.platform === 'darwin') {
child_process
.spawn('open', [filePath], {
detached: true,
stdio: 'ignore',
})
.unref();
} else {
child_process
.spawn('xdg-open', [filePath], {
detached: true,
stdio: 'ignore',
})
.unref();
}
}
export function revealFileInExplorer(filePath: string) {
if (process.platform === 'win32') {
child_process
+4
View File
@@ -36,6 +36,7 @@ import { writeWelcomeMessage } from './welcomeMessage.js';
import { writeModelCustomizations } from './modelSelector.js';
import { writeIgnoreMaxSubscription } from './ignoreMaxSubscription.js';
import { writeVersionOutput } from './versionOutput.js';
import { applySystemPrompts } from './systemPrompts.js';
export interface LocationResult {
startIndex: number;
@@ -121,6 +122,9 @@ export const applyCustomization = async (
let content = await fs.readFile(ccInstInfo.cliPath, { encoding: 'utf8' });
// Apply system prompt customizations
content = await applySystemPrompts(content, ccInstInfo.version);
// Apply themes
let result: string | null = null;
if (config.settings.themes && config.settings.themes.length > 0) {
+95
View File
@@ -0,0 +1,95 @@
import chalk from 'chalk';
import { isDebug } from '../misc.js';
import { showDiff } from './index.js';
import { loadSystemPromptsWithRegex } from '../promptSync.js';
import { setAppliedHash, computeMD5Hash } from '../systemPromptHashIndex.js';
/**
* Apply system prompt customizations to cli.js content
* @param content - The current content of cli.js
* @param version - The Claude Code version
* @returns The modified content with system prompts applied
*/
export const applySystemPrompts = async (
content: string,
version: string
): Promise<string> => {
// Load system prompts and generate regexes
const systemPrompts = await loadSystemPromptsWithRegex(version);
if (isDebug()) {
console.log(`Loaded ${systemPrompts.length} system prompts with regexes`);
}
// Search for and replace each prompt in cli.js
for (const {
promptId,
prompt,
regex,
getInterpolatedContent,
} of systemPrompts) {
const pattern = new RegExp(regex, 's'); // 's' flag for dotAll mode
const match = content.match(pattern);
if (match && match.index !== undefined) {
// Generate the interpolated content using the actual variables from the match
const interpolatedContent = getInterpolatedContent(match);
if (isDebug()) {
console.log(`\nFound match for prompt: ${prompt.name}`);
console.log(
` Match location: index ${match.index}, length ${match[0].length}`
);
console.log(
` Original content (first 100 chars): ${match[0].substring(0, 100)}...`
);
console.log(
` Replacement content (first 100 chars): ${interpolatedContent.substring(0, 100)}...`
);
console.log(` Captured variables: ${match.slice(1).join(', ')}`);
console.log(` Content identical: ${match[0] === interpolatedContent}`);
}
const oldContent = content;
const matchIndex = match.index;
const matchLength = match[0].length;
// Replace the matched content with the interpolated content from the markdown file
content = content.replace(pattern, interpolatedContent);
// Store the hash of the applied prompt content
const appliedHash = computeMD5Hash(prompt.content);
await setAppliedHash(promptId, appliedHash);
// Show diff in debug mode
if (isDebug()) {
showDiff(
oldContent,
content,
interpolatedContent,
matchIndex,
matchIndex + matchLength
);
}
} else {
console.log(
chalk.yellow(
`Could not find system prompt "${prompt.name}" in cli.js (using regex /${regex}/)`
)
);
if (isDebug()) {
console.log(`\n Debug info for ${prompt.name}:`);
console.log(
` Regex pattern (first 200 chars): ${regex.substring(0, 200)}...`
);
console.log(` Trying to match pattern in cli.js...`);
const testMatch = content.match(new RegExp(regex.substring(0, 100)));
console.log(
` Partial match result: ${testMatch ? 'found partial' : 'no match'}`
);
}
}
}
return content;
};
+889
View File
@@ -0,0 +1,889 @@
import { describe, it, expect, vi, beforeEach } from 'vitest';
import * as fs from 'node:fs/promises';
import * as promptSync from './promptSync.js';
import type { StringsPrompt, StringsFile } from './promptSync.js';
vi.mock('node:fs/promises');
vi.mock('./download.js');
const createEnoent = () => {
const error: NodeJS.ErrnoException = new Error(
'ENOENT: no such file or directory'
);
error.code = 'ENOENT';
return error;
};
describe('promptSync.ts', () => {
beforeEach(() => {
vi.clearAllMocks();
});
describe('parseMarkdownPrompt', () => {
it('should parse markdown with frontmatter', () => {
const markdown = `<!--
name: Test Prompt
description: A test prompt
ccVersion: 1.0.0
variables:
- SETTINGS
- CONFIG
-->
This is the content with \${SETTINGS.preferredName} and \${CONFIG.taskType}.`;
const result = promptSync.parseMarkdownPrompt(markdown);
expect(result).toEqual({
name: 'Test Prompt',
description: 'A test prompt',
ccVersion: '1.0.0',
variables: ['SETTINGS', 'CONFIG'],
content:
'This is the content with ${SETTINGS.preferredName} and ${CONFIG.taskType}.',
});
});
it('should handle markdown without variables', () => {
const markdown = `<!--
name: Simple Prompt
description: No variables
ccVersion: 1.0.0
-->
Simple content.`;
const result = promptSync.parseMarkdownPrompt(markdown);
expect(result).toEqual({
name: 'Simple Prompt',
description: 'No variables',
ccVersion: '1.0.0',
variables: [],
content: 'Simple content.',
});
});
it('should handle missing frontmatter fields', () => {
const markdown = `<!--
-->
Content only.`;
const result = promptSync.parseMarkdownPrompt(markdown);
expect(result).toEqual({
name: '',
description: '',
ccVersion: '',
variables: [],
content: 'Content only.',
});
});
});
describe('reconstructContentFromPieces', () => {
it('should reconstruct content with placeholders for minified variables', () => {
const pieces = [
'You will greet the user as ${',
'.preferredName}, running on ${',
'.platform}!',
];
const identifiers = [1, 2];
const identifierMap = { '1': 'SETTINGS', '2': 'CONFIG' };
const result = promptSync.reconstructContentFromPieces(
pieces,
identifiers,
identifierMap
);
expect(result).toBe(
'You will greet the user as ${SETTINGS.preferredName}, running on ${CONFIG.platform}!'
);
});
it('should handle empty identifier map', () => {
const pieces = ['Just text'];
const identifiers: number[] = [];
const identifierMap = {};
const result = promptSync.reconstructContentFromPieces(
pieces,
identifiers,
identifierMap
);
expect(result).toBe('Just text');
});
it('should handle missing identifier mappings', () => {
const pieces = ['Start ', ' end'];
const identifiers = [999];
const identifierMap = {};
const result = promptSync.reconstructContentFromPieces(
pieces,
identifiers,
identifierMap
);
expect(result).toBe('Start UNKNOWN_999 end');
});
it('should handle numeric and string identifiers', () => {
const pieces = ['Use ${', '.currentMode} and ${', '.theme} together'];
const identifiers = [1, '2'];
const identifierMap = { '1': 'STATE', '2': 'CONFIG' };
const result = promptSync.reconstructContentFromPieces(
pieces,
identifiers,
identifierMap
);
expect(result).toBe(
'Use ${STATE.currentMode} and ${CONFIG.theme} together'
);
});
});
describe('generateMarkdownFromPrompt', () => {
it('should generate markdown with variables', () => {
const prompt: StringsPrompt = {
id: 'test-id',
name: 'Test Prompt',
description: 'Test description',
version: '1.0.0',
pieces: ['You will greet the user as ${', '.preferredName}!'],
identifiers: [1],
identifierMap: { '1': 'SETTINGS' },
};
const result = promptSync.generateMarkdownFromPrompt(prompt);
expect(result).toContain('name: Test Prompt');
expect(result).toContain('description: Test description');
expect(result).toContain('ccVersion: 1.0.0');
expect(result).toContain('variables:');
expect(result).toContain('- SETTINGS');
expect(result).toContain(
'You will greet the user as ${SETTINGS.preferredName}!'
);
});
it('should generate markdown without variables when identifierMap is empty', () => {
const prompt: StringsPrompt = {
id: 'test-id',
name: 'Simple Prompt',
description: 'No vars',
version: '1.0.0',
pieces: ['Plain text'],
identifiers: [],
identifierMap: {},
};
const result = promptSync.generateMarkdownFromPrompt(prompt);
expect(result).toContain('name: Simple Prompt');
expect(result).not.toContain('variables:');
expect(result).toContain('Plain text');
});
it('should use custom content when provided', () => {
const prompt: StringsPrompt = {
id: 'test-id',
name: 'Test',
description: 'Test',
version: '1.0.0',
pieces: ['Original'],
identifiers: [],
identifierMap: {},
};
const result = promptSync.generateMarkdownFromPrompt(
prompt,
'Custom content here'
);
expect(result).toContain('Custom content here');
expect(result).not.toContain('Original');
});
it('should deduplicate variables in frontmatter', () => {
const prompt: StringsPrompt = {
id: 'test-id',
name: 'Test',
description: 'Test',
version: '1.0.0',
pieces: [
'The user name is ${',
'.userName} and again ${',
'.userName}.',
],
identifiers: [1, 1], // Same identifier used twice
identifierMap: { '1': 'SETTINGS' },
};
const result = promptSync.generateMarkdownFromPrompt(prompt);
// Count occurrences of "- SETTINGS"
const matches = result.match(/^ {2}- SETTINGS$/gm);
expect(matches?.length).toBe(1); // Should only appear once in variables list
});
});
describe('buildRegexFromPieces', () => {
it('should build regex that captures content between pieces', () => {
const pieces = ['Hello ', ', welcome to ', '!'];
const regex = promptSync.buildRegexFromPieces(pieces);
const testString = 'Hello John, welcome to Earth!';
const match = testString.match(regex);
expect(match).not.toBeNull();
expect(match![1]).toBe('John');
expect(match![2]).toBe('Earth');
});
it('should escape special regex characters in pieces', () => {
const pieces = ['(Start) ', ' [End]'];
const regex = promptSync.buildRegexFromPieces(pieces);
const testString = '(Start) content [End]';
const match = testString.match(regex);
expect(match).not.toBeNull();
expect(match![1]).toBe('content');
});
it('should handle single piece (no placeholders)', () => {
const pieces = ['Just text'];
const regex = promptSync.buildRegexFromPieces(pieces);
const testString = 'Just text';
const match = testString.match(regex);
expect(match).not.toBeNull();
expect(match!.length).toBe(1); // Only full match, no captures
});
});
describe('extractUserCustomizations', () => {
it('should extract what user put in place of minified identifiers', () => {
const pieces = [
'You will greet as ${',
'.preferredName}, on platform ${',
'.platform}!',
];
const userContent =
'You will greet as ${SETTINGS.preferredName}, on platform ${CONFIG.platform}!';
const result = promptSync.extractUserCustomizations(userContent, pieces);
expect(result).toEqual(['SETTINGS', 'CONFIG']);
});
it('should handle multiline content with identifiers', () => {
const pieces = ['Start\n${', '.value1}\nMiddle\n${', '.value2}\nEnd'];
const userContent =
'Start\n${CONFIG.value1}\nMiddle\n${STATE.value2}\nEnd';
const result = promptSync.extractUserCustomizations(userContent, pieces);
expect(result).toEqual(['CONFIG', 'STATE']);
});
it('should throw error if content does not match structure', () => {
const pieces = ['Hello ', '!'];
const userContent = 'Goodbye world!';
expect(() => {
promptSync.extractUserCustomizations(userContent, pieces);
}).toThrow('User content does not match expected structure from pieces');
});
});
describe('buildHumanToRealMapping', () => {
it('should build mapping from human-readable identifiers to what user wrote', () => {
const identifiers = [1, 2];
const identifierMap = { '1': 'SETTINGS', '2': 'CONFIG' };
const extractedCustomizations = ['SETTINGS', 'CONFIG'];
const result = promptSync.buildHumanToRealMapping(
identifiers,
identifierMap,
extractedCustomizations
);
expect(result).toEqual({
SETTINGS: 'SETTINGS',
CONFIG: 'CONFIG',
});
});
it('should handle duplicate identifiers with same values', () => {
const identifiers = [1, 2, 1];
const identifierMap = { '1': 'SETTINGS', '2': 'CONFIG' };
const extractedCustomizations = ['SETTINGS', 'CONFIG', 'SETTINGS'];
const result = promptSync.buildHumanToRealMapping(
identifiers,
identifierMap,
extractedCustomizations
);
expect(result).toEqual({
SETTINGS: 'SETTINGS',
CONFIG: 'CONFIG',
});
});
it('should throw error for duplicate identifiers with different values', () => {
const identifiers = [1, 1];
const identifierMap = { '1': 'SETTINGS' };
const extractedCustomizations = ['SETTINGS', 'CUSTOM'];
expect(() => {
promptSync.buildHumanToRealMapping(
identifiers,
identifierMap,
extractedCustomizations
);
}).toThrow('Conflicting mappings for "SETTINGS": "SETTINGS" vs "CUSTOM"');
});
it('should skip identifiers without mappings', () => {
const identifiers = [1, 999, 2];
const identifierMap = { '1': 'SETTINGS', '2': 'CONFIG' };
const extractedCustomizations = ['SETTINGS', 'UNKNOWN', 'CONFIG'];
const result = promptSync.buildHumanToRealMapping(
identifiers,
identifierMap,
extractedCustomizations
);
expect(result).toEqual({
SETTINGS: 'SETTINGS',
CONFIG: 'CONFIG',
});
});
});
describe('applyCustomizationsToPrompt', () => {
it('should apply user customizations to new prompt version', () => {
const newPrompt: StringsPrompt = {
id: 'test',
name: 'Test',
description: 'Test',
version: '2.0.0',
pieces: ['Greet user as ${', '.userName}, running on ${', '.osType}!'],
identifiers: [1, 2],
identifierMap: { '1': 'SETTINGS', '2': 'CONFIG' },
};
const humanToRealMapping = {
SETTINGS: 'SETTINGS',
CONFIG: 'CONFIG',
};
const result = promptSync.applyCustomizationsToPrompt(
newPrompt,
humanToRealMapping
);
expect(result).toBe(
'Greet user as ${SETTINGS.userName}, running on ${CONFIG.osType}!'
);
});
it('should use placeholder for new variables not in old version', () => {
const newPrompt: StringsPrompt = {
id: 'test',
name: 'Test',
description: 'Test',
version: '2.0.0',
pieces: ['User ${', '.userName}, new field: ${', '.newFeature}!'],
identifiers: [1, 3],
identifierMap: { '1': 'SETTINGS', '3': 'NEW_IDENTIFIER' },
};
const humanToRealMapping = {
SETTINGS: 'SETTINGS',
// NEW_IDENTIFIER not in mapping (new in this version)
};
const result = promptSync.applyCustomizationsToPrompt(
newPrompt,
humanToRealMapping
);
expect(result).toBe(
'User ${SETTINGS.userName}, new field: ${${NEW_IDENTIFIER}.newFeature}!'
);
});
});
describe('compareVersions', () => {
it('should return -1 when v1 < v2', () => {
expect(promptSync.compareVersions('1.0.0', '2.0.0')).toBe(-1);
expect(promptSync.compareVersions('1.2.3', '1.2.4')).toBe(-1);
expect(promptSync.compareVersions('1.9.0', '1.10.0')).toBe(-1);
});
it('should return 0 when versions are equal', () => {
expect(promptSync.compareVersions('1.0.0', '1.0.0')).toBe(0);
expect(promptSync.compareVersions('2.5.7', '2.5.7')).toBe(0);
});
it('should return 1 when v1 > v2', () => {
expect(promptSync.compareVersions('2.0.0', '1.0.0')).toBe(1);
expect(promptSync.compareVersions('1.2.4', '1.2.3')).toBe(1);
expect(promptSync.compareVersions('1.10.0', '1.9.0')).toBe(1);
});
it('should handle versions with different lengths', () => {
expect(promptSync.compareVersions('1.0', '1.0.0')).toBe(0);
expect(promptSync.compareVersions('1.0.1', '1.0')).toBe(1);
expect(promptSync.compareVersions('1.0', '1.0.1')).toBe(-1);
});
});
describe('getPromptFilePath', () => {
it('should return correct file path for prompt', () => {
const result = promptSync.getPromptFilePath('test-prompt');
expect(result).toContain('test-prompt.md');
expect(result).toContain('system-prompts');
});
});
describe('promptFileExists', () => {
it('should return true if file exists', async () => {
vi.spyOn(fs, 'access').mockResolvedValue(undefined);
const result = await promptSync.promptFileExists('test-prompt');
expect(result).toBe(true);
});
it('should return false if file does not exist', async () => {
vi.spyOn(fs, 'access').mockRejectedValue(createEnoent());
const result = await promptSync.promptFileExists('test-prompt');
expect(result).toBe(false);
});
});
describe('readPromptFile', () => {
it('should read and parse prompt file', async () => {
const mockContent = `<!--
name: Test Prompt
description: Test
ccVersion: 1.0.0
-->
Content here`;
vi.spyOn(fs, 'readFile').mockResolvedValue(mockContent);
const result = await promptSync.readPromptFile('test-prompt');
expect(result).toEqual({
name: 'Test Prompt',
description: 'Test',
ccVersion: '1.0.0',
variables: [],
content: 'Content here',
});
});
});
describe('writePromptFile', () => {
it('should write prompt file', async () => {
const writeFileSpy = vi
.spyOn(fs, 'writeFile')
.mockResolvedValue(undefined);
await promptSync.writePromptFile('test-prompt', 'content');
expect(writeFileSpy).toHaveBeenCalled();
const [filePath, content] = writeFileSpy.mock.calls[0];
expect(filePath).toContain('test-prompt.md');
expect(content).toBe('content');
});
});
describe('updateVariables', () => {
it('should update variables in frontmatter', async () => {
const mockContent = `<!--
name: Test
description: Test
ccVersion: 1.0.0
variables:
- OLD_VAR
-->
Content`;
vi.spyOn(fs, 'readFile').mockResolvedValue(mockContent);
const writeFileSpy = vi
.spyOn(fs, 'writeFile')
.mockResolvedValue(undefined);
const newIdentifierMap = { '1': 'SETTINGS', '2': 'CONFIG' };
await promptSync.updateVariables('test-prompt', newIdentifierMap);
expect(writeFileSpy).toHaveBeenCalled();
const writtenContent = writeFileSpy.mock.calls[0][1] as string;
expect(writtenContent).toContain('SETTINGS');
expect(writtenContent).toContain('CONFIG');
expect(writtenContent).not.toContain('OLD_VAR');
});
it('should remove variables field when identifierMap is empty', async () => {
const mockContent = `<!--
name: Test
description: Test
ccVersion: 1.0.0
variables:
- OLD_VAR
-->
Content`;
vi.spyOn(fs, 'readFile').mockResolvedValue(mockContent);
const writeFileSpy = vi
.spyOn(fs, 'writeFile')
.mockResolvedValue(undefined);
await promptSync.updateVariables('test-prompt', {});
expect(writeFileSpy).toHaveBeenCalled();
const writtenContent = writeFileSpy.mock.calls[0][1] as string;
expect(writtenContent).not.toContain('variables:');
expect(writtenContent).not.toContain('OLD_VAR');
});
});
describe('generateDiffHtml', () => {
it('should generate HTML diff file with two side-by-side diffs', async () => {
const writeFileSpy = vi
.spyOn(fs, 'writeFile')
.mockResolvedValue(undefined);
const htmlPath = await promptSync.generateDiffHtml(
'test-prompt',
'Test Prompt',
'Old baseline\nLine 2',
'User customization\nLine 2',
'New baseline\nLine 2',
'1.0.0',
'2.0.0',
'/path/to/test-prompt.md'
);
expect(writeFileSpy).toHaveBeenCalled();
expect(htmlPath).toContain('test-prompt.diff.html');
const htmlContent = writeFileSpy.mock.calls[0][1] as string;
expect(htmlContent).toContain('<!DOCTYPE html>');
expect(htmlContent).toContain('test-prompt');
expect(htmlContent).toContain('1.0.0');
expect(htmlContent).toContain('2.0.0');
expect(htmlContent).toContain('Your Customizations');
expect(htmlContent).toContain('Upstream Changes');
});
it('should escape HTML in content', async () => {
vi.spyOn(fs, 'writeFile').mockResolvedValue(undefined);
await promptSync.generateDiffHtml(
'test',
'Test',
'<script>alert("xss")</script>',
'<script>alert("xss")</script>',
'Safe & sound',
'1.0.0',
'2.0.0',
'/path/to/test.md'
);
const htmlContent = (
fs.writeFile as typeof fs.writeFile & {
mock: { calls: [string, string][] };
}
).mock.calls[0][1] as string;
expect(htmlContent).not.toContain('<script>');
expect(htmlContent).toContain('&lt;script&gt;');
expect(htmlContent).toContain('&amp;');
});
});
describe('syncPrompt', () => {
const mockPrompt: StringsPrompt = {
id: 'test-id',
name: 'test-prompt',
description: 'Test prompt',
version: '2.0.0',
pieces: ['Greet user as ${', '.preferredName}!'],
identifiers: [1],
identifierMap: { '1': 'SETTINGS' },
};
it('should create new file if it does not exist', async () => {
vi.spyOn(fs, 'access').mockRejectedValue(createEnoent());
const writeFileSpy = vi
.spyOn(fs, 'writeFile')
.mockResolvedValue(undefined);
const result = await promptSync.syncPrompt(mockPrompt);
expect(result.action).toBe('created');
expect(result.newVersion).toBe('2.0.0');
expect(writeFileSpy).toHaveBeenCalled();
});
it('should skip if versions match', async () => {
const mockContent = `<!--
name: test-prompt
description: Test prompt
ccVersion: 2.0.0
-->
Greet user as \${SETTINGS.preferredName}!`;
vi.spyOn(fs, 'access').mockResolvedValue(undefined);
vi.spyOn(fs, 'readFile').mockResolvedValue(mockContent);
vi.spyOn(fs, 'writeFile').mockResolvedValue(undefined);
const result = await promptSync.syncPrompt(mockPrompt);
expect(result.action).toBe('skipped');
expect(result.oldVersion).toBe('2.0.0');
expect(result.newVersion).toBe('2.0.0');
});
it('should warn about version mismatch when user version is older', async () => {
const mockContent = `<!--
name: test-prompt
description: Test prompt
ccVersion: 1.0.0
-->
Greet user as \${SETTINGS.preferredName}!`;
// Mock the old strings file for downloading old version
const mockOldStringsFile = {
version: '1.0.0',
prompts: [
{
id: 'test-prompt',
name: 'test-prompt',
description: 'Test prompt',
pieces: ['Greet user as ', '!'],
identifiers: [1],
identifierMap: { '1': 'SETTINGS.preferredName' },
version: '1.0.0',
},
],
};
const { downloadStringsFile } = await import('./download.js');
const hashIndexModule = await import('./systemPromptHashIndex.js');
vi.mocked(downloadStringsFile).mockResolvedValue(
mockOldStringsFile as StringsFile
);
vi.spyOn(hashIndexModule, 'getPromptHash').mockResolvedValue(
'different-hash'
); // Simulate user modification
vi.spyOn(fs, 'access').mockResolvedValue(undefined);
vi.spyOn(fs, 'readFile').mockResolvedValue(mockContent);
vi.spyOn(fs, 'writeFile').mockResolvedValue(undefined);
const result = await promptSync.syncPrompt(mockPrompt);
expect(result.action).toBe('conflict');
expect(result.oldVersion).toBe('1.0.0');
expect(result.newVersion).toBe('2.0.0');
expect(result.diffHtmlPath).toBeDefined();
});
it('should always update variables list', async () => {
const mockContent = `<!--
name: test-prompt
description: Test prompt
ccVersion: 2.0.0
variables:
- OLD_VAR
-->
Greet user as \${SETTINGS.preferredName}!`;
vi.spyOn(fs, 'access').mockResolvedValue(undefined);
vi.spyOn(fs, 'readFile').mockResolvedValue(mockContent);
const writeFileSpy = vi
.spyOn(fs, 'writeFile')
.mockResolvedValue(undefined);
await promptSync.syncPrompt(mockPrompt);
// Should have called writeFile for updating variables
expect(writeFileSpy).toHaveBeenCalled();
});
});
describe('syncSystemPrompts', () => {
it('should sync all prompts from strings file', async () => {
const mockStringsFile: StringsFile = {
version: '2.0.0',
prompts: [
{
id: 'prompt1',
name: 'Prompt 1',
description: 'First prompt',
version: '2.0.0',
pieces: ['Content 1'],
identifiers: [],
identifierMap: {},
},
{
id: 'prompt2',
name: 'Prompt 2',
description: 'Second prompt',
version: '2.0.0',
pieces: ['Content 2'],
identifiers: [],
identifierMap: {},
},
],
};
const { downloadStringsFile } = await import('./download.js');
const hashIndexModule = await import('./systemPromptHashIndex.js');
vi.mocked(downloadStringsFile).mockResolvedValue(
mockStringsFile as StringsFile
);
vi.spyOn(hashIndexModule, 'storeHashes').mockResolvedValue(0);
vi.spyOn(fs, 'mkdir').mockResolvedValue(undefined);
vi.spyOn(fs, 'access').mockRejectedValue(createEnoent());
vi.spyOn(fs, 'writeFile').mockResolvedValue(undefined);
const result = await promptSync.syncSystemPrompts('2.0.0');
expect(result.ccVersion).toBe('2.0.0');
expect(result.results).toHaveLength(2);
expect(result.results[0].action).toBe('created');
expect(result.results[1].action).toBe('created');
});
it('should throw error for failed prompt syncs', async () => {
const mockStringsFile: StringsFile = {
version: '2.0.0',
prompts: [
{
id: 'prompt1',
name: 'Prompt 1',
description: 'First prompt',
version: '2.0.0',
pieces: ['Content'],
identifiers: [],
identifierMap: {},
},
],
};
const { downloadStringsFile } = await import('./download.js');
vi.mocked(downloadStringsFile).mockResolvedValue(
mockStringsFile as StringsFile
);
vi.spyOn(fs, 'mkdir').mockResolvedValue(undefined);
// Make the file exist (so it tries to read it)
vi.spyOn(fs, 'access').mockResolvedValue(undefined);
// But make reading fail with permission error
vi.spyOn(fs, 'readFile').mockRejectedValue(
new Error('Permission denied')
);
await expect(promptSync.syncSystemPrompts('2.0.0')).rejects.toThrow(
'Permission denied'
);
});
it('should throw error if download fails', async () => {
const { downloadStringsFile } = await import('./download.js');
vi.mocked(downloadStringsFile).mockRejectedValue(
new Error('Download failed')
);
await expect(promptSync.syncSystemPrompts('2.0.0')).rejects.toThrow(
'Download failed'
);
});
});
describe('displaySyncResults', () => {
it('should display sync results without errors', () => {
const consoleSpy = vi.spyOn(console, 'log').mockImplementation(() => {});
const summary = {
ccVersion: '2.0.0',
results: [
{
id: 'prompt1',
name: 'Prompt 1',
description: 'Test prompt 1',
action: 'created' as const,
newVersion: '2.0.0',
},
{
id: 'prompt2',
name: 'Prompt 2',
description: 'Test prompt 2',
action: 'skipped' as const,
oldVersion: '2.0.0',
newVersion: '2.0.0',
},
],
};
promptSync.displaySyncResults(summary);
expect(consoleSpy).toHaveBeenCalled();
const output = consoleSpy.mock.calls.map(call => call[0]).join('\n');
expect(output).toContain('Skipped 1 up-to-date file');
expect(output).toContain('Created 1 new prompt file');
});
it('should display conflicts', () => {
const consoleSpy = vi.spyOn(console, 'log').mockImplementation(() => {});
const summary = {
ccVersion: '2.0.0',
results: [
{
id: 'prompt1',
name: 'Prompt 1',
description: 'Test prompt',
action: 'conflict' as const,
oldVersion: '1.0.0',
newVersion: '2.0.0',
diffHtmlPath: '/path/to/diff.html',
},
],
};
promptSync.displaySyncResults(summary);
const output = consoleSpy.mock.calls.map(call => call[0]).join('\n');
expect(output).toContain('Conflicts detected');
expect(output).toContain('1.0.0');
expect(output).toContain('2.0.0');
expect(output).toContain('diff.html');
});
});
});
File diff suppressed because it is too large Load Diff
+303
View File
@@ -0,0 +1,303 @@
import * as fs from 'node:fs/promises';
import * as path from 'path';
import * as crypto from 'crypto';
import { CONFIG_DIR } from './types.js';
import { StringsFile, reconstructContentFromPieces } from './promptSync.js';
/**
* Path to the system prompt hash index file
*/
export const HASH_INDEX_PATH = path.join(CONFIG_DIR, 'systemPromptHashes.json');
/**
* Path to the system prompt applied hashes file
* This tracks which hash was last applied to cli.js for each prompt
*/
export const APPLIED_HASHES_PATH = path.join(
CONFIG_DIR,
'systemPromptAppliedHashes.json'
);
/**
* Structure of the hash index
* Maps: "prompt-id-version" => "md5hash"
* Example: "main-system-prompt-2.0.14" => "a1b2c3..."
*/
export interface HashIndex {
[key: string]: string;
}
/**
* Generates a hash key for a prompt
* Format: "{promptId}-{version}"
*/
export const getHashKey = (promptId: string, version: string): string => {
return `${promptId}-${version}`;
};
/**
* Computes the MD5 hash of a string (after trimming leading/trailing whitespace)
*/
export const computeMD5Hash = (content: string): string => {
return crypto.createHash('md5').update(content.trim(), 'utf8').digest('hex');
};
/**
* Reads the hash index from disk. Returns empty object if file doesn't exist.
*/
export const readHashIndex = async (): Promise<HashIndex> => {
try {
const content = await fs.readFile(HASH_INDEX_PATH, 'utf8');
return JSON.parse(content);
} catch (error) {
if (error instanceof Error && 'code' in error && error.code === 'ENOENT') {
return {};
}
throw error;
}
};
/**
* Writes the hash index to disk
*/
export const writeHashIndex = async (index: HashIndex): Promise<void> => {
// Ensure config directory exists
await fs.mkdir(CONFIG_DIR, { recursive: true });
// Sort keys for consistent formatting
const sortedIndex: HashIndex = {};
const sortedKeys = Object.keys(index).sort();
for (const key of sortedKeys) {
sortedIndex[key] = index[key];
}
await fs.writeFile(
HASH_INDEX_PATH,
JSON.stringify(sortedIndex, null, 2),
'utf8'
);
};
/**
* Main utility function: Takes the entire contents of a strings-x.y.z.json file
* and inserts all the hashes that aren't already in the index.
*
* @param stringsFile - The parsed strings file (from downloadStringsFile or similar)
* @returns The number of new hashes added
*/
export const storeHashes = async (
stringsFile: StringsFile
): Promise<number> => {
const index = await readHashIndex();
let newHashCount = 0;
for (const prompt of stringsFile.prompts) {
const hashKey = getHashKey(prompt.id, prompt.version);
// Only compute and store if not already present
if (!index[hashKey]) {
const content = reconstructContentFromPieces(
prompt.pieces,
prompt.identifiers,
prompt.identifierMap
);
const hash = computeMD5Hash(content);
index[hashKey] = hash;
newHashCount++;
}
}
// Write back to disk
await writeHashIndex(index);
return newHashCount;
};
/**
* Gets the hash for a specific prompt version from the index
* Returns undefined if not found
*/
export const getPromptHash = async (
promptId: string,
version: string
): Promise<string | undefined> => {
const index = await readHashIndex();
const hashKey = getHashKey(promptId, version);
return index[hashKey];
};
/**
* Checks if a markdown file's content (excluding frontmatter) matches the expected hash
*
* @param markdownContent - The full content of a markdown file with frontmatter
* @param expectedHash - The expected MD5 hash
* @returns true if the content matches the hash (unmodified), false otherwise
*/
export const verifyMarkdownHash = (
markdownContent: string,
expectedHash: string
): boolean => {
// Extract content after frontmatter
const frontmatterMatch = markdownContent.match(
/^---\n[\s\S]+?\n---\n([\s\S]*)$/
);
if (!frontmatterMatch) {
// No frontmatter found - hash the entire content
const hash = computeMD5Hash(markdownContent.trim());
return hash === expectedHash;
}
// Hash only the content part (after frontmatter), trimmed
const contentOnly = frontmatterMatch[1].trim();
const hash = computeMD5Hash(contentOnly);
return hash === expectedHash;
};
/**
* Structure of the applied hashes file
* Maps: "prompt-id" => "md5hash" | null
* Example: "main-system-prompt" => "a1b2c3..." or null if not applied/defaults restored
*/
export interface AppliedHashIndex {
[promptId: string]: string | null;
}
/**
* Reads the applied hashes index from disk. Returns empty object if file doesn't exist.
*/
export const readAppliedHashIndex = async (): Promise<AppliedHashIndex> => {
try {
const content = await fs.readFile(APPLIED_HASHES_PATH, 'utf8');
return JSON.parse(content);
} catch (error) {
if (error instanceof Error && 'code' in error && error.code === 'ENOENT') {
return {};
}
throw error;
}
};
/**
* Writes the applied hashes index to disk
*/
export const writeAppliedHashIndex = async (
index: AppliedHashIndex
): Promise<void> => {
// Ensure config directory exists
await fs.mkdir(CONFIG_DIR, { recursive: true });
// Sort keys for consistent formatting
const sortedIndex: AppliedHashIndex = {};
const sortedKeys = Object.keys(index).sort();
for (const key of sortedKeys) {
sortedIndex[key] = index[key];
}
await fs.writeFile(
APPLIED_HASHES_PATH,
JSON.stringify(sortedIndex, null, 2),
'utf8'
);
};
/**
* Updates the applied hash for a specific prompt ID
*/
export const setAppliedHash = async (
promptId: string,
hash: string
): Promise<void> => {
const index = await readAppliedHashIndex();
index[promptId] = hash;
await writeAppliedHashIndex(index);
};
/**
* Sets all applied hashes to null (used when restoring defaults)
*/
export const clearAllAppliedHashes = async (): Promise<void> => {
const index = await readAppliedHashIndex();
const clearedIndex: AppliedHashIndex = {};
// Set all existing entries to null
for (const key of Object.keys(index)) {
clearedIndex[key] = null;
}
await writeAppliedHashIndex(clearedIndex);
};
/**
* Gets the applied hash for a specific prompt ID
* Returns undefined if not found, null if explicitly set to null
*/
export const getAppliedHash = async (
promptId: string
): Promise<string | null | undefined> => {
const index = await readAppliedHashIndex();
return index[promptId];
};
/**
* Checks if any system prompts have been modified since they were last applied.
* Compares the current hash of each prompt file with the hash stored in systemPromptAppliedHashes.json.
*
* @param systemPromptsDir - Path to the system prompts directory
* @returns true if any prompts have been modified, false otherwise
*/
export const hasUnappliedSystemPromptChanges = async (
systemPromptsDir: string
): Promise<boolean> => {
try {
const appliedHashes = await readAppliedHashIndex();
// If there are no applied hashes yet, nothing has been applied so no changes to track
if (Object.keys(appliedHashes).length === 0) {
return false;
}
// Read all .md files in the system prompts directory
const files = await fs.readdir(systemPromptsDir);
const mdFiles = files.filter(f => f.endsWith('.md'));
for (const file of mdFiles) {
const promptId = file.replace('.md', '');
const appliedHash = appliedHashes[promptId];
// If this prompt doesn't have an applied hash entry, skip it
if (appliedHash === undefined) {
continue;
}
// If the applied hash is null (restored to defaults), skip comparison
if (appliedHash === null) {
continue;
}
// Read the current file and compute its hash
const filePath = path.join(systemPromptsDir, file);
const fileContent = await fs.readFile(filePath, 'utf8');
// Parse the markdown to extract just the content (excluding frontmatter)
const matter = await import('gray-matter');
const parsed = matter.default(fileContent, {
delimiters: ['<!--', '-->'],
});
const currentHash = computeMD5Hash(parsed.content);
// If the current hash doesn't match the applied hash, changes have been made
if (currentHash !== appliedHash) {
return true;
}
}
return false;
} catch (error) {
// If we can't read the directory or files, assume no changes
if (error instanceof Error && 'code' in error && error.code === 'ENOENT') {
return false;
}
throw error;
}
};
+3 -1
View File
@@ -135,13 +135,14 @@ export interface StartupCheckInfo {
}
export enum MainMenuItem {
APPLY_CHANGES = '*Apply customizations to cli.js',
THEMES = 'Themes',
LAUNCH_TEXT = 'Launch text',
THINKING_VERBS = 'Thinking verbs',
THINKING_STYLE = 'Thinking style',
USER_MESSAGE_DISPLAY = 'User message display',
INPUT_BOX = 'Input box',
APPLY_CHANGES = '*Apply customizations to cli.js',
VIEW_SYSTEM_PROMPTS = 'View system prompts',
RESTORE_ORIGINAL = 'Restore original Claude Code (preserves tweakcc.json)',
OPEN_CONFIG = 'Open tweakcc.json',
OPEN_CLI = "Open Claude Code's cli.js",
@@ -839,6 +840,7 @@ export const DEFAULT_SETTINGS: Settings = {
export const CONFIG_DIR = path.join(os.homedir(), '.tweakcc');
export const CONFIG_FILE = path.join(CONFIG_DIR, 'config.json');
export const CLIJS_BACKUP_FILE = path.join(CONFIG_DIR, 'cli.js.backup');
export const SYSTEM_PROMPTS_DIR = path.join(CONFIG_DIR, 'system-prompts');
export interface SearchPathInfo {
pattern: string;
+530
View File
@@ -0,0 +1,530 @@
{
"name": "prompt-extractor",
"version": "0.0.0",
"lockfileVersion": 3,
"requires": true,
"packages": {
"": {
"name": "prompt-extractor",
"version": "0.0.0",
"license": "ISC",
"dependencies": {
"@anthropic-ai/sdk": "^0.32.1",
"@babel/parser": "^7.26.3",
"ollama": "^0.6.0",
"openai": "^4.73.0"
}
},
"node_modules/@anthropic-ai/sdk": {
"version": "0.32.1",
"resolved": "https://registry.npmjs.org/@anthropic-ai/sdk/-/sdk-0.32.1.tgz",
"integrity": "sha512-U9JwTrDvdQ9iWuABVsMLj8nJVwAyQz6QXvgLsVhryhCEPkLsbcP/MXxm+jYcAwLoV8ESbaTTjnD4kuAFa+Hyjg==",
"license": "MIT",
"dependencies": {
"@types/node": "^18.11.18",
"@types/node-fetch": "^2.6.4",
"abort-controller": "^3.0.0",
"agentkeepalive": "^4.2.1",
"form-data-encoder": "1.7.2",
"formdata-node": "^4.3.2",
"node-fetch": "^2.6.7"
}
},
"node_modules/@babel/helper-string-parser": {
"version": "7.27.1",
"resolved": "https://registry.npmjs.org/@babel/helper-string-parser/-/helper-string-parser-7.27.1.tgz",
"integrity": "sha512-qMlSxKbpRlAridDExk92nSobyDdpPijUq2DW6oDnUqd0iOGxmQjyqhMIihI9+zv4LPyZdRje2cavWPbCbWm3eA==",
"license": "MIT",
"engines": {
"node": ">=6.9.0"
}
},
"node_modules/@babel/helper-validator-identifier": {
"version": "7.27.1",
"resolved": "https://registry.npmjs.org/@babel/helper-validator-identifier/-/helper-validator-identifier-7.27.1.tgz",
"integrity": "sha512-D2hP9eA+Sqx1kBZgzxZh0y1trbuU+JoDkiEwqhQ36nodYqJwyEIhPSdMNd7lOm/4io72luTPWH20Yda0xOuUow==",
"license": "MIT",
"engines": {
"node": ">=6.9.0"
}
},
"node_modules/@babel/parser": {
"version": "7.28.4",
"resolved": "https://registry.npmjs.org/@babel/parser/-/parser-7.28.4.tgz",
"integrity": "sha512-yZbBqeM6TkpP9du/I2pUZnJsRMGGvOuIrhjzC1AwHwW+6he4mni6Bp/m8ijn0iOuZuPI2BfkCoSRunpyjnrQKg==",
"license": "MIT",
"dependencies": {
"@babel/types": "^7.28.4"
},
"bin": {
"parser": "bin/babel-parser.js"
},
"engines": {
"node": ">=6.0.0"
}
},
"node_modules/@babel/types": {
"version": "7.28.4",
"resolved": "https://registry.npmjs.org/@babel/types/-/types-7.28.4.tgz",
"integrity": "sha512-bkFqkLhh3pMBUQQkpVgWDWq/lqzc2678eUyDlTBhRqhCHFguYYGM0Efga7tYk4TogG/3x0EEl66/OQ+WGbWB/Q==",
"license": "MIT",
"dependencies": {
"@babel/helper-string-parser": "^7.27.1",
"@babel/helper-validator-identifier": "^7.27.1"
},
"engines": {
"node": ">=6.9.0"
}
},
"node_modules/@types/node": {
"version": "18.19.130",
"resolved": "https://registry.npmjs.org/@types/node/-/node-18.19.130.tgz",
"integrity": "sha512-GRaXQx6jGfL8sKfaIDD6OupbIHBr9jv7Jnaml9tB7l4v068PAOXqfcujMMo5PhbIs6ggR1XODELqahT2R8v0fg==",
"license": "MIT",
"dependencies": {
"undici-types": "~5.26.4"
}
},
"node_modules/@types/node-fetch": {
"version": "2.6.13",
"resolved": "https://registry.npmjs.org/@types/node-fetch/-/node-fetch-2.6.13.tgz",
"integrity": "sha512-QGpRVpzSaUs30JBSGPjOg4Uveu384erbHBoT1zeONvyCfwQxIkUshLAOqN/k9EjGviPRmWTTe6aH2qySWKTVSw==",
"license": "MIT",
"dependencies": {
"@types/node": "*",
"form-data": "^4.0.4"
}
},
"node_modules/abort-controller": {
"version": "3.0.0",
"resolved": "https://registry.npmjs.org/abort-controller/-/abort-controller-3.0.0.tgz",
"integrity": "sha512-h8lQ8tacZYnR3vNQTgibj+tODHI5/+l06Au2Pcriv/Gmet0eaj4TwWH41sO9wnHDiQsEj19q0drzdWdeAHtweg==",
"license": "MIT",
"dependencies": {
"event-target-shim": "^5.0.0"
},
"engines": {
"node": ">=6.5"
}
},
"node_modules/agentkeepalive": {
"version": "4.6.0",
"resolved": "https://registry.npmjs.org/agentkeepalive/-/agentkeepalive-4.6.0.tgz",
"integrity": "sha512-kja8j7PjmncONqaTsB8fQ+wE2mSU2DJ9D4XKoJ5PFWIdRMa6SLSN1ff4mOr4jCbfRSsxR4keIiySJU0N9T5hIQ==",
"license": "MIT",
"dependencies": {
"humanize-ms": "^1.2.1"
},
"engines": {
"node": ">= 8.0.0"
}
},
"node_modules/asynckit": {
"version": "0.4.0",
"resolved": "https://registry.npmjs.org/asynckit/-/asynckit-0.4.0.tgz",
"integrity": "sha512-Oei9OH4tRh0YqU3GxhX79dM/mwVgvbZJaSNaRk+bshkj0S5cfHcgYakreBjrHwatXKbz+IoIdYLxrKim2MjW0Q==",
"license": "MIT"
},
"node_modules/call-bind-apply-helpers": {
"version": "1.0.2",
"resolved": "https://registry.npmjs.org/call-bind-apply-helpers/-/call-bind-apply-helpers-1.0.2.tgz",
"integrity": "sha512-Sp1ablJ0ivDkSzjcaJdxEunN5/XvksFJ2sMBFfq6x0ryhQV/2b/KwFe21cMpmHtPOSij8K99/wSfoEuTObmuMQ==",
"license": "MIT",
"dependencies": {
"es-errors": "^1.3.0",
"function-bind": "^1.1.2"
},
"engines": {
"node": ">= 0.4"
}
},
"node_modules/combined-stream": {
"version": "1.0.8",
"resolved": "https://registry.npmjs.org/combined-stream/-/combined-stream-1.0.8.tgz",
"integrity": "sha512-FQN4MRfuJeHf7cBbBMJFXhKSDq+2kAArBlmRBvcvFE5BB1HZKXtSFASDhdlz9zOYwxh8lDdnvmMOe/+5cdoEdg==",
"license": "MIT",
"dependencies": {
"delayed-stream": "~1.0.0"
},
"engines": {
"node": ">= 0.8"
}
},
"node_modules/delayed-stream": {
"version": "1.0.0",
"resolved": "https://registry.npmjs.org/delayed-stream/-/delayed-stream-1.0.0.tgz",
"integrity": "sha512-ZySD7Nf91aLB0RxL4KGrKHBXl7Eds1DAmEdcoVawXnLD7SDhpNgtuII2aAkg7a7QS41jxPSZ17p4VdGnMHk3MQ==",
"license": "MIT",
"engines": {
"node": ">=0.4.0"
}
},
"node_modules/dunder-proto": {
"version": "1.0.1",
"resolved": "https://registry.npmjs.org/dunder-proto/-/dunder-proto-1.0.1.tgz",
"integrity": "sha512-KIN/nDJBQRcXw0MLVhZE9iQHmG68qAVIBg9CqmUYjmQIhgij9U5MFvrqkUL5FbtyyzZuOeOt0zdeRe4UY7ct+A==",
"license": "MIT",
"dependencies": {
"call-bind-apply-helpers": "^1.0.1",
"es-errors": "^1.3.0",
"gopd": "^1.2.0"
},
"engines": {
"node": ">= 0.4"
}
},
"node_modules/es-define-property": {
"version": "1.0.1",
"resolved": "https://registry.npmjs.org/es-define-property/-/es-define-property-1.0.1.tgz",
"integrity": "sha512-e3nRfgfUZ4rNGL232gUgX06QNyyez04KdjFrF+LTRoOXmrOgFKDg4BCdsjW8EnT69eqdYGmRpJwiPVYNrCaW3g==",
"license": "MIT",
"engines": {
"node": ">= 0.4"
}
},
"node_modules/es-errors": {
"version": "1.3.0",
"resolved": "https://registry.npmjs.org/es-errors/-/es-errors-1.3.0.tgz",
"integrity": "sha512-Zf5H2Kxt2xjTvbJvP2ZWLEICxA6j+hAmMzIlypy4xcBg1vKVnx89Wy0GbS+kf5cwCVFFzdCFh2XSCFNULS6csw==",
"license": "MIT",
"engines": {
"node": ">= 0.4"
}
},
"node_modules/es-object-atoms": {
"version": "1.1.1",
"resolved": "https://registry.npmjs.org/es-object-atoms/-/es-object-atoms-1.1.1.tgz",
"integrity": "sha512-FGgH2h8zKNim9ljj7dankFPcICIK9Cp5bm+c2gQSYePhpaG5+esrLODihIorn+Pe6FGJzWhXQotPv73jTaldXA==",
"license": "MIT",
"dependencies": {
"es-errors": "^1.3.0"
},
"engines": {
"node": ">= 0.4"
}
},
"node_modules/es-set-tostringtag": {
"version": "2.1.0",
"resolved": "https://registry.npmjs.org/es-set-tostringtag/-/es-set-tostringtag-2.1.0.tgz",
"integrity": "sha512-j6vWzfrGVfyXxge+O0x5sh6cvxAog0a/4Rdd2K36zCMV5eJ+/+tOAngRO8cODMNWbVRdVlmGZQL2YS3yR8bIUA==",
"license": "MIT",
"dependencies": {
"es-errors": "^1.3.0",
"get-intrinsic": "^1.2.6",
"has-tostringtag": "^1.0.2",
"hasown": "^2.0.2"
},
"engines": {
"node": ">= 0.4"
}
},
"node_modules/event-target-shim": {
"version": "5.0.1",
"resolved": "https://registry.npmjs.org/event-target-shim/-/event-target-shim-5.0.1.tgz",
"integrity": "sha512-i/2XbnSz/uxRCU6+NdVJgKWDTM427+MqYbkQzD321DuCQJUqOuJKIA0IM2+W2xtYHdKOmZ4dR6fExsd4SXL+WQ==",
"license": "MIT",
"engines": {
"node": ">=6"
}
},
"node_modules/form-data": {
"version": "4.0.4",
"resolved": "https://registry.npmjs.org/form-data/-/form-data-4.0.4.tgz",
"integrity": "sha512-KrGhL9Q4zjj0kiUt5OO4Mr/A/jlI2jDYs5eHBpYHPcBEVSiipAvn2Ko2HnPe20rmcuuvMHNdZFp+4IlGTMF0Ow==",
"license": "MIT",
"dependencies": {
"asynckit": "^0.4.0",
"combined-stream": "^1.0.8",
"es-set-tostringtag": "^2.1.0",
"hasown": "^2.0.2",
"mime-types": "^2.1.12"
},
"engines": {
"node": ">= 6"
}
},
"node_modules/form-data-encoder": {
"version": "1.7.2",
"resolved": "https://registry.npmjs.org/form-data-encoder/-/form-data-encoder-1.7.2.tgz",
"integrity": "sha512-qfqtYan3rxrnCk1VYaA4H+Ms9xdpPqvLZa6xmMgFvhO32x7/3J/ExcTd6qpxM0vH2GdMI+poehyBZvqfMTto8A==",
"license": "MIT"
},
"node_modules/formdata-node": {
"version": "4.4.1",
"resolved": "https://registry.npmjs.org/formdata-node/-/formdata-node-4.4.1.tgz",
"integrity": "sha512-0iirZp3uVDjVGt9p49aTaqjk84TrglENEDuqfdlZQ1roC9CWlPk6Avf8EEnZNcAqPonwkG35x4n3ww/1THYAeQ==",
"license": "MIT",
"dependencies": {
"node-domexception": "1.0.0",
"web-streams-polyfill": "4.0.0-beta.3"
},
"engines": {
"node": ">= 12.20"
}
},
"node_modules/function-bind": {
"version": "1.1.2",
"resolved": "https://registry.npmjs.org/function-bind/-/function-bind-1.1.2.tgz",
"integrity": "sha512-7XHNxH7qX9xG5mIwxkhumTox/MIRNcOgDrxWsMt2pAr23WHp6MrRlN7FBSFpCpr+oVO0F744iUgR82nJMfG2SA==",
"license": "MIT",
"funding": {
"url": "https://github.com/sponsors/ljharb"
}
},
"node_modules/get-intrinsic": {
"version": "1.3.0",
"resolved": "https://registry.npmjs.org/get-intrinsic/-/get-intrinsic-1.3.0.tgz",
"integrity": "sha512-9fSjSaos/fRIVIp+xSJlE6lfwhES7LNtKaCBIamHsjr2na1BiABJPo0mOjjz8GJDURarmCPGqaiVg5mfjb98CQ==",
"license": "MIT",
"dependencies": {
"call-bind-apply-helpers": "^1.0.2",
"es-define-property": "^1.0.1",
"es-errors": "^1.3.0",
"es-object-atoms": "^1.1.1",
"function-bind": "^1.1.2",
"get-proto": "^1.0.1",
"gopd": "^1.2.0",
"has-symbols": "^1.1.0",
"hasown": "^2.0.2",
"math-intrinsics": "^1.1.0"
},
"engines": {
"node": ">= 0.4"
},
"funding": {
"url": "https://github.com/sponsors/ljharb"
}
},
"node_modules/get-proto": {
"version": "1.0.1",
"resolved": "https://registry.npmjs.org/get-proto/-/get-proto-1.0.1.tgz",
"integrity": "sha512-sTSfBjoXBp89JvIKIefqw7U2CCebsc74kiY6awiGogKtoSGbgjYE/G/+l9sF3MWFPNc9IcoOC4ODfKHfxFmp0g==",
"license": "MIT",
"dependencies": {
"dunder-proto": "^1.0.1",
"es-object-atoms": "^1.0.0"
},
"engines": {
"node": ">= 0.4"
}
},
"node_modules/gopd": {
"version": "1.2.0",
"resolved": "https://registry.npmjs.org/gopd/-/gopd-1.2.0.tgz",
"integrity": "sha512-ZUKRh6/kUFoAiTAtTYPZJ3hw9wNxx+BIBOijnlG9PnrJsCcSjs1wyyD6vJpaYtgnzDrKYRSqf3OO6Rfa93xsRg==",
"license": "MIT",
"engines": {
"node": ">= 0.4"
},
"funding": {
"url": "https://github.com/sponsors/ljharb"
}
},
"node_modules/has-symbols": {
"version": "1.1.0",
"resolved": "https://registry.npmjs.org/has-symbols/-/has-symbols-1.1.0.tgz",
"integrity": "sha512-1cDNdwJ2Jaohmb3sg4OmKaMBwuC48sYni5HUw2DvsC8LjGTLK9h+eb1X6RyuOHe4hT0ULCW68iomhjUoKUqlPQ==",
"license": "MIT",
"engines": {
"node": ">= 0.4"
},
"funding": {
"url": "https://github.com/sponsors/ljharb"
}
},
"node_modules/has-tostringtag": {
"version": "1.0.2",
"resolved": "https://registry.npmjs.org/has-tostringtag/-/has-tostringtag-1.0.2.tgz",
"integrity": "sha512-NqADB8VjPFLM2V0VvHUewwwsw0ZWBaIdgo+ieHtK3hasLz4qeCRjYcqfB6AQrBggRKppKF8L52/VqdVsO47Dlw==",
"license": "MIT",
"dependencies": {
"has-symbols": "^1.0.3"
},
"engines": {
"node": ">= 0.4"
},
"funding": {
"url": "https://github.com/sponsors/ljharb"
}
},
"node_modules/hasown": {
"version": "2.0.2",
"resolved": "https://registry.npmjs.org/hasown/-/hasown-2.0.2.tgz",
"integrity": "sha512-0hJU9SCPvmMzIBdZFqNPXWa6dqh7WdH0cII9y+CyS8rG3nL48Bclra9HmKhVVUHyPWNH5Y7xDwAB7bfgSjkUMQ==",
"license": "MIT",
"dependencies": {
"function-bind": "^1.1.2"
},
"engines": {
"node": ">= 0.4"
}
},
"node_modules/humanize-ms": {
"version": "1.2.1",
"resolved": "https://registry.npmjs.org/humanize-ms/-/humanize-ms-1.2.1.tgz",
"integrity": "sha512-Fl70vYtsAFb/C06PTS9dZBo7ihau+Tu/DNCk/OyHhea07S+aeMWpFFkUaXRa8fI+ScZbEI8dfSxwY7gxZ9SAVQ==",
"license": "MIT",
"dependencies": {
"ms": "^2.0.0"
}
},
"node_modules/math-intrinsics": {
"version": "1.1.0",
"resolved": "https://registry.npmjs.org/math-intrinsics/-/math-intrinsics-1.1.0.tgz",
"integrity": "sha512-/IXtbwEk5HTPyEwyKX6hGkYXxM9nbj64B+ilVJnC/R6B0pH5G4V3b0pVbL7DBj4tkhBAppbQUlf6F6Xl9LHu1g==",
"license": "MIT",
"engines": {
"node": ">= 0.4"
}
},
"node_modules/mime-db": {
"version": "1.52.0",
"resolved": "https://registry.npmjs.org/mime-db/-/mime-db-1.52.0.tgz",
"integrity": "sha512-sPU4uV7dYlvtWJxwwxHD0PuihVNiE7TyAbQ5SWxDCB9mUYvOgroQOwYQQOKPJ8CIbE+1ETVlOoK1UC2nU3gYvg==",
"license": "MIT",
"engines": {
"node": ">= 0.6"
}
},
"node_modules/mime-types": {
"version": "2.1.35",
"resolved": "https://registry.npmjs.org/mime-types/-/mime-types-2.1.35.tgz",
"integrity": "sha512-ZDY+bPm5zTTF+YpCrAU9nK0UgICYPT0QtT1NZWFv4s++TNkcgVaT0g6+4R2uI4MjQjzysHB1zxuWL50hzaeXiw==",
"license": "MIT",
"dependencies": {
"mime-db": "1.52.0"
},
"engines": {
"node": ">= 0.6"
}
},
"node_modules/ms": {
"version": "2.1.3",
"resolved": "https://registry.npmjs.org/ms/-/ms-2.1.3.tgz",
"integrity": "sha512-6FlzubTLZG3J2a/NVCAleEhjzq5oxgHyaCU9yYXvcLsvoVaHJq/s5xXI6/XXP6tz7R9xAOtHnSO/tXtF3WRTlA==",
"license": "MIT"
},
"node_modules/node-domexception": {
"version": "1.0.0",
"resolved": "https://registry.npmjs.org/node-domexception/-/node-domexception-1.0.0.tgz",
"integrity": "sha512-/jKZoMpw0F8GRwl4/eLROPA3cfcXtLApP0QzLmUT/HuPCZWyB7IY9ZrMeKw2O/nFIqPQB3PVM9aYm0F312AXDQ==",
"deprecated": "Use your platform's native DOMException instead",
"funding": [
{
"type": "github",
"url": "https://github.com/sponsors/jimmywarting"
},
{
"type": "github",
"url": "https://paypal.me/jimmywarting"
}
],
"license": "MIT",
"engines": {
"node": ">=10.5.0"
}
},
"node_modules/node-fetch": {
"version": "2.7.0",
"resolved": "https://registry.npmjs.org/node-fetch/-/node-fetch-2.7.0.tgz",
"integrity": "sha512-c4FRfUm/dbcWZ7U+1Wq0AwCyFL+3nt2bEw05wfxSz+DWpWsitgmSgYmy2dQdWyKC1694ELPqMs/YzUSNozLt8A==",
"license": "MIT",
"dependencies": {
"whatwg-url": "^5.0.0"
},
"engines": {
"node": "4.x || >=6.0.0"
},
"peerDependencies": {
"encoding": "^0.1.0"
},
"peerDependenciesMeta": {
"encoding": {
"optional": true
}
}
},
"node_modules/ollama": {
"version": "0.6.0",
"resolved": "https://registry.npmjs.org/ollama/-/ollama-0.6.0.tgz",
"integrity": "sha512-FHjdU2Ok5x2HZsxPui/MBJZ5J+HzmxoWYa/p9wk736eT+uAhS8nvIICar5YgwlG5MFNjDR6UA5F3RSKq+JseOA==",
"license": "MIT",
"dependencies": {
"whatwg-fetch": "^3.6.20"
}
},
"node_modules/openai": {
"version": "4.104.0",
"resolved": "https://registry.npmjs.org/openai/-/openai-4.104.0.tgz",
"integrity": "sha512-p99EFNsA/yX6UhVO93f5kJsDRLAg+CTA2RBqdHK4RtK8u5IJw32Hyb2dTGKbnnFmnuoBv5r7Z2CURI9sGZpSuA==",
"license": "Apache-2.0",
"dependencies": {
"@types/node": "^18.11.18",
"@types/node-fetch": "^2.6.4",
"abort-controller": "^3.0.0",
"agentkeepalive": "^4.2.1",
"form-data-encoder": "1.7.2",
"formdata-node": "^4.3.2",
"node-fetch": "^2.6.7"
},
"bin": {
"openai": "bin/cli"
},
"peerDependencies": {
"ws": "^8.18.0",
"zod": "^3.23.8"
},
"peerDependenciesMeta": {
"ws": {
"optional": true
},
"zod": {
"optional": true
}
}
},
"node_modules/tr46": {
"version": "0.0.3",
"resolved": "https://registry.npmjs.org/tr46/-/tr46-0.0.3.tgz",
"integrity": "sha512-N3WMsuqV66lT30CrXNbEjx4GEwlow3v6rr4mCcv6prnfwhS01rkgyFdjPNBYd9br7LpXV1+Emh01fHnq2Gdgrw==",
"license": "MIT"
},
"node_modules/undici-types": {
"version": "5.26.5",
"resolved": "https://registry.npmjs.org/undici-types/-/undici-types-5.26.5.tgz",
"integrity": "sha512-JlCMO+ehdEIKqlFxk6IfVoAUVmgz7cU7zD/h9XZ0qzeosSHmUJVOzSQvvYSYWXkFXC+IfLKSIffhv0sVZup6pA==",
"license": "MIT"
},
"node_modules/web-streams-polyfill": {
"version": "4.0.0-beta.3",
"resolved": "https://registry.npmjs.org/web-streams-polyfill/-/web-streams-polyfill-4.0.0-beta.3.tgz",
"integrity": "sha512-QW95TCTaHmsYfHDybGMwO5IJIM93I/6vTRk+daHTWFPhwh+C8Cg7j7XyKrwrj8Ib6vYXe0ocYNrmzY4xAAN6ug==",
"license": "MIT",
"engines": {
"node": ">= 14"
}
},
"node_modules/webidl-conversions": {
"version": "3.0.1",
"resolved": "https://registry.npmjs.org/webidl-conversions/-/webidl-conversions-3.0.1.tgz",
"integrity": "sha512-2JAn3z8AR6rjK8Sm8orRC0h/bcl/DqL7tRPdGZ4I1CjdF+EaMLmYxBHyXuKL849eucPFhvBoxMsflfOb8kxaeQ==",
"license": "BSD-2-Clause"
},
"node_modules/whatwg-fetch": {
"version": "3.6.20",
"resolved": "https://registry.npmjs.org/whatwg-fetch/-/whatwg-fetch-3.6.20.tgz",
"integrity": "sha512-EqhiFU6daOA8kpjOWTL0olhVOF3i7OrFzSYiGsEMB8GcXS+RrzauAERX65xMeNWVqxA6HXH2m69Z9LaKKdisfg==",
"license": "MIT"
},
"node_modules/whatwg-url": {
"version": "5.0.0",
"resolved": "https://registry.npmjs.org/whatwg-url/-/whatwg-url-5.0.0.tgz",
"integrity": "sha512-saE57nupxk6v3HY35+jzBwYa0rKSy0XR8JSxZPwgLr7ys0IBzhGviA1/TUGJLmSVqs8pb9AnvICXEuOHLprYTw==",
"license": "MIT",
"dependencies": {
"tr46": "~0.0.3",
"webidl-conversions": "^3.0.0"
}
}
}
}
+17
View File
@@ -0,0 +1,17 @@
{
"name": "prompt-extractor",
"version": "0.0.0",
"description": "Extract AI system prompts from minified JavaScript",
"scripts": {
"test": "echo \"Error: no test specified\" && exit 1"
},
"keywords": [],
"author": "",
"license": "ISC",
"dependencies": {
"@anthropic-ai/sdk": "^0.32.1",
"@babel/parser": "^7.26.3",
"ollama": "^0.6.0",
"openai": "^4.73.0"
}
}
+468
View File
@@ -0,0 +1,468 @@
#!/usr/bin/env node
const fs = require('fs');
const parser = require('@babel/parser');
function slugify(text) {
return text
.toLowerCase()
.trim()
.replace(/[^\w\s-]/g, '')
.replace(/[\s_-]+/g, '-')
.replace(/^-+|-+$/g, '');
}
function validateInput(text, minLength = 500) {
if (!text || typeof text !== 'string') return false;
if (text.length < minLength) return false;
const first10 = text.substring(0, 10);
if (first10.startsWith('AGFzbQ') || /^[A-Z0-9+/=]{10}$/.test(first10)) {
return false;
}
const sample = text.substring(0, 500);
const words = sample.split(/\s+/).filter(w => w.length > 0);
if (words.length === 0) return false;
const uppercaseWords = words.filter(
w => w === w.toUpperCase() && /[A-Z]/.test(w)
);
const uppercaseRatio = uppercaseWords.length / words.length;
if (uppercaseRatio > 0.6) {
return false;
}
const lowerText = text.toLowerCase();
const hasYou = lowerText.includes('you');
const hasAI = lowerText.includes('ai') || lowerText.includes('assistant');
const hasInstruct =
lowerText.includes('must') ||
lowerText.includes('should') ||
lowerText.includes('always');
if (!hasYou && !hasAI && !hasInstruct) {
return false;
}
const sentencePattern = /[.!?]\s+[A-Z\(]/;
const hasSentences = sentencePattern.test(text);
if (!hasSentences) {
return false;
}
const avgWordLength =
words.reduce((sum, w) => sum + w.length, 0) / words.length;
if (avgWordLength > 15) {
return false;
}
const spaceCount = (sample.match(/\s/g) || []).length;
const spaceRatio = spaceCount / sample.length;
if (spaceRatio < 0.1) {
return false;
}
return true;
}
function extractStrings(filepath, minLength = 500) {
const code = fs.readFileSync(filepath, 'utf-8');
const ast = parser.parse(code, {
sourceType: 'module',
plugins: ['jsx', 'typescript'],
});
const stringData = [];
const traverse = node => {
if (!node || typeof node !== 'object') return;
// Extract string literals
if (node.type === 'StringLiteral') {
if (validateInput(node.value, minLength)) {
stringData.push({
name: '',
id: '',
description: '',
pieces: [node.value],
identifiers: [],
identifierMap: {},
start: node.start,
end: node.end,
});
}
}
// Extract template literals
if (node.type === 'TemplateLiteral') {
const { expressions } = node;
// Extract the entire template content directly from source (excluding backticks)
const contentStart = node.start + 1; // After opening backtick
const contentEnd = node.end - 1; // Before closing backtick
const fullContent = code.substring(contentStart, contentEnd);
// Validate before processing
if (!validateInput(fullContent, minLength)) {
return;
}
// Collect all identifiers with their positions
const allIdentifiers = []; // Array of {name, start, end} sorted by position
for (let i = 0; i < expressions.length; i++) {
const expr = expressions[i];
const traverseExpr = (exprNode, isTopLevel = true) => {
if (!exprNode || typeof exprNode !== 'object') return;
if (exprNode.type === 'Identifier' && isTopLevel) {
allIdentifiers.push({
name: exprNode.name,
start: exprNode.start - contentStart,
end: exprNode.end - contentStart,
});
}
if (exprNode.type === 'CallExpression') {
traverseExpr(exprNode.callee, true);
if (exprNode.arguments) {
exprNode.arguments.forEach(arg => traverseExpr(arg, true));
}
return;
}
if (exprNode.type === 'MemberExpression') {
traverseExpr(exprNode.object, true);
return;
}
if (exprNode.type === 'TemplateLiteral') {
if (exprNode.expressions) {
exprNode.expressions.forEach(nestedExpr =>
traverseExpr(nestedExpr, true)
);
}
return;
}
if (exprNode.type === 'ObjectExpression') {
if (exprNode.properties) {
exprNode.properties.forEach(prop => {
if (prop.value) {
traverseExpr(prop.value, false);
}
});
}
return;
}
for (const key in exprNode) {
if (key === 'loc' || key === 'start' || key === 'end') continue;
const value = exprNode[key];
if (Array.isArray(value)) {
value.forEach(v => traverseExpr(v, true));
} else if (value && typeof value === 'object') {
traverseExpr(value, true);
}
}
};
traverseExpr(expr, true);
}
// Sort identifiers by position
allIdentifiers.sort((a, b) => a.start - b.start);
// Build pieces array by splitting around identifiers, keeping ${ and }
const pieces = [];
const identifierList = [];
const identifierMap = {};
let lastPos = 0;
for (const id of allIdentifiers) {
// Find the ${ before this identifier (search backwards from id.start)
let beforeIdentifier = fullContent.substring(lastPos, id.start);
// Find the } after this identifier (search forwards from id.end)
// We need to find the matching closing brace for the interpolation
let afterIdentifierStart = id.end;
// Add the piece including everything up to and including just before the identifier
pieces.push(beforeIdentifier);
// Add identifier to the list
identifierList.push(id.name);
// Add to map if not already there
if (!identifierMap[id.name]) {
identifierMap[id.name] = '';
}
lastPos = id.end;
}
// Add the final piece after the last identifier
pieces.push(fullContent.substring(lastPos));
// Label encode the identifiers
const uniqueVars = [...new Set(identifierList)];
const varToLabel = {};
uniqueVars.forEach((varName, idx) => {
varToLabel[varName] = idx;
});
const labelEncodedIdentifiers = identifierList.map(
varName => varToLabel[varName]
);
const labelEncodedMap = {};
Object.keys(varToLabel).forEach(varName => {
labelEncodedMap[varToLabel[varName]] = '';
});
stringData.push({
name: '',
id: '',
description: '',
pieces,
identifiers: labelEncodedIdentifiers,
identifierMap: labelEncodedMap,
start: node.start,
end: node.end,
});
}
// Recursively traverse
for (const key in node) {
if (key === 'loc' || key === 'start' || key === 'end') continue;
const value = node[key];
if (Array.isArray(value)) {
value.forEach(traverse);
} else if (value && typeof value === 'object') {
traverse(value);
}
}
};
traverse(ast);
// Filter out strings that are subsets of other strings
// Step 1: Sort by start index (ascending), then by end index (descending)
// This puts earliest strings first, and among strings with same start, longest first
stringData.sort((a, b) => {
if (a.start !== b.start) {
return a.start - b.start;
}
return b.end - a.end;
});
// Step 2: Track seen ranges and filter out subsets
const seenRanges = [];
const filteredData = [];
for (const item of stringData) {
const isSubset = seenRanges.some(
range => item.start >= range.start && item.end <= range.end
);
if (!isSubset) {
filteredData.push(item);
seenRanges.push({ start: item.start, end: item.end });
}
}
return { prompts: filteredData };
}
function mergeWithExisting(newData, oldData, currentVersion) {
if (!oldData || !oldData.prompts) {
// No old data, add current version to all new prompts
return {
prompts: newData.prompts.map(item => ({
...item,
version: currentVersion,
})),
};
}
// Helper to reconstruct content from pieces and identifiers
const reconstructContent = item => {
return item.pieces.join(''); // Don't actually insert the vairables.
};
const newPrompts = newData.prompts.map((newItem, idx) => {
const newContent = reconstructContent(newItem);
// Try to find a matching old item by content and label-encoded identifiers
const matchingOld = oldData.prompts.find(oldItem => {
const oldContent = reconstructContent(oldItem);
if (newContent !== oldContent) return false;
// Also compare label-encoded identifiers
if (newItem.identifiers.length !== oldItem.identifiers.length)
return false;
return (
JSON.stringify(newItem.identifiers) ===
JSON.stringify(oldItem.identifiers)
);
});
// If we found a match, copy over the metadata
if (matchingOld) {
// Prompt matches exactly
// If old prompt has no version, use current version; otherwise use old version
return {
...newItem,
name: matchingOld.name,
id: matchingOld.id || slugify(matchingOld.name),
description: matchingOld.description,
identifierMap: matchingOld.identifierMap,
version: matchingOld.version || currentVersion,
};
}
// No exact match found - check if there's a prompt with same metadata but different content
const similarOld = oldData.prompts.find(oldItem => {
// Check if names match (not placeholder) as a heuristic for "same prompt, different content"
return oldItem.name !== '' && oldItem.name === newItem.name;
});
if (similarOld && similarOld.version) {
// Old prompt exists with a version and content changed - use current version
console.log(
`Content changed for "${newItem.name}", updating version from ${similarOld.version} to ${currentVersion}`
);
return {
...newItem,
id: similarOld.id || slugify(similarOld.name),
version: currentVersion,
};
}
// Check if there's any old prompt without a version (we should add current version)
const oldWithoutVersion = oldData.prompts.find(oldItem => !oldItem.version);
// New prompt or old prompt didn't have version - add current version
console.log(
`No match for item ${idx}: ${JSON.stringify(newContent.slice(0, 100))}`
);
console.log();
return {
...newItem,
id: slugify(newItem.name),
version: currentVersion,
};
});
return { prompts: newPrompts };
}
// CLI
if (require.main === module) {
const filepath = process.argv[2];
if (!filepath) {
console.error(
'Usage: node promptExtractor.cjs <path-to-cli.js> [output-file]'
);
process.exit(1);
}
const outputFile = process.argv[3] || 'prompts.json';
// Try to read existing output file
let existingData = null;
if (fs.existsSync(outputFile)) {
try {
const existingContent = fs.readFileSync(outputFile, 'utf-8');
existingData = JSON.parse(existingContent);
console.log(
`Found existing output file with ${existingData.prompts?.length || 0} prompts`
);
} catch (err) {
console.warn(
`Warning: Could not parse existing output file: ${err.message}`
);
}
}
// Look for package.json alongside the input file
const path = require('path');
const inputDir = path.dirname(path.resolve(filepath));
const packageJsonPath = path.join(inputDir, 'package.json');
let version = null;
if (fs.existsSync(packageJsonPath)) {
try {
const packageJson = JSON.parse(fs.readFileSync(packageJsonPath, 'utf-8'));
version = packageJson.version;
console.log(`Found package.json with version ${version}`);
} catch (err) {
console.warn(`Warning: Could not parse package.json: ${err.message}`);
}
}
// Helper functions to replace version strings with placeholder
const replaceVersionInString = (str, versionStr) => {
if (!versionStr) return str;
// Escape dots for regex
const escapedVersion = versionStr.replace(/\./g, '\\.');
// Replace version with placeholder
return str.replace(new RegExp(escapedVersion, 'g'), '<<CCVERSION>>');
};
const replaceVersionInPrompts = (data, versionStr) => {
if (!versionStr) return data;
return {
...data,
prompts: data.prompts.map(prompt => ({
...prompt,
pieces: prompt.pieces.map(piece =>
replaceVersionInString(piece, versionStr)
),
})),
};
};
const result = extractStrings(filepath);
// Replace version in newly extracted strings BEFORE merging
const versionReplacedResult = replaceVersionInPrompts(result, version);
const mergedResult = mergeWithExisting(
versionReplacedResult,
existingData,
version
);
// Sort prompts by lexicographic order of pieces joined together (without interpolated vars)
mergedResult.prompts.sort((a, b) => {
const contentA = a.pieces.join('');
const contentB = b.pieces.join('');
return contentA.localeCompare(contentB);
});
// Remove start/end fields before writing
mergedResult.prompts = mergedResult.prompts.map(({ start, end, ...rest }) => rest);
// Add version as top-level field
const outputData = {
version,
...mergedResult,
};
fs.writeFileSync(outputFile, JSON.stringify(outputData, null, 2));
console.log(`Extracted ${mergedResult.prompts.length} strings`);
console.log(`Written to ${outputFile}`);
}
module.exports = extractStrings;