okf-cli 0.9.0.0 → 0.10.0.0
raw patch · 17 files changed
+1699/−208 lines, 17 filesdep ~baikaidep ~baikai-claudedep ~baikai-kitPVP ok
version bump matches the API change (PVP)
Dependency ranges changed: baikai, baikai-claude, baikai-kit, baikai-openai, okf-core
API changes (from Hackage documentation)
- Okf.Cli.Kit: instance GHC.Classes.Eq Okf.Cli.Kit.KitCommand
- Okf.Cli.Kit: instance GHC.Internal.Show.Show Okf.Cli.Kit.KitCommand
+ Okf.Cli: Alias :: !AliasCommand -> Command
+ Okf.Cli: ProfileInit :: ProfileInitOptions -> ProfileCommand
+ Okf.Cli: ProfileInitOptions :: ![Text] -> !Maybe Text -> !FilePath -> !Bool -> !Maybe Text -> !Bool -> ProfileInitOptions
+ Okf.Cli: [bundleDir] :: ProfileInitOptions -> !FilePath
+ Okf.Cli: [sortKeys] :: ConceptsOptions -> ![SortKey]
+ Okf.Cli: builtinCommands :: [Text]
+ Okf.Cli: conceptsProfileDiagnostics :: CompiledProfile -> [Text] -> [WhereCondition] -> [FieldSelector] -> [FieldSelector] -> [SortKey] -> [Text]
+ Okf.Cli: data ProfileInitOptions
+ Okf.Cli: instance GHC.Classes.Eq Okf.Cli.ProfileInitOptions
+ Okf.Cli: instance GHC.Internal.Show.Show Okf.Cli.ProfileInitOptions
+ Okf.Cli: renderFilterProfileError :: FilterProfileError -> Text
+ Okf.Cli: renderPredicateProfileError :: FilterProfileError -> Text
+ Okf.Cli.Aliases: AliasList :: AliasCommand
+ Okf.Cli.Aliases: aliasCommandParser :: Parser AliasCommand
+ Okf.Cli.Aliases: data AliasCommand
+ Okf.Cli.Aliases: expandAlias :: [Text] -> Map Text Text -> [String] -> [String]
+ Okf.Cli.Aliases: instance GHC.Classes.Eq Okf.Cli.Aliases.AliasCommand
+ Okf.Cli.Aliases: instance GHC.Internal.Show.Show Okf.Cli.Aliases.AliasCommand
+ Okf.Cli.Aliases: isAliasCandidate :: [Text] -> [String] -> Bool
+ Okf.Cli.Aliases: renderAliases :: Map Text Text -> Text
+ Okf.Cli.Aliases: validateAliases :: Map Text Text -> Either Text ()
+ Okf.Cli.Assist: buildSessionAgentCommand :: OkfConfig -> ResolvedAgent -> AssistOptions -> IO (Either AgentRenderError (FilePath, [String]))
+ Okf.Cli.Config: [aliases] :: OkfConfig -> !Map Text Text
+ Okf.Cli.Config: instance Dhall.Marshal.Decode.FromDhall Okf.Cli.Config.ConfigShapeWithoutAliases
+ Okf.Cli.Config: instance GHC.Classes.Eq Okf.Cli.Config.ConfigShapeWithoutAliases
+ Okf.Cli.Config: instance GHC.Internal.Generics.Generic Okf.Cli.Config.ConfigShapeWithoutAliases
+ Okf.Cli.Config: instance GHC.Internal.Show.Show Okf.Cli.Config.ConfigShapeWithoutAliases
+ Okf.Cli.Config: loadAliasesForExpansion :: IO (Map Text Text)
+ Okf.Cli.Kit: HumanOutput :: OutputFormat
+ Okf.Cli.Kit: JsonOutput :: OutputFormat
+ Okf.Cli.Kit: data OutputFormat
- Okf.Cli: ConceptsOptions :: !Maybe FilePath -> ![Text] -> ![ConceptFilter] -> ![FieldSelector] -> ![FieldSelector] -> ![Text] -> !Maybe FilePath -> !Bool -> ConceptsOptions
+ Okf.Cli: ConceptsOptions :: !Maybe FilePath -> ![Text] -> ![WhereCondition] -> ![FieldSelector] -> ![FieldSelector] -> ![Text] -> ![SortKey] -> !Maybe FilePath -> !Bool -> ConceptsOptions
- Okf.Cli: [date] :: LogAddOptions -> !Maybe Text
+ Okf.Cli: [date] :: ProfileInitOptions -> !Maybe Text
- Okf.Cli: [fieldFilters] :: ConceptsOptions -> ![ConceptFilter]
+ Okf.Cli: [fieldFilters] :: ConceptsOptions -> ![WhereCondition]
- Okf.Cli: [write] :: ProfileDocumentOptions -> !Bool
+ Okf.Cli: [write] :: ProfileInitOptions -> !Bool
- Okf.Cli.Config: OkfConfig :: !KitSettings -> !AgentSettings -> !ProfileSettings -> OkfConfig
+ Okf.Cli.Config: OkfConfig :: !KitSettings -> !AgentSettings -> !ProfileSettings -> !Map Text Text -> OkfConfig
- Okf.Cli.Kit: KitInstall :: !Text -> !KitScope -> KitCommand
+ Okf.Cli.Kit: KitInstall :: !Maybe Text -> !KitScope -> !InstallOptions -> KitCommand
- Okf.Cli.Kit: KitList :: KitCommand
+ Okf.Cli.Kit: KitList :: !OutputFormat -> KitCommand
- Okf.Cli.Kit: KitStatus :: KitCommand
+ Okf.Cli.Kit: KitStatus :: !OutputFormat -> KitCommand
- Okf.Cli.Kit: KitUpdate :: !Maybe Text -> KitCommand
+ Okf.Cli.Kit: KitUpdate :: !Maybe Text -> !OverwritePolicy -> !OutputFormat -> KitCommand
Files
- CHANGELOG.md +67/−0
- help/agents.md +12/−0
- help/aliases.md +64/−0
- help/concepts.md +95/−1
- help/config.md +18/−5
- help/okf.md +3/−0
- help/profiles.md +24/−1
- help/where.md +200/−0
- okf-cli.cabal +70/−58
- src/Okf/Cli.hs +254/−40
- src/Okf/Cli/Aliases.hs +68/−0
- src/Okf/Cli/Assist.hs +21/−4
- src/Okf/Cli/Config.hs +76/−24
- src/Okf/Cli/Help.hs +8/−0
- src/Okf/Cli/Kit.hs +8/−52
- src/Okf/Cli/Kit/Config.hs +3/−6
- test/Main.hs +708/−17
CHANGELOG.md view
@@ -7,6 +7,73 @@ ## [Unreleased] +## [0.10.0.0] - 2026-10-06++### Added++- `okf kit list`, `status`, and `update` accept `--json`. Installation accepts+ `--shared`, `--tool-only`, and `--accept-shared-codex` for skill and subagent+ visibility. `okf assist --provider codex` enables OKF's tool-only skills for+ that session, including in `--print-command` output.++- User-defined command aliases in Dhall configuration, such as+ `aliases = toMap { c = "concepts" }`. Expansion applies to the first argument+ once, preserving trailing arguments and built-in commands. `okf alias` and+ `okf alias list` inspect the sorted map; `okf help aliases` explains precedence,+ whitespace splitting, and strict inspection. Older config files keep loading.++- `okf profile init [EXPORT] --bundle DIR [--write]` previews or adopts a profile with a verified descriptor, versioned indexes, Adoption log entry, and validation. Existing descriptors are refused; concept files are preserved.++- `okf concepts --where` accepts `KEY!=VALUE`, `KEY in ["A","B"]`,+ `KEY not in ["A","B"]`, and parenthesized expressions such as+ `(status in ["accepted","proposed"] and not (tags="archived"))`, alongside+ unchanged `KEY=VALUE`. Repeated legacy equalities on one key remain any-of;+ other conditions are all required. With `--profile`, every key and value in a+ condition, including excluded values and operands under `not` or `or`, is+ checked before the bundle is walked, with a neutral+ `filter value ... is outside the vocabulary for ...` diagnostic.++- `okf concepts --sort KEY[:desc]` orders the selected concepts, in text and+ JSON output, by one or more frontmatter keys: natural order for text, so+ `IR-2` precedes `IR-10`; numeric order for numbers; concepts without the key+ last in both directions; ties in concept-ID order. With `--profile`, an+ undeclared sort key is reported before the bundle is walked.++- `okf help where` is a reference topic for the `--where` condition language:+ how the form is chosen, the expression grammar and precedence, list and+ absence semantics, how repeated flags combine, and profile checking.++### Changed++- Requires `mori://shinzui/baikai/packages/baikai` 0.7.2.0,+ `mori://shinzui/baikai/packages/baikai-kit` 0.4.0.0, and+ `mori://shinzui/baikai/packages/baikai-claude` and+ `mori://shinzui/baikai/packages/baikai-openai` 0.7.1.0. Nix uses the matching+ kit release source revision.+- **Breaking (library):** `Okf.Cli.Kit.KitCommand` now re-exports the engine's+ command type: list/status/update carry `OutputFormat`, and install takes+ `Maybe Text` and `InstallOptions` in addition to scope.++- **Breaking (library):** `OkfConfig` gains `aliases :: Map Text Text`; callers+ constructing the record must supply it. On-disk Dhall configurations remain+ compatible through a frozen pre-alias decoder. `Command` gains `Alias`, and+ `Okf.Cli` exports `builtinCommands` from its parser registry.++- **Breaking (library):** the exported `ConceptsOptions.fieldFilters` field is+ now `[WhereCondition]` instead of `[ConceptFilter]`; wrap existing equalities+ in `LegacyWhere`. `Okf.Cli` additionally exports+ `conceptsProfileDiagnostics`, `renderFilterProfileError`, and+ `renderPredicateProfileError`.+- **Breaking (library):** `ConceptsOptions` gains a `sortKeys :: [SortKey]`+ field after `showFields`, and `conceptsProfileDiagnostics` takes a trailing+ `[SortKey]` argument.+- Requires `okf-core ^>=0.10.0.0`.+- `okf profile list` and `okf profile sources` report the built-in+ `mori://shinzui/okf-profiles` v0.19.0 catalogue (seventeen profiles, up from+ thirteen), including `assurance.verificationEvidence`,+ `coordination.patternApplications`, `documentation.specifications`, and+ `documentation.terminology`.+ ## [0.9.0.0] - 2026-09-13 ### Added
help/agents.md view
@@ -12,6 +12,13 @@ okf kit update [NAME] Refresh okf-kit and reinstall installed items. okf kit status Show what is installed and whether it is current. + list, status, and update accept --json for a versioned JSON document.+ Installation defaults to the manifest's visibility (tool-only if omitted).+ Use --shared to make an item available in every agent session, or --tool-only+ to restrict it to sessions OKF launches. Codex custom agents require shared+ visibility or --accept-shared-codex because Codex cannot isolate them.+ Install requires NAME; OKF does not configure an interactive kit chooser.+ ASSIST okf assist "PROMPT" Launch an interactive agent session with your@@ -39,6 +46,11 @@ '--effort minimal' reaches it as '--effort low', while Codex takes all six verbatim as '-c model_reasoning_effort=...'. Use --print-command to see what will run.++ Codex sessions enable OKF's tool-only skills through session configuration+ arguments. Ordinary Codex sessions keep those skills disabled. Existing+ installations keep their legacy placement; 'okf kit status' reports requested+ and effective visibility, including visibility-broken when repair is needed. CONFIGURATION
+ help/aliases.md view
@@ -0,0 +1,64 @@+COMMAND ALIASES++Give a command a shortcut in the same Dhall configuration used by okf config.+Aliases are case-sensitive and apply only to the first argument after okf.++CONFIGURE++ Run 'okf config init', then replace the empty aliases field with:++ , aliases = toMap { c = "concepts", h = "help" }++ Keep the other kit, agent, and profiles fields. An empty map is written as:++ , aliases = [] : List { mapKey : Text, mapValue : Text }++ Files from earlier okf versions still load unchanged with no aliases. To add+ shortcuts, use the current record shown by 'okf help config'. A name must be+ non-empty, contain no whitespace, and not begin with '-'. Expansion text must+ contain at least one word. A list with duplicate mapKey entries keeps the last+ mapValue, following Dhall's text-map decoder.++INVOKE AND INSPECT++ okf c BUNDLE --json Same as okf concepts BUNDLE --json.+ okf h aliases Same as okf help aliases.+ okf alias List configured aliases.+ okf alias list The same list, sorted by name and aligned.++ With no aliases, listing prints 'No aliases configured.'. Built-in names in+ the map appear in the list, but real commands always win when invoked.++EXPANSION RULES++ Only the first argument is looked up, exactly once. The expansion is split+ on whitespace, then the remaining arguments are appended unchanged. An alias+ a = "b" and another b = "help" makes 'okf a' try command b; it does not recurse.+ Unknown names reach the ordinary command parser.++ There is no shell evaluation, quote grouping, placeholder substitution, or+ execution of shell operators. Quotes in expansion text are literal characters.+ Use shell aliases when the expansion needs arguments containing spaces.+ Arguments you append at invocation keep the shell's existing grouping.++ Built-in commands (including alias and help), no arguments, and any first+ argument starting with '-' bypass alias configuration loading. This protects+ --help, --version, and the shell-completion protocol. Canonical commands+ complete normally; user-defined alias names are not added to completion.++SOURCE AND ERRORS++ The first existing file wins: OKF_CONFIG, ./okf-config.dhall,+ ~/.config/okf/config.dhall, then ~/.okf/config.dhall; otherwise defaults apply.+ The whole map comes from that file. An empty map suppresses lower-priority+ aliases. Only agent settings merge across scopes; alias maps do not merge.++ Possible alias invocations treat a configuration error as no aliases, so the+ ordinary unknown-command error is shown. There is no fallback to another file.+ 'okf alias list' and 'okf config show' load strictly and report configuration+ errors. Use them to diagnose an alias that stopped working. Help remains+ available even with broken configuration.++SEE ALSO++ okf help config Full configuration shape and precedence.
help/concepts.md view
@@ -25,6 +25,8 @@ --type TYPE Keep concepts whose type is exactly TYPE. --where KEY=VALUE Keep concepts whose frontmatter KEY holds VALUE.+ --where CONDITION Exclude values, match a set, or combine conditions; see+ EXCLUDING AND COMBINING below. --has KEY Keep concepts that carry KEY at all. --missing KEY Keep concepts that do not carry KEY. @@ -35,7 +37,8 @@ okf concepts BUNDLE --type Policy --where status=draft The first lists both kinds. The second lists the policies that are drafts.- --type is sugar for --where type=..., so it obeys the same rule.+ --type is sugar for --where type=..., so it obeys the same rule. The other+ --where conditions below combine differently. A filter key is either a top-level key (status) or one level of nesting (reviews.outcome, generated.by). One level is the limit, because one level is@@ -47,6 +50,49 @@ one level down: --where reviews.outcome=approved selects a concept whose second review was approved even though its first asked for changes. +EXCLUDING AND COMBINING++ --where also takes conditions that exclude values, name a set, or combine+ several questions. Quote them for the shell with single quotes:++ okf concepts BUNDLE --where 'status!=completed'+ okf concepts BUNDLE --where 'status in ["accepted","proposed"]'+ okf concepts BUNDLE --where 'status not in ["completed","rejected"]'+ okf concepts BUNDLE \+ --where '(status in ["accepted","proposed"] and not (tags="archived"))'++ KEY!=VALUE takes everything after "!=" verbatim, like KEY=VALUE. A set is a+ non-empty JSON array of strings. An argument starting with "(" is one+ parenthesized expression built from:++ KEY="VALUE" KEY!="VALUE" KEY in [...] KEY not in [...]+ has(KEY) missing(KEY) not C C and C C or C++ Inside parentheses every value is a JSON double-quoted string, so+ (status="accepted") compares with accepted, while outside them+ status="accepted" compares with a value that includes the quotes. not binds+ tighter than and, and and tighter than or; parentheses group. Operators are+ lowercase. Values are never coerced: (usage_count="12") matches a stored 12+ exactly as --where usage_count=12 does.++ SEPARATE FLAGS COMBINE DIFFERENTLY. Repeated KEY=VALUE flags on one key still+ mean "or". Every other --where condition must hold on its own, so two+ status!= flags exclude both values and two "in" flags keep only what both+ sets share. Spell a union as one larger set or with "or".++ An exclusion needs a value to judge. status!=completed and status not in+ [...] keep only concepts that actually store a status, and reject a list+ when ANY of its elements is excluded: tags!=cli drops a concept tagged+ [profiles, cli]. Write the absent case explicitly when you want it:++ okf concepts BUNDLE --where '(missing(status) or status!="completed")'++ "not" is different: it negates its whole operand, absence included, so+ '(not (status="completed"))' also keeps concepts with no status at all.++ "okf help where" is the full reference for these conditions, with more+ examples.+ SHOWING MORE COLUMNS --show KEY adds a column between the type and the title, and repeats:@@ -59,6 +105,41 @@ naming a whole mapping is that second case; --show generated.by is how you ask for what is inside it. + A profile-declared document ID is ordinary frontmatter, so --show prints it+ too, and --sort puts the rows in ID order. This lists the open improvement+ requests by ID:++ okf concepts BUNDLE --where 'status!=completed' --show requestId --sort requestId+ publish-rei-v1-integration-events Improvement Request IR-1 Publish rei.v1 ...+ first-class-unknowns Improvement Request IR-2 Model work-scoped ...+ ...++SORTING++ Rows are in concept-ID order unless you ask for another. --sort KEY orders+ them by a frontmatter key, and repeats: the first key decides, and each later+ one breaks the ties left by those before it. Append :desc to reverse one key+ (:asc, the default, is accepted too):++ okf concepts BUNDLE --sort priority:desc --sort requestId++ Text compares in natural order, so IR-2 comes before IR-10 and v0.9 before+ v0.13: runs of digits compare as numbers and everything else character by+ character, the same on every machine. A value stored as a YAML number+ compares numerically, so 1.5 comes before 10, and numbers come before text.++ A concept with no value for the key sorts after every concept that has one,+ in both directions, so --sort KEY:desc still leads with the concepts that say+ something. A list sorts by its smallest element, or its largest with :desc.+ Concepts equal on every key keep concept-ID order, so the listing is still+ the same on every run.++ --sort orders JSON output too, unlike --show. With --profile, a sort key the+ profile does not declare is reported before the bundle is walked, because a+ misspelled key would otherwise leave a listing in concept-ID order that looks+ entirely plausible. Any ':' in a --sort argument starts a direction, so a key+ containing a colon cannot be sorted on.+ TWO THINGS THAT SURPRISE PEOPLE A CONCEPT THAT OMITS A KEY NEVER MATCHES A VALUE FILTER ON IT, even where OKF@@ -89,6 +170,18 @@ subject is the command line you just typed rather than the bundle. An advisory would print a warning and then the empty listing that caused the confusion. + Every value and key in a --where condition is checked, including excluded+ values, set members, and operands under not or on either side of or, so a+ misspelled exclusion cannot silently exclude nothing:++ okf concepts BUNDLE --profile PROFILE --where 'status!=acepted'+ okf concepts: filter value acepted is outside the vocabulary for status+ status accepts: proposed, accepted, completed, rejected++ A type="..." equality inside an expression does not narrow which types' rules+ apply; only --type does. A condition that contradicts itself is not an+ error; it simply selects nothing.+ A --type value is checked against the profile's declared type names whenever the profile sets allowUnknownTypes = False, since that is how a profile spells its concept-type vocabulary. Everything else is checked against the allowed@@ -117,6 +210,7 @@ SEE ALSO + okf help where The full --where condition language reference. okf help format Bundle layout, concept IDs, and frontmatter. okf help profiles Checking a bundle against house conventions. okf help trust The report whose status column applies the default.
help/config.md view
@@ -1,8 +1,8 @@ CONFIG -okf config controls the optional agent-assistance features: where okf kit fetches-skills and subagents from, which providers to install for, and how okf assist-launches an interactive agent session.+okf config controls command aliases, profile registries, and optional agent+assistance: where okf kit fetches skills and subagents from, which providers to+install for, and how okf assist launches an interactive agent session. COMMANDS @@ -18,7 +18,7 @@ Two rules apply, because agent settings layer and nothing else does. - For kit.* and profiles.*, the first existing source wins and the others are+ For aliases, kit.*, and profiles.*, the first existing source wins and the others are never read: 1. OKF_CONFIG, when it points at an existing file@@ -59,6 +59,8 @@ FIELDS + aliases Text map of first-argument command shortcuts.+ See 'okf help aliases' for expansion rules. kit.repoUrl Git URL used by okf kit. kit.providers Providers to install kit items for. agent.provider Agent CLI okf assist launches. Defaults to@@ -83,7 +85,8 @@ A configuration file written for an earlier okf, with an 'assist' block instead of 'agent', still loads; its values are read as agent.assist.* . The older singular profiles.registry field also still loads and becomes a- one-element profiles.registries list.+ one-element profiles.registries list. Files without aliases receive an empty+ map. Empty maps suppress lower-priority aliases; maps are never scope-merged. EXAMPLE @@ -112,4 +115,14 @@ , profiles = { registries = [ "..." ] }+ , aliases = [] : List { mapKey : Text, mapValue : Text } }++ To enable shortcuts, replace the empty list with:++ , aliases = toMap { c = "concepts", h = "help" }++ Possible alias invocations ignore configuration errors and use the ordinary+ command parser. 'okf alias list' and 'okf config show' report errors strictly.+ Built-in commands and dash-prefixed startup arguments bypass alias loading.+ See 'okf help aliases' for duplicate handling and expansion boundaries.
help/okf.md view
@@ -60,6 +60,7 @@ profile List and inspect profiles from registries and local descriptors. profiles List local profile descriptor paths without opening a menu.+ alias List configured command shortcuts. config Show and manage okf configuration. kit Install and manage agent skills and subagents. assist Launch an interactive agent session with installed okf skills.@@ -87,8 +88,10 @@ okf help trust Trust tiers, staleness, and recorded provenance. okf help computations Listing and printing attested computations. okf help concepts Listing and filtering the concepts in a bundle.+ okf help where The --where condition language for okf concepts. okf help ids Profile-declared document IDs such as ADR-7. okf help interactive Picking a bundle and concept with fzf.+ okf help aliases Command shortcuts and expansion rules. okf help config Config files, precedence, and agent settings. okf help kit Installing and publishing agent skills and subagents. okf help agents Installing agent skills and launching assist.
help/profiles.md view
@@ -7,6 +7,7 @@ USAGE + okf profile init [EXPORT] --bundle DIR [--write] okf profiles okf profiles --json okf validate BUNDLE --profile PROFILE.dhall@@ -74,6 +75,28 @@ handle, `okf id list BUNDLE --profile PROFILE.dhall` to list allocations, and `okf show BUNDLE ADR-7` to resolve one. +ADOPTING A PROFILE++ okf profile init documentation.architectureDecisions --bundle docs/adr+ okf profile init documentation.architectureDecisions --bundle docs/adr --write++ Preview is the default. --write creates profile.dhall, regenerates every+ index.md with a sufficient root okf_version, appends an Adoption to log.md,+ and validates. Existing concepts are preserved; no examples are added.+ Repeat --registry and pass --no-local to choose sources just as with show.+ --date YYYY-MM-DD overrides today's UTC date and must be a valid date.++ Existing profile.dhall entries (including dangling symlinks) are refused.+ Remote imports without hashes are frozen; existing hashes are preserved.+ Local sources remain relative, live file imports. Init never upgrades a pin.+ Use the migration blueprints in mori://shinzui/okf-profiles for upgrades.++ Writes are not transactional. After a verified descriptor is written, keep+ it on failure: repair indexes with okf index --write --okf-version VERSION,+ inspect log.md before adding a missing Adoption, then rerun validation.+ Profile deviations are advisory; structural errors fail after files are+ written. The summary prints CI validation and convention-reading commands.+ LOCAL DISCOVERY `okf profiles` searches the current directory, four levels deep, for `.dhall`@@ -505,7 +528,7 @@ runs are offline. Pass --registry with a local checkout to be offline throughout. - The built-in pin currently targets v0.14.0 and publishes thirteen OKF 0.2 profiles+ The built-in pin currently targets v0.19.0 and publishes seventeen OKF 0.2 profiles with descriptions. `okf profile sources` reports that version without network access. Pass --check-latest to that command for an explicit upstream tag comparison; a failed optional check is reported but does not change the source
+ help/where.md view
@@ -0,0 +1,200 @@+THE --where CONDITION LANGUAGE++"okf concepts --where" asks a question of each concept's frontmatter and keeps+the concepts that answer yes. The simplest question is KEY=VALUE. The same flag+also excludes values, names a set, and combines questions with and, or, and+not. This topic is the full reference; "okf help concepts" covers the command+around it.++THREE FORMS, CHOSEN BY HOW THE ARGUMENT STARTS++ okf decides which grammar applies by looking at the beginning of the+ argument. It never tries one grammar and falls back to another.++ Starts with Read as Example+ ( one expression (status="draft" or has(owner))+ KEY!= standalone exclusion status!=completed+ KEY in standalone set status in ["accepted","proposed"]+ KEY not in standalone excluded set status not in ["completed"]+ anything else equality status=draft++ An equality's value is everything after the first '=', taken verbatim:+ title=research and development compares with "research and development",+ and a value may contain '=' or keep its whitespace. That is why the new forms+ are recognized only from their first characters. A KEY=VALUE argument means+ exactly what it always has.++ Once a new form is recognized, a mistake is an error that points at the+ character where reading stopped. It is never quietly reread as an equality:++ okf concepts BUNDLE --where 'status in [accepted]'+ option --where: expected a JSON double-quoted string as a set member at offset 11+ status in [accepted]+ ^++ Quote every new-form argument for the shell with single quotes, since '!',+ '(', '[', and '"' all mean something to it.++KEYS++ A key is a top-level frontmatter key (status) or one level of nesting+ (reviews.outcome, generated.by). Each part starts with a letter or underscore+ and continues with letters, digits, underscores, or hyphens. One level is the+ limit, because one level is what a profile can describe.++STANDALONE CONDITIONS++ KEY!=VALUE The key holds a value and it is not VALUE. VALUE is+ everything after "!=", verbatim, like KEY=VALUE.+ KEY in [...] The key holds one of the listed values.+ KEY not in [...] The key holds a value and none of them is listed.++ A set is a non-empty JSON array of double-quoted strings, and it must be the+ last thing in the argument:++ okf concepts BUNDLE --where 'status in ["accepted","proposed"]'++EXPRESSIONS++ An argument whose first non-space character is "(" is one parenthesized+ expression, and nothing may follow its closing parenthesis. Inside it:++ KEY="VALUE" the key holds VALUE+ KEY!="VALUE" the key holds a value and it is not VALUE+ KEY in [...] the key holds one of the listed values+ KEY not in [...] the key holds a value and none of them is listed+ has(KEY) the concept carries KEY at all+ missing(KEY) the concept does not carry KEY+ not C C does not hold+ C and C both hold+ C or C either holds+ (C) grouping++ not binds tighter than and, and and binds tighter than or, so++ (status="draft" or status="proposed" and not has(owner))++ reads as status="draft" or (status="proposed" and (not has(owner))). Use+ parentheses when you mean something else. Operators are lowercase.++ Every value inside an expression is a JSON double-quoted string, with JSON+ escapes. Outside parentheses quotes are part of the value:+ --where 'status="accepted"' compares with a value that has the quote marks in+ it, which is almost never what you want.++ Write one pair of parentheses around the whole condition.+ '(status="a") and (tags="b")' is rejected at offset 13, because the+ expression ended at the first closing parenthesis. Write+ '((status="a") and (tags="b"))' or '(status="a" and tags="b")'.++HOW A VALUE MATCHES++ Values are compared as text. A stored string, number, or boolean is a+ comparable value. Nothing is converted, so (usage_count="12") matches a+ stored 12 exactly as --where usage_count=12 does, and there is no "greater+ than".++ A LIST MATCHES IF ANY ELEMENT MATCHES. tags=cli and tags in ["cli","tui"]+ select a concept tagged [profiles, cli]. The same holds one level down for a+ list of records: reviews.outcome="approved" selects a concept with any+ approved review.++ AN EXCLUSION REJECTS THE LIST IF ANY ELEMENT IS EXCLUDED. tags!=cli and+ tags not in ["cli"] drop a concept tagged [profiles, cli]. Hiding cli means+ hiding every concept that mentions it.++ AN EXCLUSION NEEDS A VALUE TO JUDGE. status!=completed and status not in+ [...] keep only concepts that store at least one comparable value for status.+ A concept without the key, or with only null, an empty list, or records there,+ fails them exactly as it fails status=accepted. Absence never gets in through+ a value filter. Ask for it when you want it:++ okf concepts BUNDLE --where '(missing(status) or status!="completed")'++ not IS PLAIN NEGATION. It negates its whole operand, absence included, so++ okf concepts BUNDLE --where '(not (status="completed"))'++ also keeps concepts with no status at all. Choose between != and not by+ whether a concept that says nothing should pass.++ A CONCEPT THAT OMITS A KEY NEVER MATCHES A VALUE, even where OKF supplies a+ default. status="stable" selects concepts whose frontmatter says stable, not+ the ones that say nothing.++COMBINING SEVERAL --where FLAGS++ Repeated flags follow two rules, kept apart on purpose:++ Repeated KEY=VALUE equalities on the same key mean "or". --where+ status=accepted --where status=proposed selects both, as it always has.+ Equalities on different keys mean "and". --type obeys the same rule,+ because it is sugar for --where type=....++ Every other condition must hold on its own. Two status!= flags exclude both+ values. Two "in" flags keep only what both sets share. An expression and+ an equality must both hold.++ So to ask for a union of sets, write one larger set or use or. Inside an+ expression, and always means and, even on one key:+ (status="accepted" and status="proposed") selects nothing, because no+ concept's status is both. A condition that contradicts itself is not an+ error; it selects nothing.++CHECKING A CONDITION AGAINST A PROFILE++ With --profile, okf checks every key and value in the condition before it+ walks the bundle: equalities, excluded values, set members, operands under+ not, and both sides of or. A misspelled exclusion would otherwise exclude+ nothing and leave a listing that looks right:++ okf concepts BUNDLE --profile PROFILE --where 'status!=acepted'+ okf concepts: filter value acepted is outside the vocabulary for status+ status accepts: proposed, accepted, completed, rejected++ An undeclared key, a type outside a closed vocabulary, or a value that cannot+ occur is a hard error on stderr with exit 1. Only --type narrows which types'+ rules apply. A type="..." inside an expression does not narrow them.++WHAT IT IS NOT++ This is a filter, not a query language. There are no ordering comparisons,+ regular expressions, wildcards, arithmetic, or type conversion. For anything+ more, take the complete frontmatter as JSON and use jq:++ okf concepts BUNDLE --json | jq '.[] | select(.usage_count > 10)'++ The forms cost a sliver of legacy syntax. A key ending in "!", such as a!=x,+ or an argument with " in " or " not in " right after its key is now read as+ a new form.++EXAMPLES++ Open work, leaving out anything archived:++ okf concepts BUNDLE \+ --where '(status in ["accepted","proposed"] and not (tags="archived"))'++ Everything not completed, including concepts that never set a status:++ okf concepts BUNDLE --where '(missing(status) or status!="completed")'++ Policies that are drafts or have no owner yet:++ okf concepts BUNDLE --type Policy --where '(status="draft" or missing(owner))'++ Improvement requests not yet completed, with their IDs as a column, in ID+ order:++ okf concepts BUNDLE --where 'status!=completed' --show requestId --show status \+ --sort requestId++ Concepts whose provenance was not written by a given agent:++ okf concepts BUNDLE --where 'generated.by!=claude/sonnet-5'++SEE ALSO++ okf help concepts Listing concepts, columns, and JSON output.+ okf help profiles Profiles, vocabularies, and allowUnknownTypes.+ okf help format Frontmatter and the keys OKF owns.
okf-cli.cabal view
@@ -1,21 +1,20 @@-cabal-version: 3.4-name: okf-cli-version: 0.9.0.0-synopsis: Command-line interface for Open Knowledge Format bundles+cabal-version: 3.4+name: okf-cli+version: 0.10.0.0+synopsis: Command-line interface for Open Knowledge Format bundles description: okf-cli provides the @okf@ executable for working with Open Knowledge Format (OKF) bundles: validating bundles, inspecting their concept graph, and authoring documents from the command line, built on the okf-core library. -category: Data, CLI-license: BSD-3-Clause-license-file: LICENSE-author: Nadeem Bitar-maintainer: nadeem@gmail.com-copyright: (c) 2026 Nadeem Bitar-build-type: Simple-extra-doc-files: CHANGELOG.md-+category: Data, CLI+license: BSD-3-Clause+license-file: LICENSE+author: Nadeem Bitar+maintainer: nadeem@gmail.com+copyright: (c) 2026 Nadeem Bitar+build-type: Simple+extra-doc-files: CHANGELOG.md -- Help topic sources embedded at compile time by Okf.Cli.Help via -- file-embed's embedStringFile. They must ship in the sdist or the -- Hackage build fails resolving the Template Haskell splices.@@ -23,12 +22,18 @@ common common-options ghc-options:- -Wall -Wcompat -Widentities -Wincomplete-uni-patterns- -Wincomplete-record-updates -Wredundant-constraints- -fhide-source-paths -Wmissing-export-lists -Wpartial-fields+ -Wall+ -Wcompat+ -Widentities+ -Wincomplete-uni-patterns+ -Wincomplete-record-updates+ -Wredundant-constraints+ -fhide-source-paths+ -Wmissing-export-lists+ -Wpartial-fields -Wmissing-deriving-strategies - default-language: GHC2024+ default-language: GHC2024 default-extensions: DeriveAnyClass DuplicateRecordFields@@ -36,11 +41,12 @@ OverloadedStrings library- import: common-options- hs-source-dirs: src+ import: common-options+ hs-source-dirs: src exposed-modules: Okf.Cli Okf.Cli.Agent.Config+ Okf.Cli.Aliases Okf.Cli.Assist Okf.Cli.BundleDiscovery Okf.Cli.Completions@@ -53,52 +59,58 @@ Okf.Cli.ProfileDiscovery Okf.Cli.Version - other-modules: Paths_okf_cli+ other-modules: Paths_okf_cli autogen-modules: Paths_okf_cli build-depends:- , aeson >=2.2 && <2.4- , baikai ^>=0.5.0- , baikai-claude ^>=0.5.0- , baikai-kit ^>=0.1.0.4- , baikai-openai ^>=0.5.0- , base >=4.20 && <5- , bytestring >=0.11 && <0.13- , containers >=0.6 && <0.8- , dhall >=1.41 && <1.43- , directory >=1.3 && <1.4- , file-embed >=0.0.15 && <0.1- , filepath >=1.4 && <1.6- , generic-lens >=2.2 && <2.4- , githash ^>=0.1- , lens ^>=5.3- , okf-core ^>=0.9.0.0- , optparse-applicative >=0.18 && <0.20- , process >=1.6 && <1.7- , text ^>=2.1- , time >=1.12 && <1.15+ aeson >=2.2 && <2.4,+ baikai ^>=0.7.2.0,+ baikai-claude ^>=0.7.1.0,+ baikai-kit ^>=0.4.0.0,+ baikai-openai ^>=0.7.1.0,+ base >=4.20 && <5,+ bytestring >=0.11 && <0.13,+ containers >=0.6 && <0.8,+ dhall >=1.41 && <1.43,+ directory >=1.3 && <1.4,+ file-embed >=0.0.15 && <0.1,+ filepath >=1.4 && <1.6,+ generic-lens >=2.2 && <2.4,+ githash ^>=0.1,+ lens ^>=5.3,+ okf-core ^>=0.10.0.0,+ optparse-applicative >=0.18 && <0.20,+ process >=1.6 && <1.7,+ text ^>=2.1,+ time >=1.12 && <1.15, test-suite okf-cli-test- import: common-options- type: exitcode-stdio-1.0- main-is: Main.hs+ import: common-options+ type: exitcode-stdio-1.0+ main-is: Main.hs hs-source-dirs: test build-depends:- , aeson >=2.2 && <2.4- , base >=4.20 && <5- , directory- , filepath- , okf-cli- , okf-core ^>=0.9.0.0- , optparse-applicative >=0.18- , temporary- , text ^>=2.1- , time >=1.12 && <1.15+ aeson >=2.2 && <2.4,+ baikai-kit ^>=0.4.0.0,+ base >=4.20 && <5,+ containers >=0.6 && <0.8,+ directory,+ filepath,+ okf-cli,+ okf-core ^>=0.10.0.0,+ optparse-applicative >=0.18,+ temporary,+ text ^>=2.1,+ time >=1.12 && <1.15, executable okf- import: common-options- main-is: Main.hs+ import: common-options+ main-is: Main.hs hs-source-dirs: app- ghc-options: -threaded -rtsopts -with-rtsopts=-N+ ghc-options:+ -threaded+ -rtsopts+ -with-rtsopts=-N+ build-depends:- , base >=4.20 && <5- , okf-cli+ base >=4.20 && <5,+ okf-cli,
src/Okf/Cli.hs view
@@ -15,6 +15,7 @@ Options (..), ProfileCommand (..), ProfileDocumentOptions (..),+ ProfileInitOptions (..), ProfileListOptions (..), ProfileSourcesOptions (..), ProfileSourceResolution (..),@@ -34,8 +35,12 @@ computationReport, conceptReport, conceptReportJson,+ conceptsProfileDiagnostics,+ renderFilterProfileError,+ renderPredicateProfileError, observedIdPrefixes, parserInfo,+ builtinCommands, parseProfileRegistriesEnv, parseReleaseVersionTag, latestReleaseTag,@@ -91,11 +96,12 @@ renderAgentResolution, resolveAgent, )+import Okf.Cli.Aliases (AliasCommand (..), aliasCommandParser, expandAlias, isAliasCandidate, renderAliases) import Okf.Cli.Assist (AssistOptions, assistAgentOverrides, assistOptionsParser, handleAssistCommand) import Okf.Cli.BundleDiscovery (BundleDiscovery (..), discoverAvailableBundles) import Okf.Cli.Completions (CompletionsShell, completionsParser, handleCompletions) import Okf.Cli.Config-import Okf.Cli.Fzf (FzfConfig, detectFzfConfig)+import Okf.Cli.Fzf (FzfConfig, detectFzfConfig, shellQuote) import Okf.Cli.Fzf.Selector ( BundleSelection (..), ConceptOrder (..),@@ -172,6 +178,7 @@ validateProfileVersion, validateProfileWith, )+import Okf.Profile.Bootstrap import Okf.Profile.Discovery (loadProfileDescriptorWithoutNetwork) import Okf.Profile.Documentation ( DocumentationError (..),@@ -203,15 +210,22 @@ ( ConceptFilter (..), FieldSelector (..), FilterProfileError (..),+ SortKey (..),+ WhereCondition (..), checkFiltersAgainstProfile,+ checkPredicateAgainstProfile, conceptFieldValues,- filterConcepts,- parseFieldEquals,+ filterConceptsWhere, parseFieldSelector,+ parseSortKey,+ parseWhereCondition, renderFieldSelector, renderFilter, renderFilterParseError,+ renderSortKeyParseError,+ renderWhereParseError, scalarText,+ sortConcepts, ) import Okf.Trust ( Staleness (..),@@ -223,8 +237,8 @@ ) import Okf.Validation import Options.Applicative-import System.Directory (createDirectoryIfMissing, doesFileExist)-import System.Environment (lookupEnv)+import System.Directory (canonicalizePath, createDirectoryIfMissing, doesDirectoryExist, doesFileExist, doesPathExist, pathIsSymbolicLink, removeFile)+import System.Environment (getArgs, lookupEnv) import System.Exit (ExitCode (..), exitFailure, exitWith) import System.FilePath ((</>)) import System.FilePath qualified as FilePath@@ -245,6 +259,7 @@ | Computations ComputationsOptions | Concepts ConceptsOptions | Id IdOptions+ | Alias !AliasCommand | Config ConfigCommand | Profile ProfileCommand | Kit KitCommand@@ -336,10 +351,11 @@ data ConceptsOptions = ConceptsOptions { bundlePath :: !(Maybe FilePath), conceptTypes :: ![Text],- fieldFilters :: ![ConceptFilter],+ fieldFilters :: ![WhereCondition], presentFields :: ![FieldSelector], absentFields :: ![FieldSelector], showFields :: ![Text],+ sortKeys :: ![SortKey], profilePath :: !(Maybe FilePath), json :: !Bool }@@ -369,6 +385,7 @@ | ProfileSources ProfileSourcesOptions | ProfileShow ProfileShowOptions | ProfileDocument ProfileDocumentOptions+ | ProfileInit ProfileInitOptions deriving stock (Show, Eq) data ProfileListOptions = ProfileListOptions@@ -409,6 +426,16 @@ } deriving stock (Show, Eq) +data ProfileInitOptions = ProfileInitOptions+ { registryRefs :: ![Text],+ export :: !(Maybe Text),+ bundleDir :: !FilePath,+ noLocal :: !Bool,+ date :: !(Maybe Text),+ write :: !Bool+ }+ deriving stock (Show, Eq)+ -- | The winning precedence layer for a resolved profile source. This travels -- with the source so inspection never has to reconstruct resolution later. data ProfileSourceOrigin@@ -457,7 +484,10 @@ runCli :: IO () runCli = do- Options {cmd} <- execParser parserInfo+ rawArgs <- getArgs+ aliases <- if isAliasCandidate builtinCommands rawArgs then loadAliasesForExpansion else pure Map.empty+ let expandedArgs = expandAlias builtinCommands aliases rawArgs+ Options {cmd} <- handleParseResult (execParserPure defaultPrefs parserInfo expandedArgs) runCommand cmd parserInfo :: ParserInfo Options@@ -478,28 +508,35 @@ optionsParser :: Parser Options optionsParser = Options <$> commandParser +-- | One registry owns parser registration, help order, and alias protection.+commandDefinitions :: [(String, ParserInfo Command)]+commandDefinitions =+ [ ("bundles", info (Bundles <$> bundlesOptionsParser <**> helper) (progDesc "List discovered OKF bundles")),+ ("profiles", info (Profiles <$> profilesOptionsParser <**> helper) (progDesc "List local profile descriptor paths; use `okf profile list` for all effective sources")),+ ("validate", info (Validate <$> validateOptionsParser <**> helper) (progDesc "Validate an OKF bundle")),+ ("index", info (Index <$> indexOptionsParser <**> helper) (progDesc "Preview or write generated index.md files")),+ ("log", info (Log <$> logOptionsParser <**> helper) (progDesc "Preview and check log.md files")),+ ("graph", info (GraphCommand <$> graphOptionsParser <**> helper) (progDesc "Print a bundle graph")),+ ("show", info (ShowConcept <$> showOptionsParser <**> helper) (progDesc "Show one concept")),+ ("trust", info (Trust <$> trustOptionsParser <**> helper) (progDesc "Report trust tiers, status, and staleness for every concept")),+ ("sources", info (Sources <$> sourcesOptionsParser <**> helper) (progDesc "List the provenance recorded by each concept")),+ ("computations", info (Computations <$> computationsOptionsParser <**> helper) (progDesc "List the attested computations a bundle declares")),+ ("concepts", info (Concepts <$> conceptsOptionsParser <**> helper) (progDesc "List the concepts a bundle holds, with optional filters")),+ ("id", info (Id <$> idOptionsParser <**> helper) (progDesc "Allocate and list document IDs")),+ ("alias", info (Alias <$> aliasCommandParser <**> helper) (progDesc "List configured command aliases")),+ ("config", info (Config <$> configCommandParser <**> helper) (progDesc "Show and manage okf configuration")),+ ("profile", info (Profile <$> profileCommandParser <**> helper) (progDesc "List and inspect profiles from registries and local descriptors")),+ ("kit", info (Kit <$> kitCommandParser <**> helper) (progDesc "Install and manage agent skills and subagents")),+ ("assist", info (Assist <$> assistOptionsParser <**> helper) (progDesc "Launch an interactive agent session with installed okf skills")),+ ("completions", info (Completions <$> completionsParser <**> helper) (progDesc "Generate a shell completion script (bash, zsh, fish)")),+ ("help", info (Help <$> helpCommandParser <**> helper) (progDesc "Show conceptual help topics"))+ ]++builtinCommands :: [Text]+builtinCommands = map (Text.pack . fst) commandDefinitions+ commandParser :: Parser Command-commandParser =- hsubparser- ( command "bundles" (info (Bundles <$> bundlesOptionsParser <**> helper) (progDesc "List discovered OKF bundles"))- <> command "profiles" (info (Profiles <$> profilesOptionsParser <**> helper) (progDesc "List local profile descriptor paths; use `okf profile list` for all effective sources"))- <> command "validate" (info (Validate <$> validateOptionsParser <**> helper) (progDesc "Validate an OKF bundle"))- <> command "index" (info (Index <$> indexOptionsParser <**> helper) (progDesc "Preview or write generated index.md files"))- <> command "log" (info (Log <$> logOptionsParser <**> helper) (progDesc "Preview and check log.md files"))- <> command "graph" (info (GraphCommand <$> graphOptionsParser <**> helper) (progDesc "Print a bundle graph"))- <> command "show" (info (ShowConcept <$> showOptionsParser <**> helper) (progDesc "Show one concept"))- <> command "trust" (info (Trust <$> trustOptionsParser <**> helper) (progDesc "Report trust tiers, status, and staleness for every concept"))- <> command "sources" (info (Sources <$> sourcesOptionsParser <**> helper) (progDesc "List the provenance recorded by each concept"))- <> command "computations" (info (Computations <$> computationsOptionsParser <**> helper) (progDesc "List the attested computations a bundle declares"))- <> command "concepts" (info (Concepts <$> conceptsOptionsParser <**> helper) (progDesc "List the concepts a bundle holds, with optional filters"))- <> command "id" (info (Id <$> idOptionsParser <**> helper) (progDesc "Allocate and list document IDs"))- <> command "config" (info (Config <$> configCommandParser <**> helper) (progDesc "Show and manage okf configuration"))- <> command "profile" (info (Profile <$> profileCommandParser <**> helper) (progDesc "List and inspect profiles from registries and local descriptors"))- <> command "kit" (info (Kit <$> kitCommandParser <**> helper) (progDesc "Install and manage agent skills and subagents"))- <> command "assist" (info (Assist <$> assistOptionsParser <**> helper) (progDesc "Launch an interactive agent session with installed okf skills"))- <> command "completions" (info (Completions <$> completionsParser <**> helper) (progDesc "Generate a shell completion script (bash, zsh, fish)"))- <> command "help" (info (Help <$> helpCommandParser <**> helper) (progDesc "Show conceptual help topics"))- )+commandParser = hsubparser (foldMap (uncurry command) commandDefinitions) bundlesOptionsParser :: Parser BundlesOptions bundlesOptionsParser = BundlesOptions <$> jsonSwitch@@ -717,6 +754,12 @@ (progDesc "Print one registry profile in full") ) <> command+ "init"+ ( info+ (ProfileInit <$> profileInitOptionsParser <**> helper)+ (progDesc "Bootstrap a profile into a bundle: pinned descriptor, version declaration, and log")+ )+ <> command "document" ( info (ProfileDocument <$> profileDocumentOptionsParser <**> helper)@@ -755,6 +798,16 @@ <*> noLocalSwitch <*> jsonSwitch +profileInitOptionsParser :: Parser ProfileInitOptions+profileInitOptionsParser =+ ProfileInitOptions+ <$> many registryOption+ <*> optional (Text.pack <$> strArgument (metavar "EXPORT" <> help "Dotted export path from `okf profile list`"))+ <*> strOption (long "bundle" <> metavar "DIR" <> help "Bundle directory to bootstrap; created if missing")+ <*> noLocalSwitch+ <*> optional (Text.pack <$> strOption (long "date" <> metavar "YYYY-MM-DD" <> help "Adoption date; defaults to today (UTC)"))+ <*> switch (long "write" <> help "Write the descriptor, indexes, and adoption log instead of previewing")+ profileDocumentOptionsParser :: Parser ProfileDocumentOptions profileDocumentOptionsParser = ProfileDocumentOptions@@ -869,11 +922,11 @@ ) <*> many ( option- (eitherReader (first (Text.unpack . renderFilterParseError) . parseFieldEquals . Text.pack))+ (eitherReader (first (Text.unpack . renderWhereParseError) . parseWhereCondition . Text.pack)) ( long "where"- <> metavar "KEY=VALUE"+ <> metavar "CONDITION" <> help- "Keep concepts whose frontmatter KEY holds VALUE; KEY may be nested one level (reviews.outcome). Repeat the same key for any-of, different keys for all-of"+ "Keep concepts matching CONDITION: KEY=VALUE (repeat a key for any-of), KEY!=VALUE, KEY in [\"A\",\"B\"], KEY not in [\"A\",\"B\"], or a parenthesized expression such as '(status in [\"accepted\"] and not (tags=\"archived\"))' with and, or, not, has(KEY), missing(KEY). KEY may be nested one level (reviews.outcome). Other conditions given as separate flags must all hold" ) ) <*> many (option fieldSelectorReader (long "has" <> metavar "KEY" <> help "Keep concepts that carry KEY at all"))@@ -886,6 +939,14 @@ <> help "Add a column displaying KEY; repeat for more columns" ) )+ <*> many+ ( option+ (eitherReader (first (Text.unpack . renderSortKeyParseError) . parseSortKey . Text.pack))+ ( long "sort"+ <> metavar "KEY[:desc]"+ <> help "Order concepts by KEY in natural order (IR-2 before IR-10; numbers numerically; concepts without KEY last); append :desc to reverse; repeat to break ties"+ )+ ) <*> optional ( strOption ( long "profile"@@ -912,6 +973,9 @@ Computations options -> runComputations options Concepts options -> runConcepts options Id options -> runId options+ Alias AliasList -> do+ OkfConfig {aliases} <- loadConfigOrDie+ Text.IO.putStr (renderAliases aliases) Config configCommand -> runConfig configCommand Profile profileCommand -> runProfile profileCommand Kit kitCommand -> do@@ -1096,6 +1160,7 @@ ProfileSources options -> runProfileSources options ProfileShow options -> runProfileShow options ProfileDocument options -> runProfileDocument options+ ProfileInit options -> runProfileInit options -- | Decode the plural environment value. Aeson decodes directly to @[Text]@, -- so a non-array or non-string member is rejected rather than coerced. Blank@@ -1696,6 +1761,97 @@ -- @docs\/adr\/6-generated-profile-documentation.md@: the command overwrites -- exactly the files it generates, never deletes, and reports concepts already -- in the destination that this run did not generate.+-- | Preflight does not mutate the bundle. After verification, retain the+-- descriptor on later failure so recovery cannot silently move its pin.+runProfileInit :: ProfileInitOptions -> IO ()+runProfileInit ProfileInitOptions {registryRefs, export = requestedExport, bundleDir, noLocal, date, write} = do+ destination <- bootstrapPhase "preflight" "No bundle files were written." $ do+ exists <- bootstrapEntryExists bundleDir+ directory <- doesDirectoryExist bundleDir+ when (exists && not directory) $ dieText (Text.pack bundleDir <> " is not a directory")+ FilePath.normalise <$> canonicalizePath bundleDir+ let descriptor = destination </> descriptorFileName+ refuseBootstrapDescriptor descriptor+ resolved <- resolveEffectiveProfileSources registryRefs noLocal+ profiles <- loadProfileSourcesForNamedLookup resolved+ SourcedProfile {source, entry = RegistryEntry {export = selectedExport, spec}} <- selectEntry profiles requestedExport+ _ <- compileProfileOrExit (displayExport selectedExport) spec+ entryDate <- maybe todayDate pure date+ let dateCheck = Log.Log "Adoption" [Log.LogDay entryDate [Log.LogEntry (Just "Adoption") "Adopt profile"]]+ when (Log.LogDateNotIso entryDate `elem` Log.validateLog dateCheck) $+ dieText ("Invalid adoption date: " <> entryDate <> "; use a real calendar date in YYYY-MM-DD form.")+ contents <- renderBootstrapDescriptor destination (descriptorImportFor source selectedExport) >>= either (dieText . renderBootstrapError) pure+ existing <- bootstrapPhase "preflight" "No bundle files were written." $ do+ exists <- bootstrapEntryExists destination+ directory <- doesDirectoryExist destination+ when (exists && not directory) $ dieText (Text.pack destination <> " is not a directory")+ declaration <- readBundleVersion destination >>= either (dieText . renderBundleError) pure+ version <- case declaration of+ VersionDeclared declaredVersion -> pure (Just declaredVersion)+ VersionUndeclared -> pure Nothing+ VersionUnparseable raw -> dieText ("Unparseable okf_version " <> raw <> "; repair the root index.md declaration before bootstrapping.")+ when directory $ void (walkBundle destination >>= either (dieText . renderBundleError) pure)+ pure version+ let targetVersion = bootstrapOkfVersion existing spec+ versionText = maybe "(undeclared)" renderOkfVersion targetVersion+ oneLine = Text.unwords . Text.words+ message = "Adopt the `" <> oneLine (spec ^. #name) <> "` profile (`" <> oneLine (displayExport selectedExport) <> "`) from " <> oneLine (renderProfileSourceReference source) <> "."+ destinationArg = shellQuote (Text.pack destination)+ descriptorArg = shellQuote (Text.pack descriptor)+ declarationText = "okf_version \"" <> versionText <> "\""+ if not write+ then do+ renderIndexPreview (descriptor, contents)+ Text.IO.putStrLn ("index.md: " <> declarationText <> " (all generated index.md files in the bundle will be regenerated)")+ Text.IO.putStrLn ("log.md: ## " <> entryDate <> " / * **Adoption**: " <> message)+ Text.IO.putStrLn ("(preview only; pass --write to bootstrap " <> Text.pack destination <> ")")+ else do+ refuseBootstrapDescriptor descriptor+ bootstrapPhase "descriptor write" "The descriptor may be incomplete; indexes and log were not written." $ do+ createDirectoryIfMissing True destination+ Text.IO.writeFile descriptor contents+ loaded <- loadProfileFile descriptor+ case loaded of+ Right actual | actual == spec -> pure ()+ _ -> do+ bootstrapPhase "descriptor cleanup" "Descriptor verification failed; indexes and log were not written." (removeFile descriptor)+ dieText $ case loaded of+ Left _ -> "descriptor verification failed: the written descriptor could not be loaded; removed it. Indexes and log were not written."+ Right _ -> "descriptor verification failed: rendered descriptor differs from selected profile; removed it. Indexes and log were not written."+ Text.IO.putStrLn ("Wrote " <> Text.pack descriptor <> " (" <> displayExport selectedExport <> " from " <> renderProfileSourceLabel source <> ")")+ let indexRecovery = "The verified descriptor remains. Repair the problem, then run: okf index " <> destinationArg <> " --write --okf-version " <> versionText <> ". The adoption log was not written."+ bootstrapPhase "index generation" indexRecovery $ do+ indexResult <- writeBundleIndexesWith targetVersion destination+ either (\err -> dieText ("profile init: index generation failed: " <> renderBundleError err <> "\n" <> indexRecovery)) pure indexResult+ Text.IO.putStrLn ("Declared " <> declarationText <> " in " <> Text.pack (destination </> "index.md"))+ bootstrapPhase "log write" "The verified descriptor and indexes remain. Inspect log.md before adding the Adoption entry; do not append it twice." $+ runLogAdd destination (LogAddOptions Nothing "Adoption" message (Just entryDate))+ Text.IO.putStrLn "Bootstrap files have been written; validation follows."+ Text.IO.putStrLn ("Enforce in CI: okf validate " <> destinationArg <> " --strict --profile " <> descriptorArg <> " --profile-enforce --log-enforce")+ Text.IO.putStrLn ("Read the conventions: okf profile document --profile " <> descriptorArg)+ bootstrapPhase "validation" "Bootstrap files remain; repair the reported documents or log, then rerun validation." $+ runValidate (ValidateOptions (Just destination) False (Just descriptor) False False False)++bootstrapEntryExists :: FilePath -> IO Bool+bootstrapEntryExists path = do+ exists <- doesPathExist path+ if exists+ then pure True+ else do+ link <- try @IOException (pathIsSymbolicLink path)+ pure (either (const False) id link)++refuseBootstrapDescriptor :: FilePath -> IO ()+refuseBootstrapDescriptor descriptor = do+ occupied <- bootstrapEntryExists descriptor+ when occupied $+ dieText (Text.pack descriptor <> " already exists; this bundle has already adopted a profile. To move its pin, follow the okf-profiles migration blueprint (`seihou agent migrate`) or edit the tag and re-run `dhall freeze`.")++bootstrapPhase :: Text -> Text -> IO a -> IO a+bootstrapPhase phase recovery operation = do+ result <- try @IOException operation+ either (\err -> dieText ("profile init: " <> phase <> " failed: " <> Text.pack (show err) <> "\n" <> recovery)) pure result+ runProfileDocument :: ProfileDocumentOptions -> IO () runProfileDocument ProfileDocumentOptions@@ -2463,6 +2619,8 @@ -- In text mode, @--show@ adds frontmatter columns to the aligned report. In -- JSON mode, every selected row is instead the concept's complete stored -- frontmatter object; @--show@ does not project or otherwise change it.+-- @--sort@ orders the selected rows of both outputs; without it they are in+-- concept-ID order. runConcepts :: ConceptsOptions -> IO () runConcepts ConceptsOptions@@ -2472,13 +2630,14 @@ presentFields, absentFields, showFields,+ sortKeys, profilePath, json } = do resolvedBundle <- resolveBundlePath bundlePath traverse_ checkFiltersWithProfile profilePath concepts <- loadBundleOrExit resolvedBundle- let selected = filterConcepts allFilters concepts+ let selected = sortConcepts sortKeys (filterConceptsWhere optionFilters fieldFilters concepts) if json then LazyByteString.putStrLn (Aeson.encode (conceptReportJson selected)) else mapM_ Text.IO.putStrLn (conceptReport showFields selected)@@ -2487,12 +2646,11 @@ -- mechanism, so there is one matching path to reason about and to test, -- and so that @--type Policy --type Metric@ means "either" for free. typeFilters = [FieldEquals (TopLevelField "type") wanted | wanted <- conceptTypes]- -- Order does not affect the result -- 'filterConcepts' groups by selector- -- and by question -- but is kept stable so that profile diagnostics report- -- in a predictable order.- allFilters =+ -- The filters every flag but @--where@ contributes. Legacy @--where@+ -- equalities join them inside 'filterConceptsWhere', so @--type Note+ -- --where type=Policy@ is still one any-of group.+ optionFilters = typeFilters- <> fieldFilters <> (FieldPresent <$> presentFields) <> (FieldAbsent <$> absentFields) @@ -2505,13 +2663,54 @@ checkFiltersWithProfile path = do spec <- loadProfileOrExit path compiled <- compileProfileOrExit (Text.pack path) spec- case checkFiltersAgainstProfile compiled conceptTypes allFilters of+ case conceptsProfileDiagnostics compiled conceptTypes fieldFilters presentFields absentFields sortKeys of [] -> pure ()- profileErrors -> do+ diagnostics -> do -- Every error, not only the first, so one run fixes one command line.- mapM_ (Text.IO.hPutStrLn stderr . renderFilterProfileError) profileErrors+ mapM_ (Text.IO.hPutStrLn stderr) diagnostics exitWith (ExitFailure 1) +-- | Every profile diagnostic for an @okf concepts@ command line, in a stable+-- order: the legacy filters first — @--type@, legacy @--where@ equalities,+-- @--has@, @--missing@, as they always have been — then each explicit+-- condition in flag order, then each @--sort@ key. A predicate or sort-key+-- error repeated across flags is reported once.+--+-- A sort key is checked for declaration only, as @has(KEY)@ would be: there is+-- no value to check. It is worth checking at all because a misspelled sort key+-- leaves the listing in concept-ID order, which looks entirely plausible.+--+-- The two kinds render differently on purpose. A legacy equality outside a+-- vocabulary really can match nothing, and its message has always said so. A+-- predicate's out-of-vocabulary value may sit under @!=@ or @not@, where the+-- condition can still match plenty; it is still a typo worth stopping for, but+-- \"no concept can match\" would be false.+conceptsProfileDiagnostics ::+ CompiledProfile -> [Text] -> [WhereCondition] -> [FieldSelector] -> [FieldSelector] -> [SortKey] -> [Text]+conceptsProfileDiagnostics compiled conceptTypes conditions presentFields absentFields sortKeys =+ (renderFilterProfileError <$> legacyErrors)+ <> (renderPredicateProfileError <$> predicateErrors)+ <> (renderFilterProfileError <$> sortKeyErrors)+ where+ legacyFilters =+ [FieldEquals (TopLevelField "type") wanted | wanted <- conceptTypes]+ <> [conceptFilter | LegacyWhere conceptFilter <- conditions]+ <> (FieldPresent <$> presentFields)+ <> (FieldAbsent <$> absentFields)+ legacyErrors = checkFiltersAgainstProfile compiled conceptTypes legacyFilters+ predicateErrors =+ List.nub+ [ profileError+ | PredicateWhere predicate <- conditions,+ profileError <- checkPredicateAgainstProfile compiled conceptTypes predicate+ ]+ sortKeyErrors =+ List.nub+ [ profileError+ | SortKey {sortSelector} <- sortKeys,+ profileError <- checkFiltersAgainstProfile compiled conceptTypes [FieldPresent sortSelector]+ ]+ -- | Why a profile says a filter can never select anything. -- -- The message quotes the __filter__, @status=acepted@, and not the flag the user@@ -2525,6 +2724,21 @@ FilterValueNotInVocabulary selector wanted vocabulary -> "okf concepts: no concept can match " <> renderFilter (FieldEquals selector wanted)+ <> "\n"+ <> renderFieldSelector selector+ <> " accepts: "+ <> Text.intercalate ", " vocabulary++-- | Why a profile rejects an explicit @--where@ condition. Neutral about+-- matching, since the offending value may be one the condition excludes.+renderPredicateProfileError :: FilterProfileError -> Text+renderPredicateProfileError = \case+ profileError@(FilterFieldNotDeclared _) -> renderFilterProfileError profileError+ FilterValueNotInVocabulary selector outsideValue vocabulary ->+ "okf concepts: filter value "+ <> outsideValue+ <> " is outside the vocabulary for "+ <> renderFieldSelector selector <> "\n" <> renderFieldSelector selector <> " accepts: "
+ src/Okf/Cli/Aliases.hs view
@@ -0,0 +1,68 @@+-- | Pure command-alias validation and presentation, independent of config IO.+module Okf.Cli.Aliases+ ( validateAliases,+ renderAliases,+ isAliasCandidate,+ expandAlias,+ AliasCommand (..),+ aliasCommandParser,+ )+where++import Data.Char (isSpace)+import Data.Foldable (traverse_)+import Data.Map.Strict (Map)+import Data.Map.Strict qualified as Map+import Data.Text qualified as Text+import Okf.Prelude+import Options.Applicative++data AliasCommand = AliasList+ deriving stock (Show, Eq)++-- | Bare @alias@ defaults to listing; unknown subcommands remain parse errors.+aliasCommandParser :: Parser AliasCommand+aliasCommandParser =+ hsubparser (command "list" (info (pure AliasList <**> helper) (progDesc "List configured command aliases")))+ <|> pure AliasList++validateAliases :: Map Text Text -> Either Text ()+validateAliases = traverse_ validate . Map.toAscList+ where+ validate (name, expansion)+ | Text.null name = invalid "has empty name"+ | Text.any isSpace name = invalid "has whitespace in name"+ | Text.isPrefixOf "-" name = invalid "has dash-prefixed name"+ | null (Text.words expansion) = invalid "has empty expansion"+ | otherwise = Right ()+ where+ invalid problem = Left ("alias " <> Text.pack (show name) <> " " <> problem)++-- | Include a final newline so callers can print the result verbatim.+renderAliases :: Map Text Text -> Text+renderAliases aliases+ | Map.null aliases = "No aliases configured.\n"+ | otherwise =+ Text.unlines+ [ Text.justifyLeft width ' ' name <> " = " <> expansion+ | (name, expansion) <- Map.toAscList aliases+ ]+ where+ width = maximum (map Text.length (Map.keys aliases))++-- | Only the first argument can be an alias. Protected paths avoid config IO.+isAliasCandidate :: [Text] -> [String] -> Bool+isAliasCandidate _ [] = False+isAliasCandidate builtins (firstArg : _) =+ not (Text.isPrefixOf "-" name) && name `notElem` builtins+ where+ name = Text.pack firstArg++-- | Expand once using whitespace splitting, preserving every trailing argument.+-- Quotes are literal text; no shell or second alias lookup is involved.+expandAlias :: [Text] -> Map Text Text -> [String] -> [String]+expandAlias builtins aliases args@(firstArg : rest)+ | isAliasCandidate builtins args,+ Just expansion <- Map.lookup (Text.pack firstArg) aliases =+ map Text.unpack (Text.words expansion) <> rest+expandAlias _ _ args = args
src/Okf/Cli/Assist.hs view
@@ -12,16 +12,17 @@ assistAgentOverrides, handleAssistCommand, buildAgentCommand,+ buildSessionAgentCommand, ) where import Baikai.Agent (AgentRenderError, renderAgentRenderError) import Baikai.Interactive (InteractiveLaunchRequest, interactiveLaunchRequest)-import Baikai.Kit.Session (agentDirsForSession)+import Baikai.Kit.Session (agentDirsForSession, codexSessionArgs) import Baikai.Provider.Claude.Interactive (claudeInteractiveCommand, defaultClaudeInteractiveConfig) import Baikai.Provider.OpenAI.Interactive (codexInteractiveCommand, defaultCodexInteractiveConfig) import Control.Exception (IOException, try)-import Control.Lens ((&), (.~))+import Control.Lens ((%~), (&), (.~)) import Data.Bifunctor (first) import Data.Generics.Labels () import Data.Text (Text)@@ -171,11 +172,27 @@ resolvedProviderOf :: ResolvedAgent -> OkfProvider resolvedProviderOf ResolvedAgent {provider = ResolvedField {resolvedValue}} = resolvedValue +-- | Discover installed kit assets and enable this tool's hidden Codex skills+-- for the session. Both execution and --print-command use this same argv.+buildSessionAgentCommand ::+ OkfConfig -> ResolvedAgent -> AssistOptions -> IO (Either AgentRenderError (FilePath, [String]))+buildSessionAgentCommand config resolved options = do+ let launcher = launcherFor (resolvedProviderOf resolved)+ kit = kitConfig config+ agentDirs <- agentDirsForSession kit+ sessionArgs <- case resolvedProviderOf resolved of+ ProviderClaude -> pure []+ ProviderCodex -> codexSessionArgs kit+ let request =+ assistLaunchRequest launcher resolved agentDirs options+ & #extraArgs %~ (<> sessionArgs)+ pure (buildCommand launcher request)+ handleAssistCommand :: OkfConfig -> ResolvedAgent -> AssistOptions -> IO () handleAssistCommand config resolved options = do let launcher = launcherFor (resolvedProviderOf resolved)- agentDirs <- agentDirsForSession (kitConfig config)- case buildAgentCommand resolved agentDirs options of+ renderedCommand <- buildSessionAgentCommand config resolved options+ case renderedCommand of Left renderError -> do Text.IO.hPutStrLn stderr ("okf assist: " <> renderAgentRenderError renderError) exitWith (ExitFailure 2)
src/Okf/Cli/Config.hs view
@@ -26,6 +26,7 @@ emptyAgentFieldSettings, agentSharedDefaults, loadOkfConfig,+ loadAliasesForExpansion, loadAgentScopes, findConfigSource, findConfigScopes,@@ -41,10 +42,13 @@ ) where -import Control.Exception (SomeException, catch)+import Control.Exception (IOException, SomeException, catch)+import Data.Map.Strict (Map)+import Data.Map.Strict qualified as Map import Data.Text qualified as Text import Dhall (FromDhall (..), auto, genericAutoWith) import Dhall qualified+import Okf.Cli.Aliases (validateAliases) import Okf.Prelude import Okf.Profile.Registry (defaultRegistryReference) import System.Directory (doesFileExist, getCurrentDirectory, getHomeDirectory)@@ -186,6 +190,16 @@ data OkfConfig = OkfConfig { kit :: !KitSettings, agent :: !AgentSettings,+ profiles :: !ProfileSettings,+ aliases :: !(Map Text Text)+ }+ deriving stock (Generic, Eq, Show)+ deriving anyclass (FromDhall)++-- | Frozen pre-alias record, retaining the ordered profile registry list.+data ConfigShapeWithoutAliases = ConfigShapeWithoutAliases+ { kit :: !KitSettings,+ agent :: !AgentSettings, profiles :: !ProfileSettings } deriving stock (Generic, Eq, Show)@@ -252,7 +266,8 @@ providers = [ProviderClaude] }, agent = defaultAgentSettings,- profiles = defaultProfileSettings+ profiles = defaultProfileSettings,+ aliases = Map.empty } -- | The profile settings a config file that predates @profiles@ is given.@@ -332,6 +347,18 @@ Nothing -> pure (Right (defaultOkfConfig, configSource)) Just path -> fmap (,configSource) <$> decodeConfigFile path +-- | Startup-only best effort. Inspection and command handlers keep using the+-- strict loader; a broken selected file must never fall through to another file.+loadAliasesForExpansion :: IO (Map Text Text)+loadAliasesForExpansion =+ ( do+ result <- loadOkfConfig+ pure $ case result of+ Left _ -> Map.empty+ Right (OkfConfig {aliases}, _) -> aliases+ )+ `catch` \(_exception :: IOException) -> pure Map.empty+ -- | Load the @agent@ block from each scope. 'Nothing' for a scope means that -- scope has no configuration file, not that its file set nothing. loadAgentScopes :: IO (Either Text (Maybe AgentSettings, Maybe AgentSettings))@@ -367,23 +394,33 @@ -- be writing against. decodeConfigFile :: FilePath -> IO (Either Text OkfConfig) decodeConfigFile path = do- current <- tryDecode (Dhall.inputFile auto path)- case current of- Right config -> pure (Right (normalizeProfileConfig config))- Left currentError -> do- withLegacyProfiles <- tryDecode (Dhall.inputFile auto path)- case withLegacyProfiles of- Right shape -> pure (Right (fromShapeWithLegacyProfiles shape))- Left _withLegacyProfilesError -> do- withoutAgent <- tryDecode (Dhall.inputFile auto path)- case withoutAgent of- Right shape -> pure (Right (fromShapeWithoutAgent shape))- Left _withoutAgentError -> do- v020 <- tryDecode (Dhall.inputFile auto path)- pure $ case v020 of- Right shape -> Right (fromShapeV020 shape)- Left _v020Error -> Left currentError+ decoded <- decodeShapes+ pure $ do+ config <- decoded+ validateAliases (config ^. #aliases)+ pure (normalizeProfileConfig config) where+ decodeShapes = do+ current <- tryDecode (Dhall.inputFile auto path)+ case current of+ Right config -> pure (Right config)+ Left currentError -> do+ withoutAliases <- tryDecode (Dhall.inputFile auto path)+ case withoutAliases of+ Right shape -> pure (Right (fromShapeWithoutAliases shape))+ Left _ -> do+ withLegacyProfiles <- tryDecode (Dhall.inputFile auto path)+ case withLegacyProfiles of+ Right shape -> pure (Right (fromShapeWithLegacyProfiles shape))+ Left _ -> do+ withoutAgent <- tryDecode (Dhall.inputFile auto path)+ case withoutAgent of+ Right shape -> pure (Right (fromShapeWithoutAgent shape))+ Left _ -> do+ v020 <- tryDecode (Dhall.inputFile auto path)+ pure $ case v020 of+ Right shape -> Right (fromShapeV020 shape)+ Left _ -> Left currentError tryDecode :: IO a -> IO (Either Text a) tryDecode action = (Right <$> action)@@ -391,8 +428,8 @@ pure (Left (Text.pack (show exception))) normalizeProfileConfig :: OkfConfig -> OkfConfig-normalizeProfileConfig OkfConfig {kit, agent, profiles} =- OkfConfig {kit, agent, profiles = normalizeProfileSettings profiles}+normalizeProfileConfig OkfConfig {kit, agent, profiles, aliases} =+ OkfConfig {kit, agent, profiles = normalizeProfileSettings profiles, aliases} normalizeProfileSettings :: ProfileSettings -> ProfileSettings normalizeProfileSettings ProfileSettings {registries} =@@ -402,16 +439,21 @@ profileSettingsFromLegacy LegacyProfileSettings {registry} = normalizeProfileSettings (ProfileSettings {registries = [registry]}) +fromShapeWithoutAliases :: ConfigShapeWithoutAliases -> OkfConfig+fromShapeWithoutAliases ConfigShapeWithoutAliases {kit, agent, profiles} =+ OkfConfig {kit, agent, profiles, aliases = Map.empty}+ fromShapeWithLegacyProfiles :: ConfigShapeWithLegacyProfiles -> OkfConfig fromShapeWithLegacyProfiles ConfigShapeWithLegacyProfiles {kit, agent, profiles} =- OkfConfig {kit, agent, profiles = profileSettingsFromLegacy profiles}+ OkfConfig {kit, agent, profiles = profileSettingsFromLegacy profiles, aliases = Map.empty} fromShapeWithoutAgent :: ConfigShapeWithoutAgent -> OkfConfig fromShapeWithoutAgent ConfigShapeWithoutAgent {kit, assist, profiles} = OkfConfig { kit, agent = agentSettingsFromAssist assist,- profiles = profileSettingsFromLegacy profiles+ profiles = profileSettingsFromLegacy profiles,+ aliases = Map.empty } fromShapeV020 :: ConfigShapeV020 -> OkfConfig@@ -419,7 +461,8 @@ OkfConfig { kit, agent = agentSettingsFromAssist assist,- profiles = defaultProfileSettings+ profiles = defaultProfileSettings,+ aliases = Map.empty } -- | Carry a pre-@agent@ @assist@ block onto the per-command keys that replaced@@ -468,7 +511,8 @@ OkfConfig { kit = KitSettings {repoUrl, providers}, agent = agentSettings@AgentSettings {assist = agentAssist},- profiles = ProfileSettings {registries}+ profiles = ProfileSettings {registries},+ aliases } = Text.unlines ( [ "kit.repoUrl = " <> repoUrl,@@ -477,8 +521,14 @@ <> renderAgentFields "agent." (agentSharedDefaults agentSettings) <> renderAgentFields "agent.assist." agentAssist <> renderProfileRegistries registries+ <> renderConfigAliases aliases ) +renderConfigAliases :: Map Text Text -> [Text]+renderConfigAliases aliases+ | Map.null aliases = ["aliases = []"]+ | otherwise = ["aliases." <> name <> " = " <> expansion | (name, expansion) <- Map.toAscList aliases]+ renderProfileRegistries :: [Text] -> [Text] renderProfileRegistries [] = ["profiles.registries = []"] renderProfileRegistries registries = map ("profiles.registries = " <>) registries@@ -532,5 +582,7 @@ " [ \"" <> defaultRegistryReference <> "\"", " ]", " }",+ " -- Shortcuts: replace this list with toMap { c = \"concepts\", h = \"help\" }.",+ " , aliases = [] : List { mapKey : Text, mapValue : Text }", " }" ]
src/Okf/Cli/Help.hs view
@@ -51,12 +51,14 @@ HelpTopic "profiles" "Checking a bundle against house conventions" profilesTopicContent, HelpTopic "computations" "Listing and printing attested computations" computationsTopicContent, HelpTopic "concepts" "Listing and filtering the concepts in a bundle" conceptsTopicContent,+ HelpTopic "where" "The okf concepts --where condition language" whereTopicContent, HelpTopic "trust" "Trust tiers, staleness, and recorded provenance" trustTopicContent, HelpTopic "index" "Generated index.md files and the version declaration" indexTopicContent, HelpTopic "log" "log.md upkeep and the two staleness checks" logTopicContent, HelpTopic "graph" "The concept graph and what becomes an edge" graphTopicContent, HelpTopic "ids" "Profile-declared document IDs such as ADR-7" idsTopicContent, HelpTopic "interactive" "Picking a bundle and concept with fzf" interactiveTopicContent,+ HelpTopic "aliases" "Command shortcuts and expansion rules" aliasesTopicContent, HelpTopic "config" "Config files, defaults, and agent settings" configTopicContent, HelpTopic "kit" "Installing and publishing agent skills and subagents" kitTopicContent, HelpTopic "agents" "Installing agent skills and launching assist" agentsTopicContent@@ -83,6 +85,9 @@ conceptsTopicContent :: Text conceptsTopicContent = $(embedStringFile "help/concepts.md") +whereTopicContent :: Text+whereTopicContent = $(embedStringFile "help/where.md")+ trustTopicContent :: Text trustTopicContent = $(embedStringFile "help/trust.md") @@ -100,6 +105,9 @@ interactiveTopicContent :: Text interactiveTopicContent = $(embedStringFile "help/interactive.md")++aliasesTopicContent :: Text+aliasesTopicContent = $(embedStringFile "help/aliases.md") configTopicContent :: Text configTopicContent = $(embedStringFile "help/config.md")
src/Okf/Cli/Kit.hs view
@@ -1,68 +1,24 @@ -- | The @okf kit@ command group: install and manage agent skills/subagents. module Okf.Cli.Kit ( KitCommand (..),+ OutputFormat (..), kitCommandParser, handleKitCommand, ) where +import Baikai.Kit.Command (KitCommand (..), OutputFormat (..)) import Baikai.Kit.Command qualified as Engine-import Baikai.Kit.Config (KitScope (..))-import Data.Text (Text)-import Data.Text qualified as Text-import Okf.Cli.Config (OkfConfig)+import Okf.Cli.Config (OkfConfig, defaultOkfConfig) import Okf.Cli.Kit.Config (kitConfig) import Options.Applicative --- | okf-local mirror of the engine's kit command. This derives 'Eq' so okf's--- top-level command type can keep deriving 'Eq'.-data KitCommand- = KitList- | KitInstall !Text !KitScope- | KitUpdate !(Maybe Text)- | KitUninstall !Text !KitScope- | KitStatus- deriving stock (Show, Eq)-+-- | Use the engine's parser so JSON output and visibility flags stay aligned+-- with the installed engine. Configuration only supplies the tool name in help. kitCommandParser :: Parser KitCommand-kitCommandParser =- hsubparser- ( command "list" (info (pure KitList) (progDesc "List available skills and subagents"))- <> command "install" (info installParser (progDesc "Install a skill or subagent"))- <> command "update" (info updateParser (progDesc "Update installed skills and subagents"))- <> command "uninstall" (info uninstallParser (progDesc "Uninstall a skill or subagent"))- <> command "status" (info (pure KitStatus) (progDesc "Show installed skills and subagents"))- )- <|> pure KitList- where- installParser =- KitInstall- <$> textArgument (metavar "NAME" <> help "Name of the skill or subagent to install")- <*> scopeParser "Install to project scope (.okf/agents) instead of user scope"-- updateParser =- KitUpdate- <$> optional (textArgument (metavar "NAME" <> help "Name of a specific item to update (default: all)"))-- uninstallParser =- KitUninstall- <$> textArgument (metavar "NAME" <> help "Name of the skill or subagent to uninstall")- <*> scopeParser "Uninstall from project scope (.okf/agents) instead of user scope"-- scopeParser helpText = flag UserScope ProjectScope (long "project" <> help helpText)-- textArgument modifiers = Text.pack <$> strArgument modifiers+kitCommandParser = Engine.kitCommandParser (kitConfig defaultOkfConfig) --- | Translate the parsed okf command into the engine command and run it against--- the kit configuration derived from the loaded okf config.+-- | Run against the kit configuration derived from the loaded okf config. handleKitCommand :: OkfConfig -> KitCommand -> IO () handleKitCommand config kitCommand =- Engine.runKit (kitConfig config) (toEngineCommand kitCommand)--toEngineCommand :: KitCommand -> Engine.KitCommand-toEngineCommand = \case- KitList -> Engine.KitList- KitInstall name scope -> Engine.KitInstall name scope- KitUpdate name -> Engine.KitUpdate name- KitUninstall name scope -> Engine.KitUninstall name scope- KitStatus -> Engine.KitStatus+ Engine.runKit (kitConfig config) kitCommand
src/Okf/Cli/Kit/Config.hs view
@@ -5,7 +5,8 @@ where import Baikai.Interactive (InteractiveProvider (..))-import Baikai.Kit.Config (KitConfig (..))+import Baikai.Kit.Config (KitConfig)+import Baikai.Kit.Config qualified as Engine import Okf.Cli.Config (KitSettings (..), OkfConfig (..), OkfProvider (..)) -- | Build the baikai-kit configuration from okf's loaded configuration. The@@ -13,11 +14,7 @@ -- sidecar files. kitConfig :: OkfConfig -> KitConfig kitConfig OkfConfig {kit = KitSettings {repoUrl = url, providers = providerList}} =- KitConfig- { toolName = "okf",- repoUrl = url,- providers = map toInteractive providerList- }+ Engine.kitConfig "okf" url (map toInteractive providerList) toInteractive :: OkfProvider -> InteractiveProvider toInteractive = \case
test/Main.hs view
@@ -1,12 +1,18 @@ module Main (main) where +import Baikai.Kit.Config (KitScope (..))+import Baikai.Kit.Install (InstallOptions (..), OverwritePolicy (..), defaultInstallOptions)+import Baikai.Kit.Visibility (KitVisibility (..)) import Control.Exception (bracket, try) import Control.Monad (unless) import Data.Aeson (Value (..), toJSON) import Data.Aeson qualified as Aeson-import Data.Foldable (traverse_)+import Data.Aeson.KeyMap qualified as KeyMap+import Data.Bifunctor (first)+import Data.Foldable (toList, traverse_) import Data.List qualified as List import Data.List.NonEmpty (NonEmpty (..))+import Data.Map.Strict qualified as Map import Data.Text qualified as Text import Data.Text.IO qualified as Text.IO import Data.Time.Calendar (fromGregorian)@@ -14,25 +20,27 @@ import Okf.Bundle (Concept, bundleInventoryOfConcepts, conceptAttester, conceptExecutor, conceptFromDocument, conceptIdOf, conceptParameters, conceptRuntime, conceptType, walkBundle, walkBundleInventory) import Okf.Cli import Okf.Cli.Agent.Config (AgentCommandName (..), AgentConfigSource (..), AgentField (..), AgentOverrides (..), ResolvedAgent (..), ResolvedField (..), agentSourceLabel, noAgentOverrides, parseOkfEffort, parseOkfProvider, renderAgentResolution, resolveAgent)-import Okf.Cli.Assist (AssistOptions (..), buildAgentCommand)+import Okf.Cli.Aliases (AliasCommand (..), expandAlias, isAliasCandidate, renderAliases, validateAliases)+import Okf.Cli.Assist (AssistOptions (..), buildAgentCommand, buildSessionAgentCommand) import Okf.Cli.BundleDiscovery (BundleDiscovery (..), bundleSearchRootsEnvVar, discoverAvailableBundles)-import Okf.Cli.Config (AgentFieldSettings (..), AgentSettings (..), ConfigSource (..), OkfConfig (..), OkfEffort (..), OkfProvider (..), ProfileSettings (..), agentSharedDefaults, defaultOkfConfig, exampleConfigText, findConfigSource, loadAgentScopes, loadOkfConfig, okfConfigEnvVar, projectConfigPath)+import Okf.Cli.Config (AgentFieldSettings (..), AgentSettings (..), ConfigSource (..), KitSettings (..), OkfConfig (..), OkfEffort (..), OkfProvider (..), ProfileSettings (..), agentSharedDefaults, defaultOkfConfig, exampleConfigText, findConfigSource, loadAgentScopes, loadAliasesForExpansion, loadOkfConfig, okfConfigEnvVar, projectConfigPath, renderConfig) import Okf.Cli.Fzf (Candidate (..), FzfConfig (..), FzfOpts (..), optsToArgs, parseSelectionIndex, renderCandidateLines, shellQuote, withAnsi, withHeight, withNoSort, withPrompt) import Okf.Cli.Fzf.Selector (ConceptOrder (..), conceptCandidates, conceptPreviewCommand, orderConcepts, parseBundleSearchRoots, profileCandidates, profilePreviewCommand) import Okf.Cli.Help (HelpTopic (..), helpTopics)+import Okf.Cli.Kit (KitCommand (..), OutputFormat (..)) import Okf.Cli.ProfileDiscovery (ProfileDiscovery (..), discoverAvailableProfiles, parseProfileSearchRoots, profileSearchRootsEnvVar) import Okf.ConceptId (ConceptId, parseConceptId, renderConceptId) import Okf.Document (Attester (..), Executor (..), Parameter (..), parseDocument) import Okf.Index (OkfVersion (..), VersionDeclaration (..), parseOkfVersion, readBundleVersion) import Okf.Profile (Cardinality (..), CompiledProfile, FieldCondition (..), FieldFormat (..), FieldPath (..), FieldPathSegment (..), FieldRule (..), FrontmatterRules (..), HandleReferenceRule (..), NestedFieldRule (..), NestedRules (..), PathReferenceRule (..), ProfileSpec (..), ProfileViolation (..), TypeRule (..), compileProfile, loadProfileFile, validateProfile, validateProfileVersion) import Okf.Profile.Registry (ProfileSource (..), ProfileSourceLoadError (..), RegistryEntry (..), RegistryLoadError (..), RegistryRef (..), SourcedProfile (..), defaultRegistryReference)-import Okf.Query (ConceptFilter (..), FieldSelector (..), filterConcepts)+import Okf.Query (ConceptFilter (..), ConceptPredicate (..), FieldSelector (..), SortDirection (..), SortKey (..), WhereCondition (..), filterConcepts, filterConceptsWhere, parseSortKey, parseWhereCondition, sortConcepts) import Okf.Validation (ValidationProfile (..), validateBundle) import Options.Applicative-import System.Directory (Permissions (..), createDirectoryIfMissing, doesDirectoryExist, doesFileExist, getCurrentDirectory, getPermissions, getTemporaryDirectory, listDirectory, makeAbsolute, removeDirectoryRecursive, setModificationTime, setPermissions, withCurrentDirectory)-import System.Environment (lookupEnv, setEnv, unsetEnv)+import System.Directory (Permissions (..), canonicalizePath, createDirectoryIfMissing, createFileLink, doesDirectoryExist, doesFileExist, getCurrentDirectory, getPermissions, getTemporaryDirectory, listDirectory, makeAbsolute, removeDirectoryRecursive, setModificationTime, setPermissions, withCurrentDirectory)+import System.Environment (lookupEnv, setEnv, unsetEnv, withArgs) import System.Exit (ExitCode (..), exitFailure)-import System.FilePath (normalise, (</>))+import System.FilePath (normalise, takeDirectory, (</>)) import System.IO.Temp (createTempDirectory) main :: IO ()@@ -42,6 +50,8 @@ profilePickerExitCodes <- testProfilePickerExitCodes effectiveProfileSources <- testEffectiveProfileSources logAddWrites <- testLogAddWritesFile+ aliasStartup <- testAliasStartup+ aliasConfiguration <- testAliasConfiguration configDefaults <- testConfigDefaults configProjectPrecedence <- testConfigProjectPrecedence configEnvPrecedence <- testConfigEnvPrecedence@@ -58,6 +68,8 @@ assistCommandBuilder <- testAssistCommandBuilder assistModelOverride <- testAssistModelOverride assistCodexCommandBuilder <- testAssistCodexCommandBuilder+ assistKitVisibility <- testAssistKitVisibility+ profileBootstrap <- testProfileBootstrap profileDocumentWrites <- testProfileDocumentWritesBundle profileDocumentDeclaresVersion <- testProfileDocumentDeclaresOkfVersion profileDocMatchesExample <- testProfileDocumentationMatchesCommittedExample@@ -71,6 +83,12 @@ conceptsReportsFixtures <- testConceptsReportsFixtureBundle conceptsShowsFilteredColumns <- testConceptsShowsFilteredColumns conceptsReportJson <- testConceptReportJson+ conceptsWhereConditions <- testConceptsWhereConditions+ conceptsWhereJson <- testConceptsWhereJson+ conceptsPredicateDiagnostics <- testConceptsPredicateDiagnostics+ conceptsSortsRows <- testConceptsSortsRows+ conceptsSortsJson <- testConceptsSortsJson+ conceptsSortKeyDiagnostics <- testConceptsSortKeyDiagnostics conceptsReportsExample <- testConceptsReportsExampleBundle conceptsKeepsStatusDefaultOut <- testConceptsDoesNotApplyStatusDefault profileDocStrictWithTimestamp <- testProfileDocumentationStrictWithTimestamp@@ -133,7 +151,7 @@ (Computations (ComputationsOptions Nothing)), parseCommandMatches ["concepts"]- (Concepts (ConceptsOptions Nothing [] [] [] [] [] Nothing False)),+ (Concepts (ConceptsOptions Nothing [] [] [] [] [] [] Nothing False)), parseIdMatches ["id", "next", "ADR", "--profile", "p.dhall"] (IdOptions Nothing "p.dhall" (IdNext "ADR")),@@ -321,6 +339,7 @@ parseSucceeds ["kit", "install", "demo-skill", "--project"], parseSucceeds ["kit", "update"], parseSucceeds ["kit", "update", "demo-skill"],+ parseSucceeds ["kit", "update", "demo-skill", "--force"], parseSucceeds ["kit", "uninstall", "demo-skill"], parseSucceeds ["kit", "uninstall", "demo-skill", "--project"], parseSucceeds ["kit", "status"],@@ -333,9 +352,11 @@ parseSucceeds ["help", "format"], parseSucceeds ["help", "bundles"], parseSucceeds ["help", "concepts"],+ parseSucceeds ["help", "where"], any ((== "okf") . topicName) helpTopics, any ((== "bundles") . topicName) helpTopics, any ((== "concepts") . topicName) helpTopics,+ any ((== "where") . topicName) helpTopics, all (not . Text.null . topicContent) helpTopics, optsToArgs (withPrompt "bundle> " <> withHeight "40%" <> withNoSort) == ["--prompt", "bundle> ", "--height", "40%", "--no-sort"],@@ -365,6 +386,10 @@ sampleConceptDisplays == ["tables/orders\tTable\tOrders", "x \t \t"], parseSucceeds ["profile"],+ parseSucceeds ["profile", "init", "--bundle", "d"],+ parseSucceeds ["profile", "init", "postgresql", "--bundle", "d", "--write"],+ parseSucceeds ["profile", "init", "--registry", "./r", "x", "--bundle", "d", "--date", "2026-09-19"],+ parseFails ["profile", "init"], parseSucceeds ["profile", "list"], parseSucceeds ["profile", "list", "--json"], parseSucceeds ["profile", "list", "--registry", "./r.dhall"],@@ -497,6 +522,7 @@ presentFields = [], absentFields = [], showFields = [],+ sortKeys = [], profilePath = Nothing, json = False },@@ -509,6 +535,7 @@ presentFields = [], absentFields = [], showFields = [],+ sortKeys = [], profilePath = Nothing, json = True },@@ -517,10 +544,11 @@ ConceptsOptions { bundlePath = Just "b", conceptTypes = ["Policy"],- fieldFilters = [FieldEquals (TopLevelField "status") "accepted"],+ fieldFilters = [LegacyWhere (FieldEquals (TopLevelField "status") "accepted")], presentFields = [], absentFields = [], showFields = ["requestId"],+ sortKeys = [], profilePath = Nothing, json = False },@@ -533,6 +561,7 @@ presentFields = [TopLevelField "completedAt"], absentFields = [NestedField "reviews" "outcome"], showFields = [],+ sortKeys = [], profilePath = Nothing, json = False },@@ -540,7 +569,74 @@ -- a key nesting deeper than one level. parseFails ["concepts", "b", "--where", "status"], parseFails ["concepts", "b", "--where", "a.b.c=x"],+ -- Inclusion, exclusion, and grouped expressions, each kept in flag+ -- order beside a legacy equality.+ parseConceptsMatches+ [ "concepts",+ "b",+ "--where",+ "status=accepted",+ "--where",+ "status!=completed",+ "--where",+ "status in [\"accepted\",\"proposed\"]",+ "--where",+ "status not in [\"completed\",\"rejected\"]",+ "--where",+ "(missing(status) or not (tags=\"archived\"))"+ ]+ ConceptsOptions+ { bundlePath = Just "b",+ conceptTypes = [],+ fieldFilters =+ [ LegacyWhere (FieldEquals (TopLevelField "status") "accepted"),+ PredicateWhere (PredicateNotEquals (TopLevelField "status") "completed"),+ PredicateWhere (PredicateIn (TopLevelField "status") ("accepted" :| ["proposed"])),+ PredicateWhere (PredicateNotIn (TopLevelField "status") ("completed" :| ["rejected"])),+ PredicateWhere+ ( PredicateOr+ (PredicateAtom (FieldAbsent (TopLevelField "status")))+ (PredicateNot (PredicateAtom (FieldEquals (TopLevelField "tags") "archived")))+ )+ ],+ presentFields = [],+ absentFields = [],+ showFields = [],+ sortKeys = [],+ profilePath = Nothing,+ json = False+ },+ -- Malformed extended input is rejected rather than read as equality.+ parseFails ["concepts", "b", "--where", "status in []"],+ parseFails ["concepts", "b", "--where", "status in [\"a\""],+ parseFails ["concepts", "b", "--where", "status not in accepted"],+ parseFails ["concepts", "b", "--where", "(status=\"accepted\" and)"],+ parseFails ["concepts", "b", "--where", "(status=accepted)"],+ parseFails ["concepts", "b", "--where", "(a=\"1\") or (b=\"2\")"], parseFails ["concepts", "b", "--has", "a.b.c"],+ -- Sort keys keep flag order and their own direction; anything but+ -- asc or desc after a colon is rejected rather than read as a key.+ parseConceptsMatches+ ["concepts", "b", "--sort", "priority:desc", "--sort", "requestId", "--sort", "title:asc"]+ ConceptsOptions+ { bundlePath = Just "b",+ conceptTypes = [],+ fieldFilters = [],+ presentFields = [],+ absentFields = [],+ showFields = [],+ sortKeys =+ [ SortKey (TopLevelField "priority") Descending,+ SortKey (TopLevelField "requestId") Ascending,+ SortKey (TopLevelField "title") Ascending+ ],+ profilePath = Nothing,+ json = False+ },+ parseSucceeds ["concepts", "b", "--sort", "reviews.outcome:desc"],+ parseFails ["concepts", "b", "--sort", "status:up"],+ parseFails ["concepts", "b", "--sort", "a.b.c"],+ parseFails ["concepts", "b", "--sort", ":desc"], renderRegistryTable CompactTable sampleRegistryEntries == sampleRegistryTable, renderRegistryTable WideTable sampleRegistryEntries == sampleRegistryWideTable, testRegistryTableCapsEveryTextField,@@ -562,6 +658,7 @@ profileDiscoveryListing, profilePickerExitCodes, effectiveProfileSources,+ profileBootstrap, profileDocumentWrites, profileDocumentDeclaresVersion, profileDocMatchesExample,@@ -574,6 +671,12 @@ computationsReportsExample, conceptsReportsFixtures, conceptsShowsFilteredColumns,+ conceptsWhereConditions,+ conceptsWhereJson,+ conceptsPredicateDiagnostics,+ conceptsSortsRows,+ conceptsSortsJson,+ conceptsSortKeyDiagnostics, conceptsReportJson, conceptsReportsExample, conceptsKeepsStatusDefaultOut,@@ -581,6 +684,15 @@ conceptMenuOrdering, nonAsciiDiagnostics, testNestedReferenceDiagnosticRendering,+ parseCommandMatches ["alias"] (Alias AliasList),+ parseCommandMatches ["alias", "list"] (Alias AliasList),+ parseFails ["alias", "unknown"],+ testBuiltinHelp,+ any (\topic -> topicName topic == "aliases" && "toMap" `Text.isInfixOf` topicContent topic) helpTopics,+ testAliasExpansion,+ aliasStartup,+ testAliasValidationAndRendering,+ aliasConfiguration, configDefaults, configProjectPrecedence, configEnvPrecedence,@@ -597,6 +709,15 @@ assistCommandBuilder, assistModelOverride, assistCodexCommandBuilder,+ assistKitVisibility,+ parseCommandMatches ["kit"] (Kit (KitList HumanOutput)),+ parseCommandMatches ["kit", "list", "--json"] (Kit (KitList JsonOutput)),+ parseCommandMatches ["kit", "status", "--json"] (Kit (KitStatus JsonOutput)),+ parseCommandMatches ["kit", "update", "author", "--force", "--json"] (Kit (KitUpdate (Just "author") OverwriteLocalEdits JsonOutput)),+ parseCommandMatches ["kit", "install", "author"] (Kit (KitInstall (Just "author") UserScope defaultInstallOptions)),+ parseCommandMatches ["kit", "install", "author", "--project", "--shared"] (Kit (KitInstall (Just "author") ProjectScope (InstallOptions (Just SharedVisibility) False))),+ parseCommandMatches ["kit", "install", "author", "--tool-only", "--accept-shared-codex"] (Kit (KitInstall (Just "author") UserScope (InstallOptions (Just ToolOnlyVisibility) True))),+ parseCommandMatches ["kit", "uninstall", "author", "--project"] (Kit (KitUninstall "author" ProjectScope)), testAssistEffortReachesEachVendor, testAssistUnconfiguredRendersNoFlags, testAgentFlagBeatsEverything,@@ -968,18 +1089,18 @@ let rendered = renderProfileSourceResolution sampleProfileSourceResolutions FreshnessNotChecked in all (`Text.isInfixOf` rendered)- [ "okf-profiles v0.14.0 (pinned)",+ [ "okf-profiles v0.19.0 (pinned)", "[built-in default]", "[env: OKF_PROFILE_REGISTRIES]", "failed (import-failure)",- "Pinned catalogue: okf-profiles v0.14.0",+ "Pinned catalogue: okf-profiles v0.19.0", "1. --registry flag (repeatable)", "lookup fails closed." ] testProfileSourcesJsonShape :: Bool testProfileSourcesJsonShape =- profileSourcesJson sampleProfileSourceResolutions (FreshnessOutdated "v0.14.0" "v0.15.0")+ profileSourcesJson sampleProfileSourceResolutions (FreshnessOutdated "v0.19.0" "v0.20.0") == Aeson.object [ "sources" Aeson..= [ Aeson.object@@ -1009,13 +1130,13 @@ ] ] ],- "pinnedVersion" Aeson..= Just ("v0.14.0" :: Text.Text),+ "pinnedVersion" Aeson..= Just ("v0.19.0" :: Text.Text), "freshness" Aeson..= Aeson.object [ "status" Aeson..= ("outdated" :: Text.Text),- "pinnedVersion" Aeson..= ("v0.14.0" :: Text.Text),- "latestVersion" Aeson..= ("v0.15.0" :: Text.Text),- "refreshCommand" Aeson..= ("scripts/refresh-default-registry.sh v0.15.0" :: Text.Text)+ "pinnedVersion" Aeson..= ("v0.19.0" :: Text.Text),+ "latestVersion" Aeson..= ("v0.20.0" :: Text.Text),+ "refreshCommand" Aeson..= ("scripts/refresh-default-registry.sh v0.20.0" :: Text.Text) ], "precedence" Aeson..= profileSourcePrecedenceFixture ]@@ -1040,7 +1161,7 @@ && parseReleaseVersionTag "v0.9.3" == Just (ReleaseVersion 0 9 3) && parseReleaseVersionTag "0.10.0" == Nothing && parseReleaseVersionTag "v0.10" == Nothing- && pinnedRegistryTag defaultRegistryReference == Just "v0.14.0"+ && pinnedRegistryTag defaultRegistryReference == Just "v0.19.0" && latestReleaseTag "aaa\trefs/tags/v0.9.3\nbbb\trefs/tags/not-a-version\nccc\trefs/tags/v0.10.0\n" == Just "v0.10.0"@@ -1958,6 +2079,240 @@ "metrics/order-total-value Metric stable Order total value" ] +-- | @okf concepts --where@ with inclusion, exclusion, and grouped conditions+-- over the fixture bundle, read from the strings a user types.+testConceptsWhereConditions :: IO Bool+testConceptsWhereConditions = do+ let fixture = "okf-core" </> "test" </> "fixtures" </> "concept-filters"+ selectWhere raws = filterConceptsWhere [] (whereConditions raws)+ alphaAndBeta =+ [ "requests/alpha Improvement Request accepted Alpha",+ "requests/beta Improvement Request proposed Beta"+ ]+ inclusion <-+ assertConceptReport+ "okf concepts --where 'status in [\"accepted\",\"proposed\"]' --show status"+ fixture+ ["status"]+ (selectWhere ["status in [\"accepted\",\"proposed\"]"])+ alphaAndBeta+ exclusion <-+ assertConceptReport+ "okf concepts --where 'status not in [\"completed\",\"rejected\"]' --show status"+ fixture+ ["status"]+ (selectWhere ["status not in [\"completed\",\"rejected\"]"])+ alphaAndBeta+ repeatedExclusion <-+ assertConceptReport+ "okf concepts --where status!=completed --where status!=rejected --show status"+ fixture+ ["status"]+ (selectWhere ["status!=completed", "status!=rejected"])+ alphaAndBeta+ legacyThenExclusion <-+ assertConceptReport+ "okf concepts --where status=accepted --where status=proposed --where status!=proposed"+ fixture+ []+ (selectWhere ["status=accepted", "status=proposed", "status!=proposed"])+ ["requests/alpha Improvement Request Alpha"]+ negation <-+ assertConceptReport+ "okf concepts --where '(not (status=\"completed\"))'"+ fixture+ []+ (selectWhere ["(not (status=\"completed\"))"])+ [ "notes/scratch Note Scratch",+ "requests/alpha Improvement Request Alpha",+ "requests/beta Improvement Request Beta"+ ]+ reviews <-+ assertConceptReport+ "okf concepts --where 'reviews.outcome not in [\"changes-requested\"]'"+ fixture+ []+ (selectWhere ["reviews.outcome not in [\"changes-requested\"]"])+ ["requests/alpha Improvement Request Alpha"]+ contradiction <-+ assertConceptReport+ "okf concepts --where '(status=\"accepted\" and status=\"proposed\")'"+ fixture+ []+ (selectWhere ["(status=\"accepted\" and status=\"proposed\")"])+ []+ pure (and [inclusion, exclusion, repeatedExclusion, legacyThenExclusion, negation, reviews, contradiction])++-- | JSON rows for an expression selection are the complete stored frontmatter,+-- in walk order, with no status synthesized for a concept that has none.+testConceptsWhereJson :: IO Bool+testConceptsWhereJson =+ withRepositoryPath+ "okf concepts --where '(status=\"accepted\" or missing(status))' --json"+ ("okf-core" </> "test" </> "fixtures" </> "concept-filters")+ $ \bundleRoot -> do+ walked <- walkBundle bundleRoot+ case walked of+ Left bundleError -> do+ putStrLn ("failed to walk the concept-filter fixture: " <> show bundleError)+ pure False+ Right concepts -> do+ let selected = filterConceptsWhere [] (whereConditions ["(status=\"accepted\" or missing(status))"]) concepts+ rows = case conceptReportJson selected of+ Aeson.Array items -> toList items+ _ -> []+ field key = \case+ Aeson.Object fields -> KeyMap.lookup key fields+ _ -> Nothing+ titles = map (field "title") rows+ statuses = map (field "status") rows+ ok =+ titles == [Just (Aeson.String "Scratch"), Just (Aeson.String "Alpha")]+ && statuses == [Nothing, Just (Aeson.String "accepted")]+ && map (field "reviews") rows /= [Nothing, Nothing]+ unless ok $ putStrLn ("unexpected okf concepts --where JSON rows: " <> show rows)+ pure ok++-- | Profile preflight for a whole command line: legacy diagnostics keep their+-- wording and come first, then explicit conditions in flag order, each error+-- once, with a neutral message for a value that may be an exclusion.+testConceptsPredicateDiagnostics :: IO Bool+testConceptsPredicateDiagnostics =+ withRepositoryPath+ "okf concepts --profile concept-filters.dhall diagnostics"+ ("okf-core" </> "test" </> "fixtures" </> "profiles" </> "concept-filters.dhall")+ $ \descriptorPath -> do+ loaded <- loadProfileFile descriptorPath+ case loaded >>= first (Text.pack . show) . compileProfile of+ Left message -> do+ Text.IO.putStrLn ("failed to load the concept-filter profile: " <> message)+ pure False+ Right compiled -> do+ let diagnostics raws = conceptsProfileDiagnostics compiled [] (whereConditions raws) [] [] []+ statusAccepts = "status accepts: proposed, accepted, completed, rejected"+ checks =+ [ ( diagnostics ["status!=acepted"],+ ["okf concepts: filter value acepted is outside the vocabulary for status\n" <> statusAccepts]+ ),+ ( diagnostics ["(missing(status) or not (status=\"acepted\"))"],+ ["okf concepts: filter value acepted is outside the vocabulary for status\n" <> statusAccepts]+ ),+ ( diagnostics+ [ "status=acepted",+ "(statuz=\"x\" or status!=\"rejectd\")",+ "status not in [\"rejectd\"]"+ ],+ [ "okf concepts: no concept can match status=acepted\n" <> statusAccepts,+ "okf concepts: profile declares no frontmatter key named statuz",+ "okf concepts: filter value rejectd is outside the vocabulary for status\n" <> statusAccepts+ ]+ ),+ (diagnostics ["status in [\"accepted\",\"proposed\"]", "status!=completed"], [])+ ]+ failures = [(actual, expected) | (actual, expected) <- checks, actual /= expected]+ unless (null failures) $+ putStrLn ("unexpected okf concepts profile diagnostics: " <> show failures)+ pure (null failures)++-- | @okf concepts --show requestId --sort requestId@ over the concept-sorting+-- fixture: natural order puts IR-10 after IR-9, the concept with no ID comes+-- last, and the column widths are those of the rows in their new order.+testConceptsSortsRows :: IO Bool+testConceptsSortsRows = do+ let fixture = "okf-core" </> "test" </> "fixtures" </> "concept-sorting"+ ascending <-+ assertConceptReport+ "okf concepts --show requestId --sort requestId"+ fixture+ ["requestId"]+ (sortConcepts (parsedSortKeys ["requestId"]))+ [ "requests/e-one Improvement Request IR-1 One",+ "requests/b-two Improvement Request IR-2 Two",+ "requests/c-nine Improvement Request IR-9 Nine",+ "requests/a-ten Improvement Request IR-10 Ten",+ "requests/d-none Improvement Request - None"+ ]+ tieBroken <-+ assertConceptReport+ "okf concepts --show priority --sort priority:desc --sort requestId"+ fixture+ ["priority"]+ (sortConcepts (parsedSortKeys ["priority:desc", "requestId"]))+ [ "requests/b-two Improvement Request 10 Two",+ "requests/e-one Improvement Request 2 One",+ "requests/a-ten Improvement Request 2 Ten",+ "requests/c-nine Improvement Request 1.5 Nine",+ "requests/d-none Improvement Request - None"+ ]+ pure (ascending && tieBroken)++-- | JSON rows follow the sort too, and stay complete stored frontmatter: the+-- concept with no ID is last and still has no @requestId@ key.+testConceptsSortsJson :: IO Bool+testConceptsSortsJson =+ withRepositoryPath+ "okf concepts --sort requestId:desc --json"+ ("okf-core" </> "test" </> "fixtures" </> "concept-sorting")+ $ \bundleRoot -> do+ walked <- walkBundle bundleRoot+ case walked of+ Left bundleError -> do+ putStrLn ("failed to walk the concept-sorting fixture: " <> show bundleError)+ pure False+ Right concepts -> do+ let rows = case conceptReportJson (sortConcepts (parsedSortKeys ["requestId:desc"]) concepts) of+ Aeson.Array items -> toList items+ _ -> []+ field key = \case+ Aeson.Object fields -> KeyMap.lookup key fields+ _ -> Nothing+ ok =+ map (field "requestId") rows+ == (Just . Aeson.String <$> ["IR-10", "IR-9", "IR-2", "IR-1"]) <> [Nothing]+ && map (field "title") rows+ == (Just . Aeson.String <$> ["Ten", "Nine", "Two", "One", "None"])+ unless ok $ putStrLn ("unexpected okf concepts --sort JSON rows: " <> show rows)+ pure ok++-- | A sort key the profile does not declare is reported after every filter+-- diagnostic, once; declared and OKF-owned keys pass.+testConceptsSortKeyDiagnostics :: IO Bool+testConceptsSortKeyDiagnostics =+ withRepositoryPath+ "okf concepts --profile concept-filters.dhall --sort diagnostics"+ ("okf-core" </> "test" </> "fixtures" </> "profiles" </> "concept-filters.dhall")+ $ \descriptorPath -> do+ loaded <- loadProfileFile descriptorPath+ case loaded >>= first (Text.pack . show) . compileProfile of+ Left message -> do+ Text.IO.putStrLn ("failed to load the concept-filter profile: " <> message)+ pure False+ Right compiled -> do+ let diagnostics conditions keys = conceptsProfileDiagnostics compiled [] (whereConditions conditions) [] [] (parsedSortKeys keys)+ checks =+ [ (diagnostics [] ["statuz"], ["okf concepts: profile declares no frontmatter key named statuz"]),+ (diagnostics [] ["statuz", "statuz:desc"], ["okf concepts: profile declares no frontmatter key named statuz"]),+ ( diagnostics ["status!=acepted"] ["statuz"],+ [ "okf concepts: filter value acepted is outside the vocabulary for status\nstatus accepts: proposed, accepted, completed, rejected",+ "okf concepts: profile declares no frontmatter key named statuz"+ ]+ ),+ (diagnostics [] ["requestId:desc", "title", "reviews.outcome"], [])+ ]+ failures = [(actual, expected) | (actual, expected) <- checks, actual /= expected]+ unless (null failures) $+ putStrLn ("unexpected okf concepts sort-key diagnostics: " <> show failures)+ pure (null failures)++-- | Read @--sort@ arguments the way the parser does.+parsedSortKeys :: [Text.Text] -> [SortKey]+parsedSortKeys = map (either (error . show) id . parseSortKey)++-- | Read @--where@ arguments the way the parser does, failing loudly on a typo+-- in the test itself.+whereConditions :: [Text.Text] -> [WhereCondition]+whereConditions = map (either (error . show) id . parseWhereCondition)+ -- | Assert the exact lines @okf concepts@ prints for a repository bundle, after -- an optional selection over the walked concepts. assertConceptReport ::@@ -2664,6 +3019,189 @@ } } +testBuiltinHelp :: Bool+testBuiltinHelp =+ all (helpSucceeds . (\name -> [Text.unpack name, "--help"])) builtinCommands+ && helpSucceeds ["alias", "list", "--help"]+ && case execParserPure defaultPrefs parserInfo ["--help"] of+ Failure failure ->+ let (message, code) = renderFailure failure "okf"+ in code == ExitSuccess && "alias" `List.isInfixOf` message+ _ -> False+ where+ helpSucceeds args = case execParserPure defaultPrefs parserInfo args of+ Failure failure -> snd (renderFailure failure "okf") == ExitSuccess+ _ -> False++testAliasExpansion :: Bool+testAliasExpansion =+ and+ [ expand [] == [],+ expand ["unknown", "c"] == ["unknown", "c"],+ expand ["concepts", "c"] == ["concepts", "c"],+ expand ["c", "bundle with spaces", "--json"] == ["concepts", "bundle with spaces", "--json"],+ expand ["ws", "tail"] == ["help", "okf", "tail"],+ expand ["a"] == ["b"],+ expand ["b"] == ["a"],+ expand ["C"] == ["C"],+ expand ["quoted"] == ["help", "\"two", "words\""],+ expand ["literal"] == ["help", "$(echo", "okf)", ";", "graph"],+ not (isAliasCandidate builtinCommands []),+ isAliasCandidate builtinCommands ["unknown"],+ all+ ( \name ->+ not (isAliasCandidate builtinCommands [Text.unpack name])+ && expandAlias builtinCommands (Map.singleton name "graph") [Text.unpack name, "--help"] == [Text.unpack name, "--help"]+ )+ builtinCommands,+ all+ (\arg -> not (isAliasCandidate builtinCommands [arg]) && expand [arg, "c"] == [arg, "c"])+ ["--help", "--version", "-h", "--", "--bash-completion-index", "-c"]+ ]+ where+ expand =+ expandAlias+ builtinCommands+ ( Map.fromList+ [ ("c", "concepts"),+ ("a", "b"),+ ("b", "a"),+ ("ws", " \t help \n okf "),+ ("quoted", "help \"two words\""),+ ("literal", "help $(echo okf) ; graph"),+ ("--help", "graph")+ ]+ )++-- Exercise actual startup with process arguments: parser-only tests cannot+-- establish that the executable loads and expands aliases before parsing.+testAliasStartup :: IO Bool+testAliasStartup =+ withIsolatedConfigEnv "okf-cli-alias-startup" $ do+ path <- projectConfigPath+ Text.IO.writeFile path (configWithAliases "toMap { h = \"help okf\", help = \"graph\", a = \"b\", b = \"help\" }")+ aliasList <- run ["alias", "list"]+ bareAlias <- run ["alias"]+ aliasRun <- run ["h"]+ protected <- run ["help", "okf"]+ singlePass <- run ["a"]+ Text.IO.writeFile path "this is not Dhall"+ topHelp <- run ["--help"]+ version <- run ["--version"]+ topic <- run ["help", "okf"]+ completion <- run ["--bash-completion-index", "1", "--bash-completion-word", "okf", "--bash-completion-word", ""]+ unknown <- run ["h"]+ strictAliases <- run ["alias", "list"]+ aliasesHelp <- run ["help", "aliases"]+ strict <- run ["config", "show"]+ pure+ ( aliasList == Right ()+ && bareAlias == Right ()+ && strictAliases == Left (ExitFailure 1)+ && aliasesHelp == Right ()+ && aliasRun == Right ()+ && protected == Right ()+ && singlePass == Left (ExitFailure 1)+ && topHelp == Left ExitSuccess+ && version == Left ExitSuccess+ && topic == Right ()+ && completion == Left ExitSuccess+ && unknown == Left (ExitFailure 1)+ && strict == Left (ExitFailure 1)+ )+ where+ run args = try (withArgs args runCli) :: IO (Either ExitCode ())++-- Keep the old whole-record fixtures unchanged: this also tests the new frozen+-- pre-alias decoder while preserving kit, agent, and registry settings.+testAliasConfiguration :: IO Bool+testAliasConfiguration =+ withIsolatedConfigEnv "okf-cli-alias-config" $ do+ projectPath <- projectConfigPath+ root <- getCurrentDirectory+ let globalPath = root </> ".config" </> "okf" </> "config.dhall"+ envPath = root </> "env.dhall"+ expected = Map.fromList [("c", "concepts"), ("h", "help")]+ loadMap = fmap (fmap (\(config, _) -> configAliases config)) loadOkfConfig+ defaults <- loadAliasesForExpansion+ Text.IO.writeFile projectPath (configWithAliases "toMap { c = \"concepts\", h = \"help\" }")+ current <- loadMap+ config <- loadOkfConfig+ Text.IO.writeFile projectPath (agentModelConfigText "pre-alias-model")+ old <- loadOkfConfig+ createDirectoryIfMissing True (takeDirectory globalPath)+ Text.IO.writeFile globalPath (configWithAliases "toMap { g = \"graph\" }")+ Text.IO.writeFile projectPath exampleConfigText+ emptyProject <- loadMap+ Text.IO.writeFile envPath (configWithAliases "toMap { e = \"help\" }")+ setEnv okfConfigEnvVar envPath+ envWinner <- loadMap+ unsetEnv okfConfigEnvVar+ Text.IO.writeFile projectPath "this is not Dhall"+ malformed <- loadOkfConfig+ forgiving <- loadAliasesForExpansion+ invalid <-+ traverse+ ( \(expr, diagnostic) -> do+ Text.IO.writeFile projectPath (configWithAliases expr)+ strict <- loadOkfConfig+ startup <- loadAliasesForExpansion+ pure+ ( case strict of+ Left message -> diagnostic `Text.isInfixOf` message && Map.null startup+ Right _ -> False+ )+ )+ [ ("[ { mapKey = \"c\", mapValue = \" \" } ]", "alias \"c\" has empty expansion"),+ ("[ { mapKey = \"\", mapValue = \"help\" } ]", "has empty name"),+ ("[ { mapKey = \"two words\", mapValue = \"help\" } ]", "has whitespace in name"),+ ("[ { mapKey = \"--help\", mapValue = \"help\" } ]", "has dash-prefixed name"),+ ("[ { mapKey = \"c\", mapValue = 1 } ]", "")+ ]+ Text.IO.writeFile+ projectPath+ ( configWithAliases+ "[ { mapKey = \"c\", mapValue = \"graph\" }, { mapKey = \"c\", mapValue = \"concepts\" } ]"+ )+ duplicate <- loadMap+ pure $+ and+ [ Map.null defaults,+ current == Right expected,+ case config of+ Right (loadedConfig, _) -> "aliases.c = concepts\naliases.h = help\n" `Text.isInfixOf` renderConfig loadedConfig+ Left _ -> False,+ old == Right (defaultOkfConfig {agent = agentSettingsWithSharedModel "pre-alias-model"}, SourceProject projectPath),+ emptyProject == Right Map.empty,+ envWinner == Right (Map.singleton "e" "help"),+ case malformed of Left _ -> True; Right _ -> False,+ Map.null forgiving,+ and invalid,+ duplicate == Right (Map.singleton "c" "concepts"),+ "aliases = []\n" `Text.isInfixOf` renderConfig defaultOkfConfig+ ]+ where+ configAliases OkfConfig {aliases} = aliases++configWithAliases :: Text.Text -> Text.Text+configWithAliases expression =+ "let base = (" <> exampleConfigText <> ") in base // { aliases = " <> expression <> " }"++testAliasValidationAndRendering :: Bool+testAliasValidationAndRendering =+ and+ [ validateAliases Map.empty == Right (),+ validateAliases (Map.fromList [("日本語", "help"), ("help", "graph")]) == Right (),+ all+ (\name -> case validateAliases (Map.singleton name "help") of Left _ -> True; Right _ -> False)+ ["", " ", "two words", "tab\tname", "line\nname", "-c", "--help", "unicode\x2003space"],+ all+ (\expansion -> case validateAliases (Map.singleton "c" expansion) of Left _ -> True; Right _ -> False)+ ["", " ", "\t\n"],+ renderAliases Map.empty == "No aliases configured.\n",+ renderAliases (Map.fromList [("help", "graph"), ("c", "concepts")]) == "c = concepts\nhelp = graph\n"+ ]+ testConfigInvalidDhall :: IO Bool testConfigInvalidDhall = withIsolatedConfigEnv "okf-cli-config-invalid" $ do@@ -2763,6 +3301,59 @@ buildAgentCommand (resolvedAgentWith ProviderClaude Nothing Nothing Nothing) ["/a"] (assistPrompt "x") == Right ("claude", ["--add-dir", "/a", "--", "x"]) +-- | Exercise the actual launch path: user and project tool-only skills are+-- enabled for Codex, while shared skills, foreign sidecars, and Claude stay out+-- of the Codex session override.+testAssistKitVisibility :: IO Bool+testAssistKitVisibility =+ withIsolatedConfigEnv "okf-cli-assist-kit" $ do+ root <- getCurrentDirectory+ let project = root </> "project"+ config = defaultOkfConfig {kit = KitSettings "unused" [ProviderClaude, ProviderCodex]}+ codex = resolvedAgentWith ProviderCodex Nothing Nothing Nothing+ claude = resolvedAgentWith ProviderClaude Nothing Nothing (Just "Be concise")+ prompt = assistPrompt "do work"+ writeSkill base name visibility sidecar = do+ let dir = base </> ".agents" </> "skills" </> name+ createDirectoryIfMissing True dir+ Text.IO.writeFile (dir </> "SKILL.md") "# Test skill\n"+ Aeson.encodeFile (dir </> sidecar) $+ Aeson.object+ [ "name" Aeson..= name,+ "kind" Aeson..= ("skill" :: Text.Text),+ "hash" Aeson..= ("test" :: Text.Text),+ "installedAt" Aeson..= ("2026-10-06T00:00:00Z" :: Text.Text),+ "visibility" Aeson..= (visibility :: Text.Text)+ ]+ canonicalizePath (dir </> "SKILL.md")+ createDirectoryIfMissing True project+ withCurrentDirectory project $ do+ withoutSkills <- buildSessionAgentCommand config codex prompt+ userSkill <- writeSkill root "user-only" "tool-only" ".okf-kit.json"+ projectSkill <- writeSkill project "project-only" "tool-only" ".okf-kit.json"+ sharedSkill <- writeSkill project "shared" "shared" ".okf-kit.json"+ foreignSkill <- writeSkill project "foreign" "tool-only" ".other-kit.json"+ rendered <- buildSessionAgentCommand config codex prompt+ claudeRendered <- buildSessionAgentCommand config claude prompt+ pure $+ withoutSkills == buildAgentCommand codex [] prompt+ && claudeRendered == buildAgentCommand claude [] prompt+ && case rendered of+ Left _ -> False+ Right (executable, args) ->+ executable == "codex"+ && take 1 args == ["-c"]+ && last args == "do work"+ && case args of+ _ : override : _ ->+ "skills.config=[" `List.isPrefixOf` override+ && "enabled=true" `List.isInfixOf` override+ && userSkill `List.isInfixOf` override+ && projectSkill `List.isInfixOf` override+ && not (sharedSkill `List.isInfixOf` override)+ && not (foreignSkill `List.isInfixOf` override)+ _ -> False+ assistPrompt :: Text.Text -> AssistOptions assistPrompt promptText = AssistOptions@@ -2989,3 +3580,103 @@ Failure _ -> True CompletionInvoked _ -> True Success _ -> False++-- Exercise both the no-write preflight boundary and recovery after mutation.+testProfileBootstrap :: IO Bool+testProfileBootstrap =+ withRepositoryPath "profile init" ("okf-core" </> "test" </> "fixtures" </> "registry" </> "package.dhall") $ \registry -> do+ absoluteRegistry <- makeAbsolute registry+ temp <- getTemporaryDirectory+ bracket (createTempDirectory temp "okf-cli-bootstrap") removeDirectoryRecursive $ \root -> do+ let options destination = ProfileInitOptions [Text.pack absoluteRegistry] (Just "postgresql") destination True (Just "2026-09-19") True+ run opts = try @ExitCode (runCommand (Profile (ProfileInit opts)))+ success label passed = do+ unless passed (putStrLn ("profile init failed: " <> label))+ pure passed+ untouched :: String -> (FilePath -> IO ()) -> (ProfileInitOptions -> ProfileInitOptions) -> IO Bool+ untouched label prepare modify = do+ let destination = root </> label+ prepare destination+ outcome <- run (modify (options destination))+ descriptorExists <- doesFileExist (destination </> "profile.dhall")+ indexExists <- doesFileExist (destination </> "index.md")+ logExists <- doesFileExist (destination </> "log.md")+ success label (outcome == Left (ExitFailure 1) && not descriptorExists && not indexExists && not logExists)+ preview <- run ((options (root </> "preview")) {write = False})+ previewExists <- doesDirectoryExist (root </> "preview")+ previewOk <- success "preview" (preview == Right () && not previewExists)+ let green = root </> "green"+ written <- run (options green)+ actual <- loadProfileFile (green </> "profile.dhall")+ -- Select the source profile independently of the renderer.+ expected <- loadProfileFile ((takeDirectory (takeDirectory absoluteRegistry) </> "profiles" </> "postgresql.dhall"))+ version <- readBundleVersion green+ logText <- Text.IO.readFile (green </> "log.md")+ before <- Text.IO.readFile (green </> "profile.dhall")+ again <- run (options green)+ after <- Text.IO.readFile (green </> "profile.dhall")+ greenOk <- success "greenfield and refusal" (written == Right () && actual == expected && either (const False) (const True) actual && version == Right (VersionDeclared (OkfVersion 0 1)) && "## 2026-09-19" `Text.isInfixOf` logText && Text.count "**Adoption**" logText == 1 && again == Left (ExitFailure 1) && before == after)+ dateOk <- untouched "bad-date" (const (pure ())) (\opts -> opts {date = Just "2026-02-30"})+ dateSyntaxOk <- untouched "bad-date-syntax" (const (pure ())) (\opts -> opts {date = Just "yesterday"})+ directoryOk <- untouched "occupied-directory" (\d -> createDirectoryIfMissing True (d </> "profile.dhall")) id+ danglingOk <-+ untouched+ "dangling-descriptor"+ ( \d -> do+ createDirectoryIfMissing True d+ createFileLink (root </> "absent") (d </> "profile.dhall")+ )+ id+ danglingBundleOk <- untouched "dangling-bundle" (\d -> createFileLink (root </> "absent-bundle") d) id+ fileTargetOk <- untouched "file-target" (\d -> Text.IO.writeFile d "keep") id+ let badVersion = root </> "bad-version"+ oldIndex = "---\nokf_version: nope\n---\n# Index\n"+ createDirectoryIfMissing True badVersion+ Text.IO.writeFile (badVersion </> "index.md") oldIndex+ rejectedVersion <- run (options badVersion)+ indexAfter <- Text.IO.readFile (badVersion </> "index.md")+ descriptorAfter <- doesFileExist (badVersion </> "profile.dhall")+ versionOk <- success "unparseable version" (rejectedVersion == Left (ExitFailure 1) && indexAfter == oldIndex && not descriptorAfter)+ let existing = root </> "existing"+ conceptText = "---\ntype: Note\ntitle: Keep me\n---\nOriginal bytes.\n"+ createDirectoryIfMissing True (existing </> "nested")+ Text.IO.writeFile (existing </> "nested" </> "concept.md") conceptText+ Text.IO.writeFile (existing </> "index.md") "---\nokf_version: \"0.3\"\n---\n"+ Text.IO.writeFile (existing </> "log.md") "# Bundle Update Log\n\n## 2026-09-18\n* **Update**: Preserve this entry.\n"+ existingResult <- run (options existing)+ preserved <- Text.IO.readFile (existing </> "nested" </> "concept.md")+ higher <- readBundleVersion existing+ appended <- Text.IO.readFile (existing </> "log.md")+ nestedIndex <- doesFileExist (existing </> "nested" </> "index.md")+ existingOk <- success "existing bundle" (existingResult == Right () && preserved == conceptText && higher == Right (VersionDeclared (OkfVersion 0 3)) && "Preserve this entry." `Text.isInfixOf` appended && Text.count "**Adoption**" appended == 1 && nestedIndex)+ -- Invalid definitions load but must fail compilation before mutation.+ let invalidRegistry = root </> "invalid.dhall"+ Text.IO.writeFile invalidRegistry ("let registry = " <> Text.pack absoluteRegistry <> " in registry.postgresql with okfVersion = \"invalid\"")+ invalidOk <- untouched "invalid-profile" (const (pure ())) (\opts -> opts {registryRefs = [Text.pack invalidRegistry], export = Nothing})+ -- A literal dotted label is ambiguous in the registry's dotted export API.+ let mismatchRegistry = root </> "mismatch.dhall"+ Text.IO.writeFile mismatchRegistry ("let registry = " <> Text.pack absoluteRegistry <> " in { `a.b` = registry.postgresql, a = registry.postgresql // { b = registry.nested.decisions } }")+ mismatchOk <- untouched "mismatch" (const (pure ())) (\opts -> opts {registryRefs = [Text.pack mismatchRegistry], export = Just "a.b"})+ -- A reserved index directory in a nested directory passes the concept+ -- walk, then fails during index writing after descriptor verification.+ let indexFailure = root </> "index-failure"+ createDirectoryIfMissing True (indexFailure </> "nested" </> "index.md")+ Text.IO.writeFile (indexFailure </> "nested" </> "concept.md") conceptText+ indexResult <- run (options indexFailure)+ indexDescriptor <- doesFileExist (indexFailure </> "profile.dhall")+ indexLog <- doesFileExist (indexFailure </> "log.md")+ indexFailureOk <- success "index write recovery" (indexResult == Left (ExitFailure 1) && indexDescriptor && not indexLog)+ let logFailure = root </> "log-failure"+ createDirectoryIfMissing True (logFailure </> "log.md")+ logResult <- run (options logFailure)+ logDescriptor <- doesFileExist (logFailure </> "profile.dhall")+ logIndex <- doesFileExist (logFailure </> "index.md")+ logFailureOk <- success "log write recovery" (logResult == Left (ExitFailure 1) && logDescriptor && logIndex)+ let invalidBundle = root </> "invalid-bundle"+ createDirectoryIfMissing True invalidBundle+ Text.IO.writeFile (invalidBundle </> "concept.md") "---\ntitle: Missing type\n---\nBody\n"+ invalidResult <- run (options invalidBundle)+ invalidLog <- doesFileExist (invalidBundle </> "log.md")+ invalidDescriptor <- doesFileExist (invalidBundle </> "profile.dhall")+ structuralOk <- success "post-write validation" (invalidResult == Left (ExitFailure 1) && invalidLog && invalidDescriptor)+ pure (and [previewOk, greenOk, dateOk, dateSyntaxOk, directoryOk, danglingOk, danglingBundleOk, fileTargetOk, versionOk, existingOk, invalidOk, mismatchOk, indexFailureOk, logFailureOk, structuralOk])