@phuc1403/musketeer 0.2.4 → 0.3.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 (27) hide show
  1. package/INSTALLATION.md +10 -0
  2. package/README.md +1 -1
  3. package/manifest.json +13 -2
  4. package/package.json +1 -1
  5. package/src/dotnet-build-props.js +163 -0
  6. package/src/dotnet-fs-scan.js +48 -0
  7. package/src/dotnet-scaffold-copier.js +72 -0
  8. package/src/dotnet-scaffold.js +175 -0
  9. package/src/reconcile.js +24 -0
  10. package/template/.claude/skills/adr-writer/SKILL.md +123 -78
  11. package/template/.claude/skills/adr-writer/references/adr-example.md +3 -1
  12. package/template/.claude/skills/adr-writer/references/adr-template.md +37 -46
  13. package/template/.claude/skills/tdd/dotnet-build-config.md +8 -0
  14. package/template/dotnet-scaffold/__SolutionName__.sln +77 -0
  15. package/template/dotnet-scaffold/global.json +6 -0
  16. package/template/dotnet-scaffold/src/__SolutionName__.Api/Program.cs +6 -0
  17. package/template/dotnet-scaffold/src/__SolutionName__.Api/Properties/launchSettings.json +38 -0
  18. package/template/dotnet-scaffold/src/__SolutionName__.Api/__SolutionName__.Api.csproj +15 -0
  19. package/template/dotnet-scaffold/src/__SolutionName__.Api/appsettings.Development.json +8 -0
  20. package/template/dotnet-scaffold/src/__SolutionName__.Api/appsettings.json +9 -0
  21. package/template/dotnet-scaffold/src/__SolutionName__.Application/__SolutionName__.Application.csproj +14 -0
  22. package/template/dotnet-scaffold/src/__SolutionName__.Domain/__SolutionName__.Domain.csproj +24 -0
  23. package/template/dotnet-scaffold/src/__SolutionName__.Infrastructure/__SolutionName__.Infrastructure.csproj +20 -0
  24. package/template/dotnet-scaffold/tests/__SolutionName__.Api.Tests/__SolutionName__.Api.Tests.csproj +31 -0
  25. package/template/dotnet-scaffold/tests/__SolutionName__.Application.Tests/__SolutionName__.Application.Tests.csproj +29 -0
  26. package/template/dotnet-scaffold/tests/__SolutionName__.Domain.Tests/__SolutionName__.Domain.Tests.csproj +29 -0
  27. package/template/dotnet-scaffold/tests/__SolutionName__.Infrastructure.Tests/__SolutionName__.Infrastructure.Tests.csproj +29 -0
package/INSTALLATION.md CHANGED
@@ -14,8 +14,18 @@ an elevation prompt is refused — it falls back to printing the exact manual co
14
14
  | git | core, code-review | `winget install Git.Git` | `brew install git` | `apt/dnf/pacman install git` |
15
15
  | Python 3.8+ | core (skill-creator) | `winget install Python.Python.3.12` | `brew install python@3.12` | `apt install python3 python3-pip` |
16
16
  | Java 8+ | architecture (context-map) | `winget install Microsoft.OpenJDK` | `brew install --cask temurin` | `apt install openjdk-17-jdk` |
17
+ | adr-tools | architecture (adr-writer) | download the [release zip](https://github.com/npryce/adr-tools/releases), copy `src/*` into `C:\Program Files\Git\usr\bin` (Git Bash) | `brew install adr-tools` | `apt install adr-tools` · Arch: AUR `adr-tools` · Fedora/RHEL: clone + add `src/` to `PATH` |
17
18
  | gh | code-review (PR mode) | `winget install GitHub.cli` | `brew install gh` | `apt install gh` |
18
19
 
20
+ > `adr-tools` is a set of bash scripts. On Windows it runs inside Git Bash (already required by
21
+ > core), so the skill works — but `musketeer muster` cannot detect it from `cmd.exe` and will print
22
+ > a manual-install line every run. Expected; ignore it once `adr config` works in Git Bash.
23
+ >
24
+ > Running `musketeer muster` non-interactively (CI, onboarding scripts)? The `apt`/`brew` install
25
+ > commands can prompt for `sudo` or hang waiting for input. Pre-install the prereqs yourself, or
26
+ > set `MUSKETEER_SKIP_INSTALL=1` to print the manual command instead of executing it (red team
27
+ > finding 8).
28
+
19
29
  ## Secrets (never auto-installed)
20
30
 
21
31
  - `ANTHROPIC_API_KEY` — needed only by skill-creator's improve/loop scripts. You will be
package/README.md CHANGED
@@ -38,7 +38,7 @@ musketeers are in this project; promote = upgrade the binary._
38
38
  | **architecture** | off | adr-writer, architecture-characteristic-writer, context-map · CML validation hook |
39
39
  | **hallmark** | off | hallmark, hallmark-explore, hallmark-loop · auditor/explorer agents |
40
40
  | **code-review** | off | code-review skill + code-reviewer agent |
41
- | **dotnet** | off | tdd, knowledge-crunching · EF migration guard hook |
41
+ | **dotnet** | off | tdd, knowledge-crunching · EF migration guard hook · always merges 4 quality-gate props into root `Directory.Build.props` (`MUSKETEER_SKIP_DOTNET_PROPS=1` to skip) · scaffolds a generic `src`/`tests` Clean Architecture skeleton when the project is blank |
42
42
  | **design-docs** | off | inject-design-docs SessionStart hook |
43
43
 
44
44
  The muster starts every musketeer **unselected**, except ones you already installed (pre-checked from
package/manifest.json CHANGED
@@ -66,7 +66,7 @@
66
66
  "order": 1
67
67
  }
68
68
  ],
69
- "prereqs": ["java", "cm-cli"]
69
+ "prereqs": ["java", "cm-cli", "adr-tools"]
70
70
  },
71
71
  "hallmark": {
72
72
  "label": "hallmark",
@@ -97,7 +97,7 @@
97
97
  },
98
98
  "dotnet": {
99
99
  "label": "dotnet",
100
- "description": ".NET extras: tdd, knowledge-crunching + EF migration-guard & CONTEXT auto-load hooks.",
100
+ "description": ".NET extras: tdd, knowledge-crunching + EF migration-guard & CONTEXT auto-load hooks. Muster also always merges 4 quality-gate MSBuild properties into root Directory.Build.props (any dotnet-selected project, set MUSKETEER_SKIP_DOTNET_PROPS=1 to skip) and scaffolds a generic src/tests Clean Architecture skeleton when the project is genuinely blank.",
101
101
  "locked": false,
102
102
  "deps": [],
103
103
  "files": [
@@ -217,6 +217,17 @@
217
217
  "kind": "note",
218
218
  "needs": ["java"]
219
219
  },
220
+ "adr-tools": {
221
+ "detect": "adr config",
222
+ "kind": "package",
223
+ "needs": ["git"],
224
+ "install": {
225
+ "mac": "brew install adr-tools",
226
+ "linux": {
227
+ "apt": "sudo apt-get install -y adr-tools"
228
+ }
229
+ }
230
+ },
220
231
  "gh": {
221
232
  "detect": "gh --version",
222
233
  "kind": "package",
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@phuc1403/musketeer",
3
- "version": "0.2.4",
3
+ "version": "0.3.0",
4
4
  "description": "Distributable custom Claude Code harness — one declarative command scaffolds a curated company of musketeers (skills/agents/hooks) into any project's .claude/.",
5
5
  "type": "commonjs",
6
6
  "bin": {
@@ -0,0 +1,163 @@
1
+ 'use strict';
2
+
3
+ const fs = require('fs');
4
+ const path = require('path');
5
+ const { findDotnetProjectFiles } = require('./dotnet-fs-scan');
6
+
7
+ const QUALITY_GATE_PROPS = {
8
+ TreatWarningsAsErrors: 'true',
9
+ EnforceCodeStyleInBuild: 'true',
10
+ AnalysisLevel: 'latest-recommended',
11
+ AnalysisModeSecurity: 'All',
12
+ };
13
+
14
+ const PROPERTY_GROUP_OPEN_RE = /<PropertyGroup(\s[^>]*)?>/g;
15
+
16
+ function presenceRegex(tag) {
17
+ return new RegExp('<' + tag + '[\\s>]');
18
+ }
19
+
20
+ function buildMinimalFile(props) {
21
+ const lines = Object.entries(props)
22
+ .map(([k, v]) => ` <${k}>${v}</${k}>`)
23
+ .join('\n');
24
+ return `<Project>\n\n <PropertyGroup>\n${lines}\n </PropertyGroup>\n\n</Project>\n`;
25
+ }
26
+
27
+ /**
28
+ * Find the first `<PropertyGroup>` opening tag with no `Condition=` attribute.
29
+ * Accepted limitation (same spirit as the `<Choose>/<When>` non-support): a
30
+ * self-closed `<PropertyGroup />` is matched as "unconditioned" and props get
31
+ * spliced in right after it as dangling siblings, not inside an element MSBuild
32
+ * would evaluate. Vanishingly rare in real Directory.Build.props files.
33
+ * @returns {{exists:boolean, index?:number}} index is the offset right after the opening tag
34
+ */
35
+ function findUnconditionedInsertionPoint(content) {
36
+ PROPERTY_GROUP_OPEN_RE.lastIndex = 0;
37
+ let match;
38
+ while ((match = PROPERTY_GROUP_OPEN_RE.exec(content)) !== null) {
39
+ const attrs = match[1] || '';
40
+ if (!/Condition\s*=/.test(attrs)) {
41
+ return { exists: true, index: match.index + match[0].length };
42
+ }
43
+ }
44
+ return { exists: false };
45
+ }
46
+
47
+ function atomicWrite(filePath, content) {
48
+ const tmp = filePath + '.tmp';
49
+ fs.writeFileSync(tmp, content, 'utf8');
50
+ fs.renameSync(tmp, filePath);
51
+ }
52
+
53
+ /**
54
+ * Additive-only merge of MSBuild properties into a Directory.Build.props-shaped
55
+ * file. Never removes or alters an existing tag (any value, any attributes) —
56
+ * only adds tags that are entirely absent. Writes via temp-file + rename so an
57
+ * interrupted write never leaves a truncated/corrupted file, and skips writing
58
+ * entirely when there is nothing to add.
59
+ * @param {string} filePath
60
+ * @param {Record<string,string>} props ordered {tagName: value} map
61
+ * @param {string} [projectDir] containment root; `filePath` must resolve inside it or
62
+ * mergeProps throws instead of writing. Defaults to `path.dirname(filePath)` when
63
+ * omitted so containment is always enforced against SOME root — never silently skipped.
64
+ * @returns {{created:boolean, added:string[]}}
65
+ */
66
+ function mergeProps(filePath, props, projectDir) {
67
+ const resolved = path.resolve(filePath);
68
+ const root = path.resolve(projectDir || path.dirname(filePath));
69
+ if (resolved !== root && !resolved.startsWith(root + path.sep)) {
70
+ throw new Error(`mergeProps: "${filePath}" resolves outside project root "${projectDir || path.dirname(filePath)}"`);
71
+ }
72
+
73
+ let existing;
74
+ try {
75
+ existing = fs.readFileSync(resolved, 'utf8');
76
+ } catch {
77
+ existing = null;
78
+ }
79
+
80
+ if (existing === null) {
81
+ atomicWrite(resolved, buildMinimalFile(props));
82
+ return { created: true, added: Object.keys(props) };
83
+ }
84
+
85
+ const added = Object.keys(props).filter((tag) => !presenceRegex(tag).test(existing));
86
+ if (added.length === 0) return { created: false, added: [] };
87
+
88
+ const missingLines = added.map((tag) => ` <${tag}>${props[tag]}</${tag}>`).join('\n');
89
+ const insertion = findUnconditionedInsertionPoint(existing);
90
+
91
+ let updated;
92
+ if (insertion.exists) {
93
+ updated = existing.slice(0, insertion.index) + '\n' + missingLines + existing.slice(insertion.index);
94
+ } else {
95
+ const insertBefore = existing.lastIndexOf('</Project>');
96
+ if (insertBefore === -1) {
97
+ throw new Error(`mergeProps: "${resolved}" has no </Project> closing tag`);
98
+ }
99
+ const block = ` <PropertyGroup>\n${missingLines}\n </PropertyGroup>\n`;
100
+ updated = existing.slice(0, insertBefore) + block + existing.slice(insertBefore);
101
+ }
102
+
103
+ atomicWrite(resolved, updated);
104
+ return { created: false, added };
105
+ }
106
+
107
+ /**
108
+ * Ancestor directories of `fileDir`, starting at `fileDir` itself, up to (but
109
+ * excluding) `root`.
110
+ */
111
+ function ancestorsUpTo(fileDir, root) {
112
+ const dirs = [];
113
+ let cur = path.resolve(fileDir);
114
+ while (cur !== root && cur.startsWith(root + path.sep)) {
115
+ dirs.push(cur);
116
+ cur = path.dirname(cur);
117
+ }
118
+ return dirs;
119
+ }
120
+
121
+ /**
122
+ * Merge the 4 quality-gate properties into `projectDir`'s root
123
+ * Directory.Build.props, always (for any dotnet-selected project — confirmed
124
+ * scope, see plan.md §Red Team Review). Detects and logs (but does not block
125
+ * on) nested projects that already have a closer Directory.Build.props between
126
+ * them and root — MSBuild's "nearest file wins" means the root write would be
127
+ * silently invisible to those projects otherwise.
128
+ * @param {string} projectDir
129
+ * @param {(msg:string)=>void} log
130
+ * @returns {{created:boolean, added:string[], shadowed:string[]}}
131
+ */
132
+ function ensureDirectoryBuildProps(projectDir, log) {
133
+ const root = path.resolve(projectDir);
134
+ const filePath = path.join(root, 'Directory.Build.props');
135
+
136
+ const shadowed = [];
137
+ for (const file of findDotnetProjectFiles(root)) {
138
+ const shadow = ancestorsUpTo(path.dirname(file), root).some((dir) =>
139
+ fs.existsSync(path.join(dir, 'Directory.Build.props'))
140
+ );
141
+ if (shadow) shadowed.push(file);
142
+ }
143
+ if (shadowed.length > 0) {
144
+ log(
145
+ `dotnet: warning — ${shadowed.length} project(s) shadowed by a nearer Directory.Build.props, ` +
146
+ `won't see root quality gates: ${shadowed.join(', ')}`
147
+ );
148
+ }
149
+
150
+ const result = mergeProps(filePath, QUALITY_GATE_PROPS, root);
151
+
152
+ if (result.created) {
153
+ log(`dotnet: created Directory.Build.props with ${result.added.length} quality-gate props`);
154
+ } else if (result.added.length > 0) {
155
+ log(`dotnet: added ${result.added.length} quality-gate prop(s) to Directory.Build.props (${result.added.join(', ')})`);
156
+ } else {
157
+ log('dotnet: Directory.Build.props already up to date (additive-once, not force-updated)');
158
+ }
159
+
160
+ return { ...result, shadowed };
161
+ }
162
+
163
+ module.exports = { mergeProps, ensureDirectoryBuildProps, QUALITY_GATE_PROPS };
@@ -0,0 +1,48 @@
1
+ 'use strict';
2
+
3
+ const fs = require('fs');
4
+ const path = require('path');
5
+
6
+ const EXCLUDED_DIR_NAMES = new Set(['.git', 'node_modules', 'bin', 'obj', '.claude']);
7
+ const DOTNET_PROJECT_EXTENSIONS = new Set(['.sln', '.csproj', '.fsproj', '.vbproj']);
8
+
9
+ /**
10
+ * Recursively find every .NET project/solution file under rootDir.
11
+ * Symlink-safe (never follows a symlinked entry, so no cycle risk) and tolerant
12
+ * of unreadable directories — this is a best-effort scan, not a security
13
+ * boundary, so a permission error on one subtree just skips it silently rather
14
+ * than aborting the whole scan.
15
+ * @param {string} rootDir
16
+ * @returns {string[]} absolute paths, one per matched file
17
+ */
18
+ function findDotnetProjectFiles(rootDir) {
19
+ const results = [];
20
+ walk(rootDir, results);
21
+ return results;
22
+ }
23
+
24
+ function walk(dir, results) {
25
+ let entries;
26
+ try {
27
+ entries = fs.readdirSync(dir, { withFileTypes: true });
28
+ } catch {
29
+ return;
30
+ }
31
+ for (const entry of entries) {
32
+ try {
33
+ if (entry.isSymbolicLink()) continue;
34
+ const full = path.join(dir, entry.name);
35
+ if (entry.isDirectory()) {
36
+ if (EXCLUDED_DIR_NAMES.has(entry.name)) continue;
37
+ walk(full, results);
38
+ } else if (entry.isFile()) {
39
+ const ext = path.extname(entry.name).toLowerCase();
40
+ if (DOTNET_PROJECT_EXTENSIONS.has(ext)) results.push(path.resolve(full));
41
+ }
42
+ } catch {
43
+ // Unreadable entry (permission error, race with external deletion, etc.) — skip it.
44
+ }
45
+ }
46
+ }
47
+
48
+ module.exports = { findDotnetProjectFiles, DOTNET_PROJECT_EXTENSIONS, EXCLUDED_DIR_NAMES };
@@ -0,0 +1,72 @@
1
+ 'use strict';
2
+
3
+ const fs = require('fs');
4
+ const path = require('path');
5
+
6
+ function containedPath(root, candidate) {
7
+ const resolvedRoot = path.resolve(root);
8
+ const resolved = path.resolve(candidate);
9
+ if (resolved !== resolvedRoot && !resolved.startsWith(resolvedRoot + path.sep)) return null;
10
+ return resolved;
11
+ }
12
+
13
+ function substitute(text, token, replacement) {
14
+ return text.split(token).join(replacement);
15
+ }
16
+
17
+ /**
18
+ * Recursively copy every file under srcDir into stagingDir, substituting
19
+ * `token` with `replacement` in both path segments and file contents. Every
20
+ * computed destination path is validated to stay under `stagingDir` — rejects
21
+ * (throws) rather than writing outside it.
22
+ * @param {string} srcDir
23
+ * @param {string} stagingDir
24
+ * @param {string} token
25
+ * @param {string} replacement
26
+ */
27
+ function copyTemplateWithTokenSubstitution(srcDir, stagingDir, token, replacement) {
28
+ walk(srcDir, stagingDir, stagingDir, token, replacement);
29
+ }
30
+
31
+ function walk(currentSrc, currentDest, stagingRoot, token, replacement) {
32
+ const entries = fs.readdirSync(currentSrc, { withFileTypes: true });
33
+ for (const entry of entries) {
34
+ const substitutedName = substitute(entry.name, token, replacement);
35
+ const srcPath = path.join(currentSrc, entry.name);
36
+ const destPath = containedPath(stagingRoot, path.join(currentDest, substitutedName));
37
+ if (!destPath) {
38
+ throw new Error(`copyTemplateWithTokenSubstitution: computed path escapes staging dir: "${substitutedName}"`);
39
+ }
40
+
41
+ if (entry.isDirectory()) {
42
+ fs.mkdirSync(destPath, { recursive: true });
43
+ walk(srcPath, destPath, stagingRoot, token, replacement);
44
+ } else if (entry.isFile()) {
45
+ fs.mkdirSync(path.dirname(destPath), { recursive: true });
46
+ const content = fs.readFileSync(srcPath, 'utf8');
47
+ fs.writeFileSync(destPath, substitute(content, token, replacement), 'utf8');
48
+ }
49
+ }
50
+ }
51
+
52
+ /**
53
+ * Move every top-level entry from stagingDir into projectDir. Pre-checks all
54
+ * names for collisions before moving any of them, then moves each via
55
+ * fs.renameSync (atomic per entry on the same filesystem). Throws instead of
56
+ * silently overwriting if a destination name already exists.
57
+ * @param {string} stagingDir
58
+ * @param {string} projectDir
59
+ */
60
+ function promoteStagedScaffold(stagingDir, projectDir) {
61
+ const entries = fs.readdirSync(stagingDir, { withFileTypes: true });
62
+ for (const entry of entries) {
63
+ if (fs.existsSync(path.join(projectDir, entry.name))) {
64
+ throw new Error(`promoteStagedScaffold: "${entry.name}" already exists at destination`);
65
+ }
66
+ }
67
+ for (const entry of entries) {
68
+ fs.renameSync(path.join(stagingDir, entry.name), path.join(projectDir, entry.name));
69
+ }
70
+ }
71
+
72
+ module.exports = { copyTemplateWithTokenSubstitution, promoteStagedScaffold };
@@ -0,0 +1,175 @@
1
+ 'use strict';
2
+
3
+ const fs = require('fs');
4
+ const path = require('path');
5
+ const crypto = require('crypto');
6
+ const { findDotnetProjectFiles } = require('./dotnet-fs-scan');
7
+ const { copyTemplateWithTokenSubstitution, promoteStagedScaffold } = require('./dotnet-scaffold-copier');
8
+
9
+ const OTHER_STACK_MANIFESTS = ['package.json', 'go.mod', 'pom.xml', 'pyproject.toml', 'requirements.txt', 'Cargo.toml'];
10
+
11
+ // Day-1 bootstrap artifacts that don't count as "this repo is occupied" —
12
+ // broadened post-validation-interview so a repo with only initial tooling/IDE
13
+ // config isn't wrongly treated as occupied. Directory.Build.props is here for
14
+ // the same reason `.claude` is: reconcile.js's wiring always runs
15
+ // ensureDirectoryBuildProps() before scaffoldDotnetProject(), so this file
16
+ // already exists by the time this guard runs on every single dotnet muster —
17
+ // including the very first one on a genuinely blank repo.
18
+ const OCCUPANCY_ALLOWLIST = new Set([
19
+ '.git', '.claude', '.gitignore', '.gitattributes', '.editorconfig', '.musketeer.json',
20
+ 'license', 'changelog.md', '.vscode', '.idea', '.github', 'directory.build.props',
21
+ '.ds_store', 'thumbs.db', 'desktop.ini',
22
+ ]);
23
+
24
+ const STAGING_DIR_PREFIX = '.musketeer-dotnet-staging-';
25
+
26
+ const SOLUTION_NAME_TOKEN = '__SolutionName__';
27
+ const GUID_TOKEN_NAMES = [
28
+ '__SlnFolderGuid_Src__',
29
+ '__SlnFolderGuid_Tests__',
30
+ '__ProjectGuid_Domain__',
31
+ '__ProjectGuid_Application__',
32
+ '__ProjectGuid_Infrastructure__',
33
+ '__ProjectGuid_Api__',
34
+ '__ProjectGuid_DomainTests__',
35
+ '__ProjectGuid_ApplicationTests__',
36
+ '__ProjectGuid_InfrastructureTests__',
37
+ '__ProjectGuid_ApiTests__',
38
+ ];
39
+
40
+ function isAllowlistedRootEntry(name) {
41
+ if (OCCUPANCY_ALLOWLIST.has(name.toLowerCase())) return true;
42
+ return /^readme(\..+)?$/i.test(name) || /^license(\..+)?$/i.test(name);
43
+ }
44
+
45
+ /**
46
+ * A genuinely empty directory holds no stack signal — it can't itself mean
47
+ * "this repo is occupied", so it doesn't count against the root listing
48
+ * (e.g. a leftover bare `src`/`tests` pair from an unrelated convention).
49
+ */
50
+ function isEmptyDir(fullPath) {
51
+ try {
52
+ const st = fs.statSync(fullPath);
53
+ return st.isDirectory() && fs.readdirSync(fullPath).length === 0;
54
+ } catch {
55
+ return false;
56
+ }
57
+ }
58
+
59
+ /**
60
+ * Decide whether `projectDir` is genuinely blank enough to scaffold into.
61
+ * Bias toward skipping: a false-skip is safe (user scaffolds manually later),
62
+ * a false-scaffold corrupts a real project.
63
+ * @param {string} projectDir
64
+ * @returns {{scaffold:boolean, reason?:string}}
65
+ */
66
+ function shouldScaffold(projectDir) {
67
+ const dotnetFiles = findDotnetProjectFiles(projectDir);
68
+ if (dotnetFiles.length > 0) {
69
+ return { scaffold: false, reason: `existing .NET project file(s) found (e.g. ${dotnetFiles[0]})` };
70
+ }
71
+
72
+ let rootEntries;
73
+ try {
74
+ rootEntries = fs.readdirSync(projectDir, { withFileTypes: true });
75
+ } catch (err) {
76
+ return { scaffold: false, reason: `cannot read project root: ${err.message}` };
77
+ }
78
+
79
+ const names = rootEntries.map((e) => e.name);
80
+ const lowerNames = new Set(names.map((n) => n.toLowerCase()));
81
+ const otherStackManifest = OTHER_STACK_MANIFESTS.find((m) => lowerNames.has(m.toLowerCase()));
82
+ if (otherStackManifest) {
83
+ return { scaffold: false, reason: `other-stack manifest found: ${otherStackManifest}` };
84
+ }
85
+
86
+ const unexpected = names.filter((n) => {
87
+ if (isAllowlistedRootEntry(n)) return false;
88
+ if (n.startsWith(STAGING_DIR_PREFIX)) return false; // leftover from a crashed prior scaffold attempt
89
+ return !isEmptyDir(path.join(projectDir, n));
90
+ });
91
+ if (unexpected.length > 0) {
92
+ return { scaffold: false, reason: `project root already occupied by: ${unexpected.join(', ')}` };
93
+ }
94
+
95
+ return { scaffold: true };
96
+ }
97
+
98
+ /**
99
+ * Derive a PascalCase solution name from a project directory's basename.
100
+ * Unicode-aware (splits on `\p{L}\p{N}` boundaries, not ASCII `\w`) so
101
+ * non-Latin-script folder names aren't collapsed to the generic fallback.
102
+ * @param {string} projectDir
103
+ * @returns {string}
104
+ */
105
+ function deriveSolutionName(projectDir) {
106
+ const base = path.basename(path.resolve(projectDir));
107
+ const segments = base.split(/[^\p{L}\p{N}]+/u).filter(Boolean);
108
+ const pascal = segments
109
+ .map((s) => {
110
+ const chars = Array.from(s);
111
+ return chars[0].toUpperCase() + chars.slice(1).join('');
112
+ })
113
+ .join('');
114
+
115
+ if (!pascal) return 'App';
116
+ if (/^\p{N}/u.test(pascal)) return `App${pascal}`;
117
+ return pascal;
118
+ }
119
+
120
+ function substituteFreshGuids(stagingDir, solutionName) {
121
+ const slnPath = path.join(stagingDir, `${solutionName}.sln`);
122
+ let content = fs.readFileSync(slnPath, 'utf8');
123
+ for (const tokenName of GUID_TOKEN_NAMES) {
124
+ content = content.split(tokenName).join(`{${crypto.randomUUID().toUpperCase()}}`);
125
+ }
126
+ fs.writeFileSync(slnPath, content, 'utf8');
127
+ }
128
+
129
+ /**
130
+ * Scaffold a generic src/tests Clean Architecture skeleton into `projectDir`,
131
+ * only when `shouldScaffold` confirms it's genuinely blank. No-op (not an
132
+ * error) when the guard trips. Stages the whole copy in a temp directory and
133
+ * only promotes it into `projectDir` on full success, so a mid-copy failure
134
+ * leaves the real project untouched.
135
+ * @param {string} projectDir
136
+ * @param {string} templateScaffoldDir
137
+ * @param {(msg:string)=>void} log
138
+ * @returns {{scaffolded:boolean, solutionName?:string, reason?:string}}
139
+ */
140
+ function scaffoldDotnetProject(projectDir, templateScaffoldDir, log) {
141
+ const guard = shouldScaffold(projectDir);
142
+ if (!guard.scaffold) {
143
+ log(`dotnet: scaffold skipped (${guard.reason})`);
144
+ return { scaffolded: false, reason: guard.reason };
145
+ }
146
+
147
+ const solutionName = deriveSolutionName(projectDir);
148
+ // Stage under projectDir itself (not the OS temp dir) so promoteStagedScaffold's
149
+ // fs.renameSync is guaranteed same-filesystem — renaming across devices/volumes
150
+ // (common for OS temp dirs vs. a project on another drive, or in containers where
151
+ // /tmp is a separate overlay from the bind-mounted workspace) throws EXDEV and would
152
+ // silently defeat scaffolding forever on those topologies.
153
+ const stagingDir = fs.mkdtempSync(path.join(projectDir, STAGING_DIR_PREFIX));
154
+
155
+ try {
156
+ copyTemplateWithTokenSubstitution(templateScaffoldDir, stagingDir, SOLUTION_NAME_TOKEN, solutionName);
157
+ substituteFreshGuids(stagingDir, solutionName);
158
+ promoteStagedScaffold(stagingDir, projectDir);
159
+ } finally {
160
+ fs.rmSync(stagingDir, { recursive: true, force: true });
161
+ }
162
+
163
+ log(`dotnet: scaffolded src/tests skeleton as "${solutionName}"`);
164
+ return { scaffolded: true, solutionName };
165
+ }
166
+
167
+ /**
168
+ * Absolute path to the packaged template/dotnet-scaffold directory.
169
+ * @returns {string}
170
+ */
171
+ function defaultTemplateScaffoldDir() {
172
+ return path.join(__dirname, '..', 'template', 'dotnet-scaffold');
173
+ }
174
+
175
+ module.exports = { shouldScaffold, deriveSolutionName, scaffoldDotnetProject, defaultTemplateScaffoldDir };
package/src/reconcile.js CHANGED
@@ -12,12 +12,16 @@ const provisioner = require('./provisioner');
12
12
  * templateDir, muster, provisioner, log, now) to stay hermetic.
13
13
  */
14
14
  function defaultDeps() {
15
+ const dotnetScaffold = require('./dotnet-scaffold');
15
16
  return {
16
17
  manifest: loadManifest(),
17
18
  templateDir: templateClaudeDir(),
18
19
  settingsMerger,
19
20
  provisioner,
20
21
  muster: require('./muster'),
22
+ dotnetBuildProps: require('./dotnet-build-props'),
23
+ dotnetScaffold,
24
+ dotnetScaffoldTemplateDir: dotnetScaffold.defaultTemplateScaffoldDir(),
21
25
  now: () => new Date().toISOString(),
22
26
  log: (m) => process.stdout.write(m + '\n'),
23
27
  };
@@ -55,6 +59,26 @@ async function run(opts, overrides = {}) {
55
59
  copier.apply(plan, d.templateDir, projectDir);
56
60
  d.settingsMerger.generate(desired, projectDir);
57
61
 
62
+ // Deliberate exception to the .claude/-only invariant (see copier.js's containedPath
63
+ // comment) — the dotnet company deterministically touches the project's real root,
64
+ // not just .claude/.
65
+ if (desired.ids.includes('dotnet')) {
66
+ if (process.env.MUSKETEER_SKIP_DOTNET_PROPS) {
67
+ d.log('dotnet: Directory.Build.props merge skipped (MUSKETEER_SKIP_DOTNET_PROPS set)');
68
+ } else {
69
+ try {
70
+ d.dotnetBuildProps.ensureDirectoryBuildProps(projectDir, d.log);
71
+ } catch (err) {
72
+ d.log(`dotnet: Directory.Build.props merge failed (${err.message}) — continuing`);
73
+ }
74
+ }
75
+ try {
76
+ d.dotnetScaffold.scaffoldDotnetProject(projectDir, d.dotnetScaffoldTemplateDir, d.log);
77
+ } catch (err) {
78
+ d.log(`dotnet: src/tests scaffold failed (${err.message}) — continuing`);
79
+ }
80
+ }
81
+
58
82
  await d.provisioner.ensure(desired.prereqs, manifest, {
59
83
  projectDir,
60
84
  templateDir: d.templateDir,