@soleri/cli 9.7.2 → 9.8.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.
Files changed (56) hide show
  1. package/dist/commands/add-domain.js +1 -0
  2. package/dist/commands/add-domain.js.map +1 -1
  3. package/dist/commands/add-pack.js +7 -147
  4. package/dist/commands/add-pack.js.map +1 -1
  5. package/dist/commands/agent.js +130 -0
  6. package/dist/commands/agent.js.map +1 -1
  7. package/dist/commands/create.js +78 -2
  8. package/dist/commands/create.js.map +1 -1
  9. package/dist/commands/doctor.js +2 -0
  10. package/dist/commands/doctor.js.map +1 -1
  11. package/dist/commands/extend.js +17 -0
  12. package/dist/commands/extend.js.map +1 -1
  13. package/dist/commands/install-knowledge.js +1 -0
  14. package/dist/commands/install-knowledge.js.map +1 -1
  15. package/dist/commands/test.js +140 -1
  16. package/dist/commands/test.js.map +1 -1
  17. package/dist/hook-packs/flock-guard/manifest.json +2 -1
  18. package/dist/hook-packs/marketing-research/manifest.json +2 -1
  19. package/dist/hook-packs/registry.d.ts +2 -0
  20. package/dist/hook-packs/registry.js.map +1 -1
  21. package/dist/hook-packs/registry.ts +2 -0
  22. package/dist/prompts/create-wizard.d.ts +16 -2
  23. package/dist/prompts/create-wizard.js +84 -11
  24. package/dist/prompts/create-wizard.js.map +1 -1
  25. package/dist/utils/checks.d.ts +8 -5
  26. package/dist/utils/checks.js +105 -10
  27. package/dist/utils/checks.js.map +1 -1
  28. package/dist/utils/format-paths.d.ts +14 -0
  29. package/dist/utils/format-paths.js +27 -0
  30. package/dist/utils/format-paths.js.map +1 -0
  31. package/dist/utils/git.d.ts +29 -0
  32. package/dist/utils/git.js +88 -0
  33. package/dist/utils/git.js.map +1 -0
  34. package/dist/utils/logger.d.ts +1 -0
  35. package/dist/utils/logger.js +4 -0
  36. package/dist/utils/logger.js.map +1 -1
  37. package/package.json +1 -1
  38. package/src/__tests__/create-wizard-git.test.ts +208 -0
  39. package/src/__tests__/git-utils.test.ts +268 -0
  40. package/src/__tests__/scaffold-git-e2e.test.ts +105 -0
  41. package/src/commands/add-domain.ts +1 -0
  42. package/src/commands/add-pack.ts +10 -163
  43. package/src/commands/agent.ts +161 -0
  44. package/src/commands/create.ts +89 -3
  45. package/src/commands/doctor.ts +1 -0
  46. package/src/commands/extend.ts +20 -1
  47. package/src/commands/install-knowledge.ts +1 -0
  48. package/src/commands/test.ts +141 -2
  49. package/src/hook-packs/flock-guard/manifest.json +2 -1
  50. package/src/hook-packs/marketing-research/manifest.json +2 -1
  51. package/src/hook-packs/registry.ts +2 -0
  52. package/src/prompts/create-wizard.ts +109 -14
  53. package/src/utils/checks.ts +122 -13
  54. package/src/utils/format-paths.ts +41 -0
  55. package/src/utils/git.ts +118 -0
  56. package/src/utils/logger.ts +5 -0
@@ -6,6 +6,7 @@
6
6
  */
7
7
 
8
8
  import { join, dirname } from 'node:path';
9
+ import { createRequire } from 'node:module';
9
10
  import {
10
11
  existsSync,
11
12
  readFileSync,
@@ -20,6 +21,7 @@ import { homedir } from 'node:os';
20
21
  import { execFileSync } from 'node:child_process';
21
22
  import type { Command } from 'commander';
22
23
  import * as p from '@clack/prompts';
24
+ import { parse as parseYaml } from 'yaml';
23
25
  import { PackLockfile, checkNpmVersion, checkVersionCompat, SOLERI_HOME } from '@soleri/core';
24
26
  import {
25
27
  generateClaudeMdTemplate,
@@ -47,6 +49,75 @@ export function registerAgent(program: Command): void {
47
49
  return;
48
50
  }
49
51
 
52
+ if (ctx.format === 'filetree') {
53
+ // ─── File-tree agent (v7+) ─────────────────────────────
54
+ const yamlPath = join(ctx.agentPath, 'agent.yaml');
55
+ const yaml = parseYaml(readFileSync(yamlPath, 'utf-8'));
56
+ const agentName = yaml.name || yaml.id || 'unknown';
57
+ const agentId = yaml.id || 'unknown';
58
+ const domains: string[] = Array.isArray(yaml.domains) ? yaml.domains : [];
59
+
60
+ // Count skills (directories inside skills/)
61
+ const skillsDir = join(ctx.agentPath, 'skills');
62
+ let skillsCount = 0;
63
+ if (existsSync(skillsDir)) {
64
+ skillsCount = readdirSync(skillsDir, { withFileTypes: true }).filter((d) =>
65
+ d.isDirectory(),
66
+ ).length;
67
+ }
68
+
69
+ // Resolve engine version via require.resolve
70
+ let engineVersion = 'not installed';
71
+ try {
72
+ const req = createRequire(join(ctx.agentPath, 'package.json'));
73
+ const corePkgPath = req.resolve('@soleri/core/package.json');
74
+ engineVersion = JSON.parse(readFileSync(corePkgPath, 'utf-8')).version || 'unknown';
75
+ } catch {
76
+ engineVersion = 'not installed';
77
+ }
78
+
79
+ // Check for core update
80
+ const latestCore = checkNpmVersion('@soleri/core');
81
+
82
+ // Count vault entries if db exists
83
+ const dbPath = join(ctx.agentPath, 'data', 'vault.db');
84
+ const hasVault = existsSync(dbPath);
85
+
86
+ if (opts.json) {
87
+ console.log(
88
+ JSON.stringify(
89
+ {
90
+ agent: agentName,
91
+ id: agentId,
92
+ format: 'file-tree (v7)',
93
+ engine: engineVersion,
94
+ engineLatest: latestCore,
95
+ domains,
96
+ skills: skillsCount,
97
+ vault: { exists: hasVault },
98
+ },
99
+ null,
100
+ 2,
101
+ ),
102
+ );
103
+ return;
104
+ }
105
+
106
+ console.log(`\n Agent: ${agentName}`);
107
+ console.log(` ID: ${agentId}`);
108
+ console.log(` Format: file-tree (v7)`);
109
+ console.log(
110
+ ` Engine: @soleri/core ${engineVersion}${latestCore && latestCore !== engineVersion ? ` (update available: ${latestCore})` : ''}`,
111
+ );
112
+ console.log(` Domains: ${domains.length > 0 ? domains.join(', ') : 'none'}`);
113
+ console.log(` Skills: ${skillsCount}`);
114
+ console.log(`\n Vault: ${hasVault ? 'initialized' : 'not initialized'}`);
115
+ console.log('');
116
+ return;
117
+ }
118
+
119
+ // ─── Legacy TypeScript agent ──────────────────────────────
120
+
50
121
  // Read agent package.json
51
122
  const pkgPath = join(ctx.agentPath, 'package.json');
52
123
  const pkg = existsSync(pkgPath) ? JSON.parse(readFileSync(pkgPath, 'utf-8')) : {};
@@ -128,6 +199,96 @@ export function registerAgent(program: Command): void {
128
199
  return;
129
200
  }
130
201
 
202
+ // ─── File-tree agent (v7+) ────────────────────────────────
203
+ if (ctx.format === 'filetree') {
204
+ // Resolve installed @soleri/core version
205
+ let installedVersion: string | null = null;
206
+ try {
207
+ const req = createRequire(import.meta.url);
208
+ const corePkgPath = req.resolve('@soleri/core/package.json');
209
+ installedVersion = JSON.parse(readFileSync(corePkgPath, 'utf-8')).version ?? null;
210
+ } catch {
211
+ // @soleri/core not resolvable — will show as unknown
212
+ }
213
+
214
+ // Check latest version on npm
215
+ const latestCore = checkNpmVersion('@soleri/core');
216
+
217
+ if (opts.check) {
218
+ const installed = installedVersion ?? 'unknown';
219
+ const latest = latestCore ?? 'unknown';
220
+ if (installed === latest) {
221
+ console.log(`\n Engine: @soleri/core ${installed} (up to date)`);
222
+ } else {
223
+ console.log(`\n Engine: @soleri/core ${installed} → ${latest}`);
224
+ }
225
+ console.log(` Format: file-tree (updates via template refresh)`);
226
+ console.log('');
227
+ return;
228
+ }
229
+
230
+ if (opts.dryRun) {
231
+ p.log.info('Would refresh templates (regenerate _engine.md, CLAUDE.md, sync skills)');
232
+ if (latestCore && installedVersion && latestCore !== installedVersion) {
233
+ p.log.info(
234
+ `Engine: ${installedVersion} → ${latestCore} (update @soleri/core globally to upgrade)`,
235
+ );
236
+ }
237
+ return;
238
+ }
239
+
240
+ // Run refresh: regenerate _engine.md, recompose CLAUDE.md, sync skills
241
+ const enginePath = join(ctx.agentPath, 'instructions', '_engine.md');
242
+ const claudeMdPath = join(ctx.agentPath, 'CLAUDE.md');
243
+
244
+ const skillFiles = generateSkills({ id: ctx.agentId } as AgentConfig);
245
+
246
+ // 1. Sync skills
247
+ if (skillFiles.length > 0) {
248
+ let newCount = 0;
249
+ let updatedCount = 0;
250
+ for (const [relPath, content] of skillFiles) {
251
+ const fullPath = join(ctx.agentPath, relPath);
252
+ const dirPath = dirname(fullPath);
253
+ const isNew = !existsSync(fullPath);
254
+ mkdirSync(dirPath, { recursive: true });
255
+ writeFileSync(fullPath, content, 'utf-8');
256
+ if (isNew) newCount++;
257
+ else updatedCount++;
258
+ }
259
+ p.log.success(
260
+ `Synced ${skillFiles.length} skills (${newCount} new, ${updatedCount} updated)`,
261
+ );
262
+ }
263
+
264
+ // 2. Regenerate _engine.md
265
+ mkdirSync(join(ctx.agentPath, 'instructions'), { recursive: true });
266
+ writeFileSync(enginePath, getEngineRulesContent(), 'utf-8');
267
+ p.log.success(`Regenerated ${enginePath}`);
268
+
269
+ // 3. Recompose CLAUDE.md
270
+ const result = composeClaudeMd(ctx.agentPath);
271
+ writeFileSync(claudeMdPath, result.content, 'utf-8');
272
+ p.log.success(
273
+ `Regenerated ${claudeMdPath} (${result.sources.length} sources, ${result.content.length} bytes)`,
274
+ );
275
+
276
+ // 4. Show engine version status
277
+ const installed = installedVersion ?? 'unknown';
278
+ const latest = latestCore ?? 'unknown';
279
+ if (installed !== 'unknown' && latest !== 'unknown' && installed !== latest) {
280
+ p.log.info(
281
+ `Engine: ${installed} → ${latest} (run \`npm update -g @soleri/cli\` to upgrade)`,
282
+ );
283
+ } else if (installed !== 'unknown') {
284
+ p.log.info(`Engine: @soleri/core ${installed} (up to date)`);
285
+ }
286
+
287
+ p.log.info('File-tree agents update by refreshing templates from the installed engine.');
288
+ return;
289
+ }
290
+
291
+ // ─── Legacy TypeScript agent ──────────────────────────────
131
292
  const pkgPath = join(ctx.agentPath, 'package.json');
132
293
  if (!existsSync(pkgPath)) {
133
294
  p.log.error('No package.json found in agent directory.');
@@ -10,9 +10,17 @@ import {
10
10
  type SetupTarget,
11
11
  scaffoldFileTree,
12
12
  } from '@soleri/forge/lib';
13
- import { runCreateWizard } from '../prompts/create-wizard.js';
13
+ import { runCreateWizard, type WizardGitConfig } from '../prompts/create-wizard.js';
14
14
  import { listPacks } from '../hook-packs/registry.js';
15
15
  import { installPack } from '../hook-packs/installer.js';
16
+ import {
17
+ isGitInstalled,
18
+ gitInit,
19
+ gitInitialCommit,
20
+ gitAddRemote,
21
+ gitPush,
22
+ ghCreateRepo,
23
+ } from '../utils/git.js';
16
24
 
17
25
  function parseSetupTarget(value?: string): SetupTarget | undefined {
18
26
  if (!value) return undefined;
@@ -40,6 +48,7 @@ export function registerCreate(program: Command): void {
40
48
  .option('--dir <path>', `Parent directory for the agent (default: current directory)`)
41
49
  .option('--filetree', 'Create a file-tree agent (v7 — no TypeScript, no build step)')
42
50
  .option('--legacy', 'Create a legacy TypeScript agent (v6 — requires npm install + build)')
51
+ .option('--no-git', 'Skip git repository initialization (git init is on by default)')
43
52
  .description('Create a new Soleri agent')
44
53
  .action(
45
54
  async (
@@ -51,11 +60,15 @@ export function registerCreate(program: Command): void {
51
60
  setupTarget?: string;
52
61
  filetree?: boolean;
53
62
  legacy?: boolean;
63
+ git?: boolean;
54
64
  },
55
65
  ) => {
56
66
  try {
57
67
  let config;
58
68
 
69
+ let gitConfig: WizardGitConfig | undefined;
70
+ const skipGit = opts?.git === false; // Commander sets git=false when --no-git is passed
71
+
59
72
  if (name && opts?.yes && !opts?.config) {
60
73
  // Quick non-interactive: name + --yes = Italian Craftsperson defaults
61
74
  const id = name
@@ -73,6 +86,10 @@ export function registerCreate(program: Command): void {
73
86
  tone: 'mentor',
74
87
  greeting: `Ciao! I'm ${name}. Ready to build something beautiful today?`,
75
88
  });
89
+ // Non-interactive default: git init yes, no remote
90
+ if (!skipGit) {
91
+ gitConfig = { init: true };
92
+ }
76
93
  } else if (opts?.config) {
77
94
  // Non-interactive: read from config file
78
95
  const configPath = resolve(opts.config);
@@ -87,6 +104,10 @@ export function registerCreate(program: Command): void {
87
104
  process.exit(1);
88
105
  }
89
106
  config = parsed.data;
107
+ // Config path: default to git init unless --no-git or config specifies git
108
+ if (!skipGit) {
109
+ gitConfig = raw.git ?? { init: true };
110
+ }
90
111
  } else {
91
112
  // Interactive wizard
92
113
  const wizardResult = await runCreateWizard(name);
@@ -94,12 +115,15 @@ export function registerCreate(program: Command): void {
94
115
  p.outro('Cancelled.');
95
116
  return;
96
117
  }
97
- const parsed = AgentConfigSchema.safeParse(wizardResult);
118
+ const parsed = AgentConfigSchema.safeParse(wizardResult.config);
98
119
  if (!parsed.success) {
99
120
  p.log.error(`Invalid config: ${parsed.error.message}`);
100
121
  process.exit(1);
101
122
  }
102
123
  config = parsed.data;
124
+ if (!skipGit) {
125
+ gitConfig = wizardResult.git;
126
+ }
103
127
  }
104
128
 
105
129
  const setupTarget = parseSetupTarget(opts?.setupTarget);
@@ -177,6 +201,68 @@ export function registerCreate(program: Command): void {
177
201
  process.exit(1);
178
202
  }
179
203
 
204
+ // ─── Git initialization ──────────────────────────────
205
+ if (gitConfig?.init) {
206
+ const hasGit = await isGitInstalled();
207
+ if (!hasGit) {
208
+ p.log.warn(
209
+ 'git is not installed — skipping repository initialization. Install git from https://git-scm.com/',
210
+ );
211
+ } else {
212
+ const agentDir = result.agentDir;
213
+ s.start('Initializing git repository...');
214
+
215
+ const initResult = await gitInit(agentDir);
216
+ if (!initResult.ok) {
217
+ s.stop('git init failed');
218
+ p.log.warn(`git init failed: ${initResult.error}`);
219
+ } else {
220
+ const commitResult = await gitInitialCommit(
221
+ agentDir,
222
+ `feat: scaffold agent "${config.name}"`,
223
+ );
224
+ if (!commitResult.ok) {
225
+ s.stop('Initial commit failed');
226
+ p.log.warn(`Initial commit failed: ${commitResult.error}`);
227
+ } else {
228
+ s.stop('Git repository initialized with initial commit');
229
+ }
230
+
231
+ // Remote setup
232
+ if (gitConfig.remote && initResult.ok && commitResult.ok) {
233
+ if (gitConfig.remote.type === 'gh') {
234
+ s.start('Creating GitHub repository...');
235
+ const ghResult = await ghCreateRepo(config.id, {
236
+ visibility: gitConfig.remote.visibility ?? 'private',
237
+ dir: agentDir,
238
+ });
239
+ if (!ghResult.ok) {
240
+ s.stop('GitHub repo creation failed');
241
+ p.log.warn(`gh repo create failed: ${ghResult.error}`);
242
+ } else {
243
+ s.stop(`Pushed to ${ghResult.url ?? 'GitHub'}`);
244
+ }
245
+ } else if (gitConfig.remote.type === 'manual' && gitConfig.remote.url) {
246
+ s.start('Setting up remote...');
247
+ const remoteResult = await gitAddRemote(agentDir, gitConfig.remote.url);
248
+ if (!remoteResult.ok) {
249
+ s.stop('Failed to add remote');
250
+ p.log.warn(`git remote add failed: ${remoteResult.error}`);
251
+ } else {
252
+ const pushResult = await gitPush(agentDir);
253
+ if (!pushResult.ok) {
254
+ s.stop('Push failed');
255
+ p.log.warn(`git push failed: ${pushResult.error}`);
256
+ } else {
257
+ s.stop('Pushed to remote');
258
+ }
259
+ }
260
+ }
261
+ }
262
+ }
263
+ }
264
+ }
265
+
180
266
  p.note(result.summary, 'Next steps');
181
267
  p.outro('Done!');
182
268
  return;
@@ -208,7 +294,7 @@ export function registerCreate(program: Command): void {
208
294
  selectedPacks = selectedPacks.filter((pk) => available.includes(pk));
209
295
  }
210
296
  } else if (!nonInteractive && claudeSetup) {
211
- const packs = listPacks();
297
+ const packs = listPacks().filter((pk) => pk.scaffoldDefault !== false);
212
298
  const packChoices = packs.map((pk) => ({
213
299
  value: pk.name,
214
300
  label: pk.name,
@@ -15,6 +15,7 @@ export function registerDoctor(program: Command): void {
15
15
 
16
16
  for (const r of results) {
17
17
  if (r.status === 'pass') log.pass(r.label, r.detail);
18
+ else if (r.status === 'skip') log.skip(r.label, r.detail);
18
19
  else if (r.status === 'warn') {
19
20
  log.warn(r.label, r.detail);
20
21
  hasWarnings = true;
@@ -2,7 +2,22 @@ import type { Command } from 'commander';
2
2
  import * as p from '@clack/prompts';
3
3
  import { existsSync, mkdirSync, writeFileSync } from 'node:fs';
4
4
  import { join } from 'node:path';
5
- import { detectAgent } from '../utils/agent-context.js';
5
+ import { detectAgent, type AgentContext } from '../utils/agent-context.js';
6
+
7
+ function warnFiletreeAndExit(ctx: AgentContext): boolean {
8
+ if (ctx.format !== 'filetree') return false;
9
+ p.log.warn(
10
+ [
11
+ `The 'extend' command requires a TypeScript agent format.`,
12
+ '',
13
+ 'For file-tree agents, use these alternatives:',
14
+ ' • Custom skills: add SKILL.md files to skills/{name}/SKILL.md',
15
+ ' • Hook packs: soleri hooks add-pack <pack>',
16
+ ' • Custom knowledge: add JSON files to knowledge/',
17
+ ].join('\n'),
18
+ );
19
+ process.exit(0);
20
+ }
6
21
 
7
22
  export function registerExtend(program: Command): void {
8
23
  const extend = program
@@ -18,6 +33,7 @@ export function registerExtend(program: Command): void {
18
33
  p.log.error('No agent project detected. Run this from an agent root.');
19
34
  process.exit(1);
20
35
  }
36
+ warnFiletreeAndExit(ctx);
21
37
 
22
38
  const extDir = join(ctx.agentPath, 'src', 'extensions');
23
39
  if (existsSync(join(extDir, 'index.ts'))) {
@@ -53,6 +69,7 @@ export function registerExtend(program: Command): void {
53
69
  p.log.error('No agent project detected. Run this from an agent root.');
54
70
  process.exit(1);
55
71
  }
72
+ warnFiletreeAndExit(ctx);
56
73
 
57
74
  const opsDir = join(ctx.agentPath, 'src', 'extensions', 'ops');
58
75
  mkdirSync(opsDir, { recursive: true });
@@ -108,6 +125,7 @@ export function ${fnName}(runtime: AgentRuntime): OpDefinition {
108
125
  p.log.error('No agent project detected. Run this from an agent root.');
109
126
  process.exit(1);
110
127
  }
128
+ warnFiletreeAndExit(ctx);
111
129
 
112
130
  const facadesDir = join(ctx.agentPath, 'src', 'extensions', 'facades');
113
131
  mkdirSync(facadesDir, { recursive: true });
@@ -162,6 +180,7 @@ export function create${className}Facade(runtime: AgentRuntime): FacadeConfig {
162
180
  p.log.error('No agent project detected. Run this from an agent root.');
163
181
  process.exit(1);
164
182
  }
183
+ warnFiletreeAndExit(ctx);
165
184
 
166
185
  const mwDir = join(ctx.agentPath, 'src', 'extensions', 'middleware');
167
186
  mkdirSync(mwDir, { recursive: true });
@@ -98,6 +98,7 @@ export function registerInstallKnowledge(program: Command): void {
98
98
  agentPath: ctx.agentPath,
99
99
  bundlePath,
100
100
  generateFacades: opts.facades,
101
+ format: ctx.format,
101
102
  });
102
103
 
103
104
  s.stop(result.success ? result.summary : 'Installation failed');
@@ -1,8 +1,139 @@
1
1
  import { spawn } from 'node:child_process';
2
+ import { existsSync, readFileSync, readdirSync } from 'node:fs';
3
+ import { join } from 'node:path';
2
4
  import type { Command } from 'commander';
3
5
  import * as p from '@clack/prompts';
6
+ import { parse as parseYaml } from 'yaml';
7
+ import { AgentYamlSchema } from '@soleri/forge/lib';
4
8
  import { detectAgent } from '../utils/agent-context.js';
5
9
 
10
+ /**
11
+ * Run validation checks for a file-tree agent (no vitest needed).
12
+ * Returns the process exit code (0 = all passed, 1 = failures).
13
+ */
14
+ function runFiletreeChecks(agentPath: string, _agentId: string): number {
15
+ let passed = 0;
16
+ let failed = 0;
17
+ const failures: string[] = [];
18
+
19
+ // ── 1. agent.yaml validation ───────────────────────
20
+ const yamlPath = join(agentPath, 'agent.yaml');
21
+ try {
22
+ const raw = readFileSync(yamlPath, 'utf-8');
23
+ const parsed = parseYaml(raw);
24
+ const result = AgentYamlSchema.safeParse(parsed);
25
+ if (result.success) {
26
+ p.log.success('agent.yaml — valid');
27
+ passed++;
28
+ } else {
29
+ const issues = result.error.issues
30
+ .map((i) => ` ${i.path.join('.')}: ${i.message}`)
31
+ .join('\n');
32
+ p.log.error(`agent.yaml — validation failed\n${issues}`);
33
+ failures.push('agent.yaml validation');
34
+ failed++;
35
+ }
36
+ } catch (err: unknown) {
37
+ const msg = err instanceof Error ? err.message : String(err);
38
+ p.log.error(`agent.yaml — could not read or parse: ${msg}`);
39
+ failures.push('agent.yaml read/parse');
40
+ failed++;
41
+ }
42
+
43
+ // ── 2. Skills syntax check ─────────────────────────
44
+ const skillsDir = join(agentPath, 'skills');
45
+ if (existsSync(skillsDir)) {
46
+ let validSkills = 0;
47
+ let invalidSkills = 0;
48
+ const invalidNames: string[] = [];
49
+
50
+ try {
51
+ const entries = readdirSync(skillsDir, { withFileTypes: true });
52
+ const skillDirs = entries.filter((e) => e.isDirectory());
53
+
54
+ for (const dir of skillDirs) {
55
+ const skillMd = join(skillsDir, dir.name, 'SKILL.md');
56
+ if (!existsSync(skillMd)) {
57
+ invalidSkills++;
58
+ invalidNames.push(`${dir.name}: missing SKILL.md`);
59
+ continue;
60
+ }
61
+
62
+ try {
63
+ const content = readFileSync(skillMd, 'utf-8');
64
+ const hasFrontmatter = content.startsWith('---');
65
+ const hasName = /^name:/m.test(content);
66
+ const hasDescription = /^description:/m.test(content);
67
+
68
+ if (hasFrontmatter && hasName && hasDescription) {
69
+ validSkills++;
70
+ } else {
71
+ invalidSkills++;
72
+ const missing: string[] = [];
73
+ if (!hasFrontmatter) missing.push('frontmatter (---)');
74
+ if (!hasName) missing.push('name:');
75
+ if (!hasDescription) missing.push('description:');
76
+ invalidNames.push(`${dir.name}: missing ${missing.join(', ')}`);
77
+ }
78
+ } catch {
79
+ invalidSkills++;
80
+ invalidNames.push(`${dir.name}: could not read SKILL.md`);
81
+ }
82
+ }
83
+
84
+ if (invalidSkills === 0) {
85
+ p.log.success(`skills — ${validSkills} valid, 0 invalid`);
86
+ passed++;
87
+ } else {
88
+ const details = invalidNames.map((n) => ` ${n}`).join('\n');
89
+ p.log.error(`skills — ${validSkills} valid, ${invalidSkills} invalid\n${details}`);
90
+ failures.push('skills syntax');
91
+ failed++;
92
+ }
93
+ } catch {
94
+ p.log.warn('skills — could not read skills/ directory');
95
+ // Not a failure — directory exists but unreadable is unusual, warn only
96
+ }
97
+ } else {
98
+ p.log.info('skills — no skills/ directory (skipped)');
99
+ }
100
+
101
+ // ── 3. Instructions check ──────────────────────────
102
+ const instructionsDir = join(agentPath, 'instructions');
103
+ if (existsSync(instructionsDir)) {
104
+ try {
105
+ const files = readdirSync(instructionsDir).filter((f) => f.endsWith('.md'));
106
+ if (files.length > 0) {
107
+ p.log.success(`instructions — ${files.length} .md file(s) found`);
108
+ passed++;
109
+ } else {
110
+ p.log.error('instructions — directory exists but contains no .md files');
111
+ failures.push('instructions empty');
112
+ failed++;
113
+ }
114
+ } catch {
115
+ p.log.error('instructions — could not read directory');
116
+ failures.push('instructions read');
117
+ failed++;
118
+ }
119
+ } else {
120
+ p.log.error('instructions — directory not found');
121
+ failures.push('instructions missing');
122
+ failed++;
123
+ }
124
+
125
+ // ── Summary ────────────────────────────────────────
126
+ if (failed === 0) {
127
+ p.log.success(`\n${passed} check(s) passed, 0 failed`);
128
+ } else {
129
+ p.log.error(
130
+ `\n${passed} check(s) passed, ${failed} failed:\n${failures.map((f) => ` - ${f}`).join('\n')}`,
131
+ );
132
+ }
133
+
134
+ return failed > 0 ? 1 : 0;
135
+ }
136
+
6
137
  export function registerTest(program: Command): void {
7
138
  program
8
139
  .command('test')
@@ -17,6 +148,16 @@ export function registerTest(program: Command): void {
17
148
  process.exit(1);
18
149
  }
19
150
 
151
+ p.log.info(`Running tests for ${ctx.agentId}...`);
152
+
153
+ // ── File-tree agents: run validation checks (no vitest) ──
154
+ if (ctx.format === 'filetree') {
155
+ const code = runFiletreeChecks(ctx.agentPath, ctx.agentId);
156
+ process.exit(code);
157
+ return;
158
+ }
159
+
160
+ // ── TypeScript agents: spawn vitest as before ──
20
161
  const args: string[] = [];
21
162
  if (opts.watch) {
22
163
  // vitest (no "run") enables watch mode
@@ -30,8 +171,6 @@ export function registerTest(program: Command): void {
30
171
  const extra = cmd.args as string[];
31
172
  if (extra.length > 0) args.push(...extra);
32
173
 
33
- p.log.info(`Running tests for ${ctx.agentId}...`);
34
-
35
174
  const child = spawn('npx', args, {
36
175
  cwd: ctx.agentPath,
37
176
  stdio: 'inherit',
@@ -32,5 +32,6 @@
32
32
  "timeout": 10,
33
33
  "statusMessage": "Releasing lockfile guard..."
34
34
  }
35
- ]
35
+ ],
36
+ "scaffoldDefault": false
36
37
  }
@@ -20,5 +20,6 @@
20
20
  "statusMessage": "Checking marketing context..."
21
21
  }
22
22
  ],
23
- "actionLevel": "remind"
23
+ "actionLevel": "remind",
24
+ "scaffoldDefault": false
24
25
  }
@@ -31,6 +31,8 @@ export interface HookPackManifest {
31
31
  lifecycleHooks?: HookPackLifecycleHook[];
32
32
  source?: 'built-in' | 'local';
33
33
  actionLevel?: 'remind' | 'warn' | 'block';
34
+ /** If false, pack is hidden from the scaffold picker but still installable via `hooks add-pack`. */
35
+ scaffoldDefault?: boolean;
34
36
  }
35
37
 
36
38
  const __filename = fileURLToPath(import.meta.url);