@north-light/crouter 0.3.169 → 0.3.170
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.
|
@@ -8,12 +8,12 @@ import { InputError } from '../../core/io.js';
|
|
|
8
8
|
import { cliClient } from '../api-client.js';
|
|
9
9
|
import { nodeDir } from '../../core/canvas/paths.js';
|
|
10
10
|
import { mkdirSync, existsSync } from 'node:fs';
|
|
11
|
-
import { isAbsolute, join, resolve } from 'node:path';
|
|
11
|
+
import { dirname, isAbsolute, join, resolve } from 'node:path';
|
|
12
12
|
import { randomBytes, randomUUID } from 'node:crypto';
|
|
13
13
|
import { tmuxServerReachable } from '../../core/spawn.js';
|
|
14
14
|
import { BrokerClient, BrokerUnavailableError } from '../../core/broker-client/index.js';
|
|
15
15
|
import { markStopSignal } from '../../core/runtime/stop-signals.js';
|
|
16
|
-
import { validateDeck, notifyDeck, atomicWriteJson, submitDeck, submitReview, display, } from '@crouton-kit/humanloop';
|
|
16
|
+
import { validateDeck, resolveDeckBodyPaths, notifyDeck, atomicWriteJson, submitDeck, submitReview, display, } from '@crouton-kit/humanloop';
|
|
17
17
|
import { BODY_PATH_CONTRACT, DECK_SCHEMA_HINT, registerCrouterRoot, resolveMaxPanes } from './shared.js';
|
|
18
18
|
import { inboxOpenInstruction, inboxPopupHint } from '../../core/keybindings/index.js';
|
|
19
19
|
/** The asking node's id, or null when run from a bare shell (no parent to route to). */
|
|
@@ -54,14 +54,22 @@ function requireSubtitle(subtitle, field = 'subtitle') {
|
|
|
54
54
|
next: 'Write one plain-English sentence stating the decision, recommendation or status, and stakes.',
|
|
55
55
|
});
|
|
56
56
|
}
|
|
57
|
-
function validateTitledDeck(input) {
|
|
57
|
+
function validateTitledDeck(input, deckDirectory) {
|
|
58
58
|
const title = typeof input === 'object' && input !== null
|
|
59
59
|
? input.title
|
|
60
60
|
: undefined;
|
|
61
61
|
if (typeof title !== 'string' || title.trim() === '') {
|
|
62
62
|
throw new Error('deck title is required');
|
|
63
63
|
}
|
|
64
|
-
|
|
64
|
+
const deck = validateDeck(input);
|
|
65
|
+
if (deckDirectory === undefined)
|
|
66
|
+
return deck;
|
|
67
|
+
for (const interaction of deck.interactions) {
|
|
68
|
+
if (interaction.bodyPath !== undefined && isAbsolute(interaction.bodyPath)) {
|
|
69
|
+
throw new Error('bodyPath must be relative to the deck JSON; absolute paths are unsupported');
|
|
70
|
+
}
|
|
71
|
+
}
|
|
72
|
+
return resolveDeckBodyPaths(deck, deckDirectory);
|
|
65
73
|
}
|
|
66
74
|
function buildHumanAskDeck(title, subtitle, question) {
|
|
67
75
|
return validateTitledDeck({
|
|
@@ -172,7 +180,7 @@ export const humanAsk = defineLeaf({
|
|
|
172
180
|
`Returns immediately; nothing opens on screen. The human answers from the inbox popup (${inboxPopupHint()}) on their own time.`,
|
|
173
181
|
],
|
|
174
182
|
},
|
|
175
|
-
run: async (input) => {
|
|
183
|
+
run: async (input, context) => {
|
|
176
184
|
// Optional stdin reaches the leaf as an empty string when omitted, including
|
|
177
185
|
// when --context-file is supplied. Keep the raw non-empty bytes untouched.
|
|
178
186
|
const titleRaw = input['title'];
|
|
@@ -182,6 +190,7 @@ export const humanAsk = defineLeaf({
|
|
|
182
190
|
const subtitleRaw = input['subtitle'];
|
|
183
191
|
const subtitle = typeof subtitleRaw === 'string' && subtitleRaw.trim() !== '' ? subtitleRaw.trim() : undefined;
|
|
184
192
|
const deckInput = input['deck'];
|
|
193
|
+
const deckPath = context?.contextFilePaths.get('deck');
|
|
185
194
|
if (deckInput !== undefined && (question !== undefined || titleRaw !== undefined || subtitleRaw !== undefined)) {
|
|
186
195
|
throw new InputError({
|
|
187
196
|
error: 'bad_invocation',
|
|
@@ -205,7 +214,7 @@ export const humanAsk = defineLeaf({
|
|
|
205
214
|
deck = buildHumanAskDeck(simpleAsk.title, simpleAsk.subtitle, simpleAsk.question);
|
|
206
215
|
}
|
|
207
216
|
else {
|
|
208
|
-
deck = validateTitledDeck(deckInput);
|
|
217
|
+
deck = validateTitledDeck(deckInput, deckPath === undefined ? undefined : dirname(deckPath));
|
|
209
218
|
}
|
|
210
219
|
deck = canonicalizeAskKinds(deck);
|
|
211
220
|
}
|
|
@@ -16,17 +16,20 @@ import { test, describe, before, after, beforeEach, afterEach } from 'node:test'
|
|
|
16
16
|
import assert from 'node:assert/strict';
|
|
17
17
|
import { mkdtempSync, mkdirSync, writeFileSync, realpathSync, readFileSync, existsSync, symlinkSync } from 'node:fs';
|
|
18
18
|
import { tmpdir } from 'node:os';
|
|
19
|
-
import { dirname, join, basename } from 'node:path';
|
|
19
|
+
import { dirname, join, resolve, basename } from 'node:path';
|
|
20
|
+
import { Readable } from 'node:stream';
|
|
20
21
|
import { registerInboxRoot, submitDeck, validateDeck, claimTicket, completeDeck, cancelTicket, readTicketResult, atomicWriteJson, } from '@crouton-kit/humanloop';
|
|
21
22
|
import { spawnNode } from '../runtime/nodes.js';
|
|
22
23
|
import { getNode, listNodes, closeDb } from '../canvas/index.js';
|
|
23
24
|
import { readInboxSince } from '../feed/inbox.js';
|
|
24
25
|
import { humanAsk, humanReview, humanNotify } from '../../commands/human/prompts.js';
|
|
26
|
+
import { runCli } from '../command.js';
|
|
25
27
|
import { resolveRef } from '../canvas/history.js';
|
|
26
28
|
import { humanDeliver } from '../../commands/human/queue.js';
|
|
27
29
|
import { createApiServer } from '../../daemon/api/server.js';
|
|
28
30
|
import { trustedCrouterVisualHandler } from '../../daemon/api/handlers/human.js';
|
|
29
|
-
import { apiSocketPath } from '../canvas/paths.js';
|
|
31
|
+
import { apiSocketPath, nodeDir } from '../canvas/paths.js';
|
|
32
|
+
import { resolveRoot } from '../../build-root.js';
|
|
30
33
|
// ---------------------------------------------------------------------------
|
|
31
34
|
// Isolated environment
|
|
32
35
|
// ---------------------------------------------------------------------------
|
|
@@ -57,6 +60,28 @@ const ISO = '2026-07-10T12:00:00.000Z';
|
|
|
57
60
|
function benignHandler() {
|
|
58
61
|
return { command: process.execPath, args: ['-e', ''] };
|
|
59
62
|
}
|
|
63
|
+
async function withPipedStdin(body, fn) {
|
|
64
|
+
const original = process.stdin;
|
|
65
|
+
const stdin = Readable.from([Buffer.from(body)]);
|
|
66
|
+
Object.defineProperty(stdin, 'isTTY', { value: false, configurable: true });
|
|
67
|
+
Object.defineProperty(process, 'stdin', { value: stdin, configurable: true });
|
|
68
|
+
try {
|
|
69
|
+
return await fn();
|
|
70
|
+
}
|
|
71
|
+
finally {
|
|
72
|
+
Object.defineProperty(process, 'stdin', { value: original, configurable: true });
|
|
73
|
+
}
|
|
74
|
+
}
|
|
75
|
+
async function withMutedStdout(fn) {
|
|
76
|
+
const originalWrite = process.stdout.write;
|
|
77
|
+
process.stdout.write = (() => true);
|
|
78
|
+
try {
|
|
79
|
+
return await fn();
|
|
80
|
+
}
|
|
81
|
+
finally {
|
|
82
|
+
process.stdout.write = originalWrite;
|
|
83
|
+
}
|
|
84
|
+
}
|
|
60
85
|
/** Register `dir` (created fresh) as an owner-'crouter' root and return its
|
|
61
86
|
* canonical path. */
|
|
62
87
|
function registerRoot(dir) {
|
|
@@ -172,6 +197,41 @@ describe('enqueue is producer-only', () => {
|
|
|
172
197
|
}));
|
|
173
198
|
const structuredDeck = JSON.parse(readFileSync(join(structured['dir'], 'deck.json'), 'utf8'));
|
|
174
199
|
assert.deepEqual(structuredDeck.interactions.map((interaction) => interaction.kind), ['decision', 'context']);
|
|
200
|
+
const deckDir = mkdtempSync(join(tmpdir(), 'crtr-context-file-deck-'));
|
|
201
|
+
assert.notEqual(resolve(process.cwd()), deckDir, 'the CLI caller cwd must differ from the deck directory');
|
|
202
|
+
const deckPath = join(deckDir, 'deck.json');
|
|
203
|
+
writeFileSync(join(deckDir, 'body.md'), 'The body came from beside the deck JSON.');
|
|
204
|
+
writeFileSync(deckPath, JSON.stringify({
|
|
205
|
+
title: 'Context file body',
|
|
206
|
+
interactions: [{
|
|
207
|
+
id: 'decision',
|
|
208
|
+
title: 'Use the deck',
|
|
209
|
+
subtitle: 'Use the deck body so the person can decide with its context.',
|
|
210
|
+
bodyPath: 'body.md',
|
|
211
|
+
options: [{ id: 'yes', label: 'Yes' }],
|
|
212
|
+
}],
|
|
213
|
+
}));
|
|
214
|
+
const nodesBeforeContextFile = new Set(listNodes().map((node) => node.node_id));
|
|
215
|
+
await withMutedStdout(() => withPipedStdin('', async () => {
|
|
216
|
+
await runCli(await resolveRoot('human'), ['node', 'crtr', 'human', 'ask', '--context-file', deckPath]);
|
|
217
|
+
}));
|
|
218
|
+
const contextFileBridge = listNodes().find((node) => !nodesBeforeContextFile.has(node.node_id));
|
|
219
|
+
assert.ok(contextFileBridge, 'the real CLI invocation created a human bridge');
|
|
220
|
+
const contextFileDeck = JSON.parse(readFileSync(join(nodeDir(contextFileBridge.node_id), 'deck.json'), 'utf8'));
|
|
221
|
+
assert.equal(contextFileDeck.interactions[0]?.body, 'The body came from beside the deck JSON.');
|
|
222
|
+
assert.equal(contextFileDeck.interactions[0]?.bodyPath, undefined);
|
|
223
|
+
await assert.rejects(() => humanAsk.run({
|
|
224
|
+
deck: {
|
|
225
|
+
title: 'Absolute path',
|
|
226
|
+
interactions: [{
|
|
227
|
+
id: 'decision',
|
|
228
|
+
title: 'Reject absolute path',
|
|
229
|
+
subtitle: 'Reject the invalid path so the documented deck boundary remains intact.',
|
|
230
|
+
bodyPath: join(deckDir, 'body.md'),
|
|
231
|
+
options: [{ id: 'yes', label: 'Yes' }],
|
|
232
|
+
}],
|
|
233
|
+
},
|
|
234
|
+
}, { providedParams: new Set(['deck']), contextFilePaths: new Map([['deck', deckPath]]) }), /absolute paths are unsupported/);
|
|
175
235
|
const rc = JSON.parse(readFileSync(join(r['dir'], 'run.json'), 'utf8'));
|
|
176
236
|
assert.equal(rc['mode'], 'ask');
|
|
177
237
|
assert.equal(rc['job_id'], r['job_id']);
|
package/dist/core/command.d.ts
CHANGED
|
@@ -12,6 +12,11 @@ export interface LeafRunContext {
|
|
|
12
12
|
* as a positional argument, or explicitly passed; absence means TTY with no
|
|
13
13
|
* pipe (truly absent). */
|
|
14
14
|
readonly providedParams: ReadonlySet<string>;
|
|
15
|
+
/** Absolute source paths for JSON values supplied through `--context-file`,
|
|
16
|
+
* keyed by their camel-cased parameter name. The parsed JSON remains the
|
|
17
|
+
* leaf input; this metadata preserves its source directory for values that
|
|
18
|
+
* reference adjacent files. */
|
|
19
|
+
readonly contextFilePaths: ReadonlyMap<string, string>;
|
|
15
20
|
}
|
|
16
21
|
/** Opt-in flag that surfaces a node as an editor slash command (a pi prompt
|
|
17
22
|
* template / Claude Code command). When set, the bootstrap auto-writes a
|
|
@@ -149,6 +154,9 @@ export interface ParseArgvOptions {
|
|
|
149
154
|
* Called once with a ReadonlySet of parameter names that were supplied by
|
|
150
155
|
* the caller (distinct from defaulted or missing values). */
|
|
151
156
|
onProvidedParams?: (provided: ReadonlySet<string>) => void;
|
|
157
|
+
/** Optional collector for absolute source paths of JSON values supplied via
|
|
158
|
+
* `--context-file`, keyed by their camel-cased parameter name. */
|
|
159
|
+
onContextFilePaths?: (paths: ReadonlyMap<string, string>) => void;
|
|
152
160
|
/** The walked command path (e.g. `['push','final']`), supplied by the
|
|
153
161
|
* dispatcher. Used only to name the exact leaf in a schema-class error's
|
|
154
162
|
* `Next:` line, which mandates reading that leaf's `-h`. */
|
package/dist/core/command.js
CHANGED
|
@@ -14,6 +14,7 @@ import { readFileSync } from 'node:fs';
|
|
|
14
14
|
import { spawnSync } from 'node:child_process';
|
|
15
15
|
import { constants as osConstants } from 'node:os';
|
|
16
16
|
import { isatty } from 'node:tty';
|
|
17
|
+
import { resolve } from 'node:path';
|
|
17
18
|
// ---------------------------------------------------------------------------
|
|
18
19
|
// Factory functions
|
|
19
20
|
// ---------------------------------------------------------------------------
|
|
@@ -268,6 +269,7 @@ function flagNameToKey(name) {
|
|
|
268
269
|
export async function parseArgv(params, tokens, options) {
|
|
269
270
|
const result = {};
|
|
270
271
|
const provided = new Set(); // Track explicitly-provided parameter names
|
|
272
|
+
const contextFilePaths = new Map();
|
|
271
273
|
// Index params by kind for quick lookup
|
|
272
274
|
const positionalParam = params.find((p) => p.kind === 'positional');
|
|
273
275
|
const stdinParam = params.find((p) => p.kind === 'stdin');
|
|
@@ -332,6 +334,7 @@ export async function parseArgv(params, tokens, options) {
|
|
|
332
334
|
}
|
|
333
335
|
const contextFileKey = flagNameToKey(contextFileParam.name);
|
|
334
336
|
result[contextFileKey] = parsed;
|
|
337
|
+
contextFilePaths.set(contextFileKey, resolve(pathVal));
|
|
335
338
|
provided.add(contextFileKey);
|
|
336
339
|
i++;
|
|
337
340
|
continue;
|
|
@@ -539,6 +542,9 @@ export async function parseArgv(params, tokens, options) {
|
|
|
539
542
|
if (options?.onProvidedParams !== undefined) {
|
|
540
543
|
options.onProvidedParams(provided);
|
|
541
544
|
}
|
|
545
|
+
if (options?.onContextFilePaths !== undefined) {
|
|
546
|
+
options.onContextFilePaths(contextFilePaths);
|
|
547
|
+
}
|
|
542
548
|
return result;
|
|
543
549
|
}
|
|
544
550
|
export async function runCli(root, argv, options) {
|
|
@@ -618,13 +624,17 @@ export async function runCli(root, argv, options) {
|
|
|
618
624
|
const leaf = node;
|
|
619
625
|
const params = leaf.help.params !== undefined ? leaf.help.params : [];
|
|
620
626
|
let providedParams = new Set();
|
|
627
|
+
let contextFilePaths = new Map();
|
|
621
628
|
const input = await parseArgv(params, remaining, {
|
|
622
629
|
leafPath: path,
|
|
623
630
|
onProvidedParams: (provided) => {
|
|
624
631
|
providedParams = provided;
|
|
625
632
|
},
|
|
633
|
+
onContextFilePaths: (paths) => {
|
|
634
|
+
contextFilePaths = paths;
|
|
635
|
+
},
|
|
626
636
|
});
|
|
627
|
-
const context = { providedParams };
|
|
637
|
+
const context = { providedParams, contextFilePaths };
|
|
628
638
|
const result = await operationIdContext.fresh(() => leaf.run(input, context));
|
|
629
639
|
if (result !== undefined && result !== null) {
|
|
630
640
|
if (isJsonOutput()) {
|
package/package.json
CHANGED
package/runtime.lock.json
CHANGED
|
@@ -1,12 +1,12 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "@north-light/crouter",
|
|
3
|
-
"version": "0.3.
|
|
3
|
+
"version": "0.3.170",
|
|
4
4
|
"lockfileVersion": 3,
|
|
5
5
|
"requires": true,
|
|
6
6
|
"packages": {
|
|
7
7
|
"": {
|
|
8
8
|
"name": "@north-light/crouter",
|
|
9
|
-
"version": "0.3.
|
|
9
|
+
"version": "0.3.170",
|
|
10
10
|
"hasInstallScript": true,
|
|
11
11
|
"license": "MIT",
|
|
12
12
|
"dependencies": {
|