@gobi-ai/cli 2.0.24 → 2.0.25
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 +24 -79
- package/dist/commands/personal.js +23 -76
- package/dist/commands/space.js +25 -80
- 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 +5 -12
- package/skills/gobi-space/references/personal.md +2 -8
- package/skills/gobi-space/references/space.md +4 -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
|
+
}
|
package/dist/commands/global.js
CHANGED
|
@@ -1,8 +1,7 @@
|
|
|
1
1
|
import { WEB_BASE_URL } from "../constants.js";
|
|
2
2
|
import { apiGet, apiPost, apiPatch, apiDelete } from "../client.js";
|
|
3
|
-
import {
|
|
4
|
-
import {
|
|
5
|
-
import { getValidToken } from "../auth/manager.js";
|
|
3
|
+
import { isJsonMode, jsonOut, readStdin, resolveVaultSlug, unwrapResp, } from "./utils.js";
|
|
4
|
+
import { uploadPostAttachments, assertPostAttachmentMix, } from "../attachments.js";
|
|
6
5
|
function readContent(value) {
|
|
7
6
|
if (value === "-")
|
|
8
7
|
return readStdin();
|
|
@@ -188,48 +187,29 @@ export function registerGlobalCommand(program) {
|
|
|
188
187
|
// ── Create post ──
|
|
189
188
|
global
|
|
190
189
|
.command("create-post")
|
|
191
|
-
.description("Create a post in the global feed. --vault-slug attributes it to a vault you own. With no --vault-slug
|
|
190
|
+
.description("Create a post in the global feed. --vault-slug attributes it to a vault you own. With no --vault-slug, the post is created without an authorVaultSlug (vault-less personal post).")
|
|
192
191
|
.option("--title <title>", "Title of the post")
|
|
193
192
|
.option("--content <content>", "Post content (markdown supported, use \"-\" for stdin)")
|
|
194
193
|
.option("--rich-text <richText>", "Rich-text JSON array (mutually exclusive with --content)")
|
|
195
194
|
.option("--vault-slug <vaultSlug>", "Attribute the post to this vault (sets authorVaultSlug). Caller must own the vault.")
|
|
196
|
-
.option("--auto-attachments", "Upload wiki-linked [[files]] to webdrive before posting (also sets authorVaultSlug to that vault)")
|
|
197
|
-
.option("--draft-id <draftId>", "Use this draft as the source of title and content (mutually exclusive with --title/--content/--rich-text). On success, links the post back by recording postId on draft.metadata so the client can render an 'Open post' button. The draft's vaultSlug seeds --vault-slug when not given explicitly.")
|
|
198
195
|
.option("--attach <file>", "Local media file to attach. Repeatable. X-style mix rule: up to 4 photos OR 1 GIF OR 1 video. Size ceilings: 5MB photos / 15MB GIFs / 512MB video.", (value, prev = []) => [...prev, value], [])
|
|
199
196
|
.option("--repost-post-id <postId>", "Wrap an existing top-level post as the embedded card on this new post. Composes with --content / --rich-text / --attach (the wrapping author's text + media render above the embedded card). Reposts-of-reposts are collapsed to the transitive root server-side. The referenced post must exist, not be deleted, and not itself be a reply.")
|
|
200
197
|
.action(async (opts) => {
|
|
201
|
-
if (opts.
|
|
202
|
-
|
|
203
|
-
throw new Error("--draft-id sources title and content from the draft; --title, --content, and --rich-text are not allowed alongside it.");
|
|
204
|
-
}
|
|
198
|
+
if (!opts.content && !opts.richText) {
|
|
199
|
+
throw new Error("Provide either --content or --rich-text.");
|
|
205
200
|
}
|
|
206
|
-
|
|
207
|
-
|
|
208
|
-
throw new Error("Provide either --content or --rich-text (or --draft-id).");
|
|
209
|
-
}
|
|
210
|
-
if (opts.content && opts.richText) {
|
|
211
|
-
throw new Error("--content and --rich-text are mutually exclusive.");
|
|
212
|
-
}
|
|
201
|
+
if (opts.content && opts.richText) {
|
|
202
|
+
throw new Error("--content and --rich-text are mutually exclusive.");
|
|
213
203
|
}
|
|
214
|
-
const draft = opts.draftId ? await fetchDraftSummary(opts.draftId) : null;
|
|
215
|
-
const effectiveTitle = draft ? draft.title : opts.title;
|
|
216
|
-
const effectiveContent = draft ? draft.content : opts.content;
|
|
217
|
-
const effectiveVaultSlugOpt = opts.vaultSlug ?? draft?.vaultSlug ?? undefined;
|
|
218
204
|
let authorVaultSlug;
|
|
219
|
-
if (
|
|
220
|
-
authorVaultSlug = resolveVaultSlug({ vaultSlug:
|
|
205
|
+
if (opts.vaultSlug) {
|
|
206
|
+
authorVaultSlug = resolveVaultSlug({ vaultSlug: opts.vaultSlug });
|
|
221
207
|
}
|
|
222
208
|
const body = {};
|
|
223
|
-
if (
|
|
224
|
-
body.title =
|
|
225
|
-
if (
|
|
226
|
-
|
|
227
|
-
if (opts.autoAttachments && authorVaultSlug) {
|
|
228
|
-
const token = await getValidToken();
|
|
229
|
-
const links = extractWikiLinks(content);
|
|
230
|
-
await uploadAttachments(authorVaultSlug, links, token, { addToSyncfiles: true });
|
|
231
|
-
}
|
|
232
|
-
body.content = content;
|
|
209
|
+
if (opts.title != null)
|
|
210
|
+
body.title = opts.title;
|
|
211
|
+
if (opts.content != null) {
|
|
212
|
+
body.content = readContent(opts.content);
|
|
233
213
|
}
|
|
234
214
|
if (opts.richText != null) {
|
|
235
215
|
let parsed;
|
|
@@ -256,19 +236,6 @@ export function registerGlobalCommand(program) {
|
|
|
256
236
|
}
|
|
257
237
|
const resp = (await apiPost(`/posts`, body));
|
|
258
238
|
const post = unwrapResp(resp);
|
|
259
|
-
if (opts.draftId && post.id != null) {
|
|
260
|
-
try {
|
|
261
|
-
await apiPatch(`/app/drafts/${opts.draftId}/metadata`, {
|
|
262
|
-
postId: typeof post.id === "number" ? post.id : Number(post.id),
|
|
263
|
-
spaceSlug: null,
|
|
264
|
-
});
|
|
265
|
-
}
|
|
266
|
-
catch (e) {
|
|
267
|
-
// Don't fail the create if linking fails — the post is live; just
|
|
268
|
-
// surface a warning so the agent can mention it.
|
|
269
|
-
console.error(`Warning: failed to link post to draft ${opts.draftId}: ${e.message}`);
|
|
270
|
-
}
|
|
271
|
-
}
|
|
272
239
|
const shareUrl = buildPersonalPostUrl(post);
|
|
273
240
|
if (isJsonMode(global)) {
|
|
274
241
|
jsonOut({ ...post, shareUrl });
|
|
@@ -278,8 +245,7 @@ export function registerGlobalCommand(program) {
|
|
|
278
245
|
` ID: ${post.id}\n` +
|
|
279
246
|
(post.title ? ` Title: ${post.title}\n` : "") +
|
|
280
247
|
` Created: ${post.createdAt}\n` +
|
|
281
|
-
` URL: ${shareUrl}`
|
|
282
|
-
(opts.draftId ? `\n Linked to draft: ${opts.draftId}` : ""));
|
|
248
|
+
` URL: ${shareUrl}`);
|
|
283
249
|
});
|
|
284
250
|
// ── Edit post ──
|
|
285
251
|
global
|
|
@@ -289,10 +255,9 @@ export function registerGlobalCommand(program) {
|
|
|
289
255
|
.option("--content <content>", "New content (markdown supported, use \"-\" for stdin)")
|
|
290
256
|
.option("--rich-text <richText>", "Rich-text JSON array (mutually exclusive with --content)")
|
|
291
257
|
.option("--vault-slug <vaultSlug>", "Attribute the post to this vault (sets authorVaultSlug).")
|
|
292
|
-
.option("--auto-attachments", "Upload wiki-linked [[files]] to webdrive before editing (uses --vault-slug or .gobi vault)")
|
|
293
258
|
.option("--attach <file>", "Replace the post's media attachments with the given files (existing attachments are removed). Repeatable. X-style mix rule: up to 4 photos OR 1 GIF OR 1 video. Size ceilings: 5MB photos / 15MB GIFs / 512MB video. Omit to leave attachments unchanged.", (value, prev = []) => [...prev, value], [])
|
|
294
259
|
.action(async (postId, opts) => {
|
|
295
|
-
const wantsVaultChange = !!
|
|
260
|
+
const wantsVaultChange = !!opts.vaultSlug;
|
|
296
261
|
const wantsAttachChange = !!(opts.attach && opts.attach.length > 0);
|
|
297
262
|
if (opts.title == null &&
|
|
298
263
|
opts.content == null &&
|
|
@@ -305,20 +270,14 @@ export function registerGlobalCommand(program) {
|
|
|
305
270
|
throw new Error("--content and --rich-text are mutually exclusive.");
|
|
306
271
|
}
|
|
307
272
|
let authorVaultSlug;
|
|
308
|
-
if (opts.vaultSlug
|
|
273
|
+
if (opts.vaultSlug) {
|
|
309
274
|
authorVaultSlug = resolveVaultSlug(opts);
|
|
310
275
|
}
|
|
311
276
|
const body = {};
|
|
312
277
|
if (opts.title != null)
|
|
313
278
|
body.title = opts.title;
|
|
314
279
|
if (opts.content != null) {
|
|
315
|
-
|
|
316
|
-
if (opts.autoAttachments && authorVaultSlug) {
|
|
317
|
-
const token = await getValidToken();
|
|
318
|
-
const links = extractWikiLinks(content);
|
|
319
|
-
await uploadAttachments(authorVaultSlug, links, token, { addToSyncfiles: true });
|
|
320
|
-
}
|
|
321
|
-
body.content = content;
|
|
280
|
+
body.content = readContent(opts.content);
|
|
322
281
|
}
|
|
323
282
|
if (opts.richText != null) {
|
|
324
283
|
let parsed;
|
|
@@ -362,8 +321,7 @@ export function registerGlobalCommand(program) {
|
|
|
362
321
|
.description("Create a reply to a post in the global feed.")
|
|
363
322
|
.option("--content <content>", "Reply content (markdown supported, use \"-\" for stdin)")
|
|
364
323
|
.option("--rich-text <richText>", "Rich-text JSON array (mutually exclusive with --content)")
|
|
365
|
-
.option("--vault-slug <vaultSlug>", "Attribute the reply to this vault (sets authorVaultSlug).
|
|
366
|
-
.option("--auto-attachments", "Upload wiki-linked [[files]] to webdrive before posting (also attributes the reply to that vault)")
|
|
324
|
+
.option("--vault-slug <vaultSlug>", "Attribute the reply to this vault (sets authorVaultSlug).")
|
|
367
325
|
.option("--attach <file>", "Local media file to attach to this reply. Repeatable. X-style mix rule: up to 4 photos OR 1 GIF OR 1 video. Size ceilings: 5MB photos / 15MB GIFs / 512MB video.", (value, prev = []) => [...prev, value], [])
|
|
368
326
|
.action(async (postId, opts) => {
|
|
369
327
|
if (!opts.content && !opts.richText) {
|
|
@@ -373,18 +331,12 @@ export function registerGlobalCommand(program) {
|
|
|
373
331
|
throw new Error("--content and --rich-text are mutually exclusive.");
|
|
374
332
|
}
|
|
375
333
|
let authorVaultSlug;
|
|
376
|
-
if (opts.vaultSlug
|
|
334
|
+
if (opts.vaultSlug) {
|
|
377
335
|
authorVaultSlug = resolveVaultSlug(opts);
|
|
378
336
|
}
|
|
379
337
|
const body = {};
|
|
380
338
|
if (opts.content != null) {
|
|
381
|
-
|
|
382
|
-
if (opts.autoAttachments && authorVaultSlug) {
|
|
383
|
-
const token = await getValidToken();
|
|
384
|
-
const links = extractWikiLinks(content);
|
|
385
|
-
await uploadAttachments(authorVaultSlug, links, token, { addToSyncfiles: true });
|
|
386
|
-
}
|
|
387
|
-
body.content = content;
|
|
339
|
+
body.content = readContent(opts.content);
|
|
388
340
|
}
|
|
389
341
|
if (opts.richText != null) {
|
|
390
342
|
let parsed;
|
|
@@ -415,10 +367,9 @@ export function registerGlobalCommand(program) {
|
|
|
415
367
|
.description("Edit a reply you authored in the global feed.")
|
|
416
368
|
.option("--content <content>", "New reply content (markdown supported, use \"-\" for stdin)")
|
|
417
369
|
.option("--rich-text <richText>", "Rich-text JSON array (mutually exclusive with --content)")
|
|
418
|
-
.option("--vault-slug <vaultSlug>", "Attribute the reply to this vault (sets authorVaultSlug).
|
|
419
|
-
.option("--auto-attachments", "Upload wiki-linked [[files]] to webdrive before editing (also attributes the reply to that vault)")
|
|
370
|
+
.option("--vault-slug <vaultSlug>", "Attribute the reply to this vault (sets authorVaultSlug).")
|
|
420
371
|
.action(async (replyId, opts) => {
|
|
421
|
-
const wantsVaultChange = !!
|
|
372
|
+
const wantsVaultChange = !!opts.vaultSlug;
|
|
422
373
|
if (opts.content == null && opts.richText == null && !wantsVaultChange) {
|
|
423
374
|
throw new Error("Provide at least --content, --rich-text, or --vault-slug to update.");
|
|
424
375
|
}
|
|
@@ -426,18 +377,12 @@ export function registerGlobalCommand(program) {
|
|
|
426
377
|
throw new Error("--content and --rich-text are mutually exclusive.");
|
|
427
378
|
}
|
|
428
379
|
let authorVaultSlug;
|
|
429
|
-
if (opts.vaultSlug
|
|
380
|
+
if (opts.vaultSlug) {
|
|
430
381
|
authorVaultSlug = resolveVaultSlug(opts);
|
|
431
382
|
}
|
|
432
383
|
const body = {};
|
|
433
384
|
if (opts.content != null) {
|
|
434
|
-
|
|
435
|
-
if (opts.autoAttachments && authorVaultSlug) {
|
|
436
|
-
const token = await getValidToken();
|
|
437
|
-
const links = extractWikiLinks(content);
|
|
438
|
-
await uploadAttachments(authorVaultSlug, links, token, { addToSyncfiles: true });
|
|
439
|
-
}
|
|
440
|
-
body.content = content;
|
|
385
|
+
body.content = readContent(opts.content);
|
|
441
386
|
}
|
|
442
387
|
if (opts.richText != null) {
|
|
443
388
|
let parsed;
|