Compare commits

...
Author SHA1 Message Date
Tabish Bidiwale 56a4d88649 cleanup init command 2025-10-01 22:47:27 +10:00
Tabish Bidiwale 363262c8a8 Merge main into always-install-agentsmd 2025-10-01 22:15:10 +10:00
Tabish Bidiwale d7e905066c Update CLI init to install root agents 2025-10-01 21:55:21 +10:00
9 changed files with 326 additions and 83 deletions
+6
View File
@@ -1,5 +1,11 @@
# @fission-ai/openspec
## Unreleased
### Minor Changes
- Always generate the root `AGENTS.md` stub during `openspec init`, regroup the tool prompt into native vs. other assistants, and refresh docs to reflect the universal hand-off.
## 0.7.0
### Minor Changes
+2 -2
View File
@@ -124,8 +124,8 @@ openspec init
```
**What happens during initialization:**
- You'll be prompted to select your AI tool (Claude Code, Cursor, etc.)
- OpenSpec automatically configures slash commands or `AGENTS.md` based on your selection
- You'll be prompted to pick any natively supported AI tools (Claude Code, Cursor, OpenCode, etc.); other assistants always rely on the shared `AGENTS.md` stub
- OpenSpec automatically configures slash commands for the tools you choose and always writes a managed `AGENTS.md` hand-off at the project root
- A new `openspec/` directory structure is created in your project
**After setup:**
@@ -0,0 +1,15 @@
## Why
OpenSpec currently creates the root-level `AGENTS.md` stub only when teams explicitly select the "AGENTS.md standard" tool during `openspec init`. Projects that skip that checkbox never get a managed stub, so non-native assistants (Copilot, Codeium, etc.) have no entry point and later `openspec update` runs silently create the file without any context. We need to bake the stub into initialization, clarify the tool selection experience, and keep the update workflow aligned so every teammate lands on the right instructions from day one.
## What Changes
- Update `openspec init` so the root `AGENTS.md` stub is always generated (first run and extend mode) and refreshed from a shared utility instead of being tied to a tool selection.
- Redesign the AI tool selection wizard to split options into "Natively supported" (Claude, Cursor, OpenCode, …) and an informational "Other tools" section that explains the always-on `AGENTS.md` hand-off.
- Adjust CLI specs, prompts, and success messaging to reflect the new categories while keeping extend-mode behaviour consistent.
- Update automated tests and fixtures to cover the unconditional stub creation and the reworked prompt flow.
- Refresh documentation and onboarding snippets so they no longer describe the stub as opt-in and instead call out the new grouping.
- Ensure `openspec update` continues to reconcile both `openspec/AGENTS.md` and the root stub, documenting the expected behaviour so mismatched setups self-heal.
## Impact
- Affected specs: `cli-init`, `cli-update`
- Affected code: `src/core/init.ts`, `src/core/config.ts`, `src/core/configurators/agents.ts`, `src/core/templates/agents-root-stub.ts`, `src/core/update.ts`, related tests under `test/core/`
- Docs & assets: README, CHANGELOG, any setup guides that reference choosing the "AGENTS.md standard" option
@@ -0,0 +1,32 @@
## MODIFIED Requirements
### Requirement: AI Tool Configuration
The command SHALL configure AI coding assistants with OpenSpec instructions using a grouped selection experience so teams can enable native integrations while always provisioning guidance for other assistants.
#### Scenario: Prompting for AI tool selection
- **WHEN** run interactively
- **THEN** present a multi-select wizard that separates options into two headings:
- **Natively supported providers** shows each available first-party integration (Claude Code, Cursor, OpenCode, …) with checkboxes
- **Other tools** explains that the root-level `AGENTS.md` stub is always generated for AGENTS-compatible assistants and cannot be deselected
- **AND** mark already configured native tools with "(already configured)" to signal that choosing them will refresh managed content
- **AND** keep disabled or unavailable providers labelled as "coming soon" so users know they cannot opt in yet
- **AND** allow confirming the selection even when no native provider is chosen because the root stub remains enabled by default
- **AND** change the base prompt copy in extend mode to "Which natively supported AI tools would you like to add or refresh?"
### Requirement: Exit Code Adjustments
`openspec init` SHALL treat extend mode without new native tool selections as a successful refresh.
#### Scenario: Allowing empty extend runs
- **WHEN** OpenSpec is already initialized and the user selects no additional natively supported tools
- **THEN** complete successfully while refreshing the root `AGENTS.md` stub
- **AND** exit with code 0
## ADDED Requirements
### Requirement: Root instruction stub
`openspec init` SHALL always scaffold the root-level `AGENTS.md` hand-off so every teammate finds the primary OpenSpec instructions.
#### Scenario: Creating root `AGENTS.md`
- **GIVEN** the project may or may not already contain an `AGENTS.md` file
- **WHEN** initialization completes in fresh or extend mode
- **THEN** create or refresh `AGENTS.md` at the repository root using the managed marker block from `TemplateManager.getAgentsStandardTemplate()`
- **AND** preserve any existing content outside the managed markers while replacing the stub text inside them
- **AND** create the stub regardless of which native AI tools are selected
@@ -0,0 +1,10 @@
## MODIFIED Requirements
### Requirement: Tool-Agnostic Updates
The update command SHALL refresh OpenSpec-managed files in a predictable manner while respecting each team's chosen tooling.
#### Scenario: Updating files
- **WHEN** updating files
- **THEN** completely replace `openspec/AGENTS.md` with the latest template
- **AND** create or refresh the root-level `AGENTS.md` stub using the managed marker block, even if the file was previously absent
- **AND** update only the OpenSpec-managed sections inside existing AI tool files, leaving user-authored content untouched
- **AND** avoid creating new native-tool configuration files (slash commands, CLAUDE.md, etc.) unless they already exist
@@ -0,0 +1,11 @@
## 1. Implementation
- [ ] 1.1 Refactor `openspec init` to always generate the root `AGENTS.md` stub (initial run and extend mode) via shared helper logic.
- [ ] 1.2 Rework the AI tool selection wizard to surface "Natively supported" vs "Other tools" groupings and make the stub non-optional.
- [ ] 1.3 Update CLI messaging, templates, and configurators so the new flow stays in sync across init and update commands.
- [ ] 1.4 Refresh unit/integration tests to cover the unconditional stub and the regrouped prompt layout.
- [ ] 1.5 Update documentation, README snippets, and CHANGELOG entries that mention the opt-in `AGENTS.md` experience.
## 2. Validation
- [ ] 2.1 Run `pnpm test` targeting CLI init/update suites.
- [ ] 2.2 Execute `openspec validate update-cli-init-root-agents --strict`.
- [ ] 2.3 Perform a manual smoke test: run `openspec init` in a temp directory, confirm stub + grouped prompts, rerun in extend mode.
+5 -5
View File
@@ -17,9 +17,9 @@ export interface AIToolOption {
}
export const AI_TOOLS: AIToolOption[] = [
{ name: 'Claude Code (✅ OpenSpec custom slash commands available)', value: 'claude', available: true, successLabel: 'Claude Code' },
{ name: 'Cursor (✅ OpenSpec custom slash commands available)', value: 'cursor', available: true, successLabel: 'Cursor' },
{ name: 'OpenCode (✅ OpenSpec custom slash commands available)', value: 'opencode', available: true, successLabel: 'OpenCode' },
{ name: 'Kilo Code (✅ OpenSpec workflows available)', value: 'kilocode', available: true, successLabel: 'Kilo Code' },
{ name: 'AGENTS.md (works with Codex, Amp, Copilot, …)', value: 'agents', available: true, successLabel: 'your AGENTS.md-compatible assistant' }
{ name: 'Claude Code', value: 'claude', available: true, successLabel: 'Claude Code' },
{ name: 'Cursor', value: 'cursor', available: true, successLabel: 'Cursor' },
{ name: 'OpenCode', value: 'opencode', available: true, successLabel: 'OpenCode' },
{ name: 'Kilo Code', value: 'kilocode', available: true, successLabel: 'Kilo Code' },
{ name: 'AGENTS.md (works with Codex, Amp, VS Code, GitHub Copilot, …)', value: 'agents', available: false, successLabel: 'your AGENTS.md-compatible assistant' }
];
+237 -68
View File
@@ -59,11 +59,24 @@ const parseToolLabel = (raw: string): ToolLabel => {
};
};
type ToolWizardChoice = {
value: string;
label: ToolLabel;
configured: boolean;
};
const isSelectableChoice = (
choice: ToolWizardChoice
): choice is Extract<ToolWizardChoice, { selectable: true }> => choice.selectable;
type ToolWizardChoice =
| {
kind: 'heading' | 'info';
value: string;
label: ToolLabel;
selectable: false;
}
| {
kind: 'option';
value: string;
label: ToolLabel;
configured: boolean;
selectable: true;
};
type ToolWizardConfig = {
extendMode: boolean;
@@ -76,21 +89,41 @@ type WizardStep = 'intro' | 'select' | 'review';
type ToolSelectionPrompt = (config: ToolWizardConfig) => Promise<string[]>;
type RootStubStatus = 'created' | 'updated' | 'skipped';
const ROOT_STUB_CHOICE_VALUE = '__root_stub__';
const OTHER_TOOLS_HEADING_VALUE = '__heading-other__';
const LIST_SPACER_VALUE = '__list-spacer__';
const toolSelectionWizard = createPrompt<string[], ToolWizardConfig>(
(config, done) => {
const totalSteps = 3;
const [step, setStep] = useState<WizardStep>('intro');
const [cursor, setCursor] = useState<number>(0);
const [selected, setSelected] = useState<string[]>(
() => config.initialSelected ?? []
const selectableChoices = config.choices.filter(isSelectableChoice);
const initialCursorIndex = config.choices.findIndex((choice) =>
choice.selectable
);
const [cursor, setCursor] = useState<number>(
initialCursorIndex === -1 ? 0 : initialCursorIndex
);
const [selected, setSelected] = useState<string[]>(() => {
const initial = new Set(
(config.initialSelected ?? []).filter((value) =>
selectableChoices.some((choice) => choice.value === value)
)
);
return selectableChoices
.map((choice) => choice.value)
.filter((value) => initial.has(value));
});
const [error, setError] = useState<string | null>(null);
const selectedSet = new Set(selected);
const pageSize = Math.max(Math.min(config.choices.length, 7), 1);
const pageSize = Math.max(config.choices.length, 1);
const updateSelected = (next: Set<string>) => {
const ordered = config.choices
const ordered = selectableChoices
.map((choice) => choice.value)
.filter((value) => next.has(value));
setSelected(ordered);
@@ -100,8 +133,17 @@ const toolSelectionWizard = createPrompt<string[], ToolWizardConfig>(
items: config.choices,
active: cursor,
pageSize,
loop: config.choices.length > 1,
loop: false,
renderItem: ({ item, isActive }) => {
if (!item.selectable) {
const prefix = item.kind === 'info' ? ' ' : '';
const textColor =
item.kind === 'heading' ? PALETTE.lightGray : PALETTE.midGray;
return `${PALETTE.midGray(' ')} ${PALETTE.midGray(' ')} ${textColor(
`${prefix}${item.label.primary}`
)}`;
}
const isSelected = selectedSet.has(item.value);
const cursorSymbol = isActive
? PALETTE.white('›')
@@ -110,13 +152,36 @@ const toolSelectionWizard = createPrompt<string[], ToolWizardConfig>(
? PALETTE.white('◉')
: PALETTE.midGray('○');
const nameColor = isActive ? PALETTE.white : PALETTE.midGray;
const label = `${nameColor(item.label.primary)}${
item.configured ? PALETTE.midGray(' (already configured)') : ''
}`;
const annotation = item.label.annotation
? PALETTE.midGray(` (${item.label.annotation})`)
: '';
const configuredNote = item.configured
? PALETTE.midGray(' (already configured)')
: '';
const label = `${nameColor(item.label.primary)}${annotation}${configuredNote}`;
return `${cursorSymbol} ${indicator} ${label}`;
},
});
const moveCursor = (direction: 1 | -1) => {
if (selectableChoices.length === 0) {
return;
}
let nextIndex = cursor;
while (true) {
nextIndex = nextIndex + direction;
if (nextIndex < 0 || nextIndex >= config.choices.length) {
return;
}
if (config.choices[nextIndex]?.selectable) {
setCursor(nextIndex);
return;
}
}
};
useKeypress((key) => {
if (step === 'intro') {
if (isEnterKey(key)) {
@@ -127,24 +192,20 @@ const toolSelectionWizard = createPrompt<string[], ToolWizardConfig>(
if (step === 'select') {
if (isUpKey(key)) {
const previousIndex =
cursor <= 0 ? config.choices.length - 1 : cursor - 1;
setCursor(previousIndex);
moveCursor(-1);
setError(null);
return;
}
if (isDownKey(key)) {
const nextIndex =
cursor >= config.choices.length - 1 ? 0 : cursor + 1;
setCursor(nextIndex);
moveCursor(1);
setError(null);
return;
}
if (isSpaceKey(key)) {
const current = config.choices[cursor];
if (!current) return;
if (!current || !current.selectable) return;
const next = new Set(selected);
if (next.has(current.value)) {
@@ -159,17 +220,14 @@ const toolSelectionWizard = createPrompt<string[], ToolWizardConfig>(
}
if (isEnterKey(key)) {
if (selected.length === 0) {
setError('Select at least one AI tool to continue.');
return;
}
setStep('review');
setError(null);
return;
}
if (key.name === 'escape') {
setSelected([]);
const next = new Set<string>();
updateSelected(next);
setError(null);
}
return;
@@ -179,7 +237,10 @@ const toolSelectionWizard = createPrompt<string[], ToolWizardConfig>(
if (isEnterKey(key)) {
const finalSelection = config.choices
.map((choice) => choice.value)
.filter((value) => selectedSet.has(value));
.filter(
(value) =>
selectedSet.has(value) && value !== ROOT_STUB_CHOICE_VALUE
);
done(finalSelection);
return;
}
@@ -191,9 +252,30 @@ const toolSelectionWizard = createPrompt<string[], ToolWizardConfig>(
}
});
const selectedNames = config.choices
.filter((choice) => selectedSet.has(choice.value))
.map((choice) => choice.label.primary);
const rootStubChoice = selectableChoices.find(
(choice) => choice.value === ROOT_STUB_CHOICE_VALUE
);
const rootStubSelected = rootStubChoice
? selectedSet.has(ROOT_STUB_CHOICE_VALUE)
: false;
const nativeChoices = selectableChoices.filter(
(choice) => choice.value !== ROOT_STUB_CHOICE_VALUE
);
const selectedNativeChoices = nativeChoices.filter((choice) =>
selectedSet.has(choice.value)
);
const formatSummaryLabel = (
choice: Extract<ToolWizardChoice, { selectable: true }>
) => {
const annotation = choice.label.annotation
? PALETTE.midGray(` (${choice.label.annotation})`)
: '';
const configuredNote = choice.configured
? PALETTE.midGray(' (already configured)')
: '';
return `${PALETTE.white(choice.label.primary)}${annotation}${configuredNote}`;
};
const stepIndex = step === 'intro' ? 1 : step === 'select' ? 2 : 3;
const lines: string[] = [];
@@ -222,16 +304,21 @@ const toolSelectionWizard = createPrompt<string[], ToolWizardConfig>(
lines.push('');
lines.push(page);
lines.push('');
if (selectedNames.length === 0) {
lines.push(PALETTE.midGray('Selected configuration:'));
if (rootStubSelected && rootStubChoice) {
lines.push(
`${PALETTE.midGray('Selected')}: ${PALETTE.midGray(
'None selected yet'
)}`
` ${PALETTE.white('-')} ${formatSummaryLabel(rootStubChoice)}`
);
}
if (selectedNativeChoices.length === 0) {
lines.push(
` ${PALETTE.midGray('- No natively supported providers selected')}`
);
} else {
lines.push(PALETTE.midGray('Selected:'));
selectedNames.forEach((name) => {
lines.push(` ${PALETTE.white('-')} ${PALETTE.white(name)}`);
selectedNativeChoices.forEach((choice) => {
lines.push(
` ${PALETTE.white('-')} ${formatSummaryLabel(choice)}`
);
});
}
} else {
@@ -241,13 +328,23 @@ const toolSelectionWizard = createPrompt<string[], ToolWizardConfig>(
);
lines.push('');
if (selectedNames.length === 0) {
if (rootStubSelected && rootStubChoice) {
lines.push(
PALETTE.midGray('No tools selected. Press Backspace to return.')
`${PALETTE.white('▌')} ${formatSummaryLabel(rootStubChoice)}`
);
}
if (selectedNativeChoices.length === 0) {
lines.push(
PALETTE.midGray(
'No natively supported providers selected. Universal instructions will still be applied.'
)
);
} else {
selectedNames.forEach((name) => {
lines.push(`${PALETTE.white('▌')} ${PALETTE.white(name)}`);
selectedNativeChoices.forEach((choice) => {
lines.push(
`${PALETTE.white('▌')} ${formatSummaryLabel(choice)}`
);
});
}
}
@@ -285,17 +382,6 @@ export class InitCommand {
// Get configuration (after validation to avoid prompts if validation fails)
const config = await this.getConfiguration(existingToolStates, extendMode);
if (config.aiTools.length === 0) {
if (extendMode) {
throw new Error(
`OpenSpec seems to already be initialized at ${openspecPath}.\n` +
`Use 'openspec update' to update the structure.`
);
}
throw new Error('You must select at least one AI tool to configure.');
}
const availableTools = AI_TOOLS.filter((tool) => tool.available);
const selectedIds = new Set(config.aiTools);
const selectedTools = availableTools.filter((tool) =>
@@ -335,7 +421,11 @@ export class InitCommand {
// Step 2: Configure AI tools
const toolSpinner = this.startSpinner('Configuring AI tools...');
await this.configureAITools(projectPath, openspecDir, config.aiTools);
const rootStubStatus = await this.configureAITools(
projectPath,
openspecDir,
config.aiTools
);
toolSpinner.stopAndPersist({
symbol: PALETTE.white('▌'),
text: PALETTE.white('AI tools configured'),
@@ -348,7 +438,8 @@ export class InitCommand {
refreshed,
skippedExisting,
skipped,
extendMode
extendMode,
rootStubStatus
);
}
@@ -382,27 +473,69 @@ export class InitCommand {
): Promise<string[]> {
const availableTools = AI_TOOLS.filter((tool) => tool.available);
if (availableTools.length === 0) {
return [];
}
const baseMessage = extendMode
? 'Which AI tools would you like to add or refresh?'
: 'Which AI tools do you use?';
const initialSelected = extendMode
? 'Which natively supported AI tools would you like to add or refresh?'
: 'Which natively supported AI tools do you use?';
const initialNativeSelection = extendMode
? availableTools
.filter((tool) => existingTools[tool.value])
.map((tool) => tool.value)
: [];
return this.prompt({
extendMode,
baseMessage,
choices: availableTools.map((tool) => ({
const initialSelected = Array.from(new Set(initialNativeSelection));
const choices: ToolWizardChoice[] = [
{
kind: 'heading',
value: '__heading-native__',
label: {
primary:
'Natively supported providers (✔ OpenSpec custom slash commands available)',
},
selectable: false,
},
...availableTools.map<ToolWizardChoice>((tool) => ({
kind: 'option',
value: tool.value,
label: parseToolLabel(tool.name),
configured: Boolean(existingTools[tool.value]),
selectable: true,
})),
...(availableTools.length
? ([
{
kind: 'info' as const,
value: LIST_SPACER_VALUE,
label: { primary: '' },
selectable: false,
},
] as ToolWizardChoice[])
: []),
{
kind: 'heading',
value: OTHER_TOOLS_HEADING_VALUE,
label: {
primary:
'Other tools (use Universal AGENTS.md for Codex, Amp, VS Code, GitHub Copilot, …)',
},
selectable: false,
},
{
kind: 'option',
value: ROOT_STUB_CHOICE_VALUE,
label: {
primary: 'Universal AGENTS.md',
annotation: 'always available',
},
configured: extendMode,
selectable: true,
},
];
return this.prompt({
extendMode,
baseMessage,
choices,
initialSelected,
});
}
@@ -475,7 +608,12 @@ export class InitCommand {
projectPath: string,
openspecDir: string,
toolIds: string[]
): Promise<void> {
): Promise<RootStubStatus> {
const rootStubStatus = await this.configureRootAgentsStub(
projectPath,
openspecDir
);
for (const toolId of toolIds) {
const configurator = ToolRegistry.get(toolId);
if (configurator && configurator.isAvailable) {
@@ -487,6 +625,25 @@ export class InitCommand {
await slashConfigurator.generateAll(projectPath, openspecDir);
}
}
return rootStubStatus;
}
private async configureRootAgentsStub(
projectPath: string,
openspecDir: string
): Promise<RootStubStatus> {
const configurator = ToolRegistry.get('agents');
if (!configurator || !configurator.isAvailable) {
return 'skipped';
}
const stubPath = path.join(projectPath, configurator.configFileName);
const existed = await FileSystemUtils.fileExists(stubPath);
await configurator.configure(projectPath, openspecDir);
return existed ? 'updated' : 'created';
}
private displaySuccessMessage(
@@ -495,7 +652,8 @@ export class InitCommand {
refreshed: AIToolOption[],
skippedExisting: AIToolOption[],
skipped: AIToolOption[],
extendMode: boolean
extendMode: boolean,
rootStubStatus: RootStubStatus
): void {
console.log(); // Empty line for spacing
const successHeadline = extendMode
@@ -506,6 +664,16 @@ export class InitCommand {
console.log();
console.log(PALETTE.lightGray('Tool summary:'));
const summaryLines = [
rootStubStatus === 'created'
? `${PALETTE.white('▌')} ${PALETTE.white(
'Root AGENTS.md stub created for other assistants'
)}`
: null,
rootStubStatus === 'updated'
? `${PALETTE.lightGray('▌')} ${PALETTE.lightGray(
'Root AGENTS.md stub refreshed for other assistants'
)}`
: null,
created.length
? `${PALETTE.white('▌')} ${PALETTE.white(
'Created:'
@@ -587,7 +755,8 @@ export class InitCommand {
.map((tool) => tool.successLabel ?? tool.name)
.filter((name): name is string => Boolean(name));
if (names.length === 0) return PALETTE.lightGray('your AI assistant');
if (names.length === 0)
return PALETTE.lightGray('your AGENTS.md-compatible assistant');
if (names.length === 1) return PALETTE.white(names[0]);
const base = names.slice(0, -1).map((name) => PALETTE.white(name));
+8 -8
View File
@@ -129,8 +129,8 @@ describe('InitCommand', () => {
expect(updatedContent).toContain('Custom instructions here');
});
it('should create AGENTS.md in project root when AGENTS standard is selected', async () => {
queueSelections('agents', DONE);
it('should always create AGENTS.md in project root', async () => {
queueSelections(DONE);
await initCommand.execute(testDir);
@@ -313,12 +313,10 @@ describe('InitCommand', () => {
expect(await fileExists(cursorProposal)).toBe(true);
});
it('should error when extend mode selects no tools', async () => {
it('should allow extend mode with no additional native tools', async () => {
queueSelections('claude', DONE, DONE);
await initCommand.execute(testDir);
await expect(initCommand.execute(testDir)).rejects.toThrow(
/OpenSpec seems to already be initialized/
);
await expect(initCommand.execute(testDir)).resolves.toBeUndefined();
});
it('should handle non-existent target directory', async () => {
@@ -342,7 +340,7 @@ describe('InitCommand', () => {
});
it('should reference AGENTS compatible assistants in success message', async () => {
queueSelections('agents', DONE);
queueSelections(DONE);
const logSpy = vi.spyOn(console, 'log');
await initCommand.execute(testDir);
@@ -362,7 +360,9 @@ describe('InitCommand', () => {
expect(mockPrompt).toHaveBeenCalledWith(
expect.objectContaining({
baseMessage: expect.stringContaining('Which AI tools do you use?'),
baseMessage: expect.stringContaining(
'Which natively supported AI tools do you use?'
),
})
);
});