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);
});
});

View file

@ -0,0 +1,102 @@
#!/usr/bin/env node
// Cross-platform clipboard helper for linkedin-studio plugin
// Copies text to system clipboard using platform-native commands.
// Standalone: reads stdin and copies it. Import: export { copyToClipboard, clipboardAvailable }
import { execSync } from 'node:child_process';
import { dirname } from 'node:path';
import { fileURLToPath } from 'node:url';
const PLATFORM_COMMANDS = {
darwin: { copy: 'pbcopy', check: 'which pbcopy' },
win32: { copy: 'clip', check: 'where clip' },
linux: { copy: 'xclip -selection clipboard', check: 'which xclip' },
};
const LINUX_FALLBACK = { copy: 'xsel --clipboard --input', check: 'which xsel' };
/**
* Check if clipboard is available on this platform.
* @returns {{ available: boolean, platform: string }}
*/
export function clipboardAvailable() {
const platform = process.platform;
const commands = PLATFORM_COMMANDS[platform];
if (!commands) {
return { available: false, platform };
}
try {
execSync(commands.check, { stdio: 'ignore' });
return { available: true, platform };
} catch {
// Linux fallback: try xsel if xclip not found
if (platform === 'linux') {
try {
execSync(LINUX_FALLBACK.check, { stdio: 'ignore' });
return { available: true, platform };
} catch {
return { available: false, platform };
}
}
return { available: false, platform };
}
}
/**
* Copy text to the system clipboard.
* Never throws always returns a result object.
* @param {string} text - The text to copy
* @returns {{ success: boolean, platform: string }}
*/
export function copyToClipboard(text) {
const platform = process.platform;
if (typeof text !== 'string') {
return { success: false, platform };
}
const commands = PLATFORM_COMMANDS[platform];
if (!commands) {
return { success: false, platform };
}
// Determine which copy command to use
let copyCmd = commands.copy;
if (platform === 'linux') {
try {
execSync(commands.check, { stdio: 'ignore' });
} catch {
try {
execSync(LINUX_FALLBACK.check, { stdio: 'ignore' });
copyCmd = LINUX_FALLBACK.copy;
} catch {
return { success: false, platform };
}
}
}
try {
execSync(copyCmd, { input: text, stdio: ['pipe', 'ignore', 'ignore'] });
return { success: true, platform };
} catch {
return { success: false, platform };
}
}
// Standalone execution: read stdin and copy
if (process.argv[1] === fileURLToPath(import.meta.url)) {
let input = '';
process.stdin.setEncoding('utf-8');
process.stdin.on('data', (chunk) => { input += chunk; });
process.stdin.on('end', () => {
const result = copyToClipboard(input);
if (result.success) {
process.stdout.write('COPIED\n');
} else {
process.stdout.write(`FAILED:${result.platform}\n`);
process.exitCode = 1;
}
});
}

View file

@ -0,0 +1,90 @@
#!/usr/bin/env python3
"""Compile hooks.template.json + prompt .md files into hooks.json.
Usage:
python3 hooks/scripts/compile-hooks.py # Generate hooks.json
python3 hooks/scripts/compile-hooks.py --check # Verify hooks.json is up to date
"""
import json
import sys
from pathlib import Path
HOOKS_DIR = Path(__file__).resolve().parent.parent
TEMPLATE = HOOKS_DIR / "hooks.template.json"
OUTPUT = HOOKS_DIR / "hooks.json"
PROMPTS_DIR = HOOKS_DIR / "prompts"
def load_prompt(filename: str) -> str:
"""Load a prompt .md file and return its content as a string."""
path = PROMPTS_DIR / filename
if not path.exists():
print(f"ERROR: Prompt file not found: {path}", file=sys.stderr)
sys.exit(1)
content = path.read_text(encoding="utf-8")
if not content.strip():
print(f"ERROR: Prompt file is empty: {path}", file=sys.stderr)
sys.exit(1)
return content.rstrip("\n")
def resolve_prompts(obj):
"""Recursively walk JSON and replace prompt_file with inline prompt."""
if isinstance(obj, dict):
if "prompt_file" in obj:
if obj.get("type") != "prompt":
print(
f"ERROR: prompt_file used on non-prompt hook type: {obj.get('type')}",
file=sys.stderr,
)
sys.exit(1)
filename = obj.pop("prompt_file")
obj["prompt"] = load_prompt(filename)
return {k: resolve_prompts(v) for k, v in obj.items()}
if isinstance(obj, list):
return [resolve_prompts(item) for item in obj]
return obj
def compile_hooks() -> str:
"""Read template, resolve prompts, return JSON string."""
if not TEMPLATE.exists():
print(f"ERROR: Template not found: {TEMPLATE}", file=sys.stderr)
sys.exit(1)
template = json.loads(TEMPLATE.read_text(encoding="utf-8"))
resolved = resolve_prompts(template)
# Strip any top-level keys except "hooks" — Claude Code requires only "hooks"
invalid_keys = [k for k in resolved if k != "hooks"]
for k in invalid_keys:
print(f"WARNING: Stripping invalid top-level key '{k}' from output", file=sys.stderr)
del resolved[k]
return json.dumps(resolved, indent=2, ensure_ascii=False) + "\n"
def main():
check_mode = "--check" in sys.argv
compiled = compile_hooks()
if check_mode:
if not OUTPUT.exists():
print(f"ERROR: {OUTPUT} does not exist", file=sys.stderr)
sys.exit(1)
current = OUTPUT.read_text(encoding="utf-8")
if current == compiled:
print("OK: hooks.json is up to date")
sys.exit(0)
else:
print(
"DRIFT DETECTED: hooks.json does not match compiled output.\n"
"Run: python3 hooks/scripts/compile-hooks.py",
file=sys.stderr,
)
sys.exit(1)
OUTPUT.write_text(compiled, encoding="utf-8")
print(f"Compiled {OUTPUT.relative_to(HOOKS_DIR.parent)}")
if __name__ == "__main__":
main()

View file

@ -0,0 +1,70 @@
#!/usr/bin/env node
// content-gatekeeper.mjs
// Unified PreToolUse/PostToolUse gatekeeper for linkedin-studio plugin
//
// Replaces 4 nearly identical bash scripts:
// pre-content-quality-gate.sh, pre-voice-guardian.sh,
// pre-topic-rotation-gate.sh, post-creation-check.sh
//
// Usage:
// node content-gatekeeper.mjs <prompt-filename> [--no-session-marker]
//
// Arguments:
// prompt-filename - Prompt file in hooks/prompts/ (e.g. content-quality-gate.md)
// --no-session-marker - Skip creating session-active marker (for PostToolUse)
//
// Exit codes:
// 0 - Always allow (injects systemMessage or passes through)
import { readFileSync, writeFileSync, mkdirSync, existsSync } from 'node:fs';
import { join, dirname } from 'node:path';
import { fileURLToPath } from 'node:url';
import { isLinkedInContent } from './linkedin-content-filter.mjs';
const __dirname = dirname(fileURLToPath(import.meta.url));
const pluginRoot = join(__dirname, '..', '..');
const promptFile = process.argv[2];
const noSessionMarker = process.argv.includes('--no-session-marker');
if (!promptFile) {
process.stdout.write('{}');
process.exit(0);
}
// Read and parse stdin JSON
let input;
try {
input = JSON.parse(readFileSync(0, 'utf-8'));
} catch {
process.stdout.write('{}');
process.exit(0);
}
// Extract file_path from tool_input
const toolInput = input.tool_input ?? {};
const filePath = toolInput.file_path ?? toolInput.filePath ?? '';
// Check if this is LinkedIn content
if (!isLinkedInContent(filePath)) {
process.stdout.write('{}');
process.exit(0);
}
// Mark session as having LinkedIn content activity
if (!noSessionMarker) {
const sessionDir = '/tmp/linkedin-hooks';
mkdirSync(sessionDir, { recursive: true });
writeFileSync(join(sessionDir, 'session-active'), '');
}
// Load and return prompt
const promptPath = join(pluginRoot, 'hooks', 'prompts', promptFile);
if (!existsSync(promptPath)) {
process.stdout.write('{}');
process.exit(0);
}
const promptContent = readFileSync(promptPath, 'utf-8');
process.stdout.write(JSON.stringify({ systemMessage: promptContent }));
process.exit(0);

View file

@ -0,0 +1,231 @@
#!/usr/bin/env node
// RFC 5545 iCal generator for linkedin-studio plugin
// Import: import { generateIcal, generateIcalFromQueue, writeIcalFile } from './ical-generator.mjs';
// Standalone: node ical-generator.mjs --from-queue --output path/to/schedule.ics
import { writeFileSync, readFileSync } from 'node:fs';
import { dirname, join } from 'node:path';
import { fileURLToPath } from 'node:url';
const __dirname = dirname(fileURLToPath(import.meta.url));
const CRLF = '\r\n';
/**
* Escape text values per RFC 5545 Section 3.3.11.
* Backslashes first (to avoid double-escaping), then semicolons, commas, newlines.
*/
function escapeText(str) {
if (!str) return '';
return str
.replace(/\\/g, '\\\\')
.replace(/;/g, '\;')
.replace(/,/g, '\\,')
.replace(/\n/g, '\\n');
}
/**
* Fold a content line per RFC 5545 Section 3.1.
* Lines MUST NOT be longer than 75 octets. Long lines are folded by
* inserting a CRLF followed by a single whitespace character (space).
*/
function foldLine(line) {
const maxOctets = 75;
if (Buffer.byteLength(line, 'utf-8') <= maxOctets) return line;
const parts = [];
let remaining = line;
let isFirst = true;
while (Buffer.byteLength(remaining, 'utf-8') > maxOctets) {
// Find the split point: max octets for first line, max-1 for continuations (leading space)
const limit = isFirst ? maxOctets : maxOctets - 1;
let splitAt = 0;
let octetCount = 0;
for (let i = 0; i < remaining.length; i++) {
const charOctets = Buffer.byteLength(remaining[i], 'utf-8');
if (octetCount + charOctets > limit) break;
octetCount += charOctets;
splitAt = i + 1;
}
parts.push((isFirst ? '' : ' ') + remaining.slice(0, splitAt));
remaining = remaining.slice(splitAt);
isFirst = false;
}
if (remaining.length > 0) {
parts.push((isFirst ? '' : ' ') + remaining);
}
return parts.join(CRLF);
}
/**
* Format a Date as iCal UTC timestamp: YYYYMMDDTHHmmssZ
*/
function formatUtcTimestamp(date) {
const d = date || new Date();
const pad = (n) => String(n).padStart(2, '0');
return `${d.getUTCFullYear()}${pad(d.getUTCMonth() + 1)}${pad(d.getUTCDate())}T${pad(d.getUTCHours())}${pad(d.getUTCMinutes())}${pad(d.getUTCSeconds())}Z`;
}
/**
* Format date + time as iCal local datetime: YYYYMMDDTHHMMSS
*/
function formatLocalDatetime(dateStr, timeStr) {
const [y, m, d] = dateStr.split('-');
const [h, min] = (timeStr || '09:00').split(':');
return `${y}${m}${d}T${h}${min}00`;
}
/**
* Add minutes to a time string (HH:MM), returns new time as HHMMSS for iCal.
* Handles day overflow simply by capping at 23:59.
*/
function addMinutes(dateStr, timeStr, minutes) {
const [y, m, d] = dateStr.split('-').map(Number);
const [h, min] = (timeStr || '09:00').split(':').map(Number);
const totalMin = h * 60 + min + minutes;
const newH = Math.min(Math.floor(totalMin / 60), 23);
const newMin = totalMin % 60;
const pad = (n) => String(n).padStart(2, '0');
return `${pad(y)}${pad(m)}${pad(d)}T${pad(newH)}${pad(newMin)}00`;
}
/**
* Generate a minimal VTIMEZONE component.
* Full Olson TZ database support is out of scope; we provide the structural
* component so calendar apps recognize the TZID reference.
*/
function generateVtimezone(timezone) {
const lines = [
'BEGIN:VTIMEZONE',
`TZID:${timezone}`,
'BEGIN:STANDARD',
`DTSTART:19701025T030000`,
'RRULE:FREQ=YEARLY;BYDAY=-1SU;BYMONTH=10',
`TZOFFSETFROM:+0200`,
`TZOFFSETTO:+0100`,
`TZNAME:CET`,
'END:STANDARD',
'BEGIN:DAYLIGHT',
`DTSTART:19700329T020000`,
'RRULE:FREQ=YEARLY;BYDAY=-1SU;BYMONTH=3',
`TZOFFSETFROM:+0100`,
`TZOFFSETTO:+0200`,
`TZNAME:CEST`,
'END:DAYLIGHT',
'END:VTIMEZONE',
];
return lines;
}
/**
* Generate RFC 5545 compliant iCal string from event objects.
*
* @param {Array<{id, title, description, date, time, duration}>} events
* @param {Object} [options]
* @param {string} [options.timezone='Europe/Oslo'] - TZID for DTSTART/DTEND
* @returns {string} Valid .ics file content with CRLF line endings
*/
export function generateIcal(events, options = {}) {
const tz = options.timezone || 'Europe/Oslo';
const now = formatUtcTimestamp(new Date());
const lines = [
'BEGIN:VCALENDAR',
'VERSION:2.0',
'PRODID:-//linkedin-studio//EN',
'CALSCALE:GREGORIAN',
'METHOD:PUBLISH',
];
// Add VTIMEZONE if we have events
if (events.length > 0) {
lines.push(...generateVtimezone(tz));
}
for (const event of events) {
const duration = event.duration || 30;
const dtstart = formatLocalDatetime(event.date, event.time);
const dtend = addMinutes(event.date, event.time, duration);
lines.push(
'BEGIN:VEVENT',
`UID:${event.id}@linkedin-studio`,
`DTSTAMP:${now}`,
`DTSTART;TZID=${tz}:${dtstart}`,
`DTEND;TZID=${tz}:${dtend}`,
`SUMMARY:${escapeText(event.title)}`,
`DESCRIPTION:${escapeText(event.description || '')}`,
'BEGIN:VALARM',
'TRIGGER:-PT15M',
'ACTION:DISPLAY',
`DESCRIPTION:Reminder: ${escapeText(event.title)}`,
'END:VALARM',
'END:VEVENT',
);
}
lines.push('END:VCALENDAR');
// Apply line folding and join with CRLF
return lines.map(foldLine).join(CRLF) + CRLF;
}
/**
* Transform queue entries (from queue-manager.mjs) into event format.
*
* @param {Array<{id, draft_path, scheduled_date, scheduled_time, pillar, format, hook_preview}>} queueEntries
* @returns {Array<{id, title, description, date, time, duration}>}
*/
export function generateIcalFromQueue(queueEntries) {
return queueEntries.map(entry => ({
id: entry.id,
title: `LinkedIn: ${entry.hook_preview || 'Scheduled post'}`,
description: `Pillar: ${entry.pillar || '?'} | Format: ${entry.format || '?'} | Draft: ${entry.draft_path || '?'}`,
date: entry.scheduled_date,
time: entry.scheduled_time || '09:00',
duration: 30,
}));
}
/**
* Write .ics file to disk.
*
* @param {string} outputPath - Path to write the .ics file
* @param {Array} events - Event objects (from generateIcalFromQueue or direct)
* @param {Object} [options] - Options passed to generateIcal
*/
export function writeIcalFile(outputPath, events, options) {
const ical = generateIcal(events, options);
writeFileSync(outputPath, ical, 'utf-8');
return outputPath;
}
// Standalone CLI mode
if (process.argv[1] && process.argv[1].endsWith('ical-generator.mjs')) {
const args = process.argv.slice(2);
const fromQueue = args.includes('--from-queue');
const outputIdx = args.indexOf('--output');
const outputPath = outputIdx >= 0 ? args[outputIdx + 1] : null;
if (!fromQueue || !outputPath) {
console.log('Usage: node ical-generator.mjs --from-queue --output path/to/schedule.ics');
process.exit(1);
}
// Dynamic import to avoid circular dep issues
const { queueUpcoming } = await import('./queue-manager.mjs');
const upcoming = queueUpcoming(14);
if (upcoming.length === 0) {
console.log('No upcoming scheduled posts in queue.');
process.exit(0);
}
const events = generateIcalFromQueue(upcoming);
writeIcalFile(outputPath, events);
console.log(`Calendar file: ${outputPath} (${events.length} events)`);
}

View file

@ -0,0 +1,40 @@
#!/usr/bin/env node
// Shared module: determines if a file path is LinkedIn content
// Import: import { isLinkedInContent } from './linkedin-content-filter.mjs';
// Returns true for content, false for non-content
import { basename, extname } from 'node:path';
export function isLinkedInContent(filePath) {
if (!filePath) return false;
const base = basename(filePath);
const ext = extname(base).slice(1); // remove leading dot
// NEGATIVE: code/config extensions
if (['sh', 'py', 'js', 'mjs', 'ts', 'jsx', 'tsx', 'json', 'yaml', 'yml', 'toml', 'css', 'html'].includes(ext)) {
return false;
}
// NEGATIVE: template files
if (base.includes('.template')) return false;
// NEGATIVE: known non-content filenames
const nonContent = ['.local.md', 'CLAUDE.md', 'README.md', 'CHANGELOG.md', 'REMEMBER.md', 'BACKLOG.md', 'DEVELOPMENT-LOG.md'];
if (nonContent.some(n => base.endsWith(n) || base === n)) return false;
// NEGATIVE: infrastructure paths
const infraDirs = ['hooks', 'scripts', 'config', 'commands', 'agents', 'skills', 'references', 'docs', '.claude', '.claude-plugin', 'node_modules'];
const normalized = filePath.replace(/\\/g, '/');
for (const dir of infraDirs) {
if (normalized.startsWith(dir + '/') || normalized.includes('/' + dir + '/')) return false;
}
// POSITIVE: explicit LinkedIn content paths only
if (normalized.startsWith('assets/drafts/') || normalized.includes('/assets/drafts/')) return true;
if (normalized.includes('/linkedin-posts/')) return true;
if (normalized.includes('/linkedin-studio/assets/')) return true;
// DEFAULT: everything else is NOT LinkedIn content
return false;
}

View file

@ -0,0 +1,120 @@
#!/usr/bin/env node
// Personalization score calculator for linkedin-studio plugin
// Checks 8 asset categories for real user data vs placeholder templates
// Standalone: outputs SCORE:N|M/8 assets personalized
// Import: export function calculateScore(pluginRoot) => { score, personalized, categories }
import { readFileSync, existsSync, readdirSync } from 'node:fs';
import { join, basename, dirname } from 'node:path';
import { fileURLToPath } from 'node:url';
const __dirname = dirname(fileURLToPath(import.meta.url));
export function calculateScore(pluginRoot) {
let score = 0;
let personalized = 0;
const categories = 8;
// --- 1. Voice samples (25 points) ---
const voiceFile = join(pluginRoot, 'assets', 'voice-samples', 'authentic-voice-samples.md');
if (existsSync(voiceFile)) {
const content = readFileSync(voiceFile, 'utf-8');
const lineCount = content.split('\n').length;
if (lineCount > 50 && !content.includes('[Your Name]')) {
score += 25;
personalized += 1;
}
}
// --- 2. User profile (20 points) ---
const profileFile = join(pluginRoot, 'config', 'user-profile.local.md');
if (existsSync(profileFile)) {
const content = readFileSync(profileFile, 'utf-8');
const placeholderCount = (content.match(/\[Your /g) || []).length;
if (placeholderCount < 3) {
score += 20;
personalized += 1;
}
}
// --- 3. Case studies (15 points) ---
const caseDir = join(pluginRoot, 'assets', 'case-studies');
if (existsSync(caseDir)) {
let realCases = 0;
try {
for (const f of readdirSync(caseDir)) {
if (!f.endsWith('.md')) continue;
if (f === 'case-study-template.md') continue;
realCases++;
}
} catch { /* ignore */ }
if (realCases >= 2) { score += 15; personalized += 1; }
else if (realCases >= 1) { score += 8; }
}
// --- 4. Frameworks (10 points) ---
const fwDir = join(pluginRoot, 'assets', 'frameworks');
if (existsSync(fwDir)) {
let realFw = 0;
try {
for (const f of readdirSync(fwDir)) {
if (!f.endsWith('.md')) continue;
if (f === 'framework-template.md') continue;
realFw++;
}
} catch { /* ignore */ }
if (realFw >= 2) { score += 10; personalized += 1; }
else if (realFw >= 1) { score += 5; }
}
// --- 5. High-engagement posts (10 points) ---
const postsFile = join(pluginRoot, 'assets', 'examples', 'high-engagement-posts.md');
if (existsSync(postsFile)) {
const content = readFileSync(postsFile, 'utf-8');
const postCount = (content.match(/^## Post [0-9]/gm) || []).length;
if (postCount >= 3) { score += 10; personalized += 1; }
else if (postCount >= 1) { score += 4; }
}
// --- 6. Demographics (8 points) ---
const demoFile = join(pluginRoot, 'assets', 'audience-insights', 'demographics.md');
if (existsSync(demoFile)) {
const content = readFileSync(demoFile, 'utf-8');
const placeholderCount = (content.match(/\[Industry name\]|\[Function\]|\[Country\]|\[X\]%/g) || []).length;
if (placeholderCount < 5) {
score += 8;
personalized += 1;
}
}
// --- 7. Engagement patterns (7 points) ---
const patternsFile = join(pluginRoot, 'assets', 'audience-insights', 'engagement-patterns.md');
if (existsSync(patternsFile)) {
const content = readFileSync(patternsFile, 'utf-8');
const placeholderCount = (content.match(/\[Day\]|\[Time\]|\[Topic\]|\[Format\]|\[Hook type\]/g) || []).length;
if (placeholderCount < 5) {
score += 7;
personalized += 1;
}
}
// --- 8. Post templates (5 points) ---
const templatesFile = join(pluginRoot, 'assets', 'templates', 'my-post-templates.md');
if (existsSync(templatesFile)) {
const content = readFileSync(templatesFile, 'utf-8');
const unfilled = (content.match(/\[Name - e\.g\./g) || []).length;
const totalTemplates = (content.match(/^## Template [0-9]/gm) || []).length;
const filled = totalTemplates - unfilled;
if (filled >= 2) { score += 5; personalized += 1; }
else if (filled >= 1) { score += 2; }
}
return { score, personalized, categories };
}
// Standalone execution (guarded to prevent stdout contamination on import)
if (process.argv[1] === fileURLToPath(import.meta.url)) {
const pluginRoot = join(__dirname, '..', '..');
const { score, personalized, categories } = calculateScore(pluginRoot);
process.stdout.write(`SCORE:${score}|${personalized}/${categories} assets personalized\n`);
}

View file

@ -0,0 +1,112 @@
#!/usr/bin/env node
// Notification hook for linkedin-studio plugin
// Fires on idle_prompt to show posting reminders. Rate-limited: max once per 30 min.
import { readFileSync, existsSync, statSync, writeFileSync, mkdirSync } from 'node:fs';
import { join, dirname } from 'node:path';
import { fileURLToPath } from 'node:url';
import { queueToday, queueOverdue } from './queue-manager.mjs';
const __dirname = dirname(fileURLToPath(import.meta.url));
const PLUGIN_ROOT = join(__dirname, '..', '..');
const HOME = process.env.HOME || process.env.USERPROFILE || '';
const STATE_FILE = join(HOME, '.claude', 'linkedin-studio.local.md');
const SESSION_DIR = '/tmp/linkedin-hooks';
const COOLDOWN_FILE = join(SESSION_DIR, 'last-notification');
const COOLDOWN_SECONDS = 1800;
function extractYaml(content, key) {
const re = new RegExp(`^${key}: *"?([^"\\n]*)"?`, 'm');
const m = content.match(re);
return m ? m[1].trim() : '';
}
function daysSince(dateStr) {
if (!dateStr || dateStr === 'null') return null;
const epoch = new Date(dateStr).getTime();
if (isNaN(epoch)) return null;
return Math.floor((Date.now() - epoch) / 86400000);
}
// Read stdin
let input;
try {
input = JSON.parse(readFileSync(0, 'utf-8'));
} catch {
process.exit(0);
}
if ((input.notification_type || '') !== 'idle_prompt') process.exit(0);
// Rate limiting
if (existsSync(COOLDOWN_FILE)) {
const age = (Date.now() - statSync(COOLDOWN_FILE).mtime.getTime()) / 1000;
if (age < COOLDOWN_SECONDS) process.exit(0);
}
if (!existsSync(STATE_FILE)) process.exit(0);
const stateContent = readFileSync(STATE_FILE, 'utf-8');
const lastPostDate = extractYaml(stateContent, 'last_post_date');
const postsThisWeek = parseInt(extractYaml(stateContent, 'posts_this_week') || '0', 10);
const weeklyGoal = parseInt(extractYaml(stateContent, 'weekly_goal') || '3', 10);
const currentStreak = parseInt(extractYaml(stateContent, 'current_streak') || '0', 10);
const lastImportDate = extractYaml(stateContent, 'last_import_date');
const followerCount = parseInt(extractYaml(stateContent, 'follower_count') || '0', 10);
const followerTarget = parseInt(extractYaml(stateContent, 'follower_target') || '10000', 10);
const reminders = [];
// Days since last post
const dsp = daysSince(lastPostDate);
if (dsp !== null) {
if (dsp >= 3) reminders.push(`No LinkedIn post in ${dsp} days. Posting gaps >5 days reduce reach by 15-25%. Consider running /linkedin:quick or /linkedin:pipeline.`);
if (dsp >= 2 && currentStreak > 3) reminders.push(`Your ${currentStreak}-day posting streak is at risk! Last post was ${dsp} days ago. Post today to keep momentum.`);
}
// Weekly goal
const remaining = weeklyGoal - postsThisWeek;
const dow = new Date().getDay() || 7; // 1=Mon, 7=Sun
if (remaining > 0) {
if (dow >= 4 && remaining >= 2) reminders.push(`${remaining} posts remaining to hit your weekly goal of ${weeklyGoal}. It's already late in the week — consider /linkedin:batch to catch up.`);
if (dow >= 5 && remaining >= 1) reminders.push(`Weekly goal: ${postsThisWeek}/${weeklyGoal} posts. ${remaining} to go before the week ends.`);
}
// Import staleness
const dsi = daysSince(lastImportDate);
if (dsi !== null) {
if (dsi >= 14) reminders.push(`Analytics data is ${dsi} days stale. Run /linkedin:import to update your performance data.`);
else if (dsi >= 7) reminders.push(`Have you imported this week's LinkedIn data? Last import was ${dsi} days ago. Run /linkedin:import.`);
} else {
reminders.push('No LinkedIn analytics imported yet. Run /linkedin:import to start tracking performance.');
}
// Milestone
if (followerCount > 0 && followerTarget > 0) {
const pct = Math.floor(followerCount * 100 / followerTarget);
reminders.push(`10K milestone: ${followerCount}/${followerTarget} followers (${pct}% complete).`);
}
// Queue reminders
try {
const todayEntries = queueToday();
const overdueEntries = queueOverdue();
if (todayEntries.length > 0) reminders.push(`You have ${todayEntries.length} post(s) scheduled for today. Run /linkedin:calendar after posting to mark as published.`);
if (overdueEntries.length > 0) reminders.push(`${overdueEntries.length} overdue post(s) in your queue. Run /linkedin:calendar to mark as posted or reschedule.`);
} catch { /* ignore */ }
// Peak posting time
const hour = new Date().getHours();
if (dow >= 2 && dow <= 4) {
if (hour >= 7 && hour <= 8) reminders.push('Peak posting window approaching: 8-9 AM CET on Tue-Thu is optimal for LinkedIn engagement.');
if (hour >= 11 && hour <= 12) reminders.push('Secondary peak posting window: 12-1 PM CET on Tue-Thu is good for LinkedIn engagement.');
}
if (reminders.length > 0) {
mkdirSync(SESSION_DIR, { recursive: true });
writeFileSync(COOLDOWN_FILE, '');
const output = 'LinkedIn Posting Reminders:\n' + reminders.map(r => `- ${r}`).join('\n');
process.stdout.write(JSON.stringify({ systemMessage: output }));
} else {
process.stdout.write('{}');
}

View file

@ -0,0 +1,29 @@
#!/usr/bin/env node
// pre-compact.mjs
// PreCompact hook for linkedin-studio plugin
// Reminds Claude to preserve critical LinkedIn session context before compaction
//
// Exit codes:
// 0 - Always allow (informational hook)
const context = [
'Before compacting context, preserve these critical LinkedIn session details:',
'- Current post draft (full text if in progress)',
'- Chosen angle and format',
'- User feedback and iteration direction',
'- Quality check results',
'- State file values (streak, weekly count, last post date)',
'- Any planned topics or next steps',
'Ensure these survive the context compaction.',
].join('\n');
const output = {
continue: true,
hookSpecificOutput: {
hookEventName: 'PreCompact',
additionalContext: context,
},
};
process.stdout.write(JSON.stringify(output));
process.exit(0);

View file

@ -0,0 +1,125 @@
#!/usr/bin/env node
// Queue management library for linkedin-studio plugin
// Import: import { queueRead, queueToday, ... } from './queue-manager.mjs';
// Replaces python3 dependency with native Node.js JSON/Date operations
import { readFileSync, writeFileSync, existsSync, mkdirSync } from 'node:fs';
import { join, dirname } from 'node:path';
import { fileURLToPath } from 'node:url';
const __dirname = dirname(fileURLToPath(import.meta.url));
const PLUGIN_ROOT = process.env.PLUGIN_ROOT || join(__dirname, '..', '..');
const QUEUE_FILE = join(PLUGIN_ROOT, 'assets', 'drafts', 'queue.json');
function ensureQueue() {
if (!existsSync(QUEUE_FILE)) {
mkdirSync(dirname(QUEUE_FILE), { recursive: true });
writeFileSync(QUEUE_FILE, JSON.stringify({ version: 1, queue: [] }, null, 2));
}
}
function readQueue() {
ensureQueue();
try {
const data = JSON.parse(readFileSync(QUEUE_FILE, 'utf-8'));
return data.queue || [];
} catch {
return [];
}
}
function writeQueue(queue) {
ensureQueue();
const data = JSON.parse(readFileSync(QUEUE_FILE, 'utf-8'));
data.queue = queue;
writeFileSync(QUEUE_FILE, JSON.stringify(data, null, 2));
}
function todayISO() {
return new Date().toISOString().slice(0, 10);
}
// Read all queue entries
export function queueRead() {
return readQueue();
}
// Get entries scheduled for today (status=scheduled only)
export function queueToday() {
const today = todayISO();
return readQueue().filter(e => e.scheduled_date === today && e.status === 'scheduled');
}
// Get entries for next N days (status=scheduled only)
export function queueUpcoming(days = 7) {
const today = todayISO();
const end = new Date();
end.setDate(end.getDate() + days);
const endStr = end.toISOString().slice(0, 10);
return readQueue()
.filter(e => e.status === 'scheduled' && e.scheduled_date >= today && e.scheduled_date <= endStr)
.sort((a, b) => (a.scheduled_date + (a.scheduled_time || '')).localeCompare(b.scheduled_date + (b.scheduled_time || '')));
}
// Add entry to queue
export function queueAdd(id, draftPath, schedDate, schedTime, pillar, format, hookPreview, charCount) {
const queue = readQueue().filter(e => e.id !== id);
queue.push({
id,
draft_path: draftPath,
scheduled_date: schedDate,
scheduled_time: schedTime,
pillar,
format,
hook_preview: hookPreview,
character_count: charCount,
status: 'scheduled',
created_at: todayISO()
});
writeQueue(queue);
return `Added: ${id}`;
}
// Update status of a queue entry
export function queueUpdateStatus(id, newStatus) {
const queue = readQueue();
const entry = queue.find(e => e.id === id);
if (entry) {
entry.status = newStatus;
writeQueue(queue);
return `Updated: ${id} -> ${newStatus}`;
}
return `Not found: ${id}`;
}
// Get overdue entries (past scheduled_date, still "scheduled")
export function queueOverdue() {
const today = todayISO();
return readQueue()
.filter(e => e.status === 'scheduled' && (e.scheduled_date || '9999') < today)
.sort((a, b) => (a.scheduled_date || '').localeCompare(b.scheduled_date || ''));
}
// Count entries by status
export function queueCount() {
const counts = {};
for (const e of readQueue()) {
const s = e.status || 'unknown';
counts[s] = (counts[s] || 0) + 1;
}
return counts;
}
// Format queue entries as readable summary
export function queueFormatSummary(entries) {
if (!entries || entries.length === 0) return '(none)';
return entries.map(e => {
const d = e.scheduled_date || '?';
const t = e.scheduled_time || '?';
const hook = (e.hook_preview || '').slice(0, 50);
const pillar = e.pillar || '?';
const fmt = e.format || '?';
const status = e.status || '?';
return ` ${d} ${t} | ${hook}... | ${pillar} (${fmt}) [${status}]`;
}).join('\n');
}

View file

@ -0,0 +1,86 @@
#!/usr/bin/env node
// Quick-import helper for linkedin-studio plugin
// Opens LinkedIn analytics in browser, watches ~/Downloads for new CSV files
import { existsSync, mkdirSync, readdirSync, statSync, copyFileSync } from 'node:fs';
import { join, dirname } from 'node:path';
import { fileURLToPath } from 'node:url';
import { exec } from 'node:child_process';
const __dirname = dirname(fileURLToPath(import.meta.url));
const PLUGIN_ROOT = join(__dirname, '..', '..');
const HOME = process.env.HOME || process.env.USERPROFILE || '';
const EXPORTS_DIR = join(PLUGIN_ROOT, 'assets', 'analytics', 'exports');
const DOWNLOADS_DIR = join(HOME, 'Downloads');
const POLL_INTERVAL = 3000;
const MAX_WAIT = 300000; // 5 minutes
mkdirSync(EXPORTS_DIR, { recursive: true });
// Snapshot existing CSV files
function getCsvFiles() {
try {
return readdirSync(DOWNLOADS_DIR)
.filter(f => f.endsWith('.csv'))
.sort();
} catch { return []; }
}
// Cross-platform browser open
function openUrl(url) {
const cmd = process.platform === 'darwin' ? 'open'
: process.platform === 'win32' ? 'start ""'
: 'xdg-open';
exec(`${cmd} "${url}"`, () => {});
}
const beforeFiles = new Set(getCsvFiles());
console.log('Opening LinkedIn Analytics in your browser...');
openUrl('https://www.linkedin.com/analytics/creator/content/');
console.log('\nInstructions:');
console.log(' 1. Click \'Export\' (top right) in LinkedIn Analytics');
console.log(' 2. LinkedIn will download a CSV to ~/Downloads');
console.log(' 3. This script will detect it automatically\n');
console.log('Watching ~/Downloads for new CSV files (max 5 minutes)...\n');
let elapsed = 0;
const timer = setInterval(() => {
elapsed += POLL_INTERVAL;
const currentFiles = getCsvFiles();
const newFiles = currentFiles.filter(f => !beforeFiles.has(f));
for (const filename of newFiles) {
const filePath = join(DOWNLOADS_DIR, filename);
try {
const age = (Date.now() - statSync(filePath).mtime.getTime()) / 1000;
if (/linkedin|analytics|content|export/i.test(filename) || age < 60) {
console.log(`Detected: ${filename}`);
copyFileSync(filePath, join(EXPORTS_DIR, filename));
console.log(`Copied to: ${EXPORTS_DIR}/${filename}\n`);
console.log('File is ready for import. Run:');
console.log(' /linkedin:import\n');
console.log('Or import directly with:');
console.log(` ANALYTICS_ROOT="${PLUGIN_ROOT}/assets/analytics" node --import tsx "${PLUGIN_ROOT}/scripts/analytics/src/cli.ts" import "${filename}"`);
clearInterval(timer);
process.exit(0);
}
} catch { /* ignore */ }
}
if (elapsed % 15000 === 0) {
const remaining = Math.floor((MAX_WAIT - elapsed) / 60000);
console.log(` Still waiting... (${remaining}m remaining)`);
}
if (elapsed >= MAX_WAIT) {
console.log('\nTimed out after 5 minutes. No new CSV detected.\n');
console.log('You can manually copy the file:');
console.log(` mv ~/Downloads/<linkedin-csv-file>.csv ${EXPORTS_DIR}/`);
console.log(' /linkedin:import');
clearInterval(timer);
process.exit(1);
}
}, POLL_INTERVAL);

View file

@ -0,0 +1,433 @@
#!/usr/bin/env node
// SessionStart hook for linkedin-studio plugin
// Reads persistent state and session context, outputs JSON with additionalContext
import { readFileSync, existsSync, copyFileSync, writeFileSync, mkdirSync } from 'node:fs';
import { join, dirname } from 'node:path';
import { fileURLToPath } from 'node:url';
import { calculateScore } from './personalization-score.mjs';
import { queueToday, queueOverdue, queueUpcoming } from './queue-manager.mjs';
import { applyWeekRollover } from './week-rollover.mjs';
const __dirname = dirname(fileURLToPath(import.meta.url));
const PLUGIN_ROOT = join(__dirname, '..', '..');
const HOME = process.env.HOME || process.env.USERPROFILE || '';
const STATE_FILE = join(HOME, '.claude', 'linkedin-studio.local.md');
function extractYaml(content, key) {
const re = new RegExp(`^${key}: *"?([^"\\n]*)"?`, 'm');
const m = content.match(re);
return m ? m[1].trim() : '';
}
function daysSince(dateStr) {
if (!dateStr || dateStr === 'null') return null;
const epoch = new Date(dateStr).getTime();
if (isNaN(epoch)) return null;
return Math.floor((Date.now() - epoch) / 86400000);
}
function isoWeek() {
const d = new Date();
const dayNum = d.getUTCDay() || 7;
d.setUTCDate(d.getUTCDate() + 4 - dayNum);
const yearStart = new Date(Date.UTC(d.getUTCFullYear(), 0, 1));
const weekNo = Math.ceil(((d - yearStart) / 86400000 + 1) / 7);
return `${d.getUTCFullYear()}-W${String(weekNo).padStart(2, '0')}`;
}
function dayOfWeek() {
const d = new Date().getDay();
return d === 0 ? 7 : d; // 1=Mon, 7=Sun (ISO)
}
let context = '';
if (existsSync(STATE_FILE)) {
const stateContent = readFileSync(STATE_FILE, 'utf-8');
// Extract YAML frontmatter values
const lastPostDate = extractYaml(stateContent, 'last_post_date');
const lastPostTopic = extractYaml(stateContent, 'last_post_topic');
const postsThisWeek = parseInt(extractYaml(stateContent, 'posts_this_week') || '0', 10);
const weeklyGoal = parseInt(extractYaml(stateContent, 'weekly_goal') || '3', 10);
const currentStreak = parseInt(extractYaml(stateContent, 'current_streak') || '0', 10);
const currentWeek = extractYaml(stateContent, 'current_week');
const nextPlannedTopic = extractYaml(stateContent, 'next_planned_topic');
const lastImportDate = extractYaml(stateContent, 'last_import_date');
const firstPostDate = extractYaml(stateContent, 'first_post_date');
const followerCount = parseInt(extractYaml(stateContent, 'follower_count') || '0', 10);
const followerTarget = parseInt(extractYaml(stateContent, 'follower_target') || '10000', 10);
const targetDate = extractYaml(stateContent, 'target_date');
const growthRateNeeded = parseInt(extractYaml(stateContent, 'growth_rate_needed') || '0', 10);
const projected10kDate = extractYaml(stateContent, 'projected_10k_date');
// Calculate days since last post
const daysSincePost = daysSince(lastPostDate);
const daysSinceImport = daysSince(lastImportDate);
const daysSinceFirstPost = daysSince(firstPostDate);
// New creator boost window
let boostWindowStatus = '';
let boostDaysRemaining = 0;
if (daysSinceFirstPost !== null) {
if (daysSinceFirstPost <= 90) {
boostWindowStatus = 'ACTIVE';
boostDaysRemaining = 90 - daysSinceFirstPost;
} else if (daysSinceFirstPost <= 120) {
boostWindowStatus = 'TRANSITION';
} else {
boostWindowStatus = 'ESTABLISHED';
}
}
// Milestone metrics
let milestonePhase = '';
let milestoneStatus = '';
let followersNeeded = 0;
let monthsRemaining = 0;
let ratePerMonth = 0;
let phaseTransitionAlert = '';
if (followerCount > 0) {
if (followerCount < 1000) milestonePhase = 'Foundation';
else if (followerCount < 3000) milestonePhase = 'Validation';
else if (followerCount < 6000) milestonePhase = 'Acceleration';
else if (followerCount < 10000) milestonePhase = 'Authority';
else milestonePhase = 'Scale';
// Phase transition proximity
const thresholds = [
{ limit: 1000, label: 'Validation phase (1,000)' },
{ limit: 3000, label: 'Acceleration phase (3,000)' },
{ limit: 6000, label: 'Authority phase (6,000)' },
{ limit: 10000, label: 'Scale phase (10,000)' }
];
for (const { limit, label } of thresholds) {
if (followerCount < limit && followerCount >= limit * 0.9) {
phaseTransitionAlert = `${limit - followerCount} followers to ${label}`;
break;
}
}
followersNeeded = Math.max(0, followerTarget - followerCount);
// Calculate months remaining to target_date
if (targetDate && targetDate !== 'null' && targetDate !== '""') {
const [tYear, tMonth] = targetDate.split('-').map(Number);
const now = new Date();
monthsRemaining = (tYear - now.getFullYear()) * 12 + (tMonth - (now.getMonth() + 1));
if (monthsRemaining < 1) monthsRemaining = 1;
ratePerMonth = Math.floor(followersNeeded / monthsRemaining);
}
// Schedule status
if (followerCount >= followerTarget) {
milestoneStatus = 'ACHIEVED';
} else if (growthRateNeeded > 0 && monthsRemaining > 0) {
if (ratePerMonth > growthRateNeeded * 2) milestoneStatus = 'SIGNIFICANTLY BEHIND';
else if (ratePerMonth > growthRateNeeded * 1.2) milestoneStatus = 'BEHIND';
else if (ratePerMonth < growthRateNeeded * 0.8) milestoneStatus = 'AHEAD';
else milestoneStatus = 'ON TRACK';
} else if (followerCount >= followerTarget) {
milestoneStatus = 'ACHIEVED';
} else {
milestoneStatus = 'TRACKING';
}
}
// Week rollover — auto-reset posts_this_week on week change
const actualWeek = isoWeek();
let weekResetNote = '';
try {
const rollover = applyWeekRollover(stateContent, currentWeek, actualWeek);
if (rollover) {
writeFileSync(STATE_FILE, rollover.content, 'utf-8');
weekResetNote = rollover.message;
}
} catch (err) {
weekResetNote = `Warning: Week rollover failed (${err.message}). Manual reset may be needed.`;
}
// Auto-prune Recent Posts entries older than 90 days
try {
const currentState = readFileSync(STATE_FILE, 'utf-8');
const { pruneContentHistory } = await import('./state-updater.mjs');
const pruneResult = pruneContentHistory(currentState, 90);
if (pruneResult && pruneResult.pruned > 0) {
writeFileSync(STATE_FILE, pruneResult.content, 'utf-8');
weekResetNote += (weekResetNote ? ' ' : '') + `Auto-pruned ${pruneResult.pruned} posts older than 90 days from Recent Posts.`;
}
} catch {
// Non-critical: don't block session start on pruning failure
}
// Count published posts for progressive onboarding
const recentPostsSection = stateContent.match(/^## Recent Posts\n([\s\S]*?)(?=\n## [^R]|\n## $|$)/m);
let publishedPostCount = 0;
if (recentPostsSection) {
publishedPostCount = (recentPostsSection[1].match(/^\s*[-\[]/gm) || []).length;
}
// Build status line
let statusLine = `LinkedIn: ${postsThisWeek}/${weeklyGoal} posts this week | Streak: ${currentStreak} days`;
if (lastPostDate && lastPostDate !== 'null') {
statusLine += ` | Last: ${lastPostDate}`;
if (daysSincePost !== null) statusLine += ` (${daysSincePost} days ago)`;
}
if (lastImportDate && lastImportDate !== 'null' && daysSinceImport !== null) {
statusLine += ` | Import: ${daysSinceImport}d ago`;
} else {
statusLine += ' | Import: never';
}
if (milestonePhase && followerCount > 0) {
statusLine += ` | ${followerCount}/${followerTarget} followers (${milestonePhase})`;
}
// Personalization score (only show after 3+ published posts — progressive onboarding)
let pScore = null;
try {
const { score } = calculateScore(PLUGIN_ROOT);
pScore = score;
if (publishedPostCount >= 3) {
statusLine += ` | Personalization: ${score}%`;
}
} catch { /* ignore */ }
// New creator window
if (boostWindowStatus === 'ACTIVE') {
statusLine += ` | NEW CREATOR: ${boostDaysRemaining}d left`;
}
// Load queue data
let queueTodayEntries = [];
let queueOverdueEntries = [];
let queueUpcomingCount = 0;
try {
queueTodayEntries = queueToday();
queueOverdueEntries = queueOverdue();
queueUpcomingCount = queueUpcoming(7).length;
} catch { /* ignore */ }
const queueTodayCount = queueTodayEntries.length;
const queueOverdueCount = queueOverdueEntries.length;
let queueTodayText = '';
if (queueTodayCount > 0) {
queueTodayText = queueTodayEntries.map(e => {
const t = e.scheduled_time || '?';
const hook = (e.hook_preview || '').slice(0, 50);
const pillar = e.pillar || '?';
const fmt = e.format || '?';
return ` ${t}: "${hook}..." — ${pillar} (${fmt})`;
}).join('\n');
}
let queueOverdueText = '';
if (queueOverdueCount > 0) {
queueOverdueText = queueOverdueEntries.map(e => {
const d = e.scheduled_date || '?';
const hook = (e.hook_preview || '').slice(0, 50);
const pillar = e.pillar || '?';
return ` ${d}: "${hook}..." — ${pillar}`;
}).join('\n');
}
// Build context output
context = 'LinkedIn Studio session context loaded.\\n\\n';
context += `## Status\\n\`\`\`\\n${statusLine}\\n\`\`\`\\n\\n`;
if (weekResetNote) context += `**${weekResetNote}**\\n\\n`;
if (nextPlannedTopic) context += `**Planned next topic:** ${nextPlannedTopic}\\n\\n`;
if (lastPostTopic) context += `**Last post topic:** ${lastPostTopic}\\n\\n`;
// Recent posts section
const recentMatch = stateContent.match(/^## Recent Posts\n([\s\S]*?)(?=\n## [^R]|\n## $|$)/m);
if (recentMatch) {
const recentPosts = recentMatch[1].split('\n').slice(0, 10).join('\n');
if (recentPosts.trim()) context += `## Recent Posts\\n${recentPosts.replace(/\n/g, '\\n')}\\n\\n`;
}
// Today's scheduled posts
if (queueTodayText) {
context += `## Today's Scheduled Posts\\n${queueTodayText.replace(/\n/g, '\\n')}\\nRun /linkedin:calendar after posting to mark as published.\\n\\n`;
}
// Overdue posts
if (queueOverdueText) {
context += `## OVERDUE Posts\\n${queueOverdueText.replace(/\n/g, '\\n')}\\nRun /linkedin:calendar to mark as posted or reschedule.\\n\\n`;
}
// Posting reminders
let reminders = '';
if (daysSincePost !== null) {
if (daysSincePost >= 3) {
reminders += `- No LinkedIn post in ${daysSincePost} days. Posting gaps >5 days reduce reach by 15-25%. Consider /linkedin:quick or /linkedin:pipeline.\\n`;
}
if (daysSincePost >= 2 && currentStreak > 3) {
reminders += `- Your ${currentStreak}-day posting streak is at risk! Post today to keep momentum.\\n`;
}
}
// First-post nudge
if ((!firstPostDate || firstPostDate === 'null') && postsThisWeek === 0) {
reminders += '- First post not yet created! Run /linkedin:first-post to publish your first LinkedIn post in under 10 minutes.\\n';
}
// Weekly goal check
const weekRemaining = weeklyGoal - postsThisWeek;
const dow = dayOfWeek();
if (weekRemaining > 0 && dow >= 4) {
reminders += `- ${weekRemaining} posts remaining to hit weekly goal of ${weeklyGoal}. It's late in the week.\\n`;
}
// Personalization score check (only after 3+ posts — progressive onboarding)
if (pScore !== null && pScore < 50 && publishedPostCount >= 3) {
reminders += `- Personalization score is ${pScore}%. Run /linkedin:setup to improve content quality with your real voice, case studies, and audience data.\\n`;
}
// Import staleness
if (daysSinceImport !== null) {
if (daysSinceImport >= 14) {
reminders += `- Analytics data is ${daysSinceImport} days stale. Strategy recommendations may be inaccurate. Run /linkedin:import.\\n`;
} else if (daysSinceImport >= 7) {
reminders += `- Last analytics import was ${daysSinceImport} days ago. Consider /linkedin:import for fresh data.\\n`;
}
} else if (!lastImportDate || lastImportDate === 'null') {
reminders += '- No analytics data imported yet. Run /linkedin:import to start tracking performance.\\n';
}
// Milestone reminders
if (milestonePhase && followerCount > 0) {
if (milestoneStatus === 'SIGNIFICANTLY BEHIND') {
reminders += `- 10K milestone: SIGNIFICANTLY BEHIND schedule. Need ~${ratePerMonth} followers/month (2x+ original rate). Run /linkedin:strategy for corrective adjustments — current approach needs a fundamental shift.\\n`;
} else if (milestoneStatus === 'BEHIND') {
reminders += `- 10K milestone: BEHIND schedule. Need ~${ratePerMonth} followers/month. Consider /linkedin:strategy for trajectory-based adjustments.\\n`;
} else if (milestoneStatus === 'AHEAD') {
reminders += '- 10K milestone: AHEAD of schedule. Consider raising target or shifting focus to monetization (/linkedin:monetize).\\n';
}
} else if (!followerCount || followerCount === 0) {
reminders += '- No follower count tracked yet. Update follower_count in state file to enable 10K milestone tracking.\\n';
}
// Phase transition proximity
if (phaseTransitionAlert) {
reminders += `- PHASE TRANSITION: ${phaseTransitionAlert}. Run /linkedin:strategy to prepare.\\n`;
}
// New creator advantage window
if (boostWindowStatus === 'ACTIVE') {
if (boostDaysRemaining < 14) {
reminders += `- NEW CREATOR WINDOW CLOSING: Only ${boostDaysRemaining} days left! Maximize posting frequency (4-5x/week) and engagement (15-20 comments/day) now.\\n`;
} else if (boostDaysRemaining < 30) {
reminders += `- New creator window: ${boostDaysRemaining} days remaining. Maintain high frequency (4-5x/week) to lock in algorithmic momentum.\\n`;
} else {
reminders += `- New creator advantage active (${boostDaysRemaining}d left). Higher posting frequency pays outsized returns during this window.\\n`;
}
} else if (boostWindowStatus === 'TRANSITION') {
reminders += `- New creator window ended ${daysSinceFirstPost} days ago. Transition to sustainable posting rhythm (3-4x/week) and optimize based on analytics.\\n`;
}
// Queue-related reminders
if (queueTodayCount > 0) {
reminders += `- You have ${queueTodayCount} post(s) scheduled for today. Run /linkedin:calendar after posting to mark as published.\\n`;
}
if (queueOverdueCount > 0) {
reminders += `- ${queueOverdueCount} overdue post(s) in queue. Run /linkedin:calendar to mark as posted or reschedule.\\n`;
}
if (reminders) context += `## Posting Reminders\\n${reminders}\\n`;
// 10K Milestone Tracker section
if (milestonePhase && followerCount > 0) {
context += '## 10K Milestone Tracker\\n';
context += `- Current: ${followerCount} followers (Phase: ${milestonePhase})\\n`;
if (monthsRemaining > 0 && followersNeeded > 0) {
context += `- Required rate: ~${ratePerMonth} followers/month to hit ${followerTarget} by ${targetDate}\\n`;
}
if (milestoneStatus) context += `- Status: ${milestoneStatus}\\n`;
if (projected10kDate && projected10kDate !== 'null' && projected10kDate !== '""') {
context += `- Projected: ${projected10kDate} (at current rate)\\n`;
}
if (phaseTransitionAlert) context += `- PHASE TRANSITION: ${phaseTransitionAlert}\\n`;
if (milestoneStatus === 'SIGNIFICANTLY BEHIND') {
context += '- Trajectory hint: Current approach needs fundamental adjustment. Run /linkedin:strategy for corrective plan.\\n';
} else if (milestoneStatus === 'BEHIND') {
context += '- Trajectory hint: Consider /linkedin:strategy for trajectory-based adjustments to close the gap.\\n';
} else if (milestoneStatus === 'AHEAD') {
context += '- Trajectory hint: Strong momentum. Consider raising target or shifting to monetization (/linkedin:monetize).\\n';
}
context += '\\n';
}
// New creator advantage window context
if (boostWindowStatus === 'ACTIVE') {
context += '## New Creator Advantage Window\\n';
context += `- Status: ACTIVE (day ${daysSinceFirstPost} of 90, ${boostDaysRemaining} days remaining)\\n`;
context += `- First post: ${firstPostDate}\\n`;
context += '- Recommended frequency: 4-5x/week (vs standard 3x)\\n';
context += '- Recommended engagement: 15-20 strategic comments/day\\n';
context += '- Priority: Save-worthy content (frameworks, checklists, templates)\\n\\n';
} else if (boostWindowStatus === 'TRANSITION') {
context += '## New Creator Advantage Window\\n';
context += `- Status: TRANSITION (day ${daysSinceFirstPost}, window closed at day 90)\\n`;
context += '- Shift to sustainable rhythm: 3-4x/week, optimize based on analytics data\\n\\n';
}
// Queue summary
if (queueUpcomingCount > 0) {
context += '## Queue Summary\\n';
context += `- Queued posts (next 7 days): ${queueUpcomingCount}\\n`;
if (queueTodayCount > 0) context += `- Today: ${queueTodayCount} post(s)\\n`;
if (queueOverdueCount > 0) context += `- Overdue: ${queueOverdueCount} post(s)\\n`;
context += '- Manage + publish: /linkedin:calendar\\n\\n';
}
context += `State file: ${STATE_FILE}\\n`;
} else {
// Auto-initialize state file from template
const templateFile = join(PLUGIN_ROOT, 'config', 'state-file.template.md');
if (existsSync(templateFile)) {
mkdirSync(dirname(STATE_FILE), { recursive: true });
copyFileSync(templateFile, STATE_FILE);
const actualWeek = isoWeek();
let content = readFileSync(STATE_FILE, 'utf-8');
content = content.replace(/^current_week: .*/m, `current_week: "${actualWeek}"`);
writeFileSync(STATE_FILE, content);
context = `LinkedIn state file auto-initialized from template at ${STATE_FILE}.\\n`;
context += `Current ISO week set to ${actualWeek}.\\n`;
context += 'Edit the file to set your expertise_areas and weekly_goal.\\n';
} else {
context = `No LinkedIn state file found at ${STATE_FILE} and template missing.\\n`;
context += `Expected template at: ${templateFile}\\n`;
}
}
// Read REMEMBER.md for user session context
const rememberFile = join(PLUGIN_ROOT, 'REMEMBER.md');
const rememberTemplate = join(PLUGIN_ROOT, 'config', 'REMEMBER.template.md');
if (!existsSync(rememberFile) && existsSync(rememberTemplate)) {
copyFileSync(rememberTemplate, rememberFile);
let rememberContent = readFileSync(rememberFile, 'utf-8');
const today = new Date().toISOString().slice(0, 10);
rememberContent = rememberContent.replace('[Auto-filled by session-start.sh]', today);
writeFileSync(rememberFile, rememberContent);
context += '\\n## Session State\\nREMEMBER.md auto-initialized from template. Update after your first session.\\n';
} else if (existsSync(rememberFile)) {
const rememberContent = readFileSync(rememberFile, 'utf-8');
const rememberSummary = rememberContent.split('\n').slice(0, 50).join('\n');
context += `\\n## Session Context (from REMEMBER.md)\\n${rememberSummary.replace(/\n/g, '\\n')}\\n`;
}
// Output JSON for Claude Code
const output = {
continue: true,
hookSpecificOutput: {
hookEventName: 'SessionStart',
additionalContext: context.replace(/\\n/g, '\n')
}
};
process.stdout.write(JSON.stringify(output));

View file

@ -0,0 +1,253 @@
// Deterministic state mutation functions for linkedin-studio plugin.
// Pure functions operate on string content (same pattern as week-rollover.mjs).
// I/O wrapper (writeState) handles file reads/writes (same pattern as queue-manager.mjs).
import { readFileSync, writeFileSync, renameSync } from 'node:fs';
import { join, dirname } from 'node:path';
import { fileURLToPath } from 'node:url';
import { applyWeekRollover } from './week-rollover.mjs';
const __dirname = dirname(fileURLToPath(import.meta.url));
const HOME = process.env.HOME || process.env.USERPROFILE || '';
const STATE_FILE = process.env.STATE_FILE || join(HOME, '.claude', 'linkedin-studio.local.md');
function replaceField(content, field, value) {
return content.replace(
new RegExp(`^${field}: .*`, 'm'),
`${field}: ${value}`
);
}
function isoWeekFromDate(dateStr) {
const d = new Date(dateStr + 'T12:00:00Z');
const dayNum = d.getUTCDay() || 7;
d.setUTCDate(d.getUTCDate() + 4 - dayNum);
const yearStart = new Date(Date.UTC(d.getUTCFullYear(), 0, 1));
const weekNo = Math.ceil(((d - yearStart) / 86400000 + 1) / 7);
return `${d.getUTCFullYear()}-W${String(weekNo).padStart(2, '0')}`;
}
function daysBetween(dateA, dateB) {
const a = new Date(dateA + 'T12:00:00Z').getTime();
const b = new Date(dateB + 'T12:00:00Z').getTime();
if (isNaN(a) || isNaN(b)) return null;
return Math.abs(Math.round((b - a) / 86400000));
}
function extractField(content, field) {
const re = new RegExp(`^${field}: *"?([^"\\n]*)"?`, 'm');
const m = content.match(re);
return m ? m[1].trim() : '';
}
/**
* Update post tracking fields deterministically.
* @param {string} stateContent - Full state file content
* @param {{ postDate: string, postTopic: string, hookText: string, charCount: number, format: string }} opts
* @returns {{ content: string, changes: string[] } | null}
*/
export function updatePostTracking(stateContent, { postDate, postTopic, hookText, charCount, format }) {
let content = stateContent;
const changes = [];
// 1. Update last_post_date
content = replaceField(content, 'last_post_date', `"${postDate}"`);
changes.push(`last_post_date → ${postDate}`);
// 2. Update last_post_topic
content = replaceField(content, 'last_post_topic', `"${postTopic}"`);
changes.push(`last_post_topic → ${postTopic}`);
// 3. Set first_post_date if null
const existingFirst = extractField(content, 'first_post_date');
if (!existingFirst || existingFirst === 'null') {
content = replaceField(content, 'first_post_date', `"${postDate}"`);
changes.push(`first_post_date → ${postDate} (first post!)`);
}
// 4. Week rollover — check if ISO week changed
const currentWeek = extractField(content, 'current_week');
const postWeek = isoWeekFromDate(postDate);
const rollover = applyWeekRollover(content, currentWeek, postWeek);
if (rollover) {
content = rollover.content;
changes.push(rollover.message);
}
// 5. Increment posts_this_week
const currentPosts = parseInt(extractField(content, 'posts_this_week') || '0', 10);
content = replaceField(content, 'posts_this_week', String(currentPosts + 1));
changes.push(`posts_this_week → ${currentPosts + 1}`);
// 6. Update streak
const lastPostDate = extractField(stateContent, 'last_post_date');
let currentStreak = parseInt(extractField(content, 'current_streak') || '0', 10);
if (lastPostDate && lastPostDate !== 'null') {
const gap = daysBetween(lastPostDate, postDate);
if (gap !== null && gap <= 2) {
currentStreak += 1;
changes.push(`current_streak → ${currentStreak} (gap: ${gap}d)`);
} else {
currentStreak = 1;
changes.push(`current_streak → 1 (gap: ${gap}d, reset)`);
}
} else {
currentStreak = 1;
changes.push('current_streak → 1 (first post)');
}
content = replaceField(content, 'current_streak', String(currentStreak));
// 7. Update longest_streak if exceeded
const longestStreak = parseInt(extractField(content, 'longest_streak') || '0', 10);
if (currentStreak > longestStreak) {
content = replaceField(content, 'longest_streak', String(currentStreak));
changes.push(`longest_streak → ${currentStreak}`);
}
// 8. Append to Recent Posts section
const hookPreview = hookText.length > 60 ? hookText.slice(0, 57) + '...' : hookText;
const entry = `- [${postDate}] "${hookPreview}" (${charCount}) - ${postTopic}`;
content = content.replace(
/^(## Recent Posts\n\n?)/m,
`$1${entry}\n`
);
changes.push(`Recent Posts += ${postDate} "${hookPreview.slice(0, 30)}..."`);
if (content === stateContent) return null;
return { content, changes };
}
/**
* Remove Recent Posts entries older than maxAgeDays.
* @param {string} stateContent - Full state file content
* @param {number} [maxAgeDays=90]
* @returns {{ content: string, pruned: number } | null}
*/
export function pruneContentHistory(stateContent, maxAgeDays = 90) {
const today = new Date();
const cutoff = new Date(today);
cutoff.setDate(cutoff.getDate() - maxAgeDays);
const cutoffStr = cutoff.toISOString().slice(0, 10);
// Find all Recent Posts entries
const entryPattern = /^- \[(\d{4}-\d{2}-\d{2})\] .+$/gm;
const recentSection = stateContent.match(/## Recent Posts\n\n?([\s\S]*?)(?=\n## [^R]|\n## $|$)/m);
if (!recentSection || !recentSection[1].trim()) return null;
const sectionContent = recentSection[1];
let pruned = 0;
const lines = sectionContent.split('\n');
const kept = [];
for (const line of lines) {
const dateMatch = line.match(/^- \[(\d{4}-\d{2}-\d{2})\]/);
if (dateMatch) {
if (dateMatch[1] < cutoffStr) {
pruned++;
continue;
}
}
kept.push(line);
}
if (pruned === 0) return null;
const newSection = kept.join('\n');
const content = stateContent.replace(recentSection[1], newSection);
return { content, pruned };
}
/**
* Update follower count and recalculate growth metrics.
* @param {string} stateContent - Full state file content
* @param {{ count: number, month: string }} opts
* @returns {{ content: string, changes: string[] } | null}
*/
export function updateFollowerCount(stateContent, { count, month }) {
let content = stateContent;
const changes = [];
const previousCount = parseInt(extractField(content, 'follower_count') || '0', 10);
const delta = count - previousCount;
// Update follower_count
content = replaceField(content, 'follower_count', String(count));
changes.push(`follower_count → ${count} (${delta >= 0 ? '+' : ''}${delta})`);
// Recalculate growth_rate_needed
const target = parseInt(extractField(content, 'follower_target') || '10000', 10);
const targetDate = extractField(content, 'target_date');
const remaining = target - count;
if (targetDate && targetDate !== 'null' && targetDate !== '""') {
const [tYear, tMonth] = targetDate.split('-').map(Number);
const [mYear, mMonth] = month.split('-').map(Number);
const monthsLeft = (tYear - mYear) * 12 + (tMonth - mMonth);
const effectiveMonths = Math.max(1, monthsLeft);
const rateNeeded = Math.ceil(remaining / effectiveMonths);
content = replaceField(content, 'growth_rate_needed', String(rateNeeded));
changes.push(`growth_rate_needed → ${rateNeeded}/month`);
}
// Append to Milestone Log section
const logEntry = `- [${month}] ${count} (${delta >= 0 ? '+' : ''}${delta})`;
content = content.replace(
/^(## Milestone Log\n)/m,
`$1${logEntry}\n`
);
changes.push(`Milestone Log += ${month}`);
if (content === stateContent) return null;
return { content, changes };
}
/**
* I/O wrapper: read state file, apply update function, write atomically.
* @param {function(string): {content: string}|null} updateFn - Pure update function
*/
export function writeState(updateFn) {
const content = readFileSync(STATE_FILE, 'utf-8');
const result = updateFn(content);
if (!result) {
console.log('No changes needed.');
return;
}
const tmpPath = STATE_FILE + '.tmp';
writeFileSync(tmpPath, result.content, 'utf-8');
renameSync(tmpPath, STATE_FILE);
if (result.changes) {
console.log('State updated:', result.changes.join(', '));
} else if (result.pruned !== undefined) {
console.log(`Pruned ${result.pruned} old entries.`);
}
}
// Standalone mode
if (import.meta.url === `file://${process.argv[1]}`) {
const args = process.argv.slice(2);
if (args.includes('--update-post')) {
const getArg = (flag) => { const i = args.indexOf(flag); return i >= 0 ? args[i + 1] : ''; };
writeState(content => updatePostTracking(content, {
postDate: getArg('--date') || new Date().toISOString().slice(0, 10),
postTopic: getArg('--topic') || 'unknown',
hookText: getArg('--hook') || '',
charCount: parseInt(getArg('--chars') || '0', 10),
format: getArg('--format') || 'post'
}));
} else if (args.includes('--prune')) {
const days = parseInt(args[args.indexOf('--prune') + 1] || '90', 10);
writeState(content => pruneContentHistory(content, days));
} else if (args.includes('--update-followers')) {
const getArg = (flag) => { const i = args.indexOf(flag); return i >= 0 ? args[i + 1] : ''; };
writeState(content => updateFollowerCount(content, {
count: parseInt(getArg('--count') || '0', 10),
month: getArg('--month') || new Date().toISOString().slice(0, 7)
}));
} else {
console.log('Usage:');
console.log(' node state-updater.mjs --update-post --date YYYY-MM-DD --topic "topic" --hook "Hook text" --chars 1500 --format post');
console.log(' node state-updater.mjs --prune [days]');
console.log(' node state-updater.mjs --update-followers --count 920 --month 2026-04');
}
}

View file

@ -0,0 +1,90 @@
#!/usr/bin/env node
// stop-reminder.mjs
// Stop hook for linkedin-studio plugin
//
// Only fires if LinkedIn content was worked on (session marker exists).
// First stop: blocks with reason (Claude processes reminders).
// Subsequent stops within 60s: allows (prevents infinite loop).
//
// Exit codes:
// 0 - Allow (pass through or second stop)
// 2 - Not used; uses {"decision": "block"} JSON instead
import { readFileSync, writeFileSync, existsSync, statSync, unlinkSync, mkdirSync } from 'node:fs';
import { join, dirname } from 'node:path';
import { fileURLToPath } from 'node:url';
const __dirname = dirname(fileURLToPath(import.meta.url));
const pluginRoot = join(__dirname, '..', '..');
const promptFile = join(pluginRoot, 'hooks', 'prompts', 'state-update-reminder.md');
const sessionDir = '/tmp/linkedin-hooks';
const sessionMarker = join(sessionDir, 'session-active');
const lockFile = join(sessionDir, 'stop-hook.lock');
function nowSeconds() {
return Date.now() / 1000;
}
function fileAgeSeconds(filePath) {
try {
return nowSeconds() - statSync(filePath).mtime.getTime() / 1000;
} catch {
return Infinity;
}
}
function safeUnlink(filePath) {
try { unlinkSync(filePath); } catch { /* ignore */ }
}
// Read stdin
let input;
try {
input = JSON.parse(readFileSync(0, 'utf-8'));
} catch {
input = {};
}
// Infinite loop prevention: if Claude is already continuing from a Stop hook
if (input.stop_hook_active === true) {
process.stdout.write('{}');
process.exit(0);
}
// No session marker = no LinkedIn work done
if (!existsSync(sessionMarker)) {
process.stdout.write('{}');
process.exit(0);
}
// Staleness check: ignore markers older than 12 hours (43200 seconds)
if (fileAgeSeconds(sessionMarker) > 43200) {
safeUnlink(sessionMarker);
process.stdout.write('{}');
process.exit(0);
}
// Infinite-loop prevention: lock file within 60 seconds = second stop
if (existsSync(lockFile)) {
if (fileAgeSeconds(lockFile) < 60) {
safeUnlink(lockFile);
safeUnlink(sessionMarker);
process.stdout.write('{}');
process.exit(0);
}
safeUnlink(lockFile);
}
// First stop: create lock and block with reminder prompt
mkdirSync(sessionDir, { recursive: true });
writeFileSync(lockFile, '');
if (!existsSync(promptFile)) {
process.stdout.write('{}');
process.exit(0);
}
const promptContent = readFileSync(promptFile, 'utf-8');
process.stdout.write(JSON.stringify({ decision: 'block', reason: promptContent }));
process.exit(0);

View file

@ -0,0 +1,151 @@
#!/usr/bin/env node
// user-prompt-context.mjs
// UserPromptSubmit hook for linkedin-studio plugin
//
// Two-tier keyword matching in user prompts:
// Tier 1: Strong signals (slash commands, explicit phrases)
// Tier 2: "linkedin" + intent word, excluding plugin dev phrases
//
// When matched, injects voice profile reference, recent posts,
// planned topic, weekly progress, and quality scorecard reminder.
//
// Exit codes:
// 0 - Always allow (informational hook)
import { readFileSync, existsSync } from 'node:fs';
import { join, dirname } from 'node:path';
import { fileURLToPath } from 'node:url';
const __dirname = dirname(fileURLToPath(import.meta.url));
const pluginRoot = join(__dirname, '..', '..');
const home = process.env.HOME || process.env.USERPROFILE || '';
const stateFile = join(home, '.claude', 'linkedin-studio.local.md');
// Read stdin JSON
let input;
try {
input = JSON.parse(readFileSync(0, 'utf-8'));
} catch {
process.stdout.write(JSON.stringify({ continue: true }));
process.exit(0);
}
const userPrompt = (input.query ?? input.content ?? input.prompt ?? '').toLowerCase();
if (!userPrompt) {
process.stdout.write(JSON.stringify({ continue: true }));
process.exit(0);
}
// === Two-tier keyword matching ===
let isLinkedin = false;
// Tier 1: Strong signals
const strongSignals = [
'/linkedin:post', '/linkedin:quick', '/linkedin:batch',
'/linkedin:pipeline', '/linkedin:calendar', '/linkedin:video',
'/linkedin:multiplatform', '/linkedin:react', '/linkedin:summarize',
'linkedin post', 'lag en post',
'skriv en post', 'write a post', 'quick post', 'create post',
'react to this', 'turn this article into',
];
for (const signal of strongSignals) {
if (userPrompt.includes(signal)) {
isLinkedin = true;
break;
}
}
// Tier 1.5: URL + intent — detect URLs with LinkedIn-relevant intent
if (!isLinkedin) {
const urlPattern = /https?:\/\/\S+/;
if (urlPattern.test(userPrompt)) {
const urlIntentWords = ['react', 'post', 'share', 'write', 'comment', 'turn', 'create', 'linkedin'];
for (const word of urlIntentWords) {
if (userPrompt.includes(word)) {
isLinkedin = true;
break;
}
}
}
}
// Tier 2: "linkedin" + intent word (excluding plugin dev phrases)
if (!isLinkedin && userPrompt.includes('linkedin')) {
const intentWords = [
'write', 'create', 'draft', 'publish', 'skriv', 'lag',
'post', 'innlegg', 'article', 'artikkel',
];
const devExclude = /(update|fix|change|modify|edit|refactor|debug|test).*(plugin|hook|script|command|agent|skill|config)/i;
for (const intent of intentWords) {
if (userPrompt.includes(intent)) {
if (!devExclude.test(userPrompt)) {
isLinkedin = true;
break;
}
}
}
}
if (!isLinkedin) {
process.stdout.write(JSON.stringify({ continue: true }));
process.exit(0);
}
// === Build context enrichment ===
let context = '**LinkedIn Context Enrichment (auto-injected):**\n\n';
// 1. Voice profile reference
const voiceFile = join(pluginRoot, 'assets', 'voice-samples', 'authentic-voice-samples.md');
if (existsSync(voiceFile)) {
context += '**Voice Profile:** Read `assets/voice-samples/authentic-voice-samples.md` for tone matching.\n\n';
}
// 2-4. State file data
if (existsSync(stateFile)) {
try {
const stateContent = readFileSync(stateFile, 'utf-8');
// Recent posts section
const recentMatch = stateContent.match(/^## Recent Posts\s*\n([\s\S]*?)(?=^## |$)/m);
if (recentMatch) {
const recentLines = recentMatch[1]
.split('\n')
.filter(l => l.trim() && !l.startsWith('<!--'))
.slice(0, 5);
if (recentLines.length > 0) {
context += `**Recent posts (avoid repetition):**\n${recentLines.join('\n')}\n\n`;
}
}
// Next planned topic from YAML frontmatter
const topicMatch = stateContent.match(/^next_planned_topic:\s*"?([^"\n]*)"?\s*$/m);
if (topicMatch && topicMatch[1].trim()) {
context += `**Planned next topic:** ${topicMatch[1].trim()}\n\n`;
}
// Weekly progress from YAML frontmatter
const postsMatch = stateContent.match(/^posts_this_week:\s*(\d+)/m);
const goalMatch = stateContent.match(/^weekly_goal:\s*(\d+)/m);
if (postsMatch && goalMatch) {
context += `**Weekly progress:** ${postsMatch[1]}/${goalMatch[1]} posts this week.\n\n`;
}
} catch {
// State file read error — skip enrichment
}
}
// 5.5 URL detection hint
const urlMatch = (input.query ?? input.content ?? input.prompt ?? '').match(/https?:\/\/\S+/);
if (urlMatch) {
context += '**URL detected:** Consider using /linkedin:react for this URL.\n\n';
}
// 5. Quality scorecard reminder
context += '**Remember:** Use `assets/checklists/quality-scorecard.md` before finalizing.\n';
process.stdout.write(JSON.stringify({ continue: true, systemMessage: context }));
process.exit(0);

View file

@ -0,0 +1,49 @@
// Pure function for week-rollover logic.
// Exported separately for testability.
/**
* Apply week rollover to state file content.
* Returns updated content string if rollover was applied, null otherwise.
*
* @param {string} stateContent - Full state file content (with YAML frontmatter)
* @param {string} currentWeek - Week value from state file (e.g. "2026-W14")
* @param {string} actualWeek - Computed current ISO week (e.g. "2026-W15")
* @returns {{ content: string, message: string } | null}
*/
export function applyWeekRollover(stateContent, currentWeek, actualWeek) {
if (!actualWeek) return null;
// Case 1: current_week is empty — initialize without resetting posts
if (!currentWeek) {
const updated = stateContent.replace(
/^current_week: .*/m,
`current_week: "${actualWeek}"`
);
if (updated === stateContent) return null;
return {
content: updated,
message: `Initialized current_week to ${actualWeek}.`
};
}
// Case 2: week matches — no action needed
if (currentWeek === actualWeek) return null;
// Case 3: week changed — reset posts_this_week and update current_week
let updated = stateContent;
updated = updated.replace(
/^posts_this_week: .*/m,
'posts_this_week: 0'
);
updated = updated.replace(
/^current_week: .*/m,
`current_week: "${actualWeek}"`
);
if (updated === stateContent) return null;
return {
content: updated,
message: `Auto-reset: posts_this_week → 0 for new week ${actualWeek} (was ${currentWeek}).`
};
}