@crouton-kit/crouter 0.1.3 → 0.1.6

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
package/README.md CHANGED
@@ -9,5 +9,18 @@ npm install -g @crouton-kit/crouter
9
9
  ## Usage
10
10
 
11
11
  ```bash
12
- crouter --help
12
+ crtr --help
13
13
  ```
14
+
15
+ ## Official marketplace
16
+
17
+ `crtr` ships with the [crouter official marketplace](https://github.com/crouton-labs/crouter-official-marketplace) pre-installed. On first run it is cloned into your user scope and registered automatically — no plugins are enabled by default.
18
+
19
+ Browse and install plugins from it:
20
+
21
+ ```bash
22
+ crtr marketplace browse crouter-official-marketplace
23
+ crtr marketplace install crouter-official-marketplace:<plugin>
24
+ ```
25
+
26
+ To opt out of the bootstrap (e.g. in CI), set `CRTR_NO_BOOTSTRAP=1`.
package/dist/cli.js CHANGED
@@ -11,7 +11,9 @@ import { registerUpdateCommand } from './commands/update.js';
11
11
  import { registerDoctorCommand } from './commands/doctor.js';
12
12
  import { registerPlanCommand } from './commands/plan.js';
13
13
  import { registerSpecCommand } from './commands/spec.js';
14
+ import { registerAgentCommand } from './commands/agent.js';
14
15
  import { maybeAutoUpdate } from './core/auto-update.js';
16
+ import { ensureBootSkill, ensureOfficialMarketplace } from './core/bootstrap.js';
15
17
  function readPackageVersion() {
16
18
  const here = dirname(fileURLToPath(import.meta.url));
17
19
  const pkgPath = join(here, '..', 'package.json');
@@ -33,6 +35,9 @@ registerUpdateCommand(program);
33
35
  registerDoctorCommand(program);
34
36
  registerPlanCommand(program);
35
37
  registerSpecCommand(program);
38
+ registerAgentCommand(program);
39
+ ensureOfficialMarketplace(process.argv);
40
+ ensureBootSkill(process.argv);
36
41
  maybeAutoUpdate(process.argv);
37
42
  program.parseAsync().catch((err) => {
38
43
  process.stderr.write(`crtr: ${err instanceof Error ? err.message : String(err)}\n`);
@@ -0,0 +1,2 @@
1
+ import { Command } from 'commander';
2
+ export declare function registerAgentCommand(program: Command): void;
@@ -0,0 +1,265 @@
1
+ import { existsSync } from 'node:fs';
2
+ import { artifactPath, artifactsRoot } from '../core/artifact.js';
3
+ import { readConfig } from '../core/config.js';
4
+ import { spawnAgent, spawnAndDetach, awaitSession, submitToSession, DEFAULT_PANE_OPTS, } from '../core/spawn.js';
5
+ import { pathExists } from '../core/fs-utils.js';
6
+ import { notFound, usage } from '../core/errors.js';
7
+ import { handleError, hint, out, info } from '../core/output.js';
8
+ import { implementHandoffPrompt, planHandoffPrompt, reviewHandoffPrompt, } from '../prompts/agent.js';
9
+ // Seconds before the originating pane is closed in fire-and-forget workflow
10
+ // handoffs (plan/implement/review). Override with --kill-after.
11
+ const DEFAULT_KILL_SECS = 10;
12
+ async function readStdin() {
13
+ const chunks = [];
14
+ for await (const chunk of process.stdin) {
15
+ chunks.push(chunk);
16
+ }
17
+ return Buffer.concat(chunks).toString('utf8');
18
+ }
19
+ async function resolvePrompt(positional) {
20
+ if (positional !== undefined && positional !== '')
21
+ return positional;
22
+ if (!process.stdin.isTTY) {
23
+ const piped = await readStdin();
24
+ if (piped.trim() !== '')
25
+ return piped;
26
+ }
27
+ throw usage('no prompt provided. Pass a positional arg or pipe via stdin.');
28
+ }
29
+ function resolveMaxPanes(override) {
30
+ if (override !== undefined) {
31
+ const n = Number(override);
32
+ if (!Number.isFinite(n) || n < 1) {
33
+ throw usage(`--max-panes must be an integer >= 1 (got: ${override})`);
34
+ }
35
+ return Math.floor(n);
36
+ }
37
+ const cfg = readConfig('user');
38
+ return cfg.max_panes_per_window;
39
+ }
40
+ function emitDetach(result, label) {
41
+ if (result.status === 'spawned') {
42
+ const paneLabel = result.paneId === undefined ? '(unknown)' : result.paneId;
43
+ out(`handoff: ${label} launched in pane ${paneLabel}`);
44
+ hint(result.message);
45
+ return;
46
+ }
47
+ if (result.status === 'not-in-tmux') {
48
+ throw usage(result.message);
49
+ }
50
+ throw new Error(result.message);
51
+ }
52
+ function parseKillAfter(raw) {
53
+ const n = Number(raw);
54
+ if (!Number.isFinite(n) || n < 0) {
55
+ throw usage(`--kill-after must be a non-negative number (got: ${raw})`);
56
+ }
57
+ return n;
58
+ }
59
+ function baseDetachOpts() {
60
+ return {
61
+ cwd: process.cwd(),
62
+ placement: 'split-h',
63
+ killAfterSeconds: DEFAULT_KILL_SECS,
64
+ };
65
+ }
66
+ export function registerAgentCommand(program) {
67
+ const agent = program
68
+ .command('agent')
69
+ .description('spawn, fork, await, and submit between sibling claude sessions');
70
+ agent
71
+ .command('new [prompt]')
72
+ .description('spawn a fresh claude in a sibling pane; prints session id, returns async')
73
+ .option('--max-panes <n>', 'max panes per window before overflowing to a new window')
74
+ .action(async (prompt, options) => {
75
+ try {
76
+ const body = await resolvePrompt(prompt);
77
+ const maxPanes = resolveMaxPanes(options.maxPanes);
78
+ const result = spawnAgent({
79
+ prompt: body,
80
+ cwd: process.cwd(),
81
+ maxPanesPerWindow: maxPanes,
82
+ });
83
+ if (result.status === 'not-in-tmux')
84
+ throw usage(result.message);
85
+ if (result.status === 'spawn-failed')
86
+ throw new Error(result.message);
87
+ const sessionId = result.sessionId;
88
+ if (sessionId === undefined) {
89
+ throw new Error('spawn succeeded but no session id returned');
90
+ }
91
+ out(sessionId);
92
+ info(result.message);
93
+ hint(`await with: crtr agent await ${sessionId}`);
94
+ }
95
+ catch (e) {
96
+ handleError(e);
97
+ }
98
+ });
99
+ agent
100
+ .command('fork [prompt]')
101
+ .description('fork the current Claude Code session into a sibling pane with a new prompt; prints session id')
102
+ .option('--max-panes <n>', 'max panes per window before overflowing to a new window')
103
+ .action(async (prompt, options) => {
104
+ try {
105
+ const parentSessionId = process.env.CLAUDE_CODE_SESSION_ID;
106
+ if (parentSessionId === undefined || parentSessionId === '') {
107
+ throw usage('crtr agent fork requires $CLAUDE_CODE_SESSION_ID — must run inside Claude Code');
108
+ }
109
+ const body = await resolvePrompt(prompt);
110
+ const maxPanes = resolveMaxPanes(options.maxPanes);
111
+ const result = spawnAgent({
112
+ prompt: body,
113
+ cwd: process.cwd(),
114
+ fork: { sessionId: parentSessionId },
115
+ maxPanesPerWindow: maxPanes,
116
+ });
117
+ if (result.status === 'not-in-tmux')
118
+ throw usage(result.message);
119
+ if (result.status === 'spawn-failed')
120
+ throw new Error(result.message);
121
+ const sessionId = result.sessionId;
122
+ if (sessionId === undefined) {
123
+ throw new Error('spawn succeeded but no session id returned');
124
+ }
125
+ out(sessionId);
126
+ info(result.message);
127
+ hint(`await with: crtr agent await ${sessionId}`);
128
+ }
129
+ catch (e) {
130
+ handleError(e);
131
+ }
132
+ });
133
+ agent
134
+ .command('await <id>')
135
+ .description('block until agent <id> calls `crtr agent submit`; prints submitted content')
136
+ .option('--timeout <seconds>', `seconds before giving up (default ${DEFAULT_PANE_OPTS.timeoutMs / 1000})`)
137
+ .option('--keep-pane', 'do not kill the child pane after submission')
138
+ .action(async (id, options) => {
139
+ try {
140
+ let timeoutMs = DEFAULT_PANE_OPTS.timeoutMs;
141
+ if (options.timeout !== undefined) {
142
+ const n = Number(options.timeout);
143
+ if (!Number.isFinite(n) || n <= 0) {
144
+ throw usage(`--timeout must be a positive number (got: ${options.timeout})`);
145
+ }
146
+ timeoutMs = Math.floor(n * 1000);
147
+ }
148
+ const killPane = options.keepPane !== true;
149
+ const result = await awaitSession(id, { timeoutMs, killPane });
150
+ if (result.status === 'submitted') {
151
+ process.stdout.write(result.content);
152
+ if (!result.content.endsWith('\n'))
153
+ process.stdout.write('\n');
154
+ return;
155
+ }
156
+ if (result.status === 'timeout') {
157
+ throw new Error(`agent ${id} did not submit before timeout`);
158
+ }
159
+ if (result.status === 'pane-closed') {
160
+ throw new Error(`agent ${id} pane closed before submission`);
161
+ }
162
+ throw new Error(`agent ${id} await failed: ${result.status}`);
163
+ }
164
+ catch (e) {
165
+ handleError(e);
166
+ }
167
+ });
168
+ agent
169
+ .command('submit [content]')
170
+ .description('inside a crtr-spawned session, deliver content back to the parent (uses $CRTR_PIPE)')
171
+ .action(async (content) => {
172
+ try {
173
+ const sessionDir = process.env.CRTR_PIPE;
174
+ if (sessionDir === undefined || sessionDir === '') {
175
+ throw usage('not in a crtr session — $CRTR_PIPE is not set. ' +
176
+ '`crtr agent submit` is only valid inside a crtr-spawned pane.');
177
+ }
178
+ if (!existsSync(sessionDir)) {
179
+ throw notFound(`session directory not found: ${sessionDir} (the parent may have timed out)`);
180
+ }
181
+ let body;
182
+ if (content !== undefined) {
183
+ body = content;
184
+ }
185
+ else if (!process.stdin.isTTY) {
186
+ body = await readStdin();
187
+ }
188
+ else {
189
+ throw usage('no content provided. Pass content as a positional arg or pipe via stdin.');
190
+ }
191
+ if (body.trim() === '') {
192
+ throw usage('content is empty');
193
+ }
194
+ submitToSession(sessionDir, body);
195
+ out('submitted');
196
+ }
197
+ catch (e) {
198
+ handleError(e);
199
+ }
200
+ });
201
+ agent
202
+ .command('plan')
203
+ .description('launch a planner for an approved spec in a new pane; close current pane')
204
+ .requiredOption('--spec <name>', 'name of the spec to plan')
205
+ .option('--kill-after <seconds>', `seconds before closing the originating pane (default ${DEFAULT_KILL_SECS})`, String(DEFAULT_KILL_SECS))
206
+ .action((options) => {
207
+ try {
208
+ const specPath = artifactPath('specs', options.spec);
209
+ if (!pathExists(specPath)) {
210
+ throw notFound(`spec not found: ${options.spec} (looked at ${specPath})`);
211
+ }
212
+ const killAfter = parseKillAfter(options.killAfter);
213
+ const result = spawnAndDetach({
214
+ ...baseDetachOpts(),
215
+ prompt: planHandoffPrompt(specPath, artifactsRoot('plans')),
216
+ killAfterSeconds: killAfter,
217
+ });
218
+ emitDetach(result, `planner for spec ${options.spec}`);
219
+ }
220
+ catch (e) {
221
+ handleError(e);
222
+ }
223
+ });
224
+ agent
225
+ .command('implement')
226
+ .description('launch an implementer for an approved plan in a new pane; close current pane')
227
+ .requiredOption('--plan <name>', 'name of the plan to implement')
228
+ .option('--kill-after <seconds>', `seconds before closing the originating pane (default ${DEFAULT_KILL_SECS})`, String(DEFAULT_KILL_SECS))
229
+ .action((options) => {
230
+ try {
231
+ const planPath = artifactPath('plans', options.plan);
232
+ if (!pathExists(planPath)) {
233
+ throw notFound(`plan not found: ${options.plan} (looked at ${planPath})`);
234
+ }
235
+ const killAfter = parseKillAfter(options.killAfter);
236
+ const result = spawnAndDetach({
237
+ ...baseDetachOpts(),
238
+ prompt: implementHandoffPrompt(planPath),
239
+ killAfterSeconds: killAfter,
240
+ });
241
+ emitDetach(result, `implementer for plan ${options.plan}`);
242
+ }
243
+ catch (e) {
244
+ handleError(e);
245
+ }
246
+ });
247
+ agent
248
+ .command('review')
249
+ .description('launch a code reviewer of the working tree in a new pane; close current pane')
250
+ .option('--kill-after <seconds>', `seconds before closing the originating pane (default ${DEFAULT_KILL_SECS})`, String(DEFAULT_KILL_SECS))
251
+ .action((options) => {
252
+ try {
253
+ const killAfter = parseKillAfter(options.killAfter);
254
+ const result = spawnAndDetach({
255
+ ...baseDetachOpts(),
256
+ prompt: reviewHandoffPrompt(),
257
+ killAfterSeconds: killAfter,
258
+ });
259
+ emitDetach(result, 'code reviewer');
260
+ }
261
+ catch (e) {
262
+ handleError(e);
263
+ }
264
+ });
265
+ }
@@ -2,7 +2,12 @@ import { readConfig, writeConfig, configPath } from '../core/config.js';
2
2
  import { usage, notFound } from '../core/errors.js';
3
3
  import { out, jsonOut, handleError } from '../core/output.js';
4
4
  import { scopeRoot, listScopes } from '../core/scope.js';
5
- const TOP_LEVEL_KEYS = new Set(['auto_update', 'marketplaces', 'plugins']);
5
+ const TOP_LEVEL_KEYS = new Set([
6
+ 'auto_update',
7
+ 'marketplaces',
8
+ 'plugins',
9
+ 'max_panes_per_window',
10
+ ]);
6
11
  function getNestedValue(obj, key) {
7
12
  const parts = key.split('.');
8
13
  let current = obj;
@@ -43,6 +48,13 @@ function setNestedValue(cfg, key, value) {
43
48
  cfg.auto_update.crtr = coerced;
44
49
  return;
45
50
  }
51
+ if (key === 'max_panes_per_window') {
52
+ if (typeof value !== 'number' || !Number.isFinite(value) || value < 1) {
53
+ throw usage(`max_panes_per_window must be an integer >= 1`);
54
+ }
55
+ cfg.max_panes_per_window = Math.floor(value);
56
+ return;
57
+ }
46
58
  if (parts.length === 1) {
47
59
  cfg[topKey] = value;
48
60
  return;
@@ -1,9 +1,25 @@
1
- import { registerArtifactCommand } from '../core/artifact.js';
1
+ import { artifactPath, registerArtifactCommand } from '../core/artifact.js';
2
2
  import { planPrompt } from '../prompts/plan.js';
3
+ import { planReviewPrompt } from '../prompts/review.js';
3
4
  export function registerPlanCommand(program) {
4
5
  registerArtifactCommand(program, {
5
6
  command: 'plan',
6
7
  kind: 'plans',
7
8
  promptFn: planPrompt,
9
+ oversizeWarnLines: 250,
10
+ reviewer: {
11
+ extraSaveOptions: [
12
+ {
13
+ flag: '--spec <name>',
14
+ description: 'name of the spec this plan implements (enables alignment check)',
15
+ key: 'spec',
16
+ },
17
+ ],
18
+ buildPrompt: (planPath, opts) => {
19
+ const specName = opts.spec;
20
+ const specPath = specName === undefined ? null : artifactPath('specs', specName);
21
+ return planReviewPrompt(planPath, specPath);
22
+ },
23
+ },
8
24
  });
9
25
  }
@@ -1,21 +1,22 @@
1
1
  import { join } from 'node:path';
2
2
  import { writeFileSync } from 'node:fs';
3
- import { SKILL_ENTRY_FILE, SKILLS_DIR, } from '../types.js';
3
+ import { SCOPE_SKILL_PLUGIN, SKILL_ENTRY_FILE, SKILLS_DIR, } from '../types.js';
4
4
  import { skillConfigKey } from '../types.js';
5
5
  import { notFound, usage, general } from '../core/errors.js';
6
6
  import { out, hint, info, jsonOut, handleError, } from '../core/output.js';
7
- import { listScopes, requireScopeRoot, resolveScopeArg, projectScopeRoot, } from '../core/scope.js';
7
+ import { listScopes, requireScopeRoot, resolveScopeArg, projectScopeRoot, scopeSkillsDir, } from '../core/scope.js';
8
8
  import { resolveSkill, listAllSkills, listInstalledPlugins, findPluginByName, parseSkillQualifier, } from '../core/resolver.js';
9
9
  import { updateConfig, ensureScopeInitialized } from '../core/config.js';
10
10
  import { parseFrontmatter, serializeFrontmatter } from '../core/frontmatter.js';
11
11
  import { ensureDir, pathExists, readText, walkFiles } from '../core/fs-utils.js';
12
- import { skillPrompt } from '../prompts/skill.js';
12
+ import { skillPrompt, skillCreatePrompt } from '../prompts/skill.js';
13
13
  const KNOWN_VERBS = new Set([
14
14
  'list',
15
15
  'show',
16
16
  'path',
17
17
  'grep',
18
18
  'new',
19
+ 'create',
19
20
  'where',
20
21
  'enable',
21
22
  'disable',
@@ -89,8 +90,10 @@ export function registerSkillCommands(program) {
89
90
  for (const sk of skills) {
90
91
  const desc = sk.frontmatter.description !== undefined ? sk.frontmatter.description : '';
91
92
  const marker = sk.enabled ? '' : ` [disabled${sk.disabledIn ? `@${sk.disabledIn}` : ''}]`;
92
- const line = `${sk.scope}:${sk.plugin}/${sk.name}${marker}${desc ? ` — ${desc}` : ''}`;
93
- out(line);
93
+ const qualified = sk.plugin === SCOPE_SKILL_PLUGIN
94
+ ? `${sk.scope}:${sk.name}`
95
+ : `${sk.scope}:${sk.plugin}/${sk.name}`;
96
+ out(`${qualified}${marker}${desc ? ` — ${desc}` : ''}`);
94
97
  }
95
98
  }
96
99
  catch (e) {
@@ -170,18 +173,23 @@ export function registerSkillCommands(program) {
170
173
  throw usage(`invalid regex pattern: ${pattern}`);
171
174
  }
172
175
  const scopes = listScopes(opts.scope);
173
- const pluginSkillsDirs = [];
176
+ const skillsDirs = [];
174
177
  for (const s of scopes) {
178
+ if (opts.plugin === undefined || opts.plugin === SCOPE_SKILL_PLUGIN) {
179
+ const root = scopeSkillsDir(s);
180
+ if (root)
181
+ skillsDirs.push(root);
182
+ }
175
183
  for (const plugin of listInstalledPlugins(s)) {
176
184
  if (!plugin.enabled)
177
185
  continue;
178
186
  if (opts.plugin !== undefined && plugin.name !== opts.plugin)
179
187
  continue;
180
- pluginSkillsDirs.push(join(plugin.root, SKILLS_DIR));
188
+ skillsDirs.push(join(plugin.root, SKILLS_DIR));
181
189
  }
182
190
  }
183
191
  const matchLines = [];
184
- for (const skillsDir of pluginSkillsDirs) {
192
+ for (const skillsDir of skillsDirs) {
185
193
  const files = walkFiles(skillsDir);
186
194
  for (const file of files) {
187
195
  const content = readText(file);
@@ -208,19 +216,47 @@ export function registerSkillCommands(program) {
208
216
  // new
209
217
  skill
210
218
  .command('new <qualifier>')
211
- .description('scaffold a new skill as <plugin>:<name>')
219
+ .description('scaffold a new skill <name> (scope-direct) or <plugin>:<name>')
212
220
  .option('--scope <scope>', 'user|project (default: project then user)')
213
221
  .option('--description <text>', 'skill description for frontmatter')
214
222
  .action(async (qualifier, opts) => {
215
223
  try {
216
- if (!qualifier.includes(':')) {
217
- throw usage('qualifier must be in the form <plugin>:<name> (e.g. authoring:my-skill)');
218
- }
219
224
  const { plugin: pluginName, name: skillName } = parseSkillQualifier(qualifier);
220
- if (!pluginName) {
221
- throw usage('qualifier must be in the form <plugin>:<name>');
225
+ if (!skillName) {
226
+ throw usage('skill name required');
222
227
  }
223
228
  const scopeArg = opts.scope !== undefined ? resolveScopeArg(opts.scope) : undefined;
229
+ // Scope-direct: no plugin qualifier, or explicit `_:` sentinel
230
+ if (pluginName === undefined || pluginName === SCOPE_SKILL_PLUGIN) {
231
+ let scope;
232
+ if (scopeArg !== undefined && scopeArg !== 'all') {
233
+ scope = scopeArg;
234
+ }
235
+ else {
236
+ scope = projectScopeRoot() !== null ? 'project' : 'user';
237
+ }
238
+ const scopeRootPath = requireScopeRoot(scope);
239
+ ensureScopeInitialized(scope, scopeRootPath);
240
+ const skillsRoot = scopeSkillsDir(scope);
241
+ if (!skillsRoot) {
242
+ throw general(`no skills dir for scope ${scope}`);
243
+ }
244
+ const skillDir = join(skillsRoot, ...skillName.split('/'));
245
+ const skillFile = join(skillDir, SKILL_ENTRY_FILE);
246
+ if (pathExists(skillFile)) {
247
+ throw general(`skill already exists: ${skillFile}`);
248
+ }
249
+ ensureDir(skillDir);
250
+ const fm = serializeFrontmatter({
251
+ name: skillName,
252
+ description: opts.description,
253
+ });
254
+ writeFileSync(skillFile, fm, 'utf8');
255
+ out(skillFile);
256
+ hint(`crtr: scaffolded ${scope}-scope skill ${skillName} — edit directly, then ` +
257
+ `\`crtr skill ${AUTHORING_GUIDE_SKILL}\` for SKILL.md authoring guidance`);
258
+ return;
259
+ }
224
260
  let plugin;
225
261
  if (scopeArg !== undefined && scopeArg !== 'all') {
226
262
  plugin = findPluginByName(pluginName, scopeArg);
@@ -250,6 +286,14 @@ export function registerSkillCommands(program) {
250
286
  handleError(e);
251
287
  }
252
288
  });
289
+ // create — walkthrough for capturing knowledge as a skill
290
+ skill
291
+ .command('create [topic...]')
292
+ .description('walkthrough for capturing knowledge as a new skill')
293
+ .action(async (topic) => {
294
+ const arg = topic && topic.length > 0 ? topic.join(' ') : '';
295
+ out(skillCreatePrompt(arg));
296
+ });
253
297
  // where
254
298
  skill
255
299
  .command('where <name>')
@@ -374,8 +418,10 @@ export function registerSkillCommands(program) {
374
418
  ? h.skill.frontmatter.description
375
419
  : '';
376
420
  const marker = h.skill.enabled ? '' : ' [disabled]';
377
- const line = `${h.skill.scope}:${h.skill.plugin}/${h.skill.name}${marker}\t${h.matched.join(',')}\t${desc}`;
378
- out(line);
421
+ const qualified = h.skill.plugin === SCOPE_SKILL_PLUGIN
422
+ ? `${h.skill.scope}:${h.skill.name}`
423
+ : `${h.skill.scope}:${h.skill.plugin}/${h.skill.name}`;
424
+ out(`${qualified}${marker}\t${h.matched.join(',')}\t${desc}`);
379
425
  }
380
426
  }
381
427
  catch (e) {
@@ -1,9 +1,13 @@
1
1
  import { registerArtifactCommand } from '../core/artifact.js';
2
2
  import { specPrompt } from '../prompts/spec.js';
3
+ import { specReviewPrompt } from '../prompts/review.js';
3
4
  export function registerSpecCommand(program) {
4
5
  registerArtifactCommand(program, {
5
6
  command: 'spec',
6
7
  kind: 'specs',
7
8
  promptFn: specPrompt,
9
+ reviewer: {
10
+ buildPrompt: (specPath) => specReviewPrompt(specPath),
11
+ },
8
12
  });
9
13
  }
@@ -6,9 +6,21 @@ export declare function sanitizeName(raw: string): string;
6
6
  export declare function artifactPath(kind: ArtifactKind, name: string, cwd?: string): string;
7
7
  export declare function inTmux(): boolean;
8
8
  export declare function openInTmuxPane(path: string): void;
9
+ export interface SaveOptionDef {
10
+ flag: string;
11
+ description: string;
12
+ key: string;
13
+ }
14
+ export interface ReviewerConfig {
15
+ buildPrompt: (artifactPath: string, opts: Record<string, string | undefined>) => string;
16
+ extraSaveOptions?: SaveOptionDef[];
17
+ }
9
18
  export interface RegisterArtifactOptions {
10
19
  command: 'plan' | 'spec';
11
20
  kind: ArtifactKind;
12
21
  promptFn: (artifactsDir: string) => string;
22
+ reviewer?: ReviewerConfig;
23
+ /** If set and the saved body exceeds this many lines, emit a breakdown advisory. */
24
+ oversizeWarnLines?: number;
13
25
  }
14
26
  export declare function registerArtifactCommand(program: Command, opts: RegisterArtifactOptions): void;
@@ -5,7 +5,8 @@ import { writeFileSync } from 'node:fs';
5
5
  import { CRTR_DIR_NAME } from '../types.js';
6
6
  import { ensureDir, pathExists, readText, walkFiles } from './fs-utils.js';
7
7
  import { usage, notFound, general } from './errors.js';
8
- import { out, hint, jsonOut, handleError } from './output.js';
8
+ import { out, hint, jsonOut, handleError, info } from './output.js';
9
+ import { spawnSidePaneReview, DEFAULT_PANE_OPTS, } from './spawn.js';
9
10
  export function mangleCwd(cwd = process.cwd()) {
10
11
  return cwd.replace(/\//g, '-');
11
12
  }
@@ -69,12 +70,18 @@ function listArtifactNames(kind) {
69
70
  .sort();
70
71
  }
71
72
  export function registerArtifactCommand(program, opts) {
72
- const { command, kind, promptFn } = opts;
73
- const cmd = program
73
+ const { command, kind, promptFn, reviewer, oversizeWarnLines } = opts;
74
+ const saveCmd = program
74
75
  .command(`${command} [content]`)
75
76
  .description(`print the ${command} prompt, or save a ${command} with --name`)
76
- .option('--name <name>', `save the ${command} under this name`)
77
- .action(async (content, options) => {
77
+ .option('--name <name>', `save the ${command} under this name`);
78
+ if (reviewer !== undefined) {
79
+ saveCmd.option('--no-review', 'skip the auto-review step (use for trivial artifacts)');
80
+ for (const extra of reviewer.extraSaveOptions ?? []) {
81
+ saveCmd.option(extra.flag, extra.description);
82
+ }
83
+ }
84
+ saveCmd.action(async (content, options) => {
78
85
  try {
79
86
  if (options.name === undefined) {
80
87
  if (content !== undefined) {
@@ -99,15 +106,42 @@ export function registerArtifactCommand(program, opts) {
99
106
  }
100
107
  const filePath = artifactPath(kind, options.name);
101
108
  ensureDir(dirname(filePath));
102
- writeFileSync(filePath, body.endsWith('\n') ? body : body + '\n', 'utf8');
109
+ const finalBody = body.endsWith('\n') ? body : body + '\n';
110
+ writeFileSync(filePath, finalBody, 'utf8');
103
111
  out(filePath);
104
112
  if (inTmux())
105
113
  openInTmuxPane(filePath);
114
+ if (oversizeWarnLines !== undefined) {
115
+ const lineCount = finalBody.split('\n').length - 1;
116
+ if (lineCount > oversizeWarnLines) {
117
+ out('');
118
+ out('--- advisory ---');
119
+ out(`This ${command} is ${lineCount} lines (> ${oversizeWarnLines}). ` +
120
+ `Consider splitting it into multiple ${command}s under a shared prefix ` +
121
+ `(e.g. \`crtr ${command} --name ${options.name}/part-1\`, ` +
122
+ `\`crtr ${command} --name ${options.name}/part-2\`) and linking them ` +
123
+ `from a brief index ${command} at \`${options.name}\`. ` +
124
+ `Smaller ${command}s are easier to execute and review.`);
125
+ }
126
+ }
127
+ if (reviewer !== undefined) {
128
+ const reviewSkipped = options.review === false;
129
+ if (reviewSkipped) {
130
+ hint('review skipped (--no-review)');
131
+ }
132
+ else if (!inTmux()) {
133
+ hint('review skipped (not in tmux — auto-review needs a tmux pane)');
134
+ }
135
+ else {
136
+ await runReviewer(filePath, reviewer, options);
137
+ }
138
+ }
106
139
  }
107
140
  catch (e) {
108
141
  handleError(e);
109
142
  }
110
143
  });
144
+ const cmd = saveCmd;
111
145
  cmd
112
146
  .command('list')
113
147
  .description(`list ${kind} for the current directory`)
@@ -185,3 +219,31 @@ export function registerArtifactCommand(program, opts) {
185
219
  }
186
220
  });
187
221
  }
222
+ async function runReviewer(artifactFilePath, reviewer, options) {
223
+ const extraOpts = {};
224
+ for (const def of reviewer.extraSaveOptions ?? []) {
225
+ const value = options[def.key];
226
+ if (typeof value === 'string')
227
+ extraOpts[def.key] = value;
228
+ }
229
+ info('spawning reviewer in side pane (10-min budget) — output below…');
230
+ const result = await spawnSidePaneReview({
231
+ prompt: reviewer.buildPrompt(artifactFilePath, extraOpts),
232
+ cwd: process.cwd(),
233
+ timeoutMs: DEFAULT_PANE_OPTS.timeoutMs,
234
+ });
235
+ out('');
236
+ out('--- review ---');
237
+ if (result.status === 'submitted') {
238
+ out(result.content.trim());
239
+ }
240
+ else if (result.status === 'timeout') {
241
+ out('REVIEW_TIMEOUT: reviewer did not submit within the 10-minute budget.');
242
+ }
243
+ else if (result.status === 'pane-closed') {
244
+ out('REVIEW_ABORTED: reviewer pane closed before submission.');
245
+ }
246
+ else {
247
+ out(`REVIEW_FAILED: ${result.content}`);
248
+ }
249
+ }
@@ -0,0 +1,5 @@
1
+ export declare const OFFICIAL_MARKETPLACE_NAME = "crouter-official-marketplace";
2
+ export declare const OFFICIAL_MARKETPLACE_URL = "https://github.com/crouton-labs/crouter-official-marketplace.git";
3
+ export declare const OFFICIAL_MARKETPLACE_REF = "main";
4
+ export declare function ensureBootSkill(argv: string[]): void;
5
+ export declare function ensureOfficialMarketplace(argv: string[]): void;