magic-spec 1.4.1 → 1.4.3

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/CHANGELOG.md CHANGED
@@ -5,7 +5,19 @@ All notable changes to this project will be documented in this file.
5
5
  The format is based on [Keep a Changelog](https://keepachangelog.com/en/1.0.0/),
6
6
  and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0.html).
7
7
 
8
- ## [1.4.1] - 2026-03-01
8
+ ## [1.4.3] - 2026-03-01
9
+
10
+ ### Added
11
+
12
+ - **Smart Adapter Updates**: Both Python and Node.js installers now seamlessly verify and update adapter `.mdc`/`.toml` wrapper files during `--update`.
13
+ - **Smart Update Conflict Resolution**: When users manually modify `.cursor/rules/*` wrapper files in their project, the `--update` command now detects the modifications. Users can intelligently choose to skip updates for specific conflicting adapter files, preserving their changes while updating the core `.magic` engine logic.
14
+
15
+ ### Fixed
16
+
17
+ - **Updater Integrity**: Modified files that are bypassed during an update via `[s] Skip modified files` now persist their original hashes so they don't get silently overwritten in subsequent updates.
18
+ - **Testing Logic**: Fixed an issue in the `run_tests.py` exhaustive test suite section where failures during adapter testing cycles were mistakenly swallowed, reporting a false "All tests completed successfully".
19
+
20
+ ## [1.4.3] - 2026-03-01
9
21
 
10
22
  ### Fixed
11
23
 
@@ -65,5 +65,13 @@
65
65
  "CHANGELOG.md"
66
66
  ],
67
67
  "docsDir": "docs"
68
+ },
69
+ "tests": {
70
+ "sandboxDir": "installers/tests/sandbox",
71
+ "adaptersJson": "installers/adapters.json",
72
+ "pythonInstaller": "installers/python/magic_spec/__main__.py",
73
+ "nodeInstaller": "installers/node/index.js",
74
+ "testDir": "installers/tests",
75
+ "testPattern": "test_*.py"
68
76
  }
69
77
  }
@@ -116,6 +116,7 @@ const isInfo = args.includes('info');
116
116
  const isListEnvs = args.includes('--list-envs');
117
117
  const isEject = args.includes('--eject');
118
118
  const isFallbackMain = args.includes('--fallback-main');
119
+ const isLocal = args.includes('--local');
119
120
  const autoAccept = args.includes('--yes') || args.includes('-y');
120
121
 
121
122
  function parseCsvValues(raw) {
@@ -189,14 +190,15 @@ function convertToMdc(content, description) {
189
190
  return `---\ndescription: ${description || ''}\nglobs: \n---\n${content}`;
190
191
  }
191
192
 
192
- function installAdapter(sourceRoot, env, adapters) {
193
+ function installAdapter(sourceRoot, env, adapters, conflictsToSkip = []) {
194
+ const installedChecksums = {};
193
195
  const adapter = adapters[env];
194
196
  if (!adapter) {
195
197
  console.warn(`⚠️ Unknown --env value: "${env}".`);
196
198
  console.warn(` Valid values: ${Object.keys(adapters).join(', ')}`);
197
199
  console.warn(` Falling back to default ${AGENT_DIR}/`);
198
200
  copyDir(path.join(sourceRoot, AGENT_DIR), path.join(cwd, AGENT_DIR));
199
- return;
201
+ return installedChecksums;
200
202
  }
201
203
 
202
204
  const srcDir = path.join(sourceRoot, AGENT_DIR, WORKFLOWS_DIR);
@@ -204,9 +206,12 @@ function installAdapter(sourceRoot, env, adapters) {
204
206
 
205
207
  if (!fs.existsSync(srcDir)) {
206
208
  console.warn(`⚠️ Source ${AGENT_DIR}/${WORKFLOWS_DIR}/ not found.`);
207
- return;
209
+ return installedChecksums;
208
210
  }
209
211
 
212
+ if (adapter.marker) {
213
+ fs.mkdirSync(path.join(cwd, adapter.marker), { recursive: true });
214
+ }
210
215
  fs.mkdirSync(destDir, { recursive: true });
211
216
 
212
217
  for (const wfName of WORKFLOWS) {
@@ -221,22 +226,56 @@ function installAdapter(sourceRoot, env, adapters) {
221
226
  destName = destName.slice(removePrefix.length);
222
227
  }
223
228
  }
229
+
224
230
  const destFile = path.join(destDir, destName);
231
+ const relTarget = path.relative(cwd, destFile).replace(/\\/g, '/');
232
+
233
+ if (conflictsToSkip.includes(relTarget)) continue;
234
+
235
+ const content = fs.readFileSync(srcFile, 'utf8');
236
+ let finalContent = content;
225
237
 
226
238
  if (adapter.format === 'toml' || adapter.ext === '.toml') {
227
- const content = fs.readFileSync(srcFile, 'utf8');
228
- const description = `Magic SDD Workflow: ${destName}`;
229
- fs.writeFileSync(destFile, convertToToml(content, description), 'utf8');
239
+ finalContent = convertToToml(content, `Magic SDD Workflow: ${destName}`);
230
240
  } else if (adapter.format === 'mdc' || adapter.ext === '.mdc') {
231
- const content = fs.readFileSync(srcFile, 'utf8');
232
- const description = `Magic SDD Workflow: ${destName}`;
233
- fs.writeFileSync(destFile, convertToMdc(content, description), 'utf8');
234
- } else {
235
- fs.copyFileSync(srcFile, destFile);
241
+ finalContent = convertToMdc(content, `Magic SDD Workflow: ${destName}`);
242
+ }
243
+
244
+ fs.writeFileSync(destFile, finalContent, 'utf8');
245
+ installedChecksums[relTarget] = crypto.createHash('sha256').update(finalContent).digest('hex');
246
+ }
247
+
248
+ // Copy other files in .agent if any
249
+ const srcEng = path.join(sourceRoot, AGENT_DIR);
250
+ if (fs.existsSync(srcEng)) {
251
+ const items = fs.readdirSync(srcEng, { withFileTypes: true });
252
+ for (const item of items) {
253
+ if (item.name === WORKFLOWS_DIR) continue;
254
+
255
+ const srcItem = path.join(srcEng, item.name);
256
+ const destItem = path.join(cwd, AGENT_DIR, item.name);
257
+ const relTarget = path.relative(cwd, destItem).replace(/\\/g, '/');
258
+
259
+ if (item.isDirectory()) {
260
+ copyDir(srcItem, destItem);
261
+ const dirHash = getDirectoryChecksums(destItem, cwd);
262
+ Object.assign(installedChecksums, dirHash);
263
+ } else {
264
+ if (conflictsToSkip.includes(relTarget)) continue;
265
+ fs.mkdirSync(path.join(cwd, AGENT_DIR), { recursive: true });
266
+ fs.copyFileSync(srcItem, destItem);
267
+ installedChecksums[relTarget] = getFileChecksum(destItem);
268
+ }
236
269
  }
237
270
  }
238
271
 
239
- console.log(`✅ Adapter installed: ${env} ${adapter.dest}/ (${adapter.ext})`);
272
+ if (conflictsToSkip.length > 0 || isUpdate) {
273
+ console.log(`✅ Adapter updated: ${env} → ${adapter.dest}/ (${adapter.ext})`);
274
+ } else {
275
+ console.log(`✅ Adapter installed: ${env} → ${adapter.dest}/ (${adapter.ext})`);
276
+ }
277
+
278
+ return installedChecksums;
240
279
  }
241
280
 
242
281
  function runDoctor() {
@@ -331,16 +370,20 @@ function runInfo() {
331
370
  }
332
371
  console.log(`Installed version : ${installedVersion} (.magic/.version)`);
333
372
 
334
- const magicrcFile = path.join(cwd, '.magicrc');
335
- let activeEnv = 'default (.agent/)';
336
- if (fs.existsSync(magicrcFile)) {
373
+ const ADAPTERS_PATH = path.join(__dirname, '..', 'adapters.json');
374
+ let activeEnvs = [];
375
+ if (fs.existsSync(ADAPTERS_PATH)) {
337
376
  try {
338
- const rc = JSON.parse(fs.readFileSync(magicrcFile, 'utf8'));
339
- if (rc.env) activeEnv = rc.env;
377
+ const adapters = JSON.parse(fs.readFileSync(ADAPTERS_PATH, 'utf8'));
378
+ activeEnvs = detectEnvironments(adapters);
340
379
  } catch (e) { }
341
380
  }
342
- console.log(`Active env : ${activeEnv}`);
343
381
 
382
+ if (activeEnvs.length === 0) {
383
+ console.log(`Active env : default (${AGENT_DIR}/)`);
384
+ } else {
385
+ console.log(`Active envs : ${activeEnvs.join(', ')}`);
386
+ }
344
387
  const enginePresent = fs.existsSync(path.join(cwd, ENGINE_DIR));
345
388
  console.log(`Engine : ${ENGINE_DIR}/ ${enginePresent ? '✅ present' : '❌ missing'}`);
346
389
 
@@ -431,18 +474,17 @@ async function runEject() {
431
474
  }
432
475
  }
433
476
 
434
- function detectEnvironment(adapters) {
477
+ function detectEnvironments(adapters) {
478
+ const detected = [];
435
479
  for (const env in adapters) {
436
480
  const marker = adapters[env].marker;
437
- if (marker && fs.existsSync(path.join(cwd, marker))) return env;
481
+ if (marker && fs.existsSync(path.join(cwd, marker))) {
482
+ detected.push(env);
483
+ }
438
484
  }
439
- return null;
485
+ return detected;
440
486
  }
441
487
 
442
- function saveMagicRc(config) {
443
- const magicrcFile = path.join(cwd, '.magicrc');
444
- fs.writeFileSync(magicrcFile, JSON.stringify(config, null, 2), 'utf8');
445
- }
446
488
 
447
489
  function getFileChecksum(filePath) {
448
490
  if (!fs.existsSync(filePath)) return null;
@@ -482,8 +524,15 @@ async function handleConflicts(cwd) {
482
524
 
483
525
  const conflicts = [];
484
526
  for (const [relPath, storedHash] of Object.entries(storedChecksums)) {
485
- const localPath = path.join(cwd, '.magic', relPath);
486
- if (fs.existsSync(localPath)) {
527
+ if (relPath === '.checksums' || relPath === '.version') continue;
528
+
529
+ // Backward compatibility: try .magic first, then project root
530
+ let localPath = path.join(cwd, '.magic', relPath);
531
+ if (!fs.existsSync(localPath)) {
532
+ localPath = path.join(cwd, relPath);
533
+ }
534
+
535
+ if (fs.existsSync(localPath) && fs.statSync(localPath).isFile()) {
487
536
  const currentHash = getFileChecksum(localPath);
488
537
  if (currentHash !== storedHash) {
489
538
  conflicts.push(relPath);
@@ -493,7 +542,7 @@ async function handleConflicts(cwd) {
493
542
 
494
543
  if (conflicts.length === 0) return;
495
544
 
496
- console.log(`\n⚠️ Local changes detected in ${conflicts.length} file(s) in .magic/:`);
545
+ console.log(`\n⚠️ Local changes detected in ${conflicts.length} file(s):`);
497
546
  conflicts.slice(0, 5).forEach(f => console.log(` - ${f}`));
498
547
  if (conflicts.length > 5) console.log(` ... and ${conflicts.length - 5} more.`);
499
548
 
@@ -661,13 +710,14 @@ async function main() {
661
710
  console.log("\nOptions:");
662
711
  console.log(" --env <adapter> Specify environment adapter");
663
712
  console.log(" --<adapter> Shortcut for --env <adapter> (e.g. --cursor)");
664
- console.log(" --update Update engine files only");
713
+ console.log(" --update Update engine and adapter files");
714
+ console.log(" --local Use local project files instead of GitHub");
665
715
  console.log(" --fallback-main Pull payload from main branch");
666
716
  console.log(" --yes, -y Auto-accept prompts");
667
717
  process.exit(0);
668
718
  }
669
719
 
670
- console.log(isUpdate ? '🪄 Updating magic-spec (.magic only)...' : '🪄 Initializing magic-spec...');
720
+ console.log(isUpdate ? '🪄 Updating magic-spec...' : '🪄 Initializing magic-spec...');
671
721
 
672
722
  if (isUpdate) {
673
723
  createBackup();
@@ -676,18 +726,16 @@ async function main() {
676
726
  const versionToFetch = isFallbackMain ? 'main' : version;
677
727
  let sourceRoot = null;
678
728
 
679
- // Load .magicrc
680
- let magicrc = {};
681
- const magicrcFile = path.join(cwd, '.magicrc');
682
- if (fs.existsSync(magicrcFile)) {
683
- try {
684
- magicrc = JSON.parse(fs.readFileSync(magicrcFile, 'utf8'));
685
- } catch (e) { }
729
+ if (isLocal) {
730
+ // In local mode, source root is the parent of 'installers' folder
731
+ sourceRoot = path.resolve(__dirname, '..', '..');
732
+ console.log(`🏠 Using local project files from: ${sourceRoot}`);
686
733
  }
687
734
 
688
735
  try {
689
- sourceRoot = await downloadPayload(versionToFetch);
690
-
736
+ if (!isLocal) {
737
+ sourceRoot = await downloadPayload(versionToFetch);
738
+ }
691
739
  let ADAPTERS = {};
692
740
  try {
693
741
  ADAPTERS = JSON.parse(fs.readFileSync(path.join(sourceRoot, 'installers', 'adapters.json'), 'utf8'));
@@ -706,25 +754,35 @@ async function main() {
706
754
  }
707
755
  }
708
756
 
709
- let selectedEnvResolved = null;
710
757
  if (envValues.length > 0) {
711
758
  selectedEnvResolved = envValues[0];
712
- } else if (magicrc.env && magicrc.env !== 'default') {
713
- selectedEnvResolved = magicrc.env;
714
- }
715
-
716
- if (!selectedEnvResolved && !isUpdate) {
717
- const detected = detectEnvironment(ADAPTERS);
718
- if (detected && ADAPTERS[detected] && (!magicrc.env || magicrc.env === 'default')) {
719
- const adapterName = ADAPTERS[detected].description || detected;
720
- console.log(`\n💡 Detected ${adapterName} (${detected}/ directory found).`);
721
- let shouldAdopt = autoAccept;
722
- if (!shouldAdopt) {
723
- const answer = await askQuestion(` Install ${detected} adapter instead of default? (y/N): `);
724
- shouldAdopt = answer.toLowerCase() === 'y';
725
- }
726
- if (shouldAdopt) {
727
- selectedEnvResolved = detected;
759
+ }
760
+
761
+ if (envValues.length === 0) {
762
+ const detected = detectEnvironments(ADAPTERS);
763
+ if (detected.length > 0) {
764
+ if (isUpdate) {
765
+ for (const env of detected) {
766
+ if (!envValues.includes(env)) envValues.push(env);
767
+ }
768
+ selectedEnvResolved = envValues[0];
769
+ } else {
770
+ if (detected.length === 1) {
771
+ const env = detected[0];
772
+ const adapterName = ADAPTERS[env].description || env;
773
+ console.log(`\n💡 Detected ${adapterName} (${ADAPTERS[env].marker}/ directory found).`);
774
+ let shouldAdopt = autoAccept;
775
+ if (!shouldAdopt) {
776
+ const answer = await askQuestion(` Install ${env} adapter instead of default? (y/N): `);
777
+ shouldAdopt = answer.toLowerCase() === 'y';
778
+ }
779
+ if (shouldAdopt) {
780
+ selectedEnvResolved = env;
781
+ }
782
+ } else {
783
+ console.log(`\n💡 Multiple environments detected: ${detected.join(', ')}`);
784
+ console.log(` Use --env <name> or --<name> to target specific one.`);
785
+ }
728
786
  }
729
787
  }
730
788
  }
@@ -757,36 +815,45 @@ async function main() {
757
815
  }
758
816
 
759
817
  // 2. Adapters
760
- if (!isUpdate) {
761
- if (envValues.length > 0) {
762
- for (const env of envValues) {
763
- installAdapter(sourceRoot, env, ADAPTERS);
764
- }
765
- } else if (selectedEnvResolved) {
766
- installAdapter(sourceRoot, selectedEnvResolved, ADAPTERS);
767
- } else {
768
- // Default install
769
- const srcEng = path.join(sourceRoot, AGENT_DIR);
770
- const destEng = path.join(cwd, AGENT_DIR);
771
- fs.mkdirSync(destEng, { recursive: true });
772
- fs.mkdirSync(path.join(destEng, WORKFLOWS_DIR), { recursive: true });
773
-
774
- for (const wfName of WORKFLOWS) {
775
- const file = wfName + DEFAULT_EXT;
776
- const srcWf = path.join(srcEng, WORKFLOWS_DIR, file);
777
- if (fs.existsSync(srcWf)) {
778
- fs.copyFileSync(srcWf, path.join(destEng, WORKFLOWS_DIR, file));
779
- }
818
+ let adapterChecksums = {};
819
+ if (envValues.length > 0) {
820
+ for (const env of envValues) {
821
+ Object.assign(adapterChecksums, installAdapter(sourceRoot, env, ADAPTERS, conflictsToSkip));
822
+ }
823
+ } else if (selectedEnvResolved) {
824
+ Object.assign(adapterChecksums, installAdapter(sourceRoot, selectedEnvResolved, ADAPTERS, conflictsToSkip));
825
+ } else if (!isUpdate) {
826
+ // Default install
827
+ const srcEng = path.join(sourceRoot, AGENT_DIR);
828
+ const destEng = path.join(cwd, AGENT_DIR);
829
+ fs.mkdirSync(destEng, { recursive: true });
830
+ fs.mkdirSync(path.join(destEng, WORKFLOWS_DIR), { recursive: true });
831
+
832
+ for (const wfName of WORKFLOWS) {
833
+ const file = wfName + DEFAULT_EXT;
834
+ const srcWf = path.join(srcEng, WORKFLOWS_DIR, file);
835
+ if (fs.existsSync(srcWf)) {
836
+ const destWf = path.join(destEng, WORKFLOWS_DIR, file);
837
+ fs.copyFileSync(srcWf, destWf);
838
+ const relTarget = path.relative(cwd, destWf).replace(/\\/g, '/');
839
+ adapterChecksums[relTarget] = getFileChecksum(destWf);
780
840
  }
841
+ }
781
842
 
782
- // Copy other files in .agent if any (not workflows subfolder which we handled selectively)
843
+ // Copy other files in .agent if any (not workflows subfolder which we handled selectively)
844
+ if (fs.existsSync(srcEng)) {
783
845
  const items = fs.readdirSync(srcEng, { withFileTypes: true });
784
846
  for (const item of items) {
785
847
  if (item.name === WORKFLOWS_DIR) continue;
848
+ const srcItem = path.join(srcEng, item.name);
849
+ const destItem = path.join(destEng, item.name);
786
850
  if (item.isDirectory()) {
787
- copyDir(path.join(srcEng, item.name), path.join(destEng, item.name));
851
+ copyDir(srcItem, destItem);
852
+ Object.assign(adapterChecksums, getDirectoryChecksums(destItem, cwd));
788
853
  } else {
789
- fs.copyFileSync(path.join(srcEng, item.name), path.join(destEng, item.name));
854
+ fs.copyFileSync(srcItem, destItem);
855
+ const relTarget = path.relative(cwd, destItem).replace(/\\/g, '/');
856
+ adapterChecksums[relTarget] = getFileChecksum(destItem);
790
857
  }
791
858
  }
792
859
  }
@@ -842,20 +909,27 @@ async function main() {
842
909
  console.warn(`⚠️ Failed to write .magic/.version: ${vErr.message}`);
843
910
  }
844
911
 
845
- // 5. Update .magicrc - [T-2C02]
846
- try {
847
- const newConfig = {
848
- env: selectedEnvResolved || magicrc.env || 'default',
849
- version: version
850
- };
851
- saveMagicRc(newConfig);
852
- } catch (rcErr) {
853
- console.warn(`⚠️ Failed to update .magicrc: ${rcErr.message}`);
854
- }
855
912
 
856
913
  // 6. Save checksums - [T-2C03]
857
914
  try {
858
915
  const currentChecksums = getDirectoryChecksums(path.join(cwd, '.magic'));
916
+
917
+ if (isUpdate) {
918
+ const checksumsFile = path.join(cwd, '.magic', '.checksums');
919
+ if (fs.existsSync(checksumsFile)) {
920
+ try {
921
+ const oldChecksums = JSON.parse(fs.readFileSync(checksumsFile, 'utf8'));
922
+ for (const skipped of conflictsToSkip) {
923
+ if (oldChecksums[skipped]) {
924
+ currentChecksums[skipped] = oldChecksums[skipped];
925
+ }
926
+ }
927
+ } catch (e) { }
928
+ }
929
+ }
930
+
931
+ Object.assign(currentChecksums, adapterChecksums);
932
+
859
933
  fs.writeFileSync(path.join(cwd, '.magic', '.checksums'), JSON.stringify(currentChecksums, null, 2), 'utf8');
860
934
  } catch (cErr) {
861
935
  console.warn(`⚠️ Failed to save checksums: ${cErr.message}`);
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "magic-spec",
3
- "version": "1.4.1",
3
+ "version": "1.4.3",
4
4
  "description": "Magic Specification-Driven Development (SDD) Workflow",
5
5
  "author": "Oleg Alexandrov <alexandrovoleg.ru@gmail.com>",
6
6
  "license": "MIT",