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
12 changes: 8 additions & 4 deletions .agents/shared.md
Original file line number Diff line number Diff line change
Expand Up @@ -164,10 +164,14 @@ The wrapped binary is spawned by resolved path and version-asserted to the
accepted range `>=1.0.0 <2.0.0` (dev/CI pins 1.11.0). Every everyday OpenSpec
surface has a cospec command — the change lifecycle, plus `store`
(`setup`/`register` auto-run `cospec init`), `context`, `workset`, `show`,
`view`, `schemas`/`schema`, and `templates` — so there is never a reason to call
bare `openspec`. Read-only and personal surfaces are disciplined passthroughs
(no gate, full wrapped-call discipline); see docs/architecture.md and
docs/stores.md.
`view`, `schemas`/`schema`, `templates`, `config` (machine-global,
`path`/`list`/`get`/`set`/`unset`/`reset`/`edit`/`profile`), native
`completion`, and `feedback` — so there is never a reason to call bare
`openspec`. `init`/`update` stay cospec-native by design. Read-only and personal
surfaces are disciplined passthroughs (no gate, full wrapped-call discipline);
`config edit`/`profile`/`reset --all` (no `-y`) join `workset open` in the
terminal-handover class instead (inherited stdio, verbatim child exit code, no
`--json`); see docs/architecture.md and docs/stores.md.

**Wrapped-call discipline** — every call into the wrapped binary declares its
expected exit codes, a stdout deny-list, and an observable post-condition. Trust
Expand Down
5 changes: 5 additions & 0 deletions .codex/rules/cospec.rules
Original file line number Diff line number Diff line change
Expand Up @@ -9,3 +9,8 @@ prefix_rule(pattern=["cospec", "apply"], decision="allow")
prefix_rule(pattern=["cospec", "sync-blockers", "--check"], decision="allow")
prefix_rule(pattern=["cospec", "new"], decision="allow")
prefix_rule(pattern=["cospec", "doctor"], decision="allow")
prefix_rule(pattern=["cospec", "config", "get"], decision="allow")
prefix_rule(pattern=["cospec", "config", "list"], decision="allow")
prefix_rule(pattern=["cospec", "config", "path"], decision="allow")
prefix_rule(pattern=["cospec", "completion"], decision="allow")
prefix_rule(pattern=["cospec", "__complete"], decision="allow")
12 changes: 8 additions & 4 deletions AGENTS.md
Original file line number Diff line number Diff line change
Expand Up @@ -168,10 +168,14 @@ The wrapped binary is spawned by resolved path and version-asserted to the
accepted range `>=1.0.0 <2.0.0` (dev/CI pins 1.11.0). Every everyday OpenSpec
surface has a cospec command — the change lifecycle, plus `store`
(`setup`/`register` auto-run `cospec init`), `context`, `workset`, `show`,
`view`, `schemas`/`schema`, and `templates` — so there is never a reason to call
bare `openspec`. Read-only and personal surfaces are disciplined passthroughs
(no gate, full wrapped-call discipline); see docs/architecture.md and
docs/stores.md.
`view`, `schemas`/`schema`, `templates`, `config` (machine-global,
`path`/`list`/`get`/`set`/`unset`/`reset`/`edit`/`profile`), native
`completion`, and `feedback` — so there is never a reason to call bare
`openspec`. `init`/`update` stay cospec-native by design. Read-only and personal
surfaces are disciplined passthroughs (no gate, full wrapped-call discipline);
`config edit`/`profile`/`reset --all` (no `-y`) join `workset open` in the
terminal-handover class instead (inherited stdio, verbatim child exit code, no
`--json`); see docs/architecture.md and docs/stores.md.

**Wrapped-call discipline** — every call into the wrapped binary declares its
expected exit codes, a stdout deny-list, and an observable post-condition. Trust
Expand Down
12 changes: 8 additions & 4 deletions CLAUDE.md
Original file line number Diff line number Diff line change
Expand Up @@ -164,10 +164,14 @@ The wrapped binary is spawned by resolved path and version-asserted to the
accepted range `>=1.0.0 <2.0.0` (dev/CI pins 1.11.0). Every everyday OpenSpec
surface has a cospec command — the change lifecycle, plus `store`
(`setup`/`register` auto-run `cospec init`), `context`, `workset`, `show`,
`view`, `schemas`/`schema`, and `templates` — so there is never a reason to call
bare `openspec`. Read-only and personal surfaces are disciplined passthroughs
(no gate, full wrapped-call discipline); see docs/architecture.md and
docs/stores.md.
`view`, `schemas`/`schema`, `templates`, `config` (machine-global,
`path`/`list`/`get`/`set`/`unset`/`reset`/`edit`/`profile`), native
`completion`, and `feedback` — so there is never a reason to call bare
`openspec`. `init`/`update` stay cospec-native by design. Read-only and personal
surfaces are disciplined passthroughs (no gate, full wrapped-call discipline);
`config edit`/`profile`/`reset --all` (no `-y`) join `workset open` in the
terminal-handover class instead (inherited stdio, verbatim child exit code, no
`--json`); see docs/architecture.md and docs/stores.md.

**Wrapped-call discipline** — every call into the wrapped binary declares its
expected exit codes, a stdout deny-list, and an observable post-condition. Trust
Expand Down
43 changes: 23 additions & 20 deletions README.md
Original file line number Diff line number Diff line change
Expand Up @@ -95,26 +95,29 @@ grandfathering, and a blocking-changes ledger with auto-sync.

## Commands

| command | what it does |
| -------------------------------- | ------------------------------------------------------------------------------------------------ |
| `cospec init [path]` | scaffold `openspec/`, schemas, and harness files (idempotent) |
| `cospec update [--check]` | re-generate managed files from canon; `--check` is a drift gate |
| `cospec doctor` | read-only health check, incl. changes still on `schemaVersion` 1 |
| `cospec new <type> <slug>` | create a typed change; prints the artifact plan |
| `cospec migrate <change>` | stamp a grandfathered change to the current `schemaVersion`, scaffolding deferred verification |
| `cospec validate [name]` | validate changes and specs (`--all`/`--changes`/`--specs`); `--strict` promotes warnings |
| `cospec status` / `cospec list` | change status with type, gate, and archive-readiness columns (`list --specs` lists living specs) |
| `cospec instructions <artifact>` | print the authoring instruction for one artifact |
| `cospec apply <change>` | the gate — exit 0 clear, 2 blocked, 3 soft-blocked |
| `cospec archive <change>` | validate, archive, verify the move, fan out blocker sync |
| `cospec sync-blockers` | check off blocker entries whose target has shipped |
| `cospec show <item>` | read a change or spec's markdown or JSON |
| `cospec view` | the OpenSpec dashboard |
| `cospec store <sub>` | manage stores: `setup`/`register` (auto-init) / `unregister` / `remove` / `ls` / `doctor` |
| `cospec context` | a store's cross-repo working-set brief (`--json`, `--code-workspace`) |
| `cospec workset <sub>` | personal cross-repo working views: `create` / `list` / `remove` / `open` |
| `cospec schemas` / `schema` | inspect resolvable schemas (`schema which`/`validate`) |
| `cospec templates` | list per-artifact template paths |
| command | what it does |
| -------------------------------- | ------------------------------------------------------------------------------------------------- |
| `cospec init [path]` | scaffold `openspec/`, schemas, and harness files (idempotent) |
| `cospec update [--check]` | re-generate managed files from canon; `--check` is a drift gate |
| `cospec doctor` | read-only health check, incl. changes still on `schemaVersion` 1 |
| `cospec new <type> <slug>` | create a typed change; prints the artifact plan |
| `cospec migrate <change>` | stamp a grandfathered change to the current `schemaVersion`, scaffolding deferred verification |
| `cospec validate [name]` | validate changes and specs (`--all`/`--changes`/`--specs`); `--strict` promotes warnings |
| `cospec status` / `cospec list` | change status with type, gate, and archive-readiness columns (`list --specs` lists living specs) |
| `cospec instructions <artifact>` | print the authoring instruction for one artifact |
| `cospec apply <change>` | the gate — exit 0 clear, 2 blocked, 3 soft-blocked |
| `cospec archive <change>` | validate, archive, verify the move, fan out blocker sync |
| `cospec sync-blockers` | check off blocker entries whose target has shipped |
| `cospec show <item>` | read a change or spec's markdown or JSON |
| `cospec view` | the OpenSpec dashboard |
| `cospec store <sub>` | manage stores: `setup`/`register` (auto-init) / `unregister` / `remove` / `ls` / `doctor` |
| `cospec context` | a store's cross-repo working-set brief (`--json`, `--code-workspace`) |
| `cospec workset <sub>` | personal cross-repo working views: `create` / `list` / `remove` / `open` |
| `cospec schemas` / `schema` | inspect resolvable schemas (`schema which`/`validate`) |
| `cospec templates` | list per-artifact template paths |
| `cospec config <sub>` | machine-global OpenSpec config: `path`/`list`/`get`/`set`/`unset`/`reset`/`edit`/`profile` |
| `cospec completion [shell]` | print a bash/zsh/fish completion script, generated natively (no install step) |
| `cospec feedback "<msg>"` | file a bug report at `aligned-team/cospec` via `gh`; `--upstream` files at OpenSpec's own tracker |

Global flags on every command: `--json`, `--no-color`, `--cwd <path>`,
`--store <id>`. `--store` runs the whole change lifecycle against a registered
Expand Down
4 changes: 3 additions & 1 deletion apps/cli/README.md
Original file line number Diff line number Diff line change
Expand Up @@ -64,6 +64,8 @@ cospec store setup platform --path ./platform-store # create + auto-init a stor
```

Store management (`store setup|register|unregister|remove|ls|doctor`),
cross-repo context (`context`), and personal worksets (`workset`) are all
cross-repo context (`context`), personal worksets (`workset`), machine-global
config (`config path|list|get|set|unset|reset|edit|profile`), native shell
completion (`completion [bash|zsh|fish]`), and bug filing (`feedback`) are all
first-class cospec commands — you never drop out to bare `openspec`. See the
repository root for full docs.
37 changes: 36 additions & 1 deletion apps/cli/src/cli.ts
Original file line number Diff line number Diff line change
Expand Up @@ -173,6 +173,32 @@ export const COMMANDS: CommandEntry[] = [
summary: 'List per-artifact template paths',
options: ` --schema <name> Schema whose templates to list (default: spec-driven)`,
},
{
name: 'config',
summary: 'View and modify machine-global OpenSpec configuration',
usage: '<path|list|get|set|unset|reset|edit|profile> [args]',
options: ` --scope <scope> Config scope (only "global" is implemented upstream)
(config is machine-global: --store never applies; edit/profile/reset without -y
hand the terminal over and cannot emit JSON)`,
},
{
name: 'completion',
summary: 'Print the shell completion script for cospec',
usage: '[bash|zsh|fish]',
options: ` (shell omitted: detected from $SHELL; the script is printed, never installed)`,
},
{
name: 'feedback',
summary: "File feedback about cospec (--upstream files OpenSpec's)",
usage: '<message>',
options: ` --body <text> Detailed description for the report
--upstream File at Fission-AI/OpenSpec instead of aligned-team/cospec`,
},
{
name: '__complete',
summary: 'Dynamic completion source (changes|specs|types)',
hidden: true,
},
{
name: 'check-commit',
summary: 'Warn on commit-type/schema mismatch (hook entrypoint)',
Expand Down Expand Up @@ -211,10 +237,19 @@ const COMMAND_MODULES: Record<string, () => Promise<Partial<CommandModule>>> = {
schemas: () => import('./commands/schemas.ts'),
schema: () => import('./commands/schema.ts'),
templates: () => import('./commands/templates.ts'),
config: () => import('./commands/config.ts'),
completion: () => import('./commands/completion.ts'),
feedback: () => import('./commands/feedback.ts'),
__complete: () => import('./commands/complete.ts'),
'check-commit': () => import('./commands/check-commit.ts'),
}

const GLOBAL_OPTIONS = `Global options:
/**
* The global-flag help block. Exported because `core/completions/spec.ts`
* derives the completion model from this table plus these flags — completion
* must never drift from `--help`.
*/
export const GLOBAL_OPTIONS = `Global options:
--json Machine-readable output
--no-color Disable ANSI color
--cwd <path> Run as if invoked from <path>
Expand Down
89 changes: 89 additions & 0 deletions apps/cli/src/commands/complete.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,89 @@
// `cospec __complete <changes|specs|types>` — the hidden dynamic-completion
// source the generated shell scripts call at Tab time. Emits tab-separated
// `id<TAB>description` lines.
//
// EVERY failure is silent: exit 1 with nothing on stdout and nothing on stderr.
// A completion helper runs mid-keystroke, where an error message would corrupt
// the user's command line — so an unknown source, a missing openspec root, an
// unregistered store, or an unparseable wrapped payload all look the same:
// no suggestions. The whole payload is built before anything is written, so a
// late failure can never leave half a list on stdout.

import type { CommandContext } from '../cli.ts'
import { EXIT } from '../cli.ts'
import { COSPEC_TYPES } from '../core/change.ts'
import { openspecList, passthroughOpenspec } from '../core/openspec.ts'
import { resolveRoot } from '../core/root.ts'
import { TYPE_ARTIFACTS } from '../core/rules/type-facts.ts'

export const COMPLETE_SOURCES = ['changes', 'specs', 'types'] as const
export type CompleteSource = (typeof COMPLETE_SOURCES)[number]

function isCompleteSource(name: string): name is CompleteSource {
return (COMPLETE_SOURCES as readonly string[]).includes(name)
}

/** Render `id<TAB>description` lines (empty description → id alone). */
export function renderCompletionItems(items: { id: string; description?: string }[]): string {
return items
.map((item) =>
item.description === undefined || item.description.length === 0
? `${item.id}\n`
: `${item.id}\t${item.description}\n`,
)
.join('')
}

/** The 11 conventional-commit types, described by the artifacts each declares. */
function typeItems(): { id: string; description: string }[] {
return COSPEC_TYPES.map((type) => ({
id: type,
description: TYPE_ARTIFACTS[type].declared.join(', '),
}))
}

async function changeItems(ctx: CommandContext): Promise<{ id: string; description: string }[]> {
const root = await resolveRoot(ctx)
const list = await openspecList(root)
return list.changes.map((change) => ({
id: change.name,
description: `${change.status}, ${change.completedTasks}/${change.totalTasks} tasks`,
}))
}

interface SpecsPayload {
specs?: { id: string; requirementCount: number }[]
}

async function specItems(ctx: CommandContext): Promise<{ id: string; description: string }[]> {
const root = await resolveRoot(ctx)
const result = await passthroughOpenspec(['list', '--specs', '--json'], {
cwd: root.cwd,
storeArgs: root.storeArgs,
})
if (result.exitCode !== 0) throw new Error('list --specs failed')
const payload = JSON.parse(result.stdout) as SpecsPayload
return (payload.specs ?? []).map((spec) => ({
id: spec.id,
description: `${spec.requirementCount} requirements`,
}))
}

export async function run(ctx: CommandContext): Promise<number> {
const source = ctx.args[0]
if (source === undefined || !isCompleteSource(source)) return EXIT.failure
try {
const items =
source === 'types'
? typeItems()
: source === 'changes'
? await changeItems(ctx)
: await specItems(ctx)
process.stdout.write(renderCompletionItems(items))
return EXIT.success
} catch {
// Deliberate blanket catch: see the module header. Nothing is written, so
// the shell simply offers no suggestions.
return EXIT.failure
}
}
79 changes: 79 additions & 0 deletions apps/cli/src/commands/completion.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,79 @@
// `cospec completion [bash|zsh|fish]` — print the completion script for a
// shell to stdout. Generate-only by design: there is no `install`/`uninstall`
// subcommand, because rc-file mutation with backups and a matching uninstaller
// is a separate change, and because cospec must never write a line into a
// user's dotfiles that runs bare `openspec` (which is what relaying upstream's
// installer would do). The docs carry the per-shell copy-paste one-liner.

import { basename } from 'node:path'

import type { CommandContext } from '../cli.ts'
import { EXIT } from '../cli.ts'
import { renderBashCompletion } from '../core/completions/bash.ts'
import { renderFishCompletion } from '../core/completions/fish.ts'
import { buildCompletionSpec } from '../core/completions/spec.ts'
import { renderZshCompletion } from '../core/completions/zsh.ts'

export const SUPPORTED_SHELLS = ['bash', 'zsh', 'fish'] as const
export type SupportedShell = (typeof SUPPORTED_SHELLS)[number]

function isSupportedShell(name: string): name is SupportedShell {
return (SUPPORTED_SHELLS as readonly string[]).includes(name)
}

/**
* Detect the shell from `$SHELL`'s basename, stripping the leading `-` a login
* shell carries. No `ps` fork: upstream probes the parent process, which is not
* worth a spawn for a value the user can always pass explicitly.
*/
export function detectShell(shellEnv: string | undefined): SupportedShell | undefined {
if (shellEnv === undefined || shellEnv.length === 0) return undefined
const name = basename(shellEnv).replace(/^-/, '')
return isSupportedShell(name) ? name : undefined
}

/** Render the completion script for one shell from cospec's own command table. */
export function renderCompletion(shell: SupportedShell): string {
const spec = buildCompletionSpec()
if (shell === 'bash') return renderBashCompletion(spec)
if (shell === 'zsh') return renderZshCompletion(spec)
return renderFishCompletion(spec)
}

export function run(ctx: CommandContext): number {
// A shell script is not a JSON document, so `--json` is refused rather than
// faked — but the refusal is still exactly one JSON document on stdout, which
// is what a `--json` caller is entitled to.
if (ctx.flags.json) {
process.stdout.write(
`${JSON.stringify({
version: 1,
command: 'completion',
ok: false,
message: 'cospec completion emits a shell script and cannot emit JSON',
})}\n`,
)
return EXIT.failure
}

const requested = ctx.args[0]
if (requested !== undefined && !isSupportedShell(requested)) {
process.stderr.write(
`cospec completion: unsupported shell '${requested}' ` +
`(supported: ${SUPPORTED_SHELLS.join(', ')})\n`,
)
return EXIT.failure
}

const shell = requested ?? detectShell(process.env.SHELL)
if (shell === undefined) {
process.stderr.write(
'cospec completion: could not detect the shell from $SHELL — ' +
`run 'cospec completion <${SUPPORTED_SHELLS.join('|')}>'\n`,
)
return EXIT.failure
}

process.stdout.write(renderCompletion(shell))
return EXIT.success
}
Loading
Loading