Skip to content
Merged
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
133 changes: 92 additions & 41 deletions src/cli/commands/init.ts
Original file line number Diff line number Diff line change
Expand Up @@ -18,7 +18,7 @@ import {
createDocsStructure,
type SecurityMode,
} from '../utils/post-install.js';
import { DEVFLOW_PLUGINS, LEGACY_PLUGIN_NAMES, LEGACY_SKILL_NAMES, LEGACY_COMMAND_NAMES, LEGACY_RULE_NAMES, buildAssetMaps, buildFullSkillsMap, buildRulesMap, type PluginDefinition } from '../plugins.js';
import { DEVFLOW_PLUGINS, LEGACY_PLUGIN_NAMES, LEGACY_SKILL_NAMES, LEGACY_COMMAND_NAMES, LEGACY_RULE_NAMES, buildAssetMaps, buildFullSkillsMap, buildRulesMap, partitionSelectablePlugins, WORKFLOW_ORDER, type PluginDefinition } from '../plugins.js';
import { detectPlatform, detectShell, getProfilePath, getSafeDeleteInfo, hasSafeDelete } from '../utils/safe-delete.js';
import { generateSafeDeleteBlock, installToProfile, removeFromProfile, getInstalledVersion, SAFE_DELETE_BLOCK_VERSION } from '../utils/safe-delete-install.js';
import { addAmbientHook, removeAmbientHook } from './ambient.js';
Expand Down Expand Up @@ -120,6 +120,32 @@ export function parsePluginSelection(
return { selected, invalid };
}

/**
* Combine workflow and language selections into a single plugin list.
* Returns the merged array and whether a valid (non-empty) selection was made.
*
* Pure function — no I/O, no side effects; extracted for testability.
*/
export function combineSelection(
workflowSelected: string[],
languageSelected: string[],
): { plugins: string[]; accepted: boolean } {
const plugins = [...workflowSelected, ...languageSelected];
return { plugins, accepted: plugins.length > 0 };
}

/**
* Returns true when the selection loop should retry: selection was empty and
* the attempt ceiling has not been reached. Returns false when accepted or
* when attempts are exhausted (caller should exit).
*
* Pure function — no I/O, no side effects; extracted for testability.
*/
export function shouldRetry(attempt: number, maxAttempts: number, accepted: boolean): boolean {
if (accepted) return false;
return attempt < maxAttempts;
}

/**
* Options for the init command parsed by Commander.js
*/
Expand Down Expand Up @@ -196,21 +222,6 @@ export const initCommand = new Command('init')
} else if (!process.stdin.isTTY) {
p.log.info('Non-interactive mode detected, using scope: user');
scope = 'user';
} else {
const selected = await p.select({
message: 'Installation scope',
options: [
{ value: 'user', label: 'User', hint: 'all projects (~/.claude/)' },
{ value: 'local', label: 'Local', hint: 'this project only (./.claude/)' },
],
});

if (p.isCancel(selected)) {
p.cancel('Installation cancelled.');
process.exit(0);
}

scope = selected as 'user' | 'local';
}

// --hud-only: install only HUD (skip plugins, hooks, extras)
Expand Down Expand Up @@ -301,7 +312,11 @@ export const initCommand = new Command('init')
'devflow-code-review': 'parallel specialized reviewers',
'devflow-resolve': 'fix review issues by risk',
'devflow-debug': 'competing hypotheses',
'devflow-explore': 'codebase exploration + knowledge bases',
'devflow-research': 'multi-type research with synthesis',
'devflow-release': 'adaptive release with learned config',
'devflow-self-review': 'Simplifier + Scrutinizer',
'devflow-bug-analysis': 'proactive bug finding, post-pipeline',
'devflow-typescript': 'TypeScript patterns',
'devflow-react': 'React patterns',
'devflow-accessibility': 'WCAG compliance',
Expand All @@ -312,31 +327,72 @@ export const initCommand = new Command('init')
'devflow-rust': 'Rust patterns',
};

const choices = DEVFLOW_PLUGINS
.filter(pl => pl.name !== 'devflow-core-skills' && pl.name !== 'devflow-ambient' && pl.name !== 'devflow-audit-claude')
.map(pl => ({
value: pl.name,
label: pl.name.replace('devflow-', ''),
hint: pluginHints[pl.name] ?? pl.description,
}));
const { workflow, language } = partitionSelectablePlugins(DEVFLOW_PLUGINS);

const toChoice = (pl: PluginDefinition) => ({
value: pl.name,
label: pl.name.replace('devflow-', ''),
hint: pluginHints[pl.name] ?? pl.description,
});

const workflowChoices = workflow.map(toChoice);
const languageChoices = language.map(toChoice);

const preSelected = DEVFLOW_PLUGINS
.filter(pl => !pl.optional && pl.name !== 'devflow-core-skills' && pl.name !== 'devflow-ambient')
const workflowInitialValues = workflow
.filter(pl => !pl.optional)
.map(pl => pl.name);

const pluginSelection = await p.multiselect({
message: 'Select plugins to install',
options: choices,
initialValues: preSelected,
required: true,
});
// Bounded selection loop — max 3 attempts (reliability rule: no unbounded loops)
const MAX_ATTEMPTS = 3;
let attempts = 0;

while (attempts < MAX_ATTEMPTS) {
attempts++;

// Step 1 — Workflow plugins (skip if empty bucket)
let workflowSelected: string[] = [];
if (workflowChoices.length > 0) {
const step1 = await p.multiselect({
message: 'Step 1 — Workflow plugins',
options: workflowChoices,
initialValues: workflowInitialValues,
required: false,
});
if (p.isCancel(step1)) {
p.cancel('Installation cancelled.');
process.exit(0);
}
workflowSelected = step1;
}

if (p.isCancel(pluginSelection)) {
p.cancel('Installation cancelled.');
process.exit(0);
}
// Step 2 — Language plugins (skip if empty bucket)
let languageSelected: string[] = [];
if (languageChoices.length > 0) {
const step2 = await p.multiselect({
message: 'Step 2 — Language plugins',
options: languageChoices,
required: false,
});
if (p.isCancel(step2)) {
p.cancel('Installation cancelled.');
process.exit(0);
}
languageSelected = step2;
}

const { plugins: combined, accepted } = combineSelection(workflowSelected, languageSelected);

if (accepted) {
selectedPlugins = combined;
break;
}

selectedPlugins = pluginSelection as string[];
if (!shouldRetry(attempts, MAX_ATTEMPTS, accepted)) {
p.cancel('Installation cancelled — no plugins selected.');
process.exit(0);
}
p.log.warn('Select at least one plugin.');
}
}

// ╭──────────────────────────────────────────────────────────╮
Expand Down Expand Up @@ -1219,11 +1275,6 @@ export const initCommand = new Command('init')
p.log.info('Installed via file copy (Claude CLI not available)');
}

const WORKFLOW_ORDER = [
'/research', '/explore', '/plan', '/implement',
'/code-review', '/resolve', '/self-review',
'/debug', '/release', '/audit-claude',
];
const installedSet = new Set(pluginsToInstall.flatMap(p => p.commands).filter(c => c.length > 0));
const orderedCommands = WORKFLOW_ORDER.filter(cmd => installedSet.has(cmd));
if (orderedCommands.length > 0) {
Expand Down
48 changes: 48 additions & 0 deletions src/cli/plugins.ts
Original file line number Diff line number Diff line change
Expand Up @@ -691,3 +691,51 @@ export function buildRulesMap(plugins: PluginDefinition[]): Map<string, string>
* Pruning: entries can be removed after 2 major versions.
*/
export const LEGACY_RULE_NAMES: string[] = [];

/**
* Canonical display order for workflow commands shown at end of init.
* Mirrors the user-facing pipeline: research → explore → plan → implement →
* code-review → resolve → self-review → bug-analysis → debug → release → audit-claude.
* Export so init.ts can import it rather than keeping a local copy.
*/
export const WORKFLOW_ORDER: string[] = [
'/research', '/explore', '/plan', '/implement',
'/code-review', '/resolve', '/self-review', '/bug-analysis',
'/debug', '/release', '/audit-claude',
];

/**
* Partition the selectable plugins into workflow (command-bearing) and language
* (command-less, optional language/ecosystem) buckets for the two-step init UI.
*
* Excluded from both buckets (not selectable at init):
* - devflow-core-skills (always installed)
* - devflow-ambient (always installed)
* - devflow-audit-claude (installable via --plugin only)
*
* Pure function — does not mutate the input array; preserves DEVFLOW_PLUGINS
* ordering within each bucket; deterministic; no I/O.
*/
export function partitionSelectablePlugins(plugins: PluginDefinition[]): {
workflow: PluginDefinition[];
language: PluginDefinition[];
} {
const EXCLUDED = new Set(['devflow-core-skills', 'devflow-ambient', 'devflow-audit-claude']);
const workflow: PluginDefinition[] = [];
const language: PluginDefinition[] = [];

for (const plugin of plugins) {
if (EXCLUDED.has(plugin.name)) continue;
if (plugin.commands.length > 0) {
workflow.push(plugin);
} else {
// "language" bucket: today every command-less selectable plugin is a
// language/ecosystem plugin. If a non-language command-less plugin is
// added in the future, it will land here — update the bucket name or
// add an explicit category field at that point.
language.push(plugin);
}
}

return { workflow, language };
}
56 changes: 56 additions & 0 deletions tests/init-logic.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -4,6 +4,8 @@ import * as path from 'path';
import * as os from 'os';
import {
parsePluginSelection,
combineSelection,
shouldRetry,
substituteSettingsTemplate,
computeGitignoreAppend,
applyTeamsConfig,
Expand Down Expand Up @@ -74,6 +76,60 @@ describe('parsePluginSelection', () => {
});
});

describe('combineSelection', () => {
it('accepts non-empty workflow-only selection', () => {
const result = combineSelection(['devflow-implement'], []);
expect(result.plugins).toEqual(['devflow-implement']);
expect(result.accepted).toBe(true);
});

it('accepts non-empty language-only selection', () => {
const result = combineSelection([], ['devflow-typescript']);
expect(result.plugins).toEqual(['devflow-typescript']);
expect(result.accepted).toBe(true);
});

it('accepts non-empty combined selection from both buckets', () => {
const result = combineSelection(['devflow-implement'], ['devflow-typescript']);
expect(result.plugins).toEqual(['devflow-implement', 'devflow-typescript']);
expect(result.accepted).toBe(true);
});

it('rejects empty-both-buckets selection', () => {
const result = combineSelection([], []);
expect(result.plugins).toEqual([]);
expect(result.accepted).toBe(false);
});

it('workflow entries precede language entries in the merged list', () => {
const result = combineSelection(['devflow-implement', 'devflow-code-review'], ['devflow-typescript']);
expect(result.plugins).toEqual(['devflow-implement', 'devflow-code-review', 'devflow-typescript']);
});
});

describe('shouldRetry', () => {
const MAX_ATTEMPTS = 3;

it('returns false when selection is accepted (regardless of attempt count)', () => {
expect(shouldRetry(1, MAX_ATTEMPTS, true)).toBe(false);
expect(shouldRetry(2, MAX_ATTEMPTS, true)).toBe(false);
expect(shouldRetry(3, MAX_ATTEMPTS, true)).toBe(false);
});

it('returns true when not accepted and attempts remain', () => {
expect(shouldRetry(1, MAX_ATTEMPTS, false)).toBe(true);
expect(shouldRetry(2, MAX_ATTEMPTS, false)).toBe(true);
});

it('returns false when not accepted and attempt ceiling is reached (exits instead of retrying)', () => {
expect(shouldRetry(3, MAX_ATTEMPTS, false)).toBe(false);
});

it('returns false on attempt exceeding ceiling', () => {
expect(shouldRetry(4, MAX_ATTEMPTS, false)).toBe(false);
});
});

describe('substituteSettingsTemplate', () => {
it('replaces ${DEVFLOW_DIR} placeholders', () => {
const template = '{"scripts": "${DEVFLOW_DIR}/scripts", "hooks": "${DEVFLOW_DIR}/hooks"}';
Expand Down
Loading
Loading