@fission-ai/openspec 0.16.0 → 0.17.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.
Files changed (39) hide show
  1. package/README.md +21 -14
  2. package/dist/cli/index.js +67 -2
  3. package/dist/commands/change.js +4 -3
  4. package/dist/commands/completion.d.ts +72 -0
  5. package/dist/commands/completion.js +221 -0
  6. package/dist/commands/config.d.ts +8 -0
  7. package/dist/commands/config.js +198 -0
  8. package/dist/commands/show.js +3 -2
  9. package/dist/commands/spec.js +4 -3
  10. package/dist/commands/validate.js +21 -2
  11. package/dist/core/archive.js +4 -1
  12. package/dist/core/completions/command-registry.d.ts +7 -0
  13. package/dist/core/completions/command-registry.js +362 -0
  14. package/dist/core/completions/completion-provider.d.ts +60 -0
  15. package/dist/core/completions/completion-provider.js +102 -0
  16. package/dist/core/completions/factory.d.ts +51 -0
  17. package/dist/core/completions/factory.js +57 -0
  18. package/dist/core/completions/generators/zsh-generator.d.ts +58 -0
  19. package/dist/core/completions/generators/zsh-generator.js +319 -0
  20. package/dist/core/completions/installers/zsh-installer.d.ts +136 -0
  21. package/dist/core/completions/installers/zsh-installer.js +449 -0
  22. package/dist/core/completions/types.d.ts +78 -0
  23. package/dist/core/completions/types.js +2 -0
  24. package/dist/core/config-schema.d.ts +76 -0
  25. package/dist/core/config-schema.js +200 -0
  26. package/dist/core/configurators/slash/opencode.js +0 -3
  27. package/dist/core/global-config.d.ts +29 -0
  28. package/dist/core/global-config.js +87 -0
  29. package/dist/core/index.d.ts +1 -1
  30. package/dist/core/index.js +2 -1
  31. package/dist/utils/file-system.js +19 -3
  32. package/dist/utils/interactive.d.ts +12 -1
  33. package/dist/utils/interactive.js +7 -2
  34. package/dist/utils/item-discovery.d.ts +1 -0
  35. package/dist/utils/item-discovery.js +23 -0
  36. package/dist/utils/shell-detection.d.ts +20 -0
  37. package/dist/utils/shell-detection.js +41 -0
  38. package/package.json +4 -1
  39. package/scripts/postinstall.js +147 -0
@@ -0,0 +1,200 @@
1
+ import { z } from 'zod';
2
+ /**
3
+ * Zod schema for global OpenSpec configuration.
4
+ * Uses passthrough() to preserve unknown fields for forward compatibility.
5
+ */
6
+ export const GlobalConfigSchema = z
7
+ .object({
8
+ featureFlags: z
9
+ .record(z.string(), z.boolean())
10
+ .optional()
11
+ .default({}),
12
+ })
13
+ .passthrough();
14
+ /**
15
+ * Default configuration values.
16
+ */
17
+ export const DEFAULT_CONFIG = {
18
+ featureFlags: {},
19
+ };
20
+ const KNOWN_TOP_LEVEL_KEYS = new Set(Object.keys(DEFAULT_CONFIG));
21
+ /**
22
+ * Validate a config key path for CLI set operations.
23
+ * Unknown top-level keys are rejected unless explicitly allowed by the caller.
24
+ */
25
+ export function validateConfigKeyPath(path) {
26
+ const rawKeys = path.split('.');
27
+ if (rawKeys.length === 0 || rawKeys.some((key) => key.trim() === '')) {
28
+ return { valid: false, reason: 'Key path must not be empty' };
29
+ }
30
+ const rootKey = rawKeys[0];
31
+ if (!KNOWN_TOP_LEVEL_KEYS.has(rootKey)) {
32
+ return { valid: false, reason: `Unknown top-level key "${rootKey}"` };
33
+ }
34
+ if (rootKey === 'featureFlags') {
35
+ if (rawKeys.length > 2) {
36
+ return { valid: false, reason: 'featureFlags values are booleans and do not support nested keys' };
37
+ }
38
+ return { valid: true };
39
+ }
40
+ if (rawKeys.length > 1) {
41
+ return { valid: false, reason: `"${rootKey}" does not support nested keys` };
42
+ }
43
+ return { valid: true };
44
+ }
45
+ /**
46
+ * Get a nested value from an object using dot notation.
47
+ *
48
+ * @param obj - The object to access
49
+ * @param path - Dot-separated path (e.g., "featureFlags.someFlag")
50
+ * @returns The value at the path, or undefined if not found
51
+ */
52
+ export function getNestedValue(obj, path) {
53
+ const keys = path.split('.');
54
+ let current = obj;
55
+ for (const key of keys) {
56
+ if (current === null || current === undefined) {
57
+ return undefined;
58
+ }
59
+ if (typeof current !== 'object') {
60
+ return undefined;
61
+ }
62
+ current = current[key];
63
+ }
64
+ return current;
65
+ }
66
+ /**
67
+ * Set a nested value in an object using dot notation.
68
+ * Creates intermediate objects as needed.
69
+ *
70
+ * @param obj - The object to modify (mutated in place)
71
+ * @param path - Dot-separated path (e.g., "featureFlags.someFlag")
72
+ * @param value - The value to set
73
+ */
74
+ export function setNestedValue(obj, path, value) {
75
+ const keys = path.split('.');
76
+ let current = obj;
77
+ for (let i = 0; i < keys.length - 1; i++) {
78
+ const key = keys[i];
79
+ if (current[key] === undefined || current[key] === null || typeof current[key] !== 'object') {
80
+ current[key] = {};
81
+ }
82
+ current = current[key];
83
+ }
84
+ const lastKey = keys[keys.length - 1];
85
+ current[lastKey] = value;
86
+ }
87
+ /**
88
+ * Delete a nested value from an object using dot notation.
89
+ *
90
+ * @param obj - The object to modify (mutated in place)
91
+ * @param path - Dot-separated path (e.g., "featureFlags.someFlag")
92
+ * @returns true if the key existed and was deleted, false otherwise
93
+ */
94
+ export function deleteNestedValue(obj, path) {
95
+ const keys = path.split('.');
96
+ let current = obj;
97
+ for (let i = 0; i < keys.length - 1; i++) {
98
+ const key = keys[i];
99
+ if (current[key] === undefined || current[key] === null || typeof current[key] !== 'object') {
100
+ return false;
101
+ }
102
+ current = current[key];
103
+ }
104
+ const lastKey = keys[keys.length - 1];
105
+ if (lastKey in current) {
106
+ delete current[lastKey];
107
+ return true;
108
+ }
109
+ return false;
110
+ }
111
+ /**
112
+ * Coerce a string value to its appropriate type.
113
+ * - "true" / "false" -> boolean
114
+ * - Numeric strings -> number
115
+ * - Everything else -> string
116
+ *
117
+ * @param value - The string value to coerce
118
+ * @param forceString - If true, always return the value as a string
119
+ * @returns The coerced value
120
+ */
121
+ export function coerceValue(value, forceString = false) {
122
+ if (forceString) {
123
+ return value;
124
+ }
125
+ // Boolean coercion
126
+ if (value === 'true') {
127
+ return true;
128
+ }
129
+ if (value === 'false') {
130
+ return false;
131
+ }
132
+ // Number coercion - must be a valid finite number
133
+ const num = Number(value);
134
+ if (!isNaN(num) && isFinite(num) && value.trim() !== '') {
135
+ return num;
136
+ }
137
+ return value;
138
+ }
139
+ /**
140
+ * Format a value for YAML-like display.
141
+ *
142
+ * @param value - The value to format
143
+ * @param indent - Current indentation level
144
+ * @returns Formatted string
145
+ */
146
+ export function formatValueYaml(value, indent = 0) {
147
+ const indentStr = ' '.repeat(indent);
148
+ if (value === null || value === undefined) {
149
+ return 'null';
150
+ }
151
+ if (typeof value === 'boolean' || typeof value === 'number') {
152
+ return String(value);
153
+ }
154
+ if (typeof value === 'string') {
155
+ return value;
156
+ }
157
+ if (Array.isArray(value)) {
158
+ if (value.length === 0) {
159
+ return '[]';
160
+ }
161
+ return value.map((item) => `${indentStr}- ${formatValueYaml(item, indent + 1)}`).join('\n');
162
+ }
163
+ if (typeof value === 'object') {
164
+ const entries = Object.entries(value);
165
+ if (entries.length === 0) {
166
+ return '{}';
167
+ }
168
+ return entries
169
+ .map(([key, val]) => {
170
+ const formattedVal = formatValueYaml(val, indent + 1);
171
+ if (typeof val === 'object' && val !== null && Object.keys(val).length > 0) {
172
+ return `${indentStr}${key}:\n${formattedVal}`;
173
+ }
174
+ return `${indentStr}${key}: ${formattedVal}`;
175
+ })
176
+ .join('\n');
177
+ }
178
+ return String(value);
179
+ }
180
+ /**
181
+ * Validate a configuration object against the schema.
182
+ *
183
+ * @param config - The configuration to validate
184
+ * @returns Validation result with success status and optional error message
185
+ */
186
+ export function validateConfig(config) {
187
+ try {
188
+ GlobalConfigSchema.parse(config);
189
+ return { success: true };
190
+ }
191
+ catch (error) {
192
+ if (error instanceof z.ZodError) {
193
+ const zodError = error;
194
+ const messages = zodError.issues.map((e) => `${e.path.join('.')}: ${e.message}`);
195
+ return { success: false, error: messages.join('; ') };
196
+ }
197
+ return { success: false, error: 'Unknown validation error' };
198
+ }
199
+ }
200
+ //# sourceMappingURL=config-schema.js.map
@@ -8,7 +8,6 @@ const FILE_PATHS = {
8
8
  };
9
9
  const FRONTMATTER = {
10
10
  proposal: `---
11
- agent: build
12
11
  description: Scaffold a new OpenSpec change and validate strictly.
13
12
  ---
14
13
  The user has requested the following change proposal. Use the openspec instructions to create their change proposal.
@@ -17,7 +16,6 @@ The user has requested the following change proposal. Use the openspec instructi
17
16
  </UserRequest>
18
17
  `,
19
18
  apply: `---
20
- agent: build
21
19
  description: Implement an approved OpenSpec change and keep tasks in sync.
22
20
  ---
23
21
  The user has requested to implement the following change proposal. Find the change proposal and follow the instructions below. If you're not sure or if ambiguous, ask for clarification from the user.
@@ -26,7 +24,6 @@ The user has requested to implement the following change proposal. Find the chan
26
24
  </UserRequest>
27
25
  `,
28
26
  archive: `---
29
- agent: build
30
27
  description: Archive a deployed OpenSpec change and update specs.
31
28
  ---
32
29
  <ChangeId>
@@ -0,0 +1,29 @@
1
+ export declare const GLOBAL_CONFIG_DIR_NAME = "openspec";
2
+ export declare const GLOBAL_CONFIG_FILE_NAME = "config.json";
3
+ export interface GlobalConfig {
4
+ featureFlags?: Record<string, boolean>;
5
+ }
6
+ /**
7
+ * Gets the global configuration directory path following XDG Base Directory Specification.
8
+ *
9
+ * - All platforms: $XDG_CONFIG_HOME/openspec/ if XDG_CONFIG_HOME is set
10
+ * - Unix/macOS fallback: ~/.config/openspec/
11
+ * - Windows fallback: %APPDATA%/openspec/
12
+ */
13
+ export declare function getGlobalConfigDir(): string;
14
+ /**
15
+ * Gets the path to the global config file.
16
+ */
17
+ export declare function getGlobalConfigPath(): string;
18
+ /**
19
+ * Loads the global configuration from disk.
20
+ * Returns default configuration if file doesn't exist or is invalid.
21
+ * Merges loaded config with defaults to ensure new fields are available.
22
+ */
23
+ export declare function getGlobalConfig(): GlobalConfig;
24
+ /**
25
+ * Saves the global configuration to disk.
26
+ * Creates the config directory if it doesn't exist.
27
+ */
28
+ export declare function saveGlobalConfig(config: GlobalConfig): void;
29
+ //# sourceMappingURL=global-config.d.ts.map
@@ -0,0 +1,87 @@
1
+ import * as fs from 'node:fs';
2
+ import * as path from 'node:path';
3
+ import * as os from 'node:os';
4
+ // Constants
5
+ export const GLOBAL_CONFIG_DIR_NAME = 'openspec';
6
+ export const GLOBAL_CONFIG_FILE_NAME = 'config.json';
7
+ const DEFAULT_CONFIG = {
8
+ featureFlags: {}
9
+ };
10
+ /**
11
+ * Gets the global configuration directory path following XDG Base Directory Specification.
12
+ *
13
+ * - All platforms: $XDG_CONFIG_HOME/openspec/ if XDG_CONFIG_HOME is set
14
+ * - Unix/macOS fallback: ~/.config/openspec/
15
+ * - Windows fallback: %APPDATA%/openspec/
16
+ */
17
+ export function getGlobalConfigDir() {
18
+ // XDG_CONFIG_HOME takes precedence on all platforms when explicitly set
19
+ const xdgConfigHome = process.env.XDG_CONFIG_HOME;
20
+ if (xdgConfigHome) {
21
+ return path.join(xdgConfigHome, GLOBAL_CONFIG_DIR_NAME);
22
+ }
23
+ const platform = os.platform();
24
+ if (platform === 'win32') {
25
+ // Windows: use %APPDATA%
26
+ const appData = process.env.APPDATA;
27
+ if (appData) {
28
+ return path.join(appData, GLOBAL_CONFIG_DIR_NAME);
29
+ }
30
+ // Fallback for Windows if APPDATA is not set
31
+ return path.join(os.homedir(), 'AppData', 'Roaming', GLOBAL_CONFIG_DIR_NAME);
32
+ }
33
+ // Unix/macOS fallback: ~/.config
34
+ return path.join(os.homedir(), '.config', GLOBAL_CONFIG_DIR_NAME);
35
+ }
36
+ /**
37
+ * Gets the path to the global config file.
38
+ */
39
+ export function getGlobalConfigPath() {
40
+ return path.join(getGlobalConfigDir(), GLOBAL_CONFIG_FILE_NAME);
41
+ }
42
+ /**
43
+ * Loads the global configuration from disk.
44
+ * Returns default configuration if file doesn't exist or is invalid.
45
+ * Merges loaded config with defaults to ensure new fields are available.
46
+ */
47
+ export function getGlobalConfig() {
48
+ const configPath = getGlobalConfigPath();
49
+ try {
50
+ if (!fs.existsSync(configPath)) {
51
+ return { ...DEFAULT_CONFIG };
52
+ }
53
+ const content = fs.readFileSync(configPath, 'utf-8');
54
+ const parsed = JSON.parse(content);
55
+ // Merge with defaults (loaded values take precedence)
56
+ return {
57
+ ...DEFAULT_CONFIG,
58
+ ...parsed,
59
+ // Deep merge featureFlags
60
+ featureFlags: {
61
+ ...DEFAULT_CONFIG.featureFlags,
62
+ ...(parsed.featureFlags || {})
63
+ }
64
+ };
65
+ }
66
+ catch (error) {
67
+ // Log warning for parse errors, but not for missing files
68
+ if (error instanceof SyntaxError) {
69
+ console.error(`Warning: Invalid JSON in ${configPath}, using defaults`);
70
+ }
71
+ return { ...DEFAULT_CONFIG };
72
+ }
73
+ }
74
+ /**
75
+ * Saves the global configuration to disk.
76
+ * Creates the config directory if it doesn't exist.
77
+ */
78
+ export function saveGlobalConfig(config) {
79
+ const configDir = getGlobalConfigDir();
80
+ const configPath = getGlobalConfigPath();
81
+ // Create directory if it doesn't exist
82
+ if (!fs.existsSync(configDir)) {
83
+ fs.mkdirSync(configDir, { recursive: true });
84
+ }
85
+ fs.writeFileSync(configPath, JSON.stringify(config, null, 2) + '\n', 'utf-8');
86
+ }
87
+ //# sourceMappingURL=global-config.js.map
@@ -1,2 +1,2 @@
1
- export {};
1
+ export { GLOBAL_CONFIG_DIR_NAME, GLOBAL_CONFIG_FILE_NAME, type GlobalConfig, getGlobalConfigDir, getGlobalConfigPath, getGlobalConfig, saveGlobalConfig } from './global-config.js';
2
2
  //# sourceMappingURL=index.d.ts.map
@@ -1,2 +1,3 @@
1
- export {};
1
+ // Core OpenSpec logic will be implemented here
2
+ export { GLOBAL_CONFIG_DIR_NAME, GLOBAL_CONFIG_FILE_NAME, getGlobalConfigDir, getGlobalConfigPath, getGlobalConfig, saveGlobalConfig } from './global-config.js';
2
3
  //# sourceMappingURL=index.js.map
@@ -1,4 +1,4 @@
1
- import { promises as fs } from 'fs';
1
+ import { promises as fs, constants as fsConstants } from 'fs';
2
2
  import path from 'path';
3
3
  function isMarkerOnOwnLine(content, markerIndex, markerLength) {
4
4
  let leftIndex = markerIndex - 1;
@@ -72,11 +72,27 @@ export class FileSystemUtils {
72
72
  if (!stats.isFile()) {
73
73
  return true;
74
74
  }
75
- return (stats.mode & 0o222) !== 0;
75
+ // On Windows, stats.mode doesn't reliably indicate write permissions.
76
+ // Use fs.access with W_OK to check actual write permissions cross-platform.
77
+ try {
78
+ await fs.access(filePath, fsConstants.W_OK);
79
+ return true;
80
+ }
81
+ catch {
82
+ return false;
83
+ }
76
84
  }
77
85
  catch (error) {
78
86
  if (error.code === 'ENOENT') {
79
- return true;
87
+ // File doesn't exist; check if we can write to the parent directory
88
+ const parentDir = path.dirname(filePath);
89
+ try {
90
+ await fs.access(parentDir, fsConstants.W_OK);
91
+ return true;
92
+ }
93
+ catch {
94
+ return false;
95
+ }
80
96
  }
81
97
  console.debug(`Unable to determine write permissions for ${filePath}: ${error.message}`);
82
98
  return false;
@@ -1,2 +1,13 @@
1
- export declare function isInteractive(noInteractiveFlag?: boolean): boolean;
1
+ type InteractiveOptions = {
2
+ /**
3
+ * Explicit "disable prompts" flag passed by internal callers.
4
+ */
5
+ noInteractive?: boolean;
6
+ /**
7
+ * Commander-style negated option: `--no-interactive` sets this to false.
8
+ */
9
+ interactive?: boolean;
10
+ };
11
+ export declare function isInteractive(value?: boolean | InteractiveOptions): boolean;
12
+ export {};
2
13
  //# sourceMappingURL=interactive.d.ts.map
@@ -1,5 +1,10 @@
1
- export function isInteractive(noInteractiveFlag) {
2
- if (noInteractiveFlag)
1
+ function resolveNoInteractive(value) {
2
+ if (typeof value === 'boolean')
3
+ return value;
4
+ return value?.noInteractive === true || value?.interactive === false;
5
+ }
6
+ export function isInteractive(value) {
7
+ if (resolveNoInteractive(value))
3
8
  return false;
4
9
  if (process.env.OPEN_SPEC_INTERACTIVE === '0')
5
10
  return false;
@@ -1,3 +1,4 @@
1
1
  export declare function getActiveChangeIds(root?: string): Promise<string[]>;
2
2
  export declare function getSpecIds(root?: string): Promise<string[]>;
3
+ export declare function getArchivedChangeIds(root?: string): Promise<string[]>;
3
4
  //# sourceMappingURL=item-discovery.d.ts.map
@@ -46,4 +46,27 @@ export async function getSpecIds(root = process.cwd()) {
46
46
  }
47
47
  return result.sort();
48
48
  }
49
+ export async function getArchivedChangeIds(root = process.cwd()) {
50
+ const archivePath = path.join(root, 'openspec', 'changes', 'archive');
51
+ try {
52
+ const entries = await fs.readdir(archivePath, { withFileTypes: true });
53
+ const result = [];
54
+ for (const entry of entries) {
55
+ if (!entry.isDirectory() || entry.name.startsWith('.'))
56
+ continue;
57
+ const proposalPath = path.join(archivePath, entry.name, 'proposal.md');
58
+ try {
59
+ await fs.access(proposalPath);
60
+ result.push(entry.name);
61
+ }
62
+ catch {
63
+ // skip directories without proposal.md
64
+ }
65
+ }
66
+ return result.sort();
67
+ }
68
+ catch {
69
+ return [];
70
+ }
71
+ }
49
72
  //# sourceMappingURL=item-discovery.js.map
@@ -0,0 +1,20 @@
1
+ /**
2
+ * Supported shell types for completion generation
3
+ */
4
+ export type SupportedShell = 'zsh' | 'bash' | 'fish' | 'powershell';
5
+ /**
6
+ * Result of shell detection
7
+ */
8
+ export interface ShellDetectionResult {
9
+ /** The detected shell if supported, otherwise undefined */
10
+ shell: SupportedShell | undefined;
11
+ /** The raw shell name detected (even if unsupported), or undefined if nothing detected */
12
+ detected: string | undefined;
13
+ }
14
+ /**
15
+ * Detects the current user's shell based on environment variables
16
+ *
17
+ * @returns Detection result with supported shell and raw detected name
18
+ */
19
+ export declare function detectShell(): ShellDetectionResult;
20
+ //# sourceMappingURL=shell-detection.d.ts.map
@@ -0,0 +1,41 @@
1
+ /**
2
+ * Detects the current user's shell based on environment variables
3
+ *
4
+ * @returns Detection result with supported shell and raw detected name
5
+ */
6
+ export function detectShell() {
7
+ // Try SHELL environment variable first (Unix-like systems)
8
+ const shellPath = process.env.SHELL;
9
+ if (shellPath) {
10
+ const shellName = shellPath.toLowerCase();
11
+ if (shellName.includes('zsh')) {
12
+ return { shell: 'zsh', detected: 'zsh' };
13
+ }
14
+ if (shellName.includes('bash')) {
15
+ return { shell: 'bash', detected: 'bash' };
16
+ }
17
+ if (shellName.includes('fish')) {
18
+ return { shell: 'fish', detected: 'fish' };
19
+ }
20
+ // Shell detected but not supported
21
+ // Extract shell name from path (e.g., /bin/tcsh -> tcsh)
22
+ const match = shellPath.match(/\/([^/]+)$/);
23
+ const detectedName = match ? match[1] : shellPath;
24
+ return { shell: undefined, detected: detectedName };
25
+ }
26
+ // Check for PowerShell on Windows
27
+ // PSModulePath is a reliable PowerShell-specific environment variable
28
+ if (process.env.PSModulePath || process.platform === 'win32') {
29
+ const comspec = process.env.COMSPEC?.toLowerCase();
30
+ // If PSModulePath exists, we're definitely in PowerShell
31
+ if (process.env.PSModulePath) {
32
+ return { shell: 'powershell', detected: 'powershell' };
33
+ }
34
+ // On Windows without PSModulePath, we might be in cmd.exe
35
+ if (comspec?.includes('cmd.exe')) {
36
+ return { shell: undefined, detected: 'cmd.exe' };
37
+ }
38
+ }
39
+ return { shell: undefined, detected: undefined };
40
+ }
41
+ //# sourceMappingURL=shell-detection.js.map
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@fission-ai/openspec",
3
- "version": "0.16.0",
3
+ "version": "0.17.0",
4
4
  "description": "AI-native system for spec-driven development",
5
5
  "keywords": [
6
6
  "openspec",
@@ -32,6 +32,7 @@
32
32
  "files": [
33
33
  "dist",
34
34
  "bin",
35
+ "scripts/postinstall.js",
35
36
  "!dist/**/*.test.js",
36
37
  "!dist/**/__tests__",
37
38
  "!dist/**/*.map"
@@ -62,6 +63,8 @@
62
63
  "test:watch": "vitest",
63
64
  "test:ui": "vitest --ui",
64
65
  "test:coverage": "vitest --coverage",
66
+ "test:postinstall": "node scripts/postinstall.js",
67
+ "postinstall": "node scripts/postinstall.js",
65
68
  "check:pack-version": "node scripts/pack-version-check.mjs",
66
69
  "release": "pnpm run release:ci",
67
70
  "release:ci": "pnpm run check:pack-version && pnpm exec changeset publish",
@@ -0,0 +1,147 @@
1
+ #!/usr/bin/env node
2
+
3
+ /**
4
+ * Postinstall script for auto-installing shell completions
5
+ *
6
+ * This script runs automatically after npm install unless:
7
+ * - CI=true environment variable is set
8
+ * - OPENSPEC_NO_COMPLETIONS=1 environment variable is set
9
+ * - dist/ directory doesn't exist (dev setup scenario)
10
+ *
11
+ * The script never fails npm install - all errors are caught and handled gracefully.
12
+ */
13
+
14
+ import { promises as fs } from 'fs';
15
+ import path from 'path';
16
+ import { fileURLToPath } from 'url';
17
+
18
+ const __filename = fileURLToPath(import.meta.url);
19
+ const __dirname = path.dirname(__filename);
20
+
21
+ /**
22
+ * Check if we should skip installation
23
+ */
24
+ function shouldSkipInstallation() {
25
+ // Skip in CI environments
26
+ if (process.env.CI === 'true' || process.env.CI === '1') {
27
+ return { skip: true, reason: 'CI environment detected' };
28
+ }
29
+
30
+ // Skip if user opted out
31
+ if (process.env.OPENSPEC_NO_COMPLETIONS === '1') {
32
+ return { skip: true, reason: 'OPENSPEC_NO_COMPLETIONS=1 set' };
33
+ }
34
+
35
+ return { skip: false };
36
+ }
37
+
38
+ /**
39
+ * Check if dist/ directory exists
40
+ */
41
+ async function distExists() {
42
+ const distPath = path.join(__dirname, '..', 'dist');
43
+ try {
44
+ const stat = await fs.stat(distPath);
45
+ return stat.isDirectory();
46
+ } catch {
47
+ return false;
48
+ }
49
+ }
50
+
51
+ /**
52
+ * Detect the user's shell
53
+ */
54
+ async function detectShell() {
55
+ try {
56
+ const { detectShell } = await import('../dist/utils/shell-detection.js');
57
+ const result = detectShell();
58
+ return result.shell;
59
+ } catch (error) {
60
+ // Fail silently if detection module doesn't exist
61
+ return undefined;
62
+ }
63
+ }
64
+
65
+ /**
66
+ * Install completions for the detected shell
67
+ */
68
+ async function installCompletions(shell) {
69
+ try {
70
+ const { CompletionFactory } = await import('../dist/core/completions/factory.js');
71
+ const { COMMAND_REGISTRY } = await import('../dist/core/completions/command-registry.js');
72
+
73
+ // Check if shell is supported
74
+ if (!CompletionFactory.isSupported(shell)) {
75
+ console.log(`\nTip: Run 'openspec completion install' for shell completions`);
76
+ return;
77
+ }
78
+
79
+ // Generate completion script
80
+ const generator = CompletionFactory.createGenerator(shell);
81
+ const script = generator.generate(COMMAND_REGISTRY);
82
+
83
+ // Install completion script
84
+ const installer = CompletionFactory.createInstaller(shell);
85
+ const result = await installer.install(script);
86
+
87
+ if (result.success) {
88
+ // Show success message based on installation type
89
+ if (result.isOhMyZsh) {
90
+ console.log(`✓ Shell completions installed`);
91
+ console.log(` Restart shell: exec zsh`);
92
+ } else if (result.zshrcConfigured) {
93
+ console.log(`✓ Shell completions installed and configured`);
94
+ console.log(` Restart shell: exec zsh`);
95
+ } else {
96
+ console.log(`✓ Shell completions installed to ~/.zsh/completions/`);
97
+ console.log(` Add to ~/.zshrc: fpath=(~/.zsh/completions $fpath)`);
98
+ console.log(` Then: exec zsh`);
99
+ }
100
+ } else {
101
+ // Installation failed, show tip for manual install
102
+ console.log(`\nTip: Run 'openspec completion install' for shell completions`);
103
+ }
104
+ } catch (error) {
105
+ // Fail gracefully - show tip for manual install
106
+ console.log(`\nTip: Run 'openspec completion install' for shell completions`);
107
+ }
108
+ }
109
+
110
+ /**
111
+ * Main function
112
+ */
113
+ async function main() {
114
+ try {
115
+ // Check if we should skip
116
+ const skipCheck = shouldSkipInstallation();
117
+ if (skipCheck.skip) {
118
+ // Silent skip - no output
119
+ return;
120
+ }
121
+
122
+ // Check if dist/ exists (skip silently if not - expected during dev setup)
123
+ if (!(await distExists())) {
124
+ return;
125
+ }
126
+
127
+ // Detect shell
128
+ const shell = await detectShell();
129
+ if (!shell) {
130
+ console.log(`\nTip: Run 'openspec completion install' for shell completions`);
131
+ return;
132
+ }
133
+
134
+ // Install completions
135
+ await installCompletions(shell);
136
+ } catch (error) {
137
+ // Fail gracefully - never break npm install
138
+ // Show tip for manual install
139
+ console.log(`\nTip: Run 'openspec completion install' for shell completions`);
140
+ }
141
+ }
142
+
143
+ // Run main and handle any unhandled errors
144
+ main().catch(() => {
145
+ // Silent failure - never break npm install
146
+ process.exit(0);
147
+ });