agentlinkops 0.6.9 → 0.6.11
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 +1 -1
- package/.codex-plugin/plugin.json +1 -1
- package/CHANGELOG.md +18 -0
- package/README.md +44 -12
- package/cli/admission.js +9 -1
- package/cli/agent-setup.js +189 -20
- package/cli/catalog.js +7938 -1357
- package/cli/client.js +89 -23
- package/cli/cloud-error.js +21 -0
- package/cli/commands.js +15 -3
- package/cli/connection.js +74 -12
- package/cli/doctor.js +24 -9
- package/cli/gsc-browser.js +19 -6
- package/cli/import.js +4 -1
- package/cli/ledger.js +4 -7
- package/cli/lifecycle.js +5 -2
- package/cli/local-result.js +4 -4
- package/cli/main.js +50 -34
- package/cli/mcp-client.js +198 -0
- package/cli/native-lock.js +22 -0
- package/cli/oauth-credentials.js +196 -0
- package/cli/oauth-flow.js +259 -0
- package/cli/setup-plan.js +5 -5
- package/cli/status.js +14 -2
- package/cli/sync-lock.js +105 -0
- package/cli/sync.js +14 -6
- package/package.json +6 -3
- package/references/cli.md +22 -0
- package/references/local-crm-draft-handoff.md +76 -0
- package/references/portable-ledger-handoff.md +50 -0
- package/references/recipes/catalog.json +9 -9
- package/references/recipes/command-notes.md +17 -1
- package/references/recipes/placement-reconciliation.md +19 -5
- package/references/recipes/qualified-campaign-handoff.md +16 -4
- package/references/recipes/site-context-brief.md +2 -2
- package/references/recipes/sourced-linkable-asset.md +8 -8
- package/scripts/agentlinkops.py +30 -5
- package/scripts/outreach-crm-draft.py +121 -0
- package/scripts/portable-handoff.py +184 -0
- package/shared/lifecycle-contract.js +2 -2
- package/shared/response-contract.js +7 -1
- package/skills/agentlinkops-assets/SKILL.md +2 -2
- package/skills/agentlinkops-campaigns/SKILL.md +3 -3
- package/skills/agentlinkops-connect/SKILL.md +29 -6
- package/skills/agentlinkops-connect/references/agentlinkops.md +34 -13
- package/skills/agentlinkops-crm/SKILL.md +18 -4
- package/src/citations/adapter.js +3 -2
- package/src/citations/browser/engine.js +23 -7
- package/src/citations/login.js +42 -5
- package/src/discovery/contract.js +1 -1
- package/src/response-shaping.js +58 -0
- package/src/tool-views.js +3 -1
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "agentlinkops",
|
|
3
|
-
"version": "0.6.
|
|
3
|
+
"version": "0.6.11",
|
|
4
4
|
"description": "Connect an agent to AgentLinkOps and run backlink work with it: the connect skill (views, the three discovery verbs, evidence rules), local ledger and campaign skills, a portable CRM, the source-cited content pipeline, user-managed disavow lists, HTML reports and monitoring-event sync.",
|
|
5
5
|
"author": {
|
|
6
6
|
"name": "AgentLinkOps"
|
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "agentlinkops",
|
|
3
|
-
"version": "0.6.
|
|
3
|
+
"version": "0.6.11",
|
|
4
4
|
"description": "Connect an agent to AgentLinkOps and run backlink work with it: the connect skill (views, the three discovery verbs, evidence rules), local ledger and campaign skills, a portable CRM, the source-cited content pipeline, user-managed disavow lists, HTML reports and monitoring-event sync.",
|
|
5
5
|
"author": {
|
|
6
6
|
"name": "AgentLinkOps"
|
package/CHANGELOG.md
CHANGED
|
@@ -3,6 +3,24 @@
|
|
|
3
3
|
All notable changes to the `agentlinkops` package are recorded here. The version follows the
|
|
4
4
|
plugin manifests in `.claude-plugin/` and `.codex-plugin/`.
|
|
5
5
|
|
|
6
|
+
## 0.6.11 (2026-10-06)
|
|
7
|
+
|
|
8
|
+
- Keep `check --json` stdout limited to observation rows. Send empty-ledger, not-due and filter notices to stderr.
|
|
9
|
+
- Reject malformed ledger rows with line numbers and exit with status 2 before fetching or updating customer records.
|
|
10
|
+
- Apply `--fail-on-unknown` to JSON and human output. Unknown observations remain nonfailing by default.
|
|
11
|
+
- Detect duplicate placements across import batches and retain their original file line numbers.
|
|
12
|
+
|
|
13
|
+
## 0.6.10 (2026-10-06)
|
|
14
|
+
|
|
15
|
+
Inspect the installed version's help before using selectors; older published versions
|
|
16
|
+
may install the full pack.
|
|
17
|
+
|
|
18
|
+
- Default agent setup to the connection skill, its reference and detected-client connection setup. A connection-only upgrade retains previously installed optional payloads.
|
|
19
|
+
- Select optional skills and versioned recipes with repeatable `--skill ID` and `--recipe ID`; choose the complete pack explicitly with `--all-skills`.
|
|
20
|
+
- Refuse targeted skill removal while an installer-owned dependent recipe survives, including a preserved customer-edited body. Untrusted dependency metadata also refuses removal. Preview joint recipe/skill removal after reconciling edits, or preview a complete uninstall without selectors. Customer records and existing file/configuration custody rules remain in place.
|
|
21
|
+
- Let customers choose their editorial process, tools and records. Publisher guidance and the bundled CRM are optional; selected recipe recovery no longer requires an unselected campaign recipe.
|
|
22
|
+
- Make the supplied-link check visible before hosted examples. It fetches the source page without an account, repository, ledger, CRM or publisher recipe and retains observation uncertainty.
|
|
23
|
+
|
|
6
24
|
## 0.6.9 (2026-09-25)
|
|
7
25
|
|
|
8
26
|
- Read ChatGPT citations in the current anonymous layout, where inline citations and source pills are buttons carrying their sources as JSON. 0.6.8 read only anchor links and recorded these answers as unknown. Code-copy, table-copy and entity controls are recognized as non-citations; any other unrecognized control still keeps the answer unknown.
|
package/README.md
CHANGED
|
@@ -12,28 +12,50 @@ separate service; this package is the local half and needs no account.
|
|
|
12
12
|
|
|
13
13
|
## Give your agent AgentLinkOps
|
|
14
14
|
|
|
15
|
-
|
|
15
|
+
Use Node.js 22.13 or later. Choose a supplied-link check or a hosted task, then inspect
|
|
16
|
+
the installed version's help before selecting guidance.
|
|
16
17
|
|
|
17
|
-
1. **
|
|
18
|
-
|
|
19
|
-
|
|
18
|
+
1. **Inspect setup and choose guidance.** CLI 0.6.10 installs the connection skill
|
|
19
|
+
and its reference by default, with MCP setup for detected clients. Select optional
|
|
20
|
+
guidance explicitly. Setup stores no credential and reports changes. CLI 0.6.9
|
|
21
|
+
and earlier can install the complete pack. Native-client acceptance remains
|
|
22
|
+
separate from package checks.
|
|
20
23
|
|
|
21
24
|
```sh
|
|
25
|
+
npx -y agentlinkops --help
|
|
22
26
|
npx -y agentlinkops agent setup
|
|
23
27
|
```
|
|
24
28
|
|
|
25
29
|
With the `skills` CLI instead: `npx -y skills add https://agentlinkops.com`, then add
|
|
26
30
|
`https://app.agentlinkops.com/mcp` as a remote HTTP MCP server in your client.
|
|
27
|
-
|
|
28
|
-
|
|
29
|
-
|
|
31
|
+
On a version whose help supports selection, use `agentlinkops agent setup --skill ID`
|
|
32
|
+
or `--recipe ID` for optional guidance; repeat either flag for several choices.
|
|
33
|
+
Choose `--all-skills` only for the complete pack. Customer instructions and records
|
|
34
|
+
remain the customer's choice.
|
|
35
|
+
|
|
36
|
+
Preview `agentlinkops agent remove --recipe ID` or `--skill ID` before adding
|
|
37
|
+
`--apply`. The installer refuses skill removal while a recipe it owns still
|
|
38
|
+
needs the skill. Keep the skill or select both that recipe and its skill after
|
|
39
|
+
reconciling customer edits. The installer preserves edited recipe bodies, which
|
|
40
|
+
can still block joint removal.
|
|
41
|
+
For an intended complete uninstall, preview
|
|
42
|
+
`agentlinkops agent remove --scope project` (or `user`), then add `--apply`.
|
|
43
|
+
2. **Sign in for a chosen hosted task.** The client opens the AgentLinkOps sign-in;
|
|
44
|
+
you pick the account, workspace and scopes. Nothing is pasted into a chat.
|
|
45
|
+
A supplied-link check needs no account or hosted connection.
|
|
46
|
+
3. **Verify hosted access when needed.** Ask your agent to list your AgentLinkOps
|
|
47
|
+
projects, or run `agentlinkops doctor`.
|
|
30
48
|
4. **Check a placement.** Ask: "check whether [publisher page] links to [my page], show the
|
|
31
49
|
dated observation and any uncertainty." No account yet? The same verifier runs locally:
|
|
32
50
|
|
|
33
51
|
```sh
|
|
34
|
-
npx -y agentlinkops check --source https://publisher.example/resources --target https://your-site.example/guide
|
|
52
|
+
npx -y agentlinkops check --source https://publisher.example/resources --target https://your-site.example/guide --scope exact --json
|
|
35
53
|
```
|
|
36
54
|
|
|
55
|
+
This fetches a public source page and returns an observation; no repository,
|
|
56
|
+
ledger, CRM or publisher recipe is required. Read the state and evidence limits,
|
|
57
|
+
including an unknown result. A returned result does not establish presence.
|
|
58
|
+
|
|
37
59
|
The skill is a short door: the first time your agent uses AgentLinkOps in a session it reads
|
|
38
60
|
the full agent reference, which `agentlinkops skill` prints and
|
|
39
61
|
https://agentlinkops.com/SKILL.md serves. Hosted monitoring is an invitation-only pilot; the
|
|
@@ -50,11 +72,20 @@ agentlinkops --help
|
|
|
50
72
|
Or run it once without installing:
|
|
51
73
|
|
|
52
74
|
```sh
|
|
53
|
-
npx agentlinkops check
|
|
75
|
+
npx -y agentlinkops check --source https://publisher.example/resources --target https://your-site.example/guide --scope exact --json
|
|
54
76
|
```
|
|
55
77
|
|
|
56
78
|
## Check one link with no account
|
|
57
79
|
|
|
80
|
+
Check a supplied pair from any working directory, without creating a ledger:
|
|
81
|
+
|
|
82
|
+
```sh
|
|
83
|
+
agentlinkops check --source https://publisher.example/resources --target https://your-site.example/guide --scope exact --json
|
|
84
|
+
```
|
|
85
|
+
|
|
86
|
+
If you choose to retain expected links in a repository ledger, use the optional
|
|
87
|
+
local workflow:
|
|
88
|
+
|
|
58
89
|
```sh
|
|
59
90
|
cd your-repository
|
|
60
91
|
agentlinkops init
|
|
@@ -67,9 +98,10 @@ agentlinkops status
|
|
|
67
98
|
the source page and appends an observation to the mirror. `status` names every entry whose
|
|
68
99
|
observations disagree with its intent and proposes the edit; applying it is a commit you make.
|
|
69
100
|
|
|
70
|
-
|
|
71
|
-
|
|
72
|
-
|
|
101
|
+
Ledger checks return `0` when no expected placement is conclusively absent and `1` when
|
|
102
|
+
an expected placement is observed absent with complete evidence. Unknown observations do
|
|
103
|
+
not fail a run by default. Pass `--fail-on-unknown` to return `1` for an unknown result
|
|
104
|
+
in text or JSON output. Usage, configuration and malformed-ledger errors return `2`.
|
|
73
105
|
|
|
74
106
|
`agentlinkops doctor` verifies the ledger, the verifier self-test, cloud reachability and the
|
|
75
107
|
token, and prints a fix line for anything that fails.
|
package/cli/admission.js
CHANGED
|
@@ -43,6 +43,7 @@ export async function admissionMain(
|
|
|
43
43
|
argv,
|
|
44
44
|
{ client, out = console.log, err = console.error } = {},
|
|
45
45
|
) {
|
|
46
|
+
let json = false;
|
|
46
47
|
try {
|
|
47
48
|
const seen = new Set();
|
|
48
49
|
for (const token of argv.filter((a) => a.startsWith("--"))) {
|
|
@@ -53,6 +54,7 @@ export async function admissionMain(
|
|
|
53
54
|
}
|
|
54
55
|
const a = parseArgs(argv, ["url"]),
|
|
55
56
|
op = operations[a._[0]];
|
|
57
|
+
json = a.json === true;
|
|
56
58
|
if (
|
|
57
59
|
!op ||
|
|
58
60
|
a._.length !== 1 ||
|
|
@@ -124,7 +126,13 @@ export async function admissionMain(
|
|
|
124
126
|
out(JSON.stringify(result.data, null, 2));
|
|
125
127
|
return 0;
|
|
126
128
|
} catch (error) {
|
|
127
|
-
|
|
129
|
+
if (error.publicError) {
|
|
130
|
+
if (json) err(JSON.stringify({ error: error.publicError }));
|
|
131
|
+
else {
|
|
132
|
+
const next = [error.publicError.next, error.publicError.details?.next].find(value => typeof value === 'string' && value.trim());
|
|
133
|
+
err(`${error.code}: ${error.serverMessage ?? error.message}${next ? `\n${next}` : ''}`);
|
|
134
|
+
}
|
|
135
|
+
} else err(error?.code ? `${error.code}: ${error.message}` : error.message);
|
|
128
136
|
return 2;
|
|
129
137
|
}
|
|
130
138
|
}
|
package/cli/agent-setup.js
CHANGED
|
@@ -25,11 +25,19 @@ import { NUDGE } from "./skill.js";
|
|
|
25
25
|
// client. It is idempotent, never overwrites a file it did not write, and prints exactly what
|
|
26
26
|
// changed. `agent status` reports what is installed without touching anything.
|
|
27
27
|
const USAGE =
|
|
28
|
-
"agent setup [--client ID ...] [--scope project|user] [--origin URL] [--pack DIR] [--dry-run] [--json] | agent status [--json] | agent remove|recover [--scope project|user] [--apply] [--json]";
|
|
28
|
+
"agent setup [--client ID ...] [--scope project|user] [--origin URL] [--pack DIR] [--skill ID ...] [--recipe ID ...] [--all-skills] [--dry-run] [--json] | agent status [--json] | agent remove [--skill ID ...] [--recipe ID ...] [--scope project|user] [--apply] [--json] | agent recover [--scope project|user] [--apply] [--json]";
|
|
29
29
|
export const DEFAULT_ORIGIN = "https://app.agentlinkops.com";
|
|
30
30
|
export const SERVER_NAME = "agentlinkops";
|
|
31
31
|
const RECEIPT = ".agentlinkops/agent-setup.json";
|
|
32
32
|
const SHARED = Symbol("package siblings");
|
|
33
|
+
const SELECTION = Symbol("selected payloads");
|
|
34
|
+
const CORE_SKILL = "agentlinkops-connect";
|
|
35
|
+
const selectionIds = (value, flag) => {
|
|
36
|
+
const ids = value === undefined ? [] : [].concat(value);
|
|
37
|
+
if (ids.some((id) => typeof id !== "string" || !/^[a-z][a-z0-9-]{0,79}$/u.test(id)))
|
|
38
|
+
throw new ConfigError(`--${flag} requires a named ID without traversal.`);
|
|
39
|
+
return [...new Set(ids)];
|
|
40
|
+
};
|
|
33
41
|
const ignored = (path) =>
|
|
34
42
|
/(^|\/)(?:\._[^/]*|__pycache__|\.DS_Store)(?:\/|$)|\.(?:py[co]|sqlite(?:3)?(?:-[a-z]+)?|bak)$/u.test(
|
|
35
43
|
path,
|
|
@@ -182,6 +190,136 @@ export async function readPack(dir) {
|
|
|
182
190
|
return skills;
|
|
183
191
|
}
|
|
184
192
|
|
|
193
|
+
// Selection is additive: a core upgrade does not uninstall earlier opt-in payloads.
|
|
194
|
+
// Explicit full-pack installation retains the legacy complete-pack upgrade behavior.
|
|
195
|
+
export function selectPack(pack, { skills = [], recipes = [], all = false } = {}) {
|
|
196
|
+
if (all && (skills.length || recipes.length))
|
|
197
|
+
throw new ConfigError("Choose --all-skills or named --skill/--recipe selections.");
|
|
198
|
+
if (!Object.hasOwn(pack, CORE_SKILL) || !pack[CORE_SKILL]["SKILL.md"]?.bytes.length)
|
|
199
|
+
throw new ConfigError("The core connection skill is missing from the package.");
|
|
200
|
+
if (!pack[CORE_SKILL]["references/agentlinkops.md"]?.bytes.length)
|
|
201
|
+
throw new ConfigError("The core connection reference is missing from the package.");
|
|
202
|
+
if (all) return { pack, selection: { mode: "all", skills: Object.keys(pack), recipes: [] } };
|
|
203
|
+
const selected = new Set([CORE_SKILL, ...skills]);
|
|
204
|
+
const siblings = pack[SHARED] ?? {};
|
|
205
|
+
let catalog = null;
|
|
206
|
+
if (recipes.length) {
|
|
207
|
+
try { catalog = JSON.parse(siblings["references/recipes/catalog.json"]?.bytes.toString("utf8")); }
|
|
208
|
+
catch { throw new ConfigError("The package recipe catalog is unavailable or invalid."); }
|
|
209
|
+
if (catalog?.schemaVersion !== 1 || !Array.isArray(catalog.recipes))
|
|
210
|
+
throw new ConfigError("The package recipe catalog is unavailable or invalid.");
|
|
211
|
+
if (!siblings["references/recipes/command-notes.md"]?.bytes.length)
|
|
212
|
+
throw new ConfigError("The required recipe command notes are missing from the package.");
|
|
213
|
+
}
|
|
214
|
+
const chosenRecipes = recipes.map((id) => {
|
|
215
|
+
const matches = catalog.recipes.filter((row) => row?.id === id);
|
|
216
|
+
const row = matches[0];
|
|
217
|
+
if (matches.length !== 1 || row.path !== `${id}.md` ||
|
|
218
|
+
typeof row.version !== "string" || !/^\d+\.\d+\.\d+$/u.test(row.version) ||
|
|
219
|
+
typeof row.skill !== "string" || !Object.hasOwn(pack, row.skill) ||
|
|
220
|
+
!siblings[`references/recipes/${row.path}`])
|
|
221
|
+
throw new ConfigError(`Unknown or invalid recipe ${id}.`);
|
|
222
|
+
selected.add(row.skill);
|
|
223
|
+
return { id, version: row.version, skill: row.skill };
|
|
224
|
+
});
|
|
225
|
+
for (const id of selected)
|
|
226
|
+
if (!Object.hasOwn(pack, id)) throw new ConfigError(`Unknown skill ${id}.`);
|
|
227
|
+
const result = Object.fromEntries([...selected].map((id) => [id, pack[id]]));
|
|
228
|
+
const support = {};
|
|
229
|
+
// Optional growth skills retain their bundled support dependencies. Recipe bodies
|
|
230
|
+
// are separately selected; the catalog describes availability, not installation.
|
|
231
|
+
if ([...selected].some((id) => id !== CORE_SKILL))
|
|
232
|
+
for (const [name, data] of Object.entries(siblings))
|
|
233
|
+
if (!name.startsWith("references/recipes/")) support[name] = data;
|
|
234
|
+
if (recipes.length) {
|
|
235
|
+
for (const name of ["catalog.json", "command-notes.md", ...recipes.map((id) => `${id}.md`)]) {
|
|
236
|
+
const key = `references/recipes/${name}`;
|
|
237
|
+
if (siblings[key]) support[key] = siblings[key];
|
|
238
|
+
}
|
|
239
|
+
}
|
|
240
|
+
Object.defineProperty(result, SHARED, { value: support });
|
|
241
|
+
Object.defineProperty(result, SELECTION, { value: true });
|
|
242
|
+
return { pack: result, selection: { mode: "selected", skills: [...selected], recipes: chosenRecipes } };
|
|
243
|
+
}
|
|
244
|
+
|
|
245
|
+
// Targeted removal can operate with a historical receipt and no current package.
|
|
246
|
+
// Shared support is retained because other installed skills may still depend on it.
|
|
247
|
+
function selectedRemovalPath(path, skills, recipes, cwd, home) {
|
|
248
|
+
return AGENT_CLIENTS.some((client) => {
|
|
249
|
+
const bases = [client.skills.project && join(cwd, client.skills.project), expand(client.skills.user, home)].filter(Boolean);
|
|
250
|
+
return bases.some((base) => skills.some((id) => {
|
|
251
|
+
const rel = relative(join(base, id), path);
|
|
252
|
+
return rel && !rel.startsWith("..") && !isAbsolute(rel);
|
|
253
|
+
}) || recipes.some((id) => path === join(base, "..", "references", "recipes", `${id}.md`)));
|
|
254
|
+
});
|
|
255
|
+
}
|
|
256
|
+
|
|
257
|
+
// Derive dependencies from surviving owned payloads, not the most recent setup
|
|
258
|
+
// selection: a later core-only upgrade deliberately retains earlier recipes.
|
|
259
|
+
async function assertRemovalDependencies(receipt, operations, skills, cwd, home, scope) {
|
|
260
|
+
if (!skills.length) return;
|
|
261
|
+
const recovery = `Keep required skills, or preview removing each dependent recipe with its associated --skill ID after reconciling customer edits. For an intended complete uninstall, preview agent remove --scope ${scope}, then add --apply; no dependent files or MCP entries were changed.`;
|
|
262
|
+
const deleting = new Set(operations.filter((operation) => operation.bytes === null).map((operation) => operation.path));
|
|
263
|
+
const surviving = [];
|
|
264
|
+
for (const path of Object.keys(receipt.files)) {
|
|
265
|
+
if (deleting.has(path)) continue;
|
|
266
|
+
try {
|
|
267
|
+
await safePath(path);
|
|
268
|
+
if (!(await lstat(path)).isFile()) throw new Error("not a regular payload");
|
|
269
|
+
surviving.push(path);
|
|
270
|
+
} catch (error) {
|
|
271
|
+
if (error.code !== "ENOENT")
|
|
272
|
+
throw new ConfigError(`Cannot inspect surviving installer-owned payloads: an owned path is unsafe, unreadable or not a regular file. ${recovery}`);
|
|
273
|
+
}
|
|
274
|
+
}
|
|
275
|
+
const bases = new Set(AGENT_CLIENTS.flatMap((client) =>
|
|
276
|
+
[client.skills.project && join(cwd, client.skills.project), expand(client.skills.user, home)].filter(Boolean)));
|
|
277
|
+
const dependents = new Set();
|
|
278
|
+
for (const base of bases) {
|
|
279
|
+
if (skills.includes(CORE_SKILL)) {
|
|
280
|
+
for (const path of surviving) {
|
|
281
|
+
const rel = relative(base, path);
|
|
282
|
+
const id = rel.split(/[\\/]/u)[0];
|
|
283
|
+
if (rel && !rel.startsWith("..") && !isAbsolute(rel) && id !== CORE_SKILL)
|
|
284
|
+
dependents.add(`skill ${id} (requires ${CORE_SKILL})`);
|
|
285
|
+
}
|
|
286
|
+
}
|
|
287
|
+
const recipeRoot = join(base, "..", "references", "recipes");
|
|
288
|
+
const recipes = surviving.flatMap((path) => {
|
|
289
|
+
const match = /^([a-z][a-z0-9-]{0,79})\.md$/u.exec(relative(recipeRoot, path));
|
|
290
|
+
return match && match[1] !== "command-notes" ? [match[1]] : [];
|
|
291
|
+
});
|
|
292
|
+
if (!recipes.length) continue;
|
|
293
|
+
if (skills.includes(CORE_SKILL)) {
|
|
294
|
+
for (const id of recipes) dependents.add(`recipe ${id} (requires ${CORE_SKILL})`);
|
|
295
|
+
continue;
|
|
296
|
+
}
|
|
297
|
+
const catalogPath = join(recipeRoot, "catalog.json");
|
|
298
|
+
let catalog;
|
|
299
|
+
try {
|
|
300
|
+
if (!Object.hasOwn(receipt.files, catalogPath)) throw new Error("unowned catalog");
|
|
301
|
+
const catalogBytes = await bytesAt(catalogPath);
|
|
302
|
+
if (!catalogBytes || sha256(catalogBytes) !== receipt.files[catalogPath]) throw new Error("catalog custody changed");
|
|
303
|
+
catalog = JSON.parse(catalogBytes);
|
|
304
|
+
if (catalog?.schemaVersion !== 1 || !Array.isArray(catalog.recipes)) throw new Error("invalid catalog");
|
|
305
|
+
} catch {
|
|
306
|
+
throw new ConfigError(`Cannot determine dependencies of surviving recipes ${recipes.join(", ")}: the owned recipe catalog is missing, edited or invalid. ${recovery}`);
|
|
307
|
+
}
|
|
308
|
+
for (const id of recipes) {
|
|
309
|
+
const matches = catalog.recipes.filter((row) => row?.id === id);
|
|
310
|
+
const row = matches[0];
|
|
311
|
+
if (matches.length !== 1 || row.path !== `${id}.md` || typeof row.skill !== "string" ||
|
|
312
|
+
!/^[a-z][a-z0-9-]{0,79}$/u.test(row.skill))
|
|
313
|
+
throw new ConfigError(`Cannot determine the required skill for surviving recipe ${id}: its owned catalog entry is missing or ambiguous. ${recovery}`);
|
|
314
|
+
if (skills.includes(row.skill)) dependents.add(`recipe ${id} (requires skill ${row.skill}; select --recipe ${id} with --skill ${row.skill})`);
|
|
315
|
+
}
|
|
316
|
+
}
|
|
317
|
+
if (skills.includes(CORE_SKILL) && Object.keys(receipt.mcp ?? {}).length)
|
|
318
|
+
dependents.add("retained MCP connection ownership");
|
|
319
|
+
if (dependents.size)
|
|
320
|
+
throw new ConfigError(`Removal would leave surviving dependents or MCP connection ownership: ${[...dependents].sort().join("; ")}. ${recovery}`);
|
|
321
|
+
}
|
|
322
|
+
|
|
185
323
|
const expand = (path, home) =>
|
|
186
324
|
path.startsWith("~/") ? join(home, path.slice(2)) : path;
|
|
187
325
|
const exists = async (path) => !!(await lstat(path).catch(() => null));
|
|
@@ -445,7 +583,7 @@ export async function planSkills(client, pack, { cwd, home, scope, receipt }) {
|
|
|
445
583
|
unchanged: [],
|
|
446
584
|
conflicts: [],
|
|
447
585
|
desired: [],
|
|
448
|
-
roots: [
|
|
586
|
+
roots: pack[SELECTION] ? Object.keys(pack).map((name) => join(dir, name)) : [
|
|
449
587
|
dir,
|
|
450
588
|
...["scripts", "references", "templates"].map((name) =>
|
|
451
589
|
join(dir, "..", name),
|
|
@@ -764,7 +902,7 @@ export async function agentMain(
|
|
|
764
902
|
afterOperation,
|
|
765
903
|
} = {},
|
|
766
904
|
) {
|
|
767
|
-
const args = parseArgs(argv, ["client"]);
|
|
905
|
+
const args = parseArgs(argv, ["client", "skill", "recipe"]);
|
|
768
906
|
const [command, sub] = args._;
|
|
769
907
|
if (
|
|
770
908
|
command !== "agent" ||
|
|
@@ -774,24 +912,34 @@ export async function agentMain(
|
|
|
774
912
|
throw new ConfigError(USAGE);
|
|
775
913
|
const allowed =
|
|
776
914
|
sub === "setup"
|
|
777
|
-
? ["_", "client", "scope", "origin", "pack", "dry-run", "json"]
|
|
778
|
-
:
|
|
779
|
-
? ["_", "scope", "apply", "json"]
|
|
915
|
+
? ["_", "client", "scope", "origin", "pack", "skill", "recipe", "all-skills", "dry-run", "json"]
|
|
916
|
+
: sub === "remove"
|
|
917
|
+
? ["_", "scope", "apply", "skill", "recipe", "json"]
|
|
918
|
+
: sub === "recover" ? ["_", "scope", "apply", "json"]
|
|
780
919
|
: ["_", "json"];
|
|
781
920
|
if (Object.keys(args).some((k) => !allowed.includes(k)))
|
|
782
921
|
throw new ConfigError(USAGE);
|
|
922
|
+
const skillIds = selectionIds(args.skill, "skill");
|
|
923
|
+
const recipeIds = selectionIds(args.recipe, "recipe");
|
|
924
|
+
if (args["all-skills"] !== undefined && args["all-skills"] !== true)
|
|
925
|
+
throw new ConfigError("--all-skills is a boolean flag.");
|
|
783
926
|
const json = args.json === true || !isTTY;
|
|
784
927
|
const scope = args.scope ?? "project";
|
|
785
928
|
if (!["project", "user"].includes(scope))
|
|
786
929
|
throw new ConfigError("--scope is project or user");
|
|
787
|
-
const
|
|
788
|
-
|
|
789
|
-
|
|
790
|
-
|
|
791
|
-
|
|
792
|
-
|
|
793
|
-
|
|
794
|
-
|
|
930
|
+
const suppliedOrigin = args.origin ?? env.AGENTLINKOPS_API_URL ?? env.LINKTRAIL_API_URL ?? DEFAULT_ORIGIN;
|
|
931
|
+
let origin;
|
|
932
|
+
try {
|
|
933
|
+
if (typeof suppliedOrigin !== "string" || /[\u0000-\u0020\u007f"'\\@]/u.test(suppliedOrigin))
|
|
934
|
+
throw new Error("invalid origin");
|
|
935
|
+
const parsed = new URL(suppliedOrigin);
|
|
936
|
+
if (parsed.protocol !== "https:" || parsed.username || parsed.password || parsed.search || parsed.hash || parsed.pathname !== "/" ||
|
|
937
|
+
(suppliedOrigin !== parsed.origin && suppliedOrigin !== `${parsed.origin}/`))
|
|
938
|
+
throw new Error("invalid origin");
|
|
939
|
+
origin = parsed.origin;
|
|
940
|
+
} catch {
|
|
941
|
+
throw new ConfigError("--origin must be a canonical https origin without credentials, controls or a path.");
|
|
942
|
+
}
|
|
795
943
|
const only = args.client ? [].concat(args.client) : null;
|
|
796
944
|
for (const id of only ?? [])
|
|
797
945
|
if (!clientById(id))
|
|
@@ -818,9 +966,18 @@ export async function agentMain(
|
|
|
818
966
|
}
|
|
819
967
|
const receipt = await readReceipt(paths.receipt, cwd, home);
|
|
820
968
|
if (sub === "remove") {
|
|
821
|
-
const
|
|
822
|
-
|
|
969
|
+
const targeted = skillIds.length > 0 || recipeIds.length > 0;
|
|
970
|
+
const operations = [], preserved = [], retained = [];
|
|
971
|
+
for (const id of [...skillIds, ...recipeIds]) {
|
|
972
|
+
const found = Object.keys(receipt?.files ?? {}).some((path) => selectedRemovalPath(path,
|
|
973
|
+
skillIds.includes(id) ? [id] : [], recipeIds.includes(id) ? [id] : [], cwd, home));
|
|
974
|
+
if (!found) throw new ConfigError(`Selection ${id} is not owned by this installation.`);
|
|
975
|
+
}
|
|
823
976
|
for (const [path, digest] of Object.entries(receipt?.files ?? {})) {
|
|
977
|
+
if (targeted && !selectedRemovalPath(path, skillIds, recipeIds, cwd, home)) {
|
|
978
|
+
retained.push(path);
|
|
979
|
+
continue;
|
|
980
|
+
}
|
|
824
981
|
const bytes = await bytesAt(path);
|
|
825
982
|
if (bytes === null) continue;
|
|
826
983
|
if (sha256(bytes) !== digest) {
|
|
@@ -829,9 +986,10 @@ export async function agentMain(
|
|
|
829
986
|
}
|
|
830
987
|
operations.push({ path, bytes: null, expected: digest });
|
|
831
988
|
}
|
|
989
|
+
if (targeted) await assertRemovalDependencies(receipt, operations, skillIds, cwd, home, scope);
|
|
832
990
|
const remainingMcp = { ...(receipt?.mcp ?? {}) };
|
|
833
991
|
const configurations = [];
|
|
834
|
-
for (const [path, record] of Object.entries(remainingMcp)) {
|
|
992
|
+
for (const [path, record] of Object.entries(targeted ? {} : remainingMcp)) {
|
|
835
993
|
const planned = await planMcpRemoval(path, record);
|
|
836
994
|
if (planned.preserved) {
|
|
837
995
|
preserved.push(path);
|
|
@@ -845,7 +1003,7 @@ export async function agentMain(
|
|
|
845
1003
|
}
|
|
846
1004
|
const remaining = Object.fromEntries(
|
|
847
1005
|
Object.entries(receipt?.files ?? {}).filter(([p]) =>
|
|
848
|
-
preserved.includes(p),
|
|
1006
|
+
preserved.includes(p) || retained.includes(p),
|
|
849
1007
|
),
|
|
850
1008
|
);
|
|
851
1009
|
if (receipt) {
|
|
@@ -875,6 +1033,7 @@ export async function agentMain(
|
|
|
875
1033
|
.filter((o) => o.path !== paths.receipt && o.bytes === null)
|
|
876
1034
|
.map((o) => o.path),
|
|
877
1035
|
preserved,
|
|
1036
|
+
retained,
|
|
878
1037
|
configurations,
|
|
879
1038
|
},
|
|
880
1039
|
null,
|
|
@@ -912,17 +1071,21 @@ export async function agentMain(
|
|
|
912
1071
|
? {
|
|
913
1072
|
installedAt: receipt.installedAt,
|
|
914
1073
|
files: Object.keys(receipt.files).length,
|
|
1074
|
+
...(receipt.lastSetupSelection ? { lastSetupSelection: receipt.lastSetupSelection } : {}),
|
|
915
1075
|
}
|
|
916
1076
|
: null;
|
|
917
1077
|
out(json ? JSON.stringify(report, null, 2) : renderStatus(report));
|
|
918
1078
|
return 0;
|
|
919
1079
|
}
|
|
920
1080
|
|
|
921
|
-
const pack = await readPack(await locatePack(args.pack ?? packDir))
|
|
1081
|
+
const { pack, selection } = selectPack(await readPack(await locatePack(args.pack ?? packDir)), {
|
|
1082
|
+
skills: skillIds, recipes: recipeIds, all: args["all-skills"] === true,
|
|
1083
|
+
});
|
|
922
1084
|
const clients = await detectClients(home, only);
|
|
923
1085
|
const report = {
|
|
924
1086
|
origin,
|
|
925
1087
|
scope,
|
|
1088
|
+
selection,
|
|
926
1089
|
dryRun: args["dry-run"] === true,
|
|
927
1090
|
clients: [],
|
|
928
1091
|
next: [],
|
|
@@ -999,6 +1162,8 @@ export async function agentMain(
|
|
|
999
1162
|
report.next.push(`${client.name}: ${c.target} ${c.note}`);
|
|
1000
1163
|
report.clients.push(entry);
|
|
1001
1164
|
}
|
|
1165
|
+
report.retainedExisting = Object.keys(receipt?.files ?? {}).filter((path) => !desired.has(path) &&
|
|
1166
|
+
![...selectedRoots].some((root) => { const rel = relative(root, path); return rel && !rel.startsWith("..") && !isAbsolute(rel); }));
|
|
1002
1167
|
report.pruned = [];
|
|
1003
1168
|
report.preservedObsolete = [];
|
|
1004
1169
|
let ownershipChanged = false;
|
|
@@ -1039,6 +1204,7 @@ export async function agentMain(
|
|
|
1039
1204
|
origin,
|
|
1040
1205
|
scope,
|
|
1041
1206
|
clients: report.clients.map((c) => c.id),
|
|
1207
|
+
lastSetupSelection: selection,
|
|
1042
1208
|
files: ownedFiles,
|
|
1043
1209
|
mcp: ownedMcp,
|
|
1044
1210
|
},
|
|
@@ -1070,8 +1236,11 @@ export async function agentMain(
|
|
|
1070
1236
|
}
|
|
1071
1237
|
}
|
|
1072
1238
|
report.next.push(
|
|
1073
|
-
"
|
|
1239
|
+
"For a chosen hosted task, sign in through the client's MCP OAuth flow. Local supplied-link checks require no AgentLinkOps account; no credential was stored by this command.",
|
|
1074
1240
|
);
|
|
1241
|
+
if (report.retainedExisting.length)
|
|
1242
|
+
report.next.push("Earlier payloads outside this selection were retained. Preview agent remove --skill ID or --recipe ID to remove them explicitly.");
|
|
1243
|
+
report.next.push("Optional skills and recipes: agent setup --skill ID or --recipe ID; --all-skills installs the complete pack.");
|
|
1075
1244
|
report.next.push(NUDGE);
|
|
1076
1245
|
out(json ? JSON.stringify(report, null, 2) : renderSetup(report));
|
|
1077
1246
|
return report.clients.some(
|