spec-gen-cli 1.2.6 → 1.2.8
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/README.md +175 -55
- package/dist/api/analyze.d.ts.map +1 -1
- package/dist/api/analyze.js +6 -1
- package/dist/api/analyze.js.map +1 -1
- package/dist/api/audit.d.ts +10 -0
- package/dist/api/audit.d.ts.map +1 -0
- package/dist/api/audit.js +117 -0
- package/dist/api/audit.js.map +1 -0
- package/dist/api/generate.d.ts.map +1 -1
- package/dist/api/generate.js +10 -1
- package/dist/api/generate.js.map +1 -1
- package/dist/api/index.d.ts +3 -2
- package/dist/api/index.d.ts.map +1 -1
- package/dist/api/index.js +1 -0
- package/dist/api/index.js.map +1 -1
- package/dist/api/run.d.ts.map +1 -1
- package/dist/api/run.js +5 -1
- package/dist/api/run.js.map +1 -1
- package/dist/api/types.d.ts +15 -4
- package/dist/api/types.d.ts.map +1 -1
- package/dist/cli/commands/analyze.d.ts +3 -0
- package/dist/cli/commands/analyze.d.ts.map +1 -1
- package/dist/cli/commands/analyze.js +112 -17
- package/dist/cli/commands/analyze.js.map +1 -1
- package/dist/cli/commands/audit.d.ts +9 -0
- package/dist/cli/commands/audit.d.ts.map +1 -0
- package/dist/cli/commands/audit.js +98 -0
- package/dist/cli/commands/audit.js.map +1 -0
- package/dist/cli/commands/drift.d.ts.map +1 -1
- package/dist/cli/commands/drift.js +8 -10
- package/dist/cli/commands/drift.js.map +1 -1
- package/dist/cli/commands/generate.d.ts.map +1 -1
- package/dist/cli/commands/generate.js +15 -37
- package/dist/cli/commands/generate.js.map +1 -1
- package/dist/cli/commands/mcp.d.ts +102 -2
- package/dist/cli/commands/mcp.d.ts.map +1 -1
- package/dist/cli/commands/mcp.js +134 -2
- package/dist/cli/commands/mcp.js.map +1 -1
- package/dist/cli/commands/run.d.ts.map +1 -1
- package/dist/cli/commands/run.js +9 -47
- package/dist/cli/commands/run.js.map +1 -1
- package/dist/cli/commands/setup.d.ts +17 -0
- package/dist/cli/commands/setup.d.ts.map +1 -0
- package/dist/cli/commands/setup.js +201 -0
- package/dist/cli/commands/setup.js.map +1 -0
- package/dist/cli/commands/verify.d.ts.map +1 -1
- package/dist/cli/commands/verify.js +7 -8
- package/dist/cli/commands/verify.js.map +1 -1
- package/dist/cli/index.js +14 -8
- package/dist/cli/index.js.map +1 -1
- package/dist/constants.d.ts +14 -0
- package/dist/constants.d.ts.map +1 -1
- package/dist/constants.js +14 -0
- package/dist/constants.js.map +1 -1
- package/dist/core/analyzer/ai-config-generator.d.ts +54 -0
- package/dist/core/analyzer/ai-config-generator.d.ts.map +1 -0
- package/dist/core/analyzer/ai-config-generator.js +85 -0
- package/dist/core/analyzer/ai-config-generator.js.map +1 -0
- package/dist/core/analyzer/artifact-generator.d.ts +27 -2
- package/dist/core/analyzer/artifact-generator.d.ts.map +1 -1
- package/dist/core/analyzer/artifact-generator.js +86 -8
- package/dist/core/analyzer/artifact-generator.js.map +1 -1
- package/dist/core/analyzer/codebase-digest.d.ts.map +1 -1
- package/dist/core/analyzer/codebase-digest.js +12 -11
- package/dist/core/analyzer/codebase-digest.js.map +1 -1
- package/dist/core/analyzer/env-extractor.d.ts +33 -0
- package/dist/core/analyzer/env-extractor.d.ts.map +1 -0
- package/dist/core/analyzer/env-extractor.js +196 -0
- package/dist/core/analyzer/env-extractor.js.map +1 -0
- package/dist/core/analyzer/http-route-parser.d.ts +36 -1
- package/dist/core/analyzer/http-route-parser.d.ts.map +1 -1
- package/dist/core/analyzer/http-route-parser.js +276 -0
- package/dist/core/analyzer/http-route-parser.js.map +1 -1
- package/dist/core/analyzer/middleware-extractor.d.ts +29 -0
- package/dist/core/analyzer/middleware-extractor.d.ts.map +1 -0
- package/dist/core/analyzer/middleware-extractor.js +195 -0
- package/dist/core/analyzer/middleware-extractor.js.map +1 -0
- package/dist/core/analyzer/schema-extractor.d.ts +41 -0
- package/dist/core/analyzer/schema-extractor.d.ts.map +1 -0
- package/dist/core/analyzer/schema-extractor.js +229 -0
- package/dist/core/analyzer/schema-extractor.js.map +1 -0
- package/dist/core/analyzer/spec-snapshot-generator.d.ts +17 -0
- package/dist/core/analyzer/spec-snapshot-generator.d.ts.map +1 -0
- package/dist/core/analyzer/spec-snapshot-generator.js +201 -0
- package/dist/core/analyzer/spec-snapshot-generator.js.map +1 -0
- package/dist/core/analyzer/ui-component-extractor.d.ts +43 -0
- package/dist/core/analyzer/ui-component-extractor.d.ts.map +1 -0
- package/dist/core/analyzer/ui-component-extractor.js +245 -0
- package/dist/core/analyzer/ui-component-extractor.js.map +1 -0
- package/dist/core/generator/openspec-format-generator.d.ts.map +1 -1
- package/dist/core/generator/openspec-format-generator.js +8 -0
- package/dist/core/generator/openspec-format-generator.js.map +1 -1
- package/dist/core/generator/spec-pipeline.d.ts +9 -0
- package/dist/core/generator/spec-pipeline.d.ts.map +1 -1
- package/dist/core/generator/spec-pipeline.js +94 -2
- package/dist/core/generator/spec-pipeline.js.map +1 -1
- package/dist/core/generator/stages/stage1-survey.d.ts.map +1 -1
- package/dist/core/generator/stages/stage1-survey.js +43 -0
- package/dist/core/generator/stages/stage1-survey.js.map +1 -1
- package/dist/core/generator/stages/stage2-entities.d.ts.map +1 -1
- package/dist/core/generator/stages/stage2-entities.js +6 -2
- package/dist/core/generator/stages/stage2-entities.js.map +1 -1
- package/dist/core/generator/stages/stage3-services.d.ts.map +1 -1
- package/dist/core/generator/stages/stage3-services.js +9 -2
- package/dist/core/generator/stages/stage3-services.js.map +1 -1
- package/dist/core/generator/stages/stage4-api.d.ts.map +1 -1
- package/dist/core/generator/stages/stage4-api.js +6 -2
- package/dist/core/generator/stages/stage4-api.js.map +1 -1
- package/dist/core/services/llm-service.d.ts +26 -10
- package/dist/core/services/llm-service.d.ts.map +1 -1
- package/dist/core/services/llm-service.js +171 -16
- package/dist/core/services/llm-service.js.map +1 -1
- package/dist/core/services/mcp-handlers/analysis.d.ts +32 -1
- package/dist/core/services/mcp-handlers/analysis.d.ts.map +1 -1
- package/dist/core/services/mcp-handlers/analysis.js +185 -2
- package/dist/core/services/mcp-handlers/analysis.js.map +1 -1
- package/dist/core/verifier/verification-engine.d.ts +67 -6
- package/dist/core/verifier/verification-engine.d.ts.map +1 -1
- package/dist/core/verifier/verification-engine.js +316 -90
- package/dist/core/verifier/verification-engine.js.map +1 -1
- package/dist/types/index.d.ts +70 -1
- package/dist/types/index.d.ts.map +1 -1
- package/dist/types/pipeline.d.ts +9 -0
- package/dist/types/pipeline.d.ts.map +1 -1
- package/dist/utils/command-helpers.d.ts +30 -0
- package/dist/utils/command-helpers.d.ts.map +1 -1
- package/dist/utils/command-helpers.js +69 -1
- package/dist/utils/command-helpers.js.map +1 -1
- package/examples/bmad/README.md +113 -0
- package/examples/bmad/agents/architect.md +226 -0
- package/examples/bmad/agents/dev-brownfield.md +69 -0
- package/examples/bmad/setup/architect.customize.yaml +14 -0
- package/examples/bmad/tasks/implement-story.md +254 -0
- package/examples/bmad/tasks/onboarding.md +169 -0
- package/examples/bmad/tasks/refactor.md +178 -0
- package/examples/bmad/tasks/sprint-planning.md +168 -0
- package/examples/bmad/templates/story.md +108 -0
- package/examples/cline-workflows/spec-gen-analyze-codebase.md +100 -0
- package/examples/cline-workflows/spec-gen-check-spec-drift.md +102 -0
- package/examples/cline-workflows/spec-gen-execute-refactor.md +194 -0
- package/examples/cline-workflows/spec-gen-implement-feature.md +238 -0
- package/examples/cline-workflows/spec-gen-plan-refactor.md +255 -0
- package/examples/cline-workflows/spec-gen-refactor-codebase.md +16 -0
- package/examples/drift-demo/openspec/config.yaml +14 -0
- package/examples/drift-demo/openspec/specs/architecture/spec.md +30 -0
- package/examples/drift-demo/openspec/specs/auth/spec.md +71 -0
- package/examples/drift-demo/openspec/specs/database/spec.md +33 -0
- package/examples/drift-demo/openspec/specs/overview/spec.md +20 -0
- package/examples/drift-demo/openspec/specs/projects/spec.md +55 -0
- package/examples/drift-demo/openspec/specs/tasks/spec.md +78 -0
- package/examples/drift-demo/package.json +21 -0
- package/examples/drift-demo/src/auth/auth-middleware.ts +30 -0
- package/examples/drift-demo/src/auth/auth-routes.ts +29 -0
- package/examples/drift-demo/src/auth/auth-service.ts +45 -0
- package/examples/drift-demo/src/database/connection.ts +27 -0
- package/examples/drift-demo/src/index.ts +16 -0
- package/examples/drift-demo/src/projects/project-model.ts +15 -0
- package/examples/drift-demo/src/projects/project-service.ts +34 -0
- package/examples/drift-demo/src/tasks/task-model.ts +37 -0
- package/examples/drift-demo/src/tasks/task-routes.ts +53 -0
- package/examples/drift-demo/src/tasks/task-service.ts +60 -0
- package/examples/drift-demo/src/utils/validation.ts +11 -0
- package/examples/drift-demo/tests/auth.test.ts +4 -0
- package/examples/drift-demo/tests/tasks.test.ts +4 -0
- package/examples/drift-demo/tsconfig.json +10 -0
- package/examples/drift-test/run-drift-test.sh +1087 -0
- package/examples/gsd/README.md +119 -0
- package/examples/gsd/commands/gsd/spec-gen-drift.md +111 -0
- package/examples/gsd/commands/gsd/spec-gen-orient.md +191 -0
- package/examples/mistral-vibe/README.md +101 -0
- package/examples/mistral-vibe/antipatterns-template.md +18 -0
- package/examples/mistral-vibe/skills/spec-gen-analyze-codebase/SKILL.md +123 -0
- package/examples/mistral-vibe/skills/spec-gen-brainstorm/SKILL.md +379 -0
- package/examples/mistral-vibe/skills/spec-gen-debug/SKILL.md +320 -0
- package/examples/mistral-vibe/skills/spec-gen-execute-refactor/SKILL.md +210 -0
- package/examples/mistral-vibe/skills/spec-gen-generate/SKILL.md +245 -0
- package/examples/mistral-vibe/skills/spec-gen-implement-story/SKILL.md +274 -0
- package/examples/mistral-vibe/skills/spec-gen-plan-refactor/SKILL.md +251 -0
- package/examples/openspec-analysis/README.md +59 -0
- package/examples/openspec-analysis/SUMMARY.md +72 -0
- package/examples/openspec-analysis/config.json +16 -0
- package/examples/openspec-analysis/dependencies.mermaid +35 -0
- package/examples/openspec-analysis/dependency-graph.json +12116 -0
- package/examples/openspec-analysis/llm-context.json +119 -0
- package/examples/openspec-analysis/repo-structure.json +871 -0
- package/examples/openspec-cli/README.md +67 -0
- package/examples/openspec-cli/openspec/config.yaml +26 -0
- package/examples/openspec-cli/openspec/specs/architecture/spec.md +178 -0
- package/examples/openspec-cli/openspec/specs/artifact-graph/spec.md +143 -0
- package/examples/openspec-cli/openspec/specs/cli/spec.md +138 -0
- package/examples/openspec-cli/openspec/specs/overview/spec.md +60 -0
- package/examples/openspec-cli/openspec/specs/parsing/spec.md +123 -0
- package/examples/openspec-cli/openspec/specs/validation/spec.md +108 -0
- package/examples/spec-kit/README.md +104 -0
- package/examples/spec-kit/commands/drift.md +87 -0
- package/examples/spec-kit/commands/orient.md +138 -0
- package/examples/spec-kit/extension.yml +54 -0
- package/package.json +3 -6
|
@@ -0,0 +1,119 @@
|
|
|
1
|
+
{
|
|
2
|
+
"phase1_survey": {
|
|
3
|
+
"purpose": "Initial project categorization",
|
|
4
|
+
"files": [
|
|
5
|
+
{
|
|
6
|
+
"path": "repo-structure.json",
|
|
7
|
+
"tokens": 2000
|
|
8
|
+
}
|
|
9
|
+
],
|
|
10
|
+
"estimatedTokens": 2000
|
|
11
|
+
},
|
|
12
|
+
"phase2_deep": {
|
|
13
|
+
"purpose": "Core entity and logic extraction",
|
|
14
|
+
"files": [
|
|
15
|
+
{
|
|
16
|
+
"path": "src/core/completions/types.ts",
|
|
17
|
+
"content": "import { SupportedShell } from '../../utils/shell-detection.js';\n\n/**\n * Definition of a command-line flag/option\n */\nexport interface FlagDefinition {\n /**\n * Flag name without dashes (e.g., \"json\", \"strict\", \"no-interactive\")\n */\n name: string;\n\n /**\n * Short flag name without dash (e.g., \"y\" for \"-y\")\n */\n short?: string;\n\n /**\n * Human-readable description of what the flag does\n */\n description: string;\n\n /**\n * Whether the flag takes an argument value\n */\n takesValue?: boolean;\n\n /**\n * Possible values for the flag (for completion suggestions)\n */\n values?: string[];\n}\n\n/**\n * Definition of a CLI command\n */\nexport interface CommandDefinition {\n /**\n * Command name (e.g., \"init\", \"validate\", \"show\")\n */\n name: string;\n\n /**\n * Human-readable description of the command\n */\n description: string;\n\n /**\n * Flags/options supported by this command\n */\n flags: FlagDefinition[];\n\n /**\n * Subcommands (e.g., \"change show\", \"spec validate\")\n */\n subcommands?: CommandDefinition[];\n\n /**\n * Whether this command accepts a positional argument (e.g., item name, path)\n */\n acceptsPositional?: boolean;\n\n /**\n * Type of positional argument for dynamic completion\n * - 'change-id': Complete with active change IDs\n * - 'spec-id': Complete with spec IDs\n * - 'change-or-spec-id': Complete with both changes and specs\n * - 'path': Complete with file paths\n * - 'shell': Complete with supported shell names\n * - 'schema-name': Complete with available schema names\n * - undefined: No specific completion\n */\n positionalType?: 'change-id' | 'spec-id' | 'change-or-spec-id' | 'path' | 'shell' | 'schema-name';\n}\n\n/**\n * Interface for shell-specific completion script generators\n */\nexport interface CompletionGenerator {\n /**\n * The shell type this generator targets\n */\n readonly shell: SupportedShell;\n\n /**\n * Generate the completion script content\n *\n * @param commands - Command definitions to generate completions for\n * @returns The shell-specific completion script as a string\n */\n generate(commands: CommandDefinition[]): string;\n}\n",
|
|
18
|
+
"tokens": 534
|
|
19
|
+
},
|
|
20
|
+
{
|
|
21
|
+
"path": "src/core/command-generation/types.ts",
|
|
22
|
+
"content": "/**\n * Command Generation Types\n *\n * Tool-agnostic interfaces for command generation.\n * These types separate \"what to generate\" from \"how to format it\".\n */\n\n/**\n * Tool-agnostic command data.\n * Represents the content of a command without any tool-specific formatting.\n */\nexport interface CommandContent {\n /** Command identifier (e.g., 'explore', 'apply', 'new') */\n id: string;\n /** Human-readable name (e.g., 'OpenSpec Explore') */\n name: string;\n /** Brief description of command purpose */\n description: string;\n /** Grouping category (e.g., 'Workflow') */\n category: string;\n /** Array of tag strings */\n tags: string[];\n /** The command instruction content (body text) */\n body: string;\n}\n\n/**\n * Per-tool formatting strategy.\n * Each AI tool implements this interface to handle its specific file path\n * and frontmatter format requirements.\n */\nexport interface ToolCommandAdapter {\n /** Tool identifier matching AIToolOption.value (e.g., 'claude', 'cursor') */\n toolId: string;\n /**\n * Returns the file path for a command.\n * @param commandId - The command identifier (e.g., 'explore')\n * @returns Path from project root (e.g., '.claude/commands/opsx/explore.md').\n * May be absolute for tools with global-scoped prompts (e.g., Codex).\n */\n getFilePath(commandId: string): string;\n /**\n * Formats the complete file content including frontmatter.\n * @param content - The tool-agnostic command content\n * @returns Complete file content ready to write\n */\n formatFile(content: CommandContent): string;\n}\n\n/**\n * Result of generating a command file.\n */\nexport interface GeneratedCommand {\n /** File path from project root, or absolute for global-scoped tools */\n path: string;\n /** Complete file content (frontmatter + body) */\n fileContent: string;\n}\n",
|
|
23
|
+
"tokens": 453
|
|
24
|
+
},
|
|
25
|
+
{
|
|
26
|
+
"path": "src/core/artifact-graph/types.ts",
|
|
27
|
+
"content": "import { z } from 'zod';\n\n// Artifact definition schema\nexport const ArtifactSchema = z.object({\n id: z.string().min(1, { error: 'Artifact ID is required' }),\n generates: z.string().min(1, { error: 'generates field is required' }),\n description: z.string(),\n template: z.string().min(1, { error: 'template field is required' }),\n instruction: z.string().optional(),\n requires: z.array(z.string()).default([]),\n});\n\n// Apply phase configuration for schema-aware apply instructions\nexport const ApplyPhaseSchema = z.object({\n // Artifact IDs that must exist before apply is available\n requires: z.array(z.string()).min(1, { error: 'At least one required artifact' }),\n // Path to file with checkboxes for progress (relative to change dir), or null if no tracking\n tracks: z.string().nullable().optional(),\n // Custom guidance for the apply phase\n instruction: z.string().optional(),\n});\n\n// Full schema YAML structure\nexport const SchemaYamlSchema = z.object({\n name: z.string().min(1, { error: 'Schema name is required' }),\n version: z.number().int().positive({ error: 'Version must be a positive integer' }),\n description: z.string().optional(),\n artifacts: z.array(ArtifactSchema).min(1, { error: 'At least one artifact required' }),\n // Optional apply phase configuration (for schema-aware apply instructions)\n apply: ApplyPhaseSchema.optional(),\n});\n\n// Derived TypeScript types\nexport type Artifact = z.infer<typeof ArtifactSchema>;\nexport type ApplyPhase = z.infer<typeof ApplyPhaseSchema>;\nexport type SchemaYaml = z.infer<typeof SchemaYamlSchema>;\n\n// Per-change metadata schema\n// Note: schema field is validated at parse time against available schemas\n// using a lazy import to avoid circular dependencies\nexport const ChangeMetadataSchema = z.object({\n // Required: which workflow schema this change uses\n schema: z.string().min(1, { message: 'schema is required' }),\n\n // Optional: creation timestamp (ISO date string)\n created: z\n .string()\n .regex(/^\\d{4}-\\d{2}-\\d{2}$/, {\n message: 'created must be YYYY-MM-DD format',\n })\n .optional(),\n});\n\nexport type ChangeMetadata = z.infer<typeof ChangeMetadataSchema>;\n\n// Runtime state types (not Zod - internal only)\n\n// Slice 1: Simple completion tracking via filesystem\nexport type CompletedSet = Set<string>;\n\n// Return type for blocked query\nexport interface BlockedArtifacts {\n [artifactId: string]: string[];\n}\n\n",
|
|
28
|
+
"tokens": 604
|
|
29
|
+
},
|
|
30
|
+
{
|
|
31
|
+
"path": "src/core/config-schema.ts",
|
|
32
|
+
"content": "import { z } from 'zod';\n\n/**\n * Zod schema for global OpenSpec configuration.\n * Uses passthrough() to preserve unknown fields for forward compatibility.\n */\nexport const GlobalConfigSchema = z\n .object({\n featureFlags: z\n .record(z.string(), z.boolean())\n .optional()\n .default({}),\n })\n .passthrough();\n\nexport type GlobalConfigType = z.infer<typeof GlobalConfigSchema>;\n\n/**\n * Default configuration values.\n */\nexport const DEFAULT_CONFIG: GlobalConfigType = {\n featureFlags: {},\n};\n\nconst KNOWN_TOP_LEVEL_KEYS = new Set(Object.keys(DEFAULT_CONFIG));\n\n/**\n * Validate a config key path for CLI set operations.\n * Unknown top-level keys are rejected unless explicitly allowed by the caller.\n */\nexport function validateConfigKeyPath(path: string): { valid: boolean; reason?: string } {\n const rawKeys = path.split('.');\n\n if (rawKeys.length === 0 || rawKeys.some((key) => key.trim() === '')) {\n return { valid: false, reason: 'Key path must not be empty' };\n }\n\n const rootKey = rawKeys[0];\n if (!KNOWN_TOP_LEVEL_KEYS.has(rootKey)) {\n return { valid: false, reason: `Unknown top-level key \"${rootKey}\"` };\n }\n\n if (rootKey === 'featureFlags') {\n if (rawKeys.length > 2) {\n return { valid: false, reason: 'featureFlags values are booleans and do not support nested keys' };\n }\n return { valid: true };\n }\n\n if (rawKeys.length > 1) {\n return { valid: false, reason: `\"${rootKey}\" does not support nested keys` };\n }\n\n return { valid: true };\n}\n\n/**\n * Get a nested value from an object using dot notation.\n *\n * @param obj - The object to access\n * @param path - Dot-separated path (e.g., \"featureFlags.someFlag\")\n * @returns The value at the path, or undefined if not found\n */\nexport function getNestedValue(obj: Record<string, unknown>, path: string): unknown {\n const keys = path.split('.');\n let current: unknown = obj;\n\n for (const key of keys) {\n if (current === null || current === undefined) {\n return undefined;\n }\n if (typeof current !== 'object') {\n return undefined;\n }\n current = (current as Record<string, unknown>)[key];\n }\n\n return current;\n}\n\n/**\n * Set a nested value in an object using dot notation.\n * Creates intermediate objects as needed.\n *\n * @param obj - The object to modify (mutated in place)\n * @param path - Dot-separated path (e.g., \"featureFlags.someFlag\")\n * @param value - The value to set\n */\nexport function setNestedValue(obj: Record<string, unknown>, path: string, value: unknown): void {\n const keys = path.split('.');\n let current: Record<string, unknown> = obj;\n\n for (let i = 0; i < keys.length - 1; i++) {\n const key = keys[i];\n if (current[key] === undefined || current[key] === null || typeof current[key] !== 'object') {\n current[key] = {};\n }\n current = current[key] as Record<string, unknown>;\n }\n\n const lastKey = keys[keys.length - 1];\n current[lastKey] = value;\n}\n\n/**\n * Delete a nested value from an object using dot notation.\n *\n * @param obj - The object to modify (mutated in place)\n * @param path - Dot-separated path (e.g., \"featureFlags.someFlag\")\n * @returns true if the key existed and was deleted, false otherwise\n */\nexport function deleteNestedValue(obj: Record<string, unknown>, path: string): boolean {\n const keys = path.split('.');\n let current: Record<string, unknown> = obj;\n\n for (let i = 0; i < keys.length - 1; i++) {\n const key = keys[i];\n if (current[key] === undefined || current[key] === null || typeof current[key] !== 'object') {\n return false;\n }\n current = current[key] as Record<string, unknown>;\n }\n\n const lastKey = keys[keys.length - 1];\n if (lastKey in current) {\n delete current[lastKey];\n return true;\n }\n return false;\n}\n\n/**\n * Coerce a string value to its appropriate type.\n * - \"true\" / \"false\" -> boolean\n * - Numeric strings -> number\n * - Everything else -> string\n *\n * @param value - The string value to coerce\n * @param forceString - If true, always return the value as a string\n * @returns The coerced value\n */\nexport function coerceValue(value: string, forceString: boolean = false): string | number | boolean {\n if (forceString) {\n return value;\n }\n\n // Boolean coercion\n if (value === 'true') {\n return true;\n }\n if (value === 'false') {\n return false;\n }\n\n // Number coercion - must be a valid finite number\n const num = Number(value);\n if (!isNaN(num) && isFinite(num) && value.trim() !== '') {\n return num;\n }\n\n return value;\n}\n\n/**\n * Format a value for YAML-like display.\n *\n * @param value - The value to format\n * @param indent - Current indentation level\n * @returns Formatted string\n */\nexport function formatValueYaml(value: unknown, indent: number = 0): string {\n const indentStr = ' '.repeat(indent);\n\n if (value === null || value === undefined) {\n return 'null';\n }\n\n if (typeof value === 'boolean' || typeof value === 'number') {\n return String(value);\n }\n\n if (typeof value === 'string') {\n return value;\n }\n\n if (Array.isArray(value)) {\n if (value.length === 0) {\n return '[]';\n }\n return value.map((item) => `${indentStr}- ${formatValueYaml(item, indent + 1)}`).join('\\n');\n }\n\n if (typeof value === 'object') {\n const entries = Object.entries(value as Record<string, unknown>);\n if (entries.length === 0) {\n return '{}';\n }\n return entries\n .map(([key, val]) => {\n const formattedVal = formatValueYaml(val, indent + 1);\n if (typeof val === 'object' && val !== null && Object.keys(val).length > 0) {\n return `${indentStr}${key}:\\n${formattedVal}`;\n }\n return `${indentStr}${key}: ${formattedVal}`;\n })\n .join('\\n');\n }\n\n return String(value);\n}\n\n/**\n * Validate a configuration object against the schema.\n *\n * @param config - The configuration to validate\n * @returns Validation result with success status and optional error message\n */\nexport function validateConfig(config: unknown): { success: boolean; error?: string } {\n try {\n GlobalConfigSchema.parse(config);\n return { success: true };\n } catch (error) {\n if (error instanceof z.ZodError) {\n const zodError = error as z.ZodError;\n const messages = zodError.issues.map((e) => `${e.path.join('.')}: ${e.message}`);\n return { success: false, error: messages.join('; ') };\n }\n return { success: false, error: 'Unknown validation error' };\n }\n}\n",
|
|
33
|
+
"tokens": 1606
|
|
34
|
+
},
|
|
35
|
+
{
|
|
36
|
+
"path": "src/core/schemas/change.schema.ts",
|
|
37
|
+
"content": "import { z } from 'zod';\nimport { RequirementSchema } from './base.schema.js';\nimport { \n MIN_WHY_SECTION_LENGTH,\n MAX_WHY_SECTION_LENGTH,\n MAX_DELTAS_PER_CHANGE,\n VALIDATION_MESSAGES \n} from '../validation/constants.js';\n\nexport const DeltaOperationType = z.enum(['ADDED', 'MODIFIED', 'REMOVED', 'RENAMED']);\n\nexport const DeltaSchema = z.object({\n spec: z.string().min(1, VALIDATION_MESSAGES.DELTA_SPEC_EMPTY),\n operation: DeltaOperationType,\n description: z.string().min(1, VALIDATION_MESSAGES.DELTA_DESCRIPTION_EMPTY),\n requirement: RequirementSchema.optional(),\n requirements: z.array(RequirementSchema).optional(),\n rename: z.object({\n from: z.string(),\n to: z.string(),\n }).optional(),\n});\n\nexport const ChangeSchema = z.object({\n name: z.string().min(1, VALIDATION_MESSAGES.CHANGE_NAME_EMPTY),\n why: z.string()\n .min(MIN_WHY_SECTION_LENGTH, VALIDATION_MESSAGES.CHANGE_WHY_TOO_SHORT)\n .max(MAX_WHY_SECTION_LENGTH, VALIDATION_MESSAGES.CHANGE_WHY_TOO_LONG),\n whatChanges: z.string().min(1, VALIDATION_MESSAGES.CHANGE_WHAT_EMPTY),\n deltas: z.array(DeltaSchema)\n .min(1, VALIDATION_MESSAGES.CHANGE_NO_DELTAS)\n .max(MAX_DELTAS_PER_CHANGE, VALIDATION_MESSAGES.CHANGE_TOO_MANY_DELTAS),\n metadata: z.object({\n version: z.string().default('1.0.0'),\n format: z.literal('openspec-change'),\n sourcePath: z.string().optional(),\n }).optional(),\n});\n\nexport type DeltaOperation = z.infer<typeof DeltaOperationType>;\nexport type Delta = z.infer<typeof DeltaSchema>;\nexport type Change = z.infer<typeof ChangeSchema>;",
|
|
38
|
+
"tokens": 388
|
|
39
|
+
},
|
|
40
|
+
{
|
|
41
|
+
"path": "src/core/schemas/base.schema.ts",
|
|
42
|
+
"content": "import { z } from 'zod';\nimport { VALIDATION_MESSAGES } from '../validation/constants.js';\n\nexport const ScenarioSchema = z.object({\n rawText: z.string().min(1, VALIDATION_MESSAGES.SCENARIO_EMPTY),\n});\n\nexport const RequirementSchema = z.object({\n text: z.string()\n .min(1, VALIDATION_MESSAGES.REQUIREMENT_EMPTY)\n .refine(\n (text) => text.includes('SHALL') || text.includes('MUST'),\n VALIDATION_MESSAGES.REQUIREMENT_NO_SHALL\n ),\n scenarios: z.array(ScenarioSchema)\n .min(1, VALIDATION_MESSAGES.REQUIREMENT_NO_SCENARIOS),\n});\n\nexport type Scenario = z.infer<typeof ScenarioSchema>;\nexport type Requirement = z.infer<typeof RequirementSchema>;",
|
|
43
|
+
"tokens": 167
|
|
44
|
+
},
|
|
45
|
+
{
|
|
46
|
+
"path": "src/core/validation/types.ts",
|
|
47
|
+
"content": "export type ValidationLevel = 'ERROR' | 'WARNING' | 'INFO';\n\nexport interface ValidationIssue {\n level: ValidationLevel;\n path: string;\n message: string;\n line?: number;\n column?: number;\n}\n\nexport interface ValidationReport {\n valid: boolean;\n issues: ValidationIssue[];\n summary: {\n errors: number;\n warnings: number;\n info: number;\n };\n}",
|
|
48
|
+
"tokens": 90
|
|
49
|
+
},
|
|
50
|
+
{
|
|
51
|
+
"path": "src/core/artifact-graph/schema.ts",
|
|
52
|
+
"content": "import * as fs from 'node:fs';\nimport { parse as parseYaml } from 'yaml';\nimport { SchemaYamlSchema, type SchemaYaml, type Artifact } from './types.js';\n\nexport class SchemaValidationError extends Error {\n constructor(message: string) {\n super(message);\n this.name = 'SchemaValidationError';\n }\n}\n\n/**\n * Loads and validates an artifact schema from a YAML file.\n */\nexport function loadSchema(filePath: string): SchemaYaml {\n const content = fs.readFileSync(filePath, 'utf-8');\n return parseSchema(content);\n}\n\n/**\n * Parses and validates an artifact schema from YAML content.\n */\nexport function parseSchema(yamlContent: string): SchemaYaml {\n const parsed = parseYaml(yamlContent);\n\n // Validate with Zod\n const result = SchemaYamlSchema.safeParse(parsed);\n if (!result.success) {\n const errors = result.error.issues.map(e => `${e.path.join('.')}: ${e.message}`).join(', ');\n throw new SchemaValidationError(`Invalid schema: ${errors}`);\n }\n\n const schema = result.data;\n\n // Check for duplicate artifact IDs\n validateNoDuplicateIds(schema.artifacts);\n\n // Check that all requires references are valid\n validateRequiresReferences(schema.artifacts);\n\n // Check for cycles\n validateNoCycles(schema.artifacts);\n\n return schema;\n}\n\n/**\n * Validates that there are no duplicate artifact IDs.\n */\nfunction validateNoDuplicateIds(artifacts: Artifact[]): void {\n const seen = new Set<string>();\n for (const artifact of artifacts) {\n if (seen.has(artifact.id)) {\n throw new SchemaValidationError(`Duplicate artifact ID: ${artifact.id}`);\n }\n seen.add(artifact.id);\n }\n}\n\n/**\n * Validates that all `requires` references point to valid artifact IDs.\n */\nfunction validateRequiresReferences(artifacts: Artifact[]): void {\n const validIds = new Set(artifacts.map(a => a.id));\n\n for (const artifact of artifacts) {\n for (const req of artifact.requires) {\n if (!validIds.has(req)) {\n throw new SchemaValidationError(\n `Invalid dependency reference in artifact '${artifact.id}': '${req}' does not exist`\n );\n }\n }\n }\n}\n\n/**\n * Validates that there are no cyclic dependencies.\n * Uses DFS to detect cycles and reports the full cycle path.\n */\nfunction validateNoCycles(artifacts: Artifact[]): void {\n const artifactMap = new Map(artifacts.map(a => [a.id, a]));\n const visited = new Set<string>();\n const inStack = new Set<string>();\n const parent = new Map<string, string>();\n\n function dfs(id: string): string | null {\n visited.add(id);\n inStack.add(id);\n\n const artifact = artifactMap.get(id);\n if (!artifact) return null;\n\n for (const dep of artifact.requires) {\n if (!visited.has(dep)) {\n parent.set(dep, id);\n const cycle = dfs(dep);\n if (cycle) return cycle;\n } else if (inStack.has(dep)) {\n // Found a cycle - reconstruct the path\n const cyclePath = [dep];\n let current = id;\n while (current !== dep) {\n cyclePath.unshift(current);\n current = parent.get(current)!;\n }\n cyclePath.unshift(dep);\n return cyclePath.join(' → ');\n }\n }\n\n inStack.delete(id);\n return null;\n }\n\n for (const artifact of artifacts) {\n if (!visited.has(artifact.id)) {\n const cycle = dfs(artifact.id);\n if (cycle) {\n throw new SchemaValidationError(`Cyclic dependency detected: ${cycle}`);\n }\n }\n }\n}\n",
|
|
53
|
+
"tokens": 854
|
|
54
|
+
},
|
|
55
|
+
{
|
|
56
|
+
"path": "src/core/global-config.ts",
|
|
57
|
+
"content": "import * as fs from 'node:fs';\nimport * as path from 'node:path';\nimport * as os from 'node:os';\n\n// Constants\nexport const GLOBAL_CONFIG_DIR_NAME = 'openspec';\nexport const GLOBAL_CONFIG_FILE_NAME = 'config.json';\nexport const GLOBAL_DATA_DIR_NAME = 'openspec';\n\n// TypeScript interfaces\nexport interface GlobalConfig {\n featureFlags?: Record<string, boolean>;\n}\n\nconst DEFAULT_CONFIG: GlobalConfig = {\n featureFlags: {}\n};\n\n/**\n * Gets the global configuration directory path following XDG Base Directory Specification.\n *\n * - All platforms: $XDG_CONFIG_HOME/openspec/ if XDG_CONFIG_HOME is set\n * - Unix/macOS fallback: ~/.config/openspec/\n * - Windows fallback: %APPDATA%/openspec/\n */\nexport function getGlobalConfigDir(): string {\n // XDG_CONFIG_HOME takes precedence on all platforms when explicitly set\n const xdgConfigHome = process.env.XDG_CONFIG_HOME;\n if (xdgConfigHome) {\n return path.join(xdgConfigHome, GLOBAL_CONFIG_DIR_NAME);\n }\n\n const platform = os.platform();\n\n if (platform === 'win32') {\n // Windows: use %APPDATA%\n const appData = process.env.APPDATA;\n if (appData) {\n return path.join(appData, GLOBAL_CONFIG_DIR_NAME);\n }\n // Fallback for Windows if APPDATA is not set\n return path.join(os.homedir(), 'AppData', 'Roaming', GLOBAL_CONFIG_DIR_NAME);\n }\n\n // Unix/macOS fallback: ~/.config\n return path.join(os.homedir(), '.config', GLOBAL_CONFIG_DIR_NAME);\n}\n\n/**\n * Gets the global data directory path following XDG Base Directory Specification.\n * Used for user data like schema overrides.\n *\n * - All platforms: $XDG_DATA_HOME/openspec/ if XDG_DATA_HOME is set\n * - Unix/macOS fallback: ~/.local/share/openspec/\n * - Windows fallback: %LOCALAPPDATA%/openspec/\n */\nexport function getGlobalDataDir(): string {\n // XDG_DATA_HOME takes precedence on all platforms when explicitly set\n const xdgDataHome = process.env.XDG_DATA_HOME;\n if (xdgDataHome) {\n return path.join(xdgDataHome, GLOBAL_DATA_DIR_NAME);\n }\n\n const platform = os.platform();\n\n if (platform === 'win32') {\n // Windows: use %LOCALAPPDATA%\n const localAppData = process.env.LOCALAPPDATA;\n if (localAppData) {\n return path.join(localAppData, GLOBAL_DATA_DIR_NAME);\n }\n // Fallback for Windows if LOCALAPPDATA is not set\n return path.join(os.homedir(), 'AppData', 'Local', GLOBAL_DATA_DIR_NAME);\n }\n\n // Unix/macOS fallback: ~/.local/share\n return path.join(os.homedir(), '.local', 'share', GLOBAL_DATA_DIR_NAME);\n}\n\n/**\n * Gets the path to the global config file.\n */\nexport function getGlobalConfigPath(): string {\n return path.join(getGlobalConfigDir(), GLOBAL_CONFIG_FILE_NAME);\n}\n\n/**\n * Loads the global configuration from disk.\n * Returns default configuration if file doesn't exist or is invalid.\n * Merges loaded config with defaults to ensure new fields are available.\n */\nexport function getGlobalConfig(): GlobalConfig {\n const configPath = getGlobalConfigPath();\n\n try {\n if (!fs.existsSync(configPath)) {\n return { ...DEFAULT_CONFIG };\n }\n\n const content = fs.readFileSync(configPath, 'utf-8');\n const parsed = JSON.parse(content);\n\n // Merge with defaults (loaded values take precedence)\n return {\n ...DEFAULT_CONFIG,\n ...parsed,\n // Deep merge featureFlags\n featureFlags: {\n ...DEFAULT_CONFIG.featureFlags,\n ...(parsed.featureFlags || {})\n }\n };\n } catch (error) {\n // Log warning for parse errors, but not for missing files\n if (error instanceof SyntaxError) {\n console.error(`Warning: Invalid JSON in ${configPath}, using defaults`);\n }\n return { ...DEFAULT_CONFIG };\n }\n}\n\n/**\n * Saves the global configuration to disk.\n * Creates the config directory if it doesn't exist.\n */\nexport function saveGlobalConfig(config: GlobalConfig): void {\n const configDir = getGlobalConfigDir();\n const configPath = getGlobalConfigPath();\n\n // Create directory if it doesn't exist\n if (!fs.existsSync(configDir)) {\n fs.mkdirSync(configDir, { recursive: true });\n }\n\n fs.writeFileSync(configPath, JSON.stringify(config, null, 2) + '\\n', 'utf-8');\n}\n",
|
|
58
|
+
"tokens": 1027
|
|
59
|
+
},
|
|
60
|
+
{
|
|
61
|
+
"path": "src/core/project-config.ts",
|
|
62
|
+
"content": "import { existsSync, readFileSync, statSync } from 'fs';\nimport path from 'path';\nimport { parse as parseYaml } from 'yaml';\nimport { z } from 'zod';\n\n/**\n * Zod schema for project configuration.\n *\n * Purpose:\n * 1. Documentation - clearly defines the config file structure\n * 2. Type safety - TypeScript infers ProjectConfig type from schema\n * 3. Runtime validation - uses safeParse() for resilient field-by-field validation\n *\n * Why Zod over manual validation:\n * - Helps understand OpenSpec's data interfaces at a glance\n * - Single source of truth for type and validation\n * - Consistent with other OpenSpec schemas\n */\nexport const ProjectConfigSchema = z.object({\n // Required: which schema to use (e.g., \"spec-driven\", or project-local schema name)\n schema: z\n .string()\n .min(1)\n .describe('The workflow schema to use (e.g., \"spec-driven\")'),\n\n // Optional: project context (injected into all artifact instructions)\n // Max size: 50KB (enforced during parsing)\n context: z\n .string()\n .optional()\n .describe('Project context injected into all artifact instructions'),\n\n // Optional: per-artifact rules (additive to schema's built-in guidance)\n rules: z\n .record(\n z.string(), // artifact ID\n z.array(z.string()) // list of rules\n )\n .optional()\n .describe('Per-artifact rules, keyed by artifact ID'),\n});\n\nexport type ProjectConfig = z.infer<typeof ProjectConfigSchema>;\n\nconst MAX_CONTEXT_SIZE = 50 * 1024; // 50KB hard limit\n\n/**\n * Read and parse openspec/config.yaml from project root.\n * Uses resilient parsing - validates each field independently using Zod safeParse.\n * Returns null if file doesn't exist.\n * Returns partial config if some fields are invalid (with warnings).\n *\n * Performance note (Jan 2025):\n * Benchmarks showed direct file reads are fast enough without caching:\n * - Typical config (1KB): ~0.5ms per read\n * - Large config (50KB): ~1.6ms per read\n * - Missing config: ~0.01ms per read\n * Config is read 1-2 times per command (schema resolution + instruction loading),\n * adding ~1-3ms total overhead. Caching would add complexity (mtime checks,\n * invalidation logic) for negligible benefit. Direct reads also ensure config\n * changes are reflected immediately without stale cache issues.\n *\n * @param projectRoot - The root directory of the project (where `openspec/` lives)\n * @returns Parsed config or null if file doesn't exist\n */\nexport function readProjectConfig(projectRoot: string): ProjectConfig | null {\n // Try both .yaml and .yml, prefer .yaml\n let configPath = path.join(projectRoot, 'openspec', 'config.yaml');\n if (!existsSync(configPath)) {\n configPath = path.join(projectRoot, 'openspec', 'config.yml');\n if (!existsSync(configPath)) {\n return null; // No config is OK\n }\n }\n\n try {\n const content = readFileSync(configPath, 'utf-8');\n const raw = parseYaml(content);\n\n if (!raw || typeof raw !== 'object') {\n console.warn(`openspec/config.yaml is not a valid YAML object`);\n return null;\n }\n\n const config: Partial<ProjectConfig> = {};\n\n // Parse schema field using Zod\n const schemaField = z.string().min(1);\n const schemaResult = schemaField.safeParse(raw.schema);\n if (schemaResult.success) {\n config.schema = schemaResult.data;\n } else if (raw.schema !== undefined) {\n console.warn(`Invalid 'schema' field in config (must be non-empty string)`);\n }\n\n // Parse context field with size limit\n if (raw.context !== undefined) {\n const contextField = z.string();\n const contextResult = contextField.safeParse(raw.context);\n\n if (contextResult.success) {\n const contextSize = Buffer.byteLength(contextResult.data, 'utf-8');\n if (contextSize > MAX_CONTEXT_SIZE) {\n console.warn(\n `Context too large (${(contextSize / 1024).toFixed(1)}KB, limit: ${MAX_CONTEXT_SIZE / 1024}KB)`\n );\n console.warn(`Ignoring context field`);\n } else {\n config.context = contextResult.data;\n }\n } else {\n console.warn(`Invalid 'context' field in config (must be string)`);\n }\n }\n\n // Parse rules field using Zod\n if (raw.rules !== undefined) {\n const rulesField = z.record(z.string(), z.array(z.string()));\n\n // First check if it's an object structure (guard against null since typeof null === 'object')\n if (typeof raw.rules === 'object' && raw.rules !== null && !Array.isArray(raw.rules)) {\n const parsedRules: Record<string, string[]> = {};\n let hasValidRules = false;\n\n for (const [artifactId, rules] of Object.entries(raw.rules)) {\n const rulesArrayResult = z.array(z.string()).safeParse(rules);\n\n if (rulesArrayResult.success) {\n // Filter out empty strings\n const validRules = rulesArrayResult.data.filter((r) => r.length > 0);\n if (validRules.length > 0) {\n parsedRules[artifactId] = validRules;\n hasValidRules = true;\n }\n if (validRules.length < rulesArrayResult.data.length) {\n console.warn(\n `Some rules for '${artifactId}' are empty strings, ignoring them`\n );\n }\n } else {\n console.warn(\n `Rules for '${artifactId}' must be an array of strings, ignoring this artifact's rules`\n );\n }\n }\n\n if (hasValidRules) {\n config.rules = parsedRules;\n }\n } else {\n console.warn(`Invalid 'rules' field in config (must be object)`);\n }\n }\n\n // Return partial config even if some fields failed\n return Object.keys(config).length > 0 ? (config as ProjectConfig) : null;\n } catch (error) {\n console.warn(`Failed to parse openspec/config.yaml:`, error);\n return null;\n }\n}\n\n/**\n * Validate artifact IDs in rules against a schema's artifacts.\n * Called during instruction loading (when schema is known).\n * Returns warnings for unknown artifact IDs.\n *\n * @param rules - The rules object from config\n * @param validArtifactIds - Set of valid artifact IDs from the schema\n * @param schemaName - Name of the schema for error messages\n * @returns Array of warning messages for unknown artifact IDs\n */\nexport function validateConfigRules(\n rules: Record<string, string[]>,\n validArtifactIds: Set<string>,\n schemaName: string\n): string[] {\n const warnings: string[] = [];\n\n for (const artifactId of Object.keys(rules)) {\n if (!validArtifactIds.has(artifactId)) {\n const validIds = Array.from(validArtifactIds).sort().join(', ');\n warnings.push(\n `Unknown artifact ID in rules: \"${artifactId}\". ` +\n `Valid IDs for schema \"${schemaName}\": ${validIds}`\n );\n }\n }\n\n return warnings;\n}\n\n/**\n * Suggest valid schema names when user provides invalid schema.\n * Uses fuzzy matching to find similar names.\n *\n * @param invalidSchemaName - The invalid schema name from config\n * @param availableSchemas - List of available schemas with their type (built-in or project-local)\n * @returns Error message with suggestions and available schemas\n */\nexport function suggestSchemas(\n invalidSchemaName: string,\n availableSchemas: { name: string; isBuiltIn: boolean }[]\n): string {\n // Simple fuzzy match: Levenshtein distance\n function levenshtein(a: string, b: string): number {\n const matrix: number[][] = [];\n for (let i = 0; i <= b.length; i++) {\n matrix[i] = [i];\n }\n for (let j = 0; j <= a.length; j++) {\n matrix[0][j] = j;\n }\n for (let i = 1; i <= b.length; i++) {\n for (let j = 1; j <= a.length; j++) {\n if (b.charAt(i - 1) === a.charAt(j - 1)) {\n matrix[i][j] = matrix[i - 1][j - 1];\n } else {\n matrix[i][j] = Math.min(\n matrix[i - 1][j - 1] + 1,\n matrix[i][j - 1] + 1,\n matrix[i - 1][j] + 1\n );\n }\n }\n }\n return matrix[b.length][a.length];\n }\n\n // Find closest matches (distance <= 3)\n const suggestions = availableSchemas\n .map((s) => ({ ...s, distance: levenshtein(invalidSchemaName, s.name) }))\n .filter((s) => s.distance <= 3)\n .sort((a, b) => a.distance - b.distance)\n .slice(0, 3);\n\n const builtIn = availableSchemas.filter((s) => s.isBuiltIn).map((s) => s.name);\n const projectLocal = availableSchemas.filter((s) => !s.isBuiltIn).map((s) => s.name);\n\n let message = `Schema '${invalidSchemaName}' not found in openspec/config.yaml\\n\\n`;\n\n if (suggestions.length > 0) {\n message += `Did you mean one of these?\\n`;\n suggestions.forEach((s) => {\n const type = s.isBuiltIn ? 'built-in' : 'project-local';\n message += ` - ${s.name} (${type})\\n`;\n });\n message += '\\n';\n }\n\n message += `Available schemas:\\n`;\n if (builtIn.length > 0) {\n message += ` Built-in: ${builtIn.join(', ')}\\n`;\n }\n if (projectLocal.length > 0) {\n message += ` Project-local: ${projectLocal.join(', ')}\\n`;\n } else {\n message += ` Project-local: (none found)\\n`;\n }\n\n message += `\\nFix: Edit openspec/config.yaml and change 'schema: ${invalidSchemaName}' to a valid schema name`;\n\n return message;\n}\n",
|
|
63
|
+
"tokens": 2294
|
|
64
|
+
},
|
|
65
|
+
{
|
|
66
|
+
"path": "src/core/completions/completion-provider.ts",
|
|
67
|
+
"content": "import { getActiveChangeIds, getSpecIds } from '../../utils/item-discovery.js';\n\n/**\n * Cache entry for completion data\n */\ninterface CacheEntry<T> {\n data: T;\n timestamp: number;\n}\n\n/**\n * Provides dynamic completion suggestions for OpenSpec items (changes and specs).\n * Implements a 2-second cache to avoid excessive file system operations during\n * tab completion.\n */\nexport class CompletionProvider {\n private readonly cacheTTL: number;\n private changeCache: CacheEntry<string[]> | null = null;\n private specCache: CacheEntry<string[]> | null = null;\n\n /**\n * Creates a new completion provider\n *\n * @param cacheTTLMs - Cache time-to-live in milliseconds (default: 2000ms)\n * @param projectRoot - Project root directory (default: process.cwd())\n */\n constructor(\n private readonly cacheTTLMs: number = 2000,\n private readonly projectRoot: string = process.cwd()\n ) {\n this.cacheTTL = cacheTTLMs;\n }\n\n /**\n * Get all active change IDs for completion\n *\n * @returns Array of change IDs\n */\n async getChangeIds(): Promise<string[]> {\n const now = Date.now();\n\n // Check if cache is valid\n if (this.changeCache && now - this.changeCache.timestamp < this.cacheTTL) {\n return this.changeCache.data;\n }\n\n // Fetch fresh data\n const changeIds = await getActiveChangeIds(this.projectRoot);\n\n // Update cache\n this.changeCache = {\n data: changeIds,\n timestamp: now,\n };\n\n return changeIds;\n }\n\n /**\n * Get all spec IDs for completion\n *\n * @returns Array of spec IDs\n */\n async getSpecIds(): Promise<string[]> {\n const now = Date.now();\n\n // Check if cache is valid\n if (this.specCache && now - this.specCache.timestamp < this.cacheTTL) {\n return this.specCache.data;\n }\n\n // Fetch fresh data\n const specIds = await getSpecIds(this.projectRoot);\n\n // Update cache\n this.specCache = {\n data: specIds,\n timestamp: now,\n };\n\n return specIds;\n }\n\n /**\n * Get both change and spec IDs for completion\n *\n * @returns Object with changeIds and specIds arrays\n */\n async getAllIds(): Promise<{ changeIds: string[]; specIds: string[] }> {\n const [changeIds, specIds] = await Promise.all([\n this.getChangeIds(),\n this.getSpecIds(),\n ]);\n\n return { changeIds, specIds };\n }\n\n /**\n * Clear all cached data\n */\n clearCache(): void {\n this.changeCache = null;\n this.specCache = null;\n }\n\n /**\n * Get cache statistics for debugging\n *\n * @returns Cache status information\n */\n getCacheStats(): {\n changeCache: { valid: boolean; age?: number };\n specCache: { valid: boolean; age?: number };\n } {\n const now = Date.now();\n\n return {\n changeCache: {\n valid: this.changeCache !== null && now - this.changeCache.timestamp < this.cacheTTL,\n age: this.changeCache ? now - this.changeCache.timestamp : undefined,\n },\n specCache: {\n valid: this.specCache !== null && now - this.specCache.timestamp < this.cacheTTL,\n age: this.specCache ? now - this.specCache.timestamp : undefined,\n },\n };\n }\n}\n",
|
|
68
|
+
"tokens": 781
|
|
69
|
+
},
|
|
70
|
+
{
|
|
71
|
+
"path": "src/core/config.ts",
|
|
72
|
+
"content": "export const OPENSPEC_DIR_NAME = 'openspec';\n\nexport const OPENSPEC_MARKERS = {\n start: '<!-- OPENSPEC:START -->',\n end: '<!-- OPENSPEC:END -->'\n};\n\nexport interface OpenSpecConfig {\n aiTools: string[];\n}\n\nexport interface AIToolOption {\n name: string;\n value: string;\n available: boolean;\n successLabel?: string;\n skillsDir?: string; // e.g., '.claude' - /skills suffix per Agent Skills spec\n}\n\nexport const AI_TOOLS: AIToolOption[] = [\n { name: 'Amazon Q Developer', value: 'amazon-q', available: true, successLabel: 'Amazon Q Developer', skillsDir: '.amazonq' },\n { name: 'Antigravity', value: 'antigravity', available: true, successLabel: 'Antigravity', skillsDir: '.agent' },\n { name: 'Auggie (Augment CLI)', value: 'auggie', available: true, successLabel: 'Auggie', skillsDir: '.augment' },\n { name: 'Claude Code', value: 'claude', available: true, successLabel: 'Claude Code', skillsDir: '.claude' },\n { name: 'Cline', value: 'cline', available: true, successLabel: 'Cline', skillsDir: '.cline' },\n { name: 'Codex', value: 'codex', available: true, successLabel: 'Codex', skillsDir: '.codex' },\n { name: 'CodeBuddy Code (CLI)', value: 'codebuddy', available: true, successLabel: 'CodeBuddy Code', skillsDir: '.codebuddy' },\n { name: 'Continue', value: 'continue', available: true, successLabel: 'Continue (VS Code / JetBrains / Cli)', skillsDir: '.continue' },\n { name: 'CoStrict', value: 'costrict', available: true, successLabel: 'CoStrict', skillsDir: '.cospec' },\n { name: 'Crush', value: 'crush', available: true, successLabel: 'Crush', skillsDir: '.crush' },\n { name: 'Cursor', value: 'cursor', available: true, successLabel: 'Cursor', skillsDir: '.cursor' },\n { name: 'Factory Droid', value: 'factory', available: true, successLabel: 'Factory Droid', skillsDir: '.factory' },\n { name: 'Gemini CLI', value: 'gemini', available: true, successLabel: 'Gemini CLI', skillsDir: '.gemini' },\n { name: 'GitHub Copilot', value: 'github-copilot', available: true, successLabel: 'GitHub Copilot', skillsDir: '.github' },\n { name: 'iFlow', value: 'iflow', available: true, successLabel: 'iFlow', skillsDir: '.iflow' },\n { name: 'Kilo Code', value: 'kilocode', available: true, successLabel: 'Kilo Code', skillsDir: '.kilocode' },\n { name: 'OpenCode', value: 'opencode', available: true, successLabel: 'OpenCode', skillsDir: '.opencode' },\n { name: 'Qoder', value: 'qoder', available: true, successLabel: 'Qoder', skillsDir: '.qoder' },\n { name: 'Qwen Code', value: 'qwen', available: true, successLabel: 'Qwen Code', skillsDir: '.qwen' },\n { name: 'RooCode', value: 'roocode', available: true, successLabel: 'RooCode', skillsDir: '.roo' },\n { name: 'Trae', value: 'trae', available: true, successLabel: 'Trae', skillsDir: '.trae' },\n { name: 'Windsurf', value: 'windsurf', available: true, successLabel: 'Windsurf', skillsDir: '.windsurf' },\n { name: 'AGENTS.md (works with Amp, VS Code, …)', value: 'agents', available: false, successLabel: 'your AGENTS.md-compatible assistant' }\n];\n",
|
|
73
|
+
"tokens": 755
|
|
74
|
+
},
|
|
75
|
+
{
|
|
76
|
+
"path": "src/commands/workflow/schemas.ts",
|
|
77
|
+
"content": "/**\n * Schemas Command\n *\n * Lists available workflow schemas with descriptions.\n */\n\nimport chalk from 'chalk';\nimport { listSchemasWithInfo } from '../../core/artifact-graph/index.js';\n\n// -----------------------------------------------------------------------------\n// Types\n// -----------------------------------------------------------------------------\n\nexport interface SchemasOptions {\n json?: boolean;\n}\n\n// -----------------------------------------------------------------------------\n// Command Implementation\n// -----------------------------------------------------------------------------\n\nexport async function schemasCommand(options: SchemasOptions): Promise<void> {\n const projectRoot = process.cwd();\n const schemas = listSchemasWithInfo(projectRoot);\n\n if (options.json) {\n console.log(JSON.stringify(schemas, null, 2));\n return;\n }\n\n console.log('Available schemas:');\n console.log();\n\n for (const schema of schemas) {\n let sourceLabel = '';\n if (schema.source === 'project') {\n sourceLabel = chalk.cyan(' (project)');\n } else if (schema.source === 'user') {\n sourceLabel = chalk.dim(' (user override)');\n }\n console.log(` ${chalk.bold(schema.name)}${sourceLabel}`);\n console.log(` ${schema.description}`);\n console.log(` Artifacts: ${schema.artifacts.join(' → ')}`);\n console.log();\n }\n}\n",
|
|
78
|
+
"tokens": 341
|
|
79
|
+
},
|
|
80
|
+
{
|
|
81
|
+
"path": "src/commands/feedback.ts",
|
|
82
|
+
"content": "import { execSync, execFileSync } from 'child_process';\nimport { createRequire } from 'module';\nimport os from 'os';\n\nconst require = createRequire(import.meta.url);\n\n/**\n * Check if gh CLI is installed and available in PATH\n * Uses platform-appropriate command: 'where' on Windows, 'which' on Unix/macOS\n */\nfunction isGhInstalled(): boolean {\n try {\n const command = process.platform === 'win32' ? 'where gh' : 'which gh';\n execSync(command, { stdio: 'pipe' });\n return true;\n } catch {\n return false;\n }\n}\n\n/**\n * Check if gh CLI is authenticated\n */\nfunction isGhAuthenticated(): boolean {\n try {\n execSync('gh auth status', { stdio: 'pipe' });\n return true;\n } catch {\n return false;\n }\n}\n\n/**\n * Get OpenSpec version from package.json\n */\nfunction getVersion(): string {\n try {\n const { version } = require('../../package.json');\n return version;\n } catch {\n return 'unknown';\n }\n}\n\n/**\n * Get platform name\n */\nfunction getPlatform(): string {\n return os.platform();\n}\n\n/**\n * Get current timestamp in ISO format\n */\nfunction getTimestamp(): string {\n return new Date().toISOString();\n}\n\n/**\n * Generate metadata footer for feedback\n */\nfunction generateMetadata(): string {\n const version = getVersion();\n const platform = getPlatform();\n const timestamp = getTimestamp();\n\n return `---\nSubmitted via OpenSpec CLI\n- Version: ${version}\n- Platform: ${platform}\n- Timestamp: ${timestamp}`;\n}\n\n/**\n * Format the feedback title\n */\nfunction formatTitle(message: string): string {\n return `Feedback: ${message}`;\n}\n\n/**\n * Format the full feedback body\n */\nfunction formatBody(bodyText?: string): string {\n const parts: string[] = [];\n\n if (bodyText) {\n parts.push(bodyText);\n parts.push(''); // Empty line before metadata\n }\n\n parts.push(generateMetadata());\n\n return parts.join('\\n');\n}\n\n/**\n * Generate a pre-filled GitHub issue URL for manual submission\n */\nfunction generateManualSubmissionUrl(title: string, body: string): string {\n const repo = 'Fission-AI/OpenSpec';\n const encodedTitle = encodeURIComponent(title);\n const encodedBody = encodeURIComponent(body);\n const encodedLabels = encodeURIComponent('feedback');\n\n return `https://github.com/${repo}/issues/new?title=${encodedTitle}&body=${encodedBody}&labels=${encodedLabels}`;\n}\n\n/**\n * Display formatted feedback content for manual submission\n */\nfunction displayFormattedFeedback(title: string, body: string): void {\n console.log('\\n--- FORMATTED FEEDBACK ---');\n console.log(`Title: ${title}`);\n console.log(`Labels: feedback`);\n console.log('\\nBody:');\n console.log(body);\n console.log('--- END FEEDBACK ---\\n');\n}\n\n/**\n * Submit feedback via gh CLI\n * Uses execFileSync to prevent shell injection vulnerabilities\n */\nfunction submitViaGhCli(title: string, body: string): void {\n try {\n const result = execFileSync(\n 'gh',\n [\n 'issue',\n 'create',\n '--repo',\n 'Fission-AI/OpenSpec',\n '--title',\n title,\n '--body',\n body,\n '--label',\n 'feedback',\n ],\n { encoding: 'utf-8', stdio: 'pipe' }\n );\n\n const issueUrl = result.trim();\n console.log(`\\n✓ Feedback submitted successfully!`);\n console.log(`Issue URL: ${issueUrl}\\n`);\n } catch (error: any) {\n // Display the error output from gh CLI\n if (error.stderr) {\n console.error(error.stderr.toString());\n } else if (error.message) {\n console.error(error.message);\n }\n\n // Exit with the same code as gh CLI\n process.exit(error.status ?? 1);\n }\n}\n\n/**\n * Handle fallback when gh CLI is not available or not authenticated\n */\nfunction handleFallback(title: string, body: string, reason: 'missing' | 'unauthenticated'): void {\n if (reason === 'missing') {\n console.log('⚠️ GitHub CLI not found. Manual submission required.');\n } else {\n console.log('⚠️ GitHub authentication required. Manual submission required.');\n }\n\n displayFormattedFeedback(title, body);\n\n const manualUrl = generateManualSubmissionUrl(title, body);\n console.log('Please submit your feedback manually:');\n console.log(manualUrl);\n\n if (reason === 'unauthenticated') {\n console.log('\\nTo auto-submit in the future: gh auth login');\n }\n\n // Exit with success code (fallback is successful)\n process.exit(0);\n}\n\n/**\n * Feedback command implementation\n */\nexport class FeedbackCommand {\n async execute(message: string, options?: { body?: string }): Promise<void> {\n // Format title and body once for all code paths\n const title = formatTitle(message);\n const body = formatBody(options?.body);\n\n // Check if gh CLI is installed\n if (!isGhInstalled()) {\n handleFallback(title, body, 'missing');\n return;\n }\n\n // Check if gh CLI is authenticated\n if (!isGhAuthenticated()) {\n handleFallback(title, body, 'unauthenticated');\n return;\n }\n\n // Submit via gh CLI\n submitViaGhCli(title, body);\n }\n}\n",
|
|
83
|
+
"tokens": 1238
|
|
84
|
+
},
|
|
85
|
+
{
|
|
86
|
+
"path": "src/telemetry/config.ts",
|
|
87
|
+
"content": "/**\n * Global configuration for telemetry state.\n * Stores anonymous ID and notice-seen flag in ~/.config/openspec/config.json\n */\nimport { promises as fs } from 'fs';\nimport path from 'path';\nimport os from 'os';\n\nexport interface TelemetryConfig {\n anonymousId?: string;\n noticeSeen?: boolean;\n}\n\nexport interface GlobalConfig {\n telemetry?: TelemetryConfig;\n [key: string]: unknown; // Preserve other fields\n}\n\n/**\n * Get the path to the global config file.\n * Uses ~/.config/openspec/config.json on all platforms.\n */\nexport function getConfigPath(): string {\n const configDir = path.join(os.homedir(), '.config', 'openspec');\n return path.join(configDir, 'config.json');\n}\n\n/**\n * Read the global config file.\n * Returns an empty object if the file doesn't exist.\n */\nexport async function readConfig(): Promise<GlobalConfig> {\n const configPath = getConfigPath();\n try {\n const content = await fs.readFile(configPath, 'utf-8');\n return JSON.parse(content) as GlobalConfig;\n } catch (error: unknown) {\n if ((error as NodeJS.ErrnoException).code === 'ENOENT') {\n return {};\n }\n // If parse fails or other error, return empty config\n return {};\n }\n}\n\n/**\n * Write to the global config file.\n * Preserves existing fields and merges in new values.\n */\nexport async function writeConfig(updates: Partial<GlobalConfig>): Promise<void> {\n const configPath = getConfigPath();\n const configDir = path.dirname(configPath);\n\n // Ensure directory exists\n await fs.mkdir(configDir, { recursive: true });\n\n // Read existing config and merge\n const existing = await readConfig();\n const merged = { ...existing, ...updates };\n\n // Deep merge for telemetry object\n if (updates.telemetry && existing.telemetry) {\n merged.telemetry = { ...existing.telemetry, ...updates.telemetry };\n }\n\n await fs.writeFile(configPath, JSON.stringify(merged, null, 2) + '\\n');\n}\n\n/**\n * Get the telemetry config section.\n */\nexport async function getTelemetryConfig(): Promise<TelemetryConfig> {\n const config = await readConfig();\n return config.telemetry ?? {};\n}\n\n/**\n * Update the telemetry config section.\n */\nexport async function updateTelemetryConfig(updates: Partial<TelemetryConfig>): Promise<void> {\n const existing = await getTelemetryConfig();\n await writeConfig({\n telemetry: { ...existing, ...updates },\n });\n}\n",
|
|
88
|
+
"tokens": 588
|
|
89
|
+
}
|
|
90
|
+
],
|
|
91
|
+
"totalTokens": 11720
|
|
92
|
+
},
|
|
93
|
+
"phase3_validation": {
|
|
94
|
+
"purpose": "Verification samples",
|
|
95
|
+
"files": [
|
|
96
|
+
{
|
|
97
|
+
"path": "src/core/update.ts",
|
|
98
|
+
"content": "/**\n * Update Command\n *\n * Refreshes OpenSpec skills and commands for configured tools.\n * Supports smart update detection to skip updates when already current.\n */\n\nimport path from 'path';\nimport chalk from 'chalk';\nimport ora from 'ora';\nimport { createRequire } from 'module';\nimport { FileSystemUtils } from '../utils/file-system.js';\nimport { AI_TOOLS, OPENSPEC_DIR_NAME } from './config.js';\nimport {\n generateCommands,\n CommandAdapterRegistry,\n} from './command-generation/index.js';\nimport {\n getConfiguredTools,\n getAllToolVersionStatus,\n getSkillTemplates,\n getCommandContents,\n generateSkillContent,\n getToolsWithSkillsDir,\n type ToolVersionStatus,\n} from './shared/index.js';\nimport {\n detectLegacyArtifacts,\n cleanupLegacyArtifacts,\n formatCleanupSummary,\n formatDetectionSummary,\n getToolsFromLegacyArtifacts,\n type LegacyDetectionResult,\n} from './legacy-cleanup.js';\nimport { isInteractive } from '../utils/interactive.js';\n\nconst require = createRequire(import.meta.url);\nconst { version: OPENSPEC_VERSION } = require('../../package.json');\n\n/**\n * Options for the update command.\n */\nexport interface UpdateCommandOptions {\n /** Force update even when tools are up to date */\n force?: boolean;\n}\n\nexport class UpdateCommand {\n private readonly force: boolean;\n\n constructor(options: UpdateCommandOptions = {}) {\n this.force = options.force ?? false;\n }\n\n async execute(projectPath: string): Promise<void> {\n const resolvedProjectPath = path.resolve(projectPath);\n const openspecPath = path.join(resolvedProjectPath, OPENSPEC_DIR_NAME);\n\n // 1. Check openspec directory exists\n if (!await FileSystemUtils.directoryExists(openspecPath)) {\n throw new Error(`No OpenSpec directory found. Run 'openspec init' first.`);\n }\n\n // 2. Detect and handle legacy artifacts + upgrade legacy tools to new skills\n const newlyConfiguredTools = await this.handleLegacyCleanup(resolvedProjectPath);\n\n // 3. Find configured tools\n const configuredTools = getConfiguredTools(resolvedProjectPath);\n\n if (configuredTools.length === 0 && newlyConfiguredTools.length === 0) {\n console.log(chalk.yellow('No configured tools found.'));\n console.log(chalk.dim('Run \"openspec init\" to set up tools.'));\n return;\n }\n\n // 4. Check version status for all configured tools\n const toolStatuses = getAllToolVersionStatus(resolvedProjectPath, OPENSPEC_VERSION);\n\n // 5. Smart update detection\n const toolsNeedingUpdate = toolStatuses.filter((s) => s.needsUpdate);\n const toolsUpToDate = toolStatuses.filter((s) => !s.needsUpdate);\n\n if (!this.force && toolsNeedingUpdate.length === 0) {\n // All tools are up to date\n this.displayUpToDateMessage(toolStatuses);\n return;\n }\n\n // 6. Display update plan\n if (this.force) {\n console.log(`Force updating ${configuredTools.length} tool(s): ${configuredTools.join(', ')}`);\n } else {\n this.displayUpdatePlan(toolsNeedingUpdate, toolsUpToDate);\n }\n console.log();\n\n // 7. Prepare templates\n const skillTemplates = getSkillTemplates();\n const commandContents = getCommandContents();\n\n // 8. Update tools (all if force, otherwise only those needing update)\n const toolsToUpdate = this.force ? configuredTools : toolsNeedingUpdate.map((s) => s.toolId);\n const updatedTools: string[] = [];\n const failedTools: Array<{ name: string; error: string }> = [];\n\n for (const toolId of toolsToUpdate) {\n const tool = AI_TOOLS.find((t) => t.value === toolId);\n if (!tool?.skillsDir) continue;\n\n const spinner = ora(`Updating ${tool.name}...`).start();\n\n try {\n const skillsDir = path.join(resolvedProjectPath, tool.skillsDir, 'skills');\n\n // Update skill files\n for (const { template, dirName } of skillTemplates) {\n const skillDir = path.join(skillsDir, dirName);\n const skillFile = path.join(skillDir, 'SKILL.md');\n\n const skillContent = generateSkillContent(template, OPENSPEC_VERSION);\n await FileSystemUtils.writeFile(skillFile, skillContent);\n }\n\n // Update commands\n const adapter = CommandAdapterRegistry.get(tool.value);\n if (adapter) {\n const generatedCommands = generateCommands(commandContents, adapter);\n\n for (const cmd of generatedCommands) {\n const commandFile = path.isAbsolute(cmd.path) ? cmd.path : path.join(resolvedProjectPath, cmd.path);\n await FileSystemUtils.writeFile(commandFile, cmd.fileContent);\n }\n }\n\n spinner.succeed(`Updated ${tool.name}`);\n updatedTools.push(tool.name);\n } catch (error) {\n spinner.fail(`Failed to update ${tool.name}`);\n failedTools.push({\n name: tool.name,\n error: error instanceof Error ? error.message : String(error)\n });\n }\n }\n\n // 9. Summary\n console.log();\n if (updatedTools.length > 0) {\n console.log(chalk.green(`✓ Updated: $",
|
|
99
|
+
"tokens": 3384
|
|
100
|
+
},
|
|
101
|
+
{
|
|
102
|
+
"path": "test/core/update.test.ts",
|
|
103
|
+
"content": "import { describe, it, expect, beforeEach, afterEach, vi } from 'vitest';\nimport { UpdateCommand } from '../../src/core/update.js';\nimport { FileSystemUtils } from '../../src/utils/file-system.js';\nimport { OPENSPEC_MARKERS } from '../../src/core/config.js';\nimport path from 'path';\nimport fs from 'fs/promises';\nimport os from 'os';\nimport { randomUUID } from 'crypto';\n\ndescribe('UpdateCommand', () => {\n let testDir: string;\n let updateCommand: UpdateCommand;\n\n beforeEach(async () => {\n // Create a temporary test directory\n testDir = path.join(os.tmpdir(), `openspec-test-${randomUUID()}`);\n await fs.mkdir(testDir, { recursive: true });\n\n // Create openspec directory\n const openspecDir = path.join(testDir, 'openspec');\n await fs.mkdir(openspecDir, { recursive: true });\n\n updateCommand = new UpdateCommand();\n\n // Clear all mocks before each test\n vi.restoreAllMocks();\n });\n\n afterEach(async () => {\n // Restore all mocks after each test\n vi.restoreAllMocks();\n\n // Clean up test directory\n await fs.rm(testDir, { recursive: true, force: true });\n });\n\n describe('basic validation', () => {\n it('should throw error if openspec directory does not exist', async () => {\n // Remove openspec directory\n await fs.rm(path.join(testDir, 'openspec'), {\n recursive: true,\n force: true,\n });\n\n await expect(updateCommand.execute(testDir)).rejects.toThrow(\n \"No OpenSpec directory found. Run 'openspec init' first.\"\n );\n });\n\n it('should report no configured tools when none exist', async () => {\n const consoleSpy = vi.spyOn(console, 'log');\n\n await updateCommand.execute(testDir);\n\n expect(consoleSpy).toHaveBeenCalledWith(\n expect.stringContaining('No configured tools found')\n );\n\n consoleSpy.mockRestore();\n });\n });\n\n describe('skill updates', () => {\n it('should update skill files for configured Claude tool', async () => {\n // Set up a configured Claude tool by creating skill directories\n const skillsDir = path.join(testDir, '.claude', 'skills');\n const exploreSkillDir = path.join(skillsDir, 'openspec-explore');\n await fs.mkdir(exploreSkillDir, { recursive: true });\n\n // Create an existing skill file\n const oldSkillContent = `---\nname: openspec-explore (old)\ndescription: Old description\nlicense: MIT\ncompatibility: Requires openspec CLI.\nmetadata:\n author: openspec\n version: \"0.9\"\n---\n\nOld instructions content\n`;\n await fs.writeFile(\n path.join(exploreSkillDir, 'SKILL.md'),\n oldSkillContent\n );\n\n const consoleSpy = vi.spyOn(console, 'log');\n\n await updateCommand.execute(testDir);\n\n // Check skill file was updated\n const updatedSkill = await fs.readFile(\n path.join(exploreSkillDir, 'SKILL.md'),\n 'utf-8'\n );\n expect(updatedSkill).toContain('name: openspec-explore');\n expect(updatedSkill).not.toContain('Old instructions content');\n expect(updatedSkill).toContain('license: MIT');\n\n // Check console output\n expect(consoleSpy).toHaveBeenCalledWith(\n expect.stringContaining('Updating 1 tool(s): claude')\n );\n\n consoleSpy.mockRestore();\n });\n\n it('should update all 9 skill files when tool is configured', async () => {\n // Set up a configured tool with all skill directories\n const skillsDir = path.join(testDir, '.claude', 'skills');\n const skillNames = [\n 'openspec-explore',\n 'openspec-new-change',\n 'openspec-continue-change',\n 'openspec-apply-change',\n 'openspec-ff-change',\n 'openspec-sync-specs',\n 'openspec-archive-change',\n 'openspec-bulk-archive-change',\n 'openspec-verify-change',\n ];\n\n // Create at least one skill to mark tool as configured\n await fs.mkdir(path.join(skillsDir, 'openspec-explore'), {\n recursive: true,\n });\n await fs.writeFile(\n path.join(skillsDir, 'openspec-explore', 'SKILL.md'),\n 'old content'\n );\n\n await updateCommand.execute(testDir);\n\n // Verify all skill files were created/updated\n for (const skillName of skillNames) {\n const skillFile = path.join(skillsDir, skillName, 'SKILL.md');\n const exists = await FileSystemUtils.fileExists(skillFile);\n expect(exists).toBe(true);\n\n const content = await fs.readFile(skillFile, 'utf-8');\n expect(content).toContain('---');\n expect(content).toContain('name:');\n expect(content).toContain('description:');\n }\n });\n });\n\n describe('command updates', () => {\n it('should update opsx commands for configured Claude tool', async () => {\n // Set up a configured Claude tool\n const skillsDir = path.join(testDir, '.claude', 'skills');\n await fs.mkdir(path.join(skillsDir, 'openspec-explore'), {\n recursive: true,\n });\n await fs.writeFile(\n path.join(skillsDir, 'openspec-explo",
|
|
104
|
+
"tokens": 11044
|
|
105
|
+
},
|
|
106
|
+
{
|
|
107
|
+
"path": "src/core/init.ts",
|
|
108
|
+
"content": "/**\n * Init Command\n *\n * Sets up OpenSpec with Agent Skills and /opsx:* slash commands.\n * This is the unified setup command that replaces both the old init and experimental commands.\n */\n\nimport path from 'path';\nimport chalk from 'chalk';\nimport ora from 'ora';\nimport * as fs from 'fs';\nimport { createRequire } from 'module';\nimport { FileSystemUtils } from '../utils/file-system.js';\nimport {\n AI_TOOLS,\n OPENSPEC_DIR_NAME,\n AIToolOption,\n} from './config.js';\nimport { PALETTE } from './styles/palette.js';\nimport { isInteractive } from '../utils/interactive.js';\nimport { serializeConfig } from './config-prompts.js';\nimport {\n generateCommands,\n CommandAdapterRegistry,\n} from './command-generation/index.js';\nimport {\n detectLegacyArtifacts,\n cleanupLegacyArtifacts,\n formatCleanupSummary,\n formatDetectionSummary,\n type LegacyDetectionResult,\n} from './legacy-cleanup.js';\nimport {\n SKILL_NAMES,\n getToolsWithSkillsDir,\n getToolSkillStatus,\n getToolStates,\n getSkillTemplates,\n getCommandContents,\n generateSkillContent,\n type ToolSkillStatus,\n} from './shared/index.js';\n\nconst require = createRequire(import.meta.url);\nconst { version: OPENSPEC_VERSION } = require('../../package.json');\n\n// -----------------------------------------------------------------------------\n// Constants\n// -----------------------------------------------------------------------------\n\nconst DEFAULT_SCHEMA = 'spec-driven';\n\nconst PROGRESS_SPINNER = {\n interval: 80,\n frames: ['░░░', '▒░░', '▒▒░', '▒▒▒', '▓▒▒', '▓▓▒', '▓▓▓', '▒▓▓', '░▒▓'],\n};\n\n// -----------------------------------------------------------------------------\n// Types\n// -----------------------------------------------------------------------------\n\ntype InitCommandOptions = {\n tools?: string;\n force?: boolean;\n interactive?: boolean;\n};\n\n// -----------------------------------------------------------------------------\n// Init Command Class\n// -----------------------------------------------------------------------------\n\nexport class InitCommand {\n private readonly toolsArg?: string;\n private readonly force: boolean;\n private readonly interactiveOption?: boolean;\n\n constructor(options: InitCommandOptions = {}) {\n this.toolsArg = options.tools;\n this.force = options.force ?? false;\n this.interactiveOption = options.interactive;\n }\n\n async execute(targetPath: string): Promise<void> {\n const projectPath = path.resolve(targetPath);\n const openspecDir = OPENSPEC_DIR_NAME;\n const openspecPath = path.join(projectPath, openspecDir);\n\n // Validation happens silently in the background\n const extendMode = await this.validate(projectPath, openspecPath);\n\n // Check for legacy artifacts and handle cleanup\n await this.handleLegacyCleanup(projectPath, extendMode);\n\n // Show animated welcome screen (interactive mode only)\n const canPrompt = this.canPromptInteractively();\n if (canPrompt) {\n const { showWelcomeScreen } = await import('../ui/welcome-screen.js');\n await showWelcomeScreen();\n }\n\n // Get tool states before processing\n const toolStates = getToolStates(projectPath);\n\n // Get tool selection\n const selectedToolIds = await this.getSelectedTools(toolStates, extendMode);\n\n // Validate selected tools\n const validatedTools = this.validateTools(selectedToolIds, toolStates);\n\n // Create directory structure and config\n await this.createDirectoryStructure(openspecPath, extendMode);\n\n // Generate skills and commands for each tool\n const results = await this.generateSkillsAndCommands(projectPath, validatedTools);\n\n // Create config.yaml if needed\n const configStatus = await this.createConfig(openspecPath, extendMode);\n\n // Display success message\n this.displaySuccessMessage(projectPath, validatedTools, results, configStatus);\n }\n\n // ═══════════════════════════════════════════════════════════\n // VALIDATION & SETUP\n // ═══════════════════════════════════════════════════════════\n\n private async validate(\n projectPath: string,\n openspecPath: string\n ): Promise<boolean> {\n const extendMode = await FileSystemUtils.directoryExists(openspecPath);\n\n // Check write permissions\n if (!(await FileSystemUtils.ensureWritePermissions(projectPath))) {\n throw new Error(`Insufficient permissions to write to ${projectPath}`);\n }\n return extendMode;\n }\n\n private canPromptInteractively(): boolean {\n if (this.interactiveOption === false) return false;\n if (this.toolsArg !== undefined) return false;\n return isInteractive({ interactive: this.interactiveOption });\n }\n\n // ═══════════════════════════════════════════════════════════\n // LEGACY CLEANUP\n // ═══════════════════════════════════════════════════════════\n\n private async handleLegacyCleanup(projectPath: string, extendMode: boolean): Promise<void> {\n // Detect legacy artifacts\n const detection = await detectLegacyArtifacts(projectPath);\n\n if (!detection.hasLegacyArtifacts) {\n return; ",
|
|
109
|
+
"tokens": 4947
|
|
110
|
+
},
|
|
111
|
+
{
|
|
112
|
+
"path": "src/cli/index.ts",
|
|
113
|
+
"content": "import { Command } from 'commander';\nimport { createRequire } from 'module';\nimport ora from 'ora';\nimport path from 'path';\nimport { promises as fs } from 'fs';\nimport { AI_TOOLS } from '../core/config.js';\nimport { UpdateCommand } from '../core/update.js';\nimport { ListCommand } from '../core/list.js';\nimport { ArchiveCommand } from '../core/archive.js';\nimport { ViewCommand } from '../core/view.js';\nimport { registerSpecCommand } from '../commands/spec.js';\nimport { ChangeCommand } from '../commands/change.js';\nimport { ValidateCommand } from '../commands/validate.js';\nimport { ShowCommand } from '../commands/show.js';\nimport { CompletionCommand } from '../commands/completion.js';\nimport { FeedbackCommand } from '../commands/feedback.js';\nimport { registerConfigCommand } from '../commands/config.js';\nimport { registerSchemaCommand } from '../commands/schema.js';\nimport {\n statusCommand,\n instructionsCommand,\n applyInstructionsCommand,\n templatesCommand,\n schemasCommand,\n newChangeCommand,\n DEFAULT_SCHEMA,\n type StatusOptions,\n type InstructionsOptions,\n type TemplatesOptions,\n type SchemasOptions,\n type NewChangeOptions,\n} from '../commands/workflow/index.js';\nimport { maybeShowTelemetryNotice, trackCommand, shutdown } from '../telemetry/index.js';\n\nconst program = new Command();\nconst require = createRequire(import.meta.url);\nconst { version } = require('../../package.json');\n\n/**\n * Get the full command path for nested commands.\n * For example: 'change show' -> 'change:show'\n */\nfunction getCommandPath(command: Command): string {\n const names: string[] = [];\n let current: Command | null = command;\n\n while (current) {\n const name = current.name();\n // Skip the root 'openspec' command\n if (name && name !== 'openspec') {\n names.unshift(name);\n }\n current = current.parent;\n }\n\n return names.join(':') || 'openspec';\n}\n\nprogram\n .name('openspec')\n .description('AI-native system for spec-driven development')\n .version(version);\n\n// Global options\nprogram.option('--no-color', 'Disable color output');\n\n// Apply global flags and telemetry before any command runs\n// Note: preAction receives (thisCommand, actionCommand) where:\n// - thisCommand: the command where hook was added (root program)\n// - actionCommand: the command actually being executed (subcommand)\nprogram.hook('preAction', async (thisCommand, actionCommand) => {\n const opts = thisCommand.opts();\n if (opts.color === false) {\n process.env.NO_COLOR = '1';\n }\n\n // Show first-run telemetry notice (if not seen)\n await maybeShowTelemetryNotice();\n\n // Track command execution (use actionCommand to get the actual subcommand)\n const commandPath = getCommandPath(actionCommand);\n await trackCommand(commandPath, version);\n});\n\n// Shutdown telemetry after command completes\nprogram.hook('postAction', async () => {\n await shutdown();\n});\n\nconst availableToolIds = AI_TOOLS.filter((tool) => tool.skillsDir).map((tool) => tool.value);\nconst toolsOptionDescription = `Configure AI tools non-interactively. Use \"all\", \"none\", or a comma-separated list of: ${availableToolIds.join(', ')}`;\n\nprogram\n .command('init [path]')\n .description('Initialize OpenSpec in your project')\n .option('--tools <tools>', toolsOptionDescription)\n .option('--force', 'Auto-cleanup legacy files without prompting')\n .action(async (targetPath = '.', options?: { tools?: string; force?: boolean }) => {\n try {\n // Validate that the path is a valid directory\n const resolvedPath = path.resolve(targetPath);\n\n try {\n const stats = await fs.stat(resolvedPath);\n if (!stats.isDirectory()) {\n throw new Error(`Path \"${targetPath}\" is not a directory`);\n }\n } catch (error: any) {\n if (error.code === 'ENOENT') {\n // Directory doesn't exist, but we can create it\n console.log(`Directory \"${targetPath}\" doesn't exist, it will be created.`);\n } else if (error.message && error.message.includes('not a directory')) {\n throw error;\n } else {\n throw new Error(`Cannot access path \"${targetPath}\": ${error.message}`);\n }\n }\n\n const { InitCommand } = await import('../core/init.js');\n const initCommand = new InitCommand({\n tools: options?.tools,\n force: options?.force,\n });\n await initCommand.execute(targetPath);\n } catch (error) {\n console.log(); // Empty line for spacing\n ora().fail(`Error: ${(error as Error).message}`);\n process.exit(1);\n }\n });\n\n// Hidden alias: 'experimental' -> 'init' for backwards compatibility\nprogram\n .command('experimental', { hidden: true })\n .description('Alias for init (deprecated)')\n .option('--tool <tool-id>', 'Target AI tool (maps to --tools)')\n .option('--no-interactive', 'Disable interactive prompts')\n .action(async (options?: { tool?: string; noInteractive?: boolean }) => {\n try {\n console.log('Note: \"openspec experimental\" is deprecated. Use \"openspe",
|
|
114
|
+
"tokens": 4608
|
|
115
|
+
}
|
|
116
|
+
],
|
|
117
|
+
"totalTokens": 23983
|
|
118
|
+
}
|
|
119
|
+
}
|