agentlinkops 0.5.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (115) hide show
  1. package/.claude-plugin/plugin.json +10 -0
  2. package/.codex-plugin/plugin.json +24 -0
  3. package/CHANGELOG.md +52 -0
  4. package/LICENSE +190 -0
  5. package/NOTICE +17 -0
  6. package/README.md +202 -0
  7. package/bin/agentlinkops.mjs +4 -0
  8. package/bin/linktrail.mjs +6 -0
  9. package/cli/adapters/csv.js +68 -0
  10. package/cli/adapters/sqlite-crm.js +79 -0
  11. package/cli/adapters/suppliers.js +180 -0
  12. package/cli/agent-setup.js +203 -0
  13. package/cli/agentlinkops.mjs +4 -0
  14. package/cli/args.js +18 -0
  15. package/cli/catalog.js +7483 -0
  16. package/cli/check.js +119 -0
  17. package/cli/citation.js +109 -0
  18. package/cli/client.js +60 -0
  19. package/cli/commands.js +183 -0
  20. package/cli/config.js +104 -0
  21. package/cli/connection.js +90 -0
  22. package/cli/context-mcp.js +193 -0
  23. package/cli/context.js +340 -0
  24. package/cli/delivery.js +67 -0
  25. package/cli/doctor.js +283 -0
  26. package/cli/env.js +66 -0
  27. package/cli/fleet.js +82 -0
  28. package/cli/gsc-browser.js +239 -0
  29. package/cli/import.js +108 -0
  30. package/cli/ledger.js +232 -0
  31. package/cli/linktrail.mjs +6 -0
  32. package/cli/local-result.js +161 -0
  33. package/cli/main.js +523 -0
  34. package/cli/mirror.js +126 -0
  35. package/cli/mix.js +123 -0
  36. package/cli/platforms.js +161 -0
  37. package/cli/receipt-performance.js +64 -0
  38. package/cli/receipts.js +122 -0
  39. package/cli/report.js +158 -0
  40. package/cli/setup-plan.js +211 -0
  41. package/cli/skill.js +39 -0
  42. package/cli/state.js +106 -0
  43. package/cli/status.js +51 -0
  44. package/cli/sync.js +179 -0
  45. package/package.json +59 -0
  46. package/references/cli.md +198 -0
  47. package/references/disavow.md +73 -0
  48. package/references/reports.md +53 -0
  49. package/scripts/agentlinkops.py +651 -0
  50. package/scripts/disavow.py +207 -0
  51. package/scripts/linktrail.py +10 -0
  52. package/scripts/migrations/001_crm.sql +45 -0
  53. package/scripts/migrations/002_cloud_events.sql +13 -0
  54. package/scripts/migrations/003_disavow.sql +20 -0
  55. package/scripts/migrations/004_placement_costs.sql +2 -0
  56. package/scripts/report.py +113 -0
  57. package/shared/agent-clients.js +49 -0
  58. package/shared/observation-event.js +43 -0
  59. package/shared/response-contract.js +59 -0
  60. package/shared/search-connection-contract.js +18 -0
  61. package/skills/agentlinkops-assets/SKILL.md +13 -0
  62. package/skills/agentlinkops-assets/agents/openai.yaml +5 -0
  63. package/skills/agentlinkops-campaigns/SKILL.md +17 -0
  64. package/skills/agentlinkops-campaigns/agents/openai.yaml +12 -0
  65. package/skills/agentlinkops-connect/SKILL.md +33 -0
  66. package/skills/agentlinkops-connect/agents/openai.yaml +12 -0
  67. package/skills/agentlinkops-connect/references/agentlinkops.md +266 -0
  68. package/skills/agentlinkops-crm/SKILL.md +27 -0
  69. package/skills/agentlinkops-crm/agents/openai.yaml +12 -0
  70. package/skills/content-defingerprinting/SKILL.md +29 -0
  71. package/skills/content-defingerprinting/scripts/watermarks/README.md +102 -0
  72. package/skills/content-defingerprinting/scripts/watermarks/UPSTREAM.json +26 -0
  73. package/skills/content-defingerprinting/scripts/watermarks/revendor.sh +52 -0
  74. package/skills/content-defingerprinting/scripts/watermarks/vendor/LICENSE.upstream +21 -0
  75. package/skills/content-defingerprinting/scripts/watermarks/vendor/clean_text.py +96 -0
  76. package/skills/content-defingerprinting/scripts/watermarks/vendor/common.py +432 -0
  77. package/skills/content-defingerprinting/scripts/watermarks/vendor/inspect_text.py +86 -0
  78. package/skills/content-defingerprinting/scripts/watermarks/vendor/score_stylometry.py +460 -0
  79. package/skills/content-defingerprinting/scripts/watermarks/vendor/text_unicode.py +651 -0
  80. package/skills/content-defingerprinting/scripts/watermarks/verify-vendor.py +27 -0
  81. package/skills/content-defingerprinting/scripts/watermarks/wm-scan.py +212 -0
  82. package/skills/internal-linking-optimizer/SKILL.md +56 -0
  83. package/skills/internal-linking-optimizer/references/source-prompt.md +76 -0
  84. package/skills/pre-publish-review/SKILL.md +179 -0
  85. package/skills/source-cited-content-builder/SKILL.md +56 -0
  86. package/skills/source-cited-content-builder/references/source-prompt.md +19 -0
  87. package/skills/source-cited-humanizer/SKILL.md +20 -0
  88. package/src/citations/adapter.js +141 -0
  89. package/src/citations/contract.js +171 -0
  90. package/src/citations/match.js +120 -0
  91. package/src/citations/runner.js +151 -0
  92. package/src/citations/stats.js +74 -0
  93. package/src/competitors/contract.js +157 -0
  94. package/src/competitors/domain-mix.js +199 -0
  95. package/src/context/ga4.js +652 -0
  96. package/src/context/gsc.js +925 -0
  97. package/src/context/site-profile.js +445 -0
  98. package/src/context/util.js +71 -0
  99. package/src/context-tool-registry.js +27 -0
  100. package/src/corpus/acquire.js +135 -0
  101. package/src/corpus/seeds.js +182 -0
  102. package/src/discovery/contract.js +237 -0
  103. package/src/discovery/import.js +156 -0
  104. package/src/tool-views.js +117 -0
  105. package/src/verifier/fetch.js +303 -0
  106. package/src/verifier/index.js +311 -0
  107. package/src/verifier/observation-kind.js +91 -0
  108. package/src/verifier/parser.js +241 -0
  109. package/src/verifier/readiness.js +90 -0
  110. package/src/verifier/render-evidence.js +57 -0
  111. package/src/verifier/robots.js +85 -0
  112. package/src/verifier/transition.js +129 -0
  113. package/src/verifier/url.js +56 -0
  114. package/templates/SITE.template.md +59 -0
  115. package/templates/campaigns.v1.json +65 -0
package/cli/check.js ADDED
@@ -0,0 +1,119 @@
1
+ // `agentlinkops check`: the exact cloud verifier, over a ledger file, without an account.
2
+ //
3
+ // Two things make it the real thing rather than a lookalike.
4
+ //
5
+ // **It imports `verifyLink` from `src/verifier/`.** Not a copy, not a port. The cases in
6
+ // `test/fixtures/verifier-cases.js` are asserted against both this and the cloud path, so the
7
+ // two cannot drift and still show green.
8
+ //
9
+ // **It paces.** Supplying `beforeFetch` is not politeness garnish: without it the verifier
10
+ // REFUSES every host that states a robots crawl delay, and a CLI that skipped it would report
11
+ // our own omission to the customer as the publisher's fault.
12
+ import { verifyLink } from '../src/verifier/index.js';
13
+ import { observationRow } from './mirror.js';
14
+ import { cadenceSeconds, DEFAULT_CADENCE } from './ledger.js';
15
+
16
+ const sleep = ms => new Promise(resolve => setTimeout(resolve, ms));
17
+ const hostOf = url => { try { return new URL(url).hostname; } catch { return null; } };
18
+
19
+ /** A courtesy floor per host, honouring a publisher's own stated delay when it is longer. */
20
+ export function createPacer({ hostDelayMs = 2000, now = () => Date.now(), wait = sleep } = {}) {
21
+ const nextAllowed = new Map();
22
+ return {
23
+ async beforeFetch(url, context = {}) {
24
+ const host = hostOf(url);
25
+ if (!host) return;
26
+ const stated = Number(context.crawlDelaySeconds ?? 0) * 1000;
27
+ const floor = Math.max(hostDelayMs, Number.isFinite(stated) ? stated : 0);
28
+ const allowed = nextAllowed.get(host) ?? 0;
29
+ const delay = allowed - now();
30
+ if (delay > 0) await wait(delay);
31
+ nextAllowed.set(host, now() + floor);
32
+ },
33
+ };
34
+ }
35
+
36
+ /** Entries a check should visit. `retired` is kept for its history and never fetched. */
37
+ export function selectEntries(entries, { filter = null, includeRetired = false } = {}) {
38
+ return entries.filter(entry => {
39
+ if (entry.intent === 'retired' && !includeRetired) return false;
40
+ if (!filter) return true;
41
+ const haystack = [entry.id, entry.source, entry.target, entry.ref ?? '', ...(entry.tags ?? [])].join(' ').toLowerCase();
42
+ return haystack.includes(String(filter).toLowerCase());
43
+ });
44
+ }
45
+
46
+ /**
47
+ * Entries whose cadence says they are due. A check with no `--all` does not refetch the web.
48
+ *
49
+ * `lastChecked` comes from `state.json`, not from the observation mirror, because the mirror
50
+ * only records CHANGES — reading the last check time from it would make an entry that has been
51
+ * stably present for a month look like it had never been checked since the day it appeared.
52
+ */
53
+ export function dueEntries(entries, lastChecked, { now = Date.now() } = {}) {
54
+ return entries.filter(entry => {
55
+ const last = lastChecked.get(entry.id);
56
+ if (!last) return true;
57
+ const seconds = cadenceSeconds(entry.cadence ?? DEFAULT_CADENCE[entry.intent] ?? 'daily') ?? 86_400;
58
+ return now - Date.parse(last) >= seconds * 1000;
59
+ });
60
+ }
61
+
62
+ /**
63
+ * Runs the verifier over a set of entries. Hosts run in parallel, a host never runs in parallel
64
+ * with itself, and the result rows come back in the ledger's own order so a diff is stable.
65
+ */
66
+ export async function runCheck(entries, {
67
+ concurrency = 6, timeoutMs = 20_000, hostDelayMs = 2000,
68
+ verify = verifyLink, pacer = null, onResult = null,
69
+ } = {}) {
70
+ const paced = pacer ?? createPacer({ hostDelayMs });
71
+ const byHost = new Map();
72
+ for (const entry of entries) {
73
+ const host = hostOf(entry.source) ?? entry.id;
74
+ if (!byHost.has(host)) byHost.set(host, []);
75
+ byHost.get(host).push(entry);
76
+ }
77
+ const queue = [...byHost.values()];
78
+ const rows = new Map();
79
+ const worker = async () => {
80
+ while (queue.length) {
81
+ for (const entry of queue.pop()) {
82
+ const result = await verify(
83
+ { sourceUrl: entry.source, targetUrl: entry.target, targetScope: entry.scope,
84
+ expectedAnchor: entry.expect?.anchor, expectedRel: entry.expect?.rel },
85
+ // `includeHtml: false` is a contract commitment, not a size optimisation: the bytes
86
+ // must not reach the repository.
87
+ { publicFetchSafe: true, timeoutMs, includeHtml: false, beforeFetch: paced.beforeFetch });
88
+ const row = observationRow(entry.id, result);
89
+ rows.set(entry.id, row);
90
+ onResult?.(entry, row);
91
+ }
92
+ }
93
+ };
94
+ await Promise.all(Array.from({ length: Math.max(1, Math.min(concurrency, queue.length)) }, worker));
95
+ return entries.map(entry => rows.get(entry.id)).filter(Boolean);
96
+ }
97
+
98
+ /**
99
+ * The exit code, which is where "unknown is never absent" either holds or quietly stops.
100
+ *
101
+ * Only an `expected` entry observed ABSENT with COMPLETE evidence fails. None of the 471
102
+ * unknowns the T01 benchmark measured is evidence that a link is gone, and a CLI that failed on
103
+ * them would teach people to pass a flag that turns the check off.
104
+ */
105
+ export function summarize(entries, rows) {
106
+ const byId = new Map(rows.map(row => [row.id, row]));
107
+ const counts = { present: 0, absent: 0, unknown: 0, source_unavailable: 0, unchecked: 0 };
108
+ const failures = [], appeared = [];
109
+ for (const entry of entries) {
110
+ const row = byId.get(entry.id);
111
+ if (!row) { counts.unchecked++; continue; }
112
+ counts[row.state] = (counts[row.state] ?? 0) + 1;
113
+ if (entry.intent === 'expected' && row.state === 'absent' && row.complete) failures.push({ entry, row });
114
+ // A `wanted` link that turned up is the whole selling moment, so it is a headline rather
115
+ // than a line in a table.
116
+ if (entry.intent === 'wanted' && row.state === 'present') appeared.push({ entry, row });
117
+ }
118
+ return { counts, failures, appeared, exitCode: failures.length ? 1 : 0 };
119
+ }
@@ -0,0 +1,109 @@
1
+ // `agentlinkops citation` — watch AI citations as placements, locally.
2
+ //
3
+ // One prompt panel, run against engine APIs with the customer's own key, a hard
4
+ // USD cap, and evidence written into the repository ledger. The vocabulary is the
5
+ // contract's: rates with intervals and sample sizes, never "removed", and thin data
6
+ // reported as insufficient rather than interpreted.
7
+ //
8
+ // Exit codes follow the link commands: 0 nothing actionable (including
9
+ // insufficient_data — an unknown never fails), 1 a cell classified declined with
10
+ // complete evidence, 2 usage, configuration or budget-abort errors.
11
+ import { resolve } from 'node:path';
12
+
13
+ import { parseArgs } from './args.js';
14
+ import { loadPanel, runEpoch } from '../src/citations/runner.js';
15
+ import { engineIdentity } from '../src/citations/contract.js';
16
+ import { createMockEngine, createPerplexityEngine, EngineError } from '../src/citations/adapter.js';
17
+ import { describeChange } from '../src/citations/stats.js';
18
+
19
+ const USAGE = `agentlinkops citation — AI citation watches in this repository
20
+
21
+ agentlinkops citation run PANEL.json [--dir DIR] [--samples N] [--max-usd USD] [--json]
22
+ run one fixed-n epoch over a panel; evidence and
23
+ tallies land under .agentlinkops/citations/
24
+ agentlinkops citation help this help
25
+
26
+ Panel (JSON): targets (domain or url, brand, aliases), prompts, engines
27
+ ([{ "engine": "mock" | "perplexity", "model": "…" }]), optional samples and maxUsd.
28
+ Live engines need the matching environment credential (PERPLEXITY_API_KEY); the mock
29
+ engine runs everything at zero cost. Every run states its estimated spend, and the
30
+ cap is checked BEFORE each call, so it can be approached but never exceeded.
31
+
32
+ Output language: citation RATE with a Wilson interval and n, classified against the
33
+ previous epoch as declined, grown or not_distinguishable — never "removed", never
34
+ "stable", and insufficient_data when n is too thin to interpret.`;
35
+
36
+ export async function citationMain(argv = [], { cwd = process.cwd(), out = console.log, err = console.error, env = process.env } = {}) {
37
+ const args = parseArgs(argv);
38
+ const command = args._[0] ?? 'help';
39
+ if (command === 'help' || args.help) { out(USAGE); return 0; }
40
+ if (command !== 'run') { err(`unknown citation command: ${command}`); err(USAGE); return 2; }
41
+
42
+ const panelPath = args._[1];
43
+ if (!panelPath) { err('citation run needs a panel file'); return 2; }
44
+
45
+ let panel;
46
+ try {
47
+ panel = await loadPanel(resolve(cwd, panelPath));
48
+ } catch (cause) {
49
+ err(`panel could not be read or parsed: ${cause.message}`);
50
+ return 2;
51
+ }
52
+
53
+ const dir = resolve(cwd, args.dir ?? '.agentlinkops');
54
+ if (args.samples) panel.samples = Number(args.samples);
55
+ if (args['max-usd']) panel.maxUsd = Number(args['max-usd']);
56
+ // Overrides are stated, never silent: a number the panel did not ask for is a
57
+ // configuration decision the operator should see in the receipt.
58
+ if (args.samples || args['max-usd']) {
59
+ err(`overrides: samples=${panel.samples} maxUsd=${panel.maxUsd}`);
60
+ }
61
+
62
+ const engines = new Map();
63
+ for (const spec of panel.engines) {
64
+ const identity = engineIdentity(spec);
65
+ if (engines.has(identity)) continue;
66
+ if (spec.engine === 'mock') {
67
+ engines.set(identity, createMockEngine({ fixtures: panel.mockFixtures ?? {} }));
68
+ } else if (spec.engine === 'perplexity') {
69
+ // Presence only, never the value — the same rule doctor applies to tokens.
70
+ if (!env.PERPLEXITY_API_KEY) {
71
+ err(`engine ${identity} needs PERPLEXITY_API_KEY in the environment`);
72
+ return 2;
73
+ }
74
+ engines.set(identity, createPerplexityEngine({ apiKey: env.PERPLEXITY_API_KEY, model: spec.model ?? 'sonar' }));
75
+ } else {
76
+ err(`unknown engine: ${spec.engine}`);
77
+ return 2;
78
+ }
79
+ }
80
+
81
+ try {
82
+ const result = await runEpoch(panel, { dir, engines, delayMs: 250 });
83
+ if (args.json) {
84
+ out(JSON.stringify(result, null, 2));
85
+ } else {
86
+ for (const cell of result.cells) {
87
+ const line = describeChange(
88
+ { rate: cell.rate, ci_low: cell.ci_low, ci_high: cell.ci_high, n: cell.n },
89
+ cell.baseline ? { rate: cell.baseline.rate, ci_low: cell.baseline.ci_low, ci_high: cell.baseline.ci_high, n: null } : null,
90
+ );
91
+ out(`${cell.cell_id} — ${line} [${cell.classification}]`);
92
+ }
93
+ out(`epoch ${result.epochId}: estimated spend $${result.spentEstimateUsd.toFixed(4)} of $${result.maxUsd} cap`);
94
+ }
95
+ if (result.aborted) {
96
+ err(`budget abort before the next call (spent $${result.aborted.spent.toFixed(4)}, next estimate $${result.aborted.nextEstimate.toFixed(4)}, cap $${result.aborted.maxUsd}); partial tallies below the interpretation minimum report insufficient_data`);
97
+ return 2;
98
+ }
99
+ const declined = result.cells.filter((c) => c.classification === 'declined');
100
+ if (declined.length > 0) {
101
+ err(`${declined.length} cell${declined.length === 1 ? '' : 's'} declined with complete evidence`);
102
+ return 1;
103
+ }
104
+ return 0;
105
+ } catch (cause) {
106
+ if (cause instanceof EngineError) { err(cause.message); return 2; }
107
+ throw cause;
108
+ }
109
+ }
package/cli/client.js ADDED
@@ -0,0 +1,60 @@
1
+ import {restResponseContract} from '../shared/response-contract.js';
2
+ // A small HTTP client for the cloud. Scoped keys only; no human session ever reaches here.
3
+ // DP-0036-T13: the CLI names itself so the usage counters can tell it from other REST callers.
4
+ // The version follows the plugin manifests; bump it with them.
5
+ export const CLI_VERSION = '0.5.0';
6
+ export const CLI_USER_AGENT = `agentlinkops-cli/${CLI_VERSION}`;
7
+ export class CloudError extends Error {
8
+ constructor(code, status, details = null) {
9
+ super(`${code}${status ? ` (${status})` : ''}`);
10
+ this.name = 'CloudError'; this.code = code; this.status = status; this.details = details;
11
+ this.exitCode = 2;
12
+ }
13
+ }
14
+
15
+ export function createClient({ origin, token, workspaceId, projectId, fetchImpl = globalThis.fetch } = {}) {
16
+ if (!origin) throw new CloudError('NO_CLOUD_ORIGIN', 0);
17
+ if (!token) throw new CloudError('NO_TOKEN', 0);
18
+ const base = origin.replace(/\/$/u, '');
19
+ async function call(method, path, { body = null, query = null } = {}) {
20
+ const url = new URL(base + path);
21
+ for (const [key, value] of Object.entries(query ?? {})) if (value !== undefined && value !== null) url.searchParams.set(key, String(value));
22
+ const response = await fetchImpl(url.href, {
23
+ method, redirect: 'error', signal: AbortSignal.timeout(30000),
24
+ headers: {
25
+ Authorization: `Bearer ${token}`,
26
+ 'User-Agent': CLI_USER_AGENT,
27
+ ...(workspaceId ? { 'X-Workspace-ID': workspaceId } : {}),
28
+ ...(body ? { 'Content-Type': 'application/json' } : {}),
29
+ },
30
+ ...(body ? { body: JSON.stringify(body) } : {}),
31
+ });
32
+ const text = await response.text();
33
+ let payload = null;
34
+ try { payload = text ? JSON.parse(text) : null; } catch { payload = null; }
35
+ if (!response.ok) {
36
+ // The error BODY is the useful part — a 410 carries the resync instructions, and losing
37
+ // them would turn a recoverable expiry into "the sync failed, try again forever".
38
+ throw new CloudError(payload?.error?.code ?? `HTTP_${response.status}`, response.status, payload?.error?.details ?? null);
39
+ }
40
+ const parsed=restResponseContract(path,method).safeParse(payload);
41
+ if(!parsed.success)throw new CloudError('INVALID_RESPONSE',502);
42
+ return parsed.data;
43
+ }
44
+ return {
45
+ listCommands: () => call('GET', '/v1/commands', { query: { include: 'schemas' } }),
46
+ describeCommand: name => call('GET', `/v1/commands/${encodeURIComponent(name)}`),
47
+ callCommand: (name, args = {}) => call('POST', `/v1/commands/${encodeURIComponent(name)}`, { body: args }),
48
+ getWorkspace: () => call('GET', '/v1/workspace'),
49
+ getProject: projectId => call('GET', `/v1/projects/${encodeURIComponent(projectId)}`),
50
+ importWatches: (projectId, watches) => call('POST', '/v1/watches/import', { body: { projectId, watches } }),
51
+ updateWatch: (watchId, patch) => call('PATCH', `/v1/watches/${encodeURIComponent(watchId)}`, { body: patch }),
52
+ listWatches: query => call('GET', '/v1/watches', { query: { ...query, ...(projectId ? { projectId } : {}) } }),
53
+ listEvents: query => call('GET', '/v1/events', { query: { ...query, ...(projectId ? { projectId } : {}) } }),
54
+ // The cloud keeps a SEPARATE sequence for target events. One client method per feed, because
55
+ // one method with a flag is how two feeds end up sharing a cursor.
56
+ listTargets: query => call('GET', '/v1/targets', { query: { ...query, ...(projectId ? { projectId } : {}) } }),
57
+ listTargetEvents: query => call('GET', '/v1/target-events', { query: { ...query, ...(projectId ? { projectId } : {}) } }),
58
+ exportWatches: query => call('GET', '/v1/exports/watches', { query: { ...query, ...(projectId ? { projectId } : {}) } }),
59
+ };
60
+ }
@@ -0,0 +1,183 @@
1
+ import { readFile } from 'node:fs/promises';
2
+ import { resolve } from 'node:path';
3
+ import { loadConfig, ConfigError } from './config.js';
4
+ import { cloudConnection } from './connection.js';
5
+ import { createClient, CloudError } from './client.js';
6
+ import { parseArgs } from './args.js';
7
+ import { NUDGE } from './skill.js';
8
+ import { CATALOG_COMMANDS, CATALOG_TOOLSETS } from './catalog.js';
9
+ import { oneLiner } from '../src/tool-views.js';
10
+
11
+ // DP-0036-T05: the CLI's three-level discovery over the cloud catalog — index (`tools`),
12
+ // describe (`describe NAME`) and call (`call NAME`). `tools` and `describe` answer from the
13
+ // bundled snapshot in cli/catalog.js with no account and no network; `--refresh` reads the
14
+ // live catalog instead. Output is JSON whenever stdout is not a terminal or --json is given,
15
+ // and one error shape ({ error: { code, message, next } }) on every failure.
16
+ const USAGE = 'tools [TOOLSET] [--json] [--refresh] | describe NAME [--examples] [--output-schema] [--json] [--refresh] | call NAME [--args JSON | --file FILE | --set PATH=VALUE ...] [--dry-run] [-y] [--json]';
17
+ const NAME = /^[a-z][a-z0-9_]*$/;
18
+ const NEXT = {
19
+ TOOL_NOT_FOUND: 'Run `agentlinkops tools` to list commands, or `agentlinkops tools TOOLSET` for one group.',
20
+ INVALID_INPUT: 'Run `agentlinkops describe NAME` for the exact input schema.',
21
+ INSUFFICIENT_SCOPE: 'This credential lacks a scope the command needs; request it through your connection or a new project-scoped key.',
22
+ NO_CLOUD_ORIGIN: 'Run `agentlinkops connect` or set AGENTLINKOPS_API_URL.',
23
+ INVALID_CLOUD_ORIGIN: 'Cloud commands need an HTTPS origin without a path, for example https://app.agentlinkops.com.',
24
+ NO_TOKEN: 'Set AGENTLINKOPS_TOKEN or AGENTLINKOPS_API_KEY from a project-scoped key.',
25
+ PROVIDER_NOT_CONFIGURED: 'An operator must enable this supplier admission; retrying does not change it.',
26
+ OWNED_DISCOVERY_NOT_CONFIGURED: 'An operator must configure owned discovery; retrying does not change it.',
27
+ IDEMPOTENCY_CONFLICT: 'Reuse an idempotency key only with identical arguments; choose a new key for a new request.',
28
+ };
29
+ export const explain = code => NEXT[code] ?? 'Run `agentlinkops describe NAME` and check the arguments, scopes and availability.';
30
+
31
+ export const resolveName = (commands, name) => commands.find(c => c.name === name) ?? commands.find(c => c.aliases?.includes(name)) ?? null;
32
+
33
+ // The index: grouped by toolset, one line per command, core and write and admission marked.
34
+ export function renderIndex(commands, { toolset = null, json = false } = {}) {
35
+ const selected = toolset ? commands.filter(c => c.toolset === toolset) : commands;
36
+ if (json) return JSON.stringify({ toolsets: toolset ? { [toolset]: CATALOG_TOOLSETS[toolset] } : CATALOG_TOOLSETS, commands: selected.map(c => ({ name: c.name, toolset: c.toolset, tier: c.tier, readOnly: c.readOnly, summary: oneLiner(c.description, 96), ...(c.admissionGated ? { admission: 'gated' } : {}), ...(c.aliases?.length ? { aliases: c.aliases } : {}) })), next: 'Run `agentlinkops describe NAME` for a schema, then `agentlinkops call NAME --args JSON`.' }, null, 2);
37
+ const groups = new Map();
38
+ for (const c of selected) (groups.get(c.toolset) ?? groups.set(c.toolset, []).get(c.toolset)).push(c);
39
+ const lines = [];
40
+ for (const [group, members] of [...groups].sort()) {
41
+ lines.push(CATALOG_TOOLSETS[group] ? `${group}: ${CATALOG_TOOLSETS[group]}` : `${group}:`);
42
+ for (const c of members.sort((a, b) => a.name.localeCompare(b.name))) lines.push(` ${c.name}${c.tier === 'core' ? ' (core)' : ''}${c.readOnly ? '' : ' [write]'}${c.admissionGated ? ' [admission-gated]' : ''} ${oneLiner(c.description, 48)}`);
43
+ lines.push('');
44
+ }
45
+ lines.push(`${selected.length} commands${toolset ? ` in ${toolset}` : ''}. describe NAME shows the schema; call NAME --args JSON runs it.`);
46
+ return lines.join('\n');
47
+ }
48
+
49
+ // One definition: what a describe_tools call returns, printed for a terminal or as JSON.
50
+ export function renderDescription(command, requested, { examples = false, outputSchema = false, json = false } = {}) {
51
+ const props = command.inputSchema?.properties ?? {};
52
+ const required = new Set(command.inputSchema?.required ?? []);
53
+ const definition = {
54
+ name: command.name, ...(requested !== command.name ? { requestedAs: requested, note: `${requested} is a retired name; use ${command.name}.` } : {}),
55
+ toolset: command.toolset, tier: command.tier, readOnly: command.readOnly, scopes: command.scopes, description: command.description,
56
+ ...(command.admissionGated ? { admission: 'gated', note: 'Unavailable until an operator enables its supplier admission; calls return the configuration error.' } : {}),
57
+ ...(command.confirm && command.confirm !== 'none' ? { confirm: command.confirm } : {}),
58
+ inputSchema: command.inputSchema,
59
+ ...(outputSchema ? { outputSchema: command.outputSchema } : {}),
60
+ ...(examples && command.examples?.length ? { examples: command.examples } : {}),
61
+ ...(command.aliases?.length ? { aliases: command.aliases } : {}),
62
+ runWith: `agentlinkops call ${command.name} --args '{...}'`,
63
+ };
64
+ if (json) return JSON.stringify(definition, null, 2);
65
+ const lines = [`${command.name} (${command.toolset}${command.tier === 'core' ? ', core' : ''}${command.readOnly ? ', read-only' : ', write'})`];
66
+ if (definition.note) lines.push(`note: ${definition.note}`);
67
+ lines.push(`scopes: ${command.scopes.join(' ')}`, '', command.description, '', 'parameters:');
68
+ if (!Object.keys(props).length) lines.push(' (none)');
69
+ for (const [key, schema] of Object.entries(props)) {
70
+ const type = schema.enum ? schema.enum.join('|') : schema.type ?? 'any';
71
+ const bounds = [schema.minimum !== undefined ? `min ${schema.minimum}` : null, schema.maximum !== undefined ? `max ${schema.maximum}` : null, schema.maxLength !== undefined ? `≤${schema.maxLength} chars` : null].filter(Boolean).join(', ');
72
+ lines.push(` ${key}${required.has(key) ? '' : '?'}: ${type}${bounds ? ` (${bounds})` : ''}${schema.description ? ` ${schema.description}` : ''}`);
73
+ }
74
+ if (definition.examples) lines.push('', 'examples:', ...definition.examples.map(e => ` ${JSON.stringify(e)}`));
75
+ if (outputSchema) lines.push('', 'output schema:', JSON.stringify(command.outputSchema, null, 1));
76
+ if (definition.aliases) lines.push('', `also known as: ${definition.aliases.join(', ')}`);
77
+ lines.push('', `run: agentlinkops call ${command.name} --args '{...}'`);
78
+ return lines.join('\n');
79
+ }
80
+
81
+ // --set PATH=VALUE overlays: dotted paths, JSON values when they parse, strings otherwise.
82
+ export function applySet(target, assignments) {
83
+ const result = structuredClone(target);
84
+ for (const assignment of assignments) {
85
+ const at = assignment.indexOf('=');
86
+ if (at <= 0) throw new ConfigError(`--set expects PATH=VALUE, got "${assignment}"`);
87
+ const path = assignment.slice(0, at).split('.').filter(Boolean);
88
+ const raw = assignment.slice(at + 1);
89
+ let value; try { value = JSON.parse(raw); } catch { value = raw; }
90
+ let cursor = result;
91
+ for (const key of path.slice(0, -1)) { if (typeof cursor[key] !== 'object' || cursor[key] === null) cursor[key] = {}; cursor = cursor[key]; }
92
+ cursor[path.at(-1)] = value;
93
+ }
94
+ return result;
95
+ }
96
+
97
+ const errorJson = (code, message, next) => JSON.stringify({ error: { code, message, next } });
98
+
99
+ export async function commandsMain(argv, { cwd = process.cwd(), env = process.env, out = console.log, err = console.error, fetchImpl = globalThis.fetch, isTTY = process.stdout.isTTY === true } = {}) {
100
+ const flags = new Set(argv.filter(a => a === '-y' || a === '--yes'));
101
+ const args = parseArgs(argv.filter(a => a !== '-y' && a !== '--yes'), ['set']);
102
+ const [command, name] = args._;
103
+ const json = args.json === true || !isTTY;
104
+ const fail = (code, message) => { if (json) err(errorJson(code, message, explain(code))); else err(`${message}\n${explain(code)}`); return 2; };
105
+ const allowed = { tools: ['_', 'json', 'refresh'], describe: ['_', 'json', 'refresh', 'examples', 'output-schema'], call: ['_', 'args', 'file', 'set', 'dry-run', 'json'] };
106
+ if (!allowed[command] || Object.keys(args).some(key => !allowed[command].includes(key)) || (command === 'tools' && args._.length > 2) || (command !== 'tools' && (args._.length !== 2 || !NAME.test(name)))) throw new ConfigError(USAGE);
107
+ if (args.set?.length && (args.set.some(v => typeof v !== 'string') || args.args !== undefined || args.file !== undefined)) throw new ConfigError('--set cannot be combined with --args or --file');
108
+ if (args.args !== undefined && args.file !== undefined) throw new ConfigError(USAGE);
109
+
110
+ // Arguments are validated before any configuration or network is touched.
111
+ let input = {};
112
+ if (command === 'call') {
113
+ if (args.args !== undefined || args.file !== undefined) {
114
+ if ((args.args !== undefined && typeof args.args !== 'string') || (args.file !== undefined && typeof args.file !== 'string')) throw new ConfigError('Provide a JSON object or a file path.');
115
+ try { input = JSON.parse(args.file ? await readFile(resolve(cwd, args.file), 'utf8') : args.args); }
116
+ catch { throw new ConfigError('Cannot read command arguments as JSON.'); }
117
+ if (!input || Array.isArray(input) || typeof input !== 'object') throw new ConfigError('Command arguments must be a JSON object.');
118
+ }
119
+ if (args.set?.length) input = applySet(input, args.set);
120
+ }
121
+
122
+ // Catalog: bundled snapshot, or the live one when asked (needs a configured origin and key).
123
+ let commands = CATALOG_COMMANDS, client = null;
124
+ const connect = () => {
125
+ const connection = cloudConnection(loadConfigSync(), env);
126
+ if (!connection.origin) throw new CloudError('NO_CLOUD_ORIGIN', 0);
127
+ let origin;
128
+ try { origin = new URL(connection.origin); } catch { throw new CloudError('INVALID_CLOUD_ORIGIN', 0); }
129
+ if (origin.protocol !== 'https:' || origin.username || origin.password || origin.pathname !== '/' || origin.search || origin.hash) throw new CloudError('INVALID_CLOUD_ORIGIN', 0);
130
+ return createClient({ ...connection, fetchImpl });
131
+ };
132
+ let loaded = null;
133
+ const loadConfigSync = () => loaded;
134
+ const needsConfig = args.refresh === true || command === 'call';
135
+ if (needsConfig) loaded = await loadConfig({ cwd });
136
+ if (args.refresh === true) {
137
+ try {
138
+ client = connect();
139
+ const live = await client.listCommands();
140
+ commands = live.items.map(item => ({ ...item, readOnly: item.annotations?.readOnlyHint === true, admissionGated: false }));
141
+ } catch (error) {
142
+ if (error instanceof CloudError) return fail(error.code, `catalog refresh: ${error.code}${error.status ? ` (${error.status})` : ''}`);
143
+ throw error;
144
+ }
145
+ }
146
+
147
+ if (command === 'tools') {
148
+ const toolset = args._[1] ?? null;
149
+ if (toolset && !commands.some(c => c.toolset === toolset)) return fail('TOOLSET_NOT_FOUND', `No toolset named ${toolset}. Toolsets: ${[...new Set(commands.map(c => c.toolset))].sort().join(', ')}.`);
150
+ out(renderIndex(commands, { toolset, json }));
151
+ // DP-0037-T05: the index is the discovery moment; a person at a terminal gets one line on
152
+ // stderr pointing at the reference. A JSON reader (an agent piping the index) gets nothing
153
+ // extra: the stub skill already sent it to the reference, and stderr stays error objects.
154
+ if (!json) err(NUDGE);
155
+ return 0;
156
+ }
157
+ if (command === 'describe') {
158
+ const found = resolveName(commands, name);
159
+ if (!found) return fail('TOOL_NOT_FOUND', `No command named ${name}.`);
160
+ out(renderDescription(found, name, { examples: args.examples === true, outputSchema: args['output-schema'] === true, json }));
161
+ return 0;
162
+ }
163
+
164
+ // call
165
+ const found = resolveName(commands, name);
166
+ const canonical = found?.name ?? name;
167
+ if (args['dry-run'] === true) {
168
+ const plan = { dryRun: true, command: canonical, ...(canonical !== name ? { requestedAs: name } : {}), arguments: input,
169
+ ...(found ? { scopes: found.scopes, readOnly: found.readOnly, ...(found.confirm && found.confirm !== 'none' ? { confirm: found.confirm } : {}) } : { note: 'Not in the bundled catalog; the cloud decides.' }),
170
+ next: found?.readOnly === false && !flags.has('-y') && !flags.has('--yes') ? 'This command writes; run again without --dry-run (add -y to answer confirmations).' : 'Run again without --dry-run to send it.' };
171
+ out(JSON.stringify(plan, null, 2));
172
+ return 0;
173
+ }
174
+ try {
175
+ client ??= connect();
176
+ const result = await client.callCommand(canonical, input);
177
+ out(JSON.stringify(result, null, 2));
178
+ return 0;
179
+ } catch (error) {
180
+ if (error instanceof CloudError) return fail(error.code, `${canonical}: ${error.code}${error.status ? ` (${error.status})` : ''}${error.details ? ` ${JSON.stringify(error.details)}` : ''}`);
181
+ throw error;
182
+ }
183
+ }
package/cli/config.js ADDED
@@ -0,0 +1,104 @@
1
+ // Where the ledger lives, and everything else the CLI needs to know.
2
+ //
3
+ // Every path is configurable and every default is named here. The product direction is
4
+ // opinionated about truth and about the fetch boundary, and explicitly not about where a
5
+ // customer keeps their files — so the config is read before anything else is.
6
+ //
7
+ // The directory has two names for the pilot compatibility window (DP-0029-T03). `.agentlinkops/`
8
+ // is the default; `.linktrail/` is read transparently when the new directory is absent, with one
9
+ // hint per process pointing at `agentlinkops migrate`. Nothing here moves files: a directory
10
+ // rename in a customer's repository is an explicit command with a receipt, never a side effect
11
+ // of reading.
12
+ import { readFile, stat } from 'node:fs/promises';
13
+ import { resolve, dirname, join } from 'node:path';
14
+
15
+ export const LEGACY_DIR = '.linktrail';
16
+
17
+ export const DEFAULTS = Object.freeze({
18
+ dir: '.agentlinkops',
19
+ ledger: 'links.jsonl',
20
+ receipts: 'receipts.jsonl',
21
+ observations: 'observations.jsonl',
22
+ events: 'events.jsonl',
23
+ candidates: 'candidates.jsonl',
24
+ state: 'state.json',
25
+ // A courtesy floor between two requests to the same host. The verifier REFUSES any host
26
+ // stating a robots crawl delay unless a pacing hook is supplied, so a CLI without one would
27
+ // report our own omission as the publisher's fault.
28
+ hostDelayMs: 2000,
29
+ concurrency: 6,
30
+ timeoutMs: 20_000,
31
+ });
32
+
33
+ /** Both spellings, preferred first. Any config or state that names the directory accepts both. */
34
+ export const DIR_NAMES = Object.freeze([DEFAULTS.dir, LEGACY_DIR]);
35
+
36
+ export class ConfigError extends Error {
37
+ constructor(message) { super(message); this.name = 'ConfigError'; this.exitCode = 2; }
38
+ }
39
+
40
+ // Compatibility notices (the legacy-directory hint here, the LINKTRAIL_* warnings in env.js) go
41
+ // to one sink, once per process per subject, so a command that loads the config in three places
42
+ // does not say the same thing three times.
43
+ const noticed = new Set();
44
+ let noticeSink = message => console.error(message);
45
+ export function setNoticeSink(sink) { noticeSink = typeof sink === 'function' ? sink : message => console.error(message); }
46
+ export function resetCompatibilityNotices() { noticed.clear(); }
47
+ export function noticeOnce(key, message) {
48
+ if (noticed.has(key)) return false;
49
+ noticed.add(key);
50
+ noticeSink(message);
51
+ return true;
52
+ }
53
+
54
+ export const migrateHint = dir => `${dir} uses the old directory name; run \`agentlinkops migrate\` to rename it to ${DEFAULTS.dir}/ (nothing is moved until you do).`;
55
+
56
+ const isDirectory = path => stat(path).then(info => info.isDirectory(), () => false);
57
+
58
+ /**
59
+ * Which directory name is in use under `base`: the preferred one when it exists, the legacy one
60
+ * when only it exists, and the preferred one (not yet created) otherwise.
61
+ */
62
+ export async function resolveDirName(base) {
63
+ if (await isDirectory(join(base, DEFAULTS.dir))) return { name: DEFAULTS.dir, legacy: false, exists: true };
64
+ if (await isDirectory(join(base, LEGACY_DIR))) return { name: LEGACY_DIR, legacy: true, exists: true };
65
+ return { name: DEFAULTS.dir, legacy: false, exists: false };
66
+ }
67
+
68
+ /** Finds the ledger directory from `from` upwards, the way git finds its own root. */
69
+ export async function findRoot(from = process.cwd(), dir = null) {
70
+ let current = resolve(from);
71
+ const names = dir ? [dir] : DIR_NAMES;
72
+ for (;;) {
73
+ for (const name of names) {
74
+ try {
75
+ await readFile(join(current, name, DEFAULTS.ledger));
76
+ return current;
77
+ } catch { /* keep walking */ }
78
+ }
79
+ const parent = dirname(current);
80
+ if (parent === current) return null;
81
+ current = parent;
82
+ }
83
+ }
84
+
85
+ export async function loadConfig({ cwd = process.cwd(), root = null } = {}) {
86
+ const base = root ?? await findRoot(cwd) ?? cwd;
87
+ const which = await resolveDirName(base);
88
+ const dir = join(base, which.name);
89
+ if (which.legacy) noticeOnce(`legacy-dir:${dir}`, migrateHint(dir));
90
+ let file = {};
91
+ try { file = JSON.parse(await readFile(join(dir, 'config.json'), 'utf8')); }
92
+ catch (error) { if (error.code !== 'ENOENT') throw new ConfigError(`config.json is not valid JSON: ${error.message}`); }
93
+ const paths = { ...DEFAULTS, ...file.paths };
94
+ const at = name => resolve(dir, paths[name]);
95
+ return {
96
+ root: base, dir, dirName: which.name, legacyDir: which.legacy,
97
+ project: file.project ?? null, cloud: file.cloud ?? null,
98
+ defaults: { ...DEFAULTS, ...file.defaults },
99
+ paths: {
100
+ ledger: at('ledger'), observations: at('observations'), events: at('events'),
101
+ candidates: at('candidates'), state: at('state'), receipts: at('receipts'),
102
+ },
103
+ };
104
+ }