@crouton-kit/crouter 0.2.6 → 0.3.2
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 +294 -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 +613 -456
- 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 -4
- 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/core/self-update.js +105 -0
- 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/commands/update.js +0 -140
- 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,208 @@
|
|
|
1
|
+
// Tests for the sys subtree after argv migration.
|
|
2
|
+
// Exercises: positional key, --value coercion, enum validation (--scope, --target),
|
|
3
|
+
// bool presence flags (--fix, --remote, --check).
|
|
4
|
+
// Run with: node --import tsx/esm --test 'src/core/__tests__/**/*.test.ts'
|
|
5
|
+
import { test, describe } from 'node:test';
|
|
6
|
+
import assert from 'node:assert/strict';
|
|
7
|
+
import { parseArgv } from '../command.js';
|
|
8
|
+
// ---------------------------------------------------------------------------
|
|
9
|
+
// Shared param schemas (mirror sys.ts definitions)
|
|
10
|
+
// ---------------------------------------------------------------------------
|
|
11
|
+
const configGetParams = [
|
|
12
|
+
{ kind: 'positional', name: 'key', type: 'string', required: true, constraint: 'Dotted key path.' },
|
|
13
|
+
{ kind: 'flag', name: 'scope', type: 'enum', choices: ['user', 'project', 'all'], required: false, constraint: 'Scope.' },
|
|
14
|
+
];
|
|
15
|
+
const configSetParams = [
|
|
16
|
+
{ kind: 'positional', name: 'key', type: 'string', required: true, constraint: 'Dotted key path.' },
|
|
17
|
+
{ kind: 'flag', name: 'value', type: 'string', required: true, constraint: 'Value to write.' },
|
|
18
|
+
{ kind: 'flag', name: 'scope', type: 'enum', choices: ['user', 'project'], required: false, constraint: 'Scope.' },
|
|
19
|
+
];
|
|
20
|
+
const configPathParams = [
|
|
21
|
+
{ kind: 'flag', name: 'scope', type: 'enum', choices: ['user', 'project', 'all'], required: false, constraint: 'Scope.' },
|
|
22
|
+
];
|
|
23
|
+
const sysDoctorParams = [
|
|
24
|
+
{ kind: 'flag', name: 'scope', type: 'enum', choices: ['user', 'project'], required: false, constraint: 'Scope.' },
|
|
25
|
+
{ kind: 'flag', name: 'fix', type: 'bool', required: false, constraint: '' },
|
|
26
|
+
{ kind: 'flag', name: 'remote', type: 'bool', required: false, constraint: '' },
|
|
27
|
+
];
|
|
28
|
+
const sysUpdateParams = [
|
|
29
|
+
{ kind: 'flag', name: 'target', type: 'enum', choices: ['self', 'content', 'all'], required: false, constraint: '' },
|
|
30
|
+
{ kind: 'flag', name: 'check', type: 'bool', required: false, constraint: '' },
|
|
31
|
+
];
|
|
32
|
+
// ---------------------------------------------------------------------------
|
|
33
|
+
// sys config get
|
|
34
|
+
// ---------------------------------------------------------------------------
|
|
35
|
+
describe('sys config get: argv parsing', () => {
|
|
36
|
+
test('positional key is required — missing throws', async () => {
|
|
37
|
+
await assert.rejects(() => parseArgv(configGetParams, []), (err) => { assert.match(err.message, /required parameter is missing/); return true; });
|
|
38
|
+
});
|
|
39
|
+
test('positional key is captured', async () => {
|
|
40
|
+
const result = await parseArgv(configGetParams, ['auto_update.crtr']);
|
|
41
|
+
assert.equal(result['key'], 'auto_update.crtr');
|
|
42
|
+
});
|
|
43
|
+
test('--scope valid enum passes', async () => {
|
|
44
|
+
const result = await parseArgv(configGetParams, ['some.key', '--scope', 'project']);
|
|
45
|
+
assert.equal(result['scope'], 'project');
|
|
46
|
+
});
|
|
47
|
+
test('--scope "all" is valid', async () => {
|
|
48
|
+
const result = await parseArgv(configGetParams, ['some.key', '--scope', 'all']);
|
|
49
|
+
assert.equal(result['scope'], 'all');
|
|
50
|
+
});
|
|
51
|
+
test('--scope invalid enum throws', async () => {
|
|
52
|
+
await assert.rejects(() => parseArgv(configGetParams, ['some.key', '--scope', 'global']), (err) => { assert.match(err.message, /must be one of/); return true; });
|
|
53
|
+
});
|
|
54
|
+
test('--scope absent leaves field undefined', async () => {
|
|
55
|
+
const result = await parseArgv(configGetParams, ['some.key']);
|
|
56
|
+
assert.equal(result['scope'], undefined);
|
|
57
|
+
});
|
|
58
|
+
});
|
|
59
|
+
// ---------------------------------------------------------------------------
|
|
60
|
+
// sys config set — value coercion
|
|
61
|
+
// ---------------------------------------------------------------------------
|
|
62
|
+
describe('sys config set: --value coercion', () => {
|
|
63
|
+
test('string value passed through unchanged', async () => {
|
|
64
|
+
const result = await parseArgv(configSetParams, ['some.key', '--value', 'hello']);
|
|
65
|
+
assert.equal(result['value'], 'hello');
|
|
66
|
+
});
|
|
67
|
+
test('"true" string arrives as string (handler coerces)', async () => {
|
|
68
|
+
const result = await parseArgv(configSetParams, ['some.key', '--value', 'true']);
|
|
69
|
+
// The argv layer keeps it as a string; the handler calls parseConfigValue
|
|
70
|
+
assert.equal(result['value'], 'true');
|
|
71
|
+
});
|
|
72
|
+
test('"false" string arrives as string', async () => {
|
|
73
|
+
const result = await parseArgv(configSetParams, ['some.key', '--value', 'false']);
|
|
74
|
+
assert.equal(result['value'], 'false');
|
|
75
|
+
});
|
|
76
|
+
test('integer string arrives as string', async () => {
|
|
77
|
+
const result = await parseArgv(configSetParams, ['some.key', '--value', '42']);
|
|
78
|
+
assert.equal(result['value'], '42');
|
|
79
|
+
});
|
|
80
|
+
test('--value is required — missing throws', async () => {
|
|
81
|
+
await assert.rejects(() => parseArgv(configSetParams, ['some.key']), (err) => { assert.match(err.message, /required parameter is missing/); return true; });
|
|
82
|
+
});
|
|
83
|
+
test('key positional is required — missing throws', async () => {
|
|
84
|
+
await assert.rejects(() => parseArgv(configSetParams, ['--value', 'x']), (err) => { assert.match(err.message, /required parameter is missing/); return true; });
|
|
85
|
+
});
|
|
86
|
+
test('--scope valid enum passes', async () => {
|
|
87
|
+
const result = await parseArgv(configSetParams, ['some.key', '--value', 'x', '--scope', 'user']);
|
|
88
|
+
assert.equal(result['scope'], 'user');
|
|
89
|
+
});
|
|
90
|
+
test('--scope "all" rejected (not in choices)', async () => {
|
|
91
|
+
await assert.rejects(() => parseArgv(configSetParams, ['some.key', '--value', 'x', '--scope', 'all']), (err) => { assert.match(err.message, /must be one of/); return true; });
|
|
92
|
+
});
|
|
93
|
+
});
|
|
94
|
+
// ---------------------------------------------------------------------------
|
|
95
|
+
// parseConfigValue coercion (via a thin harness)
|
|
96
|
+
// ---------------------------------------------------------------------------
|
|
97
|
+
// We re-implement parseConfigValue's logic inline here to keep the test
|
|
98
|
+
// self-contained (it's a private helper in sys.ts).
|
|
99
|
+
function parseConfigValue(raw) {
|
|
100
|
+
if (raw === 'true')
|
|
101
|
+
return true;
|
|
102
|
+
if (raw === 'false')
|
|
103
|
+
return false;
|
|
104
|
+
if (/^-?\d+$/.test(raw))
|
|
105
|
+
return parseInt(raw, 10);
|
|
106
|
+
return raw;
|
|
107
|
+
}
|
|
108
|
+
describe('parseConfigValue coercion', () => {
|
|
109
|
+
test('"true" → boolean true', () => { assert.strictEqual(parseConfigValue('true'), true); });
|
|
110
|
+
test('"false" → boolean false', () => { assert.strictEqual(parseConfigValue('false'), false); });
|
|
111
|
+
test('"42" → number 42', () => { assert.strictEqual(parseConfigValue('42'), 42); });
|
|
112
|
+
test('"-1" → number -1', () => { assert.strictEqual(parseConfigValue('-1'), -1); });
|
|
113
|
+
test('"notify" → string', () => { assert.strictEqual(parseConfigValue('notify'), 'notify'); });
|
|
114
|
+
test('"3.14" → string (non-integer float stays string)', () => { assert.strictEqual(parseConfigValue('3.14'), '3.14'); });
|
|
115
|
+
test('empty string → string', () => { assert.strictEqual(parseConfigValue(''), ''); });
|
|
116
|
+
});
|
|
117
|
+
// ---------------------------------------------------------------------------
|
|
118
|
+
// sys config path
|
|
119
|
+
// ---------------------------------------------------------------------------
|
|
120
|
+
describe('sys config path: argv parsing', () => {
|
|
121
|
+
test('no args parses cleanly', async () => {
|
|
122
|
+
const result = await parseArgv(configPathParams, []);
|
|
123
|
+
assert.equal(result['scope'], undefined);
|
|
124
|
+
});
|
|
125
|
+
test('--scope user', async () => {
|
|
126
|
+
const result = await parseArgv(configPathParams, ['--scope', 'user']);
|
|
127
|
+
assert.equal(result['scope'], 'user');
|
|
128
|
+
});
|
|
129
|
+
test('--scope all', async () => {
|
|
130
|
+
const result = await parseArgv(configPathParams, ['--scope', 'all']);
|
|
131
|
+
assert.equal(result['scope'], 'all');
|
|
132
|
+
});
|
|
133
|
+
test('invalid --scope throws', async () => {
|
|
134
|
+
await assert.rejects(() => parseArgv(configPathParams, ['--scope', 'bogus']), (err) => { assert.match(err.message, /must be one of/); return true; });
|
|
135
|
+
});
|
|
136
|
+
});
|
|
137
|
+
// ---------------------------------------------------------------------------
|
|
138
|
+
// sys doctor — bool presence flags
|
|
139
|
+
// ---------------------------------------------------------------------------
|
|
140
|
+
describe('sys doctor: bool presence flags', () => {
|
|
141
|
+
test('no args: fix=false, remote=false, scope=undefined', async () => {
|
|
142
|
+
const result = await parseArgv(sysDoctorParams, []);
|
|
143
|
+
assert.equal(result['fix'], false);
|
|
144
|
+
assert.equal(result['remote'], false);
|
|
145
|
+
assert.equal(result['scope'], undefined);
|
|
146
|
+
});
|
|
147
|
+
test('--fix sets fix=true', async () => {
|
|
148
|
+
const result = await parseArgv(sysDoctorParams, ['--fix']);
|
|
149
|
+
assert.equal(result['fix'], true);
|
|
150
|
+
});
|
|
151
|
+
test('--remote sets remote=true', async () => {
|
|
152
|
+
const result = await parseArgv(sysDoctorParams, ['--remote']);
|
|
153
|
+
assert.equal(result['remote'], true);
|
|
154
|
+
});
|
|
155
|
+
test('--fix --remote both set', async () => {
|
|
156
|
+
const result = await parseArgv(sysDoctorParams, ['--fix', '--remote']);
|
|
157
|
+
assert.equal(result['fix'], true);
|
|
158
|
+
assert.equal(result['remote'], true);
|
|
159
|
+
});
|
|
160
|
+
test('--scope user is valid', async () => {
|
|
161
|
+
const result = await parseArgv(sysDoctorParams, ['--scope', 'user']);
|
|
162
|
+
assert.equal(result['scope'], 'user');
|
|
163
|
+
});
|
|
164
|
+
test('--scope project is valid', async () => {
|
|
165
|
+
const result = await parseArgv(sysDoctorParams, ['--scope', 'project']);
|
|
166
|
+
assert.equal(result['scope'], 'project');
|
|
167
|
+
});
|
|
168
|
+
test('--scope all rejected (not in choices for doctor)', async () => {
|
|
169
|
+
await assert.rejects(() => parseArgv(sysDoctorParams, ['--scope', 'all']), (err) => { assert.match(err.message, /must be one of/); return true; });
|
|
170
|
+
});
|
|
171
|
+
test('--fix=true rejected (bool takes no value)', async () => {
|
|
172
|
+
await assert.rejects(() => parseArgv(sysDoctorParams, ['--fix=true']), (err) => { assert.match(err.message, /takes no value/); return true; });
|
|
173
|
+
});
|
|
174
|
+
});
|
|
175
|
+
// ---------------------------------------------------------------------------
|
|
176
|
+
// sys update — enum target + --check bool
|
|
177
|
+
// ---------------------------------------------------------------------------
|
|
178
|
+
describe('sys update: argv parsing', () => {
|
|
179
|
+
test('no args: check=false, target=undefined', async () => {
|
|
180
|
+
const result = await parseArgv(sysUpdateParams, []);
|
|
181
|
+
assert.equal(result['check'], false);
|
|
182
|
+
assert.equal(result['target'], undefined);
|
|
183
|
+
});
|
|
184
|
+
test('--check sets check=true', async () => {
|
|
185
|
+
const result = await parseArgv(sysUpdateParams, ['--check']);
|
|
186
|
+
assert.equal(result['check'], true);
|
|
187
|
+
});
|
|
188
|
+
test('--target self is valid', async () => {
|
|
189
|
+
const result = await parseArgv(sysUpdateParams, ['--target', 'self']);
|
|
190
|
+
assert.equal(result['target'], 'self');
|
|
191
|
+
});
|
|
192
|
+
test('--target content is valid', async () => {
|
|
193
|
+
const result = await parseArgv(sysUpdateParams, ['--target', 'content']);
|
|
194
|
+
assert.equal(result['target'], 'content');
|
|
195
|
+
});
|
|
196
|
+
test('--target all is valid', async () => {
|
|
197
|
+
const result = await parseArgv(sysUpdateParams, ['--target', 'all']);
|
|
198
|
+
assert.equal(result['target'], 'all');
|
|
199
|
+
});
|
|
200
|
+
test('--target bogus throws invalid_type', async () => {
|
|
201
|
+
await assert.rejects(() => parseArgv(sysUpdateParams, ['--target', 'bogus']), (err) => { assert.match(err.message, /must be one of/); return true; });
|
|
202
|
+
});
|
|
203
|
+
test('--check --target self combined', async () => {
|
|
204
|
+
const result = await parseArgv(sysUpdateParams, ['--check', '--target', 'self']);
|
|
205
|
+
assert.equal(result['check'], true);
|
|
206
|
+
assert.equal(result['target'], 'self');
|
|
207
|
+
});
|
|
208
|
+
});
|
package/dist/core/artifact.d.ts
CHANGED
|
@@ -1,26 +1,37 @@
|
|
|
1
|
-
import { Command } from 'commander';
|
|
2
1
|
export type ArtifactKind = 'plans' | 'specs';
|
|
3
2
|
export declare function mangleCwd(cwd?: string): string;
|
|
4
3
|
export declare function artifactsRoot(kind: ArtifactKind, cwd?: string): string;
|
|
4
|
+
/** Per-cwd interactions root, mirroring artifactsRoot construction.
|
|
5
|
+
* `~/.crouter/<mangled-cwd>/interactions/`. */
|
|
6
|
+
export declare function interactionsRoot(cwd?: string): string;
|
|
7
|
+
/** Directory for one interaction. `id` is sanitized like an artifact name. */
|
|
8
|
+
export declare function interactionDir(id: string, cwd?: string): string;
|
|
5
9
|
export declare function sanitizeName(raw: string): string;
|
|
6
10
|
export declare function artifactPath(kind: ArtifactKind, name: string, cwd?: string): string;
|
|
7
|
-
|
|
8
|
-
export declare
|
|
9
|
-
export interface
|
|
10
|
-
|
|
11
|
-
|
|
12
|
-
|
|
11
|
+
/** Lines above this threshold trigger an oversize advisory in follow_up. */
|
|
12
|
+
export declare const OVERSIZE_WARN_LINES = 200;
|
|
13
|
+
export interface SaveArtifactResult {
|
|
14
|
+
path: string;
|
|
15
|
+
oversize: boolean;
|
|
16
|
+
lineCount: number;
|
|
13
17
|
}
|
|
14
|
-
|
|
15
|
-
|
|
16
|
-
|
|
18
|
+
/**
|
|
19
|
+
* Atomically write an artifact. Prepends a minimal frontmatter block when
|
|
20
|
+
* `meta` is non-empty so readers can extract structured fields without
|
|
21
|
+
* parsing the full body. Returns the written path and oversize status.
|
|
22
|
+
*/
|
|
23
|
+
export declare function saveArtifact(kind: ArtifactKind, name: string, body: string, meta?: Record<string, string>): SaveArtifactResult;
|
|
24
|
+
export interface ArtifactRecord {
|
|
25
|
+
name: string;
|
|
26
|
+
path: string;
|
|
27
|
+
body: string;
|
|
28
|
+
/** Present only in plan artifacts; null if not set. */
|
|
29
|
+
spec: string | null;
|
|
17
30
|
}
|
|
18
|
-
export
|
|
19
|
-
|
|
20
|
-
|
|
21
|
-
|
|
22
|
-
|
|
23
|
-
/** If set and the saved body exceeds this many lines, emit a breakdown advisory. */
|
|
24
|
-
oversizeWarnLines?: number;
|
|
31
|
+
export declare function readArtifact(kind: ArtifactKind, name: string): ArtifactRecord;
|
|
32
|
+
export interface ArtifactListItem {
|
|
33
|
+
name: string;
|
|
34
|
+
path: string;
|
|
35
|
+
updated_at: string;
|
|
25
36
|
}
|
|
26
|
-
export declare function
|
|
37
|
+
export declare function listArtifacts(kind: ArtifactKind): ArtifactListItem[];
|
package/dist/core/artifact.js
CHANGED
|
@@ -1,19 +1,27 @@
|
|
|
1
|
+
// Core artifact primitives for plan and spec subtrees.
|
|
2
|
+
// No commander, no spawn, no output helpers — pure data logic only.
|
|
3
|
+
// Old registerArtifactCommand (commander-based) is removed; callers ported to JSON I/O.
|
|
1
4
|
import { homedir } from 'node:os';
|
|
2
5
|
import { join, dirname } from 'node:path';
|
|
3
|
-
import {
|
|
4
|
-
import { writeFileSync } from 'node:fs';
|
|
6
|
+
import { writeFileSync, statSync } from 'node:fs';
|
|
5
7
|
import { CRTR_DIR_NAME } from '../types.js';
|
|
6
8
|
import { ensureDir, pathExists, readText, walkFiles } from './fs-utils.js';
|
|
7
|
-
import {
|
|
8
|
-
import { out, hint, jsonOut, handleError, info } from './output.js';
|
|
9
|
-
import { spawnSidePaneReview, countPanesInCurrentWindow, DEFAULT_PANE_OPTS, } from './spawn.js';
|
|
10
|
-
import { readConfig } from './config.js';
|
|
9
|
+
import { notFound, usage } from './errors.js';
|
|
11
10
|
export function mangleCwd(cwd = process.cwd()) {
|
|
12
11
|
return cwd.replace(/\//g, '-');
|
|
13
12
|
}
|
|
14
13
|
export function artifactsRoot(kind, cwd) {
|
|
15
14
|
return join(homedir(), CRTR_DIR_NAME, mangleCwd(cwd), kind);
|
|
16
15
|
}
|
|
16
|
+
/** Per-cwd interactions root, mirroring artifactsRoot construction.
|
|
17
|
+
* `~/.crouter/<mangled-cwd>/interactions/`. */
|
|
18
|
+
export function interactionsRoot(cwd) {
|
|
19
|
+
return join(homedir(), CRTR_DIR_NAME, mangleCwd(cwd), 'interactions');
|
|
20
|
+
}
|
|
21
|
+
/** Directory for one interaction. `id` is sanitized like an artifact name. */
|
|
22
|
+
export function interactionDir(id, cwd) {
|
|
23
|
+
return join(interactionsRoot(cwd), sanitizeName(id));
|
|
24
|
+
}
|
|
17
25
|
export function sanitizeName(raw) {
|
|
18
26
|
const trimmed = raw.trim().replace(/^\/+|\/+$/g, '');
|
|
19
27
|
if (trimmed === '')
|
|
@@ -29,232 +37,81 @@ export function sanitizeName(raw) {
|
|
|
29
37
|
export function artifactPath(kind, name, cwd) {
|
|
30
38
|
return join(artifactsRoot(kind, cwd), `${sanitizeName(name)}.md`);
|
|
31
39
|
}
|
|
32
|
-
|
|
33
|
-
|
|
34
|
-
|
|
35
|
-
|
|
36
|
-
|
|
37
|
-
|
|
38
|
-
|
|
39
|
-
|
|
40
|
-
|
|
41
|
-
|
|
42
|
-
|
|
43
|
-
|
|
40
|
+
/** Lines above this threshold trigger an oversize advisory in follow_up. */
|
|
41
|
+
export const OVERSIZE_WARN_LINES = 200;
|
|
42
|
+
/**
|
|
43
|
+
* Atomically write an artifact. Prepends a minimal frontmatter block when
|
|
44
|
+
* `meta` is non-empty so readers can extract structured fields without
|
|
45
|
+
* parsing the full body. Returns the written path and oversize status.
|
|
46
|
+
*/
|
|
47
|
+
export function saveArtifact(kind, name, body, meta = {}) {
|
|
48
|
+
const filePath = artifactPath(kind, name);
|
|
49
|
+
ensureDir(dirname(filePath));
|
|
50
|
+
const metaKeys = Object.keys(meta);
|
|
51
|
+
let content;
|
|
52
|
+
if (metaKeys.length > 0) {
|
|
53
|
+
const fm = '---\n' + metaKeys.map((k) => `${k}: ${meta[k]}`).join('\n') + '\n---\n';
|
|
54
|
+
content = fm + body;
|
|
44
55
|
}
|
|
45
|
-
|
|
46
|
-
|
|
47
|
-
stdio: ['ignore', 'pipe', 'pipe'],
|
|
48
|
-
});
|
|
49
|
-
if (result.error) {
|
|
50
|
-
const code = result.error.code;
|
|
51
|
-
if (code === 'ENOENT') {
|
|
52
|
-
hint('termrender not found on $PATH — install it to auto-open in tmux');
|
|
53
|
-
}
|
|
54
|
-
else {
|
|
55
|
-
hint(`termrender failed: ${result.error.message}`);
|
|
56
|
-
}
|
|
57
|
-
return;
|
|
56
|
+
else {
|
|
57
|
+
content = body;
|
|
58
58
|
}
|
|
59
|
-
|
|
60
|
-
|
|
61
|
-
|
|
62
|
-
|
|
59
|
+
const finalContent = content.endsWith('\n') ? content : content + '\n';
|
|
60
|
+
writeFileSync(filePath, finalContent, 'utf8');
|
|
61
|
+
const lineCount = finalContent.split('\n').length - 1;
|
|
62
|
+
return { path: filePath, oversize: lineCount > OVERSIZE_WARN_LINES, lineCount };
|
|
63
|
+
}
|
|
64
|
+
const FRONTMATTER_BLOCK_RE = /^---\s*\r?\n([\s\S]*?)\r?\n---\s*\r?\n?/;
|
|
65
|
+
function parseArtifactFrontmatter(source) {
|
|
66
|
+
const match = source.match(FRONTMATTER_BLOCK_RE);
|
|
67
|
+
if (!match)
|
|
68
|
+
return { fields: {}, body: source };
|
|
69
|
+
const raw = match[1];
|
|
70
|
+
const body = source.slice(match[0].length);
|
|
71
|
+
const fields = {};
|
|
72
|
+
for (const line of raw.split(/\r?\n/)) {
|
|
73
|
+
const colon = line.indexOf(':');
|
|
74
|
+
if (colon === -1)
|
|
75
|
+
continue;
|
|
76
|
+
const k = line.slice(0, colon).trim();
|
|
77
|
+
const v = line.slice(colon + 1).trim();
|
|
78
|
+
if (k !== '')
|
|
79
|
+
fields[k] = v;
|
|
63
80
|
}
|
|
64
|
-
|
|
65
|
-
if (paneId)
|
|
66
|
-
hint(`opened in tmux pane ${paneId} (live — edits to the file refresh the view)`);
|
|
81
|
+
return { fields, body };
|
|
67
82
|
}
|
|
68
|
-
|
|
69
|
-
const
|
|
70
|
-
|
|
71
|
-
|
|
83
|
+
export function readArtifact(kind, name) {
|
|
84
|
+
const filePath = artifactPath(kind, name);
|
|
85
|
+
if (!pathExists(filePath)) {
|
|
86
|
+
throw notFound(`${kind.slice(0, -1)} not found: ${name} (looked at ${filePath})`);
|
|
72
87
|
}
|
|
73
|
-
|
|
88
|
+
const raw = readText(filePath);
|
|
89
|
+
const { fields, body } = parseArtifactFrontmatter(raw);
|
|
90
|
+
const specField = fields['spec'];
|
|
91
|
+
return {
|
|
92
|
+
name,
|
|
93
|
+
path: filePath,
|
|
94
|
+
body,
|
|
95
|
+
spec: specField !== undefined ? specField : null,
|
|
96
|
+
};
|
|
74
97
|
}
|
|
75
|
-
function
|
|
98
|
+
export function listArtifacts(kind) {
|
|
76
99
|
const root = artifactsRoot(kind);
|
|
77
100
|
if (!pathExists(root))
|
|
78
101
|
return [];
|
|
79
|
-
|
|
80
|
-
|
|
81
|
-
.
|
|
82
|
-
|
|
83
|
-
export function registerArtifactCommand(program, opts) {
|
|
84
|
-
const { command, kind, promptFn, reviewer, oversizeWarnLines } = opts;
|
|
85
|
-
const saveCmd = program
|
|
86
|
-
.command(`${command} [content]`)
|
|
87
|
-
.description(`print the ${command} prompt, or save a ${command} with --name`)
|
|
88
|
-
.option('--name <name>', `save the ${command} under this name`);
|
|
89
|
-
if (reviewer !== undefined) {
|
|
90
|
-
saveCmd.option('--no-review', 'skip the auto-review step (use for trivial artifacts)');
|
|
91
|
-
for (const extra of reviewer.extraSaveOptions ?? []) {
|
|
92
|
-
saveCmd.option(extra.flag, extra.description);
|
|
93
|
-
}
|
|
94
|
-
}
|
|
95
|
-
saveCmd.action(async (content, options) => {
|
|
102
|
+
const files = walkFiles(root, (n) => n.endsWith('.md'));
|
|
103
|
+
const items = files.map((abs) => {
|
|
104
|
+
const name = abs.substring(root.length + 1).replace(/\.md$/, '');
|
|
105
|
+
let updated_at = '';
|
|
96
106
|
try {
|
|
97
|
-
|
|
98
|
-
if (content !== undefined) {
|
|
99
|
-
throw usage(`positional content requires --name (try \`crtr ${command} --name <name> ...\`)`);
|
|
100
|
-
}
|
|
101
|
-
out(promptFn(artifactsRoot(kind)));
|
|
102
|
-
return;
|
|
103
|
-
}
|
|
104
|
-
let body;
|
|
105
|
-
if (content !== undefined) {
|
|
106
|
-
body = content;
|
|
107
|
-
}
|
|
108
|
-
else if (!process.stdin.isTTY) {
|
|
109
|
-
body = await readStdin();
|
|
110
|
-
}
|
|
111
|
-
else {
|
|
112
|
-
throw usage(`no content provided. Pipe via stdin (heredoc) or pass as a positional arg:\n` +
|
|
113
|
-
` crtr ${command} --name <name> <<'EOF'\n <content>\n EOF`);
|
|
114
|
-
}
|
|
115
|
-
if (body.trim() === '') {
|
|
116
|
-
throw usage('content is empty');
|
|
117
|
-
}
|
|
118
|
-
const filePath = artifactPath(kind, options.name);
|
|
119
|
-
ensureDir(dirname(filePath));
|
|
120
|
-
const finalBody = body.endsWith('\n') ? body : body + '\n';
|
|
121
|
-
writeFileSync(filePath, finalBody, 'utf8');
|
|
122
|
-
out(filePath);
|
|
123
|
-
if (inTmux())
|
|
124
|
-
openInTmuxPane(filePath);
|
|
125
|
-
if (oversizeWarnLines !== undefined) {
|
|
126
|
-
const lineCount = finalBody.split('\n').length - 1;
|
|
127
|
-
if (lineCount > oversizeWarnLines) {
|
|
128
|
-
out('');
|
|
129
|
-
out('--- advisory ---');
|
|
130
|
-
out(`This ${command} is ${lineCount} lines (> ${oversizeWarnLines}). ` +
|
|
131
|
-
`Consider splitting it into multiple ${command}s under a shared prefix ` +
|
|
132
|
-
`(e.g. \`crtr ${command} --name ${options.name}/part-1\`, ` +
|
|
133
|
-
`\`crtr ${command} --name ${options.name}/part-2\`) and linking them ` +
|
|
134
|
-
`from a brief index ${command} at \`${options.name}\`. ` +
|
|
135
|
-
`Smaller ${command}s are easier to execute and review.`);
|
|
136
|
-
}
|
|
137
|
-
}
|
|
138
|
-
if (reviewer !== undefined) {
|
|
139
|
-
const reviewSkipped = options.review === false;
|
|
140
|
-
if (reviewSkipped) {
|
|
141
|
-
hint('review skipped (--no-review)');
|
|
142
|
-
}
|
|
143
|
-
else if (!inTmux()) {
|
|
144
|
-
hint('review skipped (not in tmux — auto-review needs a tmux pane)');
|
|
145
|
-
}
|
|
146
|
-
else {
|
|
147
|
-
await runReviewer(filePath, reviewer, options);
|
|
148
|
-
}
|
|
149
|
-
}
|
|
107
|
+
updated_at = statSync(abs).mtime.toISOString();
|
|
150
108
|
}
|
|
151
|
-
catch
|
|
152
|
-
|
|
109
|
+
catch {
|
|
110
|
+
updated_at = new Date(0).toISOString();
|
|
153
111
|
}
|
|
112
|
+
return { name, path: abs, updated_at };
|
|
154
113
|
});
|
|
155
|
-
|
|
156
|
-
|
|
157
|
-
|
|
158
|
-
.description(`list ${kind} for the current directory`)
|
|
159
|
-
.option('--json', 'emit JSON')
|
|
160
|
-
.action(async (options) => {
|
|
161
|
-
try {
|
|
162
|
-
const names = listArtifactNames(kind);
|
|
163
|
-
if (options.json) {
|
|
164
|
-
jsonOut({
|
|
165
|
-
[kind]: names.map((name) => ({ name, path: artifactPath(kind, name) })),
|
|
166
|
-
});
|
|
167
|
-
return;
|
|
168
|
-
}
|
|
169
|
-
for (const n of names)
|
|
170
|
-
out(n);
|
|
171
|
-
}
|
|
172
|
-
catch (e) {
|
|
173
|
-
handleError(e, { json: options.json });
|
|
174
|
-
}
|
|
175
|
-
});
|
|
176
|
-
cmd
|
|
177
|
-
.command('show <name>')
|
|
178
|
-
.description(`print the body of a ${command}`)
|
|
179
|
-
.action(async (name) => {
|
|
180
|
-
try {
|
|
181
|
-
const filePath = artifactPath(kind, name);
|
|
182
|
-
if (!pathExists(filePath)) {
|
|
183
|
-
throw notFound(`${command} not found: ${name} (looked at ${filePath})`);
|
|
184
|
-
}
|
|
185
|
-
out(readText(filePath));
|
|
186
|
-
hint(`crtr: edit with \`crtr ${command} edit ${name}\` (${filePath})`);
|
|
187
|
-
}
|
|
188
|
-
catch (e) {
|
|
189
|
-
handleError(e);
|
|
190
|
-
}
|
|
191
|
-
});
|
|
192
|
-
cmd
|
|
193
|
-
.command('path [name]')
|
|
194
|
-
.description(`print the absolute path of a ${command} or the ${kind} directory`)
|
|
195
|
-
.action(async (name) => {
|
|
196
|
-
try {
|
|
197
|
-
out(name === undefined ? artifactsRoot(kind) : artifactPath(kind, name));
|
|
198
|
-
}
|
|
199
|
-
catch (e) {
|
|
200
|
-
handleError(e);
|
|
201
|
-
}
|
|
202
|
-
});
|
|
203
|
-
cmd
|
|
204
|
-
.command('edit <name>')
|
|
205
|
-
.description(`open the ${command} in $EDITOR`)
|
|
206
|
-
.action(async (name) => {
|
|
207
|
-
try {
|
|
208
|
-
const filePath = artifactPath(kind, name);
|
|
209
|
-
if (!pathExists(filePath)) {
|
|
210
|
-
throw notFound(`${command} not found: ${name} (looked at ${filePath})`);
|
|
211
|
-
}
|
|
212
|
-
const editor = process.env.EDITOR !== undefined && process.env.EDITOR !== ''
|
|
213
|
-
? process.env.EDITOR
|
|
214
|
-
: 'vi';
|
|
215
|
-
await new Promise((resolve, reject) => {
|
|
216
|
-
const child = spawn(editor, [filePath], { stdio: 'inherit' });
|
|
217
|
-
child.on('error', (e) => reject(general(`failed to launch editor: ${e.message}`)));
|
|
218
|
-
child.on('close', (code) => {
|
|
219
|
-
if (code !== 0 && code !== null) {
|
|
220
|
-
reject(general(`editor exited with code ${code}`));
|
|
221
|
-
}
|
|
222
|
-
else {
|
|
223
|
-
resolve();
|
|
224
|
-
}
|
|
225
|
-
});
|
|
226
|
-
});
|
|
227
|
-
}
|
|
228
|
-
catch (e) {
|
|
229
|
-
handleError(e);
|
|
230
|
-
}
|
|
231
|
-
});
|
|
232
|
-
}
|
|
233
|
-
async function runReviewer(artifactFilePath, reviewer, options) {
|
|
234
|
-
const extraOpts = {};
|
|
235
|
-
for (const def of reviewer.extraSaveOptions ?? []) {
|
|
236
|
-
const value = options[def.key];
|
|
237
|
-
if (typeof value === 'string')
|
|
238
|
-
extraOpts[def.key] = value;
|
|
239
|
-
}
|
|
240
|
-
info('spawning reviewer in side pane (10-min budget) — output below…');
|
|
241
|
-
const result = await spawnSidePaneReview({
|
|
242
|
-
prompt: reviewer.buildPrompt(artifactFilePath, extraOpts),
|
|
243
|
-
cwd: process.cwd(),
|
|
244
|
-
timeoutMs: DEFAULT_PANE_OPTS.timeoutMs,
|
|
245
|
-
});
|
|
246
|
-
out('');
|
|
247
|
-
out('--- review ---');
|
|
248
|
-
if (result.status === 'submitted') {
|
|
249
|
-
out(result.content.trim());
|
|
250
|
-
}
|
|
251
|
-
else if (result.status === 'timeout') {
|
|
252
|
-
out('REVIEW_TIMEOUT: reviewer did not submit within the 10-minute budget.');
|
|
253
|
-
}
|
|
254
|
-
else if (result.status === 'pane-closed') {
|
|
255
|
-
out('REVIEW_ABORTED: reviewer pane closed before submission.');
|
|
256
|
-
}
|
|
257
|
-
else {
|
|
258
|
-
out(`REVIEW_FAILED: ${result.content}`);
|
|
259
|
-
}
|
|
114
|
+
// Sort ascending by name (stable key for pagination cursor).
|
|
115
|
+
items.sort((a, b) => (a.name < b.name ? -1 : a.name > b.name ? 1 : 0));
|
|
116
|
+
return items;
|
|
260
117
|
}
|
package/dist/core/auto-update.js
CHANGED
|
@@ -2,7 +2,7 @@ import { spawn } from 'node:child_process';
|
|
|
2
2
|
import { readConfig, readState, updateState } from './config.js';
|
|
3
3
|
import { nowIso } from './fs-utils.js';
|
|
4
4
|
import { info } from './output.js';
|
|
5
|
-
import { selfCheck, contentCheck } from '
|
|
5
|
+
import { selfCheck, contentCheck } from './self-update.js';
|
|
6
6
|
const HOUR_MS = 60 * 60 * 1000;
|
|
7
7
|
const SKIP_SUBCOMMANDS = new Set([
|
|
8
8
|
'update',
|
|
@@ -27,7 +27,7 @@ function withinInterval(lastIso, intervalHours) {
|
|
|
27
27
|
return Date.now() - last < intervalHours * HOUR_MS;
|
|
28
28
|
}
|
|
29
29
|
function spawnDetachedSelfUpdate() {
|
|
30
|
-
const child = spawn('npm', ['i', '-g', '@crouton-kit/
|
|
30
|
+
const child = spawn('npm', ['i', '-g', '@crouton-kit/crouter@latest'], {
|
|
31
31
|
detached: true,
|
|
32
32
|
stdio: 'ignore',
|
|
33
33
|
});
|
|
@@ -63,14 +63,21 @@ export function maybeAutoUpdate(argv) {
|
|
|
63
63
|
s.last_self_check = nowIso();
|
|
64
64
|
});
|
|
65
65
|
if (crtr === 'notify') {
|
|
66
|
-
selfCheck();
|
|
66
|
+
const r = selfCheck();
|
|
67
|
+
if (r !== null && r.latest !== r.current) {
|
|
68
|
+
process.stderr.write(`crtr: v${r.latest} available (current ${r.current}) — run \`crtr sys update\`\n`);
|
|
69
|
+
}
|
|
67
70
|
}
|
|
68
71
|
else if (crtr === 'apply') {
|
|
69
72
|
info('applying self-update in background');
|
|
70
73
|
spawnDetachedSelfUpdate();
|
|
71
74
|
}
|
|
72
75
|
if (content === 'notify') {
|
|
73
|
-
contentCheck();
|
|
76
|
+
const stale = contentCheck().filter((e) => !e.up_to_date && !e.unreachable);
|
|
77
|
+
if (stale.length > 0) {
|
|
78
|
+
const list = stale.map((e) => `${e.kind} ${e.name}`).join(', ');
|
|
79
|
+
process.stderr.write(`crtr: updates available for ${list} — run \`crtr sys update\`\n`);
|
|
80
|
+
}
|
|
74
81
|
}
|
|
75
82
|
else if (content === 'apply') {
|
|
76
83
|
info('applying content updates in background');
|