kiroo 0.8.0 → 0.9.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.
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "kiroo",
3
- "version": "0.8.0",
3
+ "version": "0.9.0",
4
4
  "description": "Git for API interactions. Record, replay, snapshot, and diff your APIs.",
5
5
  "type": "module",
6
6
  "bin": {
@@ -42,6 +42,7 @@
42
42
  "cli-table3": "^0.6.3",
43
43
  "commander": "^12.0.0",
44
44
  "dotenv": "^17.3.1",
45
+ "http-proxy": "^1.18.1",
45
46
  "inquirer": "^9.2.15",
46
47
  "js-yaml": "^4.1.0",
47
48
  "lingo.dev": "^0.133.1",
package/src/analyze.js ADDED
@@ -0,0 +1,550 @@
1
+ import axios from 'axios';
2
+ import chalk from 'chalk';
3
+ import { loadKirooConfig } from './config.js';
4
+ import { stableJSONStringify } from './deterministic.js';
5
+ import { loadSnapshotData } from './storage.js';
6
+ import { translateText } from './lingo.js';
7
+ import { getEnvVar } from './env.js';
8
+
9
+ const SEVERITY_RANK = {
10
+ none: 0,
11
+ low: 1,
12
+ medium: 2,
13
+ high: 3,
14
+ critical: 4
15
+ };
16
+
17
+ const DEFAULT_MODEL_PRIORITY = [
18
+ 'qwen/qwen3-32b',
19
+ 'moonshotai/kimi-k2-instruct-0905',
20
+ 'openai/gpt-oss-20b'
21
+ ];
22
+
23
+ function getPath(urlStr) {
24
+ try {
25
+ const urlObj = new URL(urlStr);
26
+ return urlObj.pathname;
27
+ } catch {
28
+ return urlStr.startsWith('/') ? urlStr : `/${urlStr}`;
29
+ }
30
+ }
31
+
32
+ function endpointKey(method, path) {
33
+ return `${String(method || '').toUpperCase()} ${path}`;
34
+ }
35
+
36
+ function normalizeFieldName(name) {
37
+ return String(name || '').replace(/[_-]/g, '').toLowerCase();
38
+ }
39
+
40
+ function isObject(val) {
41
+ return val !== null && typeof val === 'object' && !Array.isArray(val);
42
+ }
43
+
44
+ function compareStatus(beforeStatus, afterStatus) {
45
+ if (beforeStatus === afterStatus) return null;
46
+
47
+ const before2xx = beforeStatus >= 200 && beforeStatus < 300;
48
+ const before4xx5xx = beforeStatus >= 400;
49
+ const after3xx = afterStatus >= 300 && afterStatus < 400;
50
+ const after4xx5xx = afterStatus >= 400;
51
+
52
+ let severity = 'medium';
53
+ let breaking = true;
54
+
55
+ if (before2xx && afterStatus === 304) {
56
+ severity = 'low';
57
+ breaking = false;
58
+ } else if (before2xx && after3xx) {
59
+ severity = 'low';
60
+ breaking = false;
61
+ } else if (before2xx && after4xx5xx) {
62
+ severity = 'high';
63
+ breaking = true;
64
+ } else if (before4xx5xx && afterStatus >= 200 && afterStatus < 300) {
65
+ severity = 'low';
66
+ breaking = false;
67
+ }
68
+
69
+ return {
70
+ type: 'status_changed',
71
+ path: 'response.status',
72
+ severity,
73
+ breaking,
74
+ message: `Status changed from ${beforeStatus} to ${afterStatus}`
75
+ };
76
+ }
77
+
78
+ function compareBodies(beforeValue, afterValue, path = '') {
79
+ const issues = [];
80
+
81
+ if (beforeValue === null && afterValue === null) return issues;
82
+ if (beforeValue === null && afterValue !== null) {
83
+ issues.push({
84
+ type: 'field_type_changed',
85
+ path: path || 'root',
86
+ severity: 'medium',
87
+ breaking: false,
88
+ message: `Type changed from null to ${Array.isArray(afterValue) ? 'array' : typeof afterValue}`
89
+ });
90
+ return issues;
91
+ }
92
+ if (beforeValue !== null && afterValue === null) {
93
+ issues.push({
94
+ type: 'field_type_changed',
95
+ path: path || 'root',
96
+ severity: 'medium',
97
+ breaking: false,
98
+ message: `Type changed from ${Array.isArray(beforeValue) ? 'array' : typeof beforeValue} to null`
99
+ });
100
+ return issues;
101
+ }
102
+
103
+ const beforeType = Array.isArray(beforeValue) ? 'array' : typeof beforeValue;
104
+ const afterType = Array.isArray(afterValue) ? 'array' : typeof afterValue;
105
+
106
+ if (beforeType !== afterType) {
107
+ issues.push({
108
+ type: 'field_type_changed',
109
+ path: path || 'root',
110
+ severity: 'high',
111
+ breaking: true,
112
+ message: `Type changed from ${beforeType} to ${afterType}`
113
+ });
114
+ return issues;
115
+ }
116
+
117
+ if (isObject(beforeValue) && isObject(afterValue)) {
118
+ const beforeKeys = Object.keys(beforeValue).sort((a, b) => a.localeCompare(b));
119
+ const afterKeys = Object.keys(afterValue).sort((a, b) => a.localeCompare(b));
120
+
121
+ const removed = beforeKeys.filter((key) => !afterKeys.includes(key));
122
+ const added = afterKeys.filter((key) => !beforeKeys.includes(key));
123
+ const matchedRemoved = new Set();
124
+ const matchedAdded = new Set();
125
+
126
+ for (const removedKey of removed) {
127
+ const removedNorm = normalizeFieldName(removedKey);
128
+ const addCandidate = added.find((addedKey) => !matchedAdded.has(addedKey) && normalizeFieldName(addedKey) === removedNorm);
129
+
130
+ if (addCandidate) {
131
+ matchedRemoved.add(removedKey);
132
+ matchedAdded.add(addCandidate);
133
+ issues.push({
134
+ type: 'field_rename_candidate',
135
+ path: path || 'root',
136
+ severity: 'medium',
137
+ breaking: true,
138
+ message: `Possible rename: "${removedKey}" -> "${addCandidate}"`
139
+ });
140
+ }
141
+ }
142
+
143
+ for (const removedKey of removed) {
144
+ if (matchedRemoved.has(removedKey)) continue;
145
+ const removedPath = path ? `${path}.${removedKey}` : removedKey;
146
+ issues.push({
147
+ type: 'field_removed',
148
+ path: removedPath,
149
+ severity: 'high',
150
+ breaking: true,
151
+ message: `Field removed: ${removedPath}`
152
+ });
153
+ }
154
+
155
+ for (const addedKey of added) {
156
+ if (matchedAdded.has(addedKey)) continue;
157
+ const addedPath = path ? `${path}.${addedKey}` : addedKey;
158
+ issues.push({
159
+ type: 'field_added',
160
+ path: addedPath,
161
+ severity: 'low',
162
+ breaking: false,
163
+ message: `Field added: ${addedPath}`
164
+ });
165
+ }
166
+
167
+ for (const sharedKey of beforeKeys.filter((key) => afterKeys.includes(key))) {
168
+ const childPath = path ? `${path}.${sharedKey}` : sharedKey;
169
+ issues.push(...compareBodies(beforeValue[sharedKey], afterValue[sharedKey], childPath));
170
+ }
171
+
172
+ return issues;
173
+ }
174
+
175
+ if (Array.isArray(beforeValue) && Array.isArray(afterValue)) {
176
+ if (beforeValue.length > 0 && afterValue.length > 0) {
177
+ const itemPath = `${path || 'root'}[0]`;
178
+ issues.push(...compareBodies(beforeValue[0], afterValue[0], itemPath));
179
+ }
180
+ }
181
+
182
+ return issues;
183
+ }
184
+
185
+ function updateSummaryFromIssue(summary, issue) {
186
+ summary.totalIssues += 1;
187
+ summary.bySeverity[issue.severity] += 1;
188
+ summary.byType[issue.type] = (summary.byType[issue.type] || 0) + 1;
189
+ }
190
+
191
+ function ensureEndpoint(reportMap, method, path) {
192
+ const key = endpointKey(method, path);
193
+ if (!reportMap.has(key)) {
194
+ reportMap.set(key, {
195
+ method: String(method || '').toUpperCase(),
196
+ path,
197
+ issues: [],
198
+ highestSeverity: 'none'
199
+ });
200
+ }
201
+ return reportMap.get(key);
202
+ }
203
+
204
+ function addIssue(reportMap, summary, method, path, issue) {
205
+ const endpoint = ensureEndpoint(reportMap, method, path);
206
+ const isDuplicate = endpoint.issues.some((existing) =>
207
+ existing.type === issue.type &&
208
+ existing.path === issue.path &&
209
+ existing.message === issue.message &&
210
+ existing.severity === issue.severity
211
+ );
212
+ if (isDuplicate) {
213
+ return;
214
+ }
215
+
216
+ endpoint.issues.push(issue);
217
+ if (SEVERITY_RANK[issue.severity] > SEVERITY_RANK[endpoint.highestSeverity]) {
218
+ endpoint.highestSeverity = issue.severity;
219
+ }
220
+ updateSummaryFromIssue(summary, issue);
221
+ }
222
+
223
+ function compareInteractionsForAnalysis(beforeInteractions, afterInteractions) {
224
+ const reportMap = new Map();
225
+ const summary = {
226
+ totalEndpoints: 0,
227
+ totalIssues: 0,
228
+ bySeverity: {
229
+ low: 0,
230
+ medium: 0,
231
+ high: 0,
232
+ critical: 0
233
+ },
234
+ byType: {}
235
+ };
236
+
237
+ const sortedBefore = [...beforeInteractions].sort((a, b) => endpointKey(a.method, getPath(a.url)).localeCompare(endpointKey(b.method, getPath(b.url))));
238
+ const sortedAfter = [...afterInteractions].sort((a, b) => endpointKey(a.method, getPath(a.url)).localeCompare(endpointKey(b.method, getPath(b.url))));
239
+ const consumedBeforeIndexes = new Set();
240
+
241
+ for (const after of sortedAfter) {
242
+ const method = String(after.method || '').toUpperCase();
243
+ const path = getPath(after.url || '');
244
+ const candidates = sortedBefore
245
+ .map((item, index) => ({ item, index }))
246
+ .filter(({ item, index }) => !consumedBeforeIndexes.has(index) && String(item.method || '').toUpperCase() === method && getPath(item.url || '') === path);
247
+
248
+ const match = candidates.find(({ item }) => item.id && after.id && item.id === after.id) || candidates[0];
249
+
250
+ if (!match) {
251
+ addIssue(reportMap, summary, method, path, {
252
+ type: 'endpoint_added',
253
+ path,
254
+ severity: 'low',
255
+ breaking: false,
256
+ message: 'Endpoint added in target snapshot'
257
+ });
258
+ continue;
259
+ }
260
+
261
+ consumedBeforeIndexes.add(match.index);
262
+ const before = match.item;
263
+ const statusIssue = compareStatus(before.response?.status, after.response?.status);
264
+ if (statusIssue) {
265
+ addIssue(reportMap, summary, method, path, statusIssue);
266
+ }
267
+
268
+ if (before.response?.body !== undefined && after.response?.body !== undefined) {
269
+ const bodyIssues = compareBodies(before.response.body, after.response.body);
270
+ for (const issue of bodyIssues) {
271
+ addIssue(reportMap, summary, method, path, issue);
272
+ }
273
+ }
274
+ }
275
+
276
+ sortedBefore.forEach((before, index) => {
277
+ if (consumedBeforeIndexes.has(index)) return;
278
+ const method = String(before.method || '').toUpperCase();
279
+ const path = getPath(before.url || '');
280
+ addIssue(reportMap, summary, method, path, {
281
+ type: 'endpoint_removed',
282
+ path,
283
+ severity: 'high',
284
+ breaking: true,
285
+ message: 'Endpoint removed from target snapshot'
286
+ });
287
+ });
288
+
289
+ const endpoints = Array.from(reportMap.values())
290
+ .sort((a, b) => {
291
+ const severityDiff = SEVERITY_RANK[b.highestSeverity] - SEVERITY_RANK[a.highestSeverity];
292
+ if (severityDiff !== 0) return severityDiff;
293
+ if (a.method !== b.method) return a.method.localeCompare(b.method);
294
+ return a.path.localeCompare(b.path);
295
+ })
296
+ .map((endpoint) => ({
297
+ ...endpoint,
298
+ issues: endpoint.issues.sort((a, b) => {
299
+ const severityDiff = SEVERITY_RANK[b.severity] - SEVERITY_RANK[a.severity];
300
+ if (severityDiff !== 0) return severityDiff;
301
+ if (a.type !== b.type) return a.type.localeCompare(b.type);
302
+ return String(a.path || '').localeCompare(String(b.path || ''));
303
+ })
304
+ }));
305
+
306
+ summary.totalEndpoints = endpoints.length;
307
+ const highestSeverity = endpoints.reduce((highest, endpoint) => (
308
+ SEVERITY_RANK[endpoint.highestSeverity] > SEVERITY_RANK[highest] ? endpoint.highestSeverity : highest
309
+ ), 'none');
310
+
311
+ return {
312
+ summary,
313
+ highestSeverity,
314
+ endpoints
315
+ };
316
+ }
317
+
318
+ export function analyzeSnapshotData(sourceSnapshot, targetSnapshot) {
319
+ const sourceInteractions = sourceSnapshot?.interactions || [];
320
+ const targetInteractions = targetSnapshot?.interactions || [];
321
+ const analysis = compareInteractionsForAnalysis(sourceInteractions, targetInteractions);
322
+
323
+ return {
324
+ generatedAt: new Date().toISOString(),
325
+ source: {
326
+ tag: sourceSnapshot?.tag || 'source',
327
+ timestamp: sourceSnapshot?.timestamp
328
+ },
329
+ target: {
330
+ tag: targetSnapshot?.tag || 'target',
331
+ timestamp: targetSnapshot?.timestamp
332
+ },
333
+ summary: analysis.summary,
334
+ highestSeverity: analysis.highestSeverity,
335
+ endpoints: analysis.endpoints
336
+ };
337
+ }
338
+
339
+ function colorForSeverity(severity) {
340
+ if (severity === 'critical') return chalk.redBright;
341
+ if (severity === 'high') return chalk.red;
342
+ if (severity === 'medium') return chalk.yellow;
343
+ if (severity === 'low') return chalk.blue;
344
+ return chalk.gray;
345
+ }
346
+
347
+ function shouldFail(report, failOnSeverity) {
348
+ const threshold = String(failOnSeverity || '').toLowerCase();
349
+ if (!SEVERITY_RANK[threshold]) return false;
350
+ return SEVERITY_RANK[report.highestSeverity] >= SEVERITY_RANK[threshold];
351
+ }
352
+
353
+ async function maybeTranslate(text, lang) {
354
+ if (!lang) return text;
355
+ return translateText(text, lang);
356
+ }
357
+
358
+ async function requestGroqSummary(report, { model, maxTokens, apiKey }) {
359
+ const endpointPreview = report.endpoints.slice(0, 30).map((endpoint) => ({
360
+ method: endpoint.method,
361
+ path: endpoint.path,
362
+ highestSeverity: endpoint.highestSeverity,
363
+ issues: endpoint.issues.slice(0, 6).map((issue) => ({
364
+ type: issue.type,
365
+ severity: issue.severity,
366
+ message: issue.message
367
+ }))
368
+ }));
369
+
370
+ const prompt = [
371
+ 'You are a senior API reviewer.',
372
+ 'Return concise output only as bullet points.',
373
+ 'Rules:',
374
+ '- Maximum 5 bullet points',
375
+ '- Each bullet must be <= 14 words',
376
+ '- Focus on impact + risk + actionable fix',
377
+ '- Do not include headings or paragraphs',
378
+ '',
379
+ stableJSONStringify({
380
+ sourceTag: report.source.tag,
381
+ targetTag: report.target.tag,
382
+ summary: report.summary,
383
+ highestSeverity: report.highestSeverity,
384
+ endpoints: endpointPreview
385
+ }, 2)
386
+ ].join('\n');
387
+
388
+ const response = await axios.post(
389
+ 'https://api.groq.com/openai/v1/chat/completions',
390
+ {
391
+ model,
392
+ temperature: 0.2,
393
+ max_tokens: maxTokens,
394
+ messages: [
395
+ { role: 'system', content: 'You are an API contract analysis assistant.' },
396
+ { role: 'user', content: prompt }
397
+ ]
398
+ },
399
+ {
400
+ headers: {
401
+ Authorization: `Bearer ${apiKey}`,
402
+ 'Content-Type': 'application/json'
403
+ }
404
+ }
405
+ );
406
+
407
+ const text = response.data?.choices?.[0]?.message?.content;
408
+ if (!text) {
409
+ throw new Error(`No summary text returned by Groq model ${model}`);
410
+ }
411
+ return text.trim();
412
+ }
413
+
414
+ function toConciseBullets(rawText, report) {
415
+ let cleanedText = String(rawText || '');
416
+ cleanedText = cleanedText.replace(/<think>[\s\S]*?<\/think>/gi, ' ');
417
+ if (cleanedText.includes('<think>')) {
418
+ cleanedText = cleanedText.split('<think>')[0];
419
+ }
420
+
421
+ const lines = cleanedText
422
+ .split('\n')
423
+ .map((line) => line.trim())
424
+ .filter(Boolean);
425
+
426
+ const normalized = lines
427
+ .map((line) => line.replace(/^[-*•\d.)\s]+/, '').trim())
428
+ .filter((line) => !/^<\/?think>$/i.test(line))
429
+ .filter((line) => !/^(okay|let'?s|first,|then,|on the flip side|i need to)/i.test(line))
430
+ .filter(Boolean);
431
+
432
+ if (normalized.length === 0 || normalized.every((line) => line.length < 8)) {
433
+ return fallbackBullets(report);
434
+ }
435
+
436
+ const bullets = normalized
437
+ .slice(0, 5)
438
+ .map((line) => {
439
+ const compact = line.replace(/\s+/g, ' ');
440
+ const clipped = compact.length > 100 ? `${compact.slice(0, 97)}...` : compact;
441
+ return `- ${clipped}`;
442
+ });
443
+
444
+ return bullets;
445
+ }
446
+
447
+ function fallbackBullets(report) {
448
+ const s = report.summary;
449
+ const bullets = [];
450
+ bullets.push(`- Highest severity: ${String(report.highestSeverity).toUpperCase()}.`);
451
+ bullets.push(`- High/Critical issues: ${s.bySeverity.high + s.bySeverity.critical}.`);
452
+ bullets.push(`- Removed endpoints: ${s.byType.endpoint_removed || 0}.`);
453
+ bullets.push(`- Status changes: ${s.byType.status_changed || 0}.`);
454
+ bullets.push('- Action: patch clients first, then add compatibility aliases.');
455
+ return bullets;
456
+ }
457
+
458
+ export function resolveGroqApiKey() {
459
+ return getEnvVar('GROQ_API_KEY');
460
+ }
461
+
462
+ async function generateAiSummary(report, options) {
463
+ const apiKey = resolveGroqApiKey();
464
+ if (!apiKey) {
465
+ throw new Error('GROQ_API_KEY not found in .kiroo/env.json. Run "kiroo env set GROQ_API_KEY <your_key>" or re-run "kiroo init".');
466
+ }
467
+
468
+ const modelCandidates = options.model
469
+ ? [options.model]
470
+ : (options.modelPriority?.length ? options.modelPriority : DEFAULT_MODEL_PRIORITY);
471
+
472
+ const failures = [];
473
+ for (const model of modelCandidates) {
474
+ try {
475
+ const summary = await requestGroqSummary(report, {
476
+ model,
477
+ maxTokens: options.maxTokens,
478
+ apiKey
479
+ });
480
+ return { summary, model };
481
+ } catch (error) {
482
+ failures.push(`${model}: ${error.message}`);
483
+ }
484
+ }
485
+
486
+ throw new Error(`Groq summary failed for all candidate models. ${failures.join(' | ')}`);
487
+ }
488
+
489
+ export async function analyzeSnapshots(tag1, tag2, options = {}) {
490
+ try {
491
+ const config = loadKirooConfig();
492
+ const failOnSeverity = options.failOn || null;
493
+ const maxTokens = Number.parseInt(options.maxTokens, 10) || config.settings?.analysis?.maxCompletionTokens || 900;
494
+ const modelPriority = config.settings?.analysis?.modelPriority || DEFAULT_MODEL_PRIORITY;
495
+
496
+ const sourceSnapshot = loadSnapshotData(tag1);
497
+ const targetSnapshot = loadSnapshotData(tag2);
498
+ const report = analyzeSnapshotData(sourceSnapshot, targetSnapshot);
499
+
500
+ if (options.json) {
501
+ console.log(stableJSONStringify(report));
502
+ } else {
503
+ console.log(chalk.cyan('\n 🧠 Blast Radius Analysis'));
504
+ console.log(chalk.gray(` Source: ${tag1}`));
505
+ console.log(chalk.gray(` Target: ${tag2}\n`));
506
+
507
+ const shownEndpoints = report.endpoints.slice(0, 6);
508
+ for (const endpoint of shownEndpoints) {
509
+ const severityColor = colorForSeverity(endpoint.highestSeverity);
510
+ console.log(` ${severityColor(endpoint.highestSeverity.toUpperCase())} ${chalk.white(endpoint.method)} ${chalk.gray(endpoint.path)}`);
511
+ endpoint.issues.slice(0, 4).forEach((issue) => {
512
+ const issueColor = colorForSeverity(issue.severity);
513
+ console.log(` - ${issueColor(issue.severity)} ${issue.message}`);
514
+ });
515
+ if (endpoint.issues.length > 4) {
516
+ console.log(chalk.gray(` - ... +${endpoint.issues.length - 4} more issues`));
517
+ }
518
+ }
519
+ if (report.endpoints.length > shownEndpoints.length) {
520
+ console.log(chalk.gray(` ... +${report.endpoints.length - shownEndpoints.length} more endpoints`));
521
+ }
522
+
523
+ const summaryLine = `Issues: ${report.summary.totalIssues} | Endpoints: ${report.summary.totalEndpoints} | Highest severity: ${report.highestSeverity.toUpperCase()}`;
524
+ const translatedSummary = await maybeTranslate(summaryLine, options.lang);
525
+ console.log(chalk.cyan(`\n ${translatedSummary}`));
526
+ }
527
+
528
+ if (options.ai) {
529
+ const ai = await generateAiSummary(report, {
530
+ model: options.model,
531
+ modelPriority,
532
+ maxTokens
533
+ });
534
+ console.log(chalk.magenta(`\n 🤖 AI Summary (${ai.model})`));
535
+ toConciseBullets(ai.summary, report).forEach((bullet) => {
536
+ console.log(` ${bullet}`);
537
+ });
538
+ }
539
+
540
+ if (failOnSeverity && shouldFail(report, failOnSeverity)) {
541
+ console.error(chalk.red(`\n ✗ Analysis failed threshold (${failOnSeverity}). Highest severity is ${report.highestSeverity}.`));
542
+ process.exit(1);
543
+ }
544
+
545
+ console.log(chalk.green('\n ✅ Analysis complete.\n'));
546
+ } catch (error) {
547
+ console.error(chalk.red('\n ✗ Analysis failed:'), error.message, '\n');
548
+ process.exit(1);
549
+ }
550
+ }
package/src/config.js ADDED
@@ -0,0 +1,109 @@
1
+ import { existsSync, mkdirSync, readFileSync, writeFileSync } from 'fs';
2
+ import { join } from 'path';
3
+ import { stableJSONStringify } from './deterministic.js';
4
+
5
+ const KIROO_DIR = '.kiroo';
6
+ const CONFIG_FILE = join(KIROO_DIR, 'config.json');
7
+
8
+ const DEFAULT_CONFIG_TEMPLATE = {
9
+ projectName: 'my-api-project',
10
+ baseUrl: '',
11
+ settings: {
12
+ determinism: {
13
+ sortKeys: true
14
+ },
15
+ redaction: {
16
+ enabled: true,
17
+ redactedValue: '<REDACTED>'
18
+ },
19
+ analysis: {
20
+ failOnSeverity: 'none',
21
+ provider: 'groq',
22
+ modelPriority: [
23
+ 'qwen/qwen3-32b',
24
+ 'moonshotai/kimi-k2-instruct-0905',
25
+ 'openai/gpt-oss-20b'
26
+ ],
27
+ maxCompletionTokens: 900
28
+ },
29
+ export: {
30
+ defaultFormat: 'postman'
31
+ },
32
+ ci: {
33
+ failOnSeverity: 'high'
34
+ }
35
+ }
36
+ };
37
+
38
+ function cloneDefaultConfig() {
39
+ return JSON.parse(JSON.stringify(DEFAULT_CONFIG_TEMPLATE));
40
+ }
41
+
42
+ function mergeWithDefaults(defaultObj, currentObj) {
43
+ if (Array.isArray(defaultObj)) {
44
+ if (Array.isArray(currentObj)) return currentObj;
45
+ return [...defaultObj];
46
+ }
47
+
48
+ if (defaultObj && typeof defaultObj === 'object') {
49
+ const merged = {};
50
+ const source = currentObj && typeof currentObj === 'object' ? currentObj : {};
51
+ const keys = new Set([...Object.keys(defaultObj), ...Object.keys(source)]);
52
+
53
+ for (const key of keys) {
54
+ if (key in defaultObj) {
55
+ merged[key] = mergeWithDefaults(defaultObj[key], source[key]);
56
+ } else {
57
+ merged[key] = source[key];
58
+ }
59
+ }
60
+ return merged;
61
+ }
62
+
63
+ return currentObj !== undefined ? currentObj : defaultObj;
64
+ }
65
+
66
+ function ensureConfigFile() {
67
+ if (!existsSync(KIROO_DIR)) {
68
+ mkdirSync(KIROO_DIR);
69
+ }
70
+
71
+ if (!existsSync(CONFIG_FILE)) {
72
+ const initialConfig = {
73
+ ...cloneDefaultConfig(),
74
+ createdAt: new Date().toISOString()
75
+ };
76
+ writeFileSync(CONFIG_FILE, stableJSONStringify(initialConfig));
77
+ }
78
+ }
79
+
80
+ export function loadKirooConfig() {
81
+ ensureConfigFile();
82
+
83
+ const raw = readFileSync(CONFIG_FILE, 'utf8');
84
+ const parsed = JSON.parse(raw);
85
+ const defaults = cloneDefaultConfig();
86
+ const normalized = mergeWithDefaults(defaults, parsed);
87
+
88
+ if (!normalized.createdAt) {
89
+ normalized.createdAt = new Date().toISOString();
90
+ }
91
+
92
+ const normalizedRaw = stableJSONStringify(normalized);
93
+ if (normalizedRaw !== raw) {
94
+ writeFileSync(CONFIG_FILE, normalizedRaw);
95
+ }
96
+
97
+ return normalized;
98
+ }
99
+
100
+ export function saveKirooConfig(partialConfig = {}) {
101
+ const current = loadKirooConfig();
102
+ const merged = mergeWithDefaults(current, partialConfig);
103
+ writeFileSync(CONFIG_FILE, stableJSONStringify(merged));
104
+ return merged;
105
+ }
106
+
107
+ export function getDefaultKirooConfig() {
108
+ return cloneDefaultConfig();
109
+ }
@@ -0,0 +1,22 @@
1
+ function sortObjectKeysDeep(value) {
2
+ if (Array.isArray(value)) {
3
+ return value.map((item) => sortObjectKeysDeep(item));
4
+ }
5
+
6
+ if (value && typeof value === 'object') {
7
+ const sorted = {};
8
+ const keys = Object.keys(value).sort((a, b) => a.localeCompare(b));
9
+ for (const key of keys) {
10
+ sorted[key] = sortObjectKeysDeep(value[key]);
11
+ }
12
+ return sorted;
13
+ }
14
+
15
+ return value;
16
+ }
17
+
18
+ export function stableJSONStringify(value, space = 2) {
19
+ return JSON.stringify(sortObjectKeysDeep(value), null, space);
20
+ }
21
+
22
+ export { sortObjectKeysDeep };