Compare commits

...
Author SHA1 Message Date
Tabish Bidiwale d42de5d86e feat: scaffold Factory Droid slash commands 2025-10-14 19:58:55 +11:00
8 changed files with 238 additions and 2 deletions
+14
View File
@@ -145,6 +145,20 @@ openspec init
- Primary AI tools can trigger `/openspec` workflows without additional configuration
- Run `openspec list` to verify the setup and view any active changes
### Factory Droid custom commands
Factory Droid turns every file in `.factory/commands` into a slash command. Repos can ship shared commands under `<repo>/.factory/commands` while private shortcuts live in `~/.factory/commands`; the workspace copy wins when both exist.【F:openspec/changes/add-factory-slash-scaffold/specs/factory-cli/spec.md†L5-L16】 Only Markdown files and executables with a shebang are registered, and filenames are slugged to lowercase with spaces converted to hyphens so `/Code Review.mdx` becomes `/code-review`.【F:openspec/changes/add-factory-slash-scaffold/specs/factory-cli/spec.md†L8-L23】
Use the OpenSpec CLI to scaffold those files without memorizing Factory’s frontmatter:
```bash
openspec factory slash "Code Review" \
--description "Send a code review checklist" \
--argument-hint "<branch-name>"
```
The command creates `.factory/commands/code-review.md` with YAML frontmatter, `$ARGUMENTS` placeholders, and helpful TODOs. Add `--personal` to target `~/.factory/commands`, `--executable` to generate a `code-review.sh` script with a ready-to-edit Bash template, or `--force` to overwrite an existing file. Executable templates include a proper shebang and `set -euo pipefail` so they run inside Factory just like the docs describe.【F:openspec/changes/add-factory-slash-scaffold/specs/factory-cli/spec.md†L8-L47】
### Create Your First Change
Here's a real example showing the complete OpenSpec workflow. This works with any AI tool. Those with native slash commands will recognize the shortcuts automatically.
@@ -0,0 +1,11 @@
## Why
Factory Droid lets engineers speed up reviews and deployment rituals by loading custom slash commands from `.factory/commands`. Without tooling, every team member has to remember naming rules, directory locations, and Markdown frontmatter details from the Factory docs. This friction keeps the workflow underused and risks inconsistently formatted prompts.
## What Changes
- Add an `openspec factory slash <name>` command that scaffolds Factory Droid custom commands as Markdown prompts or executable scripts using the conventions from the Factory documentation.
- Provide options for setting description text, argument hints, personal vs workspace scope, and executable mode so teams can generate the files they need without manual boilerplate.
- Update documentation/specs to describe the Factory integration and ensure the generated files respect Factory’s slugging and directory rules.
## Impact
- Affected specs: `specs/factory-cli`
- Affected code: `src/cli/index.ts`, `src/commands/factory.ts`, `src/utils/slug.ts`, `README.md`
@@ -0,0 +1,47 @@
## ADDED Requirements
### Requirement: Slash command scaffolding
The CLI MUST provide `openspec factory slash <name>` to scaffold Factory Droid custom slash commands following the documented conventions.
#### Scenario: Create workspace markdown command
- **GIVEN** the current directory has no `.factory/commands` folder
- **WHEN** `openspec factory slash "Code Review" --description "Send a code review checklist" --argument-hint "<branch-name>"` runs without additional flags
- **THEN** the CLI creates `.factory/commands/code-review.md`
- **AND** the file contains YAML frontmatter with `description` and `argument-hint`
- **AND** the body includes a placeholder referencing `$ARGUMENTS`
- **AND** the command slug is lowercase with spaces converted to hyphens and non URL-safe characters removed.
#### Scenario: Personal command directory
- **WHEN** `openspec factory slash deploy --personal` is executed
- **THEN** the CLI creates (or reuses) the `~/.factory/commands` directory
- **AND** writes `deploy.md` into that directory instead of the workspace folder.
#### Scenario: Executable command template
- **WHEN** `openspec factory slash deploy --executable` runs
- **THEN** the CLI writes `.factory/commands/deploy.sh`
- **AND** the file begins with a shebang line (e.g., `#!/usr/bin/env bash`)
- **AND** the template includes `set -euo pipefail`
- **AND** `$1` receives the first argument passed to the slash command.
#### Scenario: Prevent accidental overwrite
- **GIVEN** `.factory/commands/checklist.md` already exists
- **WHEN** `openspec factory slash checklist` runs without `--force`
- **THEN** the CLI aborts with an explanatory error instead of overwriting the file.
#### Scenario: Force overwrite of existing command
- **WHEN** `openspec factory slash checklist --force` runs and the file exists
- **THEN** the CLI replaces the file contents with the new template.
### Requirement: Option handling and feedback
The CLI MUST provide helpful options and output when generating commands.
#### Scenario: Display success message
- **WHEN** a command file is generated successfully
- **THEN** the CLI prints the absolute path and how to trigger the slash command (e.g., `/code-review`).
#### Scenario: Argument validation
- **WHEN** the provided name is empty or resolves to an empty slug
- **THEN** the CLI exits with a validation error explaining that only letters, numbers, spaces, dashes, and underscores are supported.
#### Scenario: Description and argument hint defaults
- **WHEN** description or argument hint values are omitted
- **THEN** the CLI still emits valid frontmatter and leaves missing fields blank so Factory can register the command.
@@ -0,0 +1,12 @@
## 1. Research Factory command conventions
- [x] 1.1 Review Factory docs for `.factory/commands` discovery, slug rules, Markdown frontmatter, and executable requirements.
## 2. CLI implementation
- [x] 2.1 Add a `factory` command group with a `slash <name>` subcommand in `src/cli/index.ts`.
- [x] 2.2 Implement generator logic that slugs names, creates the correct target directory (`.factory/commands` or `~/.factory/commands`), and writes Markdown or executable templates without clobbering existing files unless `--force` is used.
- [x] 2.3 Support options for description, argument hints, executable mode, personal scope, and force overwrites.
## 3. Documentation and validation
- [x] 3.1 Add a new spec describing the Factory CLI scaffold behavior with scenarios for Markdown and executable outputs.
- [x] 3.2 Update docs/quickstart material so agents understand how to generate Factory slash commands with the new CLI command.
- [x] 3.3 Run `openspec validate add-factory-slash-scaffold --strict` and ensure checklists are updated.
+30
View File
@@ -13,6 +13,7 @@ import { registerSpecCommand } from '../commands/spec.js';
import { ChangeCommand } from '../commands/change.js';
import { ValidateCommand } from '../commands/validate.js';
import { ShowCommand } from '../commands/show.js';
import { FactoryCommand } from '../commands/factory.js';
const program = new Command();
const require = createRequire(import.meta.url);
@@ -119,6 +120,35 @@ program
}
});
const factoryCmd = program
.command('factory')
.description('Manage Factory Droid custom slash commands');
factoryCmd
.command('slash <name>')
.description('Scaffold a Factory slash command in .factory/commands')
.option('--description <text>', 'Override the command description shown in slash suggestions')
.option('--argument-hint <hint>', 'Append usage hints after the command name')
.option('--personal', 'Create the command in ~/.factory/commands instead of the workspace directory')
.option('--executable', 'Generate an executable script instead of Markdown')
.option('--force', 'Overwrite the command if it already exists')
.action(async (name: string, options?: {
description?: string;
argumentHint?: string;
personal?: boolean;
executable?: boolean;
force?: boolean;
}) => {
try {
const factoryCommand = new FactoryCommand();
await factoryCommand.slash(name, options ?? {});
} catch (error) {
console.log();
ora().fail(`Error: ${(error as Error).message}`);
process.exit(1);
}
});
// Change command with subcommands
const changeCmd = program
.command('change')
+104
View File
@@ -0,0 +1,104 @@
import os from 'os';
import path from 'path';
import { promises as fs } from 'fs';
import { slugifyFactoryCommand } from '../utils/slug.js';
export interface FactorySlashOptions {
description?: string;
argumentHint?: string;
executable?: boolean;
personal?: boolean;
force?: boolean;
}
export class FactoryCommand {
async slash(name: string, options: FactorySlashOptions = {}): Promise<void> {
const slug = slugifyFactoryCommand(name ?? '');
if (!slug) {
throw new Error('Factory command names must use letters, numbers, spaces, dashes, or underscores.');
}
const baseDir = options.personal
? path.join(os.homedir(), '.factory', 'commands')
: path.join(process.cwd(), '.factory', 'commands');
await fs.mkdir(baseDir, { recursive: true });
const extension = options.executable ? '.sh' : '.md';
const filePath = path.join(baseDir, `${slug}${extension}`);
const fileExists = await this.pathExists(filePath);
if (fileExists && !options.force) {
throw new Error(`Factory command "/${slug}" already exists at ${filePath}. Use --force to overwrite.`);
}
const content = options.executable
? this.buildExecutableTemplate(slug)
: this.buildMarkdownTemplate(options.description, options.argumentHint);
await fs.writeFile(filePath, content, 'utf8');
if (options.executable) {
await fs.chmod(filePath, 0o755);
}
const hint = options.argumentHint ? ` ${options.argumentHint}` : '';
console.log(`Created Factory slash command at ${filePath}`);
console.log(`Trigger with /${slug}${hint}`.trim());
}
private async pathExists(targetPath: string): Promise<boolean> {
try {
await fs.access(targetPath);
return true;
} catch (error: any) {
if (error?.code === 'ENOENT') {
return false;
}
throw error;
}
}
private buildMarkdownTemplate(description?: string, argumentHint?: string): string {
const frontmatter = [
'---',
`description: ${description ?? ''}`,
`argument-hint: ${argumentHint ?? ''}`,
'---',
'',
];
const body = [
'Replace this text with the instructions you want droid to follow.',
'',
'Arguments provided when invoking the slash command will appear as `$ARGUMENTS`.',
'',
'- Highlight important TODOs for the assistant.',
'- Document any prerequisites the script should assume.',
'',
'Thanks!'
];
return [...frontmatter, ...body, ''].join('\n');
}
private buildExecutableTemplate(slug: string): string {
const lines = [
'#!/usr/bin/env bash',
'',
'set -euo pipefail',
'',
'command_name="' + slug + '"',
'target="$1"',
'',
'echo "Running /${command_name} for ${target:-your task}"',
'',
'# Add your automation steps here',
'# npm install',
'# npm run lint',
'',
'echo "Finished /${command_name}"',
'',
];
return lines.join('\n');
}
}
+1 -2
View File
@@ -1,2 +1 @@
// Shared utilities will be implemented here
export {};
export { slugifyFactoryCommand } from './slug.js';
+19
View File
@@ -0,0 +1,19 @@
export function slugifyFactoryCommand(name: string): string {
const normalized = name
.normalize('NFKD')
.replace(/[\u0300-\u036f]/g, '')
.replace(/[^A-Za-z0-9 _-]/g, '')
.trim();
if (!normalized) {
return '';
}
const slug = normalized
.replace(/[\s_]+/g, '-')
.replace(/-+/g, '-')
.replace(/^-+|-+$/g, '')
.toLowerCase();
return slug;
}