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:
parent
9df3de795c
commit
b6bb61246b
196 changed files with 164 additions and 138 deletions
|
|
@ -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');
|
||||
});
|
||||
});
|
||||
|
|
@ -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/);
|
||||
});
|
||||
});
|
||||
|
|
@ -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)'));
|
||||
});
|
||||
});
|
||||
|
|
@ -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);
|
||||
});
|
||||
});
|
||||
102
plugins/linkedin-studio/hooks/scripts/clipboard-helper.mjs
Normal file
102
plugins/linkedin-studio/hooks/scripts/clipboard-helper.mjs
Normal 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;
|
||||
}
|
||||
});
|
||||
}
|
||||
90
plugins/linkedin-studio/hooks/scripts/compile-hooks.py
Executable file
90
plugins/linkedin-studio/hooks/scripts/compile-hooks.py
Executable 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()
|
||||
70
plugins/linkedin-studio/hooks/scripts/content-gatekeeper.mjs
Normal file
70
plugins/linkedin-studio/hooks/scripts/content-gatekeeper.mjs
Normal 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);
|
||||
231
plugins/linkedin-studio/hooks/scripts/ical-generator.mjs
Normal file
231
plugins/linkedin-studio/hooks/scripts/ical-generator.mjs
Normal 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)`);
|
||||
}
|
||||
|
|
@ -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;
|
||||
}
|
||||
120
plugins/linkedin-studio/hooks/scripts/personalization-score.mjs
Normal file
120
plugins/linkedin-studio/hooks/scripts/personalization-score.mjs
Normal 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`);
|
||||
}
|
||||
112
plugins/linkedin-studio/hooks/scripts/posting-reminder.mjs
Normal file
112
plugins/linkedin-studio/hooks/scripts/posting-reminder.mjs
Normal 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('{}');
|
||||
}
|
||||
29
plugins/linkedin-studio/hooks/scripts/pre-compact.mjs
Normal file
29
plugins/linkedin-studio/hooks/scripts/pre-compact.mjs
Normal 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);
|
||||
125
plugins/linkedin-studio/hooks/scripts/queue-manager.mjs
Normal file
125
plugins/linkedin-studio/hooks/scripts/queue-manager.mjs
Normal 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');
|
||||
}
|
||||
86
plugins/linkedin-studio/hooks/scripts/quick-import.mjs
Normal file
86
plugins/linkedin-studio/hooks/scripts/quick-import.mjs
Normal 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);
|
||||
433
plugins/linkedin-studio/hooks/scripts/session-start.mjs
Normal file
433
plugins/linkedin-studio/hooks/scripts/session-start.mjs
Normal 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));
|
||||
253
plugins/linkedin-studio/hooks/scripts/state-updater.mjs
Normal file
253
plugins/linkedin-studio/hooks/scripts/state-updater.mjs
Normal 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');
|
||||
}
|
||||
}
|
||||
90
plugins/linkedin-studio/hooks/scripts/stop-reminder.mjs
Normal file
90
plugins/linkedin-studio/hooks/scripts/stop-reminder.mjs
Normal 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);
|
||||
151
plugins/linkedin-studio/hooks/scripts/user-prompt-context.mjs
Normal file
151
plugins/linkedin-studio/hooks/scripts/user-prompt-context.mjs
Normal 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);
|
||||
49
plugins/linkedin-studio/hooks/scripts/week-rollover.mjs
Normal file
49
plugins/linkedin-studio/hooks/scripts/week-rollover.mjs
Normal 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}).`
|
||||
};
|
||||
}
|
||||
Loading…
Add table
Add a link
Reference in a new issue