Skip to content
Closed
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
24 changes: 24 additions & 0 deletions CHANGELOG.md
Original file line number Diff line number Diff line change
@@ -1,5 +1,29 @@
# Changelog

## Unreleased

This change aligns the installer with the harness skill discovery rules so
that shared skills are discovered exactly once.

### Changes

- Install Codex, OpenCode, and pi skills into the shared `~/.agents/skills/`
directory they all discover, instead of duplicating a copy into
`~/.config/opencode/skills/` and `~/.pi/agent/skills/`. Claude Code keeps
`~/.claude/skills/`. Duplicate definitions, same-name override warnings, and
stale copies that shadowed updated shared skills are gone.
- Keep artifacts in their harness-specific destinations, and keep the Claude
frontmatter adaptation for the Claude copy.
- Add `--migrate` to remove legacy 0.4.0 copies from
`~/.config/opencode/skills/` and `~/.pi/agent/skills/` after installing.
Only copies that match the current mstack source byte for byte are removed;
modified copies and unrelated skills are kept and reported, with backups
under the legacy directory. `--dry-run` previews the migration and reports
legacy copies left behind without `--migrate`.
- Group shared targets in installer output, note OpenCode's residual scan of
`~/.claude/skills/`, and document the discovery model in the README and the
harness adapter reference.

## 0.4.0 - 2026-09-12

This release adds a shared project workflow for teams using pstack and mstack
Expand Down
52 changes: 46 additions & 6 deletions README.md
Original file line number Diff line number Diff line change
Expand Up @@ -46,6 +46,33 @@ backups therefore stay inside that Harness's `skills/` directory, and custom
artifact backups stay beside the artifact target. mstack retains backups until
you inspect and remove them.

Codex, OpenCode, and pi share one install directory. OpenCode and pi also
discover `~/.agents/skills/`, so installing a separate copy for them would make
every Harness report duplicate definitions and keep serving stale copies after
an update. The installer therefore plans skills per discovery directory: Codex,
OpenCode, and pi share `~/.agents/skills/`, while Claude Code keeps
`~/.claude/skills/`. Installing with `--harness all` reports the shared target
as `codex, opencode, pi (shared)` in `--dry-run` output.

OpenCode also scans `~/.claude/skills/`, so the shared copy and the Claude copy
are both discoverable there; upstream OpenCode reports same-named skills as
duplicates and applies one of them. mstack keeps both copies current on every
install, so either choice loads the same instructions. Use `HARNESS_SKILLS_OPENCODE_DIR`
to pin OpenCode to a directory only it discovers when that matters.

Installations made by mstack 0.4.0 placed OpenCode and pi copies in their own
config directories. After installing, run once with `--migrate` to remove the
redundant copies from those legacy directories:

```bash
npx @3metajun/mstack --harness all --replace --migrate
```

`--migrate` only removes legacy copies that match the current mstack source
byte for byte, and only for skills selected by that install. Modified copies
and unrelated skills are kept and reported. Use `--dry-run` to preview which
copies would be removed or kept.

## Install optional artifacts

The same installer can copy the portable artifacts that accompany the skills.
Expand All @@ -61,9 +88,14 @@ The available installable artifacts are:

| Artifact | Default destination |
| --- | --- |
| `agents` | Codex: `$CODEX_HOME/agents/` as TOML; other harnesses: `agents/` beside `skills/` as Markdown |
| `meta-mode-tools` | `tools/meta-mode/` beside `skills/` |
| `guide` | `docs/guide/` beside `skills/` |
| `agents` | Codex: `$CODEX_HOME/agents/` as TOML; Claude Code: `~/.claude/agents/`; OpenCode: `~/.config/opencode/agents/`; pi: `~/.pi/agent/agents/`, as Markdown |
| `meta-mode-tools` | `tools/meta-mode/` under the harness root: Codex `~/.agents/`, Claude Code `~/.claude/`, OpenCode `~/.config/opencode/`, pi `~/.pi/agent/` |
| `guide` | `docs/guide/` under the same harness roots |

Artifacts keep their Harness-specific destinations even though OpenCode and pi
skills are shared with Codex. Relocating a harness's skills with
`HARNESS_SKILLS_*_DIR` or an environment target also relocates that harness's
artifact base beside the relocated skills directory.

Codex skills default to `~/.agents/skills/`, while Codex agents default to
`~/.codex/agents/`. An unset or empty `CODEX_HOME` uses `~/.codex`.
Expand Down Expand Up @@ -109,14 +141,22 @@ written or merged explicitly by the user.
## Choose installation directories

The default directories are defined in
[`profiles/harnesses.json`](./profiles/harnesses.json):
[`profiles/harnesses.json`](./profiles/harnesses.json). They follow each
harness's discovery rules: OpenCode and pi both discover the shared
`~/.agents/skills/` directory, so they install there instead of keeping their
own copies.

| Harness | Default directory | Override |
| --- | --- | --- |
| Codex | `~/.agents/skills/` | `HARNESS_SKILLS_CODEX_DIR` |
| Claude Code | `~/.claude/skills/` | `HARNESS_SKILLS_CLAUDE_DIR` |
| OpenCode | `~/.config/opencode/skills/` | `HARNESS_SKILLS_OPENCODE_DIR` |
| pi | `~/.pi/agent/skills/` | `HARNESS_SKILLS_PI_DIR` |
| OpenCode | `~/.agents/skills/` (shared with Codex) | `HARNESS_SKILLS_OPENCODE_DIR` |
| pi | `~/.agents/skills/` (shared with Codex) | `HARNESS_SKILLS_PI_DIR` |

An override relocates only where mstack writes the files; the harness still
scans its own discovery directories. OpenCode additionally reads
`~/.claude/skills/`, which is why the Claude copy is installed there as well
and kept identical apart from the Claude frontmatter adaptation.

Set an override to install into a mounted Fleet directory or another local
path. The path must be absolute or start with `~/`.
Expand Down
12 changes: 12 additions & 0 deletions docs/harness-adapters.md
Original file line number Diff line number Diff line change
Expand Up @@ -19,6 +19,18 @@ Sources:
- [OpenCode skills](https://opencode.ai/docs/skills)
- [pi skills](https://pi.dev/docs/latest/skills)

User-level discovery crosses these roots. OpenCode reads its own root plus
`~/.agents/skills/` and `~/.claude/skills/`, and pi reads its own root plus
`~/.agents/skills/`. The installer therefore plans one copy per discovery
directory instead of one copy per harness: Codex, OpenCode, and pi share
`~/.agents/skills/`, and Claude Code keeps `~/.claude/skills/`. OpenCode sees
the shared copy and the Claude copy and reports same-named skills as
duplicates; the installer refreshes both on every install, so either loads the
same instructions apart from the Claude frontmatter adaptation. Legacy mstack
0.4.0 copies under `~/.config/opencode/skills/` and `~/.pi/agent/skills/` are
removed by installing with `--migrate`, which keeps copies that differ from
the current source and preserves unrelated skills.

The canonical tree keeps the Agent Skills fields that all supported Harnesses
can read. Claude Code accepts `metadata` but does not act on its contents, so
`adapters/claude.json` removes that map and surfaces the logger requirement in
Expand Down
18 changes: 18 additions & 0 deletions profiles/harnesses.json
Original file line number Diff line number Diff line change
Expand Up @@ -2,6 +2,10 @@
"codex": {
"directoryVariable": "HARNESS_SKILLS_CODEX_DIR",
"fallback": [".agents", "skills"],
"skills": {
"install": "own",
"discovers": ["own"]
},
"runtime": {
"command": "codex",
"headlessArgs": ["exec", "--sandbox", "read-only"],
Expand All @@ -14,6 +18,10 @@
"configVariable": "CLAUDE_CONFIG_DIR",
"configFallback": ".claude",
"suffix": ["skills"],
"skills": {
"install": "own",
"discovers": ["own"]
},
"runtime": {
"command": "claude",
"headlessArgs": ["--print", "--no-session-persistence", "--output-format", "text"],
Expand All @@ -27,6 +35,11 @@
"configFallback": ".config",
"prefix": ["opencode"],
"suffix": ["skills"],
"skills": {
"install": "codex",
"discovers": ["own", "codex", "claude"],
"legacy": "own"
},
"runtime": {
"command": "opencode",
"headlessArgs": ["run", "--pure"],
Expand All @@ -39,6 +52,11 @@
"configVariable": "PI_CODING_AGENT_DIR",
"configFallback": ".pi/agent",
"suffix": ["skills"],
"skills": {
"install": "codex",
"discovers": ["own", "codex"],
"legacy": "own"
},
"runtime": {
"command": "pi",
"headlessArgs": ["-p", "--no-session"],
Expand Down
74 changes: 74 additions & 0 deletions scripts/harness-targets.mjs
Original file line number Diff line number Diff line change
@@ -0,0 +1,74 @@
import { homedir } from "node:os";
import { isAbsolute, join, resolve } from "node:path";

export function expandHome(path, home) {
if (path === "~") return home;
if (/^~[\\/]/.test(path)) return join(home, path.slice(2));
return path;
}

export function configuredPath(value, fallback, label, home = homedir()) {
const expanded = expandHome(value ?? fallback, home);
if (value && !isAbsolute(expanded)) {
throw new Error(`${label} must be an absolute path or start with ~/`);
}
return resolve(expanded);
}

export function pathFromParts(parts, home = homedir()) {
return parts.reduce((current, part) => join(current, part), home);
}

function harnessConfigRoot(registry, harness, env, home) {
const config = registry[harness];
const configured = env[config.configVariable]
? configuredPath(env[config.configVariable], "", config.configVariable, home)
: pathFromParts([config.configFallback], home);
return join(configured, ...(config.prefix ?? []));
}

export function ownConfigRoot(registry, harness, env = process.env, home = homedir()) {
if (harness === "codex") {
return configuredPath(env.CODEX_HOME || undefined, join(home, ".codex"), "CODEX_HOME", home);
}
return harnessConfigRoot(registry, harness, env, home);
}

export function defaultSkillsDir(registry, harness, env = process.env, home = homedir()) {
const config = registry[harness];
const skills = config.skills ?? {};
if (skills.install && skills.install !== "own") {
const referenced = registry[skills.install];
if (!referenced?.fallback) {
throw new Error(
`Harness ${harness} installs skills into the ${skills.install} directory, but ${skills.install} defines no fallback`,
);
}
return pathFromParts(referenced.fallback, home);
}
if (config.fallback) return pathFromParts(config.fallback, home);
return join(harnessConfigRoot(registry, harness, env, home), ...(config.suffix ?? []));
}

export function skillsTarget(registry, harness, options = {}) {
const env = options.env ?? process.env;
const home = options.home ?? homedir();
const config = registry[harness];
if (options.environmentTarget) {
return configuredPath(
options.environmentTarget,
"",
`${options.environmentName ?? "environment"}.${harness}`,
home,
);
}
const directory = env[config.directoryVariable];
if (directory) return configuredPath(directory, "", config.directoryVariable, home);
return defaultSkillsDir(registry, harness, env, home);
}

export function legacySkillsDir(registry, harness, env = process.env, home = homedir()) {
const config = registry[harness];
if (config.skills?.legacy !== "own") return undefined;
return join(harnessConfigRoot(registry, harness, env, home), ...(config.suffix ?? []));
}
Loading
Loading