Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
Empty file removed AGENTS.md
Empty file.
2 changes: 1 addition & 1 deletion CHANGELOG.md
Original file line number Diff line number Diff line change
Expand Up @@ -82,7 +82,7 @@

- [#747](https://github.com/Fission-AI/OpenSpec/pull/747) [`1e94443`](https://github.com/Fission-AI/OpenSpec/commit/1e94443a3551b228eecbc89e95d96d3b9600a192) Thanks [@TabishB](https://github.com/TabishB)! - ### New Features

- **Profile system** — Choose between `core` (4 essential workflows) and `custom` (pick any subset) profiles to control which skills get installed. Manage profiles with the new `openspec config profile` command
- **Profile system** — Choose between `core` (5 essential workflows), `all` (all 11 workflows), and `custom` (pick any subset) profiles to control which skills get installed. Manage profiles with the new `openspec config profile` command
- **Propose workflow** — New one-step workflow creates a complete change proposal with design, specs, and tasks from a single request — no need to run `new` then `ff` separately
- **AI tool auto-detection** — `openspec init` now scans your project for existing tool directories (`.claude/`, `.cursor/`, etc.) and pre-selects detected tools
- **Pi (pi.dev) support** — Pi coding agent is now a supported tool with prompt and skill generation
Expand Down
5 changes: 4 additions & 1 deletion docs/cli.md
Original file line number Diff line number Diff line change
Expand Up @@ -103,7 +103,7 @@ openspec init [path] [options]
|--------|-------------|
| `--tools <list>` | Configure AI tools non-interactively. Use `all`, `none`, or comma-separated list |
| `--force` | Auto-cleanup legacy files without prompting |
| `--profile <profile>` | Override global profile for this init run (`core` or `custom`) |
| `--profile <profile>` | Override global profile for this init run (`core`, `custom`, or `all`) |

`--profile custom` uses whatever workflows are currently selected in global config (`openspec config profile`).

Expand Down Expand Up @@ -1189,6 +1189,9 @@ openspec config profile

# Fast preset: switch workflows to core (keeps delivery mode)
openspec config profile core

# Fast preset: install all workflows (keeps delivery mode)
openspec config profile all
```

`openspec config profile` starts with a current-state summary, then lets you choose:
Expand Down
4 changes: 2 additions & 2 deletions docs/commands.md
Original file line number Diff line number Diff line change
Expand Up @@ -16,7 +16,7 @@ For workflow patterns and when to use each command, see [Workflows](workflows.md
| `/opsx:sync` | Merge delta specs into main specs |
| `/opsx:archive` | Archive a completed change |

### Expanded Workflow Commands (custom workflow selection)
### Expanded Workflow Commands (`custom` or `all` profile)

| Command | Purpose |
|---------|---------|
Expand All @@ -27,7 +27,7 @@ For workflow patterns and when to use each command, see [Workflows](workflows.md
| `/opsx:bulk-archive` | Archive multiple changes at once |
| `/opsx:onboard` | Guided tutorial through the complete workflow |

The default global profile is `core`. To enable expanded workflow commands, run `openspec config profile`, select workflows, then run `openspec update` in your project.
The default global profile is `core`. To enable all expanded workflow commands, run `openspec config profile all` and then `openspec update`. To pick a custom subset, run `openspec config profile` (interactive), select workflows, then `openspec update`.

---

Expand Down
4 changes: 2 additions & 2 deletions docs/getting-started.md
Original file line number Diff line number Diff line change
Expand Up @@ -12,13 +12,13 @@ OpenSpec helps you and your AI coding assistant agree on what to build before an
/opsx:propose ──► /opsx:apply ──► /opsx:sync ──► /opsx:archive
```

**Expanded path (custom workflow selection):**
**Expanded path (all or custom profile):**

```text
/opsx:new ──► /opsx:ff or /opsx:continue ──► /opsx:apply ──► /opsx:verify ──► /opsx:archive
```

The default global profile is `core`, which includes `propose`, `explore`, `apply`, `sync`, and `archive`. You can enable the expanded workflow commands with `openspec config profile` and then `openspec update`.
The default global profile is `core`, which includes `propose`, `explore`, `apply`, `sync`, and `archive`. You can enable all expanded workflow commands with `openspec config profile all`, or pick a custom subset with `openspec config profile`, then `openspec update`.

## What OpenSpec Creates

Expand Down
4 changes: 2 additions & 2 deletions docs/migration-guide.md
Original file line number Diff line number Diff line change
Expand Up @@ -85,7 +85,7 @@ Don't worry about getting it perfect. We're still learning what works best here,
Both `openspec init` and `openspec update` detect legacy files and guide you through the same cleanup process. Use whichever fits your situation:

- New installs default to profile `core` (`propose`, `explore`, `apply`, `sync`, `archive`).
- Migrated installs preserve your previously installed workflows by writing a `custom` profile when needed.
- Migrated installs preserve your previously installed workflows: if all 11 workflows are installed, you'll get the `all` profile; otherwise a `custom` profile with your detected workflows.

### Using `openspec init`

Expand Down Expand Up @@ -289,7 +289,7 @@ Command availability is profile-dependent:
| `/opsx:apply` | Implement tasks from tasks.md |
| `/opsx:archive` | Finalize and archive the change |

**Expanded workflow (custom selection):**
**Expanded workflow (all or custom profile):**

| Command | Purpose |
|---------|---------|
Expand Down
3 changes: 2 additions & 1 deletion docs/supported-tools.md
Original file line number Diff line number Diff line change
Expand Up @@ -82,8 +82,9 @@ openspec init --profile core
OpenSpec installs workflow artifacts based on selected workflows:

- **Core profile (default):** `propose`, `explore`, `apply`, `sync`, `archive`
- **Custom selection:** any subset of all workflow IDs:
- **All profile:** all 11 workflow IDs:
`propose`, `explore`, `new`, `continue`, `apply`, `ff`, `sync`, `archive`, `bulk-archive`, `verify`, `onboard`
- **Custom selection:** any subset of all workflow IDs

In other words, skill/command counts are profile-dependent and delivery-dependent, not fixed.

Expand Down
4 changes: 2 additions & 2 deletions docs/workflows.md
Original file line number Diff line number Diff line change
Expand Up @@ -45,12 +45,12 @@ Typical flow:
/opsx:propose ──► /opsx:apply ──► /opsx:sync ──► /opsx:archive
```

### Expanded/Full Workflow (custom selection)
### Expanded/Full Workflow (all or custom profile)

If you want explicit scaffold-and-build commands (`/opsx:new`, `/opsx:continue`, `/opsx:ff`, `/opsx:verify`, `/opsx:bulk-archive`, `/opsx:onboard`), enable them with:

```bash
openspec config profile
openspec config profile all
openspec update
```

Expand Down
2 changes: 1 addition & 1 deletion openspec/specs/cli-config/spec.md
Original file line number Diff line number Diff line change
Expand Up @@ -176,7 +176,7 @@ The `openspec config profile` command SHALL provide an action-first interactive
- **WHEN** user runs `openspec config profile` in an interactive terminal
- **THEN** display a current-state header with:
- current delivery value
- workflow count with profile label (core or custom)
- workflow count with profile label (core, custom, or all)

#### Scenario: Action-first menu offers skippable paths

Expand Down
2 changes: 1 addition & 1 deletion src/cli/index.ts
Original file line number Diff line number Diff line change
Expand Up @@ -118,7 +118,7 @@ program
.description('Initialize OpenSpec in your project')
.option('--tools <tools>', toolsOptionDescription)
.option('--force', 'Auto-cleanup legacy files without prompting')
.option('--profile <profile>', 'Override global config profile (core or custom)')
.option('--profile <profile>', 'Override global config profile (core, custom, or all)')
.action(async (targetPath = '.', options?: { tools?: string; force?: boolean; profile?: string }) => {
try {
// Validate that the path is a valid directory
Expand Down
20 changes: 18 additions & 2 deletions src/commands/config.ts
Original file line number Diff line number Diff line change
Expand Up @@ -124,6 +124,10 @@ export function deriveProfileFromWorkflowSelection(selectedWorkflows: string[]):
const isCoreMatch =
selectedWorkflows.length === CORE_WORKFLOWS.length &&
CORE_WORKFLOWS.every((w) => selectedWorkflows.includes(w));
const isAllMatch =
selectedWorkflows.length === ALL_WORKFLOWS.length &&
ALL_WORKFLOWS.every((w) => selectedWorkflows.includes(w));
if (isAllMatch) return 'all';
return isCoreMatch ? 'core' : 'custom';
}

Expand Down Expand Up @@ -318,6 +322,8 @@ export function registerConfigCommand(program: Command): void {
console.log(` delivery: ${config.delivery} ${deliverySource}`);
if (config.profile === 'core') {
console.log(` workflows: ${CORE_WORKFLOWS.join(', ')} (from core profile)`);
} else if (config.profile === 'all') {
console.log(` workflows: ${ALL_WORKFLOWS.join(', ')} (from all profile)`);
} else if (config.workflows && config.workflows.length > 0) {
console.log(` workflows: ${config.workflows.join(', ')} (explicit)`);
} else {
Expand Down Expand Up @@ -525,15 +531,25 @@ export function registerConfigCommand(program: Command): void {
return;
}

if (preset === 'all') {
const config = getGlobalConfig();
config.profile = 'all';
config.workflows = [...ALL_WORKFLOWS];
saveGlobalConfig(config);
const workspaceContext = await resolveWorkspaceConfigProfileContext();
printConfigProfileApplyGuidance(workspaceContext);
return;
}

if (preset) {
console.error(`Error: Unknown profile preset "${preset}". Available presets: core`);
console.error(`Error: Unknown profile preset "${preset}". Available presets: core, all`);
process.exitCode = 1;
return;
}

// Non-interactive check
if (!process.stdout.isTTY) {
console.error('Interactive mode required. Use `openspec config profile core` or set config via environment/flags.');
console.error('Interactive mode required. Use `openspec config profile core`, `openspec config profile all`, or set config via environment/flags.');
process.exitCode = 1;
return;
}
Expand Down
4 changes: 2 additions & 2 deletions src/core/completions/command-registry.ts
Original file line number Diff line number Diff line change
Expand Up @@ -19,9 +19,9 @@ export const COMMAND_REGISTRY: CommandDefinition[] = [
},
{
name: 'profile',
description: 'Override global config profile (core or custom)',
description: 'Override global config profile (core, custom, or all)',
takesValue: true,
values: ['core', 'custom'],
values: ['core', 'custom', 'all'],
},
],
},
Expand Down
2 changes: 1 addition & 1 deletion src/core/config-schema.ts
Original file line number Diff line number Diff line change
Expand Up @@ -11,7 +11,7 @@ export const GlobalConfigSchema = z
.optional()
.default({}),
profile: z
.enum(['core', 'custom'])
.enum(['core', 'custom', 'all'])
.optional()
.default('core'),
delivery: z
Expand Down
2 changes: 1 addition & 1 deletion src/core/global-config.ts
Original file line number Diff line number Diff line change
Expand Up @@ -8,7 +8,7 @@ export const GLOBAL_CONFIG_FILE_NAME = 'config.json';
export const GLOBAL_DATA_DIR_NAME = 'openspec';

// TypeScript types
export type Profile = 'core' | 'custom';
export type Profile = 'core' | 'custom' | 'all';
export type Delivery = 'both' | 'skills' | 'commands';

// TypeScript interfaces
Expand Down
4 changes: 2 additions & 2 deletions src/core/init.ts
Original file line number Diff line number Diff line change
Expand Up @@ -182,11 +182,11 @@ export class InitCommand {
return undefined;
}

if (this.profileOverride === 'core' || this.profileOverride === 'custom') {
if (this.profileOverride === 'core' || this.profileOverride === 'custom' || this.profileOverride === 'all') {
return this.profileOverride;
}

throw new Error(`Invalid profile "${this.profileOverride}". Available profiles: core, custom`);
throw new Error(`Invalid profile "${this.profileOverride}". Available profiles: core, custom, all`);
}

// ═══════════════════════════════════════════════════════════
Expand Down
26 changes: 19 additions & 7 deletions src/core/migration.ts
Original file line number Diff line number Diff line change
Expand Up @@ -6,10 +6,10 @@
*/

import type { AIToolOption } from './config.js';
import { getGlobalConfig, getGlobalConfigPath, saveGlobalConfig, type Delivery } from './global-config.js';
import { getGlobalConfig, getGlobalConfigPath, saveGlobalConfig, type Delivery, type Profile } from './global-config.js';
import { CommandAdapterRegistry } from './command-generation/index.js';
import { WORKFLOW_TO_SKILL_DIR } from './profile-sync-drift.js';
import { ALL_WORKFLOWS } from './profiles.js';
import { ALL_WORKFLOWS, getProfileWorkflows } from './profiles.js';
import path from 'path';
import * as fs from 'fs';

Expand Down Expand Up @@ -84,7 +84,8 @@ function inferDelivery(artifacts: InstalledWorkflowArtifacts): Delivery {
* Performs one-time migration if the global config does not yet have a profile field.
* Called by both init and update before profile resolution.
*
* - If no profile field exists and workflows are installed: sets profile to 'custom'
* - If no profile field exists and all workflows are installed: sets profile to 'all'.
* - If no profile field exists and some workflows are installed: sets profile to 'custom'
* with the detected workflows, preserving the user's existing setup.
* - If no profile field exists and no workflows are installed: no-op (defaults apply).
* - If profile field already exists: no-op.
Expand Down Expand Up @@ -118,14 +119,25 @@ export function migrateIfNeeded(projectPath: string, tools: AIToolOption[]): voi
return;
}

// Migrate: set profile to custom with detected workflows
config.profile = 'custom';
config.workflows = installedWorkflows;
// Migrate: choose profile based on detected workflows
const allWorkflowSet = new Set<string>(ALL_WORKFLOWS);
const isAllWorkflows = installedWorkflows.length === ALL_WORKFLOWS.length &&
installedWorkflows.every((w) => allWorkflowSet.has(w));

let migratedProfile: Profile;
if (isAllWorkflows) {
migratedProfile = 'all';
} else {
migratedProfile = 'custom';
}

config.profile = migratedProfile;
config.workflows = [...getProfileWorkflows(migratedProfile, installedWorkflows)];
if (rawConfig.delivery === undefined) {
config.delivery = inferDelivery(artifacts);
}
saveGlobalConfig(config);

console.log(`Migrated: custom profile with ${installedWorkflows.length} workflows`);
console.log(`Migrated: ${migratedProfile} profile with ${installedWorkflows.length} workflows`);
console.log("New in this version: /opsx:propose. Try 'openspec config profile core' for the streamlined experience.");
}
4 changes: 4 additions & 0 deletions src/core/profiles.ts
Original file line number Diff line number Diff line change
Expand Up @@ -37,6 +37,7 @@ export type CoreWorkflowId = (typeof CORE_WORKFLOWS)[number];
* Resolves which workflows should be active for a given profile configuration.
*
* - 'core' profile always returns CORE_WORKFLOWS
* - 'all' profile always returns ALL_WORKFLOWS
* - 'custom' profile returns the provided customWorkflows, or empty array if not provided
*/
export function getProfileWorkflows(
Expand All @@ -46,5 +47,8 @@ export function getProfileWorkflows(
if (profile === 'custom') {
return customWorkflows ?? [];
}
if (profile === 'all') {
return ALL_WORKFLOWS;
}
return CORE_WORKFLOWS;
}
4 changes: 2 additions & 2 deletions src/core/workspace/foundation.ts
Original file line number Diff line number Diff line change
Expand Up @@ -62,7 +62,7 @@ export interface WorkspaceViewState {

export interface WorkspaceSkillState {
selected_agents: string[];
last_applied_profile?: 'core' | 'custom';
last_applied_profile?: 'core' | 'custom' | 'all';
last_applied_delivery?: 'both' | 'skills' | 'commands';
last_applied_workflow_ids?: string[];
last_applied_at?: string;
Expand Down Expand Up @@ -192,7 +192,7 @@ const WorkspaceContextSchema = WorkspaceInitiativeContextSchema;
const WorkspaceSkillStateSchema = z
.object({
selected_agents: z.array(z.string()),
last_applied_profile: z.enum(['core', 'custom']).optional(),
last_applied_profile: z.enum(['core', 'custom', 'all']).optional(),
last_applied_delivery: z.enum(['both', 'skills', 'commands']).optional(),
last_applied_workflow_ids: z.array(z.string()).optional(),
last_applied_at: z.string().optional(),
Expand Down
2 changes: 1 addition & 1 deletion src/core/workspace/legacy-state.ts
Original file line number Diff line number Diff line change
Expand Up @@ -73,7 +73,7 @@ const PreferredOpenerSchema = z
const WorkspaceSkillStateSchema = z
.object({
selected_agents: z.array(z.string()),
last_applied_profile: z.enum(['core', 'custom']).optional(),
last_applied_profile: z.enum(['core', 'custom', 'all']).optional(),
last_applied_delivery: z.enum(['both', 'skills', 'commands']).optional(),
last_applied_workflow_ids: z.array(z.string()).optional(),
last_applied_at: z.string().optional(),
Expand Down
36 changes: 36 additions & 0 deletions test/commands/config-profile.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -80,6 +80,17 @@ describe('deriveProfileFromWorkflowSelection', () => {
const { deriveProfileFromWorkflowSelection } = await import('../../src/commands/config.js');
expect(deriveProfileFromWorkflowSelection(['archive', 'sync', 'apply', 'explore', 'propose'])).toBe('core');
});

it('returns all when selection has exactly all workflows', async () => {
const { deriveProfileFromWorkflowSelection } = await import('../../src/commands/config.js');
const { ALL_WORKFLOWS } = await import('../../src/core/profiles.js');
expect(deriveProfileFromWorkflowSelection([...ALL_WORKFLOWS])).toBe('all');
});

it('returns all when selection has all workflows in different order', async () => {
const { deriveProfileFromWorkflowSelection } = await import('../../src/commands/config.js');
expect(deriveProfileFromWorkflowSelection(['onboard', 'verify', 'bulk-archive', 'archive', 'sync', 'ff', 'apply', 'continue', 'new', 'explore', 'propose'])).toBe('all');
});
});

describe('config profile interactive flow', () => {
Expand Down Expand Up @@ -497,6 +508,31 @@ describe('config profile interactive flow', () => {
expect(confirm).not.toHaveBeenCalled();
});

it('all preset should install all workflows and preserve delivery setting', async () => {
const { saveGlobalConfig, getGlobalConfig } = await import('../../src/core/global-config.js');
const { ALL_WORKFLOWS } = await import('../../src/core/profiles.js');
const { select, checkbox, confirm } = await getPromptMocks();

saveGlobalConfig({ featureFlags: {}, profile: 'core', delivery: 'commands', workflows: ['propose', 'explore', 'apply', 'sync', 'archive'] });

await runConfigCommand(['profile', 'all']);

const config = getGlobalConfig();
expect(config.profile).toBe('all');
expect(config.delivery).toBe('commands');
expect(config.workflows).toEqual([...ALL_WORKFLOWS]);
expect(select).not.toHaveBeenCalled();
expect(checkbox).not.toHaveBeenCalled();
expect(confirm).not.toHaveBeenCalled();
});

it('unknown preset should show error and list available presets', async () => {
await runConfigCommand(['profile', 'unknown']);

expect(consoleErrorSpy).toHaveBeenCalledWith(expect.stringContaining('Unknown profile preset "unknown"'));
expect(consoleErrorSpy).toHaveBeenCalledWith(expect.stringContaining('Available presets: core, all'));
});

it('core preset inside a workspace should stay non-interactive and print workspace update guidance', async () => {
const { saveGlobalConfig, getGlobalConfig } = await import('../../src/core/global-config.js');
const { select, checkbox, confirm } = await getPromptMocks();
Expand Down
7 changes: 6 additions & 1 deletion test/commands/config.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -232,6 +232,10 @@ describe('config profile command', () => {

// Simulate custom selection that differs from core
const selectedWorkflows = ['explore', 'new', 'apply', 'ff', 'verify'];
const { ALL_WORKFLOWS } = await import('../../src/core/profiles.js');
const isAllMatch =
selectedWorkflows.length === ALL_WORKFLOWS.length &&
ALL_WORKFLOWS.every((w: string) => selectedWorkflows.includes(w));
const isCoreMatch =
selectedWorkflows.length === CORE_WORKFLOWS.length &&
CORE_WORKFLOWS.every((w: string) => selectedWorkflows.includes(w));
Expand All @@ -240,7 +244,7 @@ describe('config profile command', () => {

saveGlobalConfig({
featureFlags: {},
profile: isCoreMatch ? 'core' : 'custom',
profile: isAllMatch ? 'all' : isCoreMatch ? 'core' : 'custom',
delivery: 'both',
workflows: selectedWorkflows,
});
Expand All @@ -267,6 +271,7 @@ describe('config profile command', () => {
expect(validateConfig({ featureFlags: {}, profile: 'core', delivery: 'both' }).success).toBe(true);
expect(validateConfig({ featureFlags: {}, profile: 'custom', delivery: 'skills' }).success).toBe(true);
expect(validateConfig({ featureFlags: {}, profile: 'custom', delivery: 'commands', workflows: ['explore'] }).success).toBe(true);
expect(validateConfig({ featureFlags: {}, profile: 'all', delivery: 'both' }).success).toBe(true);
});

it('config schema should reject invalid profile values', async () => {
Expand Down
Loading