@crouton-kit/crouter 0.2.6 → 0.3.1

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (79) hide show
  1. package/dist/builtin-skills/skills/crouter-development/marketplaces/SKILL.md +9 -9
  2. package/dist/builtin-skills/skills/crouter-development/plugins/SKILL.md +19 -19
  3. package/dist/cli.js +42 -37
  4. package/dist/commands/__tests__/human.test.d.ts +1 -0
  5. package/dist/commands/__tests__/human.test.js +214 -0
  6. package/dist/commands/__tests__/skill.test.d.ts +1 -0
  7. package/dist/commands/__tests__/skill.test.js +287 -0
  8. package/dist/commands/debug.d.ts +3 -0
  9. package/dist/commands/debug.js +179 -0
  10. package/dist/commands/flow.d.ts +2 -0
  11. package/dist/commands/flow.js +24 -0
  12. package/dist/commands/human.d.ts +2 -0
  13. package/dist/commands/human.js +480 -0
  14. package/dist/commands/job.d.ts +2 -0
  15. package/dist/commands/job.js +669 -0
  16. package/dist/commands/pkg.d.ts +2 -0
  17. package/dist/commands/pkg.js +1021 -0
  18. package/dist/commands/plan.d.ts +4 -2
  19. package/dist/commands/plan.js +306 -22
  20. package/dist/commands/skill.d.ts +2 -2
  21. package/dist/commands/skill.js +607 -456
  22. package/dist/commands/spec.d.ts +3 -2
  23. package/dist/commands/spec.js +283 -10
  24. package/dist/commands/sys.d.ts +2 -0
  25. package/dist/commands/sys.js +712 -0
  26. package/dist/core/__tests__/argv-parser.test.d.ts +1 -0
  27. package/dist/core/__tests__/argv-parser.test.js +199 -0
  28. package/dist/core/__tests__/flow-leaves.test.d.ts +1 -0
  29. package/dist/core/__tests__/flow-leaves.test.js +248 -0
  30. package/dist/core/__tests__/job.test.d.ts +1 -0
  31. package/dist/core/__tests__/job.test.js +346 -0
  32. package/dist/core/__tests__/pkg.test.d.ts +1 -0
  33. package/dist/core/__tests__/pkg.test.js +218 -0
  34. package/dist/core/__tests__/sys.test.d.ts +1 -0
  35. package/dist/core/__tests__/sys.test.js +208 -0
  36. package/dist/core/artifact.d.ts +29 -18
  37. package/dist/core/artifact.js +78 -221
  38. package/dist/core/auto-update.js +11 -3
  39. package/dist/core/command.d.ts +36 -0
  40. package/dist/core/command.js +287 -0
  41. package/dist/core/errors.d.ts +3 -0
  42. package/dist/core/errors.js +5 -0
  43. package/dist/core/fs-utils.d.ts +1 -0
  44. package/dist/core/fs-utils.js +4 -0
  45. package/dist/core/help.d.ts +98 -0
  46. package/dist/core/help.js +163 -0
  47. package/dist/core/io.d.ts +29 -0
  48. package/dist/core/io.js +83 -0
  49. package/dist/core/jobs.d.ts +87 -0
  50. package/dist/core/jobs.js +353 -0
  51. package/dist/core/pagination.d.ts +33 -0
  52. package/dist/core/pagination.js +89 -0
  53. package/dist/core/self-update.d.ts +21 -0
  54. package/dist/{commands/update.js → core/self-update.js} +28 -63
  55. package/dist/core/spawn.d.ts +47 -65
  56. package/dist/core/spawn.js +78 -228
  57. package/dist/prompts/agent.d.ts +10 -5
  58. package/dist/prompts/agent.js +51 -74
  59. package/dist/prompts/debug.d.ts +8 -0
  60. package/dist/prompts/debug.js +37 -0
  61. package/dist/prompts/review.js +4 -11
  62. package/dist/prompts/skill.d.ts +0 -1
  63. package/dist/prompts/skill.js +95 -149
  64. package/package.json +4 -2
  65. package/dist/commands/agent.d.ts +0 -2
  66. package/dist/commands/agent.js +0 -265
  67. package/dist/commands/config.d.ts +0 -2
  68. package/dist/commands/config.js +0 -146
  69. package/dist/commands/doctor.d.ts +0 -2
  70. package/dist/commands/doctor.js +0 -268
  71. package/dist/commands/marketplace.d.ts +0 -2
  72. package/dist/commands/marketplace.js +0 -365
  73. package/dist/commands/plugin.d.ts +0 -2
  74. package/dist/commands/plugin.js +0 -367
  75. package/dist/commands/update.d.ts +0 -4
  76. package/dist/prompts/plan.d.ts +0 -1
  77. package/dist/prompts/plan.js +0 -175
  78. package/dist/prompts/spec.d.ts +0 -1
  79. 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
+ });
@@ -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
- export declare function inTmux(): boolean;
8
- export declare function openInTmuxPane(path: string): void;
9
- export interface SaveOptionDef {
10
- flag: string;
11
- description: string;
12
- key: string;
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
- export interface ReviewerConfig {
15
- buildPrompt: (artifactPath: string, opts: Record<string, string | undefined>) => string;
16
- extraSaveOptions?: SaveOptionDef[];
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 interface RegisterArtifactOptions {
19
- command: 'plan' | 'spec';
20
- kind: ArtifactKind;
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;
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 registerArtifactCommand(program: Command, opts: RegisterArtifactOptions): void;
37
+ export declare function listArtifacts(kind: ArtifactKind): ArtifactListItem[];
@@ -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 { spawn, spawnSync } from 'node:child_process';
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 { usage, notFound, general } from './errors.js';
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
- export function inTmux() {
33
- return Boolean(process.env.TMUX);
34
- }
35
- export function openInTmuxPane(path) {
36
- // Always pass --watch so the pane live-updates when the agent edits the
37
- // file. The watcher re-detects width on resize and survives parse errors.
38
- const args = ['--tmux', '--watch'];
39
- // If the current tmux window is already at the configured pane budget,
40
- // open in a new window instead of cramping the existing split further.
41
- const maxPanes = readConfig('user').max_panes_per_window;
42
- if (countPanesInCurrentWindow() >= maxPanes) {
43
- args.push('--tmux-new-window');
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
- args.push(path);
46
- const result = spawnSync('termrender', args, {
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
- if (result.status !== 0) {
60
- const stderrText = result.stderr.toString().trim();
61
- hint(`termrender exited with ${result.status}${stderrText ? `: ${stderrText}` : ''}`);
62
- return;
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
- const paneId = result.stdout.toString().trim();
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
- async function readStdin() {
69
- const chunks = [];
70
- for await (const chunk of process.stdin) {
71
- chunks.push(chunk);
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
- return Buffer.concat(chunks).toString('utf8');
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 listArtifactNames(kind) {
98
+ export function listArtifacts(kind) {
76
99
  const root = artifactsRoot(kind);
77
100
  if (!pathExists(root))
78
101
  return [];
79
- return walkFiles(root, (n) => n.endsWith('.md'))
80
- .map((abs) => abs.substring(root.length + 1).replace(/\.md$/, ''))
81
- .sort();
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
- if (options.name === undefined) {
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 (e) {
152
- handleError(e);
109
+ catch {
110
+ updated_at = new Date(0).toISOString();
153
111
  }
112
+ return { name, path: abs, updated_at };
154
113
  });
155
- const cmd = saveCmd;
156
- cmd
157
- .command('list')
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
  }
@@ -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 '../commands/update.js';
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',
@@ -63,14 +63,22 @@ 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 entries = contentCheck();
77
+ for (const e of entries) {
78
+ if (!e.up_to_date && !e.unreachable) {
79
+ process.stderr.write(`crtr: ${e.kind} ${e.name} has updates available — run \`crtr sys update\`\n`);
80
+ }
81
+ }
74
82
  }
75
83
  else if (content === 'apply') {
76
84
  info('applying content updates in background');