refactor(linkedin)!: rename plugin linkedin-thought-leadership → linkedin-studio (v3.0.0)

BREAKING CHANGE: the marketplace slug, the agent namespace
(linkedin-studio:<agent>), and the runtime state-file path
(~/.claude/linkedin-studio.local.md) all change. Reinstall required;
existing state migrated in place (post metrics, streak, history preserved).
The /linkedin:* commands are unchanged — the command namespace is set
per-command in frontmatter and was always independent of the plugin slug.
Functionality is byte-identical to v2.4.0; this release is pure identity.

- dir + manifests: plugins/linkedin-studio + plugin.json + root marketplace.json
- agent namespace updated in commands/newsletter.md (only functional invoker)
- state path updated in 4 hook scripts + topic-rotation prompt + state template
- catch-all skill dir renamed skills/linkedin-studio (5 functional skills unchanged)
- docs + version bump to 3.0.0 across README badge, CHANGELOG, root README/CLAUDE.md
- historical records (CHANGELOG past entries, docs/ build artifacts,
  config-audit v5.0.0 snapshots) intentionally retain the old slug

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
This commit is contained in:
Kjell Tore Guttormsen 2026-05-29 11:32:02 +02:00
commit b6bb61246b
196 changed files with 164 additions and 138 deletions

View file

@ -0,0 +1,86 @@
import { describe, test } from 'node:test';
import assert from 'node:assert/strict';
import { clipboardAvailable, copyToClipboard } from '../clipboard-helper.mjs';
describe('clipboardAvailable', () => {
test('returns object with available and platform fields', () => {
const result = clipboardAvailable();
assert.equal(typeof result.available, 'boolean');
assert.equal(typeof result.platform, 'string');
});
test('returns available: true on macOS (darwin)', () => {
if (process.platform !== 'darwin') return;
const result = clipboardAvailable();
assert.equal(result.available, true);
assert.equal(result.platform, 'darwin');
});
test('returns a recognized platform string', () => {
const result = clipboardAvailable();
assert.ok(
['darwin', 'win32', 'linux'].includes(result.platform),
`Unexpected platform: ${result.platform}`
);
});
});
describe('copyToClipboard', () => {
test('returns object with success and platform fields', () => {
const result = copyToClipboard('test clipboard text');
assert.equal(typeof result.success, 'boolean');
assert.equal(typeof result.platform, 'string');
});
test('copies text successfully on macOS', () => {
if (process.platform !== 'darwin') return;
const result = copyToClipboard('clipboard-helper test 2026');
assert.equal(result.success, true);
assert.equal(result.platform, 'darwin');
});
test('handles empty string input gracefully', () => {
const result = copyToClipboard('');
assert.equal(result.success, true);
assert.equal(typeof result.platform, 'string');
});
test('handles multiline text', () => {
const multiline = 'Line 1\nLine 2\nLine 3';
const result = copyToClipboard(multiline);
assert.equal(result.success, true);
});
test('handles special characters (quotes, ampersands, backticks)', () => {
const special = 'He said "hello" & she said \'goodbye\' `code` $VAR';
const result = copyToClipboard(special);
assert.equal(result.success, true);
});
test('handles unicode/emoji text', () => {
const unicode = '🚀 Thought leadership → impact';
const result = copyToClipboard(unicode);
assert.equal(result.success, true);
});
test('never throws — always returns a result object', () => {
assert.doesNotThrow(() => copyToClipboard(null));
assert.doesNotThrow(() => copyToClipboard(undefined));
assert.doesNotThrow(() => copyToClipboard(123));
});
test('returns success: false for non-string input', () => {
const result = copyToClipboard(null);
assert.equal(result.success, false);
});
});
describe('module exports', () => {
test('exports clipboardAvailable as a function', () => {
assert.equal(typeof clipboardAvailable, 'function');
});
test('exports copyToClipboard as a function', () => {
assert.equal(typeof copyToClipboard, 'function');
});
});

View file

@ -0,0 +1,158 @@
import { describe, test } from 'node:test';
import assert from 'node:assert/strict';
import { generateIcal, generateIcalFromQueue } from '../ical-generator.mjs';
const SAMPLE_EVENT = {
id: 'post-2026-04-14-ai-strategy',
title: 'LinkedIn: AI strategy in public sector',
description: 'Pillar: AI Strategy | Format: Standard | Draft: assets/drafts/week-W16/monday.md',
date: '2026-04-14',
time: '08:30',
duration: 30,
};
const SAMPLE_QUEUE_ENTRY = {
id: 'post-2026-04-14-ai-strategy',
draft_path: 'assets/drafts/week-W16/monday.md',
scheduled_date: '2026-04-14',
scheduled_time: '08:30',
pillar: 'AI Strategy',
format: 'Standard',
hook_preview: 'AI strategy in public sector',
character_count: 1450,
status: 'scheduled',
created_at: '2026-04-10',
};
describe('generateIcal', () => {
test('returns valid empty VCALENDAR for empty events array', () => {
const ical = generateIcal([]);
assert.match(ical, /^BEGIN:VCALENDAR\r\n/);
assert.match(ical, /\r\nEND:VCALENDAR\r\n$/);
assert.match(ical, /PRODID:-\/\/linkedin-studio\/\/EN/);
assert.match(ical, /VERSION:2\.0/);
assert.ok(!ical.includes('BEGIN:VEVENT'), 'should not contain VEVENT');
});
test('generates VEVENT with correct DTSTART, SUMMARY, UID', () => {
const ical = generateIcal([SAMPLE_EVENT]);
assert.match(ical, /BEGIN:VEVENT/);
assert.match(ical, /DTSTART;TZID=Europe\/Oslo:20260414T083000/);
assert.match(ical, /SUMMARY:LinkedIn: AI strategy in public sector/);
assert.match(ical, /UID:post-2026-04-14-ai-strategy@linkedin-studio/);
assert.match(ical, /END:VEVENT/);
});
test('generates correct DTEND from duration', () => {
const ical = generateIcal([SAMPLE_EVENT]);
assert.match(ical, /DTEND;TZID=Europe\/Oslo:20260414T090000/);
});
test('defaults duration to 30 minutes when not specified', () => {
const event = { ...SAMPLE_EVENT, duration: undefined };
const ical = generateIcal([event]);
assert.match(ical, /DTEND;TZID=Europe\/Oslo:20260414T090000/);
});
test('has CRLF line endings throughout', () => {
const ical = generateIcal([SAMPLE_EVENT]);
const lines = ical.split('\r\n');
assert.ok(lines.length > 5, 'should have multiple lines');
const bareLF = ical.replace(/\r\n/g, '').includes('\n');
assert.ok(!bareLF, 'should not contain bare LF without CR');
});
test('includes VALARM with 15-minute trigger', () => {
const ical = generateIcal([SAMPLE_EVENT]);
assert.match(ical, /BEGIN:VALARM/);
assert.match(ical, /TRIGGER:-PT15M/);
assert.match(ical, /ACTION:DISPLAY/);
assert.match(ical, /END:VALARM/);
});
test('includes DTSTAMP in UTC format', () => {
const ical = generateIcal([SAMPLE_EVENT]);
assert.match(ical, /DTSTAMP:\d{8}T\d{6}Z/);
});
test('folds lines longer than 75 octets', () => {
const longDescription = 'A'.repeat(200);
const event = { ...SAMPLE_EVENT, description: longDescription };
const ical = generateIcal([event]);
const lines = ical.split('\r\n');
for (const line of lines) {
const octets = Buffer.byteLength(line, 'utf-8');
assert.ok(octets <= 75, `Line exceeds 75 octets (${octets}): "${line.slice(0, 40)}..."`);
}
});
test('escapes special characters in SUMMARY and DESCRIPTION', () => {
const event = {
...SAMPLE_EVENT,
title: 'Test: commas, semicolons; and\\backslashes',
description: 'Line1\nLine2, with; special\\chars',
};
const ical = generateIcal([event]);
assert.match(ical, /SUMMARY:Test: commas\\, semicolons\; and\\\\backslashes/);
assert.match(ical, /DESCRIPTION:Line1\\nLine2\\, with\; special\\\\chars/);
});
test('handles multiple events', () => {
const event2 = {
...SAMPLE_EVENT,
id: 'post-2026-04-16-leadership',
title: 'LinkedIn: Leadership lessons',
date: '2026-04-16',
time: '12:00',
};
const ical = generateIcal([SAMPLE_EVENT, event2]);
const veventCount = (ical.match(/BEGIN:VEVENT/g) || []).length;
assert.equal(veventCount, 2);
});
test('includes VTIMEZONE for Europe/Oslo', () => {
const ical = generateIcal([SAMPLE_EVENT]);
assert.match(ical, /BEGIN:VTIMEZONE/);
assert.match(ical, /TZID:Europe\/Oslo/);
assert.match(ical, /END:VTIMEZONE/);
});
test('supports custom timezone parameter', () => {
const ical = generateIcal([SAMPLE_EVENT], { timezone: 'America/New_York' });
assert.match(ical, /TZID:America\/New_York/);
assert.match(ical, /DTSTART;TZID=America\/New_York/);
});
});
describe('generateIcalFromQueue', () => {
test('transforms queue entry format to event format', () => {
const events = generateIcalFromQueue([SAMPLE_QUEUE_ENTRY]);
assert.equal(events.length, 1);
const e = events[0];
assert.equal(e.id, 'post-2026-04-14-ai-strategy');
assert.equal(e.date, '2026-04-14');
assert.equal(e.time, '08:30');
assert.ok(e.title.includes('AI strategy in public sector'));
assert.ok(e.description.includes('AI Strategy'));
assert.ok(e.description.includes('Standard'));
});
test('handles missing scheduled_time gracefully', () => {
const entry = { ...SAMPLE_QUEUE_ENTRY, scheduled_time: undefined };
const events = generateIcalFromQueue([entry]);
assert.equal(events[0].time, '09:00');
});
test('handles empty array', () => {
const events = generateIcalFromQueue([]);
assert.deepEqual(events, []);
});
test('generates valid iCal when piped through generateIcal', () => {
const events = generateIcalFromQueue([SAMPLE_QUEUE_ENTRY]);
const ical = generateIcal(events);
assert.match(ical, /BEGIN:VCALENDAR/);
assert.match(ical, /BEGIN:VEVENT/);
assert.match(ical, /END:VCALENDAR/);
});
});

View file

@ -0,0 +1,295 @@
import { describe, test } from 'node:test';
import assert from 'node:assert/strict';
import { updatePostTracking, pruneContentHistory, updateFollowerCount } from '../state-updater.mjs';
const SAMPLE_STATE = `---
last_post_date: "2026-04-05"
first_post_date: "2026-01-15"
last_post_topic: "AI strategy"
posts_this_week: 2
weekly_goal: 3
current_streak: 5
longest_streak: 12
current_week: "2026-W14"
last_import_date: "2026-04-01"
last_import_week: "2026-W14"
follower_count: 850
follower_target: 10000
target_date: "2026-12-31"
monthly_growth: []
projected_10k_date: ""
growth_rate_needed: 0
---
# LinkedIn Session State
## Recent Posts
- [2026-04-05] "AI governance is not about..." (1450) - AI strategy
- [2026-04-03] "Three things I learned..." (1200) - leadership
- [2026-03-28] "Why most teams fail at..." (1350) - team building
## Session Notes
## Planned Content
## Milestone Log
`;
describe('updatePostTracking', () => {
test('sets last_post_date to provided date', () => {
const result = updatePostTracking(SAMPLE_STATE, {
postDate: '2026-04-07',
postTopic: 'AI governance',
hookText: 'The real problem with AI governance...',
charCount: 1500,
format: 'post'
});
assert.notEqual(result, null);
assert.match(result.content, /^last_post_date: "2026-04-07"$/m);
});
test('sets last_post_topic', () => {
const result = updatePostTracking(SAMPLE_STATE, {
postDate: '2026-04-07',
postTopic: 'AI governance',
hookText: 'The real problem...',
charCount: 1500,
format: 'post'
});
assert.match(result.content, /^last_post_topic: "AI governance"$/m);
});
test('increments posts_this_week when same week', () => {
// 2026-04-06 is a Monday, ISO W15. current_week is W14.
// Use a date that stays in W14: 2026-04-05 is Sunday W14 — but last_post_date is already 04-05.
// Let's use a state with current_week matching the post date week.
const w15State = SAMPLE_STATE.replace('current_week: "2026-W14"', 'current_week: "2026-W15"');
const result = updatePostTracking(w15State, {
postDate: '2026-04-07', // Tuesday W15
postTopic: 'test',
hookText: 'Hook',
charCount: 1000,
format: 'post'
});
assert.notEqual(result, null);
assert.match(result.content, /^posts_this_week: 3$/m); // was 2, incremented
});
test('increments streak when gap <= 2 days', () => {
const result = updatePostTracking(SAMPLE_STATE, {
postDate: '2026-04-06', // 1 day after last_post_date 2026-04-05
postTopic: 'test',
hookText: 'Hook',
charCount: 1000,
format: 'post'
});
assert.notEqual(result, null);
assert.match(result.content, /^current_streak: 6$/m); // was 5, incremented
});
test('resets streak to 1 when gap > 2 days', () => {
const result = updatePostTracking(SAMPLE_STATE, {
postDate: '2026-04-09', // 4 days after 2026-04-05
postTopic: 'test',
hookText: 'Hook',
charCount: 1000,
format: 'post'
});
assert.notEqual(result, null);
assert.match(result.content, /^current_streak: 1$/m);
});
test('sets first_post_date when null', () => {
const nullFirstPost = SAMPLE_STATE.replace(
'first_post_date: "2026-01-15"',
'first_post_date: null'
);
const result = updatePostTracking(nullFirstPost, {
postDate: '2026-04-07',
postTopic: 'test',
hookText: 'Hook',
charCount: 1000,
format: 'post'
});
assert.notEqual(result, null);
assert.match(result.content, /^first_post_date: "2026-04-07"$/m);
});
test('does NOT overwrite existing first_post_date', () => {
const result = updatePostTracking(SAMPLE_STATE, {
postDate: '2026-04-07',
postTopic: 'test',
hookText: 'Hook',
charCount: 1000,
format: 'post'
});
assert.notEqual(result, null);
assert.match(result.content, /^first_post_date: "2026-01-15"$/m);
});
test('triggers week rollover when ISO week changes', () => {
// 2026-04-14 is W16, current_week is W14
const result = updatePostTracking(SAMPLE_STATE, {
postDate: '2026-04-14',
postTopic: 'test',
hookText: 'Hook',
charCount: 1000,
format: 'post'
});
assert.notEqual(result, null);
// After rollover, posts_this_week resets to 0 then increments to 1
assert.match(result.content, /^posts_this_week: 1$/m);
assert.match(result.content, /^current_week: "2026-W16"$/m);
});
test('appends to Recent Posts section', () => {
const result = updatePostTracking(SAMPLE_STATE, {
postDate: '2026-04-06',
postTopic: 'AI governance',
hookText: 'The real problem with AI governance today...',
charCount: 1500,
format: 'post'
});
assert.notEqual(result, null);
assert.ok(result.content.includes('- [2026-04-06] "The real problem with AI governance today..." (1500) - AI governance'));
// Existing entries should still be there
assert.ok(result.content.includes('- [2026-04-05] "AI governance is not about..."'));
});
test('updates longest_streak when current exceeds it', () => {
const highStreak = SAMPLE_STATE.replace('current_streak: 5', 'current_streak: 12');
const result = updatePostTracking(highStreak, {
postDate: '2026-04-06', // 1 day gap, streak increments to 13
postTopic: 'test',
hookText: 'Hook',
charCount: 1000,
format: 'post'
});
assert.notEqual(result, null);
assert.match(result.content, /^current_streak: 13$/m);
assert.match(result.content, /^longest_streak: 13$/m);
});
test('does not update longest_streak when current is lower', () => {
const result = updatePostTracking(SAMPLE_STATE, {
postDate: '2026-04-06',
postTopic: 'test',
hookText: 'Hook',
charCount: 1000,
format: 'post'
});
assert.notEqual(result, null);
assert.match(result.content, /^current_streak: 6$/m);
assert.match(result.content, /^longest_streak: 12$/m); // unchanged
});
test('returns changes array describing what changed', () => {
const result = updatePostTracking(SAMPLE_STATE, {
postDate: '2026-04-06',
postTopic: 'AI governance',
hookText: 'Hook',
charCount: 1500,
format: 'post'
});
assert.notEqual(result, null);
assert.ok(Array.isArray(result.changes));
assert.ok(result.changes.length > 0);
});
});
describe('pruneContentHistory', () => {
test('removes entries older than 90 days', () => {
const today = new Date();
const old = new Date(today);
old.setDate(old.getDate() - 100);
const oldDate = old.toISOString().slice(0, 10);
const recent = new Date(today);
recent.setDate(recent.getDate() - 10);
const recentDate = recent.toISOString().slice(0, 10);
const stateWithOld = SAMPLE_STATE.replace(
'## Recent Posts\n\n',
`## Recent Posts\n\n- [${oldDate}] "Old post..." (1000) - old topic\n- [${recentDate}] "Recent post..." (1200) - recent topic\n`
);
const result = pruneContentHistory(stateWithOld, 90);
assert.notEqual(result, null);
assert.equal(result.pruned, 1);
assert.ok(!result.content.includes(oldDate));
assert.ok(result.content.includes(recentDate));
});
test('preserves entries within 90 days', () => {
const today = new Date();
const recent = new Date(today);
recent.setDate(recent.getDate() - 30);
const recentDate = recent.toISOString().slice(0, 10);
const stateWithRecent = SAMPLE_STATE.replace(
'## Recent Posts\n\n',
`## Recent Posts\n\n- [${recentDate}] "Recent post..." (1200) - topic\n`
);
const result = pruneContentHistory(stateWithRecent, 90);
assert.equal(result, null); // nothing to prune
});
test('returns null when no entries exist', () => {
const emptyRecent = SAMPLE_STATE.replace(
/## Recent Posts\n\n[\s\S]*?(?=## Session Notes)/,
'## Recent Posts\n\n'
);
const result = pruneContentHistory(emptyRecent, 90);
assert.equal(result, null);
});
test('handles custom maxAgeDays', () => {
const today = new Date();
const old = new Date(today);
old.setDate(old.getDate() - 40);
const oldDate = old.toISOString().slice(0, 10);
const stateWithOld = SAMPLE_STATE.replace(
'## Recent Posts\n\n',
`## Recent Posts\n\n- [${oldDate}] "Somewhat old..." (1000) - topic\n`
);
const result = pruneContentHistory(stateWithOld, 30);
assert.notEqual(result, null);
assert.equal(result.pruned, 1);
});
});
describe('updateFollowerCount', () => {
test('updates follower_count', () => {
const result = updateFollowerCount(SAMPLE_STATE, {
count: 920,
month: '2026-04'
});
assert.notEqual(result, null);
assert.match(result.content, /^follower_count: 920$/m);
});
test('recalculates growth_rate_needed', () => {
const result = updateFollowerCount(SAMPLE_STATE, {
count: 920,
month: '2026-04'
});
assert.notEqual(result, null);
const match = result.content.match(/^growth_rate_needed: (\d+)$/m);
assert.ok(match, 'growth_rate_needed should be present');
const rate = parseInt(match[1], 10);
assert.ok(rate > 0, 'growth_rate_needed should be positive');
});
test('appends to Milestone Log section', () => {
const result = updateFollowerCount(SAMPLE_STATE, {
count: 920,
month: '2026-04'
});
assert.notEqual(result, null);
assert.ok(result.content.includes('[2026-04] 920 (+70)'));
});
});

View file

@ -0,0 +1,102 @@
import { describe, test } from 'node:test';
import assert from 'node:assert/strict';
import { applyWeekRollover } from '../week-rollover.mjs';
const SAMPLE_STATE = `---
last_post_date: "2026-04-05"
first_post_date: "2026-01-15"
last_post_topic: "AI strategy"
posts_this_week: 3
weekly_goal: 3
current_streak: 5
longest_streak: 12
current_week: "2026-W14"
last_import_date: "2026-04-01"
follower_count: 850
follower_target: 10000
target_date: "2026-12-31"
---
## Recent Posts
- 2026-04-05: AI strategy post
`;
describe('applyWeekRollover', () => {
test('resets posts_this_week to 0 on week change', () => {
const result = applyWeekRollover(SAMPLE_STATE, '2026-W14', '2026-W15');
assert.notEqual(result, null);
assert.match(result.content, /^posts_this_week: 0$/m);
});
test('updates current_week to new week', () => {
const result = applyWeekRollover(SAMPLE_STATE, '2026-W14', '2026-W15');
assert.notEqual(result, null);
assert.match(result.content, /^current_week: "2026-W15"$/m);
});
test('returns descriptive message on rollover', () => {
const result = applyWeekRollover(SAMPLE_STATE, '2026-W14', '2026-W15');
assert.notEqual(result, null);
assert.ok(result.message.includes('2026-W15'));
assert.ok(result.message.includes('2026-W14'));
});
test('returns null when week matches (no change needed)', () => {
const result = applyWeekRollover(SAMPLE_STATE, '2026-W14', '2026-W14');
assert.equal(result, null);
});
test('preserves all other YAML fields unchanged', () => {
const result = applyWeekRollover(SAMPLE_STATE, '2026-W14', '2026-W15');
assert.notEqual(result, null);
assert.match(result.content, /^last_post_date: "2026-04-05"$/m);
assert.match(result.content, /^current_streak: 5$/m);
assert.match(result.content, /^weekly_goal: 3$/m);
assert.match(result.content, /^follower_count: 850$/m);
});
test('preserves markdown body after frontmatter', () => {
const result = applyWeekRollover(SAMPLE_STATE, '2026-W14', '2026-W15');
assert.notEqual(result, null);
assert.ok(result.content.includes('## Recent Posts'));
assert.ok(result.content.includes('AI strategy post'));
});
test('initializes current_week when empty without resetting posts', () => {
const stateWithEmptyWeek = SAMPLE_STATE.replace(
'current_week: "2026-W14"',
'current_week: ""'
);
const result = applyWeekRollover(stateWithEmptyWeek, '', '2026-W15');
assert.notEqual(result, null);
assert.match(result.content, /^current_week: "2026-W15"$/m);
// posts_this_week should NOT be reset (user may have manually tracked)
assert.match(result.content, /^posts_this_week: 3$/m);
});
test('returns null when actualWeek is empty', () => {
const result = applyWeekRollover(SAMPLE_STATE, '2026-W14', '');
assert.equal(result, null);
});
test('returns null when actualWeek is null/undefined', () => {
assert.equal(applyWeekRollover(SAMPLE_STATE, '2026-W14', null), null);
assert.equal(applyWeekRollover(SAMPLE_STATE, '2026-W14', undefined), null);
});
test('handles year boundary rollover (W52 → W01)', () => {
const yearEndState = SAMPLE_STATE.replace('2026-W14', '2025-W52');
const result = applyWeekRollover(yearEndState, '2025-W52', '2026-W01');
assert.notEqual(result, null);
assert.match(result.content, /^posts_this_week: 0$/m);
assert.match(result.content, /^current_week: "2026-W01"$/m);
});
test('handles posts_this_week already at 0', () => {
const zeroPostsState = SAMPLE_STATE.replace('posts_this_week: 3', 'posts_this_week: 0');
const result = applyWeekRollover(zeroPostsState, '2026-W14', '2026-W15');
assert.notEqual(result, null);
assert.match(result.content, /^posts_this_week: 0$/m);
assert.match(result.content, /^current_week: "2026-W15"$/m);
});
});