@kothar-hq/host-container-capabilities 0.0.0-stage → 0.9.0
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/LICENSE +21 -0
- package/README.md +75 -2
- package/bin/host-container-capabilities.mjs +79 -0
- package/package.json +39 -4
- package/skills/host-container-capabilities/SKILL.md +126 -0
- package/skills/host-container-capabilities/scripts/Get-HostCapabilities.ps1 +304 -0
- package/skills/host-container-capabilities/scripts/host-capabilities.sh +208 -0
package/LICENSE
ADDED
|
@@ -0,0 +1,21 @@
|
|
|
1
|
+
MIT License
|
|
2
|
+
|
|
3
|
+
Copyright (c) 2026 Justin Dearing
|
|
4
|
+
|
|
5
|
+
Permission is hereby granted, free of charge, to any person obtaining a copy
|
|
6
|
+
of this software and associated documentation files (the "Software"), to deal
|
|
7
|
+
in the Software without restriction, including without limitation the rights
|
|
8
|
+
to use, copy, modify, merge, publish, distribute, sublicense, and/or sell
|
|
9
|
+
copies of the Software, and to permit persons to whom the Software is
|
|
10
|
+
furnished to do so, subject to the following conditions:
|
|
11
|
+
|
|
12
|
+
The above copyright notice and this permission notice shall be included in all
|
|
13
|
+
copies or substantial portions of the Software.
|
|
14
|
+
|
|
15
|
+
THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
|
|
16
|
+
IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY,
|
|
17
|
+
FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE
|
|
18
|
+
AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER
|
|
19
|
+
LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM,
|
|
20
|
+
OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE
|
|
21
|
+
SOFTWARE.
|
package/README.md
CHANGED
|
@@ -1,3 +1,76 @@
|
|
|
1
|
-
#
|
|
1
|
+
# host-container-capabilities
|
|
2
2
|
|
|
3
|
-
|
|
3
|
+
An [agent skill](https://agentskills.io) that finds out which container engines a host has, and routes container work to a local engine or to CI.
|
|
4
|
+
|
|
5
|
+
Coding agents run on Windows hosts (some with WSL), on Linux and macOS, inside WSL, and in cloud sandboxes. An agent that assumes `docker` reaches a Linux engine gets it wrong on a Windows host in Windows-container mode, and one that "fixes" that by switching Docker Desktop stops every container on the host. This skill gives the agent a probed report instead:
|
|
6
|
+
|
|
7
|
+
- **Windows engine:** whether one answers, and the isolation (`process` or `hyperv`) that ltsc2019, ltsc2022 and ltsc2025 images need on this host build.
|
|
8
|
+
- **Linux engine:** the default WSL distro's own Docker, then other WSL 2 distros, then the default docker context, then an existing Docker Desktop Linux pipe. On Linux and macOS, the docker CLI's current context.
|
|
9
|
+
- **Routing:** run locally when the engine exists, otherwise offer the project's CI workflow.
|
|
10
|
+
- **Shared-host safety:** everything the agent creates is named and labelled, and only that is cleaned up. Docker Desktop is never switched, and no daemon is started or installed without asking.
|
|
11
|
+
|
|
12
|
+
The report is cached per project for 24 hours.
|
|
13
|
+
|
|
14
|
+
## Install
|
|
15
|
+
|
|
16
|
+
With the [skills CLI](https://skills.sh), from GitHub:
|
|
17
|
+
|
|
18
|
+
```sh
|
|
19
|
+
npx skills add kothar-hq/host-container-capabilities
|
|
20
|
+
```
|
|
21
|
+
|
|
22
|
+
From npm, with the installer in the package:
|
|
23
|
+
|
|
24
|
+
```sh
|
|
25
|
+
npx @kothar-hq/host-container-capabilities install # into ./.claude/skills
|
|
26
|
+
npx @kothar-hq/host-container-capabilities install --global # into ~/.claude/skills
|
|
27
|
+
npx @kothar-hq/host-container-capabilities install --dir .agents/skills
|
|
28
|
+
```
|
|
29
|
+
|
|
30
|
+
Or as a dev dependency, synced by the skills CLI:
|
|
31
|
+
|
|
32
|
+
```sh
|
|
33
|
+
npm install --save-dev @kothar-hq/host-container-capabilities
|
|
34
|
+
npx skills experimental_sync
|
|
35
|
+
```
|
|
36
|
+
|
|
37
|
+
Then add `.claude/*.local.json` to the project's `.gitignore`: the probe caches its report in `.claude/host-container-capabilities.<platform>.local.json`.
|
|
38
|
+
|
|
39
|
+
## Use
|
|
40
|
+
|
|
41
|
+
The agent loads the skill before container work. To read the report yourself:
|
|
42
|
+
|
|
43
|
+
```sh
|
|
44
|
+
sh .claude/skills/host-container-capabilities/scripts/host-capabilities.sh
|
|
45
|
+
```
|
|
46
|
+
|
|
47
|
+
```pwsh
|
|
48
|
+
.claude/skills/host-container-capabilities/scripts/Get-HostCapabilities.ps1 -AsJson
|
|
49
|
+
```
|
|
50
|
+
|
|
51
|
+
[SKILL.md](skills/host-container-capabilities/SKILL.md) describes the report's fields, the routing rules, the naming and labelling convention, and the per-host overrides file.
|
|
52
|
+
|
|
53
|
+
## Requirements
|
|
54
|
+
|
|
55
|
+
- Windows: PowerShell 7. Linux, macOS and WSL: a POSIX `sh`.
|
|
56
|
+
- The installer needs Node.js 20 or later. The skill itself doesn't need Node.
|
|
57
|
+
- Docker is the only container runtime probed today. Podman and others are tracked in the [issues](https://github.com/kothar-hq/host-container-capabilities/issues).
|
|
58
|
+
|
|
59
|
+
## Versions
|
|
60
|
+
|
|
61
|
+
0.9 is the first public release: the skill as it has been used in [zippy1981/windows-nano-baseimages](https://github.com/zippy1981/windows-nano-baseimages), made project-neutral. The report's `schemaVersion` is 1. 1.0 will follow once the report's fields are settled. See the [changelog](CHANGELOG.md).
|
|
62
|
+
|
|
63
|
+
## Develop
|
|
64
|
+
|
|
65
|
+
```sh
|
|
66
|
+
npm test # node --test: the package, the installer, and the real probe for this platform
|
|
67
|
+
npm run lint:md # markdownlint
|
|
68
|
+
```
|
|
69
|
+
|
|
70
|
+
CI also runs shellcheck, actionlint, PSScriptAnalyzer and CodeQL, and the tests on Linux, macOS and Windows. A change to `main` goes through a pull request.
|
|
71
|
+
|
|
72
|
+
A release is a pull request that changes `version` in `package.json`, in `SKILL.md` and in `CHANGELOG.md` (a test checks that they agree). Merging it runs the `release` workflow, which publishes to npm with provenance, and creates the `vX.Y.Z` tag and the GitHub release.
|
|
73
|
+
|
|
74
|
+
## Licence
|
|
75
|
+
|
|
76
|
+
[MIT](LICENSE)
|
|
@@ -0,0 +1,79 @@
|
|
|
1
|
+
#!/usr/bin/env node
|
|
2
|
+
// Installs the host-container-capabilities skill from this package into a project's (or the user's) skills directory.
|
|
3
|
+
import { cpSync, existsSync, mkdirSync, readFileSync, rmSync } from 'node:fs';
|
|
4
|
+
import { homedir } from 'node:os';
|
|
5
|
+
import { dirname, join, resolve } from 'node:path';
|
|
6
|
+
import { fileURLToPath } from 'node:url';
|
|
7
|
+
|
|
8
|
+
const packageRoot = resolve(dirname(fileURLToPath(import.meta.url)), '..');
|
|
9
|
+
const skillName = 'host-container-capabilities';
|
|
10
|
+
const skillSource = join(packageRoot, 'skills', skillName);
|
|
11
|
+
|
|
12
|
+
const usage = `Usage: ${skillName} <command> [options]
|
|
13
|
+
|
|
14
|
+
Commands:
|
|
15
|
+
install Copy the skill into .claude/skills/${skillName} in the current directory
|
|
16
|
+
path Print the directory of the skill inside this package
|
|
17
|
+
version Print the package version
|
|
18
|
+
|
|
19
|
+
Options for install:
|
|
20
|
+
--global Install into ~/.claude/skills instead of the current directory
|
|
21
|
+
--dir <path> Install into <path>/${skillName} instead (for other agents' skills directories)
|
|
22
|
+
--force Replace an existing copy
|
|
23
|
+
`;
|
|
24
|
+
|
|
25
|
+
function fail(message) {
|
|
26
|
+
process.stderr.write(`${skillName}: ${message}\n`);
|
|
27
|
+
process.exit(1);
|
|
28
|
+
}
|
|
29
|
+
|
|
30
|
+
function install(args) {
|
|
31
|
+
let target = join(process.cwd(), '.claude', 'skills');
|
|
32
|
+
let force = false;
|
|
33
|
+
for (let i = 0; i < args.length; i++) {
|
|
34
|
+
const arg = args[i];
|
|
35
|
+
if (arg === '--global') {
|
|
36
|
+
target = join(homedir(), '.claude', 'skills');
|
|
37
|
+
} else if (arg === '--dir') {
|
|
38
|
+
const dir = args[++i];
|
|
39
|
+
if (!dir) fail('--dir needs a path');
|
|
40
|
+
target = resolve(dir);
|
|
41
|
+
} else if (arg === '--force') {
|
|
42
|
+
force = true;
|
|
43
|
+
} else {
|
|
44
|
+
fail(`unknown option '${arg}'`);
|
|
45
|
+
}
|
|
46
|
+
}
|
|
47
|
+
const destination = join(target, skillName);
|
|
48
|
+
if (existsSync(destination)) {
|
|
49
|
+
if (!force) fail(`${destination} already exists. Pass --force to replace it.`);
|
|
50
|
+
rmSync(destination, { recursive: true });
|
|
51
|
+
}
|
|
52
|
+
mkdirSync(target, { recursive: true });
|
|
53
|
+
cpSync(skillSource, destination, { recursive: true });
|
|
54
|
+
process.stdout.write(`Installed ${skillName} to ${destination}\n`);
|
|
55
|
+
}
|
|
56
|
+
|
|
57
|
+
const [command, ...rest] = process.argv.slice(2);
|
|
58
|
+
switch (command) {
|
|
59
|
+
case 'install':
|
|
60
|
+
install(rest);
|
|
61
|
+
break;
|
|
62
|
+
case 'path':
|
|
63
|
+
process.stdout.write(`${skillSource}\n`);
|
|
64
|
+
break;
|
|
65
|
+
case 'version':
|
|
66
|
+
case '--version':
|
|
67
|
+
case '-v':
|
|
68
|
+
process.stdout.write(`${JSON.parse(readFileSync(join(packageRoot, 'package.json'), 'utf8')).version}\n`);
|
|
69
|
+
break;
|
|
70
|
+
case undefined:
|
|
71
|
+
case 'help':
|
|
72
|
+
case '--help':
|
|
73
|
+
case '-h':
|
|
74
|
+
process.stdout.write(usage);
|
|
75
|
+
break;
|
|
76
|
+
default:
|
|
77
|
+
process.stderr.write(usage);
|
|
78
|
+
fail(`unknown command '${command}'`);
|
|
79
|
+
}
|
package/package.json
CHANGED
|
@@ -1,6 +1,41 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "@kothar-hq/host-container-capabilities",
|
|
3
|
-
"version": "0.
|
|
4
|
-
"
|
|
5
|
-
"
|
|
6
|
-
|
|
3
|
+
"version": "0.9.0",
|
|
4
|
+
"description": "An agent skill that finds out which container engines a host has (Windows, Linux, WSL) and routes container work to a local engine or to CI.",
|
|
5
|
+
"keywords": [
|
|
6
|
+
"agent-skill",
|
|
7
|
+
"agent-skills",
|
|
8
|
+
"skills",
|
|
9
|
+
"claude-code",
|
|
10
|
+
"docker",
|
|
11
|
+
"wsl",
|
|
12
|
+
"windows-containers"
|
|
13
|
+
],
|
|
14
|
+
"license": "MIT",
|
|
15
|
+
"author": "Justin Dearing",
|
|
16
|
+
"homepage": "https://github.com/kothar-hq/host-container-capabilities#readme",
|
|
17
|
+
"bugs": "https://github.com/kothar-hq/host-container-capabilities/issues",
|
|
18
|
+
"repository": {
|
|
19
|
+
"type": "git",
|
|
20
|
+
"url": "git+https://github.com/kothar-hq/host-container-capabilities.git"
|
|
21
|
+
},
|
|
22
|
+
"type": "module",
|
|
23
|
+
"bin": {
|
|
24
|
+
"host-container-capabilities": "bin/host-container-capabilities.mjs"
|
|
25
|
+
},
|
|
26
|
+
"files": [
|
|
27
|
+
"bin",
|
|
28
|
+
"skills"
|
|
29
|
+
],
|
|
30
|
+
"engines": {
|
|
31
|
+
"node": ">=20"
|
|
32
|
+
},
|
|
33
|
+
"scripts": {
|
|
34
|
+
"test": "node --test",
|
|
35
|
+
"lint:md": "npx -y markdownlint-cli@0.50.0 --dot \"**/*.md\""
|
|
36
|
+
},
|
|
37
|
+
"publishConfig": {
|
|
38
|
+
"access": "public",
|
|
39
|
+
"provenance": true
|
|
40
|
+
}
|
|
41
|
+
}
|
|
@@ -0,0 +1,126 @@
|
|
|
1
|
+
---
|
|
2
|
+
name: host-container-capabilities
|
|
3
|
+
description: Find out which container engines this host has (a Windows engine for Windows containers, a Linux engine such as a WSL distro's own Docker, both, or neither) and route container work to a local engine or to CI. Use before any docker command, before building or testing an image, before running Linux tools such as shellcheck or actionlint in a container, or when asked whether this machine can run Linux or Windows containers.
|
|
4
|
+
license: MIT
|
|
5
|
+
metadata:
|
|
6
|
+
version: "0.9.0"
|
|
7
|
+
repository: https://github.com/kothar-hq/host-container-capabilities
|
|
8
|
+
---
|
|
9
|
+
|
|
10
|
+
# Host container capabilities
|
|
11
|
+
|
|
12
|
+
Coding agents run on Windows hosts (some with WSL), on Linux and macOS, inside WSL, and in cloud sandboxes. Don't assume which engines exist. Read the probe's report, then route the task.
|
|
13
|
+
|
|
14
|
+
The probe scripts are in this skill's `scripts/` directory, next to this file. Below, `<skill>` is the directory that holds this `SKILL.md`.
|
|
15
|
+
|
|
16
|
+
## 1. Read the report
|
|
17
|
+
|
|
18
|
+
| Shell | Command |
|
|
19
|
+
| --- | --- |
|
|
20
|
+
| PowerShell (Windows) | `<skill>/scripts/Get-HostCapabilities.ps1 -AsJson` |
|
|
21
|
+
| Bash or sh (any OS; Git Bash hands off to the PowerShell script) | `sh <skill>/scripts/host-capabilities.sh` |
|
|
22
|
+
|
|
23
|
+
Run it from inside the project. The project root is the git work tree around the current directory, or `HOST_CONTAINER_CAPABILITIES_ROOT` if that's set.
|
|
24
|
+
|
|
25
|
+
A cached report is reused for 24 hours, so it's cheap to read again. Probe again (`-Refresh`, or `--refresh` for the sh script) when a docker command can't reach its engine, when the user says something on the host changed, or before telling the user the host can't do something. On Windows a fresh probe boots WSL distros, one after another, until one has a working Docker.
|
|
26
|
+
|
|
27
|
+
The fields that decide things:
|
|
28
|
+
|
|
29
|
+
| Field | Meaning |
|
|
30
|
+
| --- | --- |
|
|
31
|
+
| `platform` | `windows`, `wsl` (the agent is running inside WSL), `linux` or `macos` |
|
|
32
|
+
| `windowsEngine.available`, `.invoke` | Whether a Windows engine answers, and the command that reaches it (`docker`, or `docker.exe` from inside WSL) |
|
|
33
|
+
| `windowsEngine.ltsc.<2019\|2022\|2025>.isolation` | `process`, `hyperv`, or `null` when that Windows version's images can't run here. Only filled in on Windows. |
|
|
34
|
+
| `linuxEngine.available`, `.invoke` | Whether a Linux engine answers, and the command that reaches it, for example `["wsl.exe","--distribution","Ubuntu","--exec","docker"]` |
|
|
35
|
+
| `linuxEngine.repoPath` | The project's path as the Linux engine's host sees it, for bind mounts |
|
|
36
|
+
| `linuxEngine.containers` minus `.claudeContainers` | Containers that belong to someone else (the user's devcontainers, CI runners). Leave them alone. |
|
|
37
|
+
| `linuxEngine.reason`, `.tried` | Why there's no Linux engine, and what was tried |
|
|
38
|
+
| `policy.sharedHost` | `true` unless the user overrode it. Shared means strict cleanup (section 4). |
|
|
39
|
+
| `policy.createdByLabel`, `.sessionLabelKey` | The labels to put on everything you create (section 4) |
|
|
40
|
+
| `github.authenticated` | Whether `gh` can dispatch workflows |
|
|
41
|
+
|
|
42
|
+
## 2. Route the task
|
|
43
|
+
|
|
44
|
+
If the project has its own routing table (in `CLAUDE.md`, `AGENTS.md` or its docs), follow it. Otherwise:
|
|
45
|
+
|
|
46
|
+
| Task | Run locally when | Otherwise |
|
|
47
|
+
| --- | --- | --- |
|
|
48
|
+
| Build or test a Windows container image | `windowsEngine.available`, and that Windows version's `isolation` isn't `null`. Pass `--isolation <value>`. Hyper-V isolated builds default to 1 GB of memory, so pass `--memory 4g` or more. | The project's CI workflow for that image |
|
|
49
|
+
| Build or test a Linux container image | `linuxEngine.available` | The project's CI workflow for that image |
|
|
50
|
+
| Linux tools in a container (shellcheck, actionlint, a build script) | `linuxEngine.available`, with the tool version CI uses | The project's lint or build workflow |
|
|
51
|
+
| A release, or anything published | Never locally, unless the project says otherwise | The project's release workflow |
|
|
52
|
+
| Tools that need no container (PSScriptAnalyzer, Pester, npm scripts) | Any host that has the tool | CI |
|
|
53
|
+
|
|
54
|
+
Dispatching a workflow is outward-facing, so ask before `gh workflow run`. If `github.authenticated` is false, ask the user to log in with `gh auth login`. Never handle tokens.
|
|
55
|
+
|
|
56
|
+
## 3. Run on the engine
|
|
57
|
+
|
|
58
|
+
Use `invoke` exactly as the report gives it, with the docker arguments after it.
|
|
59
|
+
|
|
60
|
+
```pwsh
|
|
61
|
+
$report = <skill>/scripts/Get-HostCapabilities.ps1
|
|
62
|
+
$linux = $report.linuxEngine
|
|
63
|
+
& $linux.invoke[0] @($linux.invoke | Select-Object -Skip 1) run --rm --name agent-lint-3f9a `
|
|
64
|
+
--label $report.policy.createdByLabel --label "$($report.policy.sessionLabelKey)=3f9a" `
|
|
65
|
+
--volume "$($linux.repoPath):/src:ro" --workdir /src rhysd/actionlint:1.7.12 -color
|
|
66
|
+
```
|
|
67
|
+
|
|
68
|
+
- **Prefer PowerShell on Windows.** Git Bash rewrites arguments that look like paths (`/src` becomes `C:/Program Files/Git/src`), so in Bash prefix the command with `MSYS_NO_PATHCONV=1`.
|
|
69
|
+
- **`--exec` runs docker without a shell** inside the distro. Don't pass `bash -lc '...'` through `wsl.exe`: the quoting breaks and `$variables` are expanded in the wrong place.
|
|
70
|
+
- **`/mnt/c` is slow.** That's fine for linters and for reading the project. For a real build, copy the sources into the container or a Linux directory instead of building on the bind mount.
|
|
71
|
+
- **Line endings:** a shell script checked out with CRLF fails in a Linux container. Check that `.gitattributes` keeps `*.sh` LF before running the project's scripts from a Windows checkout.
|
|
72
|
+
- **Registry pulls:** if a pull is denied, the image is private, and logging in is the user's call.
|
|
73
|
+
|
|
74
|
+
## 4. Name, label and clean up
|
|
75
|
+
|
|
76
|
+
On a shared host (`policy.sharedHost`, the default), everything you create carries:
|
|
77
|
+
|
|
78
|
+
- a name `agent-<task>-<tag>`, where `<tag>` is 4 hex characters you pick once per session
|
|
79
|
+
- `--label <policy.createdByLabel> --label <policy.sessionLabelKey>=<tag>`, on `docker run`, `docker create` and `docker build`
|
|
80
|
+
|
|
81
|
+
Prefer images that are already present (`docker images`). If you pull one, note that it wasn't there before, and remove exactly that reference when you're done.
|
|
82
|
+
|
|
83
|
+
Clean up only what carries your session's label, on the engine you created it on:
|
|
84
|
+
|
|
85
|
+
```sh
|
|
86
|
+
docker ps -aq --filter label=<policy.sessionLabelKey>=<tag> | xargs -r docker rm -f
|
|
87
|
+
docker images -q --filter label=<policy.sessionLabelKey>=<tag> | xargs -r docker rmi
|
|
88
|
+
```
|
|
89
|
+
|
|
90
|
+
Never `docker system prune`, `docker image prune` or `docker builder prune` on a shared host.
|
|
91
|
+
|
|
92
|
+
## 5. When an engine is missing
|
|
93
|
+
|
|
94
|
+
- **No Linux engine, but WSL is installed** (`linuxEngine.tried` names the distros): offer to install Docker Engine in the distro, or to start its daemon if `tried` says it isn't reachable. Both are host changes, so ask first. Otherwise, offer the CI workflow.
|
|
95
|
+
- **No Linux engine and no WSL** (Windows without WSL, or a Linux or macOS host without Docker): offer the CI workflow, or report what's missing.
|
|
96
|
+
- **No Windows engine** (Linux, macOS, or a cloud sandbox): Windows container images can only be built in CI, on a Windows runner. Don't try workarounds.
|
|
97
|
+
|
|
98
|
+
## 6. Never
|
|
99
|
+
|
|
100
|
+
- Switch Docker Desktop between Windows and Linux containers, on any host. `policy.dockerDesktopSwitch` is always `never`: switching stops every container on the engine that's switched away from.
|
|
101
|
+
- Run `wsl --shutdown` or `wsl --terminate`. They stop the user's devcontainers.
|
|
102
|
+
- Start, stop or reconfigure a daemon, or install software, without asking.
|
|
103
|
+
- Touch containers or images you didn't create, on either engine.
|
|
104
|
+
|
|
105
|
+
## Overrides
|
|
106
|
+
|
|
107
|
+
Per host, in `.claude/host-container-capabilities.overrides.local.json` in the project, which should be gitignored. It's a flat JSON object. Any key can be left out.
|
|
108
|
+
|
|
109
|
+
```json
|
|
110
|
+
{
|
|
111
|
+
"linuxEngine": "wsl:Ubuntu",
|
|
112
|
+
"sharedHost": true,
|
|
113
|
+
"maxAgeHours": 24,
|
|
114
|
+
"createdByLabel": "io.github.kothar-hq.created-by=agent",
|
|
115
|
+
"sessionLabelKey": "io.github.kothar-hq.session",
|
|
116
|
+
"runnerContainerPattern": "my-runner-*"
|
|
117
|
+
}
|
|
118
|
+
```
|
|
119
|
+
|
|
120
|
+
- `linuxEngine`: `auto` (the default), `none`, `wsl:<distro>` (Windows only), `docker` (the default docker context, if it's a Linux engine), or `docker-desktop-linux` (Windows only: Docker Desktop's Linux engine pipe, if it already exists; it's never switched to)
|
|
121
|
+
- `sharedHost`: set `false` only on a host that nothing else uses
|
|
122
|
+
- `maxAgeHours`: how long a report is reused
|
|
123
|
+
- `createdByLabel`, `sessionLabelKey`: the labels from section 4, for a project that already has a labelling convention
|
|
124
|
+
- `runnerContainerPattern`: a wildcard for CI runner containers on the Windows engine. `windowsEngine.runnerContainers` counts the matches, and is `null` when the pattern is empty.
|
|
125
|
+
|
|
126
|
+
Editing the overrides file invalidates the cache. Reports are kept in `.claude/host-container-capabilities.<platform>.local.json`, one per platform, so a Windows session and a WSL session on the same checkout don't overwrite each other. Add `.claude/*.local.json` to the project's `.gitignore`.
|
|
@@ -0,0 +1,304 @@
|
|
|
1
|
+
<#
|
|
2
|
+
.SYNOPSIS
|
|
3
|
+
Reports which container engines this host can use (a Windows engine, a Linux engine, both or neither), and caches
|
|
4
|
+
the answer for 24 hours.
|
|
5
|
+
|
|
6
|
+
.DESCRIPTION
|
|
7
|
+
The host-container-capabilities skill runs this before any container work, and routes each task to a local engine
|
|
8
|
+
or to CI.
|
|
9
|
+
|
|
10
|
+
On Windows it checks:
|
|
11
|
+
- the Windows engine behind the default docker context: its version, default isolation, and the isolation each of
|
|
12
|
+
ltsc2019, ltsc2022 and ltsc2025 needs on this host build
|
|
13
|
+
- a Linux engine, in this order: the default WSL distro's own Docker, any other WSL 2 distro's Docker (the
|
|
14
|
+
docker-desktop distros are skipped), the default docker context if it is a Linux engine, and Docker Desktop's
|
|
15
|
+
Linux engine pipe if it already exists. It stops at the first that answers. Probing a stopped WSL distro boots it.
|
|
16
|
+
- gh, and whether it is logged in (the exit code only; nothing it prints is kept)
|
|
17
|
+
|
|
18
|
+
It never switches Docker Desktop between Windows and Linux containers, never starts a daemon, and installs nothing.
|
|
19
|
+
|
|
20
|
+
The project root is $env:HOST_CONTAINER_CAPABILITIES_ROOT if set, otherwise the git work tree that contains the
|
|
21
|
+
current directory, otherwise the current directory.
|
|
22
|
+
|
|
23
|
+
The report is cached in <root>/.claude/host-container-capabilities.windows.local.json, which should be gitignored.
|
|
24
|
+
A cached report is reused until it is older than maxAgeHours, the overrides file is newer than it, or -Refresh is
|
|
25
|
+
given. Overrides are read from <root>/.claude/host-container-capabilities.overrides.local.json, a flat JSON object:
|
|
26
|
+
linuxEngine 'auto' (default), 'none', 'wsl:<distro>', 'docker' (the default docker context) or
|
|
27
|
+
'docker-desktop-linux'
|
|
28
|
+
sharedHost true (default). Set false only on a host that nothing else uses.
|
|
29
|
+
maxAgeHours 24 (default)
|
|
30
|
+
createdByLabel the key=value label the agent puts on everything it creates
|
|
31
|
+
(default 'io.github.kothar-hq.created-by=agent')
|
|
32
|
+
sessionLabelKey the label key that carries the session tag (default 'io.github.kothar-hq.session')
|
|
33
|
+
runnerContainerPattern a wildcard for the names of CI runner containers on the Windows engine, which are counted
|
|
34
|
+
in windowsEngine.runnerContainers (default '', not counted)
|
|
35
|
+
|
|
36
|
+
On Linux and macOS it runs host-capabilities.sh (in the same directory) instead, which writes the same report.
|
|
37
|
+
|
|
38
|
+
.PARAMETER Refresh
|
|
39
|
+
Probe again even if the cached report is still fresh, for example after a docker command failed to reach its engine.
|
|
40
|
+
|
|
41
|
+
.PARAMETER AsJson
|
|
42
|
+
Print the report as JSON text instead of returning an object.
|
|
43
|
+
|
|
44
|
+
.EXAMPLE
|
|
45
|
+
./Get-HostCapabilities.ps1 -AsJson
|
|
46
|
+
|
|
47
|
+
.EXAMPLE
|
|
48
|
+
(./Get-HostCapabilities.ps1 -Refresh).linuxEngine.invoke
|
|
49
|
+
#>
|
|
50
|
+
#Requires -Version 7.0
|
|
51
|
+
[CmdletBinding()]
|
|
52
|
+
param(
|
|
53
|
+
[switch]$Refresh,
|
|
54
|
+
[switch]$AsJson
|
|
55
|
+
)
|
|
56
|
+
$ErrorActionPreference = 'Stop'
|
|
57
|
+
|
|
58
|
+
if (-not $IsWindows) {
|
|
59
|
+
$shArgs = @("$PSScriptRoot/host-capabilities.sh")
|
|
60
|
+
if ($Refresh) { $shArgs += '--refresh' }
|
|
61
|
+
$json = (& sh @shArgs) -join "`n"
|
|
62
|
+
if ($LASTEXITCODE) { throw "host-capabilities.sh failed with exit code $LASTEXITCODE" }
|
|
63
|
+
if ($AsJson) { $json } else { $json | ConvertFrom-Json }
|
|
64
|
+
return
|
|
65
|
+
}
|
|
66
|
+
|
|
67
|
+
# The project root: the environment variable, else the git work tree around the current directory, else the
|
|
68
|
+
# current directory.
|
|
69
|
+
function Resolve-ProjectRoot {
|
|
70
|
+
if ($env:HOST_CONTAINER_CAPABILITIES_ROOT) {
|
|
71
|
+
return (Resolve-Path -LiteralPath $env:HOST_CONTAINER_CAPABILITIES_ROOT).Path
|
|
72
|
+
}
|
|
73
|
+
try {
|
|
74
|
+
$top = & git rev-parse --show-toplevel 2>$null
|
|
75
|
+
if ($LASTEXITCODE -eq 0 -and $top) { return (Resolve-Path -LiteralPath $top).Path }
|
|
76
|
+
} catch {
|
|
77
|
+
Write-Verbose "git is not available: $_"
|
|
78
|
+
}
|
|
79
|
+
(Get-Location).Path
|
|
80
|
+
}
|
|
81
|
+
|
|
82
|
+
$schemaVersion = 1
|
|
83
|
+
$projectRoot = Resolve-ProjectRoot
|
|
84
|
+
$cachePath = Join-Path $projectRoot '.claude/host-container-capabilities.windows.local.json'
|
|
85
|
+
$overridesPath = Join-Path $projectRoot '.claude/host-container-capabilities.overrides.local.json'
|
|
86
|
+
|
|
87
|
+
$overrides = [ordered]@{
|
|
88
|
+
linuxEngine = 'auto'
|
|
89
|
+
sharedHost = $true
|
|
90
|
+
maxAgeHours = 24
|
|
91
|
+
createdByLabel = 'io.github.kothar-hq.created-by=agent'
|
|
92
|
+
sessionLabelKey = 'io.github.kothar-hq.session'
|
|
93
|
+
runnerContainerPattern = ''
|
|
94
|
+
}
|
|
95
|
+
if (Test-Path $overridesPath) {
|
|
96
|
+
$userOverrides = Get-Content -Raw $overridesPath | ConvertFrom-Json -AsHashtable
|
|
97
|
+
foreach ($key in @($userOverrides.Keys)) {
|
|
98
|
+
if ($overrides.Contains($key)) { $overrides[$key] = $userOverrides[$key] } else { Write-Warning "Ignoring unknown override '$key'" }
|
|
99
|
+
}
|
|
100
|
+
if ($overrides.linuxEngine -notmatch '^(auto|none|docker|docker-desktop-linux|wsl:.+)$') {
|
|
101
|
+
throw "Override linuxEngine '$($overrides.linuxEngine)' must be auto, none, docker, docker-desktop-linux or wsl:<distro>"
|
|
102
|
+
}
|
|
103
|
+
if ($overrides.createdByLabel -notmatch '^[^=\s]+=\S+$') {
|
|
104
|
+
throw "Override createdByLabel '$($overrides.createdByLabel)' must be key=value, without spaces"
|
|
105
|
+
}
|
|
106
|
+
if ($overrides.sessionLabelKey -notmatch '^[^=\s]+$') {
|
|
107
|
+
throw "Override sessionLabelKey '$($overrides.sessionLabelKey)' must be a label key, without spaces or '='"
|
|
108
|
+
}
|
|
109
|
+
}
|
|
110
|
+
|
|
111
|
+
if (-not $Refresh -and (Test-Path $cachePath)) {
|
|
112
|
+
$cachedJson = Get-Content -Raw $cachePath
|
|
113
|
+
$cached = $cachedJson | ConvertFrom-Json
|
|
114
|
+
$ageHours = ([DateTimeOffset]::UtcNow.ToUnixTimeSeconds() - $cached.probedAtEpoch) / 3600
|
|
115
|
+
$overridesChanged = (Test-Path $overridesPath) -and
|
|
116
|
+
(Get-Item $overridesPath).LastWriteTimeUtc -gt (Get-Item $cachePath).LastWriteTimeUtc
|
|
117
|
+
if ($cached.schemaVersion -eq $schemaVersion -and $ageHours -lt $overrides.maxAgeHours -and -not $overridesChanged) {
|
|
118
|
+
if ($AsJson) { $cachedJson.TrimEnd() } else { $cached }
|
|
119
|
+
return
|
|
120
|
+
}
|
|
121
|
+
}
|
|
122
|
+
|
|
123
|
+
# Runs a native command and returns its exit code and output. A missing command is exit code -1.
|
|
124
|
+
function Invoke-Native {
|
|
125
|
+
param([Parameter(Mandatory)][string[]]$Command)
|
|
126
|
+
$ErrorActionPreference = 'Continue'
|
|
127
|
+
try {
|
|
128
|
+
$output = & $Command[0] @($Command | Select-Object -Skip 1) 2>&1
|
|
129
|
+
[pscustomobject]@{ ExitCode = $LASTEXITCODE; Output = (@($output | ForEach-Object { "$_" }) -join "`n").Trim() }
|
|
130
|
+
} catch {
|
|
131
|
+
[pscustomobject]@{ ExitCode = -1; Output = "$_" }
|
|
132
|
+
}
|
|
133
|
+
}
|
|
134
|
+
|
|
135
|
+
# Asks a docker CLI (an array such as 'wsl.exe','-d','Ubuntu','--exec','docker') about its engine. Returns $null
|
|
136
|
+
# and sets $script:probeError when the engine doesn't answer.
|
|
137
|
+
function Get-EngineFact {
|
|
138
|
+
param([Parameter(Mandatory)][string[]]$Docker)
|
|
139
|
+
$version = Invoke-Native ($Docker + @('version', '--format', '{{.Server.Os}}|{{.Server.Version}}'))
|
|
140
|
+
if ($version.ExitCode -ne 0 -or $version.Output -notmatch '^(\w+)\|(\S+)$') {
|
|
141
|
+
$script:probeError = ($version.Output -split "`n" | Where-Object { $_ } | Select-Object -First 1) ?? 'no output'
|
|
142
|
+
return $null
|
|
143
|
+
}
|
|
144
|
+
$os, $serverVersion = $Matches[1], $Matches[2]
|
|
145
|
+
$info = Invoke-Native ($Docker + @('info', '--format', '{{.OperatingSystem}}|{{.Isolation}}'))
|
|
146
|
+
$operatingSystem, $isolation = if ($info.ExitCode -eq 0) { $info.Output -split '\|', 2 } else { $null, $null }
|
|
147
|
+
$endpoint = Invoke-Native ($Docker + @('context', 'inspect', '--format', '{{.Endpoints.docker.Host}}'))
|
|
148
|
+
$names = Invoke-Native ($Docker + @('ps', '-a', '--format', '{{.Names}}'))
|
|
149
|
+
$mine = Invoke-Native ($Docker + @('ps', '-aq', '--filter', "label=$($overrides.createdByLabel)"))
|
|
150
|
+
[pscustomobject]@{
|
|
151
|
+
Os = $os
|
|
152
|
+
ServerVersion = $serverVersion
|
|
153
|
+
OperatingSystem = $operatingSystem
|
|
154
|
+
Isolation = if ($isolation) { $isolation } else { $null }
|
|
155
|
+
Endpoint = if ($endpoint.ExitCode -eq 0) { $endpoint.Output } else { $null }
|
|
156
|
+
Names = @($names.Output -split "`n" | Where-Object { $_ })
|
|
157
|
+
ClaudeCount = @($mine.Output -split "`n" | Where-Object { $_ }).Count
|
|
158
|
+
}
|
|
159
|
+
}
|
|
160
|
+
|
|
161
|
+
# Host
|
|
162
|
+
$os = Get-CimInstance Win32_OperatingSystem
|
|
163
|
+
$hostBuild = [Environment]::OSVersion.Version.Build
|
|
164
|
+
$hypervisorPresent = [bool](Get-CimInstance Win32_ComputerSystem).HypervisorPresent
|
|
165
|
+
$dockerDesktopInstalled = Test-Path (Join-Path $env:ProgramFiles 'Docker/Docker/Docker Desktop.exe')
|
|
166
|
+
$dockerDesktopLinuxPipe = Test-Path '\\.\pipe\dockerDesktopLinuxEngine'
|
|
167
|
+
|
|
168
|
+
# The default docker context: usually the Windows engine, or a Linux engine if Docker Desktop is in Linux mode
|
|
169
|
+
$defaultEngine = Get-EngineFact @('docker')
|
|
170
|
+
$defaultError = $script:probeError
|
|
171
|
+
|
|
172
|
+
# Windows engine. Process isolation needs the image's build to match the host's; anything else needs Hyper-V.
|
|
173
|
+
# Microsoft documents a few more cross-build process-isolation pairs, which this ignores.
|
|
174
|
+
$windowsEngine = [ordered]@{
|
|
175
|
+
available = $false; invoke = $null; endpoint = $null; serverVersion = $null; defaultIsolation = $null
|
|
176
|
+
ltsc = $null; runnerContainers = $null; reason = $null
|
|
177
|
+
}
|
|
178
|
+
if ($defaultEngine -and $defaultEngine.Os -eq 'windows') {
|
|
179
|
+
$ltsc = [ordered]@{}
|
|
180
|
+
foreach ($entry in @(@('2019', 17763), @('2022', 20348), @('2025', 26100))) {
|
|
181
|
+
$isolation = if ($hostBuild -eq $entry[1]) { 'process' } elseif ($hypervisorPresent) { 'hyperv' } else { $null }
|
|
182
|
+
$ltsc[$entry[0]] = [ordered]@{ imageBuild = $entry[1]; isolation = $isolation }
|
|
183
|
+
}
|
|
184
|
+
$windowsEngine.available = $true
|
|
185
|
+
$windowsEngine.invoke = @('docker')
|
|
186
|
+
$windowsEngine.endpoint = $defaultEngine.Endpoint
|
|
187
|
+
$windowsEngine.serverVersion = $defaultEngine.ServerVersion
|
|
188
|
+
$windowsEngine.defaultIsolation = $defaultEngine.Isolation
|
|
189
|
+
$windowsEngine.ltsc = $ltsc
|
|
190
|
+
if ($overrides.runnerContainerPattern) {
|
|
191
|
+
$windowsEngine.runnerContainers = @($defaultEngine.Names | Where-Object { $_ -like $overrides.runnerContainerPattern }).Count
|
|
192
|
+
}
|
|
193
|
+
} elseif ($defaultEngine) {
|
|
194
|
+
$windowsEngine.reason = "the default docker context is a $($defaultEngine.Os) engine; Docker Desktop is never switched"
|
|
195
|
+
} else {
|
|
196
|
+
$windowsEngine.reason = "docker: $defaultError"
|
|
197
|
+
}
|
|
198
|
+
|
|
199
|
+
# WSL distros. WSL_UTF8 makes wsl.exe print UTF-8 instead of UTF-16.
|
|
200
|
+
$previousWslUtf8 = $env:WSL_UTF8
|
|
201
|
+
$env:WSL_UTF8 = '1'
|
|
202
|
+
try {
|
|
203
|
+
$wslList = Invoke-Native @('wsl.exe', '--list', '--verbose')
|
|
204
|
+
$distros = @()
|
|
205
|
+
if ($wslList.ExitCode -eq 0) {
|
|
206
|
+
foreach ($line in $wslList.Output -split "`n") {
|
|
207
|
+
if ($line -match '^\s*(\*)?\s*(\S+)\s+(\S+)\s+([12])\s*$' -and $Matches[2] -ne 'NAME') {
|
|
208
|
+
$distros += [pscustomobject][ordered]@{
|
|
209
|
+
name = $Matches[2]; state = $Matches[3]; version = [int]$Matches[4]; default = [bool]$Matches[1]
|
|
210
|
+
}
|
|
211
|
+
}
|
|
212
|
+
}
|
|
213
|
+
}
|
|
214
|
+
$wsl = [ordered]@{
|
|
215
|
+
installed = $wslList.ExitCode -eq 0
|
|
216
|
+
defaultDistro = ($distros | Where-Object default | Select-Object -First 1).name
|
|
217
|
+
distros = $distros
|
|
218
|
+
}
|
|
219
|
+
|
|
220
|
+
# Linux engine candidates, in the order the skill prefers them
|
|
221
|
+
$candidates = @()
|
|
222
|
+
$wslCandidates = @($distros | Where-Object { $_.version -eq 2 -and $_.name -notlike 'docker-desktop*' } |
|
|
223
|
+
Sort-Object { -not $_.default })
|
|
224
|
+
switch -Regex ($overrides.linuxEngine) {
|
|
225
|
+
'^auto$' {
|
|
226
|
+
$candidates += $wslCandidates | ForEach-Object { [pscustomobject]@{ Kind = 'wsl'; Distro = $_.name } }
|
|
227
|
+
$candidates += [pscustomobject]@{ Kind = 'docker'; Distro = $null }
|
|
228
|
+
if ($dockerDesktopLinuxPipe) { $candidates += [pscustomobject]@{ Kind = 'docker-desktop-linux'; Distro = $null } }
|
|
229
|
+
}
|
|
230
|
+
'^wsl:(.+)$' { $candidates += [pscustomobject]@{ Kind = 'wsl'; Distro = $Matches[1] } }
|
|
231
|
+
'^(docker|docker-desktop-linux)$' { $candidates += [pscustomobject]@{ Kind = $Matches[1]; Distro = $null } }
|
|
232
|
+
}
|
|
233
|
+
|
|
234
|
+
$linuxEngine = [ordered]@{
|
|
235
|
+
available = $false; kind = $null; distro = $null; invoke = $null; endpoint = $null; serverVersion = $null
|
|
236
|
+
operatingSystem = $null; containers = $null; claudeContainers = $null; repoPath = $null; reason = $null; tried = @()
|
|
237
|
+
}
|
|
238
|
+
foreach ($candidate in $candidates) {
|
|
239
|
+
$docker = switch ($candidate.Kind) {
|
|
240
|
+
'wsl' { @('wsl.exe', '--distribution', $candidate.Distro, '--exec', 'docker') }
|
|
241
|
+
'docker' { @('docker') }
|
|
242
|
+
'docker-desktop-linux' { @('docker', '--host', 'npipe:////./pipe/dockerDesktopLinuxEngine') }
|
|
243
|
+
}
|
|
244
|
+
$fact = if ($candidate.Kind -eq 'docker') { $defaultEngine } else { Get-EngineFact $docker }
|
|
245
|
+
$failure = if (-not $fact) { if ($candidate.Kind -eq 'docker') { $defaultError } else { $script:probeError } }
|
|
246
|
+
elseif ($fact.Os -ne 'linux') { "it is a $($fact.Os) engine" }
|
|
247
|
+
if ($failure) {
|
|
248
|
+
$linuxEngine.tried += [ordered]@{ kind = $candidate.Kind; distro = $candidate.Distro; error = $failure }
|
|
249
|
+
continue
|
|
250
|
+
}
|
|
251
|
+
$repoPath = if ($candidate.Kind -eq 'wsl') {
|
|
252
|
+
(Invoke-Native @('wsl.exe', '--distribution', $candidate.Distro, '--exec', 'wslpath', '-a', $projectRoot)).Output
|
|
253
|
+
} else { $null }
|
|
254
|
+
$linuxEngine.available = $true
|
|
255
|
+
$linuxEngine.kind = $candidate.Kind
|
|
256
|
+
$linuxEngine.distro = $candidate.Distro
|
|
257
|
+
$linuxEngine.invoke = $docker
|
|
258
|
+
$linuxEngine.endpoint = $fact.Endpoint
|
|
259
|
+
$linuxEngine.serverVersion = $fact.ServerVersion
|
|
260
|
+
$linuxEngine.operatingSystem = $fact.OperatingSystem
|
|
261
|
+
$linuxEngine.containers = $fact.Names.Count
|
|
262
|
+
$linuxEngine.claudeContainers = $fact.ClaudeCount
|
|
263
|
+
$linuxEngine.repoPath = $repoPath
|
|
264
|
+
break
|
|
265
|
+
}
|
|
266
|
+
if (-not $linuxEngine.available) {
|
|
267
|
+
$linuxEngine.reason = if ($overrides.linuxEngine -eq 'none') { 'disabled by the linuxEngine override' }
|
|
268
|
+
elseif (-not $wsl.installed) { 'WSL is not installed and no other Linux engine answered' }
|
|
269
|
+
else { 'no candidate answered; see tried' }
|
|
270
|
+
}
|
|
271
|
+
} finally {
|
|
272
|
+
$env:WSL_UTF8 = $previousWslUtf8
|
|
273
|
+
}
|
|
274
|
+
|
|
275
|
+
$gh = [bool](Get-Command gh -ErrorAction SilentlyContinue)
|
|
276
|
+
$report = [ordered]@{
|
|
277
|
+
schemaVersion = $schemaVersion
|
|
278
|
+
probedAt = [DateTimeOffset]::UtcNow.ToString("yyyy-MM-dd'T'HH:mm:ss'Z'")
|
|
279
|
+
probedAtEpoch = [DateTimeOffset]::UtcNow.ToUnixTimeSeconds()
|
|
280
|
+
platform = 'windows'
|
|
281
|
+
overrides = $overrides
|
|
282
|
+
policy = [ordered]@{
|
|
283
|
+
sharedHost = [bool]$overrides.sharedHost
|
|
284
|
+
dockerDesktopSwitch = 'never'
|
|
285
|
+
createdByLabel = $overrides.createdByLabel
|
|
286
|
+
sessionLabelKey = $overrides.sessionLabelKey
|
|
287
|
+
}
|
|
288
|
+
host = [ordered]@{
|
|
289
|
+
os = 'windows'
|
|
290
|
+
caption = $os.Caption
|
|
291
|
+
build = $hostBuild
|
|
292
|
+
productType = if ($os.ProductType -eq 1) { 'workstation' } else { 'server' }
|
|
293
|
+
hypervisorPresent = $hypervisorPresent
|
|
294
|
+
dockerDesktop = [ordered]@{ installed = $dockerDesktopInstalled; linuxEnginePipe = $dockerDesktopLinuxPipe }
|
|
295
|
+
}
|
|
296
|
+
windowsEngine = $windowsEngine
|
|
297
|
+
linuxEngine = $linuxEngine
|
|
298
|
+
wsl = $wsl
|
|
299
|
+
github = [ordered]@{ gh = $gh; authenticated = $gh -and (Invoke-Native @('gh', 'auth', 'status')).ExitCode -eq 0 }
|
|
300
|
+
}
|
|
301
|
+
$json = $report | ConvertTo-Json -Depth 8
|
|
302
|
+
New-Item -ItemType Directory -Force -Path (Split-Path -Parent $cachePath) | Out-Null
|
|
303
|
+
Set-Content -Path $cachePath -Value $json -Encoding utf8NoBOM
|
|
304
|
+
if ($AsJson) { $json } else { $json | ConvertFrom-Json }
|
|
@@ -0,0 +1,208 @@
|
|
|
1
|
+
#!/bin/sh
|
|
2
|
+
# host-capabilities.sh: report, as JSON, which container engines this host can use, and cache the answer
|
|
3
|
+
# (the host-container-capabilities skill).
|
|
4
|
+
#
|
|
5
|
+
# host-capabilities.sh print the cached report, probing first if it is missing or stale
|
|
6
|
+
# host-capabilities.sh --refresh probe again
|
|
7
|
+
#
|
|
8
|
+
# For Linux, macOS and WSL. On Windows (Git Bash, MSYS, Cygwin) it runs Get-HostCapabilities.ps1 instead.
|
|
9
|
+
# The project root is $HOST_CONTAINER_CAPABILITIES_ROOT, else the git work tree around the current directory,
|
|
10
|
+
# else the current directory. The report is cached in <root>/.claude/host-container-capabilities.<platform>.local.json.
|
|
11
|
+
# Overrides are read from <root>/.claude/host-container-capabilities.overrides.local.json, a flat JSON object with
|
|
12
|
+
# linuxEngine (auto, none or docker), sharedHost (true), maxAgeHours (24), createdByLabel, sessionLabelKey and
|
|
13
|
+
# runnerContainerPattern. It never starts a daemon and installs nothing.
|
|
14
|
+
set -eu
|
|
15
|
+
|
|
16
|
+
here=$(cd "$(dirname "$0")" && pwd)
|
|
17
|
+
refresh=0
|
|
18
|
+
case "${1:-}" in
|
|
19
|
+
--refresh) refresh=1 ;;
|
|
20
|
+
'') ;;
|
|
21
|
+
-h | --help) sed -n '2,13p' "$0"; exit 0 ;;
|
|
22
|
+
*) echo "host-capabilities.sh: unknown argument '$1'" >&2; exit 2 ;;
|
|
23
|
+
esac
|
|
24
|
+
|
|
25
|
+
case "$(uname -s)" in
|
|
26
|
+
MINGW* | MSYS* | CYGWIN*)
|
|
27
|
+
if [ "$refresh" = 1 ]; then
|
|
28
|
+
exec pwsh -NoProfile -File "$here/Get-HostCapabilities.ps1" -AsJson -Refresh
|
|
29
|
+
fi
|
|
30
|
+
exec pwsh -NoProfile -File "$here/Get-HostCapabilities.ps1" -AsJson
|
|
31
|
+
;;
|
|
32
|
+
Darwin) platform=macos ;;
|
|
33
|
+
*) if grep -qi microsoft /proc/sys/kernel/osrelease 2>/dev/null; then platform=wsl; else platform=linux; fi ;;
|
|
34
|
+
esac
|
|
35
|
+
|
|
36
|
+
if [ -n "${HOST_CONTAINER_CAPABILITIES_ROOT:-}" ]; then
|
|
37
|
+
root=$(cd "$HOST_CONTAINER_CAPABILITIES_ROOT" && pwd)
|
|
38
|
+
elif root=$(git rev-parse --show-toplevel 2>/dev/null) && [ -n "$root" ]; then
|
|
39
|
+
:
|
|
40
|
+
else
|
|
41
|
+
root=$(pwd)
|
|
42
|
+
fi
|
|
43
|
+
|
|
44
|
+
schema=1
|
|
45
|
+
cache="$root/.claude/host-container-capabilities.$platform.local.json"
|
|
46
|
+
overrides="$root/.claude/host-container-capabilities.overrides.local.json"
|
|
47
|
+
|
|
48
|
+
# json_value <file> <key>: a top-level string, number or boolean from a flat JSON file (empty if absent)
|
|
49
|
+
json_value() {
|
|
50
|
+
[ -f "$1" ] || return 0
|
|
51
|
+
tr -d '\n\r' <"$1" | sed -n "s/.*\"$2\"[[:space:]]*:[[:space:]]*\"\{0,1\}\([^\",}[:space:]]*\).*/\1/p"
|
|
52
|
+
}
|
|
53
|
+
# js <value>: a JSON string, or null when empty
|
|
54
|
+
js() {
|
|
55
|
+
if [ -z "$1" ]; then printf null; return; fi
|
|
56
|
+
printf '"%s"' "$(printf '%s' "$1" | tr -d '\n\r\t' | sed 's/\\/\\\\/g; s/"/\\"/g')"
|
|
57
|
+
}
|
|
58
|
+
# jn <value>: a JSON number, or null when empty
|
|
59
|
+
jn() { if [ -z "$1" ]; then printf null; else printf '%s' "$1"; fi; }
|
|
60
|
+
|
|
61
|
+
ov_engine=$(json_value "$overrides" linuxEngine)
|
|
62
|
+
ov_shared=$(json_value "$overrides" sharedHost)
|
|
63
|
+
ov_age=$(json_value "$overrides" maxAgeHours)
|
|
64
|
+
ov_label=$(json_value "$overrides" createdByLabel)
|
|
65
|
+
ov_session=$(json_value "$overrides" sessionLabelKey)
|
|
66
|
+
ov_runners=$(json_value "$overrides" runnerContainerPattern)
|
|
67
|
+
: "${ov_engine:=auto}" "${ov_shared:=true}" "${ov_age:=24}"
|
|
68
|
+
: "${ov_label:=io.github.kothar-hq.created-by=agent}" "${ov_session:=io.github.kothar-hq.session}"
|
|
69
|
+
case "$ov_engine" in auto | none | docker) ;; *)
|
|
70
|
+
echo "host-capabilities.sh: override linuxEngine '$ov_engine' must be auto, none or docker here" >&2; exit 2 ;;
|
|
71
|
+
esac
|
|
72
|
+
case "$ov_shared" in true | false) ;; *) echo "host-capabilities.sh: override sharedHost must be true or false" >&2; exit 2 ;; esac
|
|
73
|
+
case "$ov_age" in '' | *[!0-9]*) echo "host-capabilities.sh: override maxAgeHours must be a whole number" >&2; exit 2 ;; esac
|
|
74
|
+
case "$ov_label" in ?*=?*) ;; *) echo "host-capabilities.sh: override createdByLabel must be key=value" >&2; exit 2 ;; esac
|
|
75
|
+
case "$ov_session" in *=*) echo "host-capabilities.sh: override sessionLabelKey must be a label key, without '='" >&2; exit 2 ;; esac
|
|
76
|
+
|
|
77
|
+
now=$(date +%s)
|
|
78
|
+
if [ "$refresh" = 0 ] && [ -f "$cache" ] && [ -z "$(find "$overrides" -newer "$cache" 2>/dev/null)" ]; then
|
|
79
|
+
cached_schema=$(json_value "$cache" schemaVersion)
|
|
80
|
+
cached_epoch=$(json_value "$cache" probedAtEpoch)
|
|
81
|
+
if [ "$cached_schema" = "$schema" ] && [ -n "$cached_epoch" ] && [ $((now - cached_epoch)) -lt $((ov_age * 3600)) ]; then
|
|
82
|
+
cat "$cache"
|
|
83
|
+
exit 0
|
|
84
|
+
fi
|
|
85
|
+
fi
|
|
86
|
+
|
|
87
|
+
# probe <docker command...>: asks a docker CLI about its engine. Sets p_os, p_version, p_osname, p_isolation,
|
|
88
|
+
# p_endpoint, p_total and p_mine, or p_error when the engine doesn't answer.
|
|
89
|
+
probe() {
|
|
90
|
+
p_os='' p_version='' p_osname='' p_isolation='' p_endpoint='' p_total='' p_mine='' p_error=''
|
|
91
|
+
if ! out=$("$@" version --format '{{.Server.Os}}|{{.Server.Version}}' 2>&1) || [ "${out#*|}" = "$out" ]; then
|
|
92
|
+
p_error=$(printf '%s\n' "$out" | sed -n '/./{p;q;}')
|
|
93
|
+
: "${p_error:=no output}"
|
|
94
|
+
return 1
|
|
95
|
+
fi
|
|
96
|
+
p_os=${out%%|*} p_version=${out#*|}
|
|
97
|
+
info=$("$@" info --format '{{.OperatingSystem}}|{{.Isolation}}' 2>/dev/null || true)
|
|
98
|
+
p_osname=${info%%|*} p_isolation=${info#*|}
|
|
99
|
+
p_endpoint=$("$@" context inspect --format '{{.Endpoints.docker.Host}}' 2>/dev/null || true)
|
|
100
|
+
p_total=$("$@" ps -aq 2>/dev/null | wc -l | tr -d ' ')
|
|
101
|
+
p_mine=$("$@" ps -aq --filter "label=$ov_label" 2>/dev/null | wc -l | tr -d ' ')
|
|
102
|
+
}
|
|
103
|
+
|
|
104
|
+
# Linux engine: the docker CLI's current context (DOCKER_HOST, Docker Desktop, colima, a distro's own Docker)
|
|
105
|
+
l_available=false l_kind='' l_invoke=null l_version='' l_osname='' l_endpoint='' l_total='' l_mine='' l_reason='' l_tried=''
|
|
106
|
+
if [ "$ov_engine" = none ]; then
|
|
107
|
+
l_reason='disabled by the linuxEngine override'
|
|
108
|
+
elif ! command -v docker >/dev/null 2>&1; then
|
|
109
|
+
l_reason='docker CLI not installed'
|
|
110
|
+
l_tried="{\"kind\": \"docker\", \"distro\": null, \"error\": $(js "$l_reason")}"
|
|
111
|
+
elif probe docker && [ "$p_os" = linux ]; then
|
|
112
|
+
l_available=true l_kind=docker l_invoke='["docker"]' l_version=$p_version l_osname=$p_osname
|
|
113
|
+
l_endpoint=$p_endpoint l_total=$p_total l_mine=$p_mine
|
|
114
|
+
else
|
|
115
|
+
if [ -n "$p_os" ]; then p_error="it is a $p_os engine"; fi
|
|
116
|
+
l_reason='no candidate answered; see tried'
|
|
117
|
+
l_tried="{\"kind\": \"docker\", \"distro\": null, \"error\": $(js "$p_error")}"
|
|
118
|
+
fi
|
|
119
|
+
|
|
120
|
+
# Windows engine: only reachable from WSL, through Windows' docker.exe
|
|
121
|
+
w_available=false w_invoke=null w_endpoint='' w_version='' w_isolation='' w_runners='' w_reason=''
|
|
122
|
+
if [ "$platform" != wsl ]; then
|
|
123
|
+
w_reason="a $platform host has no Windows engine"
|
|
124
|
+
elif ! command -v docker.exe >/dev/null 2>&1; then
|
|
125
|
+
w_reason='docker.exe is not on the PATH (WSL interop off, or no Windows Docker)'
|
|
126
|
+
elif probe docker.exe && [ "$p_os" = windows ]; then
|
|
127
|
+
w_available=true w_invoke='["docker.exe"]' w_endpoint=$p_endpoint w_version=$p_version w_isolation=$p_isolation
|
|
128
|
+
if [ -n "$ov_runners" ]; then
|
|
129
|
+
w_runners=0
|
|
130
|
+
for name in $(docker.exe ps -a --format '{{.Names}}' 2>/dev/null | tr -d '\r'); do
|
|
131
|
+
# shellcheck disable=SC2254 # the override is a wildcard pattern
|
|
132
|
+
case "$name" in $ov_runners) w_runners=$((w_runners + 1)) ;; esac
|
|
133
|
+
done
|
|
134
|
+
fi
|
|
135
|
+
w_reason='per-LTSC isolation is only worked out on Windows: run Get-HostCapabilities.ps1 with pwsh.exe'
|
|
136
|
+
else
|
|
137
|
+
if [ -n "$p_os" ]; then p_error="docker.exe reaches a $p_os engine"; fi
|
|
138
|
+
w_reason="docker.exe: $p_error"
|
|
139
|
+
fi
|
|
140
|
+
|
|
141
|
+
gh=false gh_auth=false
|
|
142
|
+
if command -v gh >/dev/null 2>&1; then
|
|
143
|
+
gh=true
|
|
144
|
+
if gh auth status >/dev/null 2>&1; then gh_auth=true; fi
|
|
145
|
+
fi
|
|
146
|
+
if [ "$platform" = macos ]; then os=macos; else os=linux; fi
|
|
147
|
+
|
|
148
|
+
mkdir -p "$(dirname "$cache")"
|
|
149
|
+
cat >"$cache" <<EOF
|
|
150
|
+
{
|
|
151
|
+
"schemaVersion": $schema,
|
|
152
|
+
"probedAt": "$(date -u +%Y-%m-%dT%H:%M:%SZ)",
|
|
153
|
+
"probedAtEpoch": $now,
|
|
154
|
+
"platform": "$platform",
|
|
155
|
+
"overrides": {
|
|
156
|
+
"linuxEngine": $(js "$ov_engine"),
|
|
157
|
+
"sharedHost": $ov_shared,
|
|
158
|
+
"maxAgeHours": $ov_age,
|
|
159
|
+
"createdByLabel": $(js "$ov_label"),
|
|
160
|
+
"sessionLabelKey": $(js "$ov_session"),
|
|
161
|
+
"runnerContainerPattern": "$(printf '%s' "$ov_runners" | sed 's/\\/\\\\/g; s/"/\\"/g')"
|
|
162
|
+
},
|
|
163
|
+
"policy": {
|
|
164
|
+
"sharedHost": $ov_shared,
|
|
165
|
+
"dockerDesktopSwitch": "never",
|
|
166
|
+
"createdByLabel": $(js "$ov_label"),
|
|
167
|
+
"sessionLabelKey": $(js "$ov_session")
|
|
168
|
+
},
|
|
169
|
+
"host": {
|
|
170
|
+
"os": "$os",
|
|
171
|
+
"caption": $(js "$(uname -sr)"),
|
|
172
|
+
"build": null,
|
|
173
|
+
"productType": null,
|
|
174
|
+
"hypervisorPresent": null,
|
|
175
|
+
"dockerDesktop": null
|
|
176
|
+
},
|
|
177
|
+
"windowsEngine": {
|
|
178
|
+
"available": $w_available,
|
|
179
|
+
"invoke": $w_invoke,
|
|
180
|
+
"endpoint": $(js "$w_endpoint"),
|
|
181
|
+
"serverVersion": $(js "$w_version"),
|
|
182
|
+
"defaultIsolation": $(js "$w_isolation"),
|
|
183
|
+
"ltsc": null,
|
|
184
|
+
"runnerContainers": $(jn "$w_runners"),
|
|
185
|
+
"reason": $(js "$w_reason")
|
|
186
|
+
},
|
|
187
|
+
"linuxEngine": {
|
|
188
|
+
"available": $l_available,
|
|
189
|
+
"kind": $(js "$l_kind"),
|
|
190
|
+
"distro": $(js "$(if [ "$platform" = wsl ]; then printf '%s' "${WSL_DISTRO_NAME:-}"; fi)"),
|
|
191
|
+
"invoke": $l_invoke,
|
|
192
|
+
"endpoint": $(js "$l_endpoint"),
|
|
193
|
+
"serverVersion": $(js "$l_version"),
|
|
194
|
+
"operatingSystem": $(js "$l_osname"),
|
|
195
|
+
"containers": $(jn "$l_total"),
|
|
196
|
+
"claudeContainers": $(jn "$l_mine"),
|
|
197
|
+
"repoPath": $(js "$root"),
|
|
198
|
+
"reason": $(js "$l_reason"),
|
|
199
|
+
"tried": [$l_tried]
|
|
200
|
+
},
|
|
201
|
+
"wsl": null,
|
|
202
|
+
"github": {
|
|
203
|
+
"gh": $gh,
|
|
204
|
+
"authenticated": $gh_auth
|
|
205
|
+
}
|
|
206
|
+
}
|
|
207
|
+
EOF
|
|
208
|
+
cat "$cache"
|