@lingjingai/scriptctl 0.5.0 → 0.6.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.
- package/README.md +3 -3
- package/dist/cli.js +199 -112
- package/dist/cli.js.map +1 -1
- package/dist/common.d.ts +1 -1
- package/dist/common.js +1 -1
- package/dist/common.js.map +1 -1
- package/dist/domain/script-core.d.ts +33 -0
- package/dist/domain/script-core.js +1065 -158
- package/dist/domain/script-core.js.map +1 -1
- package/dist/help-text.js +724 -226
- package/dist/help-text.js.map +1 -1
- package/dist/infra/providers.js +3 -3
- package/dist/infra/providers.js.map +1 -1
- package/dist/usecases/direct.d.ts +1 -0
- package/dist/usecases/direct.js +14 -2
- package/dist/usecases/direct.js.map +1 -1
- package/dist/usecases/doctor.js +1 -1
- package/dist/usecases/doctor.js.map +1 -1
- package/dist/usecases/script.d.ts +33 -7
- package/dist/usecases/script.js +1357 -404
- package/dist/usecases/script.js.map +1 -1
- package/package.json +1 -1
package/dist/usecases/script.js
CHANGED
|
@@ -3,7 +3,7 @@ import * as os from "node:os";
|
|
|
3
3
|
import * as path from "node:path";
|
|
4
4
|
import { randomUUID } from "node:crypto";
|
|
5
5
|
import { CliError, EXIT_INPUT, EXIT_NEEDS_AGENT, EXIT_OK, EXIT_RUNTIME, EXIT_USAGE, directDir, exists, readJson, readText, scriptJsonPath, sha256Text, writeJson, } from "../common.js";
|
|
6
|
-
import { applyPatchOperations, collectStateRefs, parseStateTarget, validateScript, } from "../domain/script-core.js";
|
|
6
|
+
import { applyPatchOperations, collectAssetRefs, collectStateRefs, parseAnyAddress, parseStateTarget, validateScript, PATCH_OP_SCHEMA, } from "../domain/script-core.js";
|
|
7
7
|
import { ScriptOutputApiError, ScriptOutputClient } from "../infra/script-output-api.js";
|
|
8
8
|
import { markMetadataConfidenceReviewed, readRunState, reviewBlockers, summarizeIssues, updateRunState, markPatched, } from "./direct.js";
|
|
9
9
|
function strOf(v) {
|
|
@@ -361,11 +361,23 @@ async function syncValidationResult(session, validation, revision) {
|
|
|
361
361
|
throw exc;
|
|
362
362
|
}
|
|
363
363
|
}
|
|
364
|
-
|
|
364
|
+
// Idempotency key for gateway writes. Returns the agent-provided --request-id
|
|
365
|
+
// when present, otherwise derives a deterministic key from (op, post-mutation
|
|
366
|
+
// script content). baseRevision is intentionally NOT in the hash: in a
|
|
367
|
+
// partial-success retry the client reloads at a new baseRevision but the
|
|
368
|
+
// post-mutation script is identical, so the hash matches and the gateway can
|
|
369
|
+
// dedupe. The cost of including baseRevision (an old version of this code)
|
|
370
|
+
// was that retries produced different ids and committed redundant no-op
|
|
371
|
+
// revisions to the gateway history.
|
|
372
|
+
function requestIdForScriptWrite(opts, op, payload) {
|
|
365
373
|
const explicit = strOf(opts["request_id"]).trim();
|
|
366
374
|
if (explicit)
|
|
367
375
|
return explicit;
|
|
368
|
-
|
|
376
|
+
const canonical = JSON.stringify(sortDeep({
|
|
377
|
+
op,
|
|
378
|
+
script: payload.script,
|
|
379
|
+
}));
|
|
380
|
+
return `scriptctl:${op}:${sha256Text(canonical)}`;
|
|
369
381
|
}
|
|
370
382
|
async function saveScriptSession(session, opts, op) {
|
|
371
383
|
if (!session.remote) {
|
|
@@ -374,12 +386,13 @@ async function saveScriptSession(session, opts, op) {
|
|
|
374
386
|
}
|
|
375
387
|
if (session.client === null)
|
|
376
388
|
throw new Error("remote script session missing client");
|
|
377
|
-
const
|
|
389
|
+
const baseRevision = Number(session.revision ?? 0);
|
|
390
|
+
const requestId = requestIdForScriptWrite(opts, op, { script: session.script });
|
|
378
391
|
let res;
|
|
379
392
|
try {
|
|
380
393
|
res = await session.client.replaceScript({
|
|
381
394
|
requestId,
|
|
382
|
-
baseRevision
|
|
395
|
+
baseRevision,
|
|
383
396
|
script: session.script,
|
|
384
397
|
source: "ctl",
|
|
385
398
|
});
|
|
@@ -512,55 +525,6 @@ export async function commandScriptValidate(opts) {
|
|
|
512
525
|
};
|
|
513
526
|
return [report, passed ? EXIT_OK : EXIT_NEEDS_AGENT];
|
|
514
527
|
}
|
|
515
|
-
// Parse the inspect --id value when target=action. Accepts:
|
|
516
|
-
// - "ep_001" / "1" / "01" → episode filter only
|
|
517
|
-
// - "ep_001/scn_001" → scene-level filter
|
|
518
|
-
// - "ep_001/scn_001#3" → single action
|
|
519
|
-
// Returns null fields for the levels that weren't constrained.
|
|
520
|
-
function parseActionInspectId(raw) {
|
|
521
|
-
if (!raw)
|
|
522
|
-
return { epId: null, sceneId: null, actionIndex: null };
|
|
523
|
-
let value = raw.trim();
|
|
524
|
-
let actionIndex = null;
|
|
525
|
-
if (value.includes("#")) {
|
|
526
|
-
const i = value.lastIndexOf("#");
|
|
527
|
-
const idx = parseInt(value.slice(i + 1), 10);
|
|
528
|
-
if (!Number.isFinite(idx)) {
|
|
529
|
-
throw new CliError("SCRIPT INSPECT BLOCKED: Action ref invalid", "Action ref invalid.", {
|
|
530
|
-
exitCode: EXIT_USAGE,
|
|
531
|
-
required: ["action ref: ep_001/scn_001#3"],
|
|
532
|
-
received: [raw],
|
|
533
|
-
nextSteps: ["Use ep_id/scn_id#action_index."],
|
|
534
|
-
errorCode: "INSPECT_ID_INVALID",
|
|
535
|
-
});
|
|
536
|
-
}
|
|
537
|
-
actionIndex = idx;
|
|
538
|
-
value = value.slice(0, i);
|
|
539
|
-
}
|
|
540
|
-
let epId = null;
|
|
541
|
-
let sceneId = null;
|
|
542
|
-
if (value.includes("/")) {
|
|
543
|
-
const [ep, scn] = value.split("/", 2);
|
|
544
|
-
epId = normalizeEpisodeRef(ep);
|
|
545
|
-
sceneId = scn.trim() || null;
|
|
546
|
-
}
|
|
547
|
-
else if (value) {
|
|
548
|
-
epId = normalizeEpisodeRef(value);
|
|
549
|
-
}
|
|
550
|
-
return { epId, sceneId, actionIndex };
|
|
551
|
-
}
|
|
552
|
-
// "1" / "01" / "ep_001" / "ep_15" → "ep_001" / "ep_015". Anything that already
|
|
553
|
-
// starts with "ep_" is passed through verbatim so non-standard ids still work.
|
|
554
|
-
function normalizeEpisodeRef(raw) {
|
|
555
|
-
const value = raw.trim();
|
|
556
|
-
if (!value)
|
|
557
|
-
return value;
|
|
558
|
-
if (value.startsWith("ep_"))
|
|
559
|
-
return value;
|
|
560
|
-
if (/^\d+$/.test(value))
|
|
561
|
-
return `ep_${value.padStart(3, "0")}`;
|
|
562
|
-
return value;
|
|
563
|
-
}
|
|
564
528
|
function actionAddress(epId, sceneId, idx) {
|
|
565
529
|
return `${epId}/${sceneId}#${idx}`;
|
|
566
530
|
}
|
|
@@ -584,149 +548,71 @@ function formatInspectAction(epId, sceneId, idx, action) {
|
|
|
584
548
|
const prefix = who ? `${addr} [${tag} ${who}]` : `${addr} [${tag}]`;
|
|
585
549
|
return content ? `${prefix} ${content}` : prefix;
|
|
586
550
|
}
|
|
587
|
-
export async function commandScriptInspect(opts) {
|
|
588
|
-
const session = await loadScriptForEdit(opts);
|
|
589
|
-
const script = session.script;
|
|
590
|
-
const target = strOf(opts["target"] || "summary").trim();
|
|
591
|
-
const itemId = strOf(opts["id"]).trim();
|
|
592
|
-
const lines = [];
|
|
593
|
-
if (target === "summary") {
|
|
594
|
-
const episodes = asList(script["episodes"]);
|
|
595
|
-
const scenes = [];
|
|
596
|
-
for (const ep of episodes)
|
|
597
|
-
scenes.push(...asList(ep["scenes"]));
|
|
598
|
-
const actions = [];
|
|
599
|
-
for (const scene of scenes)
|
|
600
|
-
actions.push(...asList(scene["actions"]));
|
|
601
|
-
lines.push(`title: ${script["title"] || "-"}`, `episodes: ${episodes.length}`, `scenes: ${scenes.length}`, `actions: ${actions.length}`, `actors: ${asList(script["actors"]).length}`, `locations: ${asList(script["locations"]).length}`, `props: ${asList(script["props"]).length}`, `speakers: ${asList(script["speakers"]).length}`);
|
|
602
|
-
}
|
|
603
|
-
else if (target === "episode") {
|
|
604
|
-
const minChars = parseBound(opts["min_chars"]);
|
|
605
|
-
const maxChars = parseBound(opts["max_chars"]);
|
|
606
|
-
for (const ep of asList(script["episodes"])) {
|
|
607
|
-
if (itemId && itemId !== strOf(ep["episode_id"]))
|
|
608
|
-
continue;
|
|
609
|
-
const scenes = asList(ep["scenes"]);
|
|
610
|
-
let actionCount = 0;
|
|
611
|
-
const chars = episodeCharCounts(scenes);
|
|
612
|
-
for (const scene of scenes)
|
|
613
|
-
actionCount += asList(scene["actions"]).length;
|
|
614
|
-
if (minChars !== null && chars.total < minChars)
|
|
615
|
-
continue;
|
|
616
|
-
if (maxChars !== null && chars.total > maxChars)
|
|
617
|
-
continue;
|
|
618
|
-
lines.push(`${ep["episode_id"]}: scenes=${scenes.length}, actions=${actionCount}, chars=${chars.total} (dialogue=${chars.dialogue}, action=${chars.action}), title=${ep["title"] || "-"}`);
|
|
619
|
-
}
|
|
620
|
-
}
|
|
621
|
-
else if (target === "action") {
|
|
622
|
-
// Lists action addresses + content. Designed for "find which action says X"
|
|
623
|
-
// workflows: the agent greps the content, gets back ep_NN/scn_NN#idx, then
|
|
624
|
-
// edits via `script action content-replace`. Requires at least one filter
|
|
625
|
-
// (--id / --grep) — dumping every action across 30+ episodes would blow
|
|
626
|
-
// past any sane context window.
|
|
627
|
-
const grep = strOf(opts["grep"]);
|
|
628
|
-
const filter = parseActionInspectId(itemId);
|
|
629
|
-
if (!grep && !itemId) {
|
|
630
|
-
throw new CliError("SCRIPT INSPECT BLOCKED: Action filter missing", "Action filter missing.", {
|
|
631
|
-
exitCode: EXIT_USAGE,
|
|
632
|
-
required: ["--id <ep_001|ep_001/scn_001|ep_001/scn_001#3> or --grep <text>"],
|
|
633
|
-
received: ["<empty>"],
|
|
634
|
-
nextSteps: ["Pass --grep to search content, or --id to scope to an episode/scene/action."],
|
|
635
|
-
errorCode: "INSPECT_FILTER_MISSING",
|
|
636
|
-
});
|
|
637
|
-
}
|
|
638
|
-
for (const ep of asList(script["episodes"])) {
|
|
639
|
-
const epId = strOf(ep["episode_id"]);
|
|
640
|
-
if (filter.epId && filter.epId !== epId)
|
|
641
|
-
continue;
|
|
642
|
-
for (const scene of asList(ep["scenes"])) {
|
|
643
|
-
const sceneId = strOf(scene["scene_id"]);
|
|
644
|
-
if (filter.sceneId && filter.sceneId !== sceneId)
|
|
645
|
-
continue;
|
|
646
|
-
const actions = asList(scene["actions"]);
|
|
647
|
-
for (let i = 0; i < actions.length; i++) {
|
|
648
|
-
if (filter.actionIndex !== null && filter.actionIndex !== i)
|
|
649
|
-
continue;
|
|
650
|
-
const a = actions[i];
|
|
651
|
-
if (grep) {
|
|
652
|
-
let hay = strOf(a["content"]);
|
|
653
|
-
for (const line of asList(a["lines"]))
|
|
654
|
-
hay += "\n" + strOf(line["content"]);
|
|
655
|
-
if (!hay.includes(grep))
|
|
656
|
-
continue;
|
|
657
|
-
}
|
|
658
|
-
lines.push(formatInspectAction(epId, sceneId, i, a));
|
|
659
|
-
}
|
|
660
|
-
}
|
|
661
|
-
}
|
|
662
|
-
}
|
|
663
|
-
else if (target === "asset") {
|
|
664
|
-
for (const [key, idKey, nameKey] of [
|
|
665
|
-
["actors", "actor_id", "actor_name"],
|
|
666
|
-
["locations", "location_id", "location_name"],
|
|
667
|
-
["props", "prop_id", "prop_name"],
|
|
668
|
-
]) {
|
|
669
|
-
for (const asset of asList(script[key])) {
|
|
670
|
-
if (itemId && itemId !== strOf(asset[idKey]) && itemId !== strOf(asset[nameKey]))
|
|
671
|
-
continue;
|
|
672
|
-
const singular = key.slice(0, -1);
|
|
673
|
-
lines.push(`${singular} ${asset[idKey]}: ${asset[nameKey]} states=${asList(asset["states"]).length}`);
|
|
674
|
-
}
|
|
675
|
-
}
|
|
676
|
-
}
|
|
677
|
-
else if (target === "speaker") {
|
|
678
|
-
for (const speaker of asList(script["speakers"])) {
|
|
679
|
-
if (itemId && itemId !== strOf(speaker["speaker_id"]))
|
|
680
|
-
continue;
|
|
681
|
-
lines.push(`${speaker["speaker_id"]}: ${speaker["display_name"]} [${speaker["source_kind"]}] source=${speaker["source_id"]}`);
|
|
682
|
-
}
|
|
683
|
-
}
|
|
684
|
-
else if (target === "issue") {
|
|
685
|
-
const validation = validateSession(session);
|
|
686
|
-
for (const issue of asList(validation["issues"])) {
|
|
687
|
-
if (itemId && itemId !== strOf(issue["code"]) && itemId !== strOf(issue["severity"]))
|
|
688
|
-
continue;
|
|
689
|
-
const whereParts = [];
|
|
690
|
-
for (const k of ["episode", "scene", "action_index"]) {
|
|
691
|
-
if (issue[k] !== null && issue[k] !== undefined)
|
|
692
|
-
whereParts.push(strOf(issue[k]));
|
|
693
|
-
}
|
|
694
|
-
const where = whereParts.join(" ");
|
|
695
|
-
lines.push(`${issue["severity"]} ${issue["code"]}: ${issue["summary"]}${where ? ` [${where}]` : ""}`);
|
|
696
|
-
}
|
|
697
|
-
}
|
|
698
|
-
else {
|
|
699
|
-
throw new CliError("SCRIPT INSPECT BLOCKED: Target invalid", "Target invalid.", {
|
|
700
|
-
exitCode: EXIT_USAGE,
|
|
701
|
-
required: ["target: summary, episode, action, asset, speaker, or issue"],
|
|
702
|
-
received: [target],
|
|
703
|
-
nextSteps: ["Use a supported target."],
|
|
704
|
-
errorCode: "INSPECT_TARGET_INVALID",
|
|
705
|
-
});
|
|
706
|
-
}
|
|
707
|
-
const report = {
|
|
708
|
-
title: `SCRIPT INSPECT: ${target}`,
|
|
709
|
-
op: "script.inspect",
|
|
710
|
-
changed: false,
|
|
711
|
-
summary: `${target}: ${lines.length} item(s)`,
|
|
712
|
-
result: lines.length > 0 ? lines : ["No matching items."],
|
|
713
|
-
artifacts: [session.artifactLabel],
|
|
714
|
-
next: ["Use script subcommands or script patch for edits."],
|
|
715
|
-
};
|
|
716
|
-
return [report, EXIT_OK];
|
|
717
|
-
}
|
|
718
551
|
// ---------------------------------------------------------------------------
|
|
719
552
|
// command_script_patch
|
|
720
553
|
// ---------------------------------------------------------------------------
|
|
721
554
|
export async function commandScriptPatch(opts) {
|
|
555
|
+
// --schema: dump op schemas as JSON; no script load, no file required.
|
|
556
|
+
// `--schema <op>` narrows to a single op; bare `--schema` dumps all.
|
|
557
|
+
const schemaOpt = opts["schema"];
|
|
558
|
+
if (schemaOpt !== undefined && schemaOpt !== false) {
|
|
559
|
+
const specificOp = typeof schemaOpt === "string" && schemaOpt !== "" ? schemaOpt : null;
|
|
560
|
+
if (specificOp) {
|
|
561
|
+
const entry = PATCH_OP_SCHEMA[specificOp];
|
|
562
|
+
if (!entry) {
|
|
563
|
+
throw new CliError("SCRIPT PATCH BLOCKED: Unknown op", "Unknown op.", {
|
|
564
|
+
exitCode: EXIT_USAGE,
|
|
565
|
+
required: ["existing dot-style op name (e.g. asset.rename)"],
|
|
566
|
+
received: [specificOp],
|
|
567
|
+
nextSteps: ["Run `scriptctl patch --schema` (no value) to list all ops."],
|
|
568
|
+
errorCode: "PATCH_OP_UNKNOWN",
|
|
569
|
+
});
|
|
570
|
+
}
|
|
571
|
+
const report = {
|
|
572
|
+
title: `PATCH OP SCHEMA: ${specificOp}`,
|
|
573
|
+
op: "patch.schema",
|
|
574
|
+
changed: false,
|
|
575
|
+
summary: entry.description,
|
|
576
|
+
result: [
|
|
577
|
+
`op: ${specificOp}`,
|
|
578
|
+
`required: ${entry.required.join(", ") || "(none)"}`,
|
|
579
|
+
`optional: ${entry.optional.join(", ") || "(none)"}`,
|
|
580
|
+
],
|
|
581
|
+
body: JSON.stringify({ op: specificOp, ...entry }, null, 2),
|
|
582
|
+
next: ["Construct a patch file with these fields and run `scriptctl patch <file>`."],
|
|
583
|
+
};
|
|
584
|
+
return [report, EXIT_OK];
|
|
585
|
+
}
|
|
586
|
+
const ops = Object.keys(PATCH_OP_SCHEMA).sort();
|
|
587
|
+
const report = {
|
|
588
|
+
title: "PATCH OP SCHEMA",
|
|
589
|
+
op: "patch.schema",
|
|
590
|
+
changed: false,
|
|
591
|
+
summary: `${ops.length} dot-style ops supported`,
|
|
592
|
+
result: ops.map((name) => {
|
|
593
|
+
const e = PATCH_OP_SCHEMA[name];
|
|
594
|
+
return `${name}: required=[${e.required.join(",")}] optional=[${e.optional.join(",")}]`;
|
|
595
|
+
}),
|
|
596
|
+
body: JSON.stringify(PATCH_OP_SCHEMA, null, 2),
|
|
597
|
+
next: ["Use `scriptctl patch --schema <op>` for a single op, or write a patch file and run `scriptctl patch <file>`."],
|
|
598
|
+
};
|
|
599
|
+
return [report, EXIT_OK];
|
|
600
|
+
}
|
|
722
601
|
const workspace = strOf(opts["workspace_path"] || "workspace");
|
|
723
|
-
const session = await loadScriptForEdit(opts);
|
|
724
|
-
const script = session.script;
|
|
725
602
|
const patchPath = strOf(opts["patch"]);
|
|
603
|
+
if (!patchPath) {
|
|
604
|
+
throw new CliError("SCRIPT PATCH BLOCKED: Patch file missing", "Patch file missing.", {
|
|
605
|
+
exitCode: EXIT_USAGE,
|
|
606
|
+
required: ["<file> positional argument"],
|
|
607
|
+
received: ["<empty>"],
|
|
608
|
+
nextSteps: ["Pass a patch JSON file, or use --schema to inspect op definitions."],
|
|
609
|
+
errorCode: "PATCH_FILE_MISSING",
|
|
610
|
+
});
|
|
611
|
+
}
|
|
726
612
|
if (!exists(patchPath)) {
|
|
727
613
|
throw new CliError("SCRIPT PATCH BLOCKED: Patch file not found", "Patch file not found.", {
|
|
728
614
|
exitCode: EXIT_INPUT,
|
|
729
|
-
required: ["
|
|
615
|
+
required: ["existing patch JSON file"],
|
|
730
616
|
received: [patchPath],
|
|
731
617
|
nextSteps: ["Write patch JSON and rerun."],
|
|
732
618
|
errorCode: "PATCH_NOT_FOUND",
|
|
@@ -746,10 +632,41 @@ export async function commandScriptPatch(opts) {
|
|
|
746
632
|
});
|
|
747
633
|
}
|
|
748
634
|
const operations = patchOperationsFromPayload(payload);
|
|
635
|
+
const dryRun = Boolean(opts["dry_run"]);
|
|
636
|
+
const session = await loadScriptForEdit(opts);
|
|
637
|
+
const script = session.script;
|
|
749
638
|
const applied = applyPatchOperations(script, scriptSourceText(workspace), operations);
|
|
639
|
+
if (dryRun) {
|
|
640
|
+
// Validate the in-memory mutated script WITHOUT calling saveScriptSession.
|
|
641
|
+
// Run validate but skip syncValidationResult (which writes to remote) too.
|
|
642
|
+
const validation = validateSession(session);
|
|
643
|
+
const passed = Boolean(validation["passed"]);
|
|
644
|
+
const report = {
|
|
645
|
+
title: passed ? "SCRIPT PATCH DRY-RUN PASSED" : "SCRIPT PATCH DRY-RUN: Validation needs repair",
|
|
646
|
+
op: "script.patch.dry-run",
|
|
647
|
+
changed: false,
|
|
648
|
+
summary: `would apply ${applied.length} operation(s) — no write performed`,
|
|
649
|
+
warnings: passed ? [] : summarizeIssues(asList(validation["issues"])),
|
|
650
|
+
result: [
|
|
651
|
+
`operations: ${applied.length}`,
|
|
652
|
+
`validation: ${passed ? "would pass" : "would need repair"}`,
|
|
653
|
+
"dry-run: no remote/local write performed",
|
|
654
|
+
],
|
|
655
|
+
issues: summarizeIssues(asList(validation["issues"])),
|
|
656
|
+
artifacts: [session.artifactLabel],
|
|
657
|
+
next: [passed ? "Re-run without --dry-run to commit." : "Resolve validation issues before committing."],
|
|
658
|
+
};
|
|
659
|
+
return [report, passed ? EXIT_OK : EXIT_NEEDS_AGENT];
|
|
660
|
+
}
|
|
750
661
|
const [newRevision, idempotent] = await saveScriptSession(session, opts, applied.length === 1 ? applied[0] : "script.patch");
|
|
751
662
|
const validation = validateSession(session);
|
|
752
663
|
await syncValidationResult(session, validation, newRevision);
|
|
664
|
+
// Direct-stage local patches: keep run_state in sync so reviewBlockers can
|
|
665
|
+
// unblock direct export. Detect via script path under directDir.
|
|
666
|
+
if (!session.remote && session.scriptPath && session.scriptPath.startsWith(directDir(workspace))) {
|
|
667
|
+
markMetadataConfidenceReviewed(workspace, operations);
|
|
668
|
+
markPatched(workspace, applied.length);
|
|
669
|
+
}
|
|
753
670
|
const passed = Boolean(validation["passed"]);
|
|
754
671
|
const resultLines = [
|
|
755
672
|
`operations: ${applied.length}`,
|
|
@@ -857,222 +774,6 @@ async function commandStateRefs(opts, plan = false) {
|
|
|
857
774
|
};
|
|
858
775
|
return [report, EXIT_OK];
|
|
859
776
|
}
|
|
860
|
-
export async function commandScriptState(opts, action) {
|
|
861
|
-
const args = asList(opts["_args"]);
|
|
862
|
-
if (action === "refs" || action === "delete-plan") {
|
|
863
|
-
return commandStateRefs(opts, action === "delete-plan");
|
|
864
|
-
}
|
|
865
|
-
if (args.length === 0) {
|
|
866
|
-
throw new CliError("SCRIPT STATE BLOCKED: Target missing", "Target missing.", {
|
|
867
|
-
exitCode: EXIT_USAGE,
|
|
868
|
-
required: ["state or asset target"],
|
|
869
|
-
received: ["<empty>"],
|
|
870
|
-
nextSteps: ["Run scriptctl script state --help."],
|
|
871
|
-
errorCode: "TARGET_MISSING",
|
|
872
|
-
});
|
|
873
|
-
}
|
|
874
|
-
let op;
|
|
875
|
-
if (action === "add") {
|
|
876
|
-
op = {
|
|
877
|
-
op: "state.add",
|
|
878
|
-
target: args[0],
|
|
879
|
-
name: opts["name"],
|
|
880
|
-
description: opts["description"],
|
|
881
|
-
state_id: opts["state_id"],
|
|
882
|
-
};
|
|
883
|
-
}
|
|
884
|
-
else if (action === "rename") {
|
|
885
|
-
op = { op: "state.rename", target: args[0], name: opts["name"] };
|
|
886
|
-
}
|
|
887
|
-
else if (action === "describe") {
|
|
888
|
-
op = { op: "state.describe", target: args[0], description: opts["description"] };
|
|
889
|
-
}
|
|
890
|
-
else if (action === "delete-apply") {
|
|
891
|
-
op = {
|
|
892
|
-
op: "state.delete",
|
|
893
|
-
target: args[0],
|
|
894
|
-
strategy: opts["strategy"],
|
|
895
|
-
replacement: opts["replacement"],
|
|
896
|
-
};
|
|
897
|
-
}
|
|
898
|
-
else {
|
|
899
|
-
throw new CliError("SCRIPT STATE BLOCKED: Command invalid", "Command invalid.", {
|
|
900
|
-
exitCode: EXIT_USAGE,
|
|
901
|
-
required: ["add, rename, describe, refs, delete-plan, delete-apply"],
|
|
902
|
-
received: [action],
|
|
903
|
-
nextSteps: ["Run scriptctl script state --help."],
|
|
904
|
-
errorCode: "STATE_COMMAND_INVALID",
|
|
905
|
-
});
|
|
906
|
-
}
|
|
907
|
-
return applySingleScriptOp(opts, op);
|
|
908
|
-
}
|
|
909
|
-
export async function commandScriptContext(opts, action) {
|
|
910
|
-
const args = asList(opts["_args"]);
|
|
911
|
-
if (args.length < 2) {
|
|
912
|
-
throw new CliError("SCRIPT CONTEXT BLOCKED: Arguments missing", "Arguments missing.", {
|
|
913
|
-
exitCode: EXIT_USAGE,
|
|
914
|
-
required: ["scene ref and target"],
|
|
915
|
-
received: args,
|
|
916
|
-
nextSteps: ["Run scriptctl script context --help."],
|
|
917
|
-
errorCode: "ARGS_MISSING",
|
|
918
|
-
});
|
|
919
|
-
}
|
|
920
|
-
if (action !== "set" && action !== "clear") {
|
|
921
|
-
throw new CliError("SCRIPT CONTEXT BLOCKED: Command invalid", "Command invalid.", {
|
|
922
|
-
exitCode: EXIT_USAGE,
|
|
923
|
-
required: ["set or clear"],
|
|
924
|
-
received: [action],
|
|
925
|
-
nextSteps: ["Run scriptctl script context --help."],
|
|
926
|
-
errorCode: "CONTEXT_COMMAND_INVALID",
|
|
927
|
-
});
|
|
928
|
-
}
|
|
929
|
-
const op = {
|
|
930
|
-
op: action === "set" ? "context.set" : "context.clear",
|
|
931
|
-
at: args[0],
|
|
932
|
-
target: args[1],
|
|
933
|
-
state: opts["state"],
|
|
934
|
-
};
|
|
935
|
-
return applySingleScriptOp(opts, op);
|
|
936
|
-
}
|
|
937
|
-
export async function commandScriptAction(opts, action) {
|
|
938
|
-
const args = asList(opts["_args"]);
|
|
939
|
-
if (args.length < 2) {
|
|
940
|
-
throw new CliError("SCRIPT ACTION BLOCKED: Arguments missing", "Arguments missing.", {
|
|
941
|
-
exitCode: EXIT_USAGE,
|
|
942
|
-
required: ["action ref and target"],
|
|
943
|
-
received: args,
|
|
944
|
-
nextSteps: ["Run scriptctl script action --help."],
|
|
945
|
-
errorCode: "ARGS_MISSING",
|
|
946
|
-
});
|
|
947
|
-
}
|
|
948
|
-
let op;
|
|
949
|
-
if (action === "state-change") {
|
|
950
|
-
op = {
|
|
951
|
-
op: "action.state.change",
|
|
952
|
-
at: args[0],
|
|
953
|
-
target: args[1],
|
|
954
|
-
to: opts["to"],
|
|
955
|
-
from: opts["from"],
|
|
956
|
-
effective: opts["effective"] || "after",
|
|
957
|
-
};
|
|
958
|
-
}
|
|
959
|
-
else if (action === "state-remove") {
|
|
960
|
-
op = { op: "action.state.remove", at: args[0], target: args[1] };
|
|
961
|
-
}
|
|
962
|
-
else if (action === "transition-set") {
|
|
963
|
-
op = {
|
|
964
|
-
op: "action.transition.set",
|
|
965
|
-
at: args[0],
|
|
966
|
-
target: args[1],
|
|
967
|
-
process: opts["process"],
|
|
968
|
-
contrast: opts["contrast"],
|
|
969
|
-
};
|
|
970
|
-
}
|
|
971
|
-
else if (action === "transition-clear") {
|
|
972
|
-
op = { op: "action.transition.clear", at: args[0], target: args[1] };
|
|
973
|
-
}
|
|
974
|
-
else {
|
|
975
|
-
throw new CliError("SCRIPT ACTION BLOCKED: Command invalid", "Command invalid.", {
|
|
976
|
-
exitCode: EXIT_USAGE,
|
|
977
|
-
required: ["state-change, state-remove, transition-set, transition-clear"],
|
|
978
|
-
received: [action],
|
|
979
|
-
nextSteps: ["Run scriptctl script action --help."],
|
|
980
|
-
errorCode: "ACTION_COMMAND_INVALID",
|
|
981
|
-
});
|
|
982
|
-
}
|
|
983
|
-
return applySingleScriptOp(opts, op);
|
|
984
|
-
}
|
|
985
|
-
export async function commandScriptActionContentReplace(opts) {
|
|
986
|
-
const args = asList(opts["_args"]);
|
|
987
|
-
if (args.length < 1) {
|
|
988
|
-
throw new CliError("SCRIPT ACTION BLOCKED: Action ref missing", "Action ref missing.", {
|
|
989
|
-
exitCode: EXIT_USAGE,
|
|
990
|
-
required: ["action ref: ep_001/scn_001#3"],
|
|
991
|
-
received: ["<empty>"],
|
|
992
|
-
nextSteps: ["Run scriptctl script action --help."],
|
|
993
|
-
errorCode: "ACTION_REF_MISSING",
|
|
994
|
-
});
|
|
995
|
-
}
|
|
996
|
-
// commander's requiredOption catches missing --from / --to, but it lets an
|
|
997
|
-
// explicit empty string through. We allow --to "" intentionally (it means
|
|
998
|
-
// "delete this substring") but require --from to be non-empty so the op
|
|
999
|
-
// cannot match the empty string at every cursor position.
|
|
1000
|
-
if (opts["from"] === undefined) {
|
|
1001
|
-
throw new CliError("SCRIPT ACTION BLOCKED: --from missing", "--from missing.", {
|
|
1002
|
-
exitCode: EXIT_USAGE,
|
|
1003
|
-
required: ["--from <text>"],
|
|
1004
|
-
received: ["<missing>"],
|
|
1005
|
-
nextSteps: ["Pass --from with the substring to replace."],
|
|
1006
|
-
errorCode: "FROM_TEXT_EMPTY",
|
|
1007
|
-
});
|
|
1008
|
-
}
|
|
1009
|
-
const op = {
|
|
1010
|
-
op: "action.content.replace",
|
|
1011
|
-
at: args[0],
|
|
1012
|
-
from: opts["from"],
|
|
1013
|
-
to: opts["to"] ?? "",
|
|
1014
|
-
all: Boolean(opts["all"]),
|
|
1015
|
-
};
|
|
1016
|
-
return applySingleScriptOp(opts, op);
|
|
1017
|
-
}
|
|
1018
|
-
export async function commandScriptSpeaker(opts, action) {
|
|
1019
|
-
if (action !== "add") {
|
|
1020
|
-
throw new CliError("SCRIPT SPEAKER BLOCKED: Command invalid", "Command invalid.", {
|
|
1021
|
-
exitCode: EXIT_USAGE,
|
|
1022
|
-
required: ["add"],
|
|
1023
|
-
received: [action],
|
|
1024
|
-
nextSteps: ["Run scriptctl script speaker --help."],
|
|
1025
|
-
errorCode: "SPEAKER_COMMAND_INVALID",
|
|
1026
|
-
});
|
|
1027
|
-
}
|
|
1028
|
-
const op = {
|
|
1029
|
-
op: "speaker.add",
|
|
1030
|
-
kind: opts["kind"],
|
|
1031
|
-
name: opts["name"],
|
|
1032
|
-
source_id: opts["source_id"],
|
|
1033
|
-
voice_desc: opts["voice_desc"],
|
|
1034
|
-
speaker_id: opts["speaker_id"],
|
|
1035
|
-
};
|
|
1036
|
-
return applySingleScriptOp(opts, op);
|
|
1037
|
-
}
|
|
1038
|
-
export async function commandScriptDialogue(opts, action) {
|
|
1039
|
-
const args = asList(opts["_args"]);
|
|
1040
|
-
if (args.length === 0) {
|
|
1041
|
-
throw new CliError("SCRIPT DIALOGUE BLOCKED: Action ref missing", "Action ref missing.", {
|
|
1042
|
-
exitCode: EXIT_USAGE,
|
|
1043
|
-
required: ["action ref"],
|
|
1044
|
-
received: ["<empty>"],
|
|
1045
|
-
nextSteps: ["Run scriptctl script dialogue --help."],
|
|
1046
|
-
errorCode: "ACTION_REF_MISSING",
|
|
1047
|
-
});
|
|
1048
|
-
}
|
|
1049
|
-
let op;
|
|
1050
|
-
if (action === "speakers") {
|
|
1051
|
-
op = {
|
|
1052
|
-
op: "dialogue.speakers",
|
|
1053
|
-
at: args[0],
|
|
1054
|
-
speakers: opts["speaker"] || [],
|
|
1055
|
-
delivery: opts["delivery"] || "simultaneous",
|
|
1056
|
-
};
|
|
1057
|
-
}
|
|
1058
|
-
else if (action === "overlap") {
|
|
1059
|
-
op = {
|
|
1060
|
-
op: "dialogue.overlap",
|
|
1061
|
-
at: args[0],
|
|
1062
|
-
lines: opts["line"] || [],
|
|
1063
|
-
};
|
|
1064
|
-
}
|
|
1065
|
-
else {
|
|
1066
|
-
throw new CliError("SCRIPT DIALOGUE BLOCKED: Command invalid", "Command invalid.", {
|
|
1067
|
-
exitCode: EXIT_USAGE,
|
|
1068
|
-
required: ["speakers or overlap"],
|
|
1069
|
-
received: [action],
|
|
1070
|
-
nextSteps: ["Run scriptctl script dialogue --help."],
|
|
1071
|
-
errorCode: "DIALOGUE_COMMAND_INVALID",
|
|
1072
|
-
});
|
|
1073
|
-
}
|
|
1074
|
-
return applySingleScriptOp(opts, op);
|
|
1075
|
-
}
|
|
1076
777
|
// ---------------------------------------------------------------------------
|
|
1077
778
|
// command_export (direct export)
|
|
1078
779
|
// ---------------------------------------------------------------------------
|
|
@@ -1203,4 +904,1256 @@ export function sortDeep(value) {
|
|
|
1203
904
|
}
|
|
1204
905
|
return value;
|
|
1205
906
|
}
|
|
907
|
+
// ===========================================================================
|
|
908
|
+
// 0.6.0 plural-noun query commands (replace `commandScriptInspect --target X`)
|
|
909
|
+
// ===========================================================================
|
|
910
|
+
//
|
|
911
|
+
// Each command is a thin read-only wrapper around the underlying script
|
|
912
|
+
// document. They all return a Report shaped like inspect's existing output but
|
|
913
|
+
// without the `--target` flag — the verb itself encodes the target. Filters
|
|
914
|
+
// use `--in <addr>` (compose with the address parsers) plus per-target
|
|
915
|
+
// predicates (--name / --kind / --has / --grep / --context / --type / --actor
|
|
916
|
+
// / --speaker / --severity / --code / --has-{actor,location,prop}).
|
|
917
|
+
// Resolve the speaker_id → source actor mapping once per call. Used by
|
|
918
|
+
// commandActions's --actor filter when the action speaks via a speaker.
|
|
919
|
+
function speakerSourceActorMap(script) {
|
|
920
|
+
const map = new Map();
|
|
921
|
+
for (const s of asList(script["speakers"])) {
|
|
922
|
+
const sourceKind = strOf(s["source_kind"]);
|
|
923
|
+
if (sourceKind === "actor") {
|
|
924
|
+
const sid = strOf(s["speaker_id"]);
|
|
925
|
+
const src = strOf(s["source_id"]);
|
|
926
|
+
if (sid && src)
|
|
927
|
+
map.set(sid, src);
|
|
928
|
+
}
|
|
929
|
+
}
|
|
930
|
+
return map;
|
|
931
|
+
}
|
|
932
|
+
function speakerIdsInAction(action) {
|
|
933
|
+
const ids = new Set();
|
|
934
|
+
const direct = strOf(action["speaker_id"]).trim();
|
|
935
|
+
if (direct)
|
|
936
|
+
ids.add(direct);
|
|
937
|
+
for (const sp of asList(action["speakers"])) {
|
|
938
|
+
const id = strOf(sp["speaker_id"]).trim();
|
|
939
|
+
if (id)
|
|
940
|
+
ids.add(id);
|
|
941
|
+
}
|
|
942
|
+
for (const line of asList(action["lines"])) {
|
|
943
|
+
const id = strOf(line["speaker_id"]).trim();
|
|
944
|
+
if (id)
|
|
945
|
+
ids.add(id);
|
|
946
|
+
}
|
|
947
|
+
return ids;
|
|
948
|
+
}
|
|
949
|
+
function actorIdsInAction(action, speakerToActor) {
|
|
950
|
+
const ids = new Set();
|
|
951
|
+
const direct = strOf(action["actor_id"]).trim();
|
|
952
|
+
if (direct)
|
|
953
|
+
ids.add(direct);
|
|
954
|
+
for (const spkId of speakerIdsInAction(action)) {
|
|
955
|
+
const actor = speakerToActor.get(spkId);
|
|
956
|
+
if (actor)
|
|
957
|
+
ids.add(actor);
|
|
958
|
+
}
|
|
959
|
+
return ids;
|
|
960
|
+
}
|
|
961
|
+
function parseInFilter(raw) {
|
|
962
|
+
if (!raw)
|
|
963
|
+
return { epId: null, sceneId: null, actionIndex: null };
|
|
964
|
+
try {
|
|
965
|
+
const addr = parseAnyAddress(raw);
|
|
966
|
+
if (addr.kind === "action")
|
|
967
|
+
return { epId: addr.episodeId, sceneId: addr.sceneId, actionIndex: addr.actionIndex };
|
|
968
|
+
if (addr.kind === "scene")
|
|
969
|
+
return { epId: addr.episodeId, sceneId: addr.sceneId, actionIndex: null };
|
|
970
|
+
if (addr.kind === "episode")
|
|
971
|
+
return { epId: addr.episodeId, sceneId: null, actionIndex: null };
|
|
972
|
+
}
|
|
973
|
+
catch {
|
|
974
|
+
// Fall through; raw address not recognized.
|
|
975
|
+
}
|
|
976
|
+
throw new CliError("QUERY BLOCKED: --in address invalid", "--in address invalid.", {
|
|
977
|
+
exitCode: EXIT_USAGE,
|
|
978
|
+
required: ["--in: ep_NNN | ep_NNN/scn_NNN | ep_NNN/scn_NNN#idx"],
|
|
979
|
+
received: [raw],
|
|
980
|
+
nextSteps: ["Pass a recognized address format."],
|
|
981
|
+
errorCode: "IN_ADDRESS_INVALID",
|
|
982
|
+
});
|
|
983
|
+
}
|
|
984
|
+
// Build a grep matcher. /pattern/flags → RegExp, otherwise literal substring.
|
|
985
|
+
// Pre-flight checks block the common ReDoS shapes before they hit V8's regex
|
|
986
|
+
// engine: nested quantifiers like (a+)+, (a*)+, (a*|b*)+ etc. compile fine
|
|
987
|
+
// but hang the process on adversarial input. Since --grep accepts caller-
|
|
988
|
+
// controlled patterns and runs over every action in scope, the safe move is
|
|
989
|
+
// to reject patterns with quantifier-on-group-with-internal-quantifier and
|
|
990
|
+
// cap the pattern length.
|
|
991
|
+
const MAX_GREP_PATTERN_LEN = 256;
|
|
992
|
+
function isDangerousRegex(pattern) {
|
|
993
|
+
if (pattern.length > MAX_GREP_PATTERN_LEN)
|
|
994
|
+
return true;
|
|
995
|
+
// Quantifier ({+,*,?,{n,m}}) outside a group whose interior contains another
|
|
996
|
+
// quantifier — classic catastrophic-backtracking template. The check is a
|
|
997
|
+
// heuristic, not a parser; false positives are tolerated as long as the
|
|
998
|
+
// common ReDoS shapes are blocked.
|
|
999
|
+
return /\([^)]*[*+?{][^)]*\)[*+?{]/.test(pattern);
|
|
1000
|
+
}
|
|
1001
|
+
function buildGrepMatcher(raw) {
|
|
1002
|
+
if (!raw)
|
|
1003
|
+
return null;
|
|
1004
|
+
const m = /^\/(.+)\/([gimsuy]*)$/.exec(raw);
|
|
1005
|
+
if (m) {
|
|
1006
|
+
const pattern = m[1];
|
|
1007
|
+
if (isDangerousRegex(pattern)) {
|
|
1008
|
+
throw new CliError("QUERY BLOCKED: regex unsafe", "regex pattern has nested quantifiers (ReDoS risk) or exceeds the length limit.", {
|
|
1009
|
+
exitCode: EXIT_USAGE,
|
|
1010
|
+
required: [`pattern length <= ${MAX_GREP_PATTERN_LEN}; no quantifier-on-group-with-internal-quantifier (e.g. (a+)+)`],
|
|
1011
|
+
received: [`length=${pattern.length}, sample=${pattern.slice(0, 80)}`],
|
|
1012
|
+
nextSteps: ["Rewrite the regex to avoid nested quantifiers, or use a literal substring (drop the /…/ wrapper)."],
|
|
1013
|
+
errorCode: "REGEX_UNSAFE",
|
|
1014
|
+
});
|
|
1015
|
+
}
|
|
1016
|
+
try {
|
|
1017
|
+
const re = new RegExp(pattern, m[2]);
|
|
1018
|
+
return (hay) => re.test(hay);
|
|
1019
|
+
}
|
|
1020
|
+
catch {
|
|
1021
|
+
// Malformed regex — treat as literal.
|
|
1022
|
+
}
|
|
1023
|
+
}
|
|
1024
|
+
return (hay) => hay.includes(raw);
|
|
1025
|
+
}
|
|
1026
|
+
function buildReport(op, title, lines, artifactLabel, next) {
|
|
1027
|
+
return {
|
|
1028
|
+
title,
|
|
1029
|
+
op,
|
|
1030
|
+
changed: false,
|
|
1031
|
+
summary: `${lines.length} item(s)`,
|
|
1032
|
+
result: lines.length > 0 ? lines : ["No matching items."],
|
|
1033
|
+
artifacts: [artifactLabel],
|
|
1034
|
+
next,
|
|
1035
|
+
};
|
|
1036
|
+
}
|
|
1037
|
+
// ----- summary --------------------------------------------------------------
|
|
1038
|
+
export async function commandSummary(opts) {
|
|
1039
|
+
const session = await loadScriptForEdit(opts);
|
|
1040
|
+
const script = session.script;
|
|
1041
|
+
const episodes = asList(script["episodes"]);
|
|
1042
|
+
const scenes = [];
|
|
1043
|
+
for (const ep of episodes)
|
|
1044
|
+
scenes.push(...asList(ep["scenes"]));
|
|
1045
|
+
const actions = [];
|
|
1046
|
+
for (const scene of scenes)
|
|
1047
|
+
actions.push(...asList(scene["actions"]));
|
|
1048
|
+
const lines = [
|
|
1049
|
+
`title: ${script["title"] || "-"}`,
|
|
1050
|
+
`episodes: ${episodes.length}`,
|
|
1051
|
+
`scenes: ${scenes.length}`,
|
|
1052
|
+
`actions: ${actions.length}`,
|
|
1053
|
+
`actors: ${asList(script["actors"]).length}`,
|
|
1054
|
+
`locations: ${asList(script["locations"]).length}`,
|
|
1055
|
+
`props: ${asList(script["props"]).length}`,
|
|
1056
|
+
`speakers: ${asList(script["speakers"]).length}`,
|
|
1057
|
+
];
|
|
1058
|
+
return [buildReport("query.summary", "SCRIPT SUMMARY", lines, session.artifactLabel, ["Use plural-noun queries (episodes / scenes / actions / actors / ...) or edit verbs to act on the script."]), EXIT_OK];
|
|
1059
|
+
}
|
|
1060
|
+
// ----- episodes -------------------------------------------------------------
|
|
1061
|
+
export async function commandEpisodes(opts) {
|
|
1062
|
+
const session = await loadScriptForEdit(opts);
|
|
1063
|
+
const script = session.script;
|
|
1064
|
+
const itemId = strOf(opts["id"]).trim();
|
|
1065
|
+
const minChars = parseBound(opts["min_chars"]);
|
|
1066
|
+
const maxChars = parseBound(opts["max_chars"]);
|
|
1067
|
+
const lines = [];
|
|
1068
|
+
for (const ep of asList(script["episodes"])) {
|
|
1069
|
+
if (itemId && itemId !== strOf(ep["episode_id"]))
|
|
1070
|
+
continue;
|
|
1071
|
+
const scenes = asList(ep["scenes"]);
|
|
1072
|
+
let actionCount = 0;
|
|
1073
|
+
const chars = episodeCharCounts(scenes);
|
|
1074
|
+
for (const scene of scenes)
|
|
1075
|
+
actionCount += asList(scene["actions"]).length;
|
|
1076
|
+
if (minChars !== null && chars.total < minChars)
|
|
1077
|
+
continue;
|
|
1078
|
+
if (maxChars !== null && chars.total > maxChars)
|
|
1079
|
+
continue;
|
|
1080
|
+
lines.push(`${ep["episode_id"]}: scenes=${scenes.length}, actions=${actionCount}, chars=${chars.total} (dialogue=${chars.dialogue}, action=${chars.action}), title=${ep["title"] || "-"}`);
|
|
1081
|
+
}
|
|
1082
|
+
return [buildReport("query.episodes", "EPISODES", lines, session.artifactLabel, ["Use `scriptctl scenes --in <ep>` to drill in, or edit verbs to mutate."]), EXIT_OK];
|
|
1083
|
+
}
|
|
1084
|
+
// ----- scenes ---------------------------------------------------------------
|
|
1085
|
+
function nameMaps(script) {
|
|
1086
|
+
const out = { actor: new Map(), location: new Map(), prop: new Map() };
|
|
1087
|
+
for (const a of asList(script["actors"]))
|
|
1088
|
+
out.actor.set(strOf(a["actor_id"]), strOf(a["actor_name"]));
|
|
1089
|
+
for (const l of asList(script["locations"]))
|
|
1090
|
+
out.location.set(strOf(l["location_id"]), strOf(l["location_name"]));
|
|
1091
|
+
for (const p of asList(script["props"]))
|
|
1092
|
+
out.prop.set(strOf(p["prop_id"]), strOf(p["prop_name"]));
|
|
1093
|
+
return out;
|
|
1094
|
+
}
|
|
1095
|
+
function formatScene(epId, scene, names) {
|
|
1096
|
+
const ctx = isDict(scene["context"]) ? scene["context"] : {};
|
|
1097
|
+
const env = isDict(scene["environment"]) ? scene["environment"] : {};
|
|
1098
|
+
const space = strOf(env["space"]) || "-";
|
|
1099
|
+
const time = strOf(env["time"]) || "-";
|
|
1100
|
+
const locations = asList(ctx["locations"]).map((r) => {
|
|
1101
|
+
const id = strOf(r["location_id"]);
|
|
1102
|
+
return `${id}(${names.location.get(id) || "?"})`;
|
|
1103
|
+
});
|
|
1104
|
+
const actors = asList(ctx["actors"]).map((r) => {
|
|
1105
|
+
const id = strOf(r["actor_id"]);
|
|
1106
|
+
return `${id}(${names.actor.get(id) || "?"})`;
|
|
1107
|
+
});
|
|
1108
|
+
const props = asList(ctx["props"]).map((r) => {
|
|
1109
|
+
const id = strOf(r["prop_id"]);
|
|
1110
|
+
return `${id}(${names.prop.get(id) || "?"})`;
|
|
1111
|
+
});
|
|
1112
|
+
const actionCount = asList(scene["actions"]).length;
|
|
1113
|
+
return `${epId}/${scene["scene_id"]} [${space} ${time}] location=${locations.join(",") || "-"} actors=${actors.join(",") || "-"} props=${props.join(",") || "-"} actions=${actionCount}`;
|
|
1114
|
+
}
|
|
1115
|
+
export async function commandScenes(opts) {
|
|
1116
|
+
const session = await loadScriptForEdit(opts);
|
|
1117
|
+
const script = session.script;
|
|
1118
|
+
const inFilter = parseInFilter(strOf(opts["in"]));
|
|
1119
|
+
const hasActor = strOf(opts["has_actor"]).trim();
|
|
1120
|
+
const hasLocation = strOf(opts["has_location"]).trim();
|
|
1121
|
+
const hasProp = strOf(opts["has_prop"]).trim();
|
|
1122
|
+
const names = nameMaps(script);
|
|
1123
|
+
const lines = [];
|
|
1124
|
+
for (const ep of asList(script["episodes"])) {
|
|
1125
|
+
const epId = strOf(ep["episode_id"]);
|
|
1126
|
+
if (inFilter.epId && inFilter.epId !== epId)
|
|
1127
|
+
continue;
|
|
1128
|
+
for (const scene of asList(ep["scenes"])) {
|
|
1129
|
+
const sceneId = strOf(scene["scene_id"]);
|
|
1130
|
+
if (inFilter.sceneId && inFilter.sceneId !== sceneId)
|
|
1131
|
+
continue;
|
|
1132
|
+
if (hasActor) {
|
|
1133
|
+
const refs = asList((scene["context"] || {})["actors"]);
|
|
1134
|
+
if (!refs.some((r) => strOf(r["actor_id"]) === hasActor))
|
|
1135
|
+
continue;
|
|
1136
|
+
}
|
|
1137
|
+
if (hasLocation) {
|
|
1138
|
+
const refs = asList((scene["context"] || {})["locations"]);
|
|
1139
|
+
if (!refs.some((r) => strOf(r["location_id"]) === hasLocation))
|
|
1140
|
+
continue;
|
|
1141
|
+
}
|
|
1142
|
+
if (hasProp) {
|
|
1143
|
+
const refs = asList((scene["context"] || {})["props"]);
|
|
1144
|
+
if (!refs.some((r) => strOf(r["prop_id"]) === hasProp))
|
|
1145
|
+
continue;
|
|
1146
|
+
}
|
|
1147
|
+
lines.push(formatScene(epId, scene, names));
|
|
1148
|
+
}
|
|
1149
|
+
}
|
|
1150
|
+
return [buildReport("query.scenes", "SCENES", lines, session.artifactLabel, ["Use `scriptctl actions --in <ep/scn>` to see actions, or edit verbs to mutate."]), EXIT_OK];
|
|
1151
|
+
}
|
|
1152
|
+
// ----- actions --------------------------------------------------------------
|
|
1153
|
+
export async function commandActions(opts) {
|
|
1154
|
+
const session = await loadScriptForEdit(opts);
|
|
1155
|
+
const script = session.script;
|
|
1156
|
+
const inFilter = parseInFilter(strOf(opts["in"]));
|
|
1157
|
+
const grep = strOf(opts["grep"]);
|
|
1158
|
+
const typeFilter = strOf(opts["type"]).trim();
|
|
1159
|
+
const actorFilter = strOf(opts["actor"]).trim();
|
|
1160
|
+
const speakerFilter = strOf(opts["speaker"]).trim();
|
|
1161
|
+
const hasFilter = strOf(opts["has"]).trim();
|
|
1162
|
+
// Whitelist --has: silently accepting unknown values means a typo like
|
|
1163
|
+
// "state_changes" (underscore) passed the filter-missing guard but bypassed
|
|
1164
|
+
// every concrete predicate, returning the whole script.
|
|
1165
|
+
const HAS_FILTERS = new Set(["state-changes", "transition", "lines"]);
|
|
1166
|
+
if (hasFilter && !HAS_FILTERS.has(hasFilter)) {
|
|
1167
|
+
throw new CliError("QUERY BLOCKED: --has value invalid", "--has value invalid.", {
|
|
1168
|
+
exitCode: EXIT_USAGE,
|
|
1169
|
+
required: [`--has: one of ${[...HAS_FILTERS].join(", ")}`],
|
|
1170
|
+
received: [hasFilter],
|
|
1171
|
+
nextSteps: ["Use a supported --has value."],
|
|
1172
|
+
errorCode: "HAS_FILTER_INVALID",
|
|
1173
|
+
});
|
|
1174
|
+
}
|
|
1175
|
+
const contextN = (() => {
|
|
1176
|
+
const n = opts["context"];
|
|
1177
|
+
if (n === undefined || n === null || n === "")
|
|
1178
|
+
return 0;
|
|
1179
|
+
const parsed = Number(n);
|
|
1180
|
+
return Number.isFinite(parsed) && parsed >= 0 ? Math.floor(parsed) : 0;
|
|
1181
|
+
})();
|
|
1182
|
+
const grepMatcher = buildGrepMatcher(grep);
|
|
1183
|
+
const speakerToActor = (actorFilter || speakerFilter) ? speakerSourceActorMap(script) : new Map();
|
|
1184
|
+
if (!inFilter.epId && !grep && !actorFilter && !speakerFilter && !typeFilter && !hasFilter && inFilter.actionIndex === null) {
|
|
1185
|
+
throw new CliError("QUERY BLOCKED: actions filter missing", "actions filter missing.", {
|
|
1186
|
+
exitCode: EXIT_USAGE,
|
|
1187
|
+
required: ["at least one of --in / --grep / --type / --actor / --speaker / --has"],
|
|
1188
|
+
received: ["<empty>"],
|
|
1189
|
+
nextSteps: ["Add a filter — dumping every action across all episodes is too large."],
|
|
1190
|
+
errorCode: "ACTIONS_FILTER_MISSING",
|
|
1191
|
+
});
|
|
1192
|
+
}
|
|
1193
|
+
const allInScope = [];
|
|
1194
|
+
const matchKeys = new Set();
|
|
1195
|
+
for (const ep of asList(script["episodes"])) {
|
|
1196
|
+
const epId = strOf(ep["episode_id"]);
|
|
1197
|
+
if (inFilter.epId && inFilter.epId !== epId)
|
|
1198
|
+
continue;
|
|
1199
|
+
for (const scene of asList(ep["scenes"])) {
|
|
1200
|
+
const sceneId = strOf(scene["scene_id"]);
|
|
1201
|
+
if (inFilter.sceneId && inFilter.sceneId !== sceneId)
|
|
1202
|
+
continue;
|
|
1203
|
+
const actions = asList(scene["actions"]);
|
|
1204
|
+
for (let i = 0; i < actions.length; i++) {
|
|
1205
|
+
if (inFilter.actionIndex !== null && inFilter.actionIndex !== i)
|
|
1206
|
+
continue;
|
|
1207
|
+
const a = actions[i];
|
|
1208
|
+
if (typeFilter && strOf(a["type"]).trim() !== typeFilter)
|
|
1209
|
+
continue;
|
|
1210
|
+
if (speakerFilter && !speakerIdsInAction(a).has(speakerFilter))
|
|
1211
|
+
continue;
|
|
1212
|
+
if (actorFilter && !actorIdsInAction(a, speakerToActor).has(actorFilter))
|
|
1213
|
+
continue;
|
|
1214
|
+
if (hasFilter === "state-changes" && asList(a["state_changes"]).length === 0)
|
|
1215
|
+
continue;
|
|
1216
|
+
if (hasFilter === "transition" && !isDict(a["transition_prompt"]))
|
|
1217
|
+
continue;
|
|
1218
|
+
if (hasFilter === "lines" && asList(a["lines"]).length === 0)
|
|
1219
|
+
continue;
|
|
1220
|
+
if (grepMatcher) {
|
|
1221
|
+
let hay = strOf(a["content"]);
|
|
1222
|
+
for (const line of asList(a["lines"]))
|
|
1223
|
+
hay += "\n" + strOf(line["content"]);
|
|
1224
|
+
if (!grepMatcher(hay))
|
|
1225
|
+
continue;
|
|
1226
|
+
}
|
|
1227
|
+
const key = `${epId}/${sceneId}#${i}`;
|
|
1228
|
+
matchKeys.add(key);
|
|
1229
|
+
allInScope.push({ epId, sceneId, idx: i, action: a });
|
|
1230
|
+
}
|
|
1231
|
+
}
|
|
1232
|
+
}
|
|
1233
|
+
// --context N: expand matches to include neighbors within each scene.
|
|
1234
|
+
const lines = [];
|
|
1235
|
+
if (contextN > 0 && matchKeys.size > 0) {
|
|
1236
|
+
// Walk again, emitting any hit within N of a match (same scene).
|
|
1237
|
+
const matchBySceneIdx = new Map();
|
|
1238
|
+
for (const k of matchKeys) {
|
|
1239
|
+
const [scene, idxStr] = k.split("#");
|
|
1240
|
+
const set = matchBySceneIdx.get(scene) ?? new Set();
|
|
1241
|
+
set.add(parseInt(idxStr, 10));
|
|
1242
|
+
matchBySceneIdx.set(scene, set);
|
|
1243
|
+
}
|
|
1244
|
+
for (const ep of asList(script["episodes"])) {
|
|
1245
|
+
const epId = strOf(ep["episode_id"]);
|
|
1246
|
+
for (const scene of asList(ep["scenes"])) {
|
|
1247
|
+
const sceneKey = `${epId}/${scene["scene_id"]}`;
|
|
1248
|
+
const matchIdxSet = matchBySceneIdx.get(sceneKey);
|
|
1249
|
+
if (!matchIdxSet)
|
|
1250
|
+
continue;
|
|
1251
|
+
const actions = asList(scene["actions"]);
|
|
1252
|
+
for (let i = 0; i < actions.length; i++) {
|
|
1253
|
+
let withinRange = false;
|
|
1254
|
+
for (const m of matchIdxSet) {
|
|
1255
|
+
if (Math.abs(i - m) <= contextN) {
|
|
1256
|
+
withinRange = true;
|
|
1257
|
+
break;
|
|
1258
|
+
}
|
|
1259
|
+
}
|
|
1260
|
+
if (!withinRange)
|
|
1261
|
+
continue;
|
|
1262
|
+
lines.push(formatInspectAction(epId, strOf(scene["scene_id"]), i, actions[i]));
|
|
1263
|
+
}
|
|
1264
|
+
}
|
|
1265
|
+
}
|
|
1266
|
+
}
|
|
1267
|
+
else {
|
|
1268
|
+
for (const hit of allInScope) {
|
|
1269
|
+
lines.push(formatInspectAction(hit.epId, hit.sceneId, hit.idx, hit.action));
|
|
1270
|
+
}
|
|
1271
|
+
}
|
|
1272
|
+
return [buildReport("query.actions", "ACTIONS", lines, session.artifactLabel, ["Use `scriptctl replace <addr>` or `scriptctl type <addr> <type>` etc. to edit."]), EXIT_OK];
|
|
1273
|
+
}
|
|
1274
|
+
// ----- actors / locations / props / assets ----------------------------------
|
|
1275
|
+
function listAssetsByKind(script, kind, opts) {
|
|
1276
|
+
const lines = [];
|
|
1277
|
+
const [key, idKey, nameKey] = (() => {
|
|
1278
|
+
switch (kind) {
|
|
1279
|
+
case "actor": return ["actors", "actor_id", "actor_name"];
|
|
1280
|
+
case "location": return ["locations", "location_id", "location_name"];
|
|
1281
|
+
case "prop": return ["props", "prop_id", "prop_name"];
|
|
1282
|
+
}
|
|
1283
|
+
})();
|
|
1284
|
+
// Build set of ids referenced inside the --in scope if filter is set.
|
|
1285
|
+
let scopeIds = null;
|
|
1286
|
+
if (opts.inFilter.epId) {
|
|
1287
|
+
scopeIds = new Set();
|
|
1288
|
+
for (const ep of asList(script["episodes"])) {
|
|
1289
|
+
if (strOf(ep["episode_id"]) !== opts.inFilter.epId)
|
|
1290
|
+
continue;
|
|
1291
|
+
for (const scene of asList(ep["scenes"])) {
|
|
1292
|
+
if (opts.inFilter.sceneId && strOf(scene["scene_id"]) !== opts.inFilter.sceneId)
|
|
1293
|
+
continue;
|
|
1294
|
+
const ctx = isDict(scene["context"]) ? scene["context"] : {};
|
|
1295
|
+
const pluralKey = kind === "actor" ? "actors" : kind === "location" ? "locations" : "props";
|
|
1296
|
+
for (const ref of asList(ctx[pluralKey])) {
|
|
1297
|
+
scopeIds.add(strOf(ref[idKey]));
|
|
1298
|
+
}
|
|
1299
|
+
if (opts.inFilter.actionIndex !== null) {
|
|
1300
|
+
// Narrow further to a single action's refs.
|
|
1301
|
+
const actions = asList(scene["actions"]);
|
|
1302
|
+
const a = actions[opts.inFilter.actionIndex];
|
|
1303
|
+
if (!a)
|
|
1304
|
+
continue;
|
|
1305
|
+
scopeIds = new Set();
|
|
1306
|
+
if (kind === "actor") {
|
|
1307
|
+
const aid = strOf(a["actor_id"]).trim();
|
|
1308
|
+
if (aid)
|
|
1309
|
+
scopeIds.add(aid);
|
|
1310
|
+
}
|
|
1311
|
+
}
|
|
1312
|
+
}
|
|
1313
|
+
}
|
|
1314
|
+
}
|
|
1315
|
+
for (const asset of asList(script[key])) {
|
|
1316
|
+
const id = strOf(asset[idKey]);
|
|
1317
|
+
const name = strOf(asset[nameKey]);
|
|
1318
|
+
if (opts.id && opts.id !== id)
|
|
1319
|
+
continue;
|
|
1320
|
+
if (opts.name && !name.includes(opts.name))
|
|
1321
|
+
continue;
|
|
1322
|
+
if (scopeIds && !scopeIds.has(id))
|
|
1323
|
+
continue;
|
|
1324
|
+
const aliases = asList(asset["aliases"]).map((s) => strOf(s));
|
|
1325
|
+
const role = strOf(asset["role_type"]);
|
|
1326
|
+
const descPresent = strOf(asset["description"]).trim() ? "yes" : "missing";
|
|
1327
|
+
const states = asList(asset["states"]).length;
|
|
1328
|
+
const extra = [];
|
|
1329
|
+
if (aliases.length > 0)
|
|
1330
|
+
extra.push(`aliases=(${aliases.join(",")})`);
|
|
1331
|
+
if (role)
|
|
1332
|
+
extra.push(`role=${role}`);
|
|
1333
|
+
extra.push(`states=${states}`, `description=${descPresent}`);
|
|
1334
|
+
lines.push(`${kind} ${id}: ${name || "-"} ${extra.join(" ")}`);
|
|
1335
|
+
}
|
|
1336
|
+
return lines;
|
|
1337
|
+
}
|
|
1338
|
+
export async function commandActors(opts) {
|
|
1339
|
+
const session = await loadScriptForEdit(opts);
|
|
1340
|
+
const lines = listAssetsByKind(session.script, "actor", {
|
|
1341
|
+
id: strOf(opts["id"]).trim(),
|
|
1342
|
+
name: strOf(opts["name"]).trim(),
|
|
1343
|
+
inFilter: parseInFilter(strOf(opts["in"])),
|
|
1344
|
+
});
|
|
1345
|
+
return [buildReport("query.actors", "ACTORS", lines, session.artifactLabel, ["Use `scriptctl rename actor:<id>` / `describe` / `merge` to edit."]), EXIT_OK];
|
|
1346
|
+
}
|
|
1347
|
+
export async function commandLocations(opts) {
|
|
1348
|
+
const session = await loadScriptForEdit(opts);
|
|
1349
|
+
const lines = listAssetsByKind(session.script, "location", {
|
|
1350
|
+
id: strOf(opts["id"]).trim(),
|
|
1351
|
+
name: strOf(opts["name"]).trim(),
|
|
1352
|
+
inFilter: parseInFilter(strOf(opts["in"])),
|
|
1353
|
+
});
|
|
1354
|
+
return [buildReport("query.locations", "LOCATIONS", lines, session.artifactLabel, ["Use `scriptctl rename location:<id>` etc. to edit."]), EXIT_OK];
|
|
1355
|
+
}
|
|
1356
|
+
export async function commandProps(opts) {
|
|
1357
|
+
const session = await loadScriptForEdit(opts);
|
|
1358
|
+
const lines = listAssetsByKind(session.script, "prop", {
|
|
1359
|
+
id: strOf(opts["id"]).trim(),
|
|
1360
|
+
name: strOf(opts["name"]).trim(),
|
|
1361
|
+
inFilter: parseInFilter(strOf(opts["in"])),
|
|
1362
|
+
});
|
|
1363
|
+
return [buildReport("query.props", "PROPS", lines, session.artifactLabel, ["Use `scriptctl rename prop:<id>` etc. to edit."]), EXIT_OK];
|
|
1364
|
+
}
|
|
1365
|
+
export async function commandAssets(opts) {
|
|
1366
|
+
const session = await loadScriptForEdit(opts);
|
|
1367
|
+
const kindFilter = strOf(opts["kind"]).trim();
|
|
1368
|
+
const nameOpt = strOf(opts["name"]).trim();
|
|
1369
|
+
const idOpt = strOf(opts["id"]).trim();
|
|
1370
|
+
const inFilter = parseInFilter(strOf(opts["in"]));
|
|
1371
|
+
const lines = [];
|
|
1372
|
+
for (const kind of ["actor", "location", "prop"]) {
|
|
1373
|
+
if (kindFilter && kindFilter !== kind)
|
|
1374
|
+
continue;
|
|
1375
|
+
lines.push(...listAssetsByKind(session.script, kind, { id: idOpt, name: nameOpt, inFilter }));
|
|
1376
|
+
}
|
|
1377
|
+
return [buildReport("query.assets", "ASSETS", lines, session.artifactLabel, ["Use `scriptctl actors` / `locations` / `props` for kind-scoped views."]), EXIT_OK];
|
|
1378
|
+
}
|
|
1379
|
+
// ----- speakers -------------------------------------------------------------
|
|
1380
|
+
export async function commandSpeakers(opts) {
|
|
1381
|
+
const session = await loadScriptForEdit(opts);
|
|
1382
|
+
const script = session.script;
|
|
1383
|
+
const idOpt = strOf(opts["id"]).trim();
|
|
1384
|
+
const nameOpt = strOf(opts["name"]).trim();
|
|
1385
|
+
const kindOpt = strOf(opts["kind"]).trim();
|
|
1386
|
+
const lines = [];
|
|
1387
|
+
for (const sp of asList(script["speakers"])) {
|
|
1388
|
+
const id = strOf(sp["speaker_id"]);
|
|
1389
|
+
const name = strOf(sp["display_name"]);
|
|
1390
|
+
const sourceKind = strOf(sp["source_kind"]);
|
|
1391
|
+
const sourceId = strOf(sp["source_id"]);
|
|
1392
|
+
if (idOpt && idOpt !== id)
|
|
1393
|
+
continue;
|
|
1394
|
+
if (nameOpt && !name.includes(nameOpt))
|
|
1395
|
+
continue;
|
|
1396
|
+
if (kindOpt && kindOpt !== sourceKind)
|
|
1397
|
+
continue;
|
|
1398
|
+
lines.push(`${id}: ${name} [${sourceKind}] source=${sourceId || "-"}`);
|
|
1399
|
+
}
|
|
1400
|
+
return [buildReport("query.speakers", "SPEAKERS", lines, session.artifactLabel, ["Use `scriptctl add-speaker` to register more, or `scriptctl refs <spk_id>` to see usage."]), EXIT_OK];
|
|
1401
|
+
}
|
|
1402
|
+
// ----- issues ---------------------------------------------------------------
|
|
1403
|
+
export async function commandIssues(opts) {
|
|
1404
|
+
const session = await loadScriptForEdit(opts);
|
|
1405
|
+
const severityFilter = strOf(opts["severity"]).trim();
|
|
1406
|
+
const codeFilter = strOf(opts["code"]).trim();
|
|
1407
|
+
const validation = validateSession(session);
|
|
1408
|
+
const lines = [];
|
|
1409
|
+
for (const issue of asList(validation["issues"])) {
|
|
1410
|
+
if (severityFilter && severityFilter !== strOf(issue["severity"]))
|
|
1411
|
+
continue;
|
|
1412
|
+
if (codeFilter && codeFilter !== strOf(issue["code"]))
|
|
1413
|
+
continue;
|
|
1414
|
+
const whereParts = [];
|
|
1415
|
+
for (const k of ["episode", "scene", "action_index"]) {
|
|
1416
|
+
if (issue[k] !== null && issue[k] !== undefined)
|
|
1417
|
+
whereParts.push(strOf(issue[k]));
|
|
1418
|
+
}
|
|
1419
|
+
const where = whereParts.join(" ");
|
|
1420
|
+
lines.push(`${issue["severity"]} ${issue["code"]}: ${issue["summary"]}${where ? ` [${where}]` : ""}`);
|
|
1421
|
+
}
|
|
1422
|
+
return [buildReport("query.issues", "ISSUES", lines, session.artifactLabel, ["Use the relevant edit verb to repair each issue."]), EXIT_OK];
|
|
1423
|
+
}
|
|
1424
|
+
// ----- refs (unified reverse lookup) ----------------------------------------
|
|
1425
|
+
export async function commandRefs(opts) {
|
|
1426
|
+
const session = await loadScriptForEdit(opts);
|
|
1427
|
+
const script = session.script;
|
|
1428
|
+
const args = asList(opts["_args"]);
|
|
1429
|
+
const target = strOf(args[0]).trim();
|
|
1430
|
+
if (!target) {
|
|
1431
|
+
throw new CliError("QUERY BLOCKED: refs target missing", "refs target missing.", {
|
|
1432
|
+
exitCode: EXIT_USAGE,
|
|
1433
|
+
required: ["address: actor|location|prop:<id>[/<state_id>] or spk_<id>"],
|
|
1434
|
+
received: ["<empty>"],
|
|
1435
|
+
nextSteps: ["Pass a recognized address."],
|
|
1436
|
+
errorCode: "REFS_TARGET_MISSING",
|
|
1437
|
+
});
|
|
1438
|
+
}
|
|
1439
|
+
const level = strOf(opts["level"]).trim(); // scene|action|""
|
|
1440
|
+
const addr = parseAnyAddress(target);
|
|
1441
|
+
let refs = [];
|
|
1442
|
+
if (addr.kind === "state") {
|
|
1443
|
+
refs = collectStateRefs(script, addr.assetKind, addr.assetId, addr.stateId);
|
|
1444
|
+
}
|
|
1445
|
+
else if (addr.kind === "asset") {
|
|
1446
|
+
refs = collectAssetRefs(script, addr.assetKind, addr.assetId);
|
|
1447
|
+
}
|
|
1448
|
+
else if (addr.kind === "speaker") {
|
|
1449
|
+
refs = collectAssetRefs(script, "speaker", addr.speakerId);
|
|
1450
|
+
}
|
|
1451
|
+
else {
|
|
1452
|
+
throw new CliError("QUERY BLOCKED: refs target invalid", "refs target invalid.", {
|
|
1453
|
+
exitCode: EXIT_USAGE,
|
|
1454
|
+
required: ["asset / state / speaker address"],
|
|
1455
|
+
received: [target],
|
|
1456
|
+
nextSteps: ["Use a kind:id, kind:id/state_id, or spk_id form."],
|
|
1457
|
+
errorCode: "REFS_TARGET_INVALID",
|
|
1458
|
+
});
|
|
1459
|
+
}
|
|
1460
|
+
const lines = [];
|
|
1461
|
+
for (const ref of refs) {
|
|
1462
|
+
const role = strOf(ref["role"]);
|
|
1463
|
+
if (level === "scene" && !role.startsWith("scene_") && role !== "speaker_source")
|
|
1464
|
+
continue;
|
|
1465
|
+
if (level === "action" && (role.startsWith("scene_") || role === "speaker_source"))
|
|
1466
|
+
continue;
|
|
1467
|
+
lines.push(`${ref["location"]} [${ref["role"]}]`);
|
|
1468
|
+
}
|
|
1469
|
+
return [buildReport("query.refs", `REFS ${target}`, lines, session.artifactLabel, ["Use the relevant edit verb (merge / delete --strategy / rename) to mutate."]), EXIT_OK];
|
|
1470
|
+
}
|
|
1471
|
+
// ===========================================================================
|
|
1472
|
+
// 0.6.0 edit verbs (flat top-level — replace `script <group> <leaf>` chains)
|
|
1473
|
+
// ===========================================================================
|
|
1474
|
+
//
|
|
1475
|
+
// Each verb is a thin function over applySingleScriptOp / parseAnyAddress.
|
|
1476
|
+
// Multipolar verbs (delete / merge / move / describe / insert) dispatch on
|
|
1477
|
+
// the first positional address to pick the underlying dot-style patch op.
|
|
1478
|
+
// ----- content-level (action) -----------------------------------------------
|
|
1479
|
+
export async function commandReplace(opts) {
|
|
1480
|
+
const args = asList(opts["_args"]);
|
|
1481
|
+
if (args.length < 1) {
|
|
1482
|
+
throw new CliError("VERB BLOCKED: Action ref missing", "Action ref missing.", {
|
|
1483
|
+
exitCode: EXIT_USAGE,
|
|
1484
|
+
required: ["action ref: ep_001/scn_001#3"],
|
|
1485
|
+
received: ["<empty>"],
|
|
1486
|
+
nextSteps: ["Run `scriptctl --help` for the full command surface."],
|
|
1487
|
+
errorCode: "ACTION_REF_MISSING",
|
|
1488
|
+
});
|
|
1489
|
+
}
|
|
1490
|
+
if (opts["from"] === undefined) {
|
|
1491
|
+
throw new CliError("VERB BLOCKED: --from missing", "--from missing.", {
|
|
1492
|
+
exitCode: EXIT_USAGE,
|
|
1493
|
+
required: ["--from <text>"],
|
|
1494
|
+
received: ["<missing>"],
|
|
1495
|
+
nextSteps: ["Pass --from with the substring to replace."],
|
|
1496
|
+
errorCode: "FROM_TEXT_EMPTY",
|
|
1497
|
+
});
|
|
1498
|
+
}
|
|
1499
|
+
return applySingleScriptOp(opts, {
|
|
1500
|
+
op: "action.content.replace",
|
|
1501
|
+
at: args[0],
|
|
1502
|
+
from: opts["from"],
|
|
1503
|
+
to: opts["to"] ?? "",
|
|
1504
|
+
all: Boolean(opts["all"]),
|
|
1505
|
+
});
|
|
1506
|
+
}
|
|
1507
|
+
export async function commandType(opts) {
|
|
1508
|
+
const args = asList(opts["_args"]);
|
|
1509
|
+
if (args.length < 2) {
|
|
1510
|
+
throw new CliError("VERB BLOCKED: Type args missing", "Type args missing.", {
|
|
1511
|
+
exitCode: EXIT_USAGE,
|
|
1512
|
+
required: ["<action ref> <type>"],
|
|
1513
|
+
received: args,
|
|
1514
|
+
nextSteps: ["Example: scriptctl type ep_001/scn_001#3 dialogue"],
|
|
1515
|
+
errorCode: "ARGS_MISSING",
|
|
1516
|
+
});
|
|
1517
|
+
}
|
|
1518
|
+
return applySingleScriptOp(opts, { op: "action.type.set", at: args[0], type: args[1] });
|
|
1519
|
+
}
|
|
1520
|
+
export async function commandActor(opts) {
|
|
1521
|
+
const args = asList(opts["_args"]);
|
|
1522
|
+
if (args.length < 2) {
|
|
1523
|
+
throw new CliError("VERB BLOCKED: Actor args missing", "Actor args missing.", {
|
|
1524
|
+
exitCode: EXIT_USAGE,
|
|
1525
|
+
required: ["<action ref> <actor_id|none>"],
|
|
1526
|
+
received: args,
|
|
1527
|
+
nextSteps: ["Example: scriptctl actor ep_001/scn_001#3 act_002"],
|
|
1528
|
+
errorCode: "ARGS_MISSING",
|
|
1529
|
+
});
|
|
1530
|
+
}
|
|
1531
|
+
// Empty string is almost always a shell-expansion accident ("$ACTOR" with
|
|
1532
|
+
// $ACTOR unset). Old set_action_actor required explicit allow_null; we
|
|
1533
|
+
// restore the safety by demanding the literal token `none` to clear.
|
|
1534
|
+
if (args[1].trim() === "") {
|
|
1535
|
+
throw new CliError("VERB BLOCKED: actor_id empty", "actor_id is empty.", {
|
|
1536
|
+
exitCode: EXIT_USAGE,
|
|
1537
|
+
required: ["<actor_id> or the literal 'none' to clear"],
|
|
1538
|
+
received: [`"${args[1]}"`],
|
|
1539
|
+
nextSteps: ["Pass an existing actor id, or pass 'none' to explicitly clear."],
|
|
1540
|
+
errorCode: "ACTOR_ID_EMPTY",
|
|
1541
|
+
});
|
|
1542
|
+
}
|
|
1543
|
+
return applySingleScriptOp(opts, { op: "action.actor.set", at: args[0], actor_id: args[1] });
|
|
1544
|
+
}
|
|
1545
|
+
// ----- structural (multipolar delete / move / insert) -----------------------
|
|
1546
|
+
export async function commandDelete(opts) {
|
|
1547
|
+
const args = asList(opts["_args"]);
|
|
1548
|
+
if (args.length < 1) {
|
|
1549
|
+
throw new CliError("VERB BLOCKED: Address missing", "Address missing.", {
|
|
1550
|
+
exitCode: EXIT_USAGE,
|
|
1551
|
+
required: ["address"],
|
|
1552
|
+
received: ["<empty>"],
|
|
1553
|
+
nextSteps: ["Pass an address (action / scene / asset / speaker)."],
|
|
1554
|
+
errorCode: "ADDRESS_MISSING",
|
|
1555
|
+
});
|
|
1556
|
+
}
|
|
1557
|
+
const addr = parseAnyAddress(args[0]);
|
|
1558
|
+
// Per-kind flag whitelist — silently ignoring wrong-kind flags hid real
|
|
1559
|
+
// misuse (e.g. `delete actor:X --force` did NOT bypass ref protection, but
|
|
1560
|
+
// the user assumed it did). Surface the mismatch with an explicit error.
|
|
1561
|
+
const flagsUsed = {
|
|
1562
|
+
strategy: opts["strategy"] !== undefined,
|
|
1563
|
+
replacement: opts["replacement"] !== undefined,
|
|
1564
|
+
force: Boolean(opts["force"]),
|
|
1565
|
+
};
|
|
1566
|
+
function rejectFlags(kind, allowed) {
|
|
1567
|
+
const allow = new Set(allowed);
|
|
1568
|
+
const bad = [];
|
|
1569
|
+
for (const f of ["strategy", "replacement", "force"]) {
|
|
1570
|
+
if (flagsUsed[f] && !allow.has(f))
|
|
1571
|
+
bad.push(`--${f}`);
|
|
1572
|
+
}
|
|
1573
|
+
if (bad.length > 0) {
|
|
1574
|
+
throw new CliError("VERB BLOCKED: flag not applicable", `${bad.join(", ")} cannot be used with ${kind} delete.`, {
|
|
1575
|
+
exitCode: EXIT_USAGE,
|
|
1576
|
+
required: [`for ${kind} delete: ${allowed.length === 0 ? "no extra flags" : allowed.map((f) => `--${f}`).join(" / ")}`],
|
|
1577
|
+
received: bad,
|
|
1578
|
+
nextSteps: ["Drop the unsupported flag(s) and retry."],
|
|
1579
|
+
errorCode: "DELETE_FLAG_INVALID",
|
|
1580
|
+
});
|
|
1581
|
+
}
|
|
1582
|
+
}
|
|
1583
|
+
const op = (() => {
|
|
1584
|
+
if (addr.kind === "action") {
|
|
1585
|
+
rejectFlags("action", []);
|
|
1586
|
+
return { op: "action.delete", at: args[0] };
|
|
1587
|
+
}
|
|
1588
|
+
if (addr.kind === "scene") {
|
|
1589
|
+
rejectFlags("scene", ["force"]);
|
|
1590
|
+
return { op: "scene.delete", at: args[0], force: Boolean(opts["force"]) };
|
|
1591
|
+
}
|
|
1592
|
+
if (addr.kind === "asset") {
|
|
1593
|
+
rejectFlags("asset", ["strategy", "replacement"]);
|
|
1594
|
+
return {
|
|
1595
|
+
op: "asset.delete",
|
|
1596
|
+
target: args[0],
|
|
1597
|
+
strategy: opts["strategy"],
|
|
1598
|
+
replacement: opts["replacement"],
|
|
1599
|
+
};
|
|
1600
|
+
}
|
|
1601
|
+
if (addr.kind === "speaker") {
|
|
1602
|
+
rejectFlags("speaker", ["strategy", "replacement"]);
|
|
1603
|
+
return {
|
|
1604
|
+
op: "speaker.delete",
|
|
1605
|
+
target: addr.speakerId,
|
|
1606
|
+
strategy: opts["strategy"],
|
|
1607
|
+
replacement: opts["replacement"],
|
|
1608
|
+
};
|
|
1609
|
+
}
|
|
1610
|
+
throw new CliError("VERB BLOCKED: delete cannot operate on this address", "delete cannot operate on this address.", {
|
|
1611
|
+
exitCode: EXIT_USAGE,
|
|
1612
|
+
required: ["action / scene / asset / speaker address"],
|
|
1613
|
+
received: [args[0]],
|
|
1614
|
+
nextSteps: ["Use a supported address."],
|
|
1615
|
+
errorCode: "ADDRESS_UNSUPPORTED",
|
|
1616
|
+
});
|
|
1617
|
+
})();
|
|
1618
|
+
return applySingleScriptOp(opts, op);
|
|
1619
|
+
}
|
|
1620
|
+
export async function commandMove(opts) {
|
|
1621
|
+
const args = asList(opts["_args"]);
|
|
1622
|
+
if (args.length < 2) {
|
|
1623
|
+
throw new CliError("VERB BLOCKED: Move args missing", "Move args missing.", {
|
|
1624
|
+
exitCode: EXIT_USAGE,
|
|
1625
|
+
required: ["<from-addr> <to-addr>"],
|
|
1626
|
+
received: args,
|
|
1627
|
+
nextSteps: ["Example: scriptctl move ep_001/scn_001#5 ep_001/scn_002#0"],
|
|
1628
|
+
errorCode: "ARGS_MISSING",
|
|
1629
|
+
});
|
|
1630
|
+
}
|
|
1631
|
+
const addr = parseAnyAddress(args[0]);
|
|
1632
|
+
if (addr.kind === "action") {
|
|
1633
|
+
return applySingleScriptOp(opts, { op: "action.move", at: args[0], to: args[1] });
|
|
1634
|
+
}
|
|
1635
|
+
if (addr.kind === "scene") {
|
|
1636
|
+
return applySingleScriptOp(opts, {
|
|
1637
|
+
op: "scene.move", at: args[0], to: args[1], at_index: opts["at_index"] ?? opts["at"],
|
|
1638
|
+
});
|
|
1639
|
+
}
|
|
1640
|
+
throw new CliError("VERB BLOCKED: move cannot operate on this address", "move cannot operate on this address.", {
|
|
1641
|
+
exitCode: EXIT_USAGE,
|
|
1642
|
+
required: ["action / scene address"],
|
|
1643
|
+
received: [args[0]],
|
|
1644
|
+
nextSteps: ["Use an action or scene address."],
|
|
1645
|
+
errorCode: "ADDRESS_UNSUPPORTED",
|
|
1646
|
+
});
|
|
1647
|
+
}
|
|
1648
|
+
export async function commandInsert(opts) {
|
|
1649
|
+
const args = asList(opts["_args"]);
|
|
1650
|
+
if (args.length < 1) {
|
|
1651
|
+
throw new CliError("VERB BLOCKED: Insert address missing", "Insert address missing.", {
|
|
1652
|
+
exitCode: EXIT_USAGE,
|
|
1653
|
+
required: ["<addr>: ep_NNN/scn_NNN (action insert) or ep_NNN (scene insert)"],
|
|
1654
|
+
received: ["<empty>"],
|
|
1655
|
+
nextSteps: ["Example: scriptctl insert ep_001/scn_001 --type action --content \"...\""],
|
|
1656
|
+
errorCode: "ADDRESS_MISSING",
|
|
1657
|
+
});
|
|
1658
|
+
}
|
|
1659
|
+
const addr = parseAnyAddress(args[0]);
|
|
1660
|
+
// Per-kind flag whitelist. Silently dropping mismatched flags hid two
|
|
1661
|
+
// real bugs:
|
|
1662
|
+
// - scene insert was ignoring --scene-id (the user lost custom IDs).
|
|
1663
|
+
// - mixing action + scene insert flags (`--type` with episode addr, or
|
|
1664
|
+
// `--location` with scene addr) ran the wrong dispatch silently.
|
|
1665
|
+
const flagsUsed = {
|
|
1666
|
+
type: opts["type"] !== undefined,
|
|
1667
|
+
content: opts["content"] !== undefined,
|
|
1668
|
+
actor: (opts["actor_id"] ?? opts["actor"]) !== undefined,
|
|
1669
|
+
speaker: (opts["speaker_id"] ?? opts["speaker"]) !== undefined,
|
|
1670
|
+
location: opts["location"] !== undefined,
|
|
1671
|
+
time: opts["time"] !== undefined,
|
|
1672
|
+
space: opts["space"] !== undefined,
|
|
1673
|
+
"scene-id": opts["scene_id"] !== undefined,
|
|
1674
|
+
};
|
|
1675
|
+
function rejectFlags(kind, allowed) {
|
|
1676
|
+
const allow = new Set(allowed);
|
|
1677
|
+
const bad = [];
|
|
1678
|
+
for (const f of Object.keys(flagsUsed)) {
|
|
1679
|
+
if (flagsUsed[f] && !allow.has(f))
|
|
1680
|
+
bad.push(`--${f}`);
|
|
1681
|
+
}
|
|
1682
|
+
if (bad.length > 0) {
|
|
1683
|
+
throw new CliError("VERB BLOCKED: flag not applicable", `${bad.join(", ")} cannot be used with ${kind} insert.`, {
|
|
1684
|
+
exitCode: EXIT_USAGE,
|
|
1685
|
+
required: [`for ${kind} insert: ${allowed.map((f) => `--${f}`).join(" / ")}`],
|
|
1686
|
+
received: bad,
|
|
1687
|
+
nextSteps: ["Drop the unsupported flag(s) and retry."],
|
|
1688
|
+
errorCode: "INSERT_FLAG_INVALID",
|
|
1689
|
+
});
|
|
1690
|
+
}
|
|
1691
|
+
}
|
|
1692
|
+
if (addr.kind === "scene") {
|
|
1693
|
+
rejectFlags("action", ["type", "content", "actor", "speaker"]);
|
|
1694
|
+
return applySingleScriptOp(opts, {
|
|
1695
|
+
op: "action.insert",
|
|
1696
|
+
at: args[0],
|
|
1697
|
+
type: opts["type"],
|
|
1698
|
+
content: opts["content"],
|
|
1699
|
+
at_index: opts["at_index"] ?? opts["at"],
|
|
1700
|
+
before: opts["before"],
|
|
1701
|
+
after: opts["after"],
|
|
1702
|
+
actor_id: opts["actor_id"] ?? opts["actor"],
|
|
1703
|
+
speaker_id: opts["speaker_id"] ?? opts["speaker"],
|
|
1704
|
+
});
|
|
1705
|
+
}
|
|
1706
|
+
if (addr.kind === "episode") {
|
|
1707
|
+
rejectFlags("scene", ["location", "time", "space", "scene-id"]);
|
|
1708
|
+
return applySingleScriptOp(opts, {
|
|
1709
|
+
op: "scene.insert",
|
|
1710
|
+
at: addr.episodeId,
|
|
1711
|
+
location: opts["location"],
|
|
1712
|
+
time: opts["time"],
|
|
1713
|
+
space: opts["space"],
|
|
1714
|
+
// Forward --scene-id (was previously silently dropped — scene.insert op
|
|
1715
|
+
// reads `scene_id` to honor user-supplied ids for round-trip stability).
|
|
1716
|
+
scene_id: opts["scene_id"],
|
|
1717
|
+
at_index: opts["at_index"] ?? opts["at"],
|
|
1718
|
+
before: opts["before"],
|
|
1719
|
+
after: opts["after"],
|
|
1720
|
+
});
|
|
1721
|
+
}
|
|
1722
|
+
throw new CliError("VERB BLOCKED: insert cannot operate on this address", "insert cannot operate on this address.", {
|
|
1723
|
+
exitCode: EXIT_USAGE,
|
|
1724
|
+
required: ["ep_NNN (scene insert) or ep_NNN/scn_NNN (action insert)"],
|
|
1725
|
+
received: [args[0]],
|
|
1726
|
+
nextSteps: ["Use an episode or scene address."],
|
|
1727
|
+
errorCode: "ADDRESS_UNSUPPORTED",
|
|
1728
|
+
});
|
|
1729
|
+
}
|
|
1730
|
+
export async function commandSplit(opts) {
|
|
1731
|
+
const args = asList(opts["_args"]);
|
|
1732
|
+
if (args.length < 1) {
|
|
1733
|
+
throw new CliError("VERB BLOCKED: Scene ref missing", "Scene ref missing.", {
|
|
1734
|
+
exitCode: EXIT_USAGE,
|
|
1735
|
+
required: ["<ep/scn>"],
|
|
1736
|
+
received: ["<empty>"],
|
|
1737
|
+
nextSteps: ["Example: scriptctl split ep_001/scn_005 --at 7"],
|
|
1738
|
+
errorCode: "ARGS_MISSING",
|
|
1739
|
+
});
|
|
1740
|
+
}
|
|
1741
|
+
// Commander hands back --at as a string; the scene.split handler does a
|
|
1742
|
+
// strict typeof === "number" check (no implicit coercion), so we coerce here
|
|
1743
|
+
// before dispatch. Without this, every CLI `scriptctl split` invocation
|
|
1744
|
+
// would fail with SPLIT_INDEX_INVALID.
|
|
1745
|
+
const rawAt = opts["at_index"] ?? opts["at"];
|
|
1746
|
+
const atIndex = rawAt === undefined || rawAt === null || rawAt === "" ? undefined : Number(rawAt);
|
|
1747
|
+
if (atIndex !== undefined && (!Number.isFinite(atIndex) || !Number.isInteger(atIndex))) {
|
|
1748
|
+
throw new CliError("VERB BLOCKED: --at not an integer", "--at must be an integer.", {
|
|
1749
|
+
exitCode: EXIT_USAGE,
|
|
1750
|
+
required: ["--at <integer>"],
|
|
1751
|
+
received: [String(rawAt)],
|
|
1752
|
+
nextSteps: ["Pass an integer action index."],
|
|
1753
|
+
errorCode: "AT_INDEX_INVALID",
|
|
1754
|
+
});
|
|
1755
|
+
}
|
|
1756
|
+
return applySingleScriptOp(opts, {
|
|
1757
|
+
op: "scene.split",
|
|
1758
|
+
at: args[0],
|
|
1759
|
+
at_index: atIndex,
|
|
1760
|
+
new_scene_id: opts["new_scene_id"],
|
|
1761
|
+
});
|
|
1762
|
+
}
|
|
1763
|
+
export async function commandMerge(opts) {
|
|
1764
|
+
const args = asList(opts["_args"]);
|
|
1765
|
+
if (args.length < 1) {
|
|
1766
|
+
throw new CliError("VERB BLOCKED: Source address missing", "Source address missing.", {
|
|
1767
|
+
exitCode: EXIT_USAGE,
|
|
1768
|
+
required: ["<src-addr> --into <dst-addr>"],
|
|
1769
|
+
received: args,
|
|
1770
|
+
nextSteps: ["Example: scriptctl merge actor:act_001 --into actor:act_002"],
|
|
1771
|
+
errorCode: "ARGS_MISSING",
|
|
1772
|
+
});
|
|
1773
|
+
}
|
|
1774
|
+
const into = strOf(opts["into"]);
|
|
1775
|
+
if (!into) {
|
|
1776
|
+
throw new CliError("VERB BLOCKED: --into missing", "--into missing.", {
|
|
1777
|
+
exitCode: EXIT_USAGE,
|
|
1778
|
+
required: ["--into <dst-addr>"],
|
|
1779
|
+
received: ["<missing>"],
|
|
1780
|
+
nextSteps: ["Pass --into <addr>."],
|
|
1781
|
+
errorCode: "INTO_MISSING",
|
|
1782
|
+
});
|
|
1783
|
+
}
|
|
1784
|
+
const fromAddr = parseAnyAddress(args[0]);
|
|
1785
|
+
const intoAddr = parseAnyAddress(into);
|
|
1786
|
+
if (fromAddr.kind === "asset" && intoAddr.kind === "asset") {
|
|
1787
|
+
return applySingleScriptOp(opts, { op: "asset.merge", from: args[0], into });
|
|
1788
|
+
}
|
|
1789
|
+
if (fromAddr.kind === "scene" && intoAddr.kind === "scene") {
|
|
1790
|
+
return applySingleScriptOp(opts, { op: "scene.merge", from: args[0], into });
|
|
1791
|
+
}
|
|
1792
|
+
throw new CliError("VERB BLOCKED: merge address mismatch", "merge: both sides must be the same kind (asset or scene).", {
|
|
1793
|
+
exitCode: EXIT_USAGE,
|
|
1794
|
+
required: ["asset & asset, or scene & scene"],
|
|
1795
|
+
received: [`${args[0]} → ${into}`],
|
|
1796
|
+
nextSteps: ["Use two addresses of the same kind."],
|
|
1797
|
+
errorCode: "MERGE_ADDR_MISMATCH",
|
|
1798
|
+
});
|
|
1799
|
+
}
|
|
1800
|
+
// ----- asset metadata -------------------------------------------------------
|
|
1801
|
+
export async function commandRename(opts) {
|
|
1802
|
+
const args = asList(opts["_args"]);
|
|
1803
|
+
if (args.length < 2) {
|
|
1804
|
+
throw new CliError("VERB BLOCKED: Rename args missing", "Rename args missing.", {
|
|
1805
|
+
exitCode: EXIT_USAGE,
|
|
1806
|
+
required: ["<asset-addr> <new-name>"],
|
|
1807
|
+
received: args,
|
|
1808
|
+
nextSteps: ["Example: scriptctl rename actor:act_001 \"陈墨\""],
|
|
1809
|
+
errorCode: "ARGS_MISSING",
|
|
1810
|
+
});
|
|
1811
|
+
}
|
|
1812
|
+
return applySingleScriptOp(opts, { op: "asset.rename", target: args[0], name: args[1] });
|
|
1813
|
+
}
|
|
1814
|
+
export async function commandDescribe(opts) {
|
|
1815
|
+
const args = asList(opts["_args"]);
|
|
1816
|
+
if (args.length < 2) {
|
|
1817
|
+
throw new CliError("VERB BLOCKED: Describe args missing", "Describe args missing.", {
|
|
1818
|
+
exitCode: EXIT_USAGE,
|
|
1819
|
+
required: ["<addr> <text>"],
|
|
1820
|
+
received: args,
|
|
1821
|
+
nextSteps: ["Example: scriptctl describe actor:act_001 \"男主\" or scriptctl describe actor:act_001/st_calm \"...\""],
|
|
1822
|
+
errorCode: "ARGS_MISSING",
|
|
1823
|
+
});
|
|
1824
|
+
}
|
|
1825
|
+
const addr = parseAnyAddress(args[0]);
|
|
1826
|
+
if (addr.kind === "state") {
|
|
1827
|
+
return applySingleScriptOp(opts, { op: "state.describe", target: args[0], description: args[1] });
|
|
1828
|
+
}
|
|
1829
|
+
if (addr.kind === "asset") {
|
|
1830
|
+
return applySingleScriptOp(opts, { op: "asset.describe", target: args[0], description: args[1] });
|
|
1831
|
+
}
|
|
1832
|
+
throw new CliError("VERB BLOCKED: describe address invalid", "describe address invalid.", {
|
|
1833
|
+
exitCode: EXIT_USAGE,
|
|
1834
|
+
required: ["asset address (e.g. actor:act_001) or state address (e.g. actor:act_001/st_calm)"],
|
|
1835
|
+
received: [args[0]],
|
|
1836
|
+
nextSteps: ["Use a supported address."],
|
|
1837
|
+
errorCode: "ADDRESS_UNSUPPORTED",
|
|
1838
|
+
});
|
|
1839
|
+
}
|
|
1840
|
+
export async function commandAlias(opts) {
|
|
1841
|
+
const args = asList(opts["_args"]);
|
|
1842
|
+
if (args.length < 1) {
|
|
1843
|
+
throw new CliError("VERB BLOCKED: Asset address missing", "Asset address missing.", {
|
|
1844
|
+
exitCode: EXIT_USAGE,
|
|
1845
|
+
required: ["<asset-addr>"],
|
|
1846
|
+
received: ["<empty>"],
|
|
1847
|
+
nextSteps: ["Example: scriptctl alias actor:act_001 --add \"陈总\""],
|
|
1848
|
+
errorCode: "ARGS_MISSING",
|
|
1849
|
+
});
|
|
1850
|
+
}
|
|
1851
|
+
// appendStr collectors default to []; empty arrays are truthy, so we MUST
|
|
1852
|
+
// check `.length === 0` rather than `!add` (which is always false here).
|
|
1853
|
+
// Without this, passing only --add still runs the remove op with alias=[]
|
|
1854
|
+
// and crashes mid-write at op layer after the add already succeeded.
|
|
1855
|
+
const add = asList(opts["add"]);
|
|
1856
|
+
const remove = asList(opts["remove"]);
|
|
1857
|
+
if (add.length === 0 && remove.length === 0) {
|
|
1858
|
+
throw new CliError("VERB BLOCKED: --add or --remove required", "--add or --remove required.", {
|
|
1859
|
+
exitCode: EXIT_USAGE,
|
|
1860
|
+
required: ["--add <alias> and/or --remove <alias>"],
|
|
1861
|
+
received: ["<empty>"],
|
|
1862
|
+
nextSteps: ["Pass --add and/or --remove at least once."],
|
|
1863
|
+
errorCode: "ALIAS_EMPTY",
|
|
1864
|
+
});
|
|
1865
|
+
}
|
|
1866
|
+
// Run only the ops that have content; either one of them solo, or both.
|
|
1867
|
+
let lastReport = null;
|
|
1868
|
+
if (add.length > 0) {
|
|
1869
|
+
lastReport = await applySingleScriptOp(opts, { op: "asset.alias.add", target: args[0], alias: add });
|
|
1870
|
+
}
|
|
1871
|
+
if (remove.length > 0) {
|
|
1872
|
+
lastReport = await applySingleScriptOp(opts, { op: "asset.alias.remove", target: args[0], alias: remove });
|
|
1873
|
+
}
|
|
1874
|
+
return lastReport;
|
|
1875
|
+
}
|
|
1876
|
+
export async function commandRole(opts) {
|
|
1877
|
+
const args = asList(opts["_args"]);
|
|
1878
|
+
if (args.length < 2) {
|
|
1879
|
+
throw new CliError("VERB BLOCKED: Role args missing", "Role args missing.", {
|
|
1880
|
+
exitCode: EXIT_USAGE,
|
|
1881
|
+
required: ["<actor:id> <主角|配角>"],
|
|
1882
|
+
received: args,
|
|
1883
|
+
nextSteps: ["Example: scriptctl role actor:act_001 主角"],
|
|
1884
|
+
errorCode: "ARGS_MISSING",
|
|
1885
|
+
});
|
|
1886
|
+
}
|
|
1887
|
+
return applySingleScriptOp(opts, { op: "asset.role.set", target: args[0], role_type: args[1] });
|
|
1888
|
+
}
|
|
1889
|
+
export async function commandWorldview(opts) {
|
|
1890
|
+
const args = asList(opts["_args"]);
|
|
1891
|
+
if (args.length < 1) {
|
|
1892
|
+
throw new CliError("VERB BLOCKED: Worldview value missing", "Worldview value missing.", {
|
|
1893
|
+
exitCode: EXIT_USAGE,
|
|
1894
|
+
required: ["<value>"],
|
|
1895
|
+
received: ["<empty>"],
|
|
1896
|
+
nextSteps: ["Example: scriptctl worldview 现代"],
|
|
1897
|
+
errorCode: "ARGS_MISSING",
|
|
1898
|
+
});
|
|
1899
|
+
}
|
|
1900
|
+
return applySingleScriptOp(opts, { op: "meta.worldview.set", worldview: args[0] });
|
|
1901
|
+
}
|
|
1902
|
+
// ----- state management -----------------------------------------------------
|
|
1903
|
+
export async function commandStateAdd(opts) {
|
|
1904
|
+
const args = asList(opts["_args"]);
|
|
1905
|
+
if (args.length < 2) {
|
|
1906
|
+
throw new CliError("VERB BLOCKED: state-add args missing", "state-add args missing.", {
|
|
1907
|
+
exitCode: EXIT_USAGE,
|
|
1908
|
+
required: ["<asset-addr> <state-name>"],
|
|
1909
|
+
received: args,
|
|
1910
|
+
nextSteps: ["Example: scriptctl state-add actor:act_001 \"震惊\""],
|
|
1911
|
+
errorCode: "ARGS_MISSING",
|
|
1912
|
+
});
|
|
1913
|
+
}
|
|
1914
|
+
return applySingleScriptOp(opts, {
|
|
1915
|
+
op: "state.add",
|
|
1916
|
+
target: args[0],
|
|
1917
|
+
name: args[1],
|
|
1918
|
+
description: opts["description"],
|
|
1919
|
+
state_id: opts["state_id"],
|
|
1920
|
+
});
|
|
1921
|
+
}
|
|
1922
|
+
export async function commandStateRename(opts) {
|
|
1923
|
+
const args = asList(opts["_args"]);
|
|
1924
|
+
if (args.length < 2) {
|
|
1925
|
+
throw new CliError("VERB BLOCKED: state-rename args missing", "state-rename args missing.", {
|
|
1926
|
+
exitCode: EXIT_USAGE,
|
|
1927
|
+
required: ["<state-addr> <new-name>"],
|
|
1928
|
+
received: args,
|
|
1929
|
+
nextSteps: ["Example: scriptctl state-rename actor:act_001/st_calm \"平静(新)\""],
|
|
1930
|
+
errorCode: "ARGS_MISSING",
|
|
1931
|
+
});
|
|
1932
|
+
}
|
|
1933
|
+
return applySingleScriptOp(opts, { op: "state.rename", target: args[0], name: args[1] });
|
|
1934
|
+
}
|
|
1935
|
+
export async function commandStateDelete(opts) {
|
|
1936
|
+
const args = asList(opts["_args"]);
|
|
1937
|
+
if (args.length < 1) {
|
|
1938
|
+
throw new CliError("VERB BLOCKED: state-delete address missing", "state-delete address missing.", {
|
|
1939
|
+
exitCode: EXIT_USAGE,
|
|
1940
|
+
required: ["<state-addr>"],
|
|
1941
|
+
received: ["<empty>"],
|
|
1942
|
+
nextSteps: ["Example: scriptctl state-delete actor:act_001/st_calm --strategy remove"],
|
|
1943
|
+
errorCode: "ARGS_MISSING",
|
|
1944
|
+
});
|
|
1945
|
+
}
|
|
1946
|
+
return applySingleScriptOp(opts, {
|
|
1947
|
+
op: "state.delete",
|
|
1948
|
+
target: args[0],
|
|
1949
|
+
strategy: opts["strategy"],
|
|
1950
|
+
replacement: opts["replacement"],
|
|
1951
|
+
});
|
|
1952
|
+
}
|
|
1953
|
+
// ----- scene context ref ----------------------------------------------------
|
|
1954
|
+
export async function commandContext(opts) {
|
|
1955
|
+
const args = asList(opts["_args"]);
|
|
1956
|
+
if (args.length < 2) {
|
|
1957
|
+
throw new CliError("VERB BLOCKED: context args missing", "context args missing.", {
|
|
1958
|
+
exitCode: EXIT_USAGE,
|
|
1959
|
+
required: ["<scene-addr> <asset-addr>"],
|
|
1960
|
+
received: args,
|
|
1961
|
+
nextSteps: ["Example: scriptctl context ep_001/scn_001 actor:act_001 --state st_calm"],
|
|
1962
|
+
errorCode: "ARGS_MISSING",
|
|
1963
|
+
});
|
|
1964
|
+
}
|
|
1965
|
+
// Mutex XOR: help-text promises 'exactly one of --state / --clear /
|
|
1966
|
+
// --remove'. Enforce it instead of silently picking by precedence.
|
|
1967
|
+
const stateSet = opts["state"] !== undefined;
|
|
1968
|
+
const clearSet = Boolean(opts["clear"]);
|
|
1969
|
+
const removeSet = Boolean(opts["remove"]);
|
|
1970
|
+
const flagCount = (stateSet ? 1 : 0) + (clearSet ? 1 : 0) + (removeSet ? 1 : 0);
|
|
1971
|
+
if (flagCount === 0) {
|
|
1972
|
+
throw new CliError("VERB BLOCKED: context flag missing", "context: must specify --state X, --clear, or --remove.", {
|
|
1973
|
+
exitCode: EXIT_USAGE,
|
|
1974
|
+
required: ["one of --state <state_id> / --clear / --remove"],
|
|
1975
|
+
received: ["<missing>"],
|
|
1976
|
+
nextSteps: ["Pick exactly one flag."],
|
|
1977
|
+
errorCode: "CONTEXT_FLAG_MISSING",
|
|
1978
|
+
});
|
|
1979
|
+
}
|
|
1980
|
+
if (flagCount > 1) {
|
|
1981
|
+
const passed = [];
|
|
1982
|
+
if (stateSet)
|
|
1983
|
+
passed.push("--state");
|
|
1984
|
+
if (clearSet)
|
|
1985
|
+
passed.push("--clear");
|
|
1986
|
+
if (removeSet)
|
|
1987
|
+
passed.push("--remove");
|
|
1988
|
+
throw new CliError("VERB BLOCKED: context flags mutually exclusive", "context: --state / --clear / --remove are mutually exclusive.", {
|
|
1989
|
+
exitCode: EXIT_USAGE,
|
|
1990
|
+
required: ["exactly one of --state / --clear / --remove"],
|
|
1991
|
+
received: passed,
|
|
1992
|
+
nextSteps: ["Pick one and retry."],
|
|
1993
|
+
errorCode: "CONTEXT_FLAG_CONFLICT",
|
|
1994
|
+
});
|
|
1995
|
+
}
|
|
1996
|
+
if (removeSet) {
|
|
1997
|
+
return applySingleScriptOp(opts, { op: "context.ref.remove", at: args[0], target: args[1] });
|
|
1998
|
+
}
|
|
1999
|
+
if (clearSet) {
|
|
2000
|
+
return applySingleScriptOp(opts, { op: "context.clear", at: args[0], target: args[1] });
|
|
2001
|
+
}
|
|
2002
|
+
const state = opts["state"];
|
|
2003
|
+
return applySingleScriptOp(opts, {
|
|
2004
|
+
op: "context.set",
|
|
2005
|
+
at: args[0],
|
|
2006
|
+
target: args[1],
|
|
2007
|
+
state: state === "none" ? "" : state,
|
|
2008
|
+
});
|
|
2009
|
+
}
|
|
2010
|
+
// ----- state change / transition --------------------------------------------
|
|
2011
|
+
export async function commandStateChange(opts) {
|
|
2012
|
+
const args = asList(opts["_args"]);
|
|
2013
|
+
if (args.length < 2) {
|
|
2014
|
+
throw new CliError("VERB BLOCKED: state-change args missing", "state-change args missing.", {
|
|
2015
|
+
exitCode: EXIT_USAGE,
|
|
2016
|
+
required: ["<action-addr> <asset-addr>"],
|
|
2017
|
+
received: args,
|
|
2018
|
+
nextSteps: ["Example: scriptctl state-change ep_001/scn_001#3 actor:act_001 --to st_shock"],
|
|
2019
|
+
errorCode: "ARGS_MISSING",
|
|
2020
|
+
});
|
|
2021
|
+
}
|
|
2022
|
+
// Mutex XOR: --to (set) and --clear (remove) cannot coexist.
|
|
2023
|
+
const toSet = opts["to"] !== undefined;
|
|
2024
|
+
const clearSet = Boolean(opts["clear"]);
|
|
2025
|
+
if (toSet && clearSet) {
|
|
2026
|
+
throw new CliError("VERB BLOCKED: state-change flags mutually exclusive", "state-change: --to and --clear are mutually exclusive.", {
|
|
2027
|
+
exitCode: EXIT_USAGE,
|
|
2028
|
+
required: ["exactly one of --to <state> / --clear"],
|
|
2029
|
+
received: ["--to", "--clear"],
|
|
2030
|
+
nextSteps: ["Pick one and retry."],
|
|
2031
|
+
errorCode: "STATE_CHANGE_FLAG_CONFLICT",
|
|
2032
|
+
});
|
|
2033
|
+
}
|
|
2034
|
+
if (!toSet && !clearSet) {
|
|
2035
|
+
throw new CliError("VERB BLOCKED: state-change flag missing", "state-change: must specify --to <state> or --clear.", {
|
|
2036
|
+
exitCode: EXIT_USAGE,
|
|
2037
|
+
required: ["one of --to <state> / --clear"],
|
|
2038
|
+
received: ["<missing>"],
|
|
2039
|
+
nextSteps: ["Pick exactly one flag."],
|
|
2040
|
+
errorCode: "STATE_CHANGE_FLAG_MISSING",
|
|
2041
|
+
});
|
|
2042
|
+
}
|
|
2043
|
+
if (clearSet) {
|
|
2044
|
+
return applySingleScriptOp(opts, { op: "action.state.remove", at: args[0], target: args[1] });
|
|
2045
|
+
}
|
|
2046
|
+
return applySingleScriptOp(opts, {
|
|
2047
|
+
op: "action.state.change",
|
|
2048
|
+
at: args[0],
|
|
2049
|
+
target: args[1],
|
|
2050
|
+
to: opts["to"],
|
|
2051
|
+
from: opts["from"],
|
|
2052
|
+
effective: opts["effective"] || "after",
|
|
2053
|
+
});
|
|
2054
|
+
}
|
|
2055
|
+
export async function commandTransition(opts) {
|
|
2056
|
+
const args = asList(opts["_args"]);
|
|
2057
|
+
if (args.length < 2) {
|
|
2058
|
+
throw new CliError("VERB BLOCKED: transition args missing", "transition args missing.", {
|
|
2059
|
+
exitCode: EXIT_USAGE,
|
|
2060
|
+
required: ["<action-addr> <asset-addr>"],
|
|
2061
|
+
received: args,
|
|
2062
|
+
nextSteps: ["Example: scriptctl transition ep_001/scn_001#3 actor:act_001 --process \"...\" --contrast \"...\""],
|
|
2063
|
+
errorCode: "ARGS_MISSING",
|
|
2064
|
+
});
|
|
2065
|
+
}
|
|
2066
|
+
// Mutex XOR: --clear vs --process/--contrast.
|
|
2067
|
+
const setBoth = opts["process"] !== undefined || opts["contrast"] !== undefined;
|
|
2068
|
+
const clearSet = Boolean(opts["clear"]);
|
|
2069
|
+
if (setBoth && clearSet) {
|
|
2070
|
+
throw new CliError("VERB BLOCKED: transition flags mutually exclusive", "transition: --clear is mutually exclusive with --process/--contrast.", {
|
|
2071
|
+
exitCode: EXIT_USAGE,
|
|
2072
|
+
required: ["either --clear, or --process + --contrast"],
|
|
2073
|
+
received: ["--clear", "--process/--contrast"],
|
|
2074
|
+
nextSteps: ["Pick one mode and retry."],
|
|
2075
|
+
errorCode: "TRANSITION_FLAG_CONFLICT",
|
|
2076
|
+
});
|
|
2077
|
+
}
|
|
2078
|
+
if (!setBoth && !clearSet) {
|
|
2079
|
+
throw new CliError("VERB BLOCKED: transition flag missing", "transition: must specify --process/--contrast or --clear.", {
|
|
2080
|
+
exitCode: EXIT_USAGE,
|
|
2081
|
+
required: ["--process + --contrast, or --clear"],
|
|
2082
|
+
received: ["<missing>"],
|
|
2083
|
+
nextSteps: ["Pick exactly one mode."],
|
|
2084
|
+
errorCode: "TRANSITION_FLAG_MISSING",
|
|
2085
|
+
});
|
|
2086
|
+
}
|
|
2087
|
+
if (clearSet) {
|
|
2088
|
+
return applySingleScriptOp(opts, { op: "action.transition.clear", at: args[0], target: args[1] });
|
|
2089
|
+
}
|
|
2090
|
+
return applySingleScriptOp(opts, {
|
|
2091
|
+
op: "action.transition.set",
|
|
2092
|
+
at: args[0],
|
|
2093
|
+
target: args[1],
|
|
2094
|
+
process: opts["process"],
|
|
2095
|
+
contrast: opts["contrast"],
|
|
2096
|
+
});
|
|
2097
|
+
}
|
|
2098
|
+
// ----- dialogue / speaker ---------------------------------------------------
|
|
2099
|
+
function splitSpeakerList(raw) {
|
|
2100
|
+
if (Array.isArray(raw))
|
|
2101
|
+
return raw.map((v) => strOf(v)).filter((v) => v);
|
|
2102
|
+
return strOf(raw).split(",").map((s) => s.trim()).filter((s) => s);
|
|
2103
|
+
}
|
|
2104
|
+
export async function commandDialogue(opts) {
|
|
2105
|
+
const args = asList(opts["_args"]);
|
|
2106
|
+
if (args.length < 1) {
|
|
2107
|
+
throw new CliError("VERB BLOCKED: dialogue action ref missing", "dialogue action ref missing.", {
|
|
2108
|
+
exitCode: EXIT_USAGE,
|
|
2109
|
+
required: ["<action-addr>"],
|
|
2110
|
+
received: ["<empty>"],
|
|
2111
|
+
nextSteps: ["Example: scriptctl dialogue ep_001/scn_001#3 --speakers spk_001"],
|
|
2112
|
+
errorCode: "ARGS_MISSING",
|
|
2113
|
+
});
|
|
2114
|
+
}
|
|
2115
|
+
const speakers = splitSpeakerList(opts["speakers"]);
|
|
2116
|
+
if (speakers.length === 0) {
|
|
2117
|
+
throw new CliError("VERB BLOCKED: --speakers required", "--speakers required.", {
|
|
2118
|
+
exitCode: EXIT_USAGE,
|
|
2119
|
+
required: ["--speakers id1[,id2,...]"],
|
|
2120
|
+
received: ["<empty>"],
|
|
2121
|
+
nextSteps: ["Pass one or more speaker ids."],
|
|
2122
|
+
errorCode: "SPEAKERS_EMPTY",
|
|
2123
|
+
});
|
|
2124
|
+
}
|
|
2125
|
+
return applySingleScriptOp(opts, {
|
|
2126
|
+
op: "dialogue.speakers",
|
|
2127
|
+
at: args[0],
|
|
2128
|
+
speakers,
|
|
2129
|
+
delivery: opts["delivery"] || (speakers.length > 1 ? "simultaneous" : "single"),
|
|
2130
|
+
});
|
|
2131
|
+
}
|
|
2132
|
+
export async function commandOverlap(opts) {
|
|
2133
|
+
const args = asList(opts["_args"]);
|
|
2134
|
+
if (args.length < 1) {
|
|
2135
|
+
throw new CliError("VERB BLOCKED: overlap action ref missing", "overlap action ref missing.", {
|
|
2136
|
+
exitCode: EXIT_USAGE,
|
|
2137
|
+
required: ["<action-addr>"],
|
|
2138
|
+
received: ["<empty>"],
|
|
2139
|
+
nextSteps: ["Example: scriptctl overlap ep_001/scn_001#3 --line \"spk_a:hi\" --line \"spk_b:hi\""],
|
|
2140
|
+
errorCode: "ARGS_MISSING",
|
|
2141
|
+
});
|
|
2142
|
+
}
|
|
2143
|
+
return applySingleScriptOp(opts, {
|
|
2144
|
+
op: "dialogue.overlap",
|
|
2145
|
+
at: args[0],
|
|
2146
|
+
lines: opts["line"] || [],
|
|
2147
|
+
});
|
|
2148
|
+
}
|
|
2149
|
+
export async function commandAddSpeaker(opts) {
|
|
2150
|
+
return applySingleScriptOp(opts, {
|
|
2151
|
+
op: "speaker.add",
|
|
2152
|
+
kind: opts["kind"],
|
|
2153
|
+
name: opts["name"],
|
|
2154
|
+
source_id: opts["source_id"],
|
|
2155
|
+
voice_desc: opts["voice_desc"],
|
|
2156
|
+
speaker_id: opts["speaker_id"],
|
|
2157
|
+
});
|
|
2158
|
+
}
|
|
1206
2159
|
//# sourceMappingURL=script.js.map
|