@gobi-ai/cli 2.0.24 → 2.0.26
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/.claude-plugin/marketplace.json +3 -3
- package/.claude-plugin/plugin.json +2 -2
- package/README.md +30 -35
- package/dist/commands/artifact.js +472 -0
- package/dist/commands/global.js +21 -104
- package/dist/commands/personal.js +21 -102
- package/dist/commands/space.js +20 -103
- package/dist/commands/utils.js +0 -15
- package/dist/commands/vault.js +1 -1
- package/dist/main.js +2 -2
- package/package.json +1 -1
- package/skills/gobi-artifact/SKILL.md +133 -0
- package/skills/gobi-artifact/references/artifact.md +142 -0
- package/skills/gobi-core/SKILL.md +6 -7
- package/skills/gobi-homepage/SKILL.md +1 -1
- package/skills/gobi-media/SKILL.md +2 -2
- package/skills/gobi-sense/SKILL.md +2 -2
- package/skills/gobi-space/SKILL.md +12 -18
- package/skills/gobi-space/references/global.md +17 -28
- package/skills/gobi-space/references/personal.md +17 -26
- package/skills/gobi-space/references/space.md +1 -10
- package/skills/gobi-vault/SKILL.md +3 -3
- package/skills/gobi-vault/references/vault.md +2 -2
- package/dist/commands/draft.js +0 -230
- package/skills/gobi-draft/SKILL.md +0 -113
- package/skills/gobi-draft/references/draft.md +0 -114
|
@@ -0,0 +1,472 @@
|
|
|
1
|
+
import { existsSync, readFileSync, statSync, } from "fs";
|
|
2
|
+
import { basename, extname, isAbsolute, resolve } from "path";
|
|
3
|
+
import { apiDelete, apiGet, apiPatch, apiPost } from "../client.js";
|
|
4
|
+
import { isJsonMode, jsonOut, readStdin, unwrapResp } from "./utils.js";
|
|
5
|
+
import { extractWikiLinks, uploadAttachments } from "../attachments.js";
|
|
6
|
+
import { getValidToken } from "../auth/manager.js";
|
|
7
|
+
// Artifact kinds, mirrored from the backend (entities/artifact.entity.ts).
|
|
8
|
+
const MARKDOWN_KINDS = ["markdown", "meeting_summary"];
|
|
9
|
+
const MEDIA_KINDS = ["image", "video", "gif"];
|
|
10
|
+
const ALL_KINDS = [...MEDIA_KINDS, ...MARKDOWN_KINDS];
|
|
11
|
+
function isMarkdownKind(kind) {
|
|
12
|
+
return MARKDOWN_KINDS.includes(kind);
|
|
13
|
+
}
|
|
14
|
+
function isMediaKind(kind) {
|
|
15
|
+
return MEDIA_KINDS.includes(kind);
|
|
16
|
+
}
|
|
17
|
+
// Best-effort extension → MIME mapping for artifact media uploads. Mirrors the
|
|
18
|
+
// post-attachment map in attachments.ts; the backend derives the per-tier size
|
|
19
|
+
// ceiling (5MB photos / 15MB GIFs / 512MB video) from the content type.
|
|
20
|
+
const ARTIFACT_MIME_MAP = {
|
|
21
|
+
".jpg": "image/jpeg",
|
|
22
|
+
".jpeg": "image/jpeg",
|
|
23
|
+
".png": "image/png",
|
|
24
|
+
".gif": "image/gif",
|
|
25
|
+
".webp": "image/webp",
|
|
26
|
+
".heic": "image/heic",
|
|
27
|
+
".heif": "image/heif",
|
|
28
|
+
".avif": "image/avif",
|
|
29
|
+
".svg": "image/svg+xml",
|
|
30
|
+
".bmp": "image/bmp",
|
|
31
|
+
".tiff": "image/tiff",
|
|
32
|
+
".mp4": "video/mp4",
|
|
33
|
+
".mov": "video/quicktime",
|
|
34
|
+
".webm": "video/webm",
|
|
35
|
+
".m4v": "video/x-m4v",
|
|
36
|
+
};
|
|
37
|
+
function readContent(value) {
|
|
38
|
+
if (value === "-")
|
|
39
|
+
return readStdin();
|
|
40
|
+
return value;
|
|
41
|
+
}
|
|
42
|
+
// Resolve the markdown body for create/revise from --file | --content | stdin.
|
|
43
|
+
// Returns undefined when none is given (revise may carry media instead).
|
|
44
|
+
function resolveBody(opts) {
|
|
45
|
+
if (opts.file && opts.content) {
|
|
46
|
+
throw new Error("--file and --content are mutually exclusive.");
|
|
47
|
+
}
|
|
48
|
+
if (opts.file) {
|
|
49
|
+
const abs = isAbsolute(opts.file) ? opts.file : resolve(process.cwd(), opts.file);
|
|
50
|
+
if (!existsSync(abs))
|
|
51
|
+
throw new Error(`File not found: ${opts.file}`);
|
|
52
|
+
return readFileSync(abs, "utf8");
|
|
53
|
+
}
|
|
54
|
+
if (opts.content != null)
|
|
55
|
+
return readContent(opts.content);
|
|
56
|
+
return undefined;
|
|
57
|
+
}
|
|
58
|
+
// Upload a local media file via POST /artifacts/upload-url → PUT to S3 →
|
|
59
|
+
// return { mediaUrl, mediaKey } for the artifact create/revise body. Mirrors
|
|
60
|
+
// uploadPostAttachment in attachments.ts but against the artifact endpoint.
|
|
61
|
+
async function uploadArtifactMedia(filePath) {
|
|
62
|
+
const abs = isAbsolute(filePath) ? filePath : resolve(process.cwd(), filePath);
|
|
63
|
+
if (!existsSync(abs))
|
|
64
|
+
throw new Error(`File not found: ${filePath}`);
|
|
65
|
+
const ext = extname(abs).toLowerCase();
|
|
66
|
+
const contentType = ARTIFACT_MIME_MAP[ext] || "application/octet-stream";
|
|
67
|
+
const fileSize = statSync(abs).size;
|
|
68
|
+
const initResp = (await apiPost("/artifacts/upload-url", {
|
|
69
|
+
fileName: basename(abs),
|
|
70
|
+
contentType,
|
|
71
|
+
fileSize,
|
|
72
|
+
}));
|
|
73
|
+
const data = unwrapResp(initResp);
|
|
74
|
+
const uploadUrl = data.uploadUrl;
|
|
75
|
+
const mediaUrl = data.mediaUrl;
|
|
76
|
+
const mediaKey = data.mediaKey;
|
|
77
|
+
if (!uploadUrl || !mediaUrl || !mediaKey) {
|
|
78
|
+
throw new Error("Upload init returned an incomplete payload");
|
|
79
|
+
}
|
|
80
|
+
const putRes = await fetch(uploadUrl, {
|
|
81
|
+
method: "PUT",
|
|
82
|
+
headers: { "Content-Type": contentType },
|
|
83
|
+
body: readFileSync(abs),
|
|
84
|
+
});
|
|
85
|
+
if (!putRes.ok) {
|
|
86
|
+
throw new Error(`Failed to PUT ${filePath} to S3: HTTP ${putRes.status}`);
|
|
87
|
+
}
|
|
88
|
+
return { mediaUrl, mediaKey };
|
|
89
|
+
}
|
|
90
|
+
// Append an artifact to a post without clobbering its existing artifacts. The
|
|
91
|
+
// post edit endpoint treats `artifactIds` as a full replacement of the post's
|
|
92
|
+
// artifact attachments, so we read the post's current artifact attachments,
|
|
93
|
+
// append the new id, and write the merged set.
|
|
94
|
+
async function attachArtifactToPost(postId, artifactId) {
|
|
95
|
+
const resp = (await apiGet(`/posts/${postId}`));
|
|
96
|
+
const data = unwrapResp(resp);
|
|
97
|
+
const post = (data.update || data.post || data);
|
|
98
|
+
const attachments = (post.attachments || []);
|
|
99
|
+
const existing = attachments
|
|
100
|
+
.map((a) => a.artifact?.artifactId)
|
|
101
|
+
.filter((x) => typeof x === "string");
|
|
102
|
+
const merged = existing.includes(artifactId)
|
|
103
|
+
? existing
|
|
104
|
+
: [...existing, artifactId];
|
|
105
|
+
await apiPatch(`/posts/${postId}`, { artifactIds: merged });
|
|
106
|
+
}
|
|
107
|
+
function formatRevisionLine(r) {
|
|
108
|
+
const marker = r.state === "published" ? "✓ published" : " draft";
|
|
109
|
+
const note = r.changeNote ? ` — ${r.changeNote}` : "";
|
|
110
|
+
const parent = r.parentRevisionId != null ? ` (from ${r.parentRevisionId.slice(0, 8)})` : "";
|
|
111
|
+
return ` seq ${r.seq} [${marker}] ${r.revisionId.slice(0, 8)} by user ${r.authorUserId}${parent}${note} ${r.createdAt}`;
|
|
112
|
+
}
|
|
113
|
+
function printArtifact(a) {
|
|
114
|
+
console.log(`Artifact ${a.artifactId}`);
|
|
115
|
+
console.log(` kind: ${a.kind}`);
|
|
116
|
+
if (a.title)
|
|
117
|
+
console.log(` title: ${a.title}`);
|
|
118
|
+
console.log(` owner: user ${a.ownerUserId}`);
|
|
119
|
+
if (a.ownerVault) {
|
|
120
|
+
console.log(` vault: ${a.ownerVault.slug} (${a.ownerVault.public ? "public" : "private"})`);
|
|
121
|
+
}
|
|
122
|
+
console.log(` published: ${a.isPublished ? "yes" : "no"}`);
|
|
123
|
+
console.log(` canWrite: ${a.canWrite ? "yes" : "no"}`);
|
|
124
|
+
console.log(` created: ${a.createdAt}`);
|
|
125
|
+
console.log(` updated: ${a.updatedAt}`);
|
|
126
|
+
const rev = a.revision;
|
|
127
|
+
if (rev) {
|
|
128
|
+
console.log("");
|
|
129
|
+
console.log(`Current revision (seq ${rev.seq}, ${rev.state}):`);
|
|
130
|
+
if (rev.content != null) {
|
|
131
|
+
console.log("");
|
|
132
|
+
console.log(rev.content);
|
|
133
|
+
}
|
|
134
|
+
if (rev.mediaUrl)
|
|
135
|
+
console.log(` mediaUrl: ${rev.mediaUrl}`);
|
|
136
|
+
}
|
|
137
|
+
}
|
|
138
|
+
export function registerArtifactCommand(program) {
|
|
139
|
+
const artifact = program
|
|
140
|
+
.command("artifact")
|
|
141
|
+
.description("Versioned creations attached to posts. Kinds: image | video | gif | markdown | meeting_summary. " +
|
|
142
|
+
"Always human-owned; revisions form a draft/published tree (at most one published per artifact).");
|
|
143
|
+
// ── Create ──
|
|
144
|
+
artifact
|
|
145
|
+
.command("create")
|
|
146
|
+
.description("Create an artifact. markdown/meeting_summary kinds take a body via --file, --content, or stdin (\"-\"). image/gif/video kinds upload --file. Pass --post-id to attach the new artifact to a post.")
|
|
147
|
+
.requiredOption("--kind <kind>", `Artifact kind: ${ALL_KINDS.join(" | ")}`)
|
|
148
|
+
.option("--file <path>", "Local file: markdown body (markdown kinds) or media file (media kinds)")
|
|
149
|
+
.option("--content <md>", "Markdown body inline (markdown kinds; pass \"-\" for stdin)")
|
|
150
|
+
.option("--title <t>", "Display title")
|
|
151
|
+
.option("--vault-slug <slug>", "Anchor vault for [[wikilink]] resolution (markdown kinds). Stored in metadata.vaultSlug.")
|
|
152
|
+
.option("--post-id <id>", "Attach the created artifact to this post afterward")
|
|
153
|
+
.option("--auto-attachments", "Upload wiki-linked [[files]] to webdrive before creating (markdown kinds; uses --vault-slug)")
|
|
154
|
+
.option("--change-note <note>", "Note describing this revision")
|
|
155
|
+
.action(async (opts) => {
|
|
156
|
+
const kind = opts.kind;
|
|
157
|
+
if (!ALL_KINDS.includes(kind)) {
|
|
158
|
+
throw new Error(`--kind must be one of: ${ALL_KINDS.join(", ")}`);
|
|
159
|
+
}
|
|
160
|
+
const body = { kind };
|
|
161
|
+
if (opts.title != null)
|
|
162
|
+
body.title = opts.title;
|
|
163
|
+
if (opts.vaultSlug)
|
|
164
|
+
body.vaultSlug = opts.vaultSlug;
|
|
165
|
+
if (opts.changeNote != null)
|
|
166
|
+
body.changeNote = opts.changeNote;
|
|
167
|
+
if (isMarkdownKind(kind)) {
|
|
168
|
+
const content = resolveBody(opts);
|
|
169
|
+
if (content == null) {
|
|
170
|
+
throw new Error("markdown/meeting_summary kinds require a body via --file, --content, or stdin.");
|
|
171
|
+
}
|
|
172
|
+
if (opts.autoAttachments) {
|
|
173
|
+
if (!opts.vaultSlug) {
|
|
174
|
+
throw new Error("--auto-attachments requires --vault-slug.");
|
|
175
|
+
}
|
|
176
|
+
const token = await getValidToken();
|
|
177
|
+
const links = extractWikiLinks(content);
|
|
178
|
+
await uploadAttachments(opts.vaultSlug, links, token, {
|
|
179
|
+
addToSyncfiles: true,
|
|
180
|
+
});
|
|
181
|
+
}
|
|
182
|
+
body.content = content;
|
|
183
|
+
}
|
|
184
|
+
else if (isMediaKind(kind)) {
|
|
185
|
+
if (!opts.file)
|
|
186
|
+
throw new Error(`${kind} kind requires --file.`);
|
|
187
|
+
if (opts.content != null) {
|
|
188
|
+
throw new Error("--content is only valid for markdown kinds.");
|
|
189
|
+
}
|
|
190
|
+
if (opts.autoAttachments) {
|
|
191
|
+
throw new Error("--auto-attachments is only valid for markdown kinds.");
|
|
192
|
+
}
|
|
193
|
+
const { mediaUrl, mediaKey } = await uploadArtifactMedia(opts.file);
|
|
194
|
+
body.mediaUrl = mediaUrl;
|
|
195
|
+
body.mediaKey = mediaKey;
|
|
196
|
+
}
|
|
197
|
+
const resp = (await apiPost("/artifacts", body));
|
|
198
|
+
const a = unwrapResp(resp);
|
|
199
|
+
if (opts.postId) {
|
|
200
|
+
await attachArtifactToPost(opts.postId, a.artifactId);
|
|
201
|
+
}
|
|
202
|
+
if (isJsonMode(artifact)) {
|
|
203
|
+
jsonOut(a);
|
|
204
|
+
return;
|
|
205
|
+
}
|
|
206
|
+
console.log(`Artifact created!\n` +
|
|
207
|
+
` ID: ${a.artifactId}\n` +
|
|
208
|
+
` Kind: ${a.kind}` +
|
|
209
|
+
(a.title ? `\n Title: ${a.title}` : "") +
|
|
210
|
+
(opts.postId ? `\n Attached to post: ${opts.postId}` : ""));
|
|
211
|
+
});
|
|
212
|
+
// ── Revise ──
|
|
213
|
+
artifact
|
|
214
|
+
.command("revise <artifactId>")
|
|
215
|
+
.description("Add a draft revision to an artifact. New body via --file, --content, or stdin (markdown), or --file (media). Use --from to branch off a specific revision.")
|
|
216
|
+
.option("--file <path>", "Local file: markdown body (markdown kinds) or media file (media kinds)")
|
|
217
|
+
.option("--content <md>", "Markdown body inline (markdown kinds; pass \"-\" for stdin)")
|
|
218
|
+
.option("--change-note <note>", "Note describing this revision")
|
|
219
|
+
.option("--from <revisionId>", "Branch the new draft off this revision (defaults to the latest)")
|
|
220
|
+
.option("--auto-attachments", "Upload wiki-linked [[files]] to webdrive before revising (markdown kinds; uses the artifact's stored metadata.vaultSlug)")
|
|
221
|
+
.action(async (artifactId, opts) => {
|
|
222
|
+
// Fetch the artifact first to learn its kind — that determines how
|
|
223
|
+
// --file is handled (markdown body text vs binary media upload). Also
|
|
224
|
+
// carries metadata.vaultSlug for --auto-attachments. Branching on kind
|
|
225
|
+
// (like `create`) is REQUIRED: resolveBody() reads --file as UTF-8, so
|
|
226
|
+
// calling it for a media kind would corrupt the binary into `content`.
|
|
227
|
+
const getResp = (await apiGet(`/artifacts/${artifactId}`));
|
|
228
|
+
const existing = unwrapResp(getResp);
|
|
229
|
+
const kind = existing.kind;
|
|
230
|
+
const body = {};
|
|
231
|
+
if (opts.changeNote != null)
|
|
232
|
+
body.changeNote = opts.changeNote;
|
|
233
|
+
if (opts.from)
|
|
234
|
+
body.fromRevisionId = opts.from;
|
|
235
|
+
if (isMarkdownKind(kind)) {
|
|
236
|
+
const content = resolveBody(opts);
|
|
237
|
+
if (content == null) {
|
|
238
|
+
throw new Error("markdown/meeting_summary kinds require a new body via --file, --content, or stdin.");
|
|
239
|
+
}
|
|
240
|
+
if (opts.autoAttachments) {
|
|
241
|
+
const vaultSlug = existing.metadata?.vaultSlug;
|
|
242
|
+
if (!vaultSlug) {
|
|
243
|
+
throw new Error("--auto-attachments needs the artifact's metadata.vaultSlug, which is not set.");
|
|
244
|
+
}
|
|
245
|
+
const token = await getValidToken();
|
|
246
|
+
const links = extractWikiLinks(content);
|
|
247
|
+
await uploadAttachments(vaultSlug, links, token, {
|
|
248
|
+
addToSyncfiles: true,
|
|
249
|
+
});
|
|
250
|
+
}
|
|
251
|
+
body.content = content;
|
|
252
|
+
}
|
|
253
|
+
else if (isMediaKind(kind)) {
|
|
254
|
+
if (!opts.file)
|
|
255
|
+
throw new Error(`${kind} kind requires --file.`);
|
|
256
|
+
if (opts.content != null) {
|
|
257
|
+
throw new Error("--content is only valid for markdown kinds.");
|
|
258
|
+
}
|
|
259
|
+
if (opts.autoAttachments) {
|
|
260
|
+
throw new Error("--auto-attachments is only valid for markdown kinds.");
|
|
261
|
+
}
|
|
262
|
+
const { mediaUrl, mediaKey } = await uploadArtifactMedia(opts.file);
|
|
263
|
+
body.mediaUrl = mediaUrl;
|
|
264
|
+
body.mediaKey = mediaKey;
|
|
265
|
+
}
|
|
266
|
+
const resp = (await apiPost(`/artifacts/${artifactId}/revisions`, body));
|
|
267
|
+
const rev = unwrapResp(resp);
|
|
268
|
+
if (isJsonMode(artifact)) {
|
|
269
|
+
jsonOut(rev);
|
|
270
|
+
return;
|
|
271
|
+
}
|
|
272
|
+
console.log(`Draft revision added!\n` +
|
|
273
|
+
` Revision: ${rev.revisionId}\n` +
|
|
274
|
+
` Seq: ${rev.seq}\n` +
|
|
275
|
+
` State: ${rev.state}`);
|
|
276
|
+
});
|
|
277
|
+
// ── Publish ──
|
|
278
|
+
artifact
|
|
279
|
+
.command("publish <artifactId>")
|
|
280
|
+
.description("Publish a revision (becomes the artifact's single published revision).")
|
|
281
|
+
.requiredOption("--revision <revisionId>", "Revision to publish")
|
|
282
|
+
.action(async (artifactId, opts) => {
|
|
283
|
+
const resp = (await apiPost(`/artifacts/${artifactId}/publish`, {
|
|
284
|
+
revisionId: opts.revision,
|
|
285
|
+
}));
|
|
286
|
+
const a = unwrapResp(resp);
|
|
287
|
+
if (isJsonMode(artifact)) {
|
|
288
|
+
jsonOut(a);
|
|
289
|
+
return;
|
|
290
|
+
}
|
|
291
|
+
console.log(`Published!\n Artifact: ${a.artifactId}\n Revision: ${opts.revision}`);
|
|
292
|
+
});
|
|
293
|
+
// ── Revert ──
|
|
294
|
+
artifact
|
|
295
|
+
.command("revert <artifactId>")
|
|
296
|
+
.description("Revert the artifact's published pointer to an earlier revision.")
|
|
297
|
+
.requiredOption("--to <revisionId>", "Revision to revert to")
|
|
298
|
+
.action(async (artifactId, opts) => {
|
|
299
|
+
const resp = (await apiPost(`/artifacts/${artifactId}/revert`, {
|
|
300
|
+
toRevisionId: opts.to,
|
|
301
|
+
}));
|
|
302
|
+
const a = unwrapResp(resp);
|
|
303
|
+
if (isJsonMode(artifact)) {
|
|
304
|
+
jsonOut(a);
|
|
305
|
+
return;
|
|
306
|
+
}
|
|
307
|
+
console.log(`Reverted!\n Artifact: ${a.artifactId}\n Now published: ${opts.to}`);
|
|
308
|
+
});
|
|
309
|
+
// ── History ──
|
|
310
|
+
artifact
|
|
311
|
+
.command("history <artifactId>")
|
|
312
|
+
.description("List the artifact's full revision tree (owner only).")
|
|
313
|
+
.action(async (artifactId) => {
|
|
314
|
+
const resp = (await apiGet(`/artifacts/${artifactId}/revisions`));
|
|
315
|
+
const revisions = unwrapResp(resp) || [];
|
|
316
|
+
if (isJsonMode(artifact)) {
|
|
317
|
+
jsonOut(revisions);
|
|
318
|
+
return;
|
|
319
|
+
}
|
|
320
|
+
if (!revisions.length) {
|
|
321
|
+
console.log("No revisions.");
|
|
322
|
+
return;
|
|
323
|
+
}
|
|
324
|
+
console.log(`Revision history (${revisions.length}):`);
|
|
325
|
+
for (const r of revisions)
|
|
326
|
+
console.log(formatRevisionLine(r));
|
|
327
|
+
});
|
|
328
|
+
// ── Download ──
|
|
329
|
+
artifact
|
|
330
|
+
.command("download <artifactId>")
|
|
331
|
+
.description("Download an artifact's content. markdown → write the body; media → fetch the bytes. Defaults to the published/latest revision; pass --revision to pick one. Writes to --out or stdout (markdown).")
|
|
332
|
+
.option("--revision <revisionId>", "Specific revision (defaults to the artifact's current revision)")
|
|
333
|
+
.option("--out <path>", "Write to this file (markdown defaults to stdout)")
|
|
334
|
+
.action(async (artifactId, opts) => {
|
|
335
|
+
// Resolve the target revision. Without --revision, use the artifact's
|
|
336
|
+
// current (published/latest) revision from GET /artifacts/:id.
|
|
337
|
+
let kind;
|
|
338
|
+
let rev;
|
|
339
|
+
if (opts.revision) {
|
|
340
|
+
const aResp = (await apiGet(`/artifacts/${artifactId}`));
|
|
341
|
+
kind = unwrapResp(aResp).kind;
|
|
342
|
+
const rResp = (await apiGet(`/artifacts/${artifactId}/revisions/${opts.revision}`));
|
|
343
|
+
rev = unwrapResp(rResp);
|
|
344
|
+
}
|
|
345
|
+
else {
|
|
346
|
+
const aResp = (await apiGet(`/artifacts/${artifactId}`));
|
|
347
|
+
const a = unwrapResp(aResp);
|
|
348
|
+
if (!a.revision) {
|
|
349
|
+
throw new Error("Artifact has no current revision to download.");
|
|
350
|
+
}
|
|
351
|
+
kind = a.kind;
|
|
352
|
+
rev = a.revision;
|
|
353
|
+
}
|
|
354
|
+
if (isMarkdownKind(kind)) {
|
|
355
|
+
const content = rev.content ?? "";
|
|
356
|
+
if (opts.out) {
|
|
357
|
+
const { writeFile, mkdir } = await import("fs/promises");
|
|
358
|
+
const { dirname } = await import("path");
|
|
359
|
+
await mkdir(dirname(opts.out), { recursive: true });
|
|
360
|
+
await writeFile(opts.out, content, "utf8");
|
|
361
|
+
if (isJsonMode(artifact)) {
|
|
362
|
+
jsonOut({ artifactId, revisionId: rev.revisionId, filename: opts.out, size: Buffer.byteLength(content) });
|
|
363
|
+
return;
|
|
364
|
+
}
|
|
365
|
+
console.log(`Wrote ${opts.out} (${Buffer.byteLength(content)} bytes)`);
|
|
366
|
+
return;
|
|
367
|
+
}
|
|
368
|
+
if (isJsonMode(artifact)) {
|
|
369
|
+
jsonOut({ artifactId, revisionId: rev.revisionId, content });
|
|
370
|
+
return;
|
|
371
|
+
}
|
|
372
|
+
process.stdout.write(content.endsWith("\n") ? content : content + "\n");
|
|
373
|
+
return;
|
|
374
|
+
}
|
|
375
|
+
// Media kind — fetch the mediaUrl bytes.
|
|
376
|
+
if (!rev.mediaUrl) {
|
|
377
|
+
throw new Error("Revision has no media URL to download.");
|
|
378
|
+
}
|
|
379
|
+
const res = await fetch(rev.mediaUrl);
|
|
380
|
+
if (!res.ok) {
|
|
381
|
+
throw new Error(`Failed to fetch media from ${rev.mediaUrl}: HTTP ${res.status}`);
|
|
382
|
+
}
|
|
383
|
+
const buffer = Buffer.from(await res.arrayBuffer());
|
|
384
|
+
const contentType = res.headers.get("content-type") || "application/octet-stream";
|
|
385
|
+
const out = opts.out || `${artifactId}${extForContentType(contentType)}`;
|
|
386
|
+
const { writeFile, mkdir } = await import("fs/promises");
|
|
387
|
+
const { dirname } = await import("path");
|
|
388
|
+
await mkdir(dirname(out), { recursive: true });
|
|
389
|
+
await writeFile(out, buffer);
|
|
390
|
+
if (isJsonMode(artifact)) {
|
|
391
|
+
jsonOut({ artifactId, revisionId: rev.revisionId, filename: out, contentType, size: buffer.length });
|
|
392
|
+
return;
|
|
393
|
+
}
|
|
394
|
+
console.log(`Saved media to ${out} (${buffer.length} bytes)`);
|
|
395
|
+
});
|
|
396
|
+
// ── Delete ──
|
|
397
|
+
artifact
|
|
398
|
+
.command("delete <artifactId>")
|
|
399
|
+
.description("Delete an artifact (and its revision tree).")
|
|
400
|
+
.action(async (artifactId) => {
|
|
401
|
+
await apiDelete(`/artifacts/${artifactId}`);
|
|
402
|
+
if (isJsonMode(artifact)) {
|
|
403
|
+
jsonOut({ artifactId, ok: true });
|
|
404
|
+
return;
|
|
405
|
+
}
|
|
406
|
+
console.log(`Deleted ${artifactId}.`);
|
|
407
|
+
});
|
|
408
|
+
// ── Get ──
|
|
409
|
+
artifact
|
|
410
|
+
.command("get <artifactId>")
|
|
411
|
+
.description("Get one artifact with its current revision.")
|
|
412
|
+
.action(async (artifactId) => {
|
|
413
|
+
const resp = (await apiGet(`/artifacts/${artifactId}`));
|
|
414
|
+
const a = unwrapResp(resp);
|
|
415
|
+
if (isJsonMode(artifact)) {
|
|
416
|
+
jsonOut(a);
|
|
417
|
+
return;
|
|
418
|
+
}
|
|
419
|
+
printArtifact(a);
|
|
420
|
+
});
|
|
421
|
+
// ── List ──
|
|
422
|
+
artifact
|
|
423
|
+
.command("list")
|
|
424
|
+
.description("List your artifacts (newest first).")
|
|
425
|
+
.option("--kind <kind>", `Filter by kind: ${ALL_KINDS.join(" | ")}`)
|
|
426
|
+
.option("--limit <n>", "Max items to return")
|
|
427
|
+
.action(async (opts) => {
|
|
428
|
+
const params = {};
|
|
429
|
+
if (opts.kind) {
|
|
430
|
+
if (!ALL_KINDS.includes(opts.kind)) {
|
|
431
|
+
throw new Error(`--kind must be one of: ${ALL_KINDS.join(", ")}`);
|
|
432
|
+
}
|
|
433
|
+
params.kind = opts.kind;
|
|
434
|
+
}
|
|
435
|
+
if (opts.limit)
|
|
436
|
+
params.limit = parseInt(opts.limit, 10);
|
|
437
|
+
const resp = (await apiGet("/artifacts", params));
|
|
438
|
+
const items = unwrapResp(resp) || [];
|
|
439
|
+
if (isJsonMode(artifact)) {
|
|
440
|
+
jsonOut(items);
|
|
441
|
+
return;
|
|
442
|
+
}
|
|
443
|
+
if (!items.length) {
|
|
444
|
+
console.log("No artifacts.");
|
|
445
|
+
return;
|
|
446
|
+
}
|
|
447
|
+
console.log(`Artifacts (${items.length}):`);
|
|
448
|
+
for (const a of items) {
|
|
449
|
+
const pub = a.isPublished ? "published" : "draft";
|
|
450
|
+
console.log(`- [${a.kind}] ${a.artifactId.slice(0, 8)} ${a.title ?? "(untitled)"} (${pub}, ${a.updatedAt})`);
|
|
451
|
+
}
|
|
452
|
+
});
|
|
453
|
+
}
|
|
454
|
+
// Pick a file extension for a downloaded media artifact from its content type.
|
|
455
|
+
function extForContentType(contentType) {
|
|
456
|
+
const ct = contentType.toLowerCase();
|
|
457
|
+
if (ct.includes("png"))
|
|
458
|
+
return ".png";
|
|
459
|
+
if (ct.includes("jpeg") || ct.includes("jpg"))
|
|
460
|
+
return ".jpg";
|
|
461
|
+
if (ct.includes("webp"))
|
|
462
|
+
return ".webp";
|
|
463
|
+
if (ct.includes("gif"))
|
|
464
|
+
return ".gif";
|
|
465
|
+
if (ct.includes("mp4"))
|
|
466
|
+
return ".mp4";
|
|
467
|
+
if (ct.includes("quicktime"))
|
|
468
|
+
return ".mov";
|
|
469
|
+
if (ct.includes("webm"))
|
|
470
|
+
return ".webm";
|
|
471
|
+
return "";
|
|
472
|
+
}
|