@sanqianx/claude-ai-workbench 0.11.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (90) hide show
  1. package/LICENSE +201 -0
  2. package/README.md +129 -0
  3. package/apps/agent-terminal/app.css +567 -0
  4. package/apps/agent-terminal/app.js +2392 -0
  5. package/apps/agent-terminal/index.html +158 -0
  6. package/apps/ai-profile-settings-host/index.html +25 -0
  7. package/apps/ai-profile-settings-host/page.css +1 -0
  8. package/apps/ai-profile-settings-host/page.js +113 -0
  9. package/apps/minimal-test-host/README.md +15 -0
  10. package/apps/minimal-test-host/host.css +2 -0
  11. package/apps/minimal-test-host/host.js +35 -0
  12. package/apps/minimal-test-host/index.html +58 -0
  13. package/apps/minimal-test-host/optional-mock/fault-injection.js +15 -0
  14. package/docs/sdk/FILE_LAYOUT.md +31 -0
  15. package/docs/sdk/INTEGRATION.md +126 -0
  16. package/docs/sdk/README.md +21 -0
  17. package/docs/sdk/openapi.yaml +760 -0
  18. package/package.json +51 -0
  19. package/packages/client/README.md +11 -0
  20. package/packages/client/index.d.ts +81 -0
  21. package/packages/client/index.js +198 -0
  22. package/packages/client/package.json +29 -0
  23. package/packages/contracts/README.md +9 -0
  24. package/packages/contracts/index.d.ts +252 -0
  25. package/packages/contracts/index.js +295 -0
  26. package/packages/contracts/package.json +24 -0
  27. package/packages/core/README.md +8 -0
  28. package/packages/core/index.d.ts +76 -0
  29. package/packages/core/index.js +214 -0
  30. package/packages/core/package.json +29 -0
  31. package/packages/server/README.md +17 -0
  32. package/packages/server/bin/agent-terminal-server.js +36 -0
  33. package/packages/server/bin/claude-workbench-server.js +46 -0
  34. package/packages/server/bin/claude-workbench.js +225 -0
  35. package/packages/server/index.d.ts +145 -0
  36. package/packages/server/index.js +183 -0
  37. package/packages/server/lib/agent-discovery.js +112 -0
  38. package/packages/server/lib/agent-registry.js +98 -0
  39. package/packages/server/lib/attachment-store.js +78 -0
  40. package/packages/server/lib/bridges/app-server-bridge.js +372 -0
  41. package/packages/server/lib/bridges/opencode-server-bridge.js +490 -0
  42. package/packages/server/lib/capture-settings-store.js +34 -0
  43. package/packages/server/lib/context-resolver.js +40 -0
  44. package/packages/server/lib/drivers/cli-json-runner.js +960 -0
  45. package/packages/server/lib/drivers/codex-driver.js +1413 -0
  46. package/packages/server/lib/drivers/opencode-driver.js +1527 -0
  47. package/packages/server/lib/drivers/zcode-driver.js +1167 -0
  48. package/packages/server/lib/event-stream.js +35 -0
  49. package/packages/server/lib/folder-picker.js +623 -0
  50. package/packages/server/lib/http-handler.js +254 -0
  51. package/packages/server/lib/json-file.js +19 -0
  52. package/packages/server/lib/model-detection.js +295 -0
  53. package/packages/server/lib/profile-store.js +149 -0
  54. package/packages/server/lib/project-store.js +102 -0
  55. package/packages/server/lib/session-archive-store.js +53 -0
  56. package/packages/server/lib/system-doctor.js +119 -0
  57. package/packages/server/lib/ui-preferences-store.js +48 -0
  58. package/packages/server/lib/workbench-service.js +620 -0
  59. package/packages/server/package.json +34 -0
  60. package/packages/ui/README.md +30 -0
  61. package/packages/ui/ai-profile-settings.d.ts +45 -0
  62. package/packages/ui/ai-profile-settings.js +283 -0
  63. package/packages/ui/claude-terminal.d.ts +31 -0
  64. package/packages/ui/claude-terminal.js +343 -0
  65. package/packages/ui/index.d.ts +18 -0
  66. package/packages/ui/index.js +51 -0
  67. package/packages/ui/package.json +42 -0
  68. package/packages/ui/theme.css +13 -0
  69. package/src/assets/claude-prompts.json +21 -0
  70. package/src/backend/http/ai-profile-routes.js +61 -0
  71. package/src/backend/http/claude-routes.js +170 -0
  72. package/src/backend/http/node-helpers.js +45 -0
  73. package/src/backend/index.js +21 -0
  74. package/src/backend/lib/ai-adapter.js +144 -0
  75. package/src/backend/lib/ai-profile-store.js +259 -0
  76. package/src/backend/lib/ai-vendor-presets.js +83 -0
  77. package/src/backend/lib/ai-workspace.js +140 -0
  78. package/src/backend/lib/automation-config.js +253 -0
  79. package/src/backend/lib/claude-cli-runner.js +1749 -0
  80. package/src/backend/lib/data-dir.js +169 -0
  81. package/src/backend/lib/llm-client.js +176 -0
  82. package/src/backend/lib/model-context-windows.js +82 -0
  83. package/src/backend/lib/prompt-registry.js +166 -0
  84. package/src/backend/lib/runtime-config.js +121 -0
  85. package/src/index.js +7 -0
  86. package/src/sdk-wave-a.ts +1 -0
  87. package/src/sdk-wave-b.ts +1 -0
  88. package/src/sdk-wave-c.ts +1 -0
  89. package/src/sdk-wave-d.ts +1 -0
  90. package/src/sdk-wave-e.ts +1 -0
@@ -0,0 +1,169 @@
1
+ // _site/lib/data-dir.js
2
+ //
3
+ // Resolves a stable, version-independent data directory for project-knowledge
4
+ // runtime state. Runtime data lives OUTSIDE the npm install directory so that
5
+ // `npm install -g project-knowledge` upgrades do not destroy user config
6
+ // (project registry, AI profiles with API keys, generated knowledge bases,
7
+ // logs, AI workspaces).
8
+ //
9
+ // Resolution order (highest priority first):
10
+ // 1. KB_DATA_DIR env var — used by tests and power users who want a custom
11
+ // location. Set to "." to use the current working directory.
12
+ // 2. <os.homedir()>/.project-knowledge — the default user-specific location.
13
+ //
14
+ // On first run after upgrade from a 1.x install, the legacy runtime files
15
+ // (which lived inside the npm package directory) are silently migrated into
16
+ // the new data dir. After that, all updates keep the data dir untouched and
17
+ // data survives every future `npm install -g project-knowledge`.
18
+
19
+ const fs = require('fs');
20
+ const path = require('path');
21
+ const { resolveDataDir } = require('./runtime-config');
22
+
23
+ let _resolved = null;
24
+
25
+ function getDataDir() {
26
+ const dataDir = resolveDataDir();
27
+ if (_resolved === dataDir) return _resolved;
28
+ try {
29
+ fs.mkdirSync(dataDir, { recursive: true });
30
+ } catch (err) {
31
+ // Best-effort. Callers will surface a write error when they actually
32
+ // try to write inside the dir; we don't want to crash here just because
33
+ // the user's homedir is read-only or the path is bogus.
34
+ }
35
+ _resolved = dataDir;
36
+ return dataDir;
37
+ }
38
+
39
+ // Reset the cached resolution. Tests use this to switch KB_DATA_DIR between
40
+ // cases without re-requiring the module.
41
+ function _resetCache() {
42
+ _resolved = null;
43
+ }
44
+
45
+ function hasMigrated() {
46
+ return fs.existsSync(path.join(getDataDir(), 'projects.json'));
47
+ }
48
+
49
+ // Legacy 1.x locations — these lived inside the npm package root. The
50
+ // package root for an installed package is the directory that contains
51
+ // _site/server.js, _site/lib/, package.json, etc.
52
+ const LEGACY_FILE_PATHS = [
53
+ 'projects.json',
54
+ 'ai-profiles.json',
55
+ 'knowledge-store.json',
56
+ 'logging.json',
57
+ '.jobs-log.json',
58
+ 'claude-prompts.json',
59
+ '.hook-trigger-errors.log',
60
+ ];
61
+
62
+ const LEGACY_DIR_PATHS = [
63
+ 'projects', // generated KB trees
64
+ 'logs', // structured logs
65
+ ];
66
+
67
+ const LEGACY_AI_DIR = path.join('_site', '_ai'); // per-project AI workspaces lived under _site/_ai/
68
+
69
+ function copyDir(src, dest) {
70
+ fs.mkdirSync(dest, { recursive: true });
71
+ for (const entry of fs.readdirSync(src, { withFileTypes: true })) {
72
+ const from = path.join(src, entry.name);
73
+ const to = path.join(dest, entry.name);
74
+ if (entry.isDirectory()) copyDir(from, to);
75
+ else if (entry.isFile()) fs.copyFileSync(from, to);
76
+ }
77
+ }
78
+
79
+ function migrateFromLegacy({ legacyRoot, logger } = {}) {
80
+ // Test escape hatch. Set KB_SKIP_MIGRATION=1 to run against a clean data
81
+ // dir without pulling the user's real registry in from the package root.
82
+ if (process.env.KB_SKIP_MIGRATION === '1') {
83
+ return { ok: true, migrated: false, reason: 'skipped via KB_SKIP_MIGRATION=1' };
84
+ }
85
+ if (!legacyRoot) {
86
+ return { ok: true, migrated: false, reason: 'no legacy root provided' };
87
+ }
88
+ if (!fs.existsSync(legacyRoot)) {
89
+ return { ok: true, migrated: false, reason: 'legacy root does not exist' };
90
+ }
91
+ const dataDir = getDataDir();
92
+ // Safety check: if the legacy root equals the data dir, there's nothing
93
+ // to migrate (we'd be copying onto ourselves).
94
+ if (path.resolve(legacyRoot) === path.resolve(dataDir)) {
95
+ return { ok: true, migrated: false, reason: 'legacy root equals data dir' };
96
+ }
97
+ if (hasMigrated()) {
98
+ return { ok: true, migrated: false, reason: 'already migrated' };
99
+ }
100
+
101
+ const result = {
102
+ ok: true,
103
+ migrated: false,
104
+ files: 0,
105
+ dirs: 0,
106
+ source: legacyRoot,
107
+ target: dataDir,
108
+ };
109
+
110
+ for (const rel of LEGACY_FILE_PATHS) {
111
+ const from = path.join(legacyRoot, rel);
112
+ const to = path.join(dataDir, rel);
113
+ if (fs.existsSync(from) && !fs.existsSync(to)) {
114
+ try {
115
+ fs.mkdirSync(path.dirname(to), { recursive: true });
116
+ fs.copyFileSync(from, to);
117
+ result.files++;
118
+ } catch (err) {
119
+ result.ok = false;
120
+ result.error = `failed to migrate ${rel}: ${err.message}`;
121
+ return result;
122
+ }
123
+ }
124
+ }
125
+ for (const rel of LEGACY_DIR_PATHS) {
126
+ const from = path.join(legacyRoot, rel);
127
+ const to = path.join(dataDir, rel);
128
+ if (fs.existsSync(from) && !fs.existsSync(to)) {
129
+ try {
130
+ copyDir(from, to);
131
+ result.dirs++;
132
+ } catch (err) {
133
+ result.ok = false;
134
+ result.error = `failed to migrate ${rel}/: ${err.message}`;
135
+ return result;
136
+ }
137
+ }
138
+ }
139
+ const aiFrom = path.join(legacyRoot, LEGACY_AI_DIR);
140
+ const aiTo = path.join(dataDir, '_ai');
141
+ if (fs.existsSync(aiFrom) && !fs.existsSync(aiTo)) {
142
+ try {
143
+ copyDir(aiFrom, aiTo);
144
+ result.dirs++;
145
+ } catch (err) {
146
+ result.ok = false;
147
+ result.error = `failed to migrate _site/_ai/: ${err.message}`;
148
+ return result;
149
+ }
150
+ }
151
+
152
+ if (result.files > 0 || result.dirs > 0) {
153
+ result.migrated = true;
154
+ if (logger) {
155
+ logger(`migrated runtime data to ${dataDir} (${result.files} files, ${result.dirs} dirs from ${legacyRoot})`);
156
+ }
157
+ }
158
+ return result;
159
+ }
160
+
161
+ module.exports = {
162
+ getDataDir,
163
+ hasMigrated,
164
+ migrateFromLegacy,
165
+ LEGACY_FILE_PATHS,
166
+ LEGACY_DIR_PATHS,
167
+ LEGACY_AI_DIR,
168
+ _resetCache,
169
+ };
@@ -0,0 +1,176 @@
1
+ // _site/lib/llm-client.js
2
+ //
3
+ // Tiny Anthropic Messages API client. Zero npm deps; supports HTTPS and HTTP
4
+ // Anthropic-compatible endpoints. Connection settings are resolved from an AI
5
+ // profile first, then environment variables as fallback:
6
+ //
7
+ // profile.baseUrl or ANTHROPIC_BASE_URL
8
+ // profile.apiKey or ANTHROPIC_AUTH_TOKEN
9
+ // profile.mainModel or ANTHROPIC_MODEL
10
+ // profile.timeoutMs or API_TIMEOUT_MS
11
+ //
12
+ // This lets the local UI persist model settings in ai-profiles.json while still
13
+ // keeping env vars useful for scripts and temporary overrides.
14
+
15
+ const fs = require('fs');
16
+ const path = require('path');
17
+ const http = require('http');
18
+ const https = require('https');
19
+ const { URL } = require('url');
20
+
21
+ const DEFAULT_BASE = 'https://api.anthropic.com';
22
+ const DEFAULT_VERSION = '2023-06-01';
23
+ const DEFAULT_MODEL = 'claude-haiku-4-5';
24
+ const { getDataDir } = require('./data-dir');
25
+
26
+ function aiProfilesPath() {
27
+ return path.join(getDataDir(), 'ai-profiles.json');
28
+ }
29
+
30
+ function readProfilesFile() {
31
+ try {
32
+ const cfg = JSON.parse(fs.readFileSync(aiProfilesPath(), 'utf-8'));
33
+ return Array.isArray(cfg.profiles) ? cfg.profiles : [];
34
+ } catch {
35
+ return [];
36
+ }
37
+ }
38
+
39
+ function findProfile(profileId) {
40
+ if (!profileId) return null;
41
+ return readProfilesFile().find(profile => profile && profile.id === profileId) || null;
42
+ }
43
+
44
+ function readConfig(options = {}) {
45
+ const profileId = typeof options === 'string' ? options : options.profileId;
46
+ const inlineProfile = options && typeof options === 'object' && options.profile && typeof options.profile === 'object'
47
+ ? options.profile
48
+ : null;
49
+ const profile = inlineProfile || findProfile(profileId) || {};
50
+ const timeoutMs = Number(profile.timeoutMs || process.env.API_TIMEOUT_MS || 60_000);
51
+ return {
52
+ profileId: profile.id || profileId || null,
53
+ baseUrl: profile.baseUrl || profile.apiBaseUrl || profile.anthropicBaseUrl || process.env.ANTHROPIC_BASE_URL || DEFAULT_BASE,
54
+ apiKey: profile.apiKey || profile.authToken || profile.anthropicAuthToken || process.env.ANTHROPIC_AUTH_TOKEN || '',
55
+ model: profile.mainModel || profile.model || process.env.ANTHROPIC_MODEL || DEFAULT_MODEL,
56
+ version: DEFAULT_VERSION,
57
+ timeoutMs: Number.isFinite(timeoutMs) && timeoutMs > 0 ? timeoutMs : 60_000,
58
+ };
59
+ }
60
+
61
+ function buildRequestPath(base, p) {
62
+ if (!p.startsWith('/')) return p;
63
+ const basePath = base.pathname.endsWith('/') ? base.pathname.slice(0, -1) : base.pathname;
64
+ return basePath + p;
65
+ }
66
+
67
+ function postJson({ baseUrl, path: p, body, apiKey, version, timeoutMs = 60_000 }) {
68
+ return new Promise((resolve, reject) => {
69
+ try {
70
+ const base = new URL(baseUrl);
71
+ const transport = base.protocol === 'http:' ? http : https;
72
+ const data = JSON.stringify(body);
73
+ const req = transport.request({
74
+ host: base.hostname,
75
+ port: base.port || (base.protocol === 'http:' ? 80 : 443),
76
+ path: buildRequestPath(base, p),
77
+ method: 'POST',
78
+ headers: {
79
+ 'Content-Type': 'application/json',
80
+ 'x-api-key': apiKey,
81
+ 'anthropic-version': version,
82
+ 'Content-Length': Buffer.byteLength(data),
83
+ },
84
+ timeout: timeoutMs,
85
+ }, res => {
86
+ const chunks = [];
87
+ res.on('data', c => chunks.push(c));
88
+ res.on('end', () => {
89
+ const text = Buffer.concat(chunks).toString('utf-8');
90
+ if (res.statusCode < 200 || res.statusCode >= 300) {
91
+ return reject(new Error(`HTTP ${res.statusCode}: ${text.slice(0, 500)}`));
92
+ }
93
+ try { resolve(JSON.parse(text)); }
94
+ catch (e) { reject(new Error(`bad JSON: ${e.message}; body head: ${text.slice(0, 200)}`)); }
95
+ });
96
+ });
97
+ req.on('error', reject);
98
+ req.on('timeout', () => req.destroy(new Error(`timeout after ${timeoutMs}ms`)));
99
+ req.write(data);
100
+ req.end();
101
+ } catch (e) {
102
+ reject(new Error(`bad baseUrl: ${baseUrl}: ${e.message}`));
103
+ }
104
+ });
105
+ }
106
+
107
+ async function completeText({ system, user, maxTokens = 512, model, profileId, profile }) {
108
+ const cfg = readConfig({ profileId, profile });
109
+ if (!cfg.apiKey) {
110
+ const id = profileId ? ` for profile ${profileId}` : '';
111
+ throw new Error(`API key not set${id}`);
112
+ }
113
+ const body = {
114
+ model: model || cfg.model,
115
+ max_tokens: maxTokens,
116
+ messages: [{ role: 'user', content: user }],
117
+ };
118
+ if (system) body.system = system;
119
+
120
+ const raw = await postJson({
121
+ baseUrl: cfg.baseUrl,
122
+ path: '/v1/messages',
123
+ body,
124
+ apiKey: cfg.apiKey,
125
+ version: cfg.version,
126
+ timeoutMs: cfg.timeoutMs,
127
+ });
128
+
129
+ const text = (raw.content || [])
130
+ .filter(block => block && block.type === 'text' && typeof block.text === 'string')
131
+ .map(block => block.text)
132
+ .join('\n')
133
+ .trim();
134
+
135
+ return {
136
+ text,
137
+ raw,
138
+ usage: raw.usage || null,
139
+ model: raw.model || body.model,
140
+ profileId: cfg.profileId,
141
+ };
142
+ }
143
+
144
+ async function completeJson({ system, user, schema, maxTokens = 2048, model, profileId, profile }) {
145
+ const schemaHint = schema ? `\n\nThe output MUST be a JSON object matching this schema:\n${schema}\n` : '';
146
+ const finalUser = `${user}${schemaHint}\n\nRespond with ONLY the JSON object, no prose, no markdown fences.`;
147
+ const result = await completeText({ system, user: finalUser, maxTokens, model, profileId, profile });
148
+ const text = result.text;
149
+
150
+ let parsed = null;
151
+ let parseError = null;
152
+ const candidates = [
153
+ text,
154
+ text.replace(/^```(?:json)?\s*/i, '').replace(/```\s*$/, ''),
155
+ ];
156
+ for (const candidate of candidates) {
157
+ if (!candidate) continue;
158
+ try {
159
+ parsed = JSON.parse(candidate);
160
+ parseError = null;
161
+ break;
162
+ } catch (e) {
163
+ parseError = e;
164
+ }
165
+ }
166
+
167
+ return { ...result, parsed, parseError };
168
+ }
169
+
170
+ module.exports = {
171
+ completeJson,
172
+ completeText,
173
+ readConfig,
174
+ findProfile,
175
+ aiProfilesPath,
176
+ };
@@ -0,0 +1,82 @@
1
+ // Per-family context window sizes (in tokens) for common models.
2
+ // Match strategy: lowercase the model name, then prefix-match — first hit wins.
3
+ // Entry order matters: more specific prefixes (e.g. 'claude-opus-4') precede
4
+ // broader ones (e.g. 'claude'). Keep specific entries above generic ones.
5
+ const MODEL_CONTEXT_WINDOWS = [
6
+ // Anthropic Claude — full family 200K
7
+ ['claude-opus-4', 200000],
8
+ ['claude-sonnet-4', 200000],
9
+ ['claude-haiku-4', 200000],
10
+ ['claude-3-7', 200000],
11
+ ['claude-3-5', 200000],
12
+ ['claude-3', 200000],
13
+ ['claude', 200000],
14
+
15
+ // MiniMax
16
+ ['minimax-m3', 1048576],
17
+ ['minimax-m2', 1048576],
18
+ ['minimax', 1048576],
19
+
20
+ // Zhipu GLM
21
+ ['glm-5', 1048576],
22
+ ['glm-4.6', 1048576],
23
+ ['glm-4.5', 128000],
24
+ ['glm-4-plus', 128000],
25
+ ['glm-4', 128000],
26
+ ['glm', 128000],
27
+
28
+ // DeepSeek
29
+ ['deepseek-v3', 128000],
30
+ ['deepseek-r1', 128000],
31
+ ['deepseek', 64000],
32
+
33
+ // OpenAI
34
+ ['gpt-5', 200000],
35
+ ['gpt-4.1', 1047576],
36
+ ['gpt-4o', 128000],
37
+ ['gpt-4-turbo', 128000],
38
+ ['gpt-4', 8192],
39
+ ['o3', 200000],
40
+ ['o4-mini', 200000],
41
+ ['o1', 200000],
42
+
43
+ // Qwen
44
+ ['qwen3', 131072],
45
+ ['qwen-max', 32768],
46
+ ['qwen', 32768],
47
+
48
+ // Google Gemini
49
+ ['gemini-2.5', 1048576],
50
+ ['gemini-1.5', 1048576],
51
+ ['gemini', 32768],
52
+ ];
53
+
54
+ function normalizeModelName(model) {
55
+ return String(model || '').trim().toLowerCase();
56
+ }
57
+
58
+ function lookupContextWindow(modelName) {
59
+ const name = normalizeModelName(modelName);
60
+ if (!name) return null;
61
+ for (const [prefix, size] of MODEL_CONTEXT_WINDOWS) {
62
+ if (name.startsWith(prefix)) return size;
63
+ }
64
+ return null;
65
+ }
66
+
67
+ // Resolution order: explicit profile override → model table → env var → fallback.
68
+ function resolveContextWindow({ profileContextWindow, model, env = process.env, fallback = 200000 } = {}) {
69
+ const explicit = Number(profileContextWindow);
70
+ if (Number.isFinite(explicit) && explicit > 0) return Math.floor(explicit);
71
+ const tableHit = lookupContextWindow(model);
72
+ if (tableHit) return tableHit;
73
+ const envValue = Number(env && env.CLAUDE_CONTEXT_WINDOW);
74
+ if (Number.isFinite(envValue) && envValue > 0) return Math.floor(envValue);
75
+ return fallback;
76
+ }
77
+
78
+ module.exports = {
79
+ MODEL_CONTEXT_WINDOWS,
80
+ lookupContextWindow,
81
+ resolveContextWindow,
82
+ };
@@ -0,0 +1,166 @@
1
+ // Prompt Registry — reads/writes claude-prompts.json and renders templated prompts.
2
+ //
3
+ // File location: <KB_ROOT>/claude-prompts.json
4
+ // Schema: { schema: 'claude-prompts/v1', prompts: { <key>: { description, model, permissionMode,
5
+ // allowedTools, systemPrompt, userPrompt } } }
6
+ //
7
+ // Variable interpolation: {{KEY}} placeholders in systemPrompt/userPrompt are replaced
8
+ // with values from the `vars` argument. Unknown placeholders are left untouched (so
9
+ // incomplete prompts don't crash — they show the raw token for diagnosis).
10
+
11
+ const fs = require('fs');
12
+ const path = require('path');
13
+
14
+ const { getDataDir } = require('./data-dir');
15
+ const { getWorkbenchConfig } = require('./runtime-config');
16
+ const SCHEMA = 'claude-prompts/v1';
17
+
18
+ const PLACEHOLDER_RE = /\{\{\s*([A-Z_][A-Z0-9_]*)\s*\}\}/g;
19
+
20
+ function normalizePromptsConfig(cfg) {
21
+ const normalized = cfg && typeof cfg === 'object' ? cfg : {};
22
+ if (!normalized.prompts || typeof normalized.prompts !== 'object') normalized.prompts = {};
23
+ if (!normalized.schema) normalized.schema = SCHEMA;
24
+ return normalized;
25
+ }
26
+
27
+ function promptsPath() {
28
+ const cfg = getWorkbenchConfig();
29
+ return path.join(getDataDir(), cfg.promptsFileName || 'claude-prompts.json');
30
+ }
31
+
32
+ function bundledPromptsPath() {
33
+ const cfg = getWorkbenchConfig();
34
+ return cfg.bundledPromptsPath || path.resolve(__dirname, '..', '..', 'assets', 'claude-prompts.json');
35
+ }
36
+
37
+ function readBundledPrompts() {
38
+ const file = bundledPromptsPath();
39
+ if (!fs.existsSync(file)) return { schema: SCHEMA, prompts: {} };
40
+ try {
41
+ return normalizePromptsConfig(JSON.parse(fs.readFileSync(file, 'utf-8')));
42
+ } catch {
43
+ return { schema: SCHEMA, prompts: {} };
44
+ }
45
+ }
46
+
47
+ function persistPrompts(cfg) {
48
+ const file = promptsPath();
49
+ fs.mkdirSync(path.dirname(file), { recursive: true });
50
+ fs.writeFileSync(file, JSON.stringify(cfg, null, 2) + '\n', 'utf-8');
51
+ }
52
+
53
+ function withBundledDefaults(cfg) {
54
+ const current = normalizePromptsConfig(cfg);
55
+ const bundled = readBundledPrompts();
56
+ let changed = false;
57
+ const prompts = { ...current.prompts };
58
+ for (const [key, prompt] of Object.entries(bundled.prompts || {})) {
59
+ if (!Object.prototype.hasOwnProperty.call(prompts, key)) {
60
+ prompts[key] = prompt;
61
+ changed = true;
62
+ }
63
+ }
64
+ return {
65
+ cfg: {
66
+ schema: current.schema || bundled.schema || SCHEMA,
67
+ prompts,
68
+ },
69
+ changed,
70
+ };
71
+ }
72
+
73
+ function readPrompts() {
74
+ const file = promptsPath();
75
+ if (!fs.existsSync(file)) {
76
+ const seeded = withBundledDefaults({ schema: SCHEMA, prompts: {} }).cfg;
77
+ if (Object.keys(seeded.prompts).length) {
78
+ persistPrompts(seeded);
79
+ }
80
+ return seeded;
81
+ }
82
+ try {
83
+ const result = withBundledDefaults(JSON.parse(fs.readFileSync(file, 'utf-8')));
84
+ if (result.changed) persistPrompts(result.cfg);
85
+ return result.cfg;
86
+ } catch (e) {
87
+ throw new Error(`failed to parse ${file}: ${e.message}`);
88
+ }
89
+ }
90
+
91
+ function writePrompts(cfg) {
92
+ if (!cfg || typeof cfg !== 'object') throw new Error('prompts config must be an object');
93
+ if (cfg.prompts && typeof cfg.prompts !== 'object') throw new Error('prompts field must be an object');
94
+ const normalized = {
95
+ schema: cfg.schema || SCHEMA,
96
+ prompts: cfg.prompts || {},
97
+ };
98
+ for (const [key, val] of Object.entries(normalized.prompts)) {
99
+ if (!key || typeof key !== 'string') throw new Error(`invalid prompt key: ${key}`);
100
+ if (!val || typeof val !== 'object') throw new Error(`prompt "${key}" must be an object`);
101
+ if (typeof val.userPrompt !== 'string' || !val.userPrompt) {
102
+ throw new Error(`prompt "${key}" must have a non-empty userPrompt`);
103
+ }
104
+ }
105
+ persistPrompts(normalized);
106
+ return normalized;
107
+ }
108
+
109
+ function listPromptKeys() {
110
+ return Object.keys(readPrompts().prompts);
111
+ }
112
+
113
+ function getPrompt(key) {
114
+ const cfg = readPrompts();
115
+ const p = cfg.prompts[key];
116
+ if (!p) return null;
117
+ return p;
118
+ }
119
+
120
+ function interpolate(text, vars) {
121
+ if (typeof text !== 'string') return text;
122
+ if (!vars || typeof vars !== 'object') return text;
123
+ return text.replace(PLACEHOLDER_RE, (_, name) => {
124
+ if (Object.prototype.hasOwnProperty.call(vars, name)) {
125
+ const v = vars[name];
126
+ return v == null ? '' : String(v);
127
+ }
128
+ return `{{${name}}}`;
129
+ });
130
+ }
131
+
132
+ // Render the prompt for a given key, returning all fields needed to spawn claude.
133
+ // Returns null if key doesn't exist.
134
+ function renderPrompt(key, vars) {
135
+ const p = getPrompt(key);
136
+ if (!p) return null;
137
+ const safeVars = vars || {};
138
+ return {
139
+ key,
140
+ description: p.description || '',
141
+ model: p.model || 'sonnet',
142
+ permissionMode: p.permissionMode || 'bypassPermissions',
143
+ allowedTools: Array.isArray(p.allowedTools) ? p.allowedTools.slice() : ['Read', 'Grep', 'Glob'],
144
+ systemPrompt: interpolate(p.systemPrompt || '', safeVars),
145
+ userPrompt: interpolate(p.userPrompt, safeVars),
146
+ };
147
+ }
148
+
149
+ const exported = {
150
+ promptsPath,
151
+ bundledPromptsPath,
152
+ SCHEMA,
153
+ readPrompts,
154
+ writePrompts,
155
+ listPromptKeys,
156
+ getPrompt,
157
+ renderPrompt,
158
+ interpolate,
159
+ };
160
+
161
+ Object.defineProperties(exported, {
162
+ PROMPTS_PATH: { enumerable: true, get: promptsPath },
163
+ BUNDLED_PROMPTS_PATH: { enumerable: true, get: bundledPromptsPath },
164
+ });
165
+
166
+ module.exports = exported;