@crouton-kit/crouter 0.1.4 → 0.1.7
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/cli.js +4 -1
- package/dist/commands/agent.d.ts +2 -0
- package/dist/commands/agent.js +265 -0
- package/dist/commands/config.js +13 -1
- package/dist/commands/plan.js +17 -1
- package/dist/commands/skill.js +71 -16
- package/dist/commands/spec.js +4 -0
- package/dist/core/artifact.d.ts +12 -0
- package/dist/core/artifact.js +68 -6
- package/dist/core/bootstrap.d.ts +1 -0
- package/dist/core/bootstrap.js +74 -1
- package/dist/core/config.js +5 -1
- package/dist/core/resolver.d.ts +1 -0
- package/dist/core/resolver.js +66 -6
- package/dist/core/scope.d.ts +1 -0
- package/dist/core/scope.js +4 -0
- package/dist/core/spawn.d.ts +95 -0
- package/dist/core/spawn.js +309 -0
- package/dist/prompts/agent.d.ts +13 -0
- package/dist/prompts/agent.js +114 -0
- package/dist/prompts/plan.js +39 -3
- package/dist/prompts/review.d.ts +2 -0
- package/dist/prompts/review.js +103 -0
- package/dist/prompts/skill.d.ts +2 -0
- package/dist/prompts/skill.js +409 -23
- package/dist/prompts/spec.js +28 -3
- package/dist/types.d.ts +3 -0
- package/dist/types.js +6 -0
- package/package.json +1 -1
package/dist/cli.js
CHANGED
|
@@ -11,8 +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';
|
|
15
|
-
import { ensureOfficialMarketplace } from './core/bootstrap.js';
|
|
16
|
+
import { ensureBootSkill, ensureOfficialMarketplace } from './core/bootstrap.js';
|
|
16
17
|
function readPackageVersion() {
|
|
17
18
|
const here = dirname(fileURLToPath(import.meta.url));
|
|
18
19
|
const pkgPath = join(here, '..', 'package.json');
|
|
@@ -34,7 +35,9 @@ registerUpdateCommand(program);
|
|
|
34
35
|
registerDoctorCommand(program);
|
|
35
36
|
registerPlanCommand(program);
|
|
36
37
|
registerSpecCommand(program);
|
|
38
|
+
registerAgentCommand(program);
|
|
37
39
|
ensureOfficialMarketplace(process.argv);
|
|
40
|
+
ensureBootSkill(process.argv);
|
|
38
41
|
maybeAutoUpdate(process.argv);
|
|
39
42
|
program.parseAsync().catch((err) => {
|
|
40
43
|
process.stderr.write(`crtr: ${err instanceof Error ? err.message : String(err)}\n`);
|
|
@@ -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
|
+
}
|
package/dist/commands/config.js
CHANGED
|
@@ -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([
|
|
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;
|
package/dist/commands/plan.js
CHANGED
|
@@ -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
|
}
|
package/dist/commands/skill.js
CHANGED
|
@@ -1,21 +1,23 @@
|
|
|
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, skillTemplatePrompt } 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',
|
|
20
|
+
'template',
|
|
19
21
|
'where',
|
|
20
22
|
'enable',
|
|
21
23
|
'disable',
|
|
@@ -89,8 +91,10 @@ export function registerSkillCommands(program) {
|
|
|
89
91
|
for (const sk of skills) {
|
|
90
92
|
const desc = sk.frontmatter.description !== undefined ? sk.frontmatter.description : '';
|
|
91
93
|
const marker = sk.enabled ? '' : ` [disabled${sk.disabledIn ? `@${sk.disabledIn}` : ''}]`;
|
|
92
|
-
const
|
|
93
|
-
|
|
94
|
+
const qualified = sk.plugin === SCOPE_SKILL_PLUGIN
|
|
95
|
+
? `${sk.scope}:${sk.name}`
|
|
96
|
+
: `${sk.scope}:${sk.plugin}/${sk.name}`;
|
|
97
|
+
out(`${qualified}${marker}${desc ? ` — ${desc}` : ''}`);
|
|
94
98
|
}
|
|
95
99
|
}
|
|
96
100
|
catch (e) {
|
|
@@ -170,18 +174,23 @@ export function registerSkillCommands(program) {
|
|
|
170
174
|
throw usage(`invalid regex pattern: ${pattern}`);
|
|
171
175
|
}
|
|
172
176
|
const scopes = listScopes(opts.scope);
|
|
173
|
-
const
|
|
177
|
+
const skillsDirs = [];
|
|
174
178
|
for (const s of scopes) {
|
|
179
|
+
if (opts.plugin === undefined || opts.plugin === SCOPE_SKILL_PLUGIN) {
|
|
180
|
+
const root = scopeSkillsDir(s);
|
|
181
|
+
if (root)
|
|
182
|
+
skillsDirs.push(root);
|
|
183
|
+
}
|
|
175
184
|
for (const plugin of listInstalledPlugins(s)) {
|
|
176
185
|
if (!plugin.enabled)
|
|
177
186
|
continue;
|
|
178
187
|
if (opts.plugin !== undefined && plugin.name !== opts.plugin)
|
|
179
188
|
continue;
|
|
180
|
-
|
|
189
|
+
skillsDirs.push(join(plugin.root, SKILLS_DIR));
|
|
181
190
|
}
|
|
182
191
|
}
|
|
183
192
|
const matchLines = [];
|
|
184
|
-
for (const skillsDir of
|
|
193
|
+
for (const skillsDir of skillsDirs) {
|
|
185
194
|
const files = walkFiles(skillsDir);
|
|
186
195
|
for (const file of files) {
|
|
187
196
|
const content = readText(file);
|
|
@@ -208,19 +217,47 @@ export function registerSkillCommands(program) {
|
|
|
208
217
|
// new
|
|
209
218
|
skill
|
|
210
219
|
.command('new <qualifier>')
|
|
211
|
-
.description('scaffold a new skill
|
|
220
|
+
.description('scaffold a new skill — <name> (scope-direct) or <plugin>:<name>')
|
|
212
221
|
.option('--scope <scope>', 'user|project (default: project then user)')
|
|
213
222
|
.option('--description <text>', 'skill description for frontmatter')
|
|
214
223
|
.action(async (qualifier, opts) => {
|
|
215
224
|
try {
|
|
216
|
-
if (!qualifier.includes(':')) {
|
|
217
|
-
throw usage('qualifier must be in the form <plugin>:<name> (e.g. authoring:my-skill)');
|
|
218
|
-
}
|
|
219
225
|
const { plugin: pluginName, name: skillName } = parseSkillQualifier(qualifier);
|
|
220
|
-
if (!
|
|
221
|
-
throw usage('
|
|
226
|
+
if (!skillName) {
|
|
227
|
+
throw usage('skill name required');
|
|
222
228
|
}
|
|
223
229
|
const scopeArg = opts.scope !== undefined ? resolveScopeArg(opts.scope) : undefined;
|
|
230
|
+
// Scope-direct: no plugin qualifier, or explicit `_:` sentinel
|
|
231
|
+
if (pluginName === undefined || pluginName === SCOPE_SKILL_PLUGIN) {
|
|
232
|
+
let scope;
|
|
233
|
+
if (scopeArg !== undefined && scopeArg !== 'all') {
|
|
234
|
+
scope = scopeArg;
|
|
235
|
+
}
|
|
236
|
+
else {
|
|
237
|
+
scope = projectScopeRoot() !== null ? 'project' : 'user';
|
|
238
|
+
}
|
|
239
|
+
const scopeRootPath = requireScopeRoot(scope);
|
|
240
|
+
ensureScopeInitialized(scope, scopeRootPath);
|
|
241
|
+
const skillsRoot = scopeSkillsDir(scope);
|
|
242
|
+
if (!skillsRoot) {
|
|
243
|
+
throw general(`no skills dir for scope ${scope}`);
|
|
244
|
+
}
|
|
245
|
+
const skillDir = join(skillsRoot, ...skillName.split('/'));
|
|
246
|
+
const skillFile = join(skillDir, SKILL_ENTRY_FILE);
|
|
247
|
+
if (pathExists(skillFile)) {
|
|
248
|
+
throw general(`skill already exists: ${skillFile}`);
|
|
249
|
+
}
|
|
250
|
+
ensureDir(skillDir);
|
|
251
|
+
const fm = serializeFrontmatter({
|
|
252
|
+
name: skillName,
|
|
253
|
+
description: opts.description,
|
|
254
|
+
});
|
|
255
|
+
writeFileSync(skillFile, fm, 'utf8');
|
|
256
|
+
out(skillFile);
|
|
257
|
+
hint(`crtr: scaffolded ${scope}-scope skill ${skillName} — edit directly, then ` +
|
|
258
|
+
`\`crtr skill ${AUTHORING_GUIDE_SKILL}\` for SKILL.md authoring guidance`);
|
|
259
|
+
return;
|
|
260
|
+
}
|
|
224
261
|
let plugin;
|
|
225
262
|
if (scopeArg !== undefined && scopeArg !== 'all') {
|
|
226
263
|
plugin = findPluginByName(pluginName, scopeArg);
|
|
@@ -250,6 +287,22 @@ export function registerSkillCommands(program) {
|
|
|
250
287
|
handleError(e);
|
|
251
288
|
}
|
|
252
289
|
});
|
|
290
|
+
// create — pick a template type
|
|
291
|
+
skill
|
|
292
|
+
.command('create [topic...]')
|
|
293
|
+
.description('pick a template type for a new skill (primer | playbook | freeform)')
|
|
294
|
+
.action(async (topic) => {
|
|
295
|
+
const arg = topic && topic.length > 0 ? topic.join(' ') : '';
|
|
296
|
+
out(skillCreatePrompt(arg));
|
|
297
|
+
});
|
|
298
|
+
// template — full workflow + skeleton for one template type
|
|
299
|
+
skill
|
|
300
|
+
.command('template <type> [topic...]')
|
|
301
|
+
.description('full workflow + skeleton for a template type (primer | playbook | freeform)')
|
|
302
|
+
.action(async (type, topic) => {
|
|
303
|
+
const arg = topic && topic.length > 0 ? topic.join(' ') : '';
|
|
304
|
+
out(skillTemplatePrompt(type, arg));
|
|
305
|
+
});
|
|
253
306
|
// where
|
|
254
307
|
skill
|
|
255
308
|
.command('where <name>')
|
|
@@ -374,8 +427,10 @@ export function registerSkillCommands(program) {
|
|
|
374
427
|
? h.skill.frontmatter.description
|
|
375
428
|
: '';
|
|
376
429
|
const marker = h.skill.enabled ? '' : ' [disabled]';
|
|
377
|
-
const
|
|
378
|
-
|
|
430
|
+
const qualified = h.skill.plugin === SCOPE_SKILL_PLUGIN
|
|
431
|
+
? `${h.skill.scope}:${h.skill.name}`
|
|
432
|
+
: `${h.skill.scope}:${h.skill.plugin}/${h.skill.name}`;
|
|
433
|
+
out(`${qualified}${marker}\t${h.matched.join(',')}\t${desc}`);
|
|
379
434
|
}
|
|
380
435
|
}
|
|
381
436
|
catch (e) {
|
package/dist/commands/spec.js
CHANGED
|
@@ -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
|
}
|
package/dist/core/artifact.d.ts
CHANGED
|
@@ -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;
|
package/dist/core/artifact.js
CHANGED
|
@@ -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
|
|
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
|
-
|
|
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
|
-
|
|
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
|
+
}
|
package/dist/core/bootstrap.d.ts
CHANGED
|
@@ -1,4 +1,5 @@
|
|
|
1
1
|
export declare const OFFICIAL_MARKETPLACE_NAME = "crouter-official-marketplace";
|
|
2
2
|
export declare const OFFICIAL_MARKETPLACE_URL = "https://github.com/crouton-labs/crouter-official-marketplace.git";
|
|
3
3
|
export declare const OFFICIAL_MARKETPLACE_REF = "main";
|
|
4
|
+
export declare function ensureBootSkill(argv: string[]): void;
|
|
4
5
|
export declare function ensureOfficialMarketplace(argv: string[]): void;
|