diff --git a/docs/cli.md b/docs/cli.md index 103dd7d4f..50b4977fb 100644 --- a/docs/cli.md +++ b/docs/cli.md @@ -107,7 +107,7 @@ openspec init [path] [options] `--profile custom` uses whatever workflows are currently selected in global config (`openspec config profile`). -**Supported tool IDs (`--tools`):** `amazon-q`, `antigravity`, `auggie`, `bob`, `claude`, `cline`, `codex`, `forgecode`, `codebuddy`, `continue`, `costrict`, `crush`, `cursor`, `factory`, `gemini`, `github-copilot`, `iflow`, `junie`, `kilocode`, `kimi`, `kiro`, `opencode`, `pi`, `qoder`, `lingma`, `qwen`, `roocode`, `trae`, `windsurf` +**Supported tool IDs (`--tools`):** `amazon-q`, `antigravity`, `auggie`, `bob`, `claude`, `cline`, `codex`, `forgecode`, `codebuddy`, `continue`, `costrict`, `crush`, `cursor`, `factory`, `gemini`, `github-copilot`, `iflow`, `junie`, `kilocode`, `kimi`, `kiro`, `lingma`, `minimax-code`, `opencode`, `pi`, `qoder`, `qwen`, `roocode`, `trae`, `vibe`, `windsurf` **Examples:** @@ -121,6 +121,9 @@ openspec init ./my-project # Non-interactive: configure for Claude and Cursor openspec init --tools claude,cursor +# Non-interactive: configure MiniMax Code global skills +openspec init --tools minimax-code + # Configure for all supported tools openspec init --tools all @@ -142,9 +145,12 @@ openspec/ .claude/skills/ # Claude Code skills (if claude selected) .cursor/skills/ # Cursor skills (if cursor selected) .cursor/commands/ # Cursor OPSX commands (if delivery includes commands) +~/.minimax/skills/ # MiniMax Code skills (if minimax-code selected) ... (other tool configs) ``` +MiniMax Code uses a fixed user-home target: OpenSpec writes its skills to `~/.minimax/skills/` and detects existing OpenSpec MiniMax Code setup from that directory. It does not create project-local `.minimax` or `.mavis` directories. When delivery is `commands`, MiniMax Code command generation is skipped because no command adapter exists, and existing global MiniMax Code skills are left untouched. + --- ### `openspec update` @@ -215,7 +221,7 @@ openspec workspace setup --no-interactive --json --name checkout --link /repos/p Interactive setup asks for a preferred opener and can install workspace-local OpenSpec skills for selected agents. Non-interactive setup stores a preferred opener only when `--opener` is provided; otherwise `workspace open` prompts later in interactive terminals when a supported opener is available, or asks scripts to pass `--agent ` or `--editor`. -Workspace skill installation is skills-only in this beta slice: even if global delivery is `commands` or `both`, workspace setup writes agent skill folders in the workspace root and does not create slash command files. The active global profile chooses which workflow skills are installed; `--tools` chooses which agents receive them. If `--tools` is omitted in non-interactive setup, no skills are installed and `workspace update --tools ` can add them later. +Workspace skill installation is skills-only in this beta slice: even if global delivery is `commands` or `both`, workspace setup writes agent skill folders and does not create slash command files. Project-local tools are written in the workspace root; MiniMax Code is written to `~/.minimax/skills/`. The active global profile chooses which workflow skills are installed; `--tools` chooses which agents receive them. If `--tools` is omitted in non-interactive setup, no skills are installed and `workspace update --tools ` can add them later. ### `openspec workspace list` @@ -304,7 +310,7 @@ openspec workspace update --workspace platform --tools codex,claude openspec workspace update --workspace platform --tools none ``` -`workspace update` refreshes the generated workspace guidance block and local open surface. For agent skills, it reuses the stored workspace skill agent selection when `--tools` is omitted. Passing `--tools` replaces that stored selection. It refreshes only OpenSpec-managed workflow skill directories in the workspace root, removes deselected managed workflow skills, and leaves linked repos and folders untouched. +`workspace update` refreshes the generated workspace guidance block and local open surface. For agent skills, it reuses the stored workspace skill agent selection when `--tools` is omitted. Passing `--tools` replaces that stored selection. It refreshes only OpenSpec-managed workflow skill directories in the resolved skill target, removes deselected managed workflow skills, and leaves linked repos and folders untouched. MiniMax Code workspace skills use `~/.minimax/skills/` and never create workspace-local `.minimax` or `.mavis` fallback directories. Running `openspec update` from inside a workspace does not update workspace-local files. Use `openspec workspace update` when you want workspace-local guidance and skills refreshed, and run `openspec update` inside repo-local projects when you want repo-owned tool files updated. diff --git a/docs/commands.md b/docs/commands.md index 8b0d81839..5e57a1ef0 100644 --- a/docs/commands.md +++ b/docs/commands.md @@ -619,6 +619,7 @@ Different AI tools use slightly different command syntax. Use the format that ma | Windsurf | `/opsx-propose`, `/opsx-apply` | | Copilot (IDE) | `/opsx-propose`, `/opsx-apply` | | Kimi CLI | Skill-based invocations such as `/skill:openspec-propose`, `/skill:openspec-apply-change` (no generated `opsx-*` command files) | +| MiniMax Code | Skill-based invocations from `~/.minimax/skills`, such as `openspec-propose` or `openspec-apply-change` depending on the MiniMax Code skill picker UI (no generated `opsx-*` command files) | | Trae | Skill-based invocations such as `/openspec-propose`, `/openspec-apply-change` (no generated `opsx-*` command files) | The intent is the same across tools, but how commands are surfaced can differ by integration. @@ -684,6 +685,7 @@ The AI tool doesn't recognize OpenSpec commands. - Ensure OpenSpec is initialized: `openspec init` - Regenerate skills: `openspec update` - Check that `.claude/skills/` directory exists (for Claude Code) +- Check that `~/.minimax/skills/openspec-*/SKILL.md` exists for MiniMax Code - Restart your AI tool to pick up new skills ### Artifacts not generating properly diff --git a/docs/supported-tools.md b/docs/supported-tools.md index b2ee30fb4..6c0b7e937 100644 --- a/docs/supported-tools.md +++ b/docs/supported-tools.md @@ -44,6 +44,7 @@ You can enable expanded workflows (`new`, `continue`, `ff`, `verify`, `bulk-arch | Kimi CLI (`kimi`) | `.kimi/skills/openspec-*/SKILL.md` | Not generated (no command adapter; use skill-based `/skill:openspec-*` invocations) | | Kiro (`kiro`) | `.kiro/skills/openspec-*/SKILL.md` | `.kiro/prompts/opsx-.prompt.md` | | Lingma (`lingma`) | `.lingma/skills/openspec-*/SKILL.md` | `.lingma/commands/opsx/.md` | +| MiniMax Code (`minimax-code`) | `~/.minimax/skills/openspec-*/SKILL.md` | Not generated (no command adapter; use MiniMax Code skill-based invocations) | | Mistral Vibe (`vibe`) | `.vibe/skills/openspec-*/SKILL.md` | Not generated (no command adapter; use skill-based `/openspec-*` invocations) | | OpenCode (`opencode`) | `.opencode/skills/openspec-*/SKILL.md` | `.opencode/commands/opsx-.md` | | Pi (`pi`) | `.pi/skills/openspec-*/SKILL.md` | `.pi/prompts/opsx-.md` | @@ -57,6 +58,8 @@ You can enable expanded workflows (`new`, `continue`, `ff`, `verify`, `bulk-arch \*\* GitHub Copilot prompt files are recognized as custom slash commands in IDE extensions (VS Code, JetBrains, Visual Studio). Copilot CLI does not currently consume `.github/prompts/*.prompt.md` directly. +MiniMax Code skills are installed in the user's global MiniMax Code skills directory (`~/.minimax/skills/`), not in the current repository. OpenSpec never creates repo-local `.minimax` or `.mavis` fallback directories for MiniMax Code. If delivery is `commands`, OpenSpec skips MiniMax Code command generation and preserves any existing global MiniMax Code skills. + ## Non-Interactive Setup For CI/CD or scripted setup, use `--tools` (and optionally `--profile`): @@ -75,7 +78,7 @@ openspec init --tools none openspec init --profile core ``` -**Available tool IDs (`--tools`):** `amazon-q`, `antigravity`, `auggie`, `bob`, `claude`, `cline`, `codex`, `forgecode`, `codebuddy`, `continue`, `costrict`, `crush`, `cursor`, `factory`, `gemini`, `github-copilot`, `iflow`, `junie`, `kilocode`, `kimi`, `kiro`, `lingma`, `opencode`, `pi`, `qoder`, `qwen`, `roocode`, `trae`, `vibe`, `windsurf` +**Available tool IDs (`--tools`):** `amazon-q`, `antigravity`, `auggie`, `bob`, `claude`, `cline`, `codex`, `forgecode`, `codebuddy`, `continue`, `costrict`, `crush`, `cursor`, `factory`, `gemini`, `github-copilot`, `iflow`, `junie`, `kilocode`, `kimi`, `kiro`, `lingma`, `minimax-code`, `opencode`, `pi`, `qoder`, `qwen`, `roocode`, `trae`, `vibe`, `windsurf` ## Workflow-Dependent Installation diff --git a/openspec/changes/add-minimax-code-tool-support/.openspec.yaml b/openspec/changes/add-minimax-code-tool-support/.openspec.yaml new file mode 100644 index 000000000..c86b1d7fd --- /dev/null +++ b/openspec/changes/add-minimax-code-tool-support/.openspec.yaml @@ -0,0 +1,2 @@ +schema: spec-driven +created: 2026-06-14 diff --git a/openspec/changes/add-minimax-code-tool-support/design.md b/openspec/changes/add-minimax-code-tool-support/design.md new file mode 100644 index 000000000..fd70a4f88 --- /dev/null +++ b/openspec/changes/add-minimax-code-tool-support/design.md @@ -0,0 +1,122 @@ +## Context + +OpenSpec currently treats skill installation as a project-root concern. Tool metadata is centered on `skillsDir`, detection scans project directories, and both `init` and `update` derive skill targets from `path.join(projectRoot, tool.skillsDir, 'skills')`. + +MiniMax Code needs a different skill target. Current local verification shows MiniMax Code uses the user's `~/.minimax/skills` location for user-installable skills, and `.mavis` compatibility appears to point at the same backing location. For this change, OpenSpec will target `~/.minimax/skills` directly and will not attempt to discover MiniMax runtime profile or data-dir overrides. + +The implementation should stay close to the existing model: keep the common skill generation logic, add a small shared helper for resolving the effective skill directory, and reuse the existing behavior for tools that have skills but no command adapter. + +## Goals / Non-Goals + +**Goals:** + +- Add MiniMax Code as a supported OpenSpec tool target with a stable tool id (`minimax-code`) +- Install MiniMax Code OpenSpec skills into the user's fixed MiniMax Code skill directory (`~/.minimax/skills`) +- Let `openspec init` detect, install, and refresh MiniMax Code skills using that global user-home target +- Let `openspec update` detect an existing MiniMax Code OpenSpec installation and refresh it in place +- Avoid creating repo-local `.minimax` or `.mavis` directories for MiniMax Code +- Keep implementation additive and low-disruption by reusing existing adapterless skills behavior + +**Non-Goals:** + +- Adding a MiniMax-specific slash-command adapter or command file output path +- Dynamically resolving MiniMax runtime profile or data-dir overrides +- Supporting arbitrary user-entered MiniMax skill paths +- Migrating pre-existing MiniMax user skills that are not OpenSpec-managed +- Redesigning command generation, profile delivery, or all tool metadata beyond what is needed for this fixed global skill target + +## Decisions + +### 1. Represent MiniMax Code with fixed user-home global skill target metadata + +`AI_TOOLS` should gain a minimal `globalSkillsDir` field for tools whose OpenSpec skills are not stored under `//skills`. Existing tools continue using `skillsDir`, while MiniMax Code declares `globalSkillsDir: '.minimax'`. + +Call sites that only need to know whether a tool supports skills can use `tool.skillsDir || tool.globalSkillsDir`. Path resolution still needs to distinguish the two fields: + +- `skillsDir`: resolve to `//skills` +- `globalSkillsDir`: resolve to `//skills` + +Why this over a full runtime resolver: + +- It keeps this change small and close to the current `skillsDir` model. +- It avoids making synchronous detection/update flows depend on an external MiniMax CLI command. +- It matches current local verification where `.mavis` points to `.minimax`. +- It leaves runtime discovery as a future improvement if real installations require it. + +Alternative considered: + +- Invoke MiniMax runtime configuration and parse a `dataDir`. + Deferred because it expands the failure surface and requires stronger knowledge of MiniMax CLI contracts than this first integration needs. + +### 2. Add a shared skill directory helper instead of a skill adapter system + +OpenSpec does not currently have skill adapters. Skills use common content generation and a tool-level `skillsDir`; only commands have adapters. This change should preserve that shape by adding a small helper that resolves the skill directory for a tool: + +- Project-local tools with `skillsDir`: `//skills` +- MiniMax Code with `globalSkillsDir: '.minimax'`: `/.minimax/skills` + +Why this approach: + +- It keeps `init`, `update`, detection, profile drift, and migration aligned without duplicating path rules. +- It avoids introducing a broader skill adapter abstraction before there is more than one tool-specific skill format. +- It lets MiniMax Code reuse the same skill templates and generated metadata as other tools. + +### 3. Treat MiniMax Code as a global skills-only integration + +MiniMax Code should reuse the adapterless command behavior, but not the destructive commands-only skill cleanup behavior for global user-home skills. In repo-local `openspec init` and `openspec update`, when delivery includes skills, OpenSpec writes MiniMax Code skills. When delivery includes commands, OpenSpec skips command generation for MiniMax Code because no command adapter exists. When repo delivery is `commands`, OpenSpec should leave existing MiniMax Code global skills untouched. + +Workspace setup/update is different: existing workspace skill behavior ignores command delivery and remains skills-only. When MiniMax Code is selected as a workspace agent, workspace setup/update should refresh MiniMax Code skills in `/.minimax/skills` even if global delivery is `commands` or `both`; it still never generates MiniMax command files. + +Codex is the closest reference point for global command output: Codex commands are written to `CODEX_HOME/prompts`, but Codex OpenSpec skills remain workspace/project-local, so commands-only delivery never deletes user-home OpenSpec skill directories. MiniMax Code's only OpenSpec surface in this change is a user-home global skill directory, so deleting that directory on a repo-local delivery change would have a wider blast radius than Codex. + +Why this approach: + +- It reuses existing delivery semantics instead of adding MiniMax-specific delivery rules. +- It keeps repo-local `delivery=commands` non-destructive for user-home global skill targets. +- It prevents OpenSpec from inventing unsupported MiniMax command files. +- It avoids one project's delivery setting removing OpenSpec skills used by other MiniMax Code projects. + +### 4. Detect and refresh MiniMax Code from the fixed global target + +Configured-tool detection, version checks, profile drift checks, migration scans, and update refresh logic should use the shared skill directory helper. For MiniMax Code, detection is based on `openspec-*` skill files under `~/.minimax/skills`, not on a repo-local marker directory. + +Why this approach: + +- `openspec init` can show MiniMax Code as already configured when its managed skills already exist globally. +- `openspec update` can refresh MiniMax Code even when there is no project-local MiniMax directory. +- A shared path helper avoids drift where setup, detection, and refresh disagree about the active target. + +Workspace skill setup and update should also use the same helper. Workspace code currently assumes `workspaceRoot//skills`; if it is not updated, MiniMax Code will either be excluded from workspace agent selection or written to the wrong workspace-local fallback path. + +### 5. Never create a repo-local MiniMax fallback + +OpenSpec should not create `/.minimax`, `/.mavis`, or any repo-local MiniMax fallback when setting up MiniMax Code. If the fixed home target cannot be prepared or written, MiniMax Code setup should fail for that tool with a clear filesystem error while preserving the existing per-tool success/failure summary behavior. + +Why this approach: + +- It avoids silently writing invalid files to the repository. +- It keeps the first implementation simple and predictable. +- It preserves non-OpenSpec MiniMax user skills outside `openspec-*` skill directories. `openspec-*` directories are treated as OpenSpec-owned workflow skill targets and may be overwritten or removed by profile cleanup even if the user edited their contents. + +## Risks / Trade-offs + +- `[MiniMax installations with custom data roots are not supported in this first version]` -> Mitigation: document the fixed `~/.minimax/skills` target and leave runtime discovery as future work. +- `[Global installations are less project-scoped than repo-local paths]` -> Mitigation: document MiniMax Code as a global user-home integration and display the global path in user-facing setup/update output where practical. +- `[Cross-platform home path bugs]` -> Mitigation: build targets with `os.homedir()` and `path.join()`, and cover Windows-style home directories in unit tests. +- `[Commands-only delivery could remove global MiniMax Code OpenSpec skills across projects]` -> Mitigation: commands-only delivery for MiniMax Code must skip command generation and leave existing global MiniMax Code skills untouched, mirroring Codex's non-destructive treatment of user-home surfaces. +- `[Managed cleanup or refresh could overwrite user-authored colliding MiniMax skills]` -> Mitigation: document that `openspec-*` directories under the MiniMax Code skill target are OpenSpec-managed workflow skill targets. Users should keep unrelated MiniMax skills outside the `openspec-*` namespace. + +## Migration Plan + +There is no schema or repository migration required for existing OpenSpec projects. + +Implementation rollout should follow this sequence: + +1. Add MiniMax Code metadata and the shared skill directory helper for project-local and home-relative global targets. +2. Wire detection, init, update, profile drift, migration scans, and workspace skill setup/update to use the helper. +3. Update documentation so users know MiniMax Code installs globally at `~/.minimax/skills` and uses skills as its workflow surface. +4. Verify fresh setup, repeat refresh, delivery modes, workspace setup/update, managed-only cleanup, and non-OpenSpec skill preservation before release. + +## Open Questions + +- Do MiniMax installations with explicit profile/data-dir overrides need to be supported in a follow-up runtime discovery change? diff --git a/openspec/changes/add-minimax-code-tool-support/proposal.md b/openspec/changes/add-minimax-code-tool-support/proposal.md new file mode 100644 index 000000000..140b1856b --- /dev/null +++ b/openspec/changes/add-minimax-code-tool-support/proposal.md @@ -0,0 +1,42 @@ +## Why + +OpenSpec users already expect `openspec init` to let them pick the AI tool they actually use. MiniMax Code is a meaningful gap today: users can install the desktop app and CLI locally, but OpenSpec has no supported way to target it during initialization or refresh. + +MiniMax Code does not behave like most project-local tools OpenSpec supports today. Its OpenSpec-compatible workflows are exposed through skills, and current local verification shows the active MiniMax skill location is backed by the user's `~/.minimax/skills` directory. Treating MiniMax Code like a repo-local `.minimax/commands/...` integration would produce the wrong artifacts. + +This first version intentionally uses a fixed user-home target instead of invoking MiniMax runtime configuration. Runtime profile/data-dir discovery can be added later if MiniMax installations require it in practice. + +## What Changes + +- Add MiniMax Code as a supported `openspec init --tools minimax-code` target and interactive selection. +- Define MiniMax Code as a global user-home skill-installation target, writing OpenSpec-managed skills into `~/.minimax/skills` instead of a project-local hidden folder. +- Update configured-tool detection so OpenSpec can recognize an existing MiniMax Code installation from the fixed user-home MiniMax skill location. +- Update `openspec update` so it refreshes MiniMax Code managed skills in place without creating a repo-local MiniMax directory. +- Treat MiniMax Code like a global skills-only integration: repo-local init/update generate skills when delivery includes skills, commands are skipped when delivery includes commands, and commands-only repo delivery does not delete the user's global MiniMax Code skills. Workspace setup/update remains skills-only like existing workspace behavior and still refreshes MiniMax Code skills when selected. +- Extend workspace skill setup/update so MiniMax Code can use the same shared skill target helper instead of being accidentally excluded by project-local `skillsDir` assumptions. +- Document MiniMax Code as a skills-invocable integration so users understand that OpenSpec workflows are exposed through MiniMax skills rather than adapter-generated command files. + +## Capabilities + +### New Capabilities + +_None._ + +### Modified Capabilities + +- `ai-tool-paths`: support AI tools whose OpenSpec skill target is a fixed user-home global skill directory instead of a fixed project-local `skillsDir` +- `cli-init`: allow selecting MiniMax Code and install its OpenSpec skills into the MiniMax Code user-home skills location +- `cli-update`: detect and refresh existing MiniMax Code OpenSpec-managed skills from the user-home installation target +- `workspace-links`: allow workspace skill setup/update to resolve MiniMax Code's user-home global skill target through the shared helper + +## Impact + +- `src/core/config.ts` - add MiniMax Code tool metadata and a home-relative global skill target path +- shared skill path helpers - resolve either project-local `//skills` paths or home-relative global `globalSkillsDir` values such as `.minimax` to final targets such as `~/.minimax/skills` +- `src/core/available-tools.ts` and `src/core/shared/tool-detection.ts` - detect configured MiniMax Code installs from the fixed global skill path +- `src/core/init.ts` - write MiniMax Code skills to the global target and preserve existing no-command-adapter behavior +- `src/core/update.ts` - refresh MiniMax Code managed skills in place and preserve existing global MiniMax Code skills when delivery is commands-only +- `src/core/profile-sync-drift.ts` and `src/core/migration.ts` - read MiniMax Code skill status through the shared skill path helper rather than assuming project-local `skillsDir` +- `src/core/workspace/skills.ts` and workspace command tests - route workspace skill generation, refresh, selection, and cleanup through the shared skill path helper +- `docs/supported-tools.md`, `docs/cli.md`, and `docs/commands.md` - document MiniMax Code setup, tool id, fixed global skill path, and skill invocation expectations +- `test/core/init.test.ts`, `test/core/update.test.ts`, `test/core/workspace/skills.test.ts`, `test/commands/workspace.test.ts`, and detection/path tests - cover MiniMax Code path resolution, detection, refresh, workspace setup/update, managed-only cleanup, commands-only preservation, and adapterless delivery behavior diff --git a/openspec/changes/add-minimax-code-tool-support/specs/ai-tool-paths/spec.md b/openspec/changes/add-minimax-code-tool-support/specs/ai-tool-paths/spec.md new file mode 100644 index 000000000..18dfd5fea --- /dev/null +++ b/openspec/changes/add-minimax-code-tool-support/specs/ai-tool-paths/spec.md @@ -0,0 +1,76 @@ +## MODIFIED Requirements + +### Requirement: AIToolOption skill target fields + +The `AIToolOption` interface SHALL support both fixed project-local skill targets and fixed user-home global skill targets for skill generation. + +#### Scenario: Interface includes project-local skillsDir field + +- **WHEN** a tool entry is defined in `AI_TOOLS` that supports project-local skill generation +- **THEN** it SHALL include a `skillsDir` field specifying the project-local base directory (for example `.claude`) + +#### Scenario: Interface includes user-home global skill target metadata + +- **WHEN** a tool entry in `AI_TOOLS` stores OpenSpec-managed skills in a fixed user-home global location +- **THEN** it SHALL include a `globalSkillsDir` field that lets OpenSpec derive that home-relative global skill target before detection, init, or update +- **AND** OpenSpec SHALL NOT require a project-local `skillsDir` for that tool + +#### Scenario: Skills path follows Agent Skills spec for project-local tools + +- **WHEN** generating skills for a tool with `skillsDir: '.claude'` +- **THEN** skills SHALL be written to `//skills/` +- **AND** the `/skills` suffix is appended per Agent Skills specification + +#### Scenario: Skills path follows fixed global target metadata + +- **WHEN** generating skills for a tool with a home-relative global skill target +- **THEN** skills SHALL be written to the resolved user-home global skill directory +- **AND** OpenSpec SHALL NOT prepend the project root to that target + +#### Scenario: Tool supports skills when either skill target field exists + +- **WHEN** OpenSpec builds a list of skill-capable tools for selection, validation, help, completion, detection, init, update, or workspace setup/update +- **THEN** a tool SHALL be considered skill-capable when it has either `skillsDir` or `globalSkillsDir` +- **AND** path resolution SHALL still distinguish project-local `skillsDir` from user-home global `globalSkillsDir` + +### Requirement: Path configuration for supported tools + +The `AI_TOOLS` array SHALL include skill-target metadata for tools that support the Agent Skills specification. + +#### Scenario: Claude Code paths defined + +- **WHEN** looking up the `claude` tool +- **THEN** `skillsDir` SHALL be `.claude` + +#### Scenario: Cursor paths defined + +- **WHEN** looking up the `cursor` tool +- **THEN** `skillsDir` SHALL be `.cursor` + +#### Scenario: Windsurf paths defined + +- **WHEN** looking up the `windsurf` tool +- **THEN** `skillsDir` SHALL be `.windsurf` + +#### Scenario: Kimi CLI paths defined + +- **WHEN** looking up the `kimi` tool +- **THEN** `skillsDir` SHALL be `.kimi` + +#### Scenario: MiniMax Code path strategy defined + +- **WHEN** looking up the `minimax-code` tool +- **THEN** its tool metadata SHALL include `globalSkillsDir: '.minimax'` +- **AND** OpenSpec SHALL derive its managed skill directory as `/.minimax/skills` + +#### Scenario: MiniMax Code path works with Windows user homes + +- **WHEN** tool is `minimax-code` +- **AND** the current user home is a Windows-style path +- **THEN** OpenSpec SHALL derive the managed OpenSpec skill target with platform path joining +- **AND** the resulting target SHALL be equivalent to `%USERPROFILE%\.minimax\skills` + +#### Scenario: Tools without skill target metadata + +- **WHEN** a tool has neither `skillsDir` nor `globalSkillsDir` defined +- **THEN** skill generation SHALL error with a message indicating the tool is not supported diff --git a/openspec/changes/add-minimax-code-tool-support/specs/cli-init/spec.md b/openspec/changes/add-minimax-code-tool-support/specs/cli-init/spec.md new file mode 100644 index 000000000..ac2834097 --- /dev/null +++ b/openspec/changes/add-minimax-code-tool-support/specs/cli-init/spec.md @@ -0,0 +1,71 @@ +## ADDED Requirements + +### Requirement: MiniMax Code tool selection + +The init command SHALL support MiniMax Code as a first-class tool target in both interactive and non-interactive flows. + +#### Scenario: Interactive selection includes MiniMax Code + +- **WHEN** a user runs `openspec init` interactively +- **THEN** the searchable tool selection list SHALL include MiniMax Code as a supported choice + +#### Scenario: Non-interactive selection accepts MiniMax Code + +- **WHEN** a user runs `openspec init --tools minimax-code` +- **THEN** OpenSpec SHALL accept `minimax-code` as a valid supported tool id + +### Requirement: MiniMax Code skill installation target + +The init command SHALL install MiniMax Code OpenSpec skills into the fixed MiniMax Code user-home skills directory. + +#### Scenario: MiniMax Code installs to user-home global skills directory + +- **WHEN** a user selects MiniMax Code during `openspec init` +- **AND** delivery includes skills +- **THEN** OpenSpec SHALL write MiniMax Code managed OpenSpec skills into `/.minimax/skills` +- **AND** SHALL NOT create a repo-local `.minimax` or `.mavis` skills directory for that setup + +#### Scenario: MiniMax Code overwrites OpenSpec workflow skill directories + +- **WHEN** MiniMax Code skill generation writes an `openspec-*` workflow skill directory under `/.minimax/skills` +- **AND** that workflow skill directory already exists +- **THEN** OpenSpec SHALL overwrite the generated `SKILL.md` content for that workflow +- **AND** it SHALL NOT require existing OpenSpec generated metadata before overwriting + +#### Scenario: MiniMax Code already configured + +- **WHEN** MiniMax Code managed OpenSpec skills already exist in `/.minimax/skills` +- **THEN** interactive `openspec init` SHALL show MiniMax Code as already configured for refresh + +#### Scenario: MiniMax Code user-home target cannot be written + +- **WHEN** a user selects MiniMax Code during `openspec init` +- **AND** OpenSpec cannot create or write the `/.minimax/skills` target +- **THEN** OpenSpec SHALL fail MiniMax Code setup before writing partial MiniMax Code managed files where possible +- **AND** SHALL report the filesystem error in the existing per-tool failure summary + +### Requirement: MiniMax Code adapterless delivery behavior during init + +The init command SHALL treat MiniMax Code like existing skills-capable tools that do not have command adapters. + +#### Scenario: Delivery both writes skills and skips commands + +- **WHEN** global delivery is `both` +- **AND** a user selects MiniMax Code during `openspec init` +- **THEN** OpenSpec SHALL write MiniMax Code skills into `/.minimax/skills` +- **AND** SHALL skip command generation for MiniMax Code because no command adapter exists + +#### Scenario: Delivery skills writes only skills + +- **WHEN** global delivery is `skills` +- **AND** a user selects MiniMax Code during `openspec init` +- **THEN** OpenSpec SHALL write MiniMax Code skills into `/.minimax/skills` +- **AND** SHALL NOT attempt MiniMax Code command generation + +#### Scenario: Delivery commands does not create MiniMax skills + +- **WHEN** global delivery is `commands` +- **AND** a user selects MiniMax Code during `openspec init` +- **THEN** OpenSpec SHALL NOT create MiniMax Code skills +- **AND** SHALL NOT remove existing MiniMax Code skills from `/.minimax/skills` +- **AND** SHALL skip command generation for MiniMax Code because no command adapter exists diff --git a/openspec/changes/add-minimax-code-tool-support/specs/cli-update/spec.md b/openspec/changes/add-minimax-code-tool-support/specs/cli-update/spec.md new file mode 100644 index 000000000..35990f84a --- /dev/null +++ b/openspec/changes/add-minimax-code-tool-support/specs/cli-update/spec.md @@ -0,0 +1,68 @@ +## ADDED Requirements + +### Requirement: MiniMax Code configured-tool detection during update + +The update command SHALL recognize MiniMax Code from its fixed user-home OpenSpec skill target. + +#### Scenario: MiniMax Code is detected from user-home global skills + +- **WHEN** a user runs `openspec update` +- **AND** MiniMax Code managed OpenSpec skills exist in `/.minimax/skills` +- **THEN** OpenSpec SHALL treat MiniMax Code as a configured tool even when no repo-local MiniMax directory exists + +### Requirement: MiniMax Code managed skill refresh + +The update command SHALL refresh MiniMax Code managed OpenSpec skills in the fixed user-home global skills directory. + +#### Scenario: MiniMax Code refreshes managed skills in place + +- **WHEN** a user runs `openspec update` +- **AND** MiniMax Code is configured +- **AND** delivery includes skills +- **THEN** OpenSpec SHALL refresh MiniMax Code managed OpenSpec skills in `/.minimax/skills` +- **AND** SHALL overwrite existing `openspec-*` workflow skill `SKILL.md` files without requiring OpenSpec generated metadata +- **AND** SHALL leave non-OpenSpec MiniMax files outside the `openspec-*` workflow skill targets untouched + +#### Scenario: MiniMax Code update does not create repo-local fallback directories + +- **WHEN** a user runs `openspec update` for a project with MiniMax Code configured +- **THEN** OpenSpec SHALL NOT create a repo-local `.minimax` or `.mavis` skills directory as part of the refresh + +#### Scenario: MiniMax Code user-home target cannot be written during update + +- **WHEN** MiniMax Code is selected for refresh during `openspec update` +- **AND** OpenSpec cannot create or write the `/.minimax/skills` target +- **THEN** OpenSpec SHALL fail MiniMax Code refresh before writing partial MiniMax Code managed files where possible +- **AND** SHALL report the filesystem error in the existing per-tool failure summary + +### Requirement: MiniMax Code adapterless delivery behavior during update + +The update command SHALL treat MiniMax Code like existing skills-capable tools that do not have command adapters. + +#### Scenario: Delivery both refreshes skills and skips commands + +- **WHEN** global delivery is `both` +- **AND** MiniMax Code is configured +- **THEN** OpenSpec SHALL refresh MiniMax Code skills in `/.minimax/skills` +- **AND** SHALL skip command generation for MiniMax Code because no command adapter exists + +#### Scenario: Delivery skills refreshes only skills + +- **WHEN** global delivery is `skills` +- **AND** MiniMax Code is configured +- **THEN** OpenSpec SHALL refresh MiniMax Code skills in `/.minimax/skills` +- **AND** SHALL NOT attempt MiniMax Code command generation + +#### Scenario: Delivery commands preserves global MiniMax skills + +- **WHEN** global delivery is `commands` +- **AND** MiniMax Code is configured +- **THEN** OpenSpec SHALL NOT remove existing MiniMax Code skills from `/.minimax/skills` +- **AND** SHALL skip command generation for MiniMax Code because no command adapter exists + +#### Scenario: MiniMax Code cleanup removes OpenSpec workflow skill directories by name + +- **WHEN** OpenSpec removes MiniMax Code workflow skill directories during profile cleanup or explicit managed cleanup +- **THEN** it SHALL remove known `openspec-*` workflow skill directories by directory name +- **AND** it SHALL NOT require `SKILL.md` to contain OpenSpec generated metadata before removal +- **AND** it SHALL preserve non-OpenSpec MiniMax files outside the `openspec-*` workflow skill targets diff --git a/openspec/changes/add-minimax-code-tool-support/specs/workspace-links/spec.md b/openspec/changes/add-minimax-code-tool-support/specs/workspace-links/spec.md new file mode 100644 index 000000000..a0ed2e70e --- /dev/null +++ b/openspec/changes/add-minimax-code-tool-support/specs/workspace-links/spec.md @@ -0,0 +1,48 @@ +## MODIFIED Requirements + +### Requirement: Workspace setup installs agent skills + +OpenSpec SHALL let users install OpenSpec agent skills into a workspace during workspace setup. + +#### Scenario: Installing MiniMax Code workspace skills uses global skill target + +- **WHEN** workspace setup installs agent skills +- **AND** MiniMax Code is selected as an agent +- **THEN** OpenSpec SHALL resolve the MiniMax Code skill target through the shared skill directory helper +- **AND** it SHALL write MiniMax Code OpenSpec skills to `/.minimax/skills` +- **AND** it SHALL NOT create a workspace-local `.minimax`, `.mavis`, or MiniMax fallback skills directory + +#### Scenario: Workspace setup commands delivery preserves global MiniMax skills + +- **GIVEN** global config delivery is `commands` +- **WHEN** workspace setup includes MiniMax Code in the selected agents +- **THEN** OpenSpec SHALL still write or refresh MiniMax Code OpenSpec skills in `/.minimax/skills` +- **AND** it SHALL NOT generate MiniMax Code command files +- **AND** it SHALL NOT remove existing MiniMax Code skills solely because repo-local delivery is `commands` + +### Requirement: Workspace update manages agent skills + +OpenSpec SHALL provide a workspace update flow for refreshing agent skills after setup. + +#### Scenario: Updating MiniMax Code workspace skills uses global skill target + +- **WHEN** workspace update refreshes selected agent skills +- **AND** MiniMax Code is selected or stored in workspace-local selected agents +- **THEN** OpenSpec SHALL resolve the MiniMax Code skill target through the shared skill directory helper +- **AND** it SHALL refresh MiniMax Code OpenSpec skills in `/.minimax/skills` +- **AND** it SHALL NOT create a workspace-local `.minimax`, `.mavis`, or MiniMax fallback skills directory + +#### Scenario: Workspace update removes MiniMax Code workflow skills by name + +- **WHEN** workspace update removes deselected MiniMax Code workflow skills or syncs profile-selected workflows +- **THEN** OpenSpec SHALL remove known `openspec-*` workflow skill directories by directory name +- **AND** it SHALL NOT require `SKILL.md` to contain OpenSpec generated metadata before removal +- **AND** it SHALL preserve non-OpenSpec MiniMax files outside the `openspec-*` workflow skill targets + +#### Scenario: Workspace update commands delivery preserves global MiniMax skills + +- **GIVEN** global config delivery is `commands` +- **WHEN** workspace update includes MiniMax Code in the selected or stored agents +- **THEN** OpenSpec SHALL still write or refresh MiniMax Code OpenSpec skills in `/.minimax/skills` +- **AND** it SHALL NOT generate MiniMax Code command files +- **AND** it SHALL NOT remove existing MiniMax Code skills solely because repo-local delivery is `commands` diff --git a/openspec/changes/add-minimax-code-tool-support/tasks.md b/openspec/changes/add-minimax-code-tool-support/tasks.md new file mode 100644 index 000000000..17337b468 --- /dev/null +++ b/openspec/changes/add-minimax-code-tool-support/tasks.md @@ -0,0 +1,45 @@ +## 1. Stacking and Resolver Foundation + +- [x] 1.1 Rebase this change on the latest path-planning work so MiniMax Code composes cleanly with install-scope and command-surface capability changes +- [x] 1.2 Add MiniMax Code tool metadata in `src/core/config.ts`, including its stable tool id and `globalSkillsDir: '.minimax'` +- [x] 1.3 Introduce a shared skill-directory helper that can return either project-local `skillsDir` or user-home global `globalSkillsDir` OpenSpec skill locations +- [x] 1.4 Add focused unit tests for MiniMax Code skill target resolution, including Windows-style user-home fixtures +- [x] 1.5 Update skill-capable tool filtering to treat `skillsDir || globalSkillsDir` as supported while keeping path resolution scope-aware + +## 2. Detection and Status + +- [x] 2.1 Update configured-tool detection helpers to recognize MiniMax Code from its user-home global managed skill directory +- [x] 2.2 Update available-tool detection so MiniMax Code can appear as already configured during init when its managed skills are present globally +- [x] 2.3 Update version/status helpers to read MiniMax Code generated skill metadata from the user-home global target +- [x] 2.4 Add tests for MiniMax Code detection and version checks without any repo-local `.minimax` directory +- [x] 2.5 Add automatic tests proving MiniMax Code configured-tool detection does not depend on repo-local `.minimax` or `.mavis` directories + +## 3. Init and Update Behavior + +- [x] 3.1 Extend `openspec init` validation so `--tools minimax-code` is accepted in interactive and non-interactive flows +- [x] 3.2 Update init generation logic to write MiniMax Code OpenSpec skills into `/.minimax/skills` and avoid repo-local fallback directories +- [x] 3.3 Update init summaries and error messages so MiniMax Code is reported as a supported global skills-based integration with clear filesystem failure guidance +- [x] 3.4 Update `openspec update` to refresh MiniMax Code managed skills from `/.minimax/skills` and preserve non-OpenSpec MiniMax files +- [x] 3.5 Add targeted init/update tests covering successful MiniMax Code setup, already-configured refresh, filesystem failure paths, and adapterless delivery behavior +- [x] 3.6 Match Codex's non-destructive user-home behavior: when delivery is `commands`, skip MiniMax Code command generation and do not delete existing MiniMax Code skills from `/.minimax/skills` +- [x] 3.7 Update MiniMax Code skill refresh and cleanup to treat `openspec-*` workflow skill directories as OpenSpec-owned, without requiring generated metadata checks +- [x] 3.8 Add automatic tests proving commands-only init/update leaves existing MiniMax Code global skills intact and does not create repo-local `.minimax` or `.mavis` directories +- [x] 3.9 Add automatic tests proving MiniMax Code refresh overwrites and cleanup removes colliding `openspec-*` skill directories even when generated metadata is missing + +## 4. Workspace Skills Behavior + +- [x] 4.1 Wire `src/core/workspace/skills.ts` to use the shared skill-directory helper for MiniMax Code rather than requiring project/workspace-local `skillsDir` +- [x] 4.2 Ensure workspace setup can select `minimax-code`, writes skills to `/.minimax/skills`, and reports the resolved global skills path +- [x] 4.3 Ensure workspace update refreshes stored or selected MiniMax Code workspace skills through the global target and avoids workspace-local `.minimax` or `.mavis` fallbacks +- [x] 4.4 For MiniMax Code workspace update, treat `openspec-*` workflow skill directories as OpenSpec-owned without checking generated metadata +- [x] 4.5 Add automatic workspace setup/update tests for MiniMax Code global path resolution, command-delivery skill refresh, no workspace-local fallback directories, and `openspec-*` overwrite/removal behavior + +## 5. Documentation and Verification + +- [x] 5.1 Update `docs/supported-tools.md` with MiniMax Code, its tool id, and its global skills-based installation behavior +- [x] 5.2 Update `docs/cli.md` to include `minimax-code` in supported `--tools` examples and explain how OpenSpec finds the MiniMax Code install target +- [x] 5.3 Update `docs/commands.md` command syntax guidance with MiniMax Code's skill-based invocation style +- [x] 5.4 Document that repo-local `delivery=commands` skips MiniMax Code command generation and preserves existing global MiniMax Code skills +- [x] 5.5 Run targeted tests for skill-directory helper, detection, init, update, and workspace setup/update behavior +- [x] 5.6 Run the full test suite (`pnpm test`) and resolve regressions +- [x] 5.7 Perform a manual Windows smoke test against a real MiniMax Code installation, confirming `%USERPROFILE%\.minimax\skills` is loaded and `.mavis` compatibility does not require OpenSpec-side path handling diff --git a/src/cli/index.ts b/src/cli/index.ts index 0c42f43cb..9790c72b0 100644 --- a/src/cli/index.ts +++ b/src/cli/index.ts @@ -5,6 +5,7 @@ import path from 'path'; import { fileURLToPath } from 'url'; import { promises as fs } from 'fs'; import { AI_TOOLS, OPENSPEC_DIR_NAME } from '../core/config.js'; +import { toolSupportsSkills } from '../core/shared/index.js'; import { UpdateCommand } from '../core/update.js'; import { ListCommand } from '../core/list.js'; import { ArchiveCommand } from '../core/archive.js'; @@ -94,7 +95,7 @@ program.hook('postAction', async () => { await shutdown(); }); -const availableToolIds = AI_TOOLS.filter((tool) => tool.skillsDir).map((tool) => tool.value); +const availableToolIds = AI_TOOLS.filter(toolSupportsSkills).map((tool) => tool.value); const toolsOptionDescription = `Configure AI tools non-interactively. Use "all", "none", or a comma-separated list of: ${availableToolIds.join(', ')}`; async function hasRepoLocalOpenSpecProject(projectPath: string): Promise { diff --git a/src/core/available-tools.ts b/src/core/available-tools.ts index f3dabe97d..5e9e05dc8 100644 --- a/src/core/available-tools.ts +++ b/src/core/available-tools.ts @@ -8,17 +8,29 @@ import path from 'path'; import * as fs from 'fs'; import { AI_TOOLS, type AIToolOption } from './config.js'; +import { SKILL_NAMES } from './shared/tool-detection.js'; +import { resolveToolSkillsDir, toolSupportsSkills } from './shared/skill-paths.js'; /** * Scans the project path for AI tool configuration directories and returns * the tools that are present. * * For tools with `detectionPaths`, checks those specific paths (files or - * directories). Otherwise checks for the tool's `skillsDir` directory at - * the project root. Only tools with a `skillsDir` property are considered. + * directories). Otherwise checks for the tool's project-local `skillsDir` + * directory at the project root. Global skill tools are detected only when + * OpenSpec-managed skill files already exist in their global target. */ export function getAvailableTools(projectPath: string): AIToolOption[] { return AI_TOOLS.filter((tool) => { + if (!toolSupportsSkills(tool)) return false; + + if (tool.globalSkillsDir) { + const skillsDir = resolveToolSkillsDir(projectPath, tool); + return SKILL_NAMES.some((skillName) => + fs.existsSync(path.join(skillsDir, skillName, 'SKILL.md')) + ); + } + if (!tool.skillsDir) return false; if (tool.detectionPaths && tool.detectionPaths.length > 0) { diff --git a/src/core/config.ts b/src/core/config.ts index 3be428b26..5dd7625eb 100644 --- a/src/core/config.ts +++ b/src/core/config.ts @@ -15,6 +15,7 @@ export interface AIToolOption { available: boolean; successLabel?: string; skillsDir?: string; // e.g., '.claude' - /skills suffix per Agent Skills spec + globalSkillsDir?: string; // e.g., '.minimax' - /skills suffix per Agent Skills spec, resolved from the user's home directory detectionPaths?: string[]; // Override skillsDir for auto-detection; any path existing triggers detection } @@ -41,6 +42,7 @@ export const AI_TOOLS: AIToolOption[] = [ { name: 'Kimi CLI', value: 'kimi', available: true, successLabel: 'Kimi CLI', skillsDir: '.kimi' }, { name: 'Kiro', value: 'kiro', available: true, successLabel: 'Kiro', skillsDir: '.kiro' }, { name: 'Lingma', value: 'lingma', available: true, successLabel: 'Lingma', skillsDir: '.lingma' }, + { name: 'MiniMax Code', value: 'minimax-code', available: true, successLabel: 'MiniMax Code', globalSkillsDir: '.minimax' }, { name: 'Mistral Vibe', value: 'vibe', available: true, successLabel: 'Mistral Vibe', skillsDir: '.vibe' }, { name: 'OpenCode', value: 'opencode', available: true, successLabel: 'OpenCode', skillsDir: '.opencode' }, { name: 'Pi', value: 'pi', available: true, successLabel: 'Pi', skillsDir: '.pi' }, diff --git a/src/core/init.ts b/src/core/init.ts index aa38408f2..0d89fbe93 100644 --- a/src/core/init.ts +++ b/src/core/init.ts @@ -39,6 +39,9 @@ import { getSkillTemplates, getCommandContents, generateSkillContent, + hasGlobalSkillTarget, + resolveToolSkillsDir, + toolSupportsSkills, type ToolSkillStatus, } from './shared/index.js'; import { getGlobalConfig, type Delivery, type Profile } from './global-config.js'; @@ -85,6 +88,14 @@ type InitCommandOptions = { profile?: string; }; +type ValidatedInitTool = { + value: string; + name: string; + skillsPath: string; + isGlobalSkillTarget: boolean; + wasConfigured: boolean; +}; + // ----------------------------------------------------------------------------- // Init Command Class // ----------------------------------------------------------------------------- @@ -139,7 +150,7 @@ export class InitCommand { const selectedToolIds = await this.getSelectedTools(toolStates, extendMode, detectedTools, projectPath); // Validate selected tools - const validatedTools = this.validateTools(selectedToolIds, toolStates); + const validatedTools = this.validateTools(selectedToolIds, toolStates, projectPath); // Create directory structure and config await this.createDirectoryStructure(openspecPath, extendMode); @@ -416,9 +427,10 @@ export class InitCommand { private validateTools( toolIds: string[], - toolStates: Map - ): Array<{ value: string; name: string; skillsDir: string; wasConfigured: boolean }> { - const validatedTools: Array<{ value: string; name: string; skillsDir: string; wasConfigured: boolean }> = []; + toolStates: Map, + projectPath = process.cwd() + ): ValidatedInitTool[] { + const validatedTools: ValidatedInitTool[] = []; for (const toolId of toolIds) { const tool = AI_TOOLS.find((t) => t.value === toolId); @@ -429,7 +441,7 @@ export class InitCommand { ); } - if (!tool.skillsDir) { + if (!toolSupportsSkills(tool)) { const validToolsWithSkills = getToolsWithSkillsDir(); throw new Error( `Tool '${toolId}' does not support skill generation.\nTools with skill generation support:\n ${validToolsWithSkills.join('\n ')}` @@ -440,7 +452,8 @@ export class InitCommand { validatedTools.push({ value: tool.value, name: tool.name, - skillsDir: tool.skillsDir, + skillsPath: resolveToolSkillsDir(projectPath, tool), + isGlobalSkillTarget: hasGlobalSkillTarget(tool), wasConfigured: preState?.configured ?? false, }); } @@ -493,7 +506,7 @@ export class InitCommand { private async generateSkillsAndCommands( projectPath: string, - tools: Array<{ value: string; name: string; skillsDir: string; wasConfigured: boolean }> + tools: ValidatedInitTool[] ): Promise<{ createdTools: typeof tools; refreshedTools: typeof tools; @@ -528,12 +541,9 @@ export class InitCommand { try { // Generate skill files if delivery includes skills if (shouldGenerateSkills) { - // Use tool-specific skillsDir - const skillsDir = path.join(projectPath, tool.skillsDir, 'skills'); - // Create skill directories and SKILL.md files for (const { template, dirName } of skillTemplates) { - const skillDir = path.join(skillsDir, dirName); + const skillDir = path.join(tool.skillsPath, dirName); const skillFile = path.join(skillDir, 'SKILL.md'); // Generate SKILL.md content with YAML frontmatter including generatedBy @@ -545,9 +555,8 @@ export class InitCommand { await FileSystemUtils.writeFile(skillFile, skillContent); } } - if (!shouldGenerateSkills) { - const skillsDir = path.join(projectPath, tool.skillsDir, 'skills'); - removedSkillCount += await this.removeSkillDirs(skillsDir); + if (!shouldGenerateSkills && !tool.isGlobalSkillTarget) { + removedSkillCount += await this.removeSkillDirs(tool.skillsPath); } // Generate commands if delivery includes commands @@ -625,7 +634,7 @@ export class InitCommand { private displaySuccessMessage( projectPath: string, - tools: Array<{ value: string; name: string; skillsDir: string; wasConfigured: boolean }>, + tools: ValidatedInitTool[], results: { createdTools: typeof tools; refreshedTools: typeof tools; @@ -655,15 +664,34 @@ export class InitCommand { const profile: Profile = (this.profileOverride as Profile) ?? globalConfig.profile ?? 'core'; const delivery: Delivery = globalConfig.delivery ?? 'both'; const workflows = getProfileWorkflows(profile, globalConfig.workflows); - const toolDirs = [...new Set(successfulTools.map((t) => t.skillsDir))].join(', '); - const skillCount = delivery !== 'commands' ? getSkillTemplates(workflows).length : 0; - const commandCount = delivery !== 'skills' ? getCommandContents(workflows).length : 0; - if (skillCount > 0 && commandCount > 0) { - console.log(`${skillCount} skills and ${commandCount} commands in ${toolDirs}/`); - } else if (skillCount > 0) { - console.log(`${skillCount} skills in ${toolDirs}/`); - } else if (commandCount > 0) { - console.log(`${commandCount} commands in ${toolDirs}/`); + const skillTemplates = getSkillTemplates(workflows); + const commandContents = getCommandContents(workflows); + const skillDirs = [...new Set(successfulTools.map((t) => t.skillsPath))]; + const skillCount = delivery !== 'commands' ? skillTemplates.length : 0; + + if (skillCount > 0) { + console.log(`${skillCount} skills in ${skillDirs.map((dir) => `${dir}/`).join(', ')}`); + } + + const generatedCommandTools = delivery !== 'skills' + ? successfulTools + .map((tool) => ({ tool, adapter: CommandAdapterRegistry.get(tool.value) })) + .filter((entry): entry is { tool: ValidatedInitTool; adapter: NonNullable } => Boolean(entry.adapter)) + : []; + const commandDirs = [ + ...new Set( + generatedCommandTools.flatMap(({ adapter }) => + commandContents.map((content) => { + const commandPath = adapter.getFilePath(content.id); + return path.dirname(path.isAbsolute(commandPath) ? commandPath : path.join(projectPath, commandPath)); + }) + ) + ), + ]; + const commandCount = generatedCommandTools.length * commandContents.length; + + if (commandCount > 0) { + console.log(`${commandCount} commands in ${commandDirs.map((dir) => `${dir}/`).join(', ')}`); } } diff --git a/src/core/migration.ts b/src/core/migration.ts index 48aaa41ee..e57a4a2c7 100644 --- a/src/core/migration.ts +++ b/src/core/migration.ts @@ -12,6 +12,7 @@ import { WORKFLOW_TO_SKILL_DIR } from './profile-sync-drift.js'; import { ALL_WORKFLOWS } from './profiles.js'; import path from 'path'; import * as fs from 'fs'; +import { resolveToolSkillsDir, toolSupportsSkills } from './shared/skill-paths.js'; interface InstalledWorkflowArtifacts { workflows: string[]; @@ -28,8 +29,8 @@ function scanInstalledWorkflowArtifacts( let hasCommands = false; for (const tool of tools) { - if (!tool.skillsDir) continue; - const skillsDir = path.join(projectPath, tool.skillsDir, 'skills'); + if (!toolSupportsSkills(tool)) continue; + const skillsDir = resolveToolSkillsDir(projectPath, tool); for (const workflowId of ALL_WORKFLOWS) { const skillDirName = WORKFLOW_TO_SKILL_DIR[workflowId]; diff --git a/src/core/profile-sync-drift.ts b/src/core/profile-sync-drift.ts index 782bdcc9f..e5eac8a42 100644 --- a/src/core/profile-sync-drift.ts +++ b/src/core/profile-sync-drift.ts @@ -5,6 +5,11 @@ import type { Delivery } from './global-config.js'; import { ALL_WORKFLOWS } from './profiles.js'; import { CommandAdapterRegistry } from './command-generation/index.js'; import { COMMAND_IDS, getConfiguredTools } from './shared/index.js'; +import { + hasGlobalSkillTarget, + resolveToolSkillsDir, + toolSupportsSkills, +} from './shared/skill-paths.js'; type WorkflowId = (typeof ALL_WORKFLOWS)[number]; @@ -56,6 +61,8 @@ export function toolHasAnyConfiguredCommand(projectPath: string, toolId: string) export function getCommandConfiguredTools(projectPath: string): string[] { return AI_TOOLS .filter((tool) => { + if (!toolSupportsSkills(tool)) return false; + if (hasGlobalSkillTarget(tool)) return false; if (!tool.skillsDir) return false; const toolDir = path.join(projectPath, tool.skillsDir); try { @@ -92,11 +99,11 @@ export function hasToolProfileOrDeliveryDrift( delivery: Delivery ): boolean { const tool = AI_TOOLS.find((t) => t.value === toolId); - if (!tool?.skillsDir) return false; + if (!tool || !toolSupportsSkills(tool)) return false; const knownDesiredWorkflows = toKnownWorkflows(desiredWorkflows); const desiredWorkflowSet = new Set(knownDesiredWorkflows); - const skillsDir = path.join(projectPath, tool.skillsDir, 'skills'); + const skillsDir = resolveToolSkillsDir(projectPath, tool); const adapter = CommandAdapterRegistry.get(toolId); const shouldGenerateSkills = delivery !== 'commands'; const shouldGenerateCommands = delivery !== 'skills'; @@ -119,7 +126,7 @@ export function hasToolProfileOrDeliveryDrift( return true; } } - } else { + } else if (!hasGlobalSkillTarget(tool)) { for (const workflow of ALL_WORKFLOWS) { const dirName = WORKFLOW_TO_SKILL_DIR[workflow]; const skillDir = path.join(skillsDir, dirName); @@ -181,10 +188,10 @@ function getInstalledWorkflowsForTool( options: { includeSkills: boolean; includeCommands: boolean } ): WorkflowId[] { const tool = AI_TOOLS.find((t) => t.value === toolId); - if (!tool?.skillsDir) return []; + if (!tool || !toolSupportsSkills(tool)) return []; const installed = new Set(); - const skillsDir = path.join(projectPath, tool.skillsDir, 'skills'); + const skillsDir = resolveToolSkillsDir(projectPath, tool); if (options.includeSkills) { for (const workflow of ALL_WORKFLOWS) { diff --git a/src/core/shared/index.ts b/src/core/shared/index.ts index 32b965696..930632c03 100644 --- a/src/core/shared/index.ts +++ b/src/core/shared/index.ts @@ -28,3 +28,12 @@ export { getCommandContents, generateSkillContent, } from './skill-generation.js'; + +export { + type SkillCapableTool, + toolSupportsSkills, + getSkillCapableTools, + getSkillCapableToolIds, + hasGlobalSkillTarget, + resolveToolSkillsDir, +} from './skill-paths.js'; diff --git a/src/core/shared/skill-paths.ts b/src/core/shared/skill-paths.ts new file mode 100644 index 000000000..9ff4b8a2d --- /dev/null +++ b/src/core/shared/skill-paths.ts @@ -0,0 +1,45 @@ +import os from 'node:os'; + +import { FileSystemUtils } from '../../utils/file-system.js'; +import { AI_TOOLS, type AIToolOption } from '../config.js'; + +export type SkillCapableTool = AIToolOption & ( + | { skillsDir: string } + | { globalSkillsDir: string } +); + +export function toolSupportsSkills(tool: AIToolOption): tool is SkillCapableTool { + return Boolean(tool.skillsDir || tool.globalSkillsDir); +} + +export function getSkillCapableTools(): SkillCapableTool[] { + return AI_TOOLS.filter(toolSupportsSkills); +} + +export function getSkillCapableToolIds(): string[] { + return getSkillCapableTools().map((tool) => tool.value); +} + +export function hasGlobalSkillTarget(tool: AIToolOption): boolean { + return Boolean(tool.globalSkillsDir); +} + +function getUserHomeDir(): string { + return process.env.USERPROFILE || process.env.HOME || os.homedir(); +} + +export function resolveToolSkillsDir( + projectRoot: string, + tool: SkillCapableTool, + options: { homeDir?: string } = {} +): string { + if (tool.globalSkillsDir) { + return FileSystemUtils.joinPath(options.homeDir ?? getUserHomeDir(), tool.globalSkillsDir, 'skills'); + } + + if (tool.skillsDir) { + return FileSystemUtils.joinPath(projectRoot, tool.skillsDir, 'skills'); + } + + throw new Error(`Tool '${tool.value}' does not support skill generation.`); +} diff --git a/src/core/shared/tool-detection.ts b/src/core/shared/tool-detection.ts index 72a0ebc8a..e0cd60f26 100644 --- a/src/core/shared/tool-detection.ts +++ b/src/core/shared/tool-detection.ts @@ -7,6 +7,11 @@ import path from 'path'; import * as fs from 'fs'; import { AI_TOOLS } from '../config.js'; +import { + getSkillCapableTools, + resolveToolSkillsDir, + toolSupportsSkills, +} from './skill-paths.js'; /** * Names of skill directories created by openspec init. @@ -75,10 +80,10 @@ export interface ToolVersionStatus { } /** - * Gets the list of tools with skillsDir configured. + * Gets the list of tools with skill generation configured. */ export function getToolsWithSkillsDir(): string[] { - return AI_TOOLS.filter((t) => t.skillsDir).map((t) => t.value); + return getSkillCapableTools().map((t) => t.value); } /** @@ -86,11 +91,11 @@ export function getToolsWithSkillsDir(): string[] { */ export function getToolSkillStatus(projectRoot: string, toolId: string): ToolSkillStatus { const tool = AI_TOOLS.find((t) => t.value === toolId); - if (!tool?.skillsDir) { + if (!tool || !toolSupportsSkills(tool)) { return { configured: false, fullyConfigured: false, skillCount: 0 }; } - const skillsDir = path.join(projectRoot, tool.skillsDir, 'skills'); + const skillsDir = resolveToolSkillsDir(projectRoot, tool); let skillCount = 0; for (const skillName of SKILL_NAMES) { @@ -112,7 +117,7 @@ export function getToolSkillStatus(projectRoot: string, toolId: string): ToolSki */ export function getToolStates(projectRoot: string): Map { const states = new Map(); - const toolIds = AI_TOOLS.filter((t) => t.skillsDir).map((t) => t.value); + const toolIds = getToolsWithSkillsDir(); for (const toolId of toolIds) { states.set(toolId, getToolSkillStatus(projectRoot, toolId)); @@ -163,7 +168,7 @@ export function getToolVersionStatus( currentVersion: string ): ToolVersionStatus { const tool = AI_TOOLS.find((t) => t.value === toolId); - if (!tool?.skillsDir) { + if (!tool || !toolSupportsSkills(tool)) { return { toolId, toolName: toolId, @@ -173,7 +178,7 @@ export function getToolVersionStatus( }; } - const skillsDir = path.join(projectRoot, tool.skillsDir, 'skills'); + const skillsDir = resolveToolSkillsDir(projectRoot, tool); let generatedByVersion: string | null = null; // Find the first skill file that exists and read its version @@ -202,7 +207,7 @@ export function getToolVersionStatus( */ export function getConfiguredTools(projectRoot: string): string[] { return AI_TOOLS - .filter((t) => t.skillsDir && getToolSkillStatus(projectRoot, t.value).configured) + .filter((t) => toolSupportsSkills(t) && getToolSkillStatus(projectRoot, t.value).configured) .map((t) => t.value); } diff --git a/src/core/update.ts b/src/core/update.ts index e1582cd5b..a0d9e50f1 100644 --- a/src/core/update.ts +++ b/src/core/update.ts @@ -23,6 +23,9 @@ import { getCommandContents, generateSkillContent, getToolsWithSkillsDir, + hasGlobalSkillTarget, + resolveToolSkillsDir, + toolSupportsSkills, type ToolVersionStatus, } from './shared/index.js'; import { @@ -183,12 +186,12 @@ export class UpdateCommand { for (const toolId of toolsToUpdate) { const tool = AI_TOOLS.find((t) => t.value === toolId); - if (!tool?.skillsDir) continue; + if (!tool || !toolSupportsSkills(tool)) continue; const spinner = ora(`Updating ${tool.name}...`).start(); try { - const skillsDir = path.join(resolvedProjectPath, tool.skillsDir, 'skills'); + const skillsDir = resolveToolSkillsDir(resolvedProjectPath, tool); // Generate skill files if delivery includes skills if (shouldGenerateSkills) { @@ -206,7 +209,7 @@ export class UpdateCommand { } // Delete skill directories if delivery is commands-only - if (!shouldGenerateSkills) { + if (!shouldGenerateSkills && !hasGlobalSkillTarget(tool)) { removedSkillCount += await this.removeSkillDirs(skillsDir); } @@ -677,12 +680,12 @@ export class UpdateCommand { for (const toolId of selectedTools) { const tool = AI_TOOLS.find((t) => t.value === toolId); - if (!tool?.skillsDir) continue; + if (!tool || !toolSupportsSkills(tool)) continue; const spinner = ora(`Setting up ${tool.name}...`).start(); try { - const skillsDir = path.join(projectPath, tool.skillsDir, 'skills'); + const skillsDir = resolveToolSkillsDir(projectPath, tool); // Create skill files when delivery includes skills if (shouldGenerateSkills) { diff --git a/src/core/workspace/skills.ts b/src/core/workspace/skills.ts index 9caea04a9..7ccade41a 100644 --- a/src/core/workspace/skills.ts +++ b/src/core/workspace/skills.ts @@ -3,7 +3,7 @@ import { createRequire } from 'node:module'; import { FileSystemUtils } from '../../utils/file-system.js'; import { transformToHyphenCommands } from '../../utils/command-references.js'; -import { AI_TOOLS, type AIToolOption } from '../config.js'; +import { AI_TOOLS } from '../config.js'; import { getGlobalConfig, type Delivery, type Profile } from '../global-config.js'; import { getProfileWorkflows } from '../profiles.js'; import { @@ -12,6 +12,10 @@ import { getToolSkillStatus, getToolsWithSkillsDir, extractGeneratedByVersion, + hasGlobalSkillTarget, + resolveToolSkillsDir, + toolSupportsSkills, + type SkillCapableTool, } from '../shared/index.js'; import type { WorkspaceSkillState } from './foundation.js'; @@ -65,7 +69,7 @@ interface WorkspaceSkillProfileContext { deliveryNotice: string | null; } -type WorkspaceSkillCapableTool = AIToolOption & { skillsDir: string }; +type WorkspaceSkillCapableTool = SkillCapableTool; function resolveWorkspaceSkillProfileContext(): WorkspaceSkillProfileContext { const globalConfig = getGlobalConfig(); @@ -153,7 +157,7 @@ function makeBaseWorkspaceSkillReport( } export function getWorkspaceSkillCapableTools(): WorkspaceSkillCapableTool[] { - return AI_TOOLS.filter((tool) => Boolean(tool.skillsDir)) as WorkspaceSkillCapableTool[]; + return AI_TOOLS.filter(toolSupportsSkills); } export function getWorkspaceSkillToolIds(): string[] { @@ -241,7 +245,7 @@ function getWorkspaceSkillDirectoryForTool( workspaceRoot: string, tool: WorkspaceSkillCapableTool ): string { - return FileSystemUtils.joinPath(workspaceRoot, tool.skillsDir, 'skills'); + return resolveToolSkillsDir(workspaceRoot, tool); } export function getWorkspaceSkillDirectory(workspaceRoot: string, toolId: string): string { @@ -274,7 +278,15 @@ async function pathExists(targetPath: string): Promise { } } -function isOpenSpecManagedSkillDir(skillDir: string): boolean { +function isOpenSpecManagedSkillDir( + tool: WorkspaceSkillCapableTool, + dirName: string, + skillDir: string +): boolean { + if (hasGlobalSkillTarget(tool) && dirName.startsWith('openspec-')) { + return true; + } + const skillFile = FileSystemUtils.joinPath(skillDir, 'SKILL.md'); return extractGeneratedByVersion(skillFile) !== null; } @@ -299,7 +311,7 @@ async function removeManagedWorkflowSkillDirs( continue; } - if (!isOpenSpecManagedSkillDir(skillDir)) { + if (!isOpenSpecManagedSkillDir(tool, dirName, skillDir)) { continue; } diff --git a/test/commands/config-profile.test.ts b/test/commands/config-profile.test.ts index 3cd40b3ac..ef50f7f9f 100644 --- a/test/commands/config-profile.test.ts +++ b/test/commands/config-profile.test.ts @@ -186,6 +186,9 @@ describe('config profile interactive flow', () => { originalExitCode = process.exitCode; process.env.XDG_CONFIG_HOME = tempDir; + const fakeHome = path.join(tempDir, 'home'); + process.env.HOME = fakeHome; + process.env.USERPROFILE = fakeHome; process.chdir(tempDir); (process.stdout as NodeJS.WriteStream & { isTTY?: boolean }).isTTY = true; process.exitCode = undefined; diff --git a/test/commands/context-store.test.ts b/test/commands/context-store.test.ts index 6f7ae926e..8be981835 100644 --- a/test/commands/context-store.test.ts +++ b/test/commands/context-store.test.ts @@ -2,7 +2,6 @@ import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest'; import { Command } from 'commander'; import { execFileSync } from 'node:child_process'; import * as fs from 'node:fs'; -import * as os from 'node:os'; import * as path from 'node:path'; import { @@ -14,6 +13,7 @@ import { writeContextStoreMetadataState, writeContextStoreRegistryState, } from '../../src/core/index.js'; +import { mkdtempOutsideGit } from '../helpers/temp-dir.js'; import { runCLI, type RunCLIResult } from '../helpers/run-cli.js'; vi.mock('@inquirer/prompts', () => ({ @@ -43,6 +43,7 @@ describe('context-store command', () => { let tempDir: string; let dataHome: string; let configHome: string; + let homeDir: string; let globalDataDir: string; let env: NodeJS.ProcessEnv; let originalEnv: NodeJS.ProcessEnv; @@ -55,12 +56,17 @@ describe('context-store command', () => { beforeEach(() => { vi.resetModules(); - tempDir = fs.mkdtempSync(path.join(os.tmpdir(), 'openspec-context-store-command-')); + tempDir = mkdtempOutsideGit('openspec-context-store-command-'); dataHome = path.join(tempDir, 'data'); configHome = path.join(tempDir, 'config'); + homeDir = path.join(tempDir, 'home'); env = { XDG_DATA_HOME: dataHome, XDG_CONFIG_HOME: configHome, + HOME: homeDir, + USERPROFILE: homeDir, + APPDATA: path.join(homeDir, 'AppData', 'Roaming'), + LOCALAPPDATA: path.join(homeDir, 'AppData', 'Local'), OPEN_SPEC_INTERACTIVE: '0', OPENSPEC_TELEMETRY: '0', }; @@ -147,9 +153,7 @@ describe('context-store command', () => { it('runs guided setup when no args are passed in an interactive terminal', async () => { process.env = { ...process.env, - XDG_DATA_HOME: dataHome, - XDG_CONFIG_HOME: configHome, - OPENSPEC_TELEMETRY: '0', + ...env, }; delete process.env.OPEN_SPEC_INTERACTIVE; delete process.env.CI; @@ -251,9 +255,7 @@ describe('context-store command', () => { it('requires confirmation before interactive setup uses a path inside an existing Git repo', async () => { process.env = { ...process.env, - XDG_DATA_HOME: dataHome, - XDG_CONFIG_HOME: configHome, - OPENSPEC_TELEMETRY: '0', + ...env, }; delete process.env.OPEN_SPEC_INTERACTIVE; delete process.env.CI; @@ -306,9 +308,7 @@ describe('context-store command', () => { it('does not prompt before setup validation fails', async () => { process.env = { ...process.env, - XDG_DATA_HOME: dataHome, - XDG_CONFIG_HOME: configHome, - OPENSPEC_TELEMETRY: '0', + ...env, }; delete process.env.OPEN_SPEC_INTERACTIVE; delete process.env.CI; @@ -662,9 +662,7 @@ describe('context-store command', () => { it('prompts for Git initialization in interactive setup', async () => { process.env = { ...process.env, - XDG_DATA_HOME: dataHome, - XDG_CONFIG_HOME: configHome, - OPENSPEC_TELEMETRY: '0', + ...env, }; delete process.env.OPEN_SPEC_INTERACTIVE; delete process.env.CI; diff --git a/test/commands/workspace.test.ts b/test/commands/workspace.test.ts index 2e085d1c8..eb38bd22e 100644 --- a/test/commands/workspace.test.ts +++ b/test/commands/workspace.test.ts @@ -31,15 +31,21 @@ describe('workspace command', () => { let tempDir: string; let dataHome: string; let configHome: string; + let homeDir: string; let env: NodeJS.ProcessEnv; beforeEach(() => { tempDir = fs.mkdtempSync(path.join(os.tmpdir(), 'openspec-workspace-command-')); dataHome = path.join(tempDir, 'data'); configHome = path.join(tempDir, 'config'); + homeDir = path.join(tempDir, 'home'); env = { XDG_DATA_HOME: dataHome, XDG_CONFIG_HOME: configHome, + HOME: homeDir, + USERPROFILE: homeDir, + APPDATA: path.join(homeDir, 'AppData', 'Roaming'), + LOCALAPPDATA: path.join(homeDir, 'AppData', 'Local'), OPEN_SPEC_INTERACTIVE: '0', OPENSPEC_TELEMETRY: '0', }; diff --git a/test/core/available-tools.test.ts b/test/core/available-tools.test.ts index 50d758070..ff78962c2 100644 --- a/test/core/available-tools.test.ts +++ b/test/core/available-tools.test.ts @@ -7,13 +7,19 @@ import { getAvailableTools } from '../../src/core/available-tools.js'; describe('available-tools', () => { let testDir: string; + let originalEnv: NodeJS.ProcessEnv; beforeEach(async () => { testDir = path.join(os.tmpdir(), `openspec-test-${randomUUID()}`); await fs.mkdir(testDir, { recursive: true }); + originalEnv = { ...process.env }; + const fakeHome = path.join(testDir, 'home'); + process.env.HOME = fakeHome; + process.env.USERPROFILE = fakeHome; }); afterEach(async () => { + process.env = originalEnv; await fs.rm(testDir, { recursive: true, force: true }); }); @@ -163,5 +169,32 @@ describe('available-tools', () => { expect(vibeTool?.name).toBe('Mistral Vibe'); expect(vibeTool?.skillsDir).toBe('.vibe'); }); + + it('should detect MiniMax Code when OpenSpec skills exist in the global user-home target', async () => { + const skillFile = path.join(testDir, 'home', '.minimax', 'skills', 'openspec-explore', 'SKILL.md'); + await fs.mkdir(path.dirname(skillFile), { recursive: true }); + await fs.writeFile(skillFile, 'content'); + + const tools = getAvailableTools(testDir); + const minimax = tools.find((tool) => tool.value === 'minimax-code'); + + expect(minimax).toMatchObject({ + name: 'MiniMax Code', + value: 'minimax-code', + globalSkillsDir: '.minimax', + }); + }); + + it('should not detect MiniMax Code from repo-local .minimax or .mavis directories', async () => { + await fs.mkdir(path.join(testDir, '.minimax', 'skills', 'openspec-explore'), { recursive: true }); + await fs.writeFile( + path.join(testDir, '.minimax', 'skills', 'openspec-explore', 'SKILL.md'), + 'content' + ); + await fs.mkdir(path.join(testDir, '.mavis', 'skills', 'openspec-explore'), { recursive: true }); + + const tools = getAvailableTools(testDir); + expect(tools.map((tool) => tool.value)).not.toContain('minimax-code'); + }); }); }); diff --git a/test/core/context-store/registry.test.ts b/test/core/context-store/registry.test.ts index 533fcb453..5f6ff1a98 100644 --- a/test/core/context-store/registry.test.ts +++ b/test/core/context-store/registry.test.ts @@ -1,6 +1,5 @@ import { describe, it, expect, beforeEach, afterEach, vi } from 'vitest'; import * as fs from 'node:fs'; -import * as os from 'node:os'; import * as path from 'node:path'; import { @@ -23,12 +22,13 @@ import { writeContextStoreMetadataState, writeContextStoreRegistryState, } from '../../../src/core/index.js'; +import { mkdtempOutsideGit } from '../../helpers/temp-dir.js'; describe('context store registry facade', () => { let tempDir: string; beforeEach(() => { - tempDir = fs.mkdtempSync(path.join(os.tmpdir(), 'openspec-context-store-registry-')); + tempDir = mkdtempOutsideGit('openspec-context-store-registry-'); }); afterEach(() => { diff --git a/test/core/init.test.ts b/test/core/init.test.ts index 6a436eaed..04feadcf3 100644 --- a/test/core/init.test.ts +++ b/test/core/init.test.ts @@ -36,6 +36,9 @@ describe('InitCommand', () => { configTempDir = path.join(os.tmpdir(), `openspec-config-init-${Date.now()}`); await fs.mkdir(configTempDir, { recursive: true }); process.env.XDG_CONFIG_HOME = configTempDir; + const fakeHome = path.join(testDir, 'home'); + process.env.HOME = fakeHome; + process.env.USERPROFILE = fakeHome; // Mock console.log to suppress output during tests vi.spyOn(console, 'log').mockImplementation(() => { }); @@ -148,6 +151,10 @@ describe('InitCommand', () => { const cmdFile = path.join(testDir, '.claude', 'commands', cmdName); expect(await fileExists(cmdFile)).toBe(false); } + + const commandDir = path.join(testDir, '.claude', 'commands', 'opsx'); + const logCalls = (console.log as unknown as { mock: { calls: unknown[][] } }).mock.calls.flat().map(String); + expect(logCalls.some((entry) => entry.includes('commands in') && entry.includes(commandDir))).toBe(true); }); it('should create skills in Cursor skills directory', async () => { @@ -192,6 +199,52 @@ describe('InitCommand', () => { ).toBe(true); }); + it('should install MiniMax Code skills into the user-home global target', async () => { + saveGlobalConfig({ + featureFlags: {}, + profile: 'core', + delivery: 'both', + }); + + const initCommand = new InitCommand({ tools: 'minimax-code', force: true }); + await initCommand.execute(testDir); + + const skillFile = path.join(testDir, 'home', '.minimax', 'skills', 'openspec-explore', 'SKILL.md'); + expect(await fileExists(skillFile)).toBe(true); + expect(await directoryExists(path.join(testDir, '.minimax'))).toBe(false); + expect(await directoryExists(path.join(testDir, '.mavis'))).toBe(false); + + const minimaxCommandsDir = path.join(testDir, '.minimax', 'commands'); + expect(await directoryExists(minimaxCommandsDir)).toBe(false); + + const logCalls = (console.log as unknown as { mock: { calls: unknown[][] } }).mock.calls.flat().map(String); + expect( + logCalls.some( + (entry) => entry.includes('Commands skipped for: minimax-code') && entry.includes('(no adapter)'), + ), + ).toBe(true); + expect(logCalls.some((entry) => entry.includes('commands in') && entry.includes('.minimax'))).toBe(false); + }); + + it('should leave MiniMax Code global skills intact for commands-only delivery', async () => { + saveGlobalConfig({ + featureFlags: {}, + profile: 'core', + delivery: 'commands', + }); + + const skillFile = path.join(testDir, 'home', '.minimax', 'skills', 'openspec-explore', 'SKILL.md'); + await fs.mkdir(path.dirname(skillFile), { recursive: true }); + await fs.writeFile(skillFile, 'user-home skill'); + + const initCommand = new InitCommand({ tools: 'minimax-code', force: true }); + await initCommand.execute(testDir); + + expect(await fs.readFile(skillFile, 'utf-8')).toBe('user-home skill'); + expect(await directoryExists(path.join(testDir, '.minimax'))).toBe(false); + expect(await directoryExists(path.join(testDir, '.mavis'))).toBe(false); + }); + it('should create skills for multiple tools at once', async () => { const initCommand = new InitCommand({ tools: 'claude,cursor', force: true }); @@ -500,6 +553,9 @@ describe('InitCommand - profile and detection features', () => { configTempDir = path.join(os.tmpdir(), `openspec-config-test-${Date.now()}`); await fs.mkdir(configTempDir, { recursive: true }); process.env.XDG_CONFIG_HOME = configTempDir; + const fakeHome = path.join(testDir, 'home'); + process.env.HOME = fakeHome; + process.env.USERPROFILE = fakeHome; vi.spyOn(console, 'log').mockImplementation(() => {}); confirmMock.mockReset(); confirmMock.mockResolvedValue(true); diff --git a/test/core/profile-sync-drift.test.ts b/test/core/profile-sync-drift.test.ts index 116a6e570..044a85555 100644 --- a/test/core/profile-sync-drift.test.ts +++ b/test/core/profile-sync-drift.test.ts @@ -39,13 +39,19 @@ function setupCoreCommands(projectDir: string): void { describe('profile sync drift detection', () => { let tempDir: string; + let originalEnv: NodeJS.ProcessEnv; beforeEach(() => { tempDir = path.join(os.tmpdir(), `openspec-profile-sync-drift-test-${Date.now()}-${Math.random().toString(36).slice(2)}`); fs.mkdirSync(path.join(tempDir, 'openspec'), { recursive: true }); + originalEnv = { ...process.env }; + const fakeHome = path.join(tempDir, 'home'); + process.env.HOME = fakeHome; + process.env.USERPROFILE = fakeHome; }); afterEach(() => { + process.env = originalEnv; fs.rmSync(tempDir, { recursive: true, force: true }); }); diff --git a/test/core/shared/skill-paths.test.ts b/test/core/shared/skill-paths.test.ts new file mode 100644 index 000000000..3c09737a2 --- /dev/null +++ b/test/core/shared/skill-paths.test.ts @@ -0,0 +1,49 @@ +import { describe, expect, it } from 'vitest'; + +import { AI_TOOLS } from '../../../src/core/config.js'; +import { + getSkillCapableToolIds, + resolveToolSkillsDir, + toolSupportsSkills, +} from '../../../src/core/shared/skill-paths.js'; +import { FileSystemUtils } from '../../../src/utils/file-system.js'; + +describe('skill-paths', () => { + it('treats tools with project-local or global skill targets as skill-capable', () => { + const toolIds = getSkillCapableToolIds(); + expect(toolIds).toContain('claude'); + expect(toolIds).toContain('minimax-code'); + expect(toolIds).not.toContain('agents'); + }); + + it('resolves project-local tools under the project root with the Agent Skills suffix', () => { + const claude = AI_TOOLS.find((tool) => tool.value === 'claude'); + expect(claude && toolSupportsSkills(claude)).toBe(true); + if (!claude || !toolSupportsSkills(claude)) return; + + expect(resolveToolSkillsDir('/repo/app', claude)).toBe( + FileSystemUtils.joinPath('/repo/app', '.claude', 'skills') + ); + }); + + it('resolves MiniMax Code to a user-home global skills target', () => { + const minimax = AI_TOOLS.find((tool) => tool.value === 'minimax-code'); + expect(minimax && toolSupportsSkills(minimax)).toBe(true); + if (!minimax || !toolSupportsSkills(minimax)) return; + + expect(resolveToolSkillsDir('/repo/app', minimax, { homeDir: '/home/alex' })).toBe( + FileSystemUtils.joinPath('/home/alex', '.minimax', 'skills') + ); + }); + + it('resolves MiniMax Code with Windows-style user homes', () => { + const minimax = AI_TOOLS.find((tool) => tool.value === 'minimax-code'); + expect(minimax && toolSupportsSkills(minimax)).toBe(true); + if (!minimax || !toolSupportsSkills(minimax)) return; + + expect(resolveToolSkillsDir('D:\\repos\\app', minimax, { homeDir: 'C:\\Users\\Alex' })).toBe( + 'C:\\Users\\Alex\\.minimax\\skills' + ); + }); +}); + diff --git a/test/core/shared/tool-detection.test.ts b/test/core/shared/tool-detection.test.ts index 5a66ff3cd..25e6d3450 100644 --- a/test/core/shared/tool-detection.test.ts +++ b/test/core/shared/tool-detection.test.ts @@ -16,13 +16,19 @@ import { describe('tool-detection', () => { let testDir: string; + let originalEnv: NodeJS.ProcessEnv; beforeEach(async () => { testDir = path.join(os.tmpdir(), `openspec-test-${randomUUID()}`); await fs.mkdir(testDir, { recursive: true }); + originalEnv = { ...process.env }; + const fakeHome = path.join(testDir, 'home'); + process.env.HOME = fakeHome; + process.env.USERPROFILE = fakeHome; }); afterEach(async () => { + process.env = originalEnv; await fs.rm(testDir, { recursive: true, force: true }); }); @@ -49,6 +55,7 @@ describe('tool-detection', () => { expect(tools).toContain('claude'); expect(tools).toContain('cursor'); expect(tools).toContain('windsurf'); + expect(tools).toContain('minimax-code'); expect(tools.length).toBeGreaterThan(0); }); }); @@ -91,6 +98,28 @@ describe('tool-detection', () => { expect(status.fullyConfigured).toBe(true); expect(status.skillCount).toBe(SKILL_NAMES.length); }); + + it('should detect MiniMax Code skills from the user-home global target', async () => { + const skillDir = path.join(testDir, 'home', '.minimax', 'skills', 'openspec-explore'); + await fs.mkdir(skillDir, { recursive: true }); + await fs.writeFile(path.join(skillDir, 'SKILL.md'), 'test content'); + + const status = getToolSkillStatus(testDir, 'minimax-code'); + expect(status.configured).toBe(true); + expect(status.fullyConfigured).toBe(false); + expect(status.skillCount).toBe(1); + }); + + it('should not detect MiniMax Code from repo-local .minimax or .mavis directories', async () => { + const repoLocalSkillDir = path.join(testDir, '.minimax', 'skills', 'openspec-explore'); + await fs.mkdir(repoLocalSkillDir, { recursive: true }); + await fs.writeFile(path.join(repoLocalSkillDir, 'SKILL.md'), 'test content'); + await fs.mkdir(path.join(testDir, '.mavis', 'skills', 'openspec-explore'), { recursive: true }); + + const status = getToolSkillStatus(testDir, 'minimax-code'); + expect(status.configured).toBe(false); + expect(getConfiguredTools(testDir)).not.toContain('minimax-code'); + }); }); describe('getToolStates', () => { @@ -267,6 +296,21 @@ Content here expect(status.toolId).toBe('claude'); expect(status.toolName).toBe('Claude Code'); }); + + it('should read MiniMax Code generatedBy metadata from the global skill target', async () => { + const skillDir = path.join(testDir, 'home', '.minimax', 'skills', 'openspec-explore'); + await fs.mkdir(skillDir, { recursive: true }); + await fs.writeFile(path.join(skillDir, 'SKILL.md'), `--- +metadata: + generatedBy: "0.22.0" +--- +`); + + const status = getToolVersionStatus(testDir, 'minimax-code', '0.23.0'); + expect(status.configured).toBe(true); + expect(status.generatedByVersion).toBe('0.22.0'); + expect(status.needsUpdate).toBe(true); + }); }); describe('getConfiguredTools', () => { diff --git a/test/core/update.test.ts b/test/core/update.test.ts index ea7f66a7e..b0a16df4a 100644 --- a/test/core/update.test.ts +++ b/test/core/update.test.ts @@ -41,11 +41,16 @@ function resetMockConfig() { describe('UpdateCommand', () => { let testDir: string; let updateCommand: UpdateCommand; + let originalEnv: NodeJS.ProcessEnv; beforeEach(async () => { // Create a temporary test directory testDir = path.join(os.tmpdir(), `openspec-test-${randomUUID()}`); await fs.mkdir(testDir, { recursive: true }); + originalEnv = { ...process.env }; + const fakeHome = path.join(testDir, 'home'); + process.env.HOME = fakeHome; + process.env.USERPROFILE = fakeHome; // Create openspec directory const openspecDir = path.join(testDir, 'openspec'); @@ -63,6 +68,7 @@ describe('UpdateCommand', () => { afterEach(async () => { // Restore all mocks after each test vi.restoreAllMocks(); + process.env = originalEnv; // Clean up test directory await fs.rm(testDir, { recursive: true, force: true }); @@ -190,6 +196,26 @@ Old instructions content expect(exists).toBe(false); } }); + + it('should update MiniMax Code skills from the user-home global target', async () => { + const skillsDir = path.join(testDir, 'home', '.minimax', 'skills'); + const exploreSkill = path.join(skillsDir, 'openspec-explore', 'SKILL.md'); + await fs.mkdir(path.dirname(exploreSkill), { recursive: true }); + await fs.writeFile(exploreSkill, 'old content'); + await fs.mkdir(path.join(skillsDir, 'my-custom-skill'), { recursive: true }); + await fs.writeFile(path.join(skillsDir, 'my-custom-skill', 'SKILL.md'), 'custom content'); + + await updateCommand.execute(testDir); + + const updatedSkill = await fs.readFile(exploreSkill, 'utf-8'); + expect(updatedSkill).toContain('name: openspec-explore'); + expect(updatedSkill).not.toContain('old content'); + expect(await FileSystemUtils.fileExists( + path.join(skillsDir, 'my-custom-skill', 'SKILL.md') + )).toBe(true); + expect(await FileSystemUtils.fileExists(path.join(testDir, '.minimax'))).toBe(false); + expect(await FileSystemUtils.fileExists(path.join(testDir, '.mavis'))).toBe(false); + }); }); describe('command updates', () => { @@ -1538,6 +1564,24 @@ More user content after markers. )).toBe(false); }); + it('should preserve MiniMax Code global skills in commands-only delivery', async () => { + setMockConfig({ + featureFlags: {}, + profile: 'core', + delivery: 'commands', + }); + + const skillFile = path.join(testDir, 'home', '.minimax', 'skills', 'openspec-explore', 'SKILL.md'); + await fs.mkdir(path.dirname(skillFile), { recursive: true }); + await fs.writeFile(skillFile, 'old global minimax skill'); + + await updateCommand.execute(testDir); + + expect(await fs.readFile(skillFile, 'utf-8')).toBe('old global minimax skill'); + expect(await FileSystemUtils.fileExists(path.join(testDir, '.minimax'))).toBe(false); + expect(await FileSystemUtils.fileExists(path.join(testDir, '.mavis'))).toBe(false); + }); + it('should apply config sync when templates are up to date', async () => { setMockConfig({ featureFlags: {}, diff --git a/test/core/workspace/skills.test.ts b/test/core/workspace/skills.test.ts index c776ff851..9f72e39bd 100644 --- a/test/core/workspace/skills.test.ts +++ b/test/core/workspace/skills.test.ts @@ -5,11 +5,14 @@ import * as path from 'node:path'; import { describe, expect, it } from 'vitest'; import { + generateWorkspaceAgentSkills, getWorkspaceSkillDirectory, getWorkspaceSkillToolIds, hasWorkspaceSkillProfileDrift, parseWorkspaceSkillToolsValue, + updateWorkspaceAgentSkills, } from '../../../src/core/workspace/skills.js'; +import { saveGlobalConfig } from '../../../src/core/global-config.js'; import { CORE_WORKFLOWS } from '../../../src/core/profiles.js'; function withDefaultGlobalConfig(callback: () => T): T { @@ -35,6 +38,7 @@ describe('workspace skill helpers', () => { expect(parseWorkspaceSkillToolsValue('all')).toEqual(getWorkspaceSkillToolIds()); expect(parseWorkspaceSkillToolsValue('none')).toEqual([]); expect(parseWorkspaceSkillToolsValue('Codex, claude,codex')).toEqual(['codex', 'claude']); + expect(parseWorkspaceSkillToolsValue('minimax-code')).toEqual(['minimax-code']); }); it('rejects invalid or mixed workspace --tools values', () => { @@ -52,6 +56,31 @@ describe('workspace skill helpers', () => { ); }); + it('builds MiniMax Code workspace skill paths from the user home', () => { + const previousHome = process.env.HOME; + const previousUserProfile = process.env.USERPROFILE; + const fakeHome = path.join(os.tmpdir(), 'openspec-home-alex'); + process.env.HOME = fakeHome; + process.env.USERPROFILE = fakeHome; + + try { + expect(getWorkspaceSkillDirectory('/repos/platform-workspace', 'minimax-code')).toBe( + path.join(fakeHome, '.minimax', 'skills') + ); + } finally { + if (previousHome === undefined) { + delete process.env.HOME; + } else { + process.env.HOME = previousHome; + } + if (previousUserProfile === undefined) { + delete process.env.USERPROFILE; + } else { + process.env.USERPROFILE = previousUserProfile; + } + } + }); + it('does not report profile drift when workflow IDs match in a different order', () => { withDefaultGlobalConfig(() => { expect( @@ -66,4 +95,83 @@ describe('workspace skill helpers', () => { ).toBe(false); }); }); + + it('generates MiniMax Code workspace skills in the user-home global target', async () => { + const workspaceRoot = fs.mkdtempSync(path.join(os.tmpdir(), 'openspec-workspace-root-')); + const homeRoot = fs.mkdtempSync(path.join(os.tmpdir(), 'openspec-minimax-home-')); + const configHome = fs.mkdtempSync(path.join(os.tmpdir(), 'openspec-workspace-config-')); + const previousEnv = { ...process.env }; + + process.env.HOME = homeRoot; + process.env.USERPROFILE = homeRoot; + process.env.XDG_CONFIG_HOME = configHome; + + try { + saveGlobalConfig({ featureFlags: {}, profile: 'core', delivery: 'commands' }); + + const report = await generateWorkspaceAgentSkills(workspaceRoot, ['minimax-code']); + const skillFile = path.join(homeRoot, '.minimax', 'skills', 'openspec-explore', 'SKILL.md'); + + expect(report.generated).toHaveLength(1); + expect(report.generated[0].skills_path).toBe(path.join(homeRoot, '.minimax', 'skills')); + expect(fs.existsSync(skillFile)).toBe(true); + expect(fs.existsSync(path.join(workspaceRoot, '.minimax'))).toBe(false); + expect(fs.existsSync(path.join(workspaceRoot, '.mavis'))).toBe(false); + } finally { + process.env = previousEnv; + fs.rmSync(workspaceRoot, { recursive: true, force: true }); + fs.rmSync(homeRoot, { recursive: true, force: true }); + fs.rmSync(configHome, { recursive: true, force: true }); + } + }); + + it('removes MiniMax Code openspec workflow skill directories by name during workspace update', async () => { + const workspaceRoot = fs.mkdtempSync(path.join(os.tmpdir(), 'openspec-workspace-root-')); + const homeRoot = fs.mkdtempSync(path.join(os.tmpdir(), 'openspec-minimax-home-')); + const configHome = fs.mkdtempSync(path.join(os.tmpdir(), 'openspec-workspace-config-')); + const previousEnv = { ...process.env }; + + process.env.HOME = homeRoot; + process.env.USERPROFILE = homeRoot; + process.env.XDG_CONFIG_HOME = configHome; + + try { + saveGlobalConfig({ + featureFlags: {}, + profile: 'custom', + delivery: 'both', + workflows: ['explore'], + }); + + const skillsDir = path.join(homeRoot, '.minimax', 'skills'); + const extraSkill = path.join(skillsDir, 'openspec-new-change', 'SKILL.md'); + const customSkill = path.join(skillsDir, 'my-custom-skill', 'SKILL.md'); + await fs.promises.mkdir(path.dirname(extraSkill), { recursive: true }); + await fs.promises.writeFile(extraSkill, 'user edited without generated metadata'); + await fs.promises.mkdir(path.dirname(customSkill), { recursive: true }); + await fs.promises.writeFile(customSkill, 'custom content'); + + const report = await updateWorkspaceAgentSkills( + workspaceRoot, + ['minimax-code'], + { + selected_agents: ['minimax-code'], + last_applied_profile: 'custom', + last_applied_delivery: 'both', + last_applied_workflow_ids: ['explore', 'new'], + } + ); + + expect(report.refreshed).toHaveLength(1); + expect(fs.existsSync(extraSkill)).toBe(false); + expect(fs.existsSync(customSkill)).toBe(true); + expect(fs.existsSync(path.join(workspaceRoot, '.minimax'))).toBe(false); + expect(fs.existsSync(path.join(workspaceRoot, '.mavis'))).toBe(false); + } finally { + process.env = previousEnv; + fs.rmSync(workspaceRoot, { recursive: true, force: true }); + fs.rmSync(homeRoot, { recursive: true, force: true }); + fs.rmSync(configHome, { recursive: true, force: true }); + } + }); }); diff --git a/test/helpers/temp-dir.ts b/test/helpers/temp-dir.ts new file mode 100644 index 000000000..deddbf8ed --- /dev/null +++ b/test/helpers/temp-dir.ts @@ -0,0 +1,36 @@ +import { execFileSync } from 'node:child_process'; +import * as fs from 'node:fs'; +import * as os from 'node:os'; +import * as path from 'node:path'; + +function isInsideGitRepository(dirPath: string): boolean { + try { + execFileSync('git', ['-C', dirPath, 'rev-parse', '--show-toplevel'], { + stdio: ['ignore', 'ignore', 'ignore'], + }); + return true; + } catch { + return false; + } +} + +export function mkdtempOutsideGit(prefix: string): string { + const defaultTmp = os.tmpdir(); + const rootTmp = path.join(path.parse(defaultTmp).root, 'openspec-test-temp'); + const candidates = Array.from(new Set([defaultTmp, rootTmp])); + + for (const candidate of candidates) { + try { + fs.mkdirSync(candidate, { recursive: true }); + const tempDir = fs.mkdtempSync(path.join(candidate, prefix)); + if (!isInsideGitRepository(tempDir)) { + return tempDir; + } + fs.rmSync(tempDir, { recursive: true, force: true }); + } catch { + // Try the next candidate. + } + } + + return fs.mkdtempSync(path.join(defaultTmp, prefix)); +}