@fission-ai/openspec 1.14.3 → 1.16.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 (68) hide show
  1. package/README.md +12 -10
  2. package/dist/cli/index.js +47 -0
  3. package/dist/commands/completion.js +4 -2
  4. package/dist/commands/feedback.js +33 -20
  5. package/dist/commands/install-standalone.d.ts +27 -0
  6. package/dist/commands/install-standalone.js +150 -0
  7. package/dist/commands/schema.js +10 -3
  8. package/dist/commands/validate.js +33 -3
  9. package/dist/commands/workflow/instructions.js +15 -4
  10. package/dist/commands/workflow/new-change.js +12 -4
  11. package/dist/commands/workflow/status.js +5 -2
  12. package/dist/commands/workflow/templates.js +5 -2
  13. package/dist/core/artifact-graph/resolver.d.ts +6 -0
  14. package/dist/core/artifact-graph/resolver.js +11 -0
  15. package/dist/core/available-tools.d.ts +2 -1
  16. package/dist/core/available-tools.js +8 -28
  17. package/dist/core/completion-tip.d.ts +23 -0
  18. package/dist/core/completion-tip.js +4 -4
  19. package/dist/core/completions/command-registry.js +15 -0
  20. package/dist/core/completions/installers/zsh-installer.d.ts +9 -1
  21. package/dist/core/completions/installers/zsh-installer.js +64 -19
  22. package/dist/core/config-schema.d.ts +1 -0
  23. package/dist/core/config-schema.js +1 -0
  24. package/dist/core/file-view.d.ts +13 -0
  25. package/dist/core/file-view.js +14 -0
  26. package/dist/core/github-copilot/cloud-agent.d.ts +10 -2
  27. package/dist/core/github-copilot/cloud-agent.js +13 -9
  28. package/dist/core/global-config.d.ts +4 -0
  29. package/dist/core/global-config.js +5 -1
  30. package/dist/core/id.d.ts +8 -0
  31. package/dist/core/id.js +15 -0
  32. package/dist/core/legacy-cleanup.d.ts +11 -1
  33. package/dist/core/legacy-cleanup.js +15 -18
  34. package/dist/core/migration.d.ts +27 -1
  35. package/dist/core/migration.js +77 -29
  36. package/dist/core/parsers/code-fence.js +9 -1
  37. package/dist/core/parsers/requirement-blocks.d.ts +30 -0
  38. package/dist/core/parsers/requirement-blocks.js +22 -9
  39. package/dist/core/parsers/requirement-text.d.ts +14 -0
  40. package/dist/core/parsers/requirement-text.js +39 -0
  41. package/dist/core/parsers/spec-structure.js +1 -1
  42. package/dist/core/profile-sync-drift.d.ts +4 -3
  43. package/dist/core/profile-sync-drift.js +19 -16
  44. package/dist/core/project-config.d.ts +10 -0
  45. package/dist/core/project-config.js +26 -0
  46. package/dist/core/root-selection.d.ts +6 -0
  47. package/dist/core/root-selection.js +37 -0
  48. package/dist/core/shared/tool-detection.d.ts +12 -4
  49. package/dist/core/shared/tool-detection.js +29 -27
  50. package/dist/core/shared-skill-target.d.ts +12 -5
  51. package/dist/core/shared-skill-target.js +34 -23
  52. package/dist/core/standalone-tip.d.ts +6 -0
  53. package/dist/core/standalone-tip.js +38 -0
  54. package/dist/core/store/git.d.ts +22 -1
  55. package/dist/core/store/git.js +59 -4
  56. package/dist/core/templates/workflows/verify-change.js +4 -4
  57. package/dist/core/update-plan.d.ts +127 -0
  58. package/dist/core/update-plan.js +390 -0
  59. package/dist/core/update.d.ts +45 -1
  60. package/dist/core/update.js +279 -137
  61. package/dist/core/validation/validator.d.ts +27 -2
  62. package/dist/core/validation/validator.js +58 -4
  63. package/dist/core/version-check.d.ts +8 -1
  64. package/dist/core/version-check.js +48 -5
  65. package/dist/utils/executable-path.d.ts +5 -0
  66. package/dist/utils/executable-path.js +44 -0
  67. package/package.json +5 -4
  68. package/schemas/spec-driven/schema.yaml +1 -0
@@ -3,7 +3,6 @@
3
3
  *
4
4
  * Displays artifact completion status for a change.
5
5
  */
6
- import ora from 'ora';
7
6
  import chalk from 'chalk';
8
7
  import { getChangeDir } from '../../core/planning-home.js';
9
8
  import { resolveRootForCommand, toPlanningHome, toRootOutput, withStoreFlag, isStoreSelectedRoot, findDeclaringProjectRoot, } from '../../core/root-selection.js';
@@ -33,7 +32,11 @@ export async function statusCommand(options) {
33
32
  if (!root) {
34
33
  return;
35
34
  }
36
- const spinner = options.json ? undefined : ora('Loading change status...').start();
35
+ let spinner;
36
+ if (!options.json) {
37
+ const { default: ora } = await import('ora');
38
+ spinner = ora('Loading change status...').start();
39
+ }
37
40
  try {
38
41
  const planningHome = toPlanningHome(root);
39
42
  const projectRoot = root.path;
@@ -3,7 +3,6 @@
3
3
  *
4
4
  * Shows resolved template paths for all artifacts in a schema.
5
5
  */
6
- import ora from 'ora';
7
6
  import path from 'path';
8
7
  import { resolveSchema, getSchemaDir, ArtifactGraph, } from '../../core/artifact-graph/index.js';
9
8
  import { FileSystemUtils } from '../../utils/file-system.js';
@@ -12,7 +11,11 @@ import { validateSchemaExists, DEFAULT_SCHEMA } from './shared.js';
12
11
  // Command Implementation
13
12
  // -----------------------------------------------------------------------------
14
13
  export async function templatesCommand(options) {
15
- const spinner = options.json ? undefined : ora('Loading templates...').start();
14
+ let spinner;
15
+ if (!options.json) {
16
+ const { default: ora } = await import('ora');
17
+ spinner = ora('Loading templates...').start();
18
+ }
16
19
  try {
17
20
  const projectRoot = process.cwd();
18
21
  const schemaName = validateSchemaExists(options.schema ?? DEFAULT_SCHEMA, projectRoot);
@@ -8,6 +8,12 @@ export declare class SchemaLoadError extends Error {
8
8
  readonly cause?: Error | undefined;
9
9
  constructor(message: string, schemaPath: string, cause?: Error | undefined);
10
10
  }
11
+ /**
12
+ * Points the built-in schemas at another directory. The standalone binary has
13
+ * no package directory on disk, so it passes a function that extracts its
14
+ * embedded schemas; it runs only when a command first needs them.
15
+ */
16
+ export declare function setPackageSchemasDir(resolveDir: () => string): void;
11
17
  /**
12
18
  * Gets the package's built-in schemas directory path.
13
19
  * Uses import.meta.url to resolve relative to the current module.
@@ -17,11 +17,22 @@ export class SchemaLoadError extends Error {
17
17
  this.name = 'SchemaLoadError';
18
18
  }
19
19
  }
20
+ let packageSchemasDirOverride;
21
+ /**
22
+ * Points the built-in schemas at another directory. The standalone binary has
23
+ * no package directory on disk, so it passes a function that extracts its
24
+ * embedded schemas; it runs only when a command first needs them.
25
+ */
26
+ export function setPackageSchemasDir(resolveDir) {
27
+ packageSchemasDirOverride = resolveDir;
28
+ }
20
29
  /**
21
30
  * Gets the package's built-in schemas directory path.
22
31
  * Uses import.meta.url to resolve relative to the current module.
23
32
  */
24
33
  export function getPackageSchemasDir() {
34
+ if (packageSchemasDirOverride)
35
+ return packageSchemasDirOverride();
25
36
  const currentFile = fileURLToPath(import.meta.url);
26
37
  // Navigate from dist/core/artifact-graph/ to package root's schemas/
27
38
  return path.join(path.dirname(currentFile), '..', '..', '..', 'schemas');
@@ -5,6 +5,7 @@
5
5
  * for their configuration directories.
6
6
  */
7
7
  import { type AIToolOption } from './config.js';
8
+ import { type FileView } from './file-view.js';
8
9
  /**
9
10
  * Scans the project path for AI tool configuration directories and returns
10
11
  * the tools that are present.
@@ -13,5 +14,5 @@ import { type AIToolOption } from './config.js';
13
14
  * directories). Otherwise checks the project's `skillsDir`, or managed skill
14
15
  * files in the user's home directory for a global skill target.
15
16
  */
16
- export declare function getAvailableTools(projectPath: string): AIToolOption[];
17
+ export declare function getAvailableTools(projectPath: string, view?: FileView): AIToolOption[];
17
18
  //# sourceMappingURL=available-tools.d.ts.map
@@ -5,11 +5,11 @@
5
5
  * for their configuration directories.
6
6
  */
7
7
  import path from 'path';
8
- import * as fs from 'fs';
9
8
  import { AI_TOOLS } from './config.js';
10
9
  import { reconcileSharedSkillTargets } from './shared-skill-target.js';
11
10
  import { SKILL_NAMES } from './shared/tool-detection.js';
12
11
  import { resolveToolSkillsDir, toolSupportsSkills } from './shared/skill-paths.js';
12
+ import { DISK_VIEW } from './file-view.js';
13
13
  /**
14
14
  * Scans the project path for AI tool configuration directories and returns
15
15
  * the tools that are present.
@@ -18,49 +18,29 @@ import { resolveToolSkillsDir, toolSupportsSkills } from './shared/skill-paths.j
18
18
  * directories). Otherwise checks the project's `skillsDir`, or managed skill
19
19
  * files in the user's home directory for a global skill target.
20
20
  */
21
- export function getAvailableTools(projectPath) {
21
+ export function getAvailableTools(projectPath, view = DISK_VIEW) {
22
22
  const available = AI_TOOLS.filter((tool) => {
23
23
  if (!toolSupportsSkills(tool))
24
24
  return false;
25
25
  if (tool.globalSkillsDir) {
26
26
  const skillsDir = resolveToolSkillsDir(projectPath, tool);
27
- return SKILL_NAMES.some((skillName) => fs.existsSync(path.join(skillsDir, skillName, 'SKILL.md')));
27
+ return SKILL_NAMES.some((skillName) => view.exists(path.join(skillsDir, skillName, 'SKILL.md')));
28
28
  }
29
29
  if (!tool.skillsDir)
30
30
  return false;
31
31
  if (tool.detectionPaths && tool.detectionPaths.length > 0) {
32
- // statSync without .isDirectory() — detection paths can be files or directories
33
- return tool.detectionPaths.some((p) => {
34
- try {
35
- fs.statSync(path.join(projectPath, p));
36
- return true;
37
- }
38
- catch {
39
- return false;
40
- }
41
- });
42
- }
43
- const dirPath = path.join(projectPath, tool.skillsDir);
44
- try {
45
- return fs.statSync(dirPath).isDirectory();
46
- }
47
- catch {
48
- return false;
32
+ // Presence, not type — detection paths can be files or directories
33
+ return tool.detectionPaths.some((p) => view.exists(path.join(projectPath, p)));
49
34
  }
35
+ return view.isDirectory(path.join(projectPath, tool.skillsDir));
50
36
  });
51
- const activeProjectTools = new Set(reconcileSharedSkillTargets(projectPath, available.filter((tool) => tool.skillsDir)).map((tool) => tool.value));
37
+ const activeProjectTools = new Set(reconcileSharedSkillTargets(projectPath, available.filter((tool) => tool.skillsDir), view).map((tool) => tool.value));
52
38
  const hasIndependentDetectionPath = (tool) => (tool.detectionPaths ?? []).some((detectionPath) => {
53
39
  // Skill roots still go through managed-content reconciliation below;
54
40
  // their mere existence is not an independent tool signal.
55
41
  if (detectionPath.endsWith('/skills'))
56
42
  return false;
57
- try {
58
- fs.statSync(path.join(projectPath, detectionPath));
59
- return true;
60
- }
61
- catch {
62
- return false;
63
- }
43
+ return view.exists(path.join(projectPath, detectionPath));
64
44
  });
65
45
  return available.filter((tool) => tool.globalSkillsDir ||
66
46
  hasIndependentDetectionPath(tool) ||
@@ -7,6 +7,29 @@ export interface CompletionTipOptions {
7
7
  */
8
8
  silent?: boolean;
9
9
  }
10
+ /**
11
+ * Read the global config exactly as it sits on disk.
12
+ *
13
+ * Deliberately NOT `getGlobalConfig()`: that merges in defaults, and writing the
14
+ * merged result back would stamp `profile`/`delivery` into a file the user never
15
+ * set them in. `migrateIfNeeded` treats a raw `profile` as "already migrated",
16
+ * so that stamp would permanently suppress the one-time profile migration and
17
+ * cost users their installed workflow skills.
18
+ *
19
+ * Returns null when the file exists but cannot be read or parsed — a config we
20
+ * cannot understand is left strictly alone rather than overwritten.
21
+ */
22
+ export declare function readRawConfig(): Record<string, unknown> | null;
23
+ /**
24
+ * Record the flag, re-reading the config first and replacing the file by rename.
25
+ *
26
+ * Deciding whether to show the tip costs a `ps` spawn and a stat, and a sibling
27
+ * `openspec` process can write the same file in that window — on a first run
28
+ * that is exactly when telemetry mints `anonymousId`. Re-reading here keeps the
29
+ * write down to this one key, and the rename keeps a reader from ever seeing a
30
+ * half-written config.
31
+ */
32
+ export declare function markConfigFlag(key: string): Promise<void>;
10
33
  /**
11
34
  * Print the completion tip once, the first time the CLI runs.
12
35
  * Never throws — a hint must not break a command.
@@ -76,7 +76,7 @@ async function decideTip() {
76
76
  * Returns null when the file exists but cannot be read or parsed — a config we
77
77
  * cannot understand is left strictly alone rather than overwritten.
78
78
  */
79
- function readRawConfig() {
79
+ export function readRawConfig() {
80
80
  const configPath = getGlobalConfigPath();
81
81
  if (!fs.existsSync(configPath)) {
82
82
  return {};
@@ -96,13 +96,13 @@ function readRawConfig() {
96
96
  * write down to this one key, and the rename keeps a reader from ever seeing a
97
97
  * half-written config.
98
98
  */
99
- async function markTipSeen() {
99
+ export async function markConfigFlag(key) {
100
100
  const configPath = getGlobalConfigPath();
101
101
  const current = readRawConfig() ?? {};
102
102
  // The shared atomic writer: randomized temp name, owner-only mode, temp file
103
103
  // removed on failure. A predictable `<config>.<pid>.tmp` at the default mode
104
104
  // is both guessable and world-readable once renamed over the config.
105
- await writeFileAtomically(configPath, JSON.stringify({ ...current, completionTipSeen: true }, null, 2) + '\n');
105
+ await writeFileAtomically(configPath, JSON.stringify({ ...current, [key]: true }, null, 2) + '\n');
106
106
  }
107
107
  /**
108
108
  * Print the completion tip once, the first time the CLI runs.
@@ -123,7 +123,7 @@ export async function maybeShowCompletionTip(options = {}) {
123
123
  const decision = await decideTip();
124
124
  // Record before printing: if the flag cannot be persisted, staying quiet
125
125
  // beats reprinting the tip on every future run.
126
- await markTipSeen();
126
+ await markConfigFlag('completionTipSeen');
127
127
  if (decision === 'show') {
128
128
  console.error(`\n${COMPLETION_TIP_MESSAGE}`);
129
129
  }
@@ -52,8 +52,23 @@ export const COMMAND_REGISTRY = [
52
52
  name: 'force',
53
53
  description: 'Force update even when tools are up to date',
54
54
  },
55
+ {
56
+ name: 'dry-run',
57
+ description: 'Report what the update would change without writing anything',
58
+ },
59
+ {
60
+ name: 'json',
61
+ description: 'With --dry-run, print the planned changes as JSON',
62
+ },
55
63
  ],
56
64
  },
65
+ {
66
+ name: 'install',
67
+ description: 'Install the standalone openspec, which runs without Node.js (latest release unless [version] is given)',
68
+ acceptsPositional: true,
69
+ positionals: [{ name: 'version', optional: true }],
70
+ flags: [],
71
+ },
57
72
  {
58
73
  name: 'version',
59
74
  description: 'Report the installed OpenSpec version and update availability',
@@ -1,4 +1,6 @@
1
1
  import { InstallationResult } from '../factory.js';
2
+ /** The .zshrc zsh will read, as shown to the user: `~/.zshrc` unless ZDOTDIR moves it. */
3
+ export declare function zshrcDisplayPath(homeDir?: string): string;
2
4
  /**
3
5
  * Installer for Zsh completion scripts.
4
6
  * Supports both Oh My Zsh and standard Zsh configurations.
@@ -47,6 +49,11 @@ export declare class ZshInstaller {
47
49
  * @returns Path to .zshrc
48
50
  */
49
51
  private getZshrcPath;
52
+ /**
53
+ * Every .zshrc that may hold an OpenSpec block: the one zsh reads, plus
54
+ * ~/.zshrc, where installs before #2138 wrote it even under ZDOTDIR.
55
+ */
56
+ private getZshrcCandidates;
50
57
  /**
51
58
  * Generate .zshrc configuration content
52
59
  *
@@ -72,9 +79,10 @@ export declare class ZshInstaller {
72
79
  * Remove .zshrc configuration
73
80
  * Used during uninstallation
74
81
  *
82
+ * @param zshrcPath - The .zshrc to clean; defaults to the one zsh reads
75
83
  * @returns true if removed successfully, false otherwise
76
84
  */
77
- removeZshrcConfig(): Promise<boolean>;
85
+ removeZshrcConfig(zshrcPath?: string): Promise<boolean>;
78
86
  /**
79
87
  * Install the completion script
80
88
  *
@@ -3,6 +3,23 @@ import path from 'path';
3
3
  import os from 'os';
4
4
  import { FileSystemUtils } from '../../../utils/file-system.js';
5
5
  import { shellSingleQuote } from './shell-quote.js';
6
+ /**
7
+ * zsh reads its startup files from $ZDOTDIR when it is set, and from $HOME
8
+ * only otherwise, so a block written to ~/.zshrc under an XDG-style
9
+ * ZDOTDIR is never loaded (#2138).
10
+ */
11
+ function zshrcDir(homeDir) {
12
+ const zdotdir = process.env.ZDOTDIR;
13
+ // zsh does not expand `~` in ZDOTDIR, and path.join would resolve a relative
14
+ // or `~/...` value against the current directory, writing a stray .zshrc
15
+ // into the user's project. Only an absolute ZDOTDIR moves the file.
16
+ return zdotdir && path.isAbsolute(zdotdir) ? zdotdir : homeDir;
17
+ }
18
+ /** The .zshrc zsh will read, as shown to the user: `~/.zshrc` unless ZDOTDIR moves it. */
19
+ export function zshrcDisplayPath(homeDir = os.homedir()) {
20
+ const zshrcPath = path.join(zshrcDir(homeDir), '.zshrc');
21
+ return zshrcPath === path.join(homeDir, '.zshrc') ? '~/.zshrc' : zshrcPath;
22
+ }
6
23
  /**
7
24
  * Installer for Zsh completion scripts.
8
25
  * Supports both Oh My Zsh and standard Zsh configurations.
@@ -99,7 +116,14 @@ export class ZshInstaller {
99
116
  * @returns Path to .zshrc
100
117
  */
101
118
  getZshrcPath() {
102
- return path.join(this.homeDir, '.zshrc');
119
+ return path.join(zshrcDir(this.homeDir), '.zshrc');
120
+ }
121
+ /**
122
+ * Every .zshrc that may hold an OpenSpec block: the one zsh reads, plus
123
+ * ~/.zshrc, where installs before #2138 wrote it even under ZDOTDIR.
124
+ */
125
+ getZshrcCandidates() {
126
+ return [...new Set([this.getZshrcPath(), path.join(this.homeDir, '.zshrc')])];
103
127
  }
104
128
  /**
105
129
  * Generate .zshrc configuration content
@@ -150,9 +174,8 @@ export class ZshInstaller {
150
174
  *
151
175
  * @returns true if .zshrc exists and has markers
152
176
  */
153
- async hasZshrcConfig() {
177
+ async hasZshrcConfig(zshrcPath = this.getZshrcPath()) {
154
178
  try {
155
- const zshrcPath = this.getZshrcPath();
156
179
  const content = await fs.readFile(zshrcPath, 'utf-8');
157
180
  return content.includes(this.ZSHRC_MARKERS.start) && content.includes(this.ZSHRC_MARKERS.end);
158
181
  }
@@ -164,11 +187,11 @@ export class ZshInstaller {
164
187
  * Remove .zshrc configuration
165
188
  * Used during uninstallation
166
189
  *
190
+ * @param zshrcPath - The .zshrc to clean; defaults to the one zsh reads
167
191
  * @returns true if removed successfully, false otherwise
168
192
  */
169
- async removeZshrcConfig() {
193
+ async removeZshrcConfig(zshrcPath = this.getZshrcPath()) {
170
194
  try {
171
- const zshrcPath = this.getZshrcPath();
172
195
  // Check if file exists
173
196
  try {
174
197
  await fs.access(zshrcPath);
@@ -224,16 +247,24 @@ export class ZshInstaller {
224
247
  try {
225
248
  const existingContent = await fs.readFile(targetPath, 'utf-8');
226
249
  if (existingContent === completionScript) {
227
- // Already installed and up to date
250
+ // Already installed and up to date. Still make sure the .zshrc zsh
251
+ // reads has the block: an install made before #2138 under ZDOTDIR
252
+ // wrote it to ~/.zshrc, and re-running install is how that's fixed.
253
+ const zshrcConfigured = isOhMyZsh
254
+ ? false
255
+ : await this.configureZshrc(path.dirname(targetPath));
228
256
  return {
229
257
  success: true,
230
258
  installedPath: targetPath,
231
259
  isOhMyZsh,
260
+ zshrcConfigured,
232
261
  message: 'Completion script is already installed (up to date)',
233
- instructions: [
234
- 'The completion script is already installed and up to date.',
235
- 'If completions are not working, try: exec zsh',
236
- ],
262
+ instructions: isOhMyZsh || zshrcConfigured
263
+ ? [
264
+ 'The completion script is already installed and up to date.',
265
+ 'If completions are not working, try: exec zsh',
266
+ ]
267
+ : this.generateInstructions(false, targetPath),
237
268
  };
238
269
  }
239
270
  // File exists but content is different - this is an update
@@ -336,11 +367,11 @@ export class ZshInstaller {
336
367
  }
337
368
  else {
338
369
  const completionsDir = path.dirname(installedPath);
339
- const zshrcPath = path.join(this.homeDir, '.zshrc');
370
+ const zshrcPath = this.getZshrcPath();
340
371
  return [
341
372
  'Completion script installed to ~/.zsh/completions/',
342
373
  '',
343
- 'To enable completions, add the following to your ~/.zshrc file:',
374
+ `To enable completions, add the following to your ${zshrcDisplayPath(this.homeDir)} file:`,
344
375
  '',
345
376
  ` # Add completions directory to fpath`,
346
377
  ` fpath=(${shellSingleQuote(completionsDir)} $fpath)`,
@@ -375,11 +406,19 @@ export class ZshInstaller {
375
406
  }
376
407
  // Try to remove .zshrc configuration (only for standard Zsh)
377
408
  let zshrcWasPresent = false;
378
- let zshrcCleaned = false;
409
+ const cleanedZshrcs = [];
410
+ const failedZshrcs = [];
379
411
  if (!isOhMyZsh) {
380
- zshrcWasPresent = await this.hasZshrcConfig();
381
- if (zshrcWasPresent) {
382
- zshrcCleaned = await this.removeZshrcConfig();
412
+ for (const zshrcPath of this.getZshrcCandidates()) {
413
+ if (await this.hasZshrcConfig(zshrcPath)) {
414
+ zshrcWasPresent = true;
415
+ if (await this.removeZshrcConfig(zshrcPath)) {
416
+ cleanedZshrcs.push(zshrcPath);
417
+ }
418
+ else {
419
+ failedZshrcs.push(zshrcPath);
420
+ }
421
+ }
383
422
  }
384
423
  }
385
424
  if (!scriptRemoved && !zshrcWasPresent) {
@@ -392,11 +431,17 @@ export class ZshInstaller {
392
431
  if (scriptRemoved) {
393
432
  messages.push(`Completion script removed from ${targetPath}`);
394
433
  }
395
- if (zshrcCleaned && !isOhMyZsh) {
396
- messages.push('Removed OpenSpec configuration from ~/.zshrc');
434
+ const label = (zshrcPath) => zshrcPath === path.join(this.homeDir, '.zshrc') ? '~/.zshrc' : zshrcPath;
435
+ for (const zshrcPath of cleanedZshrcs) {
436
+ messages.push(`Removed OpenSpec configuration from ${label(zshrcPath)}`);
437
+ }
438
+ // A block left behind keeps loading the completions, so say so instead
439
+ // of reporting a clean uninstall.
440
+ for (const zshrcPath of failedZshrcs) {
441
+ messages.push(`Could not remove the OpenSpec block from ${label(zshrcPath)}; delete the lines between # OPENSPEC:START and # OPENSPEC:END by hand`);
397
442
  }
398
443
  return {
399
- success: true,
444
+ success: failedZshrcs.length === 0,
400
445
  message: messages.join('. '),
401
446
  };
402
447
  }
@@ -20,6 +20,7 @@ export declare const GlobalConfigSchema: z.ZodObject<{
20
20
  enabled: z.ZodOptional<z.ZodBoolean>;
21
21
  }, z.core.$loose>>;
22
22
  completionTipSeen: z.ZodOptional<z.ZodBoolean>;
23
+ standaloneTipSeen: z.ZodOptional<z.ZodBoolean>;
23
24
  }, z.core.$loose>;
24
25
  export type GlobalConfigType = z.infer<typeof GlobalConfigSchema>;
25
26
  /**
@@ -34,6 +34,7 @@ export const GlobalConfigSchema = z
34
34
  .optional(),
35
35
  // Runtime-managed (like telemetry.noticeSeen); not user-settable via CLI set.
36
36
  completionTipSeen: z.boolean().optional(),
37
+ standaloneTipSeen: z.boolean().optional(),
37
38
  })
38
39
  .passthrough();
39
40
  /**
@@ -0,0 +1,13 @@
1
+ /**
2
+ * How a check reads the files it inspects. Normally the disk. `update
3
+ * --dry-run` passes the tree as it has planned it, so a step that reads what
4
+ * an earlier step wrote sees the same thing it would in a real update.
5
+ */
6
+ export interface FileView {
7
+ exists(filePath: string): boolean;
8
+ /** Throws, like `fs.readFileSync`, when the file is absent. */
9
+ read(filePath: string): string;
10
+ isDirectory(filePath: string): boolean;
11
+ }
12
+ export declare const DISK_VIEW: FileView;
13
+ //# sourceMappingURL=file-view.d.ts.map
@@ -0,0 +1,14 @@
1
+ import * as fs from 'fs';
2
+ export const DISK_VIEW = {
3
+ exists: (filePath) => fs.existsSync(filePath),
4
+ read: (filePath) => fs.readFileSync(filePath, 'utf-8'),
5
+ isDirectory: (filePath) => {
6
+ try {
7
+ return fs.statSync(filePath).isDirectory();
8
+ }
9
+ catch {
10
+ return false;
11
+ }
12
+ },
13
+ };
14
+ //# sourceMappingURL=file-view.js.map
@@ -28,6 +28,14 @@ export declare const COPILOT_CLOUD_FILES: {
28
28
  readonly setupSteps: string;
29
29
  readonly agent: string;
30
30
  };
31
+ /**
32
+ * Every change the cloud-file sync makes on disk. `update --dry-run` passes a
33
+ * recorder, so the plan comes from this same code rather than a description.
34
+ */
35
+ export interface CopilotCloudFileOps {
36
+ writeFile(filePath: string, content: string): Promise<void>;
37
+ removeFile(filePath: string): Promise<void>;
38
+ }
31
39
  /**
32
40
  * Reconcile Copilot cloud agent files in the project directory.
33
41
  * Creates missing files and refreshes recognized legacy generated files while
@@ -35,7 +43,7 @@ export declare const COPILOT_CLOUD_FILES: {
35
43
  *
36
44
  * @returns Object indicating which files were written.
37
45
  */
38
- export declare function writeCopilotCloudFiles(projectPath: string): Promise<{
46
+ export declare function writeCopilotCloudFiles(projectPath: string, ops?: CopilotCloudFileOps): Promise<{
39
47
  setupStepsWritten: boolean;
40
48
  agentWritten: boolean;
41
49
  }>;
@@ -45,7 +53,7 @@ export declare function writeCopilotCloudFiles(projectPath: string): Promise<{
45
53
  *
46
54
  * @returns Number of files removed.
47
55
  */
48
- export declare function removeCopilotCloudFiles(projectPath: string): Promise<number>;
56
+ export declare function removeCopilotCloudFiles(projectPath: string, ops?: CopilotCloudFileOps): Promise<number>;
49
57
  /**
50
58
  * Read the persisted opt-in for Copilot cloud-file generation.
51
59
  *
@@ -242,10 +242,14 @@ function isLegacyCopilotCloudFile(relPath, content) {
242
242
  function isManagedCopilotCloudFile(relPath, content) {
243
243
  return isCurrentCopilotCloudFile(relPath, content) || isLegacyCopilotCloudFile(relPath, content);
244
244
  }
245
- async function reconcileCopilotCloudFile(fullPath, relPath) {
245
+ const DIRECT_CLOUD_FILE_OPS = {
246
+ writeFile: (filePath, content) => FileSystemUtils.writeFile(filePath, content),
247
+ removeFile: (filePath) => fs.unlink(filePath),
248
+ };
249
+ async function reconcileCopilotCloudFile(fullPath, relPath, ops) {
246
250
  const currentContent = COPILOT_CLOUD_FILE_CONTENTS[relPath];
247
251
  if (!(await FileSystemUtils.fileExists(fullPath))) {
248
- await FileSystemUtils.writeFile(fullPath, currentContent);
252
+ await ops.writeFile(fullPath, currentContent);
249
253
  return true;
250
254
  }
251
255
  const existingContent = await FileSystemUtils.readFile(fullPath);
@@ -255,7 +259,7 @@ async function reconcileCopilotCloudFile(fullPath, relPath) {
255
259
  if (!isLegacyCopilotCloudFile(relPath, existingContent)) {
256
260
  return false;
257
261
  }
258
- await FileSystemUtils.writeFile(fullPath, currentContent);
262
+ await ops.writeFile(fullPath, currentContent);
259
263
  return true;
260
264
  }
261
265
  async function assertCreatableFilePath(filePath) {
@@ -313,7 +317,7 @@ async function classifyCopilotAgentReconciliation(agentPath, alternateAgentPath)
313
317
  *
314
318
  * @returns Object indicating which files were written.
315
319
  */
316
- export async function writeCopilotCloudFiles(projectPath) {
320
+ export async function writeCopilotCloudFiles(projectPath, ops = DIRECT_CLOUD_FILE_OPS) {
317
321
  const setupStepsPath = FileSystemUtils.resolveProjectArtifactPath(projectPath, COPILOT_CLOUD_FILES.setupSteps);
318
322
  const agentPath = FileSystemUtils.resolveProjectArtifactPath(projectPath, COPILOT_CLOUD_FILES.agent);
319
323
  const alternateAgentPath = FileSystemUtils.resolveProjectArtifactPath(projectPath, COPILOT_AGENT_ALTERNATE_FILE);
@@ -323,13 +327,13 @@ export async function writeCopilotCloudFiles(projectPath) {
323
327
  await assertMissingOrRegularFile(agentPath);
324
328
  await assertMissingOrRegularFile(alternateAgentPath);
325
329
  const agentReconciliation = await classifyCopilotAgentReconciliation(agentPath, alternateAgentPath);
326
- const setupStepsWritten = await reconcileCopilotCloudFile(setupStepsPath, COPILOT_CLOUD_FILES.setupSteps);
330
+ const setupStepsWritten = await reconcileCopilotCloudFile(setupStepsPath, COPILOT_CLOUD_FILES.setupSteps, ops);
327
331
  let agentWritten = false;
328
332
  if (agentReconciliation === 'reconcile') {
329
- agentWritten = await reconcileCopilotCloudFile(agentPath, COPILOT_CLOUD_FILES.agent);
333
+ agentWritten = await reconcileCopilotCloudFile(agentPath, COPILOT_CLOUD_FILES.agent, ops);
330
334
  }
331
335
  else if (agentReconciliation === 'remove-managed') {
332
- await fs.unlink(agentPath);
336
+ await ops.removeFile(agentPath);
333
337
  }
334
338
  return { setupStepsWritten, agentWritten };
335
339
  }
@@ -339,7 +343,7 @@ export async function writeCopilotCloudFiles(projectPath) {
339
343
  *
340
344
  * @returns Number of files removed.
341
345
  */
342
- export async function removeCopilotCloudFiles(projectPath) {
346
+ export async function removeCopilotCloudFiles(projectPath, ops = DIRECT_CLOUD_FILE_OPS) {
343
347
  let removed = 0;
344
348
  const managedPaths = Object.values(COPILOT_CLOUD_FILES).map((relPath) => ({
345
349
  relPath,
@@ -354,7 +358,7 @@ export async function removeCopilotCloudFiles(projectPath) {
354
358
  if (!isManagedCopilotCloudFile(relPath, content)) {
355
359
  continue;
356
360
  }
357
- await fs.unlink(fullPath);
361
+ await ops.removeFile(fullPath);
358
362
  removed++;
359
363
  }
360
364
  }
@@ -28,6 +28,8 @@ export interface GlobalConfig {
28
28
  telemetry?: TelemetryConfig;
29
29
  /** Whether the first-run shell-completions tip has been shown. */
30
30
  completionTipSeen?: boolean;
31
+ /** Whether the one-time note about the standalone install has been shown. */
32
+ standaloneTipSeen?: boolean;
31
33
  }
32
34
  /**
33
35
  * Gets the global configuration directory path following XDG Base Directory Specification.
@@ -94,4 +96,6 @@ export interface SaveGlobalConfigOptions {
94
96
  * existing file it cannot parse unless `replaceUnreadable` is set.
95
97
  */
96
98
  export declare function saveGlobalConfig(config: GlobalConfig, options?: SaveGlobalConfigOptions): void;
99
+ /** The bytes `saveGlobalConfig` writes, for a caller that only plans the write. */
100
+ export declare function serializeGlobalConfig(config: GlobalConfig): string;
97
101
  //# sourceMappingURL=global-config.d.ts.map
@@ -173,6 +173,10 @@ export function saveGlobalConfig(config, options = {}) {
173
173
  if (!fs.existsSync(configDir)) {
174
174
  fs.mkdirSync(configDir, { recursive: true });
175
175
  }
176
- fs.writeFileSync(configPath, JSON.stringify(config, null, 2) + '\n', 'utf-8');
176
+ fs.writeFileSync(configPath, serializeGlobalConfig(config), 'utf-8');
177
+ }
178
+ /** The bytes `saveGlobalConfig` writes, for a caller that only plans the write. */
179
+ export function serializeGlobalConfig(config) {
180
+ return JSON.stringify(config, null, 2) + '\n';
177
181
  }
178
182
  //# sourceMappingURL=global-config.js.map
package/dist/core/id.d.ts CHANGED
@@ -4,6 +4,14 @@
4
4
  */
5
5
  export declare const KEBAB_ID_REGEX: RegExp;
6
6
  export declare function isKebabId(value: string): boolean;
7
+ /**
8
+ * Best-effort normalization of an arbitrary name into the kebab grammar:
9
+ * lowercases, splits camelCase/PascalCase boundaries, and folds every
10
+ * non-alphanumeric run into a single hyphen. Used to turn a git repository
11
+ * name into a candidate store id; the result is only ever a candidate and
12
+ * is validated with {@link isKebabId} before use.
13
+ */
14
+ export declare function toKebabCase(value: string): string;
7
15
  /** Human rendering of the grammar, shared so the wording never forks. */
8
16
  export declare const KEBAB_ID_DESCRIPTION = "must be kebab-case with lowercase letters, numbers, and single hyphen separators";
9
17
  /** The fix-line twin of KEBAB_ID_DESCRIPTION, shared for the same reason. */