agentlinkops 0.6.13 → 0.6.16

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 (178) hide show
  1. package/.claude-plugin/plugin.json +2 -2
  2. package/.codex-plugin/plugin.json +2 -2
  3. package/CHANGELOG.md +25 -2
  4. package/PACK-ARTIFACT.json +16 -0
  5. package/PACKS.json +591 -0
  6. package/README.md +34 -48
  7. package/cli/adapters/suppliers.js +1 -1
  8. package/cli/agent-setup.js +101 -17
  9. package/cli/campaign-bridge.js +325 -0
  10. package/cli/campaign-outcomes.js +107 -0
  11. package/cli/catalog.js +45702 -18674
  12. package/cli/check.js +40 -10
  13. package/cli/client.js +2 -1
  14. package/cli/commands.js +17 -8
  15. package/cli/connection.js +92 -18
  16. package/cli/contact-adoption.js +91 -0
  17. package/cli/context.js +13 -2
  18. package/cli/doctor.js +15 -4
  19. package/cli/import.js +7 -1
  20. package/cli/key-store.js +115 -0
  21. package/cli/ledger.js +1 -0
  22. package/cli/local-result.js +41 -6
  23. package/cli/main.js +51 -12
  24. package/cli/mcp-client.js +2 -2
  25. package/cli/migration.js +308 -0
  26. package/cli/prompt.js +25 -0
  27. package/cli/public-http.js +168 -0
  28. package/cli/state.js +14 -0
  29. package/cli/sync.js +1 -1
  30. package/package.json +7 -8
  31. package/references/citation-panels.md +2 -0
  32. package/references/cli.md +2 -2
  33. package/references/disavow.md +1 -1
  34. package/references/discovery-handoff.md +1 -1
  35. package/references/local-crm-draft-handoff.md +1 -1
  36. package/references/portable-ledger-handoff.md +11 -10
  37. package/references/recipes/catalog.json +263 -7
  38. package/references/recipes/command-notes.md +5 -3
  39. package/references/recipes/internal-link-improvement.md +25 -0
  40. package/references/recipes/placement-reconciliation.md +53 -1
  41. package/scripts/portable-handoff.py +17 -4
  42. package/shared/admission-contract.js +3 -2
  43. package/shared/admission-report-contract.js +3 -2
  44. package/shared/analytics-history-contract.js +15 -0
  45. package/shared/campaign-outcome-contract.js +12 -0
  46. package/shared/campaign-prospect-contract.js +23 -0
  47. package/shared/classification-contract.js +54 -0
  48. package/shared/contact-record-contract.js +27 -0
  49. package/shared/disavow-contract.js +37 -9
  50. package/shared/index-observation-contract.js +5 -4
  51. package/shared/internal-links-contract.js +143 -0
  52. package/shared/link-category-contract.js +35 -0
  53. package/shared/link-category.js +138 -0
  54. package/shared/link-labels.js +26 -0
  55. package/shared/link-summary-contract.js +17 -0
  56. package/shared/observation-event.js +1 -1
  57. package/shared/onboarding.js +20 -0
  58. package/shared/response-contract.js +38 -5
  59. package/shared/search-connection-contract.js +2 -1
  60. package/shared/table-page-contract.js +13 -0
  61. package/skills/agentlinkops-assets/SKILL-DEPS.json +290 -0
  62. package/skills/agentlinkops-assets/SKILL.md +3 -1
  63. package/skills/agentlinkops-assets/agents/openai.yaml +1 -1
  64. package/skills/agentlinkops-assets/references/portable/references/cli.md +220 -0
  65. package/skills/agentlinkops-assets/references/portable/references/disavow.md +73 -0
  66. package/skills/agentlinkops-assets/references/portable/references/reports.md +53 -0
  67. package/skills/agentlinkops-assets/references/portable/scripts/agentlinkops.py +676 -0
  68. package/skills/agentlinkops-assets/references/portable/scripts/disavow.py +207 -0
  69. package/skills/agentlinkops-assets/references/portable/scripts/migrations/001_crm.sql +45 -0
  70. package/skills/agentlinkops-assets/references/portable/scripts/migrations/002_cloud_events.sql +13 -0
  71. package/skills/agentlinkops-assets/references/portable/scripts/migrations/003_disavow.sql +20 -0
  72. package/skills/agentlinkops-assets/references/portable/scripts/migrations/004_placement_costs.sql +2 -0
  73. package/skills/agentlinkops-assets/references/portable/scripts/report.py +113 -0
  74. package/skills/agentlinkops-assets/references/portable/templates/campaigns.v1.json +65 -0
  75. package/skills/agentlinkops-campaigns/SKILL-DEPS.json +336 -0
  76. package/skills/agentlinkops-campaigns/SKILL.md +7 -5
  77. package/skills/agentlinkops-campaigns/agents/openai.yaml +1 -1
  78. package/skills/agentlinkops-campaigns/references/first-campaign.md +31 -0
  79. package/skills/agentlinkops-campaigns/references/mailbox-campaign.md +46 -0
  80. package/skills/agentlinkops-campaigns/references/portable/references/cli.md +220 -0
  81. package/skills/agentlinkops-campaigns/references/portable/references/disavow.md +73 -0
  82. package/skills/agentlinkops-campaigns/references/portable/references/reports.md +53 -0
  83. package/skills/agentlinkops-campaigns/references/portable/scripts/agentlinkops.py +676 -0
  84. package/skills/agentlinkops-campaigns/references/portable/scripts/disavow.py +207 -0
  85. package/skills/agentlinkops-campaigns/references/portable/scripts/migrations/001_crm.sql +45 -0
  86. package/skills/agentlinkops-campaigns/references/portable/scripts/migrations/002_cloud_events.sql +13 -0
  87. package/skills/agentlinkops-campaigns/references/portable/scripts/migrations/003_disavow.sql +20 -0
  88. package/skills/agentlinkops-campaigns/references/portable/scripts/migrations/004_placement_costs.sql +2 -0
  89. package/skills/agentlinkops-campaigns/references/portable/scripts/report.py +113 -0
  90. package/skills/agentlinkops-campaigns/references/portable/templates/campaigns.v1.json +65 -0
  91. package/skills/agentlinkops-connect/SKILL-DEPS.json +104 -0
  92. package/skills/agentlinkops-connect/SKILL.md +2 -0
  93. package/skills/agentlinkops-connect/agents/openai.yaml +1 -1
  94. package/skills/agentlinkops-connect/references/agentlinkops.md +164 -62
  95. package/skills/agentlinkops-connect/references/placement-workflows.md +19 -0
  96. package/skills/agentlinkops-crm/SKILL-DEPS.json +381 -0
  97. package/skills/agentlinkops-crm/SKILL.md +26 -9
  98. package/skills/agentlinkops-crm/agents/openai.yaml +1 -1
  99. package/skills/agentlinkops-crm/references/portable/references/cli.md +220 -0
  100. package/skills/agentlinkops-crm/references/portable/references/disavow.md +73 -0
  101. package/skills/agentlinkops-crm/references/portable/references/local-crm-draft-handoff.md +76 -0
  102. package/skills/agentlinkops-crm/references/portable/references/portable-ledger-handoff.md +51 -0
  103. package/skills/agentlinkops-crm/references/portable/references/reports.md +53 -0
  104. package/skills/agentlinkops-crm/references/portable/scripts/agentlinkops.py +676 -0
  105. package/skills/agentlinkops-crm/references/portable/scripts/disavow.py +207 -0
  106. package/skills/agentlinkops-crm/references/portable/scripts/migrations/001_crm.sql +45 -0
  107. package/skills/agentlinkops-crm/references/portable/scripts/migrations/002_cloud_events.sql +13 -0
  108. package/skills/agentlinkops-crm/references/portable/scripts/migrations/003_disavow.sql +20 -0
  109. package/skills/agentlinkops-crm/references/portable/scripts/migrations/004_placement_costs.sql +2 -0
  110. package/skills/agentlinkops-crm/references/portable/scripts/outreach-crm-draft.py +121 -0
  111. package/skills/agentlinkops-crm/references/portable/scripts/portable-handoff.py +197 -0
  112. package/skills/agentlinkops-crm/references/portable/scripts/report.py +113 -0
  113. package/skills/agentlinkops-crm/references/portable/templates/campaigns.v1.json +65 -0
  114. package/skills/agentlinkops-discovery/SKILL-DEPS.json +301 -0
  115. package/skills/agentlinkops-discovery/SKILL.md +4 -2
  116. package/skills/agentlinkops-discovery/references/portable/references/cli.md +220 -0
  117. package/skills/agentlinkops-discovery/references/portable/references/disavow.md +73 -0
  118. package/skills/agentlinkops-discovery/references/portable/references/discovery-handoff.md +32 -0
  119. package/skills/agentlinkops-discovery/references/portable/references/reports.md +53 -0
  120. package/skills/agentlinkops-discovery/references/portable/scripts/agentlinkops.py +676 -0
  121. package/skills/agentlinkops-discovery/references/portable/scripts/disavow.py +207 -0
  122. package/skills/agentlinkops-discovery/references/portable/scripts/migrations/001_crm.sql +45 -0
  123. package/skills/agentlinkops-discovery/references/portable/scripts/migrations/002_cloud_events.sql +13 -0
  124. package/skills/agentlinkops-discovery/references/portable/scripts/migrations/003_disavow.sql +20 -0
  125. package/skills/agentlinkops-discovery/references/portable/scripts/migrations/004_placement_costs.sql +2 -0
  126. package/skills/agentlinkops-discovery/references/portable/scripts/report.py +113 -0
  127. package/skills/agentlinkops-discovery/references/portable/templates/campaigns.v1.json +65 -0
  128. package/skills/agentlinkops-discovery/references/saved-backlink-workflows.md +23 -0
  129. package/src/competitors/contract.js +17 -1
  130. package/src/context/site-profile.js +34 -121
  131. package/src/corpus/acquire.js +68 -32
  132. package/src/corpus/extract.js +147 -0
  133. package/src/corpus/frontier.js +151 -0
  134. package/src/discovery/contract.js +8 -0
  135. package/src/discovery/import.js +13 -1
  136. package/src/mentions.js +193 -0
  137. package/src/page-acquisition/acquire.js +50 -0
  138. package/src/page-acquisition/bytes.js +138 -0
  139. package/src/page-acquisition/capture.js +11 -0
  140. package/src/page-acquisition/contracts.js +67 -0
  141. package/src/page-acquisition/documents.js +166 -0
  142. package/src/page-acquisition/http.js +307 -0
  143. package/src/page-acquisition/policy.js +20 -0
  144. package/src/page-acquisition/recovery.js +164 -0
  145. package/src/page-acquisition/render-failure.js +82 -0
  146. package/src/page-extraction/html.js +359 -0
  147. package/src/page-extraction/index.js +30 -0
  148. package/src/page-extraction/metadata.js +16 -0
  149. package/src/response-shaping.js +4 -3
  150. package/src/tool-views.js +22 -12
  151. package/src/verifier/fetch.js +4 -339
  152. package/src/verifier/index.js +81 -23
  153. package/src/verifier/page-quality.js +14 -0
  154. package/src/verifier/parser.js +2 -241
  155. package/src/verifier/readiness.js +64 -13
  156. package/src/verifier/render-evidence.js +25 -2
  157. package/src/verifier/target.js +201 -0
  158. package/src/verifier/transition.js +18 -3
  159. package/src/verifier/url.js +31 -0
  160. package/src/watch-crawlability.js +144 -0
  161. package/skills/content-defingerprinting/SKILL.md +0 -29
  162. package/skills/content-defingerprinting/scripts/watermarks/README.md +0 -102
  163. package/skills/content-defingerprinting/scripts/watermarks/UPSTREAM.json +0 -26
  164. package/skills/content-defingerprinting/scripts/watermarks/revendor.sh +0 -52
  165. package/skills/content-defingerprinting/scripts/watermarks/vendor/LICENSE.upstream +0 -21
  166. package/skills/content-defingerprinting/scripts/watermarks/vendor/clean_text.py +0 -96
  167. package/skills/content-defingerprinting/scripts/watermarks/vendor/common.py +0 -432
  168. package/skills/content-defingerprinting/scripts/watermarks/vendor/inspect_text.py +0 -86
  169. package/skills/content-defingerprinting/scripts/watermarks/vendor/score_stylometry.py +0 -460
  170. package/skills/content-defingerprinting/scripts/watermarks/vendor/text_unicode.py +0 -651
  171. package/skills/content-defingerprinting/scripts/watermarks/verify-vendor.py +0 -27
  172. package/skills/content-defingerprinting/scripts/watermarks/wm-scan.py +0 -212
  173. package/skills/internal-linking-optimizer/SKILL.md +0 -56
  174. package/skills/internal-linking-optimizer/references/source-prompt.md +0 -76
  175. package/skills/pre-publish-review/SKILL.md +0 -179
  176. package/skills/source-cited-content-builder/SKILL.md +0 -56
  177. package/skills/source-cited-content-builder/references/source-prompt.md +0 -19
  178. package/skills/source-cited-humanizer/SKILL.md +0 -20
package/cli/check.js CHANGED
@@ -12,23 +12,47 @@
12
12
  import { verifyLink } from '../src/verifier/index.js';
13
13
  import { observationRow } from './mirror.js';
14
14
  import { cadenceSeconds, DEFAULT_CADENCE } from './ledger.js';
15
+ import { createNodePublicFetch } from './public-http.js';
16
+ import { setTimeout as waitTimer } from 'node:timers/promises';
17
+ import { VerificationError, withAbort } from '../src/page-acquisition/bytes.js';
15
18
 
16
- const sleep = ms => new Promise(resolve => setTimeout(resolve, ms));
19
+ const sleep = async (ms, signal) => {
20
+ // Node's timer limit must not turn a very long publisher floor into 1 ms.
21
+ for (let remaining = ms; remaining > 0;) {
22
+ const duration = Math.min(remaining, 2_147_483_647);
23
+ try { await waitTimer(duration, undefined, { signal }); }
24
+ catch (error) { if (signal?.aborted) throw new VerificationError('timeout'); throw error; }
25
+ remaining -= duration;
26
+ }
27
+ };
17
28
  const hostOf = url => { try { return new URL(url).hostname; } catch { return null; } };
18
29
 
30
+ /** Private diagnostic prefixes are publisher bytes, even when HTML opt-out is set. */
31
+ export function portableCheckObservation(result) {
32
+ const observed = structuredClone(result);
33
+ const diagnostics = [observed.robots, ...(observed.robotsHistory ?? []), observed.sourceResponse];
34
+ for (const item of diagnostics) if (item?.bodyDiagnostic) delete item.bodyDiagnostic.sampleText;
35
+ return observed;
36
+ }
37
+
19
38
  /** A courtesy floor per host, honouring a publisher's own stated delay when it is longer. */
20
39
  export function createPacer({ hostDelayMs = 2000, now = () => Date.now(), wait = sleep } = {}) {
21
- const nextAllowed = new Map();
40
+ const lastRequest = new Map();
22
41
  return {
23
42
  async beforeFetch(url, context = {}) {
43
+ if (context.signal?.aborted) throw new VerificationError('timeout');
24
44
  const host = hostOf(url);
25
45
  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;
46
+ const prior = lastRequest.get(host);
47
+ const stated = context.crawlDelaySeconds == null ? prior?.floor ?? 0 : Number(context.crawlDelaySeconds) * 1000;
48
+ const floor = Math.max(hostDelayMs, Number.isFinite(stated) ? stated : prior?.floor ?? 0);
49
+ // Robots was just fetched before its delay became known. Apply the new
50
+ // floor to that preceding request, not only to the following one.
51
+ const allowed = prior ? prior.at + Math.max(prior.floor, floor) : 0;
29
52
  const delay = allowed - now();
30
- if (delay > 0) await wait(delay);
31
- nextAllowed.set(host, now() + floor);
53
+ if (delay > 0) await withAbort(wait(delay, context.signal), context.signal);
54
+ if (context.signal?.aborted) throw new VerificationError('timeout');
55
+ lastRequest.set(host, { at: now(), floor });
32
56
  },
33
57
  };
34
58
  }
@@ -65,9 +89,14 @@ export function dueEntries(entries, lastChecked, { now = Date.now() } = {}) {
65
89
  */
66
90
  export async function runCheck(entries, {
67
91
  concurrency = 6, timeoutMs = 20_000, hostDelayMs = 2000,
68
- verify = verifyLink, pacer = null, onResult = null,
92
+ verify = verifyLink, pacer = null, onResult = null, fetchImpl = null,
69
93
  } = {}) {
94
+ if (fetchImpl != null && typeof fetchImpl !== 'function') throw new TypeError('A local check transport must be a function.');
70
95
  const paced = pacer ?? createPacer({ hostDelayMs });
96
+ // A trusted in-process caller may supply a fixture transport. Normal CLI
97
+ // commands always use the pinned Node connector, independently of global fetch.
98
+ const nativeTransport = fetchImpl == null;
99
+ const localFetch = fetchImpl ?? createNodePublicFetch();
71
100
  const byHost = new Map();
72
101
  for (const entry of entries) {
73
102
  const host = hostOf(entry.source) ?? entry.id;
@@ -84,8 +113,9 @@ export async function runCheck(entries, {
84
113
  expectedAnchor: entry.expect?.anchor, expectedRel: entry.expect?.rel },
85
114
  // `includeHtml: false` is a contract commitment, not a size optimisation: the bytes
86
115
  // must not reach the repository.
87
- { publicFetchSafe: true, timeoutMs, includeHtml: false, beforeFetch: paced.beforeFetch });
88
- const row = observationRow(entry.id, result);
116
+ { publicFetchSafe: true, fetchImpl: localFetch, timeoutMs, includeHtml: false, beforeFetch: paced.beforeFetch,
117
+ ...(verify === verifyLink && nativeTransport ? { captureTransport: { id: 'local_node_http', version: 'alo-local-node-v1' } } : {}) });
118
+ const row = observationRow(entry.id, portableCheckObservation(result));
89
119
  rows.set(entry.id, row);
90
120
  onResult?.(entry, row);
91
121
  }
package/cli/client.js CHANGED
@@ -5,7 +5,7 @@ export {CloudError} from './cloud-error.js';
5
5
  // A small HTTP client for the cloud. Scoped keys only; no human session ever reaches here.
6
6
  // DP-0036-T13: the CLI names itself so the usage counters can tell it from other REST callers.
7
7
  // The version follows the plugin manifests; bump it with them.
8
- export const CLI_VERSION = '0.6.13';
8
+ export const CLI_VERSION = '0.6.16';
9
9
  export const CLI_USER_AGENT = `agentlinkops-cli/${CLI_VERSION}`;
10
10
  const MAX_ATTEMPTS = 3;
11
11
  const MAX_RETRY_WAIT_MS = 5_000;
@@ -113,6 +113,7 @@ export function createClient({ origin, token, workspaceId, projectId, fetchImpl
113
113
  callCommand: (name, args = {}) => call('POST', `/v1/commands/${encodeURIComponent(name)}`, { body: args }),
114
114
  getWorkspace: () => call('GET', '/v1/workspace'),
115
115
  getProject: projectId => call('GET', `/v1/projects/${encodeURIComponent(projectId)}`),
116
+ listProjects: query => call('GET', '/v1/projects', { query }),
116
117
  importWatches: (projectId, watches) => call('POST', '/v1/watches/import', { body: { projectId, watches } }),
117
118
  updateWatch: (watchId, patch) => call('PATCH', `/v1/watches/${encodeURIComponent(watchId)}`, { body: patch }),
118
119
  listWatches: query => call('GET', '/v1/watches', { query: { ...query, ...(projectId ? { projectId } : {}) } }),
package/cli/commands.js CHANGED
@@ -1,11 +1,11 @@
1
1
  import { readFile } from 'node:fs/promises';
2
2
  import { resolve } from 'node:path';
3
3
  import { loadConfig, ConfigError } from './config.js';
4
- import { cloudConnection } from './connection.js';
4
+ import { cloudConnection, DEFAULT_ORIGIN } from './connection.js';
5
5
  import { createClient, CloudError } from './client.js';
6
6
  import { parseArgs } from './args.js';
7
7
  import { NUDGE } from './skill.js';
8
- import { CATALOG_COMMANDS, CATALOG_TOOLSETS } from './catalog.js';
8
+ import { CATALOG_COMMANDS, CATALOG_VERSION, CATALOG_TOOLSETS } from './catalog.js';
9
9
  import { oneLiner } from '../src/tool-views.js';
10
10
  import { resolveSchemaPath, listAvailablePaths } from '../shared/exec-grammar.js';
11
11
 
@@ -24,7 +24,7 @@ const NEXT = {
24
24
  INSUFFICIENT_SCOPE: 'This credential lacks a scope the command needs; request it through your connection or a new project-scoped key.',
25
25
  NO_CLOUD_ORIGIN: 'Run `agentlinkops connect` or set AGENTLINKOPS_API_URL.',
26
26
  INVALID_CLOUD_ORIGIN: 'Cloud commands need an HTTPS origin without a path, for example https://app.agentlinkops.com.',
27
- NO_TOKEN: 'Set AGENTLINKOPS_TOKEN or AGENTLINKOPS_API_KEY from a project-scoped key.',
27
+ NO_TOKEN: 'Run `npx -y agentlinkops connect` once in a terminal and paste an API key from the Agent page; scripts can set AGENTLINKOPS_API_KEY instead.',
28
28
  PROVIDER_NOT_CONFIGURED: 'An operator must enable this supplier admission; retrying does not change it.',
29
29
  OWNED_DISCOVERY_NOT_CONFIGURED: 'An operator must configure owned discovery; retrying does not change it.',
30
30
  IDEMPOTENCY_CONFLICT: 'Reuse an idempotency key only with identical arguments; choose a new key for a new request.',
@@ -34,9 +34,16 @@ export const explain = code => NEXT[code] ?? 'Run `agentlinkops describe NAME` a
34
34
  export const resolveName = (commands, name) => commands.find(c => c.name === name) ?? commands.find(c => c.aliases?.includes(name)) ?? null;
35
35
 
36
36
  // The index: grouped by toolset, one line per command, core and write and admission marked.
37
+ // Both forms drop per-command summaries when the complete index would exceed its token estimate
38
+ // (2,000 for the terminal, 6,000 for JSON); one toolset keeps them, and describe has the rest.
37
39
  export function renderIndex(commands, { toolset = null, json = false } = {}) {
38
40
  const selected = toolset ? commands.filter(c => c.toolset === toolset) : commands;
39
- 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, 64), ...(c.admissionGated ? { admission: 'gated' } : {}), ...(c.aliases?.length ? { aliases: c.aliases } : {}) })), next: 'Run `agentlinkops describe NAME` for a schema, then `agentlinkops call NAME --args JSON`.' });
41
+ if (json) {
42
+ const index = summaries => JSON.stringify({ version:CATALOG_VERSION,count:selected.length,toolsets: toolset ? { [toolset]: CATALOG_TOOLSETS[toolset] } : CATALOG_TOOLSETS, defaults: { tier: 'deferred' }, commands: selected.map(c => ({ name: c.name, toolset: c.toolset, ...(c.tier === 'deferred' ? {} : { tier: c.tier }), readOnly: c.readOnly, ...(summaries ? { summary: oneLiner(c.description, 40) } : {}), ...(c.admissionGated ? { admission: 'gated' } : {}), ...(c.aliases?.length ? { aliases: c.aliases } : {}) })),
43
+ next: (summaries ? '' : 'Run `agentlinkops tools TOOLSET` for one-line summaries. ') + 'Run `agentlinkops describe NAME` for a schema, then `agentlinkops call NAME --args JSON`.' });
44
+ const full = index(true);
45
+ return Math.round(full.length / 3.6) < 6000 ? full : index(false);
46
+ }
40
47
  const groups = new Map();
41
48
  for (const c of selected) (groups.get(c.toolset) ?? groups.set(c.toolset, []).get(c.toolset)).push(c);
42
49
  const lines = [];
@@ -46,7 +53,9 @@ export function renderIndex(commands, { toolset = null, json = false } = {}) {
46
53
  lines.push('');
47
54
  }
48
55
  lines.push(`${selected.length} commands${toolset ? ` in ${toolset}` : ''}. describe NAME shows the schema; call NAME --args JSON runs it.`);
49
- return lines.join('\n');
56
+ const full = lines.join('\n');
57
+ if (Math.round(full.length / 3.6) < 2000) return full;
58
+ return lines.map(line => line.startsWith(' ') ? line.slice(0, line.indexOf(' ', 2)) : line).join('\n');
50
59
  }
51
60
 
52
61
  // One definition: what a describe_tools call returns, printed for a terminal or as JSON.
@@ -137,12 +146,12 @@ export async function commandsMain(argv, { cwd = process.cwd(), env = process.en
137
146
  // Catalog: bundled snapshot, or the live one when asked (needs a configured origin and key).
138
147
  let commands = CATALOG_COMMANDS, client = null;
139
148
  const connect = () => {
149
+ // The hosted service unless an origin is configured; the key decides the workspace.
140
150
  const connection = cloudConnection(loadConfigSync(), env);
141
- if (!connection.origin) throw new CloudError('NO_CLOUD_ORIGIN', 0);
142
151
  let origin;
143
- try { origin = new URL(connection.origin); } catch { throw new CloudError('INVALID_CLOUD_ORIGIN', 0); }
152
+ try { origin = new URL(connection.origin || DEFAULT_ORIGIN); } catch { throw new CloudError('INVALID_CLOUD_ORIGIN', 0); }
144
153
  if (origin.protocol !== 'https:' || origin.username || origin.password || origin.pathname !== '/' || origin.search || origin.hash) throw new CloudError('INVALID_CLOUD_ORIGIN', 0);
145
- return createClient({ ...connection, fetchImpl });
154
+ return createClient({ ...connection, origin: origin.origin, fetchImpl });
146
155
  };
147
156
  let loaded = null;
148
157
  const loadConfigSync = () => loaded;
package/cli/connection.js CHANGED
@@ -1,5 +1,5 @@
1
1
  import { acquireSyncLock } from './sync-lock.js';
2
- import { readFile, writeFile, rename, unlink, open } from 'node:fs/promises';
2
+ import { readFile, writeFile, rename, unlink, open, stat } from 'node:fs/promises';
3
3
  import { join, resolve } from 'node:path';
4
4
  import { loadConfig, ConfigError } from './config.js';
5
5
  import { createClient } from './client.js';
@@ -7,8 +7,10 @@ import { readLedger } from './ledger.js';
7
7
  import { resolveEnv } from './env.js';
8
8
  import { createOAuthCredentialStore } from './oauth-credentials.js';
9
9
  import { discoverOAuth, receiveOAuthConsent, exchangeOAuthCode } from './oauth-flow.js';
10
+ import { API_KEY_PATTERN, STORED_KEY } from './key-store.js';
10
11
 
11
12
  export const SYNC_SCOPES = ['projects:read', 'watches:read', 'watches:write', 'events:read', 'exports:create'];
13
+ export const DEFAULT_ORIGIN = 'https://app.agentlinkops.com';
12
14
 
13
15
  // Both TOKEN and API_KEY have shipped in customer installations, under both the AGENTLINKOPS_
14
16
  // and the LINKTRAIL_ prefix (env.js owns that alias rule). Reject ambiguous identities: a
@@ -22,9 +24,16 @@ export function cloudConnection(config, env = process.env) {
22
24
  throw new ConfigError(`${sources.API_URL} disagrees with the saved cloud origin.`);
23
25
  if (new Set([config.cloud?.workspaceId, config.cloud?.workspace_id].filter(Boolean)).size > 1 || new Set([config.project?.id, config.cloud?.projectId, config.cloud?.project_id].filter(Boolean)).size > 1)
24
26
  throw new ConfigError('Saved cloud identity fields disagree.');
25
- const origin = values.API_URL || config.cloud?.origin;
26
- const token = values.TOKEN || values.API_KEY || config.cloud?.token;
27
+ let origin = values.API_URL || config.cloud?.origin;
28
+ let token = values.TOKEN || values.API_KEY || config.cloud?.token;
29
+ let tokenSource = values.TOKEN ? sources.TOKEN : values.API_KEY ? sources.API_KEY : null, storedKey = false;
27
30
  const auth = config.cloud?.auth;
31
+ // Otherwise the key `agentlinkops connect` saved for this origin, or for the default origin when
32
+ // none is configured. Only main() attaches the reader, so library callers and tests see none.
33
+ if (!token && auth === undefined && typeof env[STORED_KEY] === 'function') {
34
+ const saved = env[STORED_KEY](origin || DEFAULT_ORIGIN);
35
+ if (saved) ({ key: token, where: tokenSource } = saved), origin ||= DEFAULT_ORIGIN, storedKey = true;
36
+ }
28
37
  if (auth !== undefined) {
29
38
  if (!auth || auth.kind !== 'oauth' || token || Object.keys(auth).sort().join(',') !== 'clientId,credentialRef,issuer,kind,resource')
30
39
  throw new ConfigError('Saved OAuth connection conflicts with an API key or has an invalid authentication mode.');
@@ -36,24 +45,58 @@ export function cloudConnection(config, env = process.env) {
36
45
  throw new ConfigError('Saved OAuth binding is invalid.');
37
46
  }
38
47
  return { origin, ...(auth ? { auth: { ...auth } } : {}),
39
- token,
40
- tokenSource: values.TOKEN ? sources.TOKEN : values.API_KEY ? sources.API_KEY : null,
48
+ token, tokenSource, ...(storedKey ? { storedKey } : {}),
41
49
  workspaceId: config.cloud?.workspaceId || config.cloud?.workspace_id, projectId: config.project?.id || config.cloud?.projectId || config.cloud?.project_id };
42
50
  }
43
51
 
44
52
  export function keySetup(origin) {
45
- return `Open ${origin}/app, select the workspace, then Agent access > Create API key. Select the project and scopes ${SYNC_SCOPES.join(', ')}. Supply the key through AGENTLINKOPS_TOKEN (AGENTLINKOPS_API_KEY also works; the LINKTRAIL_ spellings remain accepted during the pilot compatibility window), then rerun connect. For CLI OAuth on Linux, use connect --oauth with an existing public client; credentials stay in the user-owned POSIX-mode store. On other platforms, use OAuth in your MCP client. MCP clients manage their own credentials.`;
53
+ return `Create an API key on the Agent page (${origin}/#/access, Create API key), then run \`npx -y agentlinkops connect\` in a terminal and paste it when asked; the CLI keeps it outside your repository. Scripts and CI can supply it through AGENTLINKOPS_API_KEY instead (AGENTLINKOPS_TOKEN also works; the LINKTRAIL_ spellings remain accepted during the pilot compatibility window). Ledger sync needs the scopes ${SYNC_SCOPES.join(', ')}. For CLI OAuth on Linux, use connect --oauth with an existing public client; credentials stay in the user-owned POSIX-mode store. On other platforms, use OAuth in your MCP client. MCP clients manage their own credentials.`;
54
+ }
55
+
56
+ const pastePrompt = origin => `Paste an API key from ${origin}/#/access (Create API key), or press Enter to cancel: `;
57
+
58
+ /**
59
+ * The CLI half of connecting: a key that works, saved where cloud commands find it. Asks for one
60
+ * when none is available and a terminal is (ask), and again when a saved key stopped working.
61
+ * Verifies before saving; never prints the key. Returns null when there is no key to use.
62
+ */
63
+ export async function connectKey({ origin, token = null, stored = false, ask = null, prompt = pastePrompt(origin), keyStore = null,
64
+ fetchImpl = globalThis.fetch, clientFactory = createClient, out = console.log }) {
65
+ let pasted = false, account;
66
+ for (;;) {
67
+ if (!token) {
68
+ token = ask ? String(await ask(prompt) ?? '').trim() : '';
69
+ if (!token) return null;
70
+ pasted = true;
71
+ }
72
+ if (!API_KEY_PATTERN.test(token))
73
+ throw new ConfigError(pasted ? 'That is not an AgentLinkOps API key (keys start with lt_). Create one on the Agent page and paste all of it.' : 'Supply a complete API key through AGENTLINKOPS_TOKEN or AGENTLINKOPS_API_KEY.');
74
+ try { account = await clientFactory({ origin, token, fetchImpl }).getWorkspace(); break; }
75
+ catch (error) {
76
+ if (!(stored && !pasted && ask && error?.status === 401)) throw error;
77
+ out('The saved key no longer works (revoked or expired).');
78
+ token = null;
79
+ }
80
+ }
81
+ if (typeof account?.workspace?.id !== 'string' || !account.workspace.id) throw new ConfigError('The cloud did not name the workspace this key belongs to.');
82
+ if (pasted) {
83
+ if (!keyStore) throw new ConfigError('This command cannot save a key here. Set AGENTLINKOPS_API_KEY instead.');
84
+ await keyStore.save(origin, token);
85
+ out(`Saved the key in ${keyStore.where}.`);
86
+ }
87
+ return { token, account, saved: pasted };
46
88
  }
47
89
 
48
90
  // Verify an existing project before saving only non-secret connection metadata.
49
91
  export async function connectMain(args, { cwd, env = process.env, out = console.log, fetchImpl = globalThis.fetch,
50
92
  credentialStore = createOAuthCredentialStore(), oauth = { discoverOAuth, receiveOAuthConsent, exchangeOAuthCode },
51
- onAuthorization = ({ authorizationUrl }) => out(`Authorize the connection: ${authorizationUrl}`), clientFactory = createClient } = {}) {
93
+ onAuthorization = ({ authorizationUrl }) => out(`Authorize the connection: ${authorizationUrl}`), clientFactory = createClient,
94
+ keyStore = null, ask = null } = {}) {
52
95
  const config = await loadConfig({ cwd });
53
96
  const connection = cloudConnection(config, env);
54
- const origin = args.origin || connection.origin || 'https://app.agentlinkops.com';
55
- const workspaceId = args.workspace || connection.workspaceId;
56
- const projectId = args['project-id'] || connection.projectId;
97
+ const origin = args.origin || connection.origin || DEFAULT_ORIGIN;
98
+ let workspaceId = args.workspace || connection.workspaceId;
99
+ let projectId = args['project-id'] || connection.projectId;
57
100
  let parsed;
58
101
  try { parsed = new URL(origin); } catch { throw new ConfigError('connect --origin requires an HTTPS origin.'); }
59
102
  if (parsed.protocol !== 'https:' || parsed.username || parsed.password || parsed.search || parsed.hash || parsed.pathname !== '/')
@@ -62,13 +105,30 @@ export async function connectMain(args, { cwd, env = process.env, out = console.
62
105
  if (args.oauth !== undefined && args.oauth !== true) throw new ConfigError('--oauth does not take a value.');
63
106
  if (!oauthRequested && ['issuer', 'client-id', 'credential-ref'].some(key => args[key] !== undefined))
64
107
  throw new ConfigError('OAuth options require --oauth.');
65
- if (oauthRequested && connection.token) throw new ConfigError('Remove API-key credentials before selecting OAuth.');
66
- if (!oauthRequested && !connection.token) { out(keySetup(parsed.origin)); return 2; }
67
- if (!oauthRequested && !/^lt_[0-9a-f]{64}$/.test(connection.token)) throw new ConfigError('Supply a complete API key through AGENTLINKOPS_TOKEN or AGENTLINKOPS_API_KEY.');
68
- if (typeof workspaceId !== 'string' || !workspaceId || typeof projectId !== 'string' || !projectId)
69
- throw new ConfigError('connect requires --workspace ID and --project-id ID from the existing cloud project.');
70
- if (config.cloud?.origin && new URL(config.cloud.origin).origin !== parsed.origin || connection.workspaceId && connection.workspaceId !== workspaceId || connection.projectId && connection.projectId !== projectId)
108
+ if (oauthRequested && connection.token && !connection.storedKey) throw new ConfigError('Remove API-key credentials before selecting OAuth.');
109
+ if (config.cloud?.origin && new URL(config.cloud.origin).origin !== parsed.origin || connection.workspaceId && workspaceId !== connection.workspaceId || connection.projectId && projectId !== connection.projectId)
71
110
  throw new ConfigError('This ledger is connected elsewhere; use a separate ledger to preserve its cursors and mapping.');
111
+ if (oauthRequested && (typeof workspaceId !== 'string' || !workspaceId || typeof projectId !== 'string' || !projectId))
112
+ throw new ConfigError('connect --oauth requires --workspace ID and --project-id ID from the existing cloud project.');
113
+ // A key saved for one origin never travels to another.
114
+ let token = oauthRequested ? undefined : connection.storedKey && connection.origin !== parsed.origin ? env[STORED_KEY]?.(parsed.origin)?.key : connection.token;
115
+ const hasLedger = await stat(join(config.dir, 'config.json')).then(() => true, () => false);
116
+ let keyWhere = connection.storedKey ? connection.tokenSource : null;
117
+ if (!oauthRequested && (!token || !workspaceId)) {
118
+ // No IDs to look up: the key names its workspace. Without a ledger here, this connects the CLI
119
+ // for cloud commands in any folder; with one, it goes on to map the ledger to a website.
120
+ const key = await connectKey({ origin: parsed.origin, token, stored: connection.storedKey === true, ask, keyStore, fetchImpl, clientFactory, out });
121
+ if (!key) { out(keySetup(parsed.origin)); return 2; }
122
+ token = key.token;
123
+ if (key.saved) keyWhere = keyStore.where;
124
+ workspaceId ||= key.account.workspace.id;
125
+ if (!hasLedger && !projectId) {
126
+ out(`Connected the CLI to ${key.account.workspace.name || 'your workspace'}. Cloud commands work from any folder now, for example: agentlinkops call list_tasks`);
127
+ return 0;
128
+ }
129
+ }
130
+ if (!oauthRequested && !API_KEY_PATTERN.test(token)) throw new ConfigError('Supply a complete API key through AGENTLINKOPS_TOKEN or AGENTLINKOPS_API_KEY.');
131
+ if (!hasLedger) throw new ConfigError('No ledger here to map to a website: run `agentlinkops init` first, or omit --project-id to connect the CLI only.');
72
132
  let ledgerIds;
73
133
  if (args.selection !== undefined) {
74
134
  if (typeof args.selection !== 'string') throw new ConfigError('--selection requires a JSON file of ledger IDs or mapping entries.');
@@ -106,9 +166,23 @@ export async function connectMain(args, { cwd, env = process.env, out = console.
106
166
  }
107
167
  }
108
168
  try {
109
- const client = clientFactory({ origin: parsed.origin, token: connection.token, ...(auth ? {auth, credentialStore} : {}), workspaceId, projectId, fetchImpl });
169
+ const client = clientFactory({ origin: parsed.origin, token, ...(auth ? {auth, credentialStore} : {}), workspaceId, projectId, fetchImpl });
110
170
  const account = await client.getWorkspace();
111
171
  if (account.workspace?.id !== workspaceId) throw new ConfigError('Cloud workspace does not match the selected workspace.');
172
+ if (!projectId) {
173
+ // Which website this ledger tracks: the key's only website, or the workspace's only one.
174
+ const allowed = account.access?.project_ids;
175
+ const projects = Array.isArray(allowed) && allowed.length === 1 ? null : ((await client.listProjects({ limit: 100 })).items ?? [])
176
+ .filter(project => !Array.isArray(allowed) || allowed.includes(project.id));
177
+ if (!projects) projectId = allowed[0];
178
+ else if (projects.length === 1) projectId = projects[0].id;
179
+ else {
180
+ out(projects.length
181
+ ? ['Choose the website this ledger tracks, then run its command:', ...projects.map(project => ` agentlinkops connect --project-id ${project.id} # ${project.domain || project.name || project.id}`)].join('\n')
182
+ : `Add a website on ${parsed.origin}/#/websites first, then run agentlinkops connect again.`);
183
+ return 2;
184
+ }
185
+ }
112
186
  // MCP readback does not expose an authoritative effective write grant.
113
187
  const missing = auth ? [] : SYNC_SCOPES.filter(scope => !account.access?.scopes?.includes(scope));
114
188
  if (missing.length) throw new ConfigError(`Key lacks sync scopes: ${missing.join(', ')}.`);
@@ -142,7 +216,7 @@ export async function connectMain(args, { cwd, env = process.env, out = console.
142
216
  }
143
217
  out(auth
144
218
  ? `Verified workspace ${workspaceId}, project ${projectId}. Saved OAuth credential reference; write permission remains unverified. Run agentlinkops sync --pull-only to retrieve history.`
145
- : `Verified workspace ${workspaceId}, project ${projectId}. Saved connection metadata; keep the key in the environment. Run agentlinkops sync --dry-run to review uploads, or agentlinkops sync --pull-only to retrieve history.`);
219
+ : `Verified workspace ${workspaceId}, project ${projectId}. Saved connection metadata; ${keyWhere ? `the key stays in ${keyWhere}` : 'keep the key in the environment'}. Run agentlinkops sync --dry-run to review uploads, or agentlinkops sync --pull-only to retrieve history.`);
146
220
  return 0;
147
221
  } catch (error) {
148
222
  // A failed connection must not strand a newly created local secret.
@@ -0,0 +1,91 @@
1
+ import {createHash} from 'node:crypto';
2
+ import {contactFields} from '../shared/contact-record-contract.js';
3
+
4
+ const digest=value=>createHash('sha256').update(JSON.stringify(value)).digest('hex');
5
+ const fields=c=>({externalRef:c.externalRef,name:c.name,email:c.email,contactUrl:c.contactUrl});
6
+ const equal=(a,b)=>JSON.stringify(a)===JSON.stringify(b);
7
+
8
+ /** A contact-only bridge. Inputs are frozen exports; there are no source-CRM writes. */
9
+ export function planContactAdoption({projectId,rows,contacts=[],links=[]}){
10
+ const seen=new Map(),results=[];
11
+ for(const [index,row]of rows.entries()){
12
+ const parsed=contactFields.safeParse(row.contact);
13
+ let reason=!parsed.success?'invalid_contact':null;
14
+ const contact=parsed.success?parsed.data:null;
15
+ const sameRef=contact?contacts.filter(c=>c.externalRef===contact.externalRef):[];
16
+ if(sameRef.length>1)reason='ambiguous_contact_reference';
17
+ if(contact&&seen.has(contact.externalRef)&&!equal(seen.get(contact.externalRef),contact))reason='conflicting_source_contact';
18
+ if(contact)seen.set(contact.externalRef,contact);
19
+ const matches=links.filter(l=>l.localReference===row.localReference);
20
+ if(!row.localReference||matches.length!==1)reason=matches.length>1?'ambiguous_link_reference':'link_reference_not_found';
21
+ const beforeContact=sameRef[0]??null,beforeLink=matches[0]??null;
22
+ if(beforeContact&&beforeContact.projectId!==projectId||beforeLink&&beforeLink.projectId!==projectId)reason='project_mismatch';
23
+ results.push({row:index+1,localReference:row.localReference??null,contact,beforeContact,beforeLink,status:reason?'conflict':'ready',reason});
24
+ }
25
+ // Reject every row in a conflicting identity group; input order cannot pick a winner.
26
+ const bad=new Set(results.filter(r=>r.reason==='conflicting_source_contact').map(r=>r.contact.externalRef));
27
+ for(const row of results)if(bad.has(row.contact?.externalRef)){row.status='conflict';row.reason='conflicting_source_contact';}
28
+ const byLink=new Map();
29
+ for(const row of results){if(!row.beforeLink)continue;const group=byLink.get(row.beforeLink.watchId)??[];group.push(row);byLink.set(row.beforeLink.watchId,group);}
30
+ for(const group of byLink.values())if(new Set(group.map(row=>row.contact?.externalRef)).size>1)for(const row of group){row.status='conflict';row.reason='conflicting_link_contact';}
31
+ const plan={schemaVersion:'agentlinkops-contact-adoption-v1',projectId,results,effects:{sourceWrites:0,emails:0,sequenceEnrollments:0,newWatches:0,checks:0}};
32
+ return{...plan,sha256:digest(plan)};
33
+ }
34
+
35
+ function verifyPlan(plan){const {sha256,...body}=plan;if(body.schemaVersion!=='agentlinkops-contact-adoption-v1'||digest(body)!==sha256)throw new Error('ADOPTION_PLAN_CHANGED');}
36
+ /** Caller supplies the authenticated command transport; no credentials or mailbox are read here. */
37
+ export async function applyContactAdoption(plan,call){
38
+ verifyPlan(plan);const results=[],effects={...plan.effects,confirmedContactWrites:0,confirmedAssociationWrites:0,rowsRequiringReadback:0};
39
+ for(const row of plan.results){
40
+ if(row.status!=='ready'){results.push({row:row.row,status:'conflict',reason:row.reason});continue;}
41
+ const base={projectId:plan.projectId};let saved=null,writeAttempted=false;
42
+ try{
43
+ const current=await call('list_contacts',{...base,externalRef:row.contact.externalRef,watchId:row.beforeLink.watchId,limit:2});
44
+ const contact=current.items[0]??null;
45
+ if(current.items.length>1)throw new Error('CONTACT_IDENTITY_CONFLICT');
46
+ // Exact desired content is a safe replay. A changed source preview never overwrites a human edit.
47
+ if(contact&&!equal(fields(contact),row.contact)&&(!row.beforeContact||contact.revision!==row.beforeContact.revision||!equal(fields(contact),fields(row.beforeContact))))throw new Error('CONTACT_CHANGED_SINCE_PREVIEW');
48
+ if(!contact&&row.beforeContact)throw new Error('CONTACT_REMOVED_SINCE_PREVIEW');
49
+ if(current.link?.contactId!==row.beforeLink.contactId&&current.link?.contactId!==contact?.id)throw new Error('LINK_CHANGED_SINCE_PREVIEW');
50
+ writeAttempted=true;saved=await call('save_contact',{...base,revision:contact?.revision??0,contact:row.contact});
51
+ if(!saved.replayed)effects.confirmedContactWrites++;
52
+ const linked=await call('set_link_contact',{...base,watchId:row.beforeLink.watchId,contactId:saved.contact.id,revision:current.link?.revision??0});
53
+ if(linked.revision!==(current.link?.revision??0))effects.confirmedAssociationWrites++;
54
+ results.push({row:row.row,status:'applied',contactId:saved.contact.id,contactRevision:saved.contact.revision,link:linked,replayed:saved.replayed,rollback:{beforeContact:row.beforeContact,beforeLink:row.beforeLink,afterContact:saved.contact,afterLink:linked}});
55
+ }catch(error){if(writeAttempted)effects.rowsRequiringReadback++;results.push({row:row.row,status:'conflict',reason:error.code??error.message,effectState:writeAttempted?'requires_readback':'none',...(saved?{contactSaved:true,contactId:saved.contact.id,contactRevision:saved.contact.revision}:{} )});}
56
+ }
57
+ return{schemaVersion:plan.schemaVersion,planSha256:plan.sha256,projectId:plan.projectId,results,effects};
58
+ }
59
+
60
+ /** Explicit compensating rollback. Concurrent edits hold the row; sender/source data is untouched. */
61
+ export async function rollbackContactAdoption(receipt,call){
62
+ const results=[],effects={sourceWrites:0,emails:0,sequenceEnrollments:0,newWatches:0,checks:0,confirmedAssociationWrites:0,confirmedContactWrites:0,confirmedContactDeletes:0,rowsRequiringReadback:0};
63
+ for(const result of [...receipt.results].reverse()){
64
+ if(!result.rollback){results.push({row:result.row,status:'no_effect'});continue;}
65
+ const {beforeContact,beforeLink,afterContact,afterLink}=result.rollback,base={projectId:receipt.projectId};
66
+ try{
67
+ const current=await call('list_contacts',{...base,externalRef:afterContact.externalRef,watchId:afterLink.watchId,limit:2});
68
+ const contact=current.items[0];
69
+ if(current.link.contactId===beforeLink.contactId&&(!beforeContact&&!contact||beforeContact&&contact&&equal(fields(contact),fields(beforeContact)))){results.push({row:result.row,status:'already_restored'});continue;}
70
+ if(!contact||contact.revision!==afterContact.revision||!equal(fields(contact),fields(afterContact)))throw new Error('ROLLBACK_CONCURRENT_CHANGE');
71
+ if(current.link.contactId!==beforeLink.contactId){
72
+ if(current.link.contactId!==afterLink.contactId||current.link.revision!==afterLink.revision)throw new Error('ROLLBACK_CONCURRENT_CHANGE');
73
+ const restoredLink=await call('set_link_contact',{...base,watchId:afterLink.watchId,contactId:beforeLink.contactId,revision:afterLink.revision});
74
+ if(restoredLink.revision!==afterLink.revision)effects.confirmedAssociationWrites++;
75
+ }
76
+ // Newly created contacts can be shared by later rows. Restore/delete once, after all links.
77
+ results.push({row:result.row,status:'link_restored',contactId:afterContact.id,beforeContact,afterContact});
78
+ }catch(error){effects.rowsRequiringReadback++;results.push({row:result.row,status:'held',reason:error.code??error.message});}
79
+ }
80
+ const restored=new Set();
81
+ for(const row of results){
82
+ if(row.status!=='link_restored'||restored.has(row.contactId))continue;
83
+ // Do not erase a new contact if another row failed to detach; preserve useful partial state.
84
+ if(results.some(r=>r.status==='held'))continue;
85
+ restored.add(row.contactId);
86
+ try{if(row.beforeContact){const saved=await call('save_contact',{projectId:receipt.projectId,revision:row.afterContact.revision,contact:fields(row.beforeContact)});if(!saved.replayed)effects.confirmedContactWrites++;}
87
+ else {const removed=await call('delete_contact',{projectId:receipt.projectId,contactId:row.contactId,revision:row.afterContact.revision,onlyIfUnlinked:true});if(removed.deleted)effects.confirmedContactDeletes++;}row.status='restored';
88
+ }catch(error){effects.rowsRequiringReadback++;row.status='held';row.reason=error.code??error.message;}
89
+ }
90
+ return{planSha256:receipt.planSha256,results,effects};
91
+ }
package/cli/context.js CHANGED
@@ -19,7 +19,9 @@ import {
19
19
  readContextState, readGscRows, refreshContext, pageContext, selectFocusPages,
20
20
  importHandoff, forgetContext, createGoogleClient, recentWindows, windowFromRange,
21
21
  } from '../src/context/gsc.js';
22
- import { buildSiteProfile, renderSiteProfile, validateProfileCitations, readSiteFacts } from '../src/context/site-profile.js';
22
+ import { buildSiteProfile, createSiteTransport, renderSiteProfile, validateProfileCitations, readSiteFacts } from '../src/context/site-profile.js';
23
+ import { createAcquisition } from '../src/corpus/acquire.js';
24
+ import { createNodePublicFetch } from './public-http.js';
23
25
  import {
24
26
  refreshGa4Context, landingPageContext, keyEventsContext, businessContextForPage,
25
27
  importGa4Handoff, forgetGa4Context, createGa4Client, readGa4Rows, GA4_SCOPE,
@@ -72,7 +74,7 @@ async function resolvePaths(cwd) {
72
74
  return { config, paths: contextPaths({ dir: config.dir, file }) };
73
75
  }
74
76
 
75
- export async function contextMain(argv = process.argv.slice(2), { cwd = process.cwd(), out = console.log, err = console.error, env = process.env } = {}) {
77
+ export async function contextMain(argv = process.argv.slice(2), { cwd = process.cwd(), out = console.log, err = console.error, env = process.env, signal } = {}) {
76
78
  const args = parseArgs(argv);
77
79
  const area = args._[0] ?? 'help';
78
80
  const command = args._[1] ?? null;
@@ -273,9 +275,18 @@ export async function contextMain(argv = process.argv.slice(2), { cwd = process.
273
275
 
274
276
  if (area === 'profile') {
275
277
  if (command === 'build') {
278
+ // Node callers qualify their own public egress. Shared document/page
279
+ // primitives stay transport-injected and retain the existing budgets.
280
+ const fetchImpl = createNodePublicFetch();
281
+ const documents = createSiteTransport({ fetchImpl });
282
+ const transport = signal ? { fetchText: (url, options = {}) => documents.fetchText(url, { ...options,
283
+ signal: options.signal ? AbortSignal.any([options.signal, signal]) : signal }) } : documents;
276
284
  const result = await buildSiteProfile({
277
285
  site: args.site === true ? null : (args.site ?? null),
278
286
  paths, extraPages: args.page ?? [],
287
+ transport,
288
+ acquisition: createAcquisition({ fetchImpl, publicFetchSafe: true, purpose: 'research', directMethod: 'node_http', signal,
289
+ captureTransport: { id: 'local_node_http', version: 'alo-local-node-v1' } }),
279
290
  });
280
291
  if (args.json) { out(JSON.stringify(result, null, 1)); return 0; }
281
292
  out(`origin ${result.origin}: robots ${result.robots.outcome}${result.robots.reason ? ` (${result.robots.reason})` : ''}, ${result.sitemaps.documents} sitemap document(s), ${result.sitemaps.urls_found} URL(s)`);
package/cli/doctor.js CHANGED
@@ -192,10 +192,19 @@ export async function doctorMain(argv = [], { cwd = process.cwd(), out = console
192
192
  // a legitimate local-only install, reported as a skip rather than a failure.
193
193
  const connection = cloudConnection(config, env);
194
194
  const origin = connection.origin || null;
195
+ const savedConnectionMetadata = config.cloud && typeof config.cloud === 'object'
196
+ && Object.keys(config.cloud).length > 0;
195
197
  const record = { at: new Date().toISOString() };
196
198
  let probed = false;
197
199
  if (!origin) {
198
- checks.push({ status: 'skip', name: 'cloud', detail: 'no cloud configured (local-only repository)' });
200
+ if (savedConnectionMetadata) {
201
+ checks.push({ status: 'fail', name: 'connection',
202
+ detail: 'saved cloud metadata is present, but the CLI API connection is not configured',
203
+ fix: 'supply an API key through AGENTLINKOPS_TOKEN or AGENTLINKOPS_API_KEY, then run `agentlinkops connect` to verify the workspace and project. MCP clients keep their own OAuth credentials' });
204
+ }
205
+ checks.push({ status: 'skip', name: 'cloud', detail: savedConnectionMetadata
206
+ ? 'CLI API origin is missing; MCP access is separate'
207
+ : 'no cloud configured (local-only repository)' });
199
208
  } else {
200
209
  const cloud = await probeCloud({ origin, fetchImpl, timeoutMs });
201
210
  record.cloud = { ok: cloud.ok, status: cloud.status, ms: cloud.ms };
@@ -215,13 +224,15 @@ export async function doctorMain(argv = [], { cwd = process.cwd(), out = console
215
224
  // Token: presence, shape, and — only when the cloud just proved reachable — whether the
216
225
  // cloud accepts it. The value itself is never printed, hashed or measured here.
217
226
  const token = connection.token || null;
218
- const source = connection.auth ? 'the private OAuth credential store' : connection.tokenSource ? `the environment (${connection.tokenSource})` : `${config.dirName}/config.json (cloud.token)`;
227
+ const source = connection.auth ? 'the private OAuth credential store' : connection.storedKey ? `${connection.tokenSource}, saved by agentlinkops connect` : connection.tokenSource ? `the environment (${connection.tokenSource})` : `${config.dirName}/config.json (cloud.token)`;
219
228
  if (!token && !connection.auth) {
220
229
  if (origin) {
221
230
  checks.push({ status: 'fail', name: 'token', detail: 'no token is set; sync and receive need one',
222
- fix: 'run agentlinkops connect for the app API-key setup path; supply AGENTLINKOPS_TOKEN or AGENTLINKOPS_API_KEY in the environment' });
231
+ fix: 'run `npx -y agentlinkops connect` in a terminal and paste an API key from the Agent page, or supply AGENTLINKOPS_API_KEY in the environment' });
223
232
  } else {
224
- checks.push({ status: 'skip', name: 'token', detail: 'no token (and no cloud configured)' });
233
+ checks.push({ status: 'skip', name: 'token', detail: savedConnectionMetadata
234
+ ? 'no CLI API key is set; MCP credentials are managed by the MCP client'
235
+ : 'no token (and no cloud configured)' });
225
236
  }
226
237
  } else if (!connection.auth && !TOKEN_SHAPE.test(token)) {
227
238
  checks.push({ status: 'fail', name: 'token', detail: `set in ${source} but it does not look like an AgentLinkOps API key (lt_ plus 64 hex characters)`,
package/cli/import.js CHANGED
@@ -54,6 +54,7 @@ export async function readImport(path, {
54
54
 
55
55
  const resolved = resolveMapping(table.header, { supplier, overrides: map, targetProvided: Boolean(target) });
56
56
  if (resolved.error) return { error: resolved.error, header: table.header };
57
+ const targetExpectation = supplier === 'google_search_console' && !resolved.mapping.target && Boolean(target);
57
58
 
58
59
  const unreadable = table.records.filter(record => record.error).map(record => ({ row: record.line, reason: record.error }));
59
60
  const readable = table.records.filter(record => record.values);
@@ -63,7 +64,12 @@ export async function readImport(path, {
63
64
  // means. Nothing is inferred — without --target the resolver above refuses the file.
64
65
  if (!resolved.mapping.target) {
65
66
  const targetUrl = targetKind === 'domain' ? `https://${target}/` : target;
66
- for (const row of rows) row.target_url = targetUrl;
67
+ for (const row of rows) {
68
+ row.target_url = targetUrl;
69
+ // This is the caller's target expectation, not a destination observed by Google.
70
+ // The verifier can use the saved run scope without asserting an exact root-page link.
71
+ if (targetExpectation) row.target_basis = 'user_supplied_target_expectation';
72
+ }
67
73
  }
68
74
 
69
75
  const targetUrl = targetKind === 'domain' ? `https://${target}/` : target;