linkedin-studio/scripts/trends/src/cli.ts
Kjell Tore Guttormsen 9de38c4939 feat(linkedin-studio): N7.5 — MR-F9 etterspørsels-sveip (Tier-5-kilder + smertepunkt + vokabular-oversettelse + arc-kontrakt) [skip-docs]
The «innenfra og ut» demand-sweep: the mechanism that FILLS the N6 reader fields
(readerQuestion/painPoint/saturation). Discovery finds "what happened"; this layer
translates it into "the problem the reader is stuck on".

- demand-spotter agent (agents 19->20, inherits session): three passes after
  discovery, before drafting — demand-sweep -> pain-point map -> vocabulary translation.
- Tier 5 demand sources in config/trends-sources.template.md (inverse of Tier 1-4;
  honest blind spots: YouTube API, Reddit approximate, HN/GitHub ground truth).
- demand signal on TrendRecord (strength + answered) — rankable twin of the verbatim
  saturation text; additive-optional, store schema stays v4 (no migration).
- arc.ts (§4 output contract): groupIntoArcs (relatedIds transitive closure),
  rankArcQuestions (etterspørsel x kan-svare x ikke-besvart), classifyMarketGap
  (supply-gap != demand-gap != saturated). Pure + deterministic (TDD).
- arcs CLI verb + /linkedin:trends --demand mode (commands stay 30); morning brief
  shows the per-candidate demand signal.

Suites: trends 276->300, test-runner 139->140, tsc clean. Others unchanged
(brain 134, hooks 140, tests 35, render 60).

MR-F9 built (bygget-men-ubevist) — the (a)/(b)/(c) evidence gate is a runtime
demonstration, proven consumer-side (plugin agents don't resolve in the dev repo).

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_014bE7VbkmR3cqHFEeGfzgwb
2026-07-23 22:26:39 +02:00

542 lines
24 KiB
JavaScript
Raw Blame History

This file contains ambiguous Unicode characters

This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.

#!/usr/bin/env node
/**
* CLI for the persistent trend store (research-engine §5, foundation layer).
*
* node --import tsx src/cli.ts add --title "<t>" --url "<u>" --topics <a,b>
* [--source <s>] [--summary "<s>"] [--store <path>]
* node --import tsx src/cli.ts query --topics <a,b> [--store <path>] [--json]
* node --import tsx src/cli.ts list [--since <YYYY-MM-DD>] [--limit <n>] [--store <path>] [--json]
* node --import tsx src/cli.ts status [--store <path>] [--json]
* node --import tsx src/cli.ts act|skip|reset|select (--id <id> | --ids <a,b,c>) [--store <path>]
* echo '<raw item|batch>' | node --import tsx src/cli.ts normalize
* echo '<scored candidates>' | node --import tsx src/cli.ts score [--mode kortform|long-form] [--threshold N]
* echo '<raw item|batch>' | node --import tsx src/cli.ts capture [--store <path>] [--json]
* node --import tsx src/cli.ts brief [--pillars <a,b>] [--fresh-days N] [--first-mover-days N] [--saturation-at N]
* [--out <dir>] [--no-mark] [--store <path>] [--json]
* node --import tsx src/cli.ts arcs [--topics <a,b>] [--out <dir>] [--store <path>] [--json]
* node --import tsx src/cli.ts schedule --pillars <a,b> [--at HH:MM] [--fresh-days N]
* [--platform auto|launchd|cron] [--install|--uninstall] [--store <path>]
*
* The capture agent (research-engine) folds freshly-polled trends into the store via
* `capture` (the normalizing batch path: stdin → normalizeItem(s) → itemToInput →
* addTrend) — which, when an item carries the agent's five judgment scores (RE-R3a),
* persists an optional relevance `score` (the deterministically-computed composite + band,
* one owner) first-sight on the record so the morning brief ranks on it — and reasons over
* accumulated history via `query`/`list`. `brief` (RE-R2b)
* renders a dated, pillar-ranked morning brief over the store to a Markdown file the
* SessionStart hook surfaces; RE-R3d adds a DERIVED temporal overlay (first-mover up / saturated
* down) as a within-tier ranking refinement, tunable via `--first-mover-days`/`--saturation-at`.
* `add` is the MANUAL single-trend path (raw flags, no
* normalization, publish-date-free). `act`/`skip`/`reset`/`select` set a trend's lifecycle status
* (RE-R3b + N6 `selected`), one id (`--id`) or a batch (`--ids a,b,c`, A1-9): the morning brief
* EXCLUDES acted/skipped/selected from the work queue (selected+acted show in "I produksjon"
* instead) and records each surfacing (per-day-idempotent
* `surfacedCount`) so the loop stops re-surfacing handled work; a re-capture refreshes the score
* (timing decays). The polling + relevance-scoring itself lives upstream; this is the deterministic store.
*
* `normalize` + `score` (RE-R1) and `capture` (RE-R2a) are the deterministic
* research-engine seam: all read their JSON PAYLOAD FROM STDIN (so they do not overload
* `--json`, which stays an output toggle). `normalize` validates raw items into the
* canonical envelope; `score` triages scored candidates (composite/band/threshold);
* `capture` normalizes + folds each valid item into the store (persisting `publishedAt`),
* reporting content-invalid items in the summary `errors[]`, never via the exit code.
*
* `schedule` (RE-R3c) is PRINT-FIRST: it emits a launchd plist (macOS) / cron line (Linux) firing the
* DETERMINISTIC `brief` daily via the `run-daily.sh` headless wrapper, plus the exact activation command.
* `--install` writes only the inert launchd plist FILE; the tool never runs `launchctl` or the cron table
* (the operator runs the one printed command). No AI capture in the nightly run — that is a later slice.
*
* Exit code: 0 on success, 2 on usage error or a not-found id (act/skip/reset). A wrong --id is an
* argument-class error; capture's content-invalid items stay in errors[] (never via the exit code).
* `schedule` adds no new exit code: an autonomy install never RUNS the system mutation.
*/
import { readFileSync, readdirSync, mkdirSync, writeFileSync, existsSync, rmSync } from "node:fs";
import { join, dirname } from "node:path";
import { homedir } from "node:os";
import { fileURLToPath } from "node:url";
import {
addTrend,
defaultStorePath,
history,
loadStore,
markSurfaced,
newestCaptureDate,
queryByTopic,
saveStore,
setStatus,
setStatusMany,
} from "./store.js";
import type { TrendStatus } from "./types.js";
import { normalizeItem, normalizeItems, itemToInput } from "./item.js";
import { triage } from "./score.js";
import type { ScoreMode } from "./score.js";
import {
rankForBrief,
renderBrief,
briefSummary,
defaultBriefDir,
surfacedIds,
diffSurfaced,
parseSurfacedFrontmatter,
selectPriorBriefFile,
} from "./brief.js";
import { launchdPlist, crontabLine, installInstructions, uninstallInstructions, defaultLabel } from "./schedule.js";
import type { ScheduleSpec } from "./schedule.js";
import { groupIntoArcs, renderArcs } from "./arc.js";
function parseFlags(args: string[]): Record<string, string> {
const out: Record<string, string> = {};
for (let i = 0; i < args.length; i++) {
const a = args[i];
if (a.startsWith("--")) {
const key = a.slice(2);
const next = args[i + 1];
if (next === undefined || next.startsWith("--")) {
out[key] = "true";
} else {
out[key] = next;
i++;
}
}
}
return out;
}
function splitTopics(raw: string | undefined): string[] {
if (!raw) return [];
return raw
.split(",")
.map((t) => t.trim())
.filter((t) => t.length > 0);
}
function usage(msg: string): never {
console.error(`error: ${msg}`);
console.error(
"usage:\n" +
' add --title "<t>" --url "<u>" --topics <a,b> [--source <s>] [--summary "<s>"] [--store <path>]\n' +
" query --topics <a,b> [--store <path>] [--json]\n" +
" list [--since <YYYY-MM-DD>] [--limit <n>] [--store <path>] [--json]\n" +
" status [--store <path>] [--json]\n" +
" act|skip|reset|select (--id <id> | --ids <a,b,c>) [--store <path>]\n" +
" normalize < raw-item-or-batch.json\n" +
" score [--mode kortform|long-form] [--threshold N] < scored-candidates.json\n" +
" capture [--store <path>] [--json] < raw-item-or-batch.json\n" +
" brief [--pillars <a,b>] [--fresh-days N] [--first-mover-days N] [--saturation-at N] [--out <dir>] [--no-mark] [--store <path>] [--json]\n" +
" arcs [--topics <a,b>] [--out <dir>] [--store <path>] [--json]\n" +
" schedule --pillars <a,b> [--at HH:MM] [--fresh-days N] [--platform auto|launchd|cron] [--install|--uninstall] [--store <path>]",
);
process.exit(2);
}
/** Read the full JSON payload from stdin, or exit 2 if it is empty/unparseable. */
function readStdinJson(): unknown {
let raw = "";
try {
raw = readFileSync(0, "utf8").trim();
} catch {
raw = "";
}
if (raw.length === 0) usage("expected a JSON payload on stdin");
try {
return JSON.parse(raw);
} catch {
usage("stdin is not valid JSON");
}
}
function today(): string {
return new Date().toISOString().slice(0, 10);
}
/** Whole days from one ISO date to another (floor); negative if `from` is later. */
function daysBetween(fromIso: string, toIso: string): number {
return Math.floor((new Date(toIso).getTime() - new Date(fromIso).getTime()) / 86400000);
}
function main(): void {
const [command, ...rest] = process.argv.slice(2);
const flags = parseFlags(rest);
const storePath = flags.store ?? defaultStorePath();
const asJson = flags.json === "true";
if (command === "add") {
const title = flags.title;
if (!title || title === "true") usage('add needs --title "<text>"');
const url = flags.url;
if (!url || url === "true") usage('add needs --url "<url>"');
const topics = splitTopics(flags.topics);
if (topics.length === 0) usage("add needs --topics <a,b>");
const store = loadStore(storePath);
const res = addTrend(store, {
title,
url,
source: flags.source && flags.source !== "true" ? flags.source : "manual",
capturedAt: today(),
topics,
...(flags.summary && flags.summary !== "true" ? { summary: flags.summary } : {}),
});
saveStore(storePath, res.store);
if (res.added) {
console.log(`Added: ${title}`);
} else {
console.log(`Duplicate — already in store${res.merged ? " (topics unioned)" : ""}: ${title}`);
}
console.log(`Store: ${storePath} (${res.store.trends.length} trends)`);
return;
}
if (command === "query") {
const topics = splitTopics(flags.topics);
if (topics.length === 0) usage("query needs --topics <a,b>");
const hits = queryByTopic(loadStore(storePath), topics);
if (asJson) {
console.log(JSON.stringify(hits, null, 2));
return;
}
if (hits.length === 0) {
console.log(`No trends found for topics: ${topics.join(", ")}`);
console.log("→ poll fresh signals (research engine), then `add` them.");
return;
}
console.log(`${hits.length} trend(s) for: ${topics.join(", ")}`);
for (const { trend, topicOverlap } of hits) {
console.log(`\n · (overlap ${topicOverlap}) ${trend.title}`);
console.log(` ${trend.url}`);
console.log(` topics: ${trend.topics.join(", ")}${trend.source}, ${trend.capturedAt}`);
if (trend.summary) console.log(` ${trend.summary}`);
}
return;
}
if (command === "list") {
const opts: { since?: string; limit?: number } = {};
if (flags.since && flags.since !== "true") opts.since = flags.since;
if (flags.limit && flags.limit !== "true") {
const n = Number.parseInt(flags.limit, 10);
if (Number.isNaN(n) || n < 0) usage("--limit must be a non-negative integer");
opts.limit = n;
}
const rows = history(loadStore(storePath), opts);
if (asJson) {
console.log(JSON.stringify(rows, null, 2));
return;
}
const scope = opts.since ? ` since ${opts.since}` : "";
console.log(`Store: ${storePath}${rows.length} trend(s)${scope}`);
for (const t of rows) {
console.log(` · ${t.capturedAt} ${t.title} {${t.topics.join(", ")}}`);
}
return;
}
if (command === "status") {
const store = loadStore(storePath);
const newest = newestCaptureDate(store);
const daysStale = newest === null ? null : daysBetween(newest, today());
if (asJson) {
console.log(JSON.stringify({ store: storePath, count: store.trends.length, newest, daysStale }));
return;
}
console.log(`Store: ${storePath}`);
console.log(` trends: ${store.trends.length}`);
if (newest === null) {
console.log(" newest: — (empty store; no captures yet)");
} else {
console.log(` newest: ${newest} (${daysStale}d ago)`);
}
return;
}
if (command === "act" || command === "skip" || command === "reset" || command === "select") {
// Ids from --ids <a,b,c> (batch, A1-9) or a single --id <id>; --ids wins when both are given.
// splitTopics is the generic comma-split+trim+drop-blank helper (ids are case-sensitive, not lowercased).
const ids =
flags.ids && flags.ids !== "true"
? splitTopics(flags.ids)
: flags.id && flags.id !== "true"
? [flags.id]
: [];
if (ids.length === 0) usage(`${command} needs --id <id> or --ids <a,b,c>`);
const status: TrendStatus =
command === "act" ? "acted" : command === "skip" ? "skipped" : command === "select" ? "selected" : "new";
const store = loadStore(storePath);
const res = setStatusMany(store, ids, status);
// All-miss is an argument-class error (exit 2, store untouched); any hit saves + reports the misses (exit 0).
if (res.found.length === 0) {
console.error(`error: no trend with id: ${res.notFound.join(", ")}`);
process.exit(2);
}
saveStore(storePath, store);
const miss = res.notFound.length > 0 ? ` (${res.notFound.length} not found: ${res.notFound.join(", ")})` : "";
console.log(`Marked ${res.found.length} ${status}${miss}`);
return;
}
if (command === "normalize") {
const payload = readStdinJson();
const out = Array.isArray(payload) ? normalizeItems(payload) : normalizeItem(payload);
console.log(JSON.stringify(out, null, 2));
return;
}
if (command === "score") {
const mode = flags.mode && flags.mode !== "true" ? flags.mode : "kortform";
if (mode !== "kortform" && mode !== "long-form") {
usage('score --mode must be "kortform" or "long-form"');
}
let threshold = 4.0;
if (flags.threshold && flags.threshold !== "true") {
const t = Number.parseFloat(flags.threshold);
if (Number.isNaN(t)) usage("--threshold must be a number");
threshold = t;
}
const payload = readStdinJson();
if (!Array.isArray(payload)) usage("score expects a JSON array of scored candidates on stdin");
try {
const result = triage(payload as Array<{ scores: Record<string, number> }>, {
mode: mode as ScoreMode,
threshold,
});
console.log(JSON.stringify(result, null, 2));
} catch (e) {
usage(`scoring failed: ${(e as Error).message}`);
}
return;
}
if (command === "capture") {
const payload = readStdinJson(); // exits 2 on empty/unparseable stdin
const raw = Array.isArray(payload) ? payload : [payload];
const { items, errors } = normalizeItems(raw);
const store = loadStore(storePath);
// Tally derived from AddResult {added, merged} (no `duplicates` field): a fold is
// `added` (new), else `merged` (existing gained topics and/or a refreshed score), else a plain `duplicate`.
let added = 0;
let merged = 0;
let duplicates = 0;
for (const item of items) {
const res = addTrend(store, itemToInput(item, today()));
if (res.added) added++;
else if (res.merged) merged++;
else duplicates++;
}
saveStore(storePath, store);
if (asJson) {
console.log(JSON.stringify({ added, duplicates, merged, errors }, null, 2));
return;
}
console.log(
`Captured into ${storePath}: ${added} added, ${merged} merged, ` +
`${duplicates} duplicate, ${errors.length} invalid (${store.trends.length} total)`,
);
return;
}
if (command === "brief") {
const pillars = splitTopics(flags.pillars);
let freshDays = 7;
if (flags["fresh-days"] && flags["fresh-days"] !== "true") {
const n = Number.parseInt(flags["fresh-days"], 10);
if (Number.isNaN(n) || n < 0) usage("--fresh-days must be a non-negative integer");
freshDays = n;
}
// RE-R3d temporal-overlay thresholds (defaults mirror brief.ts's RankOptions defaults).
let firstMoverDays = 2;
if (flags["first-mover-days"] && flags["first-mover-days"] !== "true") {
const n = Number.parseInt(flags["first-mover-days"], 10);
if (Number.isNaN(n) || n < 0) usage("--first-mover-days must be a non-negative integer");
firstMoverDays = n;
}
let saturationAt = 3;
if (flags["saturation-at"] && flags["saturation-at"] !== "true") {
const n = Number.parseInt(flags["saturation-at"], 10);
if (Number.isNaN(n) || n < 1) usage("--saturation-at must be a positive integer");
saturationAt = n;
}
// A bare `--out` yields the string "true" (parseFlags); the guard falls back to
// defaultBriefDir() so it never writes to ./true.
const outDir = flags.out && flags.out !== "true" ? flags.out : defaultBriefDir();
const day = today(); // one wall-clock read for both the ranking and the filename
const store = loadStore(storePath); // hoisted: also needed for the surfacing write below
const ranking = rankForBrief(store, pillars, day, { freshDays, firstMoverDays, saturationAt });
// RE-R3e: discover the most recent prior brief in outDir and diff today's surfaced cohort
// against its `surfaced:` line. Any fs error (no dir, unreadable file) degrades to the
// empty-prior (first-brief) path. The dir read lives HERE (the edge) — brief.ts stays fs-free.
const todayIds = surfacedIds(ranking);
let priorIds: string[] = [];
let priorDate: string | null = null;
try {
if (existsSync(outDir)) {
const priorFile = selectPriorBriefFile(readdirSync(outDir), day);
if (priorFile) {
priorIds = parseSurfacedFrontmatter(readFileSync(join(outDir, priorFile), "utf8"));
priorDate = priorFile.slice(0, 10);
}
}
} catch {
priorIds = [];
priorDate = null;
}
const diff = diffSurfaced(todayIds, priorIds, priorDate);
const md = renderBrief(ranking, diff);
const path = join(outDir, `${day}.md`);
mkdirSync(outDir, { recursive: true });
writeFileSync(path, md, "utf8");
// RE-R3b: record surfacing on the store AFTER the pure render (per-day idempotent), unless --no-mark.
// The handled (acted/skipped) records were filtered from the ranking but remain in `store`, so the
// resave preserves them; only the surfaced ids' surfacedCount/lastSurfacedAt change.
const mark = flags["no-mark"] !== "true";
const marked = mark ? markSurfaced(store, surfacedIds(ranking), day).marked : 0;
if (mark) saveStore(storePath, store);
const summary = briefSummary(ranking, diff); // SAME source the frontmatter carries (RE-R3e: incl. the delta marker)
if (asJson) {
console.log(
JSON.stringify(
{
path,
date: ranking.today,
totals: ranking.totals,
summary,
marked,
diff: {
priorDate: diff.priorDate,
added: diff.added.length,
carried: diff.carried.length,
dropped: diff.dropped.length,
},
},
null,
2,
),
);
return;
}
const deltaNote = diff.added.length > 0 && diff.priorDate !== null ? `, ${diff.added.length} nye siden sist` : "";
console.log(
`Wrote brief: ${path} (${ranking.totals.matched} matched, ${ranking.totals.fresh} fresh, ${marked} surfaced)${deltaNote}`,
);
return;
}
if (command === "arcs") {
// The demand-sweep's §4 output (MR-F9, N7.5): group the demand-swept records into veins (arcs)
// and render the reader-side output contract — question inventory ranked on etterspørsel × kan-svare
// × ikke-besvart, per-vein market verdict (supply-gap ≠ demand-gap ≠ saturated), BÆRENDE/STØTTE order.
// Reads only records that carry a demand-side signal (the sweep populated them); an optional --topics
// narrows the pool to a theme. Print to stdout by default; --out <dir> writes a dated arc map; --json
// emits the arc structures. No store mutation (a read-only view, like brief without the surfacing write).
const store = loadStore(storePath);
const wanted = splitTopics(flags.topics).map((t) => t.toLowerCase());
const swept = store.trends.filter((t) => {
const hasSignal =
t.demand !== undefined ||
t.readerQuestion !== undefined ||
t.painPoint !== undefined ||
t.saturation !== undefined;
if (!hasSignal) return false;
if (wanted.length === 0) return true;
const have = new Set(t.topics.map((x) => x.toLowerCase()));
return wanted.some((w) => have.has(w));
});
const arcs = groupIntoArcs(swept);
if (asJson) {
console.log(JSON.stringify(arcs, null, 2));
return;
}
const md = renderArcs(arcs);
if (flags.out && flags.out !== "true") {
const outDir = flags.out;
mkdirSync(outDir, { recursive: true });
const path = join(outDir, `${today()}-arcs.md`);
writeFileSync(path, md, "utf8");
console.log(`Wrote arc map: ${path} (${arcs.length} åre(r), ${swept.length} swept record(s))`);
return;
}
process.stdout.write(md);
return;
}
if (command === "schedule") {
// RE-R3c — print-first autonomous trigger. Emits (or installs) a launchd plist / cron line firing
// the DETERMINISTIC brief daily via run-daily.sh; never runs launchctl or the cron table (C2).
const pillars = splitTopics(flags.pillars);
if (pillars.length === 0) usage("schedule needs --pillars <a,b>");
const at = flags.at && flags.at !== "true" ? flags.at : "07:00";
const [hStr, mStr] = at.split(":");
const hour = Number.parseInt(hStr, 10);
const minute = Number.parseInt(mStr ?? "", 10);
if (Number.isNaN(hour) || Number.isNaN(minute) || hour < 0 || hour > 23 || minute < 0 || minute > 59) {
usage("--at must be HH:MM (00:00-23:59)");
}
let freshDays = 7;
if (flags["fresh-days"] && flags["fresh-days"] !== "true") {
const n = Number.parseInt(flags["fresh-days"], 10);
if (Number.isNaN(n) || n < 0) usage("--fresh-days must be a non-negative integer");
freshDays = n;
}
const platformFlag = flags.platform && flags.platform !== "true" ? flags.platform : "auto";
if (platformFlag !== "auto" && platformFlag !== "launchd" && platformFlag !== "cron") {
usage("--platform must be auto|launchd|cron");
}
const platform: "launchd" | "cron" =
platformFlag === "auto"
? process.platform === "darwin"
? "launchd"
: "cron"
: (platformFlag as "launchd" | "cron");
// Resolve every absolute path FROM THE RUNTIME (never hard-coded → domain-general).
const here = dirname(fileURLToPath(import.meta.url)); // .../scripts/trends/src
const wrapperPath = join(here, "..", "run-daily.sh");
const workingDir = join(here, "..");
const nodeBin = process.execPath;
// logPath is anchored to the DATA ROOT (dirname(defaultStorePath())), NOT the --store override, so a
// custom --store never splits the plist StandardOutPath from the wrapper's own cron.log (sibling of
// morning-brief/, matching brief.ts defaultBriefDir's dirname(defaultStorePath()) idiom).
const logPath = join(dirname(defaultStorePath()), "cron.log");
const root = process.env.LINKEDIN_STUDIO_DATA ?? join(homedir(), ".claude", "linkedin-studio");
// env is canonical: always NODE_BIN + a resolved-absolute data root — pins the scheduled run to the
// install-time root AND removes the wrapper's $HOME-unset `set -u` edge under a profile-less env.
const env: Record<string, string> = { NODE_BIN: nodeBin, LINKEDIN_STUDIO_DATA: root };
// The wrapper hard-codes the `brief` subcommand → the baked args carry NO leading "brief".
const args = ["--pillars", pillars.join(","), "--fresh-days", String(freshDays)];
if (flags.store && flags.store !== "true") args.push("--store", storePath);
const label = defaultLabel();
const spec: ScheduleSpec = { platform, label, nodeBin, wrapperPath, args, hour, minute, logPath, workingDir, env };
const plistTarget = join(homedir(), "Library", "LaunchAgents", `${label}.plist`);
if (flags.uninstall === "true") {
console.log(uninstallInstructions(spec, platform === "launchd" ? plistTarget : undefined));
if (platform === "launchd" && existsSync(plistTarget)) rmSync(plistTarget);
return;
}
if (flags.install === "true") {
if (platform === "launchd") {
mkdirSync(dirname(plistTarget), { recursive: true });
writeFileSync(plistTarget, launchdPlist(spec), "utf8");
console.log(plistTarget);
console.log(installInstructions(spec, plistTarget));
} else {
console.log(crontabLine(spec));
console.log(installInstructions(spec));
}
return;
}
// default / --print — emit the artifact + the activation command. No fs.
console.log(platform === "launchd" ? launchdPlist(spec) : crontabLine(spec));
console.log(installInstructions(spec, platform === "launchd" ? plistTarget : undefined));
return;
}
usage(command ? `unknown command: ${command}` : "no command given");
}
main();