Skip to content
Merged
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
2 changes: 1 addition & 1 deletion .codex-plugin/plugin.json
Original file line number Diff line number Diff line change
@@ -1,6 +1,6 @@
{
"name": "mstack",
"version": "0.2.0",
"version": "0.2.1",
"description": "Portable engineering skills for Codex, Claude Code, OpenCode, and pi.",
"author": {
"name": "3metaJun"
Expand Down
23 changes: 18 additions & 5 deletions README.md
Original file line number Diff line number Diff line change
Expand Up @@ -59,11 +59,24 @@ npx @3metajun/mstack --harness all --no-skills --artifact all

The available installable artifacts are:

| Artifact | Default destination (beside the harness `skills/` directory) |
| Artifact | Default destination |
| --- | --- |
| `agents` | `agents/` |
| `meta-mode-tools` | `tools/meta-mode/` |
| `guide` | `docs/guide/` |
| `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/` |

Codex skills default to `~/.agents/skills/`, while Codex agents default to
`~/.codex/agents/`. An unset or empty `CODEX_HOME` uses `~/.codex`.
`HARNESS_SKILLS_CODEX_DIR` relocates skills only; agents follow `CODEX_HOME`
unless an artifact override is supplied. Codex TOML and Markdown agents must
use separate target directories. Shared targets are allowed only when the
artifact source and output format match.

For SSH installs, both `/home/dev/.agents/skills` and
`/home/dev/.codex/skills` map agents to `/home/dev/.codex/agents`.
For a custom remote layout or remote `CODEX_HOME`, set
`artifacts.agents.codex` to that remote agent directory. The installer cannot
infer a remote home from an arbitrary skill path or the local `CODEX_HOME`.

Artifact destinations can be overridden per harness with
`MSTACK_ARTIFACT_<ARTIFACT>_<HARNESS>_DIR`, for example
Expand All @@ -78,7 +91,7 @@ overrides in either shape below (artifact-first is the documented form):
},
"artifacts": {
"agents": {
"codex": "C:\\path\\to\\Fleet\\shared\\agents"
"codex": "C:\\path\\to\\Fleet\\codex\\agents"
}
}
}
Expand Down
9 changes: 8 additions & 1 deletion docs/harness-adapters.md
Original file line number Diff line number Diff line change
Expand Up @@ -28,7 +28,14 @@ their official references support it.
## Delegation and session records

Codex stores agent configuration in `.codex/agents/*.toml` and
`~/.codex/agents/*.toml`. Claude Code stores subagent definitions in
`~/.codex/agents/*.toml`. The installer converts the portable Markdown agent
artifacts to Codex TOML with `name`, `description`, and
`developer_instructions`. Canonical agent files must be top-level Markdown
with non-empty, single-line `name` and `description` strings. Plain, JSON
double-quoted, and YAML single-quoted strings are supported. Unsupported
metadata and nested agent directories fail conversion before installation;
an existing same-name TOML file is never overwritten by conversion.
Claude Code stores subagent definitions in
`.claude/agents/`. OpenCode stores agent definitions in `.opencode/agents/` or
`~/.config/opencode/agents/`. pi does not require a separate agent file for
skill use; it loads skills through discovery, the `--skill` flag, or the
Expand Down
2 changes: 1 addition & 1 deletion package.json
Original file line number Diff line number Diff line change
Expand Up @@ -23,7 +23,7 @@
"install-skills": "node scripts/install.mjs",
"optimize-context": "node scripts/optimize-context.mjs",
"reconcile-context": "node scripts/reconcile-context.mjs",
"test": "node scripts/validate.mjs && node --test scripts/install.test.mjs scripts/context.test.mjs scripts/audit-context.test.mjs scripts/worktree-audit.test.mjs scripts/sync-upstream.test.mjs scripts/runtime.test.mjs scripts/environment.test.mjs scripts/skill-integrity.test.mjs"
"test": "node scripts/validate.mjs && node --test scripts/install.test.mjs scripts/context.test.mjs scripts/audit-context.test.mjs scripts/worktree-audit.test.mjs scripts/sync-upstream.test.mjs scripts/runtime.test.mjs scripts/environment.test.mjs scripts/skill-integrity.test.mjs scripts/version-integrity.test.mjs scripts/agent-format.test.mjs"
},
"bin": {
"mstack": "scripts/install.mjs"
Expand Down
7 changes: 7 additions & 0 deletions profiles/artifacts.json
Original file line number Diff line number Diff line change
Expand Up @@ -3,6 +3,13 @@
"agents": {
"source": "agents",
"path": ["agents"],
"harnesses": {
"codex": {
"path": ["agents"],
"base": "codex-home",
"format": "codex-toml"
}
},
"description": "Portable agent role definitions for harnesses that support agent files."
},
"meta-mode-tools": {
Expand Down
4 changes: 2 additions & 2 deletions profiles/environments.example.json
Original file line number Diff line number Diff line change
Expand Up @@ -9,7 +9,7 @@
},
"artifacts": {
"agents": {
"codex": "C:\\path\\to\\Fleet\\shared\\agents",
"codex": "C:\\path\\to\\Fleet\\codex\\agents",
"claude": "C:\\path\\to\\Fleet\\shared\\agents",
"opencode": "C:\\path\\to\\Fleet\\shared\\agents",
"pi": "C:\\path\\to\\Fleet\\shared\\agents"
Expand All @@ -35,7 +35,7 @@
},
"artifacts": {
"agents": {
"codex": "/home/dev/.agents/agents",
"codex": "/home/dev/.codex/agents",
"claude": "/home/dev/.claude/agents",
"opencode": "/home/dev/.config/opencode/agents",
"pi": "/home/dev/.pi/agent/agents"
Expand Down
66 changes: 66 additions & 0 deletions scripts/agent-format.mjs
Original file line number Diff line number Diff line change
@@ -0,0 +1,66 @@
const supportedFields = new Set(["name", "description"]);

function readScalar(raw, path, field) {
const value = raw.trim();
let result;
if (value.startsWith('"')) {
const match = value.match(/^("(?:[^"\\]|\\.)*")(?:[ \t]+#.*)?$/);
try {
if (!match) throw new Error();
result = JSON.parse(match[1]);
} catch {
throw new Error(`${path}: ${field} must use a valid single-line JSON double-quoted string`);
}
} else if (value.startsWith("'")) {
const match = value.match(/^'((?:[^']|'')*)'(?:[ \t]+#.*)?$/);
if (!match) throw new Error(`${path}: ${field} has an invalid single-quoted string`);
result = match[1].replace(/''/g, "'");
} else {
result = value.replace(/(?:^|[ \t]+)#.*$/, "").trimEnd();
if (/^[|>&*!\[\]{},@`%]|^[?:-](?:[ \t]|$)|:(?:[ \t]|$)/.test(result)) {
throw new Error(`${path}: ${field} uses unsupported frontmatter syntax; use a single-line string`);
}
if (/^(?:null|true|false|~|[-+]?(?:0x[0-9a-f]+|0o[0-7]+|(?:[0-9]+(?:\.[0-9]*)?|\.[0-9]+)(?:e[-+]?[0-9]+)?|\.inf|\.nan))$/i.test(result)) {
throw new Error(`${path}: ${field} must be a string; quote scalar values`);
}
}
if (!result.trim()) throw new Error(`${path}: ${field} must be a non-empty string`);
return result;
}

function tomlString(value, path, field) {
for (const character of value) {
const point = character.codePointAt(0);
if (point >= 0xd800 && point <= 0xdfff) {
throw new Error(`${path}: ${field} contains an unpaired Unicode surrogate`);
}
}
// TOML also forbids a literal DEL, which JSON.stringify leaves untouched.
return JSON.stringify(value).replace(/\u007f/g, "\\u007f");
}

export function convertAgentMarkdown(content, path) {
const match = content.match(/^---\r?\n([\s\S]*?)\r?\n---(?:\r?\n|$)/);
if (!match) throw new Error(`${path}: agent Markdown has no complete frontmatter`);
const fields = new Map();
for (const line of match[1].split(/\r?\n/)) {
if (!line.trim() || line.trimStart().startsWith("#")) continue;
const entry = line.match(/^([a-zA-Z_][\w-]*):(?:[ \t]+(.*)|$)$/);
if (!entry) throw new Error(`${path}: unsupported agent frontmatter line ${JSON.stringify(line)}`);
const [, field, raw = ""] = entry;
if (!supportedFields.has(field)) throw new Error(`${path}: unsupported agent frontmatter field ${field}`);
if (fields.has(field)) throw new Error(`${path}: duplicate agent frontmatter field ${field}`);
fields.set(field, readScalar(raw, path, field));
}
for (const field of supportedFields) {
if (!fields.has(field)) throw new Error(`${path}: agent frontmatter is missing ${field}`);
}
const body = content.slice(match[0].length);
if (!body.trim()) throw new Error(`${path}: agent body must be non-empty`);
return [
`name = ${tomlString(fields.get("name"), path, "name")}`,
`description = ${tomlString(fields.get("description"), path, "description")}`,
`developer_instructions = ${tomlString(body, path, "body")}`,
"",
].join("\n");
}
105 changes: 105 additions & 0 deletions scripts/agent-format.test.mjs
Original file line number Diff line number Diff line change
@@ -0,0 +1,105 @@
import assert from "node:assert/strict";
import { readFileSync } from "node:fs";
import { spawnSync } from "node:child_process";
import test from "node:test";
import { convertAgentMarkdown } from "./agent-format.mjs";

function markdown(frontmatter, body = "Review the supplied files.\n") {
return `---\n${frontmatter}\n---\n${body}`;
}

test("converts plain, JSON double-quoted and YAML single-quoted agent metadata", () => {
assert.equal(
convertAgentMarkdown(markdown("name: reviewer # routing name\ndescription: 'Don''t skip # details'"), "agent.md"),
'name = "reviewer"\ndescription = "Don\'t skip # details"\ndeveloper_instructions = "Review the supplied files.\\n"\n',
);
assert.equal(
convertAgentMarkdown(markdown('name: "reviewer" # routing name\ndescription: "Read \\"quoted\\" paths: C:\\\\work"'), "agent.md"),
'name = "reviewer"\ndescription = "Read \\"quoted\\" paths: C:\\\\work"\ndeveloper_instructions = "Review the supplied files.\\n"\n',
);
});

test("preserves CRLF body whitespace, Unicode, and control characters in valid TOML escapes", () => {
const source = '---\r\nname: reviewer\r\ndescription: "Unicode \\u4e2d \\ud83d\\ude00 and newline\\n"\r\n---\r\n\r\n body\t\u007f\u0000\r\n';
assert.equal(
convertAgentMarkdown(source, "agent.md"),
'name = "reviewer"\ndescription = "Unicode 中 😀 and newline\\n"\ndeveloper_instructions = "\\r\\n body\\t\\u007f\\u0000\\r\\n"\n',
);
});

test("rejects malformed metadata without reading a following field as its value", () => {
const cases = [
["name:\ndescription: valid", /name must be a non-empty string/],
["name: # empty\ndescription: valid", /name must be a non-empty string/],
['name: ""\ndescription: valid', /name must be a non-empty string/],
["name: reviewer\nname: other\ndescription: valid", /duplicate.*name/],
["name: reviewer", /missing description/],
["description: valid", /missing name/],
["name: reviewer\ndescription: |\n detail", /unsupported.*syntax/],
["name: reviewer\ndescription: >\n detail", /unsupported.*syntax/],
["name: reviewer\ndescription: [one, two]", /unsupported.*syntax/],
["name: reviewer\ndescription: {text: value}", /unsupported.*syntax/],
["name: reviewer\ndescription: &description text", /unsupported.*syntax/],
["name: reviewer\ndescription: *description", /unsupported.*syntax/],
["name: reviewer\ndescription: !!str value", /unsupported.*syntax/],
["name: reviewer\ndescription: text: value", /unsupported.*syntax/],
["name: reviewer\ndescription: valid\n nested: value", /unsupported.*line/],
["name:reviewer\ndescription: valid", /unsupported.*line/],
["name: reviewer\ndescription: valid\nmodel: custom", /unsupported.*field model/],
['name: reviewer\ndescription: "bad\\escape"', /valid.*double-quoted string/],
['name: reviewer\ndescription: "unfinished', /valid.*double-quoted string/],
["name: reviewer\ndescription: 'Don't'", /invalid single-quoted string/],
['name: reviewer\ndescription: "valid" trailing', /valid.*double-quoted string/],
];
for (const [frontmatter, expected] of cases) {
assert.throws(() => convertAgentMarkdown(markdown(frontmatter), "broken.md"), (error) => {
assert.match(error.message, /^broken\.md:/);
assert.match(error.message, expected);
return true;
});
}
for (const scalar of ["123", "true", "false", "null", "~", ".nan", "0x12", "1.5e3"]) {
assert.throws(() => convertAgentMarkdown(markdown(`name: ${scalar}\ndescription: valid`), "broken.md"), /must be a string/);
}
});

test("rejects missing frontmatter, empty body, and unpaired Unicode surrogates", () => {
assert.throws(() => convertAgentMarkdown("# No metadata", "broken.md"), /no complete frontmatter/);
assert.throws(() => convertAgentMarkdown(markdown("name: reviewer\ndescription: valid", " \n"), "broken.md"), /body must be non-empty/);
for (const invalid of ["\ud800", "\udfff"]) {
assert.throws(() => convertAgentMarkdown(markdown("name: reviewer\ndescription: valid", invalid), "broken.md"), /body contains an unpaired Unicode surrogate/);
assert.throws(() => convertAgentMarkdown(markdown(`name: reviewer\ndescription: "${invalid}"`), "broken.md"), /description contains an unpaired Unicode surrogate/);
}
});

test("round-trips actual agent files and escaped strings through Python's standard TOML parser", (t) => {
const candidates = process.platform === "win32" ? ["python", "python3"] : ["python3", "python"];
const python = candidates.find((command) => spawnSync(command, ["-c", "import tomllib"], { encoding: "utf8" }).status === 0);
if (!python) return t.skip("Python 3.11+ with tomllib is unavailable; literal conversion assertions still run");
const fixtures = [
["comment-reviewer.md", "comment-reviewer"],
["meta-agent.md", "meta-agent"],
].map(([file, name]) => {
const source = readFileSync(new URL(`../agents/${file}`, import.meta.url), "utf8");
const frontmatter = source.match(/^---\r?\n([\s\S]*?)\r?\n---\r?\n/);
return {
toml: convertAgentMarkdown(source, file),
expected: {
name,
description: frontmatter[1].split(/\r?\n/).find((line) => line.startsWith("description: ")).slice("description: ".length),
developer_instructions: source.slice(frontmatter[0].length),
},
};
});
const description = 'Quoted "paths": C:\\work, 中文 😀, newline\n';
const body = ` All controls: ${Array.from({ length: 32 }, (_, index) => String.fromCharCode(index)).join("")}\u007f\r\nUnicode 中文 😀\n`;
fixtures.push({
toml: convertAgentMarkdown(markdown(`name: reviewer\ndescription: ${JSON.stringify(description)}`, body), "escaped.md"),
expected: { name: "reviewer", description, developer_instructions: body },
});
const result = spawnSync(python, ["-c", "import json, sys, tomllib; fixtures = json.load(sys.stdin); [None if tomllib.loads(f['toml']) == f['expected'] else sys.exit('TOML round-trip mismatch') for f in fixtures]"], {
input: JSON.stringify(fixtures),
encoding: "utf8",
});
assert.equal(result.status, 0, result.stderr);
});
13 changes: 13 additions & 0 deletions scripts/check-package.mjs
Original file line number Diff line number Diff line change
@@ -1,5 +1,18 @@
import { readFileSync } from "node:fs";
import { dirname, join, resolve } from "node:path";
import { fileURLToPath } from "node:url";
import { spawnSync } from "node:child_process";

const repoRoot = resolve(dirname(fileURLToPath(import.meta.url)), "..");
const packageManifest = JSON.parse(readFileSync(join(repoRoot, "package.json"), "utf8"));
const pluginManifest = JSON.parse(readFileSync(join(repoRoot, ".codex-plugin", "plugin.json"), "utf8"));
if (packageManifest.version !== pluginManifest.version) {
throw new Error(
`Package and Codex plugin versions must match (package.json=${packageManifest.version}, ` +
`.codex-plugin/plugin.json=${pluginManifest.version}).`,
);
}

const npmArgs = ["pack", "--dry-run", "--json"];
const npmExecutable = process.env.npm_execpath ? process.execPath : "npm";
const result = spawnSync(
Expand Down
Loading