agentlinkops 0.5.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (115) hide show
  1. package/.claude-plugin/plugin.json +10 -0
  2. package/.codex-plugin/plugin.json +24 -0
  3. package/CHANGELOG.md +52 -0
  4. package/LICENSE +190 -0
  5. package/NOTICE +17 -0
  6. package/README.md +202 -0
  7. package/bin/agentlinkops.mjs +4 -0
  8. package/bin/linktrail.mjs +6 -0
  9. package/cli/adapters/csv.js +68 -0
  10. package/cli/adapters/sqlite-crm.js +79 -0
  11. package/cli/adapters/suppliers.js +180 -0
  12. package/cli/agent-setup.js +203 -0
  13. package/cli/agentlinkops.mjs +4 -0
  14. package/cli/args.js +18 -0
  15. package/cli/catalog.js +7483 -0
  16. package/cli/check.js +119 -0
  17. package/cli/citation.js +109 -0
  18. package/cli/client.js +60 -0
  19. package/cli/commands.js +183 -0
  20. package/cli/config.js +104 -0
  21. package/cli/connection.js +90 -0
  22. package/cli/context-mcp.js +193 -0
  23. package/cli/context.js +340 -0
  24. package/cli/delivery.js +67 -0
  25. package/cli/doctor.js +283 -0
  26. package/cli/env.js +66 -0
  27. package/cli/fleet.js +82 -0
  28. package/cli/gsc-browser.js +239 -0
  29. package/cli/import.js +108 -0
  30. package/cli/ledger.js +232 -0
  31. package/cli/linktrail.mjs +6 -0
  32. package/cli/local-result.js +161 -0
  33. package/cli/main.js +523 -0
  34. package/cli/mirror.js +126 -0
  35. package/cli/mix.js +123 -0
  36. package/cli/platforms.js +161 -0
  37. package/cli/receipt-performance.js +64 -0
  38. package/cli/receipts.js +122 -0
  39. package/cli/report.js +158 -0
  40. package/cli/setup-plan.js +211 -0
  41. package/cli/skill.js +39 -0
  42. package/cli/state.js +106 -0
  43. package/cli/status.js +51 -0
  44. package/cli/sync.js +179 -0
  45. package/package.json +59 -0
  46. package/references/cli.md +198 -0
  47. package/references/disavow.md +73 -0
  48. package/references/reports.md +53 -0
  49. package/scripts/agentlinkops.py +651 -0
  50. package/scripts/disavow.py +207 -0
  51. package/scripts/linktrail.py +10 -0
  52. package/scripts/migrations/001_crm.sql +45 -0
  53. package/scripts/migrations/002_cloud_events.sql +13 -0
  54. package/scripts/migrations/003_disavow.sql +20 -0
  55. package/scripts/migrations/004_placement_costs.sql +2 -0
  56. package/scripts/report.py +113 -0
  57. package/shared/agent-clients.js +49 -0
  58. package/shared/observation-event.js +43 -0
  59. package/shared/response-contract.js +59 -0
  60. package/shared/search-connection-contract.js +18 -0
  61. package/skills/agentlinkops-assets/SKILL.md +13 -0
  62. package/skills/agentlinkops-assets/agents/openai.yaml +5 -0
  63. package/skills/agentlinkops-campaigns/SKILL.md +17 -0
  64. package/skills/agentlinkops-campaigns/agents/openai.yaml +12 -0
  65. package/skills/agentlinkops-connect/SKILL.md +33 -0
  66. package/skills/agentlinkops-connect/agents/openai.yaml +12 -0
  67. package/skills/agentlinkops-connect/references/agentlinkops.md +266 -0
  68. package/skills/agentlinkops-crm/SKILL.md +27 -0
  69. package/skills/agentlinkops-crm/agents/openai.yaml +12 -0
  70. package/skills/content-defingerprinting/SKILL.md +29 -0
  71. package/skills/content-defingerprinting/scripts/watermarks/README.md +102 -0
  72. package/skills/content-defingerprinting/scripts/watermarks/UPSTREAM.json +26 -0
  73. package/skills/content-defingerprinting/scripts/watermarks/revendor.sh +52 -0
  74. package/skills/content-defingerprinting/scripts/watermarks/vendor/LICENSE.upstream +21 -0
  75. package/skills/content-defingerprinting/scripts/watermarks/vendor/clean_text.py +96 -0
  76. package/skills/content-defingerprinting/scripts/watermarks/vendor/common.py +432 -0
  77. package/skills/content-defingerprinting/scripts/watermarks/vendor/inspect_text.py +86 -0
  78. package/skills/content-defingerprinting/scripts/watermarks/vendor/score_stylometry.py +460 -0
  79. package/skills/content-defingerprinting/scripts/watermarks/vendor/text_unicode.py +651 -0
  80. package/skills/content-defingerprinting/scripts/watermarks/verify-vendor.py +27 -0
  81. package/skills/content-defingerprinting/scripts/watermarks/wm-scan.py +212 -0
  82. package/skills/internal-linking-optimizer/SKILL.md +56 -0
  83. package/skills/internal-linking-optimizer/references/source-prompt.md +76 -0
  84. package/skills/pre-publish-review/SKILL.md +179 -0
  85. package/skills/source-cited-content-builder/SKILL.md +56 -0
  86. package/skills/source-cited-content-builder/references/source-prompt.md +19 -0
  87. package/skills/source-cited-humanizer/SKILL.md +20 -0
  88. package/src/citations/adapter.js +141 -0
  89. package/src/citations/contract.js +171 -0
  90. package/src/citations/match.js +120 -0
  91. package/src/citations/runner.js +151 -0
  92. package/src/citations/stats.js +74 -0
  93. package/src/competitors/contract.js +157 -0
  94. package/src/competitors/domain-mix.js +199 -0
  95. package/src/context/ga4.js +652 -0
  96. package/src/context/gsc.js +925 -0
  97. package/src/context/site-profile.js +445 -0
  98. package/src/context/util.js +71 -0
  99. package/src/context-tool-registry.js +27 -0
  100. package/src/corpus/acquire.js +135 -0
  101. package/src/corpus/seeds.js +182 -0
  102. package/src/discovery/contract.js +237 -0
  103. package/src/discovery/import.js +156 -0
  104. package/src/tool-views.js +117 -0
  105. package/src/verifier/fetch.js +303 -0
  106. package/src/verifier/index.js +311 -0
  107. package/src/verifier/observation-kind.js +91 -0
  108. package/src/verifier/parser.js +241 -0
  109. package/src/verifier/readiness.js +90 -0
  110. package/src/verifier/render-evidence.js +57 -0
  111. package/src/verifier/robots.js +85 -0
  112. package/src/verifier/transition.js +129 -0
  113. package/src/verifier/url.js +56 -0
  114. package/templates/SITE.template.md +59 -0
  115. package/templates/campaigns.v1.json +65 -0
@@ -0,0 +1,90 @@
1
+ import { readFile, writeFile, rename, unlink, open } from 'node:fs/promises';
2
+ import { join, resolve } from 'node:path';
3
+ import { loadConfig, ConfigError } from './config.js';
4
+ import { createClient } from './client.js';
5
+ import { readLedger } from './ledger.js';
6
+ import { resolveEnv } from './env.js';
7
+
8
+ export const SYNC_SCOPES = ['projects:read', 'watches:read', 'watches:write', 'events:read', 'exports:create'];
9
+
10
+ // Both TOKEN and API_KEY have shipped in customer installations, under both the AGENTLINKOPS_
11
+ // and the LINKTRAIL_ prefix (env.js owns that alias rule). Reject ambiguous identities: a
12
+ // TOKEN that disagrees with an API_KEY fails the same way a new name that disagrees with its
13
+ // old spelling does.
14
+ export function cloudConnection(config, env = process.env) {
15
+ const { values, sources } = resolveEnv(env);
16
+ if (values.TOKEN && values.API_KEY && values.TOKEN !== values.API_KEY)
17
+ throw new ConfigError(`${sources.TOKEN} and ${sources.API_KEY} disagree; select one credential.`);
18
+ if (values.API_URL && config.cloud?.origin && new URL(values.API_URL).origin !== new URL(config.cloud.origin).origin)
19
+ throw new ConfigError(`${sources.API_URL} disagrees with the saved cloud origin.`);
20
+ 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)
21
+ throw new ConfigError('Saved cloud identity fields disagree.');
22
+ return { origin: values.API_URL || config.cloud?.origin,
23
+ token: values.TOKEN || values.API_KEY || config.cloud?.token,
24
+ tokenSource: values.TOKEN ? sources.TOKEN : values.API_KEY ? sources.API_KEY : null,
25
+ workspaceId: config.cloud?.workspaceId || config.cloud?.workspace_id, projectId: config.project?.id || config.cloud?.projectId || config.cloud?.project_id };
26
+ }
27
+
28
+ export function keySetup(origin) {
29
+ 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. MCP OAuth credentials remain in the MCP client.`;
30
+ }
31
+
32
+ // Verify an existing project before saving only non-secret connection metadata.
33
+ export async function connectMain(args, { cwd, env = process.env, out = console.log, fetchImpl = globalThis.fetch } = {}) {
34
+ const config = await loadConfig({ cwd });
35
+ const connection = cloudConnection(config, env);
36
+ const origin = args.origin || connection.origin || 'https://app.agentlinkops.com';
37
+ const workspaceId = args.workspace || connection.workspaceId;
38
+ const projectId = args['project-id'] || connection.projectId;
39
+ let parsed;
40
+ try { parsed = new URL(origin); } catch { throw new ConfigError('connect --origin requires an HTTPS origin.'); }
41
+ if (parsed.protocol !== 'https:' || parsed.username || parsed.password || parsed.search || parsed.hash || parsed.pathname !== '/')
42
+ throw new ConfigError('connect --origin requires an HTTPS origin without a path.');
43
+ if (!connection.token) { out(keySetup(parsed.origin)); return 2; }
44
+ if (!/^lt_[0-9a-f]{64}$/.test(connection.token)) throw new ConfigError('Supply a complete API key through AGENTLINKOPS_TOKEN or AGENTLINKOPS_API_KEY.');
45
+ if (typeof workspaceId !== 'string' || !workspaceId || typeof projectId !== 'string' || !projectId)
46
+ throw new ConfigError('connect requires --workspace ID and --project-id ID from the existing cloud project.');
47
+ if (config.cloud?.origin && new URL(config.cloud.origin).origin !== parsed.origin || connection.workspaceId && connection.workspaceId !== workspaceId || connection.projectId && connection.projectId !== projectId)
48
+ throw new ConfigError('This ledger is connected elsewhere; use a separate ledger to preserve its cursors and mapping.');
49
+ let ledgerIds;
50
+ if (args.selection !== undefined) {
51
+ if (typeof args.selection !== 'string') throw new ConfigError('--selection requires a JSON file of ledger IDs or mapping entries.');
52
+ const selection = JSON.parse(await readFile(resolve(cwd || process.cwd(), args.selection), 'utf8'));
53
+ ledgerIds = Array.isArray(selection) ? selection : selection.entries?.map(entry => entry.local_id);
54
+ const ledger = await readLedger(config.paths.ledger);
55
+ if (ledger.missing || ledger.problems.length || !Array.isArray(ledgerIds) || ledgerIds.some(id => typeof id !== 'string' || !ledger.entries.some(entry => entry.id === id)))
56
+ throw new ConfigError('Selection must name existing ledger IDs in a valid ledger.');
57
+ ledgerIds = [...new Set(ledgerIds)];
58
+ }
59
+ const client = createClient({ origin: parsed.origin, token: connection.token, workspaceId, fetchImpl });
60
+ const account = await client.getWorkspace();
61
+ if (account.workspace?.id !== workspaceId) throw new ConfigError('Cloud workspace does not match the selected workspace.');
62
+ const missing = SYNC_SCOPES.filter(scope => !account.access?.scopes?.includes(scope));
63
+ if (missing.length) throw new ConfigError(`Key lacks sync scopes: ${missing.join(', ')}.`);
64
+ const project = await client.getProject(projectId);
65
+ if (project.id !== projectId || project.workspace_id !== workspaceId) throw new ConfigError('Cloud project does not match the selected workspace.');
66
+ const lockPath = join(config.dir, 'sync.lock');
67
+ const lock = await open(lockPath, 'wx');
68
+ const file = join(config.dir, 'config.json'), temporary = join(config.dir, `.connect-${crypto.randomUUID()}.tmp`);
69
+ try {
70
+ const before = await readFile(file, 'utf8');
71
+ const saved = JSON.parse(before);
72
+ const savedIdentity = cloudConnection(saved, {});
73
+ if (savedIdentity.workspaceId && savedIdentity.workspaceId !== workspaceId || savedIdentity.projectId && savedIdentity.projectId !== projectId)
74
+ throw new ConfigError('This ledger is connected to another workspace or project; use a separate ledger to preserve its cursors and mapping.');
75
+ if (saved.cloud?.origin && new URL(saved.cloud.origin).origin !== parsed.origin)
76
+ throw new ConfigError('This ledger is connected to another origin; use a separate ledger to preserve its cursors and mapping.');
77
+ saved.cloud = { ...saved.cloud, origin: parsed.origin, workspaceId };
78
+ if (ledgerIds !== undefined) saved.cloud.ledgerIds = ledgerIds;
79
+ delete saved.cloud.token;
80
+ saved.project = { ...saved.project, id: projectId };
81
+ await writeFile(temporary, JSON.stringify(saved, null, 2) + '\n', { flag: 'wx', mode: 0o600 });
82
+ if (await readFile(file, 'utf8') !== before) throw new ConfigError('Connection configuration changed; retry.');
83
+ await rename(temporary, file);
84
+ } finally {
85
+ await unlink(temporary).catch(error => { if (error.code !== 'ENOENT') throw error; });
86
+ await lock.close(); await unlink(lockPath);
87
+ }
88
+ out(`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.`);
89
+ return 0;
90
+ }
@@ -0,0 +1,193 @@
1
+ import { loadConfig, DEFAULTS, LEGACY_DIR } from './config.js';
2
+ import { readEnv } from './env.js';
3
+ import {
4
+ contextPaths, loadContextConfig, readManual, buildManualContext, readContextState, readGscRows,
5
+ pageContext, selectFocusPages, refreshContext, importHandoff, createGoogleClient,
6
+ recentWindows, windowFromRange,
7
+ } from '../src/context/gsc.js';
8
+ import { buildSiteProfile, validateProfileCitations, readSiteFacts } from '../src/context/site-profile.js';
9
+ // Repository-local MCP over stdio. Host root is chosen at startup, never by tool arguments.
10
+ import { readFile, realpath } from 'node:fs/promises';
11
+ import { resolve, relative, dirname, join, isAbsolute, sep } from 'node:path';
12
+ import { pathToFileURL } from 'node:url';
13
+ import { McpServer } from '@modelcontextprotocol/server';
14
+ import { serveStdio } from '@modelcontextprotocol/server/stdio';
15
+ import { z } from 'zod';
16
+ import { registerContextTools } from '../src/context-tool-registry.js';
17
+
18
+ // Injecting path semantics lets the boundary be verified on Windows as well as POSIX.
19
+ export function isWithinContextRoot(root, file, paths = { relative, isAbsolute, sep }) {
20
+ const rel = paths.relative(root, file);
21
+ return rel !== '..' && !rel.startsWith(`..${paths.sep}`) && !paths.isAbsolute(rel);
22
+ }
23
+
24
+ const propertyName = value => {
25
+ if (typeof value !== 'string') return false;
26
+ if (/^sc-domain:[a-z0-9.-]+$/i.test(value)) return true;
27
+ try { const url = new URL(value); return ['http:', 'https:'].includes(url.protocol) && !url.username && !url.password && !url.search && !url.hash; }
28
+ catch { return false; }
29
+ };
30
+ const connectionGrants = new Set(['none', 'ok', 'refused', 'revoked', 'token_present_unverified']);
31
+ const permissionLevels = new Set(['siteOwner', 'siteFullUser', 'siteRestrictedUser', 'siteUnverifiedUser']);
32
+
33
+ // State files are editable local input. Never spread them into an agent response: even a
34
+ // nested credential field or malformed typed field must not become connection metadata.
35
+ export function connectionStatus(connection, tokenPresent) {
36
+ const raw = connection && typeof connection === 'object' ? connection : {};
37
+ const result = { grant: connectionGrants.has(raw.grant) ? raw.grant : (tokenPresent ? 'token_present_unverified' : 'none'),
38
+ token_in_env: Boolean(tokenPresent) };
39
+ if (typeof raw.checked_at === 'string' && /^\d{4}-\d{2}-\d{2}T/.test(raw.checked_at) && Number.isFinite(Date.parse(raw.checked_at))) result.checked_at = new Date(raw.checked_at).toISOString();
40
+ if (Number.isInteger(raw.last_status) && raw.last_status >= 100 && raw.last_status <= 599) result.last_status = raw.last_status;
41
+ if (Array.isArray(raw.properties)) result.properties = raw.properties.filter(propertyName);
42
+ if (raw.permission_levels && typeof raw.permission_levels === 'object' && !Array.isArray(raw.permission_levels)) {
43
+ result.permission_levels = Object.fromEntries(Object.entries(raw.permission_levels)
44
+ .filter(([property, level]) => propertyName(property) && permissionLevels.has(level)));
45
+ }
46
+ return result;
47
+ }
48
+
49
+ // Resolve existing ancestors as well as leaves: nonexistent outputs can still escape through
50
+ // a symlinked parent. Recheck on every call so config edits do not silently widen the root.
51
+ async function contained(root, file) {
52
+ const inside = path => {
53
+ if (!isWithinContextRoot(root, path)) throw new Error('Context path is outside the configured repository root');
54
+ };
55
+ inside(file);
56
+ let parent = file;
57
+ for (;;) {
58
+ try { inside(await realpath(parent)); break; }
59
+ catch (error) {
60
+ if (error.code !== 'ENOENT') throw error;
61
+ parent = dirname(parent);
62
+ }
63
+ }
64
+ return file;
65
+ }
66
+
67
+ export async function contextService(cwd, env = process.env) {
68
+
69
+ const root = await realpath(cwd);
70
+ const pathsOf = async () => {
71
+ for (const dir of [DEFAULTS.dir, LEGACY_DIR]) await contained(root, join(root, dir, 'config.json'));
72
+ const config = await loadConfig({ root });
73
+ const file = await loadContextConfig(config.dir);
74
+ const paths = contextPaths({ dir: config.dir, file });
75
+ for (const path of Object.values(paths)) await contained(root, path);
76
+ return { config, paths };
77
+ };
78
+ const service = {
79
+ status: async () => {
80
+ const { paths } = await pathsOf();
81
+ const state = await readContextState(paths.contextState);
82
+ const { rows, problems, missing } = await readGscRows(paths.gsc);
83
+ const manual = await readManual(paths.manual);
84
+ const facts = await readSiteFacts(paths.siteFacts);
85
+ return {
86
+ project: (await pathsOf()).config.project,
87
+ connection: connectionStatus(state.connection, Boolean(readEnv(env, 'GSC_TOKEN').value)),
88
+ handoff: state.handoff ?? null, availability: state.availability ?? {},
89
+ last_fetch_count: Object.keys(state.last_fetch ?? {}).length,
90
+ gsc_rows: rows.length, gsc_file_missing: missing, gsc_problems: problems,
91
+ site_fact_rows: facts.rows.length,
92
+ manual_present: manual.present, manual_entries: manual.entries.length,
93
+ };
94
+ },
95
+ manual: async () => {
96
+ const { paths } = await pathsOf();
97
+ const manual = await readManual(paths.manual);
98
+ if (!manual.present) return { error: `no manual.md at ${paths.manual}` };
99
+ return buildManualContext({ manual });
100
+ },
101
+ focus: async a => {
102
+ const { paths } = await pathsOf();
103
+ const manual = await readManual(paths.manual);
104
+ const { rows } = await readGscRows(paths.gsc);
105
+ const state = await readContextState(paths.contextState);
106
+ const window = a?.start && a?.end ? windowFromRange(a.start, a.end) : recentWindows({ days: 28, count: 1 })[0];
107
+ return { ...selectFocusPages({ manual, rows, window, connection: state.connection ?? null }), window };
108
+ },
109
+ page: async a => {
110
+ const { paths } = await pathsOf();
111
+ const { rows } = await readGscRows(paths.gsc);
112
+ const state = await readContextState(paths.contextState);
113
+ const manual = await readManual(paths.manual);
114
+ const window = a?.start && a?.end ? windowFromRange(a.start, a.end) : null;
115
+ return pageContext({ rows, page: a.page, window, properties: state.connection?.properties ?? null, connection: state.connection ?? null, manual });
116
+ },
117
+ refresh: async a => {
118
+ const { paths } = await pathsOf();
119
+ const token = readEnv(env, 'GSC_TOKEN').value ?? null;
120
+ const client = token ? createGoogleClient({ accessToken: token }) : null;
121
+ return refreshContext({ client, paths, properties: a?.properties ?? null, dataState: a?.dataState ?? 'final' });
122
+ },
123
+ import: async a => {
124
+ const { paths } = await pathsOf();
125
+ return importHandoff({ paths, file: await contained(root, resolve(root, a.file)) });
126
+ },
127
+ buildProfile: async (a, deps = {}) => {
128
+ const { paths } = await pathsOf();
129
+ return buildSiteProfile({ site: a?.site ?? null, paths, extraPages: a?.pages ?? [], ...(deps.transport ? { transport: deps.transport } : {}), ...(deps.acquisition ? { acquisition: deps.acquisition } : {}) });
130
+ },
131
+ checkProfile: async () => {
132
+ const { paths } = await pathsOf();
133
+ let profileText;
134
+ try { profileText = await readFile(paths.siteProfile, 'utf8'); }
135
+ catch { return { error: `no site-profile.md at ${paths.siteProfile}` }; }
136
+ const { rows } = await readSiteFacts(paths.siteFacts);
137
+ const manual = await readManual(paths.manual);
138
+ return validateProfileCitations({ profileText, facts: rows, manual });
139
+ },
140
+ };
141
+ return Object.fromEntries(Object.entries(service).map(([name, handler]) => [name, async (args = {}) => {
142
+ if (Boolean(args.start) !== Boolean(args.end)) throw new Error('start and end must be supplied together');
143
+ return handler(args);
144
+ }]));
145
+ }
146
+
147
+ export async function createContextServer({ root, env = process.env }) {
148
+ if (!root) throw new Error('A repository --root is required');
149
+ const context = await contextService(root, env);
150
+ const server = new McpServer({ name: 'agentlinkops-local-context', version: '0.1.0' });
151
+ // Serialize file operations, including reads: refresh/import share state files and must not
152
+ // lose updates when an agent submits several calls concurrently.
153
+ let tail = Promise.resolve();
154
+ registerContextTools({ context, contextTool(name, description, scope, input, handler, write) {
155
+ server.registerTool(name, {
156
+ description, inputSchema: z.strictObject(input),
157
+ annotations: { readOnlyHint: !write, destructiveHint: false,
158
+ idempotentHint: !write, openWorldHint: ['context_gsc_refresh', 'context_profile_build'].includes(name) },
159
+ }, args => {
160
+ const result = tail.then(async () => {
161
+ try {
162
+ const value = await handler(args);
163
+ return { content: [{ type: 'text', text: JSON.stringify(value) }], structuredContent: value,
164
+ ...(value.error || value.unresolved?.length ? { isError: true } : {}) };
165
+ } catch (error) {
166
+ // Redact tokens even from third-party exception strings; stdout is protocol-only.
167
+ let message = String(error.message ?? 'Context operation failed');
168
+ for (const secret of [env.AGENTLINKOPS_GSC_TOKEN, env.LINKTRAIL_GSC_TOKEN, env.AGENTLINKOPS_GA4_TOKEN, env.LINKTRAIL_GA4_TOKEN].filter(Boolean)) message = message.replaceAll(secret, '[redacted]');
169
+ return { isError: true, content: [{ type: 'text', text: message }] };
170
+ }
171
+ });
172
+ tail = result.catch(() => {});
173
+ return result;
174
+ });
175
+ } });
176
+ return server;
177
+ }
178
+
179
+ const directlyInvoked = await (async () => {
180
+ try { return process.argv[1] && import.meta.url === pathToFileURL(await realpath(process.argv[1])).href; }
181
+ catch { return false; }
182
+ })();
183
+ if (directlyInvoked) {
184
+ const args = process.argv.slice(2);
185
+ if (args.length !== 2 || args[0] !== '--root') {
186
+ console.error('Usage: node cli/context-mcp.js --root /absolute/customer/repository');
187
+ process.exitCode = 2;
188
+ } else {
189
+ // Build once before accepting protocol traffic so invalid roots fail at startup.
190
+ const server = await createContextServer({ root: args[1] });
191
+ serveStdio(() => server, { onerror: () => console.error('Local context MCP transport error') });
192
+ }
193
+ }
package/cli/context.js ADDED
@@ -0,0 +1,340 @@
1
+ import {parseArgs as parseArguments} from './args.js';
2
+ // `agentlinkops context …` — the DP-0017 first-party context surface (T02 GSC + manual, T03 site
3
+ // profile). STANDALONE BY CONFLICT RULE: this card may not edit src/mcp.js or register tools,
4
+ // so nothing here is dispatched from cli/main.js yet. The module is directly runnable:
5
+ //
6
+ // node cli/context.js status
7
+ // node cli/context.js gsc page https://example.com/guide --json
8
+ //
9
+ // and the wiring into main.js's USAGE/dispatch (plus any MCP tool registration) is the recorded
10
+ // follow-up for the next wave. Everything below needs no account; `gsc refresh` needs a
11
+ // customer-supplied token in AGENTLINKOPS_GSC_TOKEN (LINKTRAIL_GSC_TOKEN still accepted) and refuses quietly into `manual_only`
12
+ // without one — the path with no Google is a complete configuration, not a failure.
13
+ import { readFile } from 'node:fs/promises';
14
+ import { pathToFileURL } from 'node:url';
15
+ import { loadConfig, ConfigError } from './config.js';
16
+ import { readEnv } from './env.js';
17
+ import {
18
+ contextPaths, loadContextConfig, readManual, buildManualContext, parseManual,
19
+ readContextState, readGscRows, refreshContext, pageContext, selectFocusPages,
20
+ importHandoff, forgetContext, createGoogleClient, recentWindows, windowFromRange,
21
+ } from '../src/context/gsc.js';
22
+ import { buildSiteProfile, renderSiteProfile, validateProfileCitations, readSiteFacts } from '../src/context/site-profile.js';
23
+ import {
24
+ refreshGa4Context, landingPageContext, keyEventsContext, businessContextForPage,
25
+ importGa4Handoff, forgetGa4Context, createGa4Client, readGa4Rows, GA4_SCOPE,
26
+ } from '../src/context/ga4.js';
27
+
28
+ const USAGE = `agentlinkops context — first-party search context in your repository (DP-0017)
29
+
30
+ agentlinkops context status [--json] connection state, windows, availability floors
31
+ agentlinkops context manual [--json] the manual.md context (works with no Google)
32
+ agentlinkops context focus [--json] nominated focus pages (manual, then GSC clicks)
33
+ agentlinkops context gsc refresh [--property P]… [--data-state all] [--json]
34
+ bounded read-only refresh (<=12 calls/property,
35
+ hard cap 50; needs AGENTLINKOPS_GSC_TOKEN, else
36
+ reports manual_only)
37
+ agentlinkops context gsc page URL [--start D] [--end D] [--json]
38
+ page/query context for a supplied page, from
39
+ repo rows — no Google needed
40
+ agentlinkops context gsc import FILE [--json] import an agent/connector handoff snapshot
41
+ agentlinkops context gsc forget [--source gsc|site|all] [--json]
42
+ remove tool-owned context files; manual.md and
43
+ site-profile.md are never touched
44
+ agentlinkops context profile build [--site ORIGIN] [--page URL]… [--json]
45
+ bounded public sitemap + <=10 page profile
46
+ agentlinkops context profile render [--force] [--json]
47
+ write the editable site-profile.md scaffold
48
+ (never overwrites judgments without --force)
49
+ agentlinkops context profile check [--json] every [fact:…]/[manual:…] citation resolves
50
+ agentlinkops context ga4 refresh --property P [--start D --end D] [--json]
51
+ optional GA4 business context (<=4 reports/
52
+ property, hard cap 10; needs
53
+ AGENTLINKOPS_GA4_TOKEN, else reports ga4_absent)
54
+ agentlinkops context ga4 page URL [--start D] [--end D] [--json]
55
+ aggregate landing-page context + configured
56
+ key events, with measurement caveats
57
+ agentlinkops context ga4 key-events [--json] the configured key events and their totals
58
+ agentlinkops context ga4 import FILE [--json] import an agent/connector GA4 handoff snapshot
59
+ agentlinkops context ga4 forget [--json] remove GA4 rows + GA4 state only
60
+ agentlinkops context business URL [--start D] [--end D] [--json]
61
+ GSC search layer and GA4 business layer for a
62
+ page, side by side — never joined
63
+
64
+ Read-only. Google scopes requested: webmasters.readonly (GSC) and analytics.readonly (GA4,
65
+ separate consent) only. No CMS, no paid index, no Links API, no AI-report API.`;
66
+
67
+ const parseArgs = argv => parseArguments(argv,["property", "page"],{property:[],page:[]});
68
+
69
+ async function resolvePaths(cwd) {
70
+ const config = await loadConfig({ cwd });
71
+ const file = await loadContextConfig(config.dir);
72
+ return { config, paths: contextPaths({ dir: config.dir, file }) };
73
+ }
74
+
75
+ export async function contextMain(argv = process.argv.slice(2), { cwd = process.cwd(), out = console.log, err = console.error, env = process.env } = {}) {
76
+ const args = parseArgs(argv);
77
+ const area = args._[0] ?? 'help';
78
+ const command = args._[1] ?? null;
79
+ if (area === 'help' || args.help) { out(USAGE); return 0; }
80
+ try {
81
+ const { config, paths } = await resolvePaths(cwd);
82
+
83
+ if (area === 'status') {
84
+ const state = await readContextState(paths.contextState);
85
+ const { rows, problems, missing } = await readGscRows(paths.gsc);
86
+ const ga4 = await readGa4Rows(paths.ga4);
87
+ const manual = await readManual(paths.manual);
88
+ const facts = await readSiteFacts(paths.siteFacts);
89
+ const gscToken = readEnv(env, 'GSC_TOKEN'), ga4Token = readEnv(env, 'GA4_TOKEN');
90
+ const tokenPresent = Boolean(gscToken.value);
91
+ const ga4TokenPresent = Boolean(ga4Token.value);
92
+ const summary = {
93
+ project: config.project,
94
+ connection: { ...(state.connection ?? { grant: tokenPresent ? 'token_present_unverified' : 'none' }), token_in_env: tokenPresent },
95
+ handoff: state.handoff ?? null,
96
+ availability: state.availability ?? {},
97
+ last_fetch_count: Object.keys(state.last_fetch ?? {}).length,
98
+ ga4: { ...(state.ga4 ?? { grant: ga4TokenPresent ? 'token_present_unverified' : 'none' }), token_in_env: ga4TokenPresent, rows: ga4.rows.length, file_missing: ga4.missing },
99
+ gsc_rows: rows.length, gsc_file_missing: missing, gsc_problems: problems,
100
+ site_fact_rows: facts.rows.length,
101
+ manual_present: manual.present, manual_entries: manual.entries.length,
102
+ };
103
+ if (args.json) { out(JSON.stringify(summary, null, 1)); return 0; }
104
+ out(`connection grant: ${summary.connection.grant}${tokenPresent ? ` (${gscToken.name} present)` : ''}`);
105
+ out(`gsc rows: ${rows.length}${missing ? ' (no gsc.jsonl yet)' : ''}`);
106
+ const ga4Configured = (state.ga4?.key_events_config ?? []).length;
107
+ out(`ga4: ${summary.ga4.grant}${summary.ga4.property ? ` ${summary.ga4.property}` : ''} rows ${ga4.rows.length}${ga4Configured ? ` ${ga4Configured} key event(s) configured` : ''}`);
108
+ out(`site facts: ${facts.rows.length} manual: ${manual.present ? `${manual.entries.length} entr(ies)` : 'absent'}`);
109
+ if (Object.keys(summary.availability).length) out(`availability floors: ${JSON.stringify(summary.availability)}`);
110
+ return 0;
111
+ }
112
+
113
+ if (area === 'manual') {
114
+ const manual = await readManual(paths.manual);
115
+ if (!manual.present) { err(`no manual.md at ${paths.manual} — the no-Google floor; create it with target pages, site description, offering, assets, competitors`); return 2; }
116
+ const context = buildManualContext({ manual });
117
+ if (args.json) { out(JSON.stringify(context, null, 1)); return 0; }
118
+ out(JSON.stringify(context.inputs, null, 1));
119
+ out(`${context.pages.length} target page(s) nominated by manual alone`);
120
+ return 0;
121
+ }
122
+
123
+ if (area === 'focus') {
124
+ const manual = await readManual(paths.manual);
125
+ const { rows } = await readGscRows(paths.gsc);
126
+ const state = await readContextState(paths.contextState);
127
+ const window = args.start && args.end ? windowFromRange(args.start, args.end) : (recentWindows({ days: 28, count: 1 })[0]);
128
+ const focus = selectFocusPages({ manual, rows, window, connection: state.connection ?? null });
129
+ if (args.json) { out(JSON.stringify({ ...focus, window }, null, 1)); return 0; }
130
+ for (const entry of focus.pages) out(`${entry.page} ${entry.nominated_by.join('+')}${entry.observation ? ` clicks ${entry.observation.clicks ?? 'null'}` : ''}`);
131
+ return 0;
132
+ }
133
+
134
+ if (area === 'gsc') {
135
+ if (command === 'refresh') {
136
+ const token = readEnv(env, 'GSC_TOKEN').value ?? null;
137
+ const client = token ? createGoogleClient({ accessToken: token }) : null;
138
+ const result = await refreshContext({
139
+ client, paths,
140
+ properties: args.property?.length ? args.property : null,
141
+ dataState: args['data-state'] === 'all' ? 'all' : 'final',
142
+ });
143
+ if (args.json) { out(JSON.stringify(result, null, 1)); return 0; }
144
+ out(`state: ${result.state}`);
145
+ for (const property of result.properties) out(` ${property.siteUrl}: ${property.state}${property.rows_written ? `, ${property.rows_written} row(s)` : ''}`);
146
+ out(`calls: ${JSON.stringify(result.calls)} served from repo: ${result.served_from_repo}`);
147
+ for (const note of result.notes) out(`note: ${note}`);
148
+ return 0;
149
+ }
150
+ if (command === 'page') {
151
+ const page = args._[2];
152
+ if (!page) { err('agentlinkops context gsc page URL'); return 2; }
153
+ const { rows } = await readGscRows(paths.gsc);
154
+ const state = await readContextState(paths.contextState);
155
+ const manual = await readManual(paths.manual);
156
+ const window = args.start && args.end ? windowFromRange(args.start, args.end) : null;
157
+ const result = pageContext({ rows, page, window, properties: state.connection?.properties ?? null, connection: state.connection ?? null, manual });
158
+ if (args.json) { out(JSON.stringify(result, null, 1)); return 0; }
159
+ out(`state: ${result.state} window: ${result.window ? `${result.window.start}..${result.window.end}` : 'n/a'}${result.final_through ? ` final through ${result.final_through}` : ''}`);
160
+ if (result.page_totals) out(`totals: clicks ${result.page_totals.clicks} impressions ${result.page_totals.impressions} ctr ${result.page_totals.ctr} avg position ${result.page_totals.average_position}`);
161
+ for (const query of result.queries.slice(0, 20)) out(` "${query.query}" ${query.clicks} clicks ${query.impressions} impr ctr ${query.ctr} avg ${query.average_position}`);
162
+ for (const note of result.notes) out(`note: ${note}`);
163
+ return 0;
164
+ }
165
+ if (command === 'import') {
166
+ const file = args._[2];
167
+ if (!file) { err('agentlinkops context gsc import FILE'); return 2; }
168
+ const result = await importHandoff({ paths, file });
169
+ if (result.error) { err(result.error); return 2; }
170
+ if (args.json) { out(JSON.stringify(result, null, 1)); return 0; }
171
+ out(`imported ${result.accepted} row(s) from connector(s): ${result.connectors.join(', ')}`);
172
+ for (const refused of result.refused.slice(0, 20)) out(` refused row ${refused.row}: ${refused.reason}`);
173
+ if (result.refused.length > 20) out(` … and ${result.refused.length - 20} more`);
174
+ return result.refused.length ? 0 : 0;
175
+ }
176
+ if (command === 'forget') {
177
+ const source = args.source === true ? 'gsc' : (args.source ?? 'gsc');
178
+ const result = await forgetContext({ paths, source });
179
+ if (args.json) { out(JSON.stringify(result, null, 1)); return 0; }
180
+ for (const item of result.removed) out(`removed ${item.path}`);
181
+ for (const item of result.kept) out(`kept ${item.path} (${item.owner}-owned)`);
182
+ out(result.note);
183
+ return 0;
184
+ }
185
+ err(`unknown context gsc command: ${command ?? '(none)'}\n\n${USAGE}`);
186
+ return 2;
187
+ }
188
+
189
+ if (area === 'ga4') {
190
+ if (command === 'refresh') {
191
+ const token = readEnv(env, 'GA4_TOKEN').value ?? null;
192
+ const client = token ? createGa4Client({ accessToken: token }) : null;
193
+ const property = Array.isArray(args.property) ? args.property[0] : args.property;
194
+ if (client && !property) { err('agentlinkops context ga4 refresh --property properties/123 (or a bare numeric id)'); return 2; }
195
+ const window = args.start && args.end ? windowFromRange(args.start, args.end) : undefined;
196
+ const result = await refreshGa4Context({ client, paths, property, windows: window ? [window] : null });
197
+ if (args.json) { out(JSON.stringify(result, null, 1)); return 0; }
198
+ out(`state: ${result.state}${result.property ? ` property: ${result.property}` : ''}`);
199
+ if (result.key_events_configured) out(`key events configured: ${result.key_events_configured.map(e => e.event_name).join(', ') || '(none)'}`);
200
+ out(`calls: ${JSON.stringify(result.calls)} served from repo: ${result.served_from_repo} rows: ${result.rows_written}`);
201
+ for (const note of result.notes) out(`note: ${note}`);
202
+ return 0;
203
+ }
204
+ if (command === 'page') {
205
+ const page = args._[2];
206
+ if (!page) { err('agentlinkops context ga4 page URL'); return 2; }
207
+ const { rows } = await readGa4Rows(paths.ga4);
208
+ const state = await readContextState(paths.contextState);
209
+ const window = args.start && args.end ? windowFromRange(args.start, args.end) : null;
210
+ const result = landingPageContext({ rows, page, window, connection: state.ga4 ?? null });
211
+ if (args.json) { out(JSON.stringify(result, null, 1)); return 0; }
212
+ out(`state: ${result.state} property: ${result.property ?? 'n/a'} window: ${result.window ? `${result.window.start}..${result.window.end}` : 'n/a'}`);
213
+ if (result.totals) out(`totals: sessions ${result.totals.sessions} key events ${result.totals.key_events} per session ${result.totals.key_events_per_session} revenue ${result.totals.total_revenue}${result.currency_code ? ` ${result.currency_code}` : ''}`);
214
+ for (const event of result.events.slice(0, 20)) out(` key event "${event.event_name}" ${event.key_events} (s) revenue ${event.total_revenue}`);
215
+ for (const caveat of result.caveats) out(`caveat ${caveat.code}: ${caveat.note}`);
216
+ for (const note of result.notes) out(`note: ${note}`);
217
+ return 0;
218
+ }
219
+ if (command === 'key-events') {
220
+ const { rows } = await readGa4Rows(paths.ga4);
221
+ const window = args.start && args.end ? windowFromRange(args.start, args.end) : null;
222
+ const result = keyEventsContext({ rows, window });
223
+ if (args.json) { out(JSON.stringify(result, null, 1)); return 0; }
224
+ out(`state: ${result.state} property: ${result.property ?? 'n/a'}${result.window ? ` window: ${result.window.start}..${result.window.end}` : ''}`);
225
+ for (const event of result.key_events) out(` "${event.event_name}" (${event.counting_method ?? 'counting method unknown'}) key events: ${event.key_events ?? 'withheld/null'} [${event.basis}]`);
226
+ for (const caveat of result.caveats) out(`caveat ${caveat.code}: ${caveat.note}`);
227
+ for (const note of result.notes) out(`note: ${note}`);
228
+ return 0;
229
+ }
230
+ if (command === 'import') {
231
+ const file = args._[2];
232
+ if (!file) { err('agentlinkops context ga4 import FILE'); return 2; }
233
+ const result = await importGa4Handoff({ paths, file });
234
+ if (result.error) { err(result.error); return 2; }
235
+ if (args.json) { out(JSON.stringify(result, null, 1)); return 0; }
236
+ out(`imported ${result.accepted} row(s) from connector(s): ${result.connectors.join(', ')}`);
237
+ for (const refused of result.refused.slice(0, 20)) out(` refused row ${refused.row}: ${refused.reason}`);
238
+ return 0;
239
+ }
240
+ if (command === 'forget') {
241
+ const result = await forgetGa4Context({ paths });
242
+ if (args.json) { out(JSON.stringify(result, null, 1)); return 0; }
243
+ for (const item of result.removed) out(`removed ${item.path}`);
244
+ for (const item of result.kept) out(`kept ${item.path} (${item.owner}-owned)`);
245
+ out(result.note);
246
+ return 0;
247
+ }
248
+ err(`unknown context ga4 command: ${command ?? '(none)'}\n\n${USAGE}`);
249
+ return 2;
250
+ }
251
+
252
+ if (area === 'business') {
253
+ const page = args._[1];
254
+ if (!page) { err('agentlinkops context business URL'); return 2; }
255
+ const gsc = await readGscRows(paths.gsc);
256
+ const ga4 = await readGa4Rows(paths.ga4);
257
+ const state = await readContextState(paths.contextState);
258
+ const manual = await readManual(paths.manual);
259
+ const window = args.start && args.end ? windowFromRange(args.start, args.end) : null;
260
+ const result = businessContextForPage({
261
+ gscRows: gsc.rows, ga4Rows: ga4.rows, page, window,
262
+ properties: state.connection?.properties ?? null, connection: state.connection ?? null,
263
+ ga4Connection: state.ga4 ?? null, manual,
264
+ });
265
+ if (args.json) { out(JSON.stringify(result, null, 1)); return 0; }
266
+ out(`state: ${result.state} relation: ${result.relation}`);
267
+ const search = result.layers.search, business = result.layers.business;
268
+ out(`search layer: ${search.state}${search.page_totals ? ` — clicks ${search.page_totals.clicks}, impressions ${search.page_totals.impressions}` : ''}`);
269
+ out(`business layer: ${business.state}${business.totals ? ` — sessions ${business.totals.sessions}, key events ${business.totals.key_events}` : ''}`);
270
+ for (const caveat of result.caveats) out(`caveat ${caveat.code}: ${caveat.note}`);
271
+ return 0;
272
+ }
273
+
274
+ if (area === 'profile') {
275
+ if (command === 'build') {
276
+ const result = await buildSiteProfile({
277
+ site: args.site === true ? null : (args.site ?? null),
278
+ paths, extraPages: args.page ?? [],
279
+ });
280
+ if (args.json) { out(JSON.stringify(result, null, 1)); return 0; }
281
+ 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)`);
282
+ out(`pages: ${result.pages.length} (cap ${result.limits.selected_pages}${result.limits.pages_capped ? ', CAPPED' : ''})`);
283
+ for (const page of result.pages) out(` ${page.outcome === 'fetched' ? 'ok ' : page.outcome.padEnd(8, ' ')} ${page.url}${page.outcome !== 'fetched' ? ` [${page.reason}]` : ''}`);
284
+ out(`sitemap counters: failures ${JSON.stringify(result.sitemaps.failures)} skipped ${JSON.stringify(result.sitemaps.skipped)} truncated ${result.sitemaps.truncated}`);
285
+ for (const note of result.notes) out(`note: ${note}`);
286
+ return 0;
287
+ }
288
+ if (command === 'render') {
289
+ const { rows } = await readSiteFacts(paths.siteFacts);
290
+ if (!rows.length) { err(`no site facts at ${paths.siteFacts} — run \`agentlinkops context profile build\` first`); return 2; }
291
+ const manual = await readManual(paths.manual);
292
+ let existing = null;
293
+ try { existing = await readFile(paths.siteProfile, 'utf8'); } catch { /* absent is the normal first run */ }
294
+ if (existing && !args.force) {
295
+ err(`site-profile.md already exists at ${paths.siteProfile} and holds judgments; pass --force to regenerate (this discards edits)`);
296
+ return 2;
297
+ }
298
+ const origin = rows.find(row => row.kind === 'site.robots')?.url?.replace(/\/robots\.txt$/u, '') ?? null;
299
+ const markdown = renderSiteProfile({ facts: rows, manual, origin });
300
+ const { writeFile, mkdir } = await import('node:fs/promises');
301
+ const { dirname } = await import('node:path');
302
+ await mkdir(dirname(paths.siteProfile), { recursive: true });
303
+ await writeFile(paths.siteProfile, markdown, 'utf8');
304
+ const check = validateProfileCitations({ profileText: markdown, facts: rows, manual });
305
+ out(`wrote ${paths.siteProfile} (${check.citations} citation(s), ${check.unresolved.length} unresolved)${existing ? ' --force: previous judgments were replaced' : ''}`);
306
+ return 0;
307
+ }
308
+ if (command === 'check') {
309
+ let markdown;
310
+ try { markdown = await readFile(paths.siteProfile, 'utf8'); } catch { err(`no site-profile.md at ${paths.siteProfile}`); return 2; }
311
+ const { rows } = await readSiteFacts(paths.siteFacts);
312
+ const manual = await readManual(paths.manual);
313
+ const check = validateProfileCitations({ profileText: markdown, facts: rows, manual });
314
+ if (args.json) { out(JSON.stringify(check, null, 1)); return check.unresolved.length ? 1 : 0; }
315
+ out(`${check.citations} citation(s) checked, ${check.unresolved.length} unresolved`);
316
+ for (const ref of check.unresolved) out(` UNRESOLVED ${ref}`);
317
+ out('facts are read-only to the profile: editing a judgment never edits a fact row');
318
+ return check.unresolved.length ? 1 : 0;
319
+ }
320
+ err(`unknown context profile command: ${command ?? '(none)'}\n\n${USAGE}`);
321
+ return 2;
322
+ }
323
+
324
+ err(`unknown context area: ${area}\n\n${USAGE}`);
325
+ return 2;
326
+ } catch (error) {
327
+ if (error instanceof ConfigError) { err(error.message); return 2; }
328
+ err(`agentlinkops context: ${error?.message ?? error}`);
329
+ return 2;
330
+ }
331
+ }
332
+
333
+ // Directly runnable: `node cli/context.js …`. Registration in cli/main.js (USAGE + dispatch)
334
+ // and any MCP tool registration in src/mcp.js are the recorded follow-up for the next wave.
335
+ const invokedDirectly = (() => {
336
+ try { return process.argv[1] && import.meta.url === pathToFileURL(process.argv[1]).href; } catch { return false; }
337
+ })();
338
+ if (invokedDirectly) {
339
+ contextMain().then(code => process.exit(code), error => { console.error(error); process.exit(2); });
340
+ }