diff --git a/plugins/linkedin-studio/commands/import.md b/plugins/linkedin-studio/commands/import.md index dcd3d8f..73c0034 100644 --- a/plugins/linkedin-studio/commands/import.md +++ b/plugins/linkedin-studio/commands/import.md @@ -105,10 +105,19 @@ Options: ## Step 4: Run Import +The import CLI runs under `tsx` and depends on `csv-parse`. Both live in the +**gitignored** `scripts/analytics/node_modules/`, so on a fresh clone they are +absent and the CLI would crash with `ERR_MODULE_NOT_FOUND`. Install them once +first (idempotent — a fast no-op when already present): + +```bash +cd "${CLAUDE_PLUGIN_ROOT}/scripts/analytics" && npm install --silent +``` + Once the user selects, run the import CLI: ```bash -ANALYTICS_ROOT="${CLAUDE_PLUGIN_ROOT}/assets/analytics" node --import tsx "${CLAUDE_PLUGIN_ROOT}/scripts/analytics/src/cli.ts" import +ANALYTICS_ROOT="${CLAUDE_PLUGIN_ROOT}/assets/analytics" "${CLAUDE_PLUGIN_ROOT}/scripts/analytics/node_modules/.bin/tsx" "${CLAUDE_PLUGIN_ROOT}/scripts/analytics/src/cli.ts" import ``` If importing multiple files, run the command for each file sequentially. @@ -225,8 +234,8 @@ Run /linkedin:report (period: 4w) 1. Read `expertise_areas` from `~/.claude/linkedin-studio.local.md` 2. Call `trends` for impressions and engagement_rate over the last 4 weeks: ```bash - ANALYTICS_ROOT="${CLAUDE_PLUGIN_ROOT}/assets/analytics" node --import tsx "${CLAUDE_PLUGIN_ROOT}/scripts/analytics/src/cli.ts" trends --period 4w --metric impressions - ANALYTICS_ROOT="${CLAUDE_PLUGIN_ROOT}/assets/analytics" node --import tsx "${CLAUDE_PLUGIN_ROOT}/scripts/analytics/src/cli.ts" trends --period 4w --metric engagement_rate + ANALYTICS_ROOT="${CLAUDE_PLUGIN_ROOT}/assets/analytics" "${CLAUDE_PLUGIN_ROOT}/scripts/analytics/node_modules/.bin/tsx" "${CLAUDE_PLUGIN_ROOT}/scripts/analytics/src/cli.ts" trends --period 4w --metric impressions + ANALYTICS_ROOT="${CLAUDE_PLUGIN_ROOT}/assets/analytics" "${CLAUDE_PLUGIN_ROOT}/scripts/analytics/node_modules/.bin/tsx" "${CLAUDE_PLUGIN_ROOT}/scripts/analytics/src/cli.ts" trends --period 4w --metric engagement_rate ``` 3. Produce the Content Pillar Performance, Format Performance, and Day-of-Week Performance tables, plus exactly 3 actionable recommendations diff --git a/plugins/linkedin-studio/commands/report.md b/plugins/linkedin-studio/commands/report.md index 1397988..af144ac 100644 --- a/plugins/linkedin-studio/commands/report.md +++ b/plugins/linkedin-studio/commands/report.md @@ -37,6 +37,22 @@ You need to import your LinkedIn analytics first: 1. Run `/linkedin:import` to import CSV data 2. Then come back to generate reports +## Step 1b: Ensure analytics CLI dependencies (first run) + +The analytics CLI runs under `tsx` and depends on `csv-parse`. Both live in +`scripts/analytics/node_modules/`, which is **gitignored** — so on a fresh clone +they are absent and the CLI calls below would crash with `ERR_MODULE_NOT_FOUND`. +Install them once (idempotent — a fast no-op when already present): + +```bash +cd "${CLAUDE_PLUGIN_ROOT}/scripts/analytics" && npm install --silent +``` + +The CLI calls below invoke the locally-installed `tsx` by its absolute +`node_modules/.bin/tsx` path (not bare `tsx`/`node --import tsx`), so they resolve +from whatever working directory the command runs in — but only after the install +above has created that binary. + ## Step 2: Choose Report Type **Ask the user** using AskUserQuestion: @@ -81,7 +97,7 @@ date +%Y-W%V If the user chose monthly: ```bash -ANALYTICS_ROOT="${CLAUDE_PLUGIN_ROOT}/assets/analytics" node --import tsx "${CLAUDE_PLUGIN_ROOT}/scripts/analytics/src/cli.ts" report --month +ANALYTICS_ROOT="${CLAUDE_PLUGIN_ROOT}/assets/analytics" "${CLAUDE_PLUGIN_ROOT}/scripts/analytics/node_modules/.bin/tsx" "${CLAUDE_PLUGIN_ROOT}/scripts/analytics/src/cli.ts" report --month ``` Read the generated JSON from `assets/analytics/monthly-reports/.json`. Present the monthly summary with MoM comparison deltas, weekly breakdown, and top performers. Then jump to Step 7 for deep-dive options. @@ -91,7 +107,7 @@ Read the generated JSON from `assets/analytics/monthly-reports/.json`. If the user chose heatmap: ```bash -ANALYTICS_ROOT="${CLAUDE_PLUGIN_ROOT}/assets/analytics" node --import tsx "${CLAUDE_PLUGIN_ROOT}/scripts/analytics/src/cli.ts" heatmap +ANALYTICS_ROOT="${CLAUDE_PLUGIN_ROOT}/assets/analytics" "${CLAUDE_PLUGIN_ROOT}/scripts/analytics/node_modules/.bin/tsx" "${CLAUDE_PLUGIN_ROOT}/scripts/analytics/src/cli.ts" heatmap ``` Present the day-of-week matrix and best-day findings. Then jump to Step 7 for deep-dive options. @@ -101,12 +117,12 @@ Present the day-of-week matrix and best-day findings. Then jump to Step 7 for de Execute the report CLI command: ```bash -ANALYTICS_ROOT="${CLAUDE_PLUGIN_ROOT}/assets/analytics" node --import tsx "${CLAUDE_PLUGIN_ROOT}/scripts/analytics/src/cli.ts" report --week +ANALYTICS_ROOT="${CLAUDE_PLUGIN_ROOT}/assets/analytics" "${CLAUDE_PLUGIN_ROOT}/scripts/analytics/node_modules/.bin/tsx" "${CLAUDE_PLUGIN_ROOT}/scripts/analytics/src/cli.ts" report --week ``` **Example:** ```bash -ANALYTICS_ROOT="${CLAUDE_PLUGIN_ROOT}/assets/analytics" node --import tsx "${CLAUDE_PLUGIN_ROOT}/scripts/analytics/src/cli.ts" report --week 2026-W05 +ANALYTICS_ROOT="${CLAUDE_PLUGIN_ROOT}/assets/analytics" "${CLAUDE_PLUGIN_ROOT}/scripts/analytics/node_modules/.bin/tsx" "${CLAUDE_PLUGIN_ROOT}/scripts/analytics/src/cli.ts" report --week 2026-W05 ``` The CLI will generate: @@ -134,7 +150,7 @@ The report contains: Get additional context with trend analysis: ```bash -ANALYTICS_ROOT="${CLAUDE_PLUGIN_ROOT}/assets/analytics" node --import tsx "${CLAUDE_PLUGIN_ROOT}/scripts/analytics/src/cli.ts" trends --period month --metric impressions +ANALYTICS_ROOT="${CLAUDE_PLUGIN_ROOT}/assets/analytics" "${CLAUDE_PLUGIN_ROOT}/scripts/analytics/node_modules/.bin/tsx" "${CLAUDE_PLUGIN_ROOT}/scripts/analytics/src/cli.ts" trends --period month --metric impressions ``` This provides: @@ -149,11 +165,11 @@ After the initial trend data, automatically run trend analysis for the key metri **Run trends CLI for key metrics:** ```bash ANALYTICS_ROOT="${CLAUDE_PLUGIN_ROOT}/assets/analytics" \ - node --import tsx "${CLAUDE_PLUGIN_ROOT}/scripts/analytics/src/cli.ts" \ + "${CLAUDE_PLUGIN_ROOT}/scripts/analytics/node_modules/.bin/tsx" "${CLAUDE_PLUGIN_ROOT}/scripts/analytics/src/cli.ts" \ trends --period month --metric impressions ANALYTICS_ROOT="${CLAUDE_PLUGIN_ROOT}/assets/analytics" \ - node --import tsx "${CLAUDE_PLUGIN_ROOT}/scripts/analytics/src/cli.ts" \ + "${CLAUDE_PLUGIN_ROOT}/scripts/analytics/node_modules/.bin/tsx" "${CLAUDE_PLUGIN_ROOT}/scripts/analytics/src/cli.ts" \ trends --period month --metric engagement_rate ``` @@ -339,13 +355,13 @@ If user wants more trend analysis: ```bash # Analyze comments trend -ANALYTICS_ROOT="${CLAUDE_PLUGIN_ROOT}/assets/analytics" node --import tsx "${CLAUDE_PLUGIN_ROOT}/scripts/analytics/src/cli.ts" trends --period month --metric comments +ANALYTICS_ROOT="${CLAUDE_PLUGIN_ROOT}/assets/analytics" "${CLAUDE_PLUGIN_ROOT}/scripts/analytics/node_modules/.bin/tsx" "${CLAUDE_PLUGIN_ROOT}/scripts/analytics/src/cli.ts" trends --period month --metric comments # Analyze shares trend -ANALYTICS_ROOT="${CLAUDE_PLUGIN_ROOT}/assets/analytics" node --import tsx "${CLAUDE_PLUGIN_ROOT}/scripts/analytics/src/cli.ts" trends --period month --metric shares +ANALYTICS_ROOT="${CLAUDE_PLUGIN_ROOT}/assets/analytics" "${CLAUDE_PLUGIN_ROOT}/scripts/analytics/node_modules/.bin/tsx" "${CLAUDE_PLUGIN_ROOT}/scripts/analytics/src/cli.ts" trends --period month --metric shares # Analyze engagement rate trend -ANALYTICS_ROOT="${CLAUDE_PLUGIN_ROOT}/assets/analytics" node --import tsx "${CLAUDE_PLUGIN_ROOT}/scripts/analytics/src/cli.ts" trends --period month --metric engagementRate +ANALYTICS_ROOT="${CLAUDE_PLUGIN_ROOT}/assets/analytics" "${CLAUDE_PLUGIN_ROOT}/scripts/analytics/node_modules/.bin/tsx" "${CLAUDE_PLUGIN_ROOT}/scripts/analytics/src/cli.ts" trends --period month --metric engagementRate ``` Present additional insights from these trends. @@ -377,7 +393,7 @@ Show detailed metrics for that post and suggest what made it perform well/poorly 3. **CLI error**: Technical failure - Show error message - Check file permissions - - Verify Node.js and tsx are available + - On `ERR_MODULE_NOT_FOUND` (missing `tsx`/`csv-parse` on a fresh clone), install the analytics CLI dependencies: `cd "${CLAUDE_PLUGIN_ROOT}/scripts/analytics" && npm install --silent` ## State Integration diff --git a/plugins/linkedin-studio/scripts/analytics/src/cli.ts b/plugins/linkedin-studio/scripts/analytics/src/cli.ts index deb8fda..c6c1e1b 100644 --- a/plugins/linkedin-studio/scripts/analytics/src/cli.ts +++ b/plugins/linkedin-studio/scripts/analytics/src/cli.ts @@ -27,11 +27,11 @@ function printUsage() { LinkedIn Analytics CLI Usage: - node build/cli.js import 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 + node --import tsx src/cli.ts import Import a CSV export + node --import tsx src/cli.ts report [--week W] Generate weekly report + node --import tsx src/cli.ts report --month YYYY-MM Generate monthly report with MoM comparison + node --import tsx src/cli.ts trends [--period P] [--metric M] Show trends and alerts + node --import tsx src/cli.ts heatmap Day-of-week performance matrix Options: --week W ISO week (e.g., 2026-W05), defaults to current week @@ -39,9 +39,9 @@ Options: --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 + node --import tsx src/cli.ts import linkedin-export-2026-01-20.csv + node --import tsx src/cli.ts report --week 2026-W04 + node --import tsx src/cli.ts trends --period quarter --metric engagementRate `); } @@ -50,7 +50,7 @@ async function handleImport(root: string, args: string[]) { if (!filename) { console.error("Error: Missing filename argument"); - console.error("Usage: node build/cli.js import "); + console.error("Usage: node --import tsx src/cli.ts import "); process.exit(1); } diff --git a/plugins/linkedin-studio/scripts/analytics/src/utils/storage.ts b/plugins/linkedin-studio/scripts/analytics/src/utils/storage.ts index 3572f3a..33c7e80 100644 --- a/plugins/linkedin-studio/scripts/analytics/src/utils/storage.ts +++ b/plugins/linkedin-studio/scripts/analytics/src/utils/storage.ts @@ -6,18 +6,46 @@ import type { AnalyticsBatch, WeeklyReport, MonthlyReport, PostAnalytics } from const __dirname = dirname(fileURLToPath(import.meta.url)); /** - * Get the analytics root directory from environment or default location - * Default is assets/analytics relative to the plugin root + * Walk up from `startDir` until a directory containing `.claude-plugin/plugin.json` + * is found. Returns that directory, or null if no marker is found before the + * filesystem root. + * + * Anchoring on the plugin marker keeps the analytics root correct by + * construction regardless of whether this module runs from `src/utils/` + * (under tsx) or `build/utils/` (compiled) — and survives a future source + * move that a hardcoded "../../../../" count would silently break. + */ +export function findPluginRoot(startDir: string): string | null { + let dir = resolve(startDir); + + // dirname() of the filesystem root returns the root itself; stop when it + // no longer changes. + for (;;) { + if (existsSync(join(dir, ".claude-plugin", "plugin.json"))) { + return dir; + } + const parent = dirname(dir); + if (parent === dir) { + return null; + } + dir = parent; + } +} + +/** + * Get the analytics root directory from environment or default location. + * Default is assets/analytics under the plugin root (the dir holding + * .claude-plugin/plugin.json). The ANALYTICS_ROOT env override is the test seam. */ export function getAnalyticsRoot(): string { if (process.env.ANALYTICS_ROOT) { return resolve(process.env.ANALYTICS_ROOT); } - // Build output is at: scripts/analytics/build/utils/storage.js - // Plugin root is 4 levels up: ../../../../ - // Then assets/analytics from there - const pluginRoot = resolve(__dirname, "../../../../"); + // Anchor on the .claude-plugin/plugin.json marker. Fall back to the legacy + // 4-levels-up count (scripts/analytics/{src,build}/utils -> plugin root) only + // if no marker is found (e.g. an unusual extraction without the manifest). + const pluginRoot = findPluginRoot(__dirname) ?? resolve(__dirname, "../../../../"); return join(pluginRoot, "assets", "analytics"); } diff --git a/plugins/linkedin-studio/scripts/analytics/tests/storage-root.test.ts b/plugins/linkedin-studio/scripts/analytics/tests/storage-root.test.ts new file mode 100644 index 0000000..c4ef29c --- /dev/null +++ b/plugins/linkedin-studio/scripts/analytics/tests/storage-root.test.ts @@ -0,0 +1,77 @@ +import { describe, test, afterEach } from "node:test"; +import assert from "node:assert/strict"; +import { mkdtempSync, rmSync, mkdirSync, writeFileSync, existsSync } from "node:fs"; +import { join, resolve } from "node:path"; +import { tmpdir } from "node:os"; +import { getAnalyticsRoot, findPluginRoot } from "../src/utils/storage.js"; + +// Regression lock for the fresh-clone / foreign-CWD analytics root resolution. +// The root must anchor on the .claude-plugin/plugin.json marker (correct by +// construction), NOT on a fragile count of "../" segments that silently breaks +// if the source layout is ever moved. +describe("analytics root resolution", () => { + let tempDir: string | undefined; + const savedEnv = process.env.ANALYTICS_ROOT; + + afterEach(() => { + if (tempDir && existsSync(tempDir)) { + rmSync(tempDir, { recursive: true, force: true }); + } + tempDir = undefined; + if (savedEnv === undefined) { + delete process.env.ANALYTICS_ROOT; + } else { + process.env.ANALYTICS_ROOT = savedEnv; + } + }); + + describe("findPluginRoot", () => { + test("walks up to the directory containing .claude-plugin/plugin.json", () => { + tempDir = mkdtempSync(join(tmpdir(), "plugin-root-")); + mkdirSync(join(tempDir, ".claude-plugin"), { recursive: true }); + writeFileSync(join(tempDir, ".claude-plugin", "plugin.json"), "{}"); + // Mimic the real depth: scripts/analytics/src/utils + const start = join(tempDir, "scripts", "analytics", "src", "utils"); + mkdirSync(start, { recursive: true }); + + assert.equal(findPluginRoot(start), tempDir); + }); + + test("returns null when no marker exists up-tree", () => { + tempDir = mkdtempSync(join(tmpdir(), "no-marker-")); + const start = join(tempDir, "a", "b", "c"); + mkdirSync(start, { recursive: true }); + + assert.equal(findPluginRoot(start), null); + }); + }); + + describe("getAnalyticsRoot", () => { + test("honors ANALYTICS_ROOT override (resolved env path)", () => { + tempDir = mkdtempSync(join(tmpdir(), "analytics-root-")); + process.env.ANALYTICS_ROOT = tempDir; + + assert.equal(getAnalyticsRoot(), resolve(tempDir)); + }); + + test("default (no env) anchors on the plugin dir, not scripts/analytics/assets", () => { + delete process.env.ANALYTICS_ROOT; + + const root = getAnalyticsRoot(); + const suffix = join("assets", "analytics"); + assert.ok(root.endsWith(suffix), `expected to end with ${suffix}, got ${root}`); + + // The parent of assets/analytics must be the real plugin root (holds the marker), + // proving the root is NOT scripts/analytics/assets/analytics. + const pluginRoot = root.slice(0, root.length - (suffix.length + 1)); + assert.ok( + existsSync(join(pluginRoot, ".claude-plugin", "plugin.json")), + `plugin marker missing under resolved root parent: ${pluginRoot}`, + ); + assert.ok( + !pluginRoot.endsWith(join("scripts", "analytics")), + `root wrongly anchored under scripts/analytics: ${pluginRoot}`, + ); + }); + }); +});