@passioncode-ai/fabric-agent-adapter 0.4.3 → 0.5.1

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 (19) hide show
  1. package/CHANGELOG.md +67 -2
  2. package/LICENSE +2 -3
  3. package/README.md +36 -7
  4. package/package.json +2 -2
  5. package/plugins/fabric-agent-adapter/.claude-plugin/plugin.json +1 -1
  6. package/plugins/fabric-agent-adapter/skills/adapting-projects-to-fabric/SKILL.md +20 -4
  7. package/plugins/fabric-agent-adapter/skills/adapting-projects-to-fabric/references/provider-entry.md +57 -0
  8. package/plugins/fabric-agent-adapter/skills/adapting-projects-to-fabric/scripts/adapt_project.py +15 -2
  9. package/plugins/fabric-agent-adapter/skills/adapting-projects-to-fabric/scripts/fabric_provider.py +267 -0
  10. package/plugins/fabric-agent-adapter/skills/building-fabric-services/SKILL.md +19 -3
  11. package/plugins/fabric-agent-adapter/skills/building-fabric-services/references/interop.md +77 -0
  12. package/plugins/fabric-agent-adapter/skills/building-fabric-services/references/protocol.md +1 -1
  13. package/plugins/fabric-agent-adapter/skills/building-fabric-services/scripts/check_service.py +177 -0
  14. package/plugins/fabric-agent-adapter/skills/building-fabric-services/scripts/fabric-interop.mjs +215 -0
  15. package/plugins/fabric-agent-adapter/skills/building-fabric-services/scripts/fabric-service.mjs +8 -1
  16. package/plugins/fabric-agent-adapter/skills/building-fabric-services/scripts/fabric_interop.py +470 -0
  17. package/plugins/fabric-agent-adapter/skills/building-fabric-services/scripts/fabric_service.py +14 -2
  18. package/plugins/fabric-agent-adapter/skills/building-fabric-services/scripts/sample_service.py +118 -1
  19. package/plugins/fabric-agent-adapter/skills/creating-fabric-agents/SKILL.md +9 -3
package/CHANGELOG.md CHANGED
@@ -1,5 +1,71 @@
1
1
  # Changelog
2
2
 
3
+ ## 0.5.1 - 2026-09-30
4
+
5
+ Aligns the kits, the probe and the provider writer with the contract owner's rulings,
6
+ Fabric Agent Contract DEC-0017. **Contract pin** moves from `a22dea3` to `9cd778e`.
7
+
8
+ ### Changed
9
+
10
+ - **A job's result is the full result envelope** (OQ-0002): `result_envelope` /
11
+ `resultEnvelope` now build the contract's `result.schema.json` shape — `id`,
12
+ `contractVersion`, `outcome`, `artifacts`, `createdAt`, `producer` beside the four
13
+ collections, `output` and `usage` — and refuse `succeeded` with unverified claims
14
+ (FAC-SEM-001). `outcome` and `producer` are new required arguments.
15
+ - **The envelope carries its trace** (OQ-0003): `trace: {traceparent}`, authoritative for
16
+ a stored result. `JobStore.complete` fills it from the job's span and refuses an envelope
17
+ that names another span; `fabric.job.get` answers with the envelope's traceparent in
18
+ `_meta`, so the two always agree (FAC-SEM-022).
19
+ - **A job-backed tool serves `oneOf[result envelope, job handle]`** (OQ-0006):
20
+ `tool_for_capability` / `toolForCapability` wrap the output schema with
21
+ `job_tool_output_schema` / `jobToolOutputSchema` for a job capability, so
22
+ `structuredContent` always conforms; the manifest keeps the pure schema. The probe's
23
+ `interop.tools-match` expects the union for a job capability.
24
+ - **Provider entries name their provider by URI** (OQ-0001): `fabric_provider.py` requires
25
+ `providerId` (`--provider-id`), refuses an entry whose manifest does not resolve or whose
26
+ `provider.id` differs, and `validate` checks that the file is named `<id>.json`.
27
+
28
+ ## 0.5.0 - 2026-09-30
29
+
30
+ ### Added
31
+
32
+ - **`fabric-interop/0.1` in the kits** (Fabric Agent Contract DEC-0016).
33
+ `building-fabric-services/scripts/fabric_interop.py` and its Node twin
34
+ `fabric-interop.mjs`: W3C trace context (`child_traceparent`, `trace_ids`), a
35
+ capability served as the MCP tool of its name with annotations from its effect,
36
+ durable jobs (`JobStore`: ids survive a restart, terminal states never change,
37
+ `unknown-job` for an unknown id), the result envelope with `usage`, and elicitations
38
+ (a titled single-select in form mode, URL mode for secrets; form mode refuses a
39
+ secret field). Python also ships `McpToolServer`, a minimal `tools/list` /
40
+ `tools/call` dispatcher with `fabric.job.get` and `fabric.job.cancel` built in.
41
+ - Events carry `traceId` and `spanId` as a pair (`make_event`, `makeEvent`).
42
+ - The sample service serves MCP at `/mcp`: `sample.echo`, and `sample.draft`, a job
43
+ that stops for a title choice and completes with a traced result. `register` writes
44
+ its manifest and schemas and points the descriptor's `fabricManifest` at it.
45
+ - The probe `check_service.py` gains seven `interop.*` rules: manifest link (G-07),
46
+ capability list, tools equal to the manifest (FAC-SEM-017), job tools, unknown job,
47
+ trace propagation and event trace pairs. It calls only `tools/list` and
48
+ `fabric.job.get` for a made-up id.
49
+ - `adapting-projects-to-fabric/scripts/fabric_provider.py`: writes, validates and
50
+ removes `fabric-provider/0.1` entries for agents that are not services — argv only,
51
+ env values as `secret-ref:` references, never the id of an existing service.
52
+ - `adapt_project.py scaffold --job` writes the capability's interop block; an `mcp`
53
+ capability now requires the tool of its own name instead of `tool:replace-me`, and
54
+ `check` refuses a malformed interop block.
55
+ - Skills teach it: `building-fabric-services` gains `references/interop.md`,
56
+ `adapting-projects-to-fabric` gains `references/provider-entry.md` and step 4b, and
57
+ `creating-fabric-agents` starts new agents on it. Trigger and scenario evals added
58
+ first.
59
+
60
+ ### Changed
61
+
62
+ - **Contract pin** moves from `a5a2709` to `a22dea3` (fabric-agent-contract `main`,
63
+ AR-1: interop, provider entries, runner catalogue, pipelines). The provider profiles
64
+ and every existing schema field are unchanged; the contract added schemas and
65
+ optional fields only (DEC-0016). **One pin**: `fabric-contract.lock.json`, and
66
+ `test/validate.py` fails any live file that names another contract revision (G-11).
67
+ - The README has a newcomer path that starts the sample service and probes it.
68
+
3
69
  ## 0.4.3 - 2026-09-29
4
70
 
5
71
  ### Changed
@@ -7,8 +73,7 @@
7
73
  - **License.** The adapter is now source-available under
8
74
  `PolyForm-Noncommercial-1.0.0 OR LicenseRef-PolyForm-Internal-Use-1.0.0`, with a
9
75
  commercial license on request (contact@passioncode.ai). Versions up to and including
10
- v0.4.2, and npm releases up to and including 0.3.1, were released under MIT and remain
11
- available under it. The manifests, the three skills' `license:` and the README say the
76
+ v0.4.2, on GitHub and on npm, were released under MIT and remain available under it. The manifests, the three skills' `license:` and the README say the
12
77
  same; contributions come in under `CLA.md`, which the new PR template asks for.
13
78
  `test/validate.py` fails a tree that slips back to MIT.
14
79
  - Author and marketplace owner are PassionCode.ai (`https://passioncode.ai/`).
package/LICENSE CHANGED
@@ -15,9 +15,8 @@ this software or its code into a product or service you provide to others,
15
15
  needs a separate commercial license: contact@passioncode.ai.
16
16
  This summary is not part of either license; the texts below govern.
17
17
 
18
- Versions up to and including v0.4.2 of this repository (and npm releases up to and
19
- including 0.3.1) were released under the MIT License; those releases remain available
20
- under MIT.
18
+ Versions up to and including v0.4.2 of this repository, and of its npm package, were
19
+ released under the MIT License; those releases remain available under MIT.
21
20
 
22
21
  Required Notice: Copyright (c) 2026 Siarhei Sheleh (https://passioncode.ai)
23
22
 
package/README.md CHANGED
@@ -17,7 +17,7 @@ without moving the Project onto one proprietary agent runtime.
17
17
  [![validate](https://github.com/passioncode-ai/fabric-agent-adapter/actions/workflows/validate.yml/badge.svg)](https://github.com/passioncode-ai/fabric-agent-adapter/actions/workflows/validate.yml)
18
18
  [![license](https://img.shields.io/badge/license-source--available-blue.svg)](LICENSE)
19
19
 
20
- Private, portable Agent Skills for the
20
+ Portable Agent Skills that make any agent Fabric-compatible, for the
21
21
  [Fabric Agent Contract](https://github.com/passioncode-ai/fabric-agent-contract):
22
22
  `adapting-projects-to-fabric`, `creating-fabric-agents` and
23
23
  `building-fabric-services`.
@@ -32,6 +32,14 @@ complete `scripts/sample_service.py`, and `scripts/check_service.py`, a live pro
32
32
  python3 plugins/fabric-agent-adapter/skills/building-fabric-services/scripts/check_service.py example-agent
33
33
  ```
34
34
 
35
+ Services and agents that other agents call follow `fabric-interop/0.1`: each capability
36
+ is the MCP tool of its name, long work is a job with a stable id (`fabric.job.get`,
37
+ `fabric.job.cancel`), a question for a person is an elicitation, and every call carries
38
+ one W3C trace. The kits ship the helpers (`scripts/fabric_interop.py`,
39
+ `scripts/fabric-interop.mjs`), the sample service serves a job end to end, and the probe
40
+ checks the interop rules. An agent that is not a service is announced with a provider
41
+ entry: `adapting-projects-to-fabric/scripts/fabric_provider.py`.
42
+
35
43
  For provider bundles the adapting skill helps an agent author:
36
44
 
37
45
  - inspect the project's stable integration surfaces without executing it;
@@ -46,7 +54,7 @@ yet ship the host registry/runtime needed to connect and authorize a live provid
46
54
 
47
55
  ## Install
48
56
 
49
- The repository is private; the npm package is public. The npm path needs no GitHub access — the GitHub paths do.
57
+ The repository and the npm package are public; neither path needs an account.
50
58
 
51
59
  npm — no GitHub access required:
52
60
 
@@ -110,6 +118,23 @@ python3 "$SKILL_DIR/scripts/adapt_project.py" check /path/to/project \
110
118
  Scaffolding is non-destructive by default. It writes only the paths declared in the
111
119
  [delivery brief](docs/evidence/specs/2026-08-27-brief.md) and refuses collisions.
112
120
 
121
+ ## Verify one service (newcomer path)
122
+
123
+ After installing (above), with Python 3.9+ and no other dependency:
124
+
125
+ ```bash
126
+ KIT=plugins/fabric-agent-adapter/skills/building-fabric-services/scripts # or ~/.agents/skills/building-fabric-services/scripts
127
+ DATA=$(mktemp -d); SERVICES=$(mktemp -d)
128
+ python3 "$KIT/sample_service.py" serve --port 47190 --data-dir "$DATA" &
129
+ python3 "$KIT/sample_service.py" register --port 47190 --data-dir "$DATA" --services-dir "$SERVICES"
130
+ python3 "$KIT/check_service.py" sample --services-dir "$SERVICES" # exit 0: no FAIL
131
+ kill %1
132
+ ```
133
+
134
+ The probe prints one line per rule — `PASS`, `FAIL` or `NOT_RUN` with its evidence — and
135
+ exits 1 on any `FAIL`. Run it against your own service by its id once its installer has
136
+ written the descriptor.
137
+
113
138
  ## Validate this repository
114
139
 
115
140
  ```bash
@@ -125,17 +150,21 @@ checks copies installed elsewhere, such as `~/.agents/skills/*/SKILL.md`.
125
150
 
126
151
  ## Contract pin
127
152
 
153
+ The one pin is [`fabric-contract.lock.json`](fabric-contract.lock.json):
154
+
128
155
  - version: `0.1.0`
129
- - commit: `a5a27092ba0dcc5facfbeae8b359146dfb403e9a`
156
+ - commit: `9cd778eb6f14b977f9a5b4930826f62dc95c5619`
130
157
 
131
- The contract repository is normative. Updating the pin requires a new adapter release,
132
- fixture review, and a complete validation run.
158
+ Every other mention of the contract revision — this section, the skills' metadata, the
159
+ skill card, `adapt_project.py` — must equal it; `python3 test/validate.py` fails a tree
160
+ where one does not. The contract repository is normative. Updating the pin requires a
161
+ new adapter release, fixture review, and a complete validation run.
133
162
 
134
163
  ## License
135
164
 
136
165
  Source-available under PolyForm Noncommercial or Internal Use; commercial license on
137
166
  request (contact@passioncode.ai). SPDX:
138
167
  `PolyForm-Noncommercial-1.0.0 OR LicenseRef-PolyForm-Internal-Use-1.0.0` — see
139
- [LICENSE](LICENSE). Versions up to and including v0.4.2 (npm up to 0.3.1) were released
140
- under the MIT License and remain available under it. Contributions are accepted under
168
+ [LICENSE](LICENSE). Versions up to and including v0.4.2 (on GitHub and on npm) were
169
+ released under the MIT License and remain available under it. Contributions are accepted under
141
170
  [CLA.md](CLA.md) ([CONTRIBUTING.md](CONTRIBUTING.md)).
package/package.json CHANGED
@@ -1,9 +1,9 @@
1
1
  {
2
2
  "name": "@passioncode-ai/fabric-agent-adapter",
3
- "version": "0.4.3",
3
+ "version": "0.5.1",
4
4
  "description": "Create Fabric-compatible agents, run them as always-alive local services with dashboards (fabric-service/0.1), and adapt existing projects to the Fabric Agent Contract. This package is the installer CLI.",
5
5
  "scripts": {
6
- "test": "python3 test/validate.py && python3 -m unittest discover -s test -q && node --test test/"
6
+ "test": "python3 test/validate.py && python3 -m unittest discover -s test -q && node --test 'test/*.test.mjs'"
7
7
  },
8
8
  "bin": {
9
9
  "fabric-agent-adapter": "bin/fabric-agent-adapter.js"
@@ -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