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.
- package/.claude-plugin/plugin.json +10 -0
- package/.codex-plugin/plugin.json +24 -0
- package/CHANGELOG.md +52 -0
- package/LICENSE +190 -0
- package/NOTICE +17 -0
- package/README.md +202 -0
- package/bin/agentlinkops.mjs +4 -0
- package/bin/linktrail.mjs +6 -0
- package/cli/adapters/csv.js +68 -0
- package/cli/adapters/sqlite-crm.js +79 -0
- package/cli/adapters/suppliers.js +180 -0
- package/cli/agent-setup.js +203 -0
- package/cli/agentlinkops.mjs +4 -0
- package/cli/args.js +18 -0
- package/cli/catalog.js +7483 -0
- package/cli/check.js +119 -0
- package/cli/citation.js +109 -0
- package/cli/client.js +60 -0
- package/cli/commands.js +183 -0
- package/cli/config.js +104 -0
- package/cli/connection.js +90 -0
- package/cli/context-mcp.js +193 -0
- package/cli/context.js +340 -0
- package/cli/delivery.js +67 -0
- package/cli/doctor.js +283 -0
- package/cli/env.js +66 -0
- package/cli/fleet.js +82 -0
- package/cli/gsc-browser.js +239 -0
- package/cli/import.js +108 -0
- package/cli/ledger.js +232 -0
- package/cli/linktrail.mjs +6 -0
- package/cli/local-result.js +161 -0
- package/cli/main.js +523 -0
- package/cli/mirror.js +126 -0
- package/cli/mix.js +123 -0
- package/cli/platforms.js +161 -0
- package/cli/receipt-performance.js +64 -0
- package/cli/receipts.js +122 -0
- package/cli/report.js +158 -0
- package/cli/setup-plan.js +211 -0
- package/cli/skill.js +39 -0
- package/cli/state.js +106 -0
- package/cli/status.js +51 -0
- package/cli/sync.js +179 -0
- package/package.json +59 -0
- package/references/cli.md +198 -0
- package/references/disavow.md +73 -0
- package/references/reports.md +53 -0
- package/scripts/agentlinkops.py +651 -0
- package/scripts/disavow.py +207 -0
- package/scripts/linktrail.py +10 -0
- package/scripts/migrations/001_crm.sql +45 -0
- package/scripts/migrations/002_cloud_events.sql +13 -0
- package/scripts/migrations/003_disavow.sql +20 -0
- package/scripts/migrations/004_placement_costs.sql +2 -0
- package/scripts/report.py +113 -0
- package/shared/agent-clients.js +49 -0
- package/shared/observation-event.js +43 -0
- package/shared/response-contract.js +59 -0
- package/shared/search-connection-contract.js +18 -0
- package/skills/agentlinkops-assets/SKILL.md +13 -0
- package/skills/agentlinkops-assets/agents/openai.yaml +5 -0
- package/skills/agentlinkops-campaigns/SKILL.md +17 -0
- package/skills/agentlinkops-campaigns/agents/openai.yaml +12 -0
- package/skills/agentlinkops-connect/SKILL.md +33 -0
- package/skills/agentlinkops-connect/agents/openai.yaml +12 -0
- package/skills/agentlinkops-connect/references/agentlinkops.md +266 -0
- package/skills/agentlinkops-crm/SKILL.md +27 -0
- package/skills/agentlinkops-crm/agents/openai.yaml +12 -0
- package/skills/content-defingerprinting/SKILL.md +29 -0
- package/skills/content-defingerprinting/scripts/watermarks/README.md +102 -0
- package/skills/content-defingerprinting/scripts/watermarks/UPSTREAM.json +26 -0
- package/skills/content-defingerprinting/scripts/watermarks/revendor.sh +52 -0
- package/skills/content-defingerprinting/scripts/watermarks/vendor/LICENSE.upstream +21 -0
- package/skills/content-defingerprinting/scripts/watermarks/vendor/clean_text.py +96 -0
- package/skills/content-defingerprinting/scripts/watermarks/vendor/common.py +432 -0
- package/skills/content-defingerprinting/scripts/watermarks/vendor/inspect_text.py +86 -0
- package/skills/content-defingerprinting/scripts/watermarks/vendor/score_stylometry.py +460 -0
- package/skills/content-defingerprinting/scripts/watermarks/vendor/text_unicode.py +651 -0
- package/skills/content-defingerprinting/scripts/watermarks/verify-vendor.py +27 -0
- package/skills/content-defingerprinting/scripts/watermarks/wm-scan.py +212 -0
- package/skills/internal-linking-optimizer/SKILL.md +56 -0
- package/skills/internal-linking-optimizer/references/source-prompt.md +76 -0
- package/skills/pre-publish-review/SKILL.md +179 -0
- package/skills/source-cited-content-builder/SKILL.md +56 -0
- package/skills/source-cited-content-builder/references/source-prompt.md +19 -0
- package/skills/source-cited-humanizer/SKILL.md +20 -0
- package/src/citations/adapter.js +141 -0
- package/src/citations/contract.js +171 -0
- package/src/citations/match.js +120 -0
- package/src/citations/runner.js +151 -0
- package/src/citations/stats.js +74 -0
- package/src/competitors/contract.js +157 -0
- package/src/competitors/domain-mix.js +199 -0
- package/src/context/ga4.js +652 -0
- package/src/context/gsc.js +925 -0
- package/src/context/site-profile.js +445 -0
- package/src/context/util.js +71 -0
- package/src/context-tool-registry.js +27 -0
- package/src/corpus/acquire.js +135 -0
- package/src/corpus/seeds.js +182 -0
- package/src/discovery/contract.js +237 -0
- package/src/discovery/import.js +156 -0
- package/src/tool-views.js +117 -0
- package/src/verifier/fetch.js +303 -0
- package/src/verifier/index.js +311 -0
- package/src/verifier/observation-kind.js +91 -0
- package/src/verifier/parser.js +241 -0
- package/src/verifier/readiness.js +90 -0
- package/src/verifier/render-evidence.js +57 -0
- package/src/verifier/robots.js +85 -0
- package/src/verifier/transition.js +129 -0
- package/src/verifier/url.js +56 -0
- package/templates/SITE.template.md +59 -0
- package/templates/campaigns.v1.json +65 -0
|
@@ -0,0 +1,180 @@
|
|
|
1
|
+
// Column mappings for the exports customers already have.
|
|
2
|
+
//
|
|
3
|
+
// The architecture matters more than any individual alias list. **A supplier preset is a
|
|
4
|
+
// convenience over an explicit mapping, never a requirement**: every import can be driven by
|
|
5
|
+
// `--map source=…,target=…`, and a preset only supplies defaults for that. So when a vendor
|
|
6
|
+
// renames a column — and they do — the import still works today with one flag rather than
|
|
7
|
+
// waiting for a release. Detection failure prints the header row it actually saw and the exact
|
|
8
|
+
// `--map` that would fix it.
|
|
9
|
+
//
|
|
10
|
+
// The alias lists below are best effort, drawn from the exports these tools have written. They
|
|
11
|
+
// are matched case-insensitively with spacing and punctuation removed, so "Referring page URL",
|
|
12
|
+
// "referring_page_url" and "ReferringPageURL" are one name.
|
|
13
|
+
import { normalizeHeader } from './csv.js';
|
|
14
|
+
|
|
15
|
+
const alias = (...names) => names.map(normalizeHeader);
|
|
16
|
+
|
|
17
|
+
export const SUPPLIERS = Object.freeze({
|
|
18
|
+
ahrefs: {
|
|
19
|
+
label: 'Ahrefs backlinks export',
|
|
20
|
+
source: alias('Referring page URL', 'Referring Page URL', 'Source url', 'url_from'),
|
|
21
|
+
target: alias('Target URL', 'Link URL', 'url_to'),
|
|
22
|
+
anchor: alias('Anchor', 'Anchor text'),
|
|
23
|
+
first_seen: alias('First seen'), last_seen: alias('Last seen'),
|
|
24
|
+
nofollow: alias('Nofollow'), lost: alias('Lost'),
|
|
25
|
+
link_type: alias('Type', 'Link type'),
|
|
26
|
+
},
|
|
27
|
+
semrush: {
|
|
28
|
+
label: 'Semrush backlinks export',
|
|
29
|
+
source: alias('Source url', 'Source URL', 'Page URL'),
|
|
30
|
+
target: alias('Target url', 'Target URL'),
|
|
31
|
+
anchor: alias('Anchor', 'Anchor text'),
|
|
32
|
+
first_seen: alias('First seen'), last_seen: alias('Last seen'),
|
|
33
|
+
nofollow: alias('Nofollow'), lost: alias('Lost'),
|
|
34
|
+
link_type: alias('Type'),
|
|
35
|
+
},
|
|
36
|
+
majestic: {
|
|
37
|
+
label: 'Majestic backlinks export',
|
|
38
|
+
source: alias('Source URL', 'SourceURL'),
|
|
39
|
+
target: alias('Target URL', 'TargetURL'),
|
|
40
|
+
anchor: alias('Anchor Text', 'AnchorText'),
|
|
41
|
+
first_seen: alias('First Indexed Date', 'FirstIndexedDate', 'Date'),
|
|
42
|
+
last_seen: alias('Last Seen Date', 'LastSeenDate', 'Date'),
|
|
43
|
+
nofollow: alias('FlagNoFollow', 'NoFollow'),
|
|
44
|
+
link_type: alias('LinkType'),
|
|
45
|
+
},
|
|
46
|
+
moz: {
|
|
47
|
+
label: 'Moz Link Explorer export',
|
|
48
|
+
source: alias('Source Page', 'Source URL'),
|
|
49
|
+
target: alias('Target Page', 'Target URL'),
|
|
50
|
+
anchor: alias('Anchor Text'),
|
|
51
|
+
first_seen: alias('First Seen'), last_seen: alias('Last Seen'),
|
|
52
|
+
nofollow: alias('Nofollow'), link_type: alias('Link Type'),
|
|
53
|
+
},
|
|
54
|
+
dataforseo: {
|
|
55
|
+
label: 'DataForSEO backlinks export',
|
|
56
|
+
source: alias('url_from'), target: alias('url_to'),
|
|
57
|
+
anchor: alias('anchor'),
|
|
58
|
+
first_seen: alias('first_seen'), last_seen: alias('last_seen', 'last_visited'),
|
|
59
|
+
dofollow: alias('dofollow'), lost: alias('is_lost'),
|
|
60
|
+
link_type: alias('item_type', 'type'),
|
|
61
|
+
},
|
|
62
|
+
linkody: {
|
|
63
|
+
label: 'Linkody export',
|
|
64
|
+
source: alias('URL', 'Source URL', 'From'),
|
|
65
|
+
target: alias('Link', 'Target URL', 'To'),
|
|
66
|
+
anchor: alias('Anchor', 'Anchor text'),
|
|
67
|
+
first_seen: alias('Date found', 'First seen'), last_seen: alias('Last seen'),
|
|
68
|
+
nofollow: alias('Nofollow', 'Follow'),
|
|
69
|
+
},
|
|
70
|
+
google_search_console: {
|
|
71
|
+
label: 'Google Search Console linking pages export',
|
|
72
|
+
source: alias('Linking page', 'Linking Page', 'Source page'),
|
|
73
|
+
target: alias('Target page', 'Target Page', 'Linked page'),
|
|
74
|
+
last_seen: alias('Last crawled', 'Last crawled date'),
|
|
75
|
+
// GSC's headline exports are COUNTS PER SITE, not placements. Importing one would mean
|
|
76
|
+
// inventing a source page URL for every row, and an invented URL is a link we would then
|
|
77
|
+
// check, fail to find, and report to the customer as lost.
|
|
78
|
+
refuse: [
|
|
79
|
+
{ when: alias('Site', 'Incoming links'), because: 'this is the "Top linking sites" export: a count per site, not a list of pages. Open a site in Search Console and export its linking pages instead.' },
|
|
80
|
+
{ when: alias('Top linked pages', 'Incoming links'), because: 'this is the "Top linked pages" export: a count per page of yours, not a list of the pages that link to it.' },
|
|
81
|
+
],
|
|
82
|
+
},
|
|
83
|
+
bing_webmaster_tools: {
|
|
84
|
+
label: 'Bing Webmaster Tools backlinks export',
|
|
85
|
+
source: alias('Source URL', 'SourceUrl', 'Referring page', 'Referring page URL'),
|
|
86
|
+
target: alias('Target URL', 'TargetUrl', 'Target page'),
|
|
87
|
+
anchor: alias('Anchor text', 'AnchorText'),
|
|
88
|
+
},
|
|
89
|
+
csv: { label: 'generic CSV', source: [], target: [] },
|
|
90
|
+
});
|
|
91
|
+
|
|
92
|
+
export const SUPPLIER_NAMES = Object.freeze(Object.keys(SUPPLIERS));
|
|
93
|
+
|
|
94
|
+
const FIELDS = Object.freeze(['source', 'target', 'anchor', 'first_seen', 'last_seen', 'nofollow', 'dofollow', 'lost', 'link_type', 'supplier_row_id']);
|
|
95
|
+
|
|
96
|
+
/**
|
|
97
|
+
* Resolves header names to fields for one supplier, plus any explicit overrides.
|
|
98
|
+
*
|
|
99
|
+
* Returns the mapping AND the columns it did not claim, because those are not noise: they are
|
|
100
|
+
* the supplier's own metrics and they travel with every row.
|
|
101
|
+
*
|
|
102
|
+
* `targetProvided` covers the export whose whole file is about ONE target — a curated list, or
|
|
103
|
+
* GSC's per-page view. T06 settled the semantics: a missing target column is a SCOPE problem,
|
|
104
|
+
* not an assumption problem, and the operator naming the target on the command line is the
|
|
105
|
+
* person deciding it. Nothing is assumed silently.
|
|
106
|
+
*/
|
|
107
|
+
export function resolveMapping(header, { supplier = 'csv', overrides = {}, targetProvided = false } = {}) {
|
|
108
|
+
const spec = SUPPLIERS[supplier];
|
|
109
|
+
if (!spec) return { error: `unknown supplier "${supplier}". Known: ${SUPPLIER_NAMES.join(', ')}` };
|
|
110
|
+
const normalized = header.map(name => ({ name, key: normalizeHeader(name) }));
|
|
111
|
+
const find = names => normalized.find(column => names.includes(column.key))?.name ?? null;
|
|
112
|
+
|
|
113
|
+
for (const rule of spec.refuse ?? []) {
|
|
114
|
+
if (rule.when.every(key => normalized.some(column => column.key === key))) return { error: rule.because };
|
|
115
|
+
}
|
|
116
|
+
const mapping = {};
|
|
117
|
+
for (const field of FIELDS) {
|
|
118
|
+
const override = overrides[field];
|
|
119
|
+
if (override) {
|
|
120
|
+
if (!header.includes(override)) return { error: `--map names a column "${override}" that is not in this file` };
|
|
121
|
+
mapping[field] = override;
|
|
122
|
+
continue;
|
|
123
|
+
}
|
|
124
|
+
const found = spec[field] ? find(spec[field]) : null;
|
|
125
|
+
if (found) mapping[field] = found;
|
|
126
|
+
}
|
|
127
|
+
if (!mapping.source || (!mapping.target && !targetProvided)) {
|
|
128
|
+
const missing = [!mapping.source && 'source', !mapping.target && !targetProvided && 'target'].filter(Boolean).join(' and ');
|
|
129
|
+
return {
|
|
130
|
+
error: `could not find the ${missing} column in this file.\n columns present: ${header.join(' | ')}\n`
|
|
131
|
+
+ ` name them explicitly, for example: --map source=<column>,target=<column>`,
|
|
132
|
+
};
|
|
133
|
+
}
|
|
134
|
+
const claimed = new Set(Object.values(mapping));
|
|
135
|
+
return { mapping, unclaimed: header.filter(name => name && !claimed.has(name)) };
|
|
136
|
+
}
|
|
137
|
+
|
|
138
|
+
const TRUE = new Set(['1', 'true', 'yes', 'y', 'nofollow']);
|
|
139
|
+
const FALSE = new Set(['0', 'false', 'no', 'n', 'dofollow', '']);
|
|
140
|
+
/** A flag column, or `undefined` when the value says nothing we understand. */
|
|
141
|
+
export function readFlag(value) {
|
|
142
|
+
const text = String(value ?? '').trim().toLowerCase();
|
|
143
|
+
if (TRUE.has(text)) return true;
|
|
144
|
+
if (FALSE.has(text)) return false;
|
|
145
|
+
return undefined;
|
|
146
|
+
}
|
|
147
|
+
|
|
148
|
+
const numeric = value => {
|
|
149
|
+
const text = String(value ?? '').trim();
|
|
150
|
+
if (!text) return null;
|
|
151
|
+
const number = Number(text.replace(/,/gu, ''));
|
|
152
|
+
return Number.isFinite(number) ? number : text.slice(0, 256);
|
|
153
|
+
};
|
|
154
|
+
|
|
155
|
+
/** One CSV record as an `ImportRow`, with every unclaimed column kept as a supplier metric. */
|
|
156
|
+
export function toImportRow(values, mapping, unclaimed) {
|
|
157
|
+
const at = field => (mapping[field] ? String(values[mapping[field]] ?? '').trim() : '');
|
|
158
|
+
const nofollow = mapping.nofollow ? readFlag(at('nofollow')) : undefined;
|
|
159
|
+
const dofollow = mapping.dofollow ? readFlag(at('dofollow')) : undefined;
|
|
160
|
+
const metrics = {};
|
|
161
|
+
for (const name of unclaimed) {
|
|
162
|
+
const value = numeric(values[name]);
|
|
163
|
+
if (value !== null) metrics[name.slice(0, 64)] = value;
|
|
164
|
+
}
|
|
165
|
+
return {
|
|
166
|
+
source_url: at('source'), target_url: at('target'),
|
|
167
|
+
anchor: at('anchor') || null,
|
|
168
|
+
first_seen: at('first_seen') || null,
|
|
169
|
+
last_seen: at('last_seen') || null,
|
|
170
|
+
// Only a flag we actually understood becomes an answer. A column we could not read leaves
|
|
171
|
+
// the field absent rather than asserting "followed", which is the value a reader trusts.
|
|
172
|
+
dofollow: dofollow !== undefined ? dofollow : (nofollow !== undefined ? !nofollow : undefined),
|
|
173
|
+
rel: nofollow === true ? ['nofollow'] : undefined,
|
|
174
|
+
is_lost: mapping.lost ? readFlag(at('lost')) : undefined,
|
|
175
|
+
link_type: at('link_type') || null,
|
|
176
|
+
supplier_row_id: at('supplier_row_id') || null,
|
|
177
|
+
// 32 is the contract's ceiling; a wide export loses its widest columns rather than the row.
|
|
178
|
+
supplier_metrics: Object.keys(metrics).length ? Object.fromEntries(Object.entries(metrics).slice(0, 32)) : null,
|
|
179
|
+
};
|
|
180
|
+
}
|
|
@@ -0,0 +1,203 @@
|
|
|
1
|
+
import { readFile, writeFile, mkdir, readdir, stat, lstat } from 'node:fs/promises';
|
|
2
|
+
import { createHash } from 'node:crypto';
|
|
3
|
+
import { resolve, join, dirname, relative, isAbsolute } from 'node:path';
|
|
4
|
+
import { fileURLToPath } from 'node:url';
|
|
5
|
+
import { homedir } from 'node:os';
|
|
6
|
+
import { parseArgs } from './args.js';
|
|
7
|
+
import { ConfigError } from './config.js';
|
|
8
|
+
import { AGENT_CLIENTS, clientById } from '../shared/agent-clients.js';
|
|
9
|
+
import { NUDGE } from './skill.js';
|
|
10
|
+
|
|
11
|
+
// DP-0036-T09: `agentlinkops agent setup` (Stripe's `stripe agent setup` shape). It detects the
|
|
12
|
+
// agent clients on this machine, installs the skill pack at each client's path with digest
|
|
13
|
+
// verification, and writes the MCP entry for the view that client should use, from the one
|
|
14
|
+
// client table in shared/agent-clients.js. It stores no credential: OAuth happens in the
|
|
15
|
+
// client. It is idempotent, never overwrites a file it did not write, and prints exactly what
|
|
16
|
+
// changed. `agent status` reports what is installed without touching anything.
|
|
17
|
+
const USAGE = 'agent setup [--client ID ...] [--scope project|user] [--origin URL] [--pack DIR] [--dry-run] [--json] | agent status [--json]';
|
|
18
|
+
export const DEFAULT_ORIGIN = 'https://app.agentlinkops.com';
|
|
19
|
+
export const SERVER_NAME = 'agentlinkops';
|
|
20
|
+
const RECEIPT = '.agentlinkops/agent-setup.json';
|
|
21
|
+
const sha256 = data => createHash('sha256').update(data).digest('hex');
|
|
22
|
+
|
|
23
|
+
// Where the pack's skills live: beside the packaged CLI (packages/agentlinkops-cli/skills), in
|
|
24
|
+
// the assembled plugin, or in this repository's toolkit. The first that exists wins.
|
|
25
|
+
export async function locatePack(explicit = null, from = fileURLToPath(import.meta.url)) {
|
|
26
|
+
const candidates = explicit ? [explicit] : [join(dirname(from), '..', 'skills'), join(dirname(from), '..', 'dist', 'plugin', 'agentlinkops', 'skills'), join(dirname(from), '..', 'toolkit', 'plugin', 'agentlinkops', 'skills')];
|
|
27
|
+
for (const dir of candidates) {
|
|
28
|
+
const entries = await readdir(dir, { withFileTypes: true }).catch(() => null);
|
|
29
|
+
if (entries?.some(e => e.isDirectory())) return resolve(dir);
|
|
30
|
+
}
|
|
31
|
+
throw new ConfigError(explicit ? `No skills found under ${explicit}.` : 'No skill pack found beside this CLI; pass --pack DIR or install the agentlinkops package.');
|
|
32
|
+
}
|
|
33
|
+
|
|
34
|
+
async function walk(dir, base = dir) {
|
|
35
|
+
const out = [];
|
|
36
|
+
for (const entry of (await readdir(dir, { withFileTypes: true })).sort((a, b) => a.name.localeCompare(b.name))) {
|
|
37
|
+
const full = join(dir, entry.name);
|
|
38
|
+
if (entry.isDirectory()) out.push(...await walk(full, base));
|
|
39
|
+
else if (entry.isFile()) out.push(relative(base, full));
|
|
40
|
+
}
|
|
41
|
+
return out;
|
|
42
|
+
}
|
|
43
|
+
|
|
44
|
+
// The pack as {skillName: {relPath: {bytes, digest}}}; only directories with a SKILL.md count.
|
|
45
|
+
export async function readPack(dir) {
|
|
46
|
+
const skills = {};
|
|
47
|
+
for (const entry of (await readdir(dir, { withFileTypes: true })).filter(e => e.isDirectory()).sort((a, b) => a.name.localeCompare(b.name))) {
|
|
48
|
+
const root = join(dir, entry.name);
|
|
49
|
+
if (!(await stat(join(root, 'SKILL.md')).catch(() => null))) continue;
|
|
50
|
+
const files = {};
|
|
51
|
+
for (const rel of await walk(root)) { const bytes = await readFile(join(root, rel)); files[rel] = { bytes, digest: sha256(bytes) }; }
|
|
52
|
+
skills[entry.name] = files;
|
|
53
|
+
}
|
|
54
|
+
if (!Object.keys(skills).length) throw new ConfigError(`No skills with a SKILL.md under ${dir}.`);
|
|
55
|
+
return skills;
|
|
56
|
+
}
|
|
57
|
+
|
|
58
|
+
const expand = (path, home) => path.startsWith('~/') ? join(home, path.slice(2)) : path;
|
|
59
|
+
const exists = async path => !!(await lstat(path).catch(() => null));
|
|
60
|
+
|
|
61
|
+
export async function detectClients(home, only = null) {
|
|
62
|
+
const found = [];
|
|
63
|
+
for (const client of AGENT_CLIENTS) {
|
|
64
|
+
if (only && !only.includes(client.id)) continue;
|
|
65
|
+
const detected = only?.includes(client.id) ? true : (await Promise.all(client.detect.map(p => exists(expand(p, home))))).some(Boolean);
|
|
66
|
+
if (detected) found.push(client);
|
|
67
|
+
}
|
|
68
|
+
return found;
|
|
69
|
+
}
|
|
70
|
+
|
|
71
|
+
// MCP entry writers per format. JSON files are merged under mcpServers; TOML gets a table
|
|
72
|
+
// appended when absent; YAML clients get the snippet printed, never an edit of a config we do
|
|
73
|
+
// not own. Every writer returns {file, action, entry} with action in written | unchanged |
|
|
74
|
+
// conflict | printed.
|
|
75
|
+
function mcpEntry(client, url) {
|
|
76
|
+
switch (client.mcp.format) {
|
|
77
|
+
case 'mcpServers-json': return client.id === 'claude-code' ? { type: 'http', url } : { url };
|
|
78
|
+
case 'mcpServers-httpUrl': return { httpUrl: url };
|
|
79
|
+
default: return { url };
|
|
80
|
+
}
|
|
81
|
+
}
|
|
82
|
+
export async function planMcp(client, { cwd, home, origin, scope }) {
|
|
83
|
+
const url = `${origin}${client.view}`;
|
|
84
|
+
const entry = mcpEntry(client, url);
|
|
85
|
+
const file = client.mcp.scope === 'user' || scope === 'user' ? expand(client.mcp.file, home) : join(cwd, client.mcp.file);
|
|
86
|
+
if (client.mcp.format === 'toml-mcp_servers') {
|
|
87
|
+
const text = await readFile(file, 'utf8').catch(() => '');
|
|
88
|
+
const table = `[mcp_servers.${SERVER_NAME}]`;
|
|
89
|
+
if (text.includes(table)) return { file, action: text.includes(`url = "${url}"`) ? 'unchanged' : 'conflict', entry, url, note: text.includes(`url = "${url}"`) ? undefined : `${table} already exists with a different url; edit it by hand.` };
|
|
90
|
+
return { file, action: 'write', entry, url, text: `${text.length && !text.endsWith('\n') ? `${text}\n` : text}\n${table}\nurl = "${url}"\n` };
|
|
91
|
+
}
|
|
92
|
+
if (client.mcp.format === 'yaml-mcp_servers') return { file, action: 'printed', entry, url, snippet: `mcp_servers:\n ${SERVER_NAME}:\n url: ${url}\n` };
|
|
93
|
+
if (client.id === 'claude-code' && (client.mcp.scope === 'user' || scope === 'user')) return { file: expand('~/.claude.json', home), action: 'printed', entry, url, snippet: `claude mcp add --transport http --scope user ${SERVER_NAME} ${url}` };
|
|
94
|
+
const text = await readFile(file, 'utf8').catch(() => null);
|
|
95
|
+
let config = {};
|
|
96
|
+
if (text !== null) { try { config = JSON.parse(text); } catch { return { file, action: 'conflict', entry, url, note: 'existing file is not valid JSON; edit it by hand.' }; } }
|
|
97
|
+
const servers = config.mcpServers ?? {};
|
|
98
|
+
const current = servers[SERVER_NAME];
|
|
99
|
+
if (current && JSON.stringify(current) === JSON.stringify(entry)) return { file, action: 'unchanged', entry, url };
|
|
100
|
+
if (current) return { file, action: 'conflict', entry, url, note: `mcpServers.${SERVER_NAME} already exists with a different entry; edit it by hand.` };
|
|
101
|
+
return { file, action: 'write', entry, url, text: `${JSON.stringify({ ...config, mcpServers: { ...servers, [SERVER_NAME]: entry } }, null, 2)}\n` };
|
|
102
|
+
}
|
|
103
|
+
|
|
104
|
+
// Skill installation plan for one client: per file, install | unchanged | conflict.
|
|
105
|
+
export async function planSkills(client, pack, { cwd, home, scope, receipt }) {
|
|
106
|
+
const base = scope === 'user' || !client.skills.project ? client.skills.user : client.skills.project;
|
|
107
|
+
const root = expand(base, home);
|
|
108
|
+
const dir = isAbsolute(root) ? root : join(cwd, root);
|
|
109
|
+
const plan = { dir, install: [], unchanged: [], conflicts: [] };
|
|
110
|
+
const owned = new Set(Object.keys(receipt?.files ?? {}));
|
|
111
|
+
for (const [name, files] of Object.entries(pack)) for (const [rel, { digest }] of Object.entries(files)) {
|
|
112
|
+
const target = join(dir, name, rel);
|
|
113
|
+
const existing = await readFile(target).catch(() => null);
|
|
114
|
+
if (existing === null) { plan.install.push({ target, digest }); continue; }
|
|
115
|
+
if (sha256(existing) === digest) { plan.unchanged.push(target); continue; }
|
|
116
|
+
if (owned.has(target)) plan.install.push({ target, digest, replaces: receipt.files[target] });
|
|
117
|
+
else plan.conflicts.push({ target, note: 'exists with different content and was not installed by agentlinkops; left untouched.' });
|
|
118
|
+
}
|
|
119
|
+
return plan;
|
|
120
|
+
}
|
|
121
|
+
|
|
122
|
+
async function readReceipt(cwd) { try { return JSON.parse(await readFile(join(cwd, RECEIPT), 'utf8')); } catch { return null; } }
|
|
123
|
+
|
|
124
|
+
export async function agentMain(argv, { cwd = process.cwd(), home = homedir(), env = process.env, out = console.log, err = console.error, isTTY = process.stdout.isTTY === true, packDir = null, now = () => new Date().toISOString() } = {}) {
|
|
125
|
+
const args = parseArgs(argv, ['client']);
|
|
126
|
+
const [command, sub] = args._;
|
|
127
|
+
if (command !== 'agent' || !['setup', 'status'].includes(sub) || args._.length !== 2) throw new ConfigError(USAGE);
|
|
128
|
+
const allowed = sub === 'setup' ? ['_', 'client', 'scope', 'origin', 'pack', 'dry-run', 'json'] : ['_', 'json'];
|
|
129
|
+
if (Object.keys(args).some(k => !allowed.includes(k))) throw new ConfigError(USAGE);
|
|
130
|
+
const json = args.json === true || !isTTY;
|
|
131
|
+
const scope = args.scope ?? 'project';
|
|
132
|
+
if (!['project', 'user'].includes(scope)) throw new ConfigError('--scope is project or user');
|
|
133
|
+
const origin = (args.origin ?? env.AGENTLINKOPS_API_URL ?? env.LINKTRAIL_API_URL ?? DEFAULT_ORIGIN).replace(/\/$/u, '');
|
|
134
|
+
if (!/^https:\/\/[^/?#]+$/u.test(origin)) throw new ConfigError('--origin must be an https origin without a path.');
|
|
135
|
+
const only = args.client ? [].concat(args.client) : null;
|
|
136
|
+
for (const id of only ?? []) if (!clientById(id)) throw new ConfigError(`Unknown client ${id}. Clients: ${AGENT_CLIENTS.map(c => c.id).join(', ')}.`);
|
|
137
|
+
const receipt = await readReceipt(cwd);
|
|
138
|
+
|
|
139
|
+
if (sub === 'status') {
|
|
140
|
+
const clients = await detectClients(home, only);
|
|
141
|
+
const report = { origin, clients: [] };
|
|
142
|
+
for (const client of clients) {
|
|
143
|
+
const mcp = await planMcp(client, { cwd, home, origin, scope });
|
|
144
|
+
report.clients.push({ id: client.id, name: client.name, view: `${origin}${client.view}`, mcp: { file: mcp.file, state: mcp.action === 'unchanged' ? 'configured' : mcp.action === 'conflict' ? 'different' : 'absent' } });
|
|
145
|
+
}
|
|
146
|
+
report.receipt = receipt ? { installedAt: receipt.installedAt, files: Object.keys(receipt.files).length } : null;
|
|
147
|
+
out(json ? JSON.stringify(report, null, 2) : renderStatus(report));
|
|
148
|
+
return 0;
|
|
149
|
+
}
|
|
150
|
+
|
|
151
|
+
const pack = await readPack(await locatePack(args.pack ?? packDir));
|
|
152
|
+
const clients = await detectClients(home, only);
|
|
153
|
+
const report = { origin, scope, dryRun: args['dry-run'] === true, clients: [], next: [] };
|
|
154
|
+
if (!clients.length) { report.next.push(`No agent client detected. Pass --client with one of ${AGENT_CLIENTS.map(c => c.id).join(', ')}.`); out(json ? JSON.stringify(report, null, 2) : report.next.join('\n')); return 2; }
|
|
155
|
+
const ownedFiles = { ...(receipt?.files ?? {}) };
|
|
156
|
+
for (const client of clients) {
|
|
157
|
+
const skills = await planSkills(client, pack, { cwd, home, scope, receipt });
|
|
158
|
+
const mcp = await planMcp(client, { cwd, home, origin, scope });
|
|
159
|
+
const entry = { id: client.id, name: client.name, view: mcp.url, reason: client.reason, skills: { dir: skills.dir, installed: [], unchanged: skills.unchanged.length, conflicts: skills.conflicts }, mcp: { file: mcp.file, action: mcp.action, ...(mcp.note ? { note: mcp.note } : {}), ...(mcp.snippet ? { snippet: mcp.snippet } : {}) } };
|
|
160
|
+
if (!report.dryRun) {
|
|
161
|
+
for (const { target, digest } of skills.install) {
|
|
162
|
+
const [name, ...rest] = relative(skills.dir, target).split(/[\\/]/u);
|
|
163
|
+
await mkdir(dirname(target), { recursive: true });
|
|
164
|
+
await writeFile(target, pack[name][rest.join('/')].bytes);
|
|
165
|
+
ownedFiles[target] = digest;
|
|
166
|
+
entry.skills.installed.push(target);
|
|
167
|
+
}
|
|
168
|
+
if (mcp.action === 'write') { await mkdir(dirname(mcp.file), { recursive: true }); await writeFile(mcp.file, mcp.text); entry.mcp.action = 'written'; }
|
|
169
|
+
} else {
|
|
170
|
+
entry.skills.installed = skills.install.map(i => i.target);
|
|
171
|
+
if (mcp.action === 'write') entry.mcp.action = 'would write';
|
|
172
|
+
}
|
|
173
|
+
if (mcp.action === 'printed') report.next.push(`${client.name}: add the MCP server yourself:\n${mcp.snippet}`);
|
|
174
|
+
if (mcp.action === 'conflict') report.next.push(`${client.name}: ${mcp.note}`);
|
|
175
|
+
for (const c of skills.conflicts) report.next.push(`${client.name}: ${c.target} ${c.note}`);
|
|
176
|
+
report.clients.push(entry);
|
|
177
|
+
}
|
|
178
|
+
if (!report.dryRun) {
|
|
179
|
+
await mkdir(join(cwd, '.agentlinkops'), { recursive: true });
|
|
180
|
+
await writeFile(join(cwd, RECEIPT), `${JSON.stringify({ installedAt: now(), origin, scope, clients: report.clients.map(c => c.id), files: ownedFiles }, null, 2)}\n`);
|
|
181
|
+
}
|
|
182
|
+
report.next.push('Sign in through each client\'s MCP OAuth flow; no credential was stored by this command.');
|
|
183
|
+
report.next.push(NUDGE);
|
|
184
|
+
out(json ? JSON.stringify(report, null, 2) : renderSetup(report));
|
|
185
|
+
return report.clients.some(c => c.skills.conflicts.length || c.mcp.action === 'conflict') ? 1 : 0;
|
|
186
|
+
}
|
|
187
|
+
|
|
188
|
+
function renderStatus(report) {
|
|
189
|
+
const lines = [`origin ${report.origin}`];
|
|
190
|
+
for (const c of report.clients) lines.push(`${c.name.padEnd(12)} ${c.mcp.state.padEnd(11)} ${c.view} (${c.mcp.file})`);
|
|
191
|
+
lines.push(report.receipt ? `receipt: ${report.receipt.files} files installed at ${report.receipt.installedAt}` : 'receipt: none (run agentlinkops agent setup)');
|
|
192
|
+
return lines.join('\n');
|
|
193
|
+
}
|
|
194
|
+
function renderSetup(report) {
|
|
195
|
+
const lines = [`${report.dryRun ? 'plan' : 'done'}: origin ${report.origin}, scope ${report.scope}`];
|
|
196
|
+
for (const c of report.clients) {
|
|
197
|
+
lines.push(`${c.name}: view ${c.view}`);
|
|
198
|
+
lines.push(` skills → ${c.skills.dir}: ${c.skills.installed.length} ${report.dryRun ? 'to install' : 'installed'}, ${c.skills.unchanged} unchanged${c.skills.conflicts.length ? `, ${c.skills.conflicts.length} conflicts` : ''}`);
|
|
199
|
+
lines.push(` mcp → ${c.mcp.file}: ${c.mcp.action}`);
|
|
200
|
+
}
|
|
201
|
+
for (const n of report.next) lines.push('', n);
|
|
202
|
+
return lines.join('\n');
|
|
203
|
+
}
|
package/cli/args.js
ADDED
|
@@ -0,0 +1,18 @@
|
|
|
1
|
+
export function parseArgs(argv, multi = [], initial = {}) {
|
|
2
|
+
const MULTI = new Set(multi);
|
|
3
|
+
const args = { _: [], ...initial };
|
|
4
|
+
const set = (key, value) => { if (MULTI.has(key)) (args[key] ??= []).push(value); else args[key] = value; };
|
|
5
|
+
for (let i = 0; i < argv.length; i++) {
|
|
6
|
+
const value = argv[i];
|
|
7
|
+
if (!value.startsWith('--')) { args._.push(value); continue; }
|
|
8
|
+
const equals = value.indexOf('=');
|
|
9
|
+
if (equals > 2) { set(value.slice(2, equals), value.slice(equals + 1)); continue; }
|
|
10
|
+
const key = value.slice(2);
|
|
11
|
+
const next = argv[i + 1];
|
|
12
|
+
if (next === undefined || next.startsWith('--')) { args[key] = true; continue; }
|
|
13
|
+
set(key, next);
|
|
14
|
+
i++;
|
|
15
|
+
}
|
|
16
|
+
return args;
|
|
17
|
+
}
|
|
18
|
+
|