create-sdd-project 0.7.0 → 0.8.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/README.md CHANGED
@@ -168,14 +168,62 @@ npx create-sdd-project --doctor
168
168
 
169
169
  Exit code 1 if errors found — useful for CI pipelines.
170
170
 
171
+ ### Eject (Uninstall SDD)
172
+
173
+ Cleanly remove all SDD DevFlow files from your project:
174
+
175
+ ```bash
176
+ cd your-project
177
+ npx create-sdd-project --eject
178
+ ```
179
+
180
+ ```
181
+ šŸ—‘ļø SDD DevFlow Eject
182
+
183
+ Installed version: 0.7.0
184
+ AI tools: Claude Code + Gemini
185
+ Project type: backend
186
+
187
+ Will remove:
188
+ āœ— .claude/agents/ template agents
189
+ āœ— .claude/skills/
190
+ āœ— .claude/hooks/
191
+ āœ— .claude/settings.json
192
+ āœ— .gemini/ entire directory
193
+ āœ— ai-specs/ standards
194
+ āœ— AGENTS.md
195
+ āœ— CLAUDE.md / GEMINI.md
196
+ āœ— .env.example
197
+ āœ— .sdd-version
198
+ āœ— .github/workflows/ci.yml (SDD-generated)
199
+
200
+ Will preserve:
201
+ ⊘ .claude/agents/my-agent.md (custom agent)
202
+ ⊘ .claude/settings.local.json (personal settings)
203
+ ⊘ docs/ (project notes, specs, tickets)
204
+
205
+ Proceed? (y/N)
206
+ ```
207
+
208
+ **What gets removed:** All SDD-generated files (agents, skills, hooks, standards, configs, CI workflow).
209
+
210
+ **What is always preserved:** Custom agents, custom commands, personal settings, project documentation (`docs/`), customized CI workflows (without SDD marker).
211
+
212
+ For non-interactive eject: `npx create-sdd-project --eject --yes`
213
+
214
+ Preview what would be removed: `npx create-sdd-project --eject --diff`
215
+
216
+ To re-install later: `npx create-sdd-project --init`
217
+
171
218
  ### Preview Changes (--diff)
172
219
 
173
- See what `--init` or `--upgrade` would do without modifying anything:
220
+ See what `--init`, `--upgrade`, or `--eject` would do without modifying anything:
174
221
 
175
222
  ```bash
176
223
  cd your-existing-project
177
224
  npx create-sdd-project --init --diff # Preview init
178
225
  npx create-sdd-project --upgrade --diff # Preview upgrade
226
+ npx create-sdd-project --eject --diff # Preview eject
179
227
  ```
180
228
 
181
229
  Shows detected stack, files that would be created/replaced/preserved, and standards status — zero filesystem writes.
package/bin/cli.js CHANGED
@@ -11,6 +11,7 @@ const isInit = args.includes('--init');
11
11
  const isUpgrade = args.includes('--upgrade');
12
12
  const isDoctor = args.includes('--doctor');
13
13
  const isForce = args.includes('--force');
14
+ const isEject = args.includes('--eject');
14
15
  const isDiff = args.includes('--diff');
15
16
 
16
17
  async function main() {
@@ -20,6 +21,9 @@ async function main() {
20
21
  if (isDoctor) {
21
22
  return runDoctorCmd();
22
23
  }
24
+ if (isEject) {
25
+ return runEject();
26
+ }
23
27
  if (isUpgrade) {
24
28
  return runUpgrade();
25
29
  }
@@ -220,11 +224,64 @@ async function runUpgrade() {
220
224
  generateUpgrade(config);
221
225
  }
222
226
 
227
+ async function runEject() {
228
+ const readline = require('readline');
229
+ const {
230
+ collectEjectInventory,
231
+ buildEjectSummary,
232
+ generateEject,
233
+ } = require('../lib/eject-generator');
234
+
235
+ const cwd = process.cwd();
236
+
237
+ // Validate: must be in an existing project
238
+ if (!fs.existsSync(path.join(cwd, 'package.json'))) {
239
+ console.error('Error: No package.json found in current directory.');
240
+ console.error('The --eject flag must be run from inside an existing project.');
241
+ process.exit(1);
242
+ }
243
+
244
+ // Validate: SDD must be installed
245
+ if (!fs.existsSync(path.join(cwd, 'ai-specs'))) {
246
+ console.error('Error: ai-specs/ directory not found.');
247
+ console.error('SDD DevFlow does not appear to be installed. Nothing to eject.');
248
+ process.exit(1);
249
+ }
250
+
251
+ // Validate: no project name with --eject
252
+ if (projectName) {
253
+ console.error('Error: Cannot specify a project name with --eject.');
254
+ console.error('Usage: create-sdd-project --eject');
255
+ process.exit(1);
256
+ }
257
+
258
+ const state = collectEjectInventory(cwd);
259
+
260
+ if (!useDefaults) {
261
+ console.log('\n' + buildEjectSummary(state));
262
+ console.log('');
263
+
264
+ const rl = readline.createInterface({ input: process.stdin, output: process.stdout });
265
+ const answer = await new Promise((resolve) => {
266
+ rl.question(' Proceed? (y/N) ', resolve);
267
+ });
268
+ rl.close();
269
+
270
+ if (answer.toLowerCase() !== 'y' && answer.toLowerCase() !== 'yes') {
271
+ console.log('\nEject cancelled.\n');
272
+ return;
273
+ }
274
+ }
275
+
276
+ generateEject(cwd);
277
+ }
278
+
223
279
  async function runDiff() {
224
- if (!isInit && !isUpgrade) {
225
- console.error('Error: --diff must be combined with --init or --upgrade.');
280
+ if (!isInit && !isUpgrade && !isEject) {
281
+ console.error('Error: --diff must be combined with --init, --upgrade, or --eject.');
226
282
  console.error('Usage: create-sdd-project --init --diff');
227
283
  console.error(' create-sdd-project --upgrade --diff');
284
+ console.error(' create-sdd-project --eject --diff');
228
285
  process.exit(1);
229
286
  }
230
287
 
@@ -240,6 +297,22 @@ async function runDiff() {
240
297
  process.exit(1);
241
298
  }
242
299
 
300
+ if (isEject) {
301
+ // Same validation as --eject
302
+ if (!fs.existsSync(path.join(cwd, 'ai-specs'))) {
303
+ console.error('Error: ai-specs/ directory not found.');
304
+ console.error('SDD DevFlow does not appear to be installed. Nothing to eject.');
305
+ process.exit(1);
306
+ }
307
+
308
+ const { runEjectDiffReport } = require('../lib/diff-generator');
309
+ const { collectEjectInventory } = require('../lib/eject-generator');
310
+
311
+ const state = collectEjectInventory(cwd);
312
+ runEjectDiffReport(state);
313
+ return;
314
+ }
315
+
243
316
  const { scan } = require('../lib/scanner');
244
317
  const { buildInitDefaultConfig } = require('../lib/init-wizard');
245
318
  const { runInitDiffReport, runUpgradeDiffReport } = require('../lib/diff-generator');
@@ -271,4 +271,17 @@ function runUpgradeDiffReport(config, state) {
271
271
  console.log(lines.join('\n'));
272
272
  }
273
273
 
274
- module.exports = { runInitDiffReport, runUpgradeDiffReport };
274
+ // ── Eject Diff ──────────────────────────────────────────────
275
+
276
+ function runEjectDiffReport(state) {
277
+ const { buildEjectSummary } = require('./eject-generator');
278
+
279
+ const lines = [];
280
+ lines.push('\nšŸ” SDD DevFlow — Preview (--eject)\n');
281
+ lines.push(buildEjectSummary(state).split('\n').slice(1).join('\n')); // skip the emoji header (already have our own)
282
+ lines.push('\n No files removed. Run without --diff to apply.\n');
283
+
284
+ console.log(lines.join('\n'));
285
+ }
286
+
287
+ module.exports = { runInitDiffReport, runUpgradeDiffReport, runEjectDiffReport };
@@ -0,0 +1,352 @@
1
+ 'use strict';
2
+
3
+ const fs = require('fs');
4
+ const path = require('path');
5
+ const { TEMPLATE_AGENTS } = require('./config');
6
+ const {
7
+ readInstalledVersion,
8
+ detectAiTools,
9
+ detectProjectType,
10
+ collectCustomAgents,
11
+ collectCustomCommands,
12
+ } = require('./upgrade-generator');
13
+
14
+ /**
15
+ * Collect inventory of what eject would remove vs preserve.
16
+ */
17
+ function collectEjectInventory(cwd) {
18
+ const installedVersion = readInstalledVersion(cwd);
19
+ const aiTools = detectAiTools(cwd);
20
+ const projectType = detectProjectType(cwd);
21
+ const customAgents = collectCustomAgents(cwd);
22
+ const customCommands = collectCustomCommands(cwd);
23
+
24
+ const hasSettingsLocal = fs.existsSync(path.join(cwd, '.claude', 'settings.local.json'));
25
+ const hasDocsDir = fs.existsSync(path.join(cwd, 'docs'));
26
+
27
+ // Detect SDD-generated CI workflow
28
+ let ciIsSddGenerated = false;
29
+ const ciPath = path.join(cwd, '.github', 'workflows', 'ci.yml');
30
+ if (fs.existsSync(ciPath)) {
31
+ const ciContent = fs.readFileSync(ciPath, 'utf8');
32
+ ciIsSddGenerated = ciContent.includes('Generated by SDD DevFlow');
33
+ }
34
+
35
+ // Detect SDD entries in .gitignore
36
+ let gitignoreHasSddEntries = false;
37
+ const gitignorePath = path.join(cwd, '.gitignore');
38
+ if (fs.existsSync(gitignorePath)) {
39
+ const content = fs.readFileSync(gitignorePath, 'utf8');
40
+ gitignoreHasSddEntries = content.includes('# SDD DevFlow');
41
+ }
42
+
43
+ return {
44
+ installedVersion,
45
+ aiTools,
46
+ projectType,
47
+ customAgents,
48
+ customCommands,
49
+ hasSettingsLocal,
50
+ hasDocsDir,
51
+ ciPath: fs.existsSync(ciPath) ? ciPath : null,
52
+ ciIsSddGenerated,
53
+ gitignoreHasSddEntries,
54
+ };
55
+ }
56
+
57
+ /**
58
+ * Build the eject summary for display.
59
+ */
60
+ function buildEjectSummary(state) {
61
+ const lines = [];
62
+ lines.push('šŸ—‘ļø SDD DevFlow Eject\n');
63
+ lines.push(` Installed version: ${state.installedVersion}`);
64
+ lines.push(` AI tools: ${state.aiTools === 'both' ? 'Claude Code + Gemini' : state.aiTools === 'claude' ? 'Claude Code' : 'Gemini'}`);
65
+ lines.push(` Project type: ${state.projectType}\n`);
66
+
67
+ // Will remove
68
+ lines.push(' Will remove:');
69
+ if (state.aiTools !== 'gemini') {
70
+ lines.push(' āœ— .claude/agents/ template agents');
71
+ lines.push(' āœ— .claude/skills/');
72
+ lines.push(' āœ— .claude/hooks/');
73
+ lines.push(' āœ— .claude/settings.json');
74
+ }
75
+ if (state.aiTools !== 'claude') {
76
+ lines.push(' āœ— .gemini/ entire directory');
77
+ }
78
+ lines.push(' āœ— ai-specs/ standards');
79
+ lines.push(' āœ— AGENTS.md');
80
+ if (state.aiTools !== 'gemini') lines.push(' āœ— CLAUDE.md');
81
+ if (state.aiTools !== 'claude') lines.push(' āœ— GEMINI.md');
82
+ lines.push(' āœ— .env.example');
83
+ lines.push(' āœ— .sdd-version');
84
+ if (state.ciIsSddGenerated) {
85
+ lines.push(' āœ— .github/workflows/ci.yml (SDD-generated)');
86
+ }
87
+
88
+ // Will preserve
89
+ const preserveItems = [];
90
+ if (state.customAgents.length > 0) {
91
+ for (const a of state.customAgents) {
92
+ preserveItems.push(`${a.relativePath} (custom agent)`);
93
+ }
94
+ }
95
+ if (state.customCommands.length > 0) {
96
+ for (const c of state.customCommands) {
97
+ preserveItems.push(`${c.relativePath} (custom command)`);
98
+ }
99
+ }
100
+ if (state.hasSettingsLocal) {
101
+ preserveItems.push('.claude/settings.local.json (personal settings)');
102
+ }
103
+ if (state.hasDocsDir) {
104
+ preserveItems.push('docs/ (project notes, specs, tickets)');
105
+ }
106
+ if (state.ciPath && !state.ciIsSddGenerated) {
107
+ preserveItems.push('.github/workflows/ci.yml (customized — not SDD-generated)');
108
+ }
109
+
110
+ if (preserveItems.length > 0) {
111
+ lines.push('\n Will preserve:');
112
+ for (const item of preserveItems) {
113
+ lines.push(` ⊘ ${item}`);
114
+ }
115
+ }
116
+
117
+ // Will modify
118
+ if (state.gitignoreHasSddEntries) {
119
+ lines.push('\n Will modify:');
120
+ lines.push(' ~ .gitignore remove SDD DevFlow section');
121
+ }
122
+
123
+ return lines.join('\n');
124
+ }
125
+
126
+ /**
127
+ * Execute the eject: remove all SDD-owned files, preserve user work.
128
+ */
129
+ function generateEject(cwd) {
130
+ const state = collectEjectInventory(cwd);
131
+ let removed = 0;
132
+ let preserved = 0;
133
+
134
+ console.log(`\nEjecting SDD DevFlow from ${path.basename(cwd)}...\n`);
135
+
136
+ // --- 1. Remove template agents (preserve custom) ---
137
+ for (const toolDir of ['.claude', '.gemini']) {
138
+ const agentsDir = path.join(cwd, toolDir, 'agents');
139
+ if (!fs.existsSync(agentsDir)) continue;
140
+
141
+ const files = fs.readdirSync(agentsDir);
142
+ for (const file of files) {
143
+ if (TEMPLATE_AGENTS.includes(file)) {
144
+ fs.unlinkSync(path.join(agentsDir, file));
145
+ removed++;
146
+ }
147
+ }
148
+
149
+ // Remove agents dir if empty
150
+ if (fs.existsSync(agentsDir) && fs.readdirSync(agentsDir).length === 0) {
151
+ fs.rmSync(agentsDir, { recursive: true });
152
+ }
153
+ }
154
+ step('Removed template agents');
155
+
156
+ // --- 2. Remove skills ---
157
+ for (const toolDir of ['.claude', '.gemini']) {
158
+ const skillsDir = path.join(cwd, toolDir, 'skills');
159
+ if (fs.existsSync(skillsDir)) {
160
+ removed += countFilesRecursive(skillsDir);
161
+ fs.rmSync(skillsDir, { recursive: true });
162
+ }
163
+ }
164
+ step('Removed skills');
165
+
166
+ // --- 3. Remove hooks ---
167
+ const hooksDir = path.join(cwd, '.claude', 'hooks');
168
+ if (fs.existsSync(hooksDir)) {
169
+ removed += countFilesRecursive(hooksDir);
170
+ fs.rmSync(hooksDir, { recursive: true });
171
+ }
172
+ step('Removed hooks');
173
+
174
+ // --- 4. Handle commands ---
175
+ // .claude/commands: only remove .gitkeep, keep custom commands
176
+ const claudeCommandsDir = path.join(cwd, '.claude', 'commands');
177
+ if (fs.existsSync(claudeCommandsDir)) {
178
+ const gitkeepPath = path.join(claudeCommandsDir, '.gitkeep');
179
+ if (fs.existsSync(gitkeepPath)) {
180
+ fs.unlinkSync(gitkeepPath);
181
+ removed++;
182
+ }
183
+ // Remove dir if empty
184
+ if (fs.readdirSync(claudeCommandsDir).length === 0) {
185
+ fs.rmSync(claudeCommandsDir, { recursive: true });
186
+ }
187
+ }
188
+
189
+ // .gemini/commands: remove entirely (SDD-owned TOML commands)
190
+ const geminiCommandsDir = path.join(cwd, '.gemini', 'commands');
191
+ if (fs.existsSync(geminiCommandsDir)) {
192
+ removed += countFilesRecursive(geminiCommandsDir);
193
+ fs.rmSync(geminiCommandsDir, { recursive: true });
194
+ }
195
+
196
+ // --- 5. Handle .claude/settings.json ---
197
+ const settingsPath = path.join(cwd, '.claude', 'settings.json');
198
+ if (fs.existsSync(settingsPath)) {
199
+ try {
200
+ const settings = JSON.parse(fs.readFileSync(settingsPath, 'utf8'));
201
+ delete settings.hooks;
202
+
203
+ // If nothing meaningful remains, delete the file
204
+ const remainingKeys = Object.keys(settings);
205
+ if (remainingKeys.length === 0) {
206
+ fs.unlinkSync(settingsPath);
207
+ removed++;
208
+ } else {
209
+ // Keep permissions and other user settings
210
+ fs.writeFileSync(settingsPath, JSON.stringify(settings, null, 2) + '\n', 'utf8');
211
+ preserved++;
212
+ }
213
+ } catch {
214
+ // Corrupted JSON — remove it
215
+ fs.unlinkSync(settingsPath);
216
+ removed++;
217
+ }
218
+ }
219
+ step('Handled settings.json');
220
+
221
+ // --- 6. Remove .gemini/ entirely (styles, settings) ---
222
+ // styles
223
+ const geminiStylesDir = path.join(cwd, '.gemini', 'styles');
224
+ if (fs.existsSync(geminiStylesDir)) {
225
+ removed += countFilesRecursive(geminiStylesDir);
226
+ fs.rmSync(geminiStylesDir, { recursive: true });
227
+ }
228
+ // settings.json
229
+ const geminiSettingsPath = path.join(cwd, '.gemini', 'settings.json');
230
+ if (fs.existsSync(geminiSettingsPath)) {
231
+ fs.unlinkSync(geminiSettingsPath);
232
+ removed++;
233
+ }
234
+
235
+ // --- 7. Remove ai-specs/ entirely ---
236
+ const aiSpecsDir = path.join(cwd, 'ai-specs');
237
+ if (fs.existsSync(aiSpecsDir)) {
238
+ removed += countFilesRecursive(aiSpecsDir);
239
+ fs.rmSync(aiSpecsDir, { recursive: true });
240
+ }
241
+ step('Removed ai-specs/');
242
+
243
+ // --- 8. Remove top-level SDD files ---
244
+ const topLevelFiles = ['AGENTS.md', 'CLAUDE.md', 'GEMINI.md', '.env.example', '.sdd-version'];
245
+ for (const file of topLevelFiles) {
246
+ const filePath = path.join(cwd, file);
247
+ if (fs.existsSync(filePath)) {
248
+ fs.unlinkSync(filePath);
249
+ removed++;
250
+ }
251
+ }
252
+ step('Removed AGENTS.md, CLAUDE.md, GEMINI.md, .env.example, .sdd-version');
253
+
254
+ // --- 9. Handle CI workflow ---
255
+ if (state.ciIsSddGenerated) {
256
+ fs.unlinkSync(state.ciPath);
257
+ removed++;
258
+ step('Removed .github/workflows/ci.yml (SDD-generated)');
259
+
260
+ // Clean empty dirs
261
+ const workflowsDir = path.join(cwd, '.github', 'workflows');
262
+ if (fs.existsSync(workflowsDir) && fs.readdirSync(workflowsDir).length === 0) {
263
+ fs.rmSync(workflowsDir, { recursive: true });
264
+ }
265
+ const githubDir = path.join(cwd, '.github');
266
+ if (fs.existsSync(githubDir) && fs.readdirSync(githubDir).length === 0) {
267
+ fs.rmSync(githubDir, { recursive: true });
268
+ }
269
+ } else if (state.ciPath) {
270
+ preserved++;
271
+ step('Preserved .github/workflows/ci.yml (customized)');
272
+ }
273
+
274
+ // --- 10. Clean .gitignore ---
275
+ if (state.gitignoreHasSddEntries) {
276
+ const gitignorePath = path.join(cwd, '.gitignore');
277
+ let content = fs.readFileSync(gitignorePath, 'utf8');
278
+ // Remove the SDD DevFlow section (tolerant of leading/trailing newlines)
279
+ content = content.replace(/\n?# SDD DevFlow\ndocs\/tickets\/\*\.md\n!docs\/tickets\/\.gitkeep\n?/, '');
280
+ fs.writeFileSync(gitignorePath, content, 'utf8');
281
+ step('Cleaned .gitignore');
282
+ }
283
+
284
+ // --- 11. Clean empty parent directories ---
285
+ for (const dir of ['.claude', '.gemini']) {
286
+ const dirPath = path.join(cwd, dir);
287
+ if (fs.existsSync(dirPath)) {
288
+ cleanEmptyDirs(dirPath);
289
+ }
290
+ }
291
+
292
+ // Count preserved items
293
+ preserved += state.customAgents.length + state.customCommands.length;
294
+ if (state.hasSettingsLocal) preserved++;
295
+ if (state.hasDocsDir) preserved++;
296
+
297
+ // --- Show result ---
298
+ console.log(`\nāœ… SDD DevFlow ejected (was v${state.installedVersion})\n`);
299
+ console.log(` Removed: ${removed} SDD files`);
300
+ if (preserved > 0) {
301
+ const items = [];
302
+ if (state.customAgents.length > 0) items.push(`${state.customAgents.length} custom agent(s)`);
303
+ if (state.customCommands.length > 0) items.push(`${state.customCommands.length} custom command(s)`);
304
+ if (state.hasSettingsLocal) items.push('personal settings');
305
+ if (state.hasDocsDir) items.push('docs/');
306
+ if (state.ciPath && !state.ciIsSddGenerated) items.push('CI workflow (customized)');
307
+ console.log(` Preserved: ${items.join(', ')}`);
308
+ }
309
+ console.log('\n To re-install later: npx create-sdd-project --init\n');
310
+ }
311
+
312
+ function step(msg) {
313
+ console.log(` āœ“ ${msg}`);
314
+ }
315
+
316
+ function countFilesRecursive(dir) {
317
+ let count = 0;
318
+ for (const entry of fs.readdirSync(dir, { withFileTypes: true })) {
319
+ if (entry.isDirectory()) {
320
+ count += countFilesRecursive(path.join(dir, entry.name));
321
+ } else {
322
+ count++;
323
+ }
324
+ }
325
+ return count;
326
+ }
327
+
328
+ /**
329
+ * Remove a directory if empty, then check parent recursively.
330
+ */
331
+ function cleanEmptyDirs(dirPath) {
332
+ if (!fs.existsSync(dirPath)) return;
333
+
334
+ // First clean subdirectories
335
+ const entries = fs.readdirSync(dirPath, { withFileTypes: true });
336
+ for (const entry of entries) {
337
+ if (entry.isDirectory()) {
338
+ cleanEmptyDirs(path.join(dirPath, entry.name));
339
+ }
340
+ }
341
+
342
+ // Then remove if empty
343
+ if (fs.existsSync(dirPath) && fs.readdirSync(dirPath).length === 0) {
344
+ fs.rmSync(dirPath, { recursive: true });
345
+ }
346
+ }
347
+
348
+ module.exports = {
349
+ collectEjectInventory,
350
+ buildEjectSummary,
351
+ generateEject,
352
+ };
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "create-sdd-project",
3
- "version": "0.7.0",
3
+ "version": "0.8.0",
4
4
  "description": "Create a new SDD DevFlow project with AI-assisted development workflow",
5
5
  "bin": {
6
6
  "create-sdd-project": "bin/cli.js"