@nolrm/contextkit 0.15.1 → 0.17.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 +17 -9
- package/bin/contextkit.js +2 -1
- package/lib/commands/analyze.js +170 -11
- package/lib/commands/install.js +66 -7
- package/lib/commands/update.js +19 -3
- package/lib/integrations/base-integration.js +40 -1
- package/lib/integrations/claude-integration.js +21 -2
- package/lib/integrations/claude-integration.md +39 -0
- package/lib/integrations/continue-integration.js +2 -2
- package/lib/integrations/cursor-integration.js +2 -2
- package/lib/integrations/windsurf-integration.js +2 -2
- package/lib/utils/claude-settings.js +67 -0
- package/lib/utils/claude-settings.md +52 -0
- package/lib/utils/git-hooks.js +133 -1
- package/lib/utils/hook-detector.js +138 -0
- package/lib/utils/hook-detector.md +42 -0
- package/package.json +1 -1
package/README.md
CHANGED
|
@@ -62,8 +62,8 @@ Perfect for teams where members use different AI tools:
|
|
|
62
62
|
contextkit install claude # or: contextkit install (interactive picker)
|
|
63
63
|
|
|
64
64
|
# Each additional team member adds their platform
|
|
65
|
-
ck claude # creates CLAUDE.md + .claude/rules/
|
|
66
|
-
ck cursor # creates .cursor/rules/ (scoped .mdc files)
|
|
65
|
+
ck claude # creates CLAUDE.md + .claude/rules/ — skips if already up to date
|
|
66
|
+
ck cursor # creates .cursor/rules/ (scoped .mdc files) — skips if already up to date
|
|
67
67
|
ck copilot # creates .github/copilot-instructions.md
|
|
68
68
|
ck codex # creates AGENTS.md
|
|
69
69
|
ck opencode # creates AGENTS.md
|
|
@@ -114,7 +114,7 @@ Each platform generates bridge files that the AI tool auto-reads. If a bridge fi
|
|
|
114
114
|
/fix # diagnose and fix bugs
|
|
115
115
|
```
|
|
116
116
|
|
|
117
|
-
**Claude Code** — `CLAUDE.md` uses `@` imports to auto-load all standards into context every session (no manual reads needed, saves tokens). Skills in `.claude/skills/`.
|
|
117
|
+
**Claude Code** — `CLAUDE.md` uses `@` imports to auto-load all standards into context every session (no manual reads needed, saves tokens). Skills in `.claude/skills/`. Also writes a PostToolUse hook to `.claude/settings.json` that runs format+lint after every file edit — auto-detected for Node.js (npm/pnpm/yarn/bun), Go, and Python.
|
|
118
118
|
|
|
119
119
|
```bash
|
|
120
120
|
/analyze # scan codebase and generate standards
|
|
@@ -154,7 +154,7 @@ ContextKit installs reusable slash commands for supported platforms:
|
|
|
154
154
|
| `/refactor` | Refactor code with safety checks |
|
|
155
155
|
| `/test` | Generate comprehensive tests |
|
|
156
156
|
| `/doc` | Add documentation |
|
|
157
|
-
| `/doc-arch` | Generate architecture docs (`docs/architecture.md`
|
|
157
|
+
| `/doc-arch` | Generate architecture docs — stack-aware (Level 1). Output: `docs/<topic>.md`, or `docs/architecture.md` if no topic given. Pass a topic name, PR number, or leave blank to infer from branch. |
|
|
158
158
|
| `/doc-feature` | Generate feature-level docs (`docs/features/<name>.md`) — stack-aware (Level 2) |
|
|
159
159
|
| `/doc-component` | Generate component-level docs colocated with the target file — stack-aware (Level 3) |
|
|
160
160
|
| `/spec` | Write a component spec (MD-first) before any code is created |
|
|
@@ -182,6 +182,8 @@ Both platforms delegate to the universal command files in `.contextkit/commands/
|
|
|
182
182
|
|
|
183
183
|
The squad workflow turns a single AI session into a structured multi-role pipeline. Each role has its own slash command that reads and writes to a shared handoff file (`.contextkit/squad/handoff.md`), simulating a team of specialists.
|
|
184
184
|
|
|
185
|
+
> **Squad works standalone.** If `.contextkit/` isn't set up, `/squad` will offer to create just `.contextkit/squad/` so you can use the pipeline without a full `ck install`.
|
|
186
|
+
|
|
185
187
|
### Pipeline Roles
|
|
186
188
|
|
|
187
189
|
| Step | Role | Command | What it does |
|
|
@@ -256,10 +258,14 @@ If you have a screenshot, mockup, or design image relevant to the task, paste or
|
|
|
256
258
|
|
|
257
259
|
---
|
|
258
260
|
|
|
259
|
-
##
|
|
261
|
+
## Hooks & Quality Gates
|
|
262
|
+
|
|
263
|
+
ContextKit installs two kinds of hooks. **Git hooks** (pre-push, commit-msg) enforce quality at push time for the whole team. For **Claude Code** installs, a **PostToolUse hook** is also written to `.claude/settings.json` — it runs format+lint after every file edit in a Claude Code session, catching failures immediately rather than at push time.
|
|
260
264
|
|
|
261
265
|
ContextKit can optionally install Git hooks during `ck install`. Uses `git config core.hooksPath` to point Git at `.contextkit/hooks/` — no external dependencies like Husky required. Works in any git repo, not just Node.js projects.
|
|
262
266
|
|
|
267
|
+
If an existing hooks manager is detected (Husky, Lefthook, simple-git-hooks, an existing `core.hooksPath`, or scripts in `.git/hooks/`), `ck install` will suggest how to integrate rather than overriding your setup.
|
|
268
|
+
|
|
263
269
|
For **Node.js projects**, a `prepare` script is automatically added to `package.json` so hooks activate for all developers after `npm install` — no need for everyone to run `ck install`.
|
|
264
270
|
|
|
265
271
|
If you enable the pre-push hook on a Node.js project that has no `format` or `lint` scripts, `ck install` will offer to scaffold a minimal **prettier + eslint** setup for you (adds scripts, `.prettierrc`, `.prettierignore`, `eslint.config.js`, and installs the devDependencies). Answer No to skip and set it up manually later.
|
|
@@ -368,8 +374,9 @@ Then add your Anthropic API key as a repository secret:
|
|
|
368
374
|
# Installation & Setup
|
|
369
375
|
ck install # set up .contextkit + pick AI tool interactively
|
|
370
376
|
ck install claude # set up .contextkit + Claude, or add Claude to an existing install
|
|
371
|
-
ck
|
|
372
|
-
ck
|
|
377
|
+
ck install --force # regenerate all files, including user-customized standards
|
|
378
|
+
ck claude # add or refresh Claude integration — skips if already up to date
|
|
379
|
+
ck cursor # add or refresh Cursor integration — skips if already up to date
|
|
373
380
|
ck copilot # add GitHub Copilot integration
|
|
374
381
|
ck codex # add Codex CLI integration (AGENTS.md)
|
|
375
382
|
ck opencode # add OpenCode integration (AGENTS.md)
|
|
@@ -381,8 +388,9 @@ ck vscode # alias for copilot
|
|
|
381
388
|
|
|
382
389
|
# Analysis & Updates
|
|
383
390
|
/analyze # customize standards to your project (slash command in your AI tool)
|
|
384
|
-
ck update
|
|
385
|
-
|
|
391
|
+
ck update # pull latest commands/hooks — never overwrites your standards or glossary
|
|
392
|
+
ck update --force # also regenerate user-owned files (standards, glossary)
|
|
393
|
+
# updates are also flagged automatically after each ck command (24h cache)
|
|
386
394
|
ck status # check install & integrations
|
|
387
395
|
|
|
388
396
|
# Validation & Compliance
|
package/bin/contextkit.js
CHANGED
|
@@ -27,6 +27,7 @@ program
|
|
|
27
27
|
)
|
|
28
28
|
.option('--no-hooks', 'Skip Git hooks installation')
|
|
29
29
|
.option('--non-interactive', 'Skip interactive prompts')
|
|
30
|
+
.option('--force', 'Regenerate user-owned standards files even if they already exist')
|
|
30
31
|
.action(async (platform, options) => {
|
|
31
32
|
try {
|
|
32
33
|
await install({ ...options, ...(platform ? { platform } : { fullInstall: true }) });
|
|
@@ -53,7 +54,7 @@ program
|
|
|
53
54
|
program
|
|
54
55
|
.command('update')
|
|
55
56
|
.description('Update to latest version')
|
|
56
|
-
.option('--force', 'Force update even if no changes')
|
|
57
|
+
.option('--force', 'Force update even if no changes; also regenerates user-owned standards files')
|
|
57
58
|
.action(async (options) => {
|
|
58
59
|
try {
|
|
59
60
|
await update(options);
|
package/lib/commands/analyze.js
CHANGED
|
@@ -167,9 +167,12 @@ class AnalyzeCommand {
|
|
|
167
167
|
pathContext.tsconfig = await fs.readJson('tsconfig.json');
|
|
168
168
|
}
|
|
169
169
|
|
|
170
|
-
// Detect
|
|
171
|
-
|
|
172
|
-
|
|
170
|
+
// Detect primary language and frameworks/build tools for this path
|
|
171
|
+
const ProjectDetector = require('../utils/project-detector');
|
|
172
|
+
const primaryLanguage = new ProjectDetector().detectProjectType();
|
|
173
|
+
pathContext.primaryLanguage = primaryLanguage;
|
|
174
|
+
pathContext.frameworks = await this.detectFrameworks(primaryLanguage);
|
|
175
|
+
pathContext.buildTools = await this.detectBuildTools(primaryLanguage);
|
|
173
176
|
} catch {
|
|
174
177
|
// Continue even if one path fails
|
|
175
178
|
} finally {
|
|
@@ -457,13 +460,17 @@ class AnalyzeCommand {
|
|
|
457
460
|
}
|
|
458
461
|
|
|
459
462
|
async detectProjectStructure() {
|
|
463
|
+
const ProjectDetector = require('../utils/project-detector');
|
|
464
|
+
const primaryLanguage = new ProjectDetector().detectProjectType();
|
|
465
|
+
|
|
460
466
|
const structure = {
|
|
461
467
|
hasSrc: await fs.pathExists('src'),
|
|
462
468
|
hasApp: await fs.pathExists('src/app'),
|
|
463
469
|
hasComponents: await fs.pathExists('src/components'),
|
|
464
470
|
isMonorepo: (await fs.pathExists('packages')) || (await fs.pathExists('apps')),
|
|
465
|
-
|
|
466
|
-
|
|
471
|
+
primaryLanguage,
|
|
472
|
+
frameworks: await this.detectFrameworks(primaryLanguage),
|
|
473
|
+
buildTools: await this.detectBuildTools(primaryLanguage),
|
|
467
474
|
};
|
|
468
475
|
|
|
469
476
|
structure.type = structure.isMonorepo ? 'monorepo' : 'single-package';
|
|
@@ -471,26 +478,138 @@ class AnalyzeCommand {
|
|
|
471
478
|
return structure;
|
|
472
479
|
}
|
|
473
480
|
|
|
474
|
-
|
|
481
|
+
// ── JS language types ───────────────────────────────────────────────────────
|
|
482
|
+
_isJsLanguage(primaryLanguage) {
|
|
483
|
+
return [
|
|
484
|
+
'node', 'react', 'react-vite', 'react-webpack', 'react-rollup',
|
|
485
|
+
'nextjs', 'vue', 'vue-vite', 'vue-webpack', 'nuxt', 'angular', 'svelte',
|
|
486
|
+
].includes(primaryLanguage);
|
|
487
|
+
}
|
|
488
|
+
|
|
489
|
+
async detectFrameworks(primaryLanguage) {
|
|
490
|
+
// Non-JS projects: return language-appropriate framework info
|
|
491
|
+
if (!this._isJsLanguage(primaryLanguage)) {
|
|
492
|
+
return await this._detectNonJsFrameworks(primaryLanguage);
|
|
493
|
+
}
|
|
494
|
+
|
|
495
|
+
// JS projects: filesystem heuristics are appropriate
|
|
475
496
|
const frameworks = [];
|
|
476
497
|
|
|
477
|
-
|
|
498
|
+
// Require both a dependency signal AND directory structure to avoid false positives
|
|
499
|
+
const hasPackageJson = await fs.pathExists('package.json');
|
|
500
|
+
let deps = {};
|
|
501
|
+
if (hasPackageJson) {
|
|
502
|
+
try {
|
|
503
|
+
const pkg = await fs.readJson('package.json');
|
|
504
|
+
deps = { ...(pkg.dependencies || {}), ...(pkg.devDependencies || {}) };
|
|
505
|
+
} catch {
|
|
506
|
+
// ignore
|
|
507
|
+
}
|
|
508
|
+
}
|
|
509
|
+
|
|
510
|
+
if ((deps.react || deps['@types/react']) && (await fs.pathExists('src/components'))) {
|
|
478
511
|
frameworks.push('React');
|
|
479
512
|
}
|
|
480
|
-
if (await fs.pathExists('src/pages')) {
|
|
513
|
+
if (deps.next && (await fs.pathExists('src/pages'))) {
|
|
481
514
|
frameworks.push('Next.js');
|
|
482
515
|
}
|
|
483
|
-
if (await fs.pathExists('src/views')) {
|
|
516
|
+
if ((deps.vue || deps['@vue/core']) && (await fs.pathExists('src/views'))) {
|
|
484
517
|
frameworks.push('Vue');
|
|
485
518
|
}
|
|
486
|
-
if (await fs.pathExists('angular.json')) {
|
|
519
|
+
if (deps['@angular/core'] && (await fs.pathExists('angular.json'))) {
|
|
487
520
|
frameworks.push('Angular');
|
|
488
521
|
}
|
|
489
522
|
|
|
523
|
+
// Fallback for cases where dependency exists but directories differ (still JS)
|
|
524
|
+
if (frameworks.length === 0 && (deps.react || deps['@types/react'])) {
|
|
525
|
+
frameworks.push('React');
|
|
526
|
+
}
|
|
527
|
+
if (frameworks.length === 0 && deps.next) {
|
|
528
|
+
frameworks.push('Next.js');
|
|
529
|
+
}
|
|
530
|
+
if (frameworks.length === 0 && (deps.vue || deps['@vue/core'])) {
|
|
531
|
+
frameworks.push('Vue');
|
|
532
|
+
}
|
|
533
|
+
|
|
534
|
+
return frameworks;
|
|
535
|
+
}
|
|
536
|
+
|
|
537
|
+
async _detectNonJsFrameworks(primaryLanguage) {
|
|
538
|
+
const frameworks = [];
|
|
539
|
+
|
|
540
|
+
if (primaryLanguage === 'go') {
|
|
541
|
+
frameworks.push('Go');
|
|
542
|
+
if (await fs.pathExists('go.mod')) {
|
|
543
|
+
try {
|
|
544
|
+
const goMod = await fs.readFile('go.mod', 'utf-8');
|
|
545
|
+
const moduleLine = goMod.split('\n').find((l) => l.startsWith('module '));
|
|
546
|
+
if (moduleLine) {
|
|
547
|
+
frameworks.push(`module:${moduleLine.replace('module ', '').trim()}`);
|
|
548
|
+
}
|
|
549
|
+
} catch {
|
|
550
|
+
// ignore
|
|
551
|
+
}
|
|
552
|
+
}
|
|
553
|
+
return frameworks;
|
|
554
|
+
}
|
|
555
|
+
|
|
556
|
+
if (primaryLanguage === 'python') {
|
|
557
|
+
frameworks.push('Python');
|
|
558
|
+
const depSources = ['requirements.txt', 'pyproject.toml'];
|
|
559
|
+
for (const src of depSources) {
|
|
560
|
+
if (await fs.pathExists(src)) {
|
|
561
|
+
try {
|
|
562
|
+
const content = await fs.readFile(src, 'utf-8');
|
|
563
|
+
if (/django/i.test(content)) frameworks.push('Django');
|
|
564
|
+
if (/flask/i.test(content)) frameworks.push('Flask');
|
|
565
|
+
if (/fastapi/i.test(content)) frameworks.push('FastAPI');
|
|
566
|
+
} catch {
|
|
567
|
+
// ignore
|
|
568
|
+
}
|
|
569
|
+
break;
|
|
570
|
+
}
|
|
571
|
+
}
|
|
572
|
+
return frameworks;
|
|
573
|
+
}
|
|
574
|
+
|
|
575
|
+
if (primaryLanguage === 'rust') {
|
|
576
|
+
frameworks.push('Rust');
|
|
577
|
+
return frameworks;
|
|
578
|
+
}
|
|
579
|
+
|
|
580
|
+
if (primaryLanguage === 'java') {
|
|
581
|
+
frameworks.push('Java');
|
|
582
|
+
return frameworks;
|
|
583
|
+
}
|
|
584
|
+
|
|
585
|
+
if (primaryLanguage === 'ruby') {
|
|
586
|
+
frameworks.push('Ruby');
|
|
587
|
+
if (await fs.pathExists('Gemfile')) {
|
|
588
|
+
try {
|
|
589
|
+
const gemfile = await fs.readFile('Gemfile', 'utf-8');
|
|
590
|
+
if (/rails/i.test(gemfile)) frameworks.push('Rails');
|
|
591
|
+
} catch {
|
|
592
|
+
// ignore
|
|
593
|
+
}
|
|
594
|
+
}
|
|
595
|
+
return frameworks;
|
|
596
|
+
}
|
|
597
|
+
|
|
598
|
+
if (primaryLanguage === 'php') {
|
|
599
|
+
frameworks.push('PHP');
|
|
600
|
+
return frameworks;
|
|
601
|
+
}
|
|
602
|
+
|
|
490
603
|
return frameworks;
|
|
491
604
|
}
|
|
492
605
|
|
|
493
|
-
async detectBuildTools() {
|
|
606
|
+
async detectBuildTools(primaryLanguage) {
|
|
607
|
+
// Non-JS projects: skip JS build tool checks, return relevant tools
|
|
608
|
+
if (!this._isJsLanguage(primaryLanguage)) {
|
|
609
|
+
return await this._detectNonJsBuildTools(primaryLanguage);
|
|
610
|
+
}
|
|
611
|
+
|
|
612
|
+
// JS projects: check JS build tool config files
|
|
494
613
|
const tools = [];
|
|
495
614
|
|
|
496
615
|
if ((await fs.pathExists('vite.config.js')) || (await fs.pathExists('vite.config.ts'))) {
|
|
@@ -506,6 +625,46 @@ class AnalyzeCommand {
|
|
|
506
625
|
return tools;
|
|
507
626
|
}
|
|
508
627
|
|
|
628
|
+
async _detectNonJsBuildTools(primaryLanguage) {
|
|
629
|
+
const tools = [];
|
|
630
|
+
|
|
631
|
+
if (primaryLanguage === 'go') {
|
|
632
|
+
if (await fs.pathExists('Makefile')) tools.push('Make');
|
|
633
|
+
if (
|
|
634
|
+
(await fs.pathExists('.goreleaser.yml')) ||
|
|
635
|
+
(await fs.pathExists('.goreleaser.yaml'))
|
|
636
|
+
) {
|
|
637
|
+
tools.push('GoReleaser');
|
|
638
|
+
}
|
|
639
|
+
return tools;
|
|
640
|
+
}
|
|
641
|
+
|
|
642
|
+
if (primaryLanguage === 'python') {
|
|
643
|
+
if (await fs.pathExists('Makefile')) tools.push('Make');
|
|
644
|
+
if (await fs.pathExists('pyproject.toml')) tools.push('pyproject');
|
|
645
|
+
if (await fs.pathExists('setup.py')) tools.push('setuptools');
|
|
646
|
+
return tools;
|
|
647
|
+
}
|
|
648
|
+
|
|
649
|
+
if (primaryLanguage === 'rust') {
|
|
650
|
+
tools.push('Cargo');
|
|
651
|
+
if (await fs.pathExists('Makefile')) tools.push('Make');
|
|
652
|
+
return tools;
|
|
653
|
+
}
|
|
654
|
+
|
|
655
|
+
if (primaryLanguage === 'java') {
|
|
656
|
+
if (await fs.pathExists('pom.xml')) tools.push('Maven');
|
|
657
|
+
if (await fs.pathExists('build.gradle') || await fs.pathExists('build.gradle.kts')) {
|
|
658
|
+
tools.push('Gradle');
|
|
659
|
+
}
|
|
660
|
+
return tools;
|
|
661
|
+
}
|
|
662
|
+
|
|
663
|
+
if (await fs.pathExists('Makefile')) tools.push('Make');
|
|
664
|
+
|
|
665
|
+
return tools;
|
|
666
|
+
}
|
|
667
|
+
|
|
509
668
|
async detectAITools() {
|
|
510
669
|
const ToolDetector = require('../utils/tool-detector');
|
|
511
670
|
const detector = new ToolDetector();
|
package/lib/commands/install.js
CHANGED
|
@@ -191,23 +191,69 @@ class InstallCommand {
|
|
|
191
191
|
async addContextKitGitignoreEntries() {
|
|
192
192
|
if (!(await fs.pathExists('.gitignore'))) return;
|
|
193
193
|
|
|
194
|
+
// Only runtime/ephemeral state belongs in .gitignore.
|
|
195
|
+
// Standards, commands, hooks, and platform integrations should be committed.
|
|
194
196
|
const entries = [
|
|
195
197
|
'.contextkit/status.json',
|
|
196
198
|
'.contextkit/status.yml',
|
|
197
199
|
'.contextkit/context.md',
|
|
198
200
|
'.contextkit/squad/',
|
|
199
201
|
'.contextkit/squad-done-*/',
|
|
202
|
+
'.contextkit/backup-*/',
|
|
200
203
|
];
|
|
201
204
|
|
|
205
|
+
const HEADER = '# ContextKit — runtime state (do not commit)';
|
|
206
|
+
const HEADER_LEGACY = '# ContextKit';
|
|
207
|
+
|
|
202
208
|
try {
|
|
203
209
|
const content = await fs.readFile('.gitignore', 'utf-8');
|
|
210
|
+
|
|
211
|
+
// Detect pre-existing ignore-all pattern (.contextkit/* or .contextkit/)
|
|
212
|
+
const hasIgnoreAll =
|
|
213
|
+
content.includes('.contextkit/*') || /^\.contextkit\/\s*$/m.test(content);
|
|
214
|
+
|
|
215
|
+
if (hasIgnoreAll) {
|
|
216
|
+
// Don't modify — add a helpful note as a comment so the user knows what to do
|
|
217
|
+
const hasNote = content.includes('# Note: the above ignores all of .contextkit/');
|
|
218
|
+
if (!hasNote) {
|
|
219
|
+
const separator = content.endsWith('\n') ? '' : '\n';
|
|
220
|
+
await fs.appendFile(
|
|
221
|
+
'.gitignore',
|
|
222
|
+
`${separator}# Note: the above ignores all of .contextkit/ — consider replacing it with:\n` +
|
|
223
|
+
`# ${entries.join('\n# ')}\n` +
|
|
224
|
+
`# This lets you commit standards/, commands/, hooks/, and product/ while ignoring runtime state.\n`
|
|
225
|
+
);
|
|
226
|
+
console.log(
|
|
227
|
+
chalk.yellow(
|
|
228
|
+
'⚠️ .gitignore already has .contextkit/* (ignore-all). Added guidance comment.'
|
|
229
|
+
)
|
|
230
|
+
);
|
|
231
|
+
}
|
|
232
|
+
return;
|
|
233
|
+
}
|
|
234
|
+
|
|
204
235
|
const missing = entries.filter((e) => !content.includes(e));
|
|
205
236
|
if (missing.length === 0) return;
|
|
206
237
|
|
|
207
|
-
|
|
208
|
-
|
|
209
|
-
|
|
238
|
+
// Update legacy header to new descriptive header if present
|
|
239
|
+
let updatedContent = content;
|
|
240
|
+
if (content.includes(HEADER_LEGACY) && !content.includes(HEADER)) {
|
|
241
|
+
updatedContent = content.replace(HEADER_LEGACY, HEADER);
|
|
242
|
+
await fs.writeFile('.gitignore', updatedContent);
|
|
243
|
+
}
|
|
244
|
+
|
|
245
|
+
const hasHeader =
|
|
246
|
+
updatedContent.includes(HEADER) || updatedContent.includes(HEADER_LEGACY);
|
|
247
|
+
const header = hasHeader ? '' : `\n${HEADER}\n`;
|
|
248
|
+
const separator = updatedContent.endsWith('\n') ? '' : '\n';
|
|
210
249
|
await fs.appendFile('.gitignore', `${separator}${header}${missing.join('\n')}\n`);
|
|
250
|
+
|
|
251
|
+
console.log(chalk.green('✅ .gitignore updated — runtime state excluded'));
|
|
252
|
+
console.log(
|
|
253
|
+
chalk.dim(
|
|
254
|
+
'💡 Commit .contextkit/standards/ and .contextkit/commands/ — your team needs them'
|
|
255
|
+
)
|
|
256
|
+
);
|
|
211
257
|
} catch (error) {
|
|
212
258
|
console.log(chalk.yellow('⚠️ Could not update .gitignore:'), error.message);
|
|
213
259
|
}
|
|
@@ -383,6 +429,18 @@ class InstallCommand {
|
|
|
383
429
|
return { prePush, commitMsg };
|
|
384
430
|
}
|
|
385
431
|
|
|
432
|
+
// ── User-owned file helper ──────────────────────────────────────────────────
|
|
433
|
+
// Writes a user-owned skeleton file only if it does not already exist.
|
|
434
|
+
// With force=true, overwrites existing content (warns before doing so).
|
|
435
|
+
async _writeUserOwnedFile(relativePath, content, force = false) {
|
|
436
|
+
const fullPath = `.contextkit/${relativePath}`;
|
|
437
|
+
if (!force && (await fs.pathExists(fullPath))) {
|
|
438
|
+
console.log(chalk.dim(` ⏭ ${relativePath} already exists — skipping (use --force to regenerate)`));
|
|
439
|
+
return;
|
|
440
|
+
}
|
|
441
|
+
await fs.writeFile(fullPath, content);
|
|
442
|
+
}
|
|
443
|
+
|
|
386
444
|
async createDirectoryStructure() {
|
|
387
445
|
console.log(chalk.blue('📁 Creating structure...'));
|
|
388
446
|
|
|
@@ -679,11 +737,11 @@ This file is loaded when HTML-related tasks are detected:
|
|
|
679
737
|
};
|
|
680
738
|
|
|
681
739
|
for (const [relativePath, content] of Object.entries(skeletonFiles)) {
|
|
682
|
-
await
|
|
740
|
+
await this._writeUserOwnedFile(relativePath, content, this._force);
|
|
683
741
|
}
|
|
684
742
|
|
|
685
743
|
for (const [relativePath, content] of Object.entries(granularCodeStyleFiles)) {
|
|
686
|
-
await
|
|
744
|
+
await this._writeUserOwnedFile(relativePath, content, this._force);
|
|
687
745
|
}
|
|
688
746
|
}
|
|
689
747
|
|
|
@@ -805,11 +863,12 @@ Any design decisions, trade-offs, or open questions to resolve before coding.
|
|
|
805
863
|
};
|
|
806
864
|
|
|
807
865
|
for (const [relativePath, content] of Object.entries(skeletonFiles)) {
|
|
808
|
-
await
|
|
866
|
+
await this._writeUserOwnedFile(relativePath, content, this._force);
|
|
809
867
|
}
|
|
810
868
|
}
|
|
811
869
|
|
|
812
|
-
async downloadFiles(projectType,
|
|
870
|
+
async downloadFiles(projectType, options = {}) {
|
|
871
|
+
this._force = options.force || false;
|
|
813
872
|
try {
|
|
814
873
|
// Create skeleton standards files (will be customized by analyze)
|
|
815
874
|
console.log(chalk.blue('📝 Creating skeleton standards files...'));
|
package/lib/commands/update.js
CHANGED
|
@@ -40,6 +40,9 @@ class UpdateCommand {
|
|
|
40
40
|
);
|
|
41
41
|
}
|
|
42
42
|
|
|
43
|
+
// Store force flag for use in downloadFiles
|
|
44
|
+
this._force = options.force || false;
|
|
45
|
+
|
|
43
46
|
// Create backup
|
|
44
47
|
const backupPath = await this.createBackup();
|
|
45
48
|
|
|
@@ -222,20 +225,33 @@ class UpdateCommand {
|
|
|
222
225
|
return config;
|
|
223
226
|
}
|
|
224
227
|
|
|
228
|
+
// ── User-owned file download helper ────────────────────────────────────────
|
|
229
|
+
// Downloads a file only if it does not already exist on disk.
|
|
230
|
+
// With force=true, always downloads (overwrites).
|
|
231
|
+
async _downloadUserOwnedFile(url, dest, force = false) {
|
|
232
|
+
if (!force && (await require('fs-extra').pathExists(dest))) {
|
|
233
|
+
console.log(chalk.dim(` ⏭ ${dest} preserved (user-owned)`));
|
|
234
|
+
return;
|
|
235
|
+
}
|
|
236
|
+
await this.downloadManager.downloadFile(url, dest);
|
|
237
|
+
}
|
|
238
|
+
|
|
225
239
|
async downloadFiles(projectType, config = {}) {
|
|
226
240
|
const spinner = ora('Downloading latest files...').start();
|
|
227
241
|
|
|
228
242
|
try {
|
|
229
243
|
// Download only the real files (not skeleton files - those come from install)
|
|
244
|
+
// standards/README.md is CK-owned (attribution) — always update
|
|
230
245
|
await this.downloadManager.downloadFile(
|
|
231
246
|
`${this.repoUrl}/standards/README.md`,
|
|
232
247
|
'.contextkit/standards/README.md'
|
|
233
248
|
);
|
|
234
249
|
|
|
235
|
-
//
|
|
236
|
-
await this.
|
|
250
|
+
// glossary.md is user-owned — only write if missing, never overwrite existing
|
|
251
|
+
await this._downloadUserOwnedFile(
|
|
237
252
|
`${this.repoUrl}/standards/glossary.md`,
|
|
238
|
-
'.contextkit/standards/glossary.md'
|
|
253
|
+
'.contextkit/standards/glossary.md',
|
|
254
|
+
this._force
|
|
239
255
|
);
|
|
240
256
|
|
|
241
257
|
// Download commands
|
|
@@ -21,7 +21,46 @@ class BaseIntegration {
|
|
|
21
21
|
this.platformDir = '';
|
|
22
22
|
}
|
|
23
23
|
|
|
24
|
-
|
|
24
|
+
// Returns true when all generated files exist AND the version in the bridge
|
|
25
|
+
// file matches the current package version. Returns false if anything is
|
|
26
|
+
// missing or outdated — triggering a full regeneration.
|
|
27
|
+
async isUpToDate() {
|
|
28
|
+
// All CK-generated files must be present
|
|
29
|
+
for (const file of this.generatedFiles) {
|
|
30
|
+
if (!(await fs.pathExists(file))) return false;
|
|
31
|
+
}
|
|
32
|
+
|
|
33
|
+
// If this integration has a bridge file, check the version embedded in it
|
|
34
|
+
if (this.bridgeFiles.length > 0) {
|
|
35
|
+
const bridgeFile = this.bridgeFiles[0];
|
|
36
|
+
if (!(await fs.pathExists(bridgeFile))) return false;
|
|
37
|
+
|
|
38
|
+
try {
|
|
39
|
+
const pkg = require('../../package.json');
|
|
40
|
+
const content = await fs.readFile(bridgeFile, 'utf-8');
|
|
41
|
+
const match = content.match(/Version:\s*([0-9]+\.[0-9]+\.[0-9]+)/);
|
|
42
|
+
if (!match || match[1] !== pkg.version) return false;
|
|
43
|
+
} catch {
|
|
44
|
+
return false;
|
|
45
|
+
}
|
|
46
|
+
}
|
|
47
|
+
|
|
48
|
+
return true;
|
|
49
|
+
}
|
|
50
|
+
|
|
51
|
+
async install(force = false) {
|
|
52
|
+
if (!force && (await this.isUpToDate())) {
|
|
53
|
+
try {
|
|
54
|
+
const pkg = require('../../package.json');
|
|
55
|
+
console.log(
|
|
56
|
+
chalk.green(` ✓ ${this.displayName} integration is up to date (v${pkg.version})`)
|
|
57
|
+
);
|
|
58
|
+
} catch {
|
|
59
|
+
console.log(chalk.green(` ✓ ${this.displayName} integration is up to date`));
|
|
60
|
+
}
|
|
61
|
+
return;
|
|
62
|
+
}
|
|
63
|
+
|
|
25
64
|
if (this.platformDir) {
|
|
26
65
|
await fs.ensureDir(this.platformDir);
|
|
27
66
|
}
|
|
@@ -38,12 +38,31 @@ class ClaudeIntegration extends BaseIntegration {
|
|
|
38
38
|
this.platformDir = '.claude/rules';
|
|
39
39
|
}
|
|
40
40
|
|
|
41
|
-
async install() {
|
|
42
|
-
await super.install();
|
|
41
|
+
async install(force = false) {
|
|
42
|
+
await super.install(force);
|
|
43
43
|
const fs = require('fs-extra');
|
|
44
44
|
await fs.ensureDir('.claude/skills');
|
|
45
45
|
await this.addToGitignore('.claude/settings.local.json');
|
|
46
46
|
await this.removeLegacyFiles();
|
|
47
|
+
await this._installHook();
|
|
48
|
+
}
|
|
49
|
+
|
|
50
|
+
async _installHook() {
|
|
51
|
+
try {
|
|
52
|
+
const HookDetector = require('../utils/hook-detector');
|
|
53
|
+
const ClaudeSettings = require('../utils/claude-settings');
|
|
54
|
+
const command = await new HookDetector().detect();
|
|
55
|
+
if (command) {
|
|
56
|
+
await new ClaudeSettings().addPostToolUseHook(command);
|
|
57
|
+
console.log(chalk.green(` ✓ PostToolUse hook: ${command}`));
|
|
58
|
+
} else {
|
|
59
|
+
console.log(
|
|
60
|
+
chalk.dim(chalk.yellow(' ⚠ No format/lint tooling detected — PostToolUse hook skipped'))
|
|
61
|
+
);
|
|
62
|
+
}
|
|
63
|
+
} catch (err) {
|
|
64
|
+
console.log(chalk.yellow(` ⚠ Could not install PostToolUse hook: ${err.message}`));
|
|
65
|
+
}
|
|
47
66
|
}
|
|
48
67
|
|
|
49
68
|
async addToGitignore(entry) {
|
|
@@ -0,0 +1,39 @@
|
|
|
1
|
+
# claude-integration.js
|
|
2
|
+
|
|
3
|
+
The Claude Code platform integration. Installs `CLAUDE.md`, `.claude/rules/`, `.claude/skills/`, and a PostToolUse hook in `.claude/settings.json`.
|
|
4
|
+
|
|
5
|
+
## Extends
|
|
6
|
+
|
|
7
|
+
`BaseIntegration`
|
|
8
|
+
|
|
9
|
+
## install(force = false)
|
|
10
|
+
|
|
11
|
+
Runs the full Claude Code setup:
|
|
12
|
+
1. Writes bridge file (`CLAUDE.md`) and generated files (rules, skills) via `super.install()`
|
|
13
|
+
2. Ensures `.claude/skills/` directory exists
|
|
14
|
+
3. Adds `.claude/settings.local.json` to `.gitignore`
|
|
15
|
+
4. Removes legacy `.claude/commands/` files
|
|
16
|
+
5. Detects project tooling and installs a PostToolUse hook in `.claude/settings.json`
|
|
17
|
+
|
|
18
|
+
### Hook Installation (step 5)
|
|
19
|
+
|
|
20
|
+
Uses `HookDetector` to detect the right format+lint command for the project (Node.js, Go, or Python). If a command is found, `ClaudeSettings.addPostToolUseHook()` writes it to `.claude/settings.json` with a `_contextkit: true` marker.
|
|
21
|
+
|
|
22
|
+
- On success: logs `✓ PostToolUse hook: <command>`
|
|
23
|
+
- No tooling detected: logs a dim yellow skip message
|
|
24
|
+
- Any error: logs a yellow warning and continues (graceful degradation)
|
|
25
|
+
|
|
26
|
+
Re-running `install()` (e.g. via `ck update → refreshIntegrations()`) replaces the existing ContextKit hook rather than duplicating it.
|
|
27
|
+
|
|
28
|
+
## generatedFiles
|
|
29
|
+
|
|
30
|
+
Listed in the constructor. Does **not** include `.claude/settings.json` — that file is a merge target, not overwritten.
|
|
31
|
+
|
|
32
|
+
## Key Files Written
|
|
33
|
+
|
|
34
|
+
| File | Type | Purpose |
|
|
35
|
+
|---|---|---|
|
|
36
|
+
| `CLAUDE.md` | Bridge (merged) | Auto-loaded every Claude Code session |
|
|
37
|
+
| `.claude/rules/contextkit-*.md` | Generated | Scoped rules for standards, testing, code style |
|
|
38
|
+
| `.claude/skills/*/SKILL.md` | Generated | All slash commands |
|
|
39
|
+
| `.claude/settings.json` | Merged | PostToolUse hook entry (`_contextkit: true`) |
|
|
@@ -12,8 +12,8 @@ class ContinueIntegration extends BaseIntegration {
|
|
|
12
12
|
this.platformDir = '.continue/rules';
|
|
13
13
|
}
|
|
14
14
|
|
|
15
|
-
async install() {
|
|
16
|
-
await super.install();
|
|
15
|
+
async install(force = false) {
|
|
16
|
+
await super.install(force);
|
|
17
17
|
// Remove old vibe-kit-named files
|
|
18
18
|
const legacyFile = '.continue/rules/vibe-kit-standards.md';
|
|
19
19
|
if (await fs.pathExists(legacyFile)) {
|
|
@@ -30,8 +30,8 @@ class CursorIntegration extends BaseIntegration {
|
|
|
30
30
|
this.platformDir = '.cursor/rules';
|
|
31
31
|
}
|
|
32
32
|
|
|
33
|
-
async install() {
|
|
34
|
-
await super.install();
|
|
33
|
+
async install(force = false) {
|
|
34
|
+
await super.install(force);
|
|
35
35
|
await fs.ensureDir('.cursor/prompts');
|
|
36
36
|
// Remove old monolithic rule file if present
|
|
37
37
|
await this.removeLegacyFiles();
|
|
@@ -11,8 +11,8 @@ class WindsurfIntegration extends BaseIntegration {
|
|
|
11
11
|
this.platformDir = '.windsurf/rules';
|
|
12
12
|
}
|
|
13
13
|
|
|
14
|
-
async install() {
|
|
15
|
-
await super.install();
|
|
14
|
+
async install(force = false) {
|
|
15
|
+
await super.install(force);
|
|
16
16
|
// Remove old vibe-kit-named files
|
|
17
17
|
const fs = require('fs-extra');
|
|
18
18
|
const legacyFile = '.windsurf/rules/vibe-kit-standards.md';
|
|
@@ -0,0 +1,67 @@
|
|
|
1
|
+
const fs = require('fs-extra');
|
|
2
|
+
const chalk = require('chalk');
|
|
3
|
+
|
|
4
|
+
const SETTINGS_PATH = '.claude/settings.json';
|
|
5
|
+
|
|
6
|
+
class ClaudeSettings {
|
|
7
|
+
async read() {
|
|
8
|
+
if (!(await fs.pathExists(SETTINGS_PATH))) return {};
|
|
9
|
+
try {
|
|
10
|
+
const raw = await fs.readFile(SETTINGS_PATH, 'utf8');
|
|
11
|
+
return JSON.parse(raw);
|
|
12
|
+
} catch {
|
|
13
|
+
console.warn(
|
|
14
|
+
chalk.yellow(` ⚠ .claude/settings.json contains invalid JSON — skipping hook merge`)
|
|
15
|
+
);
|
|
16
|
+
return {};
|
|
17
|
+
}
|
|
18
|
+
}
|
|
19
|
+
|
|
20
|
+
async write(settings) {
|
|
21
|
+
await fs.ensureDir('.claude');
|
|
22
|
+
await fs.writeFile(SETTINGS_PATH, JSON.stringify(settings, null, 2) + '\n');
|
|
23
|
+
}
|
|
24
|
+
|
|
25
|
+
async addPostToolUseHook(command) {
|
|
26
|
+
if (!command) throw new Error('hook command is required');
|
|
27
|
+
|
|
28
|
+
const settings = await this.read();
|
|
29
|
+
settings.hooks = settings.hooks || {};
|
|
30
|
+
|
|
31
|
+
if (!Array.isArray(settings.hooks.PostToolUse)) {
|
|
32
|
+
if (settings.hooks.PostToolUse !== undefined) {
|
|
33
|
+
settings.hooks._contextkit_original_invalid = settings.hooks.PostToolUse;
|
|
34
|
+
}
|
|
35
|
+
settings.hooks.PostToolUse = [];
|
|
36
|
+
}
|
|
37
|
+
|
|
38
|
+
settings.hooks.PostToolUse = settings.hooks.PostToolUse.filter((entry) => !entry._contextkit);
|
|
39
|
+
|
|
40
|
+
settings.hooks.PostToolUse.push({
|
|
41
|
+
matcher: 'Edit|Write',
|
|
42
|
+
hooks: [{ type: 'command', command }],
|
|
43
|
+
_contextkit: true,
|
|
44
|
+
});
|
|
45
|
+
|
|
46
|
+
await this.write(settings);
|
|
47
|
+
}
|
|
48
|
+
|
|
49
|
+
async removeContextKitHooks() {
|
|
50
|
+
const settings = await this.read();
|
|
51
|
+
if (!Array.isArray(settings.hooks?.PostToolUse)) return;
|
|
52
|
+
|
|
53
|
+
settings.hooks.PostToolUse = settings.hooks.PostToolUse.filter((entry) => !entry._contextkit);
|
|
54
|
+
|
|
55
|
+
if (settings.hooks.PostToolUse.length === 0) {
|
|
56
|
+
delete settings.hooks.PostToolUse;
|
|
57
|
+
}
|
|
58
|
+
|
|
59
|
+
if (Object.keys(settings.hooks).length === 0) {
|
|
60
|
+
delete settings.hooks;
|
|
61
|
+
}
|
|
62
|
+
|
|
63
|
+
await this.write(settings);
|
|
64
|
+
}
|
|
65
|
+
}
|
|
66
|
+
|
|
67
|
+
module.exports = ClaudeSettings;
|
|
@@ -0,0 +1,52 @@
|
|
|
1
|
+
# claude-settings.js
|
|
2
|
+
|
|
3
|
+
Reads, merges, and writes `.claude/settings.json` safely — adding or removing ContextKit-owned entries without clobbering user content.
|
|
4
|
+
|
|
5
|
+
## Exports
|
|
6
|
+
|
|
7
|
+
`ClaudeSettings` — class
|
|
8
|
+
|
|
9
|
+
## Public API
|
|
10
|
+
|
|
11
|
+
### `read() → Promise<object>`
|
|
12
|
+
|
|
13
|
+
Returns the parsed contents of `.claude/settings.json`, or `{}` if the file does not exist. On invalid JSON, logs a yellow warning and returns `{}` (does not throw).
|
|
14
|
+
|
|
15
|
+
### `write(settings) → Promise<void>`
|
|
16
|
+
|
|
17
|
+
Writes the settings object to `.claude/settings.json` with 2-space indentation and a trailing newline. Creates `.claude/` if it does not exist.
|
|
18
|
+
|
|
19
|
+
### `addPostToolUseHook(command) → Promise<void>`
|
|
20
|
+
|
|
21
|
+
Adds a ContextKit-owned PostToolUse hook entry. Replaces any existing `_contextkit: true` entry rather than duplicating. Merges into existing file content — other keys (permissions, other hooks) are preserved.
|
|
22
|
+
|
|
23
|
+
Throws `Error('hook command is required')` if `command` is falsy.
|
|
24
|
+
|
|
25
|
+
Hook entry written:
|
|
26
|
+
```json
|
|
27
|
+
{
|
|
28
|
+
"matcher": "Edit|Write",
|
|
29
|
+
"hooks": [{ "type": "command", "command": "<command>" }],
|
|
30
|
+
"_contextkit": true
|
|
31
|
+
}
|
|
32
|
+
```
|
|
33
|
+
|
|
34
|
+
### `removeContextKitHooks() → Promise<void>`
|
|
35
|
+
|
|
36
|
+
Removes all `PostToolUse` entries where `_contextkit: true`. If the array becomes empty, deletes the `PostToolUse` key. If `hooks` becomes empty, deletes the `hooks` key. No-op if the file is absent.
|
|
37
|
+
|
|
38
|
+
## Usage Example
|
|
39
|
+
|
|
40
|
+
```javascript
|
|
41
|
+
const ClaudeSettings = require('./claude-settings');
|
|
42
|
+
|
|
43
|
+
const cs = new ClaudeSettings();
|
|
44
|
+
await cs.addPostToolUseHook('pnpm run format && pnpm run lint --fix 2>&1 | tail -20');
|
|
45
|
+
// → writes .claude/settings.json, merging into existing content
|
|
46
|
+
```
|
|
47
|
+
|
|
48
|
+
## Edge Cases & Notes
|
|
49
|
+
|
|
50
|
+
- `PostToolUse` that is not an array: the original value is preserved under `_contextkit_original_invalid`, and a fresh array is started for ContextKit's entry
|
|
51
|
+
- Invalid JSON in existing file: returns `{}` with a warning — the file is NOT overwritten (protects corrupted user files)
|
|
52
|
+
- Thread safety: no file locking; not a concern for a CLI tool
|
package/lib/utils/git-hooks.js
CHANGED
|
@@ -17,12 +17,31 @@ class GitHooksManager {
|
|
|
17
17
|
}
|
|
18
18
|
|
|
19
19
|
try {
|
|
20
|
-
// Clean up legacy Husky directory if present
|
|
20
|
+
// Clean up legacy Husky directory if present (only removes CK-owned husky hooks)
|
|
21
21
|
await this.cleanupLegacyHusky();
|
|
22
22
|
|
|
23
23
|
// Clean up legacy .git/hooks/ files from previous ContextKit versions
|
|
24
24
|
await this.cleanupLegacyGitHooks();
|
|
25
25
|
|
|
26
|
+
// Detect conflicting hooks manager — suggest integration instead of forcing override
|
|
27
|
+
const conflict = await this.detectExistingHooksManager();
|
|
28
|
+
if (conflict.detected) {
|
|
29
|
+
console.log(chalk.yellow(`\n⚠️ Existing hooks manager detected: ${conflict.type}`));
|
|
30
|
+
console.log(chalk.yellow(` ${conflict.details}`));
|
|
31
|
+
console.log(chalk.blue('\n💡 To use ContextKit quality gates with your existing setup:'));
|
|
32
|
+
const lines = conflict.suggestion.split('\n');
|
|
33
|
+
for (const line of lines) {
|
|
34
|
+
console.log(chalk.dim(` ${line}`));
|
|
35
|
+
}
|
|
36
|
+
console.log(chalk.dim('\n Skipping automatic hooks setup to avoid conflicts.'));
|
|
37
|
+
console.log(
|
|
38
|
+
chalk.dim(
|
|
39
|
+
` To force ContextKit hooks, run: git config core.hooksPath ${this.hooksPath}`
|
|
40
|
+
)
|
|
41
|
+
);
|
|
42
|
+
return;
|
|
43
|
+
}
|
|
44
|
+
|
|
26
45
|
// Remove hooks the user didn't select
|
|
27
46
|
await this.removeUnselectedHooks(hookChoices);
|
|
28
47
|
|
|
@@ -46,6 +65,119 @@ class GitHooksManager {
|
|
|
46
65
|
}
|
|
47
66
|
}
|
|
48
67
|
|
|
68
|
+
async detectExistingHooksManager() {
|
|
69
|
+
// 1. Check if core.hooksPath already points somewhere other than .contextkit/hooks
|
|
70
|
+
try {
|
|
71
|
+
const currentHooksPath = execSync('git config core.hooksPath', {
|
|
72
|
+
encoding: 'utf8',
|
|
73
|
+
stdio: 'pipe',
|
|
74
|
+
}).trim();
|
|
75
|
+
if (currentHooksPath && currentHooksPath !== this.hooksPath) {
|
|
76
|
+
return {
|
|
77
|
+
detected: true,
|
|
78
|
+
type: 'core.hooksPath',
|
|
79
|
+
details: `core.hooksPath is already set to "${currentHooksPath}"`,
|
|
80
|
+
suggestion: `Run your existing hooks via that path, or update it manually:\n git config core.hooksPath ${this.hooksPath}`,
|
|
81
|
+
};
|
|
82
|
+
}
|
|
83
|
+
} catch {
|
|
84
|
+
// core.hooksPath not set — no conflict
|
|
85
|
+
}
|
|
86
|
+
|
|
87
|
+
// 2. Check for Husky (.husky/ still present after legacy cleanup = user-owned)
|
|
88
|
+
if (fs.existsSync('.husky')) {
|
|
89
|
+
return {
|
|
90
|
+
detected: true,
|
|
91
|
+
type: 'husky',
|
|
92
|
+
details: 'Found .husky/ directory with project hooks',
|
|
93
|
+
suggestion:
|
|
94
|
+
'Call ContextKit hooks from your existing Husky hooks:\n' +
|
|
95
|
+
' echo "sh .contextkit/hooks/pre-push" >> .husky/pre-push\n' +
|
|
96
|
+
' echo "sh .contextkit/hooks/commit-msg" >> .husky/commit-msg',
|
|
97
|
+
};
|
|
98
|
+
}
|
|
99
|
+
|
|
100
|
+
// 3. Check for Lefthook
|
|
101
|
+
if (
|
|
102
|
+
fs.existsSync('.lefthook.yml') ||
|
|
103
|
+
fs.existsSync('lefthook.yml') ||
|
|
104
|
+
fs.existsSync('.lefthook')
|
|
105
|
+
) {
|
|
106
|
+
return {
|
|
107
|
+
detected: true,
|
|
108
|
+
type: 'lefthook',
|
|
109
|
+
details: 'Found Lefthook configuration',
|
|
110
|
+
suggestion:
|
|
111
|
+
'Add ContextKit quality gates to your lefthook.yml:\n' +
|
|
112
|
+
' pre-push:\n' +
|
|
113
|
+
' commands:\n' +
|
|
114
|
+
' ck-quality:\n' +
|
|
115
|
+
' run: sh .contextkit/hooks/pre-push\n' +
|
|
116
|
+
' commit-msg:\n' +
|
|
117
|
+
' commands:\n' +
|
|
118
|
+
' ck-commit:\n' +
|
|
119
|
+
' run: sh .contextkit/hooks/commit-msg {1}',
|
|
120
|
+
};
|
|
121
|
+
}
|
|
122
|
+
|
|
123
|
+
// 4. Check for simple-git-hooks or husky.hooks in package.json
|
|
124
|
+
if (fs.existsSync('package.json')) {
|
|
125
|
+
try {
|
|
126
|
+
const pkg = JSON.parse(fs.readFileSync('package.json', 'utf-8'));
|
|
127
|
+
if (pkg['simple-git-hooks']) {
|
|
128
|
+
return {
|
|
129
|
+
detected: true,
|
|
130
|
+
type: 'simple-git-hooks',
|
|
131
|
+
details: 'Found simple-git-hooks configuration in package.json',
|
|
132
|
+
suggestion:
|
|
133
|
+
'Add ContextKit hooks to your simple-git-hooks config in package.json:\n' +
|
|
134
|
+
' "simple-git-hooks": {\n' +
|
|
135
|
+
' "pre-push": "sh .contextkit/hooks/pre-push",\n' +
|
|
136
|
+
' "commit-msg": "sh .contextkit/hooks/commit-msg $1"\n' +
|
|
137
|
+
' }',
|
|
138
|
+
};
|
|
139
|
+
}
|
|
140
|
+
if (pkg.husky && pkg.husky.hooks) {
|
|
141
|
+
return {
|
|
142
|
+
detected: true,
|
|
143
|
+
type: 'husky-config',
|
|
144
|
+
details: 'Found husky.hooks configuration in package.json',
|
|
145
|
+
suggestion:
|
|
146
|
+
'Add ContextKit hooks to your husky.hooks config in package.json:\n' +
|
|
147
|
+
' "husky": { "hooks": { "pre-push": "sh .contextkit/hooks/pre-push" } }',
|
|
148
|
+
};
|
|
149
|
+
}
|
|
150
|
+
} catch {
|
|
151
|
+
// Invalid package.json — skip
|
|
152
|
+
}
|
|
153
|
+
}
|
|
154
|
+
|
|
155
|
+
// 5. Check for non-CK files in .git/hooks/
|
|
156
|
+
const hooksToCheck = ['pre-push', 'commit-msg', 'pre-commit'];
|
|
157
|
+
for (const hook of hooksToCheck) {
|
|
158
|
+
const hookPath = `.git/hooks/${hook}`;
|
|
159
|
+
if (fs.existsSync(hookPath)) {
|
|
160
|
+
try {
|
|
161
|
+
const content = fs.readFileSync(hookPath, 'utf-8');
|
|
162
|
+
if (!content.includes('ContextKit managed hook') && !content.includes('.contextkit/')) {
|
|
163
|
+
return {
|
|
164
|
+
detected: true,
|
|
165
|
+
type: 'git-hooks',
|
|
166
|
+
details: `Found existing ${hook} hook in .git/hooks/`,
|
|
167
|
+
suggestion:
|
|
168
|
+
`Add ContextKit quality gates to your existing .git/hooks/${hook}:\n` +
|
|
169
|
+
` sh .contextkit/hooks/${hook}`,
|
|
170
|
+
};
|
|
171
|
+
}
|
|
172
|
+
} catch {
|
|
173
|
+
// Unreadable hook file — skip
|
|
174
|
+
}
|
|
175
|
+
}
|
|
176
|
+
}
|
|
177
|
+
|
|
178
|
+
return { detected: false, type: null, details: null, suggestion: null };
|
|
179
|
+
}
|
|
180
|
+
|
|
49
181
|
async removeUnselectedHooks(hookChoices) {
|
|
50
182
|
// If user didn't select a hook, remove it from .contextkit/hooks/
|
|
51
183
|
// so core.hooksPath doesn't run hooks they didn't want
|
|
@@ -0,0 +1,138 @@
|
|
|
1
|
+
const fs = require('fs-extra');
|
|
2
|
+
const path = require('path');
|
|
3
|
+
const { execSync } = require('child_process');
|
|
4
|
+
|
|
5
|
+
class HookDetector {
|
|
6
|
+
_commandExists(name) {
|
|
7
|
+
try {
|
|
8
|
+
execSync(`which ${name}`, { stdio: 'ignore' });
|
|
9
|
+
return true;
|
|
10
|
+
} catch {
|
|
11
|
+
return false;
|
|
12
|
+
}
|
|
13
|
+
}
|
|
14
|
+
|
|
15
|
+
_pmPrefix(pm) {
|
|
16
|
+
switch (pm) {
|
|
17
|
+
case 'pnpm':
|
|
18
|
+
return 'pnpm run';
|
|
19
|
+
case 'yarn':
|
|
20
|
+
return 'yarn';
|
|
21
|
+
case 'bun':
|
|
22
|
+
return 'bun run';
|
|
23
|
+
default:
|
|
24
|
+
return 'npm run';
|
|
25
|
+
}
|
|
26
|
+
}
|
|
27
|
+
|
|
28
|
+
_lintFixCmd(pm, hasLintFix, hasLint) {
|
|
29
|
+
if (hasLintFix) {
|
|
30
|
+
switch (pm) {
|
|
31
|
+
case 'yarn':
|
|
32
|
+
return 'yarn lint:fix';
|
|
33
|
+
default:
|
|
34
|
+
return `${this._pmPrefix(pm)} lint:fix`;
|
|
35
|
+
}
|
|
36
|
+
}
|
|
37
|
+
if (hasLint) {
|
|
38
|
+
switch (pm) {
|
|
39
|
+
case 'pnpm':
|
|
40
|
+
return 'pnpm run lint --fix';
|
|
41
|
+
case 'yarn':
|
|
42
|
+
return 'yarn lint --fix';
|
|
43
|
+
case 'bun':
|
|
44
|
+
return 'bun run lint --fix';
|
|
45
|
+
default:
|
|
46
|
+
return 'npm run lint -- --fix';
|
|
47
|
+
}
|
|
48
|
+
}
|
|
49
|
+
return null;
|
|
50
|
+
}
|
|
51
|
+
|
|
52
|
+
async _detectNode(projectDir) {
|
|
53
|
+
const pkgPath = path.join(projectDir, 'package.json');
|
|
54
|
+
if (!(await fs.pathExists(pkgPath))) return null;
|
|
55
|
+
|
|
56
|
+
let packageJson;
|
|
57
|
+
try {
|
|
58
|
+
const raw = await fs.readFile(pkgPath, 'utf8');
|
|
59
|
+
packageJson = JSON.parse(raw);
|
|
60
|
+
} catch {
|
|
61
|
+
return null;
|
|
62
|
+
}
|
|
63
|
+
|
|
64
|
+
const scripts = packageJson.scripts || {};
|
|
65
|
+
|
|
66
|
+
let pm = 'npm';
|
|
67
|
+
if (await fs.pathExists(path.join(projectDir, 'bun.lockb'))) {
|
|
68
|
+
pm = 'bun';
|
|
69
|
+
} else if (await fs.pathExists(path.join(projectDir, 'pnpm-lock.yaml'))) {
|
|
70
|
+
pm = 'pnpm';
|
|
71
|
+
} else if (await fs.pathExists(path.join(projectDir, 'yarn.lock'))) {
|
|
72
|
+
pm = 'yarn';
|
|
73
|
+
}
|
|
74
|
+
|
|
75
|
+
const hasFormat = Boolean(scripts.format);
|
|
76
|
+
const hasLintFix = Boolean(scripts['lint:fix']);
|
|
77
|
+
const hasLint = Boolean(scripts.lint);
|
|
78
|
+
|
|
79
|
+
const parts = [];
|
|
80
|
+
|
|
81
|
+
if (hasFormat) {
|
|
82
|
+
const prefix = this._pmPrefix(pm);
|
|
83
|
+
parts.push(pm === 'yarn' ? `yarn format` : `${prefix} format`);
|
|
84
|
+
}
|
|
85
|
+
|
|
86
|
+
const lintCmd = this._lintFixCmd(pm, hasLintFix, hasLint);
|
|
87
|
+
if (lintCmd) {
|
|
88
|
+
parts.push(lintCmd);
|
|
89
|
+
}
|
|
90
|
+
|
|
91
|
+
if (parts.length === 0) return null;
|
|
92
|
+
|
|
93
|
+
return parts.join(' && ') + ' 2>&1 | tail -20';
|
|
94
|
+
}
|
|
95
|
+
|
|
96
|
+
async _detectGo(projectDir) {
|
|
97
|
+
if (!(await fs.pathExists(path.join(projectDir, 'go.mod')))) return null;
|
|
98
|
+
|
|
99
|
+
const parts = [];
|
|
100
|
+
if (this._commandExists('gofmt')) parts.push('gofmt -w .');
|
|
101
|
+
if (this._commandExists('golangci-lint')) parts.push('golangci-lint run 2>&1 | tail -20');
|
|
102
|
+
|
|
103
|
+
if (parts.length === 0) return null;
|
|
104
|
+
|
|
105
|
+
const cmd = parts.join(' && ');
|
|
106
|
+
// If only gofmt, add tail ourselves; if golangci-lint is present, it already has tail
|
|
107
|
+
return parts.length === 1 && parts[0] === 'gofmt -w .' ? cmd : cmd;
|
|
108
|
+
}
|
|
109
|
+
|
|
110
|
+
async _detectPython(projectDir) {
|
|
111
|
+
const hasPyproject = await fs.pathExists(path.join(projectDir, 'pyproject.toml'));
|
|
112
|
+
const hasRequirements = await fs.pathExists(path.join(projectDir, 'requirements.txt'));
|
|
113
|
+
if (!hasPyproject && !hasRequirements) return null;
|
|
114
|
+
|
|
115
|
+
const parts = [];
|
|
116
|
+
if (this._commandExists('black')) parts.push('black .');
|
|
117
|
+
if (this._commandExists('ruff')) parts.push('ruff check --fix . 2>&1 | tail -20');
|
|
118
|
+
|
|
119
|
+
if (parts.length === 0) return null;
|
|
120
|
+
|
|
121
|
+
return parts.join(' && ');
|
|
122
|
+
}
|
|
123
|
+
|
|
124
|
+
async detect(projectDir = process.cwd()) {
|
|
125
|
+
const node = await this._detectNode(projectDir);
|
|
126
|
+
if (node) return node;
|
|
127
|
+
|
|
128
|
+
const go = await this._detectGo(projectDir);
|
|
129
|
+
if (go) return go;
|
|
130
|
+
|
|
131
|
+
const python = await this._detectPython(projectDir);
|
|
132
|
+
if (python) return python;
|
|
133
|
+
|
|
134
|
+
return null;
|
|
135
|
+
}
|
|
136
|
+
}
|
|
137
|
+
|
|
138
|
+
module.exports = HookDetector;
|
|
@@ -0,0 +1,42 @@
|
|
|
1
|
+
# hook-detector.js
|
|
2
|
+
|
|
3
|
+
Detects the right PostToolUse hook command for a project based on its language, package manager, and available scripts.
|
|
4
|
+
|
|
5
|
+
## Exports
|
|
6
|
+
|
|
7
|
+
`HookDetector` — class
|
|
8
|
+
|
|
9
|
+
## Public API
|
|
10
|
+
|
|
11
|
+
### `detect(projectDir = process.cwd()) → Promise<string | null>`
|
|
12
|
+
|
|
13
|
+
Returns a shell command string suitable for use as a Claude Code PostToolUse hook, or `null` if no format/lint tooling is detected.
|
|
14
|
+
|
|
15
|
+
Tries three detectors in order: Node.js → Go → Python.
|
|
16
|
+
|
|
17
|
+
## Usage Example
|
|
18
|
+
|
|
19
|
+
```javascript
|
|
20
|
+
const HookDetector = require('./hook-detector');
|
|
21
|
+
|
|
22
|
+
const command = await new HookDetector().detect('/path/to/project');
|
|
23
|
+
// → "pnpm run format && pnpm run lint --fix 2>&1 | tail -20"
|
|
24
|
+
// → null (if no tooling found)
|
|
25
|
+
```
|
|
26
|
+
|
|
27
|
+
## Detection Logic
|
|
28
|
+
|
|
29
|
+
| Stack | Trigger | PM detection | Command built from |
|
|
30
|
+
|---|---|---|---|
|
|
31
|
+
| Node.js | `package.json` exists | lockfile (`bun.lockb` > `pnpm-lock.yaml` > `yarn.lock` > `npm`) | `scripts.format`, `scripts['lint:fix']`, `scripts.lint` |
|
|
32
|
+
| Go | `go.mod` exists | n/a | `gofmt` and/or `golangci-lint` (via `which`) |
|
|
33
|
+
| Python | `pyproject.toml` or `requirements.txt` | n/a | `black` and/or `ruff` (via `which`) |
|
|
34
|
+
|
|
35
|
+
For Node.js: `lint:fix` takes priority over `lint`. If only `lint` is present, `--fix` is appended (npm uses `-- --fix` separator; pnpm/yarn/bun pass args directly).
|
|
36
|
+
|
|
37
|
+
## Edge Cases & Notes
|
|
38
|
+
|
|
39
|
+
- Malformed `package.json` → returns `null`, does not throw
|
|
40
|
+
- No scripts in `package.json` → returns `null`
|
|
41
|
+
- Go/Python tools not on PATH → returns `null`
|
|
42
|
+
- Node.js takes priority when multiple stack markers coexist
|
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "@nolrm/contextkit",
|
|
3
|
-
"version": "0.
|
|
3
|
+
"version": "0.17.0",
|
|
4
4
|
"description": "ContextKit - Context Engineering for AI Development. Provide rich context to AI through structured MD files with standards, code guides, and documentation. Works with Cursor, Claude, Aider, VS Code Copilot, and more.",
|
|
5
5
|
"main": "lib/index.js",
|
|
6
6
|
"bin": {
|