Skip to content

[Feature request]: Configurable specs path (specsPath in config.yaml) #664

@lsmonki

Description

@lsmonki

Add a specsPath option to openspec/config.yaml that allows projects to configure where their specs live, instead of the hardcoded openspec/specs. Includes cross-platform path normalization and {{specsPath}} placeholder support in schema and skill templates.

Motivation

Specs are project contracts, not OpenSpec artifacts. They should be independent of the tool used to create or manage them. Currently, the path openspec/specs is hardcoded throughout the codebase (CLI commands, schema templates, skill templates), making it impossible to store specs in a location of the project's choosing.

A project might want specs at docs/specs, contracts/, or any other path that fits its structure. OpenSpec should support this without coupling specs to its own directory layout.

Proposal

Configuration

New optional field in openspec/config.yaml:

schema: spec-driven
specsPath: docs/specs      # relative to projectRoot, default: openspec/specs
  • Path is relative to projectRoot (where openspec/ lives)
  • Accepts both / and \ separators (cross-platform), with / as documented convention
  • Defaults to openspec/specs when omitted (backward compatible, no breaking changes)

Path resolution

A centralized utility resolves the configured path into three representations:

interface SpecsPaths {
  absolute: string;       // OS-native full path for file I/O
  relativePosix: string;  // relative to projectRoot, always '/' — for prompts and LLM instructions
  relative: string;       // relative to projectRoot, OS-native separators — for console output
}
Representation Linux/Mac Windows Used for
absolute /home/user/project/docs/specs C:\Users\user\project\docs\specs File I/O (fs.readFile, etc.)
relativePosix docs/specs docs/specs Prompts, LLM instructions
relative docs/specs docs\specs Console messages

Resolution normalizes any input (splits on both / and \, reconstructs per target format).

Placeholder replacement in templates

Schema templates and skill templates currently hardcode openspec/specs:

# schema.yaml (before)
instruction: |
  Check `openspec/specs/` for existing spec names.

These become placeholders:

# schema.yaml (after)
instruction: |
  Check `{{specsPath}}/` for existing spec names.

Replacement happens at two points:

  • Schema/instruction templates: The instruction loader replaces {{specsPath}} when generating instructions at runtime
  • Skill templates: openspec update replaces {{specsPath}} via the existing transformInstructions callback when generating skill files (.claude/skills/, .claude/commands/, etc.)

Both use the same replacement mechanism — an extensible Map<string, string> of placeholders. This means:

  • Same {{specsPath}} syntax everywhere (schema templates, skill templates, proposal templates)
  • AI agents editing templates see a consistent pattern, no TypeScript interpolation to confuse them
  • The map can be extended with new placeholders in the future without changing the mechanism

Important: Changing specsPath in config requires running openspec update to regenerate skill files with the new path.

Custom schemas

The {{specsPath}} placeholder works for all schemas — built-in, project-local (openspec/schemas/), and user-level (~/.local/share/openspec/schemas/). The instruction loader replaces placeholders regardless of schema origin.

  • Built-in schemas (e.g., spec-driven): Updated to use {{specsPath}} as part of this change
  • Custom schemas: Authors can use {{specsPath}} in their instructions and templates — the instruction loader handles replacement automatically
  • Existing custom schemas that hardcode openspec/specs will continue to work but won't respect specsPath configuration — authors should migrate to {{specsPath}}

Scope

  • Affected: Main specs path only. Delta specs inside changes (openspec/changes/<name>/specs/) remain unchanged — they are internal to OpenSpec's workflow.
  • CLI commands affected: spec.ts, archive.ts, specs-apply.ts, list.ts, item-discovery.ts, validate.ts, init.ts, view.ts, validator.ts
  • Templates affected: schema.yaml, templates/proposal.md, skill-templates.ts
  • Documentation: docs/customization.md needs a section for specsPath — usage, defaults, cross-platform path conventions, openspec update requirement, and guidance for custom schema authors to use {{specsPath}} instead of hardcoding paths
  • No breaking changes: Default behavior is identical to current

Implementation sketch

Config schema (project-config.ts)

export const ProjectConfigSchema = z.object({
  schema: z.string().min(1),
  specsPath: z.string().optional()
    .describe('Path to specs directory, relative to projectRoot. Default: openspec/specs'),
  context: z.string().optional(),
  rules: z.record(z.string(), z.array(z.string())).optional(),
});

Path resolution utility (new)

function resolveSpecsPaths(projectRoot: string, specsPath?: string): SpecsPaths {
  const raw = specsPath ?? path.join('openspec', 'specs');
  const segments = raw.split(/[/\\]/);
  return {
    absolute: path.resolve(projectRoot, ...segments),
    relativePosix: segments.join('/'),
    relative: path.join(...segments),
  };
}

CLI commands (e.g., archive.ts)

// Before
const mainSpecsDir = path.join(targetPath, 'openspec', 'specs');

// After
const specsPaths = resolveSpecsPaths(targetPath, projectConfig?.specsPath);
const mainSpecsDir = specsPaths.absolute;

Schema templates (schema.yaml)

# Before
instruction: |
  Check `openspec/specs/` for existing spec names.
  Modified capabilities: use the existing spec folder name from openspec/specs/<capability>/

# After
instruction: |
  Check `{{specsPath}}/` for existing spec names.
  Modified capabilities: use the existing spec folder name from {{specsPath}}/<capability>/

Proposal template (templates/proposal.md)

<!-- Before -->
Use existing spec names from openspec/specs/.

<!-- After -->
Use existing spec names from {{specsPath}}/.

Skill templates (skill-templates.ts)

Same {{specsPath}} placeholder — replaced at openspec update time, not at runtime.

// Before (hardcoded)
b. **Read the main spec** at \`openspec/specs/<capability>/spec.md\` (may not exist yet)

// After (placeholder, replaced when openspec update generates the files)
b. **Read the main spec** at \`{{specsPath}}/<capability>/spec.md\` (may not exist yet)

Instruction loader — placeholder engine

Replaces {{specsPath}} in schema templates at runtime when generating instructions:

const placeholders = new Map<string, string>([
  ['specsPath', specsPaths.relativePosix],
]);
let instruction = artifact.instruction;
for (const [key, value] of placeholders) {
  instruction = instruction.replaceAll(`{{${key}}}`, value);
}

Update command — composes with existing transformInstructions

Replaces {{specsPath}} in skill templates at openspec update time. Composes with any existing transformer (e.g., OpenCode's transformToHyphenCommands) via the existing transformInstructions callback in generateSkillContent():

const transformer = (text: string) => {
  let result = replacePlaceholders(text);
  if (tool.value === 'opencode') result = transformToHyphenCommands(result);
  return result;
};
const skillContent = generateSkillContent(template, OPENSPEC_VERSION, transformer);

Related to #581

Metadata

Metadata

Assignees

No one assigned

    Labels

    No labels
    No labels

    Type

    No type

    Projects

    No projects

    Milestone

    No milestone

    Relationships

    None yet

    Development

    No branches or pull requests

    Issue actions