@crouton-kit/crouter 0.2.5 → 0.3.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/dist/builtin-skills/skills/crouter-development/marketplaces/SKILL.md +9 -9
- package/dist/builtin-skills/skills/crouter-development/plugins/SKILL.md +19 -19
- package/dist/cli.js +42 -37
- package/dist/commands/__tests__/human.test.d.ts +1 -0
- package/dist/commands/__tests__/human.test.js +214 -0
- package/dist/commands/__tests__/skill.test.d.ts +1 -0
- package/dist/commands/__tests__/skill.test.js +287 -0
- package/dist/commands/debug.d.ts +3 -0
- package/dist/commands/debug.js +179 -0
- package/dist/commands/flow.d.ts +2 -0
- package/dist/commands/flow.js +24 -0
- package/dist/commands/human.d.ts +2 -0
- package/dist/commands/human.js +480 -0
- package/dist/commands/job.d.ts +2 -0
- package/dist/commands/job.js +669 -0
- package/dist/commands/pkg.d.ts +2 -0
- package/dist/commands/pkg.js +1021 -0
- package/dist/commands/plan.d.ts +4 -2
- package/dist/commands/plan.js +306 -22
- package/dist/commands/skill.d.ts +2 -2
- package/dist/commands/skill.js +615 -459
- package/dist/commands/spec.d.ts +3 -2
- package/dist/commands/spec.js +283 -10
- package/dist/commands/sys.d.ts +2 -0
- package/dist/commands/sys.js +712 -0
- package/dist/core/__tests__/argv-parser.test.d.ts +1 -0
- package/dist/core/__tests__/argv-parser.test.js +199 -0
- package/dist/core/__tests__/flow-leaves.test.d.ts +1 -0
- package/dist/core/__tests__/flow-leaves.test.js +248 -0
- package/dist/core/__tests__/job.test.d.ts +1 -0
- package/dist/core/__tests__/job.test.js +346 -0
- package/dist/core/__tests__/pkg.test.d.ts +1 -0
- package/dist/core/__tests__/pkg.test.js +218 -0
- package/dist/core/__tests__/sys.test.d.ts +1 -0
- package/dist/core/__tests__/sys.test.js +208 -0
- package/dist/core/artifact.d.ts +29 -18
- package/dist/core/artifact.js +78 -221
- package/dist/core/auto-update.js +11 -3
- package/dist/core/command.d.ts +36 -0
- package/dist/core/command.js +287 -0
- package/dist/core/errors.d.ts +3 -0
- package/dist/core/errors.js +5 -0
- package/dist/core/fs-utils.d.ts +1 -0
- package/dist/core/fs-utils.js +4 -0
- package/dist/core/help.d.ts +98 -0
- package/dist/core/help.js +163 -0
- package/dist/core/io.d.ts +29 -0
- package/dist/core/io.js +83 -0
- package/dist/core/jobs.d.ts +87 -0
- package/dist/core/jobs.js +353 -0
- package/dist/core/pagination.d.ts +33 -0
- package/dist/core/pagination.js +89 -0
- package/dist/core/self-update.d.ts +21 -0
- package/dist/{commands/update.js → core/self-update.js} +28 -63
- package/dist/core/spawn.d.ts +47 -65
- package/dist/core/spawn.js +78 -228
- package/dist/prompts/agent.d.ts +10 -5
- package/dist/prompts/agent.js +51 -74
- package/dist/prompts/debug.d.ts +8 -0
- package/dist/prompts/debug.js +37 -0
- package/dist/prompts/review.js +4 -11
- package/dist/prompts/skill.d.ts +0 -1
- package/dist/prompts/skill.js +95 -149
- package/package.json +4 -2
- package/dist/commands/agent.d.ts +0 -2
- package/dist/commands/agent.js +0 -265
- package/dist/commands/config.d.ts +0 -2
- package/dist/commands/config.js +0 -146
- package/dist/commands/doctor.d.ts +0 -2
- package/dist/commands/doctor.js +0 -268
- package/dist/commands/marketplace.d.ts +0 -2
- package/dist/commands/marketplace.js +0 -365
- package/dist/commands/plugin.d.ts +0 -2
- package/dist/commands/plugin.js +0 -367
- package/dist/commands/update.d.ts +0 -4
- package/dist/prompts/plan.d.ts +0 -1
- package/dist/prompts/plan.js +0 -175
- package/dist/prompts/spec.d.ts +0 -1
- package/dist/prompts/spec.js +0 -153
|
@@ -0,0 +1,712 @@
|
|
|
1
|
+
// `crtr sys` subtree: config {get,set,path}, doctor, update, version.
|
|
2
|
+
// Replaces old config.ts + doctor.ts + update.ts command files.
|
|
3
|
+
import { fileURLToPath } from 'node:url';
|
|
4
|
+
import { join, dirname } from 'node:path';
|
|
5
|
+
import { readFileSync } from 'node:fs';
|
|
6
|
+
import { defineBranch, defineLeaf } from '../core/command.js';
|
|
7
|
+
import { readConfig, writeConfig, configPath as coreConfigPath, updateConfig, updateState } from '../core/config.js';
|
|
8
|
+
import { usage, notFound } from '../core/errors.js';
|
|
9
|
+
import { scopeRoot, listScopes, builtinSkillsRoot, marketplacesDir, pluginsDir, projectScopeRoot } from '../core/scope.js';
|
|
10
|
+
import { listInstalledPlugins, listSkillsInPlugin } from '../core/resolver.js';
|
|
11
|
+
import { readMarketplaceManifest, readPluginManifest } from '../core/manifest.js';
|
|
12
|
+
import { parseFrontmatter } from '../core/frontmatter.js';
|
|
13
|
+
import { pathExists, listDirs, removePath, readText, writeText, nowIso } from '../core/fs-utils.js';
|
|
14
|
+
import { lsRemote } from '../core/git.js';
|
|
15
|
+
import { createJob, appendEvent, writeResult } from '../core/jobs.js';
|
|
16
|
+
import { selfCheck, selfUpdate, contentCheck, contentUpdate } from '../core/self-update.js';
|
|
17
|
+
import { SKILL_TYPES, isSkillType } from '../types.js';
|
|
18
|
+
// ---------------------------------------------------------------------------
|
|
19
|
+
// Package version
|
|
20
|
+
// ---------------------------------------------------------------------------
|
|
21
|
+
const __filename = fileURLToPath(import.meta.url);
|
|
22
|
+
const __dirname = dirname(__filename);
|
|
23
|
+
const PKG_ROOT = join(__dirname, '..', '..');
|
|
24
|
+
function readPackageVersion() {
|
|
25
|
+
const raw = readFileSync(join(PKG_ROOT, 'package.json'), 'utf8');
|
|
26
|
+
const parsed = JSON.parse(raw);
|
|
27
|
+
return parsed.version;
|
|
28
|
+
}
|
|
29
|
+
// ---------------------------------------------------------------------------
|
|
30
|
+
// Config helpers (ported from commands/config.ts)
|
|
31
|
+
// ---------------------------------------------------------------------------
|
|
32
|
+
const TOP_LEVEL_KEYS = new Set([
|
|
33
|
+
'auto_update',
|
|
34
|
+
'marketplaces',
|
|
35
|
+
'plugins',
|
|
36
|
+
'max_panes_per_window',
|
|
37
|
+
]);
|
|
38
|
+
function getNestedValue(obj, key) {
|
|
39
|
+
const parts = key.split('.');
|
|
40
|
+
let current = obj;
|
|
41
|
+
for (const part of parts) {
|
|
42
|
+
if (current === null || typeof current !== 'object')
|
|
43
|
+
return undefined;
|
|
44
|
+
current = current[part];
|
|
45
|
+
}
|
|
46
|
+
return current;
|
|
47
|
+
}
|
|
48
|
+
function parseConfigValue(raw) {
|
|
49
|
+
if (raw === 'true')
|
|
50
|
+
return true;
|
|
51
|
+
if (raw === 'false')
|
|
52
|
+
return false;
|
|
53
|
+
if (/^-?\d+$/.test(raw))
|
|
54
|
+
return parseInt(raw, 10);
|
|
55
|
+
return raw;
|
|
56
|
+
}
|
|
57
|
+
function setNestedValue(cfg, key, value) {
|
|
58
|
+
const parts = key.split('.');
|
|
59
|
+
const topKey = parts[0];
|
|
60
|
+
if (!TOP_LEVEL_KEYS.has(topKey)) {
|
|
61
|
+
throw usage(`unknown config key: ${topKey} (expected: ${[...TOP_LEVEL_KEYS].join('|')})`);
|
|
62
|
+
}
|
|
63
|
+
if (key === 'auto_update.content') {
|
|
64
|
+
if (value !== 'notify' && value !== 'apply' && value !== false) {
|
|
65
|
+
throw usage(`auto_update.content must be 'notify', 'apply', or false`);
|
|
66
|
+
}
|
|
67
|
+
cfg.auto_update.content = value;
|
|
68
|
+
return;
|
|
69
|
+
}
|
|
70
|
+
if (key === 'auto_update.crtr') {
|
|
71
|
+
const coerced = value === true ? 'notify' : value;
|
|
72
|
+
if (coerced !== 'notify' && coerced !== 'apply' && coerced !== false) {
|
|
73
|
+
throw usage(`auto_update.crtr must be 'notify', 'apply', or false`);
|
|
74
|
+
}
|
|
75
|
+
cfg.auto_update.crtr = coerced;
|
|
76
|
+
return;
|
|
77
|
+
}
|
|
78
|
+
if (key === 'max_panes_per_window') {
|
|
79
|
+
if (typeof value !== 'number' || !Number.isFinite(value) || value < 1) {
|
|
80
|
+
throw usage(`max_panes_per_window must be an integer >= 1`);
|
|
81
|
+
}
|
|
82
|
+
cfg.max_panes_per_window = Math.floor(value);
|
|
83
|
+
return;
|
|
84
|
+
}
|
|
85
|
+
if (parts.length === 1) {
|
|
86
|
+
cfg[topKey] = value;
|
|
87
|
+
return;
|
|
88
|
+
}
|
|
89
|
+
if (parts.length === 2 && topKey === 'auto_update') {
|
|
90
|
+
const subKey = parts[1];
|
|
91
|
+
cfg.auto_update[subKey] = value;
|
|
92
|
+
return;
|
|
93
|
+
}
|
|
94
|
+
throw usage(`unsupported key path for set: ${key}`);
|
|
95
|
+
}
|
|
96
|
+
function resolveScope(raw) {
|
|
97
|
+
if (raw === undefined)
|
|
98
|
+
return 'user';
|
|
99
|
+
if (raw === 'user' || raw === 'project')
|
|
100
|
+
return raw;
|
|
101
|
+
throw usage(`scope must be 'user' or 'project', got: ${raw}`);
|
|
102
|
+
}
|
|
103
|
+
function pass(scope, name, message) {
|
|
104
|
+
return { scope, name, status: 'pass', message };
|
|
105
|
+
}
|
|
106
|
+
function failCheck(scope, name, message, remediation) {
|
|
107
|
+
return { scope, name, status: 'fail', message, ...(remediation ? { remediation } : {}) };
|
|
108
|
+
}
|
|
109
|
+
function warnCheck(scope, name, message, remediation) {
|
|
110
|
+
return { scope, name, status: 'warn', message, ...(remediation ? { remediation } : {}) };
|
|
111
|
+
}
|
|
112
|
+
/**
|
|
113
|
+
* Surgically replace a single frontmatter scalar field's value in a SKILL.md
|
|
114
|
+
* file. Preserves the rest of the file (key order, comments, extra fields,
|
|
115
|
+
* body) exactly. Returns true on success, false if no frontmatter or no such
|
|
116
|
+
* field was found.
|
|
117
|
+
*/
|
|
118
|
+
function editFrontmatterField(filePath, field, newValue) {
|
|
119
|
+
let src;
|
|
120
|
+
try {
|
|
121
|
+
src = readText(filePath);
|
|
122
|
+
}
|
|
123
|
+
catch {
|
|
124
|
+
return false;
|
|
125
|
+
}
|
|
126
|
+
const fmMatch = src.match(/^(---\s*\r?\n)([\s\S]*?)(\r?\n---\s*\r?\n?)/);
|
|
127
|
+
if (!fmMatch)
|
|
128
|
+
return false;
|
|
129
|
+
const [, head, body, tail] = fmMatch;
|
|
130
|
+
const fieldRe = new RegExp(`^(\\s*${field}\\s*:\\s*)(.*)$`, 'm');
|
|
131
|
+
if (!fieldRe.test(body))
|
|
132
|
+
return false;
|
|
133
|
+
const quoted = /[:#\-\[\]{},&*?|<>=!%@`]/.test(newValue) || /^\s/.test(newValue) || /\s$/.test(newValue);
|
|
134
|
+
const formatted = quoted ? `"${newValue.replace(/"/g, '\\"')}"` : newValue;
|
|
135
|
+
const newBody = body.replace(fieldRe, `$1${formatted}`);
|
|
136
|
+
try {
|
|
137
|
+
writeText(filePath, head + newBody + tail + src.slice(fmMatch[0].length));
|
|
138
|
+
return true;
|
|
139
|
+
}
|
|
140
|
+
catch {
|
|
141
|
+
return false;
|
|
142
|
+
}
|
|
143
|
+
}
|
|
144
|
+
/**
|
|
145
|
+
* Apply a remediation. Returns true if applied successfully. Idempotent for
|
|
146
|
+
* the supported kinds (re-applying a config-key removal that's already gone
|
|
147
|
+
* returns true).
|
|
148
|
+
*/
|
|
149
|
+
function applyRemediation(rem) {
|
|
150
|
+
try {
|
|
151
|
+
switch (rem.kind) {
|
|
152
|
+
case 'remove_config_key': {
|
|
153
|
+
if (!rem.scope || !rem.configKey)
|
|
154
|
+
return false;
|
|
155
|
+
const segments = rem.configKey.split('.');
|
|
156
|
+
updateConfig(rem.scope, (c) => {
|
|
157
|
+
let cursor = c;
|
|
158
|
+
for (let i = 0; i < segments.length - 1; i++) {
|
|
159
|
+
const next = cursor[segments[i]];
|
|
160
|
+
if (typeof next !== 'object' || next === null)
|
|
161
|
+
return;
|
|
162
|
+
cursor = next;
|
|
163
|
+
}
|
|
164
|
+
delete cursor[segments[segments.length - 1]];
|
|
165
|
+
});
|
|
166
|
+
return true;
|
|
167
|
+
}
|
|
168
|
+
case 'rm_path': {
|
|
169
|
+
if (!rem.path)
|
|
170
|
+
return false;
|
|
171
|
+
removePath(rem.path);
|
|
172
|
+
return true;
|
|
173
|
+
}
|
|
174
|
+
case 'edit_frontmatter': {
|
|
175
|
+
if (!rem.filePath || !rem.field || rem.value === undefined)
|
|
176
|
+
return false;
|
|
177
|
+
return editFrontmatterField(rem.filePath, rem.field, rem.value);
|
|
178
|
+
}
|
|
179
|
+
default:
|
|
180
|
+
return false;
|
|
181
|
+
}
|
|
182
|
+
}
|
|
183
|
+
catch {
|
|
184
|
+
return false;
|
|
185
|
+
}
|
|
186
|
+
}
|
|
187
|
+
function readRawTypeField(skillPath) {
|
|
188
|
+
const content = readText(skillPath);
|
|
189
|
+
const { raw } = parseFrontmatter(content);
|
|
190
|
+
if (!raw)
|
|
191
|
+
return undefined;
|
|
192
|
+
const m = raw.match(/^type:\s*(.+?)\s*$/m);
|
|
193
|
+
if (!m)
|
|
194
|
+
return undefined;
|
|
195
|
+
let v = m[1].trim();
|
|
196
|
+
if ((v.startsWith('"') && v.endsWith('"')) || (v.startsWith("'") && v.endsWith("'"))) {
|
|
197
|
+
v = v.slice(1, -1);
|
|
198
|
+
}
|
|
199
|
+
return v;
|
|
200
|
+
}
|
|
201
|
+
function runChecksForBuiltin() {
|
|
202
|
+
const root = builtinSkillsRoot();
|
|
203
|
+
const plugins = listInstalledPlugins('builtin');
|
|
204
|
+
if (plugins.length === 0) {
|
|
205
|
+
return [failCheck('builtin', 'builtin:crtr:root', `builtin-skills root missing or has no valid plugin.json: ${root}`)];
|
|
206
|
+
}
|
|
207
|
+
const results = [
|
|
208
|
+
pass('builtin', 'builtin:crtr:root', `builtin-skills root present: ${root}`),
|
|
209
|
+
];
|
|
210
|
+
for (const plugin of plugins) {
|
|
211
|
+
results.push(pass('builtin', `builtin:${plugin.name}:manifest`, `manifest valid`));
|
|
212
|
+
const skills = listSkillsInPlugin(plugin);
|
|
213
|
+
for (const skill of skills) {
|
|
214
|
+
if (!skill.frontmatter.name) {
|
|
215
|
+
results.push(failCheck('builtin', `builtin:${plugin.name}:skill:${skill.name}:frontmatter`, `frontmatter missing or name field empty`));
|
|
216
|
+
}
|
|
217
|
+
else {
|
|
218
|
+
results.push(pass('builtin', `builtin:${plugin.name}:skill:${skill.name}:frontmatter`, `frontmatter valid`));
|
|
219
|
+
}
|
|
220
|
+
}
|
|
221
|
+
}
|
|
222
|
+
return results;
|
|
223
|
+
}
|
|
224
|
+
function runChecksForScope(scope, opts) {
|
|
225
|
+
if (scope === 'builtin')
|
|
226
|
+
return runChecksForBuiltin();
|
|
227
|
+
const results = [];
|
|
228
|
+
const root = scopeRoot(scope);
|
|
229
|
+
if (!root)
|
|
230
|
+
return results;
|
|
231
|
+
const cfg = readConfig(scope);
|
|
232
|
+
// Check: every config marketplace entry has a corresponding directory
|
|
233
|
+
const mktDir = marketplacesDir(scope);
|
|
234
|
+
for (const name of Object.keys(cfg.marketplaces)) {
|
|
235
|
+
if (!mktDir) {
|
|
236
|
+
results.push(failCheck(scope, `marketplace:${name}:dir`, `marketplaces directory unavailable`));
|
|
237
|
+
continue;
|
|
238
|
+
}
|
|
239
|
+
const dir = join(mktDir, name);
|
|
240
|
+
if (!pathExists(dir)) {
|
|
241
|
+
const remediation = {
|
|
242
|
+
kind: 'remove_config_key',
|
|
243
|
+
description: `Drop stale config entry config.${scope}.marketplaces.${name}`,
|
|
244
|
+
scope,
|
|
245
|
+
configKey: `marketplaces.${name}`,
|
|
246
|
+
};
|
|
247
|
+
if (opts.fix && applyRemediation(remediation)) {
|
|
248
|
+
results.push({ scope, name: `marketplace:${name}:dir`, status: 'fail', message: `directory missing — removed stale config entry`, fixed: true, remediation });
|
|
249
|
+
}
|
|
250
|
+
else {
|
|
251
|
+
results.push(failCheck(scope, `marketplace:${name}:dir`, `directory missing: ${dir}`, remediation));
|
|
252
|
+
}
|
|
253
|
+
}
|
|
254
|
+
else {
|
|
255
|
+
results.push(pass(scope, `marketplace:${name}:dir`, `directory exists`));
|
|
256
|
+
}
|
|
257
|
+
}
|
|
258
|
+
// Check: every config plugin entry has a corresponding directory
|
|
259
|
+
const plugDir = pluginsDir(scope);
|
|
260
|
+
for (const name of Object.keys(cfg.plugins)) {
|
|
261
|
+
if (!plugDir) {
|
|
262
|
+
results.push(failCheck(scope, `plugin:${name}:dir`, `plugins directory unavailable`));
|
|
263
|
+
continue;
|
|
264
|
+
}
|
|
265
|
+
const dir = join(plugDir, name);
|
|
266
|
+
if (!pathExists(dir)) {
|
|
267
|
+
const remediation = {
|
|
268
|
+
kind: 'remove_config_key',
|
|
269
|
+
description: `Drop stale config entry config.${scope}.plugins.${name}`,
|
|
270
|
+
scope,
|
|
271
|
+
configKey: `plugins.${name}`,
|
|
272
|
+
};
|
|
273
|
+
if (opts.fix && applyRemediation(remediation)) {
|
|
274
|
+
results.push({ scope, name: `plugin:${name}:dir`, status: 'fail', message: `directory missing — removed stale config entry`, fixed: true, remediation });
|
|
275
|
+
}
|
|
276
|
+
else {
|
|
277
|
+
results.push(failCheck(scope, `plugin:${name}:dir`, `directory missing: ${dir}`, remediation));
|
|
278
|
+
}
|
|
279
|
+
}
|
|
280
|
+
else {
|
|
281
|
+
results.push(pass(scope, `plugin:${name}:dir`, `directory exists`));
|
|
282
|
+
}
|
|
283
|
+
}
|
|
284
|
+
// Check: every marketplace directory has a valid manifest
|
|
285
|
+
if (mktDir && pathExists(mktDir)) {
|
|
286
|
+
for (const name of listDirs(mktDir)) {
|
|
287
|
+
const dir = join(mktDir, name);
|
|
288
|
+
const manifest = readMarketplaceManifest(dir);
|
|
289
|
+
if (!manifest) {
|
|
290
|
+
const remediation = {
|
|
291
|
+
kind: 'rm_path',
|
|
292
|
+
description: `Remove dangling marketplace directory (no valid .crouter-marketplace/marketplace.json)`,
|
|
293
|
+
path: dir,
|
|
294
|
+
};
|
|
295
|
+
if (opts.fix && applyRemediation(remediation)) {
|
|
296
|
+
results.push({ scope, name: `marketplace:${name}:manifest`, status: 'fail', message: `no valid marketplace.json — removed dangling directory`, fixed: true, remediation });
|
|
297
|
+
}
|
|
298
|
+
else {
|
|
299
|
+
results.push(failCheck(scope, `marketplace:${name}:manifest`, `no valid marketplace.json in ${dir}`, remediation));
|
|
300
|
+
}
|
|
301
|
+
}
|
|
302
|
+
else {
|
|
303
|
+
results.push(pass(scope, `marketplace:${name}:manifest`, `manifest valid`));
|
|
304
|
+
// Check: marketplace plugins[].source paths resolve (relative paths only)
|
|
305
|
+
for (const entry of manifest.plugins) {
|
|
306
|
+
if (entry.source.startsWith('http://') || entry.source.startsWith('https://') || entry.source.startsWith('git@')) {
|
|
307
|
+
continue;
|
|
308
|
+
}
|
|
309
|
+
const resolved = join(dir, entry.source);
|
|
310
|
+
if (!pathExists(resolved)) {
|
|
311
|
+
results.push(failCheck(scope, `marketplace:${name}:plugin-source:${entry.name}`, `source path does not resolve: ${resolved}`));
|
|
312
|
+
}
|
|
313
|
+
else {
|
|
314
|
+
results.push(pass(scope, `marketplace:${name}:plugin-source:${entry.name}`, `source resolves`));
|
|
315
|
+
}
|
|
316
|
+
}
|
|
317
|
+
}
|
|
318
|
+
}
|
|
319
|
+
}
|
|
320
|
+
// Check: every plugin directory has a valid manifest + no duplicate names
|
|
321
|
+
const seenPluginNames = new Map();
|
|
322
|
+
if (plugDir && pathExists(plugDir)) {
|
|
323
|
+
for (const name of listDirs(plugDir)) {
|
|
324
|
+
const dir = join(plugDir, name);
|
|
325
|
+
const manifest = readPluginManifest(dir);
|
|
326
|
+
if (!manifest) {
|
|
327
|
+
const remediation = {
|
|
328
|
+
kind: 'rm_path',
|
|
329
|
+
description: `Remove dangling plugin directory (no valid .crouter-plugin/plugin.json)`,
|
|
330
|
+
path: dir,
|
|
331
|
+
};
|
|
332
|
+
if (opts.fix && applyRemediation(remediation)) {
|
|
333
|
+
results.push({ scope, name: `plugin:${name}:manifest`, status: 'fail', message: `no valid plugin.json — removed dangling directory`, fixed: true, remediation });
|
|
334
|
+
}
|
|
335
|
+
else {
|
|
336
|
+
results.push(failCheck(scope, `plugin:${name}:manifest`, `no valid plugin.json in ${dir}`, remediation));
|
|
337
|
+
}
|
|
338
|
+
continue;
|
|
339
|
+
}
|
|
340
|
+
results.push(pass(scope, `plugin:${name}:manifest`, `manifest valid`));
|
|
341
|
+
// Duplicate names
|
|
342
|
+
if (seenPluginNames.has(name)) {
|
|
343
|
+
results.push(failCheck(scope, `plugin:${name}:duplicate`, `duplicate plugin name within scope (also at ${seenPluginNames.get(name)})`));
|
|
344
|
+
}
|
|
345
|
+
else {
|
|
346
|
+
seenPluginNames.set(name, dir);
|
|
347
|
+
}
|
|
348
|
+
// Check: skills frontmatter name. Convention: frontmatter `name:` holds
|
|
349
|
+
// the leaf segment only (e.g. "cli-design"); the full discovered name
|
|
350
|
+
// ("interface/cli-design") is derived from the path automatically.
|
|
351
|
+
const plugin = listInstalledPlugins(scope).find((p) => p.name === name);
|
|
352
|
+
if (plugin) {
|
|
353
|
+
const skills = listSkillsInPlugin(plugin);
|
|
354
|
+
for (const skill of skills) {
|
|
355
|
+
const checkName = `plugin:${name}:skill:${skill.name}:frontmatter`;
|
|
356
|
+
const segments = skill.name.split('/');
|
|
357
|
+
const baseName = segments[segments.length - 1];
|
|
358
|
+
if (skill.frontmatter.name === baseName) {
|
|
359
|
+
results.push(pass(scope, checkName, `frontmatter valid`));
|
|
360
|
+
}
|
|
361
|
+
else if (skill.frontmatter.name === '') {
|
|
362
|
+
const remediation = {
|
|
363
|
+
kind: 'edit_frontmatter',
|
|
364
|
+
description: `Set frontmatter "name: ${baseName}" (discovered name "${skill.name}" is auto-derived from the directory path; frontmatter holds the base segment only)`,
|
|
365
|
+
filePath: skill.path,
|
|
366
|
+
field: 'name',
|
|
367
|
+
value: baseName,
|
|
368
|
+
};
|
|
369
|
+
if (opts.fix && applyRemediation(remediation)) {
|
|
370
|
+
results.push({ scope, name: checkName, status: 'fail', message: `frontmatter name was missing — set to "${baseName}"`, fixed: true, remediation });
|
|
371
|
+
}
|
|
372
|
+
else {
|
|
373
|
+
results.push(failCheck(scope, checkName, `frontmatter missing or name field empty`, remediation));
|
|
374
|
+
}
|
|
375
|
+
}
|
|
376
|
+
else {
|
|
377
|
+
const remediation = {
|
|
378
|
+
kind: 'edit_frontmatter',
|
|
379
|
+
description: `Replace frontmatter "name: ${skill.frontmatter.name}" with "name: ${baseName}" (discovered name "${skill.name}" is auto-derived from the directory path; frontmatter holds the base segment only)`,
|
|
380
|
+
filePath: skill.path,
|
|
381
|
+
field: 'name',
|
|
382
|
+
value: baseName,
|
|
383
|
+
};
|
|
384
|
+
if (opts.fix && applyRemediation(remediation)) {
|
|
385
|
+
results.push({ scope, name: checkName, status: 'warn', message: `frontmatter name updated from "${skill.frontmatter.name}" to "${baseName}"`, fixed: true, remediation });
|
|
386
|
+
}
|
|
387
|
+
else {
|
|
388
|
+
results.push(warnCheck(scope, checkName, `name mismatch: frontmatter says "${skill.frontmatter.name}", expected base name "${baseName}" (discovered as "${skill.name}")`, remediation));
|
|
389
|
+
}
|
|
390
|
+
}
|
|
391
|
+
const typeCheckName = `plugin:${name}:skill:${skill.name}:type`;
|
|
392
|
+
const rawType = readRawTypeField(skill.path);
|
|
393
|
+
if (rawType === undefined) {
|
|
394
|
+
results.push(warnCheck(scope, typeCheckName, `missing type field — add one of: ${SKILL_TYPES.join(' | ')}`));
|
|
395
|
+
}
|
|
396
|
+
else if (!isSkillType(rawType)) {
|
|
397
|
+
results.push(failCheck(scope, typeCheckName, `invalid type "${rawType}" — valid: ${SKILL_TYPES.join(' | ')}`));
|
|
398
|
+
}
|
|
399
|
+
else {
|
|
400
|
+
results.push(pass(scope, typeCheckName, `type: ${rawType}`));
|
|
401
|
+
}
|
|
402
|
+
}
|
|
403
|
+
}
|
|
404
|
+
// Git remote check (slow, opt-in)
|
|
405
|
+
if (opts.remote && manifest.source) {
|
|
406
|
+
const res = lsRemote(manifest.source);
|
|
407
|
+
if (res.status !== 0) {
|
|
408
|
+
results.push(failCheck(scope, `plugin:${name}:remote`, `git remote unreachable: ${manifest.source}`));
|
|
409
|
+
}
|
|
410
|
+
else {
|
|
411
|
+
results.push(pass(scope, `plugin:${name}:remote`, `git remote reachable`));
|
|
412
|
+
}
|
|
413
|
+
}
|
|
414
|
+
}
|
|
415
|
+
}
|
|
416
|
+
return results;
|
|
417
|
+
}
|
|
418
|
+
// ---------------------------------------------------------------------------
|
|
419
|
+
// Leaf definitions
|
|
420
|
+
// ---------------------------------------------------------------------------
|
|
421
|
+
const configGet = defineLeaf({
|
|
422
|
+
name: 'get',
|
|
423
|
+
help: {
|
|
424
|
+
name: 'sys config get',
|
|
425
|
+
summary: 'read a config value by dotted key',
|
|
426
|
+
params: [
|
|
427
|
+
{ kind: 'positional', name: 'key', type: 'string', required: true, constraint: 'Dotted key path. Top-level keys: auto_update, marketplaces, plugins, max_panes_per_window.' },
|
|
428
|
+
{ kind: 'flag', name: 'scope', type: 'enum', choices: ['user', 'project', 'all'], required: false, constraint: 'Scope to read from. Default: user.' },
|
|
429
|
+
],
|
|
430
|
+
output: [
|
|
431
|
+
{ name: 'key', type: 'string', required: true, constraint: 'Echo of input key.' },
|
|
432
|
+
{ name: 'value', type: 'unknown', required: true, constraint: 'The resolved value. Type depends on the key.' },
|
|
433
|
+
{ name: 'scope', type: 'string', required: true, constraint: 'Scope the value was read from.' },
|
|
434
|
+
],
|
|
435
|
+
outputKind: 'object',
|
|
436
|
+
effects: ['None. Read-only.'],
|
|
437
|
+
},
|
|
438
|
+
run: async (input) => {
|
|
439
|
+
const key = input['key'];
|
|
440
|
+
const scope = resolveScope(input['scope']);
|
|
441
|
+
const cfg = readConfig(scope);
|
|
442
|
+
const value = getNestedValue(cfg, key);
|
|
443
|
+
if (value === undefined) {
|
|
444
|
+
throw notFound(`config key not found: ${key}`);
|
|
445
|
+
}
|
|
446
|
+
return { key, value: value, scope };
|
|
447
|
+
},
|
|
448
|
+
});
|
|
449
|
+
const configSet = defineLeaf({
|
|
450
|
+
name: 'set',
|
|
451
|
+
help: {
|
|
452
|
+
name: 'sys config set',
|
|
453
|
+
summary: 'write a config value by dotted key',
|
|
454
|
+
params: [
|
|
455
|
+
{ kind: 'positional', name: 'key', type: 'string', required: true, constraint: 'Dotted key path. Supported: auto_update.crtr, auto_update.content, auto_update.interval_hours, max_panes_per_window.' },
|
|
456
|
+
{ kind: 'flag', name: 'value', type: 'string', required: true, constraint: 'value VALUE — string, required. Stored as-is if quoted; coerced to number or boolean when unambiguous.' },
|
|
457
|
+
{ kind: 'flag', name: 'scope', type: 'enum', choices: ['user', 'project'], required: false, constraint: 'Scope to write to. Default: user.' },
|
|
458
|
+
],
|
|
459
|
+
output: [
|
|
460
|
+
{ name: 'key', type: 'string', required: true, constraint: 'Echo of input key.' },
|
|
461
|
+
{ name: 'value', type: 'unknown', required: true, constraint: 'Value as written.' },
|
|
462
|
+
{ name: 'scope', type: 'string', required: true, constraint: 'Scope the value was written to.' },
|
|
463
|
+
],
|
|
464
|
+
outputKind: 'object',
|
|
465
|
+
effects: ['Writes the updated value to config.json in the target scope.'],
|
|
466
|
+
},
|
|
467
|
+
run: async (input) => {
|
|
468
|
+
const key = input['key'];
|
|
469
|
+
const rawValue = input['value'];
|
|
470
|
+
const scope = resolveScope(input['scope']);
|
|
471
|
+
// Flags are stringly-typed; coerce to number or boolean when unambiguous
|
|
472
|
+
const parsed = parseConfigValue(rawValue);
|
|
473
|
+
const cfg = readConfig(scope);
|
|
474
|
+
setNestedValue(cfg, key, parsed);
|
|
475
|
+
writeConfig(scope, cfg);
|
|
476
|
+
// Read back the written value for echo
|
|
477
|
+
const written = getNestedValue(cfg, key);
|
|
478
|
+
return { key, value: written, scope };
|
|
479
|
+
},
|
|
480
|
+
});
|
|
481
|
+
const configPath = defineLeaf({
|
|
482
|
+
name: 'path',
|
|
483
|
+
help: {
|
|
484
|
+
name: 'sys config path',
|
|
485
|
+
summary: 'print absolute path(s) to config.json',
|
|
486
|
+
params: [
|
|
487
|
+
{ kind: 'flag', name: 'scope', type: 'enum', choices: ['user', 'project', 'all'], required: false, constraint: 'Scope to show paths for. Default: all.' },
|
|
488
|
+
],
|
|
489
|
+
output: [
|
|
490
|
+
{ name: 'paths', type: 'object[]', required: true, constraint: 'Each: {scope, path}. Only includes scopes that have a config file.' },
|
|
491
|
+
],
|
|
492
|
+
outputKind: 'object',
|
|
493
|
+
effects: ['None. Read-only.'],
|
|
494
|
+
},
|
|
495
|
+
run: async (input) => {
|
|
496
|
+
const scopeArg = input['scope'];
|
|
497
|
+
// Resolve 'all' or undefined → all writable scopes
|
|
498
|
+
let scopes;
|
|
499
|
+
if (scopeArg === undefined || scopeArg === 'all') {
|
|
500
|
+
scopes = listScopes(undefined);
|
|
501
|
+
}
|
|
502
|
+
else {
|
|
503
|
+
scopes = listScopes(scopeArg);
|
|
504
|
+
}
|
|
505
|
+
const paths = scopes
|
|
506
|
+
.map((s) => {
|
|
507
|
+
const root = scopeRoot(s);
|
|
508
|
+
if (!root)
|
|
509
|
+
return null;
|
|
510
|
+
const p = coreConfigPath(s);
|
|
511
|
+
if (!p)
|
|
512
|
+
return null;
|
|
513
|
+
return { scope: s, path: p };
|
|
514
|
+
})
|
|
515
|
+
.filter((x) => x !== null);
|
|
516
|
+
return { paths };
|
|
517
|
+
},
|
|
518
|
+
});
|
|
519
|
+
const configBranch = defineBranch({
|
|
520
|
+
name: 'config',
|
|
521
|
+
help: {
|
|
522
|
+
name: 'sys config',
|
|
523
|
+
summary: 'read and write crtr configuration',
|
|
524
|
+
children: [
|
|
525
|
+
{ name: 'get', desc: 'read a config value by key', useWhen: 'inspecting current configuration' },
|
|
526
|
+
{ name: 'set', desc: 'write a config value by key', useWhen: 'changing a configuration setting' },
|
|
527
|
+
{ name: 'path', desc: 'print path(s) to config.json', useWhen: 'locating the config file for manual inspection' },
|
|
528
|
+
],
|
|
529
|
+
},
|
|
530
|
+
children: [configGet, configSet, configPath],
|
|
531
|
+
});
|
|
532
|
+
const sysDoctorLeaf = defineLeaf({
|
|
533
|
+
name: 'doctor',
|
|
534
|
+
help: {
|
|
535
|
+
name: 'sys doctor',
|
|
536
|
+
summary: 'diagnose missing manifests, broken config entries, and skill frontmatter drift',
|
|
537
|
+
params: [
|
|
538
|
+
{ kind: 'flag', name: 'scope', type: 'enum', choices: ['user', 'project'], required: false, constraint: 'Scope to check. Default: all scopes.' },
|
|
539
|
+
{ kind: 'flag', name: 'fix', type: 'bool', required: false, constraint: 'Apply each non-pass check\'s remediation and report what was fixed.' },
|
|
540
|
+
{ kind: 'flag', name: 'remote', type: 'bool', required: false, constraint: 'Check git remotes with ls-remote (slow — makes network calls).' },
|
|
541
|
+
],
|
|
542
|
+
output: [
|
|
543
|
+
{ name: 'checks', type: 'object[]', required: true, constraint: 'Each: {scope, name, status, message, fixed?, remediation?}. status: pass | fail | warn. remediation (when present) is {kind, description, ...payload} where kind is remove_config_key | rm_path | edit_frontmatter. Sorted by scope then name.' },
|
|
544
|
+
{ name: 'ok', type: 'boolean', required: true, constraint: 'True when no unresolved fail checks remain.' },
|
|
545
|
+
],
|
|
546
|
+
outputKind: 'object',
|
|
547
|
+
effects: [
|
|
548
|
+
'Read-only unless --fix is passed.',
|
|
549
|
+
'With --fix: applies each non-pass check\'s `remediation` — removes stale config entries, deletes dangling plugin/marketplace directories, edits frontmatter name fields to the base-name convention.',
|
|
550
|
+
'Each non-pass result carries a structured `remediation` describing the fix action (absolute paths, exact config keys) so callers can apply it directly without --fix.',
|
|
551
|
+
],
|
|
552
|
+
},
|
|
553
|
+
run: async (input) => {
|
|
554
|
+
const scopeArg = input['scope'];
|
|
555
|
+
const fix = input['fix'];
|
|
556
|
+
const remote = input['remote'];
|
|
557
|
+
const scopes = listScopes(scopeArg);
|
|
558
|
+
const allResults = [];
|
|
559
|
+
for (const scope of scopes) {
|
|
560
|
+
const results = runChecksForScope(scope, { fix, remote });
|
|
561
|
+
allResults.push(...results);
|
|
562
|
+
}
|
|
563
|
+
// Sort by scope then name (mirrors old printResults grouping)
|
|
564
|
+
allResults.sort((a, b) => {
|
|
565
|
+
if (a.scope !== b.scope)
|
|
566
|
+
return a.scope.localeCompare(b.scope);
|
|
567
|
+
return a.name.localeCompare(b.name);
|
|
568
|
+
});
|
|
569
|
+
const ok = !allResults.some((r) => r.status === 'fail' && r.fixed !== true);
|
|
570
|
+
// failed count = unresolved fails
|
|
571
|
+
const failed = allResults.filter((r) => r.status === 'fail' && r.fixed !== true).length;
|
|
572
|
+
// The skeleton schema declared `ok` not `failed`; returning both for completeness
|
|
573
|
+
return { checks: allResults, ok, failed };
|
|
574
|
+
},
|
|
575
|
+
});
|
|
576
|
+
const sysUpdateLeaf = defineLeaf({
|
|
577
|
+
name: 'update',
|
|
578
|
+
help: {
|
|
579
|
+
name: 'sys update',
|
|
580
|
+
summary: 'update the crtr binary and/or installed plugins and marketplaces',
|
|
581
|
+
params: [
|
|
582
|
+
{ kind: 'flag', name: 'target', type: 'enum', choices: ['self', 'content', 'all'], required: false, constraint: "What to update. Default: all." },
|
|
583
|
+
{ kind: 'flag', name: 'check', type: 'bool', required: false, constraint: 'Check for updates without applying them (bounded, blocking).' },
|
|
584
|
+
],
|
|
585
|
+
output: [
|
|
586
|
+
{ name: 'job_id', type: 'string', required: false, constraint: 'Present when applying updates. Poll with `crtr job read result JOB_ID --wait`.' },
|
|
587
|
+
{ name: 'follow_up', type: 'string', required: false, constraint: 'Instruction for retrieving the job result.' },
|
|
588
|
+
{ name: 'updates', type: 'object[]', required: false, constraint: 'Present when --check. Each: {name, current, latest, up_to_date, unreachable, kind}.' },
|
|
589
|
+
{ name: 'up_to_date', type: 'boolean', required: false, constraint: 'Present when --check. True when all items are up to date.' },
|
|
590
|
+
],
|
|
591
|
+
outputKind: 'object',
|
|
592
|
+
effects: [
|
|
593
|
+
'--check — read-only, bounded network calls.',
|
|
594
|
+
'Default (no --check) — launches a background job; returns job handle immediately.',
|
|
595
|
+
],
|
|
596
|
+
},
|
|
597
|
+
run: async (input) => {
|
|
598
|
+
const target = input['target'];
|
|
599
|
+
const check = input['check'];
|
|
600
|
+
const resolvedTarget = target !== undefined ? target : 'all';
|
|
601
|
+
if (check) {
|
|
602
|
+
// Bounded blocking path: collect check results and return
|
|
603
|
+
const updates = [];
|
|
604
|
+
if (resolvedTarget === 'self' || resolvedTarget === 'all') {
|
|
605
|
+
const r = selfCheck();
|
|
606
|
+
if (r !== null) {
|
|
607
|
+
updates.push({
|
|
608
|
+
name: '@crouton-kit/crtr',
|
|
609
|
+
kind: 'self',
|
|
610
|
+
current: r.current,
|
|
611
|
+
latest: r.latest,
|
|
612
|
+
up_to_date: r.current === r.latest,
|
|
613
|
+
unreachable: false,
|
|
614
|
+
});
|
|
615
|
+
}
|
|
616
|
+
else {
|
|
617
|
+
updates.push({
|
|
618
|
+
name: '@crouton-kit/crtr',
|
|
619
|
+
kind: 'self',
|
|
620
|
+
current: null,
|
|
621
|
+
latest: null,
|
|
622
|
+
up_to_date: true,
|
|
623
|
+
unreachable: true,
|
|
624
|
+
});
|
|
625
|
+
}
|
|
626
|
+
}
|
|
627
|
+
if (resolvedTarget === 'content' || resolvedTarget === 'all') {
|
|
628
|
+
const entries = contentCheck();
|
|
629
|
+
for (const e of entries) {
|
|
630
|
+
updates.push({
|
|
631
|
+
name: e.name,
|
|
632
|
+
kind: e.kind,
|
|
633
|
+
current: e.current,
|
|
634
|
+
latest: e.latest,
|
|
635
|
+
up_to_date: e.up_to_date,
|
|
636
|
+
unreachable: e.unreachable,
|
|
637
|
+
});
|
|
638
|
+
}
|
|
639
|
+
}
|
|
640
|
+
const up_to_date = updates.every((u) => u.up_to_date || u.unreachable);
|
|
641
|
+
return { updates: updates, up_to_date };
|
|
642
|
+
}
|
|
643
|
+
// Long-running apply path: create a job, run in background, return handle
|
|
644
|
+
const cwd = process.cwd();
|
|
645
|
+
const { jobId } = createJob('sys-update', { cwd, pid: process.pid });
|
|
646
|
+
// Run update asynchronously without awaiting in the main path
|
|
647
|
+
void (async () => {
|
|
648
|
+
try {
|
|
649
|
+
if (resolvedTarget === 'self' || resolvedTarget === 'all') {
|
|
650
|
+
appendEvent(jobId, { level: 'info', event: 'self-update:start', message: 'running npm install -g @crouton-kit/crtr@latest' });
|
|
651
|
+
selfUpdate();
|
|
652
|
+
const scopes = ['user'];
|
|
653
|
+
if (projectScopeRoot())
|
|
654
|
+
scopes.unshift('project');
|
|
655
|
+
for (const scope of scopes) {
|
|
656
|
+
updateState(scope, (s) => {
|
|
657
|
+
s.last_self_check = nowIso();
|
|
658
|
+
});
|
|
659
|
+
}
|
|
660
|
+
appendEvent(jobId, { level: 'info', event: 'self-update:done', message: 'crtr binary updated' });
|
|
661
|
+
}
|
|
662
|
+
if (resolvedTarget === 'content' || resolvedTarget === 'all') {
|
|
663
|
+
appendEvent(jobId, { level: 'info', event: 'content-update:start', message: 'pulling updates for marketplaces and plugins' });
|
|
664
|
+
contentUpdate();
|
|
665
|
+
appendEvent(jobId, { level: 'info', event: 'content-update:done', message: 'content updates complete' });
|
|
666
|
+
}
|
|
667
|
+
writeResult(jobId, { target: resolvedTarget, status: 'done' }, 'done');
|
|
668
|
+
}
|
|
669
|
+
catch (e) {
|
|
670
|
+
const msg = e instanceof Error ? e.message : String(e);
|
|
671
|
+
appendEvent(jobId, { level: 'error', event: 'update:error', message: msg });
|
|
672
|
+
writeResult(jobId, { error: msg }, 'failed');
|
|
673
|
+
}
|
|
674
|
+
})();
|
|
675
|
+
return {
|
|
676
|
+
job_id: jobId,
|
|
677
|
+
follow_up: `crtr job read result ${jobId} --wait`,
|
|
678
|
+
};
|
|
679
|
+
},
|
|
680
|
+
});
|
|
681
|
+
const sysVersionLeaf = defineLeaf({
|
|
682
|
+
name: 'version',
|
|
683
|
+
help: {
|
|
684
|
+
name: 'sys version',
|
|
685
|
+
summary: 'print the installed crtr version',
|
|
686
|
+
params: [],
|
|
687
|
+
output: [
|
|
688
|
+
{ name: 'version', type: 'string', required: true, constraint: 'Semver string from package.json.' },
|
|
689
|
+
],
|
|
690
|
+
outputKind: 'object',
|
|
691
|
+
effects: ['None. Read-only.'],
|
|
692
|
+
},
|
|
693
|
+
run: async (_input) => {
|
|
694
|
+
return { version: readPackageVersion() };
|
|
695
|
+
},
|
|
696
|
+
});
|
|
697
|
+
export function registerSys() {
|
|
698
|
+
return defineBranch({
|
|
699
|
+
name: 'sys',
|
|
700
|
+
help: {
|
|
701
|
+
name: 'sys',
|
|
702
|
+
summary: 'crtr system configuration, diagnostics, and self-management',
|
|
703
|
+
children: [
|
|
704
|
+
{ name: 'config', desc: 'read and write configuration', useWhen: 'inspecting or changing crtr settings' },
|
|
705
|
+
{ name: 'doctor', desc: 'diagnose installation health', useWhen: 'troubleshooting missing manifests or broken config' },
|
|
706
|
+
{ name: 'update', desc: 'update binary and content', useWhen: 'upgrading crtr or its installed plugins/marketplaces' },
|
|
707
|
+
{ name: 'version', desc: 'print installed version', useWhen: 'checking which version of crtr is installed' },
|
|
708
|
+
],
|
|
709
|
+
},
|
|
710
|
+
children: [configBranch, sysDoctorLeaf, sysUpdateLeaf, sysVersionLeaf],
|
|
711
|
+
});
|
|
712
|
+
}
|