@passioncode-ai/passioncode 0.1.14 → 0.1.17
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 +40 -0
- package/README.md +22 -6
- package/SECURITY.md +24 -8
- package/bin/passioncode.js +32 -5
- package/family.json +1 -1
- package/lib/launcher.js +143 -39
- package/package.json +4 -4
- 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 +13 -1
- package/payload/plugins/fabric-agent-adapter/skills/adapting-projects-to-fabric/references/dashboard-links.md +92 -0
- package/payload/plugins/fabric-agent-adapter/skills/adapting-projects-to-fabric/scripts/check_dashboard_link.py +86 -0
- package/payload/plugins/fabric-agent-adapter/skills/building-fabric-services/SKILL.md +15 -3
- package/payload/plugins/fabric-agent-adapter/skills/building-fabric-services/references/dashboard-links.md +92 -0
- package/payload/plugins/fabric-agent-adapter/skills/building-fabric-services/references/surfaces-and-auth.md +3 -2
- package/payload/plugins/fabric-agent-adapter/skills/building-fabric-services/scripts/check_dashboard_link.py +86 -0
- package/payload/plugins/fabric-agent-adapter/skills/creating-fabric-agents/SKILL.md +13 -1
- package/payload/plugins/fabric-agent-adapter/skills/creating-fabric-agents/references/dashboard-links.md +92 -0
- package/payload/plugins/fabric-agent-adapter/skills/creating-fabric-agents/scripts/check_dashboard_link.py +86 -0
- package/payload/plugins/passioncode/.claude-plugin/plugin.json +1 -1
- package/payload/plugins/passioncode/hooks/repo-rules.js +1 -1
- package/payload/plugins/passioncode/hooks/session-start.js +34 -12
- package/payload/plugins/passioncode/hooks/update-check.js +8 -3
- package/payload/plugins/passioncode/skills/working-in-passioncode/SKILL.md +14 -3
- package/payload/plugins/passioncode/skills/working-in-passioncode/references/releasing.md +7 -6
package/CHANGELOG.md
CHANGED
|
@@ -1,5 +1,45 @@
|
|
|
1
1
|
# Changelog
|
|
2
2
|
|
|
3
|
+
## 0.1.17 - 2026-10-01
|
|
4
|
+
|
|
5
|
+
### Changed
|
|
6
|
+
|
|
7
|
+
- Pin Fabric Agent Adapter v0.5.6 so the installed set carries its dashboard deep-link procedure and portable handoff checker. The Observatory Log and working-in-passioncode members keep their existing behavior.
|
|
8
|
+
- Dashboard routing evidence belongs to the [adapter handoff](https://github.com/passioncode-ai/fabric-agent-adapter/blob/v0.5.6/docs/handoffs/2026-10-01-dashboard-links.md); the launcher installs these bytes through its existing plugin and agents-hub channels.
|
|
9
|
+
|
|
10
|
+
## 0.1.16 - 2026-10-01
|
|
11
|
+
|
|
12
|
+
### Fixed
|
|
13
|
+
|
|
14
|
+
- The packed lifecycle release check accepts both npm 11’s array and npm 12’s
|
|
15
|
+
package-name-keyed JSON output, and rejects ambiguous or malformed pack results.
|
|
16
|
+
- The update probe accepts npm 12’s singleton metadata array while preserving
|
|
17
|
+
publisher checks and rejecting ambiguous or malformed responses.
|
|
18
|
+
- The 0.1.15 tag and GitHub release remain historical: its release workflow stopped
|
|
19
|
+
at this check before npm publication. Version 0.1.16 includes the fixes and shared
|
|
20
|
+
backlog instructions described below.
|
|
21
|
+
|
|
22
|
+
## 0.1.15 - 2026-10-01
|
|
23
|
+
|
|
24
|
+
### Fixed
|
|
25
|
+
|
|
26
|
+
- Restore and uninstall preserve recoverable state, reject unsupported options before changes,
|
|
27
|
+
and report failed filesystem or plugin operations truthfully. Updates reject inconsistent
|
|
28
|
+
payloads and stop dependent work after a failed release or marketplace step.
|
|
29
|
+
- Status reports the installed release correctly and retains concurrent probe/configuration
|
|
30
|
+
updates. Self-update checks reject untrusted or incomplete npm publisher metadata and
|
|
31
|
+
recover from failed spawns and future timestamps.
|
|
32
|
+
- Node 18 test discovery, release-tag validation and the packed lifecycle smoke now guard
|
|
33
|
+
the release path. The launcher audit records the regression evidence.
|
|
34
|
+
|
|
35
|
+
### Changed
|
|
36
|
+
|
|
37
|
+
- The repository skill and SessionStart reminder direct agents to canonical local backlog
|
|
38
|
+
sources and the derived workspace backlog, with one owner per task and source-commit checks.
|
|
39
|
+
- Developer documentation now states the CLI, recovery and self-update contracts and checks
|
|
40
|
+
their local references. Tagged Fabric Agent Adapter 0.5.5 and Observatory Log 0.13.0 stay
|
|
41
|
+
in the family; this release does not add a new family member.
|
|
42
|
+
|
|
3
43
|
## 0.1.14 - 2026-10-01
|
|
4
44
|
|
|
5
45
|
### Changed
|
package/README.md
CHANGED
|
@@ -24,8 +24,8 @@ out of it, even when they implement Fabric protocols.
|
|
|
24
24
|
1. **Install** (Node 18 or newer; the Claude Code CLI on `PATH` for the plugins):
|
|
25
25
|
`npx @passioncode-ai/passioncode@latest update`, then restart your agents. Check it with
|
|
26
26
|
`npx @passioncode-ai/passioncode@latest status` (every member `claude: plugin · hub: n/n`) and
|
|
27
|
-
`claude plugin list` (
|
|
28
|
-
|
|
27
|
+
`claude plugin list` (the plugins listed in `family.json`, under `@passioncode`).
|
|
28
|
+
Always call the scoped package shown above.
|
|
29
29
|
2. **Configure:** nothing to set, no account and no key. Background updates are on by default;
|
|
30
30
|
`npx @passioncode-ai/passioncode@latest config set update.auto off` turns them off.
|
|
31
31
|
3. **MCP:** none today, and not planned. The launcher installs skills and session hooks and is
|
|
@@ -50,26 +50,41 @@ out of it, even when they implement Fabric protocols.
|
|
|
50
50
|
- **Every other agent** (Cursor, Codex, Gemini, OpenCode, Kiro, Windsurf, Goose, …) gets
|
|
51
51
|
the same skills through `~/.agents/skills`, linked from each channel that exists on
|
|
52
52
|
the machine.
|
|
53
|
-
-
|
|
53
|
+
- Updates create no skill copies in `~/.claude/skills`: a plain copy there shadows the plugin. Such
|
|
54
54
|
copies, and hub entries that pointed at a repository's working tree, are moved into
|
|
55
55
|
`~/.passioncode/quarantine/` — `npx @passioncode-ai/passioncode@latest restore` puts them back.
|
|
56
56
|
- Each version is an immutable release under `~/.passioncode/releases/<version>`.
|
|
57
57
|
|
|
58
58
|
## Staying current
|
|
59
59
|
|
|
60
|
-
At
|
|
61
|
-
|
|
60
|
+
At Claude Code session start the `passioncode` plugin uses a cached npm check for up
|
|
61
|
+
to 24 hours and, when a newer set is out **and every npm account that publishes it is in the
|
|
62
62
|
plugin's `trust.json`**, updates to that exact version in the background — it takes
|
|
63
63
|
effect in the next session. A version published by anyone else is named and not
|
|
64
64
|
installed ([SECURITY.md](SECURITY.md#the-self-update-and-who-it-trusts)).
|
|
65
65
|
`npx @passioncode-ai/passioncode@latest config set update.auto off` makes it only say so.
|
|
66
66
|
|
|
67
|
+
`status` reads local state without contacting npm. Its npm version is labelled
|
|
68
|
+
`npm latest (cached)` with the saved check timestamp, or `check time unknown`.
|
|
69
|
+
When that stable version is below the installed stable version, the line also says
|
|
70
|
+
`stale: older than installed`. A newer or equal cached version is still a saved
|
|
71
|
+
observation, not a live registry check. With no saved version it says
|
|
72
|
+
`npm latest: not checked`; `--json` retains the raw `latest` and `checkedAt` fields.
|
|
73
|
+
|
|
67
74
|
```bash
|
|
68
75
|
npx @passioncode-ai/passioncode@latest status # the installed release: its members, their channels, shadows
|
|
69
76
|
npx @passioncode-ai/passioncode@latest update --dry-run
|
|
70
77
|
npx @passioncode-ai/passioncode@latest uninstall
|
|
71
78
|
```
|
|
72
79
|
|
|
80
|
+
For supported flags, JSON fields, exit codes and partial-failure recovery, read the
|
|
81
|
+
[CLI contract](docs/reference/cli.md). `restore` repairs the latest quarantine batch;
|
|
82
|
+
it is not a release rollback. A successful `uninstall` preserves recovery files and
|
|
83
|
+
turns automatic updates off. Unsupported flags are rejected before mutation.
|
|
84
|
+
|
|
85
|
+
Repository docs describe the current source. See the [documentation index](docs/README.md)
|
|
86
|
+
and dated handoffs for which changes have actually been published or installed.
|
|
87
|
+
|
|
73
88
|
## Release (maintainers)
|
|
74
89
|
|
|
75
90
|
1. Tag every member at the version to ship; set `ref` to that tag in `family.json`.
|
|
@@ -81,7 +96,8 @@ npx @passioncode-ai/passioncode@latest uninstall
|
|
|
81
96
|
4. Merge, then tag the merge commit `vX.Y.Z` and push the tag. `.github/workflows/release.yml`
|
|
82
97
|
validates, checks the tag against both manifests, creates the GitHub release from the
|
|
83
98
|
CHANGELOG section, vendors every member **from its own repository** at its pinned tag,
|
|
84
|
-
runs the tests,
|
|
99
|
+
runs the tests, exercises packed update/status/uninstall with a fake Claude CLI
|
|
100
|
+
in a temporary home (`node scripts/smoke-pack.mjs`), publishes
|
|
85
101
|
`@passioncode-ai/passioncode` and waits until npm serves it. It runs when the repository
|
|
86
102
|
variables `RELEASE_ENABLED` and `PUBLISH_NPMJS` are `true`; npm auth is trusted publishing
|
|
87
103
|
(OIDC) or the `NPM_TOKEN` secret; a private member needs `MEMBERS_READ_TOKEN`.
|
package/SECURITY.md
CHANGED
|
@@ -11,12 +11,18 @@ that replaces it is verified. For a member's old single-member marketplace
|
|
|
11
11
|
(`legacyMarketplaces` in `family.json`), once that plugin is verified and nothing else
|
|
12
12
|
installed comes from it, it also moves the `extraKnownMarketplaces` entry out of
|
|
13
13
|
`~/.claude/settings.json` (a copy of the file is kept in the quarantine) and runs
|
|
14
|
-
`claude plugin marketplace remove <name>`.
|
|
15
|
-
|
|
14
|
+
`claude plugin marketplace remove <name>`. Displaced files are moved rather than
|
|
15
|
+
deleted: a recovery record is written before the move in
|
|
16
|
+
`~/.passioncode/quarantine/<run>/moved.json`. `restore` processes the latest batch,
|
|
17
|
+
checkpoints completed entries and refuses to overwrite a conflicting user replacement.
|
|
18
|
+
`uninstall` removes owned links/plugins, clears the active release and disables
|
|
19
|
+
automatic updates; it retains release copies and recovery records. A failed removal
|
|
20
|
+
returns a nonzero exit code. See the [CLI recovery contract](docs/reference/cli.md).
|
|
16
21
|
|
|
17
22
|
## The self-update and who it trusts
|
|
18
23
|
|
|
19
|
-
The SessionStart hook reads `~/.passioncode/state.json` and
|
|
24
|
+
The SessionStart hook reads `~/.passioncode/state.json` and reuses a check for up to
|
|
25
|
+
24 hours. An absent, invalid, future or expired timestamp starts
|
|
20
26
|
`npm view @passioncode-ai/passioncode version maintainers _npmUser --json`, detached, logging to
|
|
21
27
|
`~/.passioncode/logs/`. It records the latest version, the package's npm maintainers
|
|
22
28
|
and the account that published that version.
|
|
@@ -24,7 +30,8 @@ and the account that published that version.
|
|
|
24
30
|
The hook installs a newer version on its own only when all of these hold:
|
|
25
31
|
|
|
26
32
|
- `update.auto` is on (the default);
|
|
27
|
-
- the recorded maintainers are
|
|
33
|
+
- the npm probe exited successfully, the recorded maintainers are non-empty, the
|
|
34
|
+
publisher is known, and every maintainer **and** the publishing
|
|
28
35
|
account is listed in `trust.json` inside the plugin (`npmPublishers`);
|
|
29
36
|
a release published by this repository's `release.yml` through npm trusted publishing
|
|
30
37
|
appears as `github-actions-oidc` (npm's `npm-oidc-no-reply@github.com` identity, matched
|
|
@@ -37,10 +44,19 @@ nothing: a version from an account outside the list is reported in one line nami
|
|
|
37
44
|
account ("… not a trusted PassionCode publisher — not installing"); a name nobody has
|
|
38
45
|
published (npm E404) is silent; a failed check is shown once.
|
|
39
46
|
|
|
40
|
-
`trust.json`
|
|
41
|
-
|
|
47
|
+
The current allowlist lives in [`plugin/passioncode/trust.json`](plugin/passioncode/trust.json).
|
|
48
|
+
A missing or empty allowlist cannot authorize an update. Adding an account there is a release decision:
|
|
42
49
|
it is reviewed like code and ships in the package. A person who wants no background
|
|
43
50
|
updates at all runs `npx @passioncode-ai/passioncode@latest config set update.auto off`.
|
|
44
51
|
|
|
45
|
-
The
|
|
46
|
-
|
|
52
|
+
The package contains executable launcher code and member plugin hooks as well as
|
|
53
|
+
skill text. The vendor scans supported text-file sizes for credential-shaped strings
|
|
54
|
+
and rejects an invalid self-plugin trust list; that scan is not a proof that all
|
|
55
|
+
possible secrets or malicious code are absent. Review member code at its pinned tag.
|
|
56
|
+
Automatic updates trust the npm registry's attribution and the local cached state;
|
|
57
|
+
they do not independently verify signatures or sandbox the installed code.
|
|
58
|
+
|
|
59
|
+
Evidence: `update()`, `restore()` and `uninstall()` in `lib/launcher.js`;
|
|
60
|
+
`parseView()` and `decide()` in `plugin/passioncode/hooks/update-check.js`;
|
|
61
|
+
`scanForSecrets()` in `scripts/vendor.mjs`; regression tests in
|
|
62
|
+
`test/launcher.test.js` and `test/self-update.test.js`.
|
package/bin/passioncode.js
CHANGED
|
@@ -2,6 +2,21 @@
|
|
|
2
2
|
'use strict';
|
|
3
3
|
const L = require('../lib/launcher');
|
|
4
4
|
|
|
5
|
+
function cachedNpmStatus({ latest, checkedAt, installed }) {
|
|
6
|
+
if (!latest) return 'npm latest: not checked';
|
|
7
|
+
const checked = typeof checkedAt === 'string' && Number.isFinite(Date.parse(checkedAt))
|
|
8
|
+
? `checked ${checkedAt}` : 'check time unknown';
|
|
9
|
+
let stale = false;
|
|
10
|
+
// The registry probe accepts stable x.y.z versions only. Do not guess an
|
|
11
|
+
// ordering for other values that may have been written to the state file.
|
|
12
|
+
if (/^\d+\.\d+\.\d+$/.test(latest) && /^\d+\.\d+\.\d+$/.test(installed)) {
|
|
13
|
+
const a = latest.split('.').map(Number), b = installed.split('.').map(Number);
|
|
14
|
+
const difference = a.findIndex((part, i) => part !== b[i]);
|
|
15
|
+
stale = difference !== -1 && a[difference] < b[difference];
|
|
16
|
+
}
|
|
17
|
+
return `npm latest (cached) ${latest} (${checked}${stale ? '; stale: older than installed' : ''})`;
|
|
18
|
+
}
|
|
19
|
+
|
|
5
20
|
const HELP = `passioncode — the PassionCode.ai skill set (family.json), for every agent on this machine
|
|
6
21
|
|
|
7
22
|
npx @passioncode-ai/passioncode@latest update [--dry-run] [--json] install or update the whole set
|
|
@@ -18,6 +33,18 @@ function main(argv) {
|
|
|
18
33
|
const json = rest.includes('--json');
|
|
19
34
|
const quiet = rest.includes('--quiet');
|
|
20
35
|
const out = (value, text) => process.stdout.write(json ? JSON.stringify(value, null, 2) + '\n' : text + '\n');
|
|
36
|
+
const options = {
|
|
37
|
+
update: ['--dry-run', '--json', '--quiet'], install: ['--dry-run', '--json', '--quiet'],
|
|
38
|
+
status: ['--json'], restore: ['--json'], uninstall: ['--json'], help: [], '--help': [], '-h': [],
|
|
39
|
+
};
|
|
40
|
+
const configArgs = rest.filter((arg) => arg !== '--json');
|
|
41
|
+
const invalid = cmd === 'config'
|
|
42
|
+
? configArgs.length !== 3 || configArgs[0] !== 'set'
|
|
43
|
+
: Object.hasOwn(options, cmd) && rest.some((arg) => !options[cmd].includes(arg));
|
|
44
|
+
if (invalid) {
|
|
45
|
+
process.stderr.write(`passioncode: unsupported arguments for ${cmd}; run passioncode help.\n`);
|
|
46
|
+
return 2;
|
|
47
|
+
}
|
|
21
48
|
try {
|
|
22
49
|
if (cmd === 'update' || cmd === 'install') {
|
|
23
50
|
const r = L.update({ dryRun: rest.includes('--dry-run') });
|
|
@@ -26,8 +53,8 @@ function main(argv) {
|
|
|
26
53
|
out(r, [
|
|
27
54
|
...r.steps.map((s) => `${s.outcome.padEnd(8)} ${s.kind.padEnd(11)} ${s.detail}${s.error ? `\n ${s.error}` : ''}`),
|
|
28
55
|
'',
|
|
29
|
-
failed.length ? `${failed.length} step(s) failed
|
|
30
|
-
r.moved.length ? `${r.moved.length}
|
|
56
|
+
failed.length ? `${failed.length} step(s) failed. Completed steps remain applied. Fix the cause and retry; use passioncode restore for displaced files.` : rest.includes('--dry-run') ? `Plan for PassionCode.ai ${r.version}: nothing was changed.` : `PassionCode.ai ${r.version}: done. Restart your agent — skills load at session start.`,
|
|
57
|
+
r.moved.length ? `${r.moved.length} recovery entry(s) saved; use \`passioncode restore\`.` : '',
|
|
31
58
|
].filter(Boolean).join('\n'));
|
|
32
59
|
}
|
|
33
60
|
return failed.length ? 1 : 0;
|
|
@@ -35,7 +62,7 @@ function main(argv) {
|
|
|
35
62
|
if (cmd === 'status') {
|
|
36
63
|
const s = L.status();
|
|
37
64
|
out(s, [
|
|
38
|
-
`installed ${s.installed || 'nothing'} · package ${s.package || '?'} ·
|
|
65
|
+
`installed ${s.installed || 'nothing'} · package ${s.package || '?'} · ${cachedNpmStatus(s)}${s.auto ? ' · auto-update on' : ' · auto-update off'}`,
|
|
39
66
|
...s.members.map((m) => `${m.name.padEnd(22)} ${String(m.version).padEnd(8)} claude: ${m.claude}${m.legacy.length ? ` (+ legacy ${m.legacy.join(', ')})` : ''}${m.legacyMarketplaces.length ? ` (+ legacy marketplace ${m.legacyMarketplaces.join(', ')})` : ''} · hub: ${m.hub.filter((h) => h.ok).length}/${m.hub.length}${m.shadows.length ? ` · SHADOW: ${m.shadows.join(', ')}` : ''}`),
|
|
40
67
|
].join('\n'));
|
|
41
68
|
return 0;
|
|
@@ -45,8 +72,8 @@ function main(argv) {
|
|
|
45
72
|
out(r, r.length ? `Restored:\n ${r.join('\n ')}` : 'Nothing to restore.');
|
|
46
73
|
return 0;
|
|
47
74
|
}
|
|
48
|
-
if (cmd === 'config' &&
|
|
49
|
-
out(L.setConfig({ key:
|
|
75
|
+
if (cmd === 'config' && configArgs[0] === 'set') {
|
|
76
|
+
out(L.setConfig({ key: configArgs[1], value: configArgs[2] }), `update.auto = ${configArgs[2]}`);
|
|
50
77
|
return 0;
|
|
51
78
|
}
|
|
52
79
|
if (cmd === 'uninstall') {
|
package/family.json
CHANGED
package/lib/launcher.js
CHANGED
|
@@ -9,7 +9,7 @@
|
|
|
9
9
|
* ~/.claude/skills (a plain copy there shadows the plugin and serves its frozen
|
|
10
10
|
* version forever); every other agent reads the hub through its own channel.
|
|
11
11
|
*/
|
|
12
|
-
const {
|
|
12
|
+
const { spawnSync } = require('child_process');
|
|
13
13
|
const fs = require('fs');
|
|
14
14
|
const os = require('os');
|
|
15
15
|
const path = require('path');
|
|
@@ -49,6 +49,14 @@ function writeJson(file, value) {
|
|
|
49
49
|
}
|
|
50
50
|
function lstat(p) { try { return fs.lstatSync(p); } catch (_) { return null; } }
|
|
51
51
|
function linkTarget(p) { try { return fs.readlinkSync(p); } catch (_) { return null; } }
|
|
52
|
+
function pointsTo(at, target) { const link = linkTarget(at); return link !== null && path.resolve(path.dirname(at), link) === target; }
|
|
53
|
+
function isDirectory(at) { try { return fs.statSync(at).isDirectory(); } catch (_) { return false; } }
|
|
54
|
+
function readState(file, { tolerant = false } = {}) {
|
|
55
|
+
try { return readSettings(file).doc; } catch (error) {
|
|
56
|
+
if (tolerant) return {};
|
|
57
|
+
throw new Error(`Cannot read state ${file}: ${error.message}. Preserve the file and repair it before retrying.`);
|
|
58
|
+
}
|
|
59
|
+
}
|
|
52
60
|
|
|
53
61
|
/** Replace a symlink atomically (create beside it, rename over it). */
|
|
54
62
|
function symlinkAtomic(target, at) {
|
|
@@ -62,7 +70,8 @@ function symlinkAtomic(target, at) {
|
|
|
62
70
|
/** Claude Code's plugin CLI, overridable for tests. */
|
|
63
71
|
function claudeRunner() {
|
|
64
72
|
const bin = process.env.PASSIONCODE_CLAUDE || 'claude';
|
|
65
|
-
|
|
73
|
+
let found;
|
|
74
|
+
const available = () => found ?? (found = spawnSync(bin, ['--version'], { encoding: 'utf8', timeout: 5000 }).status === 0);
|
|
66
75
|
const run = (args, { cwd } = {}) => {
|
|
67
76
|
const r = spawnSync(bin, args, { encoding: 'utf8', timeout: 180000, ...(cwd ? { cwd } : {}) });
|
|
68
77
|
return { code: r.status === null ? 1 : r.status, out: `${r.stdout || ''}${r.stderr || ''}`.trim() };
|
|
@@ -93,15 +102,41 @@ function marketplaceAddArg(source) {
|
|
|
93
102
|
function payloadManifest(dir = PAYLOAD()) {
|
|
94
103
|
const m = readJson(path.join(dir, 'manifest.json'), null);
|
|
95
104
|
if (!m || !Array.isArray(m.members)) throw new Error(`No payload in ${dir}: the package is incomplete (run scripts/vendor.mjs).`);
|
|
105
|
+
const name = (value) => typeof value === 'string' && /^[a-z0-9][a-z0-9._-]*$/.test(value);
|
|
106
|
+
const version = (value) => typeof value === 'string' && /^\d+\.\d+\.\d+$/.test(value);
|
|
107
|
+
const names = new Set(), skills = new Set();
|
|
108
|
+
if (!version(m.version)) throw new Error(`Invalid payload version in ${dir}.`);
|
|
109
|
+
for (const member of m.members) {
|
|
110
|
+
if (!member || !name(member.name) || names.has(member.name) || !version(member.version) || !Array.isArray(member.skills)) {
|
|
111
|
+
throw new Error(`Invalid or duplicate payload member in ${dir}.`);
|
|
112
|
+
}
|
|
113
|
+
names.add(member.name);
|
|
114
|
+
for (const skill of member.skills) {
|
|
115
|
+
if (!name(skill) || skills.has(skill)) throw new Error(`Invalid or duplicate skill in ${dir}.`);
|
|
116
|
+
skills.add(skill);
|
|
117
|
+
}
|
|
118
|
+
}
|
|
96
119
|
return m;
|
|
97
120
|
}
|
|
98
121
|
|
|
122
|
+
function verifyPayload(dir, manifest) {
|
|
123
|
+
for (const m of manifest.members) {
|
|
124
|
+
const root = path.join(dir, 'plugins', m.name);
|
|
125
|
+
const plugin = readJson(path.join(root, '.claude-plugin/plugin.json'), null);
|
|
126
|
+
if (!plugin || plugin.name !== m.name || plugin.version !== m.version) throw new Error(`Payload plugin mismatch: ${m.name}.`);
|
|
127
|
+
for (const skill of m.skills) {
|
|
128
|
+
if (!fs.existsSync(path.join(root, 'skills', skill, 'SKILL.md'))) throw new Error(`Payload skill missing: ${m.name}/${skill}.`);
|
|
129
|
+
}
|
|
130
|
+
}
|
|
131
|
+
}
|
|
132
|
+
|
|
99
133
|
/** Plan and apply an update. Returns the steps with their outcomes. */
|
|
100
134
|
function update({ home, dryRun = false, claude = claudeRunner(), log = () => {} } = {}) {
|
|
101
135
|
const p = paths(home);
|
|
102
136
|
const manifest = payloadManifest();
|
|
137
|
+
verifyPayload(PAYLOAD(), manifest);
|
|
103
138
|
const version = manifest.version;
|
|
104
|
-
const state =
|
|
139
|
+
const state = readState(p.stateFile);
|
|
105
140
|
const steps = [];
|
|
106
141
|
const step = (kind, detail, fn) => {
|
|
107
142
|
if (dryRun) { steps.push({ kind, detail, outcome: 'planned' }); return true; }
|
|
@@ -115,12 +150,19 @@ function update({ home, dryRun = false, claude = claudeRunner(), log = () => {}
|
|
|
115
150
|
}
|
|
116
151
|
};
|
|
117
152
|
const moved = [];
|
|
118
|
-
const quarantineDir = path.join(p.quarantine, new Date().toISOString().replace(/[:.]/g, '-'));
|
|
119
|
-
const
|
|
153
|
+
const quarantineDir = path.join(p.quarantine, `${new Date().toISOString().replace(/[:.]/g, '-')}-${process.pid}`);
|
|
154
|
+
const recordMove = (entry) => {
|
|
155
|
+
writeJson(path.join(quarantineDir, 'moved.json'), [...moved, entry]);
|
|
156
|
+
const fresh = readState(p.stateFile);
|
|
157
|
+
fresh.quarantines = [...new Set([...(fresh.quarantines || []), quarantineDir])];
|
|
158
|
+
writeJson(p.stateFile, fresh);
|
|
159
|
+
moved.push(entry);
|
|
160
|
+
};
|
|
161
|
+
const quarantine = (target, why, replacement = null) => {
|
|
120
162
|
const dest = path.join(quarantineDir, target.replace(/^\//, '').replace(/\//g, '__'));
|
|
121
163
|
fs.mkdirSync(path.dirname(dest), { recursive: true });
|
|
164
|
+
recordMove({ from: target, to: dest, why, replacement });
|
|
122
165
|
fs.renameSync(target, dest);
|
|
123
|
-
moved.push({ from: target, to: dest, why });
|
|
124
166
|
};
|
|
125
167
|
|
|
126
168
|
/** Move a legacy marketplace out of settings.json and the registry, recorded for restore. */
|
|
@@ -151,11 +193,13 @@ function update({ home, dryRun = false, claude = claudeRunner(), log = () => {}
|
|
|
151
193
|
record.settingsBackup = path.join(quarantineDir, `settings.json.before-${name}`);
|
|
152
194
|
fs.copyFileSync(p.claudeSettings, record.settingsBackup);
|
|
153
195
|
record.settingsEntry = declared;
|
|
196
|
+
}
|
|
197
|
+
recordMove(record); // durable before settings or the registry changes
|
|
198
|
+
if (declared !== undefined) {
|
|
154
199
|
const doc = settings.doc;
|
|
155
200
|
delete doc.extraKnownMarketplaces[name];
|
|
156
201
|
writeJson(p.claudeSettings, doc);
|
|
157
202
|
}
|
|
158
|
-
moved.push(record); // recorded before the CLI runs, so restore can undo a half-finished step
|
|
159
203
|
if (registered) {
|
|
160
204
|
// No --scope: with the user entry already moved it would refuse. From ~/.passioncode
|
|
161
205
|
// "every scope" is the user one — no project's .claude/settings.json is touched.
|
|
@@ -168,14 +212,22 @@ function update({ home, dryRun = false, claude = claudeRunner(), log = () => {}
|
|
|
168
212
|
// 1. The release: an immutable copy of the payload, then `current` points at it.
|
|
169
213
|
const release = path.join(p.releases, version);
|
|
170
214
|
step('release', `install ${version} at ${release}`, () => {
|
|
171
|
-
if (lstat(path.join(release, 'manifest.json')))
|
|
215
|
+
if (lstat(path.join(release, 'manifest.json'))) {
|
|
216
|
+
const existing = payloadManifest(release);
|
|
217
|
+
verifyPayload(release, existing);
|
|
218
|
+
const identity = (m) => JSON.stringify([m.version, m.members.map(({ via, ...member }) => member)]);
|
|
219
|
+
if (identity(existing) !== identity(manifest)) throw new Error(`Release ${version} has a different manifest; use a new version instead of overwriting an immutable release.`);
|
|
220
|
+
return false;
|
|
221
|
+
}
|
|
172
222
|
const tmp = `${release}.${process.pid}.partial`;
|
|
173
223
|
fs.rmSync(tmp, { recursive: true, force: true });
|
|
174
224
|
fs.cpSync(PAYLOAD(), tmp, { recursive: true });
|
|
175
225
|
fs.renameSync(tmp, release);
|
|
176
226
|
return true;
|
|
177
227
|
});
|
|
228
|
+
if (steps.at(-1).outcome === 'failed') return { version, steps, moved };
|
|
178
229
|
step('current', `point ${p.current} at ${version}`, () => { symlinkAtomic(release, p.current); });
|
|
230
|
+
if (steps.at(-1).outcome === 'failed') return { version, steps, moved };
|
|
179
231
|
|
|
180
232
|
// 2. Claude Code: one local marketplace, one plugin per member.
|
|
181
233
|
const hasClaude = claude.available();
|
|
@@ -185,16 +237,20 @@ function update({ home, dryRun = false, claude = claudeRunner(), log = () => {}
|
|
|
185
237
|
steps.push({ kind: 'claude', detail: 'Claude Code CLI not found; its plugins were not installed', outcome: 'skipped' });
|
|
186
238
|
} else {
|
|
187
239
|
const known = readJson(path.join(p.claudePlugins, 'known_marketplaces.json'), {});
|
|
188
|
-
step('marketplace', known[MARKETPLACE] ? `update the ${MARKETPLACE} marketplace` : `add ${p.current} as the ${MARKETPLACE} marketplace`, () => {
|
|
240
|
+
const marketplaceReady = step('marketplace', known[MARKETPLACE] ? `update the ${MARKETPLACE} marketplace` : `add ${p.current} as the ${MARKETPLACE} marketplace`, () => {
|
|
189
241
|
const r = known[MARKETPLACE] ? claude.run(['plugin', 'marketplace', 'update', MARKETPLACE]) : claude.run(['plugin', 'marketplace', 'add', p.current]);
|
|
190
242
|
if (r.code !== 0) throw new Error(r.out || 'marketplace command failed');
|
|
191
243
|
});
|
|
192
|
-
for (const m of manifest.members) {
|
|
244
|
+
for (const m of marketplaceReady ? manifest.members : []) {
|
|
193
245
|
const id = `${m.name}@${MARKETPLACE}`;
|
|
194
246
|
const present = installedIds().includes(id);
|
|
195
247
|
const ok = step('plugin', `${present ? 'update' : 'install'} ${id} ${m.version}`, () => {
|
|
196
248
|
const r = claude.run(['plugin', present ? 'update' : 'install', id]);
|
|
197
249
|
if (r.code !== 0 && !/already (installed|at the latest)/i.test(r.out)) throw new Error(r.out || `plugin ${present ? 'update' : 'install'} failed`);
|
|
250
|
+
const entries = readJson(path.join(p.claudePlugins, 'installed_plugins.json'), { plugins: {} }).plugins?.[id];
|
|
251
|
+
if (!Array.isArray(entries) || !entries.some((entry) => entry.version === m.version)) {
|
|
252
|
+
throw new Error(`Claude Code did not register ${id} at version ${m.version}; legacy entries were kept.`);
|
|
253
|
+
}
|
|
198
254
|
});
|
|
199
255
|
if (ok && (dryRun || installedIds().includes(id))) verified.add(m.name);
|
|
200
256
|
// The same plugin from its old single-member marketplace is a second copy of every skill.
|
|
@@ -218,7 +274,7 @@ function update({ home, dryRun = false, claude = claudeRunner(), log = () => {}
|
|
|
218
274
|
}
|
|
219
275
|
|
|
220
276
|
// 3. Every other agent: hub link → the release, channel link → the hub.
|
|
221
|
-
const channels = CHANNELS.map((c) => path.join(p.home, c)).filter(
|
|
277
|
+
const channels = CHANNELS.map((c) => path.join(p.home, c)).filter(isDirectory);
|
|
222
278
|
for (const m of manifest.members) {
|
|
223
279
|
for (const skill of m.skills) {
|
|
224
280
|
const source = path.join(p.current, 'plugins', m.name, 'skills', skill);
|
|
@@ -226,7 +282,7 @@ function update({ home, dryRun = false, claude = claudeRunner(), log = () => {}
|
|
|
226
282
|
const existing = lstat(hubLink);
|
|
227
283
|
if (!existing || linkTarget(hubLink) !== source) {
|
|
228
284
|
step('hub', `${hubLink} → ${source}`, () => {
|
|
229
|
-
if (lstat(hubLink)) quarantine(hubLink, existing.isSymbolicLink() ? `pointed at ${linkTarget(hubLink)}` : 'a plain copy');
|
|
285
|
+
if (lstat(hubLink)) quarantine(hubLink, existing.isSymbolicLink() ? `pointed at ${linkTarget(hubLink)}` : 'a plain copy', source);
|
|
230
286
|
symlinkAtomic(source, hubLink);
|
|
231
287
|
});
|
|
232
288
|
}
|
|
@@ -235,7 +291,7 @@ function update({ home, dryRun = false, claude = claudeRunner(), log = () => {}
|
|
|
235
291
|
const cur = lstat(at);
|
|
236
292
|
if (cur && cur.isSymbolicLink() && (linkTarget(at) === hubLink || path.resolve(channel, linkTarget(at) || '') === hubLink)) continue;
|
|
237
293
|
step('channel', `${at} → ${hubLink}`, () => {
|
|
238
|
-
if (lstat(at)) quarantine(at, cur.isSymbolicLink() ? `pointed at ${linkTarget(at)}` : 'a plain copy');
|
|
294
|
+
if (lstat(at)) quarantine(at, cur.isSymbolicLink() ? `pointed at ${linkTarget(at)}` : 'a plain copy', hubLink);
|
|
239
295
|
symlinkAtomic(hubLink, at);
|
|
240
296
|
});
|
|
241
297
|
}
|
|
@@ -249,12 +305,15 @@ function update({ home, dryRun = false, claude = claudeRunner(), log = () => {}
|
|
|
249
305
|
|
|
250
306
|
if (!dryRun) {
|
|
251
307
|
if (moved.length) writeJson(path.join(quarantineDir, 'moved.json'), moved);
|
|
252
|
-
|
|
253
|
-
|
|
254
|
-
|
|
255
|
-
|
|
256
|
-
|
|
257
|
-
|
|
308
|
+
// A probe or config command may have written while the plugin CLI ran.
|
|
309
|
+
const fresh = readState(p.stateFile);
|
|
310
|
+
fresh.installed = version;
|
|
311
|
+
fresh.installedAt = new Date().toISOString();
|
|
312
|
+
fresh.members = manifest.members.map((m) => ({ name: m.name, version: m.version, ref: m.ref, commit: m.commit }));
|
|
313
|
+
fresh.quarantines = [...new Set([...(fresh.quarantines || []), ...(moved.length ? [quarantineDir] : [])])];
|
|
314
|
+
if (state.legacyRemoved) fresh.legacyRemoved = state.legacyRemoved;
|
|
315
|
+
delete fresh.updatingSince;
|
|
316
|
+
writeJson(p.stateFile, fresh);
|
|
258
317
|
}
|
|
259
318
|
log(`${dryRun ? 'Plan' : 'Updated'}: PassionCode.ai ${version}`);
|
|
260
319
|
return { version, steps, moved };
|
|
@@ -263,17 +322,36 @@ function update({ home, dryRun = false, claude = claudeRunner(), log = () => {}
|
|
|
263
322
|
/** Put back everything a run moved aside (newest run first unless one is named). */
|
|
264
323
|
function restore({ home, which, claude = claudeRunner() } = {}) {
|
|
265
324
|
const p = paths(home);
|
|
266
|
-
const state =
|
|
325
|
+
const state = readState(p.stateFile);
|
|
267
326
|
const dirs = which ? [which] : (state.quarantines || []).slice(-1);
|
|
268
327
|
const restored = [];
|
|
269
328
|
for (const dir of dirs) {
|
|
270
|
-
|
|
271
|
-
|
|
272
|
-
|
|
273
|
-
if (
|
|
274
|
-
|
|
275
|
-
|
|
276
|
-
|
|
329
|
+
const journal = path.join(dir, 'moved.json');
|
|
330
|
+
const entries = JSON.parse(fs.readFileSync(journal, 'utf8'));
|
|
331
|
+
for (const entry of [...entries].reverse()) {
|
|
332
|
+
if (entry.restoredAt) continue;
|
|
333
|
+
if (entry.kind === 'marketplace') {
|
|
334
|
+
restored.push(restoreMarketplace(p, entry, claude));
|
|
335
|
+
} else if (lstat(entry.to)) {
|
|
336
|
+
if (lstat(entry.from)) {
|
|
337
|
+
// Old journals did not record the replacement. Only recognise a link
|
|
338
|
+
// into this launcher's hub/current tree; never erase an arbitrary link.
|
|
339
|
+
const target = linkTarget(entry.from);
|
|
340
|
+
const resolved = target === null ? null : path.resolve(path.dirname(entry.from), target);
|
|
341
|
+
const owned = entry.replacement !== undefined ? resolved === entry.replacement && resolved !== null
|
|
342
|
+
: resolved && (resolved.startsWith(p.current + path.sep) || resolved.startsWith(p.hub + path.sep));
|
|
343
|
+
if (!owned) throw new Error(`Restore conflict at ${entry.from}; move the replacement aside and retry.`);
|
|
344
|
+
fs.unlinkSync(entry.from);
|
|
345
|
+
}
|
|
346
|
+
fs.mkdirSync(path.dirname(entry.from), { recursive: true });
|
|
347
|
+
fs.renameSync(entry.to, entry.from);
|
|
348
|
+
restored.push(entry.from);
|
|
349
|
+
} else if (!lstat(entry.from)) {
|
|
350
|
+
throw new Error(`Restore source missing: ${entry.to}; preserve the quarantine for recovery.`);
|
|
351
|
+
}
|
|
352
|
+
// Also recognise already-restored entries from releases with no checkpoint.
|
|
353
|
+
entry.restoredAt = new Date().toISOString();
|
|
354
|
+
writeJson(journal, entries);
|
|
277
355
|
}
|
|
278
356
|
}
|
|
279
357
|
return restored;
|
|
@@ -303,12 +381,13 @@ function restoreMarketplace(p, entry, claude) {
|
|
|
303
381
|
notes.push(`settings.json not restored (${error.message}); the original is at ${entry.settingsBackup}`);
|
|
304
382
|
}
|
|
305
383
|
}
|
|
306
|
-
|
|
384
|
+
if (notes.length) throw new Error(`Could not fully restore marketplace ${entry.name}: ${notes.join('; ')}. Retry restore after fixing it.`);
|
|
385
|
+
return `marketplace ${entry.name}`;
|
|
307
386
|
}
|
|
308
387
|
|
|
309
388
|
function status({ home, claude = claudeRunner() } = {}) {
|
|
310
389
|
const p = paths(home);
|
|
311
|
-
const state =
|
|
390
|
+
const state = readState(p.stateFile, { tolerant: true });
|
|
312
391
|
let payload = null;
|
|
313
392
|
try { payload = payloadManifest(); } catch (_) { /* reported as unknown */ }
|
|
314
393
|
// What is installed is the release `current` points at. The payload beside this CLI is
|
|
@@ -330,7 +409,7 @@ function status({ home, claude = claudeRunner() } = {}) {
|
|
|
330
409
|
shadows: m.skills.filter((s) => lstat(path.join(p.claudeSkills, s))),
|
|
331
410
|
}));
|
|
332
411
|
return {
|
|
333
|
-
installed:
|
|
412
|
+
installed: release ? release.version : (state.installed || null), latest: state.latest || null, checkedAt: state.checkedAt || null,
|
|
334
413
|
package: payload ? payload.version : null, source: release ? 'installed' : (payload ? 'package' : null),
|
|
335
414
|
auto: (state.config || {}).auto !== false, members,
|
|
336
415
|
};
|
|
@@ -339,7 +418,7 @@ function status({ home, claude = claudeRunner() } = {}) {
|
|
|
339
418
|
function setConfig({ home, key, value }) {
|
|
340
419
|
if (key !== 'update.auto' || !['on', 'off'].includes(value)) throw new Error('Only `config set update.auto on|off` is supported.');
|
|
341
420
|
const p = paths(home);
|
|
342
|
-
const state =
|
|
421
|
+
const state = readState(p.stateFile);
|
|
343
422
|
state.config = { ...(state.config || {}), auto: value === 'on' };
|
|
344
423
|
writeJson(p.stateFile, state);
|
|
345
424
|
return state.config;
|
|
@@ -347,24 +426,49 @@ function setConfig({ home, key, value }) {
|
|
|
347
426
|
|
|
348
427
|
function uninstall({ home, claude = claudeRunner() } = {}) {
|
|
349
428
|
const p = paths(home);
|
|
350
|
-
|
|
351
|
-
|
|
429
|
+
readState(p.stateFile); // refuse to overwrite a damaged recovery record
|
|
430
|
+
const manifest = lstat(p.current) ? payloadManifest(p.current) : { members: [] };
|
|
352
431
|
const removed = [];
|
|
432
|
+
const failures = [];
|
|
433
|
+
const hasClaude = claude.available();
|
|
434
|
+
const installedIds = () => Object.keys(readJson(path.join(p.claudePlugins, 'installed_plugins.json'), { plugins: {} }).plugins || {});
|
|
353
435
|
for (const m of manifest.members) {
|
|
354
436
|
for (const skill of m.skills) {
|
|
355
437
|
const hubLink = path.join(p.hub, skill);
|
|
356
|
-
if (
|
|
438
|
+
if (pointsTo(hubLink, path.join(p.current, 'plugins', m.name, 'skills', skill))) { fs.unlinkSync(hubLink); removed.push(hubLink); }
|
|
357
439
|
for (const c of CHANNELS) {
|
|
358
440
|
const at = path.join(p.home, c, skill);
|
|
359
|
-
if (
|
|
441
|
+
if (pointsTo(at, hubLink)) { fs.unlinkSync(at); removed.push(at); }
|
|
360
442
|
}
|
|
361
443
|
}
|
|
362
|
-
|
|
363
|
-
|
|
364
|
-
if (
|
|
444
|
+
const id = `${m.name}@${MARKETPLACE}`;
|
|
445
|
+
if (installedIds().includes(id)) {
|
|
446
|
+
if (!hasClaude) { failures.push(`${id}: Claude Code CLI not found`); continue; }
|
|
447
|
+
const r = claude.run(['plugin', 'uninstall', id]);
|
|
448
|
+
if (r.code === 0 && !installedIds().includes(id)) removed.push(id);
|
|
449
|
+
else failures.push(`${id}: ${r.out || 'plugin remains registered after uninstall'}`);
|
|
365
450
|
}
|
|
366
451
|
}
|
|
367
|
-
|
|
452
|
+
const known = readJson(path.join(p.claudePlugins, 'known_marketplaces.json'), {});
|
|
453
|
+
if (!failures.length && known[MARKETPLACE] && !installedIds().some((id) => id.endsWith(`@${MARKETPLACE}`))) {
|
|
454
|
+
if (!hasClaude) failures.push('Claude Code CLI not found; marketplace remains registered');
|
|
455
|
+
else {
|
|
456
|
+
const r = claude.run(['plugin', 'marketplace', 'remove', MARKETPLACE]);
|
|
457
|
+
if (r.code !== 0) failures.push(r.out || 'marketplace remove failed');
|
|
458
|
+
}
|
|
459
|
+
}
|
|
460
|
+
if (failures.length) throw new Error(`Uninstall incomplete: ${failures.join('; ')}. Fix the error and retry uninstall.`);
|
|
461
|
+
if (lstat(p.current)) {
|
|
462
|
+
if (!lstat(p.current).isSymbolicLink()) throw new Error(`Refusing to remove non-symlink ${p.current}.`);
|
|
463
|
+
fs.unlinkSync(p.current);
|
|
464
|
+
}
|
|
465
|
+
const state = readState(p.stateFile);
|
|
466
|
+
state.installed = null;
|
|
467
|
+
state.members = [];
|
|
468
|
+
state.config = { ...(state.config || {}), auto: false };
|
|
469
|
+
delete state.installedAt;
|
|
470
|
+
delete state.updatingSince;
|
|
471
|
+
writeJson(p.stateFile, state);
|
|
368
472
|
return removed;
|
|
369
473
|
}
|
|
370
474
|
|
package/package.json
CHANGED
|
@@ -1,7 +1,7 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "@passioncode-ai/passioncode",
|
|
3
|
-
"version": "0.1.
|
|
4
|
-
"description": "The PassionCode.ai agent skill set
|
|
3
|
+
"version": "0.1.17",
|
|
4
|
+
"description": "The PassionCode.ai agent skill set \u2014 Fabric Agent Adapter, Observatory Log and the organisation's working rules \u2014 for Claude Code and every other agent on the machine, updated as one set.",
|
|
5
5
|
"bin": {
|
|
6
6
|
"passioncode": "bin/passioncode.js"
|
|
7
7
|
},
|
|
@@ -23,8 +23,8 @@
|
|
|
23
23
|
"scripts": {
|
|
24
24
|
"vendor": "node scripts/vendor.mjs",
|
|
25
25
|
"vendor:release": "node scripts/vendor.mjs --release",
|
|
26
|
-
"test": "node
|
|
27
|
-
"prepublishOnly": "node scripts/vendor.mjs --release && node
|
|
26
|
+
"test": "node scripts/test.mjs",
|
|
27
|
+
"prepublishOnly": "node scripts/vendor.mjs --release && node scripts/test.mjs"
|
|
28
28
|
},
|
|
29
29
|
"engines": {
|
|
30
30
|
"node": ">=18"
|
|
@@ -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.5.
|
|
14
|
+
"version": "0.5.6",
|
|
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.17",
|
|
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",
|