@cmo-so/cli 0.12.1 → 0.12.2

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.
Files changed (2) hide show
  1. package/dist/cmo.mjs +215 -2
  2. package/package.json +1 -1
package/dist/cmo.mjs CHANGED
@@ -7662,6 +7662,8 @@ function domainApi(api2, domain) {
7662
7662
  wpBuilder: () => api2.request(`${base}/wp/builder`),
7663
7663
  connectWpBuilder: (input) => post(`${base}/wp/builder`, input),
7664
7664
  disconnectWpBuilder: () => api2.request(`${base}/wp/builder`, { method: "DELETE" }),
7665
+ /** Put one picture in the site's media library: a public https link, or a local file as base64 (≤ 10 MB). */
7666
+ wpMedia: (input) => post(`${base}/wp/media`, input),
7665
7667
  /** One WordPress REST request, signed by TOPY with the saved login (allowlisted routes only). */
7666
7668
  wpRequest: (input) => post(`${base}/wp/request`, input),
7667
7669
  /** A new website API key (aicmo_…) for the user's own code, shown once; for the CLI, which writes it to .env. */
@@ -14727,6 +14729,27 @@ function walkTouch(b) {
14727
14729
  }
14728
14730
 
14729
14731
  // ../../packages/tools/src/wp/transport.ts
14732
+ var IMAGE_EXT = {
14733
+ "image/jpeg": "jpg",
14734
+ "image/png": "png",
14735
+ "image/webp": "webp",
14736
+ "image/gif": "gif",
14737
+ "image/avif": "avif"
14738
+ };
14739
+ function sniffImage(b) {
14740
+ const at = (i, ...v) => v.every((x, k) => b[i + k] === x);
14741
+ const ascii = (i, t) => [...t].every((ch, k) => b[i + k] === ch.charCodeAt(0));
14742
+ if (at(0, 255, 216, 255)) return "image/jpeg";
14743
+ if (at(0, 137, 80, 78, 71, 13, 10, 26, 10)) return "image/png";
14744
+ if (ascii(0, "RIFF") && ascii(8, "WEBP")) return "image/webp";
14745
+ if (ascii(0, "GIF87a") || ascii(0, "GIF89a")) return "image/gif";
14746
+ if (ascii(4, "ftypavif") || ascii(4, "ftypavis")) return "image/avif";
14747
+ return null;
14748
+ }
14749
+ function mediaName(name, mime) {
14750
+ const stem = (name ?? "").trim().toLowerCase().replace(/\.[a-z0-9]{2,5}$/, "").replace(/[^a-z0-9]+/g, "-").replace(/^-+|-+$/g, "").slice(0, 80) || "picture";
14751
+ return `${stem}.${IMAGE_EXT[mime]}`;
14752
+ }
14730
14753
  var WpError = class extends Error {
14731
14754
  constructor(message2, status, code) {
14732
14755
  super(message2);
@@ -14774,6 +14797,49 @@ function directSite(siteUrl, username, password) {
14774
14797
  }
14775
14798
  const n2 = (h) => r.headers.get(h) != null ? Number(r.headers.get(h)) : void 0;
14776
14799
  return { status: r.status, data, total: n2("x-wp-total"), totalPages: n2("x-wp-totalpages") };
14800
+ },
14801
+ async uploadMedia(input) {
14802
+ let bytes = input.data;
14803
+ if (!bytes && input.url) {
14804
+ if (!/^https:\/\//i.test(input.url)) throw new WpError("The picture's link must be https.", 400);
14805
+ const g = await fetch(input.url, { headers: { Accept: "image/*" } });
14806
+ if (!g.ok) throw new WpError(`The picture's link answered HTTP ${g.status}.`, g.status);
14807
+ bytes = new Uint8Array(await g.arrayBuffer());
14808
+ }
14809
+ if (!bytes) throw new WpError("Give the picture's link or the file.", 400);
14810
+ if (bytes.length > 15 * 1024 * 1024) throw new WpError("The picture is larger than 15 MB.", 413);
14811
+ const mime = sniffImage(bytes);
14812
+ if (!mime) throw new WpError("That isn't a JPEG, PNG, WebP, GIF or AVIF picture.", 415);
14813
+ const name = mediaName(input.filename ?? input.url?.split("/").pop()?.split("?")[0], mime);
14814
+ const r = await fetch(`${base}/?rest_route=${encodeURIComponent("/wp/v2/media")}`, {
14815
+ method: "POST",
14816
+ redirect: "manual",
14817
+ headers: {
14818
+ Authorization: auth,
14819
+ "Content-Type": mime,
14820
+ "Content-Disposition": `attachment; filename="${name}"`,
14821
+ Accept: "application/json"
14822
+ },
14823
+ body: bytes
14824
+ });
14825
+ const media = await r.json().catch(() => null);
14826
+ if (!media?.id)
14827
+ throw new WpError(`WordPress didn't take the picture: ${media?.message ?? `HTTP ${r.status}`}.`, r.status);
14828
+ const meta4 = {};
14829
+ if (input.alt) meta4.alt_text = input.alt;
14830
+ if (input.title) meta4.title = input.title;
14831
+ if (input.caption) meta4.caption = input.caption;
14832
+ if (Object.keys(meta4).length)
14833
+ await this.request({ method: "POST", path: `/wp/v2/media/${media.id}`, body: meta4, query: { _fields: "id" } });
14834
+ return {
14835
+ id: media.id,
14836
+ source_url: media.source_url ?? "",
14837
+ mime_type: mime,
14838
+ bytes: bytes.length,
14839
+ width: media.media_details?.width ?? null,
14840
+ height: media.media_details?.height ?? null,
14841
+ alt: input.alt ?? ""
14842
+ };
14777
14843
  }
14778
14844
  };
14779
14845
  }
@@ -14782,6 +14848,23 @@ function apiSite(api2, domain) {
14782
14848
  return {
14783
14849
  url: `https://${domain}`,
14784
14850
  fetchPage: publicFetch,
14851
+ async uploadMedia(input) {
14852
+ let data_base64;
14853
+ if (input.data) {
14854
+ let bin = "";
14855
+ for (let i = 0; i < input.data.length; i += 32768)
14856
+ bin += String.fromCharCode(...input.data.subarray(i, i + 32768));
14857
+ data_base64 = btoa(bin);
14858
+ }
14859
+ return d.wpMedia({
14860
+ url: input.url,
14861
+ data_base64,
14862
+ filename: input.filename,
14863
+ alt: input.alt,
14864
+ title: input.title,
14865
+ caption: input.caption
14866
+ });
14867
+ },
14785
14868
  async request(req) {
14786
14869
  let r;
14787
14870
  try {
@@ -16803,6 +16886,103 @@ var sectionTools = [
16803
16886
  }
16804
16887
  })
16805
16888
  ];
16889
+ var CHAT_FILE = {
16890
+ type: "object",
16891
+ description: "The picture from this chat (ChatGPT passes it here).",
16892
+ properties: {
16893
+ download_url: { type: "string" },
16894
+ file_id: { type: "string" },
16895
+ mime_type: { type: "string" },
16896
+ file_name: { type: "string" }
16897
+ },
16898
+ required: ["download_url", "file_id"]
16899
+ };
16900
+ var mediaTools = [
16901
+ tool({
16902
+ name: "wp_media_upload",
16903
+ description: "Put a picture in the WordPress site's media library: a picture from this chat (file) or a public https link to an image (url). JPEG, PNG, WebP, GIF or AVIF, up to 15 MB; checked as a real image. Give alt text that says what the picture shows. Returns its id and source_url; then use the url in a section (wp_section_update edits.images, or slots image) or as a page's featured_media. Doesn't change any page by itself.",
16904
+ parameters: {
16905
+ type: "object",
16906
+ properties: {
16907
+ file: CHAT_FILE,
16908
+ url: str2("Public https link to the image file."),
16909
+ filename: str2("File name to save it as (default from the link), e.g. maggie-hero.webp."),
16910
+ alt: str2("Alt text: what the picture shows, in one short sentence."),
16911
+ title: str2("Media title (optional)."),
16912
+ caption: str2("Caption (optional).")
16913
+ }
16914
+ },
16915
+ group: "site",
16916
+ need: WRITE,
16917
+ risk: "W2",
16918
+ gates: [...GATES],
16919
+ fileParams: ["file"],
16920
+ confirm: (a, lang) => {
16921
+ const name = a.file?.file_name ?? (typeof a.url === "string" ? a.url.split("/").pop() : "");
16922
+ return say(
16923
+ lang,
16924
+ `Upload the picture ${q2(name, "")} to your website's media library.`,
16925
+ `\u628A\u5716\u7247 ${q2(name, "")} \u4E0A\u50B3\u5230\u7DB2\u7AD9\u7684\u5A92\u9AD4\u5EAB\u3002`
16926
+ );
16927
+ },
16928
+ run: async (ctx, a) => {
16929
+ const site = siteOf(ctx);
16930
+ if (!site.uploadMedia) throw new Error("This connection can't upload pictures.");
16931
+ const chat = a.file;
16932
+ const url = String(chat?.download_url ?? a.url ?? "").trim();
16933
+ if (!url) throw new Error("Give the picture: a file from this chat, or a public https link (url).");
16934
+ const out = await site.uploadMedia({
16935
+ url,
16936
+ filename: a.filename || chat?.file_name || void 0,
16937
+ alt: a.alt || void 0,
16938
+ title: a.title || void 0,
16939
+ caption: a.caption || void 0
16940
+ });
16941
+ return {
16942
+ uploaded: true,
16943
+ ...out,
16944
+ next: "Use source_url in wp_section_update (edits.images from \u2192 to, or slots image), or set it as the page's featured_media with wp_page_update."
16945
+ };
16946
+ }
16947
+ }),
16948
+ tool({
16949
+ name: "wp_media_list",
16950
+ description: "Pictures already in the WordPress media library (newest first), with id, link, alt text and size; search by words in the title or file name.",
16951
+ parameters: {
16952
+ type: "object",
16953
+ properties: {
16954
+ search: str2("Words in the title or file name."),
16955
+ limit: int2("How many (default 30, at most 100).")
16956
+ }
16957
+ },
16958
+ group: "site",
16959
+ need: READ,
16960
+ risk: "R0",
16961
+ gates: ["WP"],
16962
+ run: async (ctx, a) => {
16963
+ const rows = await call(siteOf(ctx), {
16964
+ path: "/wp/v2/media",
16965
+ query: {
16966
+ search: a.search || void 0,
16967
+ per_page: Math.min(100, Math.max(1, Number(a.limit) || 30)),
16968
+ media_type: "image",
16969
+ _fields: "id,source_url,alt_text,mime_type,date,title,media_details"
16970
+ }
16971
+ });
16972
+ return {
16973
+ count: rows.length,
16974
+ items: rows.map((m) => ({
16975
+ id: m.id,
16976
+ url: m.source_url,
16977
+ alt: m.alt_text,
16978
+ type: m.mime_type,
16979
+ size: m.media_details?.width ? `${m.media_details.width}\xD7${m.media_details.height}` : null,
16980
+ date: m.date
16981
+ }))
16982
+ };
16983
+ }
16984
+ })
16985
+ ];
16806
16986
  var seoFields = {
16807
16987
  title: str2("SEO title (\u224860 characters; the site name is added by the theme)."),
16808
16988
  description: str2("Meta description (\u2248150-160 characters)."),
@@ -17058,7 +17238,14 @@ var elementTools = [
17058
17238
  }
17059
17239
  })
17060
17240
  ];
17061
- var wordpressTools = [...siteTools, ...pageTools, ...sectionTools, ...seoTools, ...elementTools];
17241
+ var wordpressTools = [
17242
+ ...siteTools,
17243
+ ...pageTools,
17244
+ ...sectionTools,
17245
+ ...mediaTools,
17246
+ ...seoTools,
17247
+ ...elementTools
17248
+ ];
17062
17249
 
17063
17250
  // ../../packages/tools/src/index.ts
17064
17251
  var tools = [
@@ -20468,7 +20655,7 @@ var program = new Command();
20468
20655
  // package.json
20469
20656
  var package_default = {
20470
20657
  name: "@cmo-so/cli",
20471
- version: "0.12.1",
20658
+ version: "0.12.2",
20472
20659
  description: "CMO.so on the command line: your websites, ideas, blog posts, LinkedIn queue and results, plus a local MCP server for Claude Desktop, Cursor and other MCP clients.",
20473
20660
  type: "module",
20474
20661
  license: "MIT",
@@ -31582,6 +31769,32 @@ ${table(r.issues, ["severity", "code", "path", "detail"])}`;
31582
31769
  fonts: o.font ? Object.fromEntries(pairs(o.font)) : void 0
31583
31770
  })
31584
31771
  );
31772
+ const media = wp.command("media").description("pictures in the media library");
31773
+ media.command("ls", { isDefault: true }).option("--search <words>").option("-n, --limit <n>", "how many", "30").action(
31774
+ (o, c) => run(g(c), "wp_media_list", { search: o.search, limit: Number(o.limit) })
31775
+ );
31776
+ media.command("upload <fileOrUrl>").description("upload a picture (a local file or a public https link): JPEG, PNG, WebP, GIF or AVIF").option("--alt <text>", "what the picture shows").option("--filename <name>", "save it as").option("--title <title>").action(async (src, o, c) => {
31777
+ const opts = g(c);
31778
+ if (/^https?:\/\//i.test(src))
31779
+ return run(opts, "wp_media_upload", { url: src, alt: o.alt, filename: o.filename, title: o.title });
31780
+ const data = new Uint8Array(
31781
+ await readFile2(src).catch(() => {
31782
+ throw new CliError(`Can't read ${src}.`);
31783
+ })
31784
+ );
31785
+ const site = await siteFor(opts);
31786
+ if (!site.uploadMedia) throw new CliError("This connection can't upload pictures.");
31787
+ const preview = `Upload ${src.split("/").pop()} (${Math.round(data.length / 1024)} KB) to the media library of ${site.url}.`;
31788
+ if (opts.dryRun) {
31789
+ say2(`Would: ${preview}`);
31790
+ return;
31791
+ }
31792
+ if (!opts.yes && !await ask(preview)) throw new CliError("Nothing changed.", 1);
31793
+ print(
31794
+ await site.uploadMedia({ data, filename: o.filename ?? src.split("/").pop(), alt: o.alt, title: o.title }),
31795
+ opts.json
31796
+ );
31797
+ });
31585
31798
  wp.command("pattern <pattern>").description("change a synced pattern every page shares (contact, share, quote or id)").option("--replace <find=replace...>").option("--link <from=to...>").action(
31586
31799
  (p, o, c) => run(g(c), "wp_pattern_update", {
31587
31800
  pattern: p,
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@cmo-so/cli",
3
- "version": "0.12.1",
3
+ "version": "0.12.2",
4
4
  "description": "CMO.so on the command line: your websites, ideas, blog posts, LinkedIn queue and results, plus a local MCP server for Claude Desktop, Cursor and other MCP clients.",
5
5
  "type": "module",
6
6
  "license": "MIT",