@zzclub/pipeline 0.2.0 → 0.4.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.
Files changed (194) hide show
  1. package/README.md +366 -170
  2. package/package.json +4 -7
  3. package/src/adapter-loader.ts +138 -0
  4. package/src/adapter-types.ts +172 -0
  5. package/src/adapters/builtin-image-renderer.ts +353 -0
  6. package/src/adapters/builtin-markdown-renderer.ts +63 -0
  7. package/src/adapters/index.ts +2 -0
  8. package/src/args.test.ts +118 -0
  9. package/src/args.ts +90 -0
  10. package/src/cli.ts +51 -0
  11. package/src/commands/abandon.test.ts +104 -0
  12. package/src/commands/abandon.ts +202 -0
  13. package/src/commands/attach-body-images.ts +88 -0
  14. package/src/commands/attach-body.ts +86 -0
  15. package/src/commands/attach-newspic-spec.ts +41 -0
  16. package/src/commands/checkpoint.test.ts +91 -0
  17. package/src/commands/checkpoint.ts +66 -0
  18. package/src/commands/config.ts +106 -0
  19. package/src/commands/cos-upload.ts +62 -0
  20. package/src/commands/doctor.ts +66 -0
  21. package/src/commands/find-run.ts +55 -0
  22. package/src/commands/hermes-metrics.ts +226 -0
  23. package/src/commands/imgx.ts +60 -0
  24. package/src/commands/ingest-handoff.ts +509 -0
  25. package/src/commands/init.ts +126 -0
  26. package/src/commands/prepare-finalize.ts +348 -0
  27. package/src/commands/prepare.ts +310 -0
  28. package/src/commands/publish.ts +147 -0
  29. package/src/commands/reconcile.ts +36 -0
  30. package/src/commands/render.ts +244 -0
  31. package/src/commands/reset.test.ts +151 -0
  32. package/src/commands/reset.ts +159 -0
  33. package/src/commands/review.ts +77 -0
  34. package/src/commands/status.ts +68 -0
  35. package/src/commands/sync-blog.ts +39 -0
  36. package/src/commands/tasks.ts +50 -0
  37. package/src/commands/wechat-export.ts +68 -0
  38. package/src/config.ts +0 -0
  39. package/src/imgx/ascii-portrait.ts +77 -0
  40. package/src/imgx/assets/fonts/AlimamaShuHeiTi-Bold.ttf +0 -0
  41. package/src/imgx/assets/fonts/LXGWNeoZhiSongPlus.ttf +0 -0
  42. package/src/imgx/assets/fonts/LXGWWenKai-Regular.ttf +0 -0
  43. package/src/imgx/assets/icons/logo.png +0 -0
  44. package/src/imgx/assets/icons/logo.svg +8 -0
  45. package/{dist/assets → src}/imgx/assets/templates/longform-3-4.html +1 -1
  46. package/{dist/assets → src}/imgx/assets/templates/poster-3-4.html +69 -30
  47. package/{dist/assets → src}/imgx/assets/templates/wechat-cover-split.html +58 -27
  48. package/src/imgx/cli.ts +57 -0
  49. package/src/imgx/geometry.ts +68 -0
  50. package/src/imgx/index.ts +5 -0
  51. package/src/imgx/longform-theme.ts +243 -0
  52. package/src/imgx/poster-recipe.ts +81 -0
  53. package/src/imgx/pretext-adapter.ts +14 -0
  54. package/src/imgx/pretext-runtime.ts +61 -0
  55. package/src/imgx/references/ascii-portrait-3-4.md +59 -0
  56. package/src/imgx/references/longform-3-4.md +173 -0
  57. package/src/imgx/references/poster-3-4.md +87 -0
  58. package/src/imgx/references/tips-3-4.md +69 -0
  59. package/src/imgx/references/wechat-cover-split.md +89 -0
  60. package/src/imgx/references/x-like-posts.md +139 -0
  61. package/src/imgx/render-article.test.ts +53 -0
  62. package/src/imgx/render-article.ts +1364 -0
  63. package/src/imgx/render-ascii-portrait.ts +53 -0
  64. package/src/imgx/render-card.ts +232 -0
  65. package/src/imgx/render-x-like-posts.ts +235 -0
  66. package/src/imgx/runtime.ts +403 -0
  67. package/src/imgx/typographic-scale.test.ts +56 -0
  68. package/src/imgx/typographic-scale.ts +44 -0
  69. package/src/output.ts +632 -0
  70. package/src/plugins.ts +101 -0
  71. package/src/profiles.ts +149 -0
  72. package/src/providers/blog.test.ts +58 -0
  73. package/src/providers/blog.ts +154 -0
  74. package/src/providers/cos.ts +183 -0
  75. package/src/providers/index.test.ts +26 -0
  76. package/src/providers/index.ts +185 -0
  77. package/src/providers/wechat.test.ts +215 -0
  78. package/src/providers/wechat.ts +548 -0
  79. package/src/routes.ts +287 -0
  80. package/src/runtime-paths.ts +179 -0
  81. package/src/schema/config.ts +133 -0
  82. package/src/schema/state.ts +376 -0
  83. package/src/spawn.ts +103 -0
  84. package/src/state.ts +265 -0
  85. package/src/task-manager.ts +657 -0
  86. package/src/task-views.ts +222 -0
  87. package/src/text.test.ts +689 -0
  88. package/src/text.ts +683 -0
  89. package/src/wechat-preview/assets/fonts/SweiCurveLegCJKsc-Regular.woff +0 -0
  90. package/src/wechat-preview/browser/editor-export.css +241 -0
  91. package/src/wechat-preview/browser/editor-export.ts +103 -0
  92. package/src/wechat-preview/frontmatter-handler.ts +73 -0
  93. package/src/wechat-preview/index.test.ts +154 -0
  94. package/src/wechat-preview/index.ts +294 -0
  95. package/src/wechat-preview/themes.ts +141 -0
  96. package/src/wechat-preview/wechat-formatter.test.ts +288 -0
  97. package/src/wechat-preview/wechat-formatter.ts +1506 -0
  98. package/src/wechat-preview.test.ts +73 -0
  99. package/src/workflow-materials.test.ts +225 -0
  100. package/src/workflow-materials.ts +217 -0
  101. package/src/workflow.test.ts +2087 -0
  102. package/dist/assets/imgx/assets/icons/avatar_jinx_cartoon.jpg +0 -0
  103. package/dist/assets/imgx/assets/icons/fishbone-logo-square.png +0 -0
  104. package/dist/assets/imgx/assets/icons/fishbone-logo.jpg +0 -0
  105. package/dist/assets/imgx/assets/icons/fishbone-logo.png +0 -0
  106. package/dist/assets/imgx/assets/icons/openclaw-logo.svg +0 -22
  107. package/dist/assets/imgx/assets/icons/zzclub-logo-black.jpg +0 -0
  108. package/dist/assets/imgx/assets/icons/zzclub-logo-gray.svg +0 -25
  109. package/dist/cli.js +0 -432
  110. package/dist/node_modules/@chenglou/pretext/dist/analysis.d.ts +0 -33
  111. package/dist/node_modules/@chenglou/pretext/dist/analysis.js +0 -1063
  112. package/dist/node_modules/@chenglou/pretext/dist/bidi.d.ts +0 -1
  113. package/dist/node_modules/@chenglou/pretext/dist/bidi.js +0 -175
  114. package/dist/node_modules/@chenglou/pretext/dist/generated/bidi-data.d.ts +0 -4
  115. package/dist/node_modules/@chenglou/pretext/dist/generated/bidi-data.js +0 -979
  116. package/dist/node_modules/@chenglou/pretext/dist/layout.d.ts +0 -70
  117. package/dist/node_modules/@chenglou/pretext/dist/layout.js +0 -496
  118. package/dist/node_modules/@chenglou/pretext/dist/line-break.d.ts +0 -36
  119. package/dist/node_modules/@chenglou/pretext/dist/line-break.js +0 -820
  120. package/dist/node_modules/@chenglou/pretext/dist/measurement.d.ts +0 -28
  121. package/dist/node_modules/@chenglou/pretext/dist/measurement.js +0 -219
  122. package/dist/node_modules/@chenglou/pretext/dist/rich-inline.d.ts +0 -51
  123. package/dist/node_modules/@chenglou/pretext/dist/rich-inline.js +0 -401
  124. /package/{dist/assets → src}/imgx/assets/browser/obstacle-flow.d.ts +0 -0
  125. /package/{dist/assets → src}/imgx/assets/browser/obstacle-flow.js +0 -0
  126. /package/{dist/assets → src}/imgx/assets/icons/clover.svg +0 -0
  127. /package/{dist/assets → src}/imgx/assets/templates/ascii-portrait-3-4.html +0 -0
  128. /package/{dist/assets → src}/imgx/assets/templates/ascii-portrait-tile.html +0 -0
  129. /package/{dist/assets → src}/imgx/assets/templates/tips-3-4.html +0 -0
  130. /package/{dist/assets → src}/imgx/assets/templates/x-like-posts.html +0 -0
  131. /package/{dist/assets → src}/wechat-preview/assets/browser-dist/.vite/manifest.json +0 -0
  132. /package/{dist/assets → src}/wechat-preview/assets/browser-dist/assets/KaTeX_AMS-Regular-BQhdFMY1.woff2 +0 -0
  133. /package/{dist/assets → src}/wechat-preview/assets/browser-dist/assets/KaTeX_AMS-Regular-DMm9YOAa.woff +0 -0
  134. /package/{dist/assets → src}/wechat-preview/assets/browser-dist/assets/KaTeX_AMS-Regular-DRggAlZN.ttf +0 -0
  135. /package/{dist/assets → src}/wechat-preview/assets/browser-dist/assets/KaTeX_Caligraphic-Bold-ATXxdsX0.ttf +0 -0
  136. /package/{dist/assets → src}/wechat-preview/assets/browser-dist/assets/KaTeX_Caligraphic-Bold-BEiXGLvX.woff +0 -0
  137. /package/{dist/assets → src}/wechat-preview/assets/browser-dist/assets/KaTeX_Caligraphic-Bold-Dq_IR9rO.woff2 +0 -0
  138. /package/{dist/assets → src}/wechat-preview/assets/browser-dist/assets/KaTeX_Caligraphic-Regular-CTRA-rTL.woff +0 -0
  139. /package/{dist/assets → src}/wechat-preview/assets/browser-dist/assets/KaTeX_Caligraphic-Regular-Di6jR-x-.woff2 +0 -0
  140. /package/{dist/assets → src}/wechat-preview/assets/browser-dist/assets/KaTeX_Caligraphic-Regular-wX97UBjC.ttf +0 -0
  141. /package/{dist/assets → src}/wechat-preview/assets/browser-dist/assets/KaTeX_Fraktur-Bold-BdnERNNW.ttf +0 -0
  142. /package/{dist/assets → src}/wechat-preview/assets/browser-dist/assets/KaTeX_Fraktur-Bold-BsDP51OF.woff +0 -0
  143. /package/{dist/assets → src}/wechat-preview/assets/browser-dist/assets/KaTeX_Fraktur-Bold-CL6g_b3V.woff2 +0 -0
  144. /package/{dist/assets → src}/wechat-preview/assets/browser-dist/assets/KaTeX_Fraktur-Regular-CB_wures.ttf +0 -0
  145. /package/{dist/assets → src}/wechat-preview/assets/browser-dist/assets/KaTeX_Fraktur-Regular-CTYiF6lA.woff2 +0 -0
  146. /package/{dist/assets → src}/wechat-preview/assets/browser-dist/assets/KaTeX_Fraktur-Regular-Dxdc4cR9.woff +0 -0
  147. /package/{dist/assets → src}/wechat-preview/assets/browser-dist/assets/KaTeX_Main-Bold-Cx986IdX.woff2 +0 -0
  148. /package/{dist/assets → src}/wechat-preview/assets/browser-dist/assets/KaTeX_Main-Bold-Jm3AIy58.woff +0 -0
  149. /package/{dist/assets → src}/wechat-preview/assets/browser-dist/assets/KaTeX_Main-Bold-waoOVXN0.ttf +0 -0
  150. /package/{dist/assets → src}/wechat-preview/assets/browser-dist/assets/KaTeX_Main-BoldItalic-DxDJ3AOS.woff2 +0 -0
  151. /package/{dist/assets → src}/wechat-preview/assets/browser-dist/assets/KaTeX_Main-BoldItalic-DzxPMmG6.ttf +0 -0
  152. /package/{dist/assets → src}/wechat-preview/assets/browser-dist/assets/KaTeX_Main-BoldItalic-SpSLRI95.woff +0 -0
  153. /package/{dist/assets → src}/wechat-preview/assets/browser-dist/assets/KaTeX_Main-Italic-3WenGoN9.ttf +0 -0
  154. /package/{dist/assets → src}/wechat-preview/assets/browser-dist/assets/KaTeX_Main-Italic-BMLOBm91.woff +0 -0
  155. /package/{dist/assets → src}/wechat-preview/assets/browser-dist/assets/KaTeX_Main-Italic-NWA7e6Wa.woff2 +0 -0
  156. /package/{dist/assets → src}/wechat-preview/assets/browser-dist/assets/KaTeX_Main-Regular-B22Nviop.woff2 +0 -0
  157. /package/{dist/assets → src}/wechat-preview/assets/browser-dist/assets/KaTeX_Main-Regular-Dr94JaBh.woff +0 -0
  158. /package/{dist/assets → src}/wechat-preview/assets/browser-dist/assets/KaTeX_Main-Regular-ypZvNtVU.ttf +0 -0
  159. /package/{dist/assets → src}/wechat-preview/assets/browser-dist/assets/KaTeX_Math-BoldItalic-B3XSjfu4.ttf +0 -0
  160. /package/{dist/assets → src}/wechat-preview/assets/browser-dist/assets/KaTeX_Math-BoldItalic-CZnvNsCZ.woff2 +0 -0
  161. /package/{dist/assets → src}/wechat-preview/assets/browser-dist/assets/KaTeX_Math-BoldItalic-iY-2wyZ7.woff +0 -0
  162. /package/{dist/assets → src}/wechat-preview/assets/browser-dist/assets/KaTeX_Math-Italic-DA0__PXp.woff +0 -0
  163. /package/{dist/assets → src}/wechat-preview/assets/browser-dist/assets/KaTeX_Math-Italic-flOr_0UB.ttf +0 -0
  164. /package/{dist/assets → src}/wechat-preview/assets/browser-dist/assets/KaTeX_Math-Italic-t53AETM-.woff2 +0 -0
  165. /package/{dist/assets → src}/wechat-preview/assets/browser-dist/assets/KaTeX_SansSerif-Bold-CFMepnvq.ttf +0 -0
  166. /package/{dist/assets → src}/wechat-preview/assets/browser-dist/assets/KaTeX_SansSerif-Bold-D1sUS0GD.woff2 +0 -0
  167. /package/{dist/assets → src}/wechat-preview/assets/browser-dist/assets/KaTeX_SansSerif-Bold-DbIhKOiC.woff +0 -0
  168. /package/{dist/assets → src}/wechat-preview/assets/browser-dist/assets/KaTeX_SansSerif-Italic-C3H0VqGB.woff2 +0 -0
  169. /package/{dist/assets → src}/wechat-preview/assets/browser-dist/assets/KaTeX_SansSerif-Italic-DN2j7dab.woff +0 -0
  170. /package/{dist/assets → src}/wechat-preview/assets/browser-dist/assets/KaTeX_SansSerif-Italic-YYjJ1zSn.ttf +0 -0
  171. /package/{dist/assets → src}/wechat-preview/assets/browser-dist/assets/KaTeX_SansSerif-Regular-BNo7hRIc.ttf +0 -0
  172. /package/{dist/assets → src}/wechat-preview/assets/browser-dist/assets/KaTeX_SansSerif-Regular-CS6fqUqJ.woff +0 -0
  173. /package/{dist/assets → src}/wechat-preview/assets/browser-dist/assets/KaTeX_SansSerif-Regular-DDBCnlJ7.woff2 +0 -0
  174. /package/{dist/assets → src}/wechat-preview/assets/browser-dist/assets/KaTeX_Script-Regular-C5JkGWo-.ttf +0 -0
  175. /package/{dist/assets → src}/wechat-preview/assets/browser-dist/assets/KaTeX_Script-Regular-D3wIWfF6.woff2 +0 -0
  176. /package/{dist/assets → src}/wechat-preview/assets/browser-dist/assets/KaTeX_Script-Regular-D5yQViql.woff +0 -0
  177. /package/{dist/assets → src}/wechat-preview/assets/browser-dist/assets/KaTeX_Size1-Regular-C195tn64.woff +0 -0
  178. /package/{dist/assets → src}/wechat-preview/assets/browser-dist/assets/KaTeX_Size1-Regular-Dbsnue_I.ttf +0 -0
  179. /package/{dist/assets → src}/wechat-preview/assets/browser-dist/assets/KaTeX_Size1-Regular-mCD8mA8B.woff2 +0 -0
  180. /package/{dist/assets → src}/wechat-preview/assets/browser-dist/assets/KaTeX_Size2-Regular-B7gKUWhC.ttf +0 -0
  181. /package/{dist/assets → src}/wechat-preview/assets/browser-dist/assets/KaTeX_Size2-Regular-Dy4dx90m.woff2 +0 -0
  182. /package/{dist/assets → src}/wechat-preview/assets/browser-dist/assets/KaTeX_Size2-Regular-oD1tc_U0.woff +0 -0
  183. /package/{dist/assets → src}/wechat-preview/assets/browser-dist/assets/KaTeX_Size3-Regular-CTq5MqoE.woff +0 -0
  184. /package/{dist/assets → src}/wechat-preview/assets/browser-dist/assets/KaTeX_Size3-Regular-DgpXs0kz.ttf +0 -0
  185. /package/{dist/assets → src}/wechat-preview/assets/browser-dist/assets/KaTeX_Size4-Regular-BF-4gkZK.woff +0 -0
  186. /package/{dist/assets → src}/wechat-preview/assets/browser-dist/assets/KaTeX_Size4-Regular-DWFBv043.ttf +0 -0
  187. /package/{dist/assets → src}/wechat-preview/assets/browser-dist/assets/KaTeX_Size4-Regular-Dl5lxZxV.woff2 +0 -0
  188. /package/{dist/assets → src}/wechat-preview/assets/browser-dist/assets/KaTeX_Typewriter-Regular-C0xS9mPB.woff +0 -0
  189. /package/{dist/assets → src}/wechat-preview/assets/browser-dist/assets/KaTeX_Typewriter-Regular-CO6r4hn1.woff2 +0 -0
  190. /package/{dist/assets → src}/wechat-preview/assets/browser-dist/assets/KaTeX_Typewriter-Regular-D3Ib7_Hf.ttf +0 -0
  191. /package/{dist/assets → src}/wechat-preview/assets/browser-dist/assets/SweiCurveLegCJKsc-Regular-B3Ut5bwH.woff +0 -0
  192. /package/{dist/assets → src}/wechat-preview/assets/browser-dist/assets/style-BYNXnUvc.css +0 -0
  193. /package/{dist/assets → src}/wechat-preview/assets/browser-dist/editor-export.js +0 -0
  194. /package/{dist/assets → src}/wechat-preview/assets/templates/export-shell.html +0 -0
@@ -0,0 +1,63 @@
1
+ /**
2
+ * Built-in markdown renderer adapter — wraps wechat-preview (exportMarkdownToWechatHtml).
3
+ *
4
+ * Implements MarkdownRenderPlugin interface. This is the default adapter used
5
+ * when no user-provided plugin is configured.
6
+ */
7
+
8
+ import { exportMarkdownToWechatHtml } from "../wechat-preview";
9
+ import { findChrome } from "../imgx/runtime";
10
+ import type {
11
+ MarkdownRenderPlugin,
12
+ MarkdownRenderInput,
13
+ MarkdownRenderOutput,
14
+ PipelinePluginDoctorCheck,
15
+ } from "../adapter-types";
16
+
17
+ export const builtinMarkdownRenderer: MarkdownRenderPlugin = {
18
+ name: "builtin-wechat-preview",
19
+ version: "1.0.0",
20
+
21
+ async doctor(): Promise<PipelinePluginDoctorCheck[]> {
22
+ const checks: PipelinePluginDoctorCheck[] = [];
23
+
24
+ // Check Chrome/Chromium
25
+ const chromePath = findChrome();
26
+ if (chromePath) {
27
+ checks.push({ name: "chrome", ok: true, message: chromePath });
28
+ } else {
29
+ checks.push({
30
+ name: "chrome",
31
+ ok: false,
32
+ message:
33
+ "Chrome/Chromium not found. Required for WeChat HTML export.\n" +
34
+ "Install one of:\n" +
35
+ " macOS: brew install --cask chromium\n" +
36
+ " Ubuntu: sudo apt install chromium-browser\n" +
37
+ " Or install Google Chrome from https://www.google.com/chrome/\n" +
38
+ " Or set CHROME_PATH environment variable to the binary path.",
39
+ });
40
+ }
41
+
42
+ return checks;
43
+ },
44
+
45
+ async render(input: MarkdownRenderInput): Promise<MarkdownRenderOutput> {
46
+ const result = await exportMarkdownToWechatHtml({
47
+ markdownPath: input.markdownPath,
48
+ outPath: input.outPath,
49
+ account: input.account,
50
+ title: input.title,
51
+ previewShellOutPath: input.previewShellOutPath,
52
+ customCss: input.customCss,
53
+ });
54
+
55
+ return {
56
+ html: result.html,
57
+ htmlPath: result.htmlPath,
58
+ account: result.account,
59
+ previewStyle: result.previewStyle,
60
+ previewShellPath: result.previewShellPath,
61
+ };
62
+ },
63
+ };
@@ -0,0 +1,2 @@
1
+ export { builtinImageRenderer } from "./builtin-image-renderer";
2
+ export { builtinMarkdownRenderer } from "./builtin-markdown-renderer";
@@ -0,0 +1,118 @@
1
+ import { describe, expect, test } from "bun:test";
2
+ import { parseArgs, requireArg, optionalArg, flagArg } from "./args";
3
+
4
+ describe("parseArgs", () => {
5
+ test("handles --key value pairs", () => {
6
+ const parsed = parseArgs(["--state", "/path/to/file"]);
7
+ expect(parsed.state).toBe("/path/to/file");
8
+ });
9
+
10
+ test("handles --key=value form", () => {
11
+ const parsed = parseArgs(["--mode=content"]);
12
+ expect(parsed.mode).toBe("content");
13
+ });
14
+
15
+ test("handles boolean flags", () => {
16
+ const parsed = parseArgs(["--skip-render"]);
17
+ expect(parsed["skip-render"]).toBe(true);
18
+ });
19
+
20
+ test("handles --help", () => {
21
+ const parsed = parseArgs(["--help"]);
22
+ expect(parsed.help).toBe(true);
23
+ });
24
+
25
+ test("handles -h shorthand", () => {
26
+ const parsed = parseArgs(["-h"]);
27
+ expect(parsed.help).toBe(true);
28
+ });
29
+
30
+ test("normalizes underscores to hyphens", () => {
31
+ const parsed = parseArgs(["--requires_render", "true"]);
32
+ expect(parsed["requires-render"]).toBe("true");
33
+ expect(parsed["requires_render"]).toBe("true");
34
+ });
35
+
36
+ test("handles multiple arguments", () => {
37
+ const parsed = parseArgs(["--state", "/path", "--mode", "content", "--verbose"]);
38
+ expect(parsed.state).toBe("/path");
39
+ expect(parsed.mode).toBe("content");
40
+ expect(parsed.verbose).toBe(true);
41
+ });
42
+
43
+ test("collects positional arguments", () => {
44
+ const parsed = parseArgs(["positional1", "--state", "/path", "positional2"]);
45
+ const positional = JSON.parse(parsed._ as string) as string[];
46
+ expect(positional).toEqual(["positional1", "positional2"]);
47
+ });
48
+
49
+ test("handles empty args", () => {
50
+ const parsed = parseArgs([]);
51
+ expect(parsed.help).toBeUndefined();
52
+ expect(JSON.parse(parsed._ as string)).toEqual([]);
53
+ });
54
+
55
+ test("handles value that looks like a flag", () => {
56
+ const parsed = parseArgs(["--output", "--verbose"]);
57
+ expect(parsed.output).toBe(true);
58
+ expect(parsed.verbose).toBe(true);
59
+ });
60
+ });
61
+
62
+ describe("requireArg", () => {
63
+ test("returns string value when present", () => {
64
+ expect(requireArg({ state: "/path" }, "state", "state path")).toBe("/path");
65
+ });
66
+
67
+ test("throws for missing key", () => {
68
+ expect(() => requireArg({}, "state", "state path")).toThrow(
69
+ "Missing required argument: --state (state path)",
70
+ );
71
+ });
72
+
73
+ test("throws when value is boolean", () => {
74
+ expect(() => requireArg({ state: true }, "state", "state path")).toThrow(
75
+ "Missing required argument: --state (state path)",
76
+ );
77
+ });
78
+
79
+ test("throws when value is undefined", () => {
80
+ expect(() => requireArg({ state: undefined as unknown as string | boolean }, "state", "desc")).toThrow();
81
+ });
82
+ });
83
+
84
+ describe("optionalArg", () => {
85
+ test("returns string value when present", () => {
86
+ expect(optionalArg({ phase: "render" }, "phase")).toBe("render");
87
+ });
88
+
89
+ test("returns undefined for missing key", () => {
90
+ expect(optionalArg({}, "phase")).toBeUndefined();
91
+ });
92
+
93
+ test("returns undefined for boolean value", () => {
94
+ expect(optionalArg({ phase: true }, "phase")).toBeUndefined();
95
+ });
96
+ });
97
+
98
+ describe("flagArg", () => {
99
+ test("returns true for boolean true", () => {
100
+ expect(flagArg({ verbose: true }, "verbose")).toBe(true);
101
+ });
102
+
103
+ test("returns true for string 'true'", () => {
104
+ expect(flagArg({ verbose: "true" }, "verbose")).toBe(true);
105
+ });
106
+
107
+ test("returns false for missing key", () => {
108
+ expect(flagArg({}, "verbose")).toBe(false);
109
+ });
110
+
111
+ test("returns false for string 'false'", () => {
112
+ expect(flagArg({ verbose: "false" }, "verbose")).toBe(false);
113
+ });
114
+
115
+ test("returns false for other string values", () => {
116
+ expect(flagArg({ verbose: "yes" }, "verbose")).toBe(false);
117
+ });
118
+ });
package/src/args.ts ADDED
@@ -0,0 +1,90 @@
1
+ /**
2
+ * args.ts — Minimal argument parser for CLI subcommands.
3
+ * Uses Bun-native parseArgs when available, falls back to manual parsing.
4
+ */
5
+
6
+ /**
7
+ * Parse CLI arguments into a key-value map.
8
+ * Supports: --key value, --key=value, --flag (boolean true)
9
+ */
10
+ export function parseArgs(args: string[]): Record<string, string | boolean> {
11
+ const result: Record<string, string | boolean> = { _: "" };
12
+ let i = 0;
13
+ const positional: string[] = [];
14
+
15
+ const assignArg = (rawKey: string, value: string | boolean) => {
16
+ const normalizedKey = rawKey.replaceAll("_", "-");
17
+ result[normalizedKey] = value;
18
+ if (normalizedKey !== rawKey) {
19
+ result[rawKey] = value;
20
+ }
21
+ };
22
+
23
+ while (i < args.length) {
24
+ const arg = args[i];
25
+
26
+ if (arg === "--help" || arg === "-h") {
27
+ result.help = true;
28
+ i++;
29
+ continue;
30
+ }
31
+
32
+ if (arg.startsWith("--")) {
33
+ const eqIdx = arg.indexOf("=");
34
+ if (eqIdx !== -1) {
35
+ // --key=value
36
+ assignArg(arg.slice(2, eqIdx), arg.slice(eqIdx + 1));
37
+ } else if (i + 1 < args.length && !args[i + 1].startsWith("--")) {
38
+ // --key value
39
+ assignArg(arg.slice(2), args[i + 1]);
40
+ i++;
41
+ } else {
42
+ // --flag (boolean)
43
+ assignArg(arg.slice(2), true);
44
+ }
45
+ } else {
46
+ positional.push(arg);
47
+ }
48
+ i++;
49
+ }
50
+
51
+ result._ = JSON.stringify(positional);
52
+ return result;
53
+ }
54
+
55
+ /**
56
+ * Require a string argument, throw if missing.
57
+ */
58
+ export function requireArg(
59
+ parsed: Record<string, string | boolean>,
60
+ key: string,
61
+ description: string,
62
+ ): string {
63
+ const val = parsed[key];
64
+ if (val === undefined || typeof val === "boolean") {
65
+ throw new Error(`Missing required argument: --${key} (${description})`);
66
+ }
67
+ return val;
68
+ }
69
+
70
+ /**
71
+ * Get optional string argument.
72
+ */
73
+ export function optionalArg(
74
+ parsed: Record<string, string | boolean>,
75
+ key: string,
76
+ ): string | undefined {
77
+ const val = parsed[key];
78
+ if (val === undefined || typeof val === "boolean") return undefined;
79
+ return val;
80
+ }
81
+
82
+ /**
83
+ * Get boolean flag.
84
+ */
85
+ export function flagArg(
86
+ parsed: Record<string, string | boolean>,
87
+ key: string,
88
+ ): boolean {
89
+ return parsed[key] === true || parsed[key] === "true";
90
+ }
package/src/cli.ts ADDED
@@ -0,0 +1,51 @@
1
+ #!/usr/bin/env bun
2
+
3
+ import { readFileSync } from "fs";
4
+ import { dirname, join } from "path";
5
+ import { fileURLToPath } from "url";
6
+ import { formatUsage, getCommandRegistry } from "./plugins";
7
+
8
+ const COMMANDS = getCommandRegistry();
9
+
10
+ function printVersion() {
11
+ const scriptDir = dirname(fileURLToPath(import.meta.url));
12
+ const pkgPath = join(scriptDir, "..", "package.json");
13
+ const pkg = JSON.parse(readFileSync(pkgPath, "utf-8"));
14
+ console.log(pkg.version);
15
+ }
16
+
17
+ async function main() {
18
+ const args = process.argv.slice(2);
19
+ const cmd = args[0];
20
+
21
+ if (!cmd || cmd === "--help" || cmd === "-h") {
22
+ printUsage();
23
+ process.exit(0);
24
+ }
25
+
26
+ if (cmd === "--version" || cmd === "-V") {
27
+ printVersion();
28
+ process.exit(0);
29
+ }
30
+
31
+ const command = COMMANDS[cmd];
32
+ if (!command) {
33
+ console.error(`Unknown command: ${cmd}`);
34
+ printUsage();
35
+ process.exit(1);
36
+ }
37
+
38
+ try {
39
+ await command.handler(args.slice(1));
40
+ } catch (err) {
41
+ const msg = err instanceof Error ? err.message : String(err);
42
+ console.error(`[zzhub-pipeline ${cmd}] Error: ${msg}`);
43
+ process.exit(1);
44
+ }
45
+ }
46
+
47
+ function printUsage() {
48
+ console.log(formatUsage());
49
+ }
50
+
51
+ main();
@@ -0,0 +1,104 @@
1
+ import { beforeEach, describe, expect, test } from "bun:test";
2
+ import { mkdtemp, mkdir, writeFile } from "fs/promises";
3
+ import { join } from "path";
4
+ import { tmpdir } from "os";
5
+
6
+ import { abandon } from "./abandon";
7
+ import { defaultState, readState, writeState } from "../state";
8
+
9
+ const TEST_CONFIG_PATH = join(tmpdir(), `zzhub-pipeline-test-config-abandon-${process.pid}.json`);
10
+ process.env.ZZHUB_PIPELINE_CONFIG = TEST_CONFIG_PATH;
11
+
12
+ async function makeTempDir(prefix: string): Promise<string> {
13
+ return mkdtemp(join(tmpdir(), prefix));
14
+ }
15
+
16
+ async function captureJsonOutput<T>(fn: () => Promise<void>): Promise<T> {
17
+ const original = console.log;
18
+ const lines: string[] = [];
19
+ console.log = (...args: unknown[]) => {
20
+ lines.push(args.map((value) => String(value)).join(" "));
21
+ };
22
+ try {
23
+ await fn();
24
+ } finally {
25
+ console.log = original;
26
+ }
27
+ return JSON.parse(lines.join("\n").trim()) as T;
28
+ }
29
+
30
+ beforeEach(async () => {
31
+ await writeFile(TEST_CONFIG_PATH, "{}", "utf-8");
32
+ });
33
+
34
+ describe("abandon", () => {
35
+ test("abandon by --state sets mode to abandoned", async () => {
36
+ const workspace = await makeTempDir("zzhub-abandon-state-");
37
+ const statePath = join(workspace, "state.json");
38
+
39
+ const state = defaultState();
40
+ state.workspace_root = workspace;
41
+ state.state_path = statePath;
42
+ state.run_id = "run-abandon-1";
43
+ state.metadata.title = "To Abandon";
44
+ await writeState(statePath, state);
45
+
46
+ const output = await captureJsonOutput<Array<{ run_id: string; ok: boolean }>>(() =>
47
+ abandon(["--state", statePath]),
48
+ );
49
+
50
+ expect(output).toHaveLength(1);
51
+ expect(output[0].run_id).toBe("run-abandon-1");
52
+ expect(output[0].ok).toBe(true);
53
+
54
+ const updated = await readState(statePath);
55
+ expect(updated.mode).toBe("abandoned");
56
+ });
57
+
58
+ test("abandon by --run-id finds and abandons the correct task", async () => {
59
+ const workspace = await makeTempDir("zzhub-abandon-runid-");
60
+ const runRoot = join(workspace, ".zzhub-media", "runs");
61
+ await mkdir(runRoot, { recursive: true });
62
+
63
+ const statePath1 = join(runRoot, "run-keep.json");
64
+ const statePath2 = join(runRoot, "run-drop.json");
65
+
66
+ const state1 = defaultState();
67
+ state1.run_id = "run-keep";
68
+ state1.workspace_root = workspace;
69
+ state1.state_path = statePath1;
70
+ state1.metadata.title = "Keep";
71
+ await writeState(statePath1, state1);
72
+
73
+ const state2 = defaultState();
74
+ state2.run_id = "run-drop";
75
+ state2.workspace_root = workspace;
76
+ state2.state_path = statePath2;
77
+ state2.metadata.title = "Drop";
78
+ await writeState(statePath2, state2);
79
+
80
+ const output = await captureJsonOutput<Array<{ run_id: string; ok: boolean }>>(() =>
81
+ abandon(["--workspace", workspace, "--run-id", "run-drop"]),
82
+ );
83
+
84
+ expect(output).toHaveLength(1);
85
+ expect(output[0].run_id).toBe("run-drop");
86
+ expect(output[0].ok).toBe(true);
87
+
88
+ const kept = await readState(statePath1);
89
+ expect(kept.mode).toBe("active");
90
+
91
+ const dropped = await readState(statePath2);
92
+ expect(dropped.mode).toBe("abandoned");
93
+ });
94
+
95
+ test("abandon with non-existent --run-id throws", async () => {
96
+ const workspace = await makeTempDir("zzhub-abandon-miss-");
97
+ const runRoot = join(workspace, ".zzhub-media", "runs");
98
+ await mkdir(runRoot, { recursive: true });
99
+
100
+ expect(
101
+ abandon(["--workspace", workspace, "--run-id", "nonexistent"]),
102
+ ).rejects.toThrow("No task found with run_id: nonexistent");
103
+ });
104
+ });
@@ -0,0 +1,202 @@
1
+ /**
2
+ * abandon — Mark one or more tasks as abandoned.
3
+ *
4
+ * Non-interactive (Agent / script):
5
+ * zzhub-pipeline abandon --run-id <id>
6
+ * zzhub-pipeline abandon --state /path/to/workflow-state.json
7
+ *
8
+ * Interactive (TTY, no args):
9
+ * zzhub-pipeline abandon
10
+ * → Shows all active tasks. Space to toggle, Enter to confirm, q/Esc to cancel.
11
+ *
12
+ * Output: JSON list of { run_id, state_path, ok, error? }
13
+ */
14
+
15
+ import * as readline from "readline";
16
+ import { parseArgs, optionalArg } from "../args";
17
+ import { printResult, renderAbandon } from "../output";
18
+ import { filterActiveTasks, getTaskByStatePath, listTasks, type ListedTask } from "../task-manager";
19
+ import { updateState } from "../state";
20
+
21
+ // ── Result type ───────────────────────────────────────────────────────────────
22
+
23
+ export interface AbandonResult {
24
+ run_id: string;
25
+ state_path: string;
26
+ ok: boolean;
27
+ error?: string;
28
+ }
29
+
30
+ // ── Core mutation ─────────────────────────────────────────────────────────────
31
+
32
+ async function abandonTask(task: ListedTask): Promise<AbandonResult> {
33
+ const statePath = task.summary.state_path;
34
+ try {
35
+ await updateState(statePath, (state) => {
36
+ state.mode = "abandoned";
37
+ });
38
+ return { run_id: task.summary.run_id, state_path: statePath, ok: true };
39
+ } catch (err) {
40
+ return {
41
+ run_id: task.summary.run_id,
42
+ state_path: statePath,
43
+ ok: false,
44
+ error: err instanceof Error ? err.message : String(err),
45
+ };
46
+ }
47
+ }
48
+
49
+ // ── Interactive checkbox (raw TTY) ────────────────────────────────────────────
50
+
51
+ interface CheckboxState {
52
+ items: ListedTask[];
53
+ selected: Set<number>;
54
+ cursor: number;
55
+ }
56
+
57
+ function renderCheckbox(state: CheckboxState): string {
58
+ const lines: string[] = [
59
+ "\x1b[1mAbandon tasks\x1b[0m \x1b[2m(↑↓ move · space select · enter confirm · q cancel)\x1b[0m",
60
+ "",
61
+ ];
62
+ for (let i = 0; i < state.items.length; i++) {
63
+ const task = state.items[i];
64
+ const isCursor = i === state.cursor;
65
+ const isSelected = state.selected.has(i);
66
+ const box = isSelected ? "\x1b[31m[✗]\x1b[0m" : "\x1b[2m[ ]\x1b[0m";
67
+ const title = task.summary.metadata.title ?? task.summary.run_id;
68
+ const id = `\x1b[2m${task.summary.run_id}\x1b[0m`;
69
+ const prefix = isCursor ? "\x1b[36m▶\x1b[0m " : " ";
70
+ lines.push(`${prefix}${box} ${title} ${id}`);
71
+ }
72
+ if (state.items.length === 0) {
73
+ lines.push(" \x1b[2m(no active tasks)\x1b[0m");
74
+ }
75
+ return lines.join("\n");
76
+ }
77
+
78
+ function clearLines(count: number): void {
79
+ for (let i = 0; i < count; i++) {
80
+ process.stdout.write("\x1b[1A\x1b[2K");
81
+ }
82
+ }
83
+
84
+ async function runInteractive(items: ListedTask[]): Promise<ListedTask[]> {
85
+ if (items.length === 0) {
86
+ process.stdout.write("No active tasks to abandon.\n");
87
+ return [];
88
+ }
89
+
90
+ const state: CheckboxState = {
91
+ items,
92
+ selected: new Set(),
93
+ cursor: 0,
94
+ };
95
+
96
+ // Enter raw mode
97
+ const rl = readline.createInterface({ input: process.stdin });
98
+ process.stdin.setRawMode(true);
99
+ process.stdin.resume();
100
+
101
+ let rendered = renderCheckbox(state);
102
+ process.stdout.write(rendered + "\n");
103
+ let lineCount = rendered.split("\n").length;
104
+
105
+ return new Promise<ListedTask[]>((resolve) => {
106
+ process.stdin.on("data", (chunk: Buffer) => {
107
+ const key = chunk.toString();
108
+
109
+ if (key === "\x1b[A" || key === "k") {
110
+ state.cursor = Math.max(0, state.cursor - 1);
111
+ } else if (key === "\x1b[B" || key === "j") {
112
+ state.cursor = Math.min(items.length - 1, state.cursor + 1);
113
+ } else if (key === " ") {
114
+ if (state.selected.has(state.cursor)) {
115
+ state.selected.delete(state.cursor);
116
+ } else {
117
+ state.selected.add(state.cursor);
118
+ }
119
+ } else if (key === "\r" || key === "\n") {
120
+ cleanup();
121
+ const chosen = [...state.selected].map((i) => items[i]);
122
+ resolve(chosen);
123
+ return;
124
+ } else if (key === "q" || key === "\x03") {
125
+ cleanup();
126
+ resolve([]);
127
+ return;
128
+ } else if (key === "\x1b") {
129
+ return;
130
+ } else {
131
+ return;
132
+ }
133
+
134
+ clearLines(lineCount);
135
+ rendered = renderCheckbox(state);
136
+ process.stdout.write(rendered + "\n");
137
+ lineCount = rendered.split("\n").length;
138
+ });
139
+
140
+ function cleanup(): void {
141
+ process.stdin.setRawMode(false);
142
+ rl.close();
143
+ }
144
+ });
145
+ }
146
+
147
+ // ── Command entry point ───────────────────────────────────────────────────────
148
+
149
+ export async function abandon(args: string[]): Promise<void> {
150
+ const parsed = parseArgs(args);
151
+
152
+ if (parsed.help) {
153
+ console.log(`
154
+ Usage: zzhub-pipeline abandon [options]
155
+
156
+ Without options (interactive TTY):
157
+ Lists all active tasks. Use ↑↓ to move, space to select,
158
+ enter to confirm abandonment, q/Esc to cancel.
159
+
160
+ Options:
161
+ --run-id Run ID of the task to abandon
162
+ --state Path to the workflow-state.json to abandon
163
+ --workspace Workspace root (used when listing tasks interactively)
164
+ `.trim());
165
+ return;
166
+ }
167
+
168
+ const runId = optionalArg(parsed, "run-id");
169
+ const statePath = optionalArg(parsed, "state");
170
+ const workspace = optionalArg(parsed, "workspace");
171
+
172
+ // ── Non-interactive: direct task identification ───────────────────────────
173
+ if (runId || statePath) {
174
+ let task: ListedTask;
175
+ if (statePath) {
176
+ task = await getTaskByStatePath(statePath);
177
+ } else {
178
+ const all = await listTasks(workspace);
179
+ const found = all.find((t) => t.summary.run_id === runId);
180
+ if (!found) {
181
+ throw new Error(`No task found with run_id: ${runId}`);
182
+ }
183
+ task = found;
184
+ }
185
+ const result = await abandonTask(task);
186
+ printResult([result], renderAbandon);
187
+ return;
188
+ }
189
+
190
+ // ── Interactive: TTY checkbox ─────────────────────────────────────────────
191
+ const all = await listTasks(workspace);
192
+ const active = filterActiveTasks(all);
193
+ const chosen = await runInteractive(active);
194
+
195
+ if (chosen.length === 0) {
196
+ process.stdout.write("No tasks abandoned.\n");
197
+ return;
198
+ }
199
+
200
+ const results = await Promise.all(chosen.map(abandonTask));
201
+ printResult(results, renderAbandon);
202
+ }