fix(linkedin-studio): anchor analytics root on plugin marker + surface npm install

Wave 2 / Step 4 of the remediation plan (docs/remediation/plan.md).

PRIMARY (the real fresh-clone failure):
- scripts/analytics/node_modules is gitignored, so a fresh clone has neither
  tsx nor csv-parse. Surface an idempotent `npm install --silent` prerequisite
  at point-of-use in report.md (Step 1b) and import.md (Step 4).

DEVIATION FROM PLAN (correction-in-scope, to satisfy the plan's own Verify gate):
- The plan assumed prepending `npm install` was sufficient. Verified it is NOT:
  the commands invoke the CLI with an absolute src/cli.ts path but from the
  user's arbitrary CWD, and `node --import tsx` resolves the `tsx` specifier
  relative to CWD, not the script. There is no global tsx, so the call still
  fails with ERR_MODULE_NOT_FOUND from any CWD other than scripts/analytics.
- Complete fix: invoke the locally-installed tsx by its absolute
  node_modules/.bin/tsx path in all CLI calls (report.md x10, import.md x3), so
  they resolve from any working directory once the install above has run.
  Verified: 0 ERR_MODULE_NOT_FOUND running `report` from /tmp.

SECONDARY (latent correctness / hardening):
- Add findPluginRoot(): walks up to the dir holding .claude-plugin/plugin.json
  and anchors getAnalyticsRoot() on it, falling back to the legacy 4-up count.
  MEASURED that ../../../../ already resolved to the plugin root from BOTH
  src/utils and build/utils (both 4 levels deep), so the plan's "src-vs-build
  depth miscalibration" premise was false — this is correct-by-construction
  hardening (survives a future source move), not a live-bug fix.
- Reconcile cli.ts usage/help text: `node build/cli.js` -> `node --import tsx
  src/cli.ts` (the real runtime).
- Fix report.md troubleshooting: "Verify tsx is available" -> the actual
  install command on ERR_MODULE_NOT_FOUND.

Test-first: scripts/analytics/tests/storage-root.test.ts (red on missing
findPluginRoot export, green after). Full suite 106/106, tsc --noEmit clean,
structural lint 0 failed.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
This commit is contained in:
Kjell Tore Guttormsen 2026-05-29 21:18:33 +02:00
commit 798484bf0c
5 changed files with 159 additions and 29 deletions

View file

@ -105,10 +105,19 @@ Options:
## Step 4: Run Import ## 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: Once the user selects, run the import CLI:
```bash ```bash
ANALYTICS_ROOT="${CLAUDE_PLUGIN_ROOT}/assets/analytics" node --import tsx "${CLAUDE_PLUGIN_ROOT}/scripts/analytics/src/cli.ts" import <filename> 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 <filename>
``` ```
If importing multiple files, run the command for each file sequentially. 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` 1. Read `expertise_areas` from `~/.claude/linkedin-studio.local.md`
2. Call `trends` for impressions and engagement_rate over the last 4 weeks: 2. Call `trends` for impressions and engagement_rate over the last 4 weeks:
```bash ```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" "${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" 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 engagement_rate
``` ```
3. Produce the Content Pillar Performance, Format Performance, and 3. Produce the Content Pillar Performance, Format Performance, and
Day-of-Week Performance tables, plus exactly 3 actionable recommendations Day-of-Week Performance tables, plus exactly 3 actionable recommendations

View file

@ -37,6 +37,22 @@ You need to import your LinkedIn analytics first:
1. Run `/linkedin:import` to import CSV data 1. Run `/linkedin:import` to import CSV data
2. Then come back to generate reports 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 ## Step 2: Choose Report Type
**Ask the user** using AskUserQuestion: **Ask the user** using AskUserQuestion:
@ -81,7 +97,7 @@ date +%Y-W%V
If the user chose monthly: If the user chose monthly:
```bash ```bash
ANALYTICS_ROOT="${CLAUDE_PLUGIN_ROOT}/assets/analytics" node --import tsx "${CLAUDE_PLUGIN_ROOT}/scripts/analytics/src/cli.ts" report --month <YYYY-MM> 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 <YYYY-MM>
``` ```
Read the generated JSON from `assets/analytics/monthly-reports/<YYYY-MM>.json`. Present the monthly summary with MoM comparison deltas, weekly breakdown, and top performers. Then jump to Step 7 for deep-dive options. Read the generated JSON from `assets/analytics/monthly-reports/<YYYY-MM>.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/<YYYY-MM>.json`.
If the user chose heatmap: If the user chose heatmap:
```bash ```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. 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: Execute the report CLI command:
```bash ```bash
ANALYTICS_ROOT="${CLAUDE_PLUGIN_ROOT}/assets/analytics" node --import tsx "${CLAUDE_PLUGIN_ROOT}/scripts/analytics/src/cli.ts" report --week <YYYY-WXX> 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 <YYYY-WXX>
``` ```
**Example:** **Example:**
```bash ```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: The CLI will generate:
@ -134,7 +150,7 @@ The report contains:
Get additional context with trend analysis: Get additional context with trend analysis:
```bash ```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: 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:** **Run trends CLI for key metrics:**
```bash ```bash
ANALYTICS_ROOT="${CLAUDE_PLUGIN_ROOT}/assets/analytics" \ 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 trends --period month --metric impressions
ANALYTICS_ROOT="${CLAUDE_PLUGIN_ROOT}/assets/analytics" \ 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 trends --period month --metric engagement_rate
``` ```
@ -339,13 +355,13 @@ If user wants more trend analysis:
```bash ```bash
# Analyze comments trend # 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 # 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 # 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. 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 3. **CLI error**: Technical failure
- Show error message - Show error message
- Check file permissions - 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 ## State Integration

View file

@ -27,11 +27,11 @@ function printUsage() {
LinkedIn Analytics CLI LinkedIn Analytics CLI
Usage: Usage:
node build/cli.js import <filename> Import a CSV export node --import tsx src/cli.ts import <filename> Import a CSV export
node build/cli.js report [--week W] Generate weekly report node --import tsx src/cli.ts report [--week W] Generate weekly report
node build/cli.js report --month YYYY-MM Generate monthly report with MoM comparison node --import tsx src/cli.ts report --month YYYY-MM Generate monthly report with MoM comparison
node build/cli.js trends [--period P] [--metric M] Show trends and alerts node --import tsx src/cli.ts 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 heatmap Day-of-week performance matrix
Options: Options:
--week W ISO week (e.g., 2026-W05), defaults to current week --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") --metric M Metric to analyze: "impressions" | "reactions" | "comments" | "shares" | "clicks" | "engagementRate" (default: "impressions")
Examples: Examples:
node build/cli.js import linkedin-export-2026-01-20.csv node --import tsx src/cli.ts import linkedin-export-2026-01-20.csv
node build/cli.js report --week 2026-W04 node --import tsx src/cli.ts report --week 2026-W04
node build/cli.js trends --period quarter --metric engagementRate node --import tsx src/cli.ts trends --period quarter --metric engagementRate
`); `);
} }
@ -50,7 +50,7 @@ async function handleImport(root: string, args: string[]) {
if (!filename) { if (!filename) {
console.error("Error: Missing filename argument"); console.error("Error: Missing filename argument");
console.error("Usage: node build/cli.js import <filename>"); console.error("Usage: node --import tsx src/cli.ts import <filename>");
process.exit(1); process.exit(1);
} }

View file

@ -6,18 +6,46 @@ import type { AnalyticsBatch, WeeklyReport, MonthlyReport, PostAnalytics } from
const __dirname = dirname(fileURLToPath(import.meta.url)); const __dirname = dirname(fileURLToPath(import.meta.url));
/** /**
* Get the analytics root directory from environment or default location * Walk up from `startDir` until a directory containing `.claude-plugin/plugin.json`
* Default is assets/analytics relative to the plugin root * 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 { export function getAnalyticsRoot(): string {
if (process.env.ANALYTICS_ROOT) { if (process.env.ANALYTICS_ROOT) {
return resolve(process.env.ANALYTICS_ROOT); return resolve(process.env.ANALYTICS_ROOT);
} }
// Build output is at: scripts/analytics/build/utils/storage.js // Anchor on the .claude-plugin/plugin.json marker. Fall back to the legacy
// Plugin root is 4 levels up: ../../../../ // 4-levels-up count (scripts/analytics/{src,build}/utils -> plugin root) only
// Then assets/analytics from there // if no marker is found (e.g. an unusual extraction without the manifest).
const pluginRoot = resolve(__dirname, "../../../../"); const pluginRoot = findPluginRoot(__dirname) ?? resolve(__dirname, "../../../../");
return join(pluginRoot, "assets", "analytics"); return join(pluginRoot, "assets", "analytics");
} }

View file

@ -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}`,
);
});
});
});