Compare commits

..
Author SHA1 Message Date
Tabish Bidiwale 5ee9e56305 feat: add /opsx:explore command for exploratory thinking
Wire up the explore skill and slash command templates to the
artifact-experimental-setup command. This adds /opsx:explore as
a thinking partner mode for exploring ideas, investigating problems,
and clarifying requirements before committing to a change.

Changes:
- Add imports for getExploreSkillTemplate and getOpsxExploreCommandTemplate
- Add explore skill to skills array (generates openspec-explore/SKILL.md)
- Add explore command to commands array (generates opsx/explore.md)
- Add /opsx:explore to CLI usage message
- Update docs/experimental-workflow.md with explore command
2026-01-09 20:12:05 -08:00
29 changed files with 10 additions and 2033 deletions
+4 -93
View File
@@ -1,95 +1,6 @@
# Changesets
This directory is managed by Changesets.
This directory is managed by [Changesets](https://github.com/changesets/changesets).
- Add a changeset locally with `pnpm changeset`.
- The CI "Release (prepare)" workflow opens/updates a Version Packages PR.
- Publishing happens from a GitHub Release via the "Publish to npm" workflow.
## Quick Start
```bash
pnpm changeset
```
Follow the prompts to select version bump type and describe your changes.
## Workflow
1. **Add a changeset** — Run `pnpm changeset` locally before or after your PR
2. **Version PR** — CI opens/updates a "Version Packages" PR when changesets merge to main
3. **Release** — Merging the Version PR triggers npm publish and GitHub Release
> **Note:** Contributors only need to run `pnpm changeset`. Versioning (`changeset version`) and publishing happen automatically in CI.
## Template
Use this structure for your changeset content:
```markdown
---
"@fission-ai/openspec": patch
---
### New Features
- **Feature name** — What users can now do
### Bug Fixes
- Fixed issue where X happened when Y
### Breaking Changes
- `oldMethod()` has been removed, use `newMethod()` instead
### Deprecations
- `legacyOption` is deprecated and will be removed in v2.0
### Other
- Internal refactoring of X for better performance
```
Include only the sections relevant to your change.
## Version Bump Guide
| Type | When to use | Example |
|------|-------------|---------|
| `patch` | Bug fixes, small improvements | Fixed crash when config missing |
| `minor` | New features, non-breaking additions | Added `--verbose` flag |
| `major` | Breaking changes, removed features | Renamed `init` to `setup` |
## When to Create a Changeset
**Create one for:**
- New features or commands
- Bug fixes that affect users
- Breaking changes or deprecations
- Performance improvements users would notice
**Skip for:**
- Documentation-only changes
- Test additions/fixes
- Internal refactoring with no user impact
- CI/tooling changes
## Writing Good Descriptions
**Do:** Write for users, not developers
```markdown
- **Shell completions** — Tab completion now available for Bash, Fish, and PowerShell
```
**Don't:** Write implementation details
```markdown
- Added ShellCompletionGenerator class with Bash/Fish/PowerShell subclasses
```
**Do:** Explain the impact
```markdown
- Fixed config loading to respect `XDG_CONFIG_HOME` on Linux
```
**Don't:** Just reference the fix
```markdown
- Fixed #123
```
+1 -4
View File
@@ -1,9 +1,6 @@
{
"$schema": "https://unpkg.com/@changesets/config/schema.json",
"changelog": [
"@changesets/changelog-github",
{ "repo": "Fission-AI/OpenSpec" }
],
"changelog": "@changesets/cli/changelog",
"commit": false,
"fixed": [],
"linked": [],
-137
View File
@@ -1,137 +0,0 @@
name: Polish Release Notes
# Triggers when changesets creates a release
on:
release:
types: [published]
permissions:
contents: write
jobs:
polish:
# Only run on the main repo, not forks
if: github.repository == 'Fission-AI/OpenSpec'
runs-on: ubuntu-latest
steps:
- uses: actions/checkout@v4
- name: Get current release body
id: get-release
env:
GH_TOKEN: ${{ secrets.GITHUB_TOKEN }}
run: |
gh release view "${{ github.event.release.tag_name }}" --json body -q '.body' > current-notes.md
echo "Fetched release notes for ${{ github.event.release.tag_name }}"
- name: Transform release notes with Claude
uses: anthropics/claude-code-action@v1
id: claude
with:
claude_code_oauth_token: ${{ secrets.CLAUDE_CODE_OAUTH_TOKEN }}
github_token: ${{ secrets.GITHUB_TOKEN }}
direct_prompt: |
Transform the changelog in `current-notes.md` into release notes for OpenSpec ${{ github.event.release.tag_name }}.
## Voice
OpenSpec is a developer tool. Write like you're talking to a peer:
- Direct and practical, not marketing copy
- Focus on what changed and why it matters
- Skip the hype, keep it real
## Output
Create two files:
### 1. `release-title.txt`
A short title in this format:
```
${{ github.event.release.tag_name }} - [1-4 words describing the release]
```
Examples:
- `v0.18.0 - OPSX Experimental Workflow`
- `v0.16.0 - Antigravity, iFlow Support`
- `v0.15.0 - Gemini CLI, RooCode`
Rules for title:
- Lead with the most notable addition
- 1-4 words after the dash, no fluff
- If multiple features, comma-separate the top 2
- For bugfix-only releases, use something like `v0.17.2 - Pre-commit Hook Fix`
### 2. `polished-notes.md`
```markdown
## What's New in ${{ github.event.release.tag_name }}
[One sentence: what's the theme of this release?]
### New
- **Feature name** - What it does and why you'd use it
### Improved
- **Area** - What got better
### Fixed
- What was broken, now works
```
Omit empty sections.
## Rules
1. Write for developers using OpenSpec with AI coding assistants
2. Remove commit hashes (like `eb152eb:`), PR numbers, and changesets wrappers (`### Minor Changes`)
3. Lead with what users can do, not implementation details
4. One to two sentences per item, max
5. Use **bold** for feature/area names
6. Skip internal changes (CI, refactors, tests) unless they affect users
7. If the input is already well-formatted, just clean up structure and remove noise
## Example
Before:
```
### Minor Changes
- 8dfd824: Add OPSX experimental workflow commands and enhanced artifact system
**New Commands:**
- `/opsx:ff` - Fast-forward through artifact creation
```
After (polished-notes.md):
```
### New
- **Fast-forward mode** - Generate all planning artifacts at once with `/opsx:ff`. Useful when you already know what you're building.
```
After (release-title.txt):
```
v0.18.0 - OPSX Experimental Workflow
```
Write both files. No other output.
- name: Update release
env:
GH_TOKEN: ${{ secrets.GITHUB_TOKEN }}
run: |
TAG="${{ github.event.release.tag_name }}"
if [ -f "polished-notes.md" ] && [ -f "release-title.txt" ]; then
TITLE=$(cat release-title.txt)
gh release edit "$TAG" --title "$TITLE" --notes-file polished-notes.md
echo "Updated: $TITLE"
elif [ -f "polished-notes.md" ]; then
gh release edit "$TAG" --notes-file polished-notes.md
echo "Updated notes (title unchanged)"
else
echo "No changes generated, keeping original"
fi
+1 -12
View File
@@ -18,20 +18,9 @@ jobs:
if: github.repository == 'Fission-AI/OpenSpec'
runs-on: ubuntu-latest
steps:
# Generate GitHub App token first - used for checkout and changesets
# This allows git operations to trigger CI workflows on the version PR
# (GITHUB_TOKEN cannot trigger workflows by design)
- name: Generate GitHub App Token
id: app-token
uses: actions/create-github-app-token@v2
with:
app-id: ${{ vars.APP_ID }}
private-key: ${{ secrets.APP_PRIVATE_KEY }}
- uses: actions/checkout@v4
with:
fetch-depth: 0
token: ${{ steps.app-token.outputs.token }}
- uses: pnpm/action-setup@v4
with:
@@ -55,5 +44,5 @@ jobs:
# so package.json already contains the bumped version.
publish: pnpm run release:ci
env:
GITHUB_TOKEN: ${{ steps.app-token.outputs.token }}
GITHUB_TOKEN: ${{ secrets.GITHUB_TOKEN }}
# npm authentication handled via OIDC trusted publishing (no token needed)
-22
View File
@@ -1,25 +1,5 @@
# @fission-ai/openspec
## 0.19.0
### Minor Changes
- eb152eb: ### New Features
- **Continue IDE support** – OpenSpec now generates slash commands for [Continue](https://continue.dev/), expanding editor integration options alongside Cursor, Windsurf, Claude Code, and others
- **Shell completions for Bash, Fish, and PowerShell** – Run `openspec completion install` to set up tab completion in your preferred shell
- **`/opsx:explore` command** – A new thinking partner mode for exploring ideas and investigating problems before committing to changes
- **Codebuddy slash command improvements** – Updated frontmatter format for better compatibility
### Bug Fixes
- Shell completions now correctly offer parent-level flags (like `--help`) when a command has subcommands
- Fixed Windows compatibility issues in tests
### Other
- Added optional anonymous usage statistics to help understand how OpenSpec is used. This is **opt-out** by default – set `OPENSPEC_TELEMETRY=0` or `DO_NOT_TRACK=1` to disable. Only command names and version are collected; no arguments, file paths, or content. Automatically disabled in CI environments.
## 0.18.0
### Minor Changes
@@ -106,8 +86,6 @@
### Minor Changes
- Add Continue slash command support so `openspec init` can generate `.continue/prompts/openspec-*.prompt` files with MARKDOWN frontmatter and `$ARGUMENTS` placeholder, and refresh them on `openspec update`.
- Add Antigravity slash command support so `openspec init` can generate `.agent/workflows/openspec-*.md` files with description-only frontmatter and `openspec update` refreshes existing workflows alongside Windsurf.
## 0.15.0
-10
View File
@@ -103,7 +103,6 @@ These tools have built-in OpenSpec commands. Select the OpenSpec integration whe
| **Cline** | Workflows in `.clinerules/workflows/` directory (`.clinerules/workflows/openspec-*.md`) |
| **CodeBuddy Code (CLI)** | `/openspec:proposal`, `/openspec:apply`, `/openspec:archive` (`.codebuddy/commands/`) — see [docs](https://www.codebuddy.ai/cli) |
| **Codex** | `/openspec-proposal`, `/openspec-apply`, `/openspec-archive` (global: `~/.codex/prompts`, auto-installed) |
| **Continue** | `/openspec-proposal`, `/openspec-apply`, `/openspec-archive` (`.continue/prompts/`) |
| **CoStrict** | `/openspec-proposal`, `/openspec-apply`, `/openspec-archive` (`.cospec/openspec/commands/`) — see [docs](https://costrict.ai)|
| **Crush** | `/openspec-proposal`, `/openspec-apply`, `/openspec-archive` (`.crush/commands/openspec/`) |
| **Cursor** | `/openspec-proposal`, `/openspec-apply`, `/openspec-archive` |
@@ -411,15 +410,6 @@ You can always go back:
</details>
<details>
<summary><strong>Telemetry</strong> – OpenSpec collects anonymous usage stats (opt-out: <code>OPENSPEC_TELEMETRY=0</code>)</summary>
We collect only command names and version to understand usage patterns. No arguments, paths, content, or PII. Automatically disabled in CI.
**Opt-out:** `export OPENSPEC_TELEMETRY=0` or `export DO_NOT_TRACK=1`
</details>
## Contributing
- Install dependencies: `pnpm install`
-329
View File
@@ -1,329 +0,0 @@
# OpenSpec Versioning & Archiving: Quick Guide
> **TL;DR:** When multiple branches modify the same spec, the last archive wins (data loss). **Solution:** Use the OPSX workflow (`/opsx:sync`) for AI-driven intelligent merging. Setup: `openspec artifact-experimental-setup`. [Jump to solution](#the-solution-opsx-workflow)
## The Core Problem
**OpenSpec can silently lose changes when multiple branches modify the same requirement:**
```
Week 1:
┌─────────────┐
│ Branch A │ Adds: "Support SSO login"
│ (Feature) │ To requirement: "Authentication Methods"
└──────┬──────┘
│ Archives Monday ✓
▼
Main Spec now has: [Password, OAuth, SSO]
Week 1:
┌─────────────┐
│ Branch B │ Adds: "Support biometric login"
│ (Feature) │ To requirement: "Authentication Methods"
└──────┬──────┘ (Started from pre-SSO spec)
│ Archives Friday
▼
Main Spec now has: [Password, OAuth, Biometric] ← SSO disappeared! 😱
```
**Why?** OpenSpec replaces entire requirement blocks. No Git-like merging. No conflict detection.
## Workflow Risk
```
Multiple feature branches
│
├─── Change X: updates "Auth" requirement
├─── Change Y: updates "Auth" requirement
├─── Change Z: updates "Logging" requirement
│
Weekly batch archive (Friday)
│
└─── Last archive wins, earlier scenarios may vanish
```
## The Solution: OPSX Workflow
> **Requirements:** Claude Code, experimental workflow (stable and actively used)
The **OPSX workflow** solves the conflict problem with **AI-driven intelligent merging**. Instead of mechanical replacement, Claude reads your delta specs and understands intent.
**Available today.** Setup takes 30 seconds.
### Quick Comparison
```
Standard Archive: OPSX Sync:
┌──────────────────┐ ┌──────────────────┐
│ Replace entire │ │ AI-driven merge │
│ requirement │ │ Preserves │
│ block │ │ unmentioned │
│ │ │ scenarios │
│ Last write wins │ │ Intelligent │
│ ⚠️ Data loss │ │ ✓ Safe │
└──────────────────┘ └──────────────────┘
```
### How It Works: Your Authentication Example
Let's revisit the SSO/Biometric conflict with OPSX:
```
Week 1:
┌─────────────┐
│ Branch A │ Delta: "ADDED Requirements: Authentication Methods with SSO"
│ (Feature) │
└──────┬──────┘
│ Friday: /opsx:sync feature-a
▼
Main Spec: [Password, OAuth, SSO] ✓
┌─────────────┐
│ Branch B │ Delta: "ADDED Requirements: Authentication Methods with Biometric"
│ (Feature) │ (Delta created from pre-SSO spec)
└──────┬──────┘
│ Friday: /opsx:sync feature-b
│
│ What Claude does:
│ 1. Reads delta B: "Add Authentication Methods with Biometric"
│ 2. Reads main: "Already has Authentication Methods with SSO"
│ 3. Applies rule: "Preserve existing content not mentioned in delta"
│ 4. Merges: Keep SSO + OAuth, add Biometric
▼
Main Spec: [Password, OAuth, SSO, Biometric] ✓✓ ← Both changes preserved!
```
**Key principle:** Claude treats deltas as **instructions** ("add this scenario"), not **replacements** ("make it look exactly like this").
### Your Workflow Options
**Option 1: Friday Batch (Keeps Your Current Rhythm)**
```
Throughout week:
├─ Features complete on branches
└─ Code merges to main
Friday:
┌─────────────────────────────────────────────────┐
│ /opsx:sync feature-a │
│ ↓ Main updated with A's changes │
│ │
│ /opsx:sync feature-b │
│ ↓ Claude preserves A's changes, adds B │
│ │
│ /opsx:sync feature-c │
│ ↓ Claude preserves A+B, adds C │
│ │
│ git diff openspec/specs/ (review all) │
│ │
│ /opsx:archive feature-a │
│ /opsx:archive feature-b │
│ /opsx:archive feature-c │
└─────────────────────────────────────────────────┘
```
**Option 2: Sync-as-you-go (Even Safer)**
```
Monday: Feature A merges → /opsx:sync feature-a
Wednesday: Feature B merges → /opsx:sync feature-b (auto-merges with A)
Friday: Just /opsx:archive (specs already synced)
```
### Concrete Example: Three Teams, One Requirement
**Setup:**
- Change A: Adds SSO login
- Change B: Adds Biometric login
- Change C: Updates OAuth token expiry 1h → 2h
**Main spec starts with:**
```markdown
### Requirement: Authentication Methods
#### Scenario: Password login
...
#### Scenario: OAuth login (expires in 1h)
...
```
**Friday batch sync:**
```
/opsx:sync feature-a
├─ Delta A: ADDED "SSO login" scenario
└─ Main spec: [Password, OAuth, SSO]
/opsx:sync feature-b
├─ Delta B: ADDED "Biometric login" scenario
├─ Claude sees: Main already has SSO (not in delta B)
├─ Claude preserves: SSO
└─ Main spec: [Password, OAuth, SSO, Biometric]
/opsx:sync feature-c
├─ Delta C: MODIFIED "OAuth login" scenario (2h expiry)
├─ Claude sees: Main has SSO + Biometric (not in delta C)
├─ Claude preserves: SSO + Biometric
├─ Claude updates: OAuth expiry
└─ Main spec: [Password, OAuth (2h), SSO, Biometric]
```
**Result:** All three changes merged successfully! 🎉
### Getting Started
**1. One-time setup:**
```bash
openspec artifact-experimental-setup
# Creates /opsx:* skills in .claude/skills/
```
**2. Try it this Friday:**
```
# In Claude Code:
/opsx:sync
→ Select a change
→ Claude merges intelligently
git diff openspec/specs/
→ Review what changed
/opsx:archive
→ Move to archive when satisfied
```
**3. For new features:**
```
/opsx:new feature-name # Start new change
/opsx:ff # Create planning artifacts
/opsx:apply # Implement
/opsx:sync # Merge specs to main
/opsx:archive # Archive when done
```
### What Cases Does This Handle?
```
✓ Multiple changes adding different scenarios to same requirement
✓ One change adds, another modifies same requirement
✓ Changes affecting different parts of same requirement
✓ Mix of ADDED/MODIFIED/REMOVED operations
✓ Most real-world parallel development
⚠️ Two changes modifying exact same scenario in conflicting ways
⚠️ One removes what another modifies
Best practice: Always review git diff after each sync
```
### Why This Works
The `/opsx:sync` skill instructs Claude:
> "Preserve scenarios/content not mentioned in the delta. The delta represents *intent*, not a wholesale replacement. Apply changes intelligently - add new scenarios without removing existing ones, update only what's explicitly mentioned."
This is like having a human do the merge instead of a mechanical diff tool.
## If You Can't Use OPSX Yet
If you're not using Claude Code or can't adopt OPSX yet, here are manual workarounds:
### Before Creating Changes
```bash
# Check for conflicts
openspec list
ls openspec/changes/*/specs/your-capability/
# If overlap exists:
→ Coordinate with that team
→ Consider combining changes
→ Or sequence them (one after another)
```
### Archive Order Strategy
```
Friday Archive Session:
Step 1: Group by overlap
┌─────────────────────────────────────┐
│ No Overlap: [Z, W, V] │ ← Archive these first
│ Low Overlap: [A touches auth only] │ ← Then these
│ High Overlap:[X, Y both touch auth] │ ← Coordinate these last
└─────────────────────────────────────┘
Step 2: Archive in order
Z → W → V → A → (manually check X vs Y) → X → fix Y's delta → Y
↑
Key step: update Y's delta spec
to include X's changes before
archiving Y
```
### Safety Checklist
```bash
□ git diff main -- openspec/specs/your-capability/
□ openspec validate --strict
□ Manual review: did another change land on this spec?
□ If yes: update your delta spec to include their scenarios
```
## Your Action Plan
```
This Week:
├─ Run: openspec artifact-experimental-setup
└─ Try /opsx:sync on one change
Next Sprint:
├─ Adopt /opsx:sync for all Friday archives
└─ Switch to sync-as-you-go if it works well
Ongoing:
└─ Always run git diff after /opsx:sync to review
```
## Quick Reference Card
```
┌─────────────────────────────────────────────────────────────┐
│ OPENSPEC CONFLICT PREVENTION CHEAT SHEET │
├─────────────────────────────────────────────────────────────┤
│ │
│ RECOMMENDED: Use OPSX Workflow │
│ ✓ openspec artifact-experimental-setup (one-time) │
│ ✓ /opsx:sync after each feature │
│ ✓ git diff openspec/specs/ (review) │
│ ✓ /opsx:archive when done │
│ │
│ FRIDAY BATCH WORKFLOW: │
│ 1. /opsx:sync feature-a │
│ 2. /opsx:sync feature-b (AI merges with A) │
│ 3. /opsx:sync feature-c (AI merges with A+B) │
│ 4. git diff openspec/specs/ (review all) │
│ 5. /opsx:archive each │
│ │
│ IF NOT USING OPSX: │
│ □ openspec list (check overlaps) │
│ □ Coordinate with other teams │
│ □ Archive non-overlapping first │
│ □ Manually merge conflicts in delta specs │
│ │
└─────────────────────────────────────────────────────────────┘
```
## Key Insight
> **OpenSpec delta specs are instructions, not files.** Standard archiving does mechanical replacement (last write wins). OPSX workflow uses AI to understand intent and merge intelligently (all writes preserved). Use OPSX for parallel development.
## Additional Resources
- **Detailed Technical Analysis:** [openspec-parallel-merge-plan.md](../openspec-parallel-merge-plan.md)
- **OPSX Workflow Guide:** [experimental-workflow.md](./experimental-workflow.md)
- **Join Discussion:** [OpenSpec Discord](https://discord.gg/BYjPaKbqMt)
- **Report Issues:** [GitHub Issues](https://github.com/Fission-AI/openspec/issues)
@@ -1,2 +0,0 @@
schema: spec-driven
created: 2026-01-10
@@ -1,175 +0,0 @@
## Context
OpenSpec needs usage analytics to understand adoption and inform product decisions. PostHog provides a privacy-conscious analytics platform suitable for open source projects.
## Goals / Non-Goals
**Goals:**
- Track daily/weekly/monthly active usage
- Understand command usage patterns
- Keep implementation minimal and privacy-respecting
- Enable opt-out with minimal friction
**Non-Goals:**
- Detailed error tracking or diagnostics
- User identification or profiling
- Complex event hierarchies
- Full CLI command for telemetry management (env var sufficient for now)
## Decisions
### Opt-Out Model
**Decision:** Telemetry enabled by default, opt-out via environment variable.
```bash
OPENSPEC_TELEMETRY=0 # Disable telemetry
DO_NOT_TRACK=1 # Industry standard, also respected
```
Auto-disabled when `CI=true` is detected.
**Rationale:**
- Opt-in typically yields ~3% participation—not enough for meaningful data
- Understanding usage patterns requires statistically significant sample sizes
- Environment variable opt-out is simple and immediate
- Respecting `DO_NOT_TRACK` follows industry convention
**Alternatives considered:**
- Opt-in only - Insufficient data for product decisions
- Config file setting - More complex, env var sufficient for MVP
- Full `openspec telemetry` command - Can add later if users request
### Event Design
**Decision:** Single event type with minimal properties.
```typescript
{
event: 'command_executed',
properties: {
command: 'init', // Command name only
version: '1.2.3' // OpenSpec version
}
}
```
**Rationale:**
- Answers the core questions: how much usage, which commands are popular
- PostHog derives DAU/WAU/MAU from anonymous user counts over time
- No arguments, paths, or content—clean privacy story
- Easy to explain in disclosure notice
**Not tracked:**
- Command arguments
- File paths or contents
- Error messages or stack traces
- Project names or spec content
- IP addresses (`$ip: null` explicitly set)
### Anonymous ID
**Decision:** Random UUID, lazily generated on first telemetry send, stored in global config.
```typescript
// ~/.config/openspec/config.json
{
"telemetry": {
"anonymousId": "f47ac10b-58cc-4372-a567-0e02b2c3d479"
}
}
```
**Rationale:**
- Random UUID has no relation to the person—can't be reversed
- Stored in config so same user = same ID across sessions (needed for DAU/WAU/MAU)
- Lazy generation means no ID created if user opts out before first command
- User can delete config to reset identity
**Alternatives considered:**
- Machine-derived hash (hostname, MAC) - Feels invasive, fingerprint-like
- Per-session UUID - Breaks user counting metrics entirely
### SDK Configuration
**Decision:** PostHog Node SDK with immediate flush, shutdown on exit.
```typescript
const posthog = new PostHog(API_KEY, {
flushAt: 1, // Send immediately, don't batch
flushInterval: 0 // No timer-based flushing
});
// Before CLI exits
await posthog.shutdown();
```
**Rationale:**
- CLI processes are short-lived; batching would lose events
- `flushAt: 1` ensures each event sends immediately
- `shutdown()` guarantees flush before process exit
- Adds ~100-300ms to exit—negligible for typical CLI workflows
**Error handling:**
- Network failures silently ignored (telemetry shouldn't break CLI)
- `shutdown()` wrapped in try/catch
### Hook Location
**Decision:** Commander.js `preAction` and `postAction` hooks.
```typescript
program
.hook('preAction', (thisCommand) => {
maybeShowTelemetryNotice();
trackCommand(thisCommand.name(), VERSION);
})
.hook('postAction', async () => {
await shutdown();
});
```
**Rationale:**
- Centralized—one place for all telemetry logic
- Automatic—new commands get tracked without code changes
- Clean separation—command handlers don't know about telemetry
**Subcommand handling:**
- Track full command path for nested commands (e.g., `change:apply`)
### First-Run Notice
**Decision:** One-liner on first command ever, stored "seen" flag in config.
```
Note: OpenSpec collects anonymous usage stats. Opt out: OPENSPEC_TELEMETRY=0
```
**Rationale:**
- First command (not just `init`) ensures notice is always seen
- Non-blocking—no prompt, just informational
- One-liner is visible but not intrusive
- Storing "seen" in config prevents repeated display
**Config after first run:**
```json
{
"telemetry": {
"anonymousId": "...",
"noticeSeen": true
}
}
```
## Risks / Trade-offs
| Risk | Mitigation |
|------|------------|
| Users prefer opt-in | Clear disclosure, trivial opt-out, transparent about what's collected |
| GDPR concerns | No personal data, no IP, user can delete config |
| Slows CLI exit by ~200ms | Negligible for most workflows; can optimize if needed |
| PostHog outage affects CLI | Fire-and-forget with timeout; failures are silent |
## Open Questions
None—design is intentionally minimal. Future enhancements (dedicated command, workflow tracking) can be added based on user feedback.
@@ -1,37 +0,0 @@
## Why
OpenSpec currently has no visibility into how the tool is being used. Without analytics, we cannot:
- Understand which commands and features are most valuable to users
- Measure adoption and usage patterns
- Make data-driven decisions about product development
Adding PostHog analytics enables product insights while respecting user privacy through transparent, opt-out telemetry.
## What Changes
- Add PostHog Node.js SDK as a dependency
- Implement telemetry system with environment variable opt-out
- Track command usage (command name and version only)
- Show first-run notice informing users about telemetry
- Store anonymous ID in global config (`~/.config/openspec/config.json`)
- Respect `DO_NOT_TRACK` and `OPENSPEC_TELEMETRY=0` environment variables
- Auto-disable in CI environments
## Capabilities
### New Capabilities
- `telemetry`: Anonymous usage analytics using PostHog. Covers command tracking, opt-out controls, and first-run disclosure notice.
### Modified Capabilities
- `global-config`: Add telemetry state storage (anonymous ID, notice seen flag)
## Impact
- **Dependencies**: Add `posthog-node` package
- **Privacy**: Opt-out via env var, no personal data collected, clear disclosure
- **Configuration**: New global config fields for telemetry state
- **Network**: Async event sending with flush on exit (~100-300ms added)
- **CI/CD**: Telemetry auto-disabled when `CI=true`
- **Documentation**: Update README with telemetry disclosure
@@ -1,21 +0,0 @@
## MODIFIED Requirements
### Requirement: Global configuration storage
The system SHALL store global configuration in `~/.config/openspec/config.json`, including telemetry state with `anonymousId` and `noticeSeen` fields.
#### Scenario: Initial config creation
- **WHEN** no global config file exists
- **AND** the first telemetry event is about to be sent
- **THEN** the system creates `~/.config/openspec/config.json` with telemetry configuration
#### Scenario: Telemetry config structure
- **WHEN** reading or writing telemetry configuration
- **THEN** the config contains a `telemetry` object with `anonymousId` (string UUID) and `noticeSeen` (boolean) fields
#### Scenario: Config file format
- **WHEN** storing configuration
- **THEN** the system writes valid JSON that can be read and modified by users
#### Scenario: Existing config preservation
- **WHEN** adding telemetry fields to an existing config file
- **THEN** the system preserves all existing configuration fields
@@ -1,116 +0,0 @@
## ADDED Requirements
### Requirement: Command execution tracking
The system SHALL send a `command_executed` event to PostHog when any CLI command executes, including only the command name and OpenSpec version as properties.
#### Scenario: Standard command execution
- **WHEN** a user runs any openspec command
- **THEN** the system sends a `command_executed` event with `command` and `version` properties
#### Scenario: Subcommand execution
- **WHEN** a user runs a nested command like `openspec change apply`
- **THEN** the system sends a `command_executed` event with the full command path (e.g., `change:apply`)
### Requirement: Privacy-preserving event design
The system SHALL NOT include command arguments, file paths, project names, spec content, error messages, or IP addresses in telemetry events.
#### Scenario: Command with arguments
- **WHEN** a user runs `openspec init my-project --force`
- **THEN** the telemetry event contains only `command: "init"` and `version: "<version>"` without arguments
#### Scenario: IP address exclusion
- **WHEN** the system sends a telemetry event
- **THEN** the event explicitly sets `$ip: null` to prevent IP tracking
### Requirement: Environment variable opt-out
The system SHALL disable telemetry when `OPENSPEC_TELEMETRY=0` or `DO_NOT_TRACK=1` environment variables are set.
#### Scenario: OPENSPEC_TELEMETRY opt-out
- **WHEN** `OPENSPEC_TELEMETRY=0` is set in the environment
- **THEN** the system sends no telemetry events
#### Scenario: DO_NOT_TRACK opt-out
- **WHEN** `DO_NOT_TRACK=1` is set in the environment
- **THEN** the system sends no telemetry events
#### Scenario: Environment variable takes precedence
- **WHEN** the user has previously used the CLI (config exists)
- **AND** the user sets `OPENSPEC_TELEMETRY=0`
- **THEN** telemetry is disabled regardless of config state
### Requirement: CI environment auto-disable
The system SHALL automatically disable telemetry when `CI=true` environment variable is detected.
#### Scenario: CI environment detection
- **WHEN** `CI=true` is set in the environment
- **THEN** the system sends no telemetry events
#### Scenario: CI with explicit enable
- **WHEN** `CI=true` is set
- **AND** `OPENSPEC_TELEMETRY=1` is explicitly set
- **THEN** telemetry remains disabled (CI takes precedence for privacy)
### Requirement: First-run telemetry notice
The system SHALL display a one-line telemetry disclosure notice on the first command execution, before any telemetry is sent.
#### Scenario: First command execution
- **WHEN** a user runs their first openspec command
- **AND** telemetry is enabled
- **THEN** the system displays: "Note: OpenSpec collects anonymous usage stats. Opt out: OPENSPEC_TELEMETRY=0"
#### Scenario: Subsequent command execution
- **WHEN** a user has already seen the notice (noticeSeen: true in config)
- **THEN** the system does not display the notice
#### Scenario: Notice before telemetry
- **WHEN** displaying the first-run notice
- **THEN** the notice appears before any telemetry event is sent
### Requirement: Anonymous user identification
The system SHALL generate a random UUID as an anonymous identifier on first telemetry send, stored in global config.
#### Scenario: First telemetry event
- **WHEN** the first telemetry event is sent
- **AND** no anonymousId exists in config
- **THEN** the system generates a random UUID v4 and stores it in config
#### Scenario: Persistent identity
- **WHEN** a user runs multiple commands across sessions
- **THEN** the same anonymousId is used for all events
#### Scenario: Lazy generation with opt-out
- **WHEN** a user opts out before running any command
- **THEN** no anonymousId is ever generated or stored
### Requirement: Immediate event sending
The system SHALL send telemetry events immediately without batching, using `flushAt: 1` and `flushInterval: 0` configuration.
#### Scenario: Event transmission timing
- **WHEN** a command executes
- **THEN** the telemetry event is sent immediately, not queued for batch transmission
### Requirement: Graceful shutdown
The system SHALL call `posthog.shutdown()` before CLI exit to ensure pending events are flushed.
#### Scenario: Normal exit
- **WHEN** a command completes successfully
- **THEN** the system awaits `shutdown()` before exiting
#### Scenario: Error exit
- **WHEN** a command fails with an error
- **THEN** the system still awaits `shutdown()` before exiting
### Requirement: Silent failure handling
The system SHALL silently ignore telemetry failures without affecting CLI functionality.
#### Scenario: Network failure
- **WHEN** the telemetry request fails due to network error
- **THEN** the CLI command completes normally without error message
#### Scenario: PostHog outage
- **WHEN** PostHog service is unavailable
- **THEN** the CLI command completes normally without error message
#### Scenario: Shutdown failure
- **WHEN** `shutdown()` fails or times out
- **THEN** the CLI exits normally without error message
@@ -1,47 +0,0 @@
## 1. Setup
- [x] 1.1 Add `posthog-node` package as a dependency
- [x] 1.2 Create `src/telemetry/` module directory
- [x] 1.3 Add PostHog API key configuration (environment variable or embedded)
## 2. Global Config
- [x] 2.1 Create or extend global config module for `~/.config/openspec/config.json`
- [x] 2.2 Implement read/write functions that preserve existing config fields
- [x] 2.3 Define telemetry config structure (`anonymousId`, `noticeSeen`)
## 3. Core Telemetry Module
- [x] 3.1 Implement `isTelemetryEnabled()` checking `OPENSPEC_TELEMETRY`, `DO_NOT_TRACK`, and `CI` env vars
- [x] 3.2 Implement `getOrCreateAnonymousId()` with lazy UUID generation
- [x] 3.3 Initialize PostHog client with `flushAt: 1` and `flushInterval: 0`
- [x] 3.4 Implement `trackCommand(commandName, version)` with `$ip: null`
- [x] 3.5 Implement `shutdown()` with try/catch for silent failure handling
## 4. First-Run Notice
- [x] 4.1 Implement `maybeShowTelemetryNotice()` function
- [x] 4.2 Check `noticeSeen` flag before displaying notice
- [x] 4.3 Display notice text: "Note: OpenSpec collects anonymous usage stats. Opt out: OPENSPEC_TELEMETRY=0"
- [x] 4.4 Update `noticeSeen` in config after first display
## 5. CLI Integration
- [x] 5.1 Add Commander.js `preAction` hook to show notice and track command
- [x] 5.2 Add Commander.js `postAction` hook to call shutdown
- [x] 5.3 Handle subcommand path extraction (e.g., `change:apply`)
## 6. Testing
- [x] 6.1 Test opt-out via `OPENSPEC_TELEMETRY=0`
- [x] 6.2 Test opt-out via `DO_NOT_TRACK=1`
- [x] 6.3 Test auto-disable in CI environment
- [x] 6.4 Test first-run notice display and noticeSeen persistence
- [x] 6.5 Test anonymous ID generation and persistence
- [x] 6.6 Test silent failure on network error (mock PostHog)
## 7. Documentation
- [x] 7.1 Add telemetry disclosure section to README
- [x] 7.2 Document opt-out methods (`OPENSPEC_TELEMETRY=0`, `DO_NOT_TRACK=1`)
- [x] 7.3 Document what data is collected and not collected
-6
View File
@@ -211,12 +211,6 @@ The init command SHALL generate slash command files for supported editors using
- **AND** populate each file from shared templates so command text matches other tools
- **AND** each template includes instructions for the relevant OpenSpec workflow stage
#### Scenario: Generating slash commands for Continue
- **WHEN** the user selects Continue during initialization
- **THEN** create `.continue/prompts/openspec-proposal.prompt`, `.continue/prompts/openspec-apply.prompt`, and `.continue/prompts/openspec-archive.prompt`
- **AND** populate each file from shared templates so command text matches other tools
- **AND** each template includes instructions for the relevant OpenSpec workflow stage
#### Scenario: Generating slash commands for Factory Droid
- **WHEN** the user selects Factory Droid during initialization
- **THEN** create `.factory/commands/openspec-proposal.md`, `.factory/commands/openspec-apply.md`, and `.factory/commands/openspec-archive.md`
-5
View File
@@ -75,11 +75,6 @@ The update command SHALL refresh existing slash command files for configured too
- **AND** include Cline-specific Markdown heading frontmatter
- **AND** ensure templates include instructions for the relevant workflow stage
#### Scenario: Updating slash commands for Continue
- **WHEN** `.continue/prompts/` contains `openspec-proposal.prompt`, `openspec-apply.prompt`, and `openspec-archive.prompt`
- **THEN** refresh each file using shared templates
- **AND** ensure templates include instructions for the relevant workflow stage
#### Scenario: Updating slash commands for Crush
- **WHEN** `.crush/commands/` contains `openspec/proposal.md`, `openspec/apply.md`, and `openspec/archive.md`
- **THEN** refresh each file using shared templates
-20
View File
@@ -4,26 +4,6 @@
This spec defines how OpenSpec resolves, reads, and writes user-level global configuration. It governs the `src/core/global-config.ts` module, which provides the foundation for storing user preferences, feature flags, and settings that persist across projects. The spec ensures cross-platform compatibility by following XDG Base Directory Specification with platform-specific fallbacks, and guarantees forward/backward compatibility through schema evolution rules.
## Requirements
### Requirement: Global configuration storage
The system SHALL store global configuration in `~/.config/openspec/config.json`, including telemetry state with `anonymousId` and `noticeSeen` fields.
#### Scenario: Initial config creation
- **WHEN** no global config file exists
- **AND** the first telemetry event is about to be sent
- **THEN** the system creates `~/.config/openspec/config.json` with telemetry configuration
#### Scenario: Telemetry config structure
- **WHEN** reading or writing telemetry configuration
- **THEN** the config contains a `telemetry` object with `anonymousId` (string UUID) and `noticeSeen` (boolean) fields
#### Scenario: Config file format
- **WHEN** storing configuration
- **THEN** the system writes valid JSON that can be read and modified by users
#### Scenario: Existing config preservation
- **WHEN** adding telemetry fields to an existing config file
- **THEN** the system preserves all existing configuration fields
### Requirement: Global Config Directory Path
The system SHALL resolve the global configuration directory path following XDG Base Directory Specification with platform-specific fallbacks.
-122
View File
@@ -1,122 +0,0 @@
# telemetry Specification
## Purpose
This spec defines how OpenSpec collects anonymous usage telemetry to help improve the tool. It governs the `src/telemetry/` module, which handles PostHog integration, privacy-preserving event design, user opt-out mechanisms, and first-run notice display. The spec ensures telemetry is minimal, transparent, and respects user privacy.
## Requirements
### Requirement: Command execution tracking
The system SHALL send a `command_executed` event to PostHog when any CLI command executes, including only the command name and OpenSpec version as properties.
#### Scenario: Standard command execution
- **WHEN** a user runs any openspec command
- **THEN** the system sends a `command_executed` event with `command` and `version` properties
#### Scenario: Subcommand execution
- **WHEN** a user runs a nested command like `openspec change apply`
- **THEN** the system sends a `command_executed` event with the full command path (e.g., `change:apply`)
### Requirement: Privacy-preserving event design
The system SHALL NOT include command arguments, file paths, project names, spec content, error messages, or IP addresses in telemetry events.
#### Scenario: Command with arguments
- **WHEN** a user runs `openspec init my-project --force`
- **THEN** the telemetry event contains only `command: "init"` and `version: "<version>"` without arguments
#### Scenario: IP address exclusion
- **WHEN** the system sends a telemetry event
- **THEN** the event explicitly sets `$ip: null` to prevent IP tracking
### Requirement: Environment variable opt-out
The system SHALL disable telemetry when `OPENSPEC_TELEMETRY=0` or `DO_NOT_TRACK=1` environment variables are set.
#### Scenario: OPENSPEC_TELEMETRY opt-out
- **WHEN** `OPENSPEC_TELEMETRY=0` is set in the environment
- **THEN** the system sends no telemetry events
#### Scenario: DO_NOT_TRACK opt-out
- **WHEN** `DO_NOT_TRACK=1` is set in the environment
- **THEN** the system sends no telemetry events
#### Scenario: Environment variable takes precedence
- **WHEN** the user has previously used the CLI (config exists)
- **AND** the user sets `OPENSPEC_TELEMETRY=0`
- **THEN** telemetry is disabled regardless of config state
### Requirement: CI environment auto-disable
The system SHALL automatically disable telemetry when `CI=true` environment variable is detected.
#### Scenario: CI environment detection
- **WHEN** `CI=true` is set in the environment
- **THEN** the system sends no telemetry events
#### Scenario: CI with explicit enable
- **WHEN** `CI=true` is set
- **AND** `OPENSPEC_TELEMETRY=1` is explicitly set
- **THEN** telemetry remains disabled (CI takes precedence for privacy)
### Requirement: First-run telemetry notice
The system SHALL display a one-line telemetry disclosure notice on the first command execution, before any telemetry is sent.
#### Scenario: First command execution
- **WHEN** a user runs their first openspec command
- **AND** telemetry is enabled
- **THEN** the system displays: "Note: OpenSpec collects anonymous usage stats. Opt out: OPENSPEC_TELEMETRY=0"
#### Scenario: Subsequent command execution
- **WHEN** a user has already seen the notice (noticeSeen: true in config)
- **THEN** the system does not display the notice
#### Scenario: Notice before telemetry
- **WHEN** displaying the first-run notice
- **THEN** the notice appears before any telemetry event is sent
### Requirement: Anonymous user identification
The system SHALL generate a random UUID as an anonymous identifier on first telemetry send, stored in global config.
#### Scenario: First telemetry event
- **WHEN** the first telemetry event is sent
- **AND** no anonymousId exists in config
- **THEN** the system generates a random UUID v4 and stores it in config
#### Scenario: Persistent identity
- **WHEN** a user runs multiple commands across sessions
- **THEN** the same anonymousId is used for all events
#### Scenario: Lazy generation with opt-out
- **WHEN** a user opts out before running any command
- **THEN** no anonymousId is ever generated or stored
### Requirement: Immediate event sending
The system SHALL send telemetry events immediately without batching, using `flushAt: 1` and `flushInterval: 0` configuration.
#### Scenario: Event transmission timing
- **WHEN** a command executes
- **THEN** the telemetry event is sent immediately, not queued for batch transmission
### Requirement: Graceful shutdown
The system SHALL call `posthog.shutdown()` before CLI exit to ensure pending events are flushed.
#### Scenario: Normal exit
- **WHEN** a command completes successfully
- **THEN** the system awaits `shutdown()` before exiting
#### Scenario: Error exit
- **WHEN** a command fails with an error
- **THEN** the system still awaits `shutdown()` before exiting
### Requirement: Silent failure handling
The system SHALL silently ignore telemetry failures without affecting CLI functionality.
#### Scenario: Network failure
- **WHEN** the telemetry request fails due to network error
- **THEN** the CLI command completes normally without error message
#### Scenario: PostHog outage
- **WHEN** PostHog service is unavailable
- **THEN** the CLI command completes normally without error message
#### Scenario: Shutdown failure
- **WHEN** `shutdown()` fails or times out
- **THEN** the CLI exits normally without error message
+2 -3
View File
@@ -1,6 +1,6 @@
{
"name": "@fission-ai/openspec",
"version": "0.19.0",
"version": "0.18.0",
"description": "AI-native system for spec-driven development",
"keywords": [
"openspec",
@@ -54,13 +54,13 @@
"check:pack-version": "node scripts/pack-version-check.mjs",
"release": "pnpm run release:ci",
"release:ci": "pnpm run check:pack-version && pnpm exec changeset publish",
"release:local": "pnpm exec changeset version && pnpm run check:pack-version && pnpm exec changeset publish",
"changeset": "changeset"
},
"engines": {
"node": ">=20.19.0"
},
"devDependencies": {
"@changesets/changelog-github": "^0.5.2",
"@changesets/cli": "^2.27.7",
"@types/node": "^24.2.0",
"@vitest/ui": "^3.2.4",
@@ -76,7 +76,6 @@
"commander": "^14.0.0",
"fast-glob": "^3.3.3",
"ora": "^8.2.0",
"posthog-node": "^5.20.0",
"yaml": "^2.8.2",
"zod": "^4.0.17"
}
-84
View File
@@ -26,9 +26,6 @@ importers:
ora:
specifier: ^8.2.0
version: 8.2.0
posthog-node:
specifier: ^5.20.0
version: 5.20.0
yaml:
specifier: ^2.8.2
version: 2.8.2
@@ -36,9 +33,6 @@ importers:
specifier: ^4.0.17
version: 4.0.17
devDependencies:
'@changesets/changelog-github':
specifier: ^0.5.2
version: 0.5.2
'@changesets/cli':
specifier: ^2.27.7
version: 2.29.6(@types/node@24.2.0)
@@ -76,9 +70,6 @@ packages:
'@changesets/changelog-git@0.2.1':
resolution: {integrity: sha512-x/xEleCFLH28c3bQeQIyeZf8lFXyDFVn1SgcBiR2Tw/r4IAWlk1fzxCEZ6NxQAjF2Nwtczoen3OA2qR+UawQ8Q==}
'@changesets/changelog-github@0.5.2':
resolution: {integrity: sha512-HeGeDl8HaIGj9fQHo/tv5XKQ2SNEi9+9yl1Bss1jttPqeiASRXhfi0A2wv8yFKCp07kR1gpOI5ge6+CWNm1jPw==}
'@changesets/cli@2.29.6':
resolution: {integrity: sha512-6qCcVsIG1KQLhpQ5zE8N0PckIx4+9QlHK3z6/lwKnw7Tir71Bjw8BeOZaxA/4Jt00pcgCnCSWZnyuZf5Il05QQ==}
hasBin: true
@@ -92,9 +83,6 @@ packages:
'@changesets/get-dependents-graph@2.1.3':
resolution: {integrity: sha512-gphr+v0mv2I3Oxt19VdWRRUxq3sseyUpX9DaHpTUmLj92Y10AGy+XOtV+kbM6L/fDcpx7/ISDFK6T8A/P3lOdQ==}
'@changesets/get-github-info@0.7.0':
resolution: {integrity: sha512-+i67Bmhfj9V4KfDeS1+Tz3iF32btKZB2AAx+cYMqDSRFP7r3/ZdGbjCo+c6qkyViN9ygDuBjzageuPGJtKGe5A==}
'@changesets/get-release-plan@4.0.13':
resolution: {integrity: sha512-DWG1pus72FcNeXkM12tx+xtExyH/c9I1z+2aXlObH3i9YA7+WZEVaiHzHl03thpvAgWTRaH64MpfHxozfF7Dvg==}
@@ -496,9 +484,6 @@ packages:
'@polka/url@1.0.0-next.29':
resolution: {integrity: sha512-wwQAWhWSuHaag8c4q/KN/vCoeOJYshAIvMQwD4GpSb3OiZklFfvAgmj0VCBBImRpuF/aFgIRzllXlVX93Jevww==}
'@posthog/core@1.9.1':
resolution: {integrity: sha512-kRb1ch2dhQjsAapZmu6V66551IF2LnCbc1rnrQqnR7ArooVyJN9KOPXre16AJ3ObJz2eTfuP7x25BMyS2Y5Exw==}
'@rollup/rollup-android-arm-eabi@4.46.2':
resolution: {integrity: sha512-Zj3Hl6sN34xJtMv7Anwb5Gu01yujyE/cLBDB2gnHTAHaWS1Z38L7kuSG+oAh0giZMqG060f/YBStXtMH6FvPMA==}
cpu: [arm]
@@ -838,9 +823,6 @@ packages:
resolution: {integrity: sha512-uV2QOWP2nWzsy2aMp8aRibhi9dlzF5Hgh5SHaB9OiTGEyDTiJJyx0uy51QXdyWbtAHNua4XJzUKca3OzKUd3vA==}
engines: {node: '>= 8'}
dataloader@1.4.0:
resolution: {integrity: sha512-68s5jYdlvasItOJnCuI2Q9s4q98g0pCyL3HrcKJu8KNugUl8ahgmZYg38ysLTgQjjXX3H8CJLkAvWrclWfcalw==}
debug@4.4.1:
resolution: {integrity: sha512-KcKCqiftBJcZr++7ykoDIEwSa3XWowTfNPo92BYxjXiyYEVrUQh2aLyhxBCwww+heortUFxEJYcRzosstTEBYQ==}
engines: {node: '>=6.0'}
@@ -865,10 +847,6 @@ packages:
resolution: {integrity: sha512-WkrWp9GR4KXfKGYzOLmTuGVi1UWFfws377n9cc55/tb6DuqyF6pcQ5AbiHEshaDpY9v6oaSr2XCDidGmMwdzIA==}
engines: {node: '>=8'}
dotenv@8.6.0:
resolution: {integrity: sha512-IrPdXQsk2BbzvCBGBOTmmSH5SodmqZNt4ERAZDmW4CT+tL8VtvinqywuANaFu4bOMWki16nqf0e4oC0QIaDr/g==}
engines: {node: '>=10'}
emoji-regex@10.4.0:
resolution: {integrity: sha512-EC+0oUMY1Rqm4O6LLrgjtYDvcVYTy7chDnM4Q7030tP4Kwj3u/pR6gP9ygnp2CJMK5Gq+9Q2oqmrFJAz01DXjw==}
@@ -1214,15 +1192,6 @@ packages:
natural-compare@1.4.0:
resolution: {integrity: sha512-OWND8ei3VtNC9h7V60qff3SVobHr996CTwgxubgyQYEpg290h9J0buyECNNJexkFm5sOajh5G116RYA1c8ZMSw==}
node-fetch@2.7.0:
resolution: {integrity: sha512-c4FRfUm/dbcWZ7U+1Wq0AwCyFL+3nt2bEw05wfxSz+DWpWsitgmSgYmy2dQdWyKC1694ELPqMs/YzUSNozLt8A==}
engines: {node: 4.x || >=6.0.0}
peerDependencies:
encoding: ^0.1.0
peerDependenciesMeta:
encoding:
optional: true
onetime@7.0.0:
resolution: {integrity: sha512-VXJjc87FScF88uafS3JllDgvAm+c/Slfz06lorj2uAY34rlUu0Nt+v8wreiImcrgAjjIHp1rXpTDlLOGw29WwQ==}
engines: {node: '>=18'}
@@ -1315,10 +1284,6 @@ packages:
resolution: {integrity: sha512-3Ybi1tAuwAP9s0r1UQ2J4n5Y0G05bJkpUIO0/bI9MhwmD70S5aTWbXGBwxHrelT+XM1k6dM0pk+SwNkpTRN7Pg==}
engines: {node: ^10 || ^12 || >=14}
posthog-node@5.20.0:
resolution: {integrity: sha512-LkR5KfrvEQTnUtNKN97VxFB00KcYG1Iz8iKg8r0e/i7f1eQhg1WSZO+Jp1B4bvtHCmdpIE4HwYbvCCzFoCyjVg==}
engines: {node: '>=20'}
prelude-ls@1.2.1:
resolution: {integrity: sha512-vkcDPrRZo1QZLbn5RLGPpg/WmIQ65qoWWhcGKf/b5eplkkarX0m9z8ppCat4mlOqUsWpyNuYgO3VRyrYHSzX5g==}
engines: {node: '>= 0.8.0'}
@@ -1490,9 +1455,6 @@ packages:
resolution: {integrity: sha512-sf4i37nQ2LBx4m3wB74y+ubopq6W/dIzXg0FDGjsYnZHVa1Da8FH853wlL2gtUhg+xJXjfk3kUZS3BRoQeoQBQ==}
engines: {node: '>=6'}
tr46@0.0.3:
resolution: {integrity: sha512-N3WMsuqV66lT30CrXNbEjx4GEwlow3v6rr4mCcv6prnfwhS01rkgyFdjPNBYd9br7LpXV1+Emh01fHnq2Gdgrw==}
ts-api-utils@2.1.0:
resolution: {integrity: sha512-CUgTZL1irw8u29bzrOD/nH85jqyc74D6SshFgujOIA7osm2Rz7dYH77agkx7H4FBNxDq7Cjf+IjaX/8zwFW+ZQ==}
engines: {node: '>=18.12'}
@@ -1602,12 +1564,6 @@ packages:
jsdom:
optional: true
webidl-conversions@3.0.1:
resolution: {integrity: sha512-2JAn3z8AR6rjK8Sm8orRC0h/bcl/DqL7tRPdGZ4I1CjdF+EaMLmYxBHyXuKL849eucPFhvBoxMsflfOb8kxaeQ==}
whatwg-url@5.0.0:
resolution: {integrity: sha512-saE57nupxk6v3HY35+jzBwYa0rKSy0XR8JSxZPwgLr7ys0IBzhGviA1/TUGJLmSVqs8pb9AnvICXEuOHLprYTw==}
which@2.0.2:
resolution: {integrity: sha512-BLI3Tl1TW3Pvl70l3yq3Y64i+awpwXqsGBYWkkqMtnbXgrMD+yj7rhW0kuEDxzJaYXGjEW5ogapKNMEKNMjibA==}
engines: {node: '>= 8'}
@@ -1675,14 +1631,6 @@ snapshots:
dependencies:
'@changesets/types': 6.1.0
'@changesets/changelog-github@0.5.2':
dependencies:
'@changesets/get-github-info': 0.7.0
'@changesets/types': 6.1.0
dotenv: 8.6.0
transitivePeerDependencies:
- encoding
'@changesets/cli@2.29.6(@types/node@24.2.0)':
dependencies:
'@changesets/apply-release-plan': 7.0.12
@@ -1737,13 +1685,6 @@ snapshots:
picocolors: 1.1.1
semver: 7.7.2
'@changesets/get-github-info@0.7.0':
dependencies:
dataloader: 1.4.0
node-fetch: 2.7.0
transitivePeerDependencies:
- encoding
'@changesets/get-release-plan@4.0.13':
dependencies:
'@changesets/assemble-release-plan': 6.0.9
@@ -2097,10 +2038,6 @@ snapshots:
'@polka/url@1.0.0-next.29': {}
'@posthog/core@1.9.1':
dependencies:
cross-spawn: 7.0.6
'@rollup/rollup-android-arm-eabi@4.46.2':
optional: true
@@ -2428,8 +2365,6 @@ snapshots:
shebang-command: 2.0.0
which: 2.0.2
dataloader@1.4.0: {}
debug@4.4.1:
dependencies:
ms: 2.1.3
@@ -2444,8 +2379,6 @@ snapshots:
dependencies:
path-type: 4.0.0
dotenv@8.6.0: {}
emoji-regex@10.4.0: {}
emoji-regex@8.0.0: {}
@@ -2790,10 +2723,6 @@ snapshots:
natural-compare@1.4.0: {}
node-fetch@2.7.0:
dependencies:
whatwg-url: 5.0.0
onetime@7.0.0:
dependencies:
mimic-function: 5.0.1
@@ -2879,10 +2808,6 @@ snapshots:
picocolors: 1.1.1
source-map-js: 1.2.1
posthog-node@5.20.0:
dependencies:
'@posthog/core': 1.9.1
prelude-ls@1.2.1: {}
prettier@2.8.8: {}
@@ -3042,8 +2967,6 @@ snapshots:
totalist@3.0.1: {}
tr46@0.0.3: {}
ts-api-utils@2.1.0(typescript@5.9.3):
dependencies:
typescript: 5.9.3
@@ -3151,13 +3074,6 @@ snapshots:
- tsx
- yaml
webidl-conversions@3.0.1: {}
whatwg-url@5.0.0:
dependencies:
tr46: 0.0.3
webidl-conversions: 3.0.1
which@2.0.2:
dependencies:
isexe: 2.0.0
+2 -38
View File
@@ -15,32 +15,11 @@ import { ShowCommand } from '../commands/show.js';
import { CompletionCommand } from '../commands/completion.js';
import { registerConfigCommand } from '../commands/config.js';
import { registerArtifactWorkflowCommands } from '../commands/artifact-workflow.js';
import { maybeShowTelemetryNotice, trackCommand, shutdown } from '../telemetry/index.js';
const program = new Command();
const require = createRequire(import.meta.url);
const { version } = require('../../package.json');
/**
* Get the full command path for nested commands.
* For example: 'change show' -> 'change:show'
*/
function getCommandPath(command: Command): string {
const names: string[] = [];
let current: Command | null = command;
while (current) {
const name = current.name();
// Skip the root 'openspec' command
if (name && name !== 'openspec') {
names.unshift(name);
}
current = current.parent;
}
return names.join(':') || 'openspec';
}
program
.name('openspec')
.description('AI-native system for spec-driven development')
@@ -49,27 +28,12 @@ program
// Global options
program.option('--no-color', 'Disable color output');
// Apply global flags and telemetry before any command runs
// Note: preAction receives (thisCommand, actionCommand) where:
// - thisCommand: the command where hook was added (root program)
// - actionCommand: the command actually being executed (subcommand)
program.hook('preAction', async (thisCommand, actionCommand) => {
// Apply global flags before any command runs
program.hook('preAction', (thisCommand) => {
const opts = thisCommand.opts();
if (opts.color === false) {
process.env.NO_COLOR = '1';
}
// Show first-run telemetry notice (if not seen)
await maybeShowTelemetryNotice();
// Track command execution (use actionCommand to get the actual subcommand)
const commandPath = getCommandPath(actionCommand);
await trackCommand(commandPath, version);
});
// Shutdown telemetry after command completes
program.hook('postAction', async () => {
await shutdown();
});
const availableToolIds = AI_TOOLS.filter((tool) => tool.available).map((tool) => tool.value);
-1
View File
@@ -24,7 +24,6 @@ export const AI_TOOLS: AIToolOption[] = [
{ name: 'Cline', value: 'cline', available: true, successLabel: 'Cline' },
{ name: 'Codex', value: 'codex', available: true, successLabel: 'Codex' },
{ name: 'CodeBuddy Code (CLI)', value: 'codebuddy', available: true, successLabel: 'CodeBuddy Code' },
{ name: 'Continue', value: 'continue', available: true, successLabel: 'Continue (VS Code / JetBrains / Cli)' },
{ name: 'CoStrict', value: 'costrict', available: true, successLabel: 'CoStrict' },
{ name: 'Crush', value: 'crush', available: true, successLabel: 'Crush' },
{ name: 'Cursor', value: 'cursor', available: true, successLabel: 'Cursor' },
-51
View File
@@ -1,51 +0,0 @@
import { SlashCommandConfigurator } from './base.js';
import { SlashCommandId } from '../../templates/index.js';
const FILE_PATHS: Record<SlashCommandId, string> = {
proposal: '.continue/prompts/openspec-proposal.prompt',
apply: '.continue/prompts/openspec-apply.prompt',
archive: '.continue/prompts/openspec-archive.prompt'
};
/*
* Continue .prompt format requires YAML frontmatter:
* ---
* name: commandName
* description: description
* invokable: true
* ---
* Body...
*
* The 'invokable: true' field is required to make the prompt available as a slash command.
* We use 'openspec-proposal' as the name so the command becomes /openspec-proposal.
*/
const FRONTMATTER: Record<SlashCommandId, string> = {
proposal: `---
name: openspec-proposal
description: Scaffold a new OpenSpec change and validate strictly.
invokable: true
---`,
apply: `---
name: openspec-apply
description: Implement an approved OpenSpec change and keep tasks in sync.
invokable: true
---`,
archive: `---
name: openspec-archive
description: Archive a deployed OpenSpec change and update specs.
invokable: true
---`
};
export class ContinueSlashCommandConfigurator extends SlashCommandConfigurator {
readonly toolId = 'continue';
readonly isAvailable = true;
protected getRelativePath(id: SlashCommandId): string {
return FILE_PATHS[id];
}
protected getFrontmatter(id: SlashCommandId): string {
return FRONTMATTER[id];
}
}
-3
View File
@@ -19,7 +19,6 @@ import { QwenSlashCommandConfigurator } from './qwen.js';
import { RooCodeSlashCommandConfigurator } from './roocode.js';
import { AntigravitySlashCommandConfigurator } from './antigravity.js';
import { IflowSlashCommandConfigurator } from './iflow.js';
import { ContinueSlashCommandConfigurator } from './continue.js';
export class SlashCommandRegistry {
private static configurators: Map<string, SlashCommandConfigurator> = new Map();
@@ -45,7 +44,6 @@ export class SlashCommandRegistry {
const roocode = new RooCodeSlashCommandConfigurator();
const antigravity = new AntigravitySlashCommandConfigurator();
const iflow = new IflowSlashCommandConfigurator();
const continueTool = new ContinueSlashCommandConfigurator();
this.configurators.set(claude.toolId, claude);
this.configurators.set(codeBuddy.toolId, codeBuddy);
@@ -67,7 +65,6 @@ export class SlashCommandRegistry {
this.configurators.set(roocode.toolId, roocode);
this.configurators.set(antigravity.toolId, antigravity);
this.configurators.set(iflow.toolId, iflow);
this.configurators.set(continueTool.toolId, continueTool);
}
static register(configurator: SlashCommandConfigurator): void {
-85
View File
@@ -1,85 +0,0 @@
/**
* Global configuration for telemetry state.
* Stores anonymous ID and notice-seen flag in ~/.config/openspec/config.json
*/
import { promises as fs } from 'fs';
import path from 'path';
import os from 'os';
export interface TelemetryConfig {
anonymousId?: string;
noticeSeen?: boolean;
}
export interface GlobalConfig {
telemetry?: TelemetryConfig;
[key: string]: unknown; // Preserve other fields
}
/**
* Get the path to the global config file.
* Uses ~/.config/openspec/config.json on all platforms.
*/
export function getConfigPath(): string {
const configDir = path.join(os.homedir(), '.config', 'openspec');
return path.join(configDir, 'config.json');
}
/**
* Read the global config file.
* Returns an empty object if the file doesn't exist.
*/
export async function readConfig(): Promise<GlobalConfig> {
const configPath = getConfigPath();
try {
const content = await fs.readFile(configPath, 'utf-8');
return JSON.parse(content) as GlobalConfig;
} catch (error: unknown) {
if ((error as NodeJS.ErrnoException).code === 'ENOENT') {
return {};
}
// If parse fails or other error, return empty config
return {};
}
}
/**
* Write to the global config file.
* Preserves existing fields and merges in new values.
*/
export async function writeConfig(updates: Partial<GlobalConfig>): Promise<void> {
const configPath = getConfigPath();
const configDir = path.dirname(configPath);
// Ensure directory exists
await fs.mkdir(configDir, { recursive: true });
// Read existing config and merge
const existing = await readConfig();
const merged = { ...existing, ...updates };
// Deep merge for telemetry object
if (updates.telemetry && existing.telemetry) {
merged.telemetry = { ...existing.telemetry, ...updates.telemetry };
}
await fs.writeFile(configPath, JSON.stringify(merged, null, 2) + '\n');
}
/**
* Get the telemetry config section.
*/
export async function getTelemetryConfig(): Promise<TelemetryConfig> {
const config = await readConfig();
return config.telemetry ?? {};
}
/**
* Update the telemetry config section.
*/
export async function updateTelemetryConfig(updates: Partial<TelemetryConfig>): Promise<void> {
const existing = await getTelemetryConfig();
await writeConfig({
telemetry: { ...existing, ...updates },
});
}
-161
View File
@@ -1,161 +0,0 @@
/**
* Telemetry module for anonymous usage analytics.
*
* Privacy-first design:
* - Only tracks command name and version
* - No arguments, file paths, or content
* - Opt-out via OPENSPEC_TELEMETRY=0 or DO_NOT_TRACK=1
* - Auto-disabled in CI environments
* - Anonymous ID is a random UUID with no relation to the user
*/
import { PostHog } from 'posthog-node';
import { randomUUID } from 'crypto';
import { getTelemetryConfig, updateTelemetryConfig } from './config.js';
// PostHog API key - public key for client-side analytics
// This is safe to embed as it only allows sending events, not reading data
const POSTHOG_API_KEY = 'phc_Hthu8YvaIJ9QaFKyTG4TbVwkbd5ktcAFzVTKeMmoW2g';
// Using reverse proxy to avoid ad blockers and keep traffic on our domain
const POSTHOG_HOST = 'https://edge.openspec.dev';
let posthogClient: PostHog | null = null;
let anonymousId: string | null = null;
/**
* Check if telemetry is enabled.
*
* Disabled when:
* - OPENSPEC_TELEMETRY=0
* - DO_NOT_TRACK=1
* - CI=true (any CI environment)
*/
export function isTelemetryEnabled(): boolean {
// Check explicit opt-out
if (process.env.OPENSPEC_TELEMETRY === '0') {
return false;
}
// Respect DO_NOT_TRACK standard
if (process.env.DO_NOT_TRACK === '1') {
return false;
}
// Auto-disable in CI environments
if (process.env.CI === 'true') {
return false;
}
return true;
}
/**
* Get or create the anonymous user ID.
* Lazily generates a UUID on first call and persists it.
*/
export async function getOrCreateAnonymousId(): Promise<string> {
// Return cached value if available
if (anonymousId) {
return anonymousId;
}
// Try to load from config
const config = await getTelemetryConfig();
if (config.anonymousId) {
anonymousId = config.anonymousId;
return anonymousId;
}
// Generate new UUID and persist
anonymousId = randomUUID();
await updateTelemetryConfig({ anonymousId });
return anonymousId;
}
/**
* Get the PostHog client instance.
* Creates it on first call with CLI-optimized settings.
*/
function getClient(): PostHog {
if (!posthogClient) {
posthogClient = new PostHog(POSTHOG_API_KEY, {
host: POSTHOG_HOST,
flushAt: 1, // Send immediately, don't batch
flushInterval: 0, // No timer-based flushing
});
}
return posthogClient;
}
/**
* Track a command execution.
*
* @param commandName - The command name (e.g., 'init', 'change:apply')
* @param version - The OpenSpec version
*/
export async function trackCommand(commandName: string, version: string): Promise<void> {
if (!isTelemetryEnabled()) {
return;
}
try {
const userId = await getOrCreateAnonymousId();
const client = getClient();
client.capture({
distinctId: userId,
event: 'command_executed',
properties: {
command: commandName,
version: version,
surface: 'cli',
$ip: null, // Explicitly disable IP tracking
},
});
} catch {
// Silent failure - telemetry should never break CLI
}
}
/**
* Show first-run telemetry notice if not already seen.
*/
export async function maybeShowTelemetryNotice(): Promise<void> {
if (!isTelemetryEnabled()) {
return;
}
try {
const config = await getTelemetryConfig();
if (config.noticeSeen) {
return;
}
// Display notice
console.log(
'Note: OpenSpec collects anonymous usage stats. Opt out: OPENSPEC_TELEMETRY=0'
);
// Mark as seen
await updateTelemetryConfig({ noticeSeen: true });
} catch {
// Silent failure - telemetry should never break CLI
}
}
/**
* Shutdown the PostHog client and flush pending events.
* Call this before CLI exit.
*/
export async function shutdown(): Promise<void> {
if (!posthogClient) {
return;
}
try {
await posthogClient.shutdown();
} catch {
// Silent failure - telemetry should never break CLI exit
} finally {
posthogClient = null;
}
}
-55
View File
@@ -1151,61 +1151,6 @@ describe('InitCommand', () => {
expect(codeBuddyChoice.configured).toBe(true);
});
it('should create Continue slash command files with templates', async () => {
queueSelections('continue', DONE);
await initCommand.execute(testDir);
const continueProposal = path.join(
testDir,
'.continue/prompts/openspec-proposal.prompt'
);
const continueApply = path.join(
testDir,
'.continue/prompts/openspec-apply.prompt'
);
const continueArchive = path.join(
testDir,
'.continue/prompts/openspec-archive.prompt'
);
expect(await fileExists(continueProposal)).toBe(true);
expect(await fileExists(continueApply)).toBe(true);
expect(await fileExists(continueArchive)).toBe(true);
const proposalContent = await fs.readFile(continueProposal, 'utf-8');
expect(proposalContent).toContain('---');
expect(proposalContent).toContain('name: openspec-proposal');
expect(proposalContent).toContain('invokable: true');
expect(proposalContent).toContain('<!-- OPENSPEC:START -->');
const applyContent = await fs.readFile(continueApply, 'utf-8');
expect(applyContent).toContain('---');
expect(applyContent).toContain('name: openspec-apply');
expect(applyContent).toContain('description: Implement an approved OpenSpec change and keep tasks in sync.');
expect(applyContent).toContain('invokable: true');
expect(applyContent).toContain('Work through tasks sequentially');
const archiveContent = await fs.readFile(continueArchive, 'utf-8');
expect(archiveContent).toContain('---');
expect(archiveContent).toContain('name: openspec-archive');
expect(archiveContent).toContain('description: Archive a deployed OpenSpec change and update specs.');
expect(archiveContent).toContain('invokable: true');
expect(archiveContent).toContain('openspec archive <id> --yes');
});
it('should mark Continue as already configured during extend mode', async () => {
queueSelections('continue', DONE, 'continue', DONE);
await initCommand.execute(testDir);
await initCommand.execute(testDir);
const secondRunArgs = mockPrompt.mock.calls[1][0];
const continueChoice = secondRunArgs.choices.find(
(choice: any) => choice.value === 'continue'
);
expect(continueChoice.configured).toBe(true);
});
it('should create CODEBUDDY.md when CodeBuddy is selected', async () => {
queueSelections('codebuddy', DONE);
-74
View File
@@ -368,80 +368,6 @@ Old body
consoleSpy.mockRestore();
});
it('should refresh existing Continue prompt files', async () => {
const continuePath = path.join(
testDir,
'.continue/prompts/openspec-apply.prompt'
);
await fs.mkdir(path.dirname(continuePath), { recursive: true });
const initialContent = `---
name: openspec-apply
description: Old description
invokable: true
---
<!-- OPENSPEC:START -->
Old body
<!-- OPENSPEC:END -->`;
await fs.writeFile(continuePath, initialContent);
const consoleSpy = vi.spyOn(console, 'log');
await updateCommand.execute(testDir);
const updated = await fs.readFile(continuePath, 'utf-8');
expect(updated).toContain('name: openspec-apply');
expect(updated).toContain('invokable: true');
expect(updated).toContain('Work through tasks sequentially');
expect(updated).not.toContain('Old body');
const [logMessage] = consoleSpy.mock.calls[0];
expect(logMessage).toContain(
'Updated OpenSpec instructions (openspec/AGENTS.md'
);
expect(logMessage).toContain('AGENTS.md (created)');
expect(logMessage).toContain(
'Updated slash commands: .continue/prompts/openspec-apply.prompt'
);
consoleSpy.mockRestore();
});
it('should not create missing Continue prompt files on update', async () => {
const continueApply = path.join(
testDir,
'.continue/prompts/openspec-apply.prompt'
);
// Only create apply; leave proposal and archive missing
await fs.mkdir(path.dirname(continueApply), { recursive: true });
await fs.writeFile(
continueApply,
`---
name: openspec-apply
description: Old description
invokable: true
---
<!-- OPENSPEC:START -->
Old body
<!-- OPENSPEC:END -->`
);
await updateCommand.execute(testDir);
const continueProposal = path.join(
testDir,
'.continue/prompts/openspec-proposal.prompt'
);
const continueArchive = path.join(
testDir,
'.continue/prompts/openspec-archive.prompt'
);
// Confirm they weren't created by update
await expect(FileSystemUtils.fileExists(continueProposal)).resolves.toBe(false);
await expect(FileSystemUtils.fileExists(continueArchive)).resolves.toBe(false);
});
it('should refresh existing OpenCode slash command files', async () => {
const openCodePath = path.join(
testDir,
-185
View File
@@ -1,185 +0,0 @@
import { describe, it, expect, beforeEach, afterEach } from 'vitest';
import * as fs from 'node:fs';
import * as path from 'node:path';
import * as os from 'node:os';
import {
getConfigPath,
readConfig,
writeConfig,
getTelemetryConfig,
updateTelemetryConfig,
} from '../../src/telemetry/config.js';
describe('telemetry/config', () => {
let tempDir: string;
let originalHome: string | undefined;
let originalUserProfile: string | undefined;
beforeEach(() => {
// Create temp directory for tests
tempDir = path.join(os.tmpdir(), `openspec-telemetry-test-${Date.now()}`);
fs.mkdirSync(tempDir, { recursive: true });
// Mock HOME/USERPROFILE to point to temp dir
// On POSIX, os.homedir() uses HOME; on Windows it uses USERPROFILE
originalHome = process.env.HOME;
originalUserProfile = process.env.USERPROFILE;
process.env.HOME = tempDir;
process.env.USERPROFILE = tempDir;
});
afterEach(() => {
// Restore HOME/USERPROFILE
process.env.HOME = originalHome;
process.env.USERPROFILE = originalUserProfile;
// Clean up temp directory
fs.rmSync(tempDir, { recursive: true, force: true });
});
describe('getConfigPath', () => {
it('should return path to config.json in .config/openspec', () => {
const result = getConfigPath();
expect(result).toBe(path.join(tempDir, '.config', 'openspec', 'config.json'));
});
});
describe('readConfig', () => {
it('should return empty object when config file does not exist', async () => {
const config = await readConfig();
expect(config).toEqual({});
});
it('should load valid config from file', async () => {
const configDir = path.join(tempDir, '.config', 'openspec');
const configPath = path.join(configDir, 'config.json');
fs.mkdirSync(configDir, { recursive: true });
fs.writeFileSync(configPath, JSON.stringify({
telemetry: { anonymousId: 'test-id', noticeSeen: true }
}));
const config = await readConfig();
expect(config.telemetry).toEqual({ anonymousId: 'test-id', noticeSeen: true });
});
it('should return empty object for invalid JSON', async () => {
const configDir = path.join(tempDir, '.config', 'openspec');
const configPath = path.join(configDir, 'config.json');
fs.mkdirSync(configDir, { recursive: true });
fs.writeFileSync(configPath, '{ invalid json }');
const config = await readConfig();
expect(config).toEqual({});
});
});
describe('writeConfig', () => {
it('should create directory if it does not exist', async () => {
const configDir = path.join(tempDir, '.config', 'openspec');
await writeConfig({ telemetry: { noticeSeen: true } });
expect(fs.existsSync(configDir)).toBe(true);
});
it('should write config to file', async () => {
const configPath = path.join(tempDir, '.config', 'openspec', 'config.json');
await writeConfig({ telemetry: { anonymousId: 'test-123' } });
const content = fs.readFileSync(configPath, 'utf-8');
const parsed = JSON.parse(content);
expect(parsed.telemetry.anonymousId).toBe('test-123');
});
it('should preserve existing fields when updating', async () => {
const configDir = path.join(tempDir, '.config', 'openspec');
const configPath = path.join(configDir, 'config.json');
// Create initial config with other fields
fs.mkdirSync(configDir, { recursive: true });
fs.writeFileSync(configPath, JSON.stringify({
existingField: 'preserved',
telemetry: { anonymousId: 'old-id' }
}));
// Update telemetry
await writeConfig({ telemetry: { noticeSeen: true } });
const content = fs.readFileSync(configPath, 'utf-8');
const parsed = JSON.parse(content);
expect(parsed.existingField).toBe('preserved');
expect(parsed.telemetry.noticeSeen).toBe(true);
});
it('should deep merge telemetry fields', async () => {
const configDir = path.join(tempDir, '.config', 'openspec');
const configPath = path.join(configDir, 'config.json');
// Create initial config
fs.mkdirSync(configDir, { recursive: true });
fs.writeFileSync(configPath, JSON.stringify({
telemetry: { anonymousId: 'existing-id' }
}));
// Update with noticeSeen only
await writeConfig({ telemetry: { noticeSeen: true } });
const content = fs.readFileSync(configPath, 'utf-8');
const parsed = JSON.parse(content);
expect(parsed.telemetry.anonymousId).toBe('existing-id');
expect(parsed.telemetry.noticeSeen).toBe(true);
});
});
describe('getTelemetryConfig', () => {
it('should return empty object when no config exists', async () => {
const config = await getTelemetryConfig();
expect(config).toEqual({});
});
it('should return telemetry section from config', async () => {
const configDir = path.join(tempDir, '.config', 'openspec');
const configPath = path.join(configDir, 'config.json');
fs.mkdirSync(configDir, { recursive: true });
fs.writeFileSync(configPath, JSON.stringify({
telemetry: { anonymousId: 'my-id', noticeSeen: false }
}));
const config = await getTelemetryConfig();
expect(config).toEqual({ anonymousId: 'my-id', noticeSeen: false });
});
});
describe('updateTelemetryConfig', () => {
it('should create telemetry config when none exists', async () => {
await updateTelemetryConfig({ anonymousId: 'new-id' });
const configPath = path.join(tempDir, '.config', 'openspec', 'config.json');
const content = fs.readFileSync(configPath, 'utf-8');
const parsed = JSON.parse(content);
expect(parsed.telemetry.anonymousId).toBe('new-id');
});
it('should merge with existing telemetry config', async () => {
const configDir = path.join(tempDir, '.config', 'openspec');
const configPath = path.join(configDir, 'config.json');
fs.mkdirSync(configDir, { recursive: true });
fs.writeFileSync(configPath, JSON.stringify({
telemetry: { anonymousId: 'existing-id' }
}));
await updateTelemetryConfig({ noticeSeen: true });
const content = fs.readFileSync(configPath, 'utf-8');
const parsed = JSON.parse(content);
expect(parsed.telemetry.anonymousId).toBe('existing-id');
expect(parsed.telemetry.noticeSeen).toBe(true);
});
});
});
-135
View File
@@ -1,135 +0,0 @@
import { describe, it, expect, beforeEach, afterEach, vi } from 'vitest';
import * as fs from 'node:fs';
import * as path from 'node:path';
import * as os from 'node:os';
import { randomUUID } from 'node:crypto';
// Mock posthog-node before importing the module
vi.mock('posthog-node', () => {
return {
PostHog: vi.fn().mockImplementation(() => ({
capture: vi.fn(),
shutdown: vi.fn().mockResolvedValue(undefined),
})),
};
});
// Import after mocking
import { isTelemetryEnabled, maybeShowTelemetryNotice, shutdown, trackCommand } from '../../src/telemetry/index.js';
import { PostHog } from 'posthog-node';
describe('telemetry/index', () => {
let tempDir: string;
let originalEnv: NodeJS.ProcessEnv;
let consoleLogSpy: ReturnType<typeof vi.spyOn>;
beforeEach(() => {
// Create unique temp directory for each test using UUID
tempDir = path.join(os.tmpdir(), `openspec-telemetry-test-${randomUUID()}`);
fs.mkdirSync(tempDir, { recursive: true });
// Save original env
originalEnv = { ...process.env };
// Mock HOME to point to temp dir
process.env.HOME = tempDir;
// Clear all mocks
vi.clearAllMocks();
// Spy on console.log for notice tests
consoleLogSpy = vi.spyOn(console, 'log').mockImplementation(() => {});
});
afterEach(() => {
// Restore original env
process.env = originalEnv;
// Clean up temp directory
try {
fs.rmSync(tempDir, { recursive: true, force: true });
} catch {
// Ignore cleanup errors
}
// Restore all mocks
vi.restoreAllMocks();
});
describe('isTelemetryEnabled', () => {
it('should return false when OPENSPEC_TELEMETRY=0', () => {
process.env.OPENSPEC_TELEMETRY = '0';
expect(isTelemetryEnabled()).toBe(false);
});
it('should return false when DO_NOT_TRACK=1', () => {
process.env.DO_NOT_TRACK = '1';
expect(isTelemetryEnabled()).toBe(false);
});
it('should return false when CI=true', () => {
process.env.CI = 'true';
expect(isTelemetryEnabled()).toBe(false);
});
it('should return true when no opt-out is set', () => {
delete process.env.OPENSPEC_TELEMETRY;
delete process.env.DO_NOT_TRACK;
delete process.env.CI;
expect(isTelemetryEnabled()).toBe(true);
});
it('should prioritize OPENSPEC_TELEMETRY=0 over other settings', () => {
process.env.OPENSPEC_TELEMETRY = '0';
delete process.env.DO_NOT_TRACK;
delete process.env.CI;
expect(isTelemetryEnabled()).toBe(false);
});
});
describe('maybeShowTelemetryNotice', () => {
it('should not show notice when telemetry is disabled', async () => {
process.env.OPENSPEC_TELEMETRY = '0';
await maybeShowTelemetryNotice();
expect(consoleLogSpy).not.toHaveBeenCalled();
});
});
describe('trackCommand', () => {
it('should not track when telemetry is disabled', async () => {
process.env.OPENSPEC_TELEMETRY = '0';
await trackCommand('test', '1.0.0');
expect(PostHog).not.toHaveBeenCalled();
});
it('should track when telemetry is enabled', async () => {
delete process.env.OPENSPEC_TELEMETRY;
delete process.env.DO_NOT_TRACK;
delete process.env.CI;
await trackCommand('test', '1.0.0');
expect(PostHog).toHaveBeenCalled();
});
});
describe('shutdown', () => {
it('should not throw when no client exists', async () => {
await expect(shutdown()).resolves.not.toThrow();
});
it('should handle shutdown errors silently', async () => {
const mockPostHog = {
capture: vi.fn(),
shutdown: vi.fn().mockRejectedValue(new Error('Network error')),
};
(PostHog as any).mockImplementation(() => mockPostHog);
await expect(shutdown()).resolves.not.toThrow();
});
});
});