Skip to content

Commit 109f81f

Browse files
alfred-openspecclay-goodclaude
authored
fix(antigravity): update skill and workflow paths from .agent to .agents (#830)
* fix(antigravity): update skill and workflow paths from .agent to .agents Antigravity has migrated from .agent (singular) to .agents (plural) for workspace skills and workflows. The old .agent path still works via backward compatibility, but the official docs now specify .agents. Changes: - config.ts: skillsDir '.agent' -> '.agents' - antigravity adapter: workflow path '.agent/workflows/' -> '.agents/workflows/' - legacy-cleanup: add patterns for old .agent/ artifacts cleanup - docs: update supported-tools.md table - tests: update expected path assertions Closes #0 (reported by BugsCreator and Minh Pham in Discord) * fix(antigravity): migrate an existing .agent install to .agents Pointing Antigravity at `.agents` leaves every existing `.agent/` install behind, so this registers the move instead of only changing the target: - `.agent` becomes Antigravity's legacy skills root and legacy tool root, so update relocates managed skills and commands after generating their replacement, keeping a file the user customized. - Detection keys off `.agent` and `.agents/workflows`. The bare `.agents` root is shared with Codex, Zed, and the vendor-neutral target, so it cannot stand in for "Antigravity is set up here". - Delivery inference reads a tool's legacy roots for command files too. Without it the first update after the move saw skills but no commands, wrote `delivery: skills`, and the next update deleted every slash command. - Legacy slash-command cleanup stays scoped to the pre-opsx `openspec-*` names under `.agent`; the migration owns the `opsx-*` files, and a shared root is never glob-swept. Co-Authored-By: Claude Opus 5 <noreply@anthropic.com> * docs: record the Antigravity root move Co-Authored-By: Claude Opus 5 <noreply@anthropic.com> * fix(antigravity): require a generated command before moving its legacy copy migrateCommandFiles relocated a legacy command file whether or not the current root held a replacement. Codex never reached that path — it has no command adapter — so Antigravity is the first after-generation move where it matters: under skills-only delivery, or for a deselected workflow, the move recreated a command OpenSpec had just decided not to install. Gate the move on an existing destination, the same way migrateSkillDirs already does for after-generation timing. Reported by CodeRabbit on #830. Co-Authored-By: Claude Opus 5 <noreply@anthropic.com> * fix(antigravity): arbitrate shared agent skills * test(antigravity): cover Windows migration paths * fix(antigravity): harden shared-root migration --------- Co-authored-by: Clay Good <hi@claygood.com> Co-authored-by: Claude Opus 5 <noreply@anthropic.com>
1 parent 04b37ac commit 109f81f

17 files changed

Lines changed: 636 additions & 87 deletions
Lines changed: 5 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,5 @@
1+
---
2+
"@fission-ai/openspec": patch
3+
---
4+
5+
Write Antigravity skills and workflows to `.agents/`, arbitrate its shared skill tree with other tools, and safely migrate an existing `.agent/` install.

docs-lab/reference/supported-tools.md

Lines changed: 20 additions & 6 deletions
Original file line numberDiff line numberDiff line change
@@ -15,7 +15,7 @@ The id goes to `openspec init --tools <id>` to skip the picker ([CLI](cli.md)).
1515
| Tool | `--tools` id | Skills | Skill invocation | Commands | Command invocation |
1616
|---|---|---|---|---|---|
1717
| Amazon Q Developer | `amazon-q` | `.amazonq/skills/` | `/openspec-apply-change` | `.amazonq/prompts/` | `@opsx-apply` |
18-
| Antigravity | `antigravity` | `.agent/skills/` | `/openspec-apply-change` | `.agent/workflows/` | `/opsx-apply` |
18+
| Antigravity | `antigravity` | `.agents/skills/` | `/openspec-apply-change` | `.agents/workflows/` | `/opsx-apply` |
1919
| Auggie (Augment CLI) | `auggie` | `.augment/skills/` | `/openspec-apply-change` | `.augment/commands/` | `/opsx-apply` |
2020
| Bob Shell | `bob` | `.bob/skills/` | `/openspec-apply-change` | `.bob/commands/` | `/opsx-apply` |
2121
| Claude Code | `claude` | `.claude/skills/` | `/openspec-apply-change` | `.claude/commands/opsx/` | `/opsx:apply` |
@@ -62,6 +62,17 @@ The id goes to `openspec init --tools <id>` to skip the picker ([CLI](cli.md)).
6262

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

65+
### Antigravity
66+
67+
- **Current folder**: Antigravity v1.20.5 and later read workspace skills and
68+
workflows from `.agents/`.
69+
- **Legacy folder**: after OpenSpec writes replacements, it removes equivalent
70+
generated files from `.agent/`. Custom files and changed generated files stay in
71+
`.agent/` for you to review.
72+
- **Shared skills**: Antigravity shares `.agents/skills/` with Codex, Zed Agent, and
73+
the `agents` target. OpenSpec writes that skill tree once while still writing
74+
Antigravity commands to `.agents/workflows/`.
75+
6576
### Cline
6677

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

@@ -110,8 +122,10 @@ init prints this reminder after install.
110122

111123
- **When it fits**: any tool that reads the shared `.agents/skills/` folder,
112124
including tools with no row in the matrix.
113-
- **Alongside other targets**: fine, since each target writes its own folder. Codex
114-
shares this one; see the [Codex note](#codex).
125+
- **Alongside other targets**: Antigravity, Codex, Zed Agent, and this target share
126+
one physical skill tree. OpenSpec records one writer in `.openspec-target` and
127+
writes the tree once per run. Each tool's separate command files are still
128+
generated.
115129
- **What OpenSpec claims**: only the `openspec-*` folders and the
116130
`.openspec-target` marker. Anything else under `.agents/` is left alone.
117131
- **`AGENTS.md`**: not created or edited. The target is the `.agents/` folder, not

src/core/available-tools.ts

Lines changed: 16 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -58,7 +58,22 @@ export function getAvailableTools(projectPath: string): AIToolOption[] {
5858
available.filter((tool) => tool.skillsDir)
5959
).map((tool) => tool.value)
6060
);
61+
const hasIndependentDetectionPath = (tool: AIToolOption): boolean =>
62+
(tool.detectionPaths ?? []).some((detectionPath) => {
63+
// Skill roots still go through managed-content reconciliation below;
64+
// their mere existence is not an independent tool signal.
65+
if (detectionPath.endsWith('/skills')) return false;
66+
try {
67+
fs.statSync(path.join(projectPath, detectionPath));
68+
return true;
69+
} catch {
70+
return false;
71+
}
72+
});
6173
return available.filter(
62-
(tool) => tool.globalSkillsDir || activeProjectTools.has(tool.value)
74+
(tool) =>
75+
tool.globalSkillsDir ||
76+
hasIndependentDetectionPath(tool) ||
77+
activeProjectTools.has(tool.value)
6378
);
6479
}

src/core/command-generation/adapters/antigravity.ts

Lines changed: 2 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -10,14 +10,14 @@ import { escapeYamlValue } from '../yaml.js';
1010

1111
/**
1212
* Antigravity adapter for command generation.
13-
* File path: .agent/workflows/opsx-<id>.md
13+
* File path: .agents/workflows/opsx-<id>.md
1414
* Frontmatter: description
1515
*/
1616
export const antigravityAdapter: ToolCommandAdapter = {
1717
toolId: 'antigravity',
1818

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

2323
formatFile(content: CommandContent): string {

src/core/config.ts

Lines changed: 6 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -39,7 +39,12 @@ export interface AIToolOption {
3939

4040
export const AI_TOOLS: AIToolOption[] = [
4141
{ name: 'Amazon Q Developer', value: 'amazon-q', available: true, successLabel: 'Amazon Q Developer', skillsDir: '.amazonq', requiresIdeRestart: true },
42-
{ name: 'Antigravity', value: 'antigravity', available: true, successLabel: 'Antigravity', skillsDir: '.agent', requiresIdeRestart: true },
42+
// Antigravity moved workspace skills and workflows from `.agent` to the
43+
// shared `.agents` root in v1.20.5. Detection keys off `.agent` and
44+
// `.agents/workflows` rather than the bare `.agents` root: that root is
45+
// shared with Codex, Zed, and the vendor-neutral target, so its presence
46+
// alone says nothing about Antigravity.
47+
{ name: 'Antigravity', value: 'antigravity', available: true, successLabel: 'Antigravity', skillsDir: '.agents', legacySkillsDirs: ['.agent'], detectionPaths: ['.agent', '.agents/workflows'], requiresIdeRestart: true },
4348
{ name: 'Auggie (Augment CLI)', value: 'auggie', available: true, successLabel: 'Auggie', skillsDir: '.augment' },
4449
{ name: 'Bob Shell', value: 'bob', available: true, successLabel: 'Bob Shell', skillsDir: '.bob' },
4550
{ name: 'Claude Code', value: 'claude', available: true, successLabel: 'Claude Code', skillsDir: '.claude' },

src/core/init.ts

Lines changed: 58 additions & 38 deletions
Original file line numberDiff line numberDiff line change
@@ -59,7 +59,11 @@ import {
5959
import { getGlobalConfig, type Delivery, type Profile } from './global-config.js';
6060
import { getProfileWorkflows, CORE_WORKFLOWS, ALL_WORKFLOWS } from './profiles.js';
6161
import { getAvailableTools } from './available-tools.js';
62-
import { writeSharedSkillTarget } from './shared-skill-target.js';
62+
import {
63+
resolveSharedSkillWriters,
64+
sharedSkillRootOwner,
65+
writeSharedSkillTarget,
66+
} from './shared-skill-target.js';
6367
import { migrateIfNeeded, migrateLegacyToolDirs, describeLegacyMigration, keptInPlaceNotice, hasMovableContent, scanInstalledWorkflows as scanInstalledWorkflowsShared } from './migration.js';
6468
import {
6569
resolveCommandSurfaceCapability,
@@ -145,6 +149,7 @@ type ValidatedInitTool = {
145149
isGlobalSkillTarget: boolean;
146150
wasConfigured: boolean;
147151
requiresIdeRestart?: boolean;
152+
writesSkills: boolean;
148153
};
149154

150155
/**
@@ -767,41 +772,8 @@ export class InitCommand {
767772
toolStates: Map<string, ToolSkillStatus>,
768773
projectPath: string
769774
): ValidatedInitTool[] {
770-
const validatedTools: ValidatedInitTool[] = [];
771-
772-
const sharedAgentsTargets = ['codex', 'zed', 'agents'];
773-
const selectedSharedTargets = sharedAgentsTargets.filter((toolId) => toolIds.includes(toolId));
774-
// A Codex-rendered tree already serves Zed. Keep it when Zed is added later
775-
// so Codex users do not lose the `$openspec-*` references they require.
776-
const preserveConfiguredCodex = selectedSharedTargets.includes('zed') &&
777-
toolStates.get('codex')?.configured;
778-
const sharedTargetCandidates = preserveConfiguredCodex
779-
? [...new Set([...selectedSharedTargets, 'codex'])]
780-
: selectedSharedTargets;
781-
const sharedTargetOwner = sharedTargetCandidates.includes('codex')
782-
? 'codex'
783-
: selectedSharedTargets.includes('zed')
784-
? 'zed'
785-
: selectedSharedTargets[0];
786-
const firstSharedIndex = toolIds.findIndex((id) => sharedAgentsTargets.includes(id));
787-
const reconciledToolIds = sharedTargetCandidates.length > 1
788-
? toolIds.flatMap((toolId, index) => {
789-
if (!sharedAgentsTargets.includes(toolId)) return [toolId];
790-
return index === firstSharedIndex && sharedTargetOwner ? [sharedTargetOwner] : [];
791-
})
792-
: toolIds;
793-
if (
794-
reconciledToolIds.length !== toolIds.length ||
795-
reconciledToolIds.some((toolId, index) => toolId !== toolIds[index])
796-
) {
797-
console.log(
798-
chalk.dim(
799-
`Codex, Zed, and agents share .agents/skills; writing one tree for ${sharedTargetOwner}.`
800-
)
801-
);
802-
}
803-
804-
for (const toolId of reconciledToolIds) {
775+
const selectedTools: AIToolOption[] = [];
776+
for (const toolId of toolIds) {
805777
const tool = AI_TOOLS.find((t) => t.value === toolId);
806778
if (!tool) {
807779
const validToolIds = getToolsWithSkillsDir();
@@ -817,6 +789,49 @@ export class InitCommand {
817789
);
818790
}
819791

792+
selectedTools.push(tool);
793+
}
794+
795+
// A selected tool may share its physical skills root with an already
796+
// configured owner. Include that owner in the refresh without dropping the
797+
// selected tool: it may still have an independent command surface.
798+
const generationTools = [...selectedTools];
799+
const delivery: Delivery = getGlobalConfig().delivery ?? 'both';
800+
for (const selected of selectedTools) {
801+
if (!selected.skillsDir) continue;
802+
const selectedOwner = selected.value === 'codex' ||
803+
!shouldGenerateSkillsForTool(selected.value, delivery)
804+
? undefined
805+
: sharedSkillRootOwner(projectPath, selected.value);
806+
for (const candidate of AI_TOOLS) {
807+
if (
808+
candidate.skillsDir === selected.skillsDir &&
809+
toolStates.get(candidate.value)?.configured &&
810+
candidate.value === selectedOwner &&
811+
!generationTools.includes(candidate)
812+
) {
813+
generationTools.push(candidate);
814+
}
815+
}
816+
}
817+
818+
const skillWriters = resolveSharedSkillWriters(projectPath, generationTools);
819+
const sharedRoots = new Map<string, AIToolOption[]>();
820+
for (const tool of generationTools) {
821+
if (!tool.skillsDir) continue;
822+
const group = sharedRoots.get(tool.skillsDir) ?? [];
823+
group.push(tool);
824+
sharedRoots.set(tool.skillsDir, group);
825+
}
826+
for (const [root, group] of sharedRoots) {
827+
if (group.length < 2) continue;
828+
const owner = group.find((tool) => skillWriters.has(tool.value));
829+
console.log(chalk.dim(`${group.map((tool) => tool.name).join(', ')} share ${root}/skills; writing one tree for ${owner?.value}.`));
830+
}
831+
832+
const validatedTools: ValidatedInitTool[] = [];
833+
for (const tool of generationTools) {
834+
if (!toolSupportsSkills(tool)) continue;
820835
const preState = toolStates.get(tool.value);
821836
const skillsPath = resolveToolSkillsDir(projectPath, tool);
822837
const isGlobalSkillTarget = hasGlobalSkillTarget(tool);
@@ -829,6 +844,7 @@ export class InitCommand {
829844
isGlobalSkillTarget,
830845
wasConfigured: preState?.configured ?? false,
831846
requiresIdeRestart: tool.requiresIdeRestart,
847+
writesSkills: !tool.skillsDir || skillWriters.has(tool.value),
832848
});
833849
}
834850

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

931947
// Generate skill files if the selected delivery and tool capability allow skills
932-
if (shouldGenerateSkills) {
948+
if (shouldGenerateSkills && tool.writesSkills) {
933949
// Create skill directories and SKILL.md files
934950
for (const { template, dirName } of skillTemplates) {
935951
const skillDir = path.join(tool.skillsPath, dirName);
@@ -950,7 +966,11 @@ export class InitCommand {
950966
}
951967
writeSharedSkillTarget(projectPath, tool.value);
952968
}
953-
if (shouldRemoveSkillsForTool(tool.value, delivery) && !tool.isGlobalSkillTarget) {
969+
if (
970+
shouldRemoveSkillsForTool(tool.value, delivery) &&
971+
tool.writesSkills &&
972+
!tool.isGlobalSkillTarget
973+
) {
954974
removedSkillCount += await this.removeSkillDirs(tool.skillsRoot, tool.skillsPath);
955975
// Retain an explicit selection even when this delivery mode produces
956976
// no skills, so a divergent legacy sibling cannot reclaim ownership.

src/core/legacy-cleanup.ts

Lines changed: 6 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -56,6 +56,12 @@ export const LEGACY_SLASH_COMMAND_PATHS: Record<string, LegacySlashCommandPatter
5656
'factory': { type: 'files', pattern: '.factory/commands/openspec-*.md' },
5757
'opencode': { type: 'files', pattern: ['.opencode/command/opsx-*.md', '.opencode/command/openspec-*.md'] },
5858
'continue': { type: 'files', pattern: '.continue/prompts/openspec-*.prompt' },
59+
// Scoped to the pre-opsx filenames under Antigravity's former `.agent` root.
60+
// The current `.agents/workflows/opsx-*.md` files postdate that rename, and
61+
// the `.agent` copies of them are relocated by LEGACY_TOOL_ROOTS, which
62+
// preserves a customized file instead of deleting it. These patterns are
63+
// matched in every project, so a shared root like `.agents` is not listed:
64+
// OpenSpec never wrote `openspec-*` files there, and a user might have.
5965
'antigravity': { type: 'files', pattern: '.agent/workflows/openspec-*.md' },
6066
'iflow': { type: 'files', pattern: '.iflow/commands/openspec-*.md' },
6167
'qwen': { type: 'files', pattern: ['.qwen/commands/opsx-*.toml', '.qwen/commands/openspec-*.toml'] },

src/core/migration.ts

Lines changed: 51 additions & 12 deletions
Original file line numberDiff line numberDiff line change
@@ -53,6 +53,12 @@ export const LEGACY_TOOL_ROOTS: Record<string, LegacyToolRoot[]> = {
5353
// Codex now reads the canonical shared .agents root. Generate the current
5454
// replacement first so a divergent legacy file is preserved, not overwritten.
5555
codex: [{ root: '.codex', needsConsent: false, timing: 'after-generation' }],
56+
// Antigravity v1.20.5 moved workspace skills and workflows to `.agents` and
57+
// reads the old `.agent` root only as a fallback, so leaving a copy there
58+
// just gives the agent two of everything. Same after-generation timing as
59+
// Codex: the replacement is written first, so a divergent legacy file is
60+
// reported and kept rather than overwritten.
61+
antigravity: [{ root: '.agent', needsConsent: false, timing: 'after-generation' }],
5662
};
5763

5864
export interface LegacyToolMigration {
@@ -192,7 +198,13 @@ function collectLegacyToolMigrations(
192198
apply,
193199
legacyTiming === 'after-generation'
194200
);
195-
const commands = migrateCommandFiles(projectPath, tool, legacy.root, apply);
201+
const commands = migrateCommandFiles(
202+
projectPath,
203+
tool,
204+
legacy.root,
205+
apply,
206+
legacyTiming === 'after-generation'
207+
);
196208

197209
if (apply) {
198210
removeDirIfEmpty(path.join(legacyRootPath, 'skills'));
@@ -287,7 +299,8 @@ function migrateCommandFiles(
287299
projectPath: string,
288300
tool: AIToolOption,
289301
legacyRoot: string,
290-
apply: boolean
302+
apply: boolean,
303+
requireDestination = false
291304
): { moved: number; kept: number } {
292305
const adapter = CommandAdapterRegistry.get(tool.value);
293306
if (!adapter || !tool.skillsDir) return { moved: 0, kept: 0 };
@@ -299,10 +312,18 @@ function migrateCommandFiles(
299312
const legacyPath = legacyCommandPath(currentPath, tool.skillsDir, legacyRoot);
300313
if (!legacyPath) continue;
301314

302-
const source = path.join(projectPath, legacyPath);
315+
const source = FileSystemUtils.resolveProjectArtifactPath(projectPath, legacyPath);
303316
if (!fs.existsSync(source)) continue;
304317

305-
const destination = path.join(projectPath, currentPath);
318+
const destination = FileSystemUtils.resolveProjectArtifactPath(
319+
projectPath,
320+
currentPath.split(/[\\/]/).join(path.sep)
321+
);
322+
// An after-generation move runs once the tool has written its replacement.
323+
// No replacement means this command is not one OpenSpec installs now — a
324+
// skills-only delivery or a deselected workflow — so relocating the legacy
325+
// file would resurrect it under the current root.
326+
if (requireDestination && !fs.existsSync(destination)) continue;
306327
if (!areProjectArtifacts(projectPath, source, destination)) {
307328
console.warn(
308329
`Skipping legacy ${legacyPath} migration because it resolves outside this project.`
@@ -437,7 +458,7 @@ interface InstalledWorkflowArtifacts {
437458
function scanInstalledWorkflowArtifacts(
438459
projectPath: string,
439460
tools: AIToolOption[],
440-
includeLegacySkills = false
461+
includeLegacyRoots = false
441462
): InstalledWorkflowArtifacts {
442463
const installed = new Set<string>();
443464
let hasSkills = false;
@@ -451,7 +472,7 @@ function scanInstalledWorkflowArtifacts(
451472
skillsDirs.push(resolveToolSkillsDir(projectPath, tool));
452473
} else if (isSharedSkillTargetActive(projectPath, tool.value)) {
453474
skillsDirs.push(resolveToolSkillsDir(projectPath, tool));
454-
if (includeLegacySkills) {
475+
if (includeLegacyRoots) {
455476
skillsDirs.push(
456477
...(tool.legacySkillsDirs ?? []).map((root) =>
457478
path.join(projectPath, root, 'skills')
@@ -474,14 +495,32 @@ function scanInstalledWorkflowArtifacts(
474495
const adapter = CommandAdapterRegistry.get(tool.value);
475496
if (!adapter) continue;
476497

498+
// A root the tool has moved away from still holds the command files the
499+
// user installed there. Reading only the current root would report a
500+
// commands install as skills-only, and delivery inferred from that answer
501+
// deletes those commands on the next update.
502+
const legacyRoots = includeLegacyRoots
503+
? (LEGACY_TOOL_ROOTS[tool.value] ?? []).map((legacy) => legacy.root)
504+
: [];
505+
477506
for (const workflowId of ALL_WORKFLOWS) {
478507
const commandPath = adapter.getFilePath(workflowId);
479-
const fullPath = path.isAbsolute(commandPath)
480-
? commandPath
481-
: path.join(projectPath, commandPath);
482-
if (fs.existsSync(fullPath)) {
483-
installed.add(workflowId);
484-
hasCommands = true;
508+
const candidates = [commandPath];
509+
if (tool.skillsDir) {
510+
for (const root of legacyRoots) {
511+
const legacyPath = legacyCommandPath(commandPath, tool.skillsDir, root);
512+
if (legacyPath) candidates.push(legacyPath);
513+
}
514+
}
515+
for (const candidate of candidates) {
516+
const fullPath = path.isAbsolute(candidate)
517+
? candidate
518+
: path.join(projectPath, candidate);
519+
if (fs.existsSync(fullPath)) {
520+
installed.add(workflowId);
521+
hasCommands = true;
522+
break;
523+
}
485524
}
486525
}
487526
}

0 commit comments

Comments
 (0)