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

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
5 changes: 5 additions & 0 deletions .changeset/antigravity-agents-root.md
Original file line number Diff line number Diff line change
@@ -0,0 +1,5 @@
---
"@fission-ai/openspec": patch
---

Write Antigravity skills and workflows to `.agents/`, arbitrate its shared skill tree with other tools, and safely migrate an existing `.agent/` install.
26 changes: 20 additions & 6 deletions docs-lab/reference/supported-tools.md
Original file line number Diff line number Diff line change
Expand Up @@ -15,7 +15,7 @@ The id goes to `openspec init --tools <id>` to skip the picker ([CLI](cli.md)).
| Tool | `--tools` id | Skills | Skill invocation | Commands | Command invocation |
|---|---|---|---|---|---|
| Amazon Q Developer | `amazon-q` | `.amazonq/skills/` | `/openspec-apply-change` | `.amazonq/prompts/` | `@opsx-apply` |
| Antigravity | `antigravity` | `.agent/skills/` | `/openspec-apply-change` | `.agent/workflows/` | `/opsx-apply` |
| Antigravity | `antigravity` | `.agents/skills/` | `/openspec-apply-change` | `.agents/workflows/` | `/opsx-apply` |
| Auggie (Augment CLI) | `auggie` | `.augment/skills/` | `/openspec-apply-change` | `.augment/commands/` | `/opsx-apply` |
| Bob Shell | `bob` | `.bob/skills/` | `/openspec-apply-change` | `.bob/commands/` | `/opsx-apply` |
| Claude Code | `claude` | `.claude/skills/` | `/openspec-apply-change` | `.claude/commands/opsx/` | `/opsx:apply` |
Expand Down Expand Up @@ -62,6 +62,17 @@ The id goes to `openspec init --tools <id>` to skip the picker ([CLI](cli.md)).

A tool not listed here behaves exactly as its row reads.

### Antigravity

- **Current folder**: Antigravity v1.20.5 and later read workspace skills and
workflows from `.agents/`.
- **Legacy folder**: after OpenSpec writes replacements, it removes equivalent
generated files from `.agent/`. Custom files and changed generated files stay in
`.agent/` for you to review.
- **Shared skills**: Antigravity shares `.agents/skills/` with Codex, Zed Agent, and
the `agents` target. OpenSpec writes that skill tree once while still writing
Antigravity commands to `.agents/workflows/`.

### Cline

Cline reads commands from `.clinerules/workflows/`, not from its `.cline/` folder.
Expand All @@ -73,9 +84,10 @@ Skills stay in `.cline/skills/`.
`/openspec-<skill>` form ([upstream issue](https://github.com/openai/codex/issues/11817)).
- **No command files**: Codex runs skills directly, so init skips commands even when
delivery includes them and prints `Commands skipped for: codex (uses skills)`.
- **Shared folder**: Codex skills land in `.agents/skills/`, the same tree the shared
`agents` target uses. Selecting both keeps one tree, and its handoffs spell both
`$openspec-*` and `/openspec-*`.
- **Shared folder**: Codex skills land in `.agents/skills/`, the same tree Antigravity,
Zed Agent, and the `agents` target use. Selecting more than one keeps a single
compatible tree, and its handoffs spell both `$openspec-*` and `/openspec-*` when
Codex owns it.
- **Legacy path**: skills installed under `.codex/skills/` by older versions are
migrated on the next `openspec update`.

Expand Down Expand Up @@ -110,8 +122,10 @@ init prints this reminder after install.

- **When it fits**: any tool that reads the shared `.agents/skills/` folder,
including tools with no row in the matrix.
- **Alongside other targets**: fine, since each target writes its own folder. Codex
shares this one; see the [Codex note](#codex).
- **Alongside other targets**: Antigravity, Codex, Zed Agent, and this target share
one physical skill tree. OpenSpec records one writer in `.openspec-target` and
writes the tree once per run. Each tool's separate command files are still
generated.
- **What OpenSpec claims**: only the `openspec-*` folders and the
`.openspec-target` marker. Anything else under `.agents/` is left alone.
- **`AGENTS.md`**: not created or edited. The target is the `.agents/` folder, not
Expand Down
17 changes: 16 additions & 1 deletion src/core/available-tools.ts
Original file line number Diff line number Diff line change
Expand Up @@ -58,7 +58,22 @@ export function getAvailableTools(projectPath: string): AIToolOption[] {
available.filter((tool) => tool.skillsDir)
).map((tool) => tool.value)
);
const hasIndependentDetectionPath = (tool: AIToolOption): boolean =>
(tool.detectionPaths ?? []).some((detectionPath) => {
// Skill roots still go through managed-content reconciliation below;
// their mere existence is not an independent tool signal.
if (detectionPath.endsWith('/skills')) return false;
try {
fs.statSync(path.join(projectPath, detectionPath));
return true;
} catch {
return false;
}
});
return available.filter(
(tool) => tool.globalSkillsDir || activeProjectTools.has(tool.value)
(tool) =>
tool.globalSkillsDir ||
hasIndependentDetectionPath(tool) ||
activeProjectTools.has(tool.value)
);
}
4 changes: 2 additions & 2 deletions src/core/command-generation/adapters/antigravity.ts
Original file line number Diff line number Diff line change
Expand Up @@ -10,14 +10,14 @@ import { escapeYamlValue } from '../yaml.js';

/**
* Antigravity adapter for command generation.
* File path: .agent/workflows/opsx-<id>.md
* File path: .agents/workflows/opsx-<id>.md
* Frontmatter: description
*/
export const antigravityAdapter: ToolCommandAdapter = {
toolId: 'antigravity',

getFilePath(commandId: string): string {
return path.join('.agent', 'workflows', `opsx-${commandId}.md`);
return path.join('.agents', 'workflows', `opsx-${commandId}.md`);
},

formatFile(content: CommandContent): string {
Expand Down
7 changes: 6 additions & 1 deletion src/core/config.ts
Original file line number Diff line number Diff line change
Expand Up @@ -39,7 +39,12 @@ export interface AIToolOption {

export const AI_TOOLS: AIToolOption[] = [
{ name: 'Amazon Q Developer', value: 'amazon-q', available: true, successLabel: 'Amazon Q Developer', skillsDir: '.amazonq', requiresIdeRestart: true },
{ name: 'Antigravity', value: 'antigravity', available: true, successLabel: 'Antigravity', skillsDir: '.agent', requiresIdeRestart: true },
// Antigravity moved workspace skills and workflows from `.agent` to the
// shared `.agents` root in v1.20.5. Detection keys off `.agent` and
// `.agents/workflows` rather than the bare `.agents` root: that root is
// shared with Codex, Zed, and the vendor-neutral target, so its presence
// alone says nothing about Antigravity.
{ name: 'Antigravity', value: 'antigravity', available: true, successLabel: 'Antigravity', skillsDir: '.agents', legacySkillsDirs: ['.agent'], detectionPaths: ['.agent', '.agents/workflows'], requiresIdeRestart: true },
{ name: 'Auggie (Augment CLI)', value: 'auggie', available: true, successLabel: 'Auggie', skillsDir: '.augment' },
{ name: 'Bob Shell', value: 'bob', available: true, successLabel: 'Bob Shell', skillsDir: '.bob' },
{ name: 'Claude Code', value: 'claude', available: true, successLabel: 'Claude Code', skillsDir: '.claude' },
Expand Down
96 changes: 58 additions & 38 deletions src/core/init.ts
Original file line number Diff line number Diff line change
Expand Up @@ -59,7 +59,11 @@ import {
import { getGlobalConfig, type Delivery, type Profile } from './global-config.js';
import { getProfileWorkflows, CORE_WORKFLOWS, ALL_WORKFLOWS } from './profiles.js';
import { getAvailableTools } from './available-tools.js';
import { writeSharedSkillTarget } from './shared-skill-target.js';
import {
resolveSharedSkillWriters,
sharedSkillRootOwner,
writeSharedSkillTarget,
} from './shared-skill-target.js';
import { migrateIfNeeded, migrateLegacyToolDirs, describeLegacyMigration, keptInPlaceNotice, hasMovableContent, scanInstalledWorkflows as scanInstalledWorkflowsShared } from './migration.js';
import {
resolveCommandSurfaceCapability,
Expand Down Expand Up @@ -145,6 +149,7 @@ type ValidatedInitTool = {
isGlobalSkillTarget: boolean;
wasConfigured: boolean;
requiresIdeRestart?: boolean;
writesSkills: boolean;
};

/**
Expand Down Expand Up @@ -767,41 +772,8 @@ export class InitCommand {
toolStates: Map<string, ToolSkillStatus>,
projectPath: string
): ValidatedInitTool[] {
const validatedTools: ValidatedInitTool[] = [];

const sharedAgentsTargets = ['codex', 'zed', 'agents'];
const selectedSharedTargets = sharedAgentsTargets.filter((toolId) => toolIds.includes(toolId));
// A Codex-rendered tree already serves Zed. Keep it when Zed is added later
// so Codex users do not lose the `$openspec-*` references they require.
const preserveConfiguredCodex = selectedSharedTargets.includes('zed') &&
toolStates.get('codex')?.configured;
const sharedTargetCandidates = preserveConfiguredCodex
? [...new Set([...selectedSharedTargets, 'codex'])]
: selectedSharedTargets;
const sharedTargetOwner = sharedTargetCandidates.includes('codex')
? 'codex'
: selectedSharedTargets.includes('zed')
? 'zed'
: selectedSharedTargets[0];
const firstSharedIndex = toolIds.findIndex((id) => sharedAgentsTargets.includes(id));
const reconciledToolIds = sharedTargetCandidates.length > 1
? toolIds.flatMap((toolId, index) => {
if (!sharedAgentsTargets.includes(toolId)) return [toolId];
return index === firstSharedIndex && sharedTargetOwner ? [sharedTargetOwner] : [];
})
: toolIds;
if (
reconciledToolIds.length !== toolIds.length ||
reconciledToolIds.some((toolId, index) => toolId !== toolIds[index])
) {
console.log(
chalk.dim(
`Codex, Zed, and agents share .agents/skills; writing one tree for ${sharedTargetOwner}.`
)
);
}

for (const toolId of reconciledToolIds) {
const selectedTools: AIToolOption[] = [];
for (const toolId of toolIds) {
const tool = AI_TOOLS.find((t) => t.value === toolId);
if (!tool) {
const validToolIds = getToolsWithSkillsDir();
Expand All @@ -817,6 +789,49 @@ export class InitCommand {
);
}

selectedTools.push(tool);
}

// A selected tool may share its physical skills root with an already
// configured owner. Include that owner in the refresh without dropping the
// selected tool: it may still have an independent command surface.
const generationTools = [...selectedTools];
const delivery: Delivery = getGlobalConfig().delivery ?? 'both';
for (const selected of selectedTools) {
if (!selected.skillsDir) continue;
const selectedOwner = selected.value === 'codex' ||
!shouldGenerateSkillsForTool(selected.value, delivery)
? undefined
: sharedSkillRootOwner(projectPath, selected.value);
for (const candidate of AI_TOOLS) {
if (
candidate.skillsDir === selected.skillsDir &&
toolStates.get(candidate.value)?.configured &&
candidate.value === selectedOwner &&
!generationTools.includes(candidate)
) {
generationTools.push(candidate);
}
}
}

const skillWriters = resolveSharedSkillWriters(projectPath, generationTools);
const sharedRoots = new Map<string, AIToolOption[]>();
for (const tool of generationTools) {
if (!tool.skillsDir) continue;
const group = sharedRoots.get(tool.skillsDir) ?? [];
group.push(tool);
sharedRoots.set(tool.skillsDir, group);
}
for (const [root, group] of sharedRoots) {
if (group.length < 2) continue;
const owner = group.find((tool) => skillWriters.has(tool.value));
console.log(chalk.dim(`${group.map((tool) => tool.name).join(', ')} share ${root}/skills; writing one tree for ${owner?.value}.`));
}

const validatedTools: ValidatedInitTool[] = [];
for (const tool of generationTools) {
if (!toolSupportsSkills(tool)) continue;
const preState = toolStates.get(tool.value);
const skillsPath = resolveToolSkillsDir(projectPath, tool);
const isGlobalSkillTarget = hasGlobalSkillTarget(tool);
Expand All @@ -829,6 +844,7 @@ export class InitCommand {
isGlobalSkillTarget,
wasConfigured: preState?.configured ?? false,
requiresIdeRestart: tool.requiresIdeRestart,
writesSkills: !tool.skillsDir || skillWriters.has(tool.value),
});
}

Expand Down Expand Up @@ -929,7 +945,7 @@ export class InitCommand {
const shouldGenerateCommands = shouldGenerateCommandsForTool(tool.value, delivery);

// Generate skill files if the selected delivery and tool capability allow skills
if (shouldGenerateSkills) {
if (shouldGenerateSkills && tool.writesSkills) {
// Create skill directories and SKILL.md files
for (const { template, dirName } of skillTemplates) {
const skillDir = path.join(tool.skillsPath, dirName);
Expand All @@ -950,7 +966,11 @@ export class InitCommand {
}
writeSharedSkillTarget(projectPath, tool.value);
}
if (shouldRemoveSkillsForTool(tool.value, delivery) && !tool.isGlobalSkillTarget) {
if (
shouldRemoveSkillsForTool(tool.value, delivery) &&
tool.writesSkills &&
!tool.isGlobalSkillTarget
) {
removedSkillCount += await this.removeSkillDirs(tool.skillsRoot, tool.skillsPath);
// Retain an explicit selection even when this delivery mode produces
// no skills, so a divergent legacy sibling cannot reclaim ownership.
Expand Down
6 changes: 6 additions & 0 deletions src/core/legacy-cleanup.ts
Original file line number Diff line number Diff line change
Expand Up @@ -56,6 +56,12 @@ export const LEGACY_SLASH_COMMAND_PATHS: Record<string, LegacySlashCommandPatter
'factory': { type: 'files', pattern: '.factory/commands/openspec-*.md' },
'opencode': { type: 'files', pattern: ['.opencode/command/opsx-*.md', '.opencode/command/openspec-*.md'] },
'continue': { type: 'files', pattern: '.continue/prompts/openspec-*.prompt' },
// Scoped to the pre-opsx filenames under Antigravity's former `.agent` root.
// The current `.agents/workflows/opsx-*.md` files postdate that rename, and
// the `.agent` copies of them are relocated by LEGACY_TOOL_ROOTS, which
// preserves a customized file instead of deleting it. These patterns are
// matched in every project, so a shared root like `.agents` is not listed:
// OpenSpec never wrote `openspec-*` files there, and a user might have.
'antigravity': { type: 'files', pattern: '.agent/workflows/openspec-*.md' },
'iflow': { type: 'files', pattern: '.iflow/commands/openspec-*.md' },
'qwen': { type: 'files', pattern: ['.qwen/commands/opsx-*.toml', '.qwen/commands/openspec-*.toml'] },
Expand Down
63 changes: 51 additions & 12 deletions src/core/migration.ts
Original file line number Diff line number Diff line change
Expand Up @@ -53,6 +53,12 @@ export const LEGACY_TOOL_ROOTS: Record<string, LegacyToolRoot[]> = {
// Codex now reads the canonical shared .agents root. Generate the current
// replacement first so a divergent legacy file is preserved, not overwritten.
codex: [{ root: '.codex', needsConsent: false, timing: 'after-generation' }],
// Antigravity v1.20.5 moved workspace skills and workflows to `.agents` and
// reads the old `.agent` root only as a fallback, so leaving a copy there
// just gives the agent two of everything. Same after-generation timing as
// Codex: the replacement is written first, so a divergent legacy file is
// reported and kept rather than overwritten.
antigravity: [{ root: '.agent', needsConsent: false, timing: 'after-generation' }],
Comment thread
coderabbitai[bot] marked this conversation as resolved.
};

export interface LegacyToolMigration {
Expand Down Expand Up @@ -192,7 +198,13 @@ function collectLegacyToolMigrations(
apply,
legacyTiming === 'after-generation'
);
const commands = migrateCommandFiles(projectPath, tool, legacy.root, apply);
const commands = migrateCommandFiles(
projectPath,
tool,
legacy.root,
apply,
legacyTiming === 'after-generation'
);

if (apply) {
removeDirIfEmpty(path.join(legacyRootPath, 'skills'));
Expand Down Expand Up @@ -287,7 +299,8 @@ function migrateCommandFiles(
projectPath: string,
tool: AIToolOption,
legacyRoot: string,
apply: boolean
apply: boolean,
requireDestination = false
): { moved: number; kept: number } {
const adapter = CommandAdapterRegistry.get(tool.value);
if (!adapter || !tool.skillsDir) return { moved: 0, kept: 0 };
Expand All @@ -299,10 +312,18 @@ function migrateCommandFiles(
const legacyPath = legacyCommandPath(currentPath, tool.skillsDir, legacyRoot);
if (!legacyPath) continue;

const source = path.join(projectPath, legacyPath);
const source = FileSystemUtils.resolveProjectArtifactPath(projectPath, legacyPath);
if (!fs.existsSync(source)) continue;

const destination = path.join(projectPath, currentPath);
const destination = FileSystemUtils.resolveProjectArtifactPath(
projectPath,
currentPath.split(/[\\/]/).join(path.sep)
);
// An after-generation move runs once the tool has written its replacement.
// No replacement means this command is not one OpenSpec installs now — a
// skills-only delivery or a deselected workflow — so relocating the legacy
// file would resurrect it under the current root.
if (requireDestination && !fs.existsSync(destination)) continue;
if (!areProjectArtifacts(projectPath, source, destination)) {
console.warn(
`Skipping legacy ${legacyPath} migration because it resolves outside this project.`
Expand Down Expand Up @@ -437,7 +458,7 @@ interface InstalledWorkflowArtifacts {
function scanInstalledWorkflowArtifacts(
projectPath: string,
tools: AIToolOption[],
includeLegacySkills = false
includeLegacyRoots = false
): InstalledWorkflowArtifacts {
const installed = new Set<string>();
let hasSkills = false;
Expand All @@ -451,7 +472,7 @@ function scanInstalledWorkflowArtifacts(
skillsDirs.push(resolveToolSkillsDir(projectPath, tool));
} else if (isSharedSkillTargetActive(projectPath, tool.value)) {
skillsDirs.push(resolveToolSkillsDir(projectPath, tool));
if (includeLegacySkills) {
if (includeLegacyRoots) {
skillsDirs.push(
...(tool.legacySkillsDirs ?? []).map((root) =>
path.join(projectPath, root, 'skills')
Expand All @@ -474,14 +495,32 @@ function scanInstalledWorkflowArtifacts(
const adapter = CommandAdapterRegistry.get(tool.value);
if (!adapter) continue;

// A root the tool has moved away from still holds the command files the
// user installed there. Reading only the current root would report a
// commands install as skills-only, and delivery inferred from that answer
// deletes those commands on the next update.
const legacyRoots = includeLegacyRoots
? (LEGACY_TOOL_ROOTS[tool.value] ?? []).map((legacy) => legacy.root)
: [];

for (const workflowId of ALL_WORKFLOWS) {
const commandPath = adapter.getFilePath(workflowId);
const fullPath = path.isAbsolute(commandPath)
? commandPath
: path.join(projectPath, commandPath);
if (fs.existsSync(fullPath)) {
installed.add(workflowId);
hasCommands = true;
const candidates = [commandPath];
if (tool.skillsDir) {
for (const root of legacyRoots) {
const legacyPath = legacyCommandPath(commandPath, tool.skillsDir, root);
if (legacyPath) candidates.push(legacyPath);
}
}
for (const candidate of candidates) {
const fullPath = path.isAbsolute(candidate)
? candidate
: path.join(projectPath, candidate);
if (fs.existsSync(fullPath)) {
installed.add(workflowId);
hasCommands = true;
break;
}
}
}
}
Expand Down
Loading
Loading