clckernel 1.2.13 → 1.2.18

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/src/catalog.js CHANGED
@@ -28,147 +28,152 @@ const SAFEGUARD_CATALOG = [
28
28
  { name: 'scan_secrets', labels: ['scan_secrets', 'scan_secrets.js', 'scan_secrets.py'],
29
29
  checks: 'Secret & private-key leak scan', verify: ['verify_secret_leaks', 'verify_no_hardcoded_secrets'],
30
30
  applies_when: 'always', severity: 'error', gate_mode: 'hard', config_keys: ['scope', 'exclude_paths'],
31
- phase: 'audit', file: 'scan_secrets.js+scan_secrets.py', manifest: true, planned: false },
31
+ phase: 'audit', file: 'scan_secrets.js+scan_secrets.py', manifest: true, planned: false, lens: 'security' },
32
32
 
33
33
  { name: 'check_architecture', labels: ['check_architecture', 'check_architecture.js', 'check_architecture.py'],
34
34
  checks: 'Clean Architecture layer / server-component boundaries', verify: ['verify_architecture_layers', 'verify_server_component_boundaries'],
35
35
  applies_when: 'frontend,backend', severity: 'error', gate_mode: 'hard', config_keys: ['scope', 'exclude_paths'],
36
- phase: 'audit', file: 'check_architecture.js+check_architecture.py', manifest: true, planned: false },
36
+ phase: 'audit', file: 'check_architecture.js+check_architecture.py', manifest: true, planned: false, lens: 'architecture' },
37
37
 
38
38
  { name: 'check_custom', labels: ['check_custom', 'check_custom.js', 'check_custom.py'],
39
39
  checks: 'User-defined regex rules from `.clc-forge.yml`', verify: ['verify_custom_rules', 'verify_user_defined_regex_rules'],
40
40
  applies_when: 'always', severity: 'error', gate_mode: 'hard', config_keys: ['scope', 'exclude_paths'],
41
- phase: 'audit', file: 'check_custom.js+check_custom.py', manifest: true, planned: false },
41
+ phase: 'audit', file: 'check_custom.js+check_custom.py', manifest: true, planned: false, lens: 'architecture' },
42
42
 
43
43
  { name: 'check_a11y', labels: ['check_a11y', 'check_a11y.js'],
44
44
  checks: 'ARIA + alt accessibility', verify: ['verify_a11y', 'verify_accessibility'],
45
45
  applies_when: 'frontend', severity: 'error', gate_mode: 'hard', config_keys: ['scope', 'exclude_paths'],
46
- phase: 'audit', file: 'check_a11y.js', manifest: true, planned: false },
46
+ phase: 'audit', file: 'check_a11y.js', manifest: true, planned: false, lens: 'correctness' },
47
47
 
48
48
  { name: 'check_ui_reuse', labels: ['check_ui_reuse', 'check_ui_reuse.js'],
49
49
  checks: 'UI primitive reuse from components/ui/', verify: ['verify_ui_reuse', 'verify_component_reuse'],
50
50
  applies_when: 'frontend', severity: 'warning', gate_mode: 'advisory', config_keys: ['scope', 'exclude_paths'],
51
- phase: 'audit', file: 'check_ui_reuse.js', manifest: true, planned: false },
51
+ phase: 'audit', file: 'check_ui_reuse.js', manifest: true, planned: false, lens: 'architecture' },
52
52
 
53
53
  { name: 'check_performance', labels: ['check_performance', 'check_performance.js'],
54
54
  checks: 'next/image + tree-shaking', verify: ['verify_performance', 'verify_image_optimization'],
55
55
  applies_when: 'frontend', severity: 'warning', gate_mode: 'advisory', config_keys: ['scope', 'exclude_paths'],
56
- phase: 'audit', file: 'check_performance.js', manifest: true, planned: false },
56
+ phase: 'audit', file: 'check_performance.js', manifest: true, planned: false, lens: 'performance' },
57
57
 
58
58
  { name: 'check_responsive', labels: ['check_responsive', 'check_responsive.js'],
59
59
  checks: 'Mobile-first responsiveness, fixed-width overflow & touch target size', verify: ['verify_responsive', 'verify_mobile_adaptability'],
60
60
  applies_when: 'frontend', severity: 'warning', gate_mode: 'advisory', config_keys: ['scope', 'exclude_paths'],
61
- phase: 'audit', file: 'check_responsive.js', manifest: true, planned: false },
61
+ phase: 'audit', file: 'check_responsive.js', manifest: true, planned: false, lens: 'performance' },
62
62
 
63
63
  { name: 'check_seo', labels: ['check_seo', 'check_seo.js'],
64
64
  checks: 'SEO metadata, OpenGraph, GEO tags, semantic HTML & LCP priority', verify: ['verify_seo', 'verify_geo_metadata', 'verify_core_web_vitals'],
65
65
  applies_when: 'frontend', severity: 'warning', gate_mode: 'advisory', config_keys: ['scope', 'exclude_paths'],
66
- phase: 'audit', file: 'check_seo.js', manifest: true, planned: false },
66
+ phase: 'audit', file: 'check_seo.js', manifest: true, planned: false, lens: 'performance' },
67
67
 
68
68
  { name: 'check_storybook', labels: ['check_storybook', 'check_storybook.js'],
69
69
  checks: 'Storybook coverage (`.stories.tsx`)', verify: ['verify_storybook_coverage'],
70
70
  applies_when: 'frontend', severity: 'warning', gate_mode: 'advisory', config_keys: ['scope', 'exclude_paths'],
71
- phase: 'audit', file: 'check_storybook.js', manifest: true, planned: false },
71
+ phase: 'audit', file: 'check_storybook.js', manifest: true, planned: false, lens: 'performance' },
72
72
 
73
73
  { name: 'check_api_contracts', labels: ['check_api_contracts', 'check_api_contracts.js'],
74
74
  checks: 'Zod response-schema parsing', verify: ['verify_api_contracts', 'verify_response_schemas'],
75
75
  applies_when: 'frontend', severity: 'error', gate_mode: 'hard', config_keys: ['scope', 'exclude_paths'],
76
- phase: 'audit', file: 'check_api_contracts.js', manifest: true, planned: false },
76
+ phase: 'audit', file: 'check_api_contracts.js', manifest: true, planned: false, lens: 'correctness' },
77
77
 
78
78
  { name: 'check_migrations', labels: ['check_migrations', 'check_migrations.py'],
79
79
  checks: 'Alembic/Django migration idempotency', verify: ['verify_migration_idempotency'],
80
80
  applies_when: 'backend,fastapi,django', severity: 'error', gate_mode: 'hard', config_keys: ['scope', 'exclude_paths'],
81
- phase: 'audit', file: 'check_migrations.py', manifest: true, planned: false },
81
+ phase: 'audit', file: 'check_migrations.py', manifest: true, planned: false, lens: 'architecture' },
82
82
 
83
83
  { name: 'verify_tdd', labels: ['verify_tdd', 'verify_tdd.py'],
84
84
  checks: 'Real red→green TDD validation', verify: ['verify_tdd_red_green', 'verify_tdd_evidence'],
85
85
  applies_when: 'backend', severity: 'error', gate_mode: 'hard', config_keys: ['scope', 'exclude_paths'],
86
- phase: 'audit', file: 'verify_tdd.py', manifest: false, planned: false },
86
+ phase: 'audit', file: 'verify_tdd.py', manifest: false, planned: false, lens: 'correctness' },
87
87
 
88
88
  { name: 'check_scope', labels: ['check_scope', 'check_scope.py'],
89
89
  checks: 'git-diff vs SDD-authorization scope guardrail', verify: ['verify_scope_authorization', 'verify_sdd_scope'],
90
90
  applies_when: 'always', severity: 'error', gate_mode: 'hard', config_keys: ['scope', 'exclude_paths'],
91
- phase: 'audit', file: 'check_scope.py', manifest: false, planned: false },
91
+ phase: 'audit', file: 'check_scope.py', manifest: false, planned: false, lens: 'correctness' },
92
92
 
93
93
  { name: 'celery_guard', labels: ['celery_guard', 'celery_guard.py'],
94
94
  checks: 'Broker URL exposure, ignore_result, result backend', verify: ['verify_celery_broker_config'],
95
95
  applies_when: 'celery', severity: 'error', gate_mode: 'hard', config_keys: ['scope', 'exclude_paths'],
96
- phase: 'audit', file: 'celery_guard.py', manifest: true, planned: false },
96
+ phase: 'audit', file: 'celery_guard.py', manifest: true, planned: false, lens: 'security' },
97
97
 
98
98
  { name: 'redis_guard', labels: ['redis_guard', 'redis_guard.py'],
99
99
  checks: 'decode_responses, connect timeout, hardcoded URLs', verify: ['verify_redis_config'],
100
100
  applies_when: 'redis', severity: 'error', gate_mode: 'hard', config_keys: ['scope', 'exclude_paths'],
101
- phase: 'audit', file: 'redis_guard.py', manifest: true, planned: false },
101
+ phase: 'audit', file: 'redis_guard.py', manifest: true, planned: false, lens: 'security' },
102
102
 
103
103
  { name: 'postgres_guard', labels: ['postgres_guard', 'postgres_guard.py'],
104
104
  checks: 'Raw SQL string formatting, hstore misuse', verify: ['verify_postgres_sql_usage'],
105
105
  applies_when: 'postgresql', severity: 'error', gate_mode: 'hard', config_keys: ['scope', 'exclude_paths'],
106
- phase: 'audit', file: 'postgres_guard.py', manifest: true, planned: false },
106
+ phase: 'audit', file: 'postgres_guard.py', manifest: true, planned: false, lens: 'security' },
107
107
 
108
108
  { name: 'docker_guard', labels: ['docker_guard', 'docker_guard.py'],
109
109
  checks: 'Non-root USER, ENV secrets, missing HEALTHCHECK', verify: ['verify_dockerfile_security'],
110
110
  applies_when: 'docker', severity: 'error', gate_mode: 'hard', config_keys: ['scope', 'exclude_paths'],
111
- phase: 'audit', file: 'docker_guard.py', manifest: true, planned: false },
111
+ phase: 'audit', file: 'docker_guard.py', manifest: true, planned: false, lens: 'security' },
112
112
 
113
113
  { name: 'check_symlinks', labels: ['check_symlinks', 'check_symlinks.js'],
114
114
  checks: 'AI IDE symlinks and rule mirror verification', verify: ['verify_symlinks', 'verify_rule_mirrors'],
115
115
  applies_when: 'agent-os', severity: 'error', gate_mode: 'hard', config_keys: ['scope'],
116
- phase: 'audit', file: 'check_symlinks.js', manifest: true, planned: false },
116
+ phase: 'audit', file: 'check_symlinks.js', manifest: true, planned: false, lens: 'architecture' },
117
117
 
118
118
  { name: 'check_hitl', labels: ['check_hitl', 'check_hitl.js'],
119
119
  checks: 'Human-in-the-Loop review checkpoint validator in subagent skills', verify: ['verify_hitl', 'verify_human_review_checkpoints'],
120
120
  applies_when: 'agent-os', severity: 'error', gate_mode: 'hard', config_keys: ['scope'],
121
- phase: 'audit', file: 'check_hitl.js', manifest: true, planned: false },
121
+ phase: 'audit', file: 'check_hitl.js', manifest: true, planned: false, lens: 'architecture' },
122
122
 
123
123
  { name: 'check_domain_data', labels: ['check_domain_data', 'check_domain_data.js'],
124
124
  checks: 'Domain deliverables schema and clean state integrity', verify: ['verify_domain_data', 'verify_clean_state_metrics'],
125
125
  applies_when: 'agent-os', severity: 'error', gate_mode: 'hard', config_keys: ['scope'],
126
- phase: 'audit', file: 'check_domain_data.js', manifest: true, planned: false },
126
+ phase: 'audit', file: 'check_domain_data.js', manifest: true, planned: false, lens: 'architecture' },
127
+
128
+ { name: 'check_test_calendar_sync', labels: ['check_test_calendar_sync', 'check_test_calendar_sync.js'],
129
+ checks: 'Test calendar harness synchronization and parity check', verify: ['verify_calendar_sync', 'verify_test_calendar_sync'],
130
+ applies_when: 'agent-os', severity: 'error', gate_mode: 'hard', config_keys: ['scope'],
131
+ phase: 'audit', file: 'check_test_calendar_sync.js', manifest: true, planned: false, lens: 'performance' },
127
132
 
128
133
  { name: 'ruff', labels: ['ruff'],
129
134
  checks: 'Python AST linter (external CLI)', verify: ['verify_python_lint'],
130
135
  applies_when: 'backend', severity: 'warning', gate_mode: 'advisory', config_keys: [],
131
- phase: 'test', file: null, manifest: true, planned: false },
136
+ phase: 'test', file: null, manifest: true, planned: false, lens: 'correctness' },
132
137
 
133
138
  { name: 'pytest', labels: ['pytest'],
134
139
  checks: 'Test runner (external CLI)', verify: ['verify_test_coverage'],
135
140
  applies_when: 'backend', severity: 'warning', gate_mode: 'advisory', config_keys: [],
136
- phase: 'test', file: null, manifest: true, planned: false },
141
+ phase: 'test', file: null, manifest: true, planned: false, lens: 'correctness' },
137
142
 
138
143
  { name: 'tsc', labels: ['tsc'],
139
144
  checks: 'TypeScript typecheck (external)', verify: ['verify_typescript_types'],
140
145
  applies_when: 'frontend', severity: 'error', gate_mode: 'hard', config_keys: [],
141
- phase: 'test', file: null, manifest: true, planned: false },
146
+ phase: 'test', file: null, manifest: true, planned: false, lens: 'correctness' },
142
147
 
143
148
  { name: 'lint', labels: ['lint', 'lint-staged'],
144
149
  checks: 'lint-staged (external)', verify: ['verify_lint_staged'],
145
150
  applies_when: 'frontend', severity: 'warning', gate_mode: 'advisory', config_keys: [],
146
- phase: 'test', file: null, manifest: true, planned: false },
151
+ phase: 'test', file: null, manifest: true, planned: false, lens: 'correctness' },
147
152
 
148
153
  { name: 'graphify', labels: ['graphify'],
149
154
  checks: 'Memory graph (external, graceful-skip)', verify: ['verify_memory_graph'],
150
155
  applies_when: 'always', severity: 'warning', gate_mode: 'advisory', config_keys: [],
151
- phase: 'review', file: null, manifest: false, planned: false },
156
+ phase: 'review', file: null, manifest: false, planned: false, lens: 'architecture' },
152
157
 
153
158
  { name: 'gentle_ai_review', labels: ['gentle_ai_review', 'gentle-ai'],
154
159
  checks: 'Native bounded review gate (external, graceful-skip)', verify: ['verify_rdd_review_gate'],
155
160
  applies_when: 'always', severity: 'error', gate_mode: 'hard', config_keys: [],
156
- phase: 'review', file: null, manifest: false, planned: false },
161
+ phase: 'review', file: null, manifest: false, planned: false, lens: 'architecture' },
157
162
 
158
163
  { name: 'check_openapi_drift', labels: ['check_openapi_drift', 'check_openapi_drift.py'],
159
164
  checks: 'FastAPI OpenAPI schema drift vs app routes', verify: ['verify_openapi_drift'],
160
165
  applies_when: 'fastapi', severity: 'error', gate_mode: 'hard', config_keys: ['scope', 'exclude_paths'],
161
- phase: 'audit', file: 'check_openapi_drift.py', manifest: true, planned: true },
166
+ phase: 'audit', file: 'check_openapi_drift.py', manifest: true, planned: true, lens: 'correctness' },
162
167
 
163
168
  { name: 'check_db_efficiency', labels: ['check_db_efficiency', 'check_db_efficiency.py'],
164
169
  checks: 'ORM N+1 / select_related / prefetch_related', verify: ['verify_db_efficiency', 'verify_no_n_plus_1'],
165
170
  applies_when: 'django|sqlalchemy', severity: 'error', gate_mode: 'hard', config_keys: ['scope', 'exclude_paths'],
166
- phase: 'audit', file: 'check_db_efficiency.py', manifest: true, planned: false },
171
+ phase: 'audit', file: 'check_db_efficiency.py', manifest: true, planned: false, lens: 'performance' },
167
172
 
168
173
  { name: 'check_observability', labels: ['check_observability', 'check_observability.py'],
169
174
  checks: 'structlog structured logging; bare print/logger ban', verify: ['verify_structured_logging'],
170
175
  applies_when: 'backend', severity: 'warning', gate_mode: 'advisory', config_keys: ['scope', 'exclude_paths'],
171
- phase: 'audit', file: 'check_observability.py', manifest: true, planned: true },
176
+ phase: 'audit', file: 'check_observability.py', manifest: true, planned: true, lens: 'performance' },
172
177
  ];
173
178
 
174
179
  // Freeze the registry so config validation and generation can rely on it.
@@ -190,6 +195,9 @@ const VALID_APPLIES_WHEN = [
190
195
  'agent-os',
191
196
  ];
192
197
 
198
+ /** Canonical 4 RDD Lenses */
199
+ const VALID_LENSES = ['correctness', 'security', 'architecture', 'performance'];
200
+
193
201
  /** Normalize an input label for catalog lookup. */
194
202
  function normalizeLabel(name) {
195
203
  return typeof name === 'string' ? name.trim().toLowerCase() : '';
@@ -221,13 +229,21 @@ function gateFor(name) {
221
229
  return entry ? entry.gate_mode : null;
222
230
  }
223
231
 
232
+ /** Canonical RDD review lens for a guard name/label; null when unknown. */
233
+ function lensFor(name) {
234
+ const entry = applyTo(name);
235
+ return entry ? entry.lens : null;
236
+ }
237
+
224
238
  module.exports = {
225
239
  SAFEGUARD_CATALOG,
226
240
  VALID_SEVERITIES,
227
241
  VALID_GATE_MODES,
228
242
  VALID_PHASES,
229
243
  VALID_APPLIES_WHEN,
244
+ VALID_LENSES,
230
245
  applyTo,
231
246
  severityFor,
232
247
  gateFor,
248
+ lensFor,
233
249
  };
package/src/config.js CHANGED
@@ -4,7 +4,7 @@
4
4
  * never a silent fallback.
5
5
  */
6
6
 
7
- const { parseYaml } = require('./yaml.js');
7
+ const { parseYaml } = require('../tools/yaml.js');
8
8
  const {
9
9
  SAFEGUARD_CATALOG,
10
10
  VALID_SEVERITIES,
package/src/generator.js CHANGED
@@ -8,9 +8,33 @@ const path = require('path');
8
8
  const { execSync } = require('child_process');
9
9
  const { resolveConfig } = require('./config.js');
10
10
 
11
+ /**
12
+ * Detects whether a Node CLI required by a hook command is actually installed
13
+ * in the target project. Resolving through node_modules/.bin is what `npx` does
14
+ * first, so a missing entry there means `npx` would download it on the fly (or
15
+ * fail inside a commit hook, where the network is not available).
16
+ * @param {string} targetDir - Target project root
17
+ * @param {string} pkg - Package name to look for
18
+ * @returns {boolean}
19
+ */
20
+ function isCliInstalled(targetDir, pkg) {
21
+ if (!pkg) return false;
22
+ try {
23
+ return fs.existsSync(path.join(targetDir, 'node_modules', '.bin', pkg));
24
+ } catch (e) {
25
+ return false;
26
+ }
27
+ }
28
+
11
29
  /**
12
30
  * Generates a pre-commit hook dynamically from the adapter's tool manifest.
13
31
  * Frontend projects use .husky; backend projects use .githooks.
32
+ *
33
+ * External CLIs (language === 'external', e.g. tsc, lint-staged) are only emitted
34
+ * when the target project already depends on them: the generator does not add
35
+ * dependencies, so referencing an uninstalled binary produces a hook that fails
36
+ * on every commit.
37
+ *
14
38
  * @param {object} adapter - Adapter with getTools() and projectType
15
39
  * @param {string} targetDir - Target project root
16
40
  */
@@ -23,10 +47,17 @@ function generateHookFromManifest(adapter, targetDir) {
23
47
  : path.join(targetDir, '.githooks');
24
48
  const hookFile = path.join(hookDir, 'pre-commit');
25
49
 
50
+ // The husky bootstrap only exists when the project ran `husky init`. Source it
51
+ // only in that case, so a generated hook never points at a missing file.
52
+ const huskyBootstrap = path.join(hookDir, '_', 'husky.sh');
53
+ const useHuskyBootstrap = isFront && fs.existsSync(huskyBootstrap);
54
+
26
55
  const lines = [];
27
56
  if (isFront) {
28
57
  lines.push('#!/usr/bin/env sh');
29
- lines.push('. "$(dirname -- "$0")/_/husky.sh"');
58
+ if (useHuskyBootstrap) {
59
+ lines.push('. "$(dirname -- "$0")/_/husky.sh"');
60
+ }
30
61
  lines.push('');
31
62
  } else {
32
63
  lines.push('#!/usr/bin/env bash');
@@ -34,13 +65,26 @@ function generateHookFromManifest(adapter, targetDir) {
34
65
  lines.push('');
35
66
  }
36
67
 
68
+ const skipped = [];
37
69
  tools.forEach(tool => {
38
70
  if (!tool.command) return;
71
+ if (tool.language === 'external') {
72
+ if (!isCliInstalled(targetDir, tool.extCLI)) {
73
+ skipped.push(tool.extCLI || tool.command);
74
+ return;
75
+ }
76
+ }
39
77
  lines.push(tool.command);
40
78
  });
41
79
 
80
+ if (skipped.length > 0) {
81
+ lines.push('');
82
+ lines.push(`# Not run here (not a dependency of this project): ${skipped.join(', ')}`);
83
+ lines.push('# Install it and re-run `clckernel update` to enable it.');
84
+ }
85
+
42
86
  fs.mkdirSync(hookDir, { recursive: true });
43
- fs.writeFileSync(hookFile, lines.join('\n'), 'utf-8');
87
+ fs.writeFileSync(hookFile, lines.join('\n') + '\n', 'utf-8');
44
88
  try { fs.chmodSync(hookFile, '755'); } catch (e) {}
45
89
 
46
90
  if (!isFront) {
@@ -62,6 +106,10 @@ function installToolsFromManifest(targetDir, manifest, config) {
62
106
  const sourceToolsDir = path.join(__dirname, '..', 'tools');
63
107
  const isFront = config.projectType === 'frontend';
64
108
 
109
+ // tools/ must exist before anything is copied into it. Provisioning a project
110
+ // that has no tools/ yet used to throw ENOENT on the first copyFileSync.
111
+ fs.mkdirSync(path.join(targetDir, 'tools'), { recursive: true });
112
+
65
113
  // Validate descriptors: skip + warn tools missing required fields
66
114
  const validTools = manifest.filter(tool => {
67
115
  if (!tool.name || !tool.language || !tool.command) {
@@ -72,7 +120,8 @@ function installToolsFromManifest(targetDir, manifest, config) {
72
120
  });
73
121
 
74
122
  // Always copy the audit orchestrator for the right language
75
- const orchestrator = isFront ? 'audit.js' : 'audit.py';
123
+ const isNodeStack = config.projectType === 'frontend' || config.projectType === 'agent-os';
124
+ const orchestrator = isNodeStack ? 'audit.js' : 'audit.py';
76
125
  const orchestratorSrc = path.join(sourceToolsDir, orchestrator);
77
126
  const orchestratorDest = path.join(targetDir, 'tools', orchestrator);
78
127
  if (fs.existsSync(orchestratorSrc)) {
@@ -93,25 +142,29 @@ function installToolsFromManifest(targetDir, manifest, config) {
93
142
  }
94
143
  });
95
144
 
96
- // check_custom.js requires the shared YAML parser (../src/yaml.js),
97
- // so the generated project must ship it too.
145
+ // check_custom.js parses the project YAML config, so the generated project
146
+ // must ship the parser alongside it.
98
147
  if (validTools.some(tool => tool.path === 'check_custom.js')) {
99
- installYamlModule(targetDir, sourceToolsDir);
148
+ installYamlModule(targetDir);
100
149
  }
101
150
  }
102
151
 
103
152
  /**
104
- * Copies the zero-dependency YAML parser into a generated project's src/.
105
- * Required by tools/check_custom.js (require('../src/yaml.js')).
153
+ * Copies the zero-dependency YAML parser into the target's tools/ directory.
154
+ *
155
+ * It deliberately does NOT go into the consumer's src/: that directory belongs
156
+ * to the project, and writing there both pollutes it and silently overwrites a
157
+ * consumer file that happens to be named yaml.js. tools/ is the directory the
158
+ * harness provisions and owns.
159
+ *
106
160
  * @param {string} targetDir - Target project root
107
- * @param {string} sourceToolsDir - Bundled tools/ directory of this package
108
161
  */
109
- function installYamlModule(targetDir, sourceToolsDir) {
110
- const yamlSrc = path.join(sourceToolsDir, '..', 'src', 'yaml.js');
162
+ function installYamlModule(targetDir) {
163
+ const yamlSrc = path.join(__dirname, '..', 'tools', 'yaml.js');
111
164
  if (!fs.existsSync(yamlSrc)) return;
112
- const yamlDest = path.join(targetDir, 'src', 'yaml.js');
113
- fs.mkdirSync(path.join(targetDir, 'src'), { recursive: true });
114
- fs.copyFileSync(yamlSrc, yamlDest);
165
+ const toolsDest = path.join(targetDir, 'tools');
166
+ fs.mkdirSync(toolsDest, { recursive: true });
167
+ fs.copyFileSync(yamlSrc, path.join(toolsDest, 'yaml.js'));
115
168
  }
116
169
 
117
170
  /**
@@ -139,7 +192,7 @@ function installFallbackTools(targetDir) {
139
192
  });
140
193
 
141
194
  if (fallbackFiles.includes('check_custom.js')) {
142
- installYamlModule(targetDir, sourceToolsDir);
195
+ installYamlModule(targetDir);
143
196
  }
144
197
  }
145
198
 
@@ -155,9 +208,12 @@ function generateFallbackHook(targetDir, projectType) {
155
208
  const hookDir = path.join(targetDir, '.husky');
156
209
  fs.mkdirSync(hookDir, { recursive: true });
157
210
  const hookFile = path.join(hookDir, 'pre-commit');
211
+ // Only source the husky bootstrap when the project actually has it.
212
+ const bootstrap = fs.existsSync(path.join(hookDir, '_', 'husky.sh'))
213
+ ? '. "$(dirname -- "$0")/_/husky.sh"\n'
214
+ : '';
158
215
  fs.writeFileSync(hookFile, `#!/usr/bin/env sh
159
- . "$(dirname -- "$0")/_/husky.sh"
160
-
216
+ ${bootstrap}
161
217
  node tools/scan_secrets.js || true
162
218
  `, 'utf-8');
163
219
  try { fs.chmodSync(hookFile, '755'); } catch (e) {}
@@ -434,19 +490,27 @@ function installAgenticCLI(targetDir) {
434
490
  symlinks.forEach(link => {
435
491
  const linkPath = path.join(targetDir, link);
436
492
  const linkDir = path.dirname(linkPath);
437
-
493
+
438
494
  if (!fs.existsSync(linkDir)) {
439
495
  fs.mkdirSync(linkDir, { recursive: true });
440
496
  }
441
497
 
442
498
  const relPath = path.relative(linkDir, path.join(targetDir, sourceFile));
443
-
499
+
444
500
  try {
501
+ // Only replace an existing symlink (a stale mirror needs refreshing).
502
+ // A regular file belongs to the consumer: unlinking it would destroy
503
+ // their own rules, so leave it in place instead.
504
+ let existing = null;
445
505
  try {
446
- if (fs.lstatSync(linkPath)) {
447
- fs.unlinkSync(linkPath);
448
- }
506
+ existing = fs.lstatSync(linkPath);
449
507
  } catch (err) {}
508
+
509
+ if (existing) {
510
+ if (!existing.isSymbolicLink()) return;
511
+ fs.unlinkSync(linkPath);
512
+ }
513
+
450
514
  fs.symlinkSync(relPath, linkPath, 'file');
451
515
  } catch (e) {
452
516
  // Ignorar fallos de permisos o symlinks existentes
package/src/index.js CHANGED
@@ -9,25 +9,110 @@ const { printBanner, printBox, promptOptions, printSuccess, colors } = require('
9
9
  const { generateHarness } = require('./generator');
10
10
  const { runDoctor } = require('./doctor');
11
11
 
12
+ /** Commands that only read state. Safe to run against any project. */
13
+ const READ_ONLY_COMMANDS = new Set(['help', '--help', '-h', 'version', '--version', '-v']);
14
+
15
+ /**
16
+ * Flags that explicitly opt into the installation path. Everything else is
17
+ * rejected: a destructive default is fixed by making the default non-destructive.
18
+ */
19
+ const INSTALL_FLAGS = new Set(['--yes', '-y']);
20
+
21
+ const HELP_TEXT = `
22
+ ${'clckernel'} — CLC Kernel: The AI Agent Governance Engine
23
+
24
+ USAGE
25
+ clckernel <command> [targetDir]
26
+
27
+ READ-ONLY COMMANDS (never modify your project)
28
+ help, --help, -h Show this help
29
+ version, --version, -v Print the installed version
30
+ doctor [targetDir] Run diagnostics on a project
31
+ update [targetDir] Refresh tools, playbooks and IDE symlinks
32
+
33
+ INSTALLATION (writes to the target project)
34
+ clckernel [targetDir] --yes Provision the governance harness
35
+
36
+ Requires an explicit opt-in. Bare \`clckernel\` runs the interactive
37
+ confirmation flow; with no TTY it refuses to install.
38
+
39
+ EXAMPLES
40
+ clckernel --help
41
+ clckernel doctor .
42
+ clckernel update .
43
+ clckernel . --yes
44
+ `;
45
+
46
+ /**
47
+ * Reject any input that is not a recognized command or install flag.
48
+ * The rejected tokens are echoed so the user can see what was not understood.
49
+ * @param {string[]} args - Raw argv (without node/script)
50
+ * @returns {number} Process exit code
51
+ */
52
+ function rejectUnknownInput(args) {
53
+ const rejected = args.join(' ');
54
+ process.stderr.write(
55
+ `\n❌ Unknown input: ${rejected}\n` +
56
+ ' clckernel will not modify your project on unrecognized input.\n' +
57
+ ' Run `clckernel --help` to see available commands.\n\n'
58
+ );
59
+ return 1;
60
+ }
61
+
12
62
  async function runCli() {
13
63
  const args = process.argv.slice(2);
14
64
  const command = args[0];
15
65
 
16
- let targetDir = process.cwd();
17
- if (command && !command.startsWith('-') && command !== 'doctor' && command !== 'update') {
18
- targetDir = path.resolve(command);
66
+ if (command && READ_ONLY_COMMANDS.has(command)) {
67
+ if (command === 'version' || command === '--version' || command === '-v') {
68
+ const { version } = require('../package.json');
69
+ process.stdout.write(`clckernel v${version}\n`);
70
+ } else {
71
+ process.stdout.write(HELP_TEXT);
72
+ }
73
+ return;
74
+ }
75
+
76
+ if (args.some((a) => READ_ONLY_COMMANDS.has(a))) {
77
+ process.exitCode = rejectUnknownInput(args);
78
+ return;
19
79
  }
20
80
 
21
81
  if (command === 'doctor' || args.includes('--doctor')) {
22
- if (args[1] && args[1] !== '--doctor') targetDir = path.resolve(args[1]);
23
- runDoctor(targetDir);
82
+ const target = args[1] && args[1] !== '--doctor' ? args[1] : (args[0] === 'doctor' ? null : args[0]);
83
+ runDoctor(target ? path.resolve(target) : process.cwd());
24
84
  return;
25
85
  }
26
86
 
27
87
  if (command === 'update' || args.includes('--update')) {
28
- if (args[1] && args[1] !== '--update') targetDir = path.resolve(args[1]);
88
+ const target = args[1] && args[1] !== '--update' ? args[1] : (args[0] === 'update' ? null : args[0]);
29
89
  const { runUpdate } = require('./updater');
30
- await runUpdate(targetDir);
90
+ await runUpdate(target ? path.resolve(target) : process.cwd());
91
+ return;
92
+ }
93
+
94
+ // Everything below is destructive. Only reachable through an explicit command
95
+ // or an explicit install flag.
96
+ const flags = args.filter((a) => a.startsWith('-'));
97
+ const positionals = args.filter((a) => !a.startsWith('-'));
98
+ const unknownFlags = flags.filter((f) => !INSTALL_FLAGS.has(f));
99
+
100
+ if (unknownFlags.length > 0 || positionals.length > 1) {
101
+ process.exitCode = rejectUnknownInput(args);
102
+ return;
103
+ }
104
+
105
+ const targetDir = positionals.length === 1 ? path.resolve(positionals[0]) : process.cwd();
106
+ const explicitInstall = INSTALL_FLAGS.has(command) || flags.length > 0;
107
+ const isInteractive = Boolean(process.stdin.isTTY) && !process.env.CI;
108
+
109
+ if (!explicitInstall && !isInteractive) {
110
+ process.stderr.write(
111
+ '\n❌ Refusing to install without an explicit opt-in.\n' +
112
+ ' No TTY detected, so there is no confirmation prompt.\n' +
113
+ ' Re-run with `clckernel --yes` to provision, or `clckernel --help`.\n\n'
114
+ );
115
+ process.exitCode = 1;
31
116
  return;
32
117
  }
33
118