Compare commits

...
Author SHA1 Message Date
Clay GoodandClaude Opus 5.5 561bc44f5d test: give the Amazon Q tests their own anchor in update.test.ts
Each of the four #2022 PRs added its tests before the same describe, so
merging them in sequence conflicted. No test changes.

Part of #2022

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-14 09:43:29 -05:00
Clay GoodandClaude Opus 5.5 497ff3c36c docs(amazon-q): say when leftover skill folders are removed
The docs and changeset said existing `.amazonq/skills` folders are left
in place. That holds under `both` and `skills` delivery; `commands`
delivery still removes them, as it removes every tool's skills. The
wording now says so.

Part of #2022

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-14 09:42:07 -05:00
Clay GoodandClaude Opus 5.5 ce49e88a78 fix(amazon-q): keep Amazon Q's prompts under every delivery setting
Amazon Q reads prompts from `.amazonq/prompts` and no skills folder; its
CLI source has no skill support, and AWS docs name only prompts. The
skills OpenSpec wrote to `.amazonq/skills` never loaded, so under
skills-only delivery Amazon Q would have had no working workflow, and
#2022's default flip would have done that to every Amazon Q user.

A tool can now be commands-only: it gets commands under every delivery,
no skills, and its commands are never cleared as skills-only leftovers.
Amazon Q is the one such tool. Two consequences are handled with it:

- Detection ignores a commands-only tool's skill files. Otherwise the
  version in skill files this CLI no longer writes would mark Amazon Q
  stale on every `openspec update`.
- First-run delivery inference ignores its files. Its prompts exist under
  every delivery, so they show no preference; counting them saved
  `delivery: commands` globally for an Amazon-Q-only project, which strips
  skills from every other project on the machine. With nothing else to go
  on, no delivery is recorded and the default applies.

Existing `.amazonq/skills` files are left in place, not deleted.

Part of #2022

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-14 09:30:22 -05:00
Clay GoodandClaude Opus 5.5 9111a7654d fix(security): accept the unpatched braces advisory in pnpm audit (#2048)
* fix(security): clear the unpatched braces advisory on main

pnpm audit --prod fails on main for GHSA-vfj7-8cjw-p6xm (braces <=3.0.3,
stack exhaustion on deeply nested patterns). braces ships at runtime via
fast-glob > micromatch, and no patched version exists, so no override can
fix it.

Reject artifact output patterns that nest braces more than 16 levels deep
before they reach fast-glob, and record the advisory in auditConfig with
that mitigation and a removal check.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>

* fix(security): keep only the audit exception for the braces advisory

Move the brace-nesting guard to a follow-up PR: it adds a user-visible
limit to schema `generates` that needs a docs-lab contract update and a
spec change. The audit exception alone clears main's Security workflow.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>

* fix(security): record the braces advisory's residual risk accurately

Name both inputs that reach fast-glob (generates and apply.tracks) and
state that a crafted schema can still crash the CLI, instead of relying on
the input cap or a failed local reproduction.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>

* fix(security): drop unsupported claims from the braces risk record

Schemas resolve from the project, user, or package directories, not a
store, and the input-length cap does not prevent stack exhaustion. State
only the accepted risk and the removal check.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>

---------

Co-authored-by: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-06 00:21:34 +00:00
10 changed files with 240 additions and 43 deletions
+5
View File
@@ -0,0 +1,5 @@
---
"@fission-ai/openspec": patch
---
Amazon Q Developer now keeps its prompts under every delivery setting, including `skills`. Amazon Q reads prompts from `.amazonq/prompts/` but no skills folder, so the skills OpenSpec wrote to `.amazonq/skills/` never loaded, and switching to skills-only delivery would have left Amazon Q with no working workflow. OpenSpec no longer writes those skills. Existing ones are left in place under `both` and `skills` delivery and can be deleted by hand; `commands` delivery still removes them, as it does for every tool. Two side effects are handled: leftover skill files no longer make `openspec update` report Amazon Q as needing an update on every run, and a project where only Amazon Q is configured no longer saves `delivery: commands` to your global config on first run, which would have removed skills from your other projects.
+5 -1
View File
@@ -14,7 +14,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` |
| Amazon Q Developer | `amazon-q` | none | none | `.amazonq/prompts/` | `@opsx-apply` |
| Amp | `amp` | `.agents/skills/` | `/openspec-apply-change` | none | none |
| Antigravity | `antigravity` | `.agents/skills/` | `/openspec-apply-change` | `.agents/workflows/` | `/opsx-apply` |
| AtomCode | `atomcode` | `.atomcode/skills/` | `/openspec-apply-change` | `.atomcode/commands/` | `/opsx-apply` |
@@ -72,6 +72,10 @@ 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.
### Amazon Q Developer
Amazon Q reads prompts from `.amazonq/prompts/` but no skills folder, so OpenSpec installs its workflows as prompts only, under every delivery setting, including `skills`. Invoke one with `@`, as in `@opsx-propose`. Earlier releases also wrote `.amazonq/skills/`, which Amazon Q never loaded. Those folders do nothing, and OpenSpec leaves them in place under `both` and `skills` delivery, so you can delete them yourself; `commands` delivery removes them, as it removes every tool's skills. AWS [ends support for the Amazon Q Developer IDE plugins on April 30, 2027](https://docs.aws.amazon.com/amazonq/latest/qdeveloper-ug/q-developer-ide-end-of-support.html) and points users to Kiro, which OpenSpec also supports (`--tools kiro`).
### Amp
- **Project skills**: Amp reads OpenSpec skills from `.agents/skills/`.
+12
View File
@@ -26,3 +26,15 @@ overrides:
# Dev-only (transitive via @vitest/ui); never in the published CLI. Remove once
# transitive fflate is >=0.8.3 (check: pnpm why fflate).
fflate@<0.8.3: '>=0.8.3 <0.9'
auditConfig:
ignoreGhsas:
# GHSA-vfj7-8cjw-p6xm — braces <=3.0.3 stack exhaustion on deeply nested
# patterns. Runtime (fast-glob > micromatch > braces), and no patched braces
# exists, so no override can fix it. Accepted residual risk: schema artifact
# `generates` and `apply.tracks` patterns reach fast-glob through
# resolveArtifactOutputs without a brace-depth guard, and the 10,000-character
# input limit does not prevent stack exhaustion. A crafted project-local or
# user schema can still terminate the openspec process. Remove this exception
# once braces ships a patched release (check: pnpm why braces; pnpm audit --prod).
- GHSA-vfj7-8cjw-p6xm
+23 -2
View File
@@ -26,7 +26,21 @@ export function resolveCommandSurfaceCapability(toolId: string): CommandSurfaceC
return 'none';
}
/**
* Tools that load OpenSpec workflows only as command files, because nothing
* they run reads a SKILL.md folder. Amazon Q Developer reads its prompts from
* `.amazonq/prompts` but no skills directory, and AWS is retiring it in favor
* of Kiro. Such a tool keeps its commands under every delivery setting: skills
* alone would leave it with no way to start a workflow.
*/
const COMMANDS_ONLY_TOOLS: ReadonlySet<string> = new Set(['amazon-q']);
export function isCommandsOnlyTool(toolId: string): boolean {
return COMMANDS_ONLY_TOOLS.has(toolId);
}
export function shouldGenerateSkillsForTool(toolId: string, delivery: Delivery): boolean {
if (isCommandsOnlyTool(toolId)) return false;
return delivery !== 'commands' || resolveCommandSurfaceCapability(toolId) === 'skills-invocable';
}
@@ -35,9 +49,16 @@ export function shouldRemoveSkillsForTool(toolId: string, delivery: Delivery): b
}
export function shouldGenerateCommandsForTool(toolId: string, delivery: Delivery): boolean {
return delivery !== 'skills' && resolveCommandSurfaceCapability(toolId) === 'adapter-backed';
return (
resolveCommandSurfaceCapability(toolId) === 'adapter-backed' &&
(delivery !== 'skills' || isCommandsOnlyTool(toolId))
);
}
export function shouldReconcileCommandFilesForTool(toolId: string, delivery: Delivery): boolean {
return delivery === 'skills' && resolveCommandSurfaceCapability(toolId) === 'adapter-backed';
return (
delivery === 'skills' &&
resolveCommandSurfaceCapability(toolId) === 'adapter-backed' &&
!isCommandsOnlyTool(toolId)
);
}
+26 -5
View File
@@ -10,6 +10,7 @@ import { getGlobalConfig, getGlobalConfigPath, isGlobalConfigUnreadable, saveGlo
import { CommandAdapterRegistry } from './command-generation/index.js';
import {
resolveCommandInvocation,
isCommandsOnlyTool,
resolveCommandSurfaceCapability,
shouldGenerateCommandsForTool,
} from './command-surface.js';
@@ -467,6 +468,12 @@ function scanInstalledWorkflowArtifacts(
for (const tool of tools) {
if (!toolSupportsSkills(tool)) continue;
// A commands-only tool has command files under every delivery setting, so
// its files show which workflows are installed but say nothing about the
// user's delivery preference. Counting them would let one such project
// save `delivery: commands` globally and strip skills from every other.
const showsDeliveryPreference = !isCommandsOnlyTool(tool.value);
const skillsDirs: string[] = [];
if (tool.globalSkillsDir) {
skillsDirs.push(resolveToolSkillsDir(projectPath, tool));
@@ -487,7 +494,7 @@ function scanInstalledWorkflowArtifacts(
const skillFile = path.join(skillsDir, skillDirName, 'SKILL.md');
if (fs.existsSync(skillFile)) {
installed.add(workflowId);
hasSkills = true;
if (showsDeliveryPreference) hasSkills = true;
}
}
}
@@ -518,7 +525,7 @@ function scanInstalledWorkflowArtifacts(
: path.join(projectPath, candidate);
if (fs.existsSync(fullPath)) {
installed.add(workflowId);
hasCommands = true;
if (showsDeliveryPreference) hasCommands = true;
break;
}
}
@@ -540,14 +547,21 @@ export function scanInstalledWorkflows(projectPath: string, tools: AIToolOption[
return scanInstalledWorkflowArtifacts(projectPath, tools).workflows;
}
function inferDelivery(artifacts: InstalledWorkflowArtifacts): Delivery {
/**
* The delivery the installed files imply, or undefined when they imply none:
* every file found belongs to a tool whose surface does not follow delivery.
*/
function inferDelivery(artifacts: InstalledWorkflowArtifacts): Delivery | undefined {
if (artifacts.hasSkills && artifacts.hasCommands) {
return 'both';
}
if (artifacts.hasCommands) {
return 'commands';
}
return 'skills';
if (artifacts.hasSkills) {
return 'skills';
}
return undefined;
}
/**
@@ -598,7 +612,14 @@ export function migrateIfNeeded(projectPath: string, tools: AIToolOption[]): voi
config.profile = 'custom';
config.workflows = installedWorkflows;
if (rawConfig.delivery === undefined) {
config.delivery = inferDelivery(artifacts);
const inferred = inferDelivery(artifacts);
if (inferred) {
config.delivery = inferred;
} else {
// No preference was shown, so record none: the user stays on the
// default, rather than being pinned to whatever it is today.
delete config.delivery;
}
}
saveGlobalConfig(config);
+17 -6
View File
@@ -21,6 +21,7 @@ import {
shouldGenerateCommandsForTool,
shouldGenerateSkillsForTool,
resolveCommandSurfaceCapability,
isCommandsOnlyTool,
} from '../command-surface.js';
import {
getSkillCapableTools,
@@ -105,6 +106,12 @@ export function getToolSkillStatus(projectRoot: string, toolId: string): ToolSki
if (tool.skillsDir && !isSharedSkillTargetActive(projectRoot, toolId)) {
return { configured: false, fullyConfigured: false, skillCount: 0 };
}
// Skill files left over for a tool that cannot load skills are inert. They
// must not make the tool look skill-configured, or detection would keep
// reading a version from files this CLI no longer writes.
if (isCommandsOnlyTool(toolId)) {
return { configured: false, fullyConfigured: false, skillCount: 0 };
}
const skillsDirs = [
resolveToolSkillsDir(projectRoot, tool),
@@ -324,12 +331,16 @@ export function getToolVersionStatus(
};
}
const skillsDirs = [
resolveToolSkillsDir(projectRoot, tool),
...(tool.legacySkillsDirs ?? []).map((root) =>
path.join(projectRoot, root, 'skills')
),
];
// A commands-only tool is versioned by its command files alone; see
// getToolSkillStatus for why its leftover skill files are ignored.
const skillsDirs = isCommandsOnlyTool(toolId)
? []
: [
resolveToolSkillsDir(projectRoot, tool),
...(tool.legacySkillsDirs ?? []).map((root) =>
path.join(projectRoot, root, 'skills')
),
];
let generatedByVersion: string | null = null;
let foundSkill = false;
@@ -0,0 +1,40 @@
import { describe, it, expect } from 'vitest';
import {
isCommandsOnlyTool,
shouldGenerateCommandsForTool,
shouldGenerateSkillsForTool,
shouldReconcileCommandFilesForTool,
shouldRemoveSkillsForTool,
} from '../../src/core/command-surface.js';
const DELIVERIES = ['both', 'skills', 'commands'] as const;
describe('commands-only tools', () => {
it('treats Amazon Q as commands-only, and an ordinary command tool as not', () => {
expect(isCommandsOnlyTool('amazon-q')).toBe(true);
expect(isCommandsOnlyTool('claude')).toBe(false);
expect(isCommandsOnlyTool('codex')).toBe(false);
});
it.each(DELIVERIES)('gives Amazon Q commands and no skills under delivery %s', (delivery) => {
expect(shouldGenerateCommandsForTool('amazon-q', delivery)).toBe(true);
expect(shouldGenerateSkillsForTool('amazon-q', delivery)).toBe(false);
// Its commands are its only surface, so they are never cleared as the
// leftovers of a skills-only switch.
expect(shouldReconcileCommandFilesForTool('amazon-q', delivery)).toBe(false);
});
it('leaves its leftover skills in place except where commands-only delivery always removed them', () => {
expect(shouldRemoveSkillsForTool('amazon-q', 'both')).toBe(false);
expect(shouldRemoveSkillsForTool('amazon-q', 'skills')).toBe(false);
// Unchanged: `delivery: commands` already removed every tool's skills.
expect(shouldRemoveSkillsForTool('amazon-q', 'commands')).toBe(true);
});
it('does not change any other tool', () => {
expect(shouldGenerateCommandsForTool('claude', 'skills')).toBe(false);
expect(shouldGenerateSkillsForTool('claude', 'skills')).toBe(true);
expect(shouldReconcileCommandFilesForTool('claude', 'skills')).toBe(true);
expect(shouldGenerateSkillsForTool('codex', 'commands')).toBe(true);
});
});
+8 -10
View File
@@ -2623,20 +2623,18 @@ describe('InitCommand - profile and detection features', () => {
it('should print the @-prefixed prompt hint for amazon-q (prompt library, no slash surface)', async () => {
// Amazon Q loads .amazonq/prompts/opsx-<id>.md into its prompt library,
// invoked as @opsx-<id>. It registers no slash command under any spelling,
// so neither the hint, the generated prompts, the skills, nor the restart
// line may name one.
// so neither the hint, the generated prompts, nor the restart line may name
// one. It reads no skills directory, so no skills are written for it.
const initCommand = new InitCommand({ tools: 'amazon-q', force: true });
await initCommand.execute(testDir);
const promptFile = path.join(testDir, '.amazonq', 'prompts', 'opsx-apply.md');
const skillFile = path.join(testDir, '.amazonq', 'skills', 'openspec-apply-change', 'SKILL.md');
for (const file of [promptFile, skillFile]) {
expect(await fileExists(file)).toBe(true);
const content = await fs.readFile(file, 'utf-8');
expect(content).toContain('@opsx-apply');
expect(content).not.toContain('/opsx:');
expect(content).not.toContain('/opsx-');
}
expect(await fileExists(promptFile)).toBe(true);
const content = await fs.readFile(promptFile, 'utf-8');
expect(content).toContain('@opsx-apply');
expect(content).not.toContain('/opsx:');
expect(content).not.toContain('/opsx-');
expect(await fileExists(path.join(testDir, '.amazonq', 'skills'))).toBe(false);
const logCalls = (console.log as unknown as { mock: { calls: unknown[][] } }).mock.calls.flat().map(String);
const startHint = logCalls.find((entry) => entry.includes('Start your first change'));
+34
View File
@@ -124,6 +124,40 @@ describe('migration', () => {
expect(config.workflows).toEqual(['explore', 'archive']);
});
it('does not save a delivery from an Amazon Q project, whose prompts exist under every delivery', async () => {
// Amazon Q keeps its prompts whatever delivery is set, so they show which
// workflows are installed but not what delivery the user wants. Inferring
// `commands` from them would be saved globally and strip skills from
// every other project on the machine.
await writeManagedCommand(projectDir, 'explore', 'amazon-q');
await writeManagedCommand(projectDir, 'archive', 'amazon-q');
migrateIfNeeded(projectDir, [requireTool('amazon-q')]);
const config = readRawConfig();
expect(config.profile).toBe('custom');
expect(config.workflows).toEqual(['explore', 'archive']);
expect(config).not.toHaveProperty('delivery');
});
it('ignores leftover Amazon Q skills too when inferring delivery', async () => {
await writeManagedCommand(projectDir, 'explore', 'amazon-q');
await writeSkill(projectDir, 'openspec-explore', '.amazonq');
migrateIfNeeded(projectDir, [requireTool('amazon-q')]);
expect(readRawConfig()).not.toHaveProperty('delivery');
});
it('still infers delivery from the other tools in a project that has Amazon Q', async () => {
await writeManagedCommand(projectDir, 'explore', 'amazon-q');
await writeSkill(projectDir, 'openspec-explore');
migrateIfNeeded(projectDir, [requireTool('amazon-q'), ensureClaudeTool()]);
expect(readRawConfig().delivery).toBe('skills');
});
it('migrates to custom both delivery when managed skills and commands are detected', async () => {
await writeSkill(projectDir, 'openspec-explore');
await writeManagedCommand(projectDir, 'apply');
+70 -19
View File
@@ -1254,29 +1254,18 @@ metadata:
await initCommand.execute(testDir);
const promptFile = path.join(testDir, '.amazonq', 'prompts', 'opsx-apply.md');
const skillFile = path.join(
testDir,
'.amazonq',
'skills',
'openspec-apply-change',
'SKILL.md'
);
for (const file of [promptFile, skillFile]) {
const stale = (await fs.readFile(file, 'utf-8')).replace(/@opsx-/g, '/opsx:');
await fs.writeFile(file, stale);
}
const stale = (await fs.readFile(promptFile, 'utf-8')).replace(/@opsx-/g, '/opsx:');
await fs.writeFile(promptFile, stale);
expect(await fs.readFile(promptFile, 'utf-8')).toContain('/opsx:apply');
await new UpdateCommand({ force: true }).execute(testDir);
for (const file of [promptFile, skillFile]) {
const refreshed = await fs.readFile(file, 'utf-8');
// Positive assertion too: dropping every reference would satisfy the
// negative ones. And no stray slash may survive the rewrite.
expect(refreshed).toContain('@opsx-apply');
expect(refreshed).not.toContain('/opsx:');
expect(refreshed).not.toContain('/opsx-');
}
const refreshed = await fs.readFile(promptFile, 'utf-8');
// Positive assertion too: dropping every reference would satisfy the
// negative ones. And no stray slash may survive the rewrite.
expect(refreshed).toContain('@opsx-apply');
expect(refreshed).not.toContain('/opsx:');
expect(refreshed).not.toContain('/opsx-');
// The prompt body cross-references other prompts; those move too.
expect(await fs.readFile(promptFile, 'utf-8')).toContain('@opsx-archive');
});
@@ -2138,6 +2127,68 @@ metadata:
});
});
describe('commands-only tools (Amazon Q)', () => {
const prompts = () => path.join(testDir, '.amazonq', 'prompts');
const skills = () => path.join(testDir, '.amazonq', 'skills');
it('keeps Amazon Q prompts under skills-only delivery, its only working surface', async () => {
setMockConfig({ featureFlags: {}, profile: 'core', delivery: 'skills' });
await new InitCommand({ tools: 'amazon-q', force: true }).execute(testDir);
await new UpdateCommand({ force: true }).execute(testDir);
expect((await fs.readdir(prompts())).sort()).toContain('opsx-propose.md');
expect(await FileSystemUtils.fileExists(skills())).toBe(false);
});
it('leaves skill files from an older install untouched rather than deleting them', async () => {
// Written by an earlier release, which gave Amazon Q skills it never
// loaded. They are inert; removing directories as a side effect of an
// upgrade is not worth the risk.
await new InitCommand({ tools: 'amazon-q', force: true }).execute(testDir);
const leftover = path.join(skills(), 'openspec-propose', 'SKILL.md');
await fs.mkdir(path.dirname(leftover), { recursive: true });
const leftoverContent = '---\nname: openspec-propose\nmetadata:\n generatedBy: "0.1.0"\n---\nold\n';
await fs.writeFile(leftover, leftoverContent);
setMockConfig({ featureFlags: {}, profile: 'core', delivery: 'skills' });
await new UpdateCommand({ force: true }).execute(testDir);
expect(await fs.readFile(leftover, 'utf-8')).toBe(leftoverContent);
});
it('does not ask to update Amazon Q forever because of old skill files', async () => {
// Detection used to read a version from the skill files. Once this CLI
// stops writing them, an old version there would mark the tool stale on
// every run.
await new InitCommand({ tools: 'amazon-q', force: true }).execute(testDir);
const leftover = path.join(skills(), 'openspec-propose', 'SKILL.md');
await fs.mkdir(path.dirname(leftover), { recursive: true });
await fs.writeFile(leftover, '---\nname: openspec-propose\nmetadata:\n generatedBy: "0.1.0"\n---\nold\n');
const consoleSpy = vi.spyOn(console, 'log');
await new UpdateCommand().execute(testDir);
const logs = consoleSpy.mock.calls.flat().map(String);
expect(logs.some((entry) => entry.includes('up to date'))).toBe(true);
expect(logs.some((entry) => entry.includes('Updating 1 tool'))).toBe(false);
});
it('clears another tool\'s commands under skills-only delivery but keeps Amazon Q\'s', async () => {
await new InitCommand({ tools: 'claude,amazon-q', force: true }).execute(testDir);
setMockConfig({ featureFlags: {}, profile: 'core', delivery: 'skills' });
await new UpdateCommand({ force: true }).execute(testDir);
expect(
await FileSystemUtils.fileExists(path.join(testDir, '.claude', 'commands', 'opsx', 'propose.md'))
).toBe(false);
expect(
await FileSystemUtils.fileExists(path.join(testDir, '.claude', 'skills', 'openspec-propose', 'SKILL.md'))
).toBe(true);
expect(await FileSystemUtils.fileExists(path.join(prompts(), 'opsx-propose.md'))).toBe(true);
});
});
describe('tool detection', () => {
it('should detect tool as configured only when skill file exists', async () => {
// Create skills directory but no skill files