@passioncode-ai/passioncode 0.1.7 → 0.1.9
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/CHANGELOG.md +25 -0
- package/README.md +22 -5
- package/SECURITY.md +1 -1
- package/bin/passioncode.js +5 -5
- package/family.json +1 -1
- package/package.json +1 -1
- package/payload/.claude-plugin/marketplace.json +2 -2
- package/payload/manifest.json +8 -8
- package/payload/plugins/fabric-agent-adapter/.claude-plugin/plugin.json +1 -1
- package/payload/plugins/fabric-agent-adapter/skills/adapting-projects-to-fabric/SKILL.md +20 -4
- package/payload/plugins/fabric-agent-adapter/skills/adapting-projects-to-fabric/references/provider-entry.md +53 -0
- package/payload/plugins/fabric-agent-adapter/skills/adapting-projects-to-fabric/scripts/adapt_project.py +15 -2
- package/payload/plugins/fabric-agent-adapter/skills/adapting-projects-to-fabric/scripts/fabric_provider.py +242 -0
- package/payload/plugins/fabric-agent-adapter/skills/building-fabric-services/SKILL.md +19 -3
- package/payload/plugins/fabric-agent-adapter/skills/building-fabric-services/references/interop.md +77 -0
- package/payload/plugins/fabric-agent-adapter/skills/building-fabric-services/references/protocol.md +1 -1
- package/payload/plugins/fabric-agent-adapter/skills/building-fabric-services/scripts/check_service.py +174 -0
- package/payload/plugins/fabric-agent-adapter/skills/building-fabric-services/scripts/fabric-interop.mjs +188 -0
- package/payload/plugins/fabric-agent-adapter/skills/building-fabric-services/scripts/fabric-service.mjs +8 -1
- package/payload/plugins/fabric-agent-adapter/skills/building-fabric-services/scripts/fabric_interop.py +424 -0
- package/payload/plugins/fabric-agent-adapter/skills/building-fabric-services/scripts/fabric_service.py +14 -2
- package/payload/plugins/fabric-agent-adapter/skills/building-fabric-services/scripts/sample_service.py +116 -1
- package/payload/plugins/fabric-agent-adapter/skills/creating-fabric-agents/SKILL.md +9 -3
- package/payload/plugins/passioncode/.claude-plugin/plugin.json +1 -1
- package/payload/plugins/passioncode/hooks/repo-rules.js +36 -0
- package/payload/plugins/passioncode/hooks/session-start.js +6 -0
- package/payload/plugins/passioncode/skills/working-in-passioncode/SKILL.md +12 -0
package/CHANGELOG.md
CHANGED
|
@@ -1,5 +1,30 @@
|
|
|
1
1
|
# Changelog
|
|
2
2
|
|
|
3
|
+
## 0.1.9 - 2026-09-30
|
|
4
|
+
|
|
5
|
+
### Changed
|
|
6
|
+
|
|
7
|
+
- Fabric Agent Adapter 0.5.0 in the set (`family.json` pins `v0.5.0`, was `v0.4.3`); Observatory
|
|
8
|
+
Log stays at Observatory `v0.8.2`, still its latest tag.
|
|
9
|
+
|
|
10
|
+
### Fixed
|
|
11
|
+
|
|
12
|
+
- The CLI help, `README.md` and `SECURITY.md` told people to run `npx passioncode <command>`.
|
|
13
|
+
No unscoped `passioncode` package exists on npm (`npm view passioncode` → E404), so those
|
|
14
|
+
commands failed on any machine without a local install, and a squatted name would have run
|
|
15
|
+
someone else's code. They now name `npx @passioncode-ai/passioncode@latest <command>`.
|
|
16
|
+
- `README.md` gains a quick start for a new teammate: install, configure, MCP, develop.
|
|
17
|
+
|
|
18
|
+
## 0.1.8 - 2026-09-29
|
|
19
|
+
|
|
20
|
+
### Added
|
|
21
|
+
|
|
22
|
+
- **Read the rules first.** In a passioncode-ai repository the session-start hook now prints one
|
|
23
|
+
line sending the agent to the repository's `AGENTS.md` and the organization's `CONTRIBUTING.md`
|
|
24
|
+
before its first edit; anywhere else it prints nothing, and a missing or slow git prints
|
|
25
|
+
nothing (`hooks/repo-rules.js`).
|
|
26
|
+
- `working-in-passioncode` carries the names of Fabric ADR-0090 and the code region marker rule.
|
|
27
|
+
|
|
3
28
|
## 0.1.7 - 2026-09-29
|
|
4
29
|
|
|
5
30
|
- Observatory Log 0.12.3 (Observatory v0.8.2) in the set: source-available license and PassionCode.ai metadata; no behaviour change.
|
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
|
|
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`.
|
package/bin/passioncode.js
CHANGED
|
@@ -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]
|
|
8
|
-
npx passioncode status [--json]
|
|
9
|
-
npx passioncode restore
|
|
10
|
-
npx passioncode config set update.auto on|off
|
|
11
|
-
npx passioncode uninstall
|
|
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
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "@passioncode-ai/passioncode",
|
|
3
|
-
"version": "0.1.
|
|
3
|
+
"version": "0.1.9",
|
|
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.
|
|
14
|
+
"version": "0.5.0",
|
|
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.
|
|
38
|
+
"version": "0.1.9",
|
|
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",
|
package/payload/manifest.json
CHANGED
|
@@ -1,15 +1,15 @@
|
|
|
1
1
|
{
|
|
2
2
|
"family": "passioncode",
|
|
3
|
-
"version": "0.1.
|
|
3
|
+
"version": "0.1.9",
|
|
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.
|
|
11
|
-
"commit": "
|
|
12
|
-
"version": "0.
|
|
10
|
+
"ref": "v0.5.0",
|
|
11
|
+
"commit": "f31c2b2792f77153b1a5adda4bec6c622fc8af97",
|
|
12
|
+
"version": "0.5.0",
|
|
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:
|
|
25
|
+
"contentHash": "sha256:0f275fc3d94e42c1e8f387c409d25b675496d384a8c3eb418959d5a87d3cd64b",
|
|
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.
|
|
64
|
+
"ref": "v0.1.9",
|
|
65
65
|
"commit": null,
|
|
66
|
-
"version": "0.1.
|
|
66
|
+
"version": "0.1.9",
|
|
67
67
|
"via": "this repository",
|
|
68
68
|
"skills": [
|
|
69
69
|
"working-in-passioncode"
|
|
70
70
|
],
|
|
71
71
|
"legacyPluginIds": [],
|
|
72
72
|
"legacyMarketplaces": [],
|
|
73
|
-
"contentHash": "sha256:
|
|
73
|
+
"contentHash": "sha256:c810e4afb3cfccaa5a367933ce258b97a7d608be6f3bc791cd5c821893fd4554",
|
|
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.
|
|
6
|
+
"version": "0.5.0",
|
|
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.
|
|
8
|
+
version: "0.5.0"
|
|
9
9
|
contract-version: "0.1.0"
|
|
10
|
-
contract-commit: "
|
|
10
|
+
contract-commit: "a22dea359ba04b8fe549abe81a5131552cb90eff"
|
|
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 `
|
|
73
|
+
- commit `a22dea359ba04b8fe549abe81a5131552cb90eff`.
|
|
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`.
|
|
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. 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,53 @@
|
|
|
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) 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 --name "Example Agent" \
|
|
26
|
+
--manifest ~/.local/share/example-agent/fabric-agent.json --installed-by "example-agent 1.0.0" \
|
|
27
|
+
--env EXAMPLE_API_KEY=secret-ref:example-agent/EXAMPLE_API_KEY \
|
|
28
|
+
--stdio ~/.local/bin/example-agent mcp
|
|
29
|
+
python3 scripts/fabric_provider.py write ... --url http://127.0.0.1:47201/mcp
|
|
30
|
+
python3 scripts/fabric_provider.py validate ~/Library/Application\ Support/ai.passioncode.fabric/providers/example-agent.json
|
|
31
|
+
python3 scripts/fabric_provider.py remove example-agent # the uninstaller
|
|
32
|
+
```
|
|
33
|
+
|
|
34
|
+
`--stdio` takes the rest of the line as the argument array; there is no shell string.
|
|
35
|
+
|
|
36
|
+
It refuses, with one sentence that never quotes a value:
|
|
37
|
+
|
|
38
|
+
- an id that is already a service in `services/` (FAC-SEM-013);
|
|
39
|
+
- an env value that is not `secret-ref:<name>`, or a reference that has the shape of a
|
|
40
|
+
credential itself — a token prefix, a private key, a JWT (FAC-SEM-015);
|
|
41
|
+
- a URL other than `http://127.0.0.1:<port>/mcp`, a manifest path that is not an
|
|
42
|
+
absolute or `~/` path to `fabric-agent.json`, a shell-string command, unknown fields.
|
|
43
|
+
|
|
44
|
+
From Python: `write_provider_entry(entry)`, `remove_provider_entry(id)`,
|
|
45
|
+
`validate_provider_entry(entry)`, `providers_dir()`.
|
|
46
|
+
|
|
47
|
+
## What the entry does not do
|
|
48
|
+
|
|
49
|
+
It grants nothing: Fabric lists the agent, and admission (probes) and a project binding
|
|
50
|
+
still decide what it may do. The manifest it points at is the authority for the
|
|
51
|
+
agent's capabilities. The contract's rule that the manifest's `provider.id` "equals" the
|
|
52
|
+
entry's id cannot hold as written — `provider.id` is a URI, the entry id a slug — so only
|
|
53
|
+
"the manifest resolves" is checked until the contract answers OQ-0001.
|
|
@@ -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 = "
|
|
19
|
+
CONTRACT_COMMIT = "a22dea359ba04b8fe549abe81a5131552cb90eff"
|
|
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
|
-
|
|
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,242 @@
|
|
|
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 --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).
|
|
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
|
+
FIELDS = {"protocol", "id", "name", "summary", "manifest", "run", "source", "installedAt", "installedBy", "extensions"}
|
|
39
|
+
# FAC-SEM-015 reads a credential by its SHAPE: a reference named after a key is fine.
|
|
40
|
+
CREDENTIAL_SHAPES = [re.compile(p) for p in (
|
|
41
|
+
r"^(sk|pk|rk)[-_](live|test|proj|or|ant)?[-_]?[A-Za-z0-9_-]{16,}",
|
|
42
|
+
r"^(ghp|gho|ghu|ghs|ghr)_[A-Za-z0-9]{20,}",
|
|
43
|
+
r"^github_pat_[A-Za-z0-9_]{20,}",
|
|
44
|
+
r"^xox[abprs]-[A-Za-z0-9-]{10,}",
|
|
45
|
+
r"^(AKIA|ASIA)[A-Z0-9]{16}$",
|
|
46
|
+
r"^lin_api_[A-Za-z0-9]{20,}",
|
|
47
|
+
r"^AIza[0-9A-Za-z_-]{30,}",
|
|
48
|
+
r"-----BEGIN [A-Z ]*PRIVATE KEY-----",
|
|
49
|
+
r"^eyJ[A-Za-z0-9_-]+\.[A-Za-z0-9_-]+\.[A-Za-z0-9_-]+$",
|
|
50
|
+
r"^(?=.*[a-z])(?=.*[A-Z])(?=.*[0-9])[A-Za-z0-9+=_-]{32,}$",
|
|
51
|
+
)]
|
|
52
|
+
|
|
53
|
+
|
|
54
|
+
class ProviderError(Exception):
|
|
55
|
+
"""An entry was refused; the message is one readable sentence and never quotes a value."""
|
|
56
|
+
|
|
57
|
+
|
|
58
|
+
def services_dir() -> Path:
|
|
59
|
+
override = os.environ.get("FABRIC_SERVICES_DIR")
|
|
60
|
+
if override:
|
|
61
|
+
return Path(override).expanduser()
|
|
62
|
+
home = Path.home()
|
|
63
|
+
if sys.platform == "darwin":
|
|
64
|
+
return home / "Library/Application Support/ai.passioncode.fabric/services"
|
|
65
|
+
return Path(os.environ.get("XDG_DATA_HOME") or str(home / ".local/share")) / "passioncode-fabric/services"
|
|
66
|
+
|
|
67
|
+
|
|
68
|
+
def providers_dir() -> Path:
|
|
69
|
+
"""FABRIC_PROVIDERS_DIR, else `providers/` in the same root as `services/`."""
|
|
70
|
+
override = os.environ.get("FABRIC_PROVIDERS_DIR")
|
|
71
|
+
if override:
|
|
72
|
+
return Path(override).expanduser()
|
|
73
|
+
return services_dir().parent / "providers"
|
|
74
|
+
|
|
75
|
+
|
|
76
|
+
def looks_like_credential(value: str) -> bool:
|
|
77
|
+
return any(shape.search(value) for shape in CREDENTIAL_SHAPES)
|
|
78
|
+
|
|
79
|
+
|
|
80
|
+
def validate_provider_entry(entry: Any) -> List[str]:
|
|
81
|
+
"""Mirrors provider.schema.json and FAC-SEM-015 (the schema stays normative)."""
|
|
82
|
+
if not isinstance(entry, dict):
|
|
83
|
+
return ["an entry is a JSON object"]
|
|
84
|
+
problems: List[str] = []
|
|
85
|
+
for key in ("protocol", "id", "name", "manifest", "run", "installedAt", "installedBy"):
|
|
86
|
+
if key not in entry:
|
|
87
|
+
problems.append("missing %s" % key)
|
|
88
|
+
for key in sorted(set(entry) - FIELDS):
|
|
89
|
+
problems.append("unknown field %s" % key)
|
|
90
|
+
if problems:
|
|
91
|
+
return problems
|
|
92
|
+
if entry["protocol"] != PROTOCOL:
|
|
93
|
+
problems.append("protocol must be %s" % PROTOCOL)
|
|
94
|
+
if not _ID.match(str(entry["id"])):
|
|
95
|
+
problems.append("id must match %s" % _ID.pattern)
|
|
96
|
+
if not isinstance(entry["name"], str) or not 1 <= len(entry["name"]) <= 80:
|
|
97
|
+
problems.append("name is 1 to 80 characters")
|
|
98
|
+
if "summary" in entry and (not isinstance(entry["summary"], str) or len(entry["summary"]) > 200):
|
|
99
|
+
problems.append("summary is at most 200 characters")
|
|
100
|
+
manifest = str(entry["manifest"])
|
|
101
|
+
if not _LOCAL_PATH.match(manifest) or not re.search(r"(^|/)fabric-agent\.json$", manifest):
|
|
102
|
+
problems.append("manifest is an absolute or ~/ path to fabric-agent.json")
|
|
103
|
+
problems.extend(_run_problems(entry["run"]))
|
|
104
|
+
return problems
|
|
105
|
+
|
|
106
|
+
|
|
107
|
+
def _run_problems(run: Any) -> List[str]:
|
|
108
|
+
mcp = run.get("mcp") if isinstance(run, dict) and set(run) == {"mcp"} else None
|
|
109
|
+
if not isinstance(mcp, dict) or len(mcp) != 1 or not set(mcp) <= {"stdio", "url"}:
|
|
110
|
+
return ["run is {mcp: {stdio: ...}} or {mcp: {url: ...}}, exactly one"]
|
|
111
|
+
if "url" in mcp:
|
|
112
|
+
match = _URL.match(str(mcp["url"]))
|
|
113
|
+
return [] if match and 1 <= int(match.group(1)) <= 65535 else ["run.mcp.url is http://127.0.0.1:<port>/mcp"]
|
|
114
|
+
stdio = mcp["stdio"]
|
|
115
|
+
problems: List[str] = []
|
|
116
|
+
if not isinstance(stdio, dict) or not set(stdio) <= {"command", "env"} or "command" not in stdio:
|
|
117
|
+
return ["run.mcp.stdio is {command, env?}"]
|
|
118
|
+
command = stdio["command"]
|
|
119
|
+
if not isinstance(command, list) or not 1 <= len(command) <= 32 or not all(isinstance(a, str) and a for a in command):
|
|
120
|
+
problems.append("run.mcp.stdio.command is an argument array, never a shell string")
|
|
121
|
+
env = stdio.get("env", {})
|
|
122
|
+
if not isinstance(env, dict):
|
|
123
|
+
return problems + ["run.mcp.stdio.env is an object"]
|
|
124
|
+
for name, value in env.items():
|
|
125
|
+
if not _ENV_NAME.match(name):
|
|
126
|
+
problems.append("env name %s is not an environment variable name" % name)
|
|
127
|
+
reference = _SECRET_REF.match(value) if isinstance(value, str) else None
|
|
128
|
+
if not reference or looks_like_credential(reference.group(1)):
|
|
129
|
+
problems.append("env %s must be a secret reference (secret-ref:<name>), never a secret value (FAC-SEM-015)" % name)
|
|
130
|
+
return problems
|
|
131
|
+
|
|
132
|
+
|
|
133
|
+
def _atomic_write(path: Path, data: bytes) -> None:
|
|
134
|
+
path.parent.mkdir(parents=True, exist_ok=True)
|
|
135
|
+
os.chmod(path.parent, 0o700)
|
|
136
|
+
fd, tmp = tempfile.mkstemp(prefix="." + path.name + ".", dir=str(path.parent))
|
|
137
|
+
try:
|
|
138
|
+
with os.fdopen(fd, "wb") as handle:
|
|
139
|
+
handle.write(data)
|
|
140
|
+
handle.flush()
|
|
141
|
+
os.fsync(handle.fileno())
|
|
142
|
+
os.chmod(tmp, 0o600)
|
|
143
|
+
os.replace(tmp, path)
|
|
144
|
+
except BaseException:
|
|
145
|
+
try:
|
|
146
|
+
os.unlink(tmp)
|
|
147
|
+
except FileNotFoundError:
|
|
148
|
+
pass
|
|
149
|
+
raise
|
|
150
|
+
|
|
151
|
+
|
|
152
|
+
def _service_ids(directory: Path) -> Dict[str, str]:
|
|
153
|
+
ids: Dict[str, str] = {}
|
|
154
|
+
if directory.is_dir():
|
|
155
|
+
for path in sorted(directory.glob("*.json")):
|
|
156
|
+
try:
|
|
157
|
+
ids.setdefault(str(json.loads(path.read_text(encoding="utf-8")).get("id")), path.name)
|
|
158
|
+
except (OSError, ValueError, AttributeError):
|
|
159
|
+
continue
|
|
160
|
+
return ids
|
|
161
|
+
|
|
162
|
+
|
|
163
|
+
def write_provider_entry(entry: Dict[str, Any], directory: Optional[Path] = None, services: Optional[Path] = None) -> Path:
|
|
164
|
+
"""Installer-only. Refuses an invalid entry and an id that is already a service (FAC-SEM-013)."""
|
|
165
|
+
problems = validate_provider_entry(entry)
|
|
166
|
+
if problems:
|
|
167
|
+
raise ProviderError("Provider entry is invalid: %s." % "; ".join(problems))
|
|
168
|
+
clash = _service_ids(services or services_dir()).get(entry["id"])
|
|
169
|
+
if clash:
|
|
170
|
+
raise ProviderError("%s is already a service (%s); an id is a service or a provider, never both (FAC-SEM-013)." % (entry["id"], clash))
|
|
171
|
+
target = (directory or providers_dir()) / ("%s.json" % entry["id"])
|
|
172
|
+
_atomic_write(target, (json.dumps(entry, indent=2, ensure_ascii=False) + "\n").encode())
|
|
173
|
+
return target
|
|
174
|
+
|
|
175
|
+
|
|
176
|
+
def remove_provider_entry(provider_id: str, directory: Optional[Path] = None) -> bool:
|
|
177
|
+
"""Uninstaller-only. Returns whether there was an entry to remove."""
|
|
178
|
+
if not _ID.match(provider_id):
|
|
179
|
+
raise ProviderError("Provider id %s does not match %s." % (provider_id, _ID.pattern))
|
|
180
|
+
try:
|
|
181
|
+
((directory or providers_dir()) / ("%s.json" % provider_id)).unlink()
|
|
182
|
+
return True
|
|
183
|
+
except FileNotFoundError:
|
|
184
|
+
return False
|
|
185
|
+
|
|
186
|
+
|
|
187
|
+
def _now() -> str:
|
|
188
|
+
return dt.datetime.now(dt.timezone.utc).replace(microsecond=0).isoformat().replace("+00:00", "Z")
|
|
189
|
+
|
|
190
|
+
|
|
191
|
+
def main(argv: Optional[List[str]] = None) -> int:
|
|
192
|
+
parser = argparse.ArgumentParser(description=__doc__, formatter_class=argparse.RawDescriptionHelpFormatter)
|
|
193
|
+
sub = parser.add_subparsers(dest="command", required=True)
|
|
194
|
+
write = sub.add_parser("write")
|
|
195
|
+
write.add_argument("--id", required=True)
|
|
196
|
+
write.add_argument("--name", required=True)
|
|
197
|
+
write.add_argument("--summary")
|
|
198
|
+
write.add_argument("--manifest", required=True)
|
|
199
|
+
write.add_argument("--installed-by", required=True)
|
|
200
|
+
write.add_argument("--repository")
|
|
201
|
+
write.add_argument("--env", action="append", default=[], help="NAME=secret-ref:REF (a reference, never a value)")
|
|
202
|
+
transport = write.add_mutually_exclusive_group(required=True)
|
|
203
|
+
transport.add_argument("--url")
|
|
204
|
+
transport.add_argument("--stdio", nargs=argparse.REMAINDER, help="the executable and its arguments, last on the line")
|
|
205
|
+
remove = sub.add_parser("remove")
|
|
206
|
+
remove.add_argument("id")
|
|
207
|
+
check = sub.add_parser("validate")
|
|
208
|
+
check.add_argument("path", type=Path)
|
|
209
|
+
args = parser.parse_args(argv)
|
|
210
|
+
try:
|
|
211
|
+
if args.command == "remove":
|
|
212
|
+
print("removed" if remove_provider_entry(args.id) else "no entry for %s" % args.id)
|
|
213
|
+
return 0
|
|
214
|
+
if args.command == "validate":
|
|
215
|
+
problems = validate_provider_entry(json.loads(args.path.read_text(encoding="utf-8")))
|
|
216
|
+
print("; ".join(problems) if problems else "valid")
|
|
217
|
+
return 1 if problems else 0
|
|
218
|
+
env: Dict[str, str] = {}
|
|
219
|
+
for pair in args.env:
|
|
220
|
+
name, sep, value = pair.partition("=")
|
|
221
|
+
if not sep:
|
|
222
|
+
raise ProviderError("--env takes NAME=secret-ref:REF.")
|
|
223
|
+
env[name] = value
|
|
224
|
+
run: Dict[str, Any] = {"mcp": {"url": args.url}} if args.url else {"mcp": {"stdio": {"command": args.stdio}}}
|
|
225
|
+
if env and args.stdio:
|
|
226
|
+
run["mcp"]["stdio"]["env"] = env
|
|
227
|
+
entry: Dict[str, Any] = {"protocol": PROTOCOL, "id": args.id, "name": args.name, "manifest": args.manifest,
|
|
228
|
+
"run": run, "installedAt": _now(), "installedBy": args.installed_by}
|
|
229
|
+
if args.summary:
|
|
230
|
+
entry["summary"] = args.summary
|
|
231
|
+
if args.repository:
|
|
232
|
+
entry["source"] = {"repository": args.repository}
|
|
233
|
+
print(write_provider_entry(entry))
|
|
234
|
+
return 0
|
|
235
|
+
except (ProviderError, OSError, ValueError) as exc:
|
|
236
|
+
print(str(exc), file=sys.stderr)
|
|
237
|
+
return 1
|
|
238
|
+
|
|
239
|
+
|
|
240
|
+
if __name__ == "__main__":
|
|
241
|
+
raise SystemExit(main())
|
|
242
|
+
# #endregion provider-writer
|