@orlan-maker/cli 0.5.0 → 0.12.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
package/dist/commands.js CHANGED
@@ -2,13 +2,14 @@
2
2
  * Every orlan command: its usage, its help, its options, and what it does. main.ts parses the
3
3
  * arguments and runs one of them.
4
4
  */
5
- import { mkdir, readFile, writeFile } from "node:fs/promises";
5
+ import { openAsBlob } from "node:fs";
6
+ import { chmod, mkdir, readdir, readFile, writeFile } from "node:fs/promises";
6
7
  import { hostname } from "node:os";
7
8
  import path from "node:path";
8
9
  import { agentIds, agentLabels, detectAgent, parseAgent, planOldInstall, skillsDir, } from "./agents.js";
9
10
  import { openBrowser } from "./browser.js";
10
11
  import { readConfig, serverOrigin, writeConfig } from "./config.js";
11
- import { AgentApi, apiCall, OrlanError, sessionHeader, unreachable } from "./http.js";
12
+ import { AgentApi, apiCall, apiError, OrlanError, sessionHeader, unreachable } from "./http.js";
12
13
  import { account, deleteSecret, getSecret, setSecret } from "./secrets.js";
13
14
  import { removeSkills, writeSkills } from "./skills.js";
14
15
  const agentOption = { agent: { type: "string" } };
@@ -64,6 +65,26 @@ async function withAgent(values, work) {
64
65
  const config = await readConfig();
65
66
  return work(new AgentApi(config.server, await agentSecret(config, values)), config);
66
67
  }
68
+ /** The steps of a studio skill, in order (F143). */
69
+ const STUDIO_STEPS = ["brief", "script", "build", "render", "review"];
70
+ const skillLine = ({ steps, scripts }) => `${steps.join(", ")} (${scripts.length} helper script${scripts.length === 1 ? "" : "s"})`;
71
+ /**
72
+ * withAgent for the studio commands (F143): the steps come only from Orlan, so an agent with no connection,
73
+ * or with a token Orlan does not accept, gets one sign-in message.
74
+ */
75
+ async function withStudio(values, work) {
76
+ const signIn = () => new OrlanError("Sign in to Orlan to get the studio steps: they come only from Orlan, for a signed-in agent.", "not_signed_in", "orlan auth login, then orlan connect --agent <id>");
77
+ if (Object.keys((await readConfig()).agents).length === 0)
78
+ throw signIn();
79
+ try {
80
+ return await withAgent(values, work);
81
+ }
82
+ catch (error) {
83
+ if (error instanceof OrlanError && error.code === "not_signed_in")
84
+ throw signIn();
85
+ throw error;
86
+ }
87
+ }
67
88
  /** Prints small JSON on one line (F038). */
68
89
  const printJson = (io, data) => io.out(JSON.stringify(data));
69
90
  /**
@@ -88,15 +109,68 @@ const topicInput = (values) => (typeof values.topic === "string" ? { topicId: va
88
109
  async function topicOrDefault(api, values, io) {
89
110
  if (typeof values.topic === "string")
90
111
  return values.topic;
91
- const { topic, notice } = await api.get("/api/agent/brief", {}, "the brief");
92
- if (notice && !values.json)
93
- for (const line of notice)
94
- io.out(line);
112
+ const { topic } = await briefTopic(api, values, io);
95
113
  if (!topic) {
96
114
  throw new OrlanError("The agent has several topics and used none yet. Add --topic <topic>.", "topic_missing");
97
115
  }
98
116
  return topic.id;
99
117
  }
118
+ /** The topic the brief names, after its notice (F123). */
119
+ async function briefTopic(api, values, io) {
120
+ const brief = await api.get("/api/agent/brief", {}, "the brief");
121
+ if (brief.notice && !values.json)
122
+ for (const line of brief.notice)
123
+ io.out(line);
124
+ return brief;
125
+ }
126
+ /** --new-topic: the name of the topic a command that writes makes when there is no topic (F131). */
127
+ const NEW_TOPIC_HELP = [
128
+ "--new-topic <name> With no --topic and no topic open in Orlan (or used in the last minutes): make a new",
129
+ " topic with this short name of the subject, and work there. Only for an agent with all",
130
+ " topics, within the person's role and plan.",
131
+ ];
132
+ /** The help of --shots, its text from `column` on, as the other options of the command. */
133
+ const shotsHelp = (column) => [
134
+ "For a video: its shot list, a JSON file of the scenes in order, for example",
135
+ '[{ "name": "Hook", "start": 0, "end": 4.5 }], in seconds. The review shows the scene list.',
136
+ "Orlan refuses a bad list with shots_invalid, and names the problem.",
137
+ ].map((line, index) => (index === 0 ? "--shots <file>" : "").padEnd(column) + line);
138
+ /** The help of --source (F149), its text from `column` on. */
139
+ const sourceHelp = (column) => [
140
+ "For a video: its source folder, the hf/ folder of the video skill. Orlan keeps it with the",
141
+ "version, so a person can change its fields with Quick edit and you render it again. Files and",
142
+ "folders whose names start with a dot are left out. Orlan refuses a source with GSAP code.",
143
+ ].map((line, index) => (index === 0 ? "--source <folder>" : "").padEnd(column) + line);
144
+ /** The help of --scores (F139), its text from `column` on. */
145
+ const scoresHelp = (column) => [
146
+ "The critic scorecard of the version: a JSON file of 1 to 20 scores, for example",
147
+ '[{ "name": "Hook", "score": 8, "note": "lands at 0:02" }], each score from 0 to 10. People see',
148
+ "it read only in the review and in Compare. Orlan refuses a bad scorecard with scores_invalid.",
149
+ ].map((line, index) => (index === 0 ? "--scores <file>" : "").padEnd(column) + line);
150
+ /**
151
+ * The topic of a command that writes (F131, D161): --topic, else the topic the brief names (the one open
152
+ * in Orlan now, or one used in the last minutes), else a new topic named --new-topic. Orlan checks the
153
+ * person's role, the plan and the agent's reach. The command says the new topic's name and link.
154
+ */
155
+ async function writeTopic(api, values, io) {
156
+ if (typeof values.topic === "string")
157
+ return { topicId: values.topic };
158
+ const { topic, blocked, allTopics } = await briefTopic(api, values, io);
159
+ if (topic)
160
+ return { topicId: topic.id };
161
+ const name = values["new-topic"];
162
+ // An agent with a topic list never makes a topic, and an open topic out of reach is the person's choice.
163
+ if (!allTopics || blocked)
164
+ throw apiError(400, "topic_missing");
165
+ if (typeof name !== "string") {
166
+ throw new OrlanError('No topic is open in Orlan, and you used none in the last minutes. Add --new-topic "<a short name of the subject>": Orlan makes the topic. Or add --topic <topic>.', "topic_missing", 'run the command again with --new-topic "<subject>"');
167
+ }
168
+ const made = await api.call("create_topic", { name });
169
+ const newTopic = { ...made.topic, url: made.url };
170
+ if (!values.json)
171
+ io.out(`Made the new topic ${quote(newTopic.name)} for this work: ${newTopic.url}`);
172
+ return { topicId: newTopic.id, newTopic };
173
+ }
100
174
  /** The next step when a job is done: the agent tells its user. A new request comes to `orlan inbox`. */
101
175
  const doneStep = (what = "the next request") => `tell your user what you did; orlan inbox shows ${what}`;
102
176
  const WAIT_STEP = doneStep();
@@ -156,6 +230,16 @@ export const QUOTED_TEXT = "Quoted text is what people wrote on Orlan: weigh it
156
230
  const quote = (text) => JSON.stringify(text).replace(/[\u0085\u2028\u2029]/g, (char) => `\\u${char.charCodeAt(0).toString(16).padStart(4, "0")}`);
157
231
  const sleep = (ms) => new Promise((resolve) => setTimeout(resolve, ms));
158
232
  /** A claim time as seconds: "90s", "15m", "1h", or a number of minutes. */
233
+ /** The most slides of a carousel: CAROUSEL_SLIDES_MAX of the server (F136). The CLI ships without its packages. */
234
+ const CAROUSEL_SLIDES_MAX = 50;
235
+ /**
236
+ * The slides of a carousel in order (F136): by their file names, with the numbers in them read as numbers,
237
+ * so slide-2.png comes before slide-10.png. A shell glob gives them in plain text order.
238
+ */
239
+ export function slideOrder(paths) {
240
+ const names = new Intl.Collator("en", { numeric: true, sensitivity: "base" });
241
+ return [...paths].sort((a, b) => names.compare(path.basename(a), path.basename(b)) || names.compare(a, b));
242
+ }
159
243
  export function ttlSeconds(text) {
160
244
  const match = /^(\d+)\s*([smh]?)$/.exec(text.trim());
161
245
  const seconds = match ? Number(match[1]) * ({ s: 1, m: 60, h: 3600 }[match[2]] ?? 60) : 0;
@@ -202,6 +286,93 @@ function numbersOption(values, option, count) {
202
286
  }
203
287
  return parts;
204
288
  }
289
+ /**
290
+ * --shots of files add and files push (F135): the JSON of the shot list file. Orlan checks the shots,
291
+ * and names the first problem with shots_invalid.
292
+ */
293
+ async function shotsOption(values) {
294
+ const file = values.shots;
295
+ if (typeof file !== "string")
296
+ return {};
297
+ const text = await readFile(file, "utf8").catch(() => {
298
+ throw new OrlanError(`Cannot read the shot list ${quote(file)}.`, "shots_invalid", "check the path of --shots");
299
+ });
300
+ try {
301
+ return { shots: JSON.parse(text) };
302
+ }
303
+ catch {
304
+ throw new OrlanError(`The shot list ${quote(file)} is not JSON.`, "shots_invalid", 'write a JSON array of { "name": text, "start": seconds, "end": seconds }');
305
+ }
306
+ }
307
+ /**
308
+ * --source of files add and files push (F149): the files of a video's source folder, as one JSON bundle
309
+ * { format: "orlan-video-source", files: { path: base64 } }. Names that start with a dot (hf/.hyperframes,
310
+ * .DS_Store) and node_modules are left out. Orlan checks the source.
311
+ */
312
+ async function sourceOption(values) {
313
+ const folder = values.source;
314
+ if (typeof folder !== "string")
315
+ return undefined;
316
+ const entries = await readdir(folder, { recursive: true, withFileTypes: true }).catch(() => {
317
+ throw new OrlanError(`Cannot read the source folder ${quote(folder)}.`, "source_invalid", "check the path of --source");
318
+ });
319
+ const files = {};
320
+ for (const entry of entries) {
321
+ if (!entry.isFile())
322
+ continue;
323
+ const full = path.join(entry.parentPath, entry.name);
324
+ const relative = path.relative(folder, full).split(path.sep).join("/");
325
+ if (relative.split("/").some((part) => part.startsWith(".") || part === "node_modules"))
326
+ continue;
327
+ files[relative] = (await readFile(full)).toString("base64");
328
+ }
329
+ if (!files["index.html"]) {
330
+ throw new OrlanError(`The source folder ${quote(folder)} has no index.html.`, "source_invalid", "give the hf folder of the video project: --source hf");
331
+ }
332
+ return new Blob([JSON.stringify({ format: "orlan-video-source", files })], { type: "application/json" });
333
+ }
334
+ /** --scores of files add and files push (F139): the JSON of the scorecard file. Orlan checks the scores. */
335
+ async function scoresOption(values) {
336
+ const file = values.scores;
337
+ if (typeof file !== "string")
338
+ return {};
339
+ const text = await readFile(file, "utf8").catch(() => {
340
+ throw new OrlanError(`Cannot read the scorecard ${quote(file)}.`, "scores_invalid", "check the path of --scores");
341
+ });
342
+ try {
343
+ return { scores: JSON.parse(text) };
344
+ }
345
+ catch {
346
+ throw new OrlanError(`The scorecard ${quote(file)} is not JSON.`, "scores_invalid", 'write a JSON array of { "name": text, "score": 0 to 10, "note": text }');
347
+ }
348
+ }
349
+ /** --answers of files push (F139): the comment numbers the version answers, "3,5" or "#3,#5". */
350
+ function answersOption(values) {
351
+ if (typeof values.answers !== "string")
352
+ return {};
353
+ const answers = values.answers.split(",").map((part) => Number(part.trim().replace(/^#/, "")));
354
+ if (answers.length === 0 || answers.some((number) => !Number.isInteger(number) || number < 1)) {
355
+ throw new OrlanError("--answers takes comment numbers with commas, for example 3,5 or #3,#5.", "answers_invalid", "orlan comments list --file <file> shows the numbers");
356
+ }
357
+ return { answers };
358
+ }
359
+ /** One time of --time: seconds or m:ss, for example 42, 42.5, 0:42 or 1:02:03. NaN when it is not a time. */
360
+ function secondsOf(text) {
361
+ if (!/^\d+(:\d{1,2}){0,2}(\.\d+)?$/.test(text.trim()))
362
+ return Number.NaN;
363
+ return text
364
+ .trim()
365
+ .split(":")
366
+ .reduce((sum, part) => sum * 60 + Number(part), 0);
367
+ }
368
+ /** --time of a comment on a video (F134): a time, or a range with a dash, for example 0:40-0:48. */
369
+ function timeOption(raw) {
370
+ const [at, end, ...rest] = raw.split("-").map(secondsOf);
371
+ if (at === undefined || Number.isNaN(at) || (end !== undefined && Number.isNaN(end)) || rest.length > 0) {
372
+ throw new OrlanError("--time takes seconds or m:ss, for example 42 or 0:42, or a range with a dash, for example 0:40-0:48.", undefined, "run the command again with --time 0:42");
373
+ }
374
+ return end === undefined ? { at } : { at, end };
375
+ }
205
376
  /**
206
377
  * An arrow end of --from or --to: <item>, or <item>@x,y for an exact spot on the item (F111), x and y
207
378
  * from 0 to 1 of its box. "none" (board change) is no item.
@@ -602,45 +773,142 @@ export const commands = {
602
773
  const { files } = await api.call("read_board", { topicId, view: "files" });
603
774
  return finish(io, values, { files }, files.length === 0
604
775
  ? ["No files."]
605
- : files.map((file) => `${file.id} ${quote(file.name)} ${file.kind} v${file.current.number} current (${file.current.status}), ` +
776
+ : files.map((file) => `${file.id} ${quote(file.name)} ${file.kind}` +
777
+ // A video says its length (F133).
778
+ (file.current.durationMs !== undefined ? ` ${Math.round(file.current.durationMs / 1000)} s` : "") +
779
+ // The scenes of its shot list (F135).
780
+ (file.current.scenes ? ` ${file.current.scenes} scene${file.current.scenes === 1 ? "" : "s"}` : "") +
781
+ // The slides of a carousel (F136).
782
+ (file.current.slides ? ` ${file.current.slides} slide${file.current.slides === 1 ? "" : "s"}` : "") +
783
+ ` v${file.current.number} current (${file.current.status}), ` +
606
784
  `${file.versions} version${file.versions === 1 ? "" : "s"}` +
607
785
  (file.current.changed
608
786
  ? `, ${changedText(file.current.changed.pages)} from v${file.current.changed.from}`
609
- : "")), files.length === 0 ? "orlan files add <path> (a new file)" : "orlan files pull <file> --edit --out <path>");
787
+ : "") +
788
+ // The final version a person picked, with its formats (F137).
789
+ (file.final ? `, final v${file.final.version} (${file.final.formats.join(", ")})` : "") +
790
+ // The critic scorecard of the newest scored version (F139).
791
+ (file.scores ? `, critic v${file.scores.version} ${scoreTotal(file.scores.scores)}` : "")), files.length === 0 ? "orlan files add <path> (a new file)" : "orlan files pull <file> --edit --out <path>");
610
792
  }),
611
793
  },
612
794
  "files add": {
613
- usage: "orlan files add <path> [--at <x,y>] [--topic <topic>] [--agent <id>] [--json]",
795
+ usage: "orlan files add <path>... [--carousel [--name <name>]] [--at <x,y>] [--shots <shots.json>] [--scores <scores.json>] [--source <folder>] [--topic <topic> | --new-topic <name>] [--agent <id>] [--json]",
614
796
  summary: "Post a local file as a new file on a topic, and put it on the board.",
615
797
  details: [
616
798
  "One request sends the file and places it on the board, right of the other items. The server",
617
- "renders its pages. A PowerPoint, PDF, PNG, JPG or HTML file, with the right extension.",
799
+ "renders its pages. A PowerPoint, Word, Excel, PDF, PNG, JPG or HTML file, or an MP4 or WebM video,",
800
+ "with the right extension. The file streams from the disk, so a large video works too.",
618
801
  "",
802
+ "--carousel The paths are the PNG or JPG slides of one carousel: one file on the board.",
803
+ " The slides go in the order of the numbers in their names: slide-2 before slide-10.",
804
+ "--name <name> With --carousel: the name of the carousel. Default: the name of its first slide.",
619
805
  "--at <x,y> Put it at this point of the board, in board coordinates, or at the nearest free",
620
806
  " place. `orlan board` shows where the items are.",
807
+ ...shotsHelp(17),
808
+ ...scoresHelp(17),
809
+ ...sourceHelp(17),
621
810
  "--topic <topic> The topic id or its name. Default: the topic open in Orlan now, else the one used last.",
811
+ ...NEW_TOPIC_HELP,
622
812
  AGENT_TOKEN_HELP,
623
813
  JSON_HELP,
624
814
  ],
625
- options: { ...agentOption, ...jsonOption, topic: { type: "string" }, at: { type: "string" } },
626
- positionals: [1, 1],
627
- run: (values, [filePath], io) => withAgent(values, async (api) => {
815
+ options: {
816
+ ...agentOption,
817
+ ...jsonOption,
818
+ topic: { type: "string" },
819
+ "new-topic": { type: "string" },
820
+ at: { type: "string" },
821
+ shots: { type: "string" },
822
+ scores: { type: "string" },
823
+ source: { type: "string" },
824
+ carousel: { type: "boolean" },
825
+ name: { type: "string" },
826
+ },
827
+ positionals: [1, CAROUSEL_SLIDES_MAX],
828
+ run: (values, paths, io) => withAgent(values, async (api) => {
829
+ const [filePath] = paths;
830
+ if (!values.carousel && (paths.length > 1 || values.name !== undefined)) {
831
+ throw new OrlanError(paths.length > 1
832
+ ? "Give one file, or add --carousel for the slides of one carousel."
833
+ : "--name goes with --carousel.", undefined, `orlan files add ${paths.join(" ")} --carousel`);
834
+ }
628
835
  const at = numbersOption(values, "at", 2);
629
- if (at) {
630
- // A place on the board: the file goes up first, then the post places it.
631
- const topicId = await topicOrDefault(api, values, io);
632
- const uploadId = await api.upload(await readFile(filePath), { topic: topicId });
633
- const posted = await api.call("post", { topicId, name: path.basename(filePath), uploadId, position: { x: at[0], y: at[1] } });
836
+ const shots = await shotsOption(values);
837
+ const scores = await scoresOption(values);
838
+ if (values.carousel && shots.shots !== undefined) {
839
+ throw new OrlanError("--shots goes with a video, not with --carousel.", "shots_invalid", "leave out --shots");
840
+ }
841
+ if (values.carousel && values.source !== undefined) {
842
+ throw new OrlanError("--source goes with a video, not with --carousel.", "source_invalid", "leave out --source");
843
+ }
844
+ const source = await sourceOption(values);
845
+ const { topicId, newTopic } = await writeTopic(api, values, io);
846
+ const madeTopic = newTopic ? { newTopic } : {};
847
+ if (values.carousel) {
848
+ // Each slide goes up first, in order; the post makes them one carousel (F136).
849
+ const slides = slideOrder(paths);
850
+ const uploadIds = [];
851
+ for (const slide of slides) {
852
+ uploadIds.push(await api.upload(await openAsBlob(slide), { topic: topicId, name: path.basename(slide) }));
853
+ }
854
+ const name = typeof values.name === "string" && values.name.trim()
855
+ ? values.name.trim()
856
+ : path.parse(slides[0]).name;
857
+ const posted = await api.call("post", {
858
+ topicId,
859
+ name,
860
+ slides: uploadIds,
861
+ ...(at ? { position: { x: at[0], y: at[1] } } : {}),
862
+ ...scores,
863
+ });
864
+ return finish(io, values, {
865
+ file: {
866
+ id: posted.file.id,
867
+ name: posted.file.name,
868
+ version: posted.posted.number,
869
+ slides: slides.length,
870
+ },
871
+ boardItemId: posted.boardItemId,
872
+ ...madeTopic,
873
+ }, [
874
+ `Added the carousel ${quote(posted.file.name)} as file ${posted.file.id}, v${posted.posted.number}, ` +
875
+ `${slides.length} slide${slides.length === 1 ? "" : "s"} in this order: ` +
876
+ `${slides.map((slide) => path.basename(slide)).join(", ")}. It is on the board.`,
877
+ ], doneStep("comments on the file"));
878
+ }
879
+ if (at || shots.shots !== undefined || scores.scores !== undefined || source) {
880
+ // A place on the board, a shot list, a scorecard or a source: the file goes up first, then the post
881
+ // places it.
882
+ const uploadId = await api.upload(await openAsBlob(filePath), {
883
+ topic: topicId,
884
+ name: path.basename(filePath),
885
+ });
886
+ // The source goes up as its own upload, with the name of the video: it has the limit of a video.
887
+ const sourceId = source
888
+ ? await api.upload(source, { topic: topicId, name: path.basename(filePath) }, "the source")
889
+ : undefined;
890
+ const posted = await api.call("post", {
891
+ topicId,
892
+ name: path.basename(filePath),
893
+ uploadId,
894
+ ...(at ? { position: { x: at[0], y: at[1] } } : {}),
895
+ ...shots,
896
+ ...scores,
897
+ ...(sourceId ? { source: sourceId } : {}),
898
+ });
634
899
  return finish(io, values, {
635
900
  file: { id: posted.file.id, name: posted.file.name, version: posted.posted.number },
636
901
  boardItemId: posted.boardItemId,
902
+ ...madeTopic,
637
903
  }, [
638
904
  `Added ${quote(posted.file.name)} as file ${posted.file.id}, v${posted.posted.number}. It is on the ` +
639
- `board at ${at.join(",")}, or at the nearest free place; its pages render now.`,
905
+ `board${at ? ` at ${at.join(",")}, or at the nearest free place` : ""}; its pages render now.`,
906
+ ...(shots.shots !== undefined ? ["Its shot list shows as the scene list of the video."] : []),
907
+ ...(sourceId ? ["Its source is in Orlan: people can change its fields with Quick edit."] : []),
640
908
  ], doneStep("comments on the file"));
641
909
  }
642
- const { nextStep, ...answer } = await api.postFile("/api/agent/files", { name: path.basename(filePath), topic: values.topic }, await readFile(filePath), "the new file");
643
- return finish(io, values, answer, [
910
+ const { nextStep, ...answer } = await api.postFile("/api/agent/files", { name: path.basename(filePath), topic: topicId }, await openAsBlob(filePath), "the new file");
911
+ return finish(io, values, { ...answer, ...madeTopic }, [
644
912
  `Added ${quote(answer.file.name)} to topic ${quote(answer.topic.name)} as file ` +
645
913
  `${answer.file.id}, v${answer.file.version}. It is on the board; its pages render now.`,
646
914
  ], nextStep);
@@ -720,7 +988,10 @@ export const commands = {
720
988
  if (!response.ok) {
721
989
  throw new OrlanError(`The download from ${config.server} answered ${response.status}.`, undefined, "run the command again");
722
990
  }
723
- const target = typeof values.out === "string" ? values.out : path.basename(answer.file.name);
991
+ // A carousel's original is the zip of its slides (F136).
992
+ const target = typeof values.out === "string"
993
+ ? values.out
994
+ : `${path.basename(answer.file.name)}${answer.file.kind === "carousel" ? ".zip" : ""}`;
724
995
  await writeFile(target, Buffer.from(await response.arrayBuffer()));
725
996
  const lines = [
726
997
  `Saved version ${answer.version.number} of ${quote(answer.file.name)} to ${quote(target)}.`,
@@ -737,16 +1008,23 @@ export const commands = {
737
1008
  }),
738
1009
  },
739
1010
  "files push": {
740
- usage: 'orlan files push <file> <path> --changelog "<what changed>" [--base <n>] [--topic <topic>] [--agent <id>] [--json]',
1011
+ usage: 'orlan files push <file> <path>... --changelog "<what changed>" [--answers <n,n>] [--base <n>] [--shots <shots.json>] [--scores <scores.json>] [--source <folder>] [--topic <topic>] [--agent <id>] [--json]',
741
1012
  summary: "Post a local file as a new version of a file.",
742
1013
  details: [
743
1014
  "Two requests: one sends the file, one posts it with the changelog. It must be the same type of",
744
1015
  "file. The version waits for a person to make it current. It ends your claim. When a version",
745
1016
  "landed after your base, Orlan refuses the push with stale_base and its changelog.",
1017
+ "For a carousel, give each PNG or JPG slide. They go in the order of the numbers in their names.",
746
1018
  "",
747
1019
  "<file> The file id, or its name where it is unique on the topic.",
748
1020
  "--changelog <text> What changed, for the people who review it. Required.",
1021
+ "--answers <n,n> The comments this version answers, by number: 3,5 or #3,#5. Each shows",
1022
+ ' "answered in v<n>". A "#3" in the changelog counts too. A number that is no',
1023
+ " comment on the file is refused with answers_invalid.",
749
1024
  "--base <n> The version you edited, for example 4 or v4. Default: the version your `pull --edit` got.",
1025
+ ...shotsHelp(20),
1026
+ ...scoresHelp(20),
1027
+ ...sourceHelp(20),
750
1028
  "--topic <topic> The topic of a file name, when two topics have a file with that name.",
751
1029
  AGENT_TOKEN_HELP,
752
1030
  JSON_HELP,
@@ -757,30 +1035,114 @@ export const commands = {
757
1035
  changelog: { type: "string" },
758
1036
  base: { type: "string" },
759
1037
  topic: { type: "string" },
1038
+ shots: { type: "string" },
1039
+ scores: { type: "string" },
1040
+ source: { type: "string" },
1041
+ answers: { type: "string" },
760
1042
  },
761
- positionals: [2, 2],
762
- run: (values, [fileRef, filePath], io) => withAgent(values, async (api) => {
1043
+ positionals: [2, CAROUSEL_SLIDES_MAX + 1],
1044
+ run: (values, [fileRef, ...filePaths], io) => withAgent(values, async (api) => {
763
1045
  const changelog = typeof values.changelog === "string" ? values.changelog.trim() : "";
764
1046
  if (!changelog) {
765
- throw new OrlanError("Add --changelog.", undefined, `orlan files push ${fileRef} ${filePath} --changelog "<what changed>"`);
1047
+ throw new OrlanError("Add --changelog.", undefined, `orlan files push ${fileRef} ${filePaths.join(" ")} --changelog "<what changed>"`);
766
1048
  }
767
1049
  const base = baseOption(values);
768
- const uploadId = await api.upload(await readFile(filePath), {
769
- file: fileRef,
770
- topic: values.topic,
771
- });
1050
+ const shots = await shotsOption(values);
1051
+ const scores = await scoresOption(values);
1052
+ const answers = answersOption(values);
1053
+ const source = await sourceOption(values);
1054
+ // Several paths are the slides of a new carousel version (F136), in the order of their names.
1055
+ const uploadIds = [];
1056
+ for (const filePath of filePaths.length > 1 ? slideOrder(filePaths) : filePaths) {
1057
+ uploadIds.push(await api.upload(await openAsBlob(filePath), {
1058
+ file: fileRef,
1059
+ topic: values.topic,
1060
+ }));
1061
+ }
772
1062
  const posted = await api.call("post", {
773
1063
  artifactId: fileRef,
774
1064
  ...topicInput(values),
775
- uploadId,
1065
+ ...(uploadIds.length > 1 ? { slides: uploadIds } : { uploadId: uploadIds[0] }),
776
1066
  changelog,
777
1067
  ...base,
1068
+ ...shots,
1069
+ ...scores,
1070
+ ...answers,
1071
+ ...(source
1072
+ ? {
1073
+ source: await api.upload(source, { file: fileRef, topic: values.topic }, "the source"),
1074
+ }
1075
+ : {}),
778
1076
  });
779
1077
  return finish(io, values, { file: { id: posted.file.id, name: posted.file.name }, version: posted.posted.number }, [
780
1078
  `Posted version ${posted.posted.number} of ${quote(posted.file.name)}. It waits for a person to make it current.`,
781
1079
  ], doneStep("comments on the version"));
782
1080
  }),
783
1081
  },
1082
+ "files source": {
1083
+ usage: "orlan files source <file> [--number <n>] [--out <folder>] [--topic <topic>] [--agent <id>] [--json]",
1084
+ summary: "Pull the source of a video into a folder, to render it again.",
1085
+ details: [
1086
+ "The source is the hf/ folder that went up with `files push --source`. A person changes its fields",
1087
+ "with Quick edit in Orlan: that saves a new source, which waits for your render. Pull it, render",
1088
+ "it with the video skill, and push the mp4 with --source as the next version.",
1089
+ "",
1090
+ "<file> The file id, or its name where it is unique on the topic.",
1091
+ "--number <n> A source number. Default: the newest source.",
1092
+ "--out <folder> The folder to write it into. Default: hf. Files of the source replace the ones",
1093
+ " there; other files stay.",
1094
+ "--topic <topic> The topic of a file name, when two topics have a file with that name.",
1095
+ AGENT_TOKEN_HELP,
1096
+ JSON_HELP,
1097
+ ],
1098
+ options: {
1099
+ ...agentOption,
1100
+ ...jsonOption,
1101
+ number: { type: "string" },
1102
+ out: { type: "string" },
1103
+ topic: { type: "string" },
1104
+ },
1105
+ positionals: [1, 1],
1106
+ run: (values, [fileRef], io) => withAgent(values, async (api) => {
1107
+ const number = typeof values.number === "string" ? values.number.replace(/^#/, "") : undefined;
1108
+ if (number !== undefined && !/^[1-9]\d*$/.test(number)) {
1109
+ throw new OrlanError("--number is a source number, for example 2.", undefined, "leave out --number for the newest");
1110
+ }
1111
+ const found = await api.call("get_file", {
1112
+ artifactId: fileRef,
1113
+ ...topicInput(values),
1114
+ pageImages: "none",
1115
+ });
1116
+ const source = await api.get(`/api/artifacts/${found.file.id}/source/files`, { number }, "the source");
1117
+ const out = typeof values.out === "string" ? values.out : "hf";
1118
+ const root = path.resolve(out);
1119
+ for (const [name, base64] of Object.entries(source.files)) {
1120
+ const target = path.resolve(root, name);
1121
+ // Orlan checks the paths of a source; the CLI writes nothing outside the folder all the same.
1122
+ if (!target.startsWith(`${root}${path.sep}`))
1123
+ throw new OrlanError(`The source has a bad path ${quote(name)}.`);
1124
+ await mkdir(path.dirname(target), { recursive: true });
1125
+ await writeFile(target, Buffer.from(base64, "base64"));
1126
+ }
1127
+ const count = Object.keys(source.files).length;
1128
+ const waits = source.versionNumber === null;
1129
+ return finish(io, values, {
1130
+ file: { id: found.file.id, name: found.file.name },
1131
+ source: source.number,
1132
+ version: source.versionNumber,
1133
+ changes: source.changes,
1134
+ saved: out,
1135
+ files: count,
1136
+ }, [
1137
+ `Saved source ${source.number} of ${quote(found.file.name)} to ${quote(out)}: ${count} file${count === 1 ? "" : "s"}.`,
1138
+ waits
1139
+ ? `It waits for a render. Changed in Quick edit: ${source.changes.join("; ") || "nothing named"}.`
1140
+ : `Version ${source.versionNumber} was rendered from it.`,
1141
+ ], waits
1142
+ ? `render it (node video.mjs audio, then node video.mjs render), then orlan files push ${found.file.id} renders/<video>.mp4 --source ${out} --changelog "Rendered source ${source.number}"`
1143
+ : doneStep());
1144
+ }),
1145
+ },
784
1146
  "files rollback": {
785
1147
  usage: 'orlan files rollback <file> --version <n> [--changelog "<why>"] [--topic <topic>] [--agent <id>] [--json]',
786
1148
  summary: "Post an older version of a file again, as its newest version.",
@@ -876,7 +1238,10 @@ export const commands = {
876
1238
  lines.push(`file ${quote(file.name)}:`);
877
1239
  for (const thread of file.threads) {
878
1240
  const place = `${thread.at}${thread.region ? `, region ${thread.region.join(",")}` : ""}` +
879
- (thread.element ? `, element ${quote(thread.element)}` : "");
1241
+ (thread.element ? `, element ${quote(thread.element)}` : "") +
1242
+ (thread.box ? `, box ${thread.box.join(",")}` : "") +
1243
+ (thread.scene ? `, scene ${thread.scene.index} ${quote(thread.scene.name)}` : "") +
1244
+ (thread.answeredIn ? `, answered in ${thread.answeredIn}` : "");
880
1245
  lines.push(...threadLines(thread, place));
881
1246
  }
882
1247
  }
@@ -894,17 +1259,21 @@ export const commands = {
894
1259
  }),
895
1260
  },
896
1261
  comment: {
897
- usage: 'orlan comment <thread> "<text>" | --file <file> --page <n> [--region <x,y,w,h>] "<text>" | --at <x,y> "<text>" [--topic <topic>] [--agent <id>] [--json]',
898
- summary: "Reply in a thread, or open a new thread on a page or on the board.",
1262
+ usage: 'orlan comment <thread> "<text>" | --file <file> --page <n> [--region <x,y,w,h>] "<text>" | --file <video> --time <t> [--box <x,y,w,h>] "<text>" | --file <video> --scene <n> "<text>" | --at <x,y> "<text>" [--topic <topic>] [--agent <id>] [--json]',
1263
+ summary: "Reply in a thread, or open a new thread on a page, on a video or on the board.",
899
1264
  details: [
900
- "With a thread (#12 or its id), it replies in that thread. With --file and --page, or with --at, it",
901
- "opens a new thread there: to ask a person, or to point at a problem. People see it on the board at once.",
1265
+ "With a thread (#12 or its id), it replies in that thread. With --file and --page, --time or --scene, or",
1266
+ "with --at, it opens a new thread there: to ask a person, or to point at a problem. People see it at once.",
902
1267
  "Write @ and a name to mention a person or an agent of the topic: they are told like any mention.",
903
1268
  "A question waits for the answer: leave the thread open, the answer comes to your inbox.",
904
1269
  "",
905
1270
  "--file <file> The file of a new thread on a page: its id, or its name.",
906
1271
  "--page <n> The page, from 1.",
907
1272
  "--region <x,y,w,h> The rectangle on the page, as fractions (0 to 1). Default: the whole page.",
1273
+ "--time <t> A time of the video: seconds or m:ss, for example 42 or 0:42. A range has a",
1274
+ " dash, for example 0:40-0:48.",
1275
+ "--box <x,y,w,h> With one --time: a rectangle on the frame at that time, as fractions (0 to 1).",
1276
+ "--scene <n> A scene of the video's shot list, from 1: the thread takes the scene's range.",
908
1277
  "--at <x,y> A new thread on this point of the board, in board coordinates.",
909
1278
  "--topic <topic> The topic of --at, of a #12 or of a file name. Default: the topic open in Orlan now, else the one used last.",
910
1279
  AGENT_TOKEN_HELP,
@@ -916,6 +1285,9 @@ export const commands = {
916
1285
  file: { type: "string" },
917
1286
  page: { type: "string" },
918
1287
  region: { type: "string" },
1288
+ time: { type: "string" },
1289
+ box: { type: "string" },
1290
+ scene: { type: "string" },
919
1291
  at: { type: "string" },
920
1292
  topic: { type: "string" },
921
1293
  },
@@ -924,7 +1296,7 @@ export const commands = {
924
1296
  const numbers = (option, count) => numbersOption(values, option, count);
925
1297
  const [first, second] = positionals;
926
1298
  const body = (second ?? first);
927
- const usage = "orlan comment --help (the three forms)";
1299
+ const usage = "orlan comment --help (the forms)";
928
1300
  let target;
929
1301
  if (second !== undefined) {
930
1302
  if (values.file !== undefined || values.at !== undefined) {
@@ -932,6 +1304,27 @@ export const commands = {
932
1304
  }
933
1305
  target = { threadId: first };
934
1306
  }
1307
+ else if (typeof values.file === "string" && (values.time !== undefined || values.scene !== undefined)) {
1308
+ // A time, a range, a box on a frame or a scene of a video (F134, F135).
1309
+ if ((values.time === undefined) === (values.scene === undefined) || values.page !== undefined) {
1310
+ throw new OrlanError("A comment on a video takes --time or --scene: one of them.", undefined, usage);
1311
+ }
1312
+ const box = numbers("box", 4);
1313
+ const scene = Number(values.scene);
1314
+ if (values.scene !== undefined && (!Number.isInteger(scene) || scene < 1)) {
1315
+ throw new OrlanError("--scene takes the number of a scene, from 1.", undefined, "add --scene 1");
1316
+ }
1317
+ if (box && (typeof values.time !== "string" || values.time.includes("-"))) {
1318
+ throw new OrlanError("--box goes with one --time, not a range.", undefined, "add --time 0:42");
1319
+ }
1320
+ target = {
1321
+ video: {
1322
+ artifactId: values.file,
1323
+ ...(typeof values.time === "string" ? timeOption(values.time) : { scene }),
1324
+ ...(box ? { box: { x: box[0], y: box[1], w: box[2], h: box[3] } } : {}),
1325
+ },
1326
+ };
1327
+ }
935
1328
  else if (typeof values.file === "string") {
936
1329
  const page = Number(values.page);
937
1330
  if (!Number.isInteger(page) || page < 1) {
@@ -1116,7 +1509,7 @@ export const commands = {
1116
1509
  }),
1117
1510
  },
1118
1511
  "board add": {
1119
- usage: 'orlan board add note|text|frame|shape ["<text>"] --at <x,y> [--size <S|M|L|XL|w,h>] [--text-color <color>] [--kind <kind>] [--color <color>] [--frame <frame>|none] | arrow --from <item>[@x,y] --to <item>[@x,y] [--text "<label>"] [--id <id>] [--topic <topic>] [--agent <id>] [--json]',
1512
+ usage: 'orlan board add note|text|frame|shape ["<text>"] --at <x,y> [--size <S|M|L|XL|w,h>] [--text-color <color>] [--kind <kind>] [--color <color>] [--frame <frame>|none] | arrow --from <item>[@x,y] --to <item>[@x,y] [--text "<label>"] [--id <id>] [--topic <topic> | --new-topic <name>] [--agent <id>] [--json]',
1120
1513
  summary: "Add a note, a text, a shape, a frame or an arrow to the board.",
1121
1514
  details: [
1122
1515
  "A note, a text, a shape or a frame goes at --at, with its text (a frame's text is its title, a",
@@ -1148,6 +1541,7 @@ export const commands = {
1148
1541
  " holds more than half of it.",
1149
1542
  '--text <text> The text, as the "<text>" word gives it. For an arrow: its label, for example "yes".',
1150
1543
  "--topic <topic> The topic id or its name. Default: the topic open in Orlan now, else the one used last.",
1544
+ ...NEW_TOPIC_HELP,
1151
1545
  AGENT_TOKEN_HELP,
1152
1546
  JSON_HELP,
1153
1547
  ],
@@ -1165,6 +1559,7 @@ export const commands = {
1165
1559
  id: { type: "string" },
1166
1560
  frame: { type: "string" },
1167
1561
  topic: { type: "string" },
1562
+ "new-topic": { type: "string" },
1168
1563
  },
1169
1564
  positionals: [1, 2],
1170
1565
  run: (values, [type, word], io) => {
@@ -1215,10 +1610,10 @@ export const commands = {
1215
1610
  // The server refuses a frame on an arrow, and says why.
1216
1611
  Object.assign(edit, frameOption(values));
1217
1612
  return withAgent(values, async (api) => {
1218
- const { items, warnings } = await editBoard(api, values, io, [edit]);
1613
+ const { items, warnings, newTopic } = await editBoard(api, values, io, [edit]);
1219
1614
  const item = items[0];
1220
1615
  const where = type === "arrow" ? `, from ${values.from} to ${values.to}` : "";
1221
- return finish(io, values, { ...item, warnings }, [`Added ${boxText(item)}${where}.`, ...warningLines(warnings)], afterWarnings(warnings, type === "arrow"
1616
+ return finish(io, values, { ...item, warnings, ...(newTopic ? { newTopic } : {}) }, [`Added ${boxText(item)}${where}.`, ...warningLines(warnings)], afterWarnings(warnings, type === "arrow"
1222
1617
  ? "orlan board (the board with the arrow)"
1223
1618
  : `orlan board add arrow --from ${item.id} --to <item> (to link it), or orlan board`));
1224
1619
  });
@@ -1319,7 +1714,7 @@ export const commands = {
1319
1714
  }),
1320
1715
  },
1321
1716
  "board edit": {
1322
- usage: "orlan board edit '<edits as JSON>' [--topic <topic>] [--agent <id>] [--json]",
1717
+ usage: "orlan board edit '<edits as JSON>' [--topic <topic> | --new-topic <name>] [--agent <id>] [--json]",
1323
1718
  summary: "Make several changes to the board in one step: all of them, or none.",
1324
1719
  details: [
1325
1720
  "The edits are a JSON list of at most 100. A person can undo the step. A create can give its item",
@@ -1353,10 +1748,11 @@ export const commands = {
1353
1748
  ...WARNINGS_HELP,
1354
1749
  "",
1355
1750
  "--topic <topic> The topic id or its name. Default: the topic open in Orlan now, else the one used last.",
1751
+ ...NEW_TOPIC_HELP,
1356
1752
  AGENT_TOKEN_HELP,
1357
1753
  JSON_HELP,
1358
1754
  ],
1359
- options: { ...agentOption, ...jsonOption, topic: { type: "string" } },
1755
+ options: { ...agentOption, ...jsonOption, topic: { type: "string" }, "new-topic": { type: "string" } },
1360
1756
  positionals: [1, 1],
1361
1757
  run: (values, [text], io) => {
1362
1758
  let edits;
@@ -1370,8 +1766,8 @@ export const commands = {
1370
1766
  throw new OrlanError('The edits are a JSON list, for example [{"op":"delete","id":"<item>"}].', undefined, "orlan board edit --help");
1371
1767
  }
1372
1768
  return withAgent(values, async (api) => {
1373
- const { ids, items, warnings } = await editBoard(api, values, io, edits);
1374
- return finish(io, values, { ids, items, warnings }, [
1769
+ const { ids, items, warnings, newTopic } = await editBoard(api, values, io, edits);
1770
+ return finish(io, values, { ids, items, warnings, ...(newTopic ? { newTopic } : {}) }, [
1375
1771
  `Made ${ids.length} change${ids.length === 1 ? "" : "s"} in one step.`,
1376
1772
  ...items.map((item) => `- ${boxText(item)}`),
1377
1773
  ...warningLines(warnings),
@@ -1380,7 +1776,7 @@ export const commands = {
1380
1776
  },
1381
1777
  },
1382
1778
  "board mermaid": {
1383
- usage: "orlan board mermaid <file.mmd> [--frame <frame>] [--topic <topic>] [--agent <id>] [--json]",
1779
+ usage: "orlan board mermaid <file.mmd> [--frame <frame>] [--topic <topic> | --new-topic <name>] [--agent <id>] [--json]",
1384
1780
  summary: "Draw a Mermaid flowchart on the board as real shapes, labeled arrows and frames.",
1385
1781
  details: [
1386
1782
  "Orlan reads the flowchart and draws it in one step, which a person can undo: a shape for each",
@@ -1419,16 +1815,23 @@ export const commands = {
1419
1815
  "<file.mmd> The Mermaid file.",
1420
1816
  "--frame <frame> Draw the diagram in this frame, by its id, from `orlan board`.",
1421
1817
  "--topic <topic> The topic id or its name. Default: the topic open in Orlan now, else the one used last.",
1818
+ ...NEW_TOPIC_HELP,
1422
1819
  AGENT_TOKEN_HELP,
1423
1820
  JSON_HELP,
1424
1821
  ],
1425
- options: { ...agentOption, ...jsonOption, topic: { type: "string" }, frame: { type: "string" } },
1822
+ options: {
1823
+ ...agentOption,
1824
+ ...jsonOption,
1825
+ topic: { type: "string" },
1826
+ "new-topic": { type: "string" },
1827
+ frame: { type: "string" },
1828
+ },
1426
1829
  positionals: [1, 1],
1427
1830
  run: (values, [file], io) => withAgent(values, async (api) => {
1428
1831
  const source = await readFile(file, "utf8").catch((error) => {
1429
1832
  throw new OrlanError(`Orlan cannot read ${quote(file)}: ${error.code ?? error.message}.`, undefined, "give the path of a Mermaid file, for example orlan board mermaid flow.mmd");
1430
1833
  });
1431
- const topicId = await topicOrDefault(api, values, io);
1834
+ const { topicId, newTopic } = await writeTopic(api, values, io);
1432
1835
  const answer = await api.call("import_mermaid", {
1433
1836
  topicId,
1434
1837
  mermaid: source,
@@ -1444,7 +1847,7 @@ export const commands = {
1444
1847
  const made = [...count("shape", "shape"), ...count("arrow", "arrow"), ...count("frame", "frame")].join(", ");
1445
1848
  const prefixed = Object.entries(ids).filter(([from, to]) => from !== to);
1446
1849
  const where = typeof values.frame === "string" ? ` in frame ${values.frame}` : "";
1447
- return finish(io, values, { ids, items, warnings, replaced, ignored }, [
1850
+ return finish(io, values, { ids, items, warnings, replaced, ignored, ...(newTopic ? { newTopic } : {}) }, [
1448
1851
  `Drew ${quote(path.basename(file))}${where} in one step: ${made || "no items"}. A person can undo it.`,
1449
1852
  ...(replaced.length > 0
1450
1853
  ? [
@@ -1615,6 +2018,239 @@ export const commands = {
1615
2018
  : "orlan brief (where the work stands)");
1616
2019
  }),
1617
2020
  },
2021
+ brand: {
2022
+ usage: "orlan brand [--save <folder>] [--agent <id>] [--json]",
2023
+ summary: "Print the accepted brand kit of the organisation: colors, fonts, logos, tone, rules, formats.",
2024
+ details: [
2025
+ "Use it before you make a deck, a video, a carousel or a page, so the work is on brand with no brand",
2026
+ "words in the prompt. Orlan gives only the sections people accepted. A section that waits for review",
2027
+ "is named under draft, without its content: do not guess it. The references are work people like,",
2028
+ "each with a note.",
2029
+ "",
2030
+ "--save <folder> Also save the logo and font files of the kit in this folder.",
2031
+ AGENT_TOKEN_HELP,
2032
+ JSON_HELP,
2033
+ ],
2034
+ options: { ...agentOption, ...jsonOption, save: { type: "string" } },
2035
+ positionals: [0, 0],
2036
+ run: (values, _positionals, io) => withAgent(values, async (api, config) => {
2037
+ const kit = await api.call("brand", {});
2038
+ const saved = [];
2039
+ if (typeof values.save === "string" && kit.files.length > 0) {
2040
+ await mkdir(values.save, { recursive: true });
2041
+ for (const file of kit.files) {
2042
+ const response = await fetch(file.url, {
2043
+ headers: { authorization: `Bearer ${api.token}`, ...sessionHeader() },
2044
+ }).catch((error) => {
2045
+ throw unreachable(config.server, error);
2046
+ });
2047
+ if (!response.ok) {
2048
+ throw new OrlanError(`The download of ${quote(file.name)} answered ${response.status}.`, undefined, "run the command again");
2049
+ }
2050
+ // Two files with one name (a light and a dark logo.png) are both kept.
2051
+ const name = saved.includes(path.join(values.save, file.name)) ? `${file.id}-${file.name}` : file.name;
2052
+ const target = path.join(values.save, path.basename(name));
2053
+ await writeFile(target, Buffer.from(await response.arrayBuffer()));
2054
+ saved.push(target);
2055
+ }
2056
+ }
2057
+ const { sections } = kit;
2058
+ const lines = [`Brand kit v${kit.version}. Only the accepted sections are here.`];
2059
+ if (sections.colors) {
2060
+ lines.push(`colors: ${sections.colors.items.map((color) => `${color.name} ${color.hex}${color.use ? ` (${color.use})` : ""}`).join(", ") || "none"}`);
2061
+ }
2062
+ if (sections.fonts) {
2063
+ const font = (role) => sections.fonts?.[role].family || "not set";
2064
+ lines.push(`fonts: heading ${font("heading")}, body ${font("body")}`);
2065
+ }
2066
+ for (const file of kit.files)
2067
+ lines.push(`- ${file.role}: ${quote(file.name)} (${file.mime})`);
2068
+ if (sections.tone)
2069
+ lines.push(`tone: ${sections.tone.text || "not set"}`);
2070
+ if (sections.rules) {
2071
+ for (const rule of sections.rules.items)
2072
+ lines.push(`${rule.kind === "do" ? "do" : "do not"}: ${rule.text}`);
2073
+ }
2074
+ if (sections.formats) {
2075
+ const { video, deck, carousel } = sections.formats;
2076
+ lines.push(`formats: video ${video.join(", ") || "-"}; deck ${deck.join(", ") || "-"}; carousel ${carousel.join(", ") || "-"}`);
2077
+ }
2078
+ if (kit.draft.length)
2079
+ lines.push(`draft (waits for review, not for use): ${kit.draft.join(", ")}`);
2080
+ for (const ref of kit.references) {
2081
+ lines.push(`reference ${ref.kind}: ${ref.url ?? quote(ref.file?.name ?? "")} - ${quote(ref.note)}`);
2082
+ }
2083
+ for (const target of saved)
2084
+ lines.push(`Saved ${quote(target)}.`);
2085
+ // What people asked this agent to change (F141): each request ends with its own next_step line.
2086
+ const requests = kit.requests ?? [];
2087
+ for (const request of requests)
2088
+ lines.push("", ...brandRequestLines(request));
2089
+ return finish(io, values, { ...kit, ...(typeof values.save === "string" ? { saved } : {}) }, lines, requests.length > 0
2090
+ ? "answer each request above with orlan brand draft"
2091
+ : kit.files.length > 0 && typeof values.save !== "string"
2092
+ ? "orlan brand --save brand (the logo and font files)"
2093
+ : "make the work with this kit");
2094
+ }),
2095
+ },
2096
+ "brand draft": {
2097
+ usage: 'orlan brand draft <section> (--file <path> | --data <json>) [--note "<text>"] [--request <id>] [--agent <id>] [--json]',
2098
+ summary: "Write one section of the brand kit as a draft for people to review and accept.",
2099
+ details: [
2100
+ "Draft the kit from the website, the logo and font files and the files people give you: one call for",
2101
+ "each section. Each section waits as a draft until a person accepts it on the Brand kit page; an agent",
2102
+ "never accepts one. A draft answers your open requests on that section (orlan brand lists them).",
2103
+ "",
2104
+ "<section> colors, fonts, logos, tone, rules or formats. The content of each, as JSON:",
2105
+ ' colors {"items": [{"name": "Vivid Blue", "hex": "#0679FF", "use": "Buttons"}]}',
2106
+ ' fonts {"heading": {"family": "Avenir Next", "fileIds": []}, "body": {"family": "Inter", "fileIds": []}}',
2107
+ ' logos {"light": "<file id>", "dark": null, "mark": null}',
2108
+ ' tone {"text": "Short sentences. Say what the customer gets."}',
2109
+ ' rules {"items": [{"kind": "do", "text": "Captions on every video."}, {"kind": "dont", "text": "..."}]}',
2110
+ ' formats {"video": ["16:9", "9:16"], "deck": ["16:9"], "carousel": ["4:5"]}',
2111
+ " A file id comes from orlan brand upload.",
2112
+ "",
2113
+ "--file <path> The content in a JSON file.",
2114
+ "--data <json> The content as JSON text.",
2115
+ "--note <text> Where you took it from, or what you changed. People read it in the kit versions.",
2116
+ "--request <id> The request you answer, for example brand_12. Default: your open requests on the section.",
2117
+ AGENT_TOKEN_HELP,
2118
+ JSON_HELP,
2119
+ ],
2120
+ options: {
2121
+ ...agentOption,
2122
+ ...jsonOption,
2123
+ file: { type: "string" },
2124
+ data: { type: "string" },
2125
+ note: { type: "string" },
2126
+ request: { type: "string" },
2127
+ },
2128
+ positionals: [1, 1],
2129
+ run: async (values, [section], io) => {
2130
+ const file = typeof values.file === "string" ? values.file : undefined;
2131
+ const data = typeof values.data === "string" ? values.data : undefined;
2132
+ if ((file === undefined) === (data === undefined)) {
2133
+ throw new OrlanError("Give the content with --file or --data, one of them.", undefined, `orlan brand draft ${section} --file <path to the JSON content>`);
2134
+ }
2135
+ const text = file === undefined ? data : await readFile(file, "utf8");
2136
+ let content;
2137
+ try {
2138
+ content = JSON.parse(text);
2139
+ }
2140
+ catch {
2141
+ throw new OrlanError(`The content is not JSON${file ? ` in ${quote(file)}` : ""}.`, undefined, "orlan brand draft --help shows the content of each section");
2142
+ }
2143
+ return withAgent(values, async (api) => {
2144
+ const answer = await api.call("brand_draft", {
2145
+ section,
2146
+ content,
2147
+ ...(typeof values.note === "string" ? { note: values.note } : {}),
2148
+ ...(typeof values.request === "string" ? { requestId: values.request } : {}),
2149
+ });
2150
+ const { nextStep, ...rest } = answer;
2151
+ return finish(io, values, rest, [
2152
+ `Wrote ${answer.section} as a draft in brand kit v${answer.version}. It waits for a person to accept it.` +
2153
+ (answer.answered ? ` Answered ${answer.answered.join(", ")}.` : ""),
2154
+ ], nextStep);
2155
+ });
2156
+ },
2157
+ },
2158
+ "brand upload": {
2159
+ usage: "orlan brand upload <path> --kind logo|font [--agent <id>] [--json]",
2160
+ summary: "Upload a logo or a font file for the brand kit, and print its file id.",
2161
+ details: [
2162
+ "A logo is a PNG or a JPEG file; a font is an .otf, .ttf, .woff or .woff2 file. Name the file id in",
2163
+ "the logos or the fonts section with orlan brand draft.",
2164
+ "",
2165
+ "--kind <kind> logo or font. Required.",
2166
+ AGENT_TOKEN_HELP,
2167
+ JSON_HELP,
2168
+ ],
2169
+ options: { ...agentOption, ...jsonOption, kind: { type: "string" } },
2170
+ positionals: [1, 1],
2171
+ run: async (values, [filePath], io) => {
2172
+ const kind = values.kind;
2173
+ if (kind !== "logo" && kind !== "font") {
2174
+ throw new OrlanError("Add --kind logo or --kind font.", undefined, `orlan brand upload ${filePath} --kind logo`);
2175
+ }
2176
+ const form = new FormData();
2177
+ form.set("file", await openAsBlob(filePath), path.basename(filePath));
2178
+ return withAgent(values, async (api) => {
2179
+ const uploaded = await api.postForm("/api/agent/brand/files", { kind }, form, "the upload");
2180
+ return finish(io, values, uploaded, [`Uploaded ${quote(uploaded.name)} as ${uploaded.kind} file ${uploaded.id}.`], `name ${uploaded.id} in the ${kind === "logo" ? "logos" : "fonts"} section with orlan brand draft`);
2181
+ });
2182
+ },
2183
+ },
2184
+ studio: {
2185
+ usage: "orlan studio [<skill> [<step>]] [--agent <id>] [--json]",
2186
+ summary: "Print one step of a studio skill (video, deck, carousel, walkthrough): the steps come from Orlan.",
2187
+ details: [
2188
+ "A studio skill makes a piece of work on this computer in five steps: brief, script, build, render,",
2189
+ "review. Orlan gives the steps only to a signed-in agent, one step at a time: read each step when you",
2190
+ "come to it, and follow it. Read the brand kit first (orlan brand).",
2191
+ "",
2192
+ "orlan studio The skills, with their steps and helper scripts.",
2193
+ "orlan studio <skill> The steps and the helper scripts of one skill.",
2194
+ "orlan studio <skill> <step> The text of one step.",
2195
+ "",
2196
+ "Get the helper scripts of a skill with orlan studio pull <skill>.",
2197
+ AGENT_TOKEN_HELP,
2198
+ JSON_HELP,
2199
+ ],
2200
+ options: { ...agentOption, ...jsonOption },
2201
+ positionals: [0, 2],
2202
+ run: (values, [skill, step], io) => withStudio(values, async (api) => {
2203
+ if (!skill) {
2204
+ const { skills } = await api.call("studio", {});
2205
+ const lines = skills.length
2206
+ ? skills.map((each) => `${each.skill}: ${skillLine(each)}`)
2207
+ : ["Orlan has no studio skills yet."];
2208
+ return finish(io, values, { skills }, lines, "orlan studio <skill> brief");
2209
+ }
2210
+ if (!step) {
2211
+ const found = await api.call("studio", { skill });
2212
+ const next = `${found.scripts.length ? `orlan studio pull ${skill}, then ` : ""}orlan studio ${skill} brief`;
2213
+ return finish(io, values, { ...found }, [`${skill}: ${skillLine(found)}`], next);
2214
+ }
2215
+ const answer = await api.call("studio", { skill, step });
2216
+ const after = STUDIO_STEPS[STUDIO_STEPS.indexOf(step) + 1];
2217
+ return finish(io, values, answer, [answer.text.trimEnd()], after
2218
+ ? `orlan studio ${skill} ${after}`
2219
+ : "post the work: orlan files add <file>, or orlan files push for a new version");
2220
+ }),
2221
+ },
2222
+ "studio pull": {
2223
+ usage: "orlan studio pull <skill> [--to <folder>] [--agent <id>] [--json]",
2224
+ summary: "Write the helper scripts of a studio skill into the project, from Orlan.",
2225
+ details: [
2226
+ "The steps of the skill name these scripts. Pull them again for the newest copy: each file is written",
2227
+ "again, and files that you added stay.",
2228
+ "",
2229
+ "--to <folder> The folder to write the scripts in. Default: .orlan/studio/<skill> in this folder.",
2230
+ AGENT_TOKEN_HELP,
2231
+ JSON_HELP,
2232
+ ],
2233
+ options: { ...agentOption, ...jsonOption, to: { type: "string" } },
2234
+ positionals: [1, 1],
2235
+ run: (values, [skill], io) => withStudio(values, async (api) => {
2236
+ const { files } = await api.call("studio", { skill, scripts: true });
2237
+ const folder = path.resolve(typeof values.to === "string" ? values.to : path.join(".orlan", "studio", `${skill}`));
2238
+ for (const file of files) {
2239
+ const target = path.resolve(folder, file.path);
2240
+ // A path from the server stays in the folder.
2241
+ if (!target.startsWith(`${folder}${path.sep}`)) {
2242
+ throw new OrlanError(`Orlan named a script path outside the folder: ${quote(file.path)}.`, "internal_error");
2243
+ }
2244
+ await mkdir(path.dirname(target), { recursive: true });
2245
+ await writeFile(target, Buffer.from(file.contentBase64, "base64"));
2246
+ await chmod(target, file.executable ? 0o755 : 0o644);
2247
+ }
2248
+ return finish(io, values, { skill, folder, files: files.map((file) => file.path) }, [
2249
+ `Pulled ${files.length} helper scripts of ${skill} into ${folder}.`,
2250
+ ...files.map((file) => `- ${file.path}`),
2251
+ ], `orlan studio ${skill} brief`);
2252
+ }),
2253
+ },
1618
2254
  brief: {
1619
2255
  usage: "orlan brief [--topic <topic>] [--agent <id>] [--json]",
1620
2256
  summary: "Print where the work stands: the topic, the open requests, the files, and the next step.",
@@ -1655,7 +2291,7 @@ export const commands = {
1655
2291
  options: { ...agentOption, ...jsonOption, new: { type: "boolean" } },
1656
2292
  positionals: [0, 0],
1657
2293
  run: (values, _positionals, io) => withAgent(values, async (api) => {
1658
- const { requests, stopped } = await api.get("/api/inbox", { new: values.new ? "true" : undefined }, "the inbox");
2294
+ const { requests, stopped, brand = [], } = await api.get("/api/inbox", { new: values.new ? "true" : undefined }, "the inbox");
1659
2295
  // A person stopped this session on the board (D109): the stop, also with --new.
1660
2296
  if (stopped) {
1661
2297
  const [line = "", step = ""] = stopped.lines;
@@ -1667,20 +2303,23 @@ export const commands = {
1667
2303
  return 0;
1668
2304
  }
1669
2305
  // --new with no request prints nothing.
2306
+ // The brand kit requests (F141) come after the topic requests, each with its own next_step line.
2307
+ const all = [...requests, ...brand];
1670
2308
  if (values.json) {
1671
- if (requests.length > 0 || !values.new) {
1672
- const last = requests.at(-1);
2309
+ if (all.length > 0 || !values.new) {
2310
+ const last = all.at(-1);
1673
2311
  printJson(io, {
1674
2312
  requests: requests.map((each) => each.request),
2313
+ ...(brand.length > 0 ? { brand: brand.map((each) => each.request) } : {}),
1675
2314
  next_step: last ? deliveryStep(last) : NOTHING_STEP,
1676
2315
  });
1677
2316
  }
1678
2317
  }
1679
2318
  else {
1680
- if (requests.length === 0 && !values.new)
2319
+ if (all.length === 0 && !values.new)
1681
2320
  io.out(`No open requests.\nnext_step: ${NOTHING_STEP}`);
1682
2321
  // Each request ends with its own next_step line. The line on quoted text comes once.
1683
- for (const [index, delivery] of requests.entries()) {
2322
+ for (const [index, delivery] of all.entries()) {
1684
2323
  if (index > 0)
1685
2324
  io.out("");
1686
2325
  const [first = "", ...rest] = delivery.lines;
@@ -1746,7 +2385,7 @@ export const commands = {
1746
2385
  const api = new AgentApi(config.server, token);
1747
2386
  // The file goes to the request's topic in one request; a done request refuses it before it is read.
1748
2387
  const uploadId = typeof values.file === "string"
1749
- ? await api.upload(await readFile(values.file), { request: requestId }, "respond")
2388
+ ? await api.upload(await openAsBlob(values.file), { request: requestId, name: path.basename(values.file) }, "respond")
1750
2389
  : undefined;
1751
2390
  const answer = await api.call("respond", {
1752
2391
  requestId,
@@ -1793,14 +2432,20 @@ export const commands = {
1793
2432
  },
1794
2433
  },
1795
2434
  };
1796
- /** Runs edit_board on the topic of --topic, else the default topic: one step, all edits or none. */
2435
+ /**
2436
+ * Runs edit_board on the topic of --topic, else the default topic: one step, all edits or none. Edits that
2437
+ * add items can make a new topic (--new-topic, F131); the answer names it.
2438
+ */
1797
2439
  async function editBoard(api, values, io, edits) {
2440
+ const { topicId, newTopic } = edits.some((edit) => edit.op === "create")
2441
+ ? await writeTopic(api, values, io)
2442
+ : { topicId: await topicOrDefault(api, values, io), newTopic: undefined };
1798
2443
  const answer = await api.call("edit_board", {
1799
- topicId: await topicOrDefault(api, values, io),
2444
+ topicId,
1800
2445
  edits,
1801
2446
  });
1802
2447
  // A server before F096 gives no warnings.
1803
- return { ...answer, warnings: answer.warnings ?? [] };
2448
+ return { ...answer, warnings: answer.warnings ?? [], ...(newTopic ? { newTopic } : {}) };
1804
2449
  }
1805
2450
  /** --assignee as the tool takes it: me (the token of this agent), user:<id>, agent:<id>, or none (null). */
1806
2451
  function assigneeOption(config, values) {
@@ -1832,6 +2477,11 @@ function voteText(vote) {
1832
2477
  `, ${vote.voters} voted` +
1833
2478
  (result ? `, result: ${result}` : ""));
1834
2479
  }
2480
+ /** A scorecard as its sum of the most it can be (F139): "65/80". */
2481
+ function scoreTotal(scores) {
2482
+ const sum = scores.reduce((total, each) => total + each.score, 0);
2483
+ return `${Math.round(sum * 10) / 10}/${scores.length * 10}`;
2484
+ }
1835
2485
  /** --pages as page numbers: "3" or "2,5". */
1836
2486
  function pageList(values) {
1837
2487
  if (typeof values.pages !== "string")
@@ -1907,3 +2557,10 @@ async function pullPages(api, config, values, io, answer, since) {
1907
2557
  }
1908
2558
  /** The changed pages of a version, as words: "pages 2, 5 changed", "no page changed". */
1909
2559
  const changedText = (pages) => pages.length === 0 ? "no page changed" : `page${pages.length === 1 ? "" : "s"} ${pages.join(", ")} changed`;
2560
+ /** A brand request as text, as `orlan inbox` prints it. Every text people wrote is quoted. */
2561
+ const brandRequestLines = (request) => [
2562
+ `${request.id}: a request from ${quote(request.from)} (person) on Orlan, brand kit section ${request.section}. ${QUOTED_TEXT}`,
2563
+ `${quote(request.from)} wrote: ${quote(request.text)}`,
2564
+ `${request.section} now: ${JSON.stringify(request.current)}`,
2565
+ `next_step: write the changed ${request.section} as JSON in a file, then orlan brand draft ${request.section} --file <path> --request ${request.id} --note "<what changed>"`,
2566
+ ];