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
@@ -0,0 +1,115 @@
1
+ // Where `agentlinkops connect` keeps the API key a person pastes, so cloud commands work in any
2
+ // folder without environment variables. Never inside a repository:
3
+ //
4
+ // macOS the login Keychain, through /usr/bin/security. `security -i` takes the key on stdin,
5
+ // so it never appears in a process listing.
6
+ // Linux ~/.agentlinkops/keys/, one file per cloud origin, mode 600 in a mode 700 directory
7
+ // owned by the person; anything looser is ignored rather than trusted.
8
+ // other nothing is saved; AGENTLINKOPS_API_KEY keeps working everywhere.
9
+ //
10
+ // One key per cloud origin. Reads are synchronous because cloudConnection() is.
11
+ import { execFile, execFileSync } from 'node:child_process';
12
+ import { createHash, randomBytes } from 'node:crypto';
13
+ import { lstatSync, readFileSync } from 'node:fs';
14
+ import * as fs from 'node:fs/promises';
15
+ import { homedir } from 'node:os';
16
+ import { isAbsolute, join } from 'node:path';
17
+ import { ConfigError } from './config.js';
18
+
19
+ export const API_KEY_PATTERN = /^lt_[0-9a-f]{64}$/;
20
+ // main() attaches the reader to the environment object it hands every command, under this
21
+ // symbol: it never reaches a child process, a JSON dump or a test that passes its own env.
22
+ export const STORED_KEY = Symbol.for('agentlinkops.storedKey');
23
+
24
+ const SECURITY = '/usr/bin/security';
25
+ const SERVICE = 'agentlinkops-cli';
26
+ // An origin is a Keychain account name and a file-name input: canonical https only, no spaces
27
+ // or quotes, so it can sit unquoted in a `security -i` command line.
28
+ const canonical = origin => {
29
+ try { const url = new URL(origin); return url.protocol === 'https:' && url.origin === origin && !/[\s"'\\]/.test(origin); }
30
+ catch { return false; }
31
+ };
32
+
33
+ const runSync = (file, args) => execFileSync(file, args, { encoding: 'utf8', stdio: ['ignore', 'pipe', 'ignore'], timeout: 10000 });
34
+ const run = (file, args, input) => new Promise((resolve, reject) => {
35
+ const child = execFile(file, args, { encoding: 'utf8', timeout: 10000 }, (error, stdout) => error ? reject(error) : resolve(stdout));
36
+ child.stdin.end(input);
37
+ });
38
+
39
+ function keychainStore({ exec = runSync, execInput = run }) {
40
+ const store = {
41
+ where: 'your macOS Keychain',
42
+ readSync(origin) {
43
+ if (!canonical(origin)) return null;
44
+ try {
45
+ const key = exec(SECURITY, ['find-generic-password', '-s', SERVICE, '-a', origin, '-w']).trim();
46
+ return API_KEY_PATTERN.test(key) ? key : null;
47
+ } catch { return null; } // 44: no such item. Anything else reads as no saved key.
48
+ },
49
+ async save(origin, key) {
50
+ if (!canonical(origin) || !API_KEY_PATTERN.test(key)) throw new ConfigError('Only a complete API key for an https origin can be saved.');
51
+ await execInput(SECURITY, ['-i'], `add-generic-password -U -s ${SERVICE} -a ${origin} -w ${key}\n`).catch(() => {});
52
+ // `security -i` reports per-command failures on its own output, so read the key back.
53
+ if (store.readSync(origin) !== key) throw new ConfigError('The key could not be saved in your macOS Keychain. Set AGENTLINKOPS_API_KEY instead.');
54
+ },
55
+ async remove(origin) {
56
+ if (!canonical(origin)) return false;
57
+ try { exec(SECURITY, ['delete-generic-password', '-s', SERVICE, '-a', origin]); return true; } catch { return false; }
58
+ },
59
+ };
60
+ return store;
61
+ }
62
+
63
+ function fileStore({ home, uid = process.getuid?.() }) {
64
+ const dir = join(home, '.agentlinkops', 'keys');
65
+ const fileFor = origin => join(dir, `${createHash('sha256').update(origin).digest('hex').slice(0, 32)}.json`);
66
+ const own = info => uid !== undefined && info.uid === uid;
67
+ const privateDir = info => info.isDirectory() && own(info) && (info.mode & 0o777) === 0o700;
68
+ const privateFile = info => info.isFile() && info.nlink === 1 && own(info) && (info.mode & 0o777) === 0o600;
69
+ return {
70
+ where: '~/.agentlinkops/keys (readable only by you)',
71
+ readSync(origin) {
72
+ if (!canonical(origin)) return null;
73
+ try {
74
+ if (!privateDir(lstatSync(dir)) || !privateFile(lstatSync(fileFor(origin)))) return null;
75
+ const record = JSON.parse(readFileSync(fileFor(origin), 'utf8'));
76
+ return record?.origin === origin && API_KEY_PATTERN.test(record.key) ? record.key : null;
77
+ } catch { return null; }
78
+ },
79
+ async save(origin, key) {
80
+ if (!canonical(origin) || !API_KEY_PATTERN.test(key)) throw new ConfigError('Only a complete API key for an https origin can be saved.');
81
+ for (const path of [join(home, '.agentlinkops'), dir]) {
82
+ try { await fs.mkdir(path, { mode: 0o700 }); } catch (error) { if (error.code !== 'EEXIST') throw error; }
83
+ }
84
+ if (!privateDir(await fs.lstat(dir))) throw new ConfigError(`${dir} must be a directory only you can open (mode 700). Fix it, or set AGENTLINKOPS_API_KEY instead.`);
85
+ const path = fileFor(origin), temporary = `${path}.${randomBytes(8).toString('hex')}.tmp`;
86
+ try {
87
+ await fs.writeFile(temporary, `${JSON.stringify({ origin, key })}\n`, { flag: 'wx', mode: 0o600 });
88
+ await fs.rename(temporary, path);
89
+ } finally { await fs.unlink(temporary).catch(() => {}); }
90
+ },
91
+ async remove(origin) {
92
+ if (!canonical(origin)) return false;
93
+ try { await fs.unlink(fileFor(origin)); return true; } catch (error) { if (error.code === 'ENOENT') return false; throw error; }
94
+ },
95
+ };
96
+ }
97
+
98
+ const unsupported = {
99
+ where: null,
100
+ readSync: () => null,
101
+ async save() { throw new ConfigError('This platform has no supported place to save a key yet. Set AGENTLINKOPS_API_KEY instead.'); },
102
+ async remove() { return false; },
103
+ };
104
+
105
+ export function createKeyStore({ platform = process.platform, home = homedir(), exec, execInput, uid } = {}) {
106
+ if (platform === 'darwin') return keychainStore({ exec, execInput });
107
+ if (platform === 'linux' && typeof home === 'string' && isAbsolute(home)) return fileStore({ home, uid });
108
+ return unsupported;
109
+ }
110
+
111
+ /** The environment a command sees, plus a reader for keys saved by `agentlinkops connect`. */
112
+ export function withStoredKeys(env, store) {
113
+ if (!store) return env;
114
+ return { ...env, [STORED_KEY]: origin => { const key = store.readSync(origin); return key ? { key, where: store.where } : null; } };
115
+ }
package/cli/ledger.js CHANGED
@@ -214,6 +214,7 @@ export function toWatchInput(entry) {
214
214
  // caller can say so rather than discovering it when a reference comes back short.
215
215
  const reference = entry.ref ? `${entry.id} ${entry.ref}` : entry.id;
216
216
  return {
217
+ intent: ({wanted:'prospect',expected:'earned',retired:'retired'})[entry.intent],
217
218
  sourceUrl: entry.source, targetUrl: entry.target, targetScope: entry.scope,
218
219
  cadenceSeconds: cadenceSeconds(entry.cadence ?? DEFAULT_CADENCE[entry.intent] ?? 'daily'),
219
220
  expectedAnchor: entry.expect?.anchor ?? null,
@@ -15,11 +15,26 @@ export const LOCAL_RESULT_SCHEMA = 'agentlinkops.local-check.v1';
15
15
 
16
16
  const timestamp = z.string().refine(value => Number.isFinite(Date.parse(value)) && new Date(value).toISOString() === value);
17
17
  const strings = z.array(z.string());
18
+ const digest = z.string().regex(/^[a-f0-9]{64}$/u);
19
+ const capturePin = z.string().min(1).max(128).regex(/^[^\x00-\x20\x7f]+$/u);
20
+ const bodyDiagnosticSchema = z.strictObject({ version: z.literal(1), bytes: z.number().int().min(0).max(64 * 1024),
21
+ complete: z.boolean(), truncated: z.boolean(), sha256: digest, looksLikeHtml: z.boolean() });
22
+ const googlebotSchema = z.strictObject({ productToken: z.literal('Googlebot'), allowed: z.boolean().nullable(),
23
+ reason: z.string().nullable(), matchedRule: z.string().nullable(), crawlDelaySeconds: z.number().finite().min(0).nullable() });
24
+ const captureSchema = z.strictObject({ version: z.literal(1), representation: z.literal('raw_html'),
25
+ requestedUrl: z.string(), finalUrl: z.string(), httpStatus: z.number().int().min(100).max(599), acquiredAt: timestamp,
26
+ body: z.strictObject({ complete: z.literal(true), truncated: z.literal(false), bytes: z.number().int().min(0).max(2 * 1024 * 1024),
27
+ sha256: digest, decodedSha256: digest, charset: z.string().max(64).nullable().optional() }),
28
+ transport: z.strictObject({ id: capturePin, version: capturePin, qualified: z.literal(true) }),
29
+ coverage: z.strictObject({ mainDocument: z.literal(true), iframes: z.literal(false), shadowRoots: z.literal(false) }) });
18
30
  const redirectSchema = z.strictObject({ from: z.string(), to: z.string(), status: z.number().int() });
19
31
  const robotsSchema = z.strictObject({ allowed: z.boolean().nullable(), reason: z.string().nullable(),
20
32
  matchedRule: z.string().nullable(), crawlDelaySeconds: z.number().nullable(), fetched: z.string().nullable(),
21
33
  sourceUrl: z.string().optional(), robotsUrl: z.string().optional(), httpStatus: z.number().int().nullable().optional(),
22
- productToken: z.string().optional(), redirects: z.array(redirectSchema).max(5).optional(), retryAfterSeconds: z.number().optional() });
34
+ productToken: z.string().optional(), redirects: z.array(redirectSchema).max(5).optional(), retryAfterSeconds: z.number().finite().min(0).max(86400).optional(),
35
+ googlebot: googlebotSchema.optional(), decisionReason: z.string().nullable().optional(),
36
+ policy: z.literal('customer_supplied_monitor').optional(), fetchAuthorized: z.boolean().optional(), overrideUsed: z.literal(false).optional(),
37
+ bodyDiagnostic: bodyDiagnosticSchema.optional() });
23
38
  const readinessSchema = z.strictObject({ requiresRender: z.boolean(), reason: z.string().nullable(), possibleLoginWall: z.boolean(),
24
39
  hasVisiblePassword: z.boolean().optional(), targetInScript: z.boolean().optional(), emptyAppRoot: z.boolean().optional(), emptyProfileRoot: z.boolean().optional() });
25
40
  const occurrenceSchema = z.strictObject({ href: z.string(), targetUrl: z.string(), anchor: z.string(), rel: strings,
@@ -28,15 +43,18 @@ const observationSchema = z.strictObject({
28
43
  state: z.enum(['present','absent','unknown','source_unavailable']), reason: z.string(), sourceUrl: z.string(), finalUrl: z.string(), targetUrl: z.string(),
29
44
  targetScope: z.enum(['exact','domain','subdomain','path']), httpStatus: z.number().int().nullable(), checkedAt: timestamp,
30
45
  occurrences: z.array(occurrenceSchema).max(1000),
31
- directives: z.strictObject({ meta: z.array(z.strictObject({ name: z.string(), content: z.string() })), xRobotsTag: strings, noindex: z.boolean(), nofollow: z.boolean(), indexingStatus: z.string() }),
46
+ directives: z.strictObject({ meta: z.array(z.strictObject({ name: z.string(), content: z.string() })), xRobotsTag: strings, noindex: z.boolean(), nofollow: z.boolean(), indexingStatus: z.string(),
47
+ googlebot: z.strictObject({ noindex: z.boolean(), nofollow: z.boolean() }).optional() }),
32
48
  redirects: z.array(redirectSchema),
33
49
  evidence: z.strictObject({ method: z.string(), checkerVersion: z.string(), complete: z.boolean(), fetchedAt: timestamp.nullable(),
34
50
  bytes: z.number().optional(), contentType: z.string().nullable().optional(), etag: z.string().nullable().optional(), lastModified: z.string().nullable().optional(),
35
- baseUrl: z.string().optional(), rendered: z.boolean().optional(), sha256: z.string().optional(), parseErrors: strings.optional(),
36
- readiness: readinessSchema.optional(), renderEligibility: z.enum(['required', 'comparison']).optional() }),
51
+ baseUrl: z.string().optional(), rendered: z.literal(false).optional(), sha256: digest.optional(), decodedSha256: digest.optional(),
52
+ capture: captureSchema.nullable().optional(), parseErrors: strings.optional(),
53
+ readiness: readinessSchema.optional(), renderEligibility: z.enum(['required', 'comparison']).optional(),
54
+ renderAttempt: z.strictObject({ attempted: z.literal(false), reason: z.literal('render_not_configured') }).optional() }),
37
55
  robots: robotsSchema.optional(), robotsHistory: z.array(robotsSchema).max(6).optional(),
38
56
  sourceResponse: z.strictObject({ url: z.string(), httpStatus: z.number().int(), contentType: z.string().max(128).nullable(),
39
- retryAfterSeconds: z.number().nullable(), challenge: z.boolean() }).optional(),
57
+ retryAfterSeconds: z.number().nullable(), challenge: z.boolean(), bodyDiagnostic: bodyDiagnosticSchema.optional() }).optional(),
40
58
  expectations: z.strictObject({ expectedAnchor: z.string().nullable(), expectedRel: strings.nullable(), anchorMatches: z.boolean().nullable(), relMatches: z.boolean().nullable(), satisfied: z.boolean() }).optional(),
41
59
  linkSignature: z.string().optional(), warnings: strings.optional(), retryAfterSeconds: z.number().optional(),
42
60
  });
@@ -88,11 +106,28 @@ export function validateLocalResult(result) {
88
106
  const rejectBytes = value => {
89
107
  if (!value || typeof value !== 'object') return;
90
108
  for (const [key, child] of Object.entries(value)) {
91
- if (['html','body','headers'].includes(key.toLowerCase())) throw new Error('portable observations cannot contain raw HTML/body/headers');
109
+ if (['html','body','headers'].includes(key.toLowerCase())
110
+ && !(key === 'body' && value === observation.evidence?.capture)) throw new Error('portable observations cannot contain raw HTML/body/headers');
92
111
  rejectBytes(child);
93
112
  }
94
113
  };
95
114
  rejectBytes(observation);
115
+ const capture = observation.evidence.capture;
116
+ if (capture) {
117
+ if (capture.requestedUrl !== input.source || capture.finalUrl !== observation.finalUrl
118
+ || !validatePublicUrl(capture.finalUrl).valid || capture.httpStatus !== observation.httpStatus
119
+ || capture.acquiredAt !== observation.evidence.fetchedAt
120
+ || capture.body.bytes !== observation.evidence.bytes || capture.body.sha256 !== observation.evidence.sha256
121
+ || capture.body.decodedSha256 !== observation.evidence.decodedSha256
122
+ || observation.evidence.method !== 'http_html') throw new Error('portable capture does not match its observation');
123
+ if (capture.body.charset != null) {
124
+ try { new TextDecoder(capture.body.charset, { fatal: true }); }
125
+ catch { throw new Error('invalid portable capture charset'); }
126
+ }
127
+ }
128
+ if (observation.evidence.renderAttempt && (observation.state !== 'unknown' || observation.evidence.complete !== false
129
+ || !['possible_render_required', 'navigation_requires_browser'].includes(observation.reason)
130
+ || observation.evidence.renderEligibility !== 'required')) throw new Error('invalid portable refused-render observation');
96
131
  if (observation.occurrences.some(item => !item || typeof item.targetUrl !== 'string' || !Array.isArray(item.rel)
97
132
  || !matchesTarget(item.targetUrl, input.target, input.scope))) throw new Error('invalid portable occurrence');
98
133
  if (observation.state === 'present' && (!observation.evidence.complete || !observation.occurrences.length)) throw new Error('present requires complete matching occurrences');
package/cli/main.js CHANGED
@@ -2,6 +2,7 @@ import {projectPlacements} from './placement-projection.js';
2
2
  import {chronologyMain} from './chronology.js';
3
3
  import { acquireSyncLock } from './sync-lock.js';
4
4
  import {lifecycleMain} from './lifecycle.js';
5
+ import {campaignMain,CAMPAIGN_CLI_HELP} from './campaign-bridge.js';
5
6
  import {admissionMain} from './admission.js';
6
7
  import { commandsMain } from './commands.js';
7
8
  import { disavowMain } from './disavow.js';
@@ -23,8 +24,10 @@ import { checkToFile, adoptLocalResult } from './local-result.js';
23
24
  import { disagreements, transitions } from './status.js';
24
25
  import { readImport, parseMap } from './import.js';
25
26
  import { freezeDataset, renderReport, datasetDigest } from './report.js';
26
- import { createClient, CloudError } from './client.js';
27
- import { cloudConnection, connectMain, keySetup } from './connection.js';
27
+ import { createClient, CloudError, CLI_VERSION } from './client.js';
28
+ import { cloudConnection, connectKey, connectMain, keySetup } from './connection.js';
29
+ import { createKeyStore, withStoredKeys } from './key-store.js';
30
+ import { askSecret } from './prompt.js';
28
31
  import { syncPlan, pushExpectations, pullLifecycleMirrors, pullEvents, cloudObservationRows, watchIndex, pullTargetEvents } from './sync.js';
29
32
  import { SUPPLIER_NAMES } from './adapters/suppliers.js';
30
33
  import { fleetSummary, renderFleet } from './fleet.js';
@@ -46,6 +49,8 @@ import { citationMain } from './citation.js';
46
49
 
47
50
  const USAGE = `agentlinkops — a backlink ledger that lives in your repository
48
51
 
52
+ agentlinkops --version print the installed CLI version
53
+
49
54
  agentlinkops skill [--url|--list [--json]|NAME] read the agent reference or a named skill
50
55
  before the first AgentLinkOps call
51
56
  agentlinkops init create .agentlinkops/ here
@@ -57,8 +62,9 @@ const USAGE = `agentlinkops — a backlink ledger that lives in your repository
57
62
  agentlinkops chronology pull --watch-id ID [--limit N] [--cursor TOKEN] [--all] [--out FILE]
58
63
  agentlinkops chronology export --pages FILE [--submissions FILE] [--out FILE]
59
64
  agentlinkops index pull --watch-id ID [--limit N] [--before ID]
65
+ agentlinkops campaign plan|apply|reconcile|rollback|pull-plan|pull-apply|pull-rollback|outcomes-plan|outcomes-apply
66
+ preview and reconcile portable campaign records; no sending or automatic checks
60
67
  agentlinkops lifecycle get|update|clear|renew|report
61
- agentlinkops admission list|add|update|remove|evaluate|reevaluate|receipt --project ID
62
68
  agentlinkops disavow list|history|propose|import|export|approve|reject|delete
63
69
  agentlinkops index import FILE | export [--out FILE] | csv URLS.csv [--out FILE]
64
70
  agentlinkops index submissions-import FILE | submissions-export --project-id ID [--out FILE]
@@ -82,7 +88,9 @@ const USAGE = `agentlinkops — a backlink ledger that lives in your repository
82
88
  .agentlinkops/citations/; live engines need
83
89
  engine-specific setup; the mock engine makes no paid calls
84
90
  agentlinkops receive --body FILE --headers FILE signed notification; authenticated pull
85
- agentlinkops connect --workspace ID --project-id ID [--origin URL] [--selection FILE] [--oauth --issuer URL --client-id ID [--credential-ref REF]]
91
+ agentlinkops connect [--project-id ID] [--workspace ID] [--origin URL] [--selection FILE] [--oauth --issuer URL --client-id ID [--credential-ref REF]]
92
+ paste an API key once; it is saved outside the
93
+ repository and the key names its workspace
86
94
  agentlinkops tools [TOOLSET] [--json] [--refresh] cloud commands by toolset (offline snapshot;
87
95
  --refresh reads the live catalog)
88
96
  agentlinkops describe NAME [--examples] [--output-schema] [--json] one command's exact schema
@@ -91,7 +99,8 @@ const USAGE = `agentlinkops — a backlink ledger that lives in your repository
91
99
  agentlinkops agent setup [--client ID ...] [--scope project|user] [--origin URL] [--dry-run]
92
100
  core connection/reference and the right MCP view;
93
101
  optional --skill ID ... / --recipe ID ... / --all-skills;
94
- stores no credential; preserves customer instructions
102
+ stores no credential unless you paste a CLI key
103
+ when asked; preserves customer instructions
95
104
  agentlinkops agent status what is installed where
96
105
  agentlinkops agent remove [--skill ID ...] [--recipe ID ...] [--scope project|user] [--apply] [--json]
97
106
  agentlinkops agent recover [--scope project|user] [--apply] [--json]
@@ -126,6 +135,24 @@ evidence · 2 usage, configuration or ledger error. Unknown is nonfailing by def
126
135
  Names: \`linktrail\` still runs this CLI, LINKTRAIL_* variables are still read, and an existing
127
136
  .linktrail/ directory is still used, each with a one-line notice, until the DP-0029 cutover.`;
128
137
 
138
+ // After `agent setup` on a terminal: the CLI half, so the folder's agent can also run
139
+ // `agentlinkops call`. Optional (Enter skips), and setup has already succeeded either way.
140
+ async function connectCliAfterSetup(origin, { env, ask, keyStore, fetchImpl, out }) {
141
+ let connection = null;
142
+ try { connection = cloudConnection({ cloud: { origin } }, env); } catch { /* Disagreeing variables: connect reports them. */ }
143
+ out('');
144
+ if (connection?.token) { out(`The agentlinkops CLI can use this account too (key in ${connection.tokenSource}).`); return; }
145
+ try {
146
+ const key = await connectKey({ origin, ask, keyStore, fetchImpl, out,
147
+ prompt: `To use the agentlinkops CLI as well, paste an API key from ${origin}/#/access (Create API key), or press Enter to skip: ` });
148
+ out(key
149
+ ? `The CLI is connected to ${key.account.workspace.name || 'your workspace'}. Try: agentlinkops call list_tasks`
150
+ : 'Skipped. Run `npx -y agentlinkops connect` when you want the CLI.');
151
+ } catch (error) {
152
+ out(`The CLI wasn't connected: ${error.serverMessage ?? error.message}. Run \`npx -y agentlinkops connect\` to try again.`);
153
+ }
154
+ }
155
+
129
156
  const parseArgs = argv => parseArguments(argv,["tag", "project", "observations-of", "state-of", "observations", "benchmark", "seed"],{tag:[]});
130
157
 
131
158
  const plural = (n, one, many = `${one}s`) => `${n} ${n === 1 ? one : many}`;
@@ -139,7 +166,16 @@ function reportProblems(problems, what, out) {
139
166
  if (problems.length > 20) out(` … and ${problems.length - 20} more`);
140
167
  }
141
168
 
142
- export async function main(argv = process.argv.slice(2), { cwd = process.cwd(), out = console.log, err = console.error, env = process.env, fetchImpl = globalThis.fetch, afterHistoryPersisted, afterStatePersisted } = {}) {
169
+ export async function main(argv = process.argv.slice(2), options) {
170
+ // The executables call main() with no options, the one caller that reads the person's saved
171
+ // key and asks on their terminal. Every other caller passes options and gets neither.
172
+ const executable = options === undefined;
173
+ const { cwd = process.cwd(), out = console.log, err = console.error, fetchImpl = globalThis.fetch, localFetchImpl = null, afterHistoryPersisted, afterStatePersisted, signal,
174
+ keyStore = executable ? createKeyStore() : null,
175
+ ask = executable && process.stdin.isTTY === true && process.stderr.isTTY === true ? askSecret : null } = options ?? {};
176
+ if (argv.length === 1 && argv[0] === '--version') { out(CLI_VERSION); return 0; }
177
+ if (argv[0] === 'campaign' && argv.length===2 && ['--help','help'].includes(argv[1])) { out(CAMPAIGN_CLI_HELP); return 0; }
178
+ const env = withStoredKeys(options?.env ?? process.env, keyStore);
143
179
  if (argv[0] === 'gsc-links') return await gscLinksMain(argv, { cwd, out });
144
180
  // Citation watches keep their own files under .agentlinkops/citations/ and never
145
181
  // touch the link ledger, so they dispatch before its requirement like context does.
@@ -155,6 +191,7 @@ export async function main(argv = process.argv.slice(2), { cwd = process.cwd(),
155
191
  try {
156
192
  if (args['recover-cursors'] !== undefined && (command !== 'sync' || args['recover-cursors'] !== true || args['pull-only'] !== true || args['push-only']))
157
193
  throw new ConfigError('Cursor recovery requires agentlinkops sync --pull-only --recover-cursors; it performs no remote writes.');
194
+ if (command === 'campaign') return await campaignMain(args,{cwd,env,fetchImpl,out});
158
195
  if (command === 'lifecycle') return await lifecycleMain(args,{cwd,env,fetchImpl,out,err});
159
196
  if (command === 'admission') { const config=await loadConfig({cwd}); return await admissionMain(argv.slice(1),{client:createClient({...cloudConnection(config,env),fetchImpl}),out,err}); }
160
197
  if (command === 'disavow') return await disavowMain(args,{cwd,env,fetchImpl,out});
@@ -163,17 +200,18 @@ export async function main(argv = process.argv.slice(2), { cwd = process.cwd(),
163
200
  if (command === 'setup') return await setupPlanMain(argv, { cwd, out });
164
201
  if (command === 'tools' || command === 'describe' || command === 'schema' || command === 'call') return await commandsMain(argv, { cwd, out, err, env, fetchImpl });
165
202
  if (command === 'skill') return await skillMain(argv, { out });
166
- if (command === 'agent') return await agentMain(argv, { cwd, out, err });
203
+ if (command === 'agent') return await agentMain(argv, { cwd, out, err, env, connectCli: ask && keyStore ? origin => connectCliAfterSetup(origin, { env, ask, keyStore, fetchImpl, out }) : null });
167
204
  if (command === 'connect') {
168
- if (args._.length !== 1 || args.tag.length || Object.keys(args).some(key => !['_', 'tag', 'origin', 'workspace', 'project-id', 'selection', 'oauth', 'issuer', 'client-id', 'credential-ref'].includes(key))) throw new ConfigError('connect --workspace ID --project-id ID [--origin URL] [--selection FILE] [--oauth --issuer URL --client-id ID [--credential-ref REF]]');
169
- return await connectMain(args, { cwd, out, env, fetchImpl });
205
+ if (args._.length !== 1 || args.tag.length || Object.keys(args).some(key => !['_', 'tag', 'origin', 'workspace', 'project-id', 'selection', 'oauth', 'issuer', 'client-id', 'credential-ref'].includes(key))) throw new ConfigError('connect [--project-id ID] [--workspace ID] [--origin URL] [--selection FILE] [--oauth --issuer URL --client-id ID [--credential-ref REF]]');
206
+ return await connectMain(args, { cwd, out, env, fetchImpl, keyStore, ask });
170
207
  }
171
208
  if (command === 'check' && (args.source !== undefined || args.target !== undefined)) {
172
209
  if (typeof args.source !== 'string' || typeof args.target !== 'string') throw new ConfigError('check requires both --source URL and --target URL');
173
210
  const allowed = new Set(['_', 'source', 'target', 'scope', 'json', 'out']);
174
211
  if (Object.keys(args).some(key => key !== 'tag' && !allowed.has(key)) || args.tag.length || args._.length !== 1) throw new ConfigError('unsupported option for a single local check');
175
212
  if (args.out !== undefined && (typeof args.out !== 'string' || !args.out.trim())) throw new ConfigError('--out requires a file path');
176
- const result = await checkToFile({ source: args.source, target: args.target, scope: args.scope }, args.out ? resolve(cwd, args.out) : null);
213
+ const result = await checkToFile({ source: args.source, target: args.target, scope: args.scope }, args.out ? resolve(cwd, args.out) : null,
214
+ { fetchImpl: localFetchImpl });
177
215
  out(args.json ? JSON.stringify(result, null, 2) : `${result.observation.state} ${result.observation.reason} ${result.checked_at}${args.out ? ` saved ${args.out}` : ''}`);
178
216
  return 0;
179
217
  }
@@ -333,7 +371,7 @@ export async function main(argv = process.argv.slice(2), { cwd = process.cwd(),
333
371
  // DP-0017's context surface owns its config and files and needs no ledger, so it runs
334
372
  // before the ledger check below. contextMain parses its own argv; hand it everything
335
373
  // after the command word (found positionally, because flags may precede it).
336
- return await contextMain(argv.slice(argv.indexOf(command) + 1), { cwd, out, err });
374
+ return await contextMain(argv.slice(argv.indexOf(command) + 1), { cwd, out, err,signal });
337
375
  }
338
376
  if (command === 'doctor') {
339
377
  // Doctor must run exactly where the ledger cannot be trusted yet: its job is to name
@@ -341,7 +379,7 @@ export async function main(argv = process.argv.slice(2), { cwd = process.cwd(),
341
379
  // file itself. Takes no flags and repairs nothing. Awaited on purpose: a promise returned
342
380
  // from inside the try escapes the ConfigError handler below, and a disagreeing pair of
343
381
  // token spellings would surface as a stack trace instead of the exit-2 message.
344
- return await doctorMain(argv.slice(argv.indexOf(command) + 1), { cwd, out, err });
382
+ return await doctorMain(argv.slice(argv.indexOf(command) + 1), { cwd, out, err, env });
345
383
  }
346
384
 
347
385
  const config = await loadConfig({ cwd });
@@ -616,6 +654,7 @@ export async function main(argv = process.argv.slice(2), { cwd = process.cwd(),
616
654
  concurrency: Number(args.concurrency ?? config.defaults.concurrency ?? DEFAULTS.concurrency),
617
655
  timeoutMs: Number(args.timeout ?? config.defaults.timeoutMs ?? DEFAULTS.timeoutMs),
618
656
  hostDelayMs: Number(args['host-delay'] ?? config.defaults.hostDelayMs ?? DEFAULTS.hostDelayMs),
657
+ fetchImpl: localFetchImpl,
619
658
  });
620
659
  // The mirror records changes; `state.json` records activity. Appending a row that says
621
660
  // the same thing as the one above it is 2 KB of git churn per link per run, and the only
package/cli/mcp-client.js CHANGED
@@ -62,7 +62,7 @@ function retryDelay(error, attempt, now) {
62
62
 
63
63
  async function sdkClient({url, token, workspaceId, fetchImpl}) {
64
64
  const {Client, StreamableHTTPClientTransport} = await import('@modelcontextprotocol/client');
65
- const client = new Client({name: 'agentlinkops-cli', version: '0.6.13'});
65
+ const client = new Client({name: 'agentlinkops-cli', version: '0.6.16'});
66
66
  const destination = new URL(url);
67
67
  const transport = new StreamableHTTPClientTransport(destination, {
68
68
  authProvider: {token: async () => token},
@@ -71,7 +71,7 @@ async function sdkClient({url, token, workspaceId, fetchImpl}) {
71
71
  if (target.href !== destination.href) fail('OAUTH_TOKEN_DESTINATION_MISMATCH');
72
72
  const headers = new Headers(init.headers ?? (input instanceof Request ? input.headers : undefined));
73
73
  if (workspaceId) headers.set('X-Workspace-ID', workspaceId);
74
- headers.set('User-Agent', 'agentlinkops-cli/0.6.13');
74
+ headers.set('User-Agent', 'agentlinkops-cli/0.6.16');
75
75
  const response = await fetchImpl(input, {...init, headers, redirect: 'error', signal: AbortSignal.timeout(30000)});
76
76
  if (response.redirected || response.status >= 300 && response.status < 400
77
77
  || response.url && response.url !== destination.href) fail('OAUTH_TOKEN_DESTINATION_MISMATCH');