kushi-agents 5.0.1 → 5.0.2
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/README.md +25 -7
- package/bin/cli.mjs +73 -45
- package/package.json +51 -51
- package/plugin/instructions/multi-host-install.instructions.md +125 -0
- package/plugin/skills/self-check/SKILL.md +1 -0
- package/plugin/skills/self-check/run.ps1 +111 -1
- package/src/constants.mjs +39 -1
- package/src/multi-host-install.test.mjs +170 -0
- package/src/multi-host.mjs +277 -0
package/README.md
CHANGED
|
@@ -121,20 +121,38 @@ See [Quickstart](https://gim-home.github.io/kushi/getting-started/quickstart/) f
|
|
|
121
121
|
|
|
122
122
|
## Install
|
|
123
123
|
|
|
124
|
-
|
|
124
|
+
Kushi supports **two host surfaces** as first-class peers (v5.0.2+):
|
|
125
125
|
|
|
126
|
-
|
|
126
|
+
| Host | Install path | Best for |
|
|
127
|
+
|---------------------------------------|------------------------------------|-----------------------------------------|
|
|
128
|
+
| **Clawpilot CLI** | `~/.copilot/m-skills/kushi/` | Scheduled / overnight runs (e.g. `kushi refresh <project>` at 6 AM via automation) |
|
|
129
|
+
| **VS Code Chat** ("GitHub Copilot Chat") | `~/.vscode/chat/skills/kushi/` | Interactive use (`@kushi what's the MACC for X?`) |
|
|
130
|
+
|
|
131
|
+
Both hosts read the **same** Evidence/ tree on disk, so a refresh from one is immediately visible from the other — the same user routinely lives in both. SKILL content is host-agnostic (no per-host branching, enforced by self-check `D32.multi-host`).
|
|
127
132
|
|
|
128
133
|
```bash
|
|
129
|
-
|
|
130
|
-
npx kushi-agents --
|
|
134
|
+
# Install to a single host
|
|
135
|
+
npx kushi-agents --clawpilot # Clawpilot only
|
|
136
|
+
npx kushi-agents --vscode # VS Code Chat only
|
|
137
|
+
|
|
138
|
+
# Install to BOTH at once (auto-detects what's present + targets both)
|
|
139
|
+
npx kushi-agents --all-hosts
|
|
140
|
+
|
|
141
|
+
# Uninstall
|
|
142
|
+
npx kushi-agents --uninstall # all detected hosts
|
|
143
|
+
npx kushi-agents --uninstall --clawpilot # Clawpilot only
|
|
144
|
+
npx kushi-agents --uninstall --vscode # VS Code Chat only
|
|
145
|
+
npx kushi-agents --uninstall --all # both
|
|
146
|
+
|
|
147
|
+
# Legacy workspace install (per-project .kushi/ in cwd)
|
|
148
|
+
npx kushi-agents # default = standard profile
|
|
149
|
+
npx kushi-agents --profile core # aggregator only
|
|
131
150
|
```
|
|
132
151
|
|
|
133
|
-
|
|
152
|
+
The 2-host matrix is a deliberate cap — see [`plugin/instructions/multi-host-install.instructions.md`](plugin/instructions/multi-host-install.instructions.md) for the rationale + per-host layout details.
|
|
134
153
|
|
|
135
154
|
```bash
|
|
136
|
-
npx kushi-agents --clawpilot
|
|
137
|
-
npx kushi-agents --clawpilot --profile full
|
|
155
|
+
npx kushi-agents --clawpilot --profile full # everything
|
|
138
156
|
```
|
|
139
157
|
|
|
140
158
|
To switch profiles later, re-run with `--force` (cleanly handles downgrades):
|
package/bin/cli.mjs
CHANGED
|
@@ -1,6 +1,7 @@
|
|
|
1
1
|
#!/usr/bin/env node
|
|
2
2
|
|
|
3
3
|
import { main } from '../src/main.mjs';
|
|
4
|
+
import { runMultiHost } from '../src/multi-host.mjs';
|
|
4
5
|
|
|
5
6
|
const args = process.argv.slice(2);
|
|
6
7
|
|
|
@@ -10,72 +11,99 @@ if (args.includes('--help') || args.includes('-h')) {
|
|
|
10
11
|
|
|
11
12
|
Installs the Kushi multi-source project-evidence + Q&A agent.
|
|
12
13
|
|
|
13
|
-
|
|
14
|
-
--
|
|
15
|
-
--
|
|
16
|
-
--
|
|
14
|
+
Host installs (v5.0.2+ — install into a host's user-global skill folder):
|
|
15
|
+
--clawpilot Install to ~/.copilot/m-skills/kushi/
|
|
16
|
+
--vscode Install to ~/.vscode/chat/skills/kushi/ (a.k.a. GitHub Copilot Chat)
|
|
17
|
+
--all-hosts Install to BOTH hosts
|
|
18
|
+
--uninstall [--clawpilot|--vscode|--all]
|
|
19
|
+
Cleanly remove the kushi install + skills-metadata.json entry
|
|
20
|
+
from the chosen host(s). Default = all detected hosts.
|
|
21
|
+
|
|
22
|
+
Workspace install (legacy / default when no host flag is given):
|
|
23
|
+
--target vscode Install to <cwd>/.kushi/ + update .vscode/settings.json [default]
|
|
24
|
+
--target clawpilot Alias for --clawpilot (kept for back-compat)
|
|
17
25
|
|
|
18
26
|
Profile (controls what gets installed):
|
|
19
|
-
--profile core Aggregator only (pull + consolidate + ask).
|
|
20
|
-
|
|
21
|
-
|
|
22
|
-
--profile standard Core + State/ rollup (Kushi's default opinion). [DEFAULT]
|
|
23
|
-
--profile full Standard + report packs (FDE weekly/customer/handoff).
|
|
27
|
+
--profile core Aggregator only (pull + consolidate + ask).
|
|
28
|
+
--profile standard Core + State/ rollup. [DEFAULT]
|
|
29
|
+
--profile full Standard + report packs.
|
|
24
30
|
|
|
25
31
|
Options:
|
|
26
32
|
--force Overwrite existing destination without asking
|
|
27
|
-
--yes, -y Skip the project-root check
|
|
28
|
-
|
|
29
|
-
--no-
|
|
30
|
-
--no-instructions Skip .github/copilot-instructions.md merge (vscode target only)
|
|
33
|
+
--yes, -y Skip the project-root check
|
|
34
|
+
--no-settings Skip .vscode/settings.json update (vscode workspace target only)
|
|
35
|
+
--no-instructions Skip .github/copilot-instructions.md merge (vscode workspace target only)
|
|
31
36
|
|
|
32
37
|
WorkIQ (REQUIRED — Kushi cannot pull evidence without it):
|
|
33
38
|
--with-workiq Auto-install WorkIQ via winget (Windows) / brew (macOS)
|
|
34
39
|
--workiq-path <abs> Use this explicit path to the workiq binary
|
|
35
|
-
--skip-workiq-check Bypass the WorkIQ pre-flight check
|
|
36
|
-
bootstrap/refresh will block until WorkIQ is installed)
|
|
40
|
+
--skip-workiq-check Bypass the WorkIQ pre-flight check
|
|
37
41
|
|
|
38
42
|
--help, -h Show this help
|
|
39
43
|
|
|
40
44
|
After install, talk to Kushi:
|
|
41
|
-
|
|
42
|
-
|
|
43
|
-
|
|
44
|
-
|
|
45
|
-
|
|
46
|
-
|
|
47
|
-
ask <project> <q> Cited Q&A over Evidence/ (auto-routes) — all profiles
|
|
45
|
+
bootstrap <project> First-time setup
|
|
46
|
+
refresh <project> Incremental refresh + rebuild State/
|
|
47
|
+
state <project> Re-render State/ from existing Evidence
|
|
48
|
+
consolidate <project> Merge per-user evidence
|
|
49
|
+
status <project> Show run-log
|
|
50
|
+
ask <project> <q> Cited Q&A over Evidence/ (auto-routes)
|
|
48
51
|
|
|
49
52
|
In VS Code Chat the prefix is "@Kushi". In Clawpilot just say "kushi <verb>".
|
|
50
53
|
`);
|
|
51
54
|
process.exit(0);
|
|
52
55
|
}
|
|
53
56
|
|
|
54
|
-
|
|
55
|
-
|
|
56
|
-
|
|
57
|
-
|
|
57
|
+
// ── multi-host mode (v5.0.2+) ───────────────────────────────────────────────
|
|
58
|
+
// Trigger when the user passes any of: --vscode, --all-hosts, --uninstall.
|
|
59
|
+
// --clawpilot ALONE continues to route through the legacy main.mjs path so
|
|
60
|
+
// the existing target=clawpilot flow stays byte-identical.
|
|
61
|
+
const wantsVscode = args.includes('--vscode');
|
|
62
|
+
const wantsAllHosts = args.includes('--all-hosts');
|
|
63
|
+
const wantsUninstall = args.includes('--uninstall');
|
|
64
|
+
|
|
65
|
+
if (wantsVscode || wantsAllHosts || wantsUninstall) {
|
|
66
|
+
const hosts = [];
|
|
67
|
+
if (args.includes('--clawpilot')) hosts.push('clawpilot');
|
|
68
|
+
if (wantsVscode) hosts.push('vscode');
|
|
69
|
+
const all = wantsAllHosts || args.includes('--all');
|
|
70
|
+
|
|
71
|
+
runMultiHost({
|
|
72
|
+
hosts,
|
|
73
|
+
all,
|
|
74
|
+
uninstall: wantsUninstall,
|
|
75
|
+
profile: getFlag('--profile'),
|
|
76
|
+
}).catch((err) => {
|
|
77
|
+
console.error(`\n ${err.message}\n`);
|
|
58
78
|
process.exit(1);
|
|
79
|
+
});
|
|
80
|
+
} else {
|
|
81
|
+
let target = getFlag('--target');
|
|
82
|
+
if (args.includes('--clawpilot')) {
|
|
83
|
+
if (target && target !== 'clawpilot') {
|
|
84
|
+
console.error(`\n Conflicting flags: --target ${target} and --clawpilot.\n`);
|
|
85
|
+
process.exit(1);
|
|
86
|
+
}
|
|
87
|
+
target = 'clawpilot';
|
|
59
88
|
}
|
|
60
|
-
target = 'clawpilot';
|
|
61
|
-
}
|
|
62
89
|
|
|
63
|
-
const options = {
|
|
64
|
-
|
|
65
|
-
|
|
66
|
-
|
|
67
|
-
|
|
68
|
-
|
|
69
|
-
|
|
70
|
-
|
|
71
|
-
|
|
72
|
-
|
|
73
|
-
};
|
|
74
|
-
|
|
75
|
-
main(options).catch((err) => {
|
|
76
|
-
|
|
77
|
-
|
|
78
|
-
});
|
|
90
|
+
const options = {
|
|
91
|
+
force: args.includes('--force'),
|
|
92
|
+
yes: args.includes('--yes') || args.includes('-y'),
|
|
93
|
+
noSettings: args.includes('--no-settings'),
|
|
94
|
+
noInstructions: args.includes('--no-instructions'),
|
|
95
|
+
target,
|
|
96
|
+
profile: getFlag('--profile'),
|
|
97
|
+
withWorkiq: args.includes('--with-workiq'),
|
|
98
|
+
workiqPath: getFlag('--workiq-path'),
|
|
99
|
+
skipWorkiqCheck: args.includes('--skip-workiq-check'),
|
|
100
|
+
};
|
|
101
|
+
|
|
102
|
+
main(options).catch((err) => {
|
|
103
|
+
console.error(`\n ${err.message}\n`);
|
|
104
|
+
process.exit(1);
|
|
105
|
+
});
|
|
106
|
+
}
|
|
79
107
|
|
|
80
108
|
function getFlag(flag) {
|
|
81
109
|
const idx = args.indexOf(flag);
|
|
@@ -85,4 +113,4 @@ function getFlag(flag) {
|
|
|
85
113
|
const prefix = flag + '=';
|
|
86
114
|
const match = args.find((a) => a.startsWith(prefix));
|
|
87
115
|
return match ? match.slice(prefix.length) : undefined;
|
|
88
|
-
}
|
|
116
|
+
}
|
package/package.json
CHANGED
|
@@ -1,52 +1,52 @@
|
|
|
1
|
-
{
|
|
2
|
-
"name": "kushi-agents",
|
|
3
|
-
"version": "5.0.
|
|
4
|
-
"description": "Install Kushi — multi-source project evidence agent with Comprehensive Structured Capture (CSC) into weekly-only files across Email, Teams, OneNote, Loop, SharePoint, Meetings, CRM, ADO. Meetings retain a sibling verbatim/ audit folder. WorkIQ-only for M365 sources (Graph / m365_* FORBIDDEN as fallbacks; user-paste is first-class). Host-agnostic.",
|
|
5
|
-
"type": "module",
|
|
6
|
-
"bin": {
|
|
7
|
-
"kushi-agents": "./bin/cli.mjs"
|
|
8
|
-
},
|
|
9
|
-
"files": [
|
|
10
|
-
"bin/",
|
|
11
|
-
"src/",
|
|
12
|
-
"plugin/",
|
|
13
|
-
".github/copilot-instructions.kushi.md"
|
|
14
|
-
],
|
|
15
|
-
"engines": {
|
|
16
|
-
"node": ">=18.0.0"
|
|
17
|
-
},
|
|
18
|
-
"dependencies": {
|
|
19
|
-
"@mozilla/readability": "^0.6.0",
|
|
20
|
-
"jsdom": "^29.1.1",
|
|
21
|
-
"jsonc-parser": "^3.3.1"
|
|
22
|
-
},
|
|
23
|
-
"keywords": [
|
|
24
|
-
"vscode",
|
|
25
|
-
"copilot",
|
|
26
|
-
"agents",
|
|
27
|
-
"kushi",
|
|
28
|
-
"project-evidence",
|
|
29
|
-
"workiq",
|
|
30
|
-
"m365",
|
|
31
|
-
"ai",
|
|
32
|
-
"cli"
|
|
33
|
-
],
|
|
34
|
-
"repository": {
|
|
35
|
-
"type": "git",
|
|
36
|
-
"url": "git+https://github.com/gim-home/kushi.git"
|
|
37
|
-
},
|
|
38
|
-
"homepage": "https://gim-home.github.io/kushi/",
|
|
39
|
-
"bugs": {
|
|
40
|
-
"url": "https://github.com/gim-home/kushi/issues"
|
|
41
|
-
},
|
|
42
|
-
"license": "MIT",
|
|
43
|
-
"scripts": {
|
|
44
|
-
"test": "node --test src/check-workiq.test.mjs src/seed-config.test.mjs src/sanitize-workiq-input.test.mjs src/detect-vertex-repo.test.mjs src/vertex-validate.test.mjs src/emit-vertex.e2e.test.mjs src/config-root-resolve.test.mjs src/forbidden-workiq-phrasings.test.mjs",
|
|
45
|
-
"test:integration:bootstrap": "node src/bootstrap-dryrun.integration.test.mjs",
|
|
46
|
-
"smoke": "node scripts/smoke.mjs",
|
|
47
|
-
"prepublishOnly": "npm test && npm run smoke"
|
|
48
|
-
},
|
|
49
|
-
"publishConfig": {
|
|
50
|
-
"access": "public"
|
|
51
|
-
}
|
|
1
|
+
{
|
|
2
|
+
"name": "kushi-agents",
|
|
3
|
+
"version": "5.0.2",
|
|
4
|
+
"description": "Install Kushi — multi-source project evidence agent with Comprehensive Structured Capture (CSC) into weekly-only files across Email, Teams, OneNote, Loop, SharePoint, Meetings, CRM, ADO. Meetings retain a sibling verbatim/ audit folder. WorkIQ-only for M365 sources (Graph / m365_* FORBIDDEN as fallbacks; user-paste is first-class). Host-agnostic.",
|
|
5
|
+
"type": "module",
|
|
6
|
+
"bin": {
|
|
7
|
+
"kushi-agents": "./bin/cli.mjs"
|
|
8
|
+
},
|
|
9
|
+
"files": [
|
|
10
|
+
"bin/",
|
|
11
|
+
"src/",
|
|
12
|
+
"plugin/",
|
|
13
|
+
".github/copilot-instructions.kushi.md"
|
|
14
|
+
],
|
|
15
|
+
"engines": {
|
|
16
|
+
"node": ">=18.0.0"
|
|
17
|
+
},
|
|
18
|
+
"dependencies": {
|
|
19
|
+
"@mozilla/readability": "^0.6.0",
|
|
20
|
+
"jsdom": "^29.1.1",
|
|
21
|
+
"jsonc-parser": "^3.3.1"
|
|
22
|
+
},
|
|
23
|
+
"keywords": [
|
|
24
|
+
"vscode",
|
|
25
|
+
"copilot",
|
|
26
|
+
"agents",
|
|
27
|
+
"kushi",
|
|
28
|
+
"project-evidence",
|
|
29
|
+
"workiq",
|
|
30
|
+
"m365",
|
|
31
|
+
"ai",
|
|
32
|
+
"cli"
|
|
33
|
+
],
|
|
34
|
+
"repository": {
|
|
35
|
+
"type": "git",
|
|
36
|
+
"url": "git+https://github.com/gim-home/kushi.git"
|
|
37
|
+
},
|
|
38
|
+
"homepage": "https://gim-home.github.io/kushi/",
|
|
39
|
+
"bugs": {
|
|
40
|
+
"url": "https://github.com/gim-home/kushi/issues"
|
|
41
|
+
},
|
|
42
|
+
"license": "MIT",
|
|
43
|
+
"scripts": {
|
|
44
|
+
"test": "node --test src/check-workiq.test.mjs src/seed-config.test.mjs src/sanitize-workiq-input.test.mjs src/detect-vertex-repo.test.mjs src/vertex-validate.test.mjs src/emit-vertex.e2e.test.mjs src/config-root-resolve.test.mjs src/forbidden-workiq-phrasings.test.mjs src/multi-host-install.test.mjs",
|
|
45
|
+
"test:integration:bootstrap": "node src/bootstrap-dryrun.integration.test.mjs",
|
|
46
|
+
"smoke": "node scripts/smoke.mjs",
|
|
47
|
+
"prepublishOnly": "npm test && npm run smoke"
|
|
48
|
+
},
|
|
49
|
+
"publishConfig": {
|
|
50
|
+
"access": "public"
|
|
51
|
+
}
|
|
52
52
|
}
|
|
@@ -0,0 +1,125 @@
|
|
|
1
|
+
---
|
|
2
|
+
name: "multi-host-install"
|
|
3
|
+
version: "1.0.0"
|
|
4
|
+
description: "USE WHEN installing kushi to a user-global host (Clawpilot or VS Code Chat), uninstalling, or wondering why kushi ships to two hosts (not three or N) and how the layouts differ. DO NOT USE for workspace-local .kushi/ install — that is the legacy --target vscode flow."
|
|
5
|
+
---
|
|
6
|
+
|
|
7
|
+
# Multi-host install doctrine (v5.0.2+)
|
|
8
|
+
|
|
9
|
+
Kushi ships as a single host-agnostic skill bundle, installed into a host's
|
|
10
|
+
user-global skill folder by `npx kushi-agents`. **Exactly two hosts are
|
|
11
|
+
supported.** No third host. No N-host generality.
|
|
12
|
+
|
|
13
|
+
## The 2-host matrix
|
|
14
|
+
|
|
15
|
+
| Host ID | Display name | Skill dir | Metadata file |
|
|
16
|
+
|--------------|-----------------------------------------|------------------------------------|----------------------------------------------|
|
|
17
|
+
| `clawpilot` | Clawpilot CLI | `~/.copilot/m-skills/kushi/` | `~/.copilot/m-skills/skills-metadata.json` |
|
|
18
|
+
| `vscode` | VS Code Chat ("GitHub Copilot Chat") | `~/.vscode/chat/skills/kushi/` | `~/.vscode/chat/skills/skills-metadata.json` |
|
|
19
|
+
|
|
20
|
+
> The VS Code Chat path is the canonical user-global skill folder location
|
|
21
|
+
> assumed by this doctrine. If a future VS Code Chat release ships a
|
|
22
|
+
> different canonical path, update `VSCODE_CHAT_DEST_SUBPATH` in
|
|
23
|
+
> `src/constants.mjs` — every other surface reads from there.
|
|
24
|
+
|
|
25
|
+
## Why only these two
|
|
26
|
+
|
|
27
|
+
- **Clawpilot** — primary scheduled / overnight surface (e.g. `kushi refresh <project>`
|
|
28
|
+
run from a Clawpilot automation at 6 AM).
|
|
29
|
+
- **VS Code Chat** — primary interactive surface (`@kushi what's the MACC for X?`
|
|
30
|
+
asked the next morning).
|
|
31
|
+
|
|
32
|
+
The same user routinely lives in both — automation in Clawpilot, follow-up
|
|
33
|
+
questions in VS Code Chat. Both hosts read the **same** Evidence/ tree on disk,
|
|
34
|
+
so a refresh in one is visible from the other immediately.
|
|
35
|
+
|
|
36
|
+
A third host (e.g. a hypothetical web UI) would force per-host SKILL surgery
|
|
37
|
+
or a content-branching shim. The 2-host matrix is the deliberate stopping
|
|
38
|
+
point — it covers ≥ 99 % of the actual usage pattern and keeps cross-host
|
|
39
|
+
parity trivially enforceable.
|
|
40
|
+
|
|
41
|
+
## Per-host layout
|
|
42
|
+
|
|
43
|
+
Both hosts get **byte-identical** content under their skill dir:
|
|
44
|
+
|
|
45
|
+
```text
|
|
46
|
+
<host-skill-dir>/
|
|
47
|
+
├── SKILL.md (mirrored from agents/kushi.agent.md)
|
|
48
|
+
├── agents/kushi.agent.md
|
|
49
|
+
├── instructions/<name>.instructions.md
|
|
50
|
+
├── prompts/<name>.prompt.md
|
|
51
|
+
├── skills/<name>/SKILL.md (+ references/, run.ps1, etc.)
|
|
52
|
+
├── templates/<...>
|
|
53
|
+
├── lib/<...>
|
|
54
|
+
├── reference-packs/<...>
|
|
55
|
+
├── config/
|
|
56
|
+
│ ├── shared/ (team-owned, safe to commit)
|
|
57
|
+
│ └── user/ (per-contributor, gitignored)
|
|
58
|
+
└── kushi-install.json (profile manifest)
|
|
59
|
+
```
|
|
60
|
+
|
|
61
|
+
And at the **parent** dir, `skills-metadata.json` carries the host's skill
|
|
62
|
+
registry. The installer upserts a single `{"name": "kushi", ...}` entry that
|
|
63
|
+
points `instructions` at the host's own `SKILL.md` (absolute path).
|
|
64
|
+
|
|
65
|
+
There is **no per-host content branching**. A SKILL.md that opens with `WHEN
|
|
66
|
+
running on Clawpilot, do X; WHEN running on VS Code Chat, do Y` is a defect.
|
|
67
|
+
|
|
68
|
+
## Detection rules
|
|
69
|
+
|
|
70
|
+
A host is **detected** when its parent dir exists on the file system:
|
|
71
|
+
|
|
72
|
+
| Host | Detection probe |
|
|
73
|
+
|------------|---------------------------------------|
|
|
74
|
+
| clawpilot | `~/.copilot/m-skills/` exists |
|
|
75
|
+
| vscode | `~/.vscode/chat/skills/` exists |
|
|
76
|
+
|
|
77
|
+
`bin/cli.mjs` runs detection before any install/uninstall and prints
|
|
78
|
+
`Detected hosts: …` so the user sees which targets will be touched by the
|
|
79
|
+
default behavior.
|
|
80
|
+
|
|
81
|
+
## Install / uninstall flags
|
|
82
|
+
|
|
83
|
+
| Flag | Effect |
|
|
84
|
+
|--------------------------------------|--------|
|
|
85
|
+
| `--clawpilot` | Install to Clawpilot host (alone: also legacy `--target clawpilot` path) |
|
|
86
|
+
| `--vscode` | Install to VS Code Chat host |
|
|
87
|
+
| `--all-hosts` | Install to BOTH hosts (regardless of detection) |
|
|
88
|
+
| `--uninstall` | Uninstall from all *detected* hosts |
|
|
89
|
+
| `--uninstall --clawpilot` | Uninstall from Clawpilot only |
|
|
90
|
+
| `--uninstall --vscode` | Uninstall from VS Code Chat only |
|
|
91
|
+
| `--uninstall --all` | Uninstall from BOTH hosts |
|
|
92
|
+
| (no flag) | Workspace install (legacy `.kushi/` in cwd) |
|
|
93
|
+
|
|
94
|
+
## `kushi refresh <project>` is host-agnostic
|
|
95
|
+
|
|
96
|
+
`refresh` is just a SKILL — the same `plugin/skills/refresh-project/SKILL.md`
|
|
97
|
+
content runs under both hosts. It writes Evidence to disk at the user-
|
|
98
|
+
configured engagement root (`~/Documents/Engagements/<project>/` by default),
|
|
99
|
+
which lives **outside** the host skill dir. That is why a Clawpilot-driven
|
|
100
|
+
refresh is immediately visible to a VS Code Chat `ask` — neither host owns
|
|
101
|
+
the data.
|
|
102
|
+
|
|
103
|
+
## Cross-host parity rule
|
|
104
|
+
|
|
105
|
+
Every SKILL.md, prompt, instruction, template, and lib asset MUST work
|
|
106
|
+
identically under both hosts. Concretely:
|
|
107
|
+
|
|
108
|
+
- No `if (HOST === 'clawpilot') ...` style branching in any markdown body.
|
|
109
|
+
- No host-specific paths in skill content (always use `<engagement-root>` /
|
|
110
|
+
`<project>` placeholders that resolve at runtime via the standard
|
|
111
|
+
`engagement-root-resolution.instructions.md` chain).
|
|
112
|
+
- The two `SKILL.md` files (one per host) are required to be **byte-identical**
|
|
113
|
+
— enforced by `self-check` `D32.multi-host` in deep mode (temp-dir dry-run).
|
|
114
|
+
|
|
115
|
+
If a future feature genuinely requires host-specific behavior (e.g. UI affordance
|
|
116
|
+
only one host provides), it belongs in a host-specific *helper* outside `plugin/`
|
|
117
|
+
— not inside the shared skill bundle.
|
|
118
|
+
|
|
119
|
+
## See also
|
|
120
|
+
|
|
121
|
+
- `host-portability.instructions.md` — older doctrine on per-host portability of
|
|
122
|
+
individual skill calls (still in force for runtime tool selection).
|
|
123
|
+
- `D32.multi-host` self-check (deep) — validates installer + temp-dir layout.
|
|
124
|
+
- `src/multi-host.mjs` — the installer/uninstaller implementation.
|
|
125
|
+
- `src/multi-host-install.test.mjs` — node:test coverage.
|
|
@@ -68,6 +68,7 @@ Checks split into **core** (always run) and **deep** (opt-in).
|
|
|
68
68
|
| D30.validation-loop | Writer validation loop | every writer skill (writes to `Evidence/`, `State/`, `_graph/`, `dashboards/`, `tours/`) ends with a `## Validation loop` section. |
|
|
69
69
|
| D30.description-optimized | Trigger-based description | every SKILL.md `description:` front-matter leads with `USE WHEN` or `WHEN ` per <https://agentskills.io/skill-creation/optimizing-descriptions>. |
|
|
70
70
|
| D31.genealogy | Release genealogy entry exists | every `git tag` matching `v<x.y.z>` MUST appear in `docs/genealogy.md` as a `## v<x.y.z>` heading or be named under a parent's "Patch lineage" line. See `release-genealogy.instructions.md`. |
|
|
71
|
+
| D32.multi-host | Multi-host install integrity | validates `src/multi-host.mjs` exports + `bin/cli.mjs` flag handling, then performs a temp-dir dry-run install for BOTH supported hosts (Clawpilot + VS Code Chat) under a fake `$HOME` in `$env:TEMP`. Asserts SKILL.md + agent file + skills/ + prompts/ + skills-metadata.json with a kushi entry are present, then asserts a clean uninstall. NEVER touches the real `~/.copilot/` or `~/.vscode/`. See `multi-host-install.instructions.md`. |
|
|
71
72
|
| **CSC weekly-layout checks (kushi v4.9.0)** | | gated on `Resolve-EngagementRoots` — no-ops on the kushi repo itself. |
|
|
72
73
|
| D11.csc | CSC entity coverage + depth | every `Evidence/<alias>/<source>/weekly/*-csc.md` has ≥ 1 entity heading; per-source minimum bullet count + populated-section count (meetings 25/6, email 8/4, teams 6/3, onenote 10/4, sharepoint 8/3, crm 12/5, ado 8/4). Coverage-Notes-only blocks (low-signal escape) are exempt. |
|
|
73
74
|
| D12.csc | CSC section order | every entity block's `###` section headings appear in the canonical order: Participants → Topics → Q&A → Who Said What → Decisions → Dates & Numbers → Action Items → Next Steps → Open Questions → Risks → Customer Asks → Artifacts → Coverage Notes. |
|
|
@@ -1522,6 +1522,116 @@ if ($Deep) {
|
|
|
1522
1522
|
}
|
|
1523
1523
|
}
|
|
1524
1524
|
}
|
|
1525
|
+
|
|
1526
|
+
# === D32.multi-host — installer integrity for the 2-host install matrix ===
|
|
1527
|
+
# Per multi-host-install.instructions.md, kushi supports exactly two hosts
|
|
1528
|
+
# (clawpilot, vscode-chat). This block verifies:
|
|
1529
|
+
# - the installer module + flags exist,
|
|
1530
|
+
# - a temp-dir dry-run install for each host produces the expected layout
|
|
1531
|
+
# (SKILL.md mirror, agent file, skills/, prompts/, instructions/,
|
|
1532
|
+
# skills-metadata.json with a kushi entry),
|
|
1533
|
+
# - uninstall cleanly removes both.
|
|
1534
|
+
# Uses $env:TEMP-based fake $HOME — never touches the real ~/.copilot/ or ~/.vscode/.
|
|
1535
|
+
$mhMod = Join-Path $Root 'src/multi-host.mjs'
|
|
1536
|
+
$mhDoc = Join-Path $Root 'plugin/instructions/multi-host-install.instructions.md'
|
|
1537
|
+
$cliMod = Join-Path $Root 'bin/cli.mjs'
|
|
1538
|
+
if (-not (Test-Path $mhMod)) {
|
|
1539
|
+
Add-Finding 'D32.multi-host' 'Multi-host install integrity' 'warning' 'src/multi-host.mjs is missing' 'The multi-host installer module ships in src/multi-host.mjs (v5.0.2+). Restore it from git.' $mhMod 0
|
|
1540
|
+
} elseif (-not (Test-Path $mhDoc)) {
|
|
1541
|
+
Add-Finding 'D32.multi-host' 'Multi-host install integrity' 'warning' 'plugin/instructions/multi-host-install.instructions.md is missing' 'The multi-host doctrine must ship alongside the installer.' $mhDoc 0
|
|
1542
|
+
} else {
|
|
1543
|
+
$mhText = Get-Content -Raw $mhMod
|
|
1544
|
+
$cliText = if (Test-Path $cliMod) { Get-Content -Raw $cliMod } else { '' }
|
|
1545
|
+
foreach ($exp in @('installHost', 'uninstallHost', 'detectHosts', 'resolveHostPaths', 'upsertSkillsMetadata')) {
|
|
1546
|
+
if ($mhText -notmatch "export\s+function\s+$exp\b") {
|
|
1547
|
+
Add-Finding 'D32.multi-host' 'Multi-host install integrity' 'warning' "src/multi-host.mjs missing export: $exp" "Re-export $exp from src/multi-host.mjs — bin/cli.mjs and tests depend on it." $mhMod 0
|
|
1548
|
+
}
|
|
1549
|
+
}
|
|
1550
|
+
foreach ($flag in @('--vscode', '--all-hosts', '--uninstall', '--clawpilot')) {
|
|
1551
|
+
if ($cliText -notmatch [regex]::Escape($flag)) {
|
|
1552
|
+
Add-Finding 'D32.multi-host' 'Multi-host install integrity' 'warning' "bin/cli.mjs does not handle $flag" "Add $flag parsing per multi-host-install.instructions.md." $cliMod 0
|
|
1553
|
+
}
|
|
1554
|
+
}
|
|
1555
|
+
|
|
1556
|
+
# Temp-dir dry-run: spawn `node -e` to install both hosts into a fake $HOME
|
|
1557
|
+
# under $env:TEMP, then assert the layout, then uninstall.
|
|
1558
|
+
$tmpBase = if ($env:TEMP) { $env:TEMP } else { [System.IO.Path]::GetTempPath() }
|
|
1559
|
+
$fakeHome = Join-Path $tmpBase ("kushi-d32-" + [Guid]::NewGuid().ToString('N').Substring(0, 12))
|
|
1560
|
+
try {
|
|
1561
|
+
$node = Get-Command node -ErrorAction SilentlyContinue
|
|
1562
|
+
if (-not $node) {
|
|
1563
|
+
Add-Finding 'D32.multi-host' 'Multi-host install integrity' 'warning' 'node not on PATH — skipped dry-run' 'Install Node.js (>= 18) to enable the D32 dry-run integration check.' $mhMod 0
|
|
1564
|
+
} else {
|
|
1565
|
+
$script = @"
|
|
1566
|
+
import { installHost, uninstallHost, resolveHostPaths } from 'file:///$($mhMod -replace '\\', '/')';
|
|
1567
|
+
import fs from 'node:fs';
|
|
1568
|
+
import path from 'node:path';
|
|
1569
|
+
const HOME = process.argv[2];
|
|
1570
|
+
const out = { hosts: {}, errors: [] };
|
|
1571
|
+
for (const id of ['clawpilot', 'vscode']) {
|
|
1572
|
+
try {
|
|
1573
|
+
const r = installHost(id, { home: HOME, force: true, quiet: true });
|
|
1574
|
+
const { skillDir, metadata } = resolveHostPaths(id, HOME);
|
|
1575
|
+
out.hosts[id] = {
|
|
1576
|
+
copied: r.copied,
|
|
1577
|
+
hasSkillMd: fs.existsSync(path.join(skillDir, 'SKILL.md')),
|
|
1578
|
+
hasAgent: fs.existsSync(path.join(skillDir, 'agents/kushi.agent.md')),
|
|
1579
|
+
hasSkills: fs.existsSync(path.join(skillDir, 'skills')),
|
|
1580
|
+
hasPrompts: fs.existsSync(path.join(skillDir, 'prompts')),
|
|
1581
|
+
hasMeta: fs.existsSync(metadata),
|
|
1582
|
+
metaHasKushi: fs.existsSync(metadata) && JSON.parse(fs.readFileSync(metadata, 'utf-8')).some(e => e && e.name === 'kushi'),
|
|
1583
|
+
};
|
|
1584
|
+
} catch (e) { out.errors.push(id + ': ' + e.message); }
|
|
1585
|
+
}
|
|
1586
|
+
for (const id of ['clawpilot', 'vscode']) {
|
|
1587
|
+
try {
|
|
1588
|
+
uninstallHost(id, { home: HOME, quiet: true });
|
|
1589
|
+
const { skillDir } = resolveHostPaths(id, HOME);
|
|
1590
|
+
out.hosts[id].uninstalled = !fs.existsSync(skillDir);
|
|
1591
|
+
} catch (e) { out.errors.push('uninstall ' + id + ': ' + e.message); }
|
|
1592
|
+
}
|
|
1593
|
+
process.stdout.write(JSON.stringify(out));
|
|
1594
|
+
"@
|
|
1595
|
+
$scriptPath = Join-Path $fakeHome 'd32-probe.mjs'
|
|
1596
|
+
New-Item -ItemType Directory -Force -Path $fakeHome | Out-Null
|
|
1597
|
+
Set-Content -LiteralPath $scriptPath -Value $script -Encoding UTF8
|
|
1598
|
+
$procOut = & node $scriptPath $fakeHome 2>&1
|
|
1599
|
+
$exit = $LASTEXITCODE
|
|
1600
|
+
$jsonLine = ($procOut -join "`n")
|
|
1601
|
+
if ($exit -ne 0) {
|
|
1602
|
+
Add-Finding 'D32.multi-host' 'Multi-host install integrity' 'warning' "Dry-run install failed (exit $exit): $jsonLine" 'Run the installer manually with NODE_DEBUG=kushi to capture the failure.' $mhMod 0
|
|
1603
|
+
} else {
|
|
1604
|
+
try { $parsed = $jsonLine | ConvertFrom-Json } catch { $parsed = $null }
|
|
1605
|
+
if (-not $parsed) {
|
|
1606
|
+
Add-Finding 'D32.multi-host' 'Multi-host install integrity' 'warning' "Dry-run produced no parseable JSON output" 'See the probe script output in $env:TEMP for clues.' $mhMod 0
|
|
1607
|
+
} else {
|
|
1608
|
+
foreach ($id in 'clawpilot', 'vscode') {
|
|
1609
|
+
$h = $parsed.hosts.$id
|
|
1610
|
+
if (-not $h) {
|
|
1611
|
+
Add-Finding 'D32.multi-host' 'Multi-host install integrity' 'warning' "Dry-run produced no result for host '$id'" 'Check installHost(...) for that host id.' $mhMod 0
|
|
1612
|
+
continue
|
|
1613
|
+
}
|
|
1614
|
+
foreach ($prop in 'hasSkillMd', 'hasAgent', 'hasSkills', 'hasPrompts', 'hasMeta', 'metaHasKushi') {
|
|
1615
|
+
if (-not $h.$prop) {
|
|
1616
|
+
Add-Finding 'D32.multi-host' 'Multi-host install integrity' 'warning' "Dry-run host '$id' missing layout element: $prop" 'Verify installHost copies all asset dirs and upserts skills-metadata.json.' $mhMod 0
|
|
1617
|
+
}
|
|
1618
|
+
}
|
|
1619
|
+
if (-not $h.uninstalled) {
|
|
1620
|
+
Add-Finding 'D32.multi-host' 'Multi-host install integrity' 'warning' "Dry-run host '$id' was not cleanly uninstalled" 'Verify uninstallHost removes the skill dir.' $mhMod 0
|
|
1621
|
+
}
|
|
1622
|
+
}
|
|
1623
|
+
if ($parsed.errors -and $parsed.errors.Count -gt 0) {
|
|
1624
|
+
foreach ($e in $parsed.errors) {
|
|
1625
|
+
Add-Finding 'D32.multi-host' 'Multi-host install integrity' 'warning' "Dry-run error: $e" 'See multi-host.mjs for the failing code path.' $mhMod 0
|
|
1626
|
+
}
|
|
1627
|
+
}
|
|
1628
|
+
}
|
|
1629
|
+
}
|
|
1630
|
+
}
|
|
1631
|
+
} finally {
|
|
1632
|
+
if (Test-Path $fakeHome) { Remove-Item -LiteralPath $fakeHome -Recurse -Force -ErrorAction SilentlyContinue }
|
|
1633
|
+
}
|
|
1634
|
+
}
|
|
1525
1635
|
}
|
|
1526
1636
|
|
|
1527
1637
|
# === Output ===
|
|
@@ -1571,4 +1681,4 @@ if ($Json) {
|
|
|
1571
1681
|
}
|
|
1572
1682
|
|
|
1573
1683
|
if ($StrictExit -and $findings.Count -gt 0) { exit 1 }
|
|
1574
|
-
exit 0
|
|
1684
|
+
exit 0
|
package/src/constants.mjs
CHANGED
|
@@ -1,13 +1,51 @@
|
|
|
1
1
|
/** Default destination path relative to the user's project root (vscode target). */
|
|
2
2
|
export const DEFAULT_DEST = '.kushi';
|
|
3
3
|
|
|
4
|
-
/** Default destination for the `clawpilot` install target (absolute). */
|
|
4
|
+
/** Default destination for the `clawpilot` install target (absolute, relative to $HOME). */
|
|
5
5
|
export const CLAWPILOT_DEST_SUBPATH = '.copilot/m-skills/kushi';
|
|
6
6
|
|
|
7
|
+
/** Default destination for the `vscode-chat` install target (absolute, relative to $HOME). */
|
|
8
|
+
export const VSCODE_CHAT_DEST_SUBPATH = '.vscode/chat/skills/kushi';
|
|
9
|
+
|
|
10
|
+
/** Parent skill directories for each host (where skills-metadata.json lives). */
|
|
11
|
+
export const CLAWPILOT_PARENT_SUBPATH = '.copilot/m-skills';
|
|
12
|
+
export const VSCODE_CHAT_PARENT_SUBPATH = '.vscode/chat/skills';
|
|
13
|
+
|
|
14
|
+
/** Skill name used in skills-metadata.json. */
|
|
15
|
+
export const SKILL_NAME = 'kushi';
|
|
16
|
+
|
|
17
|
+
/** Filename of the host skill registry. */
|
|
18
|
+
export const SKILLS_METADATA_FILE = 'skills-metadata.json';
|
|
19
|
+
|
|
7
20
|
/** Install target identifiers. */
|
|
8
21
|
export const TARGET_VSCODE = 'vscode';
|
|
9
22
|
export const TARGET_CLAWPILOT = 'clawpilot';
|
|
10
23
|
|
|
24
|
+
/**
|
|
25
|
+
* Supported host targets for the multi-host installer (v5.0.2+).
|
|
26
|
+
*
|
|
27
|
+
* Only two hosts are supported by design — Clawpilot (CLI / scheduled) and
|
|
28
|
+
* VS Code Chat (a.k.a. "GitHub Copilot Chat", interactive). See
|
|
29
|
+
* `plugin/instructions/multi-host-install.instructions.md` for the rationale.
|
|
30
|
+
*
|
|
31
|
+
* Per-host layout is identical (kushi/SKILL.md + asset dirs + config/) so
|
|
32
|
+
* SKILLs themselves can stay host-agnostic.
|
|
33
|
+
*/
|
|
34
|
+
export const HOSTS = [
|
|
35
|
+
{
|
|
36
|
+
id: 'clawpilot',
|
|
37
|
+
label: 'Clawpilot',
|
|
38
|
+
parent: CLAWPILOT_PARENT_SUBPATH,
|
|
39
|
+
skillDir: CLAWPILOT_DEST_SUBPATH,
|
|
40
|
+
},
|
|
41
|
+
{
|
|
42
|
+
id: 'vscode',
|
|
43
|
+
label: 'VS Code Chat',
|
|
44
|
+
parent: VSCODE_CHAT_PARENT_SUBPATH,
|
|
45
|
+
skillDir: VSCODE_CHAT_DEST_SUBPATH,
|
|
46
|
+
},
|
|
47
|
+
];
|
|
48
|
+
|
|
11
49
|
/**
|
|
12
50
|
* In Clawpilot mode, the orchestrator agent file is duplicated as the
|
|
13
51
|
* top-level SKILL.md so Clawpilot's skill discovery (which expects
|
|
@@ -0,0 +1,170 @@
|
|
|
1
|
+
// Integration test: multi-host install (v5.0.2+).
|
|
2
|
+
//
|
|
3
|
+
// Installs Kushi into a fake $HOME using temp dirs that mimic both Clawpilot
|
|
4
|
+
// and VS Code Chat host layouts. Verifies the resulting on-disk layout,
|
|
5
|
+
// the skills-metadata.json upsert, and a clean uninstall.
|
|
6
|
+
//
|
|
7
|
+
// Uses `node:test` so it runs under `npm test`. Does NOT touch the real
|
|
8
|
+
// ~/.copilot/ or ~/.vscode/ directories.
|
|
9
|
+
|
|
10
|
+
import test from 'node:test';
|
|
11
|
+
import assert from 'node:assert/strict';
|
|
12
|
+
import fs from 'node:fs';
|
|
13
|
+
import os from 'node:os';
|
|
14
|
+
import path from 'node:path';
|
|
15
|
+
import {
|
|
16
|
+
installHost,
|
|
17
|
+
uninstallHost,
|
|
18
|
+
resolveHostPaths,
|
|
19
|
+
detectHosts,
|
|
20
|
+
upsertSkillsMetadata,
|
|
21
|
+
removeSkillFromMetadata,
|
|
22
|
+
} from './multi-host.mjs';
|
|
23
|
+
import { HOSTS } from './constants.mjs';
|
|
24
|
+
|
|
25
|
+
function makeTempHome(label) {
|
|
26
|
+
return fs.mkdtempSync(path.join(os.tmpdir(), `kushi-mh-${label}-`));
|
|
27
|
+
}
|
|
28
|
+
|
|
29
|
+
test('HOSTS list is exactly clawpilot + vscode (no scope creep)', () => {
|
|
30
|
+
assert.equal(HOSTS.length, 2);
|
|
31
|
+
assert.deepEqual(
|
|
32
|
+
HOSTS.map((h) => h.id).sort(),
|
|
33
|
+
['clawpilot', 'vscode'],
|
|
34
|
+
);
|
|
35
|
+
});
|
|
36
|
+
|
|
37
|
+
test('detectHosts returns empty for a fresh empty home', () => {
|
|
38
|
+
const home = makeTempHome('detect-empty');
|
|
39
|
+
try {
|
|
40
|
+
const detected = detectHosts(home);
|
|
41
|
+
assert.equal(detected.length, 0);
|
|
42
|
+
} finally {
|
|
43
|
+
fs.rmSync(home, { recursive: true, force: true });
|
|
44
|
+
}
|
|
45
|
+
});
|
|
46
|
+
|
|
47
|
+
test('detectHosts finds clawpilot when its parent dir exists', () => {
|
|
48
|
+
const home = makeTempHome('detect-claw');
|
|
49
|
+
try {
|
|
50
|
+
const claw = HOSTS.find((h) => h.id === 'clawpilot');
|
|
51
|
+
fs.mkdirSync(path.join(home, ...claw.parent.split('/')), { recursive: true });
|
|
52
|
+
const detected = detectHosts(home);
|
|
53
|
+
assert.equal(detected.length, 1);
|
|
54
|
+
assert.equal(detected[0].id, 'clawpilot');
|
|
55
|
+
} finally {
|
|
56
|
+
fs.rmSync(home, { recursive: true, force: true });
|
|
57
|
+
}
|
|
58
|
+
});
|
|
59
|
+
|
|
60
|
+
for (const hostId of ['clawpilot', 'vscode']) {
|
|
61
|
+
test(`installHost('${hostId}') lays down SKILL.md + assets + skills-metadata.json`, () => {
|
|
62
|
+
const home = makeTempHome(hostId);
|
|
63
|
+
try {
|
|
64
|
+
const { skillDir, metadata, parent } = resolveHostPaths(hostId, home);
|
|
65
|
+
const result = installHost(hostId, { home, force: true, quiet: true });
|
|
66
|
+
|
|
67
|
+
assert.equal(result.host, hostId);
|
|
68
|
+
assert.ok(result.copied > 0, 'copied at least one file');
|
|
69
|
+
|
|
70
|
+
// Layout checks
|
|
71
|
+
assert.ok(fs.existsSync(path.join(skillDir, 'SKILL.md')), 'top-level SKILL.md exists');
|
|
72
|
+
assert.ok(fs.existsSync(path.join(skillDir, 'agents', 'kushi.agent.md')), 'agent file copied');
|
|
73
|
+
assert.ok(fs.existsSync(path.join(skillDir, 'skills')), 'skills/ dir exists');
|
|
74
|
+
assert.ok(fs.existsSync(path.join(skillDir, 'prompts')), 'prompts/ dir exists');
|
|
75
|
+
assert.ok(fs.existsSync(path.join(skillDir, 'instructions')), 'instructions/ dir exists');
|
|
76
|
+
assert.ok(fs.existsSync(path.join(skillDir, 'kushi-install.json')), 'profile manifest written');
|
|
77
|
+
|
|
78
|
+
// SKILL.md should be byte-identical to agents/kushi.agent.md (mirror invariant)
|
|
79
|
+
const skillBytes = fs.readFileSync(path.join(skillDir, 'SKILL.md'));
|
|
80
|
+
const agentBytes = fs.readFileSync(path.join(skillDir, 'agents', 'kushi.agent.md'));
|
|
81
|
+
assert.deepEqual(skillBytes, agentBytes, 'SKILL.md mirrors agent file byte-for-byte');
|
|
82
|
+
|
|
83
|
+
// skills-metadata.json checks
|
|
84
|
+
assert.ok(fs.existsSync(metadata), 'skills-metadata.json created');
|
|
85
|
+
const entries = JSON.parse(fs.readFileSync(metadata, 'utf-8'));
|
|
86
|
+
assert.ok(Array.isArray(entries), 'metadata is an array');
|
|
87
|
+
const kushi = entries.find((e) => e.name === 'kushi');
|
|
88
|
+
assert.ok(kushi, 'kushi entry present');
|
|
89
|
+
assert.ok(kushi.id.startsWith('kushi-'), 'id has version suffix');
|
|
90
|
+
assert.equal(kushi.enabled, true);
|
|
91
|
+
assert.ok(kushi.instructions.includes(path.join(skillDir, 'SKILL.md')), 'instructions point at this host\'s SKILL.md');
|
|
92
|
+
|
|
93
|
+
// Parent dir is under correct path for this host
|
|
94
|
+
const expectedParent = path.join(home, ...HOSTS.find((h) => h.id === hostId).parent.split('/'));
|
|
95
|
+
assert.equal(parent, expectedParent);
|
|
96
|
+
} finally {
|
|
97
|
+
fs.rmSync(home, { recursive: true, force: true });
|
|
98
|
+
}
|
|
99
|
+
});
|
|
100
|
+
|
|
101
|
+
test(`uninstallHost('${hostId}') removes the skill dir + metadata entry`, () => {
|
|
102
|
+
const home = makeTempHome(`un-${hostId}`);
|
|
103
|
+
try {
|
|
104
|
+
installHost(hostId, { home, force: true, quiet: true });
|
|
105
|
+
const { skillDir, metadata } = resolveHostPaths(hostId, home);
|
|
106
|
+
assert.ok(fs.existsSync(skillDir));
|
|
107
|
+
assert.ok(fs.existsSync(metadata));
|
|
108
|
+
|
|
109
|
+
const res = uninstallHost(hostId, { home, quiet: true });
|
|
110
|
+
assert.equal(res.skillDirRemoved, true);
|
|
111
|
+
assert.equal(res.metadataAction, 'removed');
|
|
112
|
+
assert.ok(!fs.existsSync(skillDir), 'skill dir gone');
|
|
113
|
+
|
|
114
|
+
// metadata file still exists but kushi entry is removed
|
|
115
|
+
const entries = JSON.parse(fs.readFileSync(metadata, 'utf-8'));
|
|
116
|
+
assert.ok(!entries.some((e) => e.name === 'kushi'), 'kushi entry removed');
|
|
117
|
+
} finally {
|
|
118
|
+
fs.rmSync(home, { recursive: true, force: true });
|
|
119
|
+
}
|
|
120
|
+
});
|
|
121
|
+
}
|
|
122
|
+
|
|
123
|
+
test('installHost is idempotent (re-install does not duplicate metadata)', () => {
|
|
124
|
+
const home = makeTempHome('idem');
|
|
125
|
+
try {
|
|
126
|
+
installHost('clawpilot', { home, force: true, quiet: true });
|
|
127
|
+
installHost('clawpilot', { home, force: true, quiet: true });
|
|
128
|
+
const { metadata } = resolveHostPaths('clawpilot', home);
|
|
129
|
+
const entries = JSON.parse(fs.readFileSync(metadata, 'utf-8'));
|
|
130
|
+
const kushiEntries = entries.filter((e) => e.name === 'kushi');
|
|
131
|
+
assert.equal(kushiEntries.length, 1, 'exactly one kushi entry after re-install');
|
|
132
|
+
} finally {
|
|
133
|
+
fs.rmSync(home, { recursive: true, force: true });
|
|
134
|
+
}
|
|
135
|
+
});
|
|
136
|
+
|
|
137
|
+
test('all-hosts install lands in BOTH host dirs simultaneously', () => {
|
|
138
|
+
const home = makeTempHome('all');
|
|
139
|
+
try {
|
|
140
|
+
installHost('clawpilot', { home, force: true, quiet: true });
|
|
141
|
+
installHost('vscode', { home, force: true, quiet: true });
|
|
142
|
+
const clawPaths = resolveHostPaths('clawpilot', home);
|
|
143
|
+
const vscPaths = resolveHostPaths('vscode', home);
|
|
144
|
+
assert.ok(fs.existsSync(path.join(clawPaths.skillDir, 'SKILL.md')));
|
|
145
|
+
assert.ok(fs.existsSync(path.join(vscPaths.skillDir, 'SKILL.md')));
|
|
146
|
+
// The two SKILL.md files should be byte-identical (cross-host parity).
|
|
147
|
+
const a = fs.readFileSync(path.join(clawPaths.skillDir, 'SKILL.md'));
|
|
148
|
+
const b = fs.readFileSync(path.join(vscPaths.skillDir, 'SKILL.md'));
|
|
149
|
+
assert.deepEqual(a, b, 'cross-host parity: SKILL.md bytes match');
|
|
150
|
+
} finally {
|
|
151
|
+
fs.rmSync(home, { recursive: true, force: true });
|
|
152
|
+
}
|
|
153
|
+
});
|
|
154
|
+
|
|
155
|
+
test('upsertSkillsMetadata + removeSkillFromMetadata round-trip', () => {
|
|
156
|
+
const home = makeTempHome('meta');
|
|
157
|
+
try {
|
|
158
|
+
const metaPath = path.join(home, 'skills-metadata.json');
|
|
159
|
+
const skillDir = path.join(home, 'kushi');
|
|
160
|
+
fs.mkdirSync(skillDir, { recursive: true });
|
|
161
|
+
|
|
162
|
+
assert.equal(upsertSkillsMetadata(metaPath, skillDir, '5.0.2', 'Test desc.'), 'created');
|
|
163
|
+
assert.equal(upsertSkillsMetadata(metaPath, skillDir, '5.0.2', 'Test desc.'), 'unchanged');
|
|
164
|
+
assert.equal(upsertSkillsMetadata(metaPath, skillDir, '5.0.3', 'Newer.'), 'updated');
|
|
165
|
+
assert.equal(removeSkillFromMetadata(metaPath), 'removed');
|
|
166
|
+
assert.equal(removeSkillFromMetadata(metaPath), 'absent');
|
|
167
|
+
} finally {
|
|
168
|
+
fs.rmSync(home, { recursive: true, force: true });
|
|
169
|
+
}
|
|
170
|
+
});
|
|
@@ -0,0 +1,277 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Multi-host installer (v5.0.2+).
|
|
3
|
+
*
|
|
4
|
+
* Kushi ships as a single host-agnostic skill bundle. Two hosts are supported:
|
|
5
|
+
*
|
|
6
|
+
* 1. Clawpilot CLI — ~/.copilot/m-skills/kushi/
|
|
7
|
+
* 2. VS Code Chat — ~/.vscode/chat/skills/kushi/
|
|
8
|
+
* (also known as "GitHub Copilot Chat" in user terminology)
|
|
9
|
+
*
|
|
10
|
+
* The per-host layout is identical: top-level SKILL.md (mirrored from
|
|
11
|
+
* agents/kushi.agent.md), asset dirs (skills/, prompts/, instructions/,
|
|
12
|
+
* templates/, lib/, reference-packs/), config/, and a parent-level
|
|
13
|
+
* skills-metadata.json entry pointing at SKILL.md.
|
|
14
|
+
*
|
|
15
|
+
* SKILL content is host-agnostic — there is no per-host branching in any
|
|
16
|
+
* SKILL.md, prompt, template, or lib file. Cross-host parity is enforced by
|
|
17
|
+
* `multi-host-install.instructions.md` and self-check `D32.multi-host`.
|
|
18
|
+
*
|
|
19
|
+
* This module is invoked from `bin/cli.mjs` when the user passes
|
|
20
|
+
* `--vscode`, `--all-hosts`, or `--uninstall`. The legacy `--clawpilot`
|
|
21
|
+
* shortcut continues to flow through `main.mjs::installClawpilot` for full
|
|
22
|
+
* backward compatibility.
|
|
23
|
+
*/
|
|
24
|
+
|
|
25
|
+
import fs from 'node:fs';
|
|
26
|
+
import os from 'node:os';
|
|
27
|
+
import path from 'node:path';
|
|
28
|
+
import { fileURLToPath } from 'node:url';
|
|
29
|
+
import {
|
|
30
|
+
HOSTS,
|
|
31
|
+
SKILL_NAME,
|
|
32
|
+
SKILLS_METADATA_FILE,
|
|
33
|
+
PLUGIN_SOURCE_DIR,
|
|
34
|
+
CLAWPILOT_AGENT_SOURCE,
|
|
35
|
+
CLAWPILOT_SKILL_DEST,
|
|
36
|
+
} from './constants.mjs';
|
|
37
|
+
import { copyAssets } from './copy-assets.mjs';
|
|
38
|
+
import { seedConfig } from './seed-config.mjs';
|
|
39
|
+
import {
|
|
40
|
+
resolveProfile,
|
|
41
|
+
makeIncludeFilter,
|
|
42
|
+
writeInstalledManifest,
|
|
43
|
+
} from './profile-resolver.mjs';
|
|
44
|
+
|
|
45
|
+
const PKG_ROOT = path.resolve(
|
|
46
|
+
path.dirname(fileURLToPath(import.meta.url)),
|
|
47
|
+
'..',
|
|
48
|
+
);
|
|
49
|
+
|
|
50
|
+
/** Resolve full per-host paths against an explicit home directory. */
|
|
51
|
+
export function resolveHostPaths(hostId, home = os.homedir()) {
|
|
52
|
+
const host = HOSTS.find((h) => h.id === hostId);
|
|
53
|
+
if (!host) throw new Error(`Unknown host: ${hostId}. Known: ${HOSTS.map((h) => h.id).join(', ')}`);
|
|
54
|
+
const parent = path.join(home, ...host.parent.split('/'));
|
|
55
|
+
const skillDir = path.join(home, ...host.skillDir.split('/'));
|
|
56
|
+
const metadata = path.join(parent, SKILLS_METADATA_FILE);
|
|
57
|
+
return { host, parent, skillDir, metadata };
|
|
58
|
+
}
|
|
59
|
+
|
|
60
|
+
/**
|
|
61
|
+
* Detect which hosts have evidence of being present on this machine.
|
|
62
|
+
* Returns the array of HOSTS entries whose parent directory already exists.
|
|
63
|
+
*/
|
|
64
|
+
export function detectHosts(home = os.homedir()) {
|
|
65
|
+
return HOSTS.filter((h) => {
|
|
66
|
+
const parent = path.join(home, ...h.parent.split('/'));
|
|
67
|
+
return fs.existsSync(parent);
|
|
68
|
+
});
|
|
69
|
+
}
|
|
70
|
+
|
|
71
|
+
function readSkillsMetadata(metadataPath) {
|
|
72
|
+
if (!fs.existsSync(metadataPath)) return [];
|
|
73
|
+
try {
|
|
74
|
+
const raw = fs.readFileSync(metadataPath, 'utf-8').trim();
|
|
75
|
+
if (!raw) return [];
|
|
76
|
+
const parsed = JSON.parse(raw);
|
|
77
|
+
return Array.isArray(parsed) ? parsed : [];
|
|
78
|
+
} catch {
|
|
79
|
+
return [];
|
|
80
|
+
}
|
|
81
|
+
}
|
|
82
|
+
|
|
83
|
+
function writeSkillsMetadata(metadataPath, entries) {
|
|
84
|
+
fs.mkdirSync(path.dirname(metadataPath), { recursive: true });
|
|
85
|
+
fs.writeFileSync(metadataPath, JSON.stringify(entries, null, 2) + '\n', 'utf-8');
|
|
86
|
+
}
|
|
87
|
+
|
|
88
|
+
/**
|
|
89
|
+
* Upsert the kushi entry in a host's skills-metadata.json. Pure I/O — no
|
|
90
|
+
* console output. Returns 'created' | 'updated' | 'unchanged'.
|
|
91
|
+
*/
|
|
92
|
+
export function upsertSkillsMetadata(metadataPath, skillDir, version, description) {
|
|
93
|
+
const entries = readSkillsMetadata(metadataPath);
|
|
94
|
+
const skillMdPath = path.join(skillDir, CLAWPILOT_SKILL_DEST);
|
|
95
|
+
const desired = {
|
|
96
|
+
id: `${SKILL_NAME}-${version}`,
|
|
97
|
+
name: SKILL_NAME,
|
|
98
|
+
description: description || `Kushi v${version} — multi-source project evidence + Q&A agent. See SKILL.md.`,
|
|
99
|
+
instructions: `When this skill is invoked, ALWAYS read the full SKILL.md FIRST: \`${skillMdPath}\`. It is the orchestrator for verb-routing (bootstrap, refresh, state, consolidate, status, pull <source>).`,
|
|
100
|
+
enabled: true,
|
|
101
|
+
createdAt: new Date().toISOString(),
|
|
102
|
+
scope: 'local',
|
|
103
|
+
};
|
|
104
|
+
const idx = entries.findIndex((e) => e && e.name === SKILL_NAME);
|
|
105
|
+
if (idx === -1) {
|
|
106
|
+
entries.push(desired);
|
|
107
|
+
writeSkillsMetadata(metadataPath, entries);
|
|
108
|
+
return 'created';
|
|
109
|
+
}
|
|
110
|
+
const existing = entries[idx];
|
|
111
|
+
// Preserve original createdAt if present.
|
|
112
|
+
if (existing.createdAt) desired.createdAt = existing.createdAt;
|
|
113
|
+
if (JSON.stringify(existing) === JSON.stringify(desired)) return 'unchanged';
|
|
114
|
+
entries[idx] = desired;
|
|
115
|
+
writeSkillsMetadata(metadataPath, entries);
|
|
116
|
+
return 'updated';
|
|
117
|
+
}
|
|
118
|
+
|
|
119
|
+
/**
|
|
120
|
+
* Remove the kushi entry from a host's skills-metadata.json (if any).
|
|
121
|
+
* Returns 'removed' | 'absent' | 'missing-file'.
|
|
122
|
+
*/
|
|
123
|
+
export function removeSkillFromMetadata(metadataPath) {
|
|
124
|
+
if (!fs.existsSync(metadataPath)) return 'missing-file';
|
|
125
|
+
const entries = readSkillsMetadata(metadataPath);
|
|
126
|
+
const next = entries.filter((e) => !(e && e.name === SKILL_NAME));
|
|
127
|
+
if (next.length === entries.length) return 'absent';
|
|
128
|
+
if (next.length === 0) {
|
|
129
|
+
// Leave an empty array rather than deleting the file — host expects it.
|
|
130
|
+
writeSkillsMetadata(metadataPath, []);
|
|
131
|
+
} else {
|
|
132
|
+
writeSkillsMetadata(metadataPath, next);
|
|
133
|
+
}
|
|
134
|
+
return 'removed';
|
|
135
|
+
}
|
|
136
|
+
|
|
137
|
+
/**
|
|
138
|
+
* Install Kushi into a single host. Mirrors the existing `installClawpilot`
|
|
139
|
+
* shape but is fully parameterized by `hostId` and an optional `home` override
|
|
140
|
+
* (used by tests + dry-runs to redirect away from the live ~/.copilot/).
|
|
141
|
+
*
|
|
142
|
+
* @returns {{ host: string, skillDir: string, copied: number, metadata: string }}
|
|
143
|
+
*/
|
|
144
|
+
export function installHost(hostId, { home = os.homedir(), force = true, profile, quiet = false } = {}) {
|
|
145
|
+
const { host, skillDir, metadata } = resolveHostPaths(hostId, home);
|
|
146
|
+
|
|
147
|
+
// Wipe profile-dependent dirs first so an upgrade is clean.
|
|
148
|
+
if (fs.existsSync(skillDir) && force) {
|
|
149
|
+
for (const sub of ['skills', 'prompts', 'instructions', 'templates', 'reference-packs', 'lib', 'agents']) {
|
|
150
|
+
const p = path.join(skillDir, sub);
|
|
151
|
+
if (fs.existsSync(p)) fs.rmSync(p, { recursive: true, force: true });
|
|
152
|
+
}
|
|
153
|
+
}
|
|
154
|
+
fs.mkdirSync(skillDir, { recursive: true });
|
|
155
|
+
|
|
156
|
+
// Resolve profile (use plugin.json default if not given).
|
|
157
|
+
const pluginJsonPath = path.join(PKG_ROOT, PLUGIN_SOURCE_DIR, 'plugin.json');
|
|
158
|
+
const pluginJson = JSON.parse(fs.readFileSync(pluginJsonPath, 'utf-8'));
|
|
159
|
+
const profileName = profile || pluginJson.default_profile || 'standard';
|
|
160
|
+
const resolved = resolveProfile(pluginJsonPath, profileName);
|
|
161
|
+
const includeFilter = makeIncludeFilter(resolved);
|
|
162
|
+
|
|
163
|
+
const { copied } = copyAssets(PKG_ROOT, skillDir, includeFilter);
|
|
164
|
+
|
|
165
|
+
const version = JSON.parse(fs.readFileSync(path.join(PKG_ROOT, 'package.json'), 'utf-8')).version;
|
|
166
|
+
writeInstalledManifest(skillDir, resolved, version);
|
|
167
|
+
seedConfig(PKG_ROOT, skillDir);
|
|
168
|
+
|
|
169
|
+
// Mirror agents/kushi.agent.md as top-level SKILL.md.
|
|
170
|
+
const agentSrc = path.join(PKG_ROOT, PLUGIN_SOURCE_DIR, CLAWPILOT_AGENT_SOURCE);
|
|
171
|
+
const skillDst = path.join(skillDir, CLAWPILOT_SKILL_DEST);
|
|
172
|
+
if (fs.existsSync(agentSrc)) {
|
|
173
|
+
fs.cpSync(agentSrc, skillDst, { force: true });
|
|
174
|
+
}
|
|
175
|
+
|
|
176
|
+
// Update skills-metadata.json — extract description from agent front-matter.
|
|
177
|
+
const description = extractFrontmatterDescription(agentSrc);
|
|
178
|
+
const action = upsertSkillsMetadata(metadata, skillDir, version, description);
|
|
179
|
+
|
|
180
|
+
if (!quiet) {
|
|
181
|
+
const home2 = home;
|
|
182
|
+
const disp = skillDir.startsWith(home2 + path.sep)
|
|
183
|
+
? '~' + path.sep + path.relative(home2, skillDir)
|
|
184
|
+
: skillDir;
|
|
185
|
+
console.log(` [${host.label}] ${action} ${disp}\\ (${copied} files, profile=${resolved.profile})`);
|
|
186
|
+
console.log(` [${host.label}] skills-metadata.json @ ${path.relative(home2, metadata)}`);
|
|
187
|
+
}
|
|
188
|
+
|
|
189
|
+
return { host: host.id, skillDir, copied, metadata };
|
|
190
|
+
}
|
|
191
|
+
|
|
192
|
+
/**
|
|
193
|
+
* Uninstall Kushi from a single host. Removes the skill dir and the
|
|
194
|
+
* skills-metadata.json entry. Safe to call when nothing is installed.
|
|
195
|
+
*
|
|
196
|
+
* @returns {{ host: string, skillDirRemoved: boolean, metadataAction: string }}
|
|
197
|
+
*/
|
|
198
|
+
export function uninstallHost(hostId, { home = os.homedir(), quiet = false } = {}) {
|
|
199
|
+
const { host, skillDir, metadata } = resolveHostPaths(hostId, home);
|
|
200
|
+
let skillDirRemoved = false;
|
|
201
|
+
if (fs.existsSync(skillDir)) {
|
|
202
|
+
fs.rmSync(skillDir, { recursive: true, force: true });
|
|
203
|
+
skillDirRemoved = true;
|
|
204
|
+
}
|
|
205
|
+
const metadataAction = removeSkillFromMetadata(metadata);
|
|
206
|
+
|
|
207
|
+
if (!quiet) {
|
|
208
|
+
const disp = skillDir.startsWith(home + path.sep)
|
|
209
|
+
? '~' + path.sep + path.relative(home, skillDir)
|
|
210
|
+
: skillDir;
|
|
211
|
+
if (skillDirRemoved) console.log(` [${host.label}] removed ${disp}\\`);
|
|
212
|
+
else console.log(` [${host.label}] (no install present at ${disp}\\)`);
|
|
213
|
+
console.log(` [${host.label}] skills-metadata.json: ${metadataAction}`);
|
|
214
|
+
}
|
|
215
|
+
return { host: host.id, skillDirRemoved, metadataAction };
|
|
216
|
+
}
|
|
217
|
+
|
|
218
|
+
function extractFrontmatterDescription(filePath) {
|
|
219
|
+
if (!fs.existsSync(filePath)) return null;
|
|
220
|
+
const txt = fs.readFileSync(filePath, 'utf-8');
|
|
221
|
+
const m = txt.match(/^---\r?\n([\s\S]*?)\r?\n---/);
|
|
222
|
+
if (!m) return null;
|
|
223
|
+
const yaml = m[1];
|
|
224
|
+
// Try multi-line quoted "description: \"...\"" first, then single-line.
|
|
225
|
+
const dm = yaml.match(/^description:\s*"((?:\\.|[^"\\])*)"/m);
|
|
226
|
+
if (dm) return dm[1].replace(/\\"/g, '"').replace(/\\n/g, ' ').trim();
|
|
227
|
+
const sm = yaml.match(/^description:\s*(.+?)\s*$/m);
|
|
228
|
+
return sm ? sm[1].trim() : null;
|
|
229
|
+
}
|
|
230
|
+
|
|
231
|
+
/**
|
|
232
|
+
* High-level dispatcher used by the CLI when any multi-host flag is set.
|
|
233
|
+
* Parses the parsed args object and prints a banner + per-host result lines.
|
|
234
|
+
*
|
|
235
|
+
* @param {{ hosts?: string[], all?: boolean, uninstall?: boolean, profile?: string, home?: string }} opts
|
|
236
|
+
*/
|
|
237
|
+
export async function runMultiHost(opts) {
|
|
238
|
+
const home = opts.home || os.homedir();
|
|
239
|
+
const detected = detectHosts(home);
|
|
240
|
+
let targets;
|
|
241
|
+
|
|
242
|
+
if (opts.all) {
|
|
243
|
+
targets = HOSTS.slice();
|
|
244
|
+
} else if (opts.hosts && opts.hosts.length) {
|
|
245
|
+
targets = opts.hosts.map((id) => {
|
|
246
|
+
const h = HOSTS.find((x) => x.id === id);
|
|
247
|
+
if (!h) throw new Error(`Unknown host '${id}'. Supported: ${HOSTS.map((h) => h.id).join(', ')}`);
|
|
248
|
+
return h;
|
|
249
|
+
});
|
|
250
|
+
} else {
|
|
251
|
+
// No explicit host → use auto-detected list.
|
|
252
|
+
targets = detected.slice();
|
|
253
|
+
if (targets.length === 0) {
|
|
254
|
+
console.error('\n No host detected and no host flag given.');
|
|
255
|
+
console.error(' Pass --clawpilot, --vscode, or --all-hosts to choose a target.\n');
|
|
256
|
+
process.exit(1);
|
|
257
|
+
}
|
|
258
|
+
}
|
|
259
|
+
|
|
260
|
+
console.log('');
|
|
261
|
+
console.log(` Kushi multi-host ${opts.uninstall ? 'uninstall' : 'install'}`);
|
|
262
|
+
console.log(` Detected hosts: ${detected.length ? detected.map((h) => h.label).join(', ') : '(none)'}`);
|
|
263
|
+
console.log(` Targeting: ${targets.map((h) => h.label).join(', ')}`);
|
|
264
|
+
console.log('');
|
|
265
|
+
|
|
266
|
+
const results = [];
|
|
267
|
+
for (const t of targets) {
|
|
268
|
+
if (opts.uninstall) {
|
|
269
|
+
results.push(uninstallHost(t.id, { home }));
|
|
270
|
+
} else {
|
|
271
|
+
results.push(installHost(t.id, { home, force: true, profile: opts.profile }));
|
|
272
|
+
}
|
|
273
|
+
}
|
|
274
|
+
console.log('');
|
|
275
|
+
console.log(` Done. ${results.length} host(s) processed.\n`);
|
|
276
|
+
return results;
|
|
277
|
+
}
|