Skip to content

Commit f928b1f

Browse files
authored
Merge pull request #55 from spaarke-dev/feat/linkedin-cli-standalone-mode
feat(linkedin): add standalone-post mode to publish CLI + skill
2 parents 3382988 + 0f71f57 commit f928b1f

3 files changed

Lines changed: 383 additions & 76 deletions

File tree

.claude/skills/publish-linkedin/SKILL.md

Lines changed: 90 additions & 17 deletions
Original file line numberDiff line numberDiff line change
@@ -1,20 +1,30 @@
11
# publish-linkedin
22

33
---
4-
description: Publish a Spaarke blog article to LinkedIn as a feed-card post, with chat approval gate
4+
description: Publish a Spaarke piece to LinkedIn (blog promotion or standalone post), with chat approval gate
55
tags: [linkedin, publishing, marketing, orchestration]
66
appliesTo: ["publish to linkedin", "/publish-linkedin"]
77
alwaysApply: false
88
---
99

1010
## Purpose
1111

12-
**Orchestrator Skill** — The operator's only interface for promoting a
13-
published Spaarke blog article to LinkedIn. Drafts a feed-card
14-
commentary in the voice appropriate to the target surface (company
15-
page or personal account), resolves the 1920×1080 image, gates on a
16-
chat approval, then shells out to `npx tsx scripts/linkedin-publish.ts` which
17-
holds the OAuth credentials and talks to LinkedIn's Posts API.
12+
**Orchestrator Skill** — The operator's only interface for promoting
13+
Spaarke content to LinkedIn. Two modes:
14+
15+
- **blog-promo** *(default)* — promotes a published blog article as a
16+
feed-card post (link card with thumbnail and excerpt). Requires a
17+
live `content/blog/<date>-<slug>.mdx` and a canonical URL on
18+
`spaarke.com/why-spaarke/<slug>`.
19+
- **standalone** — posts a self-contained piece of content (no
20+
backing blog article). Body comes from
21+
`content-platform/articles/<slug>/draft.md`. Image is optional.
22+
23+
Drafts commentary in the voice appropriate to the target surface
24+
(company page or personal account) for blog-promo; uses the
25+
draft.md body verbatim for standalone. Gates on chat approval, then
26+
shells out to `npx tsx scripts/linkedin-publish.ts` which holds the
27+
OAuth credentials and talks to LinkedIn's Posts API.
1828

1929
Full architectural spec: [`projects/linkedin-publishing/spec.md`](../../../projects/linkedin-publishing/spec.md).
2030
The 7-gate workflow this skill implements is §4.2. The two-voice
@@ -50,14 +60,20 @@ evolve independently.
5060

5161
- User says "publish to LinkedIn", "publish <slug> to LinkedIn", or
5262
invokes `/publish-linkedin <slug>`.
53-
- A `content/blog/<date>-<slug>.mdx` file exists, has `draft: false`,
54-
and the corresponding URL is live on `spaarke.com/why-spaarke/<slug>`.
55-
- After the article has gone through the `content-pipeline` workflow
56-
and been merged to `main`.
63+
- For **blog-promo** (default): a `content/blog/<date>-<slug>.mdx`
64+
file exists, has `draft: false`, and the corresponding URL is live
65+
on `spaarke.com/why-spaarke/<slug>`. After the article has gone
66+
through the `content-pipeline` workflow and been merged to `main`.
67+
- For **standalone**: a `content-platform/articles/<slug>/draft.md`
68+
file exists with the post body. No blog article required.
69+
70+
Mode dispatch: if `--mode=` is not specified, the skill auto-detects
71+
by looking for `content/blog/<date>-<slug>.mdx`. If found,
72+
blog-promo; if absent but `articles/<slug>/draft.md` exists,
73+
standalone. If both or neither exist, asks the operator.
5774

5875
Do **not** use this skill for:
5976
- Native LinkedIn Articles (Pulse) — out of scope per spec §2.
60-
- Posts that aren't promoting an existing `content/blog/` piece.
6177
- Scheduled future-dated posts — this is a single-click *now*
6278
publisher.
6379

@@ -68,13 +84,23 @@ Do **not** use this skill for:
6884
### Step 1 — Validate
6985

7086
```
71-
PARSE invocation: <slug> [--target=company|personal] [--draft-fresh]
87+
PARSE invocation: <slug> [--target=company|personal] [--mode=blog-promo|standalone] [--draft-fresh]
7288
7389
IF --target is missing:
7490
-> ASK: "Which surface — company page or personal account?"
7591
-> WAIT for "company" | "personal"
7692
77-
VALIDATE:
93+
RESOLVE mode:
94+
IF --mode is provided -> use it.
95+
ELSE auto-detect:
96+
blog-mdx-exists = exists(content/blog/<date>-<slug>.mdx)
97+
draft-md-exists = exists(content-platform/articles/<slug>/draft.md)
98+
IF blog-mdx-exists AND NOT draft-md-exists -> mode = blog-promo
99+
IF draft-md-exists AND NOT blog-mdx-exists -> mode = standalone
100+
IF both -> ASK: "Both a blog article and a standalone draft exist. Which mode?"
101+
IF neither -> STOP with helpful error.
102+
103+
VALIDATE (blog-promo):
78104
1. content/blog/<date>-<slug>.mdx exists (glob by slug suffix).
79105
2. Frontmatter `draft:` is missing or false.
80106
3. Frontmatter has `title`, `description`, `summary`. Pull
@@ -86,11 +112,17 @@ VALIDATE:
86112
npx tsx scripts/linkedin-publish.ts --slug=<slug> --target=<target> --dry-run
87113
and capture any "secret not found" / "token expired" surface.
88114
115+
VALIDATE (standalone):
116+
1. content-platform/articles/<slug>/draft.md exists and has a non-empty body.
117+
2. Skip the "live URL" check entirely — standalone posts have no backing article.
118+
3. KV credentials for the chosen target exist. Probe by running:
119+
npx tsx scripts/linkedin-publish.ts --slug=<slug> --target=<target> --mode=standalone --dry-run
120+
89121
IF validation fails:
90122
-> STOP — surface the specific issue.
91123
-> If a 401 / token error: route to Error Handling §"Token expired".
92-
-> If 404 on the canonical URL: "The article isn't live yet —
93-
check the deploy or wait for SWA to finish."
124+
-> If 404 on the canonical URL (blog-promo only): "The article
125+
isn't live yet — check the deploy or wait for SWA to finish."
94126
```
95127

96128
**Output to user**:
@@ -115,6 +147,8 @@ Next: resolve the LinkedIn image.
115147

116148
### Step 2 — Resolve image
117149

150+
**For blog-promo:**
151+
118152
```
119153
PRIMARY: public/articles/<slug>/linkedin-1920x1080.png
120154
IF exists -> use it.
@@ -139,6 +173,22 @@ NO ASSET:
139173
hero.svg or linkedin-1920x1080.png and re-run."
140174
```
141175

176+
**For standalone:**
177+
178+
```
179+
DEFAULT: skip image. Standalone posts are typically text-only —
180+
LinkedIn auto-renders an OG link card from the first URL in
181+
commentary, which is usually the desired behavior.
182+
183+
IF operator wants an image:
184+
-> ASK: "Standalone posts default to text-only. Want to attach an
185+
image? Reply with the path (relative to repo root), or
186+
'skip' to post text-only."
187+
-> If a path: validate the file exists, is PNG/JPG, ≤ 8 MB.
188+
189+
The CLI will pass --image=<path> (or omit) accordingly.
190+
```
191+
142192
**Output to user**:
143193
```
144194
Image resolved:
@@ -158,6 +208,8 @@ Next: resolve the commentary.
158208

159209
### Step 3 — Resolve commentary
160210

211+
**For blog-promo:**
212+
161213
```
162214
DRAFT-FRESH PATH:
163215
IF --draft-fresh was passed -> skip to "Draft fresh" below.
@@ -188,6 +240,22 @@ ALWAYS surface to operator the voice doc(s) used to draft, so they
188240
can override.
189241
```
190242

243+
**For standalone:**
244+
245+
```
246+
READ content-platform/articles/<slug>/draft.md.
247+
-> Strip frontmatter; the body becomes the LinkedIn commentary.
248+
-> Surface to operator: "Using draft from
249+
content-platform/articles/<slug>/draft.md (the writer-approved
250+
text — no voice re-drafting in standalone mode)."
251+
252+
Standalone mode does NOT re-draft from frontmatter. The post body
253+
is whatever the writer wrote in draft.md. If the operator wants to
254+
change it, they can:
255+
- Reply `edit "<copy>"` at the approval gate (Step 5).
256+
- Or edit draft.md directly and re-invoke the skill.
257+
```
258+
191259
**Output to user**:
192260
```
193261
Commentary drafted using voice: <path or "placeholder profile">
@@ -275,7 +343,12 @@ explicit `approve`.
275343

276344
```
277345
EXECUTE (Bash tool):
278-
npx tsx scripts/linkedin-publish.ts --slug=<slug> --target=<target>
346+
blog-promo:
347+
npx tsx scripts/linkedin-publish.ts --slug=<slug> --target=<target>
348+
standalone (text-only):
349+
npx tsx scripts/linkedin-publish.ts --slug=<slug> --target=<target> --mode=standalone
350+
standalone (with image):
351+
npx tsx scripts/linkedin-publish.ts --slug=<slug> --target=<target> --mode=standalone --image=<path>
279352
280353
The CLI:
281354
1. Loads tokens from KV for <target>; refreshes inline if needed.

projects/linkedin-publishing/spec.md

Lines changed: 65 additions & 18 deletions
Original file line numberDiff line numberDiff line change
@@ -46,6 +46,10 @@ voice, and credential pair.
4646

4747
- One-command publishing from Claude Code chat, voice-aware
4848
(company vs personal), with a chat-based approval gate.
49+
- Support both **blog promotion** (feed-card with link card to a
50+
published article) and **standalone posts** (self-contained
51+
commentary with no backing blog article — founder takes on
52+
industry news, market observations, etc.).
4953
- Credentials in Azure Key Vault (`sprk-demo-kv`), never in `.env`,
5054
never in source, never in chat.
5155
- Automatic refresh-token loop so the operator doesn't have to
@@ -348,39 +352,51 @@ dependency.
348352
```
349353
--slug required, e.g. "the-iq-stack"
350354
--target required: "personal" | "company"
355+
--mode optional: "blog-promo" (default) | "standalone"
351356
--dry-run optional: show the API call body without executing
352357
--commentary optional: inline commentary, overrides file
353-
--image optional: path override (defaults to per-article png)
358+
--image optional: path override (defaults to per-article png in blog-promo;
359+
required-if-supplied in standalone)
354360
```
355361

362+
**Mode dispatch:**
363+
364+
- **`blog-promo`** *(default — backward-compatible)* — the original
365+
flow. Promotes a published blog article: requires a live
366+
`content/blog/<date>-<slug>.mdx` and the canonical URL on
367+
`spaarke.com/why-spaarke/<slug>`. Commentary lives in
368+
`content-platform/published/linkedin-posts/<slug>.md`. Image is
369+
required (defaults to `public/articles/<slug>/linkedin-1920x1080.png`).
370+
Post body is the `content.article` content type (link card with
371+
title, description, thumbnail).
372+
373+
- **`standalone`** *(new)* — for self-contained LinkedIn posts that
374+
don't promote a blog article (e.g., founder commentary on industry
375+
news, syndication-of-syndication style threads). Reads commentary
376+
from `content-platform/articles/<slug>/draft.md` (the writer's
377+
workspace). Image is optional. Post body has no `content.article`
378+
block — either text-only (no `content`) or text-plus-image
379+
(`content.media`). LinkedIn's algorithm renders an OG link card
380+
from the first URL in commentary, so multi-link standalone posts
381+
work naturally.
382+
356383
### 6.2 Execution
357384

385+
Shared across both modes:
386+
358387
```
359388
1. Load tokens from KV for the chosen target.
360389
If access-token expires-at < now + 5 min, call refresh inline.
361390
362-
2. Validate image exists at the resolved path.
363-
File size < 8 MB (LinkedIn limit).
391+
2. Validate commentary length (≤ 3000 chars). Warn at 2700.
364392
365-
3. Upload image via Images API:
393+
3. Upload image via Images API (only if an image path resolved):
366394
a. POST /rest/images?action=initializeUpload
367395
body: { initializeUploadRequest: { owner: <author URN> } }
368396
b. PUT the binary to the returned uploadUrl.
369397
c. Receive image URN in the initializeUpload response.
370398
371-
4. Build post body (article content type):
372-
author: <author URN>
373-
commentary: <approved copy>
374-
visibility: PUBLIC
375-
distribution: { feedDistribution: MAIN_FEED, targetEntities: [], thirdPartyDistributionChannels: [] }
376-
content:
377-
article:
378-
source: https://www.spaarke.com/why-spaarke/<slug>
379-
thumbnail: <image URN from step 3>
380-
title: <from frontmatter>
381-
description: <from summary, ≤ 200 chars>
382-
lifecycleState: PUBLISHED
383-
isReshareDisabledByAuthor: false
399+
4. Build post body — SHAPE DIFFERS BY MODE (see below).
384400
385401
5. POST /rest/posts with headers:
386402
Authorization: Bearer <token>
@@ -393,11 +409,42 @@ dependency.
393409
394410
7. Write to disk:
395411
- published/linkedin-posts/<slug>.md (commentary + post URL)
396-
- content-platform/calendar.md (new row)
412+
- content-platform/calendar.md (LinkedIn column updated)
397413
398414
8. Output post URL to stdout for the skill to display.
399415
```
400416

417+
Post-body shapes:
418+
419+
```
420+
blog-promo (existing — content.article):
421+
author: <author URN>
422+
commentary: <approved copy>
423+
visibility: PUBLIC
424+
distribution: { feedDistribution: MAIN_FEED, ... }
425+
content:
426+
article:
427+
source: https://www.spaarke.com/why-spaarke/<slug>
428+
thumbnail: <image URN from step 3>
429+
title: <from frontmatter>
430+
description: <from summary, ≤ 200 chars>
431+
lifecycleState: PUBLISHED
432+
isReshareDisabledByAuthor: false
433+
434+
standalone (new — text-only OR content.media):
435+
author: <author URN>
436+
commentary: <draft.md body, verbatim>
437+
visibility: PUBLIC
438+
distribution: { feedDistribution: MAIN_FEED, ... }
439+
[content: // ONLY if image supplied
440+
media:
441+
id: <image URN from step 3>
442+
title: <optional alt text>
443+
]
444+
lifecycleState: PUBLISHED
445+
isReshareDisabledByAuthor: false
446+
```
447+
401448
### 6.3 Idempotency
402449

403450
LinkedIn doesn't expose an idempotency key on `/rest/posts`. If the

0 commit comments

Comments
 (0)