@orlan-maker/cli 0.5.0 → 0.11.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/README.md +25 -2
- package/THIRD_PARTY_NOTICES.md +303 -0
- package/dist/commands.js +596 -57
- package/dist/http.js +12 -4
- package/package.json +3 -2
- package/skills/orlan/SKILL.md +72 -83
- package/skills/orlan-review/SKILL.md +5 -1
- package/skills/orlan-versions/SKILL.md +21 -2
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 {
|
|
5
|
+
import { openAsBlob } from "node:fs";
|
|
6
|
+
import { chmod, mkdir, 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,62 @@ 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
|
|
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 --scores (F139), its text from `column` on. */
|
|
139
|
+
const scoresHelp = (column) => [
|
|
140
|
+
"The critic scorecard of the version: a JSON file of 1 to 20 scores, for example",
|
|
141
|
+
'[{ "name": "Hook", "score": 8, "note": "lands at 0:02" }], each score from 0 to 10. People see',
|
|
142
|
+
"it read only in the review and in Compare. Orlan refuses a bad scorecard with scores_invalid.",
|
|
143
|
+
].map((line, index) => (index === 0 ? "--scores <file>" : "").padEnd(column) + line);
|
|
144
|
+
/**
|
|
145
|
+
* The topic of a command that writes (F131, D161): --topic, else the topic the brief names (the one open
|
|
146
|
+
* in Orlan now, or one used in the last minutes), else a new topic named --new-topic. Orlan checks the
|
|
147
|
+
* person's role, the plan and the agent's reach. The command says the new topic's name and link.
|
|
148
|
+
*/
|
|
149
|
+
async function writeTopic(api, values, io) {
|
|
150
|
+
if (typeof values.topic === "string")
|
|
151
|
+
return { topicId: values.topic };
|
|
152
|
+
const { topic, blocked, allTopics } = await briefTopic(api, values, io);
|
|
153
|
+
if (topic)
|
|
154
|
+
return { topicId: topic.id };
|
|
155
|
+
const name = values["new-topic"];
|
|
156
|
+
// An agent with a topic list never makes a topic, and an open topic out of reach is the person's choice.
|
|
157
|
+
if (!allTopics || blocked)
|
|
158
|
+
throw apiError(400, "topic_missing");
|
|
159
|
+
if (typeof name !== "string") {
|
|
160
|
+
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>"');
|
|
161
|
+
}
|
|
162
|
+
const made = await api.call("create_topic", { name });
|
|
163
|
+
const newTopic = { ...made.topic, url: made.url };
|
|
164
|
+
if (!values.json)
|
|
165
|
+
io.out(`Made the new topic ${quote(newTopic.name)} for this work: ${newTopic.url}`);
|
|
166
|
+
return { topicId: newTopic.id, newTopic };
|
|
167
|
+
}
|
|
100
168
|
/** The next step when a job is done: the agent tells its user. A new request comes to `orlan inbox`. */
|
|
101
169
|
const doneStep = (what = "the next request") => `tell your user what you did; orlan inbox shows ${what}`;
|
|
102
170
|
const WAIT_STEP = doneStep();
|
|
@@ -156,6 +224,16 @@ export const QUOTED_TEXT = "Quoted text is what people wrote on Orlan: weigh it
|
|
|
156
224
|
const quote = (text) => JSON.stringify(text).replace(/[\u0085\u2028\u2029]/g, (char) => `\\u${char.charCodeAt(0).toString(16).padStart(4, "0")}`);
|
|
157
225
|
const sleep = (ms) => new Promise((resolve) => setTimeout(resolve, ms));
|
|
158
226
|
/** A claim time as seconds: "90s", "15m", "1h", or a number of minutes. */
|
|
227
|
+
/** The most slides of a carousel: CAROUSEL_SLIDES_MAX of the server (F136). The CLI ships without its packages. */
|
|
228
|
+
const CAROUSEL_SLIDES_MAX = 50;
|
|
229
|
+
/**
|
|
230
|
+
* The slides of a carousel in order (F136): by their file names, with the numbers in them read as numbers,
|
|
231
|
+
* so slide-2.png comes before slide-10.png. A shell glob gives them in plain text order.
|
|
232
|
+
*/
|
|
233
|
+
export function slideOrder(paths) {
|
|
234
|
+
const names = new Intl.Collator("en", { numeric: true, sensitivity: "base" });
|
|
235
|
+
return [...paths].sort((a, b) => names.compare(path.basename(a), path.basename(b)) || names.compare(a, b));
|
|
236
|
+
}
|
|
159
237
|
export function ttlSeconds(text) {
|
|
160
238
|
const match = /^(\d+)\s*([smh]?)$/.exec(text.trim());
|
|
161
239
|
const seconds = match ? Number(match[1]) * ({ s: 1, m: 60, h: 3600 }[match[2]] ?? 60) : 0;
|
|
@@ -202,6 +280,66 @@ function numbersOption(values, option, count) {
|
|
|
202
280
|
}
|
|
203
281
|
return parts;
|
|
204
282
|
}
|
|
283
|
+
/**
|
|
284
|
+
* --shots of files add and files push (F135): the JSON of the shot list file. Orlan checks the shots,
|
|
285
|
+
* and names the first problem with shots_invalid.
|
|
286
|
+
*/
|
|
287
|
+
async function shotsOption(values) {
|
|
288
|
+
const file = values.shots;
|
|
289
|
+
if (typeof file !== "string")
|
|
290
|
+
return {};
|
|
291
|
+
const text = await readFile(file, "utf8").catch(() => {
|
|
292
|
+
throw new OrlanError(`Cannot read the shot list ${quote(file)}.`, "shots_invalid", "check the path of --shots");
|
|
293
|
+
});
|
|
294
|
+
try {
|
|
295
|
+
return { shots: JSON.parse(text) };
|
|
296
|
+
}
|
|
297
|
+
catch {
|
|
298
|
+
throw new OrlanError(`The shot list ${quote(file)} is not JSON.`, "shots_invalid", 'write a JSON array of { "name": text, "start": seconds, "end": seconds }');
|
|
299
|
+
}
|
|
300
|
+
}
|
|
301
|
+
/** --scores of files add and files push (F139): the JSON of the scorecard file. Orlan checks the scores. */
|
|
302
|
+
async function scoresOption(values) {
|
|
303
|
+
const file = values.scores;
|
|
304
|
+
if (typeof file !== "string")
|
|
305
|
+
return {};
|
|
306
|
+
const text = await readFile(file, "utf8").catch(() => {
|
|
307
|
+
throw new OrlanError(`Cannot read the scorecard ${quote(file)}.`, "scores_invalid", "check the path of --scores");
|
|
308
|
+
});
|
|
309
|
+
try {
|
|
310
|
+
return { scores: JSON.parse(text) };
|
|
311
|
+
}
|
|
312
|
+
catch {
|
|
313
|
+
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 }');
|
|
314
|
+
}
|
|
315
|
+
}
|
|
316
|
+
/** --answers of files push (F139): the comment numbers the version answers, "3,5" or "#3,#5". */
|
|
317
|
+
function answersOption(values) {
|
|
318
|
+
if (typeof values.answers !== "string")
|
|
319
|
+
return {};
|
|
320
|
+
const answers = values.answers.split(",").map((part) => Number(part.trim().replace(/^#/, "")));
|
|
321
|
+
if (answers.length === 0 || answers.some((number) => !Number.isInteger(number) || number < 1)) {
|
|
322
|
+
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");
|
|
323
|
+
}
|
|
324
|
+
return { answers };
|
|
325
|
+
}
|
|
326
|
+
/** 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. */
|
|
327
|
+
function secondsOf(text) {
|
|
328
|
+
if (!/^\d+(:\d{1,2}){0,2}(\.\d+)?$/.test(text.trim()))
|
|
329
|
+
return Number.NaN;
|
|
330
|
+
return text
|
|
331
|
+
.trim()
|
|
332
|
+
.split(":")
|
|
333
|
+
.reduce((sum, part) => sum * 60 + Number(part), 0);
|
|
334
|
+
}
|
|
335
|
+
/** --time of a comment on a video (F134): a time, or a range with a dash, for example 0:40-0:48. */
|
|
336
|
+
function timeOption(raw) {
|
|
337
|
+
const [at, end, ...rest] = raw.split("-").map(secondsOf);
|
|
338
|
+
if (at === undefined || Number.isNaN(at) || (end !== undefined && Number.isNaN(end)) || rest.length > 0) {
|
|
339
|
+
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");
|
|
340
|
+
}
|
|
341
|
+
return end === undefined ? { at } : { at, end };
|
|
342
|
+
}
|
|
205
343
|
/**
|
|
206
344
|
* An arrow end of --from or --to: <item>, or <item>@x,y for an exact spot on the item (F111), x and y
|
|
207
345
|
* from 0 to 1 of its box. "none" (board change) is no item.
|
|
@@ -602,45 +740,129 @@ export const commands = {
|
|
|
602
740
|
const { files } = await api.call("read_board", { topicId, view: "files" });
|
|
603
741
|
return finish(io, values, { files }, files.length === 0
|
|
604
742
|
? ["No files."]
|
|
605
|
-
: files.map((file) => `${file.id} ${quote(file.name)} ${file.kind}
|
|
743
|
+
: files.map((file) => `${file.id} ${quote(file.name)} ${file.kind}` +
|
|
744
|
+
// A video says its length (F133).
|
|
745
|
+
(file.current.durationMs !== undefined ? ` ${Math.round(file.current.durationMs / 1000)} s` : "") +
|
|
746
|
+
// The scenes of its shot list (F135).
|
|
747
|
+
(file.current.scenes ? ` ${file.current.scenes} scene${file.current.scenes === 1 ? "" : "s"}` : "") +
|
|
748
|
+
// The slides of a carousel (F136).
|
|
749
|
+
(file.current.slides ? ` ${file.current.slides} slide${file.current.slides === 1 ? "" : "s"}` : "") +
|
|
750
|
+
` v${file.current.number} current (${file.current.status}), ` +
|
|
606
751
|
`${file.versions} version${file.versions === 1 ? "" : "s"}` +
|
|
607
752
|
(file.current.changed
|
|
608
753
|
? `, ${changedText(file.current.changed.pages)} from v${file.current.changed.from}`
|
|
609
|
-
: "")
|
|
754
|
+
: "") +
|
|
755
|
+
// The final version a person picked, with its formats (F137).
|
|
756
|
+
(file.final ? `, final v${file.final.version} (${file.final.formats.join(", ")})` : "") +
|
|
757
|
+
// The critic scorecard of the newest scored version (F139).
|
|
758
|
+
(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
759
|
}),
|
|
611
760
|
},
|
|
612
761
|
"files add": {
|
|
613
|
-
usage: "orlan files add <path> [--at <x,y>] [--topic <topic>] [--agent <id>] [--json]",
|
|
762
|
+
usage: "orlan files add <path>... [--carousel [--name <name>]] [--at <x,y>] [--shots <shots.json>] [--scores <scores.json>] [--topic <topic> | --new-topic <name>] [--agent <id>] [--json]",
|
|
614
763
|
summary: "Post a local file as a new file on a topic, and put it on the board.",
|
|
615
764
|
details: [
|
|
616
765
|
"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,
|
|
766
|
+
"renders its pages. A PowerPoint, Word, Excel, PDF, PNG, JPG or HTML file, or an MP4 or WebM video,",
|
|
767
|
+
"with the right extension. The file streams from the disk, so a large video works too.",
|
|
618
768
|
"",
|
|
769
|
+
"--carousel The paths are the PNG or JPG slides of one carousel: one file on the board.",
|
|
770
|
+
" The slides go in the order of the numbers in their names: slide-2 before slide-10.",
|
|
771
|
+
"--name <name> With --carousel: the name of the carousel. Default: the name of its first slide.",
|
|
619
772
|
"--at <x,y> Put it at this point of the board, in board coordinates, or at the nearest free",
|
|
620
773
|
" place. `orlan board` shows where the items are.",
|
|
774
|
+
...shotsHelp(17),
|
|
775
|
+
...scoresHelp(17),
|
|
621
776
|
"--topic <topic> The topic id or its name. Default: the topic open in Orlan now, else the one used last.",
|
|
777
|
+
...NEW_TOPIC_HELP,
|
|
622
778
|
AGENT_TOKEN_HELP,
|
|
623
779
|
JSON_HELP,
|
|
624
780
|
],
|
|
625
|
-
options: {
|
|
626
|
-
|
|
627
|
-
|
|
781
|
+
options: {
|
|
782
|
+
...agentOption,
|
|
783
|
+
...jsonOption,
|
|
784
|
+
topic: { type: "string" },
|
|
785
|
+
"new-topic": { type: "string" },
|
|
786
|
+
at: { type: "string" },
|
|
787
|
+
shots: { type: "string" },
|
|
788
|
+
scores: { type: "string" },
|
|
789
|
+
carousel: { type: "boolean" },
|
|
790
|
+
name: { type: "string" },
|
|
791
|
+
},
|
|
792
|
+
positionals: [1, CAROUSEL_SLIDES_MAX],
|
|
793
|
+
run: (values, paths, io) => withAgent(values, async (api) => {
|
|
794
|
+
const [filePath] = paths;
|
|
795
|
+
if (!values.carousel && (paths.length > 1 || values.name !== undefined)) {
|
|
796
|
+
throw new OrlanError(paths.length > 1
|
|
797
|
+
? "Give one file, or add --carousel for the slides of one carousel."
|
|
798
|
+
: "--name goes with --carousel.", undefined, `orlan files add ${paths.join(" ")} --carousel`);
|
|
799
|
+
}
|
|
628
800
|
const at = numbersOption(values, "at", 2);
|
|
629
|
-
|
|
630
|
-
|
|
631
|
-
|
|
632
|
-
|
|
633
|
-
|
|
801
|
+
const shots = await shotsOption(values);
|
|
802
|
+
const scores = await scoresOption(values);
|
|
803
|
+
if (values.carousel && shots.shots !== undefined) {
|
|
804
|
+
throw new OrlanError("--shots goes with a video, not with --carousel.", "shots_invalid", "leave out --shots");
|
|
805
|
+
}
|
|
806
|
+
const { topicId, newTopic } = await writeTopic(api, values, io);
|
|
807
|
+
const madeTopic = newTopic ? { newTopic } : {};
|
|
808
|
+
if (values.carousel) {
|
|
809
|
+
// Each slide goes up first, in order; the post makes them one carousel (F136).
|
|
810
|
+
const slides = slideOrder(paths);
|
|
811
|
+
const uploadIds = [];
|
|
812
|
+
for (const slide of slides) {
|
|
813
|
+
uploadIds.push(await api.upload(await openAsBlob(slide), { topic: topicId, name: path.basename(slide) }));
|
|
814
|
+
}
|
|
815
|
+
const name = typeof values.name === "string" && values.name.trim()
|
|
816
|
+
? values.name.trim()
|
|
817
|
+
: path.parse(slides[0]).name;
|
|
818
|
+
const posted = await api.call("post", {
|
|
819
|
+
topicId,
|
|
820
|
+
name,
|
|
821
|
+
slides: uploadIds,
|
|
822
|
+
...(at ? { position: { x: at[0], y: at[1] } } : {}),
|
|
823
|
+
...scores,
|
|
824
|
+
});
|
|
825
|
+
return finish(io, values, {
|
|
826
|
+
file: {
|
|
827
|
+
id: posted.file.id,
|
|
828
|
+
name: posted.file.name,
|
|
829
|
+
version: posted.posted.number,
|
|
830
|
+
slides: slides.length,
|
|
831
|
+
},
|
|
832
|
+
boardItemId: posted.boardItemId,
|
|
833
|
+
...madeTopic,
|
|
834
|
+
}, [
|
|
835
|
+
`Added the carousel ${quote(posted.file.name)} as file ${posted.file.id}, v${posted.posted.number}, ` +
|
|
836
|
+
`${slides.length} slide${slides.length === 1 ? "" : "s"} in this order: ` +
|
|
837
|
+
`${slides.map((slide) => path.basename(slide)).join(", ")}. It is on the board.`,
|
|
838
|
+
], doneStep("comments on the file"));
|
|
839
|
+
}
|
|
840
|
+
if (at || shots.shots !== undefined || scores.scores !== undefined) {
|
|
841
|
+
// A place on the board, a shot list or a scorecard: the file goes up first, then the post places it.
|
|
842
|
+
const uploadId = await api.upload(await openAsBlob(filePath), {
|
|
843
|
+
topic: topicId,
|
|
844
|
+
name: path.basename(filePath),
|
|
845
|
+
});
|
|
846
|
+
const posted = await api.call("post", {
|
|
847
|
+
topicId,
|
|
848
|
+
name: path.basename(filePath),
|
|
849
|
+
uploadId,
|
|
850
|
+
...(at ? { position: { x: at[0], y: at[1] } } : {}),
|
|
851
|
+
...shots,
|
|
852
|
+
...scores,
|
|
853
|
+
});
|
|
634
854
|
return finish(io, values, {
|
|
635
855
|
file: { id: posted.file.id, name: posted.file.name, version: posted.posted.number },
|
|
636
856
|
boardItemId: posted.boardItemId,
|
|
857
|
+
...madeTopic,
|
|
637
858
|
}, [
|
|
638
859
|
`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.`,
|
|
860
|
+
`board${at ? ` at ${at.join(",")}, or at the nearest free place` : ""}; its pages render now.`,
|
|
861
|
+
...(shots.shots !== undefined ? ["Its shot list shows as the scene list of the video."] : []),
|
|
640
862
|
], doneStep("comments on the file"));
|
|
641
863
|
}
|
|
642
|
-
const { nextStep, ...answer } = await api.postFile("/api/agent/files", { name: path.basename(filePath), topic:
|
|
643
|
-
return finish(io, values, answer, [
|
|
864
|
+
const { nextStep, ...answer } = await api.postFile("/api/agent/files", { name: path.basename(filePath), topic: topicId }, await openAsBlob(filePath), "the new file");
|
|
865
|
+
return finish(io, values, { ...answer, ...madeTopic }, [
|
|
644
866
|
`Added ${quote(answer.file.name)} to topic ${quote(answer.topic.name)} as file ` +
|
|
645
867
|
`${answer.file.id}, v${answer.file.version}. It is on the board; its pages render now.`,
|
|
646
868
|
], nextStep);
|
|
@@ -720,7 +942,10 @@ export const commands = {
|
|
|
720
942
|
if (!response.ok) {
|
|
721
943
|
throw new OrlanError(`The download from ${config.server} answered ${response.status}.`, undefined, "run the command again");
|
|
722
944
|
}
|
|
723
|
-
|
|
945
|
+
// A carousel's original is the zip of its slides (F136).
|
|
946
|
+
const target = typeof values.out === "string"
|
|
947
|
+
? values.out
|
|
948
|
+
: `${path.basename(answer.file.name)}${answer.file.kind === "carousel" ? ".zip" : ""}`;
|
|
724
949
|
await writeFile(target, Buffer.from(await response.arrayBuffer()));
|
|
725
950
|
const lines = [
|
|
726
951
|
`Saved version ${answer.version.number} of ${quote(answer.file.name)} to ${quote(target)}.`,
|
|
@@ -737,16 +962,22 @@ export const commands = {
|
|
|
737
962
|
}),
|
|
738
963
|
},
|
|
739
964
|
"files push": {
|
|
740
|
-
usage: 'orlan files push <file> <path
|
|
965
|
+
usage: 'orlan files push <file> <path>... --changelog "<what changed>" [--answers <n,n>] [--base <n>] [--shots <shots.json>] [--scores <scores.json>] [--topic <topic>] [--agent <id>] [--json]',
|
|
741
966
|
summary: "Post a local file as a new version of a file.",
|
|
742
967
|
details: [
|
|
743
968
|
"Two requests: one sends the file, one posts it with the changelog. It must be the same type of",
|
|
744
969
|
"file. The version waits for a person to make it current. It ends your claim. When a version",
|
|
745
970
|
"landed after your base, Orlan refuses the push with stale_base and its changelog.",
|
|
971
|
+
"For a carousel, give each PNG or JPG slide. They go in the order of the numbers in their names.",
|
|
746
972
|
"",
|
|
747
973
|
"<file> The file id, or its name where it is unique on the topic.",
|
|
748
974
|
"--changelog <text> What changed, for the people who review it. Required.",
|
|
975
|
+
"--answers <n,n> The comments this version answers, by number: 3,5 or #3,#5. Each shows",
|
|
976
|
+
' "answered in v<n>". A "#3" in the changelog counts too. A number that is no',
|
|
977
|
+
" comment on the file is refused with answers_invalid.",
|
|
749
978
|
"--base <n> The version you edited, for example 4 or v4. Default: the version your `pull --edit` got.",
|
|
979
|
+
...shotsHelp(20),
|
|
980
|
+
...scoresHelp(20),
|
|
750
981
|
"--topic <topic> The topic of a file name, when two topics have a file with that name.",
|
|
751
982
|
AGENT_TOKEN_HELP,
|
|
752
983
|
JSON_HELP,
|
|
@@ -757,24 +988,37 @@ export const commands = {
|
|
|
757
988
|
changelog: { type: "string" },
|
|
758
989
|
base: { type: "string" },
|
|
759
990
|
topic: { type: "string" },
|
|
991
|
+
shots: { type: "string" },
|
|
992
|
+
scores: { type: "string" },
|
|
993
|
+
answers: { type: "string" },
|
|
760
994
|
},
|
|
761
|
-
positionals: [2,
|
|
762
|
-
run: (values, [fileRef,
|
|
995
|
+
positionals: [2, CAROUSEL_SLIDES_MAX + 1],
|
|
996
|
+
run: (values, [fileRef, ...filePaths], io) => withAgent(values, async (api) => {
|
|
763
997
|
const changelog = typeof values.changelog === "string" ? values.changelog.trim() : "";
|
|
764
998
|
if (!changelog) {
|
|
765
|
-
throw new OrlanError("Add --changelog.", undefined, `orlan files push ${fileRef} ${
|
|
999
|
+
throw new OrlanError("Add --changelog.", undefined, `orlan files push ${fileRef} ${filePaths.join(" ")} --changelog "<what changed>"`);
|
|
766
1000
|
}
|
|
767
1001
|
const base = baseOption(values);
|
|
768
|
-
const
|
|
769
|
-
|
|
770
|
-
|
|
771
|
-
|
|
1002
|
+
const shots = await shotsOption(values);
|
|
1003
|
+
const scores = await scoresOption(values);
|
|
1004
|
+
const answers = answersOption(values);
|
|
1005
|
+
// Several paths are the slides of a new carousel version (F136), in the order of their names.
|
|
1006
|
+
const uploadIds = [];
|
|
1007
|
+
for (const filePath of filePaths.length > 1 ? slideOrder(filePaths) : filePaths) {
|
|
1008
|
+
uploadIds.push(await api.upload(await openAsBlob(filePath), {
|
|
1009
|
+
file: fileRef,
|
|
1010
|
+
topic: values.topic,
|
|
1011
|
+
}));
|
|
1012
|
+
}
|
|
772
1013
|
const posted = await api.call("post", {
|
|
773
1014
|
artifactId: fileRef,
|
|
774
1015
|
...topicInput(values),
|
|
775
|
-
uploadId,
|
|
1016
|
+
...(uploadIds.length > 1 ? { slides: uploadIds } : { uploadId: uploadIds[0] }),
|
|
776
1017
|
changelog,
|
|
777
1018
|
...base,
|
|
1019
|
+
...shots,
|
|
1020
|
+
...scores,
|
|
1021
|
+
...answers,
|
|
778
1022
|
});
|
|
779
1023
|
return finish(io, values, { file: { id: posted.file.id, name: posted.file.name }, version: posted.posted.number }, [
|
|
780
1024
|
`Posted version ${posted.posted.number} of ${quote(posted.file.name)}. It waits for a person to make it current.`,
|
|
@@ -876,7 +1120,10 @@ export const commands = {
|
|
|
876
1120
|
lines.push(`file ${quote(file.name)}:`);
|
|
877
1121
|
for (const thread of file.threads) {
|
|
878
1122
|
const place = `${thread.at}${thread.region ? `, region ${thread.region.join(",")}` : ""}` +
|
|
879
|
-
(thread.element ? `, element ${quote(thread.element)}` : "")
|
|
1123
|
+
(thread.element ? `, element ${quote(thread.element)}` : "") +
|
|
1124
|
+
(thread.box ? `, box ${thread.box.join(",")}` : "") +
|
|
1125
|
+
(thread.scene ? `, scene ${thread.scene.index} ${quote(thread.scene.name)}` : "") +
|
|
1126
|
+
(thread.answeredIn ? `, answered in ${thread.answeredIn}` : "");
|
|
880
1127
|
lines.push(...threadLines(thread, place));
|
|
881
1128
|
}
|
|
882
1129
|
}
|
|
@@ -894,17 +1141,21 @@ export const commands = {
|
|
|
894
1141
|
}),
|
|
895
1142
|
},
|
|
896
1143
|
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.",
|
|
1144
|
+
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]',
|
|
1145
|
+
summary: "Reply in a thread, or open a new thread on a page, on a video or on the board.",
|
|
899
1146
|
details: [
|
|
900
|
-
"With a thread (#12 or its id), it replies in that thread. With --file and --page, or
|
|
901
|
-
"opens a new thread there: to ask a person, or to point at a problem. People see it
|
|
1147
|
+
"With a thread (#12 or its id), it replies in that thread. With --file and --page, --time or --scene, or",
|
|
1148
|
+
"with --at, it opens a new thread there: to ask a person, or to point at a problem. People see it at once.",
|
|
902
1149
|
"Write @ and a name to mention a person or an agent of the topic: they are told like any mention.",
|
|
903
1150
|
"A question waits for the answer: leave the thread open, the answer comes to your inbox.",
|
|
904
1151
|
"",
|
|
905
1152
|
"--file <file> The file of a new thread on a page: its id, or its name.",
|
|
906
1153
|
"--page <n> The page, from 1.",
|
|
907
1154
|
"--region <x,y,w,h> The rectangle on the page, as fractions (0 to 1). Default: the whole page.",
|
|
1155
|
+
"--time <t> A time of the video: seconds or m:ss, for example 42 or 0:42. A range has a",
|
|
1156
|
+
" dash, for example 0:40-0:48.",
|
|
1157
|
+
"--box <x,y,w,h> With one --time: a rectangle on the frame at that time, as fractions (0 to 1).",
|
|
1158
|
+
"--scene <n> A scene of the video's shot list, from 1: the thread takes the scene's range.",
|
|
908
1159
|
"--at <x,y> A new thread on this point of the board, in board coordinates.",
|
|
909
1160
|
"--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
1161
|
AGENT_TOKEN_HELP,
|
|
@@ -916,6 +1167,9 @@ export const commands = {
|
|
|
916
1167
|
file: { type: "string" },
|
|
917
1168
|
page: { type: "string" },
|
|
918
1169
|
region: { type: "string" },
|
|
1170
|
+
time: { type: "string" },
|
|
1171
|
+
box: { type: "string" },
|
|
1172
|
+
scene: { type: "string" },
|
|
919
1173
|
at: { type: "string" },
|
|
920
1174
|
topic: { type: "string" },
|
|
921
1175
|
},
|
|
@@ -924,7 +1178,7 @@ export const commands = {
|
|
|
924
1178
|
const numbers = (option, count) => numbersOption(values, option, count);
|
|
925
1179
|
const [first, second] = positionals;
|
|
926
1180
|
const body = (second ?? first);
|
|
927
|
-
const usage = "orlan comment --help (the
|
|
1181
|
+
const usage = "orlan comment --help (the forms)";
|
|
928
1182
|
let target;
|
|
929
1183
|
if (second !== undefined) {
|
|
930
1184
|
if (values.file !== undefined || values.at !== undefined) {
|
|
@@ -932,6 +1186,27 @@ export const commands = {
|
|
|
932
1186
|
}
|
|
933
1187
|
target = { threadId: first };
|
|
934
1188
|
}
|
|
1189
|
+
else if (typeof values.file === "string" && (values.time !== undefined || values.scene !== undefined)) {
|
|
1190
|
+
// A time, a range, a box on a frame or a scene of a video (F134, F135).
|
|
1191
|
+
if ((values.time === undefined) === (values.scene === undefined) || values.page !== undefined) {
|
|
1192
|
+
throw new OrlanError("A comment on a video takes --time or --scene: one of them.", undefined, usage);
|
|
1193
|
+
}
|
|
1194
|
+
const box = numbers("box", 4);
|
|
1195
|
+
const scene = Number(values.scene);
|
|
1196
|
+
if (values.scene !== undefined && (!Number.isInteger(scene) || scene < 1)) {
|
|
1197
|
+
throw new OrlanError("--scene takes the number of a scene, from 1.", undefined, "add --scene 1");
|
|
1198
|
+
}
|
|
1199
|
+
if (box && (typeof values.time !== "string" || values.time.includes("-"))) {
|
|
1200
|
+
throw new OrlanError("--box goes with one --time, not a range.", undefined, "add --time 0:42");
|
|
1201
|
+
}
|
|
1202
|
+
target = {
|
|
1203
|
+
video: {
|
|
1204
|
+
artifactId: values.file,
|
|
1205
|
+
...(typeof values.time === "string" ? timeOption(values.time) : { scene }),
|
|
1206
|
+
...(box ? { box: { x: box[0], y: box[1], w: box[2], h: box[3] } } : {}),
|
|
1207
|
+
},
|
|
1208
|
+
};
|
|
1209
|
+
}
|
|
935
1210
|
else if (typeof values.file === "string") {
|
|
936
1211
|
const page = Number(values.page);
|
|
937
1212
|
if (!Number.isInteger(page) || page < 1) {
|
|
@@ -1116,7 +1391,7 @@ export const commands = {
|
|
|
1116
1391
|
}),
|
|
1117
1392
|
},
|
|
1118
1393
|
"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]',
|
|
1394
|
+
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
1395
|
summary: "Add a note, a text, a shape, a frame or an arrow to the board.",
|
|
1121
1396
|
details: [
|
|
1122
1397
|
"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 +1423,7 @@ export const commands = {
|
|
|
1148
1423
|
" holds more than half of it.",
|
|
1149
1424
|
'--text <text> The text, as the "<text>" word gives it. For an arrow: its label, for example "yes".',
|
|
1150
1425
|
"--topic <topic> The topic id or its name. Default: the topic open in Orlan now, else the one used last.",
|
|
1426
|
+
...NEW_TOPIC_HELP,
|
|
1151
1427
|
AGENT_TOKEN_HELP,
|
|
1152
1428
|
JSON_HELP,
|
|
1153
1429
|
],
|
|
@@ -1165,6 +1441,7 @@ export const commands = {
|
|
|
1165
1441
|
id: { type: "string" },
|
|
1166
1442
|
frame: { type: "string" },
|
|
1167
1443
|
topic: { type: "string" },
|
|
1444
|
+
"new-topic": { type: "string" },
|
|
1168
1445
|
},
|
|
1169
1446
|
positionals: [1, 2],
|
|
1170
1447
|
run: (values, [type, word], io) => {
|
|
@@ -1215,10 +1492,10 @@ export const commands = {
|
|
|
1215
1492
|
// The server refuses a frame on an arrow, and says why.
|
|
1216
1493
|
Object.assign(edit, frameOption(values));
|
|
1217
1494
|
return withAgent(values, async (api) => {
|
|
1218
|
-
const { items, warnings } = await editBoard(api, values, io, [edit]);
|
|
1495
|
+
const { items, warnings, newTopic } = await editBoard(api, values, io, [edit]);
|
|
1219
1496
|
const item = items[0];
|
|
1220
1497
|
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"
|
|
1498
|
+
return finish(io, values, { ...item, warnings, ...(newTopic ? { newTopic } : {}) }, [`Added ${boxText(item)}${where}.`, ...warningLines(warnings)], afterWarnings(warnings, type === "arrow"
|
|
1222
1499
|
? "orlan board (the board with the arrow)"
|
|
1223
1500
|
: `orlan board add arrow --from ${item.id} --to <item> (to link it), or orlan board`));
|
|
1224
1501
|
});
|
|
@@ -1319,7 +1596,7 @@ export const commands = {
|
|
|
1319
1596
|
}),
|
|
1320
1597
|
},
|
|
1321
1598
|
"board edit": {
|
|
1322
|
-
usage: "orlan board edit '<edits as JSON>' [--topic <topic>] [--agent <id>] [--json]",
|
|
1599
|
+
usage: "orlan board edit '<edits as JSON>' [--topic <topic> | --new-topic <name>] [--agent <id>] [--json]",
|
|
1323
1600
|
summary: "Make several changes to the board in one step: all of them, or none.",
|
|
1324
1601
|
details: [
|
|
1325
1602
|
"The edits are a JSON list of at most 100. A person can undo the step. A create can give its item",
|
|
@@ -1353,10 +1630,11 @@ export const commands = {
|
|
|
1353
1630
|
...WARNINGS_HELP,
|
|
1354
1631
|
"",
|
|
1355
1632
|
"--topic <topic> The topic id or its name. Default: the topic open in Orlan now, else the one used last.",
|
|
1633
|
+
...NEW_TOPIC_HELP,
|
|
1356
1634
|
AGENT_TOKEN_HELP,
|
|
1357
1635
|
JSON_HELP,
|
|
1358
1636
|
],
|
|
1359
|
-
options: { ...agentOption, ...jsonOption, topic: { type: "string" } },
|
|
1637
|
+
options: { ...agentOption, ...jsonOption, topic: { type: "string" }, "new-topic": { type: "string" } },
|
|
1360
1638
|
positionals: [1, 1],
|
|
1361
1639
|
run: (values, [text], io) => {
|
|
1362
1640
|
let edits;
|
|
@@ -1370,8 +1648,8 @@ export const commands = {
|
|
|
1370
1648
|
throw new OrlanError('The edits are a JSON list, for example [{"op":"delete","id":"<item>"}].', undefined, "orlan board edit --help");
|
|
1371
1649
|
}
|
|
1372
1650
|
return withAgent(values, async (api) => {
|
|
1373
|
-
const { ids, items, warnings } = await editBoard(api, values, io, edits);
|
|
1374
|
-
return finish(io, values, { ids, items, warnings }, [
|
|
1651
|
+
const { ids, items, warnings, newTopic } = await editBoard(api, values, io, edits);
|
|
1652
|
+
return finish(io, values, { ids, items, warnings, ...(newTopic ? { newTopic } : {}) }, [
|
|
1375
1653
|
`Made ${ids.length} change${ids.length === 1 ? "" : "s"} in one step.`,
|
|
1376
1654
|
...items.map((item) => `- ${boxText(item)}`),
|
|
1377
1655
|
...warningLines(warnings),
|
|
@@ -1380,7 +1658,7 @@ export const commands = {
|
|
|
1380
1658
|
},
|
|
1381
1659
|
},
|
|
1382
1660
|
"board mermaid": {
|
|
1383
|
-
usage: "orlan board mermaid <file.mmd> [--frame <frame>] [--topic <topic>] [--agent <id>] [--json]",
|
|
1661
|
+
usage: "orlan board mermaid <file.mmd> [--frame <frame>] [--topic <topic> | --new-topic <name>] [--agent <id>] [--json]",
|
|
1384
1662
|
summary: "Draw a Mermaid flowchart on the board as real shapes, labeled arrows and frames.",
|
|
1385
1663
|
details: [
|
|
1386
1664
|
"Orlan reads the flowchart and draws it in one step, which a person can undo: a shape for each",
|
|
@@ -1419,16 +1697,23 @@ export const commands = {
|
|
|
1419
1697
|
"<file.mmd> The Mermaid file.",
|
|
1420
1698
|
"--frame <frame> Draw the diagram in this frame, by its id, from `orlan board`.",
|
|
1421
1699
|
"--topic <topic> The topic id or its name. Default: the topic open in Orlan now, else the one used last.",
|
|
1700
|
+
...NEW_TOPIC_HELP,
|
|
1422
1701
|
AGENT_TOKEN_HELP,
|
|
1423
1702
|
JSON_HELP,
|
|
1424
1703
|
],
|
|
1425
|
-
options: {
|
|
1704
|
+
options: {
|
|
1705
|
+
...agentOption,
|
|
1706
|
+
...jsonOption,
|
|
1707
|
+
topic: { type: "string" },
|
|
1708
|
+
"new-topic": { type: "string" },
|
|
1709
|
+
frame: { type: "string" },
|
|
1710
|
+
},
|
|
1426
1711
|
positionals: [1, 1],
|
|
1427
1712
|
run: (values, [file], io) => withAgent(values, async (api) => {
|
|
1428
1713
|
const source = await readFile(file, "utf8").catch((error) => {
|
|
1429
1714
|
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
1715
|
});
|
|
1431
|
-
const topicId = await
|
|
1716
|
+
const { topicId, newTopic } = await writeTopic(api, values, io);
|
|
1432
1717
|
const answer = await api.call("import_mermaid", {
|
|
1433
1718
|
topicId,
|
|
1434
1719
|
mermaid: source,
|
|
@@ -1444,7 +1729,7 @@ export const commands = {
|
|
|
1444
1729
|
const made = [...count("shape", "shape"), ...count("arrow", "arrow"), ...count("frame", "frame")].join(", ");
|
|
1445
1730
|
const prefixed = Object.entries(ids).filter(([from, to]) => from !== to);
|
|
1446
1731
|
const where = typeof values.frame === "string" ? ` in frame ${values.frame}` : "";
|
|
1447
|
-
return finish(io, values, { ids, items, warnings, replaced, ignored }, [
|
|
1732
|
+
return finish(io, values, { ids, items, warnings, replaced, ignored, ...(newTopic ? { newTopic } : {}) }, [
|
|
1448
1733
|
`Drew ${quote(path.basename(file))}${where} in one step: ${made || "no items"}. A person can undo it.`,
|
|
1449
1734
|
...(replaced.length > 0
|
|
1450
1735
|
? [
|
|
@@ -1615,6 +1900,239 @@ export const commands = {
|
|
|
1615
1900
|
: "orlan brief (where the work stands)");
|
|
1616
1901
|
}),
|
|
1617
1902
|
},
|
|
1903
|
+
brand: {
|
|
1904
|
+
usage: "orlan brand [--save <folder>] [--agent <id>] [--json]",
|
|
1905
|
+
summary: "Print the accepted brand kit of the organisation: colors, fonts, logos, tone, rules, formats.",
|
|
1906
|
+
details: [
|
|
1907
|
+
"Use it before you make a deck, a video, a carousel or a page, so the work is on brand with no brand",
|
|
1908
|
+
"words in the prompt. Orlan gives only the sections people accepted. A section that waits for review",
|
|
1909
|
+
"is named under draft, without its content: do not guess it. The references are work people like,",
|
|
1910
|
+
"each with a note.",
|
|
1911
|
+
"",
|
|
1912
|
+
"--save <folder> Also save the logo and font files of the kit in this folder.",
|
|
1913
|
+
AGENT_TOKEN_HELP,
|
|
1914
|
+
JSON_HELP,
|
|
1915
|
+
],
|
|
1916
|
+
options: { ...agentOption, ...jsonOption, save: { type: "string" } },
|
|
1917
|
+
positionals: [0, 0],
|
|
1918
|
+
run: (values, _positionals, io) => withAgent(values, async (api, config) => {
|
|
1919
|
+
const kit = await api.call("brand", {});
|
|
1920
|
+
const saved = [];
|
|
1921
|
+
if (typeof values.save === "string" && kit.files.length > 0) {
|
|
1922
|
+
await mkdir(values.save, { recursive: true });
|
|
1923
|
+
for (const file of kit.files) {
|
|
1924
|
+
const response = await fetch(file.url, {
|
|
1925
|
+
headers: { authorization: `Bearer ${api.token}`, ...sessionHeader() },
|
|
1926
|
+
}).catch((error) => {
|
|
1927
|
+
throw unreachable(config.server, error);
|
|
1928
|
+
});
|
|
1929
|
+
if (!response.ok) {
|
|
1930
|
+
throw new OrlanError(`The download of ${quote(file.name)} answered ${response.status}.`, undefined, "run the command again");
|
|
1931
|
+
}
|
|
1932
|
+
// Two files with one name (a light and a dark logo.png) are both kept.
|
|
1933
|
+
const name = saved.includes(path.join(values.save, file.name)) ? `${file.id}-${file.name}` : file.name;
|
|
1934
|
+
const target = path.join(values.save, path.basename(name));
|
|
1935
|
+
await writeFile(target, Buffer.from(await response.arrayBuffer()));
|
|
1936
|
+
saved.push(target);
|
|
1937
|
+
}
|
|
1938
|
+
}
|
|
1939
|
+
const { sections } = kit;
|
|
1940
|
+
const lines = [`Brand kit v${kit.version}. Only the accepted sections are here.`];
|
|
1941
|
+
if (sections.colors) {
|
|
1942
|
+
lines.push(`colors: ${sections.colors.items.map((color) => `${color.name} ${color.hex}${color.use ? ` (${color.use})` : ""}`).join(", ") || "none"}`);
|
|
1943
|
+
}
|
|
1944
|
+
if (sections.fonts) {
|
|
1945
|
+
const font = (role) => sections.fonts?.[role].family || "not set";
|
|
1946
|
+
lines.push(`fonts: heading ${font("heading")}, body ${font("body")}`);
|
|
1947
|
+
}
|
|
1948
|
+
for (const file of kit.files)
|
|
1949
|
+
lines.push(`- ${file.role}: ${quote(file.name)} (${file.mime})`);
|
|
1950
|
+
if (sections.tone)
|
|
1951
|
+
lines.push(`tone: ${sections.tone.text || "not set"}`);
|
|
1952
|
+
if (sections.rules) {
|
|
1953
|
+
for (const rule of sections.rules.items)
|
|
1954
|
+
lines.push(`${rule.kind === "do" ? "do" : "do not"}: ${rule.text}`);
|
|
1955
|
+
}
|
|
1956
|
+
if (sections.formats) {
|
|
1957
|
+
const { video, deck, carousel } = sections.formats;
|
|
1958
|
+
lines.push(`formats: video ${video.join(", ") || "-"}; deck ${deck.join(", ") || "-"}; carousel ${carousel.join(", ") || "-"}`);
|
|
1959
|
+
}
|
|
1960
|
+
if (kit.draft.length)
|
|
1961
|
+
lines.push(`draft (waits for review, not for use): ${kit.draft.join(", ")}`);
|
|
1962
|
+
for (const ref of kit.references) {
|
|
1963
|
+
lines.push(`reference ${ref.kind}: ${ref.url ?? quote(ref.file?.name ?? "")} - ${quote(ref.note)}`);
|
|
1964
|
+
}
|
|
1965
|
+
for (const target of saved)
|
|
1966
|
+
lines.push(`Saved ${quote(target)}.`);
|
|
1967
|
+
// What people asked this agent to change (F141): each request ends with its own next_step line.
|
|
1968
|
+
const requests = kit.requests ?? [];
|
|
1969
|
+
for (const request of requests)
|
|
1970
|
+
lines.push("", ...brandRequestLines(request));
|
|
1971
|
+
return finish(io, values, { ...kit, ...(typeof values.save === "string" ? { saved } : {}) }, lines, requests.length > 0
|
|
1972
|
+
? "answer each request above with orlan brand draft"
|
|
1973
|
+
: kit.files.length > 0 && typeof values.save !== "string"
|
|
1974
|
+
? "orlan brand --save brand (the logo and font files)"
|
|
1975
|
+
: "make the work with this kit");
|
|
1976
|
+
}),
|
|
1977
|
+
},
|
|
1978
|
+
"brand draft": {
|
|
1979
|
+
usage: 'orlan brand draft <section> (--file <path> | --data <json>) [--note "<text>"] [--request <id>] [--agent <id>] [--json]',
|
|
1980
|
+
summary: "Write one section of the brand kit as a draft for people to review and accept.",
|
|
1981
|
+
details: [
|
|
1982
|
+
"Draft the kit from the website, the logo and font files and the files people give you: one call for",
|
|
1983
|
+
"each section. Each section waits as a draft until a person accepts it on the Brand kit page; an agent",
|
|
1984
|
+
"never accepts one. A draft answers your open requests on that section (orlan brand lists them).",
|
|
1985
|
+
"",
|
|
1986
|
+
"<section> colors, fonts, logos, tone, rules or formats. The content of each, as JSON:",
|
|
1987
|
+
' colors {"items": [{"name": "Vivid Blue", "hex": "#0679FF", "use": "Buttons"}]}',
|
|
1988
|
+
' fonts {"heading": {"family": "Avenir Next", "fileIds": []}, "body": {"family": "Inter", "fileIds": []}}',
|
|
1989
|
+
' logos {"light": "<file id>", "dark": null, "mark": null}',
|
|
1990
|
+
' tone {"text": "Short sentences. Say what the customer gets."}',
|
|
1991
|
+
' rules {"items": [{"kind": "do", "text": "Captions on every video."}, {"kind": "dont", "text": "..."}]}',
|
|
1992
|
+
' formats {"video": ["16:9", "9:16"], "deck": ["16:9"], "carousel": ["4:5"]}',
|
|
1993
|
+
" A file id comes from orlan brand upload.",
|
|
1994
|
+
"",
|
|
1995
|
+
"--file <path> The content in a JSON file.",
|
|
1996
|
+
"--data <json> The content as JSON text.",
|
|
1997
|
+
"--note <text> Where you took it from, or what you changed. People read it in the kit versions.",
|
|
1998
|
+
"--request <id> The request you answer, for example brand_12. Default: your open requests on the section.",
|
|
1999
|
+
AGENT_TOKEN_HELP,
|
|
2000
|
+
JSON_HELP,
|
|
2001
|
+
],
|
|
2002
|
+
options: {
|
|
2003
|
+
...agentOption,
|
|
2004
|
+
...jsonOption,
|
|
2005
|
+
file: { type: "string" },
|
|
2006
|
+
data: { type: "string" },
|
|
2007
|
+
note: { type: "string" },
|
|
2008
|
+
request: { type: "string" },
|
|
2009
|
+
},
|
|
2010
|
+
positionals: [1, 1],
|
|
2011
|
+
run: async (values, [section], io) => {
|
|
2012
|
+
const file = typeof values.file === "string" ? values.file : undefined;
|
|
2013
|
+
const data = typeof values.data === "string" ? values.data : undefined;
|
|
2014
|
+
if ((file === undefined) === (data === undefined)) {
|
|
2015
|
+
throw new OrlanError("Give the content with --file or --data, one of them.", undefined, `orlan brand draft ${section} --file <path to the JSON content>`);
|
|
2016
|
+
}
|
|
2017
|
+
const text = file === undefined ? data : await readFile(file, "utf8");
|
|
2018
|
+
let content;
|
|
2019
|
+
try {
|
|
2020
|
+
content = JSON.parse(text);
|
|
2021
|
+
}
|
|
2022
|
+
catch {
|
|
2023
|
+
throw new OrlanError(`The content is not JSON${file ? ` in ${quote(file)}` : ""}.`, undefined, "orlan brand draft --help shows the content of each section");
|
|
2024
|
+
}
|
|
2025
|
+
return withAgent(values, async (api) => {
|
|
2026
|
+
const answer = await api.call("brand_draft", {
|
|
2027
|
+
section,
|
|
2028
|
+
content,
|
|
2029
|
+
...(typeof values.note === "string" ? { note: values.note } : {}),
|
|
2030
|
+
...(typeof values.request === "string" ? { requestId: values.request } : {}),
|
|
2031
|
+
});
|
|
2032
|
+
const { nextStep, ...rest } = answer;
|
|
2033
|
+
return finish(io, values, rest, [
|
|
2034
|
+
`Wrote ${answer.section} as a draft in brand kit v${answer.version}. It waits for a person to accept it.` +
|
|
2035
|
+
(answer.answered ? ` Answered ${answer.answered.join(", ")}.` : ""),
|
|
2036
|
+
], nextStep);
|
|
2037
|
+
});
|
|
2038
|
+
},
|
|
2039
|
+
},
|
|
2040
|
+
"brand upload": {
|
|
2041
|
+
usage: "orlan brand upload <path> --kind logo|font [--agent <id>] [--json]",
|
|
2042
|
+
summary: "Upload a logo or a font file for the brand kit, and print its file id.",
|
|
2043
|
+
details: [
|
|
2044
|
+
"A logo is a PNG or a JPEG file; a font is an .otf, .ttf, .woff or .woff2 file. Name the file id in",
|
|
2045
|
+
"the logos or the fonts section with orlan brand draft.",
|
|
2046
|
+
"",
|
|
2047
|
+
"--kind <kind> logo or font. Required.",
|
|
2048
|
+
AGENT_TOKEN_HELP,
|
|
2049
|
+
JSON_HELP,
|
|
2050
|
+
],
|
|
2051
|
+
options: { ...agentOption, ...jsonOption, kind: { type: "string" } },
|
|
2052
|
+
positionals: [1, 1],
|
|
2053
|
+
run: async (values, [filePath], io) => {
|
|
2054
|
+
const kind = values.kind;
|
|
2055
|
+
if (kind !== "logo" && kind !== "font") {
|
|
2056
|
+
throw new OrlanError("Add --kind logo or --kind font.", undefined, `orlan brand upload ${filePath} --kind logo`);
|
|
2057
|
+
}
|
|
2058
|
+
const form = new FormData();
|
|
2059
|
+
form.set("file", await openAsBlob(filePath), path.basename(filePath));
|
|
2060
|
+
return withAgent(values, async (api) => {
|
|
2061
|
+
const uploaded = await api.postForm("/api/agent/brand/files", { kind }, form, "the upload");
|
|
2062
|
+
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`);
|
|
2063
|
+
});
|
|
2064
|
+
},
|
|
2065
|
+
},
|
|
2066
|
+
studio: {
|
|
2067
|
+
usage: "orlan studio [<skill> [<step>]] [--agent <id>] [--json]",
|
|
2068
|
+
summary: "Print one step of a studio skill (video, deck, carousel, walkthrough): the steps come from Orlan.",
|
|
2069
|
+
details: [
|
|
2070
|
+
"A studio skill makes a piece of work on this computer in five steps: brief, script, build, render,",
|
|
2071
|
+
"review. Orlan gives the steps only to a signed-in agent, one step at a time: read each step when you",
|
|
2072
|
+
"come to it, and follow it. Read the brand kit first (orlan brand).",
|
|
2073
|
+
"",
|
|
2074
|
+
"orlan studio The skills, with their steps and helper scripts.",
|
|
2075
|
+
"orlan studio <skill> The steps and the helper scripts of one skill.",
|
|
2076
|
+
"orlan studio <skill> <step> The text of one step.",
|
|
2077
|
+
"",
|
|
2078
|
+
"Get the helper scripts of a skill with orlan studio pull <skill>.",
|
|
2079
|
+
AGENT_TOKEN_HELP,
|
|
2080
|
+
JSON_HELP,
|
|
2081
|
+
],
|
|
2082
|
+
options: { ...agentOption, ...jsonOption },
|
|
2083
|
+
positionals: [0, 2],
|
|
2084
|
+
run: (values, [skill, step], io) => withStudio(values, async (api) => {
|
|
2085
|
+
if (!skill) {
|
|
2086
|
+
const { skills } = await api.call("studio", {});
|
|
2087
|
+
const lines = skills.length
|
|
2088
|
+
? skills.map((each) => `${each.skill}: ${skillLine(each)}`)
|
|
2089
|
+
: ["Orlan has no studio skills yet."];
|
|
2090
|
+
return finish(io, values, { skills }, lines, "orlan studio <skill> brief");
|
|
2091
|
+
}
|
|
2092
|
+
if (!step) {
|
|
2093
|
+
const found = await api.call("studio", { skill });
|
|
2094
|
+
const next = `${found.scripts.length ? `orlan studio pull ${skill}, then ` : ""}orlan studio ${skill} brief`;
|
|
2095
|
+
return finish(io, values, { ...found }, [`${skill}: ${skillLine(found)}`], next);
|
|
2096
|
+
}
|
|
2097
|
+
const answer = await api.call("studio", { skill, step });
|
|
2098
|
+
const after = STUDIO_STEPS[STUDIO_STEPS.indexOf(step) + 1];
|
|
2099
|
+
return finish(io, values, answer, [answer.text.trimEnd()], after
|
|
2100
|
+
? `orlan studio ${skill} ${after}`
|
|
2101
|
+
: "post the work: orlan files add <file>, or orlan files push for a new version");
|
|
2102
|
+
}),
|
|
2103
|
+
},
|
|
2104
|
+
"studio pull": {
|
|
2105
|
+
usage: "orlan studio pull <skill> [--to <folder>] [--agent <id>] [--json]",
|
|
2106
|
+
summary: "Write the helper scripts of a studio skill into the project, from Orlan.",
|
|
2107
|
+
details: [
|
|
2108
|
+
"The steps of the skill name these scripts. Pull them again for the newest copy: each file is written",
|
|
2109
|
+
"again, and files that you added stay.",
|
|
2110
|
+
"",
|
|
2111
|
+
"--to <folder> The folder to write the scripts in. Default: .orlan/studio/<skill> in this folder.",
|
|
2112
|
+
AGENT_TOKEN_HELP,
|
|
2113
|
+
JSON_HELP,
|
|
2114
|
+
],
|
|
2115
|
+
options: { ...agentOption, ...jsonOption, to: { type: "string" } },
|
|
2116
|
+
positionals: [1, 1],
|
|
2117
|
+
run: (values, [skill], io) => withStudio(values, async (api) => {
|
|
2118
|
+
const { files } = await api.call("studio", { skill, scripts: true });
|
|
2119
|
+
const folder = path.resolve(typeof values.to === "string" ? values.to : path.join(".orlan", "studio", `${skill}`));
|
|
2120
|
+
for (const file of files) {
|
|
2121
|
+
const target = path.resolve(folder, file.path);
|
|
2122
|
+
// A path from the server stays in the folder.
|
|
2123
|
+
if (!target.startsWith(`${folder}${path.sep}`)) {
|
|
2124
|
+
throw new OrlanError(`Orlan named a script path outside the folder: ${quote(file.path)}.`, "internal_error");
|
|
2125
|
+
}
|
|
2126
|
+
await mkdir(path.dirname(target), { recursive: true });
|
|
2127
|
+
await writeFile(target, Buffer.from(file.contentBase64, "base64"));
|
|
2128
|
+
await chmod(target, file.executable ? 0o755 : 0o644);
|
|
2129
|
+
}
|
|
2130
|
+
return finish(io, values, { skill, folder, files: files.map((file) => file.path) }, [
|
|
2131
|
+
`Pulled ${files.length} helper scripts of ${skill} into ${folder}.`,
|
|
2132
|
+
...files.map((file) => `- ${file.path}`),
|
|
2133
|
+
], `orlan studio ${skill} brief`);
|
|
2134
|
+
}),
|
|
2135
|
+
},
|
|
1618
2136
|
brief: {
|
|
1619
2137
|
usage: "orlan brief [--topic <topic>] [--agent <id>] [--json]",
|
|
1620
2138
|
summary: "Print where the work stands: the topic, the open requests, the files, and the next step.",
|
|
@@ -1655,7 +2173,7 @@ export const commands = {
|
|
|
1655
2173
|
options: { ...agentOption, ...jsonOption, new: { type: "boolean" } },
|
|
1656
2174
|
positionals: [0, 0],
|
|
1657
2175
|
run: (values, _positionals, io) => withAgent(values, async (api) => {
|
|
1658
|
-
const { requests, stopped } = await api.get("/api/inbox", { new: values.new ? "true" : undefined }, "the inbox");
|
|
2176
|
+
const { requests, stopped, brand = [], } = await api.get("/api/inbox", { new: values.new ? "true" : undefined }, "the inbox");
|
|
1659
2177
|
// A person stopped this session on the board (D109): the stop, also with --new.
|
|
1660
2178
|
if (stopped) {
|
|
1661
2179
|
const [line = "", step = ""] = stopped.lines;
|
|
@@ -1667,20 +2185,23 @@ export const commands = {
|
|
|
1667
2185
|
return 0;
|
|
1668
2186
|
}
|
|
1669
2187
|
// --new with no request prints nothing.
|
|
2188
|
+
// The brand kit requests (F141) come after the topic requests, each with its own next_step line.
|
|
2189
|
+
const all = [...requests, ...brand];
|
|
1670
2190
|
if (values.json) {
|
|
1671
|
-
if (
|
|
1672
|
-
const last =
|
|
2191
|
+
if (all.length > 0 || !values.new) {
|
|
2192
|
+
const last = all.at(-1);
|
|
1673
2193
|
printJson(io, {
|
|
1674
2194
|
requests: requests.map((each) => each.request),
|
|
2195
|
+
...(brand.length > 0 ? { brand: brand.map((each) => each.request) } : {}),
|
|
1675
2196
|
next_step: last ? deliveryStep(last) : NOTHING_STEP,
|
|
1676
2197
|
});
|
|
1677
2198
|
}
|
|
1678
2199
|
}
|
|
1679
2200
|
else {
|
|
1680
|
-
if (
|
|
2201
|
+
if (all.length === 0 && !values.new)
|
|
1681
2202
|
io.out(`No open requests.\nnext_step: ${NOTHING_STEP}`);
|
|
1682
2203
|
// Each request ends with its own next_step line. The line on quoted text comes once.
|
|
1683
|
-
for (const [index, delivery] of
|
|
2204
|
+
for (const [index, delivery] of all.entries()) {
|
|
1684
2205
|
if (index > 0)
|
|
1685
2206
|
io.out("");
|
|
1686
2207
|
const [first = "", ...rest] = delivery.lines;
|
|
@@ -1746,7 +2267,7 @@ export const commands = {
|
|
|
1746
2267
|
const api = new AgentApi(config.server, token);
|
|
1747
2268
|
// The file goes to the request's topic in one request; a done request refuses it before it is read.
|
|
1748
2269
|
const uploadId = typeof values.file === "string"
|
|
1749
|
-
? await api.upload(await
|
|
2270
|
+
? await api.upload(await openAsBlob(values.file), { request: requestId, name: path.basename(values.file) }, "respond")
|
|
1750
2271
|
: undefined;
|
|
1751
2272
|
const answer = await api.call("respond", {
|
|
1752
2273
|
requestId,
|
|
@@ -1793,14 +2314,20 @@ export const commands = {
|
|
|
1793
2314
|
},
|
|
1794
2315
|
},
|
|
1795
2316
|
};
|
|
1796
|
-
/**
|
|
2317
|
+
/**
|
|
2318
|
+
* Runs edit_board on the topic of --topic, else the default topic: one step, all edits or none. Edits that
|
|
2319
|
+
* add items can make a new topic (--new-topic, F131); the answer names it.
|
|
2320
|
+
*/
|
|
1797
2321
|
async function editBoard(api, values, io, edits) {
|
|
2322
|
+
const { topicId, newTopic } = edits.some((edit) => edit.op === "create")
|
|
2323
|
+
? await writeTopic(api, values, io)
|
|
2324
|
+
: { topicId: await topicOrDefault(api, values, io), newTopic: undefined };
|
|
1798
2325
|
const answer = await api.call("edit_board", {
|
|
1799
|
-
topicId
|
|
2326
|
+
topicId,
|
|
1800
2327
|
edits,
|
|
1801
2328
|
});
|
|
1802
2329
|
// A server before F096 gives no warnings.
|
|
1803
|
-
return { ...answer, warnings: answer.warnings ?? [] };
|
|
2330
|
+
return { ...answer, warnings: answer.warnings ?? [], ...(newTopic ? { newTopic } : {}) };
|
|
1804
2331
|
}
|
|
1805
2332
|
/** --assignee as the tool takes it: me (the token of this agent), user:<id>, agent:<id>, or none (null). */
|
|
1806
2333
|
function assigneeOption(config, values) {
|
|
@@ -1832,6 +2359,11 @@ function voteText(vote) {
|
|
|
1832
2359
|
`, ${vote.voters} voted` +
|
|
1833
2360
|
(result ? `, result: ${result}` : ""));
|
|
1834
2361
|
}
|
|
2362
|
+
/** A scorecard as its sum of the most it can be (F139): "65/80". */
|
|
2363
|
+
function scoreTotal(scores) {
|
|
2364
|
+
const sum = scores.reduce((total, each) => total + each.score, 0);
|
|
2365
|
+
return `${Math.round(sum * 10) / 10}/${scores.length * 10}`;
|
|
2366
|
+
}
|
|
1835
2367
|
/** --pages as page numbers: "3" or "2,5". */
|
|
1836
2368
|
function pageList(values) {
|
|
1837
2369
|
if (typeof values.pages !== "string")
|
|
@@ -1907,3 +2439,10 @@ async function pullPages(api, config, values, io, answer, since) {
|
|
|
1907
2439
|
}
|
|
1908
2440
|
/** The changed pages of a version, as words: "pages 2, 5 changed", "no page changed". */
|
|
1909
2441
|
const changedText = (pages) => pages.length === 0 ? "no page changed" : `page${pages.length === 1 ? "" : "s"} ${pages.join(", ")} changed`;
|
|
2442
|
+
/** A brand request as text, as `orlan inbox` prints it. Every text people wrote is quoted. */
|
|
2443
|
+
const brandRequestLines = (request) => [
|
|
2444
|
+
`${request.id}: a request from ${quote(request.from)} (person) on Orlan, brand kit section ${request.section}. ${QUOTED_TEXT}`,
|
|
2445
|
+
`${quote(request.from)} wrote: ${quote(request.text)}`,
|
|
2446
|
+
`${request.section} now: ${JSON.stringify(request.current)}`,
|
|
2447
|
+
`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>"`,
|
|
2448
|
+
];
|