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

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

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

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

View file

@ -0,0 +1,447 @@
import { parseLinkedInCSV } from "./parsers/csv-parser.js";
import {
getAnalyticsRoot,
ensureDirectories,
saveBatch,
loadAllPosts,
} from "./utils/storage.js";
import { detectAlerts } from "./utils/alerts.js";
import { mean, standardDeviation } from "./utils/stats.js";
import { generateWeeklyReport, getCurrentISOWeek } from "./reports/weekly.js";
import { generateHeatmap } from "./reports/heatmap.js";
import { generateMonthlyReport } from "./reports/monthly.js";
import { join } from "node:path";
import { existsSync } from "node:fs";
import type { PostMetrics } from "./models/types.js";
const args = process.argv.slice(2);
const command = args[0];
function parseOption(args: string[], flag: string): string | undefined {
const idx = args.indexOf(flag);
return idx !== -1 && idx + 1 < args.length ? args[idx + 1] : undefined;
}
function printUsage() {
console.log(`
LinkedIn Analytics CLI
Usage:
node build/cli.js import <filename> Import a CSV export
node build/cli.js report [--week W] Generate weekly report
node build/cli.js report --month YYYY-MM Generate monthly report with MoM comparison
node build/cli.js trends [--period P] [--metric M] Show trends and alerts
node build/cli.js heatmap Day-of-week performance matrix
Options:
--week W ISO week (e.g., 2026-W05), defaults to current week
--period P Time period: "week" | "month" | "quarter" | "all" (default: "month")
--metric M Metric to analyze: "impressions" | "reactions" | "comments" | "shares" | "clicks" | "engagementRate" (default: "impressions")
Examples:
node build/cli.js import linkedin-export-2026-01-20.csv
node build/cli.js report --week 2026-W04
node build/cli.js trends --period quarter --metric engagementRate
`);
}
async function handleImport(root: string, args: string[]) {
const filename = args[1];
if (!filename) {
console.error("Error: Missing filename argument");
console.error("Usage: node build/cli.js import <filename>");
process.exit(1);
}
const fullPath = join(root, "exports", filename);
if (!existsSync(fullPath)) {
console.error(`Error: File not found: ${fullPath}`);
console.error(`\nMake sure the CSV file is placed in: ${join(root, "exports")}`);
process.exit(1);
}
console.log(`Importing ${filename}...`);
try {
const batch = parseLinkedInCSV(fullPath, filename);
const savedFilename = saveBatch(root, batch);
console.log("\nImport successful!");
console.log("─────────────────────────────────────");
console.log(`Posts imported: ${batch.postCount}`);
console.log(`Date range: ${batch.dateRange.from} to ${batch.dateRange.to}`);
console.log(`Batch ID: ${batch.batchId}`);
console.log(`Saved to: posts/${savedFilename}`);
// Run alert detection on imported posts
const alerts = detectAlerts(batch.posts, "impressions");
if (alerts.length > 0) {
console.log("\nImmediate alerts detected:");
console.log("─────────────────────────────────────");
for (const alert of alerts.slice(0, 5)) {
const icon = alert.severity === "critical" ? "🔴" : alert.severity === "warning" ? "⚠️" : "";
console.log(`${icon} [${alert.severity.toUpperCase()}] ${alert.message}`);
}
if (alerts.length > 5) {
console.log(`\n... and ${alerts.length - 5} more alerts`);
}
} else {
console.log("\nNo anomalies detected in imported data.");
}
} catch (err) {
console.error(`Error parsing CSV: ${err instanceof Error ? err.message : String(err)}`);
process.exit(1);
}
}
async function handleReport(root: string, args: string[]) {
const monthOption = parseOption(args, "--month");
if (monthOption) {
return handleMonthlyReport(root, monthOption);
}
const weekOption = parseOption(args, "--week");
const week = weekOption || getCurrentISOWeek();
console.log(`Generating weekly report for ${week}...`);
try {
const report = generateWeeklyReport(root, week);
console.log("\nWeekly Report");
console.log("═════════════════════════════════════");
console.log(`Week: ${report.week}`);
console.log(`Generated at: ${new Date(report.generatedAt).toLocaleString()}`);
console.log();
console.log("Summary");
console.log("─────────────────────────────────────");
console.log(`Total posts: ${report.summary.totalPosts}`);
console.log(`Total impressions: ${report.summary.totalImpressions.toLocaleString()}`);
console.log(`Total reactions: ${report.summary.totalReactions.toLocaleString()}`);
console.log(`Total comments: ${report.summary.totalComments.toLocaleString()}`);
console.log(`Total shares: ${report.summary.totalShares.toLocaleString()}`);
console.log(`Total clicks: ${report.summary.totalClicks.toLocaleString()}`);
console.log(`Avg engagement: ${report.summary.avgEngagementRate.toFixed(2)}%`);
console.log(`Avg impressions: ${Math.round(report.summary.avgImpressionsPerPost).toLocaleString()} per post`);
console.log();
if (report.topPerformers.length > 0) {
console.log("Top Performers");
console.log("─────────────────────────────────────");
for (const post of report.topPerformers.slice(0, 5)) {
const title = post.title.length > 50 ? post.title.substring(0, 47) + "..." : post.title;
console.log(`${title}`);
console.log(` ${post.metrics.impressions.toLocaleString()} impressions | ${post.metrics.engagementRate.toFixed(2)}% engagement | ${post.publishedDate}`);
}
console.log();
}
if (report.underperformers.length > 0) {
console.log("Underperformers");
console.log("─────────────────────────────────────");
for (const post of report.underperformers.slice(0, 3)) {
const title = post.title.length > 50 ? post.title.substring(0, 47) + "..." : post.title;
console.log(`${title}`);
console.log(` ${post.metrics.impressions.toLocaleString()} impressions | ${post.metrics.engagementRate.toFixed(2)}% engagement | ${post.publishedDate}`);
}
console.log();
}
console.log("Trends");
console.log("─────────────────────────────────────");
console.log(`Impressions trend: ${report.trends.impressionsTrend.toUpperCase()} (${report.trends.percentChange.impressions > 0 ? "+" : ""}${report.trends.percentChange.impressions.toFixed(1)}%)`);
console.log(`Engagement trend: ${report.trends.engagementTrend.toUpperCase()} (${report.trends.percentChange.engagement > 0 ? "+" : ""}${report.trends.percentChange.engagement.toFixed(1)}%)`);
console.log(`Compared to: ${report.trends.comparedTo}`);
console.log();
if (report.alerts.length > 0) {
console.log("Alerts");
console.log("─────────────────────────────────────");
for (const alert of report.alerts) {
const icon = alert.severity === "critical" ? "🔴" : alert.severity === "warning" ? "⚠️" : "";
console.log(`${icon} [${alert.severity.toUpperCase()}] ${alert.message}`);
}
console.log();
}
console.log(`Report saved to: weekly-reports/${week}.json`);
} catch (err) {
console.error(`Error generating report: ${err instanceof Error ? err.message : String(err)}`);
process.exit(1);
}
}
/**
* Type guard to check if a string is a valid PostMetrics key
*/
function isPostMetric(value: string): value is keyof PostMetrics {
const validMetrics: (keyof PostMetrics)[] = [
"impressions",
"reactions",
"comments",
"shares",
"clicks",
"engagementRate",
];
return validMetrics.includes(value as keyof PostMetrics);
}
async function handleTrends(root: string, args: string[]) {
const periodOption = parseOption(args, "--period") || "month";
const metricOption = parseOption(args, "--metric") || "impressions";
const validPeriods = ["week", "month", "quarter", "all"];
if (!validPeriods.includes(periodOption)) {
console.error(`Error: Invalid period "${periodOption}". Must be one of: ${validPeriods.join(", ")}`);
process.exit(1);
}
if (!isPostMetric(metricOption)) {
const validMetrics: (keyof PostMetrics)[] = [
"impressions",
"reactions",
"comments",
"shares",
"clicks",
"engagementRate",
];
console.error(`Error: Invalid metric "${metricOption}". Must be one of: ${validMetrics.join(", ")}`);
process.exit(1);
}
const period = periodOption as "week" | "month" | "quarter" | "all";
const metric = metricOption;
console.log(`Analyzing trends for ${metric} over ${period}...`);
try {
const allPosts = loadAllPosts(root);
if (allPosts.length === 0) {
console.error("Error: No posts found. Import some data first.");
process.exit(1);
}
// Calculate date range based on period
const now = new Date();
let fromDate = new Date(0); // Beginning of time for "all"
if (period === "week") {
fromDate = new Date(now.getTime() - 7 * 24 * 60 * 60 * 1000);
} else if (period === "month") {
fromDate = new Date(now.getTime() - 30 * 24 * 60 * 60 * 1000);
} else if (period === "quarter") {
fromDate = new Date(now.getTime() - 90 * 24 * 60 * 60 * 1000);
}
const fromDateStr = fromDate.toISOString().split("T")[0];
// Filter posts by period
const filteredPosts = allPosts.filter(
(post) => post.publishedDate >= fromDateStr
);
if (filteredPosts.length === 0) {
console.error(`Error: No posts found in the ${period} period.`);
process.exit(1);
}
// Calculate statistics
const values = filteredPosts.map((post) => post.metrics[metric]);
const avg = mean(values);
const stdDev = standardDeviation(values);
const min = Math.min(...values);
const max = Math.max(...values);
console.log("\nTrend Analysis");
console.log("═════════════════════════════════════");
console.log(`Metric: ${metric}`);
console.log(`Period: ${period}`);
console.log(`Posts analyzed: ${filteredPosts.length}`);
console.log(`Date range: ${filteredPosts[filteredPosts.length - 1].publishedDate} to ${filteredPosts[0].publishedDate}`);
console.log();
console.log("Statistics");
console.log("─────────────────────────────────────");
console.log(`Mean: ${avg.toFixed(2)}`);
console.log(`Std deviation: ${stdDev.toFixed(2)}`);
console.log(`Min: ${min.toFixed(2)}`);
console.log(`Max: ${max.toFixed(2)}`);
console.log();
// Generate alerts
const alerts = detectAlerts(filteredPosts, metric);
if (alerts.length > 0) {
console.log("Alerts");
console.log("─────────────────────────────────────");
for (const alert of alerts) {
const icon = alert.severity === "critical" ? "🔴" : alert.severity === "warning" ? "⚠️" : "";
console.log(`${icon} [${alert.severity.toUpperCase()}] ${alert.message}`);
}
} else {
console.log("No anomalies detected in this period.");
}
} catch (err) {
console.error(`Error analyzing trends: ${err instanceof Error ? err.message : String(err)}`);
process.exit(1);
}
}
async function handleMonthlyReport(root: string, month: string) {
console.log(`Generating monthly report for ${month}...`);
try {
const report = generateMonthlyReport(root, month);
console.log("\nMonthly Report");
console.log("═════════════════════════════════════");
console.log(`Month: ${report.month}`);
console.log(`Generated at: ${new Date(report.generatedAt).toLocaleString()}`);
console.log();
console.log("Summary");
console.log("─────────────────────────────────────");
const s = report.summary;
const fmtDelta = (val: number | null, suffix = "%") =>
val !== null ? ` (${val > 0 ? "+" : ""}${val}${suffix})` : "";
console.log(`Posts: ${s.totalPosts}${fmtDelta(report.trends.percentChange.postCount)}`);
console.log(`Impressions: ${s.totalImpressions.toLocaleString()}${fmtDelta(report.trends.percentChange.impressions)}`);
console.log(`Avg per post: ${s.avgImpressionsPerPost.toLocaleString()}`);
console.log(`Avg engagement: ${s.avgEngagementRate.toFixed(2)}%${fmtDelta(report.trends.percentChange.engagement)}`);
console.log(`Reactions: ${s.totalReactions.toLocaleString()}`);
console.log(`Comments: ${s.totalComments.toLocaleString()}`);
console.log(`Shares: ${s.totalShares.toLocaleString()}`);
console.log(`Clicks: ${s.totalClicks.toLocaleString()}`);
console.log();
if (report.byWeek.length > 0) {
console.log("Week Breakdown");
console.log("─────────────────────────────────────");
for (const w of report.byWeek) {
console.log(`${w.week}: ${w.postCount} posts | ${w.avgImpressions.toLocaleString()} avg impr | ${w.avgEngagementRate.toFixed(1)}% eng`);
}
console.log();
}
if (report.topPerformers.length > 0) {
console.log("Top Performers");
console.log("─────────────────────────────────────");
for (const post of report.topPerformers.slice(0, 5)) {
const title = post.title.length > 50 ? post.title.substring(0, 47) + "..." : post.title;
console.log(`${title}`);
console.log(` ${post.metrics.impressions.toLocaleString()} impressions | ${post.metrics.engagementRate.toFixed(2)}% eng | ${post.publishedDate}`);
}
console.log();
}
if (report.trends.comparedTo) {
console.log(`Compared to: ${report.trends.comparedTo}`);
} else {
console.log("No previous month data for comparison.");
}
console.log();
if (report.alerts.length > 0) {
console.log("Alerts");
console.log("─────────────────────────────────────");
for (const alert of report.alerts) {
const icon = alert.severity === "critical" ? "🔴" : alert.severity === "warning" ? "⚠️" : "";
console.log(`${icon} [${alert.severity.toUpperCase()}] ${alert.message}`);
}
console.log();
}
console.log(`Report saved to: monthly-reports/${month}.json`);
} catch (err) {
console.error(`Error generating monthly report: ${err instanceof Error ? err.message : String(err)}`);
process.exit(1);
}
}
async function handleHeatmap(root: string) {
console.log("Generating day-of-week heatmap...");
try {
const allPosts = loadAllPosts(root);
if (allPosts.length === 0) {
console.error("Error: No posts found. Import some data first.");
process.exit(1);
}
const report = generateHeatmap(allPosts);
console.log("\nDay-of-Week Performance Heatmap");
console.log("═════════════════════════════════════");
console.log(`Posts analyzed: ${report.postsAnalyzed}`);
console.log(`Date range: ${report.dateRange.from} to ${report.dateRange.to}`);
console.log();
// Print table header
const days = report.byDayOfWeek.map(d => d.dayName.slice(0, 3).padStart(7));
console.log(` ${days.join("")}`);
console.log(` ${"───────".repeat(7)}`);
// Posts row
const postCounts = report.byDayOfWeek.map(d => String(d.postCount).padStart(7));
console.log(`Posts: ${postCounts.join("")}`);
// Impressions row
const impressions = report.byDayOfWeek.map(d =>
d.postCount > 0 ? d.avgImpressions.toLocaleString().padStart(7) : " -"
);
console.log(`Impr: ${impressions.join("")}`);
// Engagement rate row
const engRates = report.byDayOfWeek.map(d =>
d.postCount > 0 ? `${d.avgEngagementRate.toFixed(1)}%`.padStart(7) : " -"
);
console.log(`Eng: ${engRates.join("")}`);
console.log();
console.log(`Best day for impressions: ${report.bestDayImpressions}`);
console.log(`Best day for engagement: ${report.bestDayEngagement}`);
console.log("\nNote: LinkedIn CSV exports do not include publish time.");
console.log("This heatmap shows day-of-week only.");
} catch (err) {
console.error(`Error generating heatmap: ${err instanceof Error ? err.message : String(err)}`);
process.exit(1);
}
}
async function main() {
const root = getAnalyticsRoot();
ensureDirectories(root);
switch (command) {
case "import":
await handleImport(root, args);
break;
case "report":
await handleReport(root, args);
break;
case "trends":
await handleTrends(root, args);
break;
case "heatmap":
await handleHeatmap(root);
break;
default:
printUsage();
process.exit(command ? 1 : 0);
}
}
main().catch((err) => {
console.error("Fatal error:", err instanceof Error ? err.message : String(err));
process.exit(1);
});