worclaude 2.5.0 → 2.6.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/CHANGELOG.md +65 -0
- package/package.json +4 -2
- package/src/commands/doctor.js +4 -6
- package/src/commands/init.js +9 -6
- package/src/commands/scan.js +126 -0
- package/src/commands/setup-state.js +113 -0
- package/src/commands/status.js +6 -4
- package/src/commands/upgrade.js +32 -20
- package/src/core/drift-checks.js +2 -1
- package/src/core/file-categorizer.js +38 -0
- package/src/core/merger.js +14 -7
- package/src/core/migration.js +77 -1
- package/src/core/project-scanner/detectors/ci.js +51 -0
- package/src/core/project-scanner/detectors/deployment.js +32 -0
- package/src/core/project-scanner/detectors/env-variables.js +78 -0
- package/src/core/project-scanner/detectors/external-apis.js +45 -0
- package/src/core/project-scanner/detectors/frameworks.js +56 -0
- package/src/core/project-scanner/detectors/language.js +131 -0
- package/src/core/project-scanner/detectors/linting.js +58 -0
- package/src/core/project-scanner/detectors/monorepo.js +93 -0
- package/src/core/project-scanner/detectors/orm.js +72 -0
- package/src/core/project-scanner/detectors/package-manager.js +57 -0
- package/src/core/project-scanner/detectors/readme.js +123 -0
- package/src/core/project-scanner/detectors/scripts.js +128 -0
- package/src/core/project-scanner/detectors/spec-docs.js +110 -0
- package/src/core/project-scanner/detectors/testing.js +88 -0
- package/src/core/project-scanner/index.js +126 -0
- package/src/core/project-scanner/manifests.js +120 -0
- package/src/core/remover.js +6 -3
- package/src/core/scaffolder.js +1 -0
- package/src/core/setup-state.js +213 -0
- package/src/index.js +39 -0
- package/templates/commands/setup.md +512 -113
|
@@ -0,0 +1,126 @@
|
|
|
1
|
+
import path from 'node:path';
|
|
2
|
+
import { fileURLToPath, pathToFileURL } from 'node:url';
|
|
3
|
+
import fs from 'fs-extra';
|
|
4
|
+
import { dirExists } from '../../utils/file.js';
|
|
5
|
+
import { resetManifestCache } from './manifests.js';
|
|
6
|
+
|
|
7
|
+
const __dirname = path.dirname(fileURLToPath(import.meta.url));
|
|
8
|
+
const DETECTOR_TIMEOUT_MS = 5000;
|
|
9
|
+
const SCHEMA_VERSION = 1;
|
|
10
|
+
|
|
11
|
+
async function loadDetectorsFromDisk() {
|
|
12
|
+
const detectorsDir = path.join(__dirname, 'detectors');
|
|
13
|
+
const entries = await fs.readdir(detectorsDir, { withFileTypes: true });
|
|
14
|
+
const detectorFiles = entries.filter(
|
|
15
|
+
(e) =>
|
|
16
|
+
e.isFile() && !e.name.startsWith('.') && !e.name.startsWith('_') && e.name.endsWith('.js')
|
|
17
|
+
);
|
|
18
|
+
return Promise.all(
|
|
19
|
+
detectorFiles.map(async (entry) => {
|
|
20
|
+
const fileUrl = pathToFileURL(path.join(detectorsDir, entry.name)).href;
|
|
21
|
+
const mod = await import(fileUrl);
|
|
22
|
+
if (typeof mod.default !== 'function') {
|
|
23
|
+
throw new Error(`Detector ${entry.name} must export a default function`);
|
|
24
|
+
}
|
|
25
|
+
return { name: path.basename(entry.name, '.js'), fn: mod.default };
|
|
26
|
+
})
|
|
27
|
+
);
|
|
28
|
+
}
|
|
29
|
+
|
|
30
|
+
function normalizeOverride(overrideDetectors) {
|
|
31
|
+
return overrideDetectors.map((entry, index) => {
|
|
32
|
+
if (typeof entry === 'function') {
|
|
33
|
+
return { name: entry.name || `override-${index}`, fn: entry };
|
|
34
|
+
}
|
|
35
|
+
if (entry && typeof entry.fn === 'function') {
|
|
36
|
+
return { name: entry.name || `override-${index}`, fn: entry.fn };
|
|
37
|
+
}
|
|
38
|
+
throw new Error(`overrideDetectors[${index}] must be a function or { name, fn } object`);
|
|
39
|
+
});
|
|
40
|
+
}
|
|
41
|
+
|
|
42
|
+
function withTimeout(promise, ms) {
|
|
43
|
+
let timeoutId;
|
|
44
|
+
const timeout = new Promise((_, reject) => {
|
|
45
|
+
timeoutId = setTimeout(() => reject(new Error('timeout')), ms);
|
|
46
|
+
});
|
|
47
|
+
return Promise.race([promise, timeout]).finally(() => clearTimeout(timeoutId));
|
|
48
|
+
}
|
|
49
|
+
|
|
50
|
+
async function runDetector(detector, projectRoot) {
|
|
51
|
+
try {
|
|
52
|
+
const results = await withTimeout(
|
|
53
|
+
Promise.resolve().then(() => detector.fn(projectRoot)),
|
|
54
|
+
DETECTOR_TIMEOUT_MS
|
|
55
|
+
);
|
|
56
|
+
if (!Array.isArray(results)) {
|
|
57
|
+
return {
|
|
58
|
+
results: [],
|
|
59
|
+
error: {
|
|
60
|
+
detector: detector.name,
|
|
61
|
+
kind: 'exception',
|
|
62
|
+
message: `Detector did not return an array (got ${typeof results})`,
|
|
63
|
+
},
|
|
64
|
+
};
|
|
65
|
+
}
|
|
66
|
+
const annotated = results.map((r) => ({ ...r, detector: detector.name }));
|
|
67
|
+
return { results: annotated, error: null };
|
|
68
|
+
} catch (err) {
|
|
69
|
+
const kind = err && err.message === 'timeout' ? 'timeout' : 'exception';
|
|
70
|
+
const message =
|
|
71
|
+
kind === 'timeout'
|
|
72
|
+
? `Detector exceeded ${DETECTOR_TIMEOUT_MS}ms timeout`
|
|
73
|
+
: String(err && err.message ? err.message : err);
|
|
74
|
+
return { results: [], error: { detector: detector.name, kind, message } };
|
|
75
|
+
}
|
|
76
|
+
}
|
|
77
|
+
|
|
78
|
+
export async function scanProject(projectRoot, options = {}) {
|
|
79
|
+
if (!projectRoot || typeof projectRoot !== 'string') {
|
|
80
|
+
throw new Error('scanProject requires a projectRoot string');
|
|
81
|
+
}
|
|
82
|
+
const absoluteRoot = path.resolve(projectRoot);
|
|
83
|
+
if (!(await dirExists(absoluteRoot))) {
|
|
84
|
+
throw new Error(`projectRoot not found: ${absoluteRoot}`);
|
|
85
|
+
}
|
|
86
|
+
|
|
87
|
+
resetManifestCache();
|
|
88
|
+
|
|
89
|
+
const detectors = options.overrideDetectors
|
|
90
|
+
? normalizeOverride(options.overrideDetectors)
|
|
91
|
+
: await loadDetectorsFromDisk();
|
|
92
|
+
|
|
93
|
+
const settled = await Promise.all(detectors.map((d) => runDetector(d, absoluteRoot)));
|
|
94
|
+
|
|
95
|
+
const results = [];
|
|
96
|
+
const errors = [];
|
|
97
|
+
for (const { results: r, error } of settled) {
|
|
98
|
+
for (const item of r) results.push(item);
|
|
99
|
+
if (error) errors.push(error);
|
|
100
|
+
}
|
|
101
|
+
|
|
102
|
+
return {
|
|
103
|
+
schemaVersion: SCHEMA_VERSION,
|
|
104
|
+
generatedAt: new Date().toISOString(),
|
|
105
|
+
projectRoot: absoluteRoot,
|
|
106
|
+
results,
|
|
107
|
+
errors,
|
|
108
|
+
};
|
|
109
|
+
}
|
|
110
|
+
|
|
111
|
+
export async function writeDetectionReport(report, projectRoot) {
|
|
112
|
+
if (!report || typeof report !== 'object') {
|
|
113
|
+
throw new Error('writeDetectionReport requires a report object');
|
|
114
|
+
}
|
|
115
|
+
if (report.schemaVersion !== SCHEMA_VERSION) {
|
|
116
|
+
throw new Error(`Unsupported schemaVersion: ${report.schemaVersion}`);
|
|
117
|
+
}
|
|
118
|
+
if (!Array.isArray(report.results) || !Array.isArray(report.errors)) {
|
|
119
|
+
throw new Error('Report must have results and errors arrays');
|
|
120
|
+
}
|
|
121
|
+
const cacheDir = path.join(projectRoot, '.claude', 'cache');
|
|
122
|
+
await fs.ensureDir(cacheDir);
|
|
123
|
+
const filePath = path.join(cacheDir, 'detection-report.json');
|
|
124
|
+
await fs.writeFile(filePath, JSON.stringify(report, null, 2) + '\n', 'utf-8');
|
|
125
|
+
return filePath;
|
|
126
|
+
}
|
|
@@ -0,0 +1,120 @@
|
|
|
1
|
+
import path from 'node:path';
|
|
2
|
+
import { parse as parseToml } from 'smol-toml';
|
|
3
|
+
import { readFile, fileExists } from '../../utils/file.js';
|
|
4
|
+
|
|
5
|
+
// Per-scan caches — cleared by resetManifestCache() at the start of each scanProject call.
|
|
6
|
+
// Without this, detectors that independently call readPackageJson / readPyprojectToml would
|
|
7
|
+
// each re-read and re-parse the same files (up to 7× package.json, 5× pyproject.toml per scan).
|
|
8
|
+
const pkgCache = new Map();
|
|
9
|
+
const pyprojectCache = new Map();
|
|
10
|
+
|
|
11
|
+
export function resetManifestCache() {
|
|
12
|
+
pkgCache.clear();
|
|
13
|
+
pyprojectCache.clear();
|
|
14
|
+
}
|
|
15
|
+
|
|
16
|
+
export async function readPackageJson(projectRoot) {
|
|
17
|
+
if (pkgCache.has(projectRoot)) return pkgCache.get(projectRoot);
|
|
18
|
+
const filePath = path.join(projectRoot, 'package.json');
|
|
19
|
+
let result = null;
|
|
20
|
+
if (await fileExists(filePath)) {
|
|
21
|
+
try {
|
|
22
|
+
const raw = await readFile(filePath);
|
|
23
|
+
result = JSON.parse(raw.replace(/^/, ''));
|
|
24
|
+
} catch {
|
|
25
|
+
result = null;
|
|
26
|
+
}
|
|
27
|
+
}
|
|
28
|
+
pkgCache.set(projectRoot, result);
|
|
29
|
+
return result;
|
|
30
|
+
}
|
|
31
|
+
|
|
32
|
+
export async function readPyprojectToml(projectRoot) {
|
|
33
|
+
if (pyprojectCache.has(projectRoot)) return pyprojectCache.get(projectRoot);
|
|
34
|
+
const filePath = path.join(projectRoot, 'pyproject.toml');
|
|
35
|
+
let result = null;
|
|
36
|
+
if (await fileExists(filePath)) {
|
|
37
|
+
try {
|
|
38
|
+
const raw = await readFile(filePath);
|
|
39
|
+
result = parseToml(raw);
|
|
40
|
+
} catch {
|
|
41
|
+
result = null;
|
|
42
|
+
}
|
|
43
|
+
}
|
|
44
|
+
pyprojectCache.set(projectRoot, result);
|
|
45
|
+
return result;
|
|
46
|
+
}
|
|
47
|
+
|
|
48
|
+
function normalizeDepMap(input) {
|
|
49
|
+
if (!input || typeof input !== 'object') return {};
|
|
50
|
+
if (Array.isArray(input)) {
|
|
51
|
+
const out = {};
|
|
52
|
+
for (const entry of input) {
|
|
53
|
+
if (typeof entry === 'string') {
|
|
54
|
+
const match = entry.match(/^\s*([A-Za-z0-9_.\-[\]]+)/);
|
|
55
|
+
if (match) out[match[1]] = entry;
|
|
56
|
+
}
|
|
57
|
+
}
|
|
58
|
+
return out;
|
|
59
|
+
}
|
|
60
|
+
return input;
|
|
61
|
+
}
|
|
62
|
+
|
|
63
|
+
export function flattenPyprojectDeps(pyproject) {
|
|
64
|
+
if (!pyproject) return {};
|
|
65
|
+
const deps = {};
|
|
66
|
+
const assign = (map) => {
|
|
67
|
+
const normalized = normalizeDepMap(map);
|
|
68
|
+
for (const [k, v] of Object.entries(normalized)) {
|
|
69
|
+
// Skip Poetry's interpreter pin — `python = "^3.11"` is the runtime, not a dep.
|
|
70
|
+
if (k === 'python') continue;
|
|
71
|
+
if (!(k in deps)) deps[k] = typeof v === 'string' ? v : '';
|
|
72
|
+
}
|
|
73
|
+
};
|
|
74
|
+
|
|
75
|
+
if (pyproject.project) {
|
|
76
|
+
if (pyproject.project.dependencies) assign(pyproject.project.dependencies);
|
|
77
|
+
if (pyproject.project['optional-dependencies']) {
|
|
78
|
+
for (const group of Object.values(pyproject.project['optional-dependencies'])) {
|
|
79
|
+
assign(group);
|
|
80
|
+
}
|
|
81
|
+
}
|
|
82
|
+
}
|
|
83
|
+
|
|
84
|
+
if (pyproject.tool && pyproject.tool.poetry) {
|
|
85
|
+
if (pyproject.tool.poetry.dependencies) assign(pyproject.tool.poetry.dependencies);
|
|
86
|
+
if (pyproject.tool.poetry.group) {
|
|
87
|
+
for (const group of Object.values(pyproject.tool.poetry.group)) {
|
|
88
|
+
if (group && group.dependencies) assign(group.dependencies);
|
|
89
|
+
}
|
|
90
|
+
}
|
|
91
|
+
}
|
|
92
|
+
|
|
93
|
+
if (pyproject['dependency-groups']) {
|
|
94
|
+
for (const group of Object.values(pyproject['dependency-groups'])) {
|
|
95
|
+
assign(group);
|
|
96
|
+
}
|
|
97
|
+
}
|
|
98
|
+
|
|
99
|
+
return deps;
|
|
100
|
+
}
|
|
101
|
+
|
|
102
|
+
export async function getAllDeps(projectRoot) {
|
|
103
|
+
const pkg = await readPackageJson(projectRoot);
|
|
104
|
+
const pyproject = await readPyprojectToml(projectRoot);
|
|
105
|
+
const js = {};
|
|
106
|
+
if (pkg) {
|
|
107
|
+
for (const k of Object.keys(pkg.dependencies || {})) js[k] = pkg.dependencies[k];
|
|
108
|
+
for (const k of Object.keys(pkg.devDependencies || {})) js[k] = pkg.devDependencies[k];
|
|
109
|
+
}
|
|
110
|
+
const py = flattenPyprojectDeps(pyproject);
|
|
111
|
+
return { js, py, hasPackageJson: pkg !== null, hasPyproject: pyproject !== null };
|
|
112
|
+
}
|
|
113
|
+
|
|
114
|
+
export function depMatches(deps, pattern) {
|
|
115
|
+
if (pattern.endsWith('/*')) {
|
|
116
|
+
const prefix = pattern.slice(0, -1);
|
|
117
|
+
return Object.keys(deps).some((name) => name.startsWith(prefix));
|
|
118
|
+
}
|
|
119
|
+
return Object.prototype.hasOwnProperty.call(deps, pattern);
|
|
120
|
+
}
|
package/src/core/remover.js
CHANGED
|
@@ -9,6 +9,7 @@ import {
|
|
|
9
9
|
listFilesRecursive,
|
|
10
10
|
removeDirectory,
|
|
11
11
|
} from '../utils/file.js';
|
|
12
|
+
import { WORKFLOW_REF_DIR } from './file-categorizer.js';
|
|
12
13
|
|
|
13
14
|
/**
|
|
14
15
|
* Classify .claude/ files into safe-to-delete, modified, missing, and user-owned.
|
|
@@ -55,8 +56,9 @@ export async function classifyClaudeFiles(projectRoot, meta) {
|
|
|
55
56
|
const relKey = path.relative(claudeDir, fp).split(path.sep).join('/');
|
|
56
57
|
if (allTrackedKeys.has(relKey)) continue;
|
|
57
58
|
|
|
58
|
-
//
|
|
59
|
-
|
|
59
|
+
// Upgrade artifacts — safe to delete: anything under workflow-ref/ (new
|
|
60
|
+
// location) or the legacy `.workflow-ref.md` sibling suffix.
|
|
61
|
+
if (relKey.startsWith(`${WORKFLOW_REF_DIR}/`) || relKey.endsWith('.workflow-ref.md')) {
|
|
60
62
|
safeToDelete.push(relKey);
|
|
61
63
|
} else {
|
|
62
64
|
userOwned.push(relKey);
|
|
@@ -115,7 +117,7 @@ export async function removeTrackedFiles(projectRoot, fileKeys) {
|
|
|
115
117
|
}
|
|
116
118
|
|
|
117
119
|
// Clean up empty subdirectories
|
|
118
|
-
for (const subdir of ['agents', 'commands', 'skills']) {
|
|
120
|
+
for (const subdir of ['agents', 'commands', 'skills', WORKFLOW_REF_DIR]) {
|
|
119
121
|
const dirPath = path.join(claudeDir, subdir);
|
|
120
122
|
if (await dirExists(dirPath)) {
|
|
121
123
|
const remaining = await listFilesRecursive(dirPath);
|
|
@@ -189,6 +191,7 @@ export async function cleanGitignore(projectRoot) {
|
|
|
189
191
|
'.claude/workflow-meta.json',
|
|
190
192
|
'.claude/worktrees/',
|
|
191
193
|
'.claude/.stop-hook-active',
|
|
194
|
+
'.claude/cache/',
|
|
192
195
|
]);
|
|
193
196
|
|
|
194
197
|
const filtered = lines.filter((line) => !REMOVE_LINES.has(line.trim()));
|
package/src/core/scaffolder.js
CHANGED
|
@@ -0,0 +1,213 @@
|
|
|
1
|
+
import path from 'node:path';
|
|
2
|
+
import fs from 'fs-extra';
|
|
3
|
+
|
|
4
|
+
export const SCHEMA_VERSION = 1;
|
|
5
|
+
|
|
6
|
+
export const STATE_NAMES = [
|
|
7
|
+
'INIT',
|
|
8
|
+
'SCAN',
|
|
9
|
+
'CONFIRM_HIGH',
|
|
10
|
+
'CONFIRM_MEDIUM',
|
|
11
|
+
'INTERVIEW_STORY',
|
|
12
|
+
'INTERVIEW_ARCH',
|
|
13
|
+
'INTERVIEW_FEATURES',
|
|
14
|
+
'INTERVIEW_WORKFLOW',
|
|
15
|
+
'INTERVIEW_CONVENTIONS',
|
|
16
|
+
'INTERVIEW_VERIFICATION',
|
|
17
|
+
'WRITE',
|
|
18
|
+
'DONE',
|
|
19
|
+
];
|
|
20
|
+
|
|
21
|
+
export const QUESTION_IDS = [
|
|
22
|
+
'story.audience',
|
|
23
|
+
'story.problem',
|
|
24
|
+
'story.analogs',
|
|
25
|
+
'arch.classification',
|
|
26
|
+
'arch.modules',
|
|
27
|
+
'arch.entities',
|
|
28
|
+
'arch.external_apis',
|
|
29
|
+
'arch.stack_rationale',
|
|
30
|
+
'features.core',
|
|
31
|
+
'features.nice_to_have',
|
|
32
|
+
'features.non_goals',
|
|
33
|
+
'workflow.new_dev_steps',
|
|
34
|
+
'workflow.env_values',
|
|
35
|
+
'conventions.patterns',
|
|
36
|
+
'conventions.errors',
|
|
37
|
+
'conventions.logging',
|
|
38
|
+
'conventions.api_format',
|
|
39
|
+
'conventions.naming',
|
|
40
|
+
'conventions.rules',
|
|
41
|
+
'verification.manual',
|
|
42
|
+
'verification.staging',
|
|
43
|
+
'verification.required_checks',
|
|
44
|
+
];
|
|
45
|
+
|
|
46
|
+
// Unchecked-namespace routing: rejected high-confidence fields re-asked in the
|
|
47
|
+
// state matching the field's natural section. Source of truth for
|
|
48
|
+
// interviewAnswers key validation.
|
|
49
|
+
export const UNCHECKED_ROUTING = {
|
|
50
|
+
readme: 'story',
|
|
51
|
+
specDocs: 'story',
|
|
52
|
+
packageManager: 'arch',
|
|
53
|
+
language: 'arch',
|
|
54
|
+
frameworks: 'arch',
|
|
55
|
+
orm: 'arch',
|
|
56
|
+
monorepo: 'arch',
|
|
57
|
+
deployment: 'arch',
|
|
58
|
+
externalApis: 'arch',
|
|
59
|
+
scripts: 'workflow',
|
|
60
|
+
envVariables: 'workflow',
|
|
61
|
+
linting: 'workflow',
|
|
62
|
+
ci: 'workflow',
|
|
63
|
+
testing: 'verification',
|
|
64
|
+
};
|
|
65
|
+
|
|
66
|
+
export function getStateFilePath(projectRoot) {
|
|
67
|
+
return path.join(projectRoot, '.claude', 'cache', 'setup-state.json');
|
|
68
|
+
}
|
|
69
|
+
|
|
70
|
+
function isUncheckedKey(key) {
|
|
71
|
+
const match = /^(story|arch|workflow|verification)\.unchecked\.([A-Za-z][A-Za-z0-9]*)$/.exec(key);
|
|
72
|
+
if (!match) return false;
|
|
73
|
+
const [, prefix, field] = match;
|
|
74
|
+
return UNCHECKED_ROUTING[field] === prefix;
|
|
75
|
+
}
|
|
76
|
+
|
|
77
|
+
function isKnownQuestionId(key) {
|
|
78
|
+
return QUESTION_IDS.includes(key);
|
|
79
|
+
}
|
|
80
|
+
|
|
81
|
+
function isKnownStateName(name) {
|
|
82
|
+
return STATE_NAMES.includes(name);
|
|
83
|
+
}
|
|
84
|
+
|
|
85
|
+
function validateState(state) {
|
|
86
|
+
if (!state || typeof state !== 'object' || Array.isArray(state)) {
|
|
87
|
+
throw new Error('state must be an object');
|
|
88
|
+
}
|
|
89
|
+
if (state.schemaVersion !== SCHEMA_VERSION) {
|
|
90
|
+
throw new Error(
|
|
91
|
+
`Unsupported schemaVersion: ${state.schemaVersion} (expected ${SCHEMA_VERSION})`
|
|
92
|
+
);
|
|
93
|
+
}
|
|
94
|
+
if (typeof state.startedAt !== 'string' || !state.startedAt) {
|
|
95
|
+
throw new Error('state.startedAt must be a non-empty ISO string');
|
|
96
|
+
}
|
|
97
|
+
if (typeof state.updatedAt !== 'string' || !state.updatedAt) {
|
|
98
|
+
throw new Error('state.updatedAt must be a non-empty ISO string');
|
|
99
|
+
}
|
|
100
|
+
if (!isKnownStateName(state.currentState)) {
|
|
101
|
+
throw new Error(`Unknown currentState: ${state.currentState}`);
|
|
102
|
+
}
|
|
103
|
+
if (typeof state.detectionReportPath !== 'string') {
|
|
104
|
+
throw new Error('state.detectionReportPath must be a string');
|
|
105
|
+
}
|
|
106
|
+
if (!Array.isArray(state.highConfirmedAccepted)) {
|
|
107
|
+
throw new Error('state.highConfirmedAccepted must be an array');
|
|
108
|
+
}
|
|
109
|
+
if (!Array.isArray(state.highConfirmedRejected)) {
|
|
110
|
+
throw new Error('state.highConfirmedRejected must be an array');
|
|
111
|
+
}
|
|
112
|
+
if (!state.mediumResolved || typeof state.mediumResolved !== 'object') {
|
|
113
|
+
throw new Error('state.mediumResolved must be an object');
|
|
114
|
+
}
|
|
115
|
+
for (const [k, v] of Object.entries(state.mediumResolved)) {
|
|
116
|
+
if (typeof v !== 'string') {
|
|
117
|
+
throw new Error(`state.mediumResolved.${k} must be a string`);
|
|
118
|
+
}
|
|
119
|
+
}
|
|
120
|
+
if (!state.interviewAnswers || typeof state.interviewAnswers !== 'object') {
|
|
121
|
+
throw new Error('state.interviewAnswers must be an object');
|
|
122
|
+
}
|
|
123
|
+
for (const [k, v] of Object.entries(state.interviewAnswers)) {
|
|
124
|
+
if (typeof v !== 'string') {
|
|
125
|
+
throw new Error(`state.interviewAnswers.${k} must be a string`);
|
|
126
|
+
}
|
|
127
|
+
if (!isKnownQuestionId(k) && !isUncheckedKey(k)) {
|
|
128
|
+
throw new Error(
|
|
129
|
+
`state.interviewAnswers has unknown key: ${k} (must be in QuestionId enumeration or a routed <state>.unchecked.<field> key)`
|
|
130
|
+
);
|
|
131
|
+
}
|
|
132
|
+
}
|
|
133
|
+
if (state.writeResults !== undefined) {
|
|
134
|
+
if (!state.writeResults || typeof state.writeResults !== 'object') {
|
|
135
|
+
throw new Error('state.writeResults must be an object when present');
|
|
136
|
+
}
|
|
137
|
+
for (const [k, v] of Object.entries(state.writeResults)) {
|
|
138
|
+
if (typeof v !== 'string') {
|
|
139
|
+
throw new Error(`state.writeResults.${k} must be a string`);
|
|
140
|
+
}
|
|
141
|
+
}
|
|
142
|
+
}
|
|
143
|
+
}
|
|
144
|
+
|
|
145
|
+
export async function loadSetupState(projectRoot) {
|
|
146
|
+
const filePath = getStateFilePath(projectRoot);
|
|
147
|
+
if (!(await fs.pathExists(filePath))) {
|
|
148
|
+
return null;
|
|
149
|
+
}
|
|
150
|
+
const raw = await fs.readFile(filePath, 'utf-8');
|
|
151
|
+
let parsed;
|
|
152
|
+
try {
|
|
153
|
+
parsed = JSON.parse(raw);
|
|
154
|
+
} catch (err) {
|
|
155
|
+
throw new Error(`Corrupt setup-state.json: ${err.message}`);
|
|
156
|
+
}
|
|
157
|
+
validateState(parsed);
|
|
158
|
+
return parsed;
|
|
159
|
+
}
|
|
160
|
+
|
|
161
|
+
export async function saveSetupState(projectRoot, state) {
|
|
162
|
+
const filePath = getStateFilePath(projectRoot);
|
|
163
|
+
await fs.ensureDir(path.dirname(filePath));
|
|
164
|
+
|
|
165
|
+
const merged = {
|
|
166
|
+
...state,
|
|
167
|
+
startedAt: (await readExistingStartedAt(filePath)) || state.startedAt,
|
|
168
|
+
updatedAt: new Date().toISOString(),
|
|
169
|
+
};
|
|
170
|
+
|
|
171
|
+
validateState(merged);
|
|
172
|
+
await fs.writeFile(filePath, JSON.stringify(merged, null, 2) + '\n', 'utf-8');
|
|
173
|
+
return filePath;
|
|
174
|
+
}
|
|
175
|
+
|
|
176
|
+
// Preserves startedAt across re-saves so the timeline reflects when setup
|
|
177
|
+
// actually began, not when the most recent mutation happened. Returns null
|
|
178
|
+
// for fresh saves. Corrupt JSON is treated as "no preserved value"; other
|
|
179
|
+
// I/O errors propagate so we never silently lose timeline data.
|
|
180
|
+
async function readExistingStartedAt(filePath) {
|
|
181
|
+
let raw;
|
|
182
|
+
try {
|
|
183
|
+
raw = await fs.readFile(filePath, 'utf-8');
|
|
184
|
+
} catch (err) {
|
|
185
|
+
if (err.code === 'ENOENT') return null;
|
|
186
|
+
throw err;
|
|
187
|
+
}
|
|
188
|
+
try {
|
|
189
|
+
const existing = JSON.parse(raw);
|
|
190
|
+
return typeof existing.startedAt === 'string' && existing.startedAt ? existing.startedAt : null;
|
|
191
|
+
} catch (err) {
|
|
192
|
+
if (err instanceof SyntaxError) return null;
|
|
193
|
+
throw err;
|
|
194
|
+
}
|
|
195
|
+
}
|
|
196
|
+
|
|
197
|
+
export async function clearSetupState(projectRoot) {
|
|
198
|
+
const filePath = getStateFilePath(projectRoot);
|
|
199
|
+
await fs.remove(filePath);
|
|
200
|
+
}
|
|
201
|
+
|
|
202
|
+
export function isSetupStateStale(state, staleHours = 24) {
|
|
203
|
+
if (!state || typeof state.updatedAt !== 'string') {
|
|
204
|
+
throw new Error('isSetupStateStale requires a state with updatedAt');
|
|
205
|
+
}
|
|
206
|
+
const updated = Date.parse(state.updatedAt);
|
|
207
|
+
if (Number.isNaN(updated)) {
|
|
208
|
+
throw new Error(`Invalid updatedAt: ${state.updatedAt}`);
|
|
209
|
+
}
|
|
210
|
+
const ageMs = Date.now() - updated;
|
|
211
|
+
const staleMs = staleHours * 60 * 60 * 1000;
|
|
212
|
+
return ageMs >= staleMs;
|
|
213
|
+
}
|
package/src/index.js
CHANGED
|
@@ -10,6 +10,8 @@ import { restoreCommand } from './commands/restore.js';
|
|
|
10
10
|
import { diffCommand } from './commands/diff.js';
|
|
11
11
|
import { deleteCommand } from './commands/delete.js';
|
|
12
12
|
import { doctorCommand } from './commands/doctor.js';
|
|
13
|
+
import { scanCommand } from './commands/scan.js';
|
|
14
|
+
import { setupStateCommand } from './commands/setup-state.js';
|
|
13
15
|
|
|
14
16
|
const program = new Command();
|
|
15
17
|
|
|
@@ -62,4 +64,41 @@ program
|
|
|
62
64
|
.option('--json', 'Output results as JSON')
|
|
63
65
|
.action((options) => doctorCommand(options));
|
|
64
66
|
|
|
67
|
+
program
|
|
68
|
+
.command('scan')
|
|
69
|
+
.description('Scan project for detectable facts (writes .claude/cache/detection-report.json)')
|
|
70
|
+
.option('--path <dir>', 'Project root to scan', process.cwd())
|
|
71
|
+
.option('--json', 'Print the detection report as JSON to stdout')
|
|
72
|
+
.option('--quiet', 'Suppress human-readable summary (still writes the report file)')
|
|
73
|
+
.action((options) => scanCommand(options));
|
|
74
|
+
|
|
75
|
+
const setupState = program
|
|
76
|
+
.command('setup-state')
|
|
77
|
+
.description('Inspect or mutate the /setup state file (.claude/cache/setup-state.json)');
|
|
78
|
+
|
|
79
|
+
setupState
|
|
80
|
+
.command('show')
|
|
81
|
+
.description('Print the state file as JSON, or "no state" if absent')
|
|
82
|
+
.option('--path <dir>', 'Project root', process.cwd())
|
|
83
|
+
.action((options) => setupStateCommand('show', options));
|
|
84
|
+
|
|
85
|
+
setupState
|
|
86
|
+
.command('save')
|
|
87
|
+
.description('Read a JSON state from stdin, validate, and persist')
|
|
88
|
+
.option('--stdin', 'Read JSON from stdin (required)')
|
|
89
|
+
.option('--path <dir>', 'Project root', process.cwd())
|
|
90
|
+
.action((options) => setupStateCommand('save', options));
|
|
91
|
+
|
|
92
|
+
setupState
|
|
93
|
+
.command('reset')
|
|
94
|
+
.description('Delete the state file (idempotent)')
|
|
95
|
+
.option('--path <dir>', 'Project root', process.cwd())
|
|
96
|
+
.action((options) => setupStateCommand('reset', options));
|
|
97
|
+
|
|
98
|
+
setupState
|
|
99
|
+
.command('resume-info')
|
|
100
|
+
.description('Print state/age/staleness summary, or "no state" if absent')
|
|
101
|
+
.option('--path <dir>', 'Project root', process.cwd())
|
|
102
|
+
.action((options) => setupStateCommand('resume-info', options));
|
|
103
|
+
|
|
65
104
|
program.parse();
|