@easysociable/cli 0.1.1 → 0.1.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 (108) hide show
  1. package/README.md +4 -1
  2. package/dist/easysociable.js +301 -2829
  3. package/dist/easysociable.js.map +1 -1
  4. package/package.json +17 -12
  5. package/skills/ONBOARDING.md +3 -1
  6. package/skills/easysociable-content/INSTALL.md +60 -0
  7. package/skills/easysociable-content/SKILL.md +107 -96
  8. package/skills/easysociable-content/fixtures/golden-run-en/draft.md +13 -0
  9. package/skills/easysociable-content/fixtures/golden-run-en/idea.md +26 -0
  10. package/skills/easysociable-content/fixtures/golden-run-en/pack-tiktok.json +11 -0
  11. package/skills/easysociable-content/platforms/claude.md +1 -2
  12. package/skills/easysociable-content/platforms/codex.md +1 -2
  13. package/skills/easysociable-content/platforms/hermes.md +1 -2
  14. package/skills/easysociable-content/platforms/openclaw.md +1 -2
  15. package/skills/easysociable-content/references/CLI.md +25 -12
  16. package/skills/easysociable-content/references/CONTRACT_SYNC.md +42 -0
  17. package/skills/easysociable-content/references/MCP.md +7 -12
  18. package/skills/easysociable-content/references/PAGE_PACK.md +128 -5
  19. package/skills/easysociable-content/references/PLATFORM_GUIDE.md +36 -0
  20. package/skills/easysociable-content/references/RUN_LAYOUT.md +73 -0
  21. package/skills/easysociable-content/references/TROUBLESHOOTING.md +11 -9
  22. package/skills/easysociable-content/references/VAULT.md +32 -12
  23. package/skills/easysociable-content/references/VOICE.md +16 -0
  24. package/skills/easysociable-content/scripts/contracts.mjs +183 -0
  25. package/skills/easysociable-content/scripts/export-contracts.mjs +76 -0
  26. package/skills/easysociable-content/scripts/fixture.test.mjs +24 -0
  27. package/skills/easysociable-content/scripts/package-skill.mjs +49 -0
  28. package/skills/easysociable-content/scripts/setup.mjs +127 -83
  29. package/skills/easysociable-content/scripts/setup.test.mjs +49 -38
  30. package/skills/easysociable-content/scripts/starter.mjs +50 -48
  31. package/skills/easysociable-content/scripts/validate-export.mjs +42 -0
  32. package/skills/easysociable-content/scripts/validate-pack.mjs +48 -0
  33. package/skills/easysociable-content/scripts/validate-run.mjs +100 -0
  34. package/skills/easysociable-content/scripts/validate.test.mjs +101 -0
  35. package/skills/easysociable-content/scripts/workbench.mjs +135 -0
  36. package/skills/easysociable-content/starter/CLAUDE.md +756 -0
  37. package/skills/easysociable-content/starter/accounts/README.md +9 -0
  38. package/skills/easysociable-content/starter/accounts/_index.md +31 -0
  39. package/skills/easysociable-content/starter/accounts/overrides/README.md +3 -0
  40. package/skills/easysociable-content/starter/discovery/README.md +18 -0
  41. package/skills/easysociable-content/starter/discovery/_index.md +27 -0
  42. package/skills/easysociable-content/starter/discovery/_template.md +41 -0
  43. package/skills/easysociable-content/starter/engine.json +19 -0
  44. package/skills/easysociable-content/starter/exports/README.md +28 -0
  45. package/skills/easysociable-content/starter/inbox/README.md +9 -0
  46. package/skills/easysociable-content/starter/inbox/_index.md +24 -0
  47. package/skills/easysociable-content/starter/inbox/entries/README.md +3 -0
  48. package/skills/easysociable-content/starter/platforms/README.md +19 -0
  49. package/skills/easysociable-content/starter/platforms/ig.md +69 -0
  50. package/skills/easysociable-content/starter/platforms/instagram.md +5 -0
  51. package/skills/easysociable-content/starter/platforms/linkedin.md +72 -0
  52. package/skills/easysociable-content/starter/platforms/threads.md +69 -0
  53. package/skills/easysociable-content/starter/platforms/tiktok.md +71 -0
  54. package/skills/easysociable-content/starter/platforms/x.md +78 -0
  55. package/skills/easysociable-content/starter/platforms/xiaohongshu.md +19 -0
  56. package/skills/easysociable-content/starter/profiles/_index.md +13 -0
  57. package/skills/easysociable-content/starter/profiles/default/README.md +11 -0
  58. package/skills/easysociable-content/starter/profiles/default/audience.md +38 -0
  59. package/skills/easysociable-content/starter/profiles/default/pillars.md +33 -0
  60. package/skills/easysociable-content/starter/profiles/default/voice.md +24 -0
  61. package/skills/easysociable-content/starter/published/_index.md +20 -0
  62. package/skills/easysociable-content/starter/raw/README.md +4 -0
  63. package/skills/easysociable-content/starter/raw/_index.md +12 -0
  64. package/skills/easysociable-content/starter/runs/README.md +22 -0
  65. package/skills/easysociable-content/starter/runs/_index.md +41 -0
  66. package/skills/easysociable-content/starter/runs/_template/brief.md +66 -0
  67. package/skills/easysociable-content/starter/runs/_template/draft.md +11 -0
  68. package/skills/easysociable-content/starter/runs/_template/editor.md +18 -0
  69. package/skills/easysociable-content/starter/runs/_template/feedback.md +15 -0
  70. package/skills/easysociable-content/starter/runs/_template/idea.md +27 -0
  71. package/skills/easysociable-content/starter/runs/_template/pack.md +47 -0
  72. package/skills/easysociable-content/starter/runs/_template/packet.md +25 -0
  73. package/skills/easysociable-content/starter/runs/_template/rubric.md +19 -0
  74. package/skills/easysociable-content/starter/swipe/_index.md +27 -0
  75. package/skills/easysociable-content/starter/swipe/_template.md +34 -0
  76. package/skills/easysociable-content/starter/topics/README.md +25 -0
  77. package/skills/easysociable-content/starter/topics/WATCHLIST.md +65 -0
  78. package/skills/easysociable-content/starter/topics/_index.md +44 -0
  79. package/skills/easysociable-content/starter/topics/_template-item.md +90 -0
  80. package/skills/easysociable-content/starter/topics/items/README.md +4 -0
  81. package/skills/easysociable-content/starter/topics/scoring.md +201 -0
  82. package/skills/easysociable-content/starter/topics/watchlists/README.md +10 -0
  83. package/skills/easysociable-content/starter/topics/watchlists/_shared.md +14 -0
  84. package/skills/easysociable-content/starter/topics/watchlists/default.md +6 -0
  85. package/skills/easysociable-content/starter/wiki/README.md +13 -0
  86. package/skills/easysociable-content/starter/wiki/atoms/_index.md +23 -0
  87. package/skills/easysociable-content/starter/wiki/atoms/_template.md +31 -0
  88. package/skills/easysociable-content/starter/wiki/claims/_index.md +26 -0
  89. package/skills/easysociable-content/starter/wiki/claims/_template.md +38 -0
  90. package/skills/easysociable-content/starter/workbench/README.md +17 -0
  91. package/skills/easysociable-content/workbench/build.py +1074 -0
  92. package/skills/easysociable-content/workbench/serve.py +53 -0
  93. package/skills/easysociable-content/workbench/template.html +2119 -0
  94. package/skills/easysociable-content/workflows/ANALYSIS_AND_COVERAGE.md +14 -5
  95. package/skills/easysociable-content/workflows/CONTENT_EXPORT.md +16 -11
  96. package/skills/easysociable-content/workflows/GETTING_STARTED.md +40 -0
  97. package/skills/easysociable-content/workflows/MCP_FIRST.md +3 -8
  98. package/skills/easysociable-content/workflows/OUT_OF_SCOPE.md +18 -0
  99. package/skills/easysociable-content/workflows/PACK_AUTHORING.md +42 -0
  100. package/skills/easysociable-content/workflows/SWIPE_CAPTURE.md +18 -0
  101. package/skills/easysociable-content/workflows/TOPIC_DRAFT.md +28 -4
  102. package/skills/easysociable-content/workflows/WORKING_DRAFT.md +17 -8
  103. package/skills/easysociable-slideshow/SKILL.md +20 -13
  104. package/skills/easysociable-slideshow/references/VAULT.md +13 -11
  105. package/skills/easysociable-template/scripts/generate-theme-from-hex.mjs +0 -0
  106. package/skills/easysociable-content/workflows/DEVICE_TASKS.md +0 -5
  107. package/skills/easysociable-content/workflows/EXPERIMENTS.md +0 -6
  108. package/skills/easysociable-content/workflows/PUBLICATION.md +0 -5
@@ -1,6 +1,129 @@
1
- # PagePack handoff
1
+ # PagePack handoff (`content-pack.v1`)
2
2
 
3
- `content-pack.v1` is the boundary between semantic content and visual
4
- composition. It contains platform, optional title, and ordered pages with a
5
- semantic `role` and public `slots`. The Slideshow Skill performs template
6
- selection and rendering; Content does not call Template APIs.
3
+ The Pack is the boundary between semantic content (Content Skill) and visual
4
+ composition (Template / Slideshow Skills). Content writes slots and roles;
5
+ downstream skills choose Page Templates and render.
6
+
7
+ **Source of truth for caps and roles:** `packages/template-public-contracts`
8
+ (`contentPackSchema`). This file is the Agent-facing summary.
9
+
10
+ ## Schema
11
+
12
+ ```json
13
+ {
14
+ "schemaVersion": "content-pack.v1",
15
+ "platform": "tiktok",
16
+ "title": "Optional title",
17
+ "pages": [
18
+ {
19
+ "role": "hook",
20
+ "slots": { "text_0": "Hook line", "text_1": "Support" },
21
+ "pageTemplateId": "tpl_selected_at_handoff"
22
+ }
23
+ ]
24
+ }
25
+ ```
26
+
27
+ | Field | Required | Notes |
28
+ |---|---|---|
29
+ | `schemaVersion` | yes | Must be `content-pack.v1` |
30
+ | `platform` | yes | See platform table below |
31
+ | `title` | no | Display title for Studio |
32
+ | `pages` | yes | Ordered array, length within platform caps |
33
+ | `pages[].role` | yes | Closed `pageRoles` enum |
34
+ | `pages[].slots` | yes | Public slot key → string/number/boolean/null |
35
+ | `pages[].pageTemplateId` | yes | Published Page Template selected by Slideshow before `create` |
36
+
37
+ ## Forbidden in Pack
38
+
39
+ Never include:
40
+
41
+ - `deck_template_id` / Deck references
42
+ - Template AST, Binding Maps, RenderPlans
43
+ - `ContentDocument` / `contentDocumentHash`
44
+ - Private gateway credentials or image URLs as slot values (use `imageId` after import)
45
+
46
+ ## Platform page caps
47
+
48
+ | Platform | Min pages | Max pages | V1 Slideshow |
49
+ |---|---:|---:|---|
50
+ | `tiktok` | 4 | 35 | yes |
51
+ | `instagram` | 2 | 20 | yes |
52
+ | `linkedin` | 2 | 300 | yes |
53
+ | `threads` | 2 | 20 | yes |
54
+ | `xiaohongshu` | — | — | not a Content Pack route in V1; use Template single-page |
55
+
56
+ `x` is not a Pack platform. Use Template fill-slots for single-page X graphics.
57
+
58
+ ## Page roles (closed set)
59
+
60
+ `cover`, `hook`, `setup`, `context`, `value`, `point`, `checklist`, `proof`,
61
+ `evidence`, `compare`, `screenshot`, `metric`, `quote`, `steps`, `cta`, `follow`, `recap`
62
+
63
+ Pick roles that match the narrative arc. Slideshow maps each page to a published
64
+ Page Template with compatible `supportedRoles`.
65
+
66
+ ## File naming
67
+
68
+ During the run, write vault-native pack markdown:
69
+
70
+ ```text
71
+ pack.md
72
+ pack-<platform>.md
73
+ ```
74
+
75
+ `content-pack.v1` JSON is created **at Slideshow/Template handoff**, not during draft:
76
+
77
+ ```text
78
+ pack-<platform>.json
79
+ ```
80
+
81
+ Example: `pack-tiktok.json`. Pure X text has no pack.
82
+
83
+ ## Validation
84
+
85
+ ```bash
86
+ node scripts/validate-pack.mjs --file ./pack-tiktok.json --platform tiktok --json
87
+ ```
88
+
89
+ Before Slideshow `create`, also run:
90
+
91
+ ```bash
92
+ node scripts/validate-pack.mjs --file ./pack-tiktok.json --require-page-template-id --json
93
+ ```
94
+
95
+ ## Examples
96
+
97
+ ### English — TikTok (4 pages, content stage)
98
+
99
+ ```json
100
+ {
101
+ "schemaVersion": "content-pack.v1",
102
+ "platform": "tiktok",
103
+ "title": "Morning routine tips",
104
+ "pages": [
105
+ { "role": "hook", "pageTemplateId": "tpl_hook_001", "slots": { "text_0": "Stop scrolling — this changed my mornings" } },
106
+ { "role": "value", "pageTemplateId": "tpl_value_001", "slots": { "text_0": "Wake at the same time", "text_1": "No phone for 20 minutes" } },
107
+ { "role": "value", "pageTemplateId": "tpl_value_001", "slots": { "text_0": "Move before caffeine", "text_1": "Even 5 minutes counts" } },
108
+ { "role": "cta", "pageTemplateId": "tpl_cta_001", "slots": { "text_0": "Save this for tomorrow" } }
109
+ ]
110
+ }
111
+ ```
112
+
113
+ ### 中文 — TikTok(4 页,内容阶段)
114
+
115
+ ```json
116
+ {
117
+ "schemaVersion": "content-pack.v1",
118
+ "platform": "tiktok",
119
+ "title": "晨间习惯三条",
120
+ "pages": [
121
+ { "role": "hook", "pageTemplateId": "tpl_hook_001", "slots": { "text_0": "别再瞎忙了——我靠这三条稳住早晨" } },
122
+ { "role": "value", "pageTemplateId": "tpl_value_001", "slots": { "text_0": "固定起床时间", "text_1": "醒来先别碰手机" } },
123
+ { "role": "value", "pageTemplateId": "tpl_value_001", "slots": { "text_0": "喝咖啡前先动一动", "text_1": "五分钟也算数" } },
124
+ { "role": "cta", "pageTemplateId": "tpl_cta_001", "slots": { "text_0": "先收藏,明早试" } }
125
+ ]
126
+ }
127
+ ```
128
+
129
+ JSON field names and `role` values stay English even when slot copy is 中文.
@@ -0,0 +1,36 @@
1
+ # Platform guide
2
+
3
+ Use this matrix before opening a run. **Ask** if the user did not name a platform. Multi-select → **N runs**.
4
+
5
+ | Platform | Slideshow (V1) | Pack platform key | Page cap (slideshow) | Default path |
6
+ |---|---|---|---:|---|
7
+ | TikTok | yes | `tiktok` | 4–35 | Slideshow |
8
+ | Instagram | yes | `instagram` (`ig`) | 2–20 | Slideshow |
9
+ | LinkedIn | yes | `linkedin` | 2–300 | Slideshow |
10
+ | Threads | yes | `threads` | 2–20 | Slideshow |
11
+ | X | **no** | — (no Pack) | — | Template single-page or text-only |
12
+ | Xiaohongshu | **no** (V1) | — | — | Template single-page |
13
+
14
+ Craft notes live under `Content Engine/platforms/`. Map: `instagram` → `ig.md`. Read the file before brief/draft/editor.
15
+
16
+ ## Routing
17
+
18
+ ```text
19
+ 1 page → easysociable-template
20
+ 2+ pages + slideshow platform → easysociable-slideshow
21
+ x → easysociable-template only (if images)
22
+ xiaohongshu → easysociable-template until slideshow ships
23
+ ```
24
+
25
+ ## Canvas hints (for downstream skills)
26
+
27
+ | Platform | Typical canvas | Notes |
28
+ |---|---|---|
29
+ | tiktok | 9:16 @ 1080×1920 | Photo Mode carousel |
30
+ | instagram / ig | 4:5 or 1:1 @ 1080 | Feed carousel |
31
+ | linkedin | 4:5 or 1:1 | Document-style decks |
32
+ | threads | 4:5 or 1:1 | Conversational copy |
33
+ | x | 16:9 or 1:1 | Single image post |
34
+ | xiaohongshu | 3:4 @ 1080×1440 | See Template `PLATFORM_SPECS` |
35
+
36
+ Content Skill does not pick Page Templates — only platform, roles and slot copy. Pack JSON is written at Slideshow/Template handoff, not during draft.
@@ -0,0 +1,73 @@
1
+ # Run layout
2
+
3
+ Copy `runs/_template/` to `runs/<YYYY-MM-DD-short-slug[-platform]>/`.
4
+
5
+ **One folder = one platform.** Same Topic on another network = another folder sharing `topic_id`.
6
+
7
+ ```text
8
+ Content Engine/runs/<slug>/
9
+ ```
10
+
11
+ ## Core files
12
+
13
+ | File | Purpose |
14
+ |---|---|
15
+ | `idea.md` | Run id, status, **this** platform, selection (`swipe_id` / `atoms` / `claim_id`) |
16
+ | `brief.md` | Angle, constraints, selection why |
17
+ | `packet.md` | Writer context (pillar + voice + audience + craft notes) |
18
+ | `draft.md` | Copy for **this platform only** |
19
+ | `editor.md` | REVISE / APPROVE |
20
+ | `rubric.md` | 6 × 0–2; SHIP ≥ 8 |
21
+ | `pack.md` | Image deck slots after SHIP (slideshow platforms) |
22
+ | `feedback.md` | URL + `win`/`flat`/`loss` after publish |
23
+ | `pack-<platform>.json` | Complete `content-pack.v1` — **only at render handoff** |
24
+
25
+ ## `idea.md` frontmatter
26
+
27
+ ```yaml
28
+ ---
29
+ run_id: RUN-YYYYMMDD-slug
30
+ status: RUN_OPEN
31
+ created: YYYY-MM-DD
32
+ ---
33
+ ```
34
+
35
+ Body must include `primary_platform` equal to `platforms` (single value) and the three selection fields. `none` is allowed with why.
36
+
37
+ ### File status vs index status
38
+
39
+ | `idea.md` | `runs/_index.md` |
40
+ |---|---|
41
+ | `RUN_OPEN` | `open` |
42
+ | `BRIEF_WAIT` | `brief` |
43
+ | `DRAFTED` / `EDIT_WAIT` | `draft` |
44
+ | `SCORED` | `scored` |
45
+ | `READY_TO_POST` | `ready` |
46
+ | `PUBLISHED` | `published` |
47
+ | `HOLD` / `KILLED` / `SUPERSEDED` | `hold` / `killed` / `superseded` |
48
+
49
+ Do not use index status `partial` for new runs.
50
+
51
+ ## Validation
52
+
53
+ Semantic run (no Pack JSON yet):
54
+
55
+ ```bash
56
+ node scripts/validate-run.mjs --run <run-dir> --json
57
+ ```
58
+
59
+ Slideshow handoff (Pack JSON required):
60
+
61
+ ```bash
62
+ node scripts/validate-run.mjs --run <run-dir> --require-pack --json
63
+ node scripts/validate-pack.mjs --file <run-dir>/pack-<platform>.json --platform <platform> --json
64
+ ```
65
+
66
+ ## Export path
67
+
68
+ Final PNGs at the **engine root**, not inside the run folder:
69
+
70
+ ```text
71
+ Content Engine/exports/<run-slug>/01.png
72
+ Content Engine/exports/<run-slug>/<platform>/01.png # optional subfolder
73
+ ```
@@ -1,11 +1,13 @@
1
1
  # Troubleshooting
2
2
 
3
- The V1 Content Skill has no Content CLI, Local API, SQLite runtime or runtime
4
- token. If setup fails, stop before writing files, show the Obsidian install and
5
- Vault-selection guidance, and ask the user to retry after the prerequisite is
6
- ready. Never invent `--data-dir`, `init`, `doctor` or runtime credentials.
7
-
8
- If a local Run is incomplete, keep the existing Markdown/JSON intact and report
9
- the missing file or field. Do not auto-migrate or repair an existing Content
10
- Engine folder. Publication, metrics, retrospectives and experiments are
11
- deferred in V1; keep those notes as ordinary Markdown only.
3
+ No Content CLI, Local API, SQLite, or Obsidian requirement.
4
+
5
+ If setup fails, stop before overwriting files. `python3` missing → install Python 3; starter files may already be written.
6
+
7
+ If port 8765 is in use, reuse `http://127.0.0.1:8765/workbench/` — do not start a second server. Rebuild with `python3 workbench/build.py`.
8
+
9
+ Never overwrite an existing `workbench/template.html`. `--apply` only adds missing files.
10
+
11
+ If a run is incomplete, keep existing markdown and report the missing file. `validate-run.mjs` / `validate-pack.mjs` list concrete gaps. Do not auto-migrate an existing engine.
12
+
13
+ Deferred features: `workflows/OUT_OF_SCOPE.md`.
@@ -1,20 +1,40 @@
1
- # Vault layout
1
+ # Engine layout
2
2
 
3
- Setup asks the user to choose a locale, then creates one engine root:
3
+ The skill creates one English root:
4
4
 
5
- | Locale | Root | Runs | Exports |
6
- |---|---|---|---|
7
- | `en` | `Content Engine/` | `runs/<slug>/` | `exports/<slug>/<platform>/` |
8
- | `zh` | `内容引擎/` | `成稿/<slug>/` | `导出/<slug>/<platform>/` |
5
+ ```text
6
+ <parent>/Content Engine/
7
+ ```
9
8
 
10
- Read `locale.json` in that root after setup:
9
+ `--root` is the **parent** directory, or the `Content Engine/` path itself.
11
10
 
12
11
  ```json
13
- { "locale": "zh" }
12
+ {
13
+ "schemaVersion": "content-engine.v1",
14
+ "locale": "en",
15
+ "root": "Content Engine",
16
+ "paths": { "runs": "runs", "exports": "exports", "workbench": "workbench" }
17
+ }
18
+ ```
19
+
20
+ Folders: `accounts`, `inbox`, `swipe`, `topics`, `discovery`, `raw`, `profiles`, `published`, `wiki`, `platforms`, `runs`, `exports`, `workbench`.
21
+
22
+ Platform craft files: `x.md`, `ig.md` (`instagram` → `ig.md`), `tiktok.md`, `linkedin.md`, `threads.md`, `xiaohongshu.md`.
23
+
24
+ ## Workbench
25
+
26
+ Copied from the skill package **only when missing**. Never overwrite an existing `workbench/template.html`.
27
+
28
+ ```text
29
+ http://127.0.0.1:8765/workbench/
14
30
  ```
15
31
 
16
- English folders stay English (`accounts`, `inbox`, `swipe`, `topics`, …).
17
- Chinese folders are `账号`, `收件箱`, `拆解`, `选题`, `素材`, `档案`, `已发布`,
18
- `百科`, `平台`. Platform note filenames stay `x.md`, `threads.md`, `tiktok.md`.
32
+ Rebuild with `python3 workbench/build.py` after markdown writes.
33
+
34
+ ## Export path
35
+
36
+ ```text
37
+ Content Engine/exports/<run-slug>/01.png
38
+ ```
19
39
 
20
- Never create both roots in one Vault. Pack JSON keys stay English.
40
+ Exports are **not** nested under `runs/<slug>/`. See `references/RUN_LAYOUT.md`.
@@ -0,0 +1,16 @@
1
+ # Local voice vs hosted Profile
2
+
3
+ | Concept | Where | When | Purpose |
4
+ |---|---|---|---|
5
+ | **Local voice** | `profiles/<id>/voice.md` | First draft (optional) | Identity + tone for the Agent |
6
+ | **Local profile** | `profiles/<id>/` | Multi-brand | Pillars + audience + voice |
7
+ | **Hosted Brand Profile** | Gateway (`easysociable profiles …`) | First render | Visual identity: Theme, logo, quotas |
8
+
9
+ ## Rules
10
+
11
+ 1. **Setup does not create a hosted Profile.**
12
+ 2. **Local voice is optional.** Ask once; user may skip.
13
+ 3. **Hosted Profile is required for render**, owned by Template/Slideshow skills.
14
+ 4. Never store API keys, passwords or Stripe tokens in `profiles/` or `accounts/`.
15
+
16
+ When writing `draft.md` or pack slots, read the resolved voice file and audience **core + this platform**.
@@ -0,0 +1,183 @@
1
+ /** ponytail: mirrors @easysociable/template-public-contracts — upgrade by diffing that package */
2
+
3
+ import { existsSync, readFileSync, readdirSync, statSync } from 'node:fs'
4
+ import { join, basename } from 'node:path'
5
+
6
+ export const PAGE_ROLES = [
7
+ 'cover', 'hook', 'setup', 'context', 'value', 'point', 'checklist', 'proof',
8
+ 'evidence', 'compare', 'screenshot', 'metric', 'quote', 'steps', 'cta', 'follow', 'recap',
9
+ ]
10
+
11
+ export const PACK_PLATFORMS = ['tiktok', 'xiaohongshu', 'instagram', 'linkedin', 'threads']
12
+
13
+ export const SLIDESHOW_PLATFORMS = ['tiktok', 'instagram', 'linkedin', 'threads']
14
+
15
+ export const PLATFORM_CAPS = {
16
+ tiktok: { hardMin: 4, hardMax: 35 },
17
+ xiaohongshu: { hardMin: 2, hardMax: 18 },
18
+ instagram: { hardMin: 2, hardMax: 20 },
19
+ linkedin: { hardMin: 2, hardMax: 300 },
20
+ threads: { hardMin: 2, hardMax: 20 },
21
+ }
22
+
23
+ const FORBIDDEN_PACK_KEYS = ['deck_template_id', 'deckTemplateId']
24
+
25
+ const PACK_KEYS = new Set(['schemaVersion', 'platform', 'title', 'pages'])
26
+ const PAGE_KEYS = new Set(['pageTemplateId', 'role', 'slots'])
27
+ export const RUN_FILE_STATUSES = [
28
+ 'RUN_OPEN', 'BRIEF_WAIT', 'DRAFTED', 'EDIT_WAIT', 'SCORED',
29
+ 'READY_TO_POST', 'PUBLISHED', 'HOLD', 'KILLED', 'SUPERSEDED',
30
+ ]
31
+ export const RUN_INDEX_STATUSES = [
32
+ 'open', 'brief', 'draft', 'scored', 'ready', 'published', 'hold', 'killed', 'superseded', 'partial',
33
+ ]
34
+ const RUN_STATUSES = [
35
+ ...RUN_FILE_STATUSES,
36
+ ...RUN_INDEX_STATUSES,
37
+ 'template-ready', 'pack-ready', 'handed-off', 'exported',
38
+ ]
39
+
40
+ function isRecord(value) {
41
+ return typeof value === 'object' && value !== null && !Array.isArray(value)
42
+ }
43
+
44
+ function pushForbidden(errors, value, path) {
45
+ const text = JSON.stringify(value).toLowerCase()
46
+ for (const key of FORBIDDEN_PACK_KEYS) {
47
+ if (text.includes(key.toLowerCase())) errors.push(`${path}: forbidden field ${key}`)
48
+ }
49
+ }
50
+
51
+ function pushUnknownKeys(errors, value, allowed, path) {
52
+ for (const key of Object.keys(value)) {
53
+ if (!allowed.has(key)) errors.push(`${path}: unknown field ${key}`)
54
+ }
55
+ }
56
+
57
+ function isPublicSlotValue(value) {
58
+ return value === null || typeof value === 'string' || typeof value === 'number' || typeof value === 'boolean'
59
+ }
60
+
61
+ export function validateContentPack(pack, options = {}) {
62
+ const { platform: expectedPlatform } = options
63
+ const errors = []
64
+ const warnings = []
65
+
66
+ if (!isRecord(pack)) {
67
+ errors.push('pack: must be a JSON object')
68
+ return { valid: false, errors, warnings }
69
+ }
70
+
71
+ pushForbidden(errors, pack, 'pack')
72
+ pushUnknownKeys(errors, pack, PACK_KEYS, 'pack')
73
+
74
+ if (pack.schemaVersion !== 'content-pack.v1') errors.push('schemaVersion: must be content-pack.v1')
75
+ if (!PACK_PLATFORMS.includes(pack.platform)) {
76
+ errors.push(`platform: must be one of ${PACK_PLATFORMS.join(', ')}`)
77
+ }
78
+ if (expectedPlatform && pack.platform !== expectedPlatform) {
79
+ errors.push(`platform: expected ${expectedPlatform}, got ${pack.platform}`)
80
+ }
81
+ if (pack.title !== undefined && typeof pack.title !== 'string') errors.push('title: must be a string')
82
+ if (!Array.isArray(pack.pages) || pack.pages.length === 0) {
83
+ errors.push('pages: must be a non-empty array')
84
+ return { valid: errors.length === 0, errors, warnings }
85
+ }
86
+
87
+ const cap = PLATFORM_CAPS[pack.platform]
88
+ if (cap && (pack.pages.length < cap.hardMin || pack.pages.length > cap.hardMax)) {
89
+ errors.push(`pages: ${pack.platform} requires ${cap.hardMin}-${cap.hardMax} pages, got ${pack.pages.length}`)
90
+ }
91
+
92
+ pack.pages.forEach((page, index) => {
93
+ const label = `pages[${index}]`
94
+ if (!isRecord(page)) {
95
+ errors.push(`${label}: must be an object`)
96
+ return
97
+ }
98
+ pushUnknownKeys(errors, page, PAGE_KEYS, label)
99
+ if (!PAGE_ROLES.includes(page.role)) errors.push(`${label}.role: invalid page role`)
100
+ if (typeof page.pageTemplateId !== 'string' || page.pageTemplateId.length === 0) {
101
+ errors.push(`${label}.pageTemplateId: required and must be a non-empty string`)
102
+ }
103
+ if (!isRecord(page.slots)) {
104
+ errors.push(`${label}.slots: must be an object`)
105
+ }
106
+ else {
107
+ for (const [key, value] of Object.entries(page.slots)) {
108
+ if (!isPublicSlotValue(value)) errors.push(`${label}.slots.${key}: must be string, number, boolean or null`)
109
+ }
110
+ }
111
+ })
112
+
113
+ if (!SLIDESHOW_PLATFORMS.includes(pack.platform)) {
114
+ warnings.push(`platform ${pack.platform}: not supported by easysociable-slideshow in V1`)
115
+ }
116
+
117
+ return { valid: errors.length === 0, errors, warnings }
118
+ }
119
+
120
+ export function parseFrontmatter(text) {
121
+ const match = text.match(/^---\r?\n([\s\S]*?)\r?\n---/)
122
+ if (!match) return {}
123
+ const fields = {}
124
+ for (const line of match[1].split('\n')) {
125
+ const idx = line.indexOf(':')
126
+ if (idx === -1) continue
127
+ const key = line.slice(0, idx).trim()
128
+ const value = line.slice(idx + 1).trim().replace(/^['"]|['"]$/g, '')
129
+ if (key) fields[key] = value
130
+ }
131
+ return fields
132
+ }
133
+
134
+ export function ideaField(text, name) {
135
+ const match = text.match(new RegExp(`\\*\\*${name}:\\*\\*\\s*(.+)`, 'i'))
136
+ return match ? match[1].trim() : ''
137
+ }
138
+
139
+ export function validateRunLayout(runDir, options = {}) {
140
+ const { requirePack = false } = options
141
+ const errors = []
142
+ const warnings = []
143
+ const files = { ideaMd: false, draftMd: false, packMd: false, packs: [] }
144
+ const ideaPath = join(runDir, 'idea.md')
145
+
146
+ if (!existsSync(runDir) || !statSync(runDir).isDirectory()) {
147
+ errors.push('run: directory required')
148
+ return { valid: false, errors, warnings, files, frontmatter: {} }
149
+ }
150
+ if (!existsSync(ideaPath)) {
151
+ errors.push('idea.md: required')
152
+ return { valid: false, errors, warnings, files, frontmatter: {} }
153
+ }
154
+
155
+ files.ideaMd = true
156
+ const ideaText = readFileSync(ideaPath, 'utf8')
157
+ const frontmatter = parseFrontmatter(ideaText)
158
+ const platform = (frontmatter.platform || ideaField(ideaText, 'primary_platform') || ideaField(ideaText, 'platforms')).replace(/\s.*/, '')
159
+ frontmatter.platform = platform
160
+ frontmatter.slug = frontmatter.slug || basename(runDir)
161
+ if (!frontmatter.run_id) errors.push('idea.md frontmatter: missing run_id')
162
+ if (!frontmatter.status) errors.push('idea.md frontmatter: missing status')
163
+ if (frontmatter.status && !RUN_STATUSES.includes(frontmatter.status)) {
164
+ errors.push(`idea.md status: must be one of the engine run statuses`)
165
+ }
166
+ if (!platform) warnings.push('idea.md: primary_platform recommended')
167
+
168
+ files.draftMd = existsSync(join(runDir, 'draft.md'))
169
+ files.packMd = existsSync(join(runDir, 'pack.md')) || readdirSync(runDir).some((name) => /^pack-[a-z0-9_-]+\.md$/i.test(name))
170
+ if (!files.draftMd) warnings.push('draft.md: recommended')
171
+
172
+ files.packs = readdirSync(runDir).filter((name) => /^pack-[a-z0-9_-]+\.json$/i.test(name))
173
+ const slideshowRun = SLIDESHOW_PLATFORMS.includes(platform) || platform === 'ig'
174
+ const expectedPack = platform === 'ig' ? 'pack-instagram.json' : (platform ? `pack-${platform}.json` : 'pack-<platform>.json')
175
+ if (requirePack && files.packs.length === 0) {
176
+ errors.push(`${expectedPack}: required for slideshow handoff`)
177
+ }
178
+ if (!slideshowRun && files.packs.length > 0) {
179
+ errors.push('pack-<platform>.json: only slideshow platforms may contain a Content Pack')
180
+ }
181
+
182
+ return { valid: errors.length === 0, errors, warnings, files, frontmatter }
183
+ }
@@ -0,0 +1,76 @@
1
+ import { existsSync, readFileSync, readdirSync, statSync } from 'node:fs'
2
+ import { basename, join } from 'node:path'
3
+ import { createHash } from 'node:crypto'
4
+
5
+ function isRecord(value) {
6
+ return typeof value === 'object' && value !== null && !Array.isArray(value)
7
+ }
8
+
9
+ function sha256(bytes) {
10
+ return createHash('sha256').update(bytes).digest('hex')
11
+ }
12
+
13
+ export function validateExportDirectory(exportDir, options = {}) {
14
+ const expectedPages = options.expectedPages
15
+ const errors = []
16
+ const warnings = []
17
+ const files = { manifest: false, pngs: [] }
18
+
19
+ if (!existsSync(exportDir) || !statSync(exportDir).isDirectory()) {
20
+ return { valid: false, errors: ['export: directory required'], warnings, files }
21
+ }
22
+
23
+ const manifestPath = join(exportDir, 'manifest.json')
24
+ if (!existsSync(manifestPath)) {
25
+ errors.push('manifest.json: required')
26
+ return { valid: false, errors, warnings, files }
27
+ }
28
+ files.manifest = true
29
+
30
+ let manifest
31
+ try {
32
+ manifest = JSON.parse(readFileSync(manifestPath, 'utf8'))
33
+ }
34
+ catch (error) {
35
+ errors.push(`manifest.json: invalid JSON (${error instanceof Error ? error.message : String(error)})`)
36
+ return { valid: false, errors, warnings, files }
37
+ }
38
+ if (!isRecord(manifest)) errors.push('manifest.json: must contain an object')
39
+
40
+ const entries = Array.isArray(manifest?.pages)
41
+ ? manifest.pages
42
+ : Array.isArray(manifest?.artifacts) ? manifest.artifacts : []
43
+ if (entries.length === 0) errors.push('manifest.json: pages or artifacts must be a non-empty array')
44
+
45
+ files.pngs = readdirSync(exportDir).filter(name => /^\d{2}(?:-[^/]+)?\.png$/i.test(name) && statSync(join(exportDir, name)).isFile())
46
+ if (entries.length > 0 && files.pngs.length !== entries.length) {
47
+ errors.push(`export: manifest has ${entries.length} page(s), found ${files.pngs.length} PNG file(s)`)
48
+ }
49
+ if (Number.isInteger(expectedPages) && expectedPages > 0 && entries.length !== expectedPages) {
50
+ errors.push(`export: expected ${expectedPages} page(s), manifest has ${entries.length}`)
51
+ }
52
+
53
+ const pngByName = new Map(files.pngs.map(name => [name, name]))
54
+ entries.forEach((entry, index) => {
55
+ if (!isRecord(entry)) {
56
+ errors.push(`manifest[${index}]: must be an object`)
57
+ return
58
+ }
59
+ const file = typeof entry.file === 'string' && entry.file.length > 0
60
+ ? basename(entry.file)
61
+ : `${String(index + 1).padStart(2, '0')}.png`
62
+ if (file !== entry.file && entry.file !== undefined) errors.push(`manifest[${index}].file: must be a local basename`)
63
+ if (!pngByName.has(file)) {
64
+ errors.push(`manifest[${index}].file: missing ${file}`)
65
+ return
66
+ }
67
+ const bytes = readFileSync(join(exportDir, file))
68
+ if (bytes.length === 0) errors.push(`${file}: empty file`)
69
+ if (bytes.length < 8 || bytes.subarray(0, 8).toString('hex') !== '89504e470d0a1a0a') {
70
+ errors.push(`${file}: not a PNG file`)
71
+ }
72
+ if (entry.sha256 && entry.sha256 !== sha256(bytes)) errors.push(`${file}: sha256 mismatch`)
73
+ })
74
+
75
+ return { valid: errors.length === 0, errors, warnings, files, manifest }
76
+ }
@@ -0,0 +1,24 @@
1
+ import assert from 'node:assert/strict'
2
+ import { readFileSync } from 'node:fs'
3
+ import { join } from 'node:path'
4
+ import { spawnSync } from 'node:child_process'
5
+
6
+ const root = new URL('..', import.meta.url).pathname
7
+ const goldenRun = join(root, 'fixtures/golden-run-en')
8
+ const validateRun = join(root, 'scripts/validate-run.mjs')
9
+ const validatePack = join(root, 'scripts/validate-pack.mjs')
10
+
11
+ const packFile = join(goldenRun, 'pack-tiktok.json')
12
+ const packCli = spawnSync(process.execPath, [validatePack, '--file', packFile, '--platform', 'tiktok', '--json'], { encoding: 'utf8' })
13
+ assert.equal(packCli.status, 0, packCli.stdout + packCli.stderr)
14
+
15
+ const runCli = spawnSync(process.execPath, [validateRun, '--run', goldenRun, '--require-pack', '--json'], { encoding: 'utf8' })
16
+ assert.equal(runCli.status, 0, runCli.stdout + runCli.stderr)
17
+ const runReport = JSON.parse(runCli.stdout)
18
+ assert.equal(runReport.valid, true)
19
+ assert.equal(runReport.layout.frontmatter.status, 'pack-ready')
20
+
21
+ const pack = JSON.parse(readFileSync(packFile, 'utf8'))
22
+ assert.equal(pack.pages.length, 4)
23
+
24
+ console.log('Golden fixture validation passed.')
@@ -0,0 +1,49 @@
1
+ #!/usr/bin/env node
2
+
3
+ import { cpSync, existsSync, mkdirSync, rmSync } from 'node:fs'
4
+ import { join, relative, resolve, sep } from 'node:path'
5
+
6
+ const skillRoot = resolve(new URL('..', import.meta.url).pathname)
7
+ const include = ['SKILL.md', 'INSTALL.md', 'scripts', 'references', 'workflows', 'platforms', 'fixtures', 'starter', 'workbench']
8
+
9
+ function parseArgs(argv) {
10
+ let output = ''
11
+ let force = false
12
+ for (let i = 0; i < argv.length; i += 1) {
13
+ if (argv[i] === '--output') output = argv[++i] ?? ''
14
+ else if (argv[i] === '--force') force = true
15
+ else if (argv[i] === '--help' || argv[i] === '-h') return { help: true }
16
+ else throw new Error(`Unknown argument: ${argv[i]}`)
17
+ }
18
+ return { output, force }
19
+ }
20
+
21
+ try {
22
+ const options = parseArgs(process.argv.slice(2))
23
+ if (options.help) {
24
+ process.stdout.write('Usage: node package-skill.mjs --output <destination-dir> [--force]\n')
25
+ process.exit(0)
26
+ }
27
+ if (!options.output) throw new Error('--output is required')
28
+
29
+ const dest = resolve(options.output)
30
+ const sourceToDest = relative(skillRoot, dest)
31
+ const destToSource = relative(dest, skillRoot)
32
+ const touchesSource = dest === skillRoot
33
+ || (!sourceToDest.startsWith(`..${sep}`) && sourceToDest !== '..')
34
+ || (!destToSource.startsWith(`..${sep}`) && destToSource !== '..')
35
+ if (touchesSource) throw new Error('--output must be outside the skill source directory')
36
+ if (existsSync(dest) && !options.force) throw new Error('Output already exists; pass --force to replace it')
37
+ if (existsSync(dest)) rmSync(dest, { recursive: true, force: true })
38
+ mkdirSync(dest, { recursive: true })
39
+
40
+ for (const name of include) {
41
+ cpSync(join(skillRoot, name), join(dest, name), { recursive: true })
42
+ }
43
+
44
+ process.stdout.write(`Packaged skill → ${dest}\n`)
45
+ }
46
+ catch (error) {
47
+ console.error(error instanceof Error ? error.message : String(error))
48
+ process.exitCode = 1
49
+ }