agentlinkops 0.6.9 → 0.6.10

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 (51) hide show
  1. package/.claude-plugin/plugin.json +1 -1
  2. package/.codex-plugin/plugin.json +1 -1
  3. package/CHANGELOG.md +11 -0
  4. package/README.md +44 -12
  5. package/cli/admission.js +9 -1
  6. package/cli/agent-setup.js +189 -20
  7. package/cli/catalog.js +7938 -1357
  8. package/cli/client.js +89 -23
  9. package/cli/cloud-error.js +21 -0
  10. package/cli/commands.js +15 -3
  11. package/cli/connection.js +74 -12
  12. package/cli/doctor.js +24 -9
  13. package/cli/gsc-browser.js +19 -6
  14. package/cli/ledger.js +4 -7
  15. package/cli/lifecycle.js +5 -2
  16. package/cli/local-result.js +4 -4
  17. package/cli/main.js +42 -27
  18. package/cli/mcp-client.js +198 -0
  19. package/cli/native-lock.js +22 -0
  20. package/cli/oauth-credentials.js +196 -0
  21. package/cli/oauth-flow.js +259 -0
  22. package/cli/setup-plan.js +5 -5
  23. package/cli/status.js +14 -2
  24. package/cli/sync-lock.js +105 -0
  25. package/cli/sync.js +14 -6
  26. package/package.json +6 -3
  27. package/references/cli.md +22 -0
  28. package/references/local-crm-draft-handoff.md +76 -0
  29. package/references/portable-ledger-handoff.md +50 -0
  30. package/references/recipes/catalog.json +9 -9
  31. package/references/recipes/command-notes.md +17 -1
  32. package/references/recipes/placement-reconciliation.md +19 -5
  33. package/references/recipes/qualified-campaign-handoff.md +16 -4
  34. package/references/recipes/site-context-brief.md +2 -2
  35. package/references/recipes/sourced-linkable-asset.md +8 -8
  36. package/scripts/agentlinkops.py +30 -5
  37. package/scripts/outreach-crm-draft.py +121 -0
  38. package/scripts/portable-handoff.py +184 -0
  39. package/shared/lifecycle-contract.js +2 -2
  40. package/shared/response-contract.js +7 -1
  41. package/skills/agentlinkops-assets/SKILL.md +2 -2
  42. package/skills/agentlinkops-campaigns/SKILL.md +3 -3
  43. package/skills/agentlinkops-connect/SKILL.md +29 -6
  44. package/skills/agentlinkops-connect/references/agentlinkops.md +34 -13
  45. package/skills/agentlinkops-crm/SKILL.md +17 -3
  46. package/src/citations/adapter.js +3 -2
  47. package/src/citations/browser/engine.js +23 -7
  48. package/src/citations/login.js +42 -5
  49. package/src/discovery/contract.js +1 -1
  50. package/src/response-shaping.js +58 -0
  51. package/src/tool-views.js +3 -1
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "agentlinkops",
3
- "version": "0.6.9",
3
+ "version": "0.6.10",
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.9",
3
+ "version": "0.6.10",
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,17 @@
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.10 (2026-10-06)
7
+
8
+ Inspect the installed version's help before using selectors; older published versions
9
+ may install the full pack.
10
+
11
+ - Default agent setup to the connection skill, its reference and detected-client connection setup. A connection-only upgrade retains previously installed optional payloads.
12
+ - Select optional skills and versioned recipes with repeatable `--skill ID` and `--recipe ID`; choose the complete pack explicitly with `--all-skills`.
13
+ - 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.
14
+ - 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.
15
+ - 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.
16
+
6
17
  ## 0.6.9 (2026-09-25)
7
18
 
8
19
  - 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
- Four steps, the same ones the docs and the landing page show. Node.js 22.13 or later.
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. **Install the skill.** Detects the agent clients on this machine (Claude Code, Codex,
18
- Cursor, Gemini CLI, Hermes), installs the skill pack at each one's skills path and writes
19
- its MCP entry; stores no credential and prints what it changed.
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
- 2. **Sign in when your agent asks.** The client opens the AgentLinkOps sign-in; you pick the
28
- account, workspace and scopes. Nothing is pasted into a chat.
29
- 3. **Verify.** Ask your agent to list your AgentLinkOps projects, or run `agentlinkops doctor`.
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
- Exit codes: `0` when every expected link is present, `1` only when an expected link is observed
71
- absent with complete evidence, `2` for a usage, configuration or ledger error. An unknown never
72
- fails the run. Pass `--fail-on-unknown` when your CI wants it to.
101
+ For ledger checks, exit codes are `0` when every expected link is present, `1` only
102
+ when an expected link is observed absent with complete evidence, and `2` for a usage,
103
+ configuration or ledger error. An unknown never fails the run.
104
+ Pass `--fail-on-unknown` when your CI wants it to.
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
- err(error?.code ? `${error.code}: ${error.message}` : error.message);
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
  }
@@ -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
- : ["remove", "recover"].includes(sub)
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 origin = (
788
- args.origin ??
789
- env.AGENTLINKOPS_API_URL ??
790
- env.LINKTRAIL_API_URL ??
791
- DEFAULT_ORIGIN
792
- ).replace(/\/$/u, "");
793
- if (!/^https:\/\/[^/?#]+$/u.test(origin))
794
- throw new ConfigError("--origin must be an https origin without a path.");
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 operations = [],
822
- preserved = [];
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
- "Sign in through each client's MCP OAuth flow; no credential was stored by this command.",
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(