mirror of
https://github.com/Fission-AI/OpenSpec.git
synced 2026-10-03 22:13:19 +08:00
Compare commits
5
Commits
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
ffa7c17e17 | ||
|
|
7a44a5514b | ||
|
|
8e5d025ef5 | ||
|
|
3b72a98fed | ||
|
|
e28ccd0c46 |
@@ -1,19 +1,8 @@
|
||||
# Repository Guidelines
|
||||
<!-- OPENSPEC:START -->
|
||||
# OpenSpec Instructions
|
||||
|
||||
## Project Structure & Module Organization
|
||||
OpenSpec ships as a TypeScript-first CLI. Source code lives in `src`, with feature logic in `core`, interactive flows in `cli`, reusable helpers in `utils`, and command wiring in `commands`. After `pnpm run build`, deliverables land in `dist` and feed the published entry point `bin/openspec.js`. Specs and change proposals reside in `openspec/specs` and `openspec/changes`; update them whenever behavior shifts so automation stays aligned. Shared assets live in `assets`, and Vitest suites in `test` mirror the source layout for easy cross-reference.
|
||||
This project uses OpenSpec to manage AI assistant workflows.
|
||||
|
||||
## Build, Test, and Development Commands
|
||||
Run `pnpm install` to sync dependencies. `pnpm run build` compiles TypeScript to `dist` and must stay green before release. Use `pnpm run dev` for a `tsc --watch` loop and `pnpm run dev:cli` to rebuild then execute the local CLI. `pnpm test` runs the Vitest suite once, `pnpm run test:watch` keeps it hot while iterating, and `pnpm run test:coverage` verifies instrumentation thresholds. Use `pnpm run changeset` when preparing a release entry.
|
||||
|
||||
## Coding Style & Naming Conventions
|
||||
We follow idiomatic TypeScript with ES modules, 2-space indentation, and semicolons. Prefer named exports from index barrels and keep filenames kebab-cased (e.g., `list-command.ts`). Classes use `PascalCase`, functions and variables use `camelCase`, and constants representing flags may use `SCREAMING_SNAKE_CASE`. Keep modules small, colocate helpers under `src/utils`, and avoid new dependencies without spec-backed justification.
|
||||
|
||||
## Testing Guidelines
|
||||
Every behavior change needs Vitest coverage under `test`, co-located by feature (e.g., `test/core/update.test.ts`). Name suites after the module under test and lean on `vitest.setup.ts` for shared configuration. Run `pnpm test` before pushing and add regression cases for each bug fix or spec requirement.
|
||||
|
||||
## Commit & Pull Request Guidelines
|
||||
Commits follow Conventional Commits (`type(scope): subject`) and stay single-line. Reference the touched module in the scope when practical. Each PR should summarize the spec or issue it fulfills, list manual verification steps, and note updates to any `openspec/` assets. Include CLI output snippets or screenshots when the UX changes, and ensure CI and coverage checks pass before requesting review.
|
||||
|
||||
## OpenSpec Workflow Tips
|
||||
Treat specs as the contract: update `openspec/project.md` or the relevant `openspec/specs/*.md` before coding, then run `pnpm run dev:cli` to validate the CLI against the revised artifacts. `openspec list --specs` confirms the catalog, and `openspec change` drafts proposals—commit these alongside code so reviewers can trace rationale to implementation.
|
||||
- Full guidance lives in '@/openspec/AGENTS.md'.
|
||||
- Keep this managed block so 'openspec update' can refresh the instructions.
|
||||
<!-- OPENSPEC:END -->
|
||||
|
||||
@@ -0,0 +1,13 @@
|
||||
## Why
|
||||
The project root currently receives a full copy of the OpenSpec agent instructions, duplicating the content that also lives in `openspec/AGENTS.md`. When teams edit one copy but not the other, the files drift and onboarding assistants see conflicting guidance.
|
||||
|
||||
## What Changes
|
||||
- Keep generating the complete template in `openspec/AGENTS.md` during `openspec init` and follow-up updates.
|
||||
- Replace the root-level file (`AGENTS.md` or `CLAUDE.md`, depending on tool selection) with a short hand-off that explains the project uses OpenSpec and points directly to `openspec/AGENTS.md`.
|
||||
- Add a dedicated stub template so both the init and update flows reuse the same minimal copy instructions.
|
||||
- Update CLI tests and documentation to reflect the new root-level messaging and ensure the OpenSpec marker block still protects future updates.
|
||||
|
||||
## Impact
|
||||
- Affected specs: `cli-init`, `cli-update`
|
||||
- Affected code: `src/core/init.ts`, `src/core/update.ts`, `src/core/templates/agents-template.ts`
|
||||
- Update assets/readmes that mention the root `AGENTS.md` contents to reference the new stub message.
|
||||
@@ -0,0 +1,15 @@
|
||||
## 1. Templates
|
||||
- [x] 1.1 Add a shared stub template that renders the root agent instructions hand-off message.
|
||||
- [x] 1.2 Ensure the stub covers both `AGENTS.md` and `CLAUDE.md` variants.
|
||||
|
||||
## 2. Init Flow
|
||||
- [x] 2.1 Update `createInitArtifacts` to write the stub to the project root instead of the full instructions.
|
||||
- [x] 2.2 Preserve the managed block markers so future updates can overwrite the stub safely.
|
||||
|
||||
## 3. Update Flow
|
||||
- [x] 3.1 Make the update command refresh the root stub rather than the full instructions.
|
||||
- [x] 3.2 Confirm the update log output still reflects the files that changed.
|
||||
|
||||
## 4. Tests & Docs
|
||||
- [x] 4.1 Adjust CLI/init tests to match the new root content.
|
||||
- [x] 4.2 Document the stub message in `openspec/specs/cli-init` and `openspec/specs/cli-update` (and any relevant README snippets).
|
||||
@@ -38,7 +38,7 @@ The command SHALL generate required template files with appropriate content for
|
||||
|
||||
#### Scenario: Generating template files
|
||||
- **WHEN** initializing OpenSpec
|
||||
- **THEN** generate `AGENTS.md` containing complete OpenSpec instructions for AI assistants
|
||||
- **THEN** generate `openspec/AGENTS.md` containing complete OpenSpec instructions for AI assistants
|
||||
- **AND** generate `project.md` with project context template
|
||||
|
||||
### Requirement: AI Tool Configuration
|
||||
@@ -52,7 +52,7 @@ The command SHALL configure AI coding assistants with OpenSpec instructions base
|
||||
- **AND** list every available tool with a checkbox:
|
||||
- Claude Code (creates or refreshes CLAUDE.md and slash commands)
|
||||
- Cursor (creates or refreshes `.cursor/commands/*` slash commands)
|
||||
- AGENTS.md standard (creates or refreshes AGENTS.md with OpenSpec markers)
|
||||
- AGENTS.md standard (creates or refreshes AGENTS.md stub with OpenSpec markers)
|
||||
- **AND** show "(already configured)" beside tools whose managed files exist so users understand selections will refresh content
|
||||
- **AND** treat disabled tools as "coming soon" and keep them unselectable
|
||||
- **AND** allow confirming with Enter after selecting one or more tools
|
||||
@@ -65,16 +65,22 @@ The command SHALL properly configure selected AI tools with OpenSpec-specific in
|
||||
|
||||
- **WHEN** Claude Code is selected
|
||||
- **THEN** create or update `CLAUDE.md` in the project root directory (not inside openspec/)
|
||||
- **AND** populate the managed block with a short stub that points teammates to `@/openspec/AGENTS.md`
|
||||
|
||||
#### Scenario: Creating new CLAUDE.md
|
||||
|
||||
- **WHEN** CLAUDE.md does not exist
|
||||
- **THEN** create new file with OpenSpec content wrapped in markers:
|
||||
- **THEN** create new file with stub instructions wrapped in markers so the full workflow stays in `openspec/AGENTS.md`:
|
||||
```markdown
|
||||
<!-- OPENSPEC:START -->
|
||||
# OpenSpec Instructions
|
||||
|
||||
Instructions for AI coding assistants using OpenSpec for spec-driven development.
|
||||
This project uses OpenSpec to manage AI assistant workflows.
|
||||
|
||||
- Full guidance lives in '@/openspec/AGENTS.md'.
|
||||
- Keep this managed block so 'openspec update' can refresh the instructions.
|
||||
<!-- OPENSPEC:END -->
|
||||
```
|
||||
|
||||
### Requirement: Interactive Mode
|
||||
The command SHALL provide an interactive menu for AI tool selection with clear navigation instructions.
|
||||
@@ -168,4 +174,4 @@ Manual creation of OpenSpec structure is error-prone and creates adoption fricti
|
||||
- Consistent structure across all projects
|
||||
- Proper AI instruction files are always included
|
||||
- Quick onboarding for new projects
|
||||
- Clear conventions from the start
|
||||
- Clear conventions from the start
|
||||
|
||||
@@ -10,6 +10,7 @@ The update command SHALL update OpenSpec instruction files to the latest templat
|
||||
#### Scenario: Running update command
|
||||
- **WHEN** a user runs `openspec update`
|
||||
- **THEN** replace `openspec/AGENTS.md` with the latest template
|
||||
- **AND** if a root-level stub (`AGENTS.md`/`CLAUDE.md`) exists, refresh it so it points to `@/openspec/AGENTS.md`
|
||||
|
||||
### Requirement: Prerequisites
|
||||
|
||||
@@ -28,6 +29,7 @@ The update command SHALL handle file updates in a predictable and safe manner.
|
||||
#### Scenario: Updating files
|
||||
- **WHEN** updating files
|
||||
- **THEN** completely replace `openspec/AGENTS.md` with the latest template
|
||||
- **AND** if a root-level stub exists, update the managed block content so it keeps directing teammates to `@/openspec/AGENTS.md`
|
||||
|
||||
### Requirement: Tool-Agnostic Updates
|
||||
The update command SHALL handle file updates in a predictable and safe manner while respecting team tool choices.
|
||||
@@ -36,11 +38,12 @@ The update command SHALL handle file updates in a predictable and safe manner wh
|
||||
|
||||
- **WHEN** updating files
|
||||
- **THEN** completely replace `openspec/AGENTS.md` with the latest template
|
||||
- **AND** create or update the root-level `AGENTS.md` using the OpenSpec markers
|
||||
- **AND** update the root-level `AGENTS.md` using the OpenSpec markers only when that file already exists, keeping the stub content that links to `@/openspec/AGENTS.md`
|
||||
- **AND** update only the OpenSpec-managed blocks in **existing** AI tool files using markers
|
||||
- **AND** use the default directory name `openspec`
|
||||
- **AND** be idempotent (repeated runs have no additional effect)
|
||||
- **AND** respect team members' AI tool choices by not creating additional tool files beyond the root `AGENTS.md`
|
||||
- **AND** do not create new root-level stub files when none are present
|
||||
|
||||
### Requirement: Core Files Always Updated
|
||||
The update command SHALL always update the core OpenSpec files and display an ASCII-safe success message.
|
||||
@@ -48,6 +51,7 @@ The update command SHALL always update the core OpenSpec files and display an AS
|
||||
#### Scenario: Successful update
|
||||
- **WHEN** the update completes successfully
|
||||
- **THEN** replace `openspec/AGENTS.md` with the latest template
|
||||
- **AND** if a root-level stub exists, refresh it so it still directs contributors to `@/openspec/AGENTS.md`
|
||||
|
||||
### Requirement: Slash Command Updates
|
||||
The update command SHALL refresh existing slash command files for configured tools without creating new ones.
|
||||
@@ -63,7 +67,7 @@ The update command SHALL refresh existing slash command files for configured too
|
||||
- **AND** ensure templates include instructions for the relevant workflow stage
|
||||
|
||||
#### Scenario: Updating slash commands for OpenCode
|
||||
- **WHEN** `.opencode/commands/` contains `openspec-proposal.md`, `openspec-apply.md`, and `openspec-archive.md`
|
||||
- **WHEN** `.opencode/command/` contains `openspec-proposal.md`, `openspec-apply.md`, and `openspec-archive.md`
|
||||
- **THEN** refresh each file using shared templates
|
||||
- **AND** ensure templates include instructions for the relevant workflow stage
|
||||
|
||||
@@ -104,4 +108,4 @@ Users SHALL be able to:
|
||||
The update process SHALL be:
|
||||
- Simple and fast (no version checking)
|
||||
- Predictable (same result every time)
|
||||
- Self-contained (no network required)
|
||||
- Self-contained (no network required)
|
||||
|
||||
+1
-7
@@ -22,19 +22,13 @@ import {
|
||||
OPENSPEC_DIR_NAME,
|
||||
AIToolOption,
|
||||
} from './config.js';
|
||||
import { PALETTE } from './styles/palette.js';
|
||||
|
||||
const PROGRESS_SPINNER = {
|
||||
interval: 80,
|
||||
frames: ['░░░', '▒░░', '▒▒░', '▒▒▒', '▓▒▒', '▓▓▒', '▓▓▓', '▒▓▓', '░▒▓'],
|
||||
};
|
||||
|
||||
const PALETTE = {
|
||||
white: chalk.hex('#f4f4f4'),
|
||||
lightGray: chalk.hex('#c8c8c8'),
|
||||
midGray: chalk.hex('#8a8a8a'),
|
||||
darkGray: chalk.hex('#4a4a4a'),
|
||||
};
|
||||
|
||||
const LETTER_MAP: Record<string, string[]> = {
|
||||
O: [' ████ ', '██ ██', '██ ██', '██ ██', ' ████ '],
|
||||
P: ['█████ ', '██ ██', '█████ ', '██ ', '██ '],
|
||||
|
||||
@@ -0,0 +1,8 @@
|
||||
import chalk from 'chalk';
|
||||
|
||||
export const PALETTE = {
|
||||
white: chalk.hex('#f4f4f4'),
|
||||
lightGray: chalk.hex('#c8c8c8'),
|
||||
midGray: chalk.hex('#8a8a8a'),
|
||||
darkGray: chalk.hex('#4a4a4a')
|
||||
};
|
||||
@@ -0,0 +1,16 @@
|
||||
export const agentsRootStubTemplate = `# OpenSpec Instructions
|
||||
|
||||
These instructions are for AI assistants working in this project.
|
||||
|
||||
Always open \`@/openspec/AGENTS.md\` when the request:
|
||||
- Mentions planning or proposals (words like proposal, spec, change, plan)
|
||||
- Introduces new capabilities, breaking changes, architecture shifts, or big performance/security work
|
||||
- Sounds ambiguous and you need the authoritative spec before coding
|
||||
|
||||
Use \`@/openspec/AGENTS.md\` to learn:
|
||||
- How to create and apply change proposals
|
||||
- Spec format and conventions
|
||||
- Project structure and guidelines
|
||||
|
||||
Keep this managed block so 'openspec update' can refresh the instructions.
|
||||
`;
|
||||
@@ -1 +1 @@
|
||||
export { agentsTemplate as claudeTemplate } from './agents-template.js';
|
||||
export { agentsRootStubTemplate as claudeTemplate } from './agents-root-stub.js';
|
||||
|
||||
@@ -1,6 +1,7 @@
|
||||
import { agentsTemplate } from './agents-template.js';
|
||||
import { projectTemplate, ProjectContext } from './project-template.js';
|
||||
import { claudeTemplate } from './claude-template.js';
|
||||
import { agentsRootStubTemplate } from './agents-root-stub.js';
|
||||
import { getSlashCommandBody, SlashCommandId } from './slash-command-templates.js';
|
||||
|
||||
export interface Template {
|
||||
@@ -27,7 +28,7 @@ export class TemplateManager {
|
||||
}
|
||||
|
||||
static getAgentsStandardTemplate(): string {
|
||||
return agentsTemplate;
|
||||
return agentsRootStubTemplate;
|
||||
}
|
||||
|
||||
static getSlashCommandBody(id: SlashCommandId): string {
|
||||
|
||||
+76
-49
@@ -1,10 +1,9 @@
|
||||
import path from 'path';
|
||||
import { FileSystemUtils } from '../utils/file-system.js';
|
||||
import { OPENSPEC_DIR_NAME, OPENSPEC_MARKERS } from './config.js';
|
||||
import { agentsTemplate } from './templates/agents-template.js';
|
||||
import { TemplateManager } from './templates/index.js';
|
||||
import { OPENSPEC_DIR_NAME } from './config.js';
|
||||
import { ToolRegistry } from './configurators/registry.js';
|
||||
import { SlashCommandRegistry } from './configurators/slash/registry.js';
|
||||
import { agentsTemplate } from './templates/agents-template.js';
|
||||
|
||||
export class UpdateCommand {
|
||||
async execute(projectPath: string): Promise<void> {
|
||||
@@ -19,41 +18,51 @@ export class UpdateCommand {
|
||||
|
||||
// 2. Update AGENTS.md (full replacement)
|
||||
const agentsPath = path.join(openspecPath, 'AGENTS.md');
|
||||
const rootAgentsPath = path.join(resolvedProjectPath, 'AGENTS.md');
|
||||
const rootAgentsExisted = await FileSystemUtils.fileExists(rootAgentsPath);
|
||||
|
||||
await FileSystemUtils.writeFile(agentsPath, agentsTemplate);
|
||||
const agentsStandardContent = TemplateManager.getAgentsStandardTemplate();
|
||||
await FileSystemUtils.updateFileWithMarkers(
|
||||
rootAgentsPath,
|
||||
agentsStandardContent,
|
||||
OPENSPEC_MARKERS.start,
|
||||
OPENSPEC_MARKERS.end
|
||||
);
|
||||
|
||||
// 3. Update existing AI tool configuration files only
|
||||
const configurators = ToolRegistry.getAll();
|
||||
const slashConfigurators = SlashCommandRegistry.getAll();
|
||||
let updatedFiles: string[] = [];
|
||||
let failedFiles: string[] = [];
|
||||
let updatedSlashFiles: string[] = [];
|
||||
let failedSlashTools: string[] = [];
|
||||
|
||||
const updatedFiles: string[] = [];
|
||||
const createdFiles: string[] = [];
|
||||
const failedFiles: string[] = [];
|
||||
const updatedSlashFiles: string[] = [];
|
||||
const failedSlashTools: string[] = [];
|
||||
|
||||
for (const configurator of configurators) {
|
||||
const configFilePath = path.join(resolvedProjectPath, configurator.configFileName);
|
||||
|
||||
// Only update if the file already exists
|
||||
if (await FileSystemUtils.fileExists(configFilePath)) {
|
||||
try {
|
||||
if (!await FileSystemUtils.canWriteFile(configFilePath)) {
|
||||
throw new Error(`Insufficient permissions to modify ${configurator.configFileName}`);
|
||||
}
|
||||
await configurator.configure(resolvedProjectPath, openspecPath);
|
||||
updatedFiles.push(configurator.configFileName);
|
||||
} catch (error) {
|
||||
failedFiles.push(configurator.configFileName);
|
||||
console.error(`Failed to update ${configurator.configFileName}: ${error instanceof Error ? error.message : String(error)}`);
|
||||
const configFilePath = path.join(
|
||||
resolvedProjectPath,
|
||||
configurator.configFileName
|
||||
);
|
||||
const fileExists = await FileSystemUtils.fileExists(configFilePath);
|
||||
const shouldConfigure =
|
||||
fileExists || configurator.configFileName === 'AGENTS.md';
|
||||
|
||||
if (!shouldConfigure) {
|
||||
continue;
|
||||
}
|
||||
|
||||
try {
|
||||
if (fileExists && !await FileSystemUtils.canWriteFile(configFilePath)) {
|
||||
throw new Error(
|
||||
`Insufficient permissions to modify ${configurator.configFileName}`
|
||||
);
|
||||
}
|
||||
|
||||
await configurator.configure(resolvedProjectPath, openspecPath);
|
||||
updatedFiles.push(configurator.configFileName);
|
||||
|
||||
if (!fileExists) {
|
||||
createdFiles.push(configurator.configFileName);
|
||||
}
|
||||
} catch (error) {
|
||||
failedFiles.push(configurator.configFileName);
|
||||
console.error(
|
||||
`Failed to update ${configurator.configFileName}: ${
|
||||
error instanceof Error ? error.message : String(error)
|
||||
}`
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
@@ -63,38 +72,56 @@ export class UpdateCommand {
|
||||
}
|
||||
|
||||
try {
|
||||
const updated = await slashConfigurator.updateExisting(resolvedProjectPath, openspecPath);
|
||||
updatedSlashFiles = updatedSlashFiles.concat(updated);
|
||||
const updated = await slashConfigurator.updateExisting(
|
||||
resolvedProjectPath,
|
||||
openspecPath
|
||||
);
|
||||
updatedSlashFiles.push(...updated);
|
||||
} catch (error) {
|
||||
failedSlashTools.push(slashConfigurator.toolId);
|
||||
console.error(
|
||||
`Failed to update slash commands for ${slashConfigurator.toolId}: ${error instanceof Error ? error.message : String(error)}`
|
||||
`Failed to update slash commands for ${slashConfigurator.toolId}: ${
|
||||
error instanceof Error ? error.message : String(error)
|
||||
}`
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
// 4. Success message (ASCII-safe)
|
||||
const instructionUpdates = ['openspec/AGENTS.md'];
|
||||
instructionUpdates.push(`AGENTS.md${rootAgentsExisted ? '' : ' (created)'}`);
|
||||
const summaryParts: string[] = [];
|
||||
const instructionFiles: string[] = ['openspec/AGENTS.md'];
|
||||
|
||||
const messages: string[] = [`Updated OpenSpec instructions (${instructionUpdates.join(', ')})`];
|
||||
|
||||
if (updatedFiles.length > 0) {
|
||||
messages.push(`Updated AI tool files: ${updatedFiles.join(', ')}`);
|
||||
if (updatedFiles.includes('AGENTS.md')) {
|
||||
instructionFiles.push(
|
||||
createdFiles.includes('AGENTS.md') ? 'AGENTS.md (created)' : 'AGENTS.md'
|
||||
);
|
||||
}
|
||||
|
||||
summaryParts.push(
|
||||
`Updated OpenSpec instructions (${instructionFiles.join(', ')})`
|
||||
);
|
||||
|
||||
const aiToolFiles = updatedFiles.filter((file) => file !== 'AGENTS.md');
|
||||
if (aiToolFiles.length > 0) {
|
||||
summaryParts.push(`Updated AI tool files: ${aiToolFiles.join(', ')}`);
|
||||
}
|
||||
|
||||
if (updatedSlashFiles.length > 0) {
|
||||
messages.push(`Updated slash commands: ${updatedSlashFiles.join(', ')}`);
|
||||
}
|
||||
|
||||
if (failedFiles.length > 0) {
|
||||
messages.push(`Failed to update: ${failedFiles.join(', ')}`);
|
||||
summaryParts.push(
|
||||
`Updated slash commands: ${updatedSlashFiles.join(', ')}`
|
||||
);
|
||||
}
|
||||
|
||||
if (failedSlashTools.length > 0) {
|
||||
messages.push(`Failed slash command updates: ${failedSlashTools.join(', ')}`);
|
||||
const failedItems = [
|
||||
...failedFiles,
|
||||
...failedSlashTools.map(
|
||||
(toolId) => `slash command refresh (${toolId})`
|
||||
),
|
||||
];
|
||||
|
||||
if (failedItems.length > 0) {
|
||||
summaryParts.push(`Failed to update: ${failedItems.join(', ')}`);
|
||||
}
|
||||
|
||||
console.log(messages.join('\n'));
|
||||
|
||||
console.log(summaryParts.join(' | '));
|
||||
}
|
||||
}
|
||||
|
||||
@@ -1,6 +1,46 @@
|
||||
import { promises as fs } from 'fs';
|
||||
import path from 'path';
|
||||
|
||||
function isMarkerOnOwnLine(content: string, markerIndex: number, markerLength: number): boolean {
|
||||
let leftIndex = markerIndex - 1;
|
||||
while (leftIndex >= 0 && content[leftIndex] !== '\n') {
|
||||
const char = content[leftIndex];
|
||||
if (char !== ' ' && char !== '\t' && char !== '\r') {
|
||||
return false;
|
||||
}
|
||||
leftIndex--;
|
||||
}
|
||||
|
||||
let rightIndex = markerIndex + markerLength;
|
||||
while (rightIndex < content.length && content[rightIndex] !== '\n') {
|
||||
const char = content[rightIndex];
|
||||
if (char !== ' ' && char !== '\t' && char !== '\r') {
|
||||
return false;
|
||||
}
|
||||
rightIndex++;
|
||||
}
|
||||
|
||||
return true;
|
||||
}
|
||||
|
||||
function findMarkerIndex(
|
||||
content: string,
|
||||
marker: string,
|
||||
fromIndex = 0
|
||||
): number {
|
||||
let currentIndex = content.indexOf(marker, fromIndex);
|
||||
|
||||
while (currentIndex !== -1) {
|
||||
if (isMarkerOnOwnLine(content, currentIndex, marker.length)) {
|
||||
return currentIndex;
|
||||
}
|
||||
|
||||
currentIndex = content.indexOf(marker, currentIndex + marker.length);
|
||||
}
|
||||
|
||||
return -1;
|
||||
}
|
||||
|
||||
export class FileSystemUtils {
|
||||
static async createDirectory(dirPath: string): Promise<void> {
|
||||
await fs.mkdir(dirPath, { recursive: true });
|
||||
@@ -70,10 +110,18 @@ export class FileSystemUtils {
|
||||
if (await this.fileExists(filePath)) {
|
||||
existingContent = await this.readFile(filePath);
|
||||
|
||||
const startIndex = existingContent.indexOf(startMarker);
|
||||
const endIndex = existingContent.indexOf(endMarker);
|
||||
|
||||
const startIndex = findMarkerIndex(existingContent, startMarker);
|
||||
const endIndex = startIndex !== -1
|
||||
? findMarkerIndex(existingContent, endMarker, startIndex + startMarker.length)
|
||||
: findMarkerIndex(existingContent, endMarker);
|
||||
|
||||
if (startIndex !== -1 && endIndex !== -1) {
|
||||
if (endIndex < startIndex) {
|
||||
throw new Error(
|
||||
`Invalid marker state in ${filePath}. End marker appears before start marker.`
|
||||
);
|
||||
}
|
||||
|
||||
const before = existingContent.substring(0, startIndex);
|
||||
const after = existingContent.substring(endIndex + endMarker.length);
|
||||
existingContent = before + startMarker + '\n' + content + '\n' + endMarker + after;
|
||||
@@ -109,4 +157,4 @@ export class FileSystemUtils {
|
||||
return false;
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@@ -106,7 +106,8 @@ describe('InitCommand', () => {
|
||||
|
||||
const content = await fs.readFile(claudePath, 'utf-8');
|
||||
expect(content).toContain('<!-- OPENSPEC:START -->');
|
||||
expect(content).toContain('OpenSpec Instructions');
|
||||
expect(content).toContain("@/openspec/AGENTS.md");
|
||||
expect(content).toContain('openspec update');
|
||||
expect(content).toContain('<!-- OPENSPEC:END -->');
|
||||
});
|
||||
|
||||
@@ -122,7 +123,8 @@ describe('InitCommand', () => {
|
||||
|
||||
const updatedContent = await fs.readFile(claudePath, 'utf-8');
|
||||
expect(updatedContent).toContain('<!-- OPENSPEC:START -->');
|
||||
expect(updatedContent).toContain('OpenSpec Instructions');
|
||||
expect(updatedContent).toContain("@/openspec/AGENTS.md");
|
||||
expect(updatedContent).toContain('openspec update');
|
||||
expect(updatedContent).toContain('<!-- OPENSPEC:END -->');
|
||||
expect(updatedContent).toContain('Custom instructions here');
|
||||
});
|
||||
@@ -137,7 +139,8 @@ describe('InitCommand', () => {
|
||||
|
||||
const content = await fs.readFile(rootAgentsPath, 'utf-8');
|
||||
expect(content).toContain('<!-- OPENSPEC:START -->');
|
||||
expect(content).toContain('OpenSpec Instructions');
|
||||
expect(content).toContain("@/openspec/AGENTS.md");
|
||||
expect(content).toContain('openspec update');
|
||||
expect(content).toContain('<!-- OPENSPEC:END -->');
|
||||
|
||||
const claudeExists = await fileExists(path.join(testDir, 'CLAUDE.md'));
|
||||
|
||||
@@ -51,7 +51,8 @@ More content after.`;
|
||||
const updatedContent = await fs.readFile(claudePath, 'utf-8');
|
||||
expect(updatedContent).toContain('<!-- OPENSPEC:START -->');
|
||||
expect(updatedContent).toContain('<!-- OPENSPEC:END -->');
|
||||
expect(updatedContent).toContain('OpenSpec Instructions');
|
||||
expect(updatedContent).toContain("@/openspec/AGENTS.md");
|
||||
expect(updatedContent).toContain('openspec update');
|
||||
expect(updatedContent).toContain('Some existing content here');
|
||||
expect(updatedContent).toContain('More content after');
|
||||
|
||||
@@ -303,7 +304,8 @@ Old content
|
||||
|
||||
const content = await fs.readFile(rootAgentsPath, 'utf-8');
|
||||
expect(content).toContain('<!-- OPENSPEC:START -->');
|
||||
expect(content).toContain('OpenSpec Instructions');
|
||||
expect(content).toContain("@/openspec/AGENTS.md");
|
||||
expect(content).toContain('openspec update');
|
||||
expect(content).toContain('<!-- OPENSPEC:END -->');
|
||||
});
|
||||
|
||||
@@ -319,7 +321,8 @@ Old content
|
||||
const updated = await fs.readFile(rootAgentsPath, 'utf-8');
|
||||
expect(updated).toContain('# Custom intro');
|
||||
expect(updated).toContain('# Footnotes');
|
||||
expect(updated).toContain('OpenSpec Instructions');
|
||||
expect(updated).toContain("@/openspec/AGENTS.md");
|
||||
expect(updated).toContain('openspec update');
|
||||
expect(updated).not.toContain('Old content');
|
||||
|
||||
const [logMessage] = consoleSpy.mock.calls[0];
|
||||
|
||||
@@ -248,5 +248,40 @@ Line 5 with gap`;
|
||||
const result = await fs.readFile(filePath, 'utf-8');
|
||||
expect(result).toContain(content);
|
||||
});
|
||||
|
||||
it('should ignore inline mentions of markers when updating content', async () => {
|
||||
const filePath = path.join(testDir, 'inline-mentions.md');
|
||||
const existingFile = `Intro referencing markers like ${START_MARKER} and ${END_MARKER} inside text.
|
||||
|
||||
${START_MARKER}
|
||||
Original content
|
||||
${END_MARKER}
|
||||
`;
|
||||
|
||||
await fs.writeFile(filePath, existingFile);
|
||||
|
||||
await FileSystemUtils.updateFileWithMarkers(
|
||||
filePath,
|
||||
'Updated content',
|
||||
START_MARKER,
|
||||
END_MARKER
|
||||
);
|
||||
|
||||
const firstResult = await fs.readFile(filePath, 'utf-8');
|
||||
expect(firstResult).toContain('Intro referencing markers like');
|
||||
expect(firstResult).toContain('Updated content');
|
||||
expect(firstResult.match(new RegExp(START_MARKER, 'g'))?.length).toBe(2);
|
||||
expect(firstResult.match(new RegExp(END_MARKER, 'g'))?.length).toBe(2);
|
||||
|
||||
await FileSystemUtils.updateFileWithMarkers(
|
||||
filePath,
|
||||
'Updated content',
|
||||
START_MARKER,
|
||||
END_MARKER
|
||||
);
|
||||
|
||||
const secondResult = await fs.readFile(filePath, 'utf-8');
|
||||
expect(secondResult).toBe(firstResult);
|
||||
});
|
||||
});
|
||||
});
|
||||
});
|
||||
|
||||
Reference in New Issue
Block a user