@passioncode-ai/passioncode 0.1.8 → 0.1.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 (24) hide show
  1. package/CHANGELOG.md +23 -0
  2. package/README.md +22 -5
  3. package/SECURITY.md +1 -1
  4. package/bin/passioncode.js +5 -5
  5. package/family.json +1 -1
  6. package/package.json +1 -1
  7. package/payload/.claude-plugin/marketplace.json +2 -2
  8. package/payload/manifest.json +8 -8
  9. package/payload/plugins/fabric-agent-adapter/.claude-plugin/plugin.json +1 -1
  10. package/payload/plugins/fabric-agent-adapter/skills/adapting-projects-to-fabric/SKILL.md +20 -4
  11. package/payload/plugins/fabric-agent-adapter/skills/adapting-projects-to-fabric/references/provider-entry.md +57 -0
  12. package/payload/plugins/fabric-agent-adapter/skills/adapting-projects-to-fabric/scripts/adapt_project.py +15 -2
  13. package/payload/plugins/fabric-agent-adapter/skills/adapting-projects-to-fabric/scripts/fabric_provider.py +267 -0
  14. package/payload/plugins/fabric-agent-adapter/skills/building-fabric-services/SKILL.md +19 -3
  15. package/payload/plugins/fabric-agent-adapter/skills/building-fabric-services/references/interop.md +77 -0
  16. package/payload/plugins/fabric-agent-adapter/skills/building-fabric-services/references/protocol.md +1 -1
  17. package/payload/plugins/fabric-agent-adapter/skills/building-fabric-services/scripts/check_service.py +177 -0
  18. package/payload/plugins/fabric-agent-adapter/skills/building-fabric-services/scripts/fabric-interop.mjs +215 -0
  19. package/payload/plugins/fabric-agent-adapter/skills/building-fabric-services/scripts/fabric-service.mjs +8 -1
  20. package/payload/plugins/fabric-agent-adapter/skills/building-fabric-services/scripts/fabric_interop.py +470 -0
  21. package/payload/plugins/fabric-agent-adapter/skills/building-fabric-services/scripts/fabric_service.py +14 -2
  22. package/payload/plugins/fabric-agent-adapter/skills/building-fabric-services/scripts/sample_service.py +118 -1
  23. package/payload/plugins/fabric-agent-adapter/skills/creating-fabric-agents/SKILL.md +9 -3
  24. package/payload/plugins/passioncode/.claude-plugin/plugin.json +1 -1
package/CHANGELOG.md CHANGED
@@ -1,5 +1,28 @@
1
1
  # Changelog
2
2
 
3
+ ## 0.1.10 - 2026-09-30
4
+
5
+ ### Changed
6
+
7
+ - Fabric Agent Adapter 0.5.1 in the set (`family.json` pins `v0.5.1`, was `v0.5.0`): the kits follow the
8
+ contract rulings of DEC-0017 — full result envelope with its trace for jobs, a job tool's
9
+ `outputSchema` as the union of envelope and handle, `providers/` entries carrying `providerId`.
10
+
11
+ ## 0.1.9 - 2026-09-30
12
+
13
+ ### Changed
14
+
15
+ - Fabric Agent Adapter 0.5.0 in the set (`family.json` pins `v0.5.0`, was `v0.4.3`); Observatory
16
+ Log stays at Observatory `v0.8.2`, still its latest tag.
17
+
18
+ ### Fixed
19
+
20
+ - The CLI help, `README.md` and `SECURITY.md` told people to run `npx passioncode <command>`.
21
+ No unscoped `passioncode` package exists on npm (`npm view passioncode` → E404), so those
22
+ commands failed on any machine without a local install, and a squatted name would have run
23
+ someone else's code. They now name `npx @passioncode-ai/passioncode@latest <command>`.
24
+ - `README.md` gains a quick start for a new teammate: install, configure, MCP, develop.
25
+
3
26
  ## 0.1.8 - 2026-09-29
4
27
 
5
28
  ### Added
package/README.md CHANGED
@@ -18,6 +18,23 @@ npx @passioncode-ai/passioncode@latest update
18
18
  Only PassionCode.ai products are members. Agents that people build for themselves stay
19
19
  out of it, even when they implement Fabric protocols.
20
20
 
21
+ ## Quick start for a new teammate
22
+
23
+ 1. **Install** (Node 18 or newer; the Claude Code CLI on `PATH` for the plugins):
24
+ `npx @passioncode-ai/passioncode@latest update`, then restart your agents. Check it with
25
+ `npx @passioncode-ai/passioncode@latest status` (every member `claude: plugin · hub: n/n`) and
26
+ `claude plugin list` (three plugins `@passioncode`). Always call the scoped package: there is
27
+ no unscoped `passioncode` package on npm.
28
+ 2. **Configure:** nothing to set, no account and no key. Background updates are on by default;
29
+ `npx @passioncode-ai/passioncode@latest config set update.auto off` turns them off.
30
+ 3. **MCP:** none. The launcher installs skills and session hooks; it neither serves nor calls an
31
+ MCP server. The tools that do (Fabric Switchboard, Fabric Inbox) document their own.
32
+ 4. **Develop:** clone, then `npm test` — there are no dependencies and no lockfile, so there is
33
+ nothing to install (`npm ci` refuses to run here). `npm run vendor` builds `payload/` from the
34
+ members pinned in `family.json`; `claude plugin validate ./payload --strict` checks it. Start
35
+ in `lib/launcher.js` (update, restore, status, uninstall) and `bin/passioncode.js` (the CLI);
36
+ [AGENTS.md](AGENTS.md) maps the rest and [CONTRIBUTING.md](CONTRIBUTING.md) the rules.
37
+
21
38
  ## How it installs
22
39
 
23
40
  - **Claude Code** gets plugins from a local marketplace named `passioncode` at
@@ -31,7 +48,7 @@ out of it, even when they implement Fabric protocols.
31
48
  the machine.
32
49
  - Nothing is written to `~/.claude/skills`: a plain copy there shadows the plugin. Such
33
50
  copies, and hub entries that pointed at a repository's working tree, are moved into
34
- `~/.passioncode/quarantine/` — `npx passioncode restore` puts them back.
51
+ `~/.passioncode/quarantine/` — `npx @passioncode-ai/passioncode@latest restore` puts them back.
35
52
  - Each version is an immutable release under `~/.passioncode/releases/<version>`.
36
53
 
37
54
  ## Staying current
@@ -41,12 +58,12 @@ day and, when a newer set is out **and every npm account that publishes it is in
41
58
  plugin's `trust.json`**, updates to that exact version in the background — it takes
42
59
  effect in the next session. A version published by anyone else is named and not
43
60
  installed ([SECURITY.md](SECURITY.md#the-self-update-and-who-it-trusts)).
44
- `npx passioncode config set update.auto off` makes it only say so.
61
+ `npx @passioncode-ai/passioncode@latest config set update.auto off` makes it only say so.
45
62
 
46
63
  ```bash
47
- npx passioncode status # the installed release: its members, their channels, shadows
48
- npx passioncode update --dry-run
49
- npx passioncode uninstall
64
+ npx @passioncode-ai/passioncode@latest status # the installed release: its members, their channels, shadows
65
+ npx @passioncode-ai/passioncode@latest update --dry-run
66
+ npx @passioncode-ai/passioncode@latest uninstall
50
67
  ```
51
68
 
52
69
  ## Release (maintainers)
package/SECURITY.md CHANGED
@@ -40,7 +40,7 @@ published (npm E404) is silent; a failed check is shown once.
40
40
  `trust.json` ships **empty**, so auto-update stays inert until the maintainers name the
41
41
  npm account(s) that publish PassionCode. Adding an account there is a release decision:
42
42
  it is reviewed like code and ships in the package. A person who wants no background
43
- updates at all runs `npx passioncode config set update.auto off`.
43
+ updates at all runs `npx @passioncode-ai/passioncode@latest config set update.auto off`.
44
44
 
45
45
  The published package carries skill text only; the vendor step refuses to build when it
46
46
  finds a credential-shaped string, or when the self plugin has no valid `trust.json`.
@@ -4,11 +4,11 @@ const L = require('../lib/launcher');
4
4
 
5
5
  const HELP = `passioncode — every PassionCode.ai skill, for every agent on this machine
6
6
 
7
- npx @passioncode-ai/passioncode@latest update [--dry-run] [--json] install or update the whole set
8
- npx passioncode status [--json] what is installed where
9
- npx passioncode restore put back what the last update moved aside
10
- npx passioncode config set update.auto on|off background updates at session start
11
- npx passioncode uninstall remove the set (quarantined items stay restorable)
7
+ npx @passioncode-ai/passioncode@latest update [--dry-run] [--json] install or update the whole set
8
+ npx @passioncode-ai/passioncode@latest status [--json] what is installed where
9
+ npx @passioncode-ai/passioncode@latest restore put back what the last update moved aside
10
+ npx @passioncode-ai/passioncode@latest config set update.auto on|off background updates at session start
11
+ npx @passioncode-ai/passioncode@latest uninstall remove the set (quarantined items stay restorable)
12
12
 
13
13
  Claude Code gets plugins from the local "passioncode" marketplace; other agents get
14
14
  the same skills through ~/.agents/skills. Restart your agent after an update.`;
package/family.json CHANGED
@@ -8,7 +8,7 @@
8
8
  "name": "fabric-agent-adapter",
9
9
  "displayName": "Fabric Agent Adapter",
10
10
  "repo": "passioncode-ai/fabric-agent-adapter",
11
- "ref": "v0.4.3",
11
+ "ref": "v0.5.1",
12
12
  "kind": "plugin",
13
13
  "path": "plugins/fabric-agent-adapter",
14
14
  "legacyPluginIds": [
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@passioncode-ai/passioncode",
3
- "version": "0.1.8",
3
+ "version": "0.1.10",
4
4
  "description": "Every PassionCode.ai agent skill — Fabric Agent Adapter, Observatory Log and the organisation's working rules — for Claude Code and every other agent on the machine, updated as one set.",
5
5
  "bin": {
6
6
  "passioncode": "bin/passioncode.js"
@@ -11,7 +11,7 @@
11
11
  "name": "fabric-agent-adapter",
12
12
  "displayName": "Fabric Agent Adapter",
13
13
  "source": "./plugins/fabric-agent-adapter",
14
- "version": "0.4.3",
14
+ "version": "0.5.1",
15
15
  "description": "Create new Fabric-compatible agents, build them as always-alive local services with dashboards (fabric-service/0.1), and adapt existing projects to the Fabric Agent Contract through evidence-backed MCP, A2A, or local-runner provider bundles.",
16
16
  "author": {
17
17
  "name": "PassionCode.ai",
@@ -35,7 +35,7 @@
35
35
  "name": "passioncode",
36
36
  "displayName": "PassionCode.ai",
37
37
  "source": "./plugins/passioncode",
38
- "version": "0.1.8",
38
+ "version": "0.1.10",
39
39
  "description": "The organisation's working rules for every contributor's agent (working-in-passioncode), and a once-a-day check that keeps the PassionCode.ai set current.",
40
40
  "author": {
41
41
  "name": "PassionCode.ai",
@@ -1,15 +1,15 @@
1
1
  {
2
2
  "family": "passioncode",
3
- "version": "0.1.8",
3
+ "version": "0.1.10",
4
4
  "release": true,
5
5
  "members": [
6
6
  {
7
7
  "name": "fabric-agent-adapter",
8
8
  "displayName": "Fabric Agent Adapter",
9
9
  "repo": "passioncode-ai/fabric-agent-adapter",
10
- "ref": "v0.4.3",
11
- "commit": "c1a57abd7889c3aed1b0e97b00d4e089cc53036e",
12
- "version": "0.4.3",
10
+ "ref": "v0.5.1",
11
+ "commit": "761be46e5047879f65630cbb05a82344220f0175",
12
+ "version": "0.5.1",
13
13
  "via": "clone of https://***@github.com/passioncode-ai/fabric-agent-adapter.git",
14
14
  "skills": [
15
15
  "adapting-projects-to-fabric",
@@ -22,7 +22,7 @@
22
22
  "legacyMarketplaces": [
23
23
  "fabric-agent-adapter"
24
24
  ],
25
- "contentHash": "sha256:7074066a0046ac055a6545f44bf2729d316047710e39846ce666b254620aff34",
25
+ "contentHash": "sha256:b3dd5a474a99f51fefa353134f47f23730251ccec7b9052327be0e338ceffef2",
26
26
  "description": "Create new Fabric-compatible agents, build them as always-alive local services with dashboards (fabric-service/0.1), and adapt existing projects to the Fabric Agent Contract through evidence-backed MCP, A2A, or local-runner provider bundles.",
27
27
  "author": {
28
28
  "name": "PassionCode.ai",
@@ -61,16 +61,16 @@
61
61
  "name": "passioncode",
62
62
  "displayName": "PassionCode.ai",
63
63
  "repo": "passioncode-ai/passioncode",
64
- "ref": "v0.1.8",
64
+ "ref": "v0.1.10",
65
65
  "commit": null,
66
- "version": "0.1.8",
66
+ "version": "0.1.10",
67
67
  "via": "this repository",
68
68
  "skills": [
69
69
  "working-in-passioncode"
70
70
  ],
71
71
  "legacyPluginIds": [],
72
72
  "legacyMarketplaces": [],
73
- "contentHash": "sha256:e233f0c95abb4f060aa8428c4a1509e4ef1a3777b5597a6771d7abd2a3a439ef",
73
+ "contentHash": "sha256:57360b29ce3e8dcd3e846ed94c1798385400bc68c43e40c398869e459dd3f9cc",
74
74
  "description": "The organisation's working rules for every contributor's agent (working-in-passioncode), and a once-a-day check that keeps the PassionCode.ai set current.",
75
75
  "author": {
76
76
  "name": "PassionCode.ai",
@@ -3,7 +3,7 @@
3
3
  "name": "fabric-agent-adapter",
4
4
  "displayName": "Fabric Agent Adapter",
5
5
  "description": "Create new Fabric-compatible agents, build them as always-alive local services with dashboards (fabric-service/0.1), and adapt existing projects to the Fabric Agent Contract through evidence-backed MCP, A2A, or local-runner provider bundles.",
6
- "version": "0.4.3",
6
+ "version": "0.5.1",
7
7
  "author": {
8
8
  "name": "PassionCode.ai",
9
9
  "url": "https://passioncode.ai/"
@@ -5,9 +5,9 @@ license: PolyForm-Noncommercial-1.0.0 OR LicenseRef-PolyForm-Internal-Use-1.0.0
5
5
  compatibility: Requires filesystem access and Python 3.9+. Exact schema checks additionally need git, Node.js, pnpm, and the pinned private fabric-agent-contract checkout. Works without those tools in an explicitly degraded structural-check mode.
6
6
  metadata:
7
7
  author: PassionCode.ai
8
- version: "0.4.3"
8
+ version: "0.5.1"
9
9
  contract-version: "0.1.0"
10
- contract-commit: "a5a27092ba0dcc5facfbeae8b359146dfb403e9a"
10
+ contract-commit: "9cd778eb6f14b977f9a5b4930826f62dc95c5619"
11
11
  ---
12
12
 
13
13
  # Adapting projects to Fabric
@@ -70,7 +70,7 @@ Use exactly:
70
70
 
71
71
  - contract version `0.1.0`;
72
72
  - repository `https://github.com/passioncode-ai/fabric-agent-contract`;
73
- - commit `a5a27092ba0dcc5facfbeae8b359146dfb403e9a`.
73
+ - commit `9cd778eb6f14b977f9a5b4930826f62dc95c5619`.
74
74
 
75
75
  Read the pinned contract's guide
76
76
  `docs/guides/connecting-compatible-agents.md`, the selected profile specification, and
@@ -93,7 +93,10 @@ python3 <skill-dir>/scripts/adapt_project.py scaffold <project-root> \
93
93
  --schema-base https://agents.example/fabric
94
94
  ```
95
95
 
96
- Valid profiles are `mcp`, `a2a`, and `local-runner`. The helper creates only the locked
96
+ Valid profiles are `mcp`, `a2a`, and `local-runner`. For `mcp` the capability is served
97
+ as the MCP tool of its own name (`requiredFeatures: ["tool:<capability>"]`, contract
98
+ `fabric-interop/0.1`); add `--job` when its work may outlive one request, which writes
99
+ `"job": true` in the capability's interop block. The helper creates only the locked
97
100
  target paths. It refuses any collision. Do not use `--force` unless the user explicitly
98
101
  authorizes replacement after the exact conflicting files and diff are shown.
99
102
 
@@ -108,6 +111,11 @@ Preserve the chosen ownership boundary:
108
111
  - A2A maps the provider's remote task, progress, artifact, cancellation, and terminal
109
112
  states; Fabric does not take over its internal loop.
110
113
  - MCP exposes bounded tools/resources/prompts while Fabric owns planning and retries.
114
+ The served tool's name, input and output schemas equal the manifest's (FAC-SEM-017);
115
+ long work returns a job handle and serves `fabric.job.get` / `fabric.job.cancel`; a
116
+ question for a person is a form-mode choice, a secret goes through URL mode; every call
117
+ runs as a child span of `_meta.traceparent`. The kit for all of it is
118
+ `building-fabric-services`'s `scripts/fabric_interop.py`.
111
119
  - Local runner maps typed input, executable identity, result location, cancellation,
112
120
  heartbeat, and partial results without relying on ambient accounts.
113
121
 
@@ -115,6 +123,14 @@ Keep model selection and provider-internal reasoning outside the Fabric contract
115
123
  typed outcomes, evidence, artifacts, and protocol-visible state—not chain-of-thought.
116
124
  Treat all provider output as untrusted until schemas and semantic assertions pass.
117
125
 
126
+ ### 4b. Announce an agent that is not a service
127
+
128
+ An agent Fabric reaches as a CLI or a stdio MCP server gets a provider entry, written by
129
+ its installer with [`scripts/fabric_provider.py`](scripts/fabric_provider.py) and removed
130
+ by its uninstaller — argv arrays only, env values as `secret-ref:` references, never the
131
+ id of an existing service, and a `providerId` equal to the manifest's `provider.id`. Load [provider entries](references/provider-entry.md). An
132
+ agent that runs as a service uses a descriptor instead (`building-fabric-services`).
133
+
118
134
  ### 5. Make probes safe and meaningful
119
135
 
120
136
  For each capability, create at least one bounded fixture that:
@@ -0,0 +1,57 @@
1
+ # Provider entries: agents that are not services
2
+
3
+ Normative source: the Fabric Agent Contract's `docs/specification/provider.md`
4
+ (`fabric-provider/0.1`, DEC-0016, rulings DEC-0017) at the commit this plugin pins.
5
+
6
+ An agent Fabric reaches as a CLI or a stdio MCP server — not a long-running service —
7
+ is announced by one entry, `providers/<id>.json`, in the same root as the services
8
+ directory:
9
+
10
+ | Platform | Directory |
11
+ |---|---|
12
+ | macOS | `~/Library/Application Support/ai.passioncode.fabric/providers/` |
13
+ | Linux | `${XDG_DATA_HOME:-~/.local/share}/passioncode-fabric/providers/` |
14
+ | any | `FABRIC_PROVIDERS_DIR` when set |
15
+
16
+ A service uses a descriptor instead (the `building-fabric-services` skill). One id is a
17
+ service or a provider, never both.
18
+
19
+ ## The writer
20
+
21
+ `scripts/fabric_provider.py` is the installer's tool; it writes atomically, mode 0600,
22
+ and refuses what the contract refuses.
23
+
24
+ ```bash
25
+ python3 scripts/fabric_provider.py write --id example-agent --provider-id https://agents.example/providers/example-agent \
26
+ --name "Example Agent" \
27
+ --manifest ~/.local/share/example-agent/fabric-agent.json --installed-by "example-agent 1.0.0" \
28
+ --env EXAMPLE_API_KEY=secret-ref:example-agent/EXAMPLE_API_KEY \
29
+ --stdio ~/.local/bin/example-agent mcp
30
+ python3 scripts/fabric_provider.py write ... --url http://127.0.0.1:47201/mcp
31
+ python3 scripts/fabric_provider.py validate ~/Library/Application\ Support/ai.passioncode.fabric/providers/example-agent.json
32
+ python3 scripts/fabric_provider.py remove example-agent # the uninstaller
33
+ ```
34
+
35
+ `--stdio` takes the rest of the line as the argument array; there is no shell string.
36
+
37
+ It refuses, with one sentence that never quotes a value:
38
+
39
+ - a manifest that does not resolve, or whose `provider.id` is not the entry's
40
+ `providerId` — the two URIs are compared as URIs (FAC-SEM-014);
41
+ - an id that is already a service in `services/` (FAC-SEM-013);
42
+ - an env value that is not `secret-ref:<name>`, or a reference that has the shape of a
43
+ credential itself — a token prefix, a private key, a JWT (FAC-SEM-015: a value is
44
+ checked by its form; name patterns apply to names);
45
+ - a URL other than `http://127.0.0.1:<port>/mcp`, a manifest path that is not an
46
+ absolute or `~/` path to `fabric-agent.json`, a shell-string command, unknown fields.
47
+
48
+ `validate` also checks that the file is named `<id>.json` (FAC-SEM-014, slug half).
49
+ From Python: `write_provider_entry(entry)`, `remove_provider_entry(id)`,
50
+ `validate_provider_entry(entry)`, `manifest_problems(entry)`, `providers_dir()`.
51
+
52
+ ## What the entry does not do
53
+
54
+ It grants nothing: Fabric lists the agent, and admission (probes) and a project binding
55
+ still decide what it may do. The manifest it points at is the authority for the
56
+ agent's capabilities. Equality is between like things (DEC-0017): the slug `id` with the
57
+ file name, the URI `providerId` with the manifest's URI `provider.id`.
@@ -16,7 +16,8 @@ from urllib.parse import urlparse
16
16
 
17
17
  CONTRACT_VERSION = "0.1.0"
18
18
  CONTRACT_REPOSITORY = "https://github.com/passioncode-ai/fabric-agent-contract"
19
- CONTRACT_COMMIT = "a5a27092ba0dcc5facfbeae8b359146dfb403e9a"
19
+ CONTRACT_COMMIT = "9cd778eb6f14b977f9a5b4930826f62dc95c5619"
20
+ INTEROP_KEY = "https://fabric.passioncode.ai/agent-contract/extensions/interop/0.1"
20
21
  MCP_REVISION = "2026-07-28"
21
22
  A2A_VERSION = "1.0"
22
23
  LOCAL_VERSION = "fabric-local-runner/0.1"
@@ -167,7 +168,8 @@ def _profile(args: argparse.Namespace, schema_base: str) -> Dict[str, Any]:
167
168
  "kind": "mcp",
168
169
  "protocolRevision": MCP_REVISION,
169
170
  "connection": connection,
170
- "requiredFeatures": ["tool:replace-me"],
171
+ # fabric-interop/0.1 C3.1: the capability is served as the MCP tool of its own name.
172
+ "requiredFeatures": ["tool:%s" % args.capability_name],
171
173
  "probes": [probe],
172
174
  }
173
175
  if profile == "a2a":
@@ -215,6 +217,8 @@ def _generated_files(args: argparse.Namespace) -> Dict[str, str]:
215
217
  _require_absolute_uri(args.agent_card_url, "Agent Card URL")
216
218
  if urlparse(args.agent_card_url).scheme != "https":
217
219
  raise AdaptationError("Agent Card URL must use HTTPS")
220
+ if getattr(args, "job", False) and args.profile != "mcp":
221
+ raise AdaptationError("--job marks an MCP capability as a job (fabric-interop/0.1); %s has no job handle" % args.profile)
218
222
  for runtime_arg in args.runtime_arg:
219
223
  lowered = runtime_arg.lower().replace("_", "").replace("-", "")
220
224
  if any(term in lowered for term in ("password", "apikey", "accesstoken", "clientsecret")):
@@ -249,6 +253,8 @@ def _generated_files(args: argparse.Namespace) -> Dict[str, str]:
249
253
  }
250
254
  ],
251
255
  }
256
+ if getattr(args, "job", False):
257
+ manifest["capabilities"][0]["extensions"] = {INTEROP_KEY: {"job": True}}
252
258
  lock = {
253
259
  "contract": "fabric-agent-contract",
254
260
  "version": CONTRACT_VERSION,
@@ -431,6 +437,12 @@ def check_project(root: Path, contract: Optional[Path]) -> Dict[str, Any]:
431
437
  errors.append("capability[%d] must pin %s=%s" % (index, expected[0], expected[1]))
432
438
  if not profile.get("probes"):
433
439
  errors.append("capability[%d] must declare probes" % index)
440
+ block = (capability.get("extensions") or {}).get(INTEROP_KEY)
441
+ if block is not None:
442
+ if not isinstance(block, dict) or set(block) - {"job"} or not isinstance(block.get("job", False), bool):
443
+ errors.append("capability[%d] interop block must be {\"job\": true|false}" % index)
444
+ elif kind != "mcp":
445
+ errors.append("capability[%d] interop block is for an mcp capability, not %s" % (index, kind))
434
446
  for field in ("inputSchema", "outputSchema"):
435
447
  value = capability.get(field)
436
448
  try:
@@ -503,6 +515,7 @@ def build_parser() -> argparse.ArgumentParser:
503
515
  scaffold.add_argument("--runner-kind", default="replace-me")
504
516
  scaffold.add_argument("--executable-ref", default="urn:executable:replace-me")
505
517
  scaffold.add_argument("--runtime-arg", action="append", default=[])
518
+ scaffold.add_argument("--job", action="store_true", help="the capability's work may outlive one request (fabric-interop/0.1 job)")
506
519
  scaffold.add_argument("--force", action="store_true")
507
520
  scaffold.add_argument("--json", action="store_true")
508
521
 
@@ -0,0 +1,267 @@
1
+ #!/usr/bin/env python3
2
+ """Write and remove fabric-provider/0.1 entries: agents that are not services.
3
+
4
+ An agent reached as a CLI or a stdio MCP server announces itself with one entry in
5
+ the providers directory, beside the services directory. Only its installer writes the
6
+ entry and only its uninstaller removes it; the entry grants no Project access.
7
+ Standard library only; it does not import the service kit, so this skill stands alone.
8
+
9
+ fabric_provider.py write --id ID --provider-id URI --name NAME --manifest PATH --installed-by TEXT
10
+ (--url http://127.0.0.1:PORT/mcp | --stdio EXECUTABLE [ARG ...])
11
+ [--env NAME=secret-ref:REF ...] [--summary TEXT] [--repository URL]
12
+ fabric_provider.py remove ID
13
+ fabric_provider.py validate PATH
14
+
15
+ Normative source: fabric-agent-contract docs/specification/provider.md (DEC-0016, rulings DEC-0017).
16
+ """
17
+
18
+ # #region provider-writer — docs: plugins/fabric-agent-adapter/skills/adapting-projects-to-fabric/references/provider-entry.md#the-writer
19
+
20
+ from __future__ import annotations
21
+
22
+ import argparse
23
+ import datetime as dt
24
+ import json
25
+ import os
26
+ from pathlib import Path
27
+ import re
28
+ import sys
29
+ import tempfile
30
+ from typing import Any, Dict, List, Optional
31
+
32
+ PROTOCOL = "fabric-provider/0.1"
33
+ _ID = re.compile(r"^[a-z][a-z0-9-]{1,62}$")
34
+ _ENV_NAME = re.compile(r"^[A-Z_][A-Z0-9_]*$")
35
+ _SECRET_REF = re.compile(r"^secret-ref:([A-Za-z0-9][A-Za-z0-9._/:@-]{0,255})$")
36
+ _LOCAL_PATH = re.compile(r"^(~/|/)[^\x00]*$")
37
+ _URL = re.compile(r"^http://127\.0\.0\.1:([0-9]{1,5})/mcp$")
38
+ _URI = re.compile(r"^[A-Za-z][A-Za-z0-9+.-]*:[^\s]+$")
39
+ FIELDS = {"protocol", "id", "providerId", "name", "summary", "manifest", "run", "source", "installedAt", "installedBy", "extensions"}
40
+ # FAC-SEM-015 reads a credential by its SHAPE: a reference named after a key is fine.
41
+ CREDENTIAL_SHAPES = [re.compile(p) for p in (
42
+ r"^(sk|pk|rk)[-_](live|test|proj|or|ant)?[-_]?[A-Za-z0-9_-]{16,}",
43
+ r"^(ghp|gho|ghu|ghs|ghr)_[A-Za-z0-9]{20,}",
44
+ r"^github_pat_[A-Za-z0-9_]{20,}",
45
+ r"^xox[abprs]-[A-Za-z0-9-]{10,}",
46
+ r"^(AKIA|ASIA)[A-Z0-9]{16}$",
47
+ r"^lin_api_[A-Za-z0-9]{20,}",
48
+ r"^AIza[0-9A-Za-z_-]{30,}",
49
+ r"-----BEGIN [A-Z ]*PRIVATE KEY-----",
50
+ r"^eyJ[A-Za-z0-9_-]+\.[A-Za-z0-9_-]+\.[A-Za-z0-9_-]+$",
51
+ r"^(?=.*[a-z])(?=.*[A-Z])(?=.*[0-9])[A-Za-z0-9+=_-]{32,}$",
52
+ )]
53
+
54
+
55
+ class ProviderError(Exception):
56
+ """An entry was refused; the message is one readable sentence and never quotes a value."""
57
+
58
+
59
+ def services_dir() -> Path:
60
+ override = os.environ.get("FABRIC_SERVICES_DIR")
61
+ if override:
62
+ return Path(override).expanduser()
63
+ home = Path.home()
64
+ if sys.platform == "darwin":
65
+ return home / "Library/Application Support/ai.passioncode.fabric/services"
66
+ return Path(os.environ.get("XDG_DATA_HOME") or str(home / ".local/share")) / "passioncode-fabric/services"
67
+
68
+
69
+ def providers_dir() -> Path:
70
+ """FABRIC_PROVIDERS_DIR, else `providers/` in the same root as `services/`."""
71
+ override = os.environ.get("FABRIC_PROVIDERS_DIR")
72
+ if override:
73
+ return Path(override).expanduser()
74
+ return services_dir().parent / "providers"
75
+
76
+
77
+ def looks_like_credential(value: str) -> bool:
78
+ return any(shape.search(value) for shape in CREDENTIAL_SHAPES)
79
+
80
+
81
+ def validate_provider_entry(entry: Any) -> List[str]:
82
+ """Mirrors provider.schema.json and FAC-SEM-015 (the schema stays normative)."""
83
+ if not isinstance(entry, dict):
84
+ return ["an entry is a JSON object"]
85
+ problems: List[str] = []
86
+ for key in ("protocol", "id", "providerId", "name", "manifest", "run", "installedAt", "installedBy"):
87
+ if key not in entry:
88
+ problems.append("missing %s" % key)
89
+ for key in sorted(set(entry) - FIELDS):
90
+ problems.append("unknown field %s" % key)
91
+ if problems:
92
+ return problems
93
+ if entry["protocol"] != PROTOCOL:
94
+ problems.append("protocol must be %s" % PROTOCOL)
95
+ if not _ID.match(str(entry["id"])):
96
+ problems.append("id must match %s" % _ID.pattern)
97
+ if not _URI.match(str(entry["providerId"])):
98
+ problems.append("providerId is the absolute URI of the manifest's provider.id")
99
+ if not isinstance(entry["name"], str) or not 1 <= len(entry["name"]) <= 80:
100
+ problems.append("name is 1 to 80 characters")
101
+ if "summary" in entry and (not isinstance(entry["summary"], str) or len(entry["summary"]) > 200):
102
+ problems.append("summary is at most 200 characters")
103
+ manifest = str(entry["manifest"])
104
+ if not _LOCAL_PATH.match(manifest) or not re.search(r"(^|/)fabric-agent\.json$", manifest):
105
+ problems.append("manifest is an absolute or ~/ path to fabric-agent.json")
106
+ problems.extend(_run_problems(entry["run"]))
107
+ return problems
108
+
109
+
110
+ def _run_problems(run: Any) -> List[str]:
111
+ mcp = run.get("mcp") if isinstance(run, dict) and set(run) == {"mcp"} else None
112
+ if not isinstance(mcp, dict) or len(mcp) != 1 or not set(mcp) <= {"stdio", "url"}:
113
+ return ["run is {mcp: {stdio: ...}} or {mcp: {url: ...}}, exactly one"]
114
+ if "url" in mcp:
115
+ match = _URL.match(str(mcp["url"]))
116
+ return [] if match and 1 <= int(match.group(1)) <= 65535 else ["run.mcp.url is http://127.0.0.1:<port>/mcp"]
117
+ stdio = mcp["stdio"]
118
+ problems: List[str] = []
119
+ if not isinstance(stdio, dict) or not set(stdio) <= {"command", "env"} or "command" not in stdio:
120
+ return ["run.mcp.stdio is {command, env?}"]
121
+ command = stdio["command"]
122
+ if not isinstance(command, list) or not 1 <= len(command) <= 32 or not all(isinstance(a, str) and a for a in command):
123
+ problems.append("run.mcp.stdio.command is an argument array, never a shell string")
124
+ env = stdio.get("env", {})
125
+ if not isinstance(env, dict):
126
+ return problems + ["run.mcp.stdio.env is an object"]
127
+ for name, value in env.items():
128
+ if not _ENV_NAME.match(name):
129
+ problems.append("env name %s is not an environment variable name" % name)
130
+ reference = _SECRET_REF.match(value) if isinstance(value, str) else None
131
+ if not reference or looks_like_credential(reference.group(1)):
132
+ problems.append("env %s must be a secret reference (secret-ref:<name>), never a secret value (FAC-SEM-015)" % name)
133
+ return problems
134
+
135
+
136
+ def manifest_problems(entry: Dict[str, Any]) -> List[str]:
137
+ """FAC-SEM-014 (DEC-0017), URI half: the manifest resolves and its provider.id equals providerId."""
138
+ try:
139
+ manifest = json.loads(Path(os.path.expanduser(str(entry["manifest"]))).read_text(encoding="utf-8"))
140
+ except (OSError, ValueError):
141
+ return ["the manifest %s does not resolve (FAC-SEM-014)" % entry["manifest"]]
142
+ provider = manifest.get("provider") if isinstance(manifest, dict) else None
143
+ if not isinstance(provider, dict) or not isinstance(manifest.get("capabilities"), list):
144
+ return ["%s is not a provider manifest (FAC-SEM-014)" % entry["manifest"]]
145
+ if provider.get("id") != entry["providerId"]:
146
+ return ["providerId %s is not the manifest's provider.id %s (FAC-SEM-014)" % (entry["providerId"], provider.get("id"))]
147
+ return []
148
+
149
+
150
+ def _atomic_write(path: Path, data: bytes) -> None:
151
+ path.parent.mkdir(parents=True, exist_ok=True)
152
+ os.chmod(path.parent, 0o700)
153
+ fd, tmp = tempfile.mkstemp(prefix="." + path.name + ".", dir=str(path.parent))
154
+ try:
155
+ with os.fdopen(fd, "wb") as handle:
156
+ handle.write(data)
157
+ handle.flush()
158
+ os.fsync(handle.fileno())
159
+ os.chmod(tmp, 0o600)
160
+ os.replace(tmp, path)
161
+ except BaseException:
162
+ try:
163
+ os.unlink(tmp)
164
+ except FileNotFoundError:
165
+ pass
166
+ raise
167
+
168
+
169
+ def _service_ids(directory: Path) -> Dict[str, str]:
170
+ ids: Dict[str, str] = {}
171
+ if directory.is_dir():
172
+ for path in sorted(directory.glob("*.json")):
173
+ try:
174
+ ids.setdefault(str(json.loads(path.read_text(encoding="utf-8")).get("id")), path.name)
175
+ except (OSError, ValueError, AttributeError):
176
+ continue
177
+ return ids
178
+
179
+
180
+ def write_provider_entry(entry: Dict[str, Any], directory: Optional[Path] = None, services: Optional[Path] = None) -> Path:
181
+ """Installer-only. Refuses an invalid entry and an id that is already a service (FAC-SEM-013)."""
182
+ problems = validate_provider_entry(entry)
183
+ if not problems:
184
+ problems = manifest_problems(entry)
185
+ if problems:
186
+ raise ProviderError("Provider entry is invalid: %s." % "; ".join(problems))
187
+ clash = _service_ids(services or services_dir()).get(entry["id"])
188
+ if clash:
189
+ raise ProviderError("%s is already a service (%s); an id is a service or a provider, never both (FAC-SEM-013)." % (entry["id"], clash))
190
+ target = (directory or providers_dir()) / ("%s.json" % entry["id"])
191
+ _atomic_write(target, (json.dumps(entry, indent=2, ensure_ascii=False) + "\n").encode())
192
+ return target
193
+
194
+
195
+ def remove_provider_entry(provider_id: str, directory: Optional[Path] = None) -> bool:
196
+ """Uninstaller-only. Returns whether there was an entry to remove."""
197
+ if not _ID.match(provider_id):
198
+ raise ProviderError("Provider id %s does not match %s." % (provider_id, _ID.pattern))
199
+ try:
200
+ ((directory or providers_dir()) / ("%s.json" % provider_id)).unlink()
201
+ return True
202
+ except FileNotFoundError:
203
+ return False
204
+
205
+
206
+ def _now() -> str:
207
+ return dt.datetime.now(dt.timezone.utc).replace(microsecond=0).isoformat().replace("+00:00", "Z")
208
+
209
+
210
+ def main(argv: Optional[List[str]] = None) -> int:
211
+ parser = argparse.ArgumentParser(description=__doc__, formatter_class=argparse.RawDescriptionHelpFormatter)
212
+ sub = parser.add_subparsers(dest="command", required=True)
213
+ write = sub.add_parser("write")
214
+ write.add_argument("--id", required=True)
215
+ write.add_argument("--provider-id", required=True, help="the manifest's provider.id (a URI)")
216
+ write.add_argument("--name", required=True)
217
+ write.add_argument("--summary")
218
+ write.add_argument("--manifest", required=True)
219
+ write.add_argument("--installed-by", required=True)
220
+ write.add_argument("--repository")
221
+ write.add_argument("--env", action="append", default=[], help="NAME=secret-ref:REF (a reference, never a value)")
222
+ transport = write.add_mutually_exclusive_group(required=True)
223
+ transport.add_argument("--url")
224
+ transport.add_argument("--stdio", nargs=argparse.REMAINDER, help="the executable and its arguments, last on the line")
225
+ remove = sub.add_parser("remove")
226
+ remove.add_argument("id")
227
+ check = sub.add_parser("validate")
228
+ check.add_argument("path", type=Path)
229
+ args = parser.parse_args(argv)
230
+ try:
231
+ if args.command == "remove":
232
+ print("removed" if remove_provider_entry(args.id) else "no entry for %s" % args.id)
233
+ return 0
234
+ if args.command == "validate":
235
+ entry = json.loads(args.path.read_text(encoding="utf-8"))
236
+ problems = validate_provider_entry(entry)
237
+ if isinstance(entry, dict) and entry.get("id") != args.path.stem:
238
+ problems.append("the file name %s is not <id>.json for %s (FAC-SEM-014)" % (args.path.name, entry.get("id")))
239
+ if not problems:
240
+ problems = manifest_problems(entry)
241
+ print("; ".join(problems) if problems else "valid")
242
+ return 1 if problems else 0
243
+ env: Dict[str, str] = {}
244
+ for pair in args.env:
245
+ name, sep, value = pair.partition("=")
246
+ if not sep:
247
+ raise ProviderError("--env takes NAME=secret-ref:REF.")
248
+ env[name] = value
249
+ run: Dict[str, Any] = {"mcp": {"url": args.url}} if args.url else {"mcp": {"stdio": {"command": args.stdio}}}
250
+ if env and args.stdio:
251
+ run["mcp"]["stdio"]["env"] = env
252
+ entry: Dict[str, Any] = {"protocol": PROTOCOL, "id": args.id, "providerId": args.provider_id, "name": args.name, "manifest": args.manifest,
253
+ "run": run, "installedAt": _now(), "installedBy": args.installed_by}
254
+ if args.summary:
255
+ entry["summary"] = args.summary
256
+ if args.repository:
257
+ entry["source"] = {"repository": args.repository}
258
+ print(write_provider_entry(entry))
259
+ return 0
260
+ except (ProviderError, OSError, ValueError) as exc:
261
+ print(str(exc), file=sys.stderr)
262
+ return 1
263
+
264
+
265
+ if __name__ == "__main__":
266
+ raise SystemExit(main())
267
+ # #endregion provider-writer