Compare commits
6 Commits
fix/795-ci
...
main
| Author | SHA1 | Date | |
|---|---|---|---|
| d801d6c4c8 | |||
| d3bf52898b | |||
| 3f77229e88 | |||
| 686c881fe4 | |||
| fe7a468c9d | |||
| cabf02e7b9 |
@@ -22,10 +22,10 @@
|
|||||||
FROM node:24-alpine
|
FROM node:24-alpine
|
||||||
|
|
||||||
# Native toolchain required to compile node-gyp deps on musl, plus the
|
# Native toolchain required to compile node-gyp deps on musl, plus the
|
||||||
# postgresql-client used by the test step's pg_isready readiness probe. `bash`
|
# postgresql-client used by the test step's pg_isready readiness probe. `bash`,
|
||||||
# and `git` are baked here too — framework shell tests require both without
|
# `git`, and `jq` are baked here too — framework shell tests and the shipped
|
||||||
# paying for per-run package installation in ci.yml.
|
# Codex review wrappers require them without per-run installation in ci.yml.
|
||||||
RUN apk add --no-cache python3 make g++ postgresql-client bash git
|
RUN apk add --no-cache python3 make g++ postgresql-client bash git jq
|
||||||
|
|
||||||
# Pin pnpm to the repo's packageManager version via corepack.
|
# Pin pnpm to the repo's packageManager version via corepack.
|
||||||
RUN corepack enable && corepack prepare pnpm@10.6.2 --activate
|
RUN corepack enable && corepack prepare pnpm@10.6.2 --activate
|
||||||
|
|||||||
@@ -97,7 +97,10 @@ mosaic config path # Print config file path
|
|||||||
```bash
|
```bash
|
||||||
mosaic doctor # Health audit — detect drift and missing files
|
mosaic doctor # Health audit — detect drift and missing files
|
||||||
mosaic sync # Sync skills from canonical source
|
mosaic sync # Sync skills from canonical source
|
||||||
mosaic update # Check for and install CLI updates
|
mosaic skill list # Audit Claude skill registrations and conflicts
|
||||||
|
mosaic skill register <name> # Register one canonical skill with Claude Code
|
||||||
|
mosaic skill unregister <name> # Remove one Mosaic-owned Claude link
|
||||||
|
mosaic update # Update CLI/framework and auto-register canonical skills
|
||||||
mosaic wizard # Full guided setup wizard
|
mosaic wizard # Full guided setup wizard
|
||||||
mosaic bootstrap <path> # Bootstrap a repo with Mosaic standards
|
mosaic bootstrap <path> # Bootstrap a repo with Mosaic standards
|
||||||
mosaic coord init # Initialize a new orchestration mission
|
mosaic coord init # Initialize a new orchestration mission
|
||||||
@@ -349,6 +352,8 @@ bash tools/install.sh --yes # Non-interactive, accept all defaults
|
|||||||
bash tools/install.sh --no-auto-launch # Skip auto-launch of wizard
|
bash tools/install.sh --no-auto-launch # Skip auto-launch of wizard
|
||||||
```
|
```
|
||||||
|
|
||||||
|
The installer rejects unrecognized flags or positional arguments before making changes and prints the supported-option usage.
|
||||||
|
|
||||||
## Contributing
|
## Contributing
|
||||||
|
|
||||||
```bash
|
```bash
|
||||||
|
|||||||
@@ -1,5 +1,10 @@
|
|||||||
# Documentation Sitemap
|
# Documentation Sitemap
|
||||||
|
|
||||||
|
## CLI and skill management
|
||||||
|
|
||||||
|
- [Skill registration user guide](guides/user-guide.md#claude-code-skill-registration) — register, unregister, list statuses, automatic install/update reconciliation, and Claude reload behavior.
|
||||||
|
- [Skill bridge developer guide](guides/dev-guide.md#claude-code-skill-bridge) — path-validation, ownership, clobber-protection, install/update wiring, tests, and Pi/Codex scope notes.
|
||||||
|
|
||||||
## Fleet configuration management
|
## Fleet configuration management
|
||||||
|
|
||||||
- [Generated environment boundary](fleet/reference/generated-env-boundary.md) — roster-derived launch projection, strict local data, legacy quarantine, and downstream interface evidence.
|
- [Generated environment boundary](fleet/reference/generated-env-boundary.md) — roster-derived launch projection, strict local data, legacy quarantine, and downstream interface evidence.
|
||||||
|
|||||||
@@ -8,8 +8,9 @@
|
|||||||
4. [Adding New Agent Tools](#adding-new-agent-tools)
|
4. [Adding New Agent Tools](#adding-new-agent-tools)
|
||||||
5. [Adding New MCP Tools](#adding-new-mcp-tools)
|
5. [Adding New MCP Tools](#adding-new-mcp-tools)
|
||||||
6. [Database Schema and Migrations](#database-schema-and-migrations)
|
6. [Database Schema and Migrations](#database-schema-and-migrations)
|
||||||
7. [API Endpoint Reference](#api-endpoint-reference)
|
7. [Claude Code Skill Bridge](#claude-code-skill-bridge)
|
||||||
8. [Local Fleet Canary](./fleet-local-canary.md)
|
8. [API Endpoint Reference](#api-endpoint-reference)
|
||||||
|
9. [Local Fleet Canary](./fleet-local-canary.md)
|
||||||
|
|
||||||
---
|
---
|
||||||
|
|
||||||
@@ -353,6 +354,37 @@ defined there.
|
|||||||
|
|
||||||
---
|
---
|
||||||
|
|
||||||
|
## Claude Code Skill Bridge
|
||||||
|
|
||||||
|
The framework's canonical skill root is `~/.config/mosaic/skills/`; Claude Code
|
||||||
|
requires registrations under `~/.claude/skills/`. The implementation in
|
||||||
|
`packages/mosaic/src/commands/skill.ts` owns only direct-child symlinks whose
|
||||||
|
resolved target remains inside the canonical root.
|
||||||
|
|
||||||
|
Security invariants:
|
||||||
|
|
||||||
|
1. Validate the user-supplied name before filesystem access against
|
||||||
|
`[A-Za-z0-9][A-Za-z0-9._-]*`. Separators, control characters, whitespace,
|
||||||
|
`..`, absolute paths, and leading `-` are invalid; filesystem-derived invalid
|
||||||
|
names are escaped before terminal output.
|
||||||
|
2. Never replace a real file, directory, foreign symlink, or live misdirected
|
||||||
|
symlink in the Claude skill directory.
|
||||||
|
3. Repair a dangling link only when its lexical target is inside the canonical
|
||||||
|
Mosaic skills root.
|
||||||
|
4. Unregister only a symlink pointing inside that root.
|
||||||
|
5. Enumerate canonical directories at runtime; never hardcode framework skill
|
||||||
|
names.
|
||||||
|
|
||||||
|
`finalizeStage` reconciles after wizard/framework synchronization, and
|
||||||
|
`runFrameworkReseed` reconciles after the sync-only `mosaic update` path. A
|
||||||
|
foreign conflict is reported but does not prevent unrelated canonical skills
|
||||||
|
from registering. Filesystem tests use injected temporary roots in
|
||||||
|
`skill.spec.ts`, `finalize-skills.spec.ts`, and `update-checker.reseed.spec.ts`.
|
||||||
|
|
||||||
|
M1 intentionally manages Claude Code only. Pi's Mosaic launcher can discover the
|
||||||
|
canonical root directly. Codex still relies on the existing full skill-sync
|
||||||
|
linker and needs separate parity analysis before this lifecycle API is extended.
|
||||||
|
|
||||||
## API Endpoint Reference
|
## API Endpoint Reference
|
||||||
|
|
||||||
All endpoints are served by the gateway at `http://localhost:14242` by default.
|
All endpoints are served by the gateway at `http://localhost:14242` by default.
|
||||||
|
|||||||
@@ -183,6 +183,8 @@ non-interactive use:
|
|||||||
--no-auto-launch # Skip auto-launch of wizard after install
|
--no-auto-launch # Skip auto-launch of wizard after install
|
||||||
```
|
```
|
||||||
|
|
||||||
|
Unrecognized flags or positional arguments fail before installation starts and print the supported-option usage.
|
||||||
|
|
||||||
Or if installed globally:
|
Or if installed globally:
|
||||||
|
|
||||||
```bash
|
```bash
|
||||||
@@ -307,6 +309,39 @@ mosaic quality-rails
|
|||||||
|
|
||||||
---
|
---
|
||||||
|
|
||||||
|
### Claude Code Skill Registration
|
||||||
|
|
||||||
|
Mosaic stores canonical skills under `~/.config/mosaic/skills/`. Claude Code scans
|
||||||
|
`~/.claude/skills/`, so Mosaic maintains one symlink per skill between those
|
||||||
|
directories.
|
||||||
|
|
||||||
|
```bash
|
||||||
|
mosaic skill list
|
||||||
|
mosaic skill register <name>
|
||||||
|
mosaic skill unregister <name>
|
||||||
|
```
|
||||||
|
|
||||||
|
- `register` is idempotent and repairs a dangling Mosaic-owned link. Names use
|
||||||
|
the safe grammar `[A-Za-z0-9][A-Za-z0-9._-]*`; files, directories, foreign
|
||||||
|
symlinks, path traversal, absolute paths, and names beginning with `-` are
|
||||||
|
refused.
|
||||||
|
- `unregister` is idempotent when no entry exists. It removes only symlinks that
|
||||||
|
point inside `~/.config/mosaic/skills/`; foreign entries are never removed.
|
||||||
|
- `list` reports `registered`, `unregistered`, `dangling`, `foreign`,
|
||||||
|
`foreign-dangling`, or `misdirected` for each canonical or Claude entry.
|
||||||
|
|
||||||
|
Install, wizard finalization, and `mosaic update` framework re-seeding reconcile
|
||||||
|
every canonical skill automatically. A skill directory added after initial
|
||||||
|
setup therefore receives its Claude bridge without a per-skill code change or
|
||||||
|
manual `ln -s`. If Claude Code is already running, use `/reload-skills` or start
|
||||||
|
a new session after registration so its in-process skill registry rescans.
|
||||||
|
|
||||||
|
This command group is Claude-only in M1. Pi can consume Mosaic's canonical skill
|
||||||
|
root through its Mosaic launcher configuration and does not need this Claude
|
||||||
|
bridge. Codex has a separate link path managed by the legacy full skill-sync
|
||||||
|
script; equivalent lifecycle management remains follow-up scope and is not
|
||||||
|
changed here.
|
||||||
|
|
||||||
## Sub-package Commands
|
## Sub-package Commands
|
||||||
|
|
||||||
Each Mosaic sub-package exposes its full API surface through the `mosaic` CLI.
|
Each Mosaic sub-package exposes its full API surface through the `mosaic` CLI.
|
||||||
|
|||||||
86
docs/scratchpads/804-install-unknown-flags.md
Normal file
86
docs/scratchpads/804-install-unknown-flags.md
Normal file
@@ -0,0 +1,86 @@
|
|||||||
|
# Issue #804 — fail closed on unknown installer arguments
|
||||||
|
|
||||||
|
## Objective
|
||||||
|
|
||||||
|
Implement Part 1 of Gitea issue #804 only: `tools/install.sh` must reject every unrecognized flag or argument with an actionable STDERR error and nonzero exit before installation starts.
|
||||||
|
|
||||||
|
## Scope and constraints
|
||||||
|
|
||||||
|
- Preserve all currently recognized options and behavior, including `-y` and `--ref <branch>`.
|
||||||
|
- No positional arguments are currently accepted by the parser.
|
||||||
|
- Do not add `--next`, `MOSAIC_NEXT`, prerelease routing, or any Part 2 behavior.
|
||||||
|
- TDD is mandatory: add and observe a failing process-level regression test before changing `tools/install.sh`.
|
||||||
|
- Worker lifecycle ends after branch push, PR creation, and coordinator notification; do not merge or close #804.
|
||||||
|
- Existing launcher-owned changes in `.mosaic/orchestrator/mission.json` and `.mosaic/orchestrator/session.lock` are out of scope and must not be committed.
|
||||||
|
|
||||||
|
## Requirements and acceptance criteria
|
||||||
|
|
||||||
|
- Unknown input names the offending argument on STDERR.
|
||||||
|
- STDERR includes a short installer usage hint.
|
||||||
|
- Exit status is nonzero.
|
||||||
|
- The installer does not invoke npm or otherwise proceed into installation.
|
||||||
|
- Existing recognized flags remain unchanged.
|
||||||
|
|
||||||
|
## Plan
|
||||||
|
|
||||||
|
1. Add a process-level Vitest regression using the installer test location under `packages/mosaic/src/commands/`.
|
||||||
|
2. Run the focused test and record the expected RED failure.
|
||||||
|
3. Commit the RED test as `test(#804): ...`.
|
||||||
|
4. Replace the parser catch-all with a fail-closed STDERR error and usage hint.
|
||||||
|
5. Update concise installer-facing documentation without introducing prerelease behavior.
|
||||||
|
6. Run focused tests, shell syntax validation, package tests, lint, typecheck, and format checks.
|
||||||
|
7. Run independent review tooling and remediate findings.
|
||||||
|
8. Commit as `fix(#804): ...`, queue-guard, push, open a PR containing `Closes #804.`, notify the coordinator, and exit.
|
||||||
|
|
||||||
|
## Budget
|
||||||
|
|
||||||
|
- No explicit token cap supplied.
|
||||||
|
- Working estimate: 8K tokens; narrow two-file behavior/test change plus concise docs and delivery gates.
|
||||||
|
|
||||||
|
## Progress
|
||||||
|
|
||||||
|
- 2026-07-17: Loaded mission state, issue #804, delivery/QA/documentation rails, and relevant TDD/Vitest/pnpm/Gitea skills.
|
||||||
|
- 2026-07-17: Confirmed the parser has no legitimate positional arguments and currently drops all unmatched input via `*) shift ;;`.
|
||||||
|
- 2026-07-17: Installed locked workspace dependencies with a worktree-local pnpm store; no lockfile changes.
|
||||||
|
- 2026-07-17: Added the process-level unknown-argument regression with an isolated `$HOME` and npm shim.
|
||||||
|
- 2026-07-17: Replaced the silent catch-all with STDERR error + usage output and exit 2 before preflight or installation.
|
||||||
|
- 2026-07-17: Initial Codex code review found an unknown option could still be consumed as the `--ref` value. Added a second RED reproducer, then rejected option-shaped/missing `--ref` values without changing valid `--ref <branch>` behavior. The review's launcher-state note is handled by excluding both `.mosaic/orchestrator/` files from commits.
|
||||||
|
- 2026-07-17: Updated README, user guide, and packaged framework README with the fail-closed argument contract. No API, auth, admin, sitemap/navigation, or publishing surface changed.
|
||||||
|
|
||||||
|
## Verification
|
||||||
|
|
||||||
|
- RED: `pnpm --filter @mosaicstack/mosaic exec vitest run src/commands/install-arguments.spec.ts` — expected failure: installer exited `0` instead of nonzero at the exit-status assertion; confirms the test reproduces the silent-drop defect before production changes.
|
||||||
|
- Remediation RED: the added `--cli --ref --bogus` case exited `0`, proving `--ref` could swallow an unknown option before the guard was added.
|
||||||
|
- GREEN: `pnpm --filter @mosaicstack/mosaic exec vitest run src/commands/install-arguments.spec.ts src/commands/install-heading.spec.ts` — 2 files, 3 tests passed.
|
||||||
|
- Situational process check: unknown positional input exited 2, named the input on STDERR, printed usage, and did not call the npm shim.
|
||||||
|
- `bash -n tools/install.sh` — passed.
|
||||||
|
- Bare `--ref` process check — exited 2 with `Missing value for --ref` and usage.
|
||||||
|
- `pnpm --filter @mosaicstack/mosaic test` — 69 files, 1,287 tests passed; framework shell checks passed. The first attempt lacked generated `dist/cli.js`; `pnpm --filter @mosaicstack/mosaic build` restored the required test precondition and the full rerun passed.
|
||||||
|
- `pnpm lint` — 23/23 tasks passed.
|
||||||
|
- `pnpm typecheck` — 42/42 tasks passed.
|
||||||
|
- `pnpm format:check` — passed.
|
||||||
|
- Codex code re-review against `origin/main` — `approve`, 0 blockers/should-fix/suggestions.
|
||||||
|
- Codex security re-review against `origin/main` — risk `none`, 0 findings.
|
||||||
|
|
||||||
|
## Acceptance evidence
|
||||||
|
|
||||||
|
| Criterion | Evidence |
|
||||||
|
| --- | --- |
|
||||||
|
| Unknown input is named on STDERR | Process-level Vitest assertions for `--bogus`, including after `--ref` |
|
||||||
|
| Short usage hint is printed on STDERR | Vitest usage regex + manual process output |
|
||||||
|
| Exit is nonzero | Vitest status assertions and manual exit 2 |
|
||||||
|
| Installation does not proceed | Isolated npm shim marker remains absent |
|
||||||
|
| Recognized behavior is preserved | Parser cases are unchanged except validation of malformed `--ref`; full Mosaic package suite passed |
|
||||||
|
| Part 2 is excluded | No `--next`, `MOSAIC_NEXT`, dist-tag, or prerelease routing changes |
|
||||||
|
|
||||||
|
## Documentation checklist
|
||||||
|
|
||||||
|
- Current canonical `docs/PRD.md` remains unchanged; issue #804 and the coordinator brief supply this bounded defect's acceptance contract.
|
||||||
|
- Updated installer behavior in root README, user guide, and packaged framework README in the same logical change set.
|
||||||
|
- API/OpenAPI, auth/permissions, admin operations, developer architecture, sitemap/navigation, and external publishing are not affected.
|
||||||
|
- Scratchpad remains under `docs/scratchpads/`; no root-hygiene changes.
|
||||||
|
|
||||||
|
## Risks and blockers
|
||||||
|
|
||||||
|
- Part 2 remains owner-gated under #805 and is intentionally excluded.
|
||||||
|
- No implementation blocker remains. Independent coordinator RoR, CI, merge, and issue closure remain pending after worker handoff.
|
||||||
112
docs/scratchpads/824-mosaic-skill-cli.md
Normal file
112
docs/scratchpads/824-mosaic-skill-cli.md
Normal file
@@ -0,0 +1,112 @@
|
|||||||
|
# Issue #824 — Mosaic skill CLI and Claude bridge auto-sync
|
||||||
|
|
||||||
|
## Objective
|
||||||
|
|
||||||
|
Deliver `mosaic skill register|unregister|list` plus install/upgrade reconciliation of every canonical `~/.config/mosaic/skills/*` entry into `~/.claude/skills/`, without clobbering runtime-owned files or directories.
|
||||||
|
|
||||||
|
## Scope and constraints
|
||||||
|
|
||||||
|
- Issue: mosaicstack/stack#824
|
||||||
|
- Branch: `feat/824-mosaic-skill-cli`
|
||||||
|
- M1 runtime: Claude Code only.
|
||||||
|
- Pi/Codex parity is documentation-only; no non-Claude bridge implementation.
|
||||||
|
- Do not author the downstream `mosaic-context-refresh` skill.
|
||||||
|
- Workers do not modify `docs/TASKS.md`, merge, close #824, or touch `main`.
|
||||||
|
- TDD is mandatory and red-first; filesystem tests use temporary directories only.
|
||||||
|
- Budget: no explicit token cap supplied; use a focused single-worker implementation with no new dependencies.
|
||||||
|
|
||||||
|
## Requirements mapping
|
||||||
|
|
||||||
|
1. Register creates the canonical Claude symlink and is idempotent.
|
||||||
|
2. Names are untrusted: reject empty/escaping/absolute/separator/`..`/leading-dash names before filesystem mutation, with clear CLI stderr and nonzero status.
|
||||||
|
3. Register repairs only Mosaic-owned dangling symlinks and refuses foreign files, directories, and symlinks.
|
||||||
|
4. Unregister removes only symlinks pointing inside the canonical Mosaic skills root and is idempotent when absent.
|
||||||
|
5. List reports registered, dangling, foreign, and canonical-but-unregistered skills.
|
||||||
|
6. Install and upgrade generically reconcile all canonical skills after framework sync/re-seed, continuing past foreign conflicts without clobbering them.
|
||||||
|
7. User/developer documentation describes commands, status meanings, security boundaries, and Claude-only M1 scope.
|
||||||
|
|
||||||
|
## Plan
|
||||||
|
|
||||||
|
1. Add co-located failing Vitest coverage for all filesystem behaviors and auto-sync.
|
||||||
|
2. Run the focused spec and record the expected RED failure.
|
||||||
|
3. Commit the red contract as `test(#824): ...`.
|
||||||
|
4. Implement the skill bridge and Commander command registration.
|
||||||
|
5. Wire reconciliation into wizard finalize and `mosaic update` re-seed, preserving non-clobber behavior.
|
||||||
|
6. Update canonical docs and sitemap if navigation changes.
|
||||||
|
7. Run focused tests, package tests, typecheck, lint, and formatting.
|
||||||
|
8. Commit implementation/docs as `feat(#824): ...`, queue-guard, push, open PR with `Closes #824.`, fire completion event, and notify the coordinator.
|
||||||
|
|
||||||
|
## Progress
|
||||||
|
|
||||||
|
- 2026-07-17: Loaded mission/delivery/TDD/documentation rails, issue #824, active mission state, and relevant installer/update paths.
|
||||||
|
- 2026-07-17: Confirmed `mosaic update` invokes `framework/install.sh` with `MOSAIC_SYNC_ONLY=1`; that path exits before existing post-install skill linking, leaving newly present canonical skills unregistered.
|
||||||
|
- 2026-07-17: Coordinator addendum classified the user-supplied skill name and runtime symlink target as a path-traversal/symlink-injection surface. Expanded the initial red contract to reject traversal before mutation, preserve every foreign entry, and unregister Mosaic-owned links only.
|
||||||
|
- 2026-07-17: Implemented the Commander command group and secure generic bridge; wired wizard finalize and successful framework re-seed reconciliation; updated user/developer/installed/root docs and sitemap.
|
||||||
|
- 2026-07-17: Focused, package-wide, repository baseline, temp-home situational, and independent review gates completed. Ready for scoped feature commit, queue guard, push, and PR handoff.
|
||||||
|
|
||||||
|
## Tests and evidence
|
||||||
|
|
||||||
|
### TDD evidence
|
||||||
|
|
||||||
|
- RED environment attempt: `pnpm --filter @mosaicstack/mosaic exec vitest run src/commands/skill.spec.ts` initially could not locate Vitest because this fresh worktree had no dependencies.
|
||||||
|
- Dependency setup: `pnpm install --frozen-lockfile --store-dir /home/hermes/.local/share/pnpm/store` succeeded. The explicit store was required because machine pnpm config incorrectly resolves the default store under `/root`.
|
||||||
|
- RED behavior: focused Vitest failed with `Failed to load url ./skill.js ... Does the file exist?`, proving the bridge API was absent.
|
||||||
|
- RED integration: finalize/update specs failed because no Claude links or `skillSync` result existed.
|
||||||
|
- RED symlink injection: symlinked Claude/canonical root tests failed because the initial implementation followed ancestor links.
|
||||||
|
- GREEN after review remediation: `skill.spec.ts` 36/36, `finalize-skills.spec.ts` 6/6, and `update-checker.reseed.spec.ts` 30/30.
|
||||||
|
|
||||||
|
### Baseline gates
|
||||||
|
|
||||||
|
- `pnpm --filter '@mosaicstack/mosaic...' run build` — pass (fresh-worktree dependency outputs built).
|
||||||
|
- `pnpm --filter @mosaicstack/mosaic run typecheck` — pass.
|
||||||
|
- `pnpm --filter @mosaicstack/mosaic run lint` — pass.
|
||||||
|
- `pnpm --filter @mosaicstack/mosaic test` — pass: 69 files, 1,325 Vitest tests plus framework shell suite.
|
||||||
|
- `pnpm typecheck` — pass: 42/42 Turbo tasks.
|
||||||
|
- `pnpm lint` — pass: 23/23 Turbo tasks.
|
||||||
|
- `pnpm format:check` — pass.
|
||||||
|
|
||||||
|
### Situational evidence
|
||||||
|
|
||||||
|
A built-CLI temp-home smoke test (no real `~/.claude` or Mosaic config touched) proved:
|
||||||
|
|
||||||
|
- register creates the exact link and a second run reports `already registered`;
|
||||||
|
- list reports registered and unregistered canonical skills;
|
||||||
|
- `../../etc` exits 1 with `Invalid skill name` and creates no escaped path;
|
||||||
|
- unregister removes the managed link and a second run reports `already unregistered`;
|
||||||
|
- a fake successful framework re-seed generically registered both `added-after-setup` and `second-skill` from runtime directory enumeration.
|
||||||
|
|
||||||
|
### Review evidence
|
||||||
|
|
||||||
|
- Initial uncommitted Codex code/security review described name validation/clobber protection as strong; its only finding was the harness-owned, unrelated `.mosaic/orchestrator/session.lock`, which is excluded from all commits and the PR.
|
||||||
|
- Exact branch review then identified two remediations: preserve successful framework re-seed status when bridge-wide reconciliation fails, and reject/escape control-character names to prevent terminal/log injection.
|
||||||
|
- Both findings were reproduced red-first and remediated. A subsequent exact review identified one finalize failure-isolation blocker; a root-wide bridge error now warns and allows wizard doctor/summary/next-steps completion, with a red-first regression.
|
||||||
|
- All remediations passed the full package and repository gates. Final exact-head review is rerun after amending the feature commit.
|
||||||
|
|
||||||
|
### Acceptance mapping
|
||||||
|
|
||||||
|
| Acceptance criterion | Evidence |
|
||||||
|
| --- | --- |
|
||||||
|
| register/unregister/list, idempotent | `skill.spec.ts` and built-CLI temp-home smoke |
|
||||||
|
| traversal/symlink-injection protection | invalid-name matrix, foreign file/dir/link tests, symlinked-root tests |
|
||||||
|
| list flags dangling and foreign entries | deterministic list status test |
|
||||||
|
| install and upgrade auto-sync every canonical directory | finalize + framework re-seed integration specs; two-skill built-module smoke |
|
||||||
|
| newly added skill becomes discoverable without manual link | `added-after-setup` auto-sync creates exact Claude link; Claude can rescan with `/reload-skills` or a new session |
|
||||||
|
| Pi/Codex parity captured as scope note | user guide, developer guide, installed framework README |
|
||||||
|
| documentation gate | root README, user guide, developer guide, framework README, sitemap |
|
||||||
|
|
||||||
|
## Risks
|
||||||
|
|
||||||
|
- Symlink replacement uses `lstat` semantics so dangling links are detectable without following them.
|
||||||
|
- Link ownership is determined lexically against the canonical skills root, and existing symlink ancestors in either managed root are rejected before mutation.
|
||||||
|
- Auto-sync continues across per-skill conflicts while never deleting real files/directories or foreign symlinks.
|
||||||
|
- Claude Code discovers filesystem skills at session launch/reload boundaries; bridge creation makes a later `/reload-skills` or new session able to discover the skill, but cannot mutate an already-cached in-process registry by itself.
|
||||||
|
- Pi does not need this Claude bridge because its Mosaic launcher can consume the canonical root. Codex lifecycle parity remains explicitly deferred.
|
||||||
|
- No deployment surface is affected.
|
||||||
|
|
||||||
|
## PR #826 review remediation
|
||||||
|
|
||||||
|
- 2026-07-17: Exact-head RoR requested changes for two ownership bugs: installer pruning deleted foreign-name links under `MOSAIC_HOME` outside canonical skills, and unregister deleted a same-root link targeting a different skill. It also requested trailing-dot rejection and executable coverage support.
|
||||||
|
- RED evidence: focused regression run failed 4 tests: register/unregister accepted `safe.`, misdirected unregister did not throw, and the install linker deleted the foreign-name link.
|
||||||
|
- GREEN evidence: `skill.spec.ts` passes 43/43, including live and dangling foreign-name links in a temp HOME/MOSAIC_HOME and the misdirected unregister invariant.
|
||||||
|
- Coverage: `vitest run src/commands/skill.spec.ts --coverage` passes configured 85% thresholds for `skill.ts`: 91.05% statements/lines, 86.27% branches, 95.23% functions.
|
||||||
|
- Full gates: package build passed; package tests passed 69 files / 1,332 tests plus framework shell suite; repository typecheck 42/42, lint 23/23, and format check passed.
|
||||||
38
docs/scratchpads/ms-792-fleet-enoent-installer.md
Normal file
38
docs/scratchpads/ms-792-fleet-enoent-installer.md
Normal file
@@ -0,0 +1,38 @@
|
|||||||
|
# ms-792 — Fleet roster error handling and installer heading
|
||||||
|
|
||||||
|
## Objective
|
||||||
|
|
||||||
|
Make expected missing or malformed fleet roster configuration fail with an actionable message and nonzero exit instead of a raw Node stack trace. Ensure the installer preserves the `@mosaicstack/mosaic` heading.
|
||||||
|
|
||||||
|
## Plan
|
||||||
|
|
||||||
|
1. Add failing coverage for missing and malformed roster input.
|
||||||
|
2. Centralize roster-file read and parse error translation; add the CLI async error boundary.
|
||||||
|
3. Sweep fleet command read paths that bypass the roster loader.
|
||||||
|
4. Replace the installer heading output with format-safe rendering and test it.
|
||||||
|
5. Run focused and repository quality checks; request independent review.
|
||||||
|
|
||||||
|
## Progress
|
||||||
|
|
||||||
|
- 2026-07-16: Confirmed issue #792 and branch base `9745bc3f`.
|
||||||
|
- 2026-07-16: Installed locked workspace dependencies using a worktree-local pnpm store; no `.mosaic/` files were changed intentionally.
|
||||||
|
- 2026-07-16: Added a shared roster read/parse guard and routed v1 fleet commands plus v1/v2 selection through Commander’s actionable nonzero error path. V2 command modules already return structured nonzero JSON errors for their guarded reads.
|
||||||
|
- 2026-07-16: Replaced installer heading `echo` with format-safe `printf`; added a regression check for the scoped package heading.
|
||||||
|
- 2026-07-16: Rebuilt CLI and manually verified `fleet ps` with no roster prints the initialization hint, exits 1, and has no stack trace.
|
||||||
|
- 2026-07-17: Rebased #818 onto `origin/main` at `9ddc6fbd` (#791 PR3). The added `fleet regen` command had a canonical roster read in its sibling module; it now uses the same missing-roster guard and Commander exit path. Internal NORTH_STAR, preset, and post-write invariant reads remain intentionally unguarded.
|
||||||
|
- 2026-07-17: RoR found that semantically invalid v1 documents still escaped as plain `Error` values. `normalizeFleetRosterV1` now preserves each validation message while converting it to `FleetRosterConfigurationError`, so its command callers use the actionable nonzero Commander path.
|
||||||
|
|
||||||
|
## Verification
|
||||||
|
|
||||||
|
- `pnpm --filter @mosaicstack/mosaic test` — PASS (61 files, 1,046 tests; executed outside sandbox because CLI smoke tests spawn Node)
|
||||||
|
- `pnpm typecheck` — PASS
|
||||||
|
- `pnpm lint` — PASS
|
||||||
|
- `pnpm format:check` — PASS
|
||||||
|
- `pnpm --filter @mosaicstack/mosaic exec vitest run src/commands/fleet.spec.ts src/commands/install-heading.spec.ts` — PASS (209 tests)
|
||||||
|
- `pnpm --filter @mosaicstack/mosaic exec vitest run src/commands/fleet-regen-command.spec.ts` — PASS (27 tests, including missing canonical roster)
|
||||||
|
- `pnpm --filter @mosaicstack/mosaic exec vitest run src/commands/fleet.spec.ts -t "semantically invalid v1 roster"` — RED then PASS; verifies duplicate agent names are reported as `fleet.roster` exit 1 without a stack trace.
|
||||||
|
- Instrumented Vitest coverage is unavailable because `@vitest/coverage-v8` is not declared in this repository. Each branch added in the roster guard has direct unit coverage.
|
||||||
|
|
||||||
|
## Risks / blockers
|
||||||
|
|
||||||
|
- Dependency installation is required before executing Vitest, TypeScript, lint, and formatting gates.
|
||||||
@@ -177,15 +177,23 @@ bash tools/install.sh --cli # npm CLI only (skip framework)
|
|||||||
bash tools/install.sh --ref v1.0 # Install from a specific git ref
|
bash tools/install.sh --ref v1.0 # Install from a specific git ref
|
||||||
```
|
```
|
||||||
|
|
||||||
|
The installer rejects unrecognized flags or positional arguments before making changes and prints the supported-option usage.
|
||||||
|
|
||||||
## Universal Skills
|
## Universal Skills
|
||||||
|
|
||||||
The installer syncs skills from `mosaic/agent-skills` into `~/.config/mosaic/skills/`, then links each skill into runtime directories.
|
The installer syncs skills from `mosaic/agent-skills` into `~/.config/mosaic/skills/`. Install, wizard finalization, and `mosaic update` automatically reconcile every canonical skill into Claude Code's `~/.claude/skills/` directory.
|
||||||
|
|
||||||
```bash
|
```bash
|
||||||
mosaic sync # Full sync (clone + link)
|
mosaic sync # Full canonical catalog sync
|
||||||
~/.config/mosaic/bin/mosaic-sync-skills --link-only # Re-link only
|
mosaic skill list # Show registered, missing, dangling, and foreign entries
|
||||||
|
mosaic skill register <name> # Register or repair one canonical Claude link
|
||||||
|
mosaic skill unregister <name> # Remove one Mosaic-owned Claude link
|
||||||
```
|
```
|
||||||
|
|
||||||
|
Skill names are direct children using `[A-Za-z0-9][A-Za-z0-9._-]*`, not paths. Registration rejects traversal/control characters and never replaces foreign files, directories, or symlinks; unregister removes only links that point inside the canonical Mosaic skill root. After registering during a running Claude Code session, use `/reload-skills` or start a new session.
|
||||||
|
|
||||||
|
M1 lifecycle management targets Claude Code. Pi can discover the canonical Mosaic root through its launcher configuration. Codex parity remains follow-up scope and continues to use the existing full skill-sync linker.
|
||||||
|
|
||||||
## Health Audit
|
## Health Audit
|
||||||
|
|
||||||
```bash
|
```bash
|
||||||
|
|||||||
@@ -161,6 +161,7 @@ link_targets=(
|
|||||||
)
|
)
|
||||||
|
|
||||||
canonical_real="$(readlink -f "$MOSAIC_SKILLS_DIR")"
|
canonical_real="$(readlink -f "$MOSAIC_SKILLS_DIR")"
|
||||||
|
local_real="$(readlink -f "$MOSAIC_LOCAL_SKILLS_DIR")"
|
||||||
|
|
||||||
# Build an associative array from the colon-separated whitelist for O(1) lookup.
|
# Build an associative array from the colon-separated whitelist for O(1) lookup.
|
||||||
# When MOSAIC_INSTALL_SKILLS is empty, all skills are allowed.
|
# When MOSAIC_INSTALL_SKILLS is empty, all skills are allowed.
|
||||||
@@ -203,7 +204,14 @@ link_skill_into_target() {
|
|||||||
link_path="$target_dir/$name"
|
link_path="$target_dir/$name"
|
||||||
|
|
||||||
if [[ -L "$link_path" ]]; then
|
if [[ -L "$link_path" ]]; then
|
||||||
|
local raw_target resolved_target
|
||||||
|
raw_target="$(readlink "$link_path")"
|
||||||
|
resolved_target="$(node -e 'const p=require("node:path"); process.stdout.write(p.resolve(p.dirname(process.argv[1]), process.argv[2]));' "$link_path" "$raw_target")"
|
||||||
|
if [[ "$resolved_target" == "$canonical_real/"* || "$resolved_target" == "$local_real/"* ]]; then
|
||||||
ln -sfn "$skill_path" "$link_path"
|
ln -sfn "$skill_path" "$link_path"
|
||||||
|
else
|
||||||
|
echo "[mosaic-skills] Preserve foreign runtime symlink: $link_path"
|
||||||
|
fi
|
||||||
return
|
return
|
||||||
fi
|
fi
|
||||||
|
|
||||||
@@ -234,14 +242,10 @@ prune_stale_links_in_target() {
|
|||||||
continue
|
continue
|
||||||
fi
|
fi
|
||||||
|
|
||||||
resolved="$(readlink -f "$link_path" 2>/dev/null || true)"
|
# -m resolves lexical dangling targets too. If resolution fails, ownership
|
||||||
if [[ -z "$resolved" ]]; then
|
# is unproven and the link must be preserved.
|
||||||
rm -f "$link_path"
|
resolved="$(readlink -m "$link_path" 2>/dev/null || true)"
|
||||||
echo "[mosaic-skills] Removed stale broken skill link: $link_path"
|
if [[ -n "$resolved" && "$resolved" == "$canonical_real/"* ]]; then
|
||||||
continue
|
|
||||||
fi
|
|
||||||
|
|
||||||
if [[ "$resolved" == "$MOSAIC_HOME/"* ]]; then
|
|
||||||
rm -f "$link_path"
|
rm -f "$link_path"
|
||||||
echo "[mosaic-skills] Removed stale retired skill link: $link_path"
|
echo "[mosaic-skills] Removed stale retired skill link: $link_path"
|
||||||
fi
|
fi
|
||||||
|
|||||||
@@ -79,9 +79,26 @@ function Link-SkillIntoTarget {
|
|||||||
|
|
||||||
$linkPath = Join-Path $TargetDir $name
|
$linkPath = Join-Path $TargetDir $name
|
||||||
|
|
||||||
# Already a junction/symlink — recreate
|
# Recreate only Mosaic-owned junctions/symlinks. Foreign reparse points are
|
||||||
|
# runtime-owned and must never be clobbered by install/upgrade auto-sync.
|
||||||
$existing = Get-Item $linkPath -Force -ErrorAction SilentlyContinue
|
$existing = Get-Item $linkPath -Force -ErrorAction SilentlyContinue
|
||||||
if ($existing -and ($existing.Attributes -band [System.IO.FileAttributes]::ReparsePoint)) {
|
if ($existing -and ($existing.Attributes -band [System.IO.FileAttributes]::ReparsePoint)) {
|
||||||
|
$rawTarget = @($existing.Target)[0]
|
||||||
|
$candidate = if ([System.IO.Path]::IsPathRooted($rawTarget)) {
|
||||||
|
$rawTarget
|
||||||
|
}
|
||||||
|
else {
|
||||||
|
Join-Path (Split-Path $linkPath -Parent) $rawTarget
|
||||||
|
}
|
||||||
|
$resolvedTarget = [System.IO.Path]::GetFullPath($candidate)
|
||||||
|
$canonicalRoot = [System.IO.Path]::GetFullPath($MosaicSkillsDir).TrimEnd('\') + '\'
|
||||||
|
$localRoot = [System.IO.Path]::GetFullPath($MosaicLocalSkillsDir).TrimEnd('\') + '\'
|
||||||
|
$owned = $resolvedTarget.StartsWith($canonicalRoot, [System.StringComparison]::OrdinalIgnoreCase) -or
|
||||||
|
$resolvedTarget.StartsWith($localRoot, [System.StringComparison]::OrdinalIgnoreCase)
|
||||||
|
if (-not $owned) {
|
||||||
|
Write-Host "[mosaic-skills] Preserve foreign runtime symlink: $linkPath"
|
||||||
|
return
|
||||||
|
}
|
||||||
Remove-Item $linkPath -Force
|
Remove-Item $linkPath -Force
|
||||||
}
|
}
|
||||||
elseif ($existing) {
|
elseif ($existing) {
|
||||||
|
|||||||
@@ -70,6 +70,8 @@ Security vulnerability review focusing on:
|
|||||||
~/.config/mosaic/tools/codex/codex-security-review.sh -n 42
|
~/.config/mosaic/tools/codex/codex-security-review.sh -n 42
|
||||||
```
|
```
|
||||||
|
|
||||||
|
PR mode resolves the provider's PR diff rather than relying on the caller's checked-out branch. On Gitea, it fetches the base and `refs/pull/<number>/head` refs and diffs those explicit refs. If the refs cannot be fetched or the resulting diff is empty, the command exits nonzero before Codex runs or a review is posted.
|
||||||
|
|
||||||
### Review Against Base Branch
|
### Review Against Base Branch
|
||||||
|
|
||||||
```bash
|
```bash
|
||||||
@@ -253,7 +255,7 @@ Run the script from inside a git repository.
|
|||||||
|
|
||||||
### "No changes found to review"
|
### "No changes found to review"
|
||||||
|
|
||||||
The specified mode (--uncommitted, --base, etc.) found no changes to review.
|
The specified non-PR mode (`--uncommitted`, `--base`, etc.) found no changes to review. PR mode instead fails closed with an actionable error when it cannot construct a non-empty provider diff; verify the PR number, remote, provider login, and ref access before retrying.
|
||||||
|
|
||||||
### "Codex produced no output"
|
### "Codex produced no output"
|
||||||
|
|
||||||
|
|||||||
@@ -44,38 +44,47 @@ build_diff_context() {
|
|||||||
diff_text=$(git show "$value" 2>/dev/null)
|
diff_text=$(git show "$value" 2>/dev/null)
|
||||||
;;
|
;;
|
||||||
pr)
|
pr)
|
||||||
# For PRs, we need to fetch the PR diff
|
# Provider detection writes its result to stdout; suppress it so it cannot
|
||||||
detect_platform
|
# be mistaken for diff content when this function is used in a substitution.
|
||||||
|
detect_platform >/dev/null
|
||||||
if [[ "$PLATFORM" == "github" ]]; then
|
if [[ "$PLATFORM" == "github" ]]; then
|
||||||
diff_text=$(gh pr diff "$value" 2>/dev/null)
|
diff_text=$(gh pr diff "$value" 2>/dev/null) || {
|
||||||
|
echo "Error: Failed to fetch the diff for PR #${value}." >&2
|
||||||
|
return 1
|
||||||
|
}
|
||||||
elif [[ "$PLATFORM" == "gitea" ]]; then
|
elif [[ "$PLATFORM" == "gitea" ]]; then
|
||||||
# tea doesn't have a direct pr diff command, use git
|
local pr_base base_ref pr_head_ref
|
||||||
local pr_base
|
pr_base=$(tea pr list --fields index,base --output simple 2>/dev/null | awk -v pr="$value" '$1 == pr { print $2; exit }')
|
||||||
pr_base=$(tea pr list --fields index,base --output simple 2>/dev/null | grep "^${value}" | awk '{print $2}')
|
if [[ -z "$pr_base" ]]; then
|
||||||
if [[ -n "$pr_base" ]]; then
|
echo "Error: Could not resolve the base branch for Gitea PR #${value}." >&2
|
||||||
diff_text=$(git diff "${pr_base}...HEAD" 2>/dev/null)
|
return 1
|
||||||
else
|
|
||||||
# Fallback: fetch PR info via API
|
|
||||||
local repo_info
|
|
||||||
repo_info=$(get_repo_info)
|
|
||||||
local remote_url
|
|
||||||
remote_url=$(git remote get-url origin 2>/dev/null)
|
|
||||||
local host
|
|
||||||
host=$(echo "$remote_url" | sed -E 's|.*://([^/]+).*|\1|; s|.*@([^:]+).*|\1|')
|
|
||||||
diff_text=$(curl -s "https://${host}/api/v1/repos/${repo_info}/pulls/${value}" \
|
|
||||||
-H "Authorization: token $(tea login list --output simple 2>/dev/null | head -1 | awk '{print $2}')" \
|
|
||||||
2>/dev/null | jq -r '.diff_url // empty')
|
|
||||||
if [[ -n "$diff_text" && "$diff_text" != "null" ]]; then
|
|
||||||
diff_text=$(curl -s "$diff_text" 2>/dev/null)
|
|
||||||
else
|
|
||||||
diff_text=$(git diff "main...HEAD" 2>/dev/null)
|
|
||||||
fi
|
fi
|
||||||
|
|
||||||
|
base_ref="refs/remotes/origin/${pr_base}"
|
||||||
|
pr_head_ref="refs/remotes/origin/pr/${value}/head"
|
||||||
|
if ! git fetch --quiet origin \
|
||||||
|
"+refs/heads/${pr_base}:${base_ref}" \
|
||||||
|
"+refs/pull/${value}/head:${pr_head_ref}"; then
|
||||||
|
echo "Error: Failed to fetch the base and head refs for Gitea PR #${value}." >&2
|
||||||
|
return 1
|
||||||
fi
|
fi
|
||||||
|
diff_text=$(git diff "${base_ref}...${pr_head_ref}") || {
|
||||||
|
echo "Error: Failed to diff the fetched refs for Gitea PR #${value}." >&2
|
||||||
|
return 1
|
||||||
|
}
|
||||||
|
else
|
||||||
|
echo "Error: Unsupported git platform while resolving PR #${value}." >&2
|
||||||
|
return 1
|
||||||
fi
|
fi
|
||||||
;;
|
;;
|
||||||
esac
|
esac
|
||||||
|
|
||||||
echo "$diff_text"
|
if [[ "$mode" == "pr" && -z "${diff_text//[[:space:]]/}" ]]; then
|
||||||
|
echo "Error: Unable to construct a non-empty diff for PR #${value}; verify the PR refs and provider access." >&2
|
||||||
|
return 1
|
||||||
|
fi
|
||||||
|
|
||||||
|
printf '%s\n' "$diff_text"
|
||||||
}
|
}
|
||||||
|
|
||||||
# Format JSON findings as markdown for PR comments
|
# Format JSON findings as markdown for PR comments
|
||||||
|
|||||||
158
packages/mosaic/framework/tools/codex/test-pr-diff-context.sh
Normal file
158
packages/mosaic/framework/tools/codex/test-pr-diff-context.sh
Normal file
@@ -0,0 +1,158 @@
|
|||||||
|
#!/bin/bash
|
||||||
|
# Hermetic regression coverage for Gitea PR diff construction and fail-closed reviews.
|
||||||
|
set -euo pipefail
|
||||||
|
|
||||||
|
SCRIPT_DIR="$(cd "$(dirname "${BASH_SOURCE[0]}")" && pwd)"
|
||||||
|
TMP_DIR=$(mktemp -d)
|
||||||
|
trap 'rm -rf "$TMP_DIR"' EXIT
|
||||||
|
|
||||||
|
fail() {
|
||||||
|
echo "not ok - $*" >&2
|
||||||
|
exit 1
|
||||||
|
}
|
||||||
|
|
||||||
|
assert_contains() {
|
||||||
|
local haystack="$1" needle="$2"
|
||||||
|
if [[ "$haystack" != *"$needle"* ]]; then
|
||||||
|
printf 'actual output:\n%s\n' "$haystack" >&2
|
||||||
|
fail "expected output to contain: $needle"
|
||||||
|
fi
|
||||||
|
}
|
||||||
|
|
||||||
|
# Prevent CI-provided repository context from leaking into the fixture repositories.
|
||||||
|
unset GIT_DIR GIT_WORK_TREE GIT_INDEX_FILE GIT_OBJECT_DIRECTORY \
|
||||||
|
GIT_ALTERNATE_OBJECT_DIRECTORIES GIT_COMMON_DIR
|
||||||
|
export GIT_AUTHOR_NAME="Codex Fixture"
|
||||||
|
export GIT_AUTHOR_EMAIL="codex-fixture@example.test"
|
||||||
|
export GIT_COMMITTER_NAME="$GIT_AUTHOR_NAME"
|
||||||
|
export GIT_COMMITTER_EMAIL="$GIT_AUTHOR_EMAIL"
|
||||||
|
export GITEA_LOGIN="fixture"
|
||||||
|
export GITEA_TOKEN="fixture-token"
|
||||||
|
export GITEA_URL="file://$TMP_DIR"
|
||||||
|
|
||||||
|
create_pr_fixture() {
|
||||||
|
local fixture_root="$1" head_mode="$2"
|
||||||
|
local origin="$fixture_root/origin.git"
|
||||||
|
local seed="$fixture_root/seed"
|
||||||
|
local work="$fixture_root/work"
|
||||||
|
local base_sha head_sha
|
||||||
|
|
||||||
|
mkdir -p "$fixture_root"
|
||||||
|
git init --quiet --bare "$origin"
|
||||||
|
git init --quiet --initial-branch=release/next "$seed"
|
||||||
|
printf 'base\n' > "$seed/pr-change.ts"
|
||||||
|
git -C "$seed" add pr-change.ts
|
||||||
|
git -C "$seed" commit --quiet -m "fixture base"
|
||||||
|
base_sha=$(git -C "$seed" rev-parse HEAD)
|
||||||
|
git -C "$seed" remote add origin "$origin"
|
||||||
|
git -C "$seed" push --quiet origin release/next
|
||||||
|
git --git-dir="$origin" symbolic-ref HEAD refs/heads/release/next
|
||||||
|
|
||||||
|
if [[ "$head_mode" == "changed" ]]; then
|
||||||
|
git -C "$seed" switch --quiet -c feature/pr-795
|
||||||
|
printf 'actual-pr-change\n' > "$seed/pr-change.ts"
|
||||||
|
git -C "$seed" commit --quiet -am "fixture PR head"
|
||||||
|
head_sha=$(git -C "$seed" rev-parse HEAD)
|
||||||
|
git -C "$seed" push --quiet origin HEAD:refs/pull/795/head
|
||||||
|
else
|
||||||
|
head_sha="$base_sha"
|
||||||
|
git --git-dir="$origin" update-ref refs/pull/795/head "$head_sha"
|
||||||
|
fi
|
||||||
|
|
||||||
|
# Gitea's provider-owned PR head ref now exists in the local bare origin.
|
||||||
|
git clone --quiet "$origin" "$work"
|
||||||
|
printf '%s\n' "$work"
|
||||||
|
}
|
||||||
|
|
||||||
|
FAKE_BIN="$TMP_DIR/bin"
|
||||||
|
mkdir -p "$FAKE_BIN"
|
||||||
|
cat > "$FAKE_BIN/tea" <<'STUB'
|
||||||
|
#!/bin/bash
|
||||||
|
if [[ "$*" == "pr list --fields index,base --output simple" ]]; then
|
||||||
|
printf '795 release/next\n'
|
||||||
|
exit 0
|
||||||
|
fi
|
||||||
|
exit 1
|
||||||
|
STUB
|
||||||
|
cat > "$FAKE_BIN/codex" <<'STUB'
|
||||||
|
#!/bin/bash
|
||||||
|
printf 'CODEX %s\n' "$*" >> "$CODEX_LOG"
|
||||||
|
exit 99
|
||||||
|
STUB
|
||||||
|
chmod +x "$FAKE_BIN/tea" "$FAKE_BIN/codex"
|
||||||
|
export PATH="$FAKE_BIN:$PATH"
|
||||||
|
|
||||||
|
# The valid fixture is a fresh clone on the non-main base. The PR head exists only
|
||||||
|
# at refs/pull/795/head, so local HEAD cannot accidentally satisfy the assertion.
|
||||||
|
if [[ "${1:-all}" != "fail-closed" ]]; then
|
||||||
|
VALID_WORK=$(create_pr_fixture "$TMP_DIR/valid" changed)
|
||||||
|
(
|
||||||
|
cd "$VALID_WORK"
|
||||||
|
# shellcheck source=common.sh
|
||||||
|
source "$SCRIPT_DIR/common.sh"
|
||||||
|
diff_context=$(build_diff_context pr 795)
|
||||||
|
assert_contains "$diff_context" "actual-pr-change"
|
||||||
|
|
||||||
|
base_sha=$(git rev-parse refs/remotes/origin/release/next)
|
||||||
|
head_sha=$(git rev-parse refs/remotes/origin/pr/795/head)
|
||||||
|
local_sha=$(git rev-parse HEAD)
|
||||||
|
[[ "$local_sha" == "$base_sha" ]] || fail "fixture clone is not on the PR base"
|
||||||
|
[[ "$head_sha" != "$base_sha" ]] || fail "fixture PR head does not differ from its base"
|
||||||
|
git show-ref --verify --quiet refs/remotes/origin/pr/795/head || \
|
||||||
|
fail "fetched PR head ref is missing"
|
||||||
|
[[ "$(git diff --name-only "${base_sha}...${head_sha}")" == "pr-change.ts" ]] || \
|
||||||
|
fail "explicit PR refs do not contain the fixture change"
|
||||||
|
if git show-ref --verify --quiet refs/heads/main || \
|
||||||
|
git show-ref --verify --quiet refs/remotes/origin/main; then
|
||||||
|
fail "fixture unexpectedly contains a main ref"
|
||||||
|
fi
|
||||||
|
)
|
||||||
|
echo "ok - Gitea PR mode fetches and diffs explicit non-main base and PR head refs"
|
||||||
|
fi
|
||||||
|
|
||||||
|
# Build an empty PR entirely inside another local repository. Both review wrappers
|
||||||
|
# must emit the PR-numbered error before Codex or the stubbed post path can execute.
|
||||||
|
if [[ "${1:-all}" != "pr-head" ]]; then
|
||||||
|
EMPTY_WORK=$(create_pr_fixture "$TMP_DIR/empty" empty)
|
||||||
|
SANDBOX="$TMP_DIR/sandbox"
|
||||||
|
mkdir -p "$SANDBOX/tools/codex/schemas" "$SANDBOX/tools/git"
|
||||||
|
cp "$SCRIPT_DIR/common.sh" \
|
||||||
|
"$SCRIPT_DIR/codex-code-review.sh" \
|
||||||
|
"$SCRIPT_DIR/codex-security-review.sh" \
|
||||||
|
"$SANDBOX/tools/codex/"
|
||||||
|
cp "$SCRIPT_DIR/schemas/code-review-schema.json" \
|
||||||
|
"$SCRIPT_DIR/schemas/security-review-schema.json" \
|
||||||
|
"$SANDBOX/tools/codex/schemas/"
|
||||||
|
cp "$SCRIPT_DIR/../git/detect-platform.sh" "$SANDBOX/tools/git/"
|
||||||
|
|
||||||
|
cat > "$SANDBOX/tools/git/pr-review.sh" <<'STUB'
|
||||||
|
#!/bin/bash
|
||||||
|
printf 'POST %s\n' "$*" >> "$POST_LOG"
|
||||||
|
STUB
|
||||||
|
chmod +x "$SANDBOX/tools/git/pr-review.sh"
|
||||||
|
|
||||||
|
POST_LOG="$TMP_DIR/post.log"
|
||||||
|
CODEX_LOG="$TMP_DIR/codex.log"
|
||||||
|
export POST_LOG CODEX_LOG
|
||||||
|
|
||||||
|
for review_kind in code security; do
|
||||||
|
: > "$POST_LOG"
|
||||||
|
: > "$CODEX_LOG"
|
||||||
|
review_script="$SANDBOX/tools/codex/codex-${review_kind}-review.sh"
|
||||||
|
set +e
|
||||||
|
(
|
||||||
|
cd "$EMPTY_WORK"
|
||||||
|
"$review_script" -n 795
|
||||||
|
) >"$TMP_DIR/${review_kind}.stdout" 2>"$TMP_DIR/${review_kind}.stderr"
|
||||||
|
review_status=$?
|
||||||
|
set -e
|
||||||
|
|
||||||
|
stderr_text=$(cat "$TMP_DIR/${review_kind}.stderr")
|
||||||
|
[[ "$review_status" -ne 0 ]] || fail "${review_kind} review returned success for an empty PR diff"
|
||||||
|
[[ ! -s "$CODEX_LOG" ]] || fail "Codex ran for an empty ${review_kind} PR diff"
|
||||||
|
[[ ! -s "$POST_LOG" ]] || fail "${review_kind} review auto-post ran for an empty PR diff"
|
||||||
|
assert_contains "$stderr_text" "Error:"
|
||||||
|
assert_contains "$stderr_text" "PR #795"
|
||||||
|
echo "ok - empty ${review_kind} PR diff fails closed before Codex and auto-post"
|
||||||
|
done
|
||||||
|
fi
|
||||||
@@ -24,7 +24,8 @@
|
|||||||
"build": "tsc",
|
"build": "tsc",
|
||||||
"lint": "eslint src",
|
"lint": "eslint src",
|
||||||
"typecheck": "tsc --noEmit",
|
"typecheck": "tsc --noEmit",
|
||||||
"test": "vitest run --passWithNoTests"
|
"test": "vitest run --passWithNoTests && pnpm run test:framework-shell",
|
||||||
|
"test:framework-shell": "bash framework/tools/codex/test-pr-diff-context.sh"
|
||||||
},
|
},
|
||||||
"dependencies": {
|
"dependencies": {
|
||||||
"@mosaicstack/brain": "workspace:*",
|
"@mosaicstack/brain": "workspace:*",
|
||||||
@@ -52,6 +53,7 @@
|
|||||||
},
|
},
|
||||||
"devDependencies": {
|
"devDependencies": {
|
||||||
"@types/node": "^22.0.0",
|
"@types/node": "^22.0.0",
|
||||||
|
"@vitest/coverage-v8": "^2.0.0",
|
||||||
"@types/react": "^18.3.0",
|
"@types/react": "^18.3.0",
|
||||||
"tsx": "^4.0.0",
|
"tsx": "^4.0.0",
|
||||||
"typescript": "^5.8.0",
|
"typescript": "^5.8.0",
|
||||||
|
|||||||
@@ -18,6 +18,7 @@ import { registerFleetCommand } from './commands/fleet.js';
|
|||||||
import { registerMissionCommand } from './commands/mission.js';
|
import { registerMissionCommand } from './commands/mission.js';
|
||||||
import { registerUninstallCommand } from './commands/uninstall.js';
|
import { registerUninstallCommand } from './commands/uninstall.js';
|
||||||
import { registerRestoreCommand } from './commands/restore.js';
|
import { registerRestoreCommand } from './commands/restore.js';
|
||||||
|
import { registerSkillCommand } from './commands/skill.js';
|
||||||
// prdy is registered via launch.ts
|
// prdy is registered via launch.ts
|
||||||
import { registerLaunchCommands } from './commands/launch.js';
|
import { registerLaunchCommands } from './commands/launch.js';
|
||||||
import { registerAuthCommand } from './commands/auth.js';
|
import { registerAuthCommand } from './commands/auth.js';
|
||||||
@@ -67,7 +68,7 @@ Command Groups:
|
|||||||
|
|
||||||
Runtime: tui, login, sessions
|
Runtime: tui, login, sessions
|
||||||
Gateway: gateway
|
Gateway: gateway
|
||||||
Framework: agent, bootstrap, coord, doctor, fleet, init, launch, mission, prdy, seq, sync, upgrade, wizard, yolo
|
Framework: agent, bootstrap, coord, doctor, fleet, init, launch, mission, prdy, seq, skill, sync, upgrade, wizard, yolo
|
||||||
Platform: update
|
Platform: update
|
||||||
Runtimes: claude, codex, opencode, pi
|
Runtimes: claude, codex, opencode, pi
|
||||||
`,
|
`,
|
||||||
@@ -411,6 +412,10 @@ registerUninstallCommand(program);
|
|||||||
|
|
||||||
registerRestoreCommand(program);
|
registerRestoreCommand(program);
|
||||||
|
|
||||||
|
// ─── skill ───────────────────────────────────────────────────────────────────
|
||||||
|
|
||||||
|
registerSkillCommand(program);
|
||||||
|
|
||||||
// ─── telemetry ───────────────────────────────────────────────────────────────
|
// ─── telemetry ───────────────────────────────────────────────────────────────
|
||||||
|
|
||||||
registerTelemetryCommand(program);
|
registerTelemetryCommand(program);
|
||||||
@@ -471,6 +476,18 @@ program
|
|||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
console.log('✔ Framework re-seeded.');
|
console.log('✔ Framework re-seeded.');
|
||||||
|
if (reseed.skillSyncError) {
|
||||||
|
console.error(` ⚠ Claude skill reconciliation skipped: ${reseed.skillSyncError}`);
|
||||||
|
}
|
||||||
|
const skillConflicts = reseed.skillSync?.conflicts ?? [];
|
||||||
|
const skillChanges =
|
||||||
|
(reseed.skillSync?.registered.length ?? 0) + (reseed.skillSync?.repaired.length ?? 0);
|
||||||
|
if (skillChanges > 0) {
|
||||||
|
console.log(`✔ Registered ${skillChanges.toString()} Mosaic skill(s) with Claude Code.`);
|
||||||
|
}
|
||||||
|
for (const conflict of skillConflicts) {
|
||||||
|
console.error(` ⚠ Skill registration skipped for ${conflict.name}: ${conflict.reason}`);
|
||||||
|
}
|
||||||
// Propagate shipped systemd unit fixes to the ACTIVE units (re-seed only
|
// Propagate shipped systemd unit fixes to the ACTIVE units (re-seed only
|
||||||
// touches ~/.config/mosaic/systemd/user; systemd runs ~/.config/systemd/user).
|
// touches ~/.config/mosaic/systemd/user; systemd runs ~/.config/systemd/user).
|
||||||
const units = refreshActiveFleetUnits();
|
const units = refreshActiveFleetUnits();
|
||||||
|
|||||||
@@ -150,6 +150,17 @@ describe('projectRosterV2AgentGeneratedEnv', (): void => {
|
|||||||
});
|
});
|
||||||
|
|
||||||
describe('mosaic fleet regen', (): void => {
|
describe('mosaic fleet regen', (): void => {
|
||||||
|
it('reports a missing canonical roster with the shared initialization hint', async (): Promise<void> => {
|
||||||
|
const home = await mkdtemp(join(tmpdir(), 'mosaic-fleet-regen-missing-roster-'));
|
||||||
|
cleanup = home;
|
||||||
|
|
||||||
|
await expect(
|
||||||
|
program(home, recordingRunner([])).parseAsync(['node', 'mosaic', 'fleet', 'regen', '--json']),
|
||||||
|
).rejects.toThrow(
|
||||||
|
`No fleet roster found at ${join(home, 'fleet', 'roster.yaml')}. Run \`mosaic fleet init\``,
|
||||||
|
);
|
||||||
|
});
|
||||||
|
|
||||||
it('is dry-run by default: reports the plan and writes nothing', async (): Promise<void> => {
|
it('is dry-run by default: reports the plan and writes nothing', async (): Promise<void> => {
|
||||||
const home = await fleetHome();
|
const home = await fleetHome();
|
||||||
const calls: string[][] = [];
|
const calls: string[][] = [];
|
||||||
|
|||||||
@@ -1,4 +1,4 @@
|
|||||||
import { readFile, stat } from 'node:fs/promises';
|
import { stat } from 'node:fs/promises';
|
||||||
import { homedir } from 'node:os';
|
import { homedir } from 'node:os';
|
||||||
import { join, relative, resolve } from 'node:path';
|
import { join, relative, resolve } from 'node:path';
|
||||||
import type { Command } from 'commander';
|
import type { Command } from 'commander';
|
||||||
@@ -18,6 +18,7 @@ import {
|
|||||||
validateRosterV2Semantics,
|
validateRosterV2Semantics,
|
||||||
type FleetRosterV2,
|
type FleetRosterV2,
|
||||||
} from '../fleet/roster-v2.js';
|
} from '../fleet/roster-v2.js';
|
||||||
|
import { FleetRosterConfigurationError, readFleetRosterText } from '../fleet/fleet-roster-v1.js';
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* `mosaic fleet regen` — recovery-framed regeneration of the roster-derived
|
* `mosaic fleet regen` — recovery-framed regeneration of the roster-derived
|
||||||
@@ -303,7 +304,7 @@ function defaultReadRoster(
|
|||||||
mosaicHome: string,
|
mosaicHome: string,
|
||||||
): (rosterPath: string) => Promise<FleetRosterV2> {
|
): (rosterPath: string) => Promise<FleetRosterV2> {
|
||||||
return async (rosterPath: string): Promise<FleetRosterV2> => {
|
return async (rosterPath: string): Promise<FleetRosterV2> => {
|
||||||
const roster = parseRosterV2(await readFile(rosterPath, 'utf8'), 'yaml');
|
const roster = parseRosterV2(await readFleetRosterText(rosterPath), 'yaml');
|
||||||
// Enforce the SAME semantic gate as reconcile/plan/verify (persona resolution
|
// Enforce the SAME semantic gate as reconcile/plan/verify (persona resolution
|
||||||
// + protected-class tool-policy match) so regen cannot project a roster the
|
// + protected-class tool-policy match) so regen cannot project a roster the
|
||||||
// rest of the fleet surface would reject.
|
// rest of the fleet surface would reject.
|
||||||
@@ -433,6 +434,10 @@ export function registerFleetRegenCommand(fleetCommand: Command, deps: FleetRege
|
|||||||
// the operator must finish/verify (and clear the lock) before restarting.
|
// the operator must finish/verify (and clear the lock) before restarting.
|
||||||
if (result.incomplete || result.cleanup) process.exitCode = 1;
|
if (result.incomplete || result.cleanup) process.exitCode = 1;
|
||||||
} catch (error: unknown) {
|
} catch (error: unknown) {
|
||||||
|
if (error instanceof FleetRosterConfigurationError) {
|
||||||
|
fleetCommand.error(error.message, { code: 'fleet.roster', exitCode: 1 });
|
||||||
|
return;
|
||||||
|
}
|
||||||
process.exitCode = 1;
|
process.exitCode = 1;
|
||||||
const message = error instanceof Error ? error.message : String(error);
|
const message = error instanceof Error ? error.message : String(error);
|
||||||
process.stderr.write(`mosaic fleet regen failed: ${message}\n`);
|
process.stderr.write(`mosaic fleet regen failed: ${message}\n`);
|
||||||
|
|||||||
@@ -60,6 +60,7 @@ import {
|
|||||||
type SleepFn,
|
type SleepFn,
|
||||||
} from './fleet.js';
|
} from './fleet.js';
|
||||||
import { registerAgentCommand } from './agent.js';
|
import { registerAgentCommand } from './agent.js';
|
||||||
|
import { parseFleetRosterDocument, readFleetRosterText } from '../fleet/fleet-roster-v1.js';
|
||||||
|
|
||||||
function buildProgram(): Command {
|
function buildProgram(): Command {
|
||||||
const program = new Command();
|
const program = new Command();
|
||||||
@@ -166,6 +167,91 @@ describe('registerFleetCommand', () => {
|
|||||||
});
|
});
|
||||||
});
|
});
|
||||||
|
|
||||||
|
describe('fleet roster configuration failures', () => {
|
||||||
|
it('reports a missing roster with an actionable initialization hint', async () => {
|
||||||
|
const home = await tempDir();
|
||||||
|
try {
|
||||||
|
const program = buildProgram();
|
||||||
|
|
||||||
|
await expect(
|
||||||
|
program.parseAsync(['node', 'mosaic', 'fleet', '--mosaic-home', home, 'ps']),
|
||||||
|
).rejects.toThrow(
|
||||||
|
`No fleet roster found at ${join(home, 'fleet', 'roster.json')}. Run \`mosaic fleet init\``,
|
||||||
|
);
|
||||||
|
} finally {
|
||||||
|
await rm(home, { recursive: true, force: true });
|
||||||
|
}
|
||||||
|
});
|
||||||
|
|
||||||
|
it('reports a malformed roster with the path and repair hint', async () => {
|
||||||
|
const home = await tempDir();
|
||||||
|
const rosterPath = join(home, 'fleet', 'roster.json');
|
||||||
|
try {
|
||||||
|
await mkdir(dirname(rosterPath), { recursive: true });
|
||||||
|
await writeFile(rosterPath, '{not valid json');
|
||||||
|
|
||||||
|
await expect(loadFleetRoster(rosterPath)).rejects.toThrow(
|
||||||
|
`Fleet roster at ${rosterPath} is invalid. Fix the file or run \`mosaic fleet init --force\``,
|
||||||
|
);
|
||||||
|
} finally {
|
||||||
|
await rm(home, { recursive: true, force: true });
|
||||||
|
}
|
||||||
|
});
|
||||||
|
|
||||||
|
it('reports an unreadable roster without exposing the filesystem error', async () => {
|
||||||
|
const home = await tempDir();
|
||||||
|
try {
|
||||||
|
await expect(readFleetRosterText(home)).rejects.toThrow(
|
||||||
|
`Could not read fleet roster at ${home}. Check the file exists and is readable.`,
|
||||||
|
);
|
||||||
|
} finally {
|
||||||
|
await rm(home, { recursive: true, force: true });
|
||||||
|
}
|
||||||
|
});
|
||||||
|
|
||||||
|
it('reports a malformed roster while selecting the v1/v2 command path', () => {
|
||||||
|
expect(() => parseFleetRosterDocument('version: [', '/srv/mosaic/fleet/roster.yaml')).toThrow(
|
||||||
|
'Fleet roster at /srv/mosaic/fleet/roster.yaml is invalid. Fix the file or run `mosaic fleet init --force`.',
|
||||||
|
);
|
||||||
|
});
|
||||||
|
|
||||||
|
it('reports a semantically invalid v1 roster as an actionable nonzero command error', async () => {
|
||||||
|
const home = await tempDir();
|
||||||
|
const rosterPath = join(home, 'fleet', 'roster.yaml');
|
||||||
|
const stderrSpy = vi.spyOn(process.stderr, 'write').mockImplementation(() => true);
|
||||||
|
try {
|
||||||
|
await mkdir(dirname(rosterPath), { recursive: true });
|
||||||
|
await writeFile(
|
||||||
|
rosterPath,
|
||||||
|
[
|
||||||
|
'version: 1',
|
||||||
|
'transport: tmux',
|
||||||
|
'agents:',
|
||||||
|
' - name: canary-pi',
|
||||||
|
' runtime: pi',
|
||||||
|
' - name: canary-pi',
|
||||||
|
' runtime: codex',
|
||||||
|
].join('\n'),
|
||||||
|
);
|
||||||
|
|
||||||
|
await expect(
|
||||||
|
buildProgram().parseAsync(['node', 'mosaic', 'fleet', '--mosaic-home', home, 'ps']),
|
||||||
|
).rejects.toMatchObject({
|
||||||
|
code: 'fleet.roster',
|
||||||
|
exitCode: 1,
|
||||||
|
message: 'Fleet roster has duplicate agent name: canary-pi.',
|
||||||
|
});
|
||||||
|
expect(stderrSpy.mock.calls.flat().join('')).toContain(
|
||||||
|
'Fleet roster has duplicate agent name: canary-pi.',
|
||||||
|
);
|
||||||
|
expect(stderrSpy.mock.calls.flat().join('')).not.toMatch(/\n\s+at\s/);
|
||||||
|
} finally {
|
||||||
|
stderrSpy.mockRestore();
|
||||||
|
await rm(home, { recursive: true, force: true });
|
||||||
|
}
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
describe('fleet roster parsing', () => {
|
describe('fleet roster parsing', () => {
|
||||||
let cleanup: string | undefined;
|
let cleanup: string | undefined;
|
||||||
|
|
||||||
|
|||||||
@@ -19,8 +19,11 @@ import * as readline from 'node:readline';
|
|||||||
import type { Command } from 'commander';
|
import type { Command } from 'commander';
|
||||||
import YAML from 'yaml';
|
import YAML from 'yaml';
|
||||||
import {
|
import {
|
||||||
|
FleetRosterConfigurationError,
|
||||||
getRosterAgent,
|
getRosterAgent,
|
||||||
loadFleetRoster,
|
loadFleetRoster,
|
||||||
|
parseFleetRosterDocument,
|
||||||
|
readFleetRosterText,
|
||||||
resolveInstalledFleetRosterPath,
|
resolveInstalledFleetRosterPath,
|
||||||
type FleetAgent,
|
type FleetAgent,
|
||||||
type FleetRoster,
|
type FleetRoster,
|
||||||
@@ -1913,7 +1916,7 @@ export function registerFleetCommand(program: Command, deps: FleetCommandDeps =
|
|||||||
const commandOpts = cmd.opts<{ mosaicHome: string; roster?: string }>();
|
const commandOpts = cmd.opts<{ mosaicHome: string; roster?: string }>();
|
||||||
const activePaths = resolveFleetPaths(commandOpts.mosaicHome);
|
const activePaths = resolveFleetPaths(commandOpts.mosaicHome);
|
||||||
const rosterPath = await resolveRosterPath(commandOpts.mosaicHome, commandOpts.roster);
|
const rosterPath = await resolveRosterPath(commandOpts.mosaicHome, commandOpts.roster);
|
||||||
const roster = await loadFleetRoster(rosterPath);
|
const roster = await loadRosterAtPath(cmd, rosterPath);
|
||||||
|
|
||||||
const newAgent: FleetAgent = {
|
const newAgent: FleetAgent = {
|
||||||
name,
|
name,
|
||||||
@@ -1973,7 +1976,7 @@ export function registerFleetCommand(program: Command, deps: FleetCommandDeps =
|
|||||||
const commandOpts = cmd.opts<{ mosaicHome: string; roster?: string }>();
|
const commandOpts = cmd.opts<{ mosaicHome: string; roster?: string }>();
|
||||||
const activePaths = resolveFleetPaths(commandOpts.mosaicHome);
|
const activePaths = resolveFleetPaths(commandOpts.mosaicHome);
|
||||||
const rosterPath = await resolveRosterPath(commandOpts.mosaicHome, commandOpts.roster);
|
const rosterPath = await resolveRosterPath(commandOpts.mosaicHome, commandOpts.roster);
|
||||||
const roster = await loadFleetRoster(rosterPath);
|
const roster = await loadRosterAtPath(cmd, rosterPath);
|
||||||
|
|
||||||
// Guard: throws if removing leaves 0 orchestrators or agent not in roster
|
// Guard: throws if removing leaves 0 orchestrators or agent not in roster
|
||||||
const updatedRoster = removeAgentFromRoster(roster, name);
|
const updatedRoster = removeAgentFromRoster(roster, name);
|
||||||
@@ -2402,14 +2405,19 @@ async function installFleet(cmd: Command, frameworkRoot: string): Promise<void>
|
|||||||
|
|
||||||
async function loadRosterForCommand(cmd: Command): Promise<FleetRoster> {
|
async function loadRosterForCommand(cmd: Command): Promise<FleetRoster> {
|
||||||
const opts = cmd.opts<{ mosaicHome: string; roster?: string }>();
|
const opts = cmd.opts<{ mosaicHome: string; roster?: string }>();
|
||||||
return loadFleetRoster(await resolveRosterPath(opts.mosaicHome, opts.roster));
|
return loadRosterAtPath(cmd, await resolveRosterPath(opts.mosaicHome, opts.roster));
|
||||||
}
|
}
|
||||||
|
|
||||||
/** Routes only a v2 roster to the M3 desired-state control plane; v1 aliases stay compatible. */
|
/** Routes only a v2 roster to the M3 desired-state control plane; v1 aliases stay compatible. */
|
||||||
async function usesRosterV2ControlPlane(cmd: Command): Promise<boolean> {
|
async function usesRosterV2ControlPlane(cmd: Command): Promise<boolean> {
|
||||||
const opts = cmd.opts<{ mosaicHome: string; roster?: string }>();
|
const opts = cmd.opts<{ mosaicHome: string; roster?: string }>();
|
||||||
const path = await resolveRosterPath(opts.mosaicHome, opts.roster);
|
const path = await resolveRosterPath(opts.mosaicHome, opts.roster);
|
||||||
const parsed: unknown = YAML.parse(await readFile(path, 'utf8'));
|
let parsed: unknown;
|
||||||
|
try {
|
||||||
|
parsed = parseFleetRosterDocument(await readFleetRosterText(path), path);
|
||||||
|
} catch (error) {
|
||||||
|
reportFleetRosterConfigurationError(cmd, error);
|
||||||
|
}
|
||||||
return (
|
return (
|
||||||
typeof parsed === 'object' &&
|
typeof parsed === 'object' &&
|
||||||
parsed !== null &&
|
parsed !== null &&
|
||||||
@@ -2425,7 +2433,22 @@ async function loadRosterFromAgentCommand(
|
|||||||
): Promise<FleetRoster> {
|
): Promise<FleetRoster> {
|
||||||
const opts = command.optsWithGlobals<{ mosaicHome?: string; roster?: string }>();
|
const opts = command.optsWithGlobals<{ mosaicHome?: string; roster?: string }>();
|
||||||
const mosaicHome = opts.mosaicHome ?? mosaicHomeOverride ?? defaultMosaicHome();
|
const mosaicHome = opts.mosaicHome ?? mosaicHomeOverride ?? defaultMosaicHome();
|
||||||
return loadFleetRoster(await resolveRosterPath(mosaicHome, opts.roster));
|
return loadRosterAtPath(command, await resolveRosterPath(mosaicHome, opts.roster));
|
||||||
|
}
|
||||||
|
|
||||||
|
async function loadRosterAtPath(command: Command, path: string): Promise<FleetRoster> {
|
||||||
|
try {
|
||||||
|
return await loadFleetRoster(path);
|
||||||
|
} catch (error) {
|
||||||
|
reportFleetRosterConfigurationError(command, error);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
function reportFleetRosterConfigurationError(command: Command, error: unknown): never {
|
||||||
|
if (error instanceof FleetRosterConfigurationError) {
|
||||||
|
command.error(error.message, { code: 'fleet.roster', exitCode: 1 });
|
||||||
|
}
|
||||||
|
throw error;
|
||||||
}
|
}
|
||||||
|
|
||||||
function resolveMosaicHomeFromCommand(command: Command, override?: string): string {
|
function resolveMosaicHomeFromCommand(command: Command, override?: string): string {
|
||||||
|
|||||||
61
packages/mosaic/src/commands/install-arguments.spec.ts
Normal file
61
packages/mosaic/src/commands/install-arguments.spec.ts
Normal file
@@ -0,0 +1,61 @@
|
|||||||
|
import { spawnSync } from 'node:child_process';
|
||||||
|
import { existsSync } from 'node:fs';
|
||||||
|
import { chmod, mkdir, mkdtemp, rm, writeFile } from 'node:fs/promises';
|
||||||
|
import { tmpdir } from 'node:os';
|
||||||
|
import { join } from 'node:path';
|
||||||
|
import { fileURLToPath } from 'node:url';
|
||||||
|
import { describe, expect, it } from 'vitest';
|
||||||
|
|
||||||
|
const INSTALLER_PATH = fileURLToPath(new URL('../../../../tools/install.sh', import.meta.url));
|
||||||
|
|
||||||
|
async function runInstaller(args: string[]): Promise<{
|
||||||
|
status: number | null;
|
||||||
|
stderr: string;
|
||||||
|
npmCalled: boolean;
|
||||||
|
}> {
|
||||||
|
const home = await mkdtemp(join(tmpdir(), 'mosaic-installer-args-'));
|
||||||
|
const bin = join(home, 'bin');
|
||||||
|
const npmMarker = join(home, 'npm-called');
|
||||||
|
|
||||||
|
try {
|
||||||
|
await mkdir(bin);
|
||||||
|
const npmShim = join(bin, 'npm');
|
||||||
|
await writeFile(npmShim, `#!/bin/sh\n: > "${npmMarker}"\n`);
|
||||||
|
await chmod(npmShim, 0o755);
|
||||||
|
|
||||||
|
const result = spawnSync('bash', [INSTALLER_PATH, ...args], {
|
||||||
|
encoding: 'utf8',
|
||||||
|
env: {
|
||||||
|
...process.env,
|
||||||
|
HOME: home,
|
||||||
|
MOSAIC_NO_COLOR: '1',
|
||||||
|
PATH: `${bin}:${process.env.PATH ?? ''}`,
|
||||||
|
},
|
||||||
|
});
|
||||||
|
|
||||||
|
return {
|
||||||
|
status: result.status,
|
||||||
|
stderr: result.stderr,
|
||||||
|
npmCalled: existsSync(npmMarker),
|
||||||
|
};
|
||||||
|
} finally {
|
||||||
|
await rm(home, { recursive: true, force: true });
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
function expectUnknownArgument(result: Awaited<ReturnType<typeof runInstaller>>): void {
|
||||||
|
expect(result.status).not.toBe(0);
|
||||||
|
expect(result.stderr).toContain('Unknown argument: --bogus');
|
||||||
|
expect(result.stderr).toMatch(/Usage: .*install\.sh/);
|
||||||
|
expect(result.npmCalled).toBe(false);
|
||||||
|
}
|
||||||
|
|
||||||
|
describe('installer arguments', () => {
|
||||||
|
it('rejects an unknown argument before installation starts', async () => {
|
||||||
|
expectUnknownArgument(await runInstaller(['--cli', '--bogus']));
|
||||||
|
});
|
||||||
|
|
||||||
|
it('does not let --ref consume an unknown option', async () => {
|
||||||
|
expectUnknownArgument(await runInstaller(['--cli', '--ref', '--bogus']));
|
||||||
|
});
|
||||||
|
});
|
||||||
17
packages/mosaic/src/commands/install-heading.spec.ts
Normal file
17
packages/mosaic/src/commands/install-heading.spec.ts
Normal file
@@ -0,0 +1,17 @@
|
|||||||
|
import { readFile } from 'node:fs/promises';
|
||||||
|
import { fileURLToPath } from 'node:url';
|
||||||
|
import { describe, expect, it } from 'vitest';
|
||||||
|
|
||||||
|
const INSTALLER_PATH = fileURLToPath(new URL('../../../../tools/install.sh', import.meta.url));
|
||||||
|
|
||||||
|
describe('installer CLI package heading', () => {
|
||||||
|
it('renders the scoped package name intact', async () => {
|
||||||
|
const installer = await readFile(INSTALLER_PATH, 'utf8');
|
||||||
|
const step = installer.match(/^step\(\)\s*\{.*\}$/m)?.[0];
|
||||||
|
|
||||||
|
expect(step).toBeDefined();
|
||||||
|
|
||||||
|
expect(step).toContain('printf \'\\n%s%s%s\\n\' "$BOLD" "$*" "$RESET"');
|
||||||
|
expect(installer).toContain('step "@mosaicstack/mosaic (npm package)"');
|
||||||
|
});
|
||||||
|
});
|
||||||
421
packages/mosaic/src/commands/skill.spec.ts
Normal file
421
packages/mosaic/src/commands/skill.spec.ts
Normal file
@@ -0,0 +1,421 @@
|
|||||||
|
import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest';
|
||||||
|
import { Command } from 'commander';
|
||||||
|
import { spawnSync } from 'node:child_process';
|
||||||
|
import { fileURLToPath } from 'node:url';
|
||||||
|
import {
|
||||||
|
existsSync,
|
||||||
|
lstatSync,
|
||||||
|
mkdirSync,
|
||||||
|
mkdtempSync,
|
||||||
|
readlinkSync,
|
||||||
|
rmSync,
|
||||||
|
symlinkSync,
|
||||||
|
writeFileSync,
|
||||||
|
} from 'node:fs';
|
||||||
|
import { tmpdir } from 'node:os';
|
||||||
|
import { join } from 'node:path';
|
||||||
|
import {
|
||||||
|
listSkills,
|
||||||
|
registerSkill,
|
||||||
|
registerSkillCommand,
|
||||||
|
syncClaudeSkills,
|
||||||
|
unregisterSkill,
|
||||||
|
type SkillPaths,
|
||||||
|
} from './skill.js';
|
||||||
|
|
||||||
|
const LEGACY_SYNC_SCRIPT = fileURLToPath(
|
||||||
|
new URL('../../framework/tools/_scripts/mosaic-sync-skills', import.meta.url),
|
||||||
|
);
|
||||||
|
|
||||||
|
describe('Claude skill bridge', () => {
|
||||||
|
let root: string;
|
||||||
|
let paths: SkillPaths;
|
||||||
|
|
||||||
|
beforeEach(() => {
|
||||||
|
root = mkdtempSync(join(tmpdir(), 'mosaic-skill-cli-'));
|
||||||
|
paths = {
|
||||||
|
mosaicSkillsDir: join(root, '.config', 'mosaic', 'skills'),
|
||||||
|
claudeSkillsDir: join(root, '.claude', 'skills'),
|
||||||
|
};
|
||||||
|
mkdirSync(paths.mosaicSkillsDir, { recursive: true });
|
||||||
|
});
|
||||||
|
|
||||||
|
afterEach(() => {
|
||||||
|
rmSync(root, { recursive: true, force: true });
|
||||||
|
});
|
||||||
|
|
||||||
|
function createSkill(name: string): string {
|
||||||
|
const skillDir = join(paths.mosaicSkillsDir, name);
|
||||||
|
mkdirSync(skillDir, { recursive: true });
|
||||||
|
writeFileSync(join(skillDir, 'SKILL.md'), `# ${name}\n`);
|
||||||
|
return skillDir;
|
||||||
|
}
|
||||||
|
|
||||||
|
function expectCorrectLink(name: string): void {
|
||||||
|
const linkPath = join(paths.claudeSkillsDir, name);
|
||||||
|
expect(lstatSync(linkPath).isSymbolicLink()).toBe(true);
|
||||||
|
expect(readlinkSync(linkPath)).toBe(join(paths.mosaicSkillsDir, name));
|
||||||
|
}
|
||||||
|
|
||||||
|
describe('name validation', () => {
|
||||||
|
const invalidNames = [
|
||||||
|
'../../etc',
|
||||||
|
'/abs/path',
|
||||||
|
'a/b',
|
||||||
|
String.raw`a\b`,
|
||||||
|
'-rf',
|
||||||
|
'..',
|
||||||
|
'safe.',
|
||||||
|
'space name',
|
||||||
|
'line\nbreak',
|
||||||
|
'escape\u001B[31m',
|
||||||
|
];
|
||||||
|
|
||||||
|
for (const name of invalidNames) {
|
||||||
|
it(`rejects ${JSON.stringify(name)} before register can escape its roots`, () => {
|
||||||
|
expect(() => registerSkill(name, paths)).toThrow(/invalid skill name/i);
|
||||||
|
expect(existsSync(paths.claudeSkillsDir)).toBe(false);
|
||||||
|
});
|
||||||
|
|
||||||
|
it(`rejects ${JSON.stringify(name)} before unregister can escape its roots`, () => {
|
||||||
|
expect(() => unregisterSkill(name, paths)).toThrow(/invalid skill name/i);
|
||||||
|
expect(existsSync(paths.claudeSkillsDir)).toBe(false);
|
||||||
|
});
|
||||||
|
}
|
||||||
|
});
|
||||||
|
|
||||||
|
describe('CLI validation errors', () => {
|
||||||
|
let previousExitCode: number | string | null | undefined;
|
||||||
|
|
||||||
|
beforeEach(() => {
|
||||||
|
previousExitCode = process.exitCode;
|
||||||
|
process.exitCode = undefined;
|
||||||
|
});
|
||||||
|
|
||||||
|
afterEach(() => {
|
||||||
|
process.exitCode = previousExitCode;
|
||||||
|
});
|
||||||
|
|
||||||
|
it.each(['register', 'unregister'])(
|
||||||
|
'reports invalid %s names on stderr and sets a nonzero exit status',
|
||||||
|
async (subcommand) => {
|
||||||
|
const error = vi.spyOn(console, 'error').mockImplementation(() => undefined);
|
||||||
|
const program = new Command().exitOverride();
|
||||||
|
registerSkillCommand(program, paths);
|
||||||
|
|
||||||
|
await program.parseAsync(['node', 'mosaic', 'skill', subcommand, '../../etc']);
|
||||||
|
|
||||||
|
expect(error).toHaveBeenCalledWith(expect.stringMatching(/invalid skill name/i));
|
||||||
|
expect(process.exitCode).toBe(1);
|
||||||
|
expect(existsSync(paths.claudeSkillsDir)).toBe(false);
|
||||||
|
error.mockRestore();
|
||||||
|
},
|
||||||
|
);
|
||||||
|
});
|
||||||
|
|
||||||
|
describe('CLI status output', () => {
|
||||||
|
let previousExitCode: number | string | null | undefined;
|
||||||
|
|
||||||
|
beforeEach(() => {
|
||||||
|
previousExitCode = process.exitCode;
|
||||||
|
process.exitCode = undefined;
|
||||||
|
});
|
||||||
|
|
||||||
|
afterEach(() => {
|
||||||
|
process.exitCode = previousExitCode;
|
||||||
|
});
|
||||||
|
|
||||||
|
async function run(...args: string[]): Promise<void> {
|
||||||
|
const program = new Command().exitOverride();
|
||||||
|
registerSkillCommand(program, paths);
|
||||||
|
await program.parseAsync(['node', 'mosaic', 'skill', ...args]);
|
||||||
|
}
|
||||||
|
|
||||||
|
it('reports register repair/idempotency and unregister idempotency statuses', async () => {
|
||||||
|
const log = vi.spyOn(console, 'log').mockImplementation(() => undefined);
|
||||||
|
createSkill('status-skill');
|
||||||
|
|
||||||
|
await run('register', 'status-skill');
|
||||||
|
await run('register', 'status-skill');
|
||||||
|
rmSync(join(paths.claudeSkillsDir, 'status-skill'));
|
||||||
|
symlinkSync(
|
||||||
|
join(paths.mosaicSkillsDir, 'retired'),
|
||||||
|
join(paths.claudeSkillsDir, 'status-skill'),
|
||||||
|
);
|
||||||
|
await run('register', 'status-skill');
|
||||||
|
await run('unregister', 'status-skill');
|
||||||
|
await run('unregister', 'status-skill');
|
||||||
|
|
||||||
|
expect(log.mock.calls.flat()).toEqual([
|
||||||
|
'status-skill: registered',
|
||||||
|
'status-skill: already registered',
|
||||||
|
'status-skill: repaired dangling registration',
|
||||||
|
'status-skill: unregistered',
|
||||||
|
'status-skill: already unregistered',
|
||||||
|
]);
|
||||||
|
log.mockRestore();
|
||||||
|
});
|
||||||
|
|
||||||
|
it('reports empty and populated skill lists', async () => {
|
||||||
|
const log = vi.spyOn(console, 'log').mockImplementation(() => undefined);
|
||||||
|
|
||||||
|
await run('list');
|
||||||
|
createSkill('listed');
|
||||||
|
await run('list');
|
||||||
|
|
||||||
|
expect(log).toHaveBeenCalledWith('No Mosaic or Claude Code skills found.');
|
||||||
|
expect(log).toHaveBeenCalledWith(expect.stringMatching(/^unregistered\s+listed$/));
|
||||||
|
log.mockRestore();
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
describe('registerSkill', () => {
|
||||||
|
it('creates the exact canonical symlink and is idempotent', () => {
|
||||||
|
createSkill('new-skill');
|
||||||
|
|
||||||
|
expect(registerSkill('new-skill', paths).status).toBe('registered');
|
||||||
|
expectCorrectLink('new-skill');
|
||||||
|
|
||||||
|
expect(registerSkill('new-skill', paths).status).toBe('already-registered');
|
||||||
|
expectCorrectLink('new-skill');
|
||||||
|
});
|
||||||
|
|
||||||
|
it('repairs a dangling Mosaic-owned symlink', () => {
|
||||||
|
createSkill('new-skill');
|
||||||
|
mkdirSync(paths.claudeSkillsDir, { recursive: true });
|
||||||
|
symlinkSync(
|
||||||
|
join(paths.mosaicSkillsDir, 'retired-skill'),
|
||||||
|
join(paths.claudeSkillsDir, 'new-skill'),
|
||||||
|
);
|
||||||
|
|
||||||
|
expect(registerSkill('new-skill', paths).status).toBe('repaired');
|
||||||
|
expectCorrectLink('new-skill');
|
||||||
|
});
|
||||||
|
|
||||||
|
it.each(['file', 'directory', 'symlink'] as const)(
|
||||||
|
'refuses to clobber a foreign %s at the target',
|
||||||
|
(kind) => {
|
||||||
|
createSkill('protected');
|
||||||
|
mkdirSync(paths.claudeSkillsDir, { recursive: true });
|
||||||
|
const target = join(paths.claudeSkillsDir, 'protected');
|
||||||
|
const foreign = join(root, 'foreign');
|
||||||
|
|
||||||
|
if (kind === 'file') writeFileSync(target, 'keep me\n');
|
||||||
|
if (kind === 'directory') mkdirSync(target);
|
||||||
|
if (kind === 'symlink') {
|
||||||
|
writeFileSync(foreign, 'keep me\n');
|
||||||
|
symlinkSync(foreign, target);
|
||||||
|
}
|
||||||
|
|
||||||
|
expect(() => registerSkill('protected', paths)).toThrow(/foreign|refus/i);
|
||||||
|
if (kind === 'file') expect(lstatSync(target).isFile()).toBe(true);
|
||||||
|
if (kind === 'directory') expect(lstatSync(target).isDirectory()).toBe(true);
|
||||||
|
if (kind === 'symlink') expect(readlinkSync(target)).toBe(foreign);
|
||||||
|
},
|
||||||
|
);
|
||||||
|
|
||||||
|
it('refuses a symlinked Claude skills ancestor instead of writing outside the bridge root', () => {
|
||||||
|
createSkill('protected');
|
||||||
|
const externalClaude = join(root, 'external-claude');
|
||||||
|
mkdirSync(externalClaude);
|
||||||
|
symlinkSync(externalClaude, join(root, '.claude'));
|
||||||
|
|
||||||
|
expect(() => registerSkill('protected', paths)).toThrow(
|
||||||
|
/symlink.*ancestor|ancestor.*symlink/i,
|
||||||
|
);
|
||||||
|
expect(existsSync(join(externalClaude, 'skills', 'protected'))).toBe(false);
|
||||||
|
});
|
||||||
|
|
||||||
|
it('refuses a symlinked canonical skills root instead of registering an external source', () => {
|
||||||
|
rmSync(paths.mosaicSkillsDir, { recursive: true });
|
||||||
|
const externalSkills = join(root, 'external-skills');
|
||||||
|
mkdirSync(join(externalSkills, 'protected'), { recursive: true });
|
||||||
|
symlinkSync(externalSkills, paths.mosaicSkillsDir);
|
||||||
|
|
||||||
|
expect(() => registerSkill('protected', paths)).toThrow(
|
||||||
|
/symlink.*ancestor|ancestor.*symlink/i,
|
||||||
|
);
|
||||||
|
expect(existsSync(paths.claudeSkillsDir)).toBe(false);
|
||||||
|
});
|
||||||
|
|
||||||
|
it('refuses a dangling foreign symlink rather than treating it as repairable', () => {
|
||||||
|
createSkill('protected');
|
||||||
|
mkdirSync(paths.claudeSkillsDir, { recursive: true });
|
||||||
|
const foreignMissing = join(root, 'foreign-missing');
|
||||||
|
const target = join(paths.claudeSkillsDir, 'protected');
|
||||||
|
symlinkSync(foreignMissing, target);
|
||||||
|
|
||||||
|
expect(() => registerSkill('protected', paths)).toThrow(/foreign|refus/i);
|
||||||
|
expect(readlinkSync(target)).toBe(foreignMissing);
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
describe('unregisterSkill', () => {
|
||||||
|
it('removes a Mosaic-owned symlink and is idempotent when absent', () => {
|
||||||
|
createSkill('removable');
|
||||||
|
registerSkill('removable', paths);
|
||||||
|
|
||||||
|
expect(unregisterSkill('removable', paths).status).toBe('unregistered');
|
||||||
|
expect(existsSync(join(paths.claudeSkillsDir, 'removable'))).toBe(false);
|
||||||
|
|
||||||
|
expect(unregisterSkill('removable', paths).status).toBe('already-unregistered');
|
||||||
|
});
|
||||||
|
|
||||||
|
it('refuses to remove a misdirected Mosaic-root symlink', () => {
|
||||||
|
createSkill('other');
|
||||||
|
mkdirSync(paths.claudeSkillsDir, { recursive: true });
|
||||||
|
const requested = join(paths.claudeSkillsDir, 'requested');
|
||||||
|
symlinkSync(join(paths.mosaicSkillsDir, 'other'), requested);
|
||||||
|
|
||||||
|
expect(() => unregisterSkill('requested', paths)).toThrow(/misdirected/i);
|
||||||
|
expect(readlinkSync(requested)).toBe(join(paths.mosaicSkillsDir, 'other'));
|
||||||
|
});
|
||||||
|
|
||||||
|
it.each(['file', 'directory', 'symlink'] as const)('refuses to remove a foreign %s', (kind) => {
|
||||||
|
mkdirSync(paths.claudeSkillsDir, { recursive: true });
|
||||||
|
const target = join(paths.claudeSkillsDir, 'protected');
|
||||||
|
const foreign = join(root, 'foreign');
|
||||||
|
|
||||||
|
if (kind === 'file') writeFileSync(target, 'keep me\n');
|
||||||
|
if (kind === 'directory') mkdirSync(target);
|
||||||
|
if (kind === 'symlink') {
|
||||||
|
writeFileSync(foreign, 'keep me\n');
|
||||||
|
symlinkSync(foreign, target);
|
||||||
|
}
|
||||||
|
|
||||||
|
expect(() => unregisterSkill('protected', paths)).toThrow(/foreign|refus/i);
|
||||||
|
expect(lstatSync(target)).toBeDefined();
|
||||||
|
if (kind === 'symlink') expect(readlinkSync(target)).toBe(foreign);
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
describe('listSkills', () => {
|
||||||
|
it('flags registered, unregistered, Mosaic-owned dangling, and foreign entries', () => {
|
||||||
|
createSkill('registered');
|
||||||
|
createSkill('unregistered');
|
||||||
|
registerSkill('registered', paths);
|
||||||
|
symlinkSync(join(paths.mosaicSkillsDir, 'retired'), join(paths.claudeSkillsDir, 'dangling'));
|
||||||
|
writeFileSync(join(paths.claudeSkillsDir, 'foreign-file'), 'keep me\n');
|
||||||
|
symlinkSync(join(root, 'missing-foreign'), join(paths.claudeSkillsDir, 'foreign-link'));
|
||||||
|
|
||||||
|
expect(listSkills(paths)).toEqual([
|
||||||
|
expect.objectContaining({ name: 'dangling', status: 'dangling' }),
|
||||||
|
expect.objectContaining({ name: 'foreign-file', status: 'foreign' }),
|
||||||
|
expect.objectContaining({ name: 'foreign-link', status: 'foreign-dangling' }),
|
||||||
|
expect.objectContaining({ name: 'registered', status: 'registered' }),
|
||||||
|
expect.objectContaining({ name: 'unregistered', status: 'unregistered' }),
|
||||||
|
]);
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
describe('install linker compatibility', () => {
|
||||||
|
it('preserves foreign-name links into Mosaic home but outside canonical skills', () => {
|
||||||
|
createSkill('missing');
|
||||||
|
mkdirSync(paths.claudeSkillsDir, { recursive: true });
|
||||||
|
const mosaicHome = join(root, '.config', 'mosaic');
|
||||||
|
const liveForeignTarget = join(mosaicHome, 'foreign-non-skill-target');
|
||||||
|
mkdirSync(liveForeignTarget);
|
||||||
|
const liveForeignLink = join(paths.claudeSkillsDir, 'foreign-tool');
|
||||||
|
const danglingForeignLink = join(paths.claudeSkillsDir, 'unresolvable-foreign');
|
||||||
|
symlinkSync(liveForeignTarget, liveForeignLink);
|
||||||
|
symlinkSync(join(mosaicHome, 'foreign-missing'), danglingForeignLink);
|
||||||
|
|
||||||
|
const result = spawnSync('bash', [LEGACY_SYNC_SCRIPT, '--link-only'], {
|
||||||
|
encoding: 'utf8',
|
||||||
|
env: { ...process.env, HOME: root, MOSAIC_HOME: mosaicHome },
|
||||||
|
});
|
||||||
|
|
||||||
|
expect(result.status, result.stderr).toBe(0);
|
||||||
|
expect(readlinkSync(liveForeignLink)).toBe(liveForeignTarget);
|
||||||
|
expect(readlinkSync(danglingForeignLink)).toBe(join(mosaicHome, 'foreign-missing'));
|
||||||
|
expect(readlinkSync(join(paths.claudeSkillsDir, 'missing'))).toBe(
|
||||||
|
join(paths.mosaicSkillsDir, 'missing'),
|
||||||
|
);
|
||||||
|
});
|
||||||
|
|
||||||
|
it('preserves live and dangling foreign Claude symlinks while linking missing skills', () => {
|
||||||
|
createSkill('dangling-foreign');
|
||||||
|
createSkill('live-foreign');
|
||||||
|
createSkill('missing');
|
||||||
|
mkdirSync(paths.claudeSkillsDir, { recursive: true });
|
||||||
|
const external = join(root, 'external');
|
||||||
|
mkdirSync(external);
|
||||||
|
const liveLink = join(paths.claudeSkillsDir, 'live-foreign');
|
||||||
|
const danglingLink = join(paths.claudeSkillsDir, 'dangling-foreign');
|
||||||
|
symlinkSync(external, liveLink);
|
||||||
|
symlinkSync(join(root, 'external-missing'), danglingLink);
|
||||||
|
|
||||||
|
const result = spawnSync('bash', [LEGACY_SYNC_SCRIPT, '--link-only'], {
|
||||||
|
encoding: 'utf8',
|
||||||
|
env: { ...process.env, HOME: root, MOSAIC_HOME: join(root, '.config', 'mosaic') },
|
||||||
|
});
|
||||||
|
|
||||||
|
expect(result.status, result.stderr).toBe(0);
|
||||||
|
expect(readlinkSync(liveLink)).toBe(external);
|
||||||
|
expect(readlinkSync(danglingLink)).toBe(join(root, 'external-missing'));
|
||||||
|
expect(readlinkSync(join(paths.claudeSkillsDir, 'missing'))).toBe(
|
||||||
|
join(paths.mosaicSkillsDir, 'missing'),
|
||||||
|
);
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
describe('syncClaudeSkills', () => {
|
||||||
|
it('generically creates every missing canonical link and repairs managed broken links', () => {
|
||||||
|
createSkill('added-after-setup');
|
||||||
|
createSkill('another-new-skill');
|
||||||
|
mkdirSync(paths.claudeSkillsDir, { recursive: true });
|
||||||
|
symlinkSync(
|
||||||
|
join(paths.mosaicSkillsDir, 'retired'),
|
||||||
|
join(paths.claudeSkillsDir, 'added-after-setup'),
|
||||||
|
);
|
||||||
|
|
||||||
|
const result = syncClaudeSkills(paths);
|
||||||
|
|
||||||
|
expect(result).toEqual({
|
||||||
|
registered: ['another-new-skill'],
|
||||||
|
repaired: ['added-after-setup'],
|
||||||
|
unchanged: [],
|
||||||
|
conflicts: [],
|
||||||
|
});
|
||||||
|
expectCorrectLink('added-after-setup');
|
||||||
|
expectCorrectLink('another-new-skill');
|
||||||
|
});
|
||||||
|
|
||||||
|
it('escapes an invalid filesystem-derived name in conflict output', () => {
|
||||||
|
createSkill('line\nbreak');
|
||||||
|
|
||||||
|
const result = syncClaudeSkills(paths);
|
||||||
|
|
||||||
|
expect(result.registered).toEqual([]);
|
||||||
|
expect(result.conflicts).toEqual([
|
||||||
|
expect.objectContaining({
|
||||||
|
name: '"line\\nbreak"',
|
||||||
|
reason: expect.stringMatching(/invalid/i),
|
||||||
|
}),
|
||||||
|
]);
|
||||||
|
expect(existsSync(paths.claudeSkillsDir)).toBe(false);
|
||||||
|
});
|
||||||
|
|
||||||
|
it('continues syncing other skills without clobbering foreign entries', () => {
|
||||||
|
createSkill('blocked');
|
||||||
|
createSkill('link-me');
|
||||||
|
mkdirSync(paths.claudeSkillsDir, { recursive: true });
|
||||||
|
const blocked = join(paths.claudeSkillsDir, 'blocked');
|
||||||
|
writeFileSync(blocked, 'keep me\n');
|
||||||
|
|
||||||
|
const result = syncClaudeSkills(paths);
|
||||||
|
|
||||||
|
expect(result.registered).toEqual(['link-me']);
|
||||||
|
expect(result.conflicts).toEqual([
|
||||||
|
expect.objectContaining({
|
||||||
|
name: 'blocked',
|
||||||
|
reason: expect.stringMatching(/foreign|refus/i),
|
||||||
|
}),
|
||||||
|
]);
|
||||||
|
expect(readlinkSync(join(paths.claudeSkillsDir, 'link-me'))).toBe(
|
||||||
|
join(paths.mosaicSkillsDir, 'link-me'),
|
||||||
|
);
|
||||||
|
expect(lstatSync(blocked).isFile()).toBe(true);
|
||||||
|
});
|
||||||
|
});
|
||||||
|
});
|
||||||
419
packages/mosaic/src/commands/skill.ts
Normal file
419
packages/mosaic/src/commands/skill.ts
Normal file
@@ -0,0 +1,419 @@
|
|||||||
|
import {
|
||||||
|
existsSync,
|
||||||
|
lstatSync,
|
||||||
|
mkdirSync,
|
||||||
|
readdirSync,
|
||||||
|
readlinkSync,
|
||||||
|
symlinkSync,
|
||||||
|
unlinkSync,
|
||||||
|
type Stats,
|
||||||
|
} from 'node:fs';
|
||||||
|
import { homedir } from 'node:os';
|
||||||
|
import { dirname, isAbsolute, join, parse, relative, resolve, sep } from 'node:path';
|
||||||
|
import type { Command } from 'commander';
|
||||||
|
import { DEFAULT_MOSAIC_HOME } from '../constants.js';
|
||||||
|
|
||||||
|
export interface SkillPaths {
|
||||||
|
mosaicSkillsDir: string;
|
||||||
|
claudeSkillsDir: string;
|
||||||
|
}
|
||||||
|
|
||||||
|
export type SkillRegistrationStatus = 'registered' | 'already-registered' | 'repaired';
|
||||||
|
export type SkillUnregistrationStatus = 'unregistered' | 'already-unregistered';
|
||||||
|
export type SkillListStatus =
|
||||||
|
| 'registered'
|
||||||
|
| 'unregistered'
|
||||||
|
| 'dangling'
|
||||||
|
| 'foreign'
|
||||||
|
| 'foreign-dangling'
|
||||||
|
| 'misdirected';
|
||||||
|
|
||||||
|
export interface SkillRegistrationResult {
|
||||||
|
name: string;
|
||||||
|
status: SkillRegistrationStatus;
|
||||||
|
sourcePath: string;
|
||||||
|
linkPath: string;
|
||||||
|
}
|
||||||
|
|
||||||
|
export interface SkillUnregistrationResult {
|
||||||
|
name: string;
|
||||||
|
status: SkillUnregistrationStatus;
|
||||||
|
linkPath: string;
|
||||||
|
}
|
||||||
|
|
||||||
|
export interface SkillListEntry {
|
||||||
|
name: string;
|
||||||
|
status: SkillListStatus;
|
||||||
|
sourcePath?: string;
|
||||||
|
linkPath: string;
|
||||||
|
targetPath?: string;
|
||||||
|
}
|
||||||
|
|
||||||
|
export interface SkillSyncConflict {
|
||||||
|
name: string;
|
||||||
|
reason: string;
|
||||||
|
}
|
||||||
|
|
||||||
|
export interface SkillSyncResult {
|
||||||
|
registered: string[];
|
||||||
|
repaired: string[];
|
||||||
|
unchanged: string[];
|
||||||
|
conflicts: SkillSyncConflict[];
|
||||||
|
}
|
||||||
|
|
||||||
|
const SAFE_SKILL_NAME = /^[A-Za-z0-9][A-Za-z0-9._-]*$/;
|
||||||
|
|
||||||
|
export class SkillBridgeError extends Error {
|
||||||
|
public constructor(message: string) {
|
||||||
|
super(message);
|
||||||
|
this.name = 'SkillBridgeError';
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/** Resolve the production bridge paths while keeping tests injectable. */
|
||||||
|
export function getDefaultSkillPaths(): SkillPaths {
|
||||||
|
const mosaicHome = process.env['MOSAIC_HOME'] ?? DEFAULT_MOSAIC_HOME;
|
||||||
|
const claudeHome = process.env['CLAUDE_HOME'] ?? join(homedir(), '.claude');
|
||||||
|
return {
|
||||||
|
mosaicSkillsDir: join(mosaicHome, 'skills'),
|
||||||
|
claudeSkillsDir: join(claudeHome, 'skills'),
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Reject a user-supplied name before any filesystem operation.
|
||||||
|
* A skill name must identify one direct child in both managed roots.
|
||||||
|
*/
|
||||||
|
export function validateSkillName(name: string): void {
|
||||||
|
if (
|
||||||
|
name.length === 0 ||
|
||||||
|
name.startsWith('-') ||
|
||||||
|
name.endsWith('.') ||
|
||||||
|
name.includes('..') ||
|
||||||
|
name.includes('/') ||
|
||||||
|
name.includes('\\') ||
|
||||||
|
isAbsolute(name) ||
|
||||||
|
!SAFE_SKILL_NAME.test(name)
|
||||||
|
) {
|
||||||
|
throw new SkillBridgeError(
|
||||||
|
`Invalid skill name ${JSON.stringify(name)}: use letters, numbers, dots, underscores, or hyphens; start with a letter or number; and do not use paths, "..", or a leading "-".`,
|
||||||
|
);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
function displaySkillName(name: string): string {
|
||||||
|
return SAFE_SKILL_NAME.test(name) ? name : JSON.stringify(name);
|
||||||
|
}
|
||||||
|
|
||||||
|
function lstatIfPresent(path: string): Stats | undefined {
|
||||||
|
try {
|
||||||
|
return lstatSync(path);
|
||||||
|
} catch (error: unknown) {
|
||||||
|
if (error instanceof Error && 'code' in error && error.code === 'ENOENT') return undefined;
|
||||||
|
throw error;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
function assertNoSymlinkAncestors(path: string): void {
|
||||||
|
const absolute = resolve(path);
|
||||||
|
const pathRoot = parse(absolute).root;
|
||||||
|
let current = pathRoot;
|
||||||
|
|
||||||
|
for (const segment of relative(pathRoot, absolute).split(sep)) {
|
||||||
|
if (segment.length === 0) continue;
|
||||||
|
current = join(current, segment);
|
||||||
|
const entry = lstatIfPresent(current);
|
||||||
|
if (!entry) break;
|
||||||
|
if (entry.isSymbolicLink()) {
|
||||||
|
throw new SkillBridgeError(
|
||||||
|
`Refusing symlink ancestor at ${current}; managed skill roots must resolve without symlink traversal.`,
|
||||||
|
);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
function assertManagedRoots(paths: SkillPaths): void {
|
||||||
|
assertNoSymlinkAncestors(paths.mosaicSkillsDir);
|
||||||
|
assertNoSymlinkAncestors(paths.claudeSkillsDir);
|
||||||
|
}
|
||||||
|
|
||||||
|
function directChild(root: string, name: string): string {
|
||||||
|
const resolvedRoot = resolve(root);
|
||||||
|
const child = resolve(resolvedRoot, name);
|
||||||
|
if (dirname(child) !== resolvedRoot) {
|
||||||
|
throw new SkillBridgeError(`Invalid skill name "${name}": resolved path escapes its root.`);
|
||||||
|
}
|
||||||
|
return child;
|
||||||
|
}
|
||||||
|
|
||||||
|
function resolveLinkTarget(linkPath: string): string {
|
||||||
|
return resolve(dirname(linkPath), readlinkSync(linkPath));
|
||||||
|
}
|
||||||
|
|
||||||
|
function isInsideSkillsRoot(targetPath: string, skillsRoot: string): boolean {
|
||||||
|
const rel = relative(resolve(skillsRoot), resolve(targetPath));
|
||||||
|
return rel.length > 0 && rel !== '..' && !rel.startsWith(`..${sep}`) && !isAbsolute(rel);
|
||||||
|
}
|
||||||
|
|
||||||
|
function isDangling(linkPath: string): boolean {
|
||||||
|
return !existsSync(linkPath);
|
||||||
|
}
|
||||||
|
|
||||||
|
function assertSourceSkill(name: string, paths: SkillPaths): string {
|
||||||
|
const sourcePath = directChild(paths.mosaicSkillsDir, name);
|
||||||
|
const source = lstatIfPresent(sourcePath);
|
||||||
|
if (!source?.isDirectory()) {
|
||||||
|
throw new SkillBridgeError(
|
||||||
|
`Canonical skill directory not found: ${sourcePath}. Add the skill under the Mosaic skills directory before registering it.`,
|
||||||
|
);
|
||||||
|
}
|
||||||
|
return sourcePath;
|
||||||
|
}
|
||||||
|
|
||||||
|
function foreignTargetError(linkPath: string): SkillBridgeError {
|
||||||
|
return new SkillBridgeError(
|
||||||
|
`Refusing to modify foreign entry at ${linkPath}; only symlinks pointing inside the Mosaic skills directory are managed.`,
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
/** Register one canonical skill with Claude Code without clobbering foreign entries. */
|
||||||
|
export function registerSkill(
|
||||||
|
name: string,
|
||||||
|
paths: SkillPaths = getDefaultSkillPaths(),
|
||||||
|
): SkillRegistrationResult {
|
||||||
|
validateSkillName(name);
|
||||||
|
assertManagedRoots(paths);
|
||||||
|
const sourcePath = assertSourceSkill(name, paths);
|
||||||
|
const linkPath = directChild(paths.claudeSkillsDir, name);
|
||||||
|
const existing = lstatIfPresent(linkPath);
|
||||||
|
|
||||||
|
if (!existing) {
|
||||||
|
mkdirSync(paths.claudeSkillsDir, { recursive: true });
|
||||||
|
symlinkSync(sourcePath, linkPath);
|
||||||
|
return { name, status: 'registered', sourcePath, linkPath };
|
||||||
|
}
|
||||||
|
|
||||||
|
if (!existing.isSymbolicLink()) throw foreignTargetError(linkPath);
|
||||||
|
|
||||||
|
const existingTarget = resolveLinkTarget(linkPath);
|
||||||
|
if (!isInsideSkillsRoot(existingTarget, paths.mosaicSkillsDir)) {
|
||||||
|
throw foreignTargetError(linkPath);
|
||||||
|
}
|
||||||
|
|
||||||
|
if (existingTarget === resolve(sourcePath) && !isDangling(linkPath)) {
|
||||||
|
return { name, status: 'already-registered', sourcePath, linkPath };
|
||||||
|
}
|
||||||
|
|
||||||
|
if (!isDangling(linkPath)) {
|
||||||
|
throw new SkillBridgeError(
|
||||||
|
`Refusing to replace live Mosaic skill symlink at ${linkPath}; it points to ${existingTarget}, not ${sourcePath}.`,
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
unlinkSync(linkPath);
|
||||||
|
symlinkSync(sourcePath, linkPath);
|
||||||
|
return { name, status: 'repaired', sourcePath, linkPath };
|
||||||
|
}
|
||||||
|
|
||||||
|
/** Unregister only a symlink owned by the canonical Mosaic skills root. */
|
||||||
|
export function unregisterSkill(
|
||||||
|
name: string,
|
||||||
|
paths: SkillPaths = getDefaultSkillPaths(),
|
||||||
|
): SkillUnregistrationResult {
|
||||||
|
validateSkillName(name);
|
||||||
|
assertManagedRoots(paths);
|
||||||
|
const linkPath = directChild(paths.claudeSkillsDir, name);
|
||||||
|
const existing = lstatIfPresent(linkPath);
|
||||||
|
|
||||||
|
if (!existing) return { name, status: 'already-unregistered', linkPath };
|
||||||
|
if (!existing.isSymbolicLink()) throw foreignTargetError(linkPath);
|
||||||
|
|
||||||
|
const targetPath = resolveLinkTarget(linkPath);
|
||||||
|
if (!isInsideSkillsRoot(targetPath, paths.mosaicSkillsDir)) throw foreignTargetError(linkPath);
|
||||||
|
|
||||||
|
const expectedTarget = resolve(directChild(paths.mosaicSkillsDir, name));
|
||||||
|
if (targetPath !== expectedTarget) {
|
||||||
|
throw new SkillBridgeError(
|
||||||
|
`Refusing to unregister misdirected Mosaic skill symlink at ${linkPath}; it points to ${targetPath}, not ${expectedTarget}.`,
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
unlinkSync(linkPath);
|
||||||
|
return { name, status: 'unregistered', linkPath };
|
||||||
|
}
|
||||||
|
|
||||||
|
function canonicalSkillNames(paths: SkillPaths): string[] {
|
||||||
|
assertNoSymlinkAncestors(paths.mosaicSkillsDir);
|
||||||
|
const root = lstatIfPresent(paths.mosaicSkillsDir);
|
||||||
|
if (!root) return [];
|
||||||
|
if (!root.isDirectory()) {
|
||||||
|
throw new SkillBridgeError(
|
||||||
|
`Canonical skills path is not a directory: ${paths.mosaicSkillsDir}`,
|
||||||
|
);
|
||||||
|
}
|
||||||
|
return readdirSync(paths.mosaicSkillsDir, { withFileTypes: true })
|
||||||
|
.filter((entry) => entry.isDirectory())
|
||||||
|
.map((entry) => entry.name)
|
||||||
|
.sort();
|
||||||
|
}
|
||||||
|
|
||||||
|
function claudeEntryNames(paths: SkillPaths): string[] {
|
||||||
|
assertNoSymlinkAncestors(paths.claudeSkillsDir);
|
||||||
|
const root = lstatIfPresent(paths.claudeSkillsDir);
|
||||||
|
if (!root) return [];
|
||||||
|
if (!root.isDirectory()) {
|
||||||
|
throw new SkillBridgeError(`Claude skills path is not a directory: ${paths.claudeSkillsDir}`);
|
||||||
|
}
|
||||||
|
return readdirSync(paths.claudeSkillsDir)
|
||||||
|
.filter((name) => name.length > 0)
|
||||||
|
.sort();
|
||||||
|
}
|
||||||
|
|
||||||
|
/** Return a deterministic union of canonical skills and Claude bridge entries. */
|
||||||
|
export function listSkills(paths: SkillPaths = getDefaultSkillPaths()): SkillListEntry[] {
|
||||||
|
const canonicalNames = new Set(canonicalSkillNames(paths));
|
||||||
|
const names = new Set([...canonicalNames, ...claudeEntryNames(paths)]);
|
||||||
|
const entries: SkillListEntry[] = [];
|
||||||
|
|
||||||
|
for (const name of [...names].sort()) {
|
||||||
|
const sourcePath = canonicalNames.has(name)
|
||||||
|
? directChild(paths.mosaicSkillsDir, name)
|
||||||
|
: undefined;
|
||||||
|
const linkPath = directChild(paths.claudeSkillsDir, name);
|
||||||
|
const installed = lstatIfPresent(linkPath);
|
||||||
|
|
||||||
|
if (!installed) {
|
||||||
|
if (sourcePath) entries.push({ name, status: 'unregistered', sourcePath, linkPath });
|
||||||
|
continue;
|
||||||
|
}
|
||||||
|
|
||||||
|
if (!installed.isSymbolicLink()) {
|
||||||
|
entries.push({ name, status: 'foreign', sourcePath, linkPath });
|
||||||
|
continue;
|
||||||
|
}
|
||||||
|
|
||||||
|
const targetPath = resolveLinkTarget(linkPath);
|
||||||
|
const owned = isInsideSkillsRoot(targetPath, paths.mosaicSkillsDir);
|
||||||
|
const dangling = isDangling(linkPath);
|
||||||
|
|
||||||
|
if (!owned) {
|
||||||
|
entries.push({
|
||||||
|
name,
|
||||||
|
status: dangling ? 'foreign-dangling' : 'foreign',
|
||||||
|
sourcePath,
|
||||||
|
linkPath,
|
||||||
|
targetPath,
|
||||||
|
});
|
||||||
|
continue;
|
||||||
|
}
|
||||||
|
|
||||||
|
if (dangling) {
|
||||||
|
entries.push({ name, status: 'dangling', sourcePath, linkPath, targetPath });
|
||||||
|
continue;
|
||||||
|
}
|
||||||
|
|
||||||
|
entries.push({
|
||||||
|
name,
|
||||||
|
status: sourcePath && targetPath === resolve(sourcePath) ? 'registered' : 'misdirected',
|
||||||
|
sourcePath,
|
||||||
|
linkPath,
|
||||||
|
targetPath,
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
return entries;
|
||||||
|
}
|
||||||
|
|
||||||
|
/** Reconcile every canonical skill directory while preserving all foreign entries. */
|
||||||
|
export function syncClaudeSkills(paths: SkillPaths = getDefaultSkillPaths()): SkillSyncResult {
|
||||||
|
const result: SkillSyncResult = {
|
||||||
|
registered: [],
|
||||||
|
repaired: [],
|
||||||
|
unchanged: [],
|
||||||
|
conflicts: [],
|
||||||
|
};
|
||||||
|
|
||||||
|
for (const name of canonicalSkillNames(paths)) {
|
||||||
|
try {
|
||||||
|
const registration = registerSkill(name, paths);
|
||||||
|
if (registration.status === 'registered') result.registered.push(name);
|
||||||
|
if (registration.status === 'repaired') result.repaired.push(name);
|
||||||
|
if (registration.status === 'already-registered') result.unchanged.push(name);
|
||||||
|
} catch (error: unknown) {
|
||||||
|
result.conflicts.push({
|
||||||
|
name: displaySkillName(name),
|
||||||
|
reason: error instanceof Error ? error.message : String(error),
|
||||||
|
});
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
return result;
|
||||||
|
}
|
||||||
|
|
||||||
|
function reportCommandError(error: unknown): void {
|
||||||
|
console.error(error instanceof Error ? error.message : String(error));
|
||||||
|
process.exitCode = 1;
|
||||||
|
}
|
||||||
|
|
||||||
|
/** Register the `mosaic skill` command group. */
|
||||||
|
export function registerSkillCommand(
|
||||||
|
program: Command,
|
||||||
|
paths: SkillPaths = getDefaultSkillPaths(),
|
||||||
|
): void {
|
||||||
|
const skill = program
|
||||||
|
.command('skill')
|
||||||
|
.description('Manage Claude Code skill registrations')
|
||||||
|
.configureHelp({ sortSubcommands: true });
|
||||||
|
|
||||||
|
skill
|
||||||
|
.command('register <name>')
|
||||||
|
.description('Register a Mosaic skill with Claude Code')
|
||||||
|
.action((name: string) => {
|
||||||
|
try {
|
||||||
|
const result = registerSkill(name, paths);
|
||||||
|
if (result.status === 'already-registered') {
|
||||||
|
console.log(`${name}: already registered`);
|
||||||
|
} else if (result.status === 'repaired') {
|
||||||
|
console.log(`${name}: repaired dangling registration`);
|
||||||
|
} else {
|
||||||
|
console.log(`${name}: registered`);
|
||||||
|
}
|
||||||
|
} catch (error: unknown) {
|
||||||
|
reportCommandError(error);
|
||||||
|
}
|
||||||
|
});
|
||||||
|
|
||||||
|
skill
|
||||||
|
.command('unregister <name>')
|
||||||
|
.description('Unregister a Mosaic skill from Claude Code')
|
||||||
|
.action((name: string) => {
|
||||||
|
try {
|
||||||
|
const result = unregisterSkill(name, paths);
|
||||||
|
console.log(
|
||||||
|
result.status === 'already-unregistered'
|
||||||
|
? `${name}: already unregistered`
|
||||||
|
: `${name}: unregistered`,
|
||||||
|
);
|
||||||
|
} catch (error: unknown) {
|
||||||
|
reportCommandError(error);
|
||||||
|
}
|
||||||
|
});
|
||||||
|
|
||||||
|
skill
|
||||||
|
.command('list')
|
||||||
|
.description('List registered, dangling, foreign, and unregistered skills')
|
||||||
|
.action(() => {
|
||||||
|
try {
|
||||||
|
const entries = listSkills(paths);
|
||||||
|
if (entries.length === 0) {
|
||||||
|
console.log('No Mosaic or Claude Code skills found.');
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
for (const entry of entries) {
|
||||||
|
console.log(`${entry.status.padEnd(17)} ${displaySkillName(entry.name)}`);
|
||||||
|
}
|
||||||
|
} catch (error: unknown) {
|
||||||
|
reportCommandError(error);
|
||||||
|
}
|
||||||
|
});
|
||||||
|
}
|
||||||
@@ -104,6 +104,10 @@ export interface FleetRoster {
|
|||||||
|
|
||||||
export type FleetRosterInputFormat = 'yaml' | 'json';
|
export type FleetRosterInputFormat = 'yaml' | 'json';
|
||||||
|
|
||||||
|
export class FleetRosterConfigurationError extends Error {
|
||||||
|
override name = 'FleetRosterConfigurationError';
|
||||||
|
}
|
||||||
|
|
||||||
export function resolveInstalledFleetRosterPath(mosaicHome: string): string {
|
export function resolveInstalledFleetRosterPath(mosaicHome: string): string {
|
||||||
const yamlPath = join(mosaicHome, 'fleet', 'roster.yaml');
|
const yamlPath = join(mosaicHome, 'fleet', 'roster.yaml');
|
||||||
try {
|
try {
|
||||||
@@ -138,8 +142,52 @@ export function parseFleetRosterV1(
|
|||||||
}
|
}
|
||||||
|
|
||||||
export async function loadFleetRoster(path: string): Promise<FleetRoster> {
|
export async function loadFleetRoster(path: string): Promise<FleetRoster> {
|
||||||
const source = await readFile(path, 'utf8');
|
const source = await readFleetRosterText(path);
|
||||||
|
try {
|
||||||
return parseFleetRosterV1(source, path.endsWith('.json') ? 'json' : 'yaml');
|
return parseFleetRosterV1(source, path.endsWith('.json') ? 'json' : 'yaml');
|
||||||
|
} catch (error) {
|
||||||
|
if (isRosterParserError(error)) throw invalidFleetRosterError(path);
|
||||||
|
throw error;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/** Read an operator-owned roster with errors that say how to recover. */
|
||||||
|
export async function readFleetRosterText(path: string): Promise<string> {
|
||||||
|
try {
|
||||||
|
return await readFile(path, 'utf8');
|
||||||
|
} catch (error) {
|
||||||
|
if (isNodeErrorCode(error, 'ENOENT')) {
|
||||||
|
throw new FleetRosterConfigurationError(
|
||||||
|
`No fleet roster found at ${path}. Run \`mosaic fleet init\` to create one.`,
|
||||||
|
);
|
||||||
|
}
|
||||||
|
throw new FleetRosterConfigurationError(
|
||||||
|
`Could not read fleet roster at ${path}. Check the file exists and is readable.`,
|
||||||
|
);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/** Parse a roster document needed only to select the v1/v2 command path. */
|
||||||
|
export function parseFleetRosterDocument(source: string, path: string): unknown {
|
||||||
|
try {
|
||||||
|
return YAML.parse(source);
|
||||||
|
} catch (error) {
|
||||||
|
if (isRosterParserError(error)) throw invalidFleetRosterError(path);
|
||||||
|
throw error;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
function invalidFleetRosterError(path: string): FleetRosterConfigurationError {
|
||||||
|
return new FleetRosterConfigurationError(
|
||||||
|
`Fleet roster at ${path} is invalid. Fix the file or run \`mosaic fleet init --force\`.`,
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
function isRosterParserError(error: unknown): boolean {
|
||||||
|
return (
|
||||||
|
error instanceof SyntaxError ||
|
||||||
|
(error instanceof Error && (error.name === 'YAMLParseError' || error.name === 'YAMLWarning'))
|
||||||
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
export function getRosterAgent(roster: FleetRoster, name: string): FleetAgent {
|
export function getRosterAgent(roster: FleetRoster, name: string): FleetAgent {
|
||||||
@@ -149,6 +197,16 @@ export function getRosterAgent(roster: FleetRoster, name: string): FleetAgent {
|
|||||||
}
|
}
|
||||||
|
|
||||||
export function normalizeFleetRosterV1(raw: RawFleetRoster): FleetRoster {
|
export function normalizeFleetRosterV1(raw: RawFleetRoster): FleetRoster {
|
||||||
|
try {
|
||||||
|
return normalizeFleetRosterV1Unchecked(raw);
|
||||||
|
} catch (error) {
|
||||||
|
if (error instanceof FleetRosterConfigurationError) throw error;
|
||||||
|
if (error instanceof Error) throw new FleetRosterConfigurationError(error.message);
|
||||||
|
throw error;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
function normalizeFleetRosterV1Unchecked(raw: RawFleetRoster): FleetRoster {
|
||||||
assertObject(raw, 'Fleet roster');
|
assertObject(raw, 'Fleet roster');
|
||||||
assertKnownKeys(raw, 'Fleet roster', [
|
assertKnownKeys(raw, 'Fleet roster', [
|
||||||
'version',
|
'version',
|
||||||
|
|||||||
@@ -7,6 +7,7 @@ import {
|
|||||||
mkdirSync,
|
mkdirSync,
|
||||||
readdirSync,
|
readdirSync,
|
||||||
readFileSync,
|
readFileSync,
|
||||||
|
readlinkSync,
|
||||||
rmSync,
|
rmSync,
|
||||||
statSync,
|
statSync,
|
||||||
symlinkSync,
|
symlinkSync,
|
||||||
@@ -300,6 +301,50 @@ describe('repairFleetCommsTools', () => {
|
|||||||
});
|
});
|
||||||
|
|
||||||
describe('runFrameworkReseed', () => {
|
describe('runFrameworkReseed', () => {
|
||||||
|
it('auto-registers every canonical skill after a successful upgrade re-seed', () => {
|
||||||
|
const root = mkdtempSync(join(tmpdir(), 'mosaic-reseed-skills-'));
|
||||||
|
const framework = join(root, 'framework');
|
||||||
|
const home = join(root, 'mosaic');
|
||||||
|
const claudeSkills = join(root, '.claude', 'skills');
|
||||||
|
mkdirSync(framework, { recursive: true });
|
||||||
|
mkdirSync(join(home, 'skills', 'added-after-setup'), { recursive: true });
|
||||||
|
mkdirSync(join(home, 'skills', 'another-new-skill'), { recursive: true });
|
||||||
|
writeFileSync(join(framework, 'install.sh'), '#!/usr/bin/env bash\nexit 0\n', { mode: 0o755 });
|
||||||
|
|
||||||
|
const res = runFrameworkReseed(framework, home, claudeSkills);
|
||||||
|
|
||||||
|
expect(res.ok).toBe(true);
|
||||||
|
expect(res.skillSync).toMatchObject({
|
||||||
|
registered: ['added-after-setup', 'another-new-skill'],
|
||||||
|
conflicts: [],
|
||||||
|
});
|
||||||
|
expect(readlinkSync(join(claudeSkills, 'added-after-setup'))).toBe(
|
||||||
|
join(home, 'skills', 'added-after-setup'),
|
||||||
|
);
|
||||||
|
expect(readlinkSync(join(claudeSkills, 'another-new-skill'))).toBe(
|
||||||
|
join(home, 'skills', 'another-new-skill'),
|
||||||
|
);
|
||||||
|
rmSync(root, { recursive: true, force: true });
|
||||||
|
});
|
||||||
|
|
||||||
|
it('keeps a successful framework re-seed successful when bridge reconciliation fails', () => {
|
||||||
|
const root = mkdtempSync(join(tmpdir(), 'mosaic-reseed-bridge-failure-'));
|
||||||
|
const framework = join(root, 'framework');
|
||||||
|
const home = join(root, 'mosaic');
|
||||||
|
const claudeSkills = join(root, '.claude', 'skills');
|
||||||
|
mkdirSync(framework, { recursive: true });
|
||||||
|
mkdirSync(home, { recursive: true });
|
||||||
|
writeFileSync(join(home, 'skills'), 'invalid canonical root\n');
|
||||||
|
writeFileSync(join(framework, 'install.sh'), '#!/usr/bin/env bash\nexit 0\n', { mode: 0o755 });
|
||||||
|
|
||||||
|
const res = runFrameworkReseed(framework, home, claudeSkills);
|
||||||
|
|
||||||
|
expect(res.ok).toBe(true);
|
||||||
|
expect(res.skillSync).toBeUndefined();
|
||||||
|
expect(res.skillSyncError).toMatch(/not a directory/i);
|
||||||
|
rmSync(root, { recursive: true, force: true });
|
||||||
|
});
|
||||||
|
|
||||||
it('reports not-ok (not throw) when the installer is absent', () => {
|
it('reports not-ok (not throw) when the installer is absent', () => {
|
||||||
const missing = mkdtempSync(join(tmpdir(), 'mosaic-noinstaller-'));
|
const missing = mkdtempSync(join(tmpdir(), 'mosaic-noinstaller-'));
|
||||||
const res = runFrameworkReseed(missing, join(missing, 'home'));
|
const res = runFrameworkReseed(missing, join(missing, 'home'));
|
||||||
|
|||||||
@@ -43,6 +43,7 @@ import {
|
|||||||
ensureManagedDirectory,
|
ensureManagedDirectory,
|
||||||
readRegularFileSecure,
|
readRegularFileSecure,
|
||||||
} from '../fleet/secure-file.js';
|
} from '../fleet/secure-file.js';
|
||||||
|
import { getDefaultSkillPaths, syncClaudeSkills, type SkillSyncResult } from '../commands/skill.js';
|
||||||
|
|
||||||
// ─── Types ──────────────────────────────────────────────────────────────────
|
// ─── Types ──────────────────────────────────────────────────────────────────
|
||||||
|
|
||||||
@@ -871,19 +872,39 @@ export function repairFleetCommsTools(
|
|||||||
* describing what happened (so callers can message + decide on relaunch).
|
* describing what happened (so callers can message + decide on relaunch).
|
||||||
* Best-effort: a missing installer or a non-zero exit is reported, not thrown.
|
* Best-effort: a missing installer or a non-zero exit is reported, not thrown.
|
||||||
*/
|
*/
|
||||||
|
export interface FrameworkReseedResult {
|
||||||
|
ok: boolean;
|
||||||
|
reason?: string;
|
||||||
|
skillSync?: SkillSyncResult;
|
||||||
|
skillSyncError?: string;
|
||||||
|
}
|
||||||
|
|
||||||
export function runFrameworkReseed(
|
export function runFrameworkReseed(
|
||||||
frameworkRoot = resolveBundledFrameworkRoot(),
|
frameworkRoot = resolveBundledFrameworkRoot(),
|
||||||
mosaicHome = join(homedir(), '.config', 'mosaic'),
|
mosaicHome = join(homedir(), '.config', 'mosaic'),
|
||||||
): { ok: boolean; reason?: string } {
|
claudeSkillsDir = getDefaultSkillPaths().claudeSkillsDir,
|
||||||
|
): FrameworkReseedResult {
|
||||||
const { installer, command, env } = buildReseedCommand(frameworkRoot, mosaicHome);
|
const { installer, command, env } = buildReseedCommand(frameworkRoot, mosaicHome);
|
||||||
if (!existsSync(installer)) {
|
if (!existsSync(installer)) {
|
||||||
return { ok: false, reason: `installer not found: ${installer}` };
|
return { ok: false, reason: `installer not found: ${installer}` };
|
||||||
}
|
}
|
||||||
try {
|
try {
|
||||||
execSync(command, { stdio: 'inherit', env: { ...process.env, ...env }, timeout: 120_000 });
|
execSync(command, { stdio: 'inherit', env: { ...process.env, ...env }, timeout: 120_000 });
|
||||||
return { ok: true };
|
} catch (error: unknown) {
|
||||||
} catch (err) {
|
return { ok: false, reason: error instanceof Error ? error.message : String(error) };
|
||||||
return { ok: false, reason: err instanceof Error ? err.message : String(err) };
|
}
|
||||||
|
|
||||||
|
try {
|
||||||
|
const skillSync = syncClaudeSkills({
|
||||||
|
mosaicSkillsDir: join(mosaicHome, 'skills'),
|
||||||
|
claudeSkillsDir,
|
||||||
|
});
|
||||||
|
return { ok: true, skillSync };
|
||||||
|
} catch (error: unknown) {
|
||||||
|
return {
|
||||||
|
ok: true,
|
||||||
|
skillSyncError: error instanceof Error ? error.message : String(error),
|
||||||
|
};
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@@ -9,7 +9,7 @@
|
|||||||
*/
|
*/
|
||||||
|
|
||||||
import { describe, it, expect, vi, beforeEach, afterEach } from 'vitest';
|
import { describe, it, expect, vi, beforeEach, afterEach } from 'vitest';
|
||||||
import { mkdtempSync, mkdirSync, writeFileSync, rmSync } from 'node:fs';
|
import { mkdtempSync, mkdirSync, readlinkSync, writeFileSync, rmSync } from 'node:fs';
|
||||||
import { join } from 'node:path';
|
import { join } from 'node:path';
|
||||||
import { tmpdir } from 'node:os';
|
import { tmpdir } from 'node:os';
|
||||||
import type { WizardState } from '../types.js';
|
import type { WizardState } from '../types.js';
|
||||||
@@ -113,6 +113,40 @@ describe('finalizeStage — skill installer', () => {
|
|||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
it('auto-registers every canonical skill even when it was added after initial setup', async () => {
|
||||||
|
const claudeHome = join(tmp, '.claude');
|
||||||
|
const previousClaudeHome = process.env['CLAUDE_HOME'];
|
||||||
|
process.env['CLAUDE_HOME'] = claudeHome;
|
||||||
|
mkdirSync(join(tmp, 'skills', 'added-after-setup'), { recursive: true });
|
||||||
|
mkdirSync(join(tmp, 'skills', 'another-new-skill'), { recursive: true });
|
||||||
|
|
||||||
|
try {
|
||||||
|
await finalizeStage(buildPrompter(), makeState(tmp, []), makeConfigService());
|
||||||
|
|
||||||
|
expect(readlinkSync(join(claudeHome, 'skills', 'added-after-setup'))).toBe(
|
||||||
|
join(tmp, 'skills', 'added-after-setup'),
|
||||||
|
);
|
||||||
|
expect(readlinkSync(join(claudeHome, 'skills', 'another-new-skill'))).toBe(
|
||||||
|
join(tmp, 'skills', 'another-new-skill'),
|
||||||
|
);
|
||||||
|
} finally {
|
||||||
|
if (previousClaudeHome === undefined) delete process.env['CLAUDE_HOME'];
|
||||||
|
else process.env['CLAUDE_HOME'] = previousClaudeHome;
|
||||||
|
}
|
||||||
|
});
|
||||||
|
|
||||||
|
it('warns and completes finalization when bridge-wide reconciliation fails', async () => {
|
||||||
|
writeFileSync(join(tmp, 'skills'), 'invalid canonical root\n');
|
||||||
|
const p = buildPrompter();
|
||||||
|
|
||||||
|
await finalizeStage(p, makeState(tmp, []), makeConfigService());
|
||||||
|
|
||||||
|
expect(p.warn).toHaveBeenCalledWith(
|
||||||
|
expect.stringMatching(/Claude skill reconciliation skipped.*not a directory/i),
|
||||||
|
);
|
||||||
|
expect(p.outro).toHaveBeenCalledWith('Mosaic is ready.');
|
||||||
|
});
|
||||||
|
|
||||||
it('passes MOSAIC_INSTALL_SKILLS with the selected skill list', async () => {
|
it('passes MOSAIC_INSTALL_SKILLS with the selected skill list', async () => {
|
||||||
const state = makeState(tmp, ['brainstorming', 'lint', 'systematic-debugging']);
|
const state = makeState(tmp, ['brainstorming', 'lint', 'systematic-debugging']);
|
||||||
const p = buildPrompter();
|
const p = buildPrompter();
|
||||||
|
|||||||
@@ -7,6 +7,11 @@ import type { ConfigService } from '../config/config-service.js';
|
|||||||
import type { WizardState } from '../types.js';
|
import type { WizardState } from '../types.js';
|
||||||
import { getShellProfilePath } from '../platform/detect.js';
|
import { getShellProfilePath } from '../platform/detect.js';
|
||||||
import { ManifestError } from '../framework/manifest.js';
|
import { ManifestError } from '../framework/manifest.js';
|
||||||
|
import {
|
||||||
|
getDefaultSkillPaths,
|
||||||
|
syncClaudeSkills,
|
||||||
|
type SkillSyncResult as ClaudeSkillSyncResult,
|
||||||
|
} from '../commands/skill.js';
|
||||||
|
|
||||||
function linkRuntimeAssets(mosaicHome: string, skipClaudeHooks: boolean): void {
|
function linkRuntimeAssets(mosaicHome: string, skipClaudeHooks: boolean): void {
|
||||||
const script = join(mosaicHome, 'bin', 'mosaic-link-runtime-assets');
|
const script = join(mosaicHome, 'bin', 'mosaic-link-runtime-assets');
|
||||||
@@ -205,7 +210,27 @@ export async function finalizeStage(
|
|||||||
skillsResult = syncSkills(state.mosaicHome, state.selectedSkills);
|
skillsResult = syncSkills(state.mosaicHome, state.selectedSkills);
|
||||||
}
|
}
|
||||||
|
|
||||||
// 5. Run doctor
|
// 5. Reconcile every canonical Mosaic skill into Claude Code. This is
|
||||||
|
// intentionally independent of the first-run selected-skill fetch above:
|
||||||
|
// framework installs/upgrades must also register skills added after setup.
|
||||||
|
spin.update('Registering Mosaic skills with Claude Code...');
|
||||||
|
let bridgeResult: ClaudeSkillSyncResult = {
|
||||||
|
registered: [],
|
||||||
|
repaired: [],
|
||||||
|
unchanged: [],
|
||||||
|
conflicts: [],
|
||||||
|
};
|
||||||
|
let bridgeFailure: string | undefined;
|
||||||
|
try {
|
||||||
|
bridgeResult = syncClaudeSkills({
|
||||||
|
mosaicSkillsDir: join(state.mosaicHome, 'skills'),
|
||||||
|
claudeSkillsDir: getDefaultSkillPaths().claudeSkillsDir,
|
||||||
|
});
|
||||||
|
} catch (error: unknown) {
|
||||||
|
bridgeFailure = error instanceof Error ? error.message : String(error);
|
||||||
|
}
|
||||||
|
|
||||||
|
// 6. Run doctor
|
||||||
spin.update('Running health audit...');
|
spin.update('Running health audit...');
|
||||||
const doctorResult = runDoctor(state.mosaicHome);
|
const doctorResult = runDoctor(state.mosaicHome);
|
||||||
|
|
||||||
@@ -217,10 +242,15 @@ export async function finalizeStage(
|
|||||||
p.warn("Run 'mosaic sync' manually after installation to install skills.");
|
p.warn("Run 'mosaic sync' manually after installation to install skills.");
|
||||||
}
|
}
|
||||||
|
|
||||||
// 6. PATH setup
|
if (bridgeFailure) p.warn(`Claude skill reconciliation skipped: ${bridgeFailure}`);
|
||||||
|
for (const conflict of bridgeResult.conflicts) {
|
||||||
|
p.warn(`Skill registration skipped for ${conflict.name}: ${conflict.reason}`);
|
||||||
|
}
|
||||||
|
|
||||||
|
// 7. PATH setup
|
||||||
const pathAction = setupPath(state.mosaicHome, p);
|
const pathAction = setupPath(state.mosaicHome, p);
|
||||||
|
|
||||||
// 7. Summary
|
// 8. Summary
|
||||||
const skillsSummary = skillsResult.success
|
const skillsSummary = skillsResult.success
|
||||||
? skillsResult.installedCount > 0
|
? skillsResult.installedCount > 0
|
||||||
? `${skillsResult.installedCount.toString()} installed`
|
? `${skillsResult.installedCount.toString()} installed`
|
||||||
@@ -245,7 +275,7 @@ export async function finalizeStage(
|
|||||||
|
|
||||||
p.note(summary.join('\n'), 'Installation Summary');
|
p.note(summary.join('\n'), 'Installation Summary');
|
||||||
|
|
||||||
// 8. Next steps
|
// 9. Next steps
|
||||||
const nextSteps: string[] = [];
|
const nextSteps: string[] = [];
|
||||||
if (pathAction === 'added') {
|
if (pathAction === 'added') {
|
||||||
const profilePath = getShellProfilePath();
|
const profilePath = getShellProfilePath();
|
||||||
|
|||||||
@@ -5,5 +5,16 @@ export default defineConfig({
|
|||||||
globals: true,
|
globals: true,
|
||||||
environment: 'node',
|
environment: 'node',
|
||||||
testTimeout: 30_000,
|
testTimeout: 30_000,
|
||||||
|
coverage: {
|
||||||
|
provider: 'v8',
|
||||||
|
include: ['src/commands/skill.ts'],
|
||||||
|
reporter: ['text', 'json-summary'],
|
||||||
|
thresholds: {
|
||||||
|
statements: 85,
|
||||||
|
branches: 85,
|
||||||
|
functions: 85,
|
||||||
|
lines: 85,
|
||||||
|
},
|
||||||
|
},
|
||||||
},
|
},
|
||||||
});
|
});
|
||||||
|
|||||||
3
pnpm-lock.yaml
generated
3
pnpm-lock.yaml
generated
@@ -607,6 +607,9 @@ importers:
|
|||||||
'@types/react':
|
'@types/react':
|
||||||
specifier: ^18.3.0
|
specifier: ^18.3.0
|
||||||
version: 18.3.28
|
version: 18.3.28
|
||||||
|
'@vitest/coverage-v8':
|
||||||
|
specifier: ^2.0.0
|
||||||
|
version: 2.1.9(vitest@2.1.9(@types/node@22.19.15)(jsdom@29.0.0(@noble/hashes@2.0.1))(lightningcss@1.31.1))
|
||||||
tsx:
|
tsx:
|
||||||
specifier: ^4.0.0
|
specifier: ^4.0.0
|
||||||
version: 4.21.0
|
version: 4.21.0
|
||||||
|
|||||||
@@ -61,17 +61,38 @@ if [[ "${MOSAIC_DEV:-0}" == "1" ]]; then
|
|||||||
FLAG_DEV=true
|
FLAG_DEV=true
|
||||||
fi
|
fi
|
||||||
|
|
||||||
|
installer_usage() {
|
||||||
|
printf 'Usage: install.sh [--check] [--framework] [--cli] [--ref <branch>] [--dev] [--yes|-y] [--no-auto-launch] [--uninstall]\n' >&2
|
||||||
|
}
|
||||||
|
|
||||||
while [[ $# -gt 0 ]]; do
|
while [[ $# -gt 0 ]]; do
|
||||||
case "$1" in
|
case "$1" in
|
||||||
--check) FLAG_CHECK=true; shift ;;
|
--check) FLAG_CHECK=true; shift ;;
|
||||||
--framework) FLAG_CLI=false; shift ;;
|
--framework) FLAG_CLI=false; shift ;;
|
||||||
--cli) FLAG_FRAMEWORK=false; shift ;;
|
--cli) FLAG_FRAMEWORK=false; shift ;;
|
||||||
--ref) GIT_REF="${2:-main}"; shift 2 ;;
|
--ref)
|
||||||
|
if [[ $# -lt 2 ]] || [[ -z "$2" ]]; then
|
||||||
|
printf 'Error: Missing value for --ref\n' >&2
|
||||||
|
installer_usage
|
||||||
|
exit 2
|
||||||
|
fi
|
||||||
|
if [[ "$2" == -* ]]; then
|
||||||
|
printf 'Error: Unknown argument: %s\n' "$2" >&2
|
||||||
|
installer_usage
|
||||||
|
exit 2
|
||||||
|
fi
|
||||||
|
GIT_REF="$2"
|
||||||
|
shift 2
|
||||||
|
;;
|
||||||
--dev) FLAG_DEV=true; shift ;;
|
--dev) FLAG_DEV=true; shift ;;
|
||||||
--yes|-y) FLAG_YES=true; shift ;;
|
--yes|-y) FLAG_YES=true; shift ;;
|
||||||
--no-auto-launch) FLAG_NO_AUTO_LAUNCH=true; shift ;;
|
--no-auto-launch) FLAG_NO_AUTO_LAUNCH=true; shift ;;
|
||||||
--uninstall) FLAG_UNINSTALL=true; shift ;;
|
--uninstall) FLAG_UNINSTALL=true; shift ;;
|
||||||
*) shift ;;
|
*)
|
||||||
|
printf 'Error: Unknown argument: %s\n' "$1" >&2
|
||||||
|
installer_usage
|
||||||
|
exit 2
|
||||||
|
;;
|
||||||
esac
|
esac
|
||||||
done
|
done
|
||||||
|
|
||||||
@@ -212,7 +233,7 @@ ok() { echo "${G}✔${RESET} $*"; }
|
|||||||
warn() { echo "${Y}⚠${RESET} $*"; }
|
warn() { echo "${Y}⚠${RESET} $*"; }
|
||||||
fail() { echo "${R}✖${RESET} $*" >&2; }
|
fail() { echo "${R}✖${RESET} $*" >&2; }
|
||||||
dim() { echo "${DIM}$*${RESET}"; }
|
dim() { echo "${DIM}$*${RESET}"; }
|
||||||
step() { echo ""; echo "${BOLD}$*${RESET}"; }
|
step() { printf '\n%s%s%s\n' "$BOLD" "$*" "$RESET"; }
|
||||||
|
|
||||||
# ─── helpers ──────────────────────────────────────────────────────────────────
|
# ─── helpers ──────────────────────────────────────────────────────────────────
|
||||||
|
|
||||||
|
|||||||
Reference in New Issue
Block a user