@reconcrap/boss-recommend-mcp 1.3.38 → 2.0.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 (85) hide show
  1. package/README.md +53 -33
  2. package/package.json +61 -9
  3. package/skills/boss-recommend-pipeline/SKILL.md +4 -0
  4. package/src/chat-mcp.js +1333 -0
  5. package/src/chat-runtime-config.js +559 -0
  6. package/src/cli.js +1095 -196
  7. package/src/core/browser/index.js +378 -0
  8. package/src/core/capture/index.js +298 -0
  9. package/src/core/cv-acquisition/index.js +219 -0
  10. package/src/core/greet-quota/index.js +54 -0
  11. package/src/core/infinite-list/index.js +459 -0
  12. package/src/core/reporting/legacy-csv.js +332 -0
  13. package/src/core/run/index.js +286 -0
  14. package/src/core/screening/index.js +1166 -0
  15. package/src/core/self-heal/index.js +848 -0
  16. package/src/domains/chat/cards.js +129 -0
  17. package/src/domains/chat/constants.js +183 -0
  18. package/src/domains/chat/detail.js +1369 -0
  19. package/src/domains/chat/index.js +7 -0
  20. package/src/domains/chat/jobs.js +334 -0
  21. package/src/domains/chat/page-guard.js +88 -0
  22. package/src/domains/chat/roots.js +56 -0
  23. package/src/domains/chat/run-service.js +1101 -0
  24. package/src/domains/recommend/actions.js +457 -0
  25. package/src/domains/recommend/cards.js +228 -0
  26. package/src/domains/recommend/constants.js +141 -0
  27. package/src/domains/recommend/detail.js +341 -0
  28. package/src/domains/recommend/filters.js +581 -0
  29. package/src/domains/recommend/index.js +10 -0
  30. package/src/domains/recommend/jobs.js +232 -0
  31. package/src/domains/recommend/refresh.js +204 -0
  32. package/src/domains/recommend/roots.js +78 -0
  33. package/src/domains/recommend/run-service.js +903 -0
  34. package/src/domains/recommend/scopes.js +245 -0
  35. package/src/domains/recruit/actions.js +277 -0
  36. package/src/domains/recruit/cards.js +67 -0
  37. package/src/domains/recruit/constants.js +130 -0
  38. package/src/domains/recruit/detail.js +414 -0
  39. package/src/domains/recruit/index.js +9 -0
  40. package/src/domains/recruit/instruction-parser.js +451 -0
  41. package/src/domains/recruit/refresh.js +40 -0
  42. package/src/domains/recruit/roots.js +68 -0
  43. package/src/domains/recruit/run-service.js +580 -0
  44. package/src/domains/recruit/search.js +1149 -0
  45. package/src/index.js +578 -419
  46. package/src/recommend-mcp.js +1257 -0
  47. package/src/recruit-mcp.js +1035 -0
  48. package/src/adapters.js +0 -3079
  49. package/src/boss-chat.js +0 -1037
  50. package/src/pipeline.js +0 -2249
  51. package/src/recommend-healing-config.js +0 -131
  52. package/src/recommend-healing-rules.json +0 -261
  53. package/src/self-heal.js +0 -2237
  54. package/src/test-adapters-runtime.js +0 -628
  55. package/src/test-boss-chat.js +0 -3196
  56. package/src/test-index-async.js +0 -498
  57. package/src/test-parser.js +0 -742
  58. package/src/test-pipeline.js +0 -2703
  59. package/src/test-run-state.js +0 -152
  60. package/src/test-self-heal.js +0 -224
  61. package/vendor/boss-chat-cli/README.md +0 -134
  62. package/vendor/boss-chat-cli/package.json +0 -53
  63. package/vendor/boss-chat-cli/src/app.js +0 -1501
  64. package/vendor/boss-chat-cli/src/browser/chat-page.js +0 -3562
  65. package/vendor/boss-chat-cli/src/cli.js +0 -1713
  66. package/vendor/boss-chat-cli/src/mcp/server.js +0 -149
  67. package/vendor/boss-chat-cli/src/mcp/tool-runtime.js +0 -193
  68. package/vendor/boss-chat-cli/src/runtime/async-run-state.js +0 -260
  69. package/vendor/boss-chat-cli/src/runtime/interaction.js +0 -102
  70. package/vendor/boss-chat-cli/src/runtime/run-control.js +0 -102
  71. package/vendor/boss-chat-cli/src/services/chrome-client.js +0 -107
  72. package/vendor/boss-chat-cli/src/services/llm.js +0 -1292
  73. package/vendor/boss-chat-cli/src/services/llm.test.js +0 -326
  74. package/vendor/boss-chat-cli/src/services/profile-store.js +0 -173
  75. package/vendor/boss-chat-cli/src/services/report-store.js +0 -317
  76. package/vendor/boss-chat-cli/src/services/resume-capture.js +0 -469
  77. package/vendor/boss-chat-cli/src/services/resume-network.js +0 -727
  78. package/vendor/boss-chat-cli/src/services/state-store.js +0 -90
  79. package/vendor/boss-chat-cli/src/utils/customer-key.js +0 -82
  80. package/vendor/boss-recommend-screen-cli/boss-recommend-screen-cli.cjs +0 -6927
  81. package/vendor/boss-recommend-screen-cli/scripts/capture-full-resume-canvas.cjs +0 -817
  82. package/vendor/boss-recommend-screen-cli/scripts/stitch_resume_chunks.py +0 -141
  83. package/vendor/boss-recommend-screen-cli/test-recoverable-resume-failures.cjs +0 -2294
  84. package/vendor/boss-recommend-search-cli/src/cli.js +0 -1698
  85. package/vendor/boss-recommend-search-cli/src/test-job-selection.js +0 -211
@@ -1,326 +0,0 @@
1
- import assert from 'node:assert/strict';
2
- import test from 'node:test';
3
-
4
- import { LlmClient, __testables } from './llm.js';
5
-
6
- function createJsonResponse(payload) {
7
- return {
8
- ok: true,
9
- status: 200,
10
- statusText: 'OK',
11
- async json() {
12
- return payload;
13
- },
14
- async text() {
15
- return JSON.stringify(payload);
16
- },
17
- };
18
- }
19
-
20
- function createErrorResponse(status, message) {
21
- return {
22
- ok: false,
23
- status,
24
- statusText: '',
25
- async json() {
26
- return {};
27
- },
28
- async text() {
29
- return message;
30
- },
31
- };
32
- }
33
-
34
- function createCompletionsPayload(content) {
35
- return {
36
- choices: [
37
- {
38
- message: {
39
- content,
40
- },
41
- },
42
- ],
43
- };
44
- }
45
-
46
- function createClient(fetchImpl) {
47
- return new LlmClient(
48
- {
49
- baseUrl: 'https://example.invalid/v1',
50
- apiKey: 'test-key',
51
- model: 'test-model',
52
- },
53
- {
54
- fetchImpl,
55
- preferCompletions: true,
56
- maxRetries: 1,
57
- timeoutMs: 5000,
58
- },
59
- );
60
- }
61
-
62
- function getPromptFromOptions(options = {}) {
63
- const payload = JSON.parse(String(options.body || '{}'));
64
- return String(
65
- payload?.messages?.[0]?.content?.[0]?.text ||
66
- payload?.input?.[0]?.content?.[0]?.text ||
67
- '',
68
- );
69
- }
70
-
71
- async function withChunkEnv(overrides, fn) {
72
- const keys = [
73
- 'BOSS_CHAT_TEXT_CHUNK_SIZE_CHARS',
74
- 'BOSS_CHAT_TEXT_CHUNK_OVERLAP_CHARS',
75
- 'BOSS_CHAT_TEXT_MAX_CHUNKS',
76
- ];
77
- const previous = Object.fromEntries(keys.map((key) => [key, process.env[key]]));
78
- for (const [key, value] of Object.entries(overrides || {})) {
79
- process.env[key] = String(value);
80
- }
81
- try {
82
- return await fn();
83
- } finally {
84
- for (const key of keys) {
85
- if (previous[key] === undefined) {
86
- delete process.env[key];
87
- } else {
88
- process.env[key] = previous[key];
89
- }
90
- }
91
- }
92
- }
93
-
94
- function buildCandidate(resumeText) {
95
- return {
96
- name: '候选人A',
97
- sourceJob: '算法工程师',
98
- resumeProfile: {
99
- primarySchool: '南京大学',
100
- schools: ['南京大学'],
101
- major: '人工智能',
102
- majors: ['人工智能'],
103
- company: '测试公司',
104
- position: '算法工程师',
105
- },
106
- resumeText,
107
- evidenceCorpus: resumeText,
108
- };
109
- }
110
-
111
- test('evaluateTextResume keeps the full-text fast path when context allows', async () => {
112
- const marker = '__END_OF_FULL_RESUME__';
113
- const resumeText = `${'A'.repeat(32000)}${marker}`;
114
- let callCount = 0;
115
- let capturedPrompt = '';
116
- const client = createClient(async (_url, options) => {
117
- callCount += 1;
118
- capturedPrompt = getPromptFromOptions(options);
119
- return createJsonResponse(createCompletionsPayload('{"passed":false}'));
120
- });
121
-
122
- const result = await client.evaluateTextResume({
123
- screeningCriteria: '有 AI 项目经验',
124
- candidate: buildCandidate(resumeText),
125
- });
126
-
127
- assert.equal(result.passed, false);
128
- assert.equal(result.evaluationMode, 'text');
129
- assert.equal(result.aggregateRetryUsed, false);
130
- assert.equal(callCount, 1);
131
- assert.equal(capturedPrompt.includes(marker), true);
132
- });
133
-
134
- test('evaluateTextResume aggregates chunk evidence for long resumes instead of using any-pass shortcut', async () => {
135
- const chunkOneMarker = '本科 2025 届,南京大学';
136
- const chunkTwoMarker = '有 2 段 AI 项目与工作经历';
137
- const resumeText = `${chunkOneMarker}\n${'A'.repeat(1300)}\n${chunkTwoMarker}\n${'B'.repeat(300)}`;
138
- const expectedChunkCount = __testables.splitTextByChunks(resumeText, 1000, 1, 6).length;
139
- let callCount = 0;
140
- let aggregatePrompt = '';
141
- const client = createClient(async (_url, options) => {
142
- callCount += 1;
143
- const prompt = getPromptFromOptions(options);
144
- if (callCount === 1) {
145
- return createErrorResponse(400, 'maximum context length exceeded');
146
- }
147
- const chunkMatch = prompt.match(/当前分段:\s*(\d+)\/(\d+)/);
148
- if (chunkMatch) {
149
- const chunkIndex = Number(chunkMatch[1]);
150
- const chunkTotal = Number(chunkMatch[2]);
151
- const isFirst = chunkIndex === 1;
152
- const isLast = chunkIndex === chunkTotal;
153
- return createJsonResponse(
154
- createCompletionsPayload(
155
- JSON.stringify({
156
- chunk_passed: false,
157
- chunk_summary: isFirst ? '教育信息命中' : isLast ? '项目与工作信息命中' : '中间分段主要是补充描述',
158
- hard_evidence: isFirst ? ['本科 2025 届', '南京大学'] : isLast ? ['AI 项目', '2 段工作经历'] : [],
159
- soft_evidence: [],
160
- hard_blockers: [],
161
- missing_or_uncertain: isLast ? [] : ['还需结合后续分段'],
162
- quoted_spans: isFirst ? ['本科 2025 届'] : isLast ? ['2 段工作经历'] : [],
163
- chunk_index: chunkIndex,
164
- chunk_total: chunkTotal,
165
- }),
166
- ),
167
- );
168
- }
169
- aggregatePrompt = prompt;
170
- return createJsonResponse(
171
- createCompletionsPayload(
172
- JSON.stringify({
173
- passed: true,
174
- reason: '综合全部分段后,教育背景与 AI 项目/工作经历共同满足筛选条件。',
175
- summary: '跨 chunk 证据成立',
176
- evidence: ['本科 2025 届', 'AI 项目', '2 段工作经历'],
177
- }),
178
- ),
179
- );
180
- });
181
-
182
- const result = await withChunkEnv(
183
- {
184
- BOSS_CHAT_TEXT_CHUNK_SIZE_CHARS: '1000',
185
- BOSS_CHAT_TEXT_CHUNK_OVERLAP_CHARS: '1',
186
- BOSS_CHAT_TEXT_MAX_CHUNKS: '6',
187
- },
188
- () =>
189
- client.evaluateTextResume({
190
- screeningCriteria: '有 AI 项目经验',
191
- candidate: buildCandidate(resumeText),
192
- }),
193
- );
194
-
195
- assert.equal(result.passed, true);
196
- assert.equal(result.evaluationMode, 'text-chunk-aggregate');
197
- assert.equal(result.chunkIndex, null);
198
- assert.equal(result.chunkTotal, expectedChunkCount);
199
- assert.equal(result.aggregateRetryUsed, false);
200
- assert.equal(callCount, expectedChunkCount + 2);
201
- assert.equal(aggregatePrompt.includes(`"chunk_count": ${expectedChunkCount}`), true);
202
- assert.equal(aggregatePrompt.includes('本科 2025 届'), true);
203
- assert.equal(aggregatePrompt.includes('AI 项目'), true);
204
- });
205
-
206
- test('evaluateTextResume does not pass when one chunk passes but aggregate decision rejects', async () => {
207
- const resumeText = `${'A'.repeat(900)}\n局部命中关键词\n${'B'.repeat(900)}`;
208
- const client = createClient(async (_url, options) => {
209
- const prompt = getPromptFromOptions(options);
210
- if (prompt.includes('简历文本:') && !prompt.includes('当前分段:')) {
211
- return createErrorResponse(400, 'maximum context length exceeded');
212
- }
213
- const chunkMatch = prompt.match(/当前分段:\s*(\d+)\/(\d+)/);
214
- if (chunkMatch) {
215
- const chunkIndex = Number(chunkMatch[1]);
216
- return createJsonResponse(
217
- createCompletionsPayload(
218
- JSON.stringify({
219
- chunk_passed: chunkIndex === 1,
220
- chunk_summary: chunkIndex === 1 ? '局部关键词命中' : '缺少完整证据',
221
- hard_evidence: chunkIndex === 1 ? ['局部关键词'] : [],
222
- soft_evidence: [],
223
- hard_blockers: chunkIndex === 1 ? [] : ['缺少连续工作/项目证据'],
224
- missing_or_uncertain: ['完整时间线不足'],
225
- quoted_spans: chunkIndex === 1 ? ['局部关键词'] : ['缺少连续工作/项目证据'],
226
- chunk_index: chunkIndex,
227
- chunk_total: Number(chunkMatch[2]),
228
- }),
229
- ),
230
- );
231
- }
232
- return createJsonResponse(
233
- createCompletionsPayload(
234
- JSON.stringify({
235
- passed: false,
236
- reason: '综合全部分段后仍缺少完整项目与工作链路,不能通过。',
237
- summary: '聚合后不通过',
238
- evidence: ['缺少连续工作/项目证据'],
239
- }),
240
- ),
241
- );
242
- });
243
-
244
- const result = await withChunkEnv(
245
- {
246
- BOSS_CHAT_TEXT_CHUNK_SIZE_CHARS: '1000',
247
- BOSS_CHAT_TEXT_CHUNK_OVERLAP_CHARS: '1',
248
- BOSS_CHAT_TEXT_MAX_CHUNKS: '6',
249
- },
250
- () =>
251
- client.evaluateTextResume({
252
- screeningCriteria: '有 AI 项目经验',
253
- candidate: buildCandidate(resumeText),
254
- }),
255
- );
256
-
257
- assert.equal(result.passed, false);
258
- assert.equal(result.evaluationMode, 'text-chunk-aggregate');
259
- });
260
-
261
- test('evaluateTextResume retries aggregate once with compacted evidence and fails on invalid aggregate JSON', async () => {
262
- const resumeText = `${'第一段证据 '.repeat(160)}\n${'第二段证据 '.repeat(160)}`;
263
- const aggregatePrompts = [];
264
- const client = createClient(async (url, options) => {
265
- const prompt = getPromptFromOptions(options);
266
- if (prompt.includes('简历文本:') && !prompt.includes('当前分段:')) {
267
- return createErrorResponse(400, 'maximum context length exceeded');
268
- }
269
- if (prompt.includes('当前分段:')) {
270
- const chunkMatch = prompt.match(/当前分段:\s*(\d+)\/(\d+)/);
271
- const chunkIndex = Number(chunkMatch[1]);
272
- return createJsonResponse(
273
- createCompletionsPayload(
274
- JSON.stringify({
275
- chunk_passed: false,
276
- chunk_summary: `分段 ${chunkIndex} 提取到多条证据`,
277
- hard_evidence: [`关键证据 ${chunkIndex}-1`, `关键证据 ${chunkIndex}-2`, `关键证据 ${chunkIndex}-3`],
278
- soft_evidence: [`补充证据 ${chunkIndex}-1`, `补充证据 ${chunkIndex}-2`],
279
- hard_blockers: [],
280
- missing_or_uncertain: [`待确认信息 ${chunkIndex}-1`, `待确认信息 ${chunkIndex}-2`],
281
- quoted_spans: [`原文片段 ${chunkIndex}-1`, `原文片段 ${chunkIndex}-2`],
282
- chunk_index: chunkIndex,
283
- chunk_total: Number(chunkMatch[2]),
284
- }),
285
- ),
286
- );
287
- }
288
- aggregatePrompts.push(prompt);
289
- if (aggregatePrompts.length === 1) {
290
- return createErrorResponse(400, 'maximum context length exceeded');
291
- }
292
- if (String(url || '').includes('/responses')) {
293
- return createJsonResponse({
294
- output_text: '{"reason":"missing passed field","summary":"invalid"}',
295
- });
296
- }
297
- return createJsonResponse(
298
- createCompletionsPayload(
299
- JSON.stringify({
300
- reason: 'missing passed field',
301
- summary: 'invalid',
302
- }),
303
- ),
304
- );
305
- });
306
-
307
- await assert.rejects(
308
- () =>
309
- withChunkEnv(
310
- {
311
- BOSS_CHAT_TEXT_CHUNK_SIZE_CHARS: '1000',
312
- BOSS_CHAT_TEXT_CHUNK_OVERLAP_CHARS: '1',
313
- BOSS_CHAT_TEXT_MAX_CHUNKS: '6',
314
- },
315
- () =>
316
- client.evaluateTextResume({
317
- screeningCriteria: '有 AI 项目经验',
318
- candidate: buildCandidate(resumeText),
319
- }),
320
- ),
321
- /missing boolean "passed"|unparsable/i,
322
- );
323
-
324
- assert.equal(aggregatePrompts.length >= 2, true);
325
- assert.equal(aggregatePrompts[1].length < aggregatePrompts[0].length, true);
326
- });
@@ -1,173 +0,0 @@
1
- import { mkdir, readFile, writeFile } from 'node:fs/promises';
2
- import path from 'node:path';
3
-
4
- export const DEFAULT_GREETING_TEXT = 'Hi同学,能麻烦发下简历吗?';
5
-
6
- const DEFAULT_PROFILE = {
7
- screeningCriteria: '',
8
- targetCount: null,
9
- startFrom: 'unread',
10
- greetingText: DEFAULT_GREETING_TEXT,
11
- jobSelection: null,
12
- llm: {
13
- baseUrl: '',
14
- apiKey: '',
15
- model: '',
16
- thinkingLevel: '',
17
- timeoutMs: 60000,
18
- maxRetries: 3,
19
- },
20
- chrome: {
21
- port: 9222,
22
- },
23
- runtime: {
24
- batchRestEnabled: true,
25
- safePacing: true,
26
- },
27
- };
28
-
29
- function cloneDefaultProfile() {
30
- return JSON.parse(JSON.stringify(DEFAULT_PROFILE));
31
- }
32
-
33
- function mergeProfile(base, override) {
34
- return {
35
- ...base,
36
- ...override,
37
- llm: {
38
- ...base.llm,
39
- ...(override?.llm || {}),
40
- },
41
- chrome: {
42
- ...base.chrome,
43
- ...(override?.chrome || {}),
44
- },
45
- runtime: {
46
- ...base.runtime,
47
- ...(override?.runtime || {}),
48
- },
49
- };
50
- }
51
-
52
- function normalizeNumber(value, fallback) {
53
- const parsed = Number.parseInt(String(value), 10);
54
- return Number.isFinite(parsed) && parsed > 0 ? parsed : fallback;
55
- }
56
-
57
- function normalizeOptionalPositiveNumber(value, fallback = null) {
58
- if (value === null || value === undefined || String(value).trim() === '') {
59
- return fallback;
60
- }
61
- const parsed = Number.parseInt(String(value), 10);
62
- return Number.isFinite(parsed) && parsed > 0 ? parsed : fallback;
63
- }
64
-
65
- function normalizeJobSelection(jobSelection) {
66
- if (!jobSelection || typeof jobSelection !== 'object') {
67
- return null;
68
- }
69
- const value = String(jobSelection.value || '').trim();
70
- const label = String(jobSelection.label || '').trim();
71
- if (!value && !label) {
72
- return null;
73
- }
74
- return {
75
- value: value || null,
76
- label: label || null,
77
- };
78
- }
79
-
80
- export function toPersistentProfile(profile = {}) {
81
- const normalized = normalizeProfile(profile);
82
- return {
83
- greetingText: normalized.greetingText,
84
- llm: {
85
- baseUrl: normalized.llm.baseUrl,
86
- apiKey: normalized.llm.apiKey,
87
- model: normalized.llm.model,
88
- thinkingLevel: normalized.llm.thinkingLevel,
89
- timeoutMs: normalized.llm.timeoutMs,
90
- maxRetries: normalized.llm.maxRetries,
91
- },
92
- chrome: {
93
- port: normalized.chrome.port,
94
- },
95
- runtime: {
96
- batchRestEnabled: normalized.runtime.batchRestEnabled,
97
- safePacing: normalized.runtime.safePacing,
98
- },
99
- };
100
- }
101
-
102
- export function normalizeProfile(profile = {}) {
103
- const merged = mergeProfile(cloneDefaultProfile(), profile);
104
- merged.screeningCriteria = String(merged.screeningCriteria || '').trim();
105
- merged.startFrom = String(merged.startFrom || '').trim().toLowerCase() === 'all' ? 'all' : 'unread';
106
- merged.targetCount = normalizeOptionalPositiveNumber(merged.targetCount, null);
107
- merged.greetingText = String(merged.greetingText || '').trim() || DEFAULT_GREETING_TEXT;
108
- merged.jobSelection = normalizeJobSelection(merged.jobSelection);
109
- merged.chrome.port = normalizeNumber(merged.chrome.port, DEFAULT_PROFILE.chrome.port);
110
- merged.llm.baseUrl = String(merged.llm.baseUrl || '').trim().replace(/\/+$/, '');
111
- merged.llm.apiKey = String(merged.llm.apiKey || '').trim();
112
- merged.llm.model = String(merged.llm.model || '').trim();
113
- merged.llm.thinkingLevel = String(
114
- merged.llm.thinkingLevel || merged.llm.llmThinkingLevel || merged.llm.reasoningEffort || merged.llm.reasoning_effort || '',
115
- ).trim();
116
- merged.llm.timeoutMs = normalizeNumber(merged.llm.timeoutMs, DEFAULT_PROFILE.llm.timeoutMs);
117
- merged.llm.maxRetries = normalizeNumber(merged.llm.maxRetries, DEFAULT_PROFILE.llm.maxRetries);
118
- merged.runtime.batchRestEnabled = merged.runtime.batchRestEnabled !== false;
119
- merged.runtime.safePacing = merged.runtime.safePacing !== false;
120
- return merged;
121
- }
122
-
123
- export function validateProfile(profile) {
124
- const missing = [];
125
- if (!profile.llm.baseUrl) missing.push('llm.baseUrl');
126
- if (!profile.llm.apiKey) missing.push('llm.apiKey');
127
- if (!profile.llm.model) missing.push('llm.model');
128
- if (!profile.chrome.port) missing.push('chrome.port');
129
- return missing;
130
- }
131
-
132
- export class ProfileStore {
133
- constructor(baseDir) {
134
- this.baseDir = baseDir;
135
- this.profilesDir = path.join(baseDir, 'profiles');
136
- }
137
-
138
- profilePath(profileName) {
139
- return path.join(this.profilesDir, `${profileName}.json`);
140
- }
141
-
142
- async ensureDir() {
143
- await mkdir(this.profilesDir, { recursive: true });
144
- }
145
-
146
- async load(profileName) {
147
- await this.ensureDir();
148
- try {
149
- const raw = await readFile(this.profilePath(profileName), 'utf8');
150
- return normalizeProfile(JSON.parse(raw));
151
- } catch (error) {
152
- if (error && error.code === 'ENOENT') {
153
- return null;
154
- }
155
- throw error;
156
- }
157
- }
158
-
159
- async save(profileName, profile) {
160
- await this.ensureDir();
161
- const normalized = toPersistentProfile(profile);
162
- await writeFile(
163
- this.profilePath(profileName),
164
- `${JSON.stringify(normalized, null, 2)}\n`,
165
- 'utf8',
166
- );
167
- return normalizeProfile(normalized);
168
- }
169
-
170
- mergeWithOverrides(profile, overrides) {
171
- return normalizeProfile(mergeProfile(profile || cloneDefaultProfile(), overrides));
172
- }
173
- }