@vitronai/themis 0.1.14 → 1.2.1
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 +37 -0
- package/README.md +28 -10
- package/docs/agents-adoption.md +71 -3
- package/docs/api.md +8 -4
- package/docs/migration.md +9 -5
- package/docs/roadmap.md +1 -1
- package/docs/schemas/migration-report.v1.json +122 -0
- package/docs/vscode-extension.md +2 -0
- package/package.json +8 -2
- package/scripts/claude-hook.js +153 -0
- package/src/cli.js +80 -7
- package/src/generate.js +95 -1
- package/src/init.js +122 -4
- package/src/migrate.js +127 -5
- package/templates/AGENTS.themis.md +2 -2
- package/templates/CLAUDE.themis.md +43 -0
- package/templates/claude-commands/themis-fix.md +14 -0
- package/templates/claude-commands/themis-generate.md +14 -0
- package/templates/claude-commands/themis-migrate.md +18 -0
- package/templates/claude-commands/themis-test.md +12 -0
- package/templates/claude-skill/SKILL.md +94 -0
- package/templates/cursorrules.themis.md +28 -0
- package/themis.ai.json +16 -0
|
@@ -0,0 +1,153 @@
|
|
|
1
|
+
#!/usr/bin/env node
|
|
2
|
+
|
|
3
|
+
// Themis Claude Code PostToolUse hook.
|
|
4
|
+
//
|
|
5
|
+
// This script is invoked by Claude Code after Edit/Write/MultiEdit tool calls.
|
|
6
|
+
// It reads the tool input from stdin, decides whether the edit is worth
|
|
7
|
+
// re-running tests for, and if so runs `themis test --reporter agent` (using
|
|
8
|
+
// --rerun-failed when there is a prior failed-tests artifact). When tests
|
|
9
|
+
// fail, the JSON failure payload is written to stderr and the script exits
|
|
10
|
+
// with code 2 — Claude Code feeds that back into the model so it can fix
|
|
11
|
+
// failures using the structured `failures[].cluster` and
|
|
12
|
+
// `failures[].repairHints` fields.
|
|
13
|
+
//
|
|
14
|
+
// Wire it up in `.claude/settings.json`:
|
|
15
|
+
//
|
|
16
|
+
// {
|
|
17
|
+
// "hooks": {
|
|
18
|
+
// "PostToolUse": [
|
|
19
|
+
// {
|
|
20
|
+
// "matcher": "Edit|Write|MultiEdit",
|
|
21
|
+
// "hooks": [
|
|
22
|
+
// { "type": "command", "command": "node node_modules/@vitronai/themis/scripts/claude-hook.js" }
|
|
23
|
+
// ]
|
|
24
|
+
// }
|
|
25
|
+
// ]
|
|
26
|
+
// }
|
|
27
|
+
// }
|
|
28
|
+
//
|
|
29
|
+
// Disable temporarily by setting THEMIS_HOOK_DISABLED=1 in your environment.
|
|
30
|
+
// Disable permanently by removing the entry from .claude/settings.json.
|
|
31
|
+
|
|
32
|
+
'use strict';
|
|
33
|
+
|
|
34
|
+
const fs = require('fs');
|
|
35
|
+
const path = require('path');
|
|
36
|
+
const { spawnSync } = require('child_process');
|
|
37
|
+
|
|
38
|
+
const SOURCE_EXT = new Set(['.js', '.jsx', '.ts', '.tsx', '.mjs', '.cjs']);
|
|
39
|
+
const IGNORED_PATH_SEGMENTS = ['.themis', '__themis__', 'node_modules', '.git'];
|
|
40
|
+
|
|
41
|
+
function exitSilent() {
|
|
42
|
+
process.exit(0);
|
|
43
|
+
}
|
|
44
|
+
|
|
45
|
+
function readStdinSync() {
|
|
46
|
+
try {
|
|
47
|
+
return fs.readFileSync(0, 'utf8');
|
|
48
|
+
} catch (_err) {
|
|
49
|
+
return '';
|
|
50
|
+
}
|
|
51
|
+
}
|
|
52
|
+
|
|
53
|
+
function parsePayload(raw) {
|
|
54
|
+
if (!raw) return null;
|
|
55
|
+
try {
|
|
56
|
+
return JSON.parse(raw);
|
|
57
|
+
} catch (_err) {
|
|
58
|
+
return null;
|
|
59
|
+
}
|
|
60
|
+
}
|
|
61
|
+
|
|
62
|
+
function extractFilePath(payload) {
|
|
63
|
+
if (!payload || typeof payload !== 'object') return null;
|
|
64
|
+
const input = payload.tool_input;
|
|
65
|
+
if (!input || typeof input !== 'object') return null;
|
|
66
|
+
if (typeof input.file_path === 'string') return input.file_path;
|
|
67
|
+
// MultiEdit nests edits in an array but still uses the top-level file_path.
|
|
68
|
+
return null;
|
|
69
|
+
}
|
|
70
|
+
|
|
71
|
+
function isWorthRerunning(filePath, cwd) {
|
|
72
|
+
if (!filePath) return false;
|
|
73
|
+
const normalized = path.isAbsolute(filePath) ? filePath : path.resolve(cwd, filePath);
|
|
74
|
+
const relative = path.relative(cwd, normalized);
|
|
75
|
+
if (relative.startsWith('..')) return false;
|
|
76
|
+
|
|
77
|
+
const segments = relative.split(path.sep);
|
|
78
|
+
for (const segment of segments) {
|
|
79
|
+
if (IGNORED_PATH_SEGMENTS.includes(segment)) return false;
|
|
80
|
+
}
|
|
81
|
+
|
|
82
|
+
const ext = path.extname(normalized).toLowerCase();
|
|
83
|
+
if (!SOURCE_EXT.has(ext)) return false;
|
|
84
|
+
|
|
85
|
+
return true;
|
|
86
|
+
}
|
|
87
|
+
|
|
88
|
+
function hasFailedTestsArtifact(cwd) {
|
|
89
|
+
const candidates = [
|
|
90
|
+
path.join(cwd, '.themis', 'runs', 'failed-tests.json'),
|
|
91
|
+
path.join(cwd, '.themis', 'failed-tests.json')
|
|
92
|
+
];
|
|
93
|
+
return candidates.some((candidate) => fs.existsSync(candidate));
|
|
94
|
+
}
|
|
95
|
+
|
|
96
|
+
function findThemisBin(cwd) {
|
|
97
|
+
// Prefer the locally installed bin so the hook does not depend on `npx`
|
|
98
|
+
// resolution behavior or network state.
|
|
99
|
+
const localBin = path.join(cwd, 'node_modules', '.bin', 'themis');
|
|
100
|
+
if (fs.existsSync(localBin)) return { command: localBin, args: [] };
|
|
101
|
+
const localScript = path.join(cwd, 'node_modules', '@vitronai', 'themis', 'bin', 'themis.js');
|
|
102
|
+
if (fs.existsSync(localScript)) return { command: process.execPath, args: [localScript] };
|
|
103
|
+
return { command: 'npx', args: ['--no-install', 'themis'] };
|
|
104
|
+
}
|
|
105
|
+
|
|
106
|
+
function main() {
|
|
107
|
+
if (process.env.THEMIS_HOOK_DISABLED) exitSilent();
|
|
108
|
+
|
|
109
|
+
const raw = readStdinSync();
|
|
110
|
+
const payload = parsePayload(raw);
|
|
111
|
+
const cwd = (payload && typeof payload.cwd === 'string' && payload.cwd) || process.cwd();
|
|
112
|
+
const filePath = extractFilePath(payload);
|
|
113
|
+
|
|
114
|
+
if (!isWorthRerunning(filePath, cwd)) exitSilent();
|
|
115
|
+
|
|
116
|
+
const themis = findThemisBin(cwd);
|
|
117
|
+
const args = [...themis.args, 'test', '--reporter', 'agent'];
|
|
118
|
+
if (hasFailedTestsArtifact(cwd)) args.push('--rerun-failed');
|
|
119
|
+
|
|
120
|
+
const result = spawnSync(themis.command, args, {
|
|
121
|
+
cwd,
|
|
122
|
+
stdio: ['ignore', 'pipe', 'pipe'],
|
|
123
|
+
env: process.env,
|
|
124
|
+
maxBuffer: 32 * 1024 * 1024
|
|
125
|
+
});
|
|
126
|
+
|
|
127
|
+
if (result.error) {
|
|
128
|
+
// Hook itself failed (binary not found, etc). Stay silent rather than
|
|
129
|
+
// blocking the user's edit loop on infrastructure problems.
|
|
130
|
+
exitSilent();
|
|
131
|
+
}
|
|
132
|
+
|
|
133
|
+
const stdout = (result.stdout || Buffer.alloc(0)).toString('utf8');
|
|
134
|
+
const stderr = (result.stderr || Buffer.alloc(0)).toString('utf8');
|
|
135
|
+
|
|
136
|
+
if (result.status === 0) {
|
|
137
|
+
exitSilent();
|
|
138
|
+
}
|
|
139
|
+
|
|
140
|
+
// Tests failed. Surface the agent JSON payload (or stderr fallback) to
|
|
141
|
+
// Claude via stderr + exit 2 so it lands in the model's context.
|
|
142
|
+
process.stderr.write('Themis tests failed after edit. Use failures[].cluster and failures[].repairHints below to fix:\n');
|
|
143
|
+
if (stdout.trim().length > 0) {
|
|
144
|
+
process.stderr.write(stdout);
|
|
145
|
+
if (!stdout.endsWith('\n')) process.stderr.write('\n');
|
|
146
|
+
} else if (stderr.trim().length > 0) {
|
|
147
|
+
process.stderr.write(stderr);
|
|
148
|
+
if (!stderr.endsWith('\n')) process.stderr.write('\n');
|
|
149
|
+
}
|
|
150
|
+
process.exit(2);
|
|
151
|
+
}
|
|
152
|
+
|
|
153
|
+
main();
|
package/src/cli.js
CHANGED
|
@@ -23,14 +23,55 @@ async function main(argv) {
|
|
|
23
23
|
if (command === 'init') {
|
|
24
24
|
const initFlags = parseInitFlags(argv.slice(1));
|
|
25
25
|
const initResult = runInit(cwd, initFlags);
|
|
26
|
-
console.log('Themis initialized. Next: npx themis generate
|
|
27
|
-
if (
|
|
28
|
-
|
|
29
|
-
|
|
26
|
+
console.log('Themis initialized. Next: npx themis generate <source-root> && npx themis test');
|
|
27
|
+
if (initResult.autoDetected) {
|
|
28
|
+
const detected = [];
|
|
29
|
+
if (initResult.agents) detected.push('AGENTS.md');
|
|
30
|
+
if (initResult.claudeCode) detected.push('Claude Code');
|
|
31
|
+
if (initResult.cursor) detected.push('Cursor');
|
|
32
|
+
if (detected.length > 0) {
|
|
33
|
+
console.log(`Auto-detected: ${detected.join(', ')}`);
|
|
34
|
+
}
|
|
35
|
+
}
|
|
36
|
+
if (initFlags.agents || initResult.agents) {
|
|
37
|
+
const agents = initResult.agents;
|
|
38
|
+
if (agents && agents.created) {
|
|
39
|
+
console.log(`Agents: created ${formatCliPath(cwd, agents.path)} from the Themis downstream template.`);
|
|
30
40
|
} else {
|
|
31
41
|
console.log('Agents: skipped AGENTS.md scaffold because one already exists.');
|
|
32
42
|
}
|
|
33
43
|
}
|
|
44
|
+
if (initResult.cursor) {
|
|
45
|
+
const cur = initResult.cursor;
|
|
46
|
+
if (cur.created) {
|
|
47
|
+
console.log(`Cursor: created ${formatCliPath(cwd, cur.path)} from the Themis Cursor template.`);
|
|
48
|
+
} else if (cur.appended) {
|
|
49
|
+
console.log(`Cursor: appended Themis section to ${formatCliPath(cwd, cur.path)}.`);
|
|
50
|
+
} else {
|
|
51
|
+
console.log(`Cursor: skipped .cursorrules update (already mentions @vitronai/themis).`);
|
|
52
|
+
}
|
|
53
|
+
}
|
|
54
|
+
if (initResult.claudeCode) {
|
|
55
|
+
const cc = initResult.claudeCode;
|
|
56
|
+
if (cc.claudeMd.created) {
|
|
57
|
+
console.log(`Claude Code: created ${formatCliPath(cwd, cc.claudeMd.path)} from the Themis Claude template.`);
|
|
58
|
+
} else if (cc.claudeMd.appended) {
|
|
59
|
+
console.log(`Claude Code: appended Themis section to ${formatCliPath(cwd, cc.claudeMd.path)}.`);
|
|
60
|
+
} else {
|
|
61
|
+
console.log(`Claude Code: skipped CLAUDE.md update (already mentions @vitronai/themis).`);
|
|
62
|
+
}
|
|
63
|
+
if (cc.skill.created) {
|
|
64
|
+
console.log(`Claude Code: installed skill at ${formatCliPath(cwd, cc.skill.path)}.`);
|
|
65
|
+
} else {
|
|
66
|
+
console.log(`Claude Code: skipped skill (${formatCliPath(cwd, cc.skill.path)} already exists).`);
|
|
67
|
+
}
|
|
68
|
+
if (cc.commands.written.length > 0) {
|
|
69
|
+
console.log(`Claude Code: installed ${cc.commands.written.length} slash command(s) under ${formatCliPath(cwd, cc.commands.dir)}.`);
|
|
70
|
+
}
|
|
71
|
+
if (cc.commands.skipped.length > 0) {
|
|
72
|
+
console.log(`Claude Code: skipped ${cc.commands.skipped.length} existing slash command file(s).`);
|
|
73
|
+
}
|
|
74
|
+
}
|
|
34
75
|
return;
|
|
35
76
|
}
|
|
36
77
|
|
|
@@ -95,6 +136,17 @@ async function main(argv) {
|
|
|
95
136
|
if (result.convertedFiles && result.convertedFiles.length > 0) {
|
|
96
137
|
console.log(`Codemods: converted ${result.convertedFiles.length} file(s) to Themis-native patterns.`);
|
|
97
138
|
}
|
|
139
|
+
if (result.assist) {
|
|
140
|
+
console.log(`Assistant: analyzed ${result.assistSummary.analyzedFiles} migrated file(s).`);
|
|
141
|
+
if (result.assistSummary.findings.length > 0) {
|
|
142
|
+
console.log(
|
|
143
|
+
`Assistant: flagged ${result.assistSummary.findings.length} manual follow-up item(s) across ${result.assistSummary.unresolvedFiles.length} file(s).`
|
|
144
|
+
);
|
|
145
|
+
} else {
|
|
146
|
+
console.log('Assistant: no unsupported Jest/Vitest-only patterns detected in migrated files.');
|
|
147
|
+
}
|
|
148
|
+
console.log(`Report: ${formatCliPath(cwd, result.reportPath)}`);
|
|
149
|
+
}
|
|
98
150
|
console.log('Runtime compatibility is enabled for @jest/globals, vitest, and @testing-library/react imports.');
|
|
99
151
|
console.log('Next: run npx themis test or npm run test:themis');
|
|
100
152
|
return;
|
|
@@ -500,7 +552,8 @@ function parseMigrateFlags(args) {
|
|
|
500
552
|
const flags = {
|
|
501
553
|
source: args[0],
|
|
502
554
|
rewriteImports: false,
|
|
503
|
-
convert: false
|
|
555
|
+
convert: false,
|
|
556
|
+
assist: false
|
|
504
557
|
};
|
|
505
558
|
|
|
506
559
|
for (let i = 1; i < args.length; i += 1) {
|
|
@@ -511,6 +564,16 @@ function parseMigrateFlags(args) {
|
|
|
511
564
|
}
|
|
512
565
|
if (token === '--convert') {
|
|
513
566
|
flags.convert = true;
|
|
567
|
+
continue;
|
|
568
|
+
}
|
|
569
|
+
if (token === '--assist') {
|
|
570
|
+
flags.assist = true;
|
|
571
|
+
flags.rewriteImports = true;
|
|
572
|
+
flags.convert = true;
|
|
573
|
+
continue;
|
|
574
|
+
}
|
|
575
|
+
if (token.startsWith('-')) {
|
|
576
|
+
throw new Error(`Unsupported migrate option: ${token}`);
|
|
514
577
|
}
|
|
515
578
|
}
|
|
516
579
|
|
|
@@ -519,7 +582,9 @@ function parseMigrateFlags(args) {
|
|
|
519
582
|
|
|
520
583
|
function parseInitFlags(args) {
|
|
521
584
|
const flags = {
|
|
522
|
-
agents: false
|
|
585
|
+
agents: false,
|
|
586
|
+
claudeCode: false,
|
|
587
|
+
cursor: false
|
|
523
588
|
};
|
|
524
589
|
|
|
525
590
|
for (let i = 0; i < args.length; i += 1) {
|
|
@@ -528,6 +593,14 @@ function parseInitFlags(args) {
|
|
|
528
593
|
flags.agents = true;
|
|
529
594
|
continue;
|
|
530
595
|
}
|
|
596
|
+
if (token === '--claude-code' || token === '--claude') {
|
|
597
|
+
flags.claudeCode = true;
|
|
598
|
+
continue;
|
|
599
|
+
}
|
|
600
|
+
if (token === '--cursor') {
|
|
601
|
+
flags.cursor = true;
|
|
602
|
+
continue;
|
|
603
|
+
}
|
|
531
604
|
if (token.startsWith('-')) {
|
|
532
605
|
throw new Error(`Unsupported init option: ${token}`);
|
|
533
606
|
}
|
|
@@ -741,7 +814,7 @@ function printUsage() {
|
|
|
741
814
|
console.log(' generate [path] Scan source files and generate Themis contract tests');
|
|
742
815
|
console.log(' Options: [--json] [--plan] [--output path] [--files a,b] [--match-source regex] [--match-export regex] [--scenario name] [--min-confidence level] [--require-confidence level] [--include regex] [--exclude regex] [--review] [--update] [--clean] [--changed] [--force] [--strict] [--write-hints] [--fail-on-skips] [--fail-on-conflicts]');
|
|
743
816
|
console.log(' scan [path] Alias for generate');
|
|
744
|
-
console.log(' migrate <jest|vitest> [--rewrite-imports] [--convert] Scaffold an incremental migration bridge for existing suites');
|
|
817
|
+
console.log(' migrate <jest|vitest> [--rewrite-imports] [--convert] [--assist] Scaffold an incremental migration bridge for existing suites');
|
|
745
818
|
console.log(' test [--json] [--agent] [--next] [--reporter spec|next|json|agent|html] [--workers N] [--stability N] [--environment node|jsdom] [--isolation worker|in-process] [--cache] [--update-contracts] [--fix] [-w|--watch] [--html-output path] [--match regex] [--rerun-failed] [--no-memes] [--lexicon classic|themis]');
|
|
746
819
|
}
|
|
747
820
|
|
package/src/generate.js
CHANGED
|
@@ -609,7 +609,7 @@ function resolveScanTarget(projectRoot, targetDir) {
|
|
|
609
609
|
const requestedPath = path.resolve(projectRoot, targetDir);
|
|
610
610
|
|
|
611
611
|
if (!fs.existsSync(requestedPath)) {
|
|
612
|
-
throw new Error(
|
|
612
|
+
throw new Error(buildMissingGenerateTargetMessage(projectRoot, requestedPath));
|
|
613
613
|
}
|
|
614
614
|
|
|
615
615
|
const stat = fs.statSync(requestedPath);
|
|
@@ -632,6 +632,100 @@ function resolveScanTarget(projectRoot, targetDir) {
|
|
|
632
632
|
};
|
|
633
633
|
}
|
|
634
634
|
|
|
635
|
+
function buildMissingGenerateTargetMessage(projectRoot, requestedPath) {
|
|
636
|
+
const requestedDisplay = formatPathForDisplay(projectRoot, requestedPath);
|
|
637
|
+
const suggestions = collectGenerateTargetSuggestions(projectRoot, requestedPath);
|
|
638
|
+
|
|
639
|
+
if (suggestions.length === 0) {
|
|
640
|
+
return `Generate target not found: ${requestedDisplay}`;
|
|
641
|
+
}
|
|
642
|
+
|
|
643
|
+
return [
|
|
644
|
+
`Generate target not found: ${requestedDisplay}`,
|
|
645
|
+
'Detected likely source roots in this repo:',
|
|
646
|
+
...suggestions.map((suggestion) => `- ${suggestion.label}: npx themis generate ${suggestion.commandTarget}`)
|
|
647
|
+
].join('\n');
|
|
648
|
+
}
|
|
649
|
+
|
|
650
|
+
function collectGenerateTargetSuggestions(projectRoot, requestedPath) {
|
|
651
|
+
const requestedName = path.basename(requestedPath);
|
|
652
|
+
const suggestions = [];
|
|
653
|
+
const candidateDirs = [
|
|
654
|
+
{
|
|
655
|
+
label: 'Next app router source',
|
|
656
|
+
dir: path.join(projectRoot, 'app'),
|
|
657
|
+
commandTarget: 'app'
|
|
658
|
+
},
|
|
659
|
+
{
|
|
660
|
+
label: 'Pages router source',
|
|
661
|
+
dir: path.join(projectRoot, 'pages'),
|
|
662
|
+
commandTarget: 'pages'
|
|
663
|
+
},
|
|
664
|
+
{
|
|
665
|
+
label: 'Source tree',
|
|
666
|
+
dir: path.join(projectRoot, 'src'),
|
|
667
|
+
commandTarget: 'src'
|
|
668
|
+
}
|
|
669
|
+
];
|
|
670
|
+
|
|
671
|
+
for (const candidate of candidateDirs) {
|
|
672
|
+
if (candidate.dir === requestedPath || path.basename(candidate.dir) === requestedName) {
|
|
673
|
+
continue;
|
|
674
|
+
}
|
|
675
|
+
if (!containsEligibleSourceFiles(candidate.dir)) {
|
|
676
|
+
continue;
|
|
677
|
+
}
|
|
678
|
+
suggestions.push(candidate);
|
|
679
|
+
}
|
|
680
|
+
|
|
681
|
+
if (containsEligibleSourceFiles(projectRoot) && requestedPath !== projectRoot) {
|
|
682
|
+
suggestions.push({
|
|
683
|
+
label: 'Repo root scan',
|
|
684
|
+
dir: projectRoot,
|
|
685
|
+
commandTarget: '.'
|
|
686
|
+
});
|
|
687
|
+
}
|
|
688
|
+
|
|
689
|
+
return suggestions;
|
|
690
|
+
}
|
|
691
|
+
|
|
692
|
+
function containsEligibleSourceFiles(dirPath) {
|
|
693
|
+
if (!dirPath || !fs.existsSync(dirPath)) {
|
|
694
|
+
return false;
|
|
695
|
+
}
|
|
696
|
+
|
|
697
|
+
const stat = fs.statSync(dirPath);
|
|
698
|
+
if (stat.isFile()) {
|
|
699
|
+
return isEligibleSourceFile(dirPath);
|
|
700
|
+
}
|
|
701
|
+
if (!stat.isDirectory()) {
|
|
702
|
+
return false;
|
|
703
|
+
}
|
|
704
|
+
|
|
705
|
+
const stack = [dirPath];
|
|
706
|
+
while (stack.length > 0) {
|
|
707
|
+
const current = stack.pop();
|
|
708
|
+
const entries = fs.readdirSync(current, { withFileTypes: true });
|
|
709
|
+
for (const entry of entries) {
|
|
710
|
+
if (entry.name === 'node_modules' || entry.name === '.git' || entry.name === '.themis' || entry.name === '__themis__') {
|
|
711
|
+
continue;
|
|
712
|
+
}
|
|
713
|
+
|
|
714
|
+
const fullPath = path.join(current, entry.name);
|
|
715
|
+
if (entry.isDirectory()) {
|
|
716
|
+
stack.push(fullPath);
|
|
717
|
+
continue;
|
|
718
|
+
}
|
|
719
|
+
|
|
720
|
+
if (entry.isFile() && isEligibleSourceFile(fullPath)) {
|
|
721
|
+
return true;
|
|
722
|
+
}
|
|
723
|
+
}
|
|
724
|
+
}
|
|
725
|
+
|
|
726
|
+
return false;
|
|
727
|
+
}
|
|
728
|
+
|
|
635
729
|
function inferMirrorBase(projectRoot, filePath) {
|
|
636
730
|
const relative = normalizePath(path.relative(projectRoot, filePath));
|
|
637
731
|
const segments = relative.split('/');
|
package/src/init.js
CHANGED
|
@@ -3,17 +3,36 @@ const path = require('path');
|
|
|
3
3
|
const { initConfig } = require('./config');
|
|
4
4
|
const { ensureGitignoreEntries } = require('./gitignore');
|
|
5
5
|
|
|
6
|
-
const
|
|
6
|
+
const TEMPLATES_DIR = path.join(__dirname, '..', 'templates');
|
|
7
|
+
const AGENTS_TEMPLATE_PATH = path.join(TEMPLATES_DIR, 'AGENTS.themis.md');
|
|
8
|
+
const CLAUDE_TEMPLATE_PATH = path.join(TEMPLATES_DIR, 'CLAUDE.themis.md');
|
|
9
|
+
const CLAUDE_SKILL_TEMPLATE_PATH = path.join(TEMPLATES_DIR, 'claude-skill', 'SKILL.md');
|
|
10
|
+
const CLAUDE_COMMANDS_TEMPLATE_DIR = path.join(TEMPLATES_DIR, 'claude-commands');
|
|
11
|
+
const CURSOR_TEMPLATE_PATH = path.join(TEMPLATES_DIR, 'cursorrules.themis.md');
|
|
7
12
|
|
|
8
13
|
function runInit(cwd, options = {}) {
|
|
9
14
|
initConfig(cwd);
|
|
10
15
|
ensureGitignoreEntries(cwd, ['.themis/', '__themis__/reports/', '__themis__/shims/']);
|
|
11
16
|
|
|
12
|
-
|
|
13
|
-
|
|
17
|
+
const hasExplicitFlags = options.agents || options.claudeCode || options.cursor;
|
|
18
|
+
const detected = hasExplicitFlags ? options : detectAgents(cwd, options);
|
|
19
|
+
|
|
20
|
+
const result = {};
|
|
21
|
+
|
|
22
|
+
if (detected.agents) {
|
|
23
|
+
result.agents = ensureAgentsTemplate(cwd);
|
|
24
|
+
}
|
|
25
|
+
|
|
26
|
+
if (detected.claudeCode) {
|
|
27
|
+
result.claudeCode = ensureClaudeCodeAssets(cwd);
|
|
28
|
+
}
|
|
29
|
+
|
|
30
|
+
if (detected.cursor) {
|
|
31
|
+
result.cursor = writeCursorRules(cwd);
|
|
14
32
|
}
|
|
15
33
|
|
|
16
|
-
|
|
34
|
+
result.autoDetected = !hasExplicitFlags;
|
|
35
|
+
return result;
|
|
17
36
|
}
|
|
18
37
|
|
|
19
38
|
function ensureAgentsTemplate(cwd) {
|
|
@@ -33,6 +52,105 @@ function ensureAgentsTemplate(cwd) {
|
|
|
33
52
|
};
|
|
34
53
|
}
|
|
35
54
|
|
|
55
|
+
function ensureClaudeCodeAssets(cwd) {
|
|
56
|
+
const result = {
|
|
57
|
+
claudeMd: writeClaudeMd(cwd),
|
|
58
|
+
skill: writeClaudeSkill(cwd),
|
|
59
|
+
commands: writeClaudeCommands(cwd)
|
|
60
|
+
};
|
|
61
|
+
return result;
|
|
62
|
+
}
|
|
63
|
+
|
|
64
|
+
function writeClaudeMd(cwd) {
|
|
65
|
+
const targetPath = path.join(cwd, 'CLAUDE.md');
|
|
66
|
+
const source = fs.readFileSync(CLAUDE_TEMPLATE_PATH, 'utf8');
|
|
67
|
+
|
|
68
|
+
if (!fs.existsSync(targetPath)) {
|
|
69
|
+
fs.writeFileSync(targetPath, source, 'utf8');
|
|
70
|
+
return { path: targetPath, created: true, appended: false };
|
|
71
|
+
}
|
|
72
|
+
|
|
73
|
+
const existing = fs.readFileSync(targetPath, 'utf8');
|
|
74
|
+
if (existing.includes('@vitronai/themis')) {
|
|
75
|
+
return { path: targetPath, created: false, appended: false };
|
|
76
|
+
}
|
|
77
|
+
|
|
78
|
+
const separator = existing.endsWith('\n') ? '\n' : '\n\n';
|
|
79
|
+
fs.writeFileSync(targetPath, existing + separator + source, 'utf8');
|
|
80
|
+
return { path: targetPath, created: false, appended: true };
|
|
81
|
+
}
|
|
82
|
+
|
|
83
|
+
function writeClaudeSkill(cwd) {
|
|
84
|
+
const targetDir = path.join(cwd, '.claude', 'skills', 'themis');
|
|
85
|
+
const targetPath = path.join(targetDir, 'SKILL.md');
|
|
86
|
+
if (fs.existsSync(targetPath)) {
|
|
87
|
+
return { path: targetPath, created: false };
|
|
88
|
+
}
|
|
89
|
+
fs.mkdirSync(targetDir, { recursive: true });
|
|
90
|
+
const source = fs.readFileSync(CLAUDE_SKILL_TEMPLATE_PATH, 'utf8');
|
|
91
|
+
fs.writeFileSync(targetPath, source, 'utf8');
|
|
92
|
+
return { path: targetPath, created: true };
|
|
93
|
+
}
|
|
94
|
+
|
|
95
|
+
function writeClaudeCommands(cwd) {
|
|
96
|
+
const targetDir = path.join(cwd, '.claude', 'commands');
|
|
97
|
+
fs.mkdirSync(targetDir, { recursive: true });
|
|
98
|
+
|
|
99
|
+
const entries = fs.readdirSync(CLAUDE_COMMANDS_TEMPLATE_DIR);
|
|
100
|
+
const written = [];
|
|
101
|
+
const skipped = [];
|
|
102
|
+
|
|
103
|
+
for (const entry of entries) {
|
|
104
|
+
if (!entry.endsWith('.md')) continue;
|
|
105
|
+
const sourcePath = path.join(CLAUDE_COMMANDS_TEMPLATE_DIR, entry);
|
|
106
|
+
const targetPath = path.join(targetDir, entry);
|
|
107
|
+
if (fs.existsSync(targetPath)) {
|
|
108
|
+
skipped.push(targetPath);
|
|
109
|
+
continue;
|
|
110
|
+
}
|
|
111
|
+
const source = fs.readFileSync(sourcePath, 'utf8');
|
|
112
|
+
fs.writeFileSync(targetPath, source, 'utf8');
|
|
113
|
+
written.push(targetPath);
|
|
114
|
+
}
|
|
115
|
+
|
|
116
|
+
return { dir: targetDir, written, skipped };
|
|
117
|
+
}
|
|
118
|
+
|
|
119
|
+
function detectAgents(cwd) {
|
|
120
|
+
const flags = { agents: true, claudeCode: false, cursor: false };
|
|
121
|
+
|
|
122
|
+
// Claude Code: .claude/ dir or CLAUDE.md exists
|
|
123
|
+
if (fs.existsSync(path.join(cwd, '.claude')) || fs.existsSync(path.join(cwd, 'CLAUDE.md'))) {
|
|
124
|
+
flags.claudeCode = true;
|
|
125
|
+
}
|
|
126
|
+
|
|
127
|
+
// Cursor: .cursorrules or .cursor/ dir exists
|
|
128
|
+
if (fs.existsSync(path.join(cwd, '.cursorrules')) || fs.existsSync(path.join(cwd, '.cursor'))) {
|
|
129
|
+
flags.cursor = true;
|
|
130
|
+
}
|
|
131
|
+
|
|
132
|
+
return flags;
|
|
133
|
+
}
|
|
134
|
+
|
|
135
|
+
function writeCursorRules(cwd) {
|
|
136
|
+
const targetPath = path.join(cwd, '.cursorrules');
|
|
137
|
+
const source = fs.readFileSync(CURSOR_TEMPLATE_PATH, 'utf8');
|
|
138
|
+
|
|
139
|
+
if (!fs.existsSync(targetPath)) {
|
|
140
|
+
fs.writeFileSync(targetPath, source, 'utf8');
|
|
141
|
+
return { path: targetPath, created: true, appended: false };
|
|
142
|
+
}
|
|
143
|
+
|
|
144
|
+
const existing = fs.readFileSync(targetPath, 'utf8');
|
|
145
|
+
if (existing.includes('@vitronai/themis')) {
|
|
146
|
+
return { path: targetPath, created: false, appended: false };
|
|
147
|
+
}
|
|
148
|
+
|
|
149
|
+
const separator = existing.endsWith('\n') ? '\n' : '\n\n';
|
|
150
|
+
fs.writeFileSync(targetPath, existing + separator + source, 'utf8');
|
|
151
|
+
return { path: targetPath, created: false, appended: true };
|
|
152
|
+
}
|
|
153
|
+
|
|
36
154
|
module.exports = {
|
|
37
155
|
runInit
|
|
38
156
|
};
|