opensdd 0.1.0

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.
@@ -0,0 +1,176 @@
1
+ import fs from 'node:fs';
2
+ import path from 'node:path';
3
+
4
+ const DEFAULT_REGISTRY = 'https://github.com/deepagents-ai/opensdd';
5
+
6
+ export function resolveRegistry(options, manifest) {
7
+ if (options?.registry) return options.registry;
8
+ if (manifest?.registry) return manifest.registry;
9
+ return DEFAULT_REGISTRY;
10
+ }
11
+
12
+ export function isLocalPath(source) {
13
+ return !source.startsWith('http://') && !source.startsWith('https://');
14
+ }
15
+
16
+ export function isGitHubUrl(source) {
17
+ return /github\.com/.test(source);
18
+ }
19
+
20
+ export function parseGitHubUrl(url) {
21
+ const match = url.match(/github\.com\/([^/]+)\/([^/.]+)/);
22
+ if (!match) throw new Error(`Invalid GitHub URL: ${url}`);
23
+ return { owner: match[1], repo: match[2] };
24
+ }
25
+
26
+ async function githubApiFetch(urlPath, owner, repo) {
27
+ const url = `https://api.github.com/repos/${owner}/${repo}/contents/${urlPath}`;
28
+ const headers = {
29
+ 'Accept': 'application/vnd.github.v3+json',
30
+ 'User-Agent': 'opensdd-cli',
31
+ };
32
+ if (process.env.GITHUB_TOKEN) {
33
+ headers['Authorization'] = `token ${process.env.GITHUB_TOKEN}`;
34
+ }
35
+ const response = await fetch(url, { headers });
36
+ if (!response.ok) {
37
+ if (response.status === 404) return null;
38
+ throw new Error(`GitHub API error: ${response.status} ${response.statusText} for ${url}`);
39
+ }
40
+ return response.json();
41
+ }
42
+
43
+ async function githubRawFetch(filePath, owner, repo) {
44
+ const data = await githubApiFetch(filePath, owner, repo);
45
+ if (!data) return null;
46
+ if (data.content) {
47
+ return Buffer.from(data.content, 'base64').toString('utf-8');
48
+ }
49
+ // For larger files, use download_url
50
+ if (data.download_url) {
51
+ const response = await fetch(data.download_url, {
52
+ headers: { 'User-Agent': 'opensdd-cli' },
53
+ });
54
+ if (!response.ok) throw new Error(`Failed to download ${filePath}`);
55
+ return response.text();
56
+ }
57
+ throw new Error(`Could not fetch content for ${filePath}`);
58
+ }
59
+
60
+ /**
61
+ * List all specs available in the registry.
62
+ * Returns an array of index.json objects.
63
+ */
64
+ export async function listRegistrySpecs(registrySource) {
65
+ if (isLocalPath(registrySource)) {
66
+ const registryDir = path.join(registrySource, 'registry');
67
+ if (!fs.existsSync(registryDir)) return [];
68
+ const entries = fs.readdirSync(registryDir, { withFileTypes: true });
69
+ const specs = [];
70
+ for (const entry of entries) {
71
+ if (entry.isDirectory()) {
72
+ const indexPath = path.join(registryDir, entry.name, 'index.json');
73
+ if (fs.existsSync(indexPath)) {
74
+ try {
75
+ const index = JSON.parse(fs.readFileSync(indexPath, 'utf-8'));
76
+ specs.push(index);
77
+ } catch {
78
+ console.warn(`Warning: Malformed index.json for ${entry.name}, skipping.`);
79
+ }
80
+ }
81
+ }
82
+ }
83
+ return specs;
84
+ }
85
+
86
+ // GitHub registry
87
+ const { owner, repo } = parseGitHubUrl(registrySource);
88
+ const contents = await githubApiFetch('registry', owner, repo);
89
+ if (!contents || !Array.isArray(contents)) return [];
90
+
91
+ const specs = [];
92
+ for (const item of contents) {
93
+ if (item.type === 'dir') {
94
+ try {
95
+ const indexContent = await githubRawFetch(`registry/${item.name}/index.json`, owner, repo);
96
+ if (indexContent) {
97
+ specs.push(JSON.parse(indexContent));
98
+ }
99
+ } catch {
100
+ console.warn(`Warning: Could not read index.json for ${item.name}, skipping.`);
101
+ }
102
+ }
103
+ }
104
+ return specs;
105
+ }
106
+
107
+ /**
108
+ * Fetch index.json for a specific spec from the registry.
109
+ */
110
+ export async function fetchSpecIndex(registrySource, specName) {
111
+ if (isLocalPath(registrySource)) {
112
+ const indexPath = path.join(registrySource, 'registry', specName, 'index.json');
113
+ if (!fs.existsSync(indexPath)) return null;
114
+ return JSON.parse(fs.readFileSync(indexPath, 'utf-8'));
115
+ }
116
+
117
+ const { owner, repo } = parseGitHubUrl(registrySource);
118
+ const content = await githubRawFetch(`registry/${specName}/index.json`, owner, repo);
119
+ if (!content) return null;
120
+ return JSON.parse(content);
121
+ }
122
+
123
+ /**
124
+ * Fetch manifest.json for a specific spec version from the registry.
125
+ */
126
+ export async function fetchSpecManifest(registrySource, specName, version) {
127
+ if (isLocalPath(registrySource)) {
128
+ const manifestPath = path.join(registrySource, 'registry', specName, version, 'manifest.json');
129
+ if (!fs.existsSync(manifestPath)) return null;
130
+ return JSON.parse(fs.readFileSync(manifestPath, 'utf-8'));
131
+ }
132
+
133
+ const { owner, repo } = parseGitHubUrl(registrySource);
134
+ const content = await githubRawFetch(`registry/${specName}/${version}/manifest.json`, owner, repo);
135
+ if (!content) return null;
136
+ return JSON.parse(content);
137
+ }
138
+
139
+ /**
140
+ * Fetch all files for a specific spec version from the registry.
141
+ * Returns an object mapping filename -> content string.
142
+ */
143
+ export async function fetchSpecFiles(registrySource, specName, version) {
144
+ const files = {};
145
+
146
+ if (isLocalPath(registrySource)) {
147
+ const versionDir = path.join(registrySource, 'registry', specName, version);
148
+ if (!fs.existsSync(versionDir)) return null;
149
+ const entries = fs.readdirSync(versionDir, { withFileTypes: true });
150
+ for (const entry of entries) {
151
+ if (entry.isFile()) {
152
+ files[entry.name] = fs.readFileSync(path.join(versionDir, entry.name), 'utf-8');
153
+ }
154
+ }
155
+ return files;
156
+ }
157
+
158
+ // GitHub registry
159
+ const { owner, repo } = parseGitHubUrl(registrySource);
160
+ const contents = await githubApiFetch(`registry/${specName}/${version}`, owner, repo);
161
+ if (!contents || !Array.isArray(contents)) return null;
162
+
163
+ for (const item of contents) {
164
+ if (item.type === 'file') {
165
+ const content = await githubRawFetch(
166
+ `registry/${specName}/${version}/${item.name}`,
167
+ owner,
168
+ repo
169
+ );
170
+ if (content !== null) {
171
+ files[item.name] = content;
172
+ }
173
+ }
174
+ }
175
+ return files;
176
+ }
@@ -0,0 +1,204 @@
1
+ import fs from 'node:fs';
2
+ import path from 'node:path';
3
+ import { fileURLToPath } from 'node:url';
4
+
5
+ const __filename = fileURLToPath(import.meta.url);
6
+ const __dirname = path.dirname(__filename);
7
+
8
+ const OPENSDD_SECTION_START = '<!-- OpenSDD Skills (managed by opensdd init \u2014 do not edit this section) -->';
9
+ const OPENSDD_SECTION_END = '<!-- /OpenSDD Skills -->';
10
+
11
+ function getSkillContent() {
12
+ const opensddDir = path.resolve(__dirname, '../../opensdd');
13
+ return {
14
+ sddManager: fs.readFileSync(path.join(opensddDir, 'sdd-manager.md'), 'utf-8'),
15
+ sddGenerate: fs.readFileSync(path.join(opensddDir, 'sdd-generate.md'), 'utf-8'),
16
+ specFormat: fs.readFileSync(path.join(opensddDir, 'spec-format.md'), 'utf-8'),
17
+ };
18
+ }
19
+
20
+ function ensureDir(dir) {
21
+ fs.mkdirSync(dir, { recursive: true });
22
+ }
23
+
24
+ function writeFileSync(filePath, content) {
25
+ ensureDir(path.dirname(filePath));
26
+ fs.writeFileSync(filePath, content);
27
+ }
28
+
29
+ /**
30
+ * Update a managed section in a file (GEMINI.md or AGENTS.md).
31
+ * Creates the file if it doesn't exist.
32
+ * Only modifies the clearly delimited OpenSDD section.
33
+ */
34
+ function updateManagedSection(filePath, sectionBody) {
35
+ let content = '';
36
+ if (fs.existsSync(filePath)) {
37
+ content = fs.readFileSync(filePath, 'utf-8');
38
+ }
39
+
40
+ const sectionContent = `${OPENSDD_SECTION_START}\n${sectionBody}\n${OPENSDD_SECTION_END}`;
41
+
42
+ const startIdx = content.indexOf(OPENSDD_SECTION_START);
43
+ const endIdx = content.indexOf(OPENSDD_SECTION_END);
44
+
45
+ if (startIdx !== -1 && endIdx !== -1) {
46
+ // Replace existing section
47
+ content =
48
+ content.substring(0, startIdx) +
49
+ sectionContent +
50
+ content.substring(endIdx + OPENSDD_SECTION_END.length);
51
+ } else if (startIdx !== -1) {
52
+ // Start marker exists but no end marker — replace from start to end
53
+ content = content.substring(0, startIdx) + sectionContent;
54
+ } else {
55
+ // No existing section — append
56
+ if (content.length > 0 && !content.endsWith('\n')) {
57
+ content += '\n';
58
+ }
59
+ if (content.length > 0) {
60
+ content += '\n';
61
+ }
62
+ content += sectionContent + '\n';
63
+ }
64
+
65
+ fs.writeFileSync(filePath, content);
66
+ }
67
+
68
+ /**
69
+ * Install both skills (sdd-manager and sdd-generate) into all 6 supported agent formats.
70
+ * Returns an array of warnings for non-critical failures.
71
+ * Throws on critical failures (e.g., Claude Code installation fails).
72
+ */
73
+ export function installSkills(projectRoot) {
74
+ const skills = getSkillContent();
75
+ const warnings = [];
76
+
77
+ // 1. Claude Code (critical — Gemini and Amp depend on this)
78
+ const claudeBase = path.join(projectRoot, '.claude', 'skills');
79
+ writeFileSync(
80
+ path.join(claudeBase, 'sdd-manager', 'SKILL.md'),
81
+ skills.sddManager
82
+ );
83
+ writeFileSync(
84
+ path.join(claudeBase, 'sdd-manager', 'references', 'spec-format.md'),
85
+ skills.specFormat
86
+ );
87
+ writeFileSync(
88
+ path.join(claudeBase, 'sdd-generate', 'SKILL.md'),
89
+ skills.sddGenerate
90
+ );
91
+ writeFileSync(
92
+ path.join(claudeBase, 'sdd-generate', 'references', 'spec-format.md'),
93
+ skills.specFormat
94
+ );
95
+
96
+ // 2. Codex CLI
97
+ try {
98
+ const codexBase = path.join(projectRoot, '.agents', 'skills');
99
+ writeFileSync(
100
+ path.join(codexBase, 'sdd-manager', 'SKILL.md'),
101
+ skills.sddManager
102
+ );
103
+ writeFileSync(
104
+ path.join(codexBase, 'sdd-manager', 'references', 'spec-format.md'),
105
+ skills.specFormat
106
+ );
107
+ writeFileSync(
108
+ path.join(codexBase, 'sdd-generate', 'SKILL.md'),
109
+ skills.sddGenerate
110
+ );
111
+ writeFileSync(
112
+ path.join(codexBase, 'sdd-generate', 'references', 'spec-format.md'),
113
+ skills.specFormat
114
+ );
115
+ } catch (err) {
116
+ warnings.push(`Could not install Codex CLI skills: ${err.message}`);
117
+ }
118
+
119
+ // 3. Cursor
120
+ try {
121
+ const cursorBase = path.join(projectRoot, '.cursor', 'rules');
122
+ ensureDir(cursorBase);
123
+
124
+ const sddManagerCursor = `---
125
+ description: "Implement, update, and verify installed OpenSDD dependency specs. Use when the user asks to implement a spec, process a spec update, check conformance, or create a deviation."
126
+ alwaysApply: false
127
+ ---
128
+
129
+ ${skills.sddManager}`;
130
+
131
+ const sddGenerateCursor = `---
132
+ description: "Generate an OpenSDD behavioral spec from existing code. Use when the user asks to generate, create, or extract a spec from a repository or codebase."
133
+ alwaysApply: false
134
+ ---
135
+
136
+ ${skills.sddGenerate}`;
137
+
138
+ const specFormatCursor = `---
139
+ description: "OpenSDD spec format reference. Defines the structure and rules for behavioral specifications. Referenced by sdd-manager and sdd-generate skills."
140
+ alwaysApply: false
141
+ ---
142
+
143
+ ${skills.specFormat}`;
144
+
145
+ writeFileSync(path.join(cursorBase, 'sdd-manager.md'), sddManagerCursor);
146
+ writeFileSync(path.join(cursorBase, 'sdd-generate.md'), sddGenerateCursor);
147
+ writeFileSync(path.join(cursorBase, 'opensdd-spec-format.md'), specFormatCursor);
148
+ } catch (err) {
149
+ warnings.push(`Could not install Cursor skills: ${err.message}`);
150
+ }
151
+
152
+ // 4. GitHub Copilot
153
+ try {
154
+ const copilotBase = path.join(projectRoot, '.github', 'instructions');
155
+ ensureDir(copilotBase);
156
+
157
+ const copilotFrontmatter = `---
158
+ applyTo: "**"
159
+ ---
160
+
161
+ `;
162
+
163
+ writeFileSync(
164
+ path.join(copilotBase, 'sdd-manager.instructions.md'),
165
+ copilotFrontmatter + skills.sddManager
166
+ );
167
+ writeFileSync(
168
+ path.join(copilotBase, 'sdd-generate.instructions.md'),
169
+ copilotFrontmatter + skills.sddGenerate
170
+ );
171
+ writeFileSync(
172
+ path.join(copilotBase, 'opensdd-spec-format.instructions.md'),
173
+ copilotFrontmatter + skills.specFormat
174
+ );
175
+ } catch (err) {
176
+ warnings.push(`Could not install GitHub Copilot skills: ${err.message}`);
177
+ }
178
+
179
+ // 5. Gemini CLI
180
+ try {
181
+ const geminiPath = path.join(projectRoot, 'GEMINI.md');
182
+ const geminiBody = `@.claude/skills/sdd-manager/SKILL.md
183
+ @.claude/skills/sdd-manager/references/spec-format.md
184
+ @.claude/skills/sdd-generate/SKILL.md
185
+ @.claude/skills/sdd-generate/references/spec-format.md`;
186
+ updateManagedSection(geminiPath, geminiBody);
187
+ } catch (err) {
188
+ warnings.push(`Could not install Gemini CLI skills: ${err.message}`);
189
+ }
190
+
191
+ // 6. Amp
192
+ try {
193
+ const ampPath = path.join(projectRoot, 'AGENTS.md');
194
+ const ampBody = `@.claude/skills/sdd-manager/SKILL.md
195
+ @.claude/skills/sdd-manager/references/spec-format.md
196
+ @.claude/skills/sdd-generate/SKILL.md
197
+ @.claude/skills/sdd-generate/references/spec-format.md`;
198
+ updateManagedSection(ampPath, ampBody);
199
+ } catch (err) {
200
+ warnings.push(`Could not install Amp skills: ${err.message}`);
201
+ }
202
+
203
+ return warnings;
204
+ }
@@ -0,0 +1,122 @@
1
+ import fs from 'node:fs';
2
+ import path from 'node:path';
3
+
4
+ /**
5
+ * Validate a spec directory for conformance to the OpenSDD spec-format.
6
+ * Returns an object with errors, warnings, and detailed results per file.
7
+ */
8
+ export function validateSpec(specDir) {
9
+ const result = {
10
+ errors: [],
11
+ warnings: [],
12
+ specErrors: [],
13
+ specWarnings: [],
14
+ manifestErrors: [],
15
+ manifestExists: false,
16
+ hasDeviations: false,
17
+ manifest: null,
18
+ };
19
+
20
+ // Check for spec.md
21
+ const specMdPath = path.join(specDir, 'spec.md');
22
+ if (!fs.existsSync(specMdPath)) {
23
+ result.specErrors.push('Missing required file: spec.md');
24
+ result.errors.push('Missing required file: spec.md');
25
+ } else {
26
+ const content = fs.readFileSync(specMdPath, 'utf-8');
27
+ validateSpecMd(content, result);
28
+ }
29
+
30
+ // Check for manifest.json (optional, but validate if present)
31
+ const manifestPath = path.join(specDir, 'manifest.json');
32
+ if (fs.existsSync(manifestPath)) {
33
+ result.manifestExists = true;
34
+ try {
35
+ const manifest = JSON.parse(fs.readFileSync(manifestPath, 'utf-8'));
36
+ result.manifest = manifest;
37
+ validateManifest(manifest, result);
38
+ } catch (err) {
39
+ result.manifestErrors.push(`Malformed JSON: ${err.message}`);
40
+ result.errors.push('manifest.json: Malformed JSON');
41
+ }
42
+ }
43
+
44
+ // Check for deviations.md (specs for registry MUST NOT contain deviations)
45
+ if (fs.existsSync(path.join(specDir, 'deviations.md'))) {
46
+ result.hasDeviations = true;
47
+ result.errors.push('deviations.md found (specs for the registry must not contain deviations)');
48
+ }
49
+
50
+ return result;
51
+ }
52
+
53
+ function validateSpecMd(content, result) {
54
+ const lines = content.split('\n');
55
+
56
+ // Check for H1 header — spec says "MUST start with an H1 header"
57
+ // Find the first non-empty line
58
+ const firstContentIndex = lines.findIndex((l) => l.trim().length > 0);
59
+ const firstContentLine = firstContentIndex !== -1 ? lines[firstContentIndex] : '';
60
+
61
+ if (!/^# .+/.test(firstContentLine)) {
62
+ result.specErrors.push('Missing required: H1 header with blockquote summary');
63
+ result.errors.push('spec.md: Missing H1 header with blockquote summary');
64
+ } else {
65
+ // Check for blockquote summary after H1
66
+ let foundBlockquote = false;
67
+ for (let i = firstContentIndex + 1; i < Math.min(firstContentIndex + 5, lines.length); i++) {
68
+ if (lines[i].startsWith('> ')) {
69
+ foundBlockquote = true;
70
+ break;
71
+ }
72
+ }
73
+ if (!foundBlockquote) {
74
+ result.specErrors.push('Missing required: blockquote summary after H1 header');
75
+ result.errors.push('spec.md: Missing blockquote summary after H1');
76
+ }
77
+ }
78
+
79
+ // Check for Behavioral Contract section
80
+ if (!content.includes('## Behavioral Contract')) {
81
+ result.specErrors.push('Missing required: ## Behavioral Contract section');
82
+ result.errors.push('spec.md: Missing Behavioral Contract section');
83
+ }
84
+
85
+ // SHOULD warnings for recommended sections
86
+ if (!content.includes('## NOT Specified')) {
87
+ result.specWarnings.push('Missing ## NOT Specified section (recommended)');
88
+ result.warnings.push('spec.md: Missing NOT Specified section');
89
+ }
90
+
91
+ if (!content.includes('## Invariants')) {
92
+ result.specWarnings.push('Missing ## Invariants section (recommended)');
93
+ result.warnings.push('spec.md: Missing Invariants section');
94
+ }
95
+
96
+ if (!content.includes('## Edge Cases')) {
97
+ result.specWarnings.push('Missing ## Edge Cases section (recommended)');
98
+ result.warnings.push('spec.md: Missing Edge Cases section');
99
+ }
100
+ }
101
+
102
+ function validateManifest(manifest, result) {
103
+ if (!manifest.name) {
104
+ result.manifestErrors.push('Missing required field: name');
105
+ result.errors.push('manifest.json: Missing name');
106
+ }
107
+
108
+ if (!manifest.spec_format) {
109
+ result.manifestErrors.push('Missing required field: spec_format');
110
+ result.errors.push('manifest.json: Missing spec_format');
111
+ }
112
+
113
+ if (!manifest.version) {
114
+ result.manifestErrors.push('Missing required field: version');
115
+ result.errors.push('manifest.json: Missing version');
116
+ } else if (!/^\d+\.\d+\.\d+/.test(manifest.version)) {
117
+ result.manifestErrors.push(
118
+ `Invalid version format: ${manifest.version} (must be valid semver)`
119
+ );
120
+ result.errors.push('manifest.json: Invalid version format');
121
+ }
122
+ }