okf-cli 0.1.0.0 → 0.1.1.0
raw patch · 7 files changed
+895/−66 lines, 7 filesdep +containersdep +directorydep +file-embeddep ~okf-corePVP: major bump suggested
API removals or changes: PVP suggests a major version bump
Dependencies added: containers, directory, file-embed, filepath, githash, process, temporary, time
Dependency ranges changed: okf-core
API changes (from Hackage documentation)
+ Okf.Cli: Completions :: CompletionsShell -> Command
+ Okf.Cli: Help :: HelpCommand -> Command
+ Okf.Cli: Log :: LogOptions -> Command
+ Okf.Cli: LogAdd :: LogAddOptions -> LogSub
+ Okf.Cli: LogAddOptions :: !Maybe Text -> !Text -> !Text -> !Maybe Text -> LogAddOptions
+ Okf.Cli: LogOptions :: !FilePath -> !Bool -> !Maybe Text -> !LogSub -> LogOptions
+ Okf.Cli: LogPreview :: LogSub
+ Okf.Cli: [checkStale] :: LogOptions -> !Bool
+ Okf.Cli: [conceptId] :: LogAddOptions -> !Maybe Text
+ Okf.Cli: [date] :: LogAddOptions -> !Maybe Text
+ Okf.Cli: [kind] :: LogAddOptions -> !Text
+ Okf.Cli: [logEnforce] :: ValidateOptions -> !Bool
+ Okf.Cli: [logSub] :: LogOptions -> !LogSub
+ Okf.Cli: [message] :: LogAddOptions -> !Text
+ Okf.Cli: [profileEnforce] :: ValidateOptions -> !Bool
+ Okf.Cli: [profilePath] :: ValidateOptions -> !Maybe FilePath
+ Okf.Cli: [sinceRef] :: LogOptions -> !Maybe Text
+ Okf.Cli: data LogAddOptions
+ Okf.Cli: data LogOptions
+ Okf.Cli: data LogSub
+ Okf.Cli: instance GHC.Classes.Eq Okf.Cli.GitDrift
+ Okf.Cli: instance GHC.Classes.Eq Okf.Cli.LogAddOptions
+ Okf.Cli: instance GHC.Classes.Eq Okf.Cli.LogOptions
+ Okf.Cli: instance GHC.Classes.Eq Okf.Cli.LogSub
+ Okf.Cli: instance GHC.Internal.Generics.Generic Okf.Cli.GitDrift
+ Okf.Cli: instance GHC.Internal.Show.Show Okf.Cli.GitDrift
+ Okf.Cli: instance GHC.Internal.Show.Show Okf.Cli.LogAddOptions
+ Okf.Cli: instance GHC.Internal.Show.Show Okf.Cli.LogOptions
+ Okf.Cli: instance GHC.Internal.Show.Show Okf.Cli.LogSub
+ Okf.Cli: runLogAdd :: FilePath -> LogAddOptions -> IO ()
+ Okf.Cli.Completions: Bash :: CompletionsShell
+ Okf.Cli.Completions: Fish :: CompletionsShell
+ Okf.Cli.Completions: Zsh :: CompletionsShell
+ Okf.Cli.Completions: completionsParser :: Parser CompletionsShell
+ Okf.Cli.Completions: data CompletionsShell
+ Okf.Cli.Completions: handleCompletions :: CompletionsShell -> IO ()
+ Okf.Cli.Completions: instance GHC.Classes.Eq Okf.Cli.Completions.CompletionsShell
+ Okf.Cli.Completions: instance GHC.Internal.Show.Show Okf.Cli.Completions.CompletionsShell
+ Okf.Cli.Completions: renderCompletionScript :: CompletionsShell -> Text
+ Okf.Cli.Help: HelpTopic :: !Text -> !Text -> !Text -> HelpTopic
+ Okf.Cli.Help: ListTopics :: HelpCommand
+ Okf.Cli.Help: ShowTopic :: !Text -> HelpCommand
+ Okf.Cli.Help: [topicContent] :: HelpTopic -> !Text
+ Okf.Cli.Help: [topicDescription] :: HelpTopic -> !Text
+ Okf.Cli.Help: [topicName] :: HelpTopic -> !Text
+ Okf.Cli.Help: data HelpCommand
+ Okf.Cli.Help: data HelpTopic
+ Okf.Cli.Help: handleHelpCommand :: HelpCommand -> IO ()
+ Okf.Cli.Help: helpCommandParser :: Parser HelpCommand
+ Okf.Cli.Help: helpTopics :: [HelpTopic]
+ Okf.Cli.Help: instance GHC.Classes.Eq Okf.Cli.Help.HelpCommand
+ Okf.Cli.Help: instance GHC.Classes.Eq Okf.Cli.Help.HelpTopic
+ Okf.Cli.Help: instance GHC.Internal.Show.Show Okf.Cli.Help.HelpCommand
+ Okf.Cli.Help: instance GHC.Internal.Show.Show Okf.Cli.Help.HelpTopic
+ Okf.Cli.Version: appVersion :: Text
+ Okf.Cli.Version: appVersionWithGit :: Text
+ Okf.Cli.Version: gitCommitShort :: Maybe Text
- Okf.Cli: ValidateOptions :: !FilePath -> !Bool -> ValidateOptions
+ Okf.Cli: ValidateOptions :: !FilePath -> !Bool -> !Maybe FilePath -> !Bool -> !Bool -> ValidateOptions
Files
- CHANGELOG.md +31/−0
- okf-cli.cabal +52/−48
- src/Okf/Cli.hs +366/−10
- src/Okf/Cli/Completions.hs +148/−0
- src/Okf/Cli/Help.hs +99/−0
- src/Okf/Cli/Version.hs +55/−0
- test/Main.hs +144/−8
CHANGELOG.md view
@@ -7,6 +7,37 @@ ## [Unreleased] +## [0.1.1.0] - 2026-06-28++### Added++- `okf --version`, including git SHA reporting for Cabal and Nix builds when+ available.+- Shell completion generation for supported shells.+- `okf help` command with embedded conceptual topic guides (`okf`, `format`,+ `validation`, `profiles`), including a guide explaining what the Open Knowledge+ Format is. The guides are plain text baked into the binary at compile time, so+ `okf help <topic>` works with no network or docs checkout.+- Profile-based validation: `okf validate --profile <descriptor>.dhall` checks a+ bundle against a team's house conventions (allowed `type` strings, required+ frontmatter keys, `resource:` schemes, file layout, and `# Schema` columns)+ declared in a Dhall descriptor. Profiles are not part of the OKF standard, so+ deviations are advisory by default; `--profile-enforce` fails the command on+ drift. Ships an example bundle (`examples/postgresql-sample`), a sample+ descriptor (`docs/profiles/postgresql.dhall`), and a user guide+ (`docs/user/profiles.md`).+- Log support: `okf-core` can parse, serialize, and validate `log.md` files;+ `okf-cli` can preview, validate, author log entries, and report drift between+ bundle logs and git history.+- Canonical OKF profile schema Dhall modules with drift tests.++### Changed++- Expanded the README and user guides to cover the current CLI, profile+ validation, and log workflows.+- Updated release, Nix, and repository metadata so both packages build and check+ as separate Hackage packages.+ ## [0.1.0.0] - 2026-06-19 Initial release.
okf-cli.cabal view
@@ -1,35 +1,29 @@-cabal-version: 3.4-name: okf-cli-version: 0.1.0.0-synopsis: Command-line interface for Open Knowledge Format bundles+cabal-version: 3.4+name: okf-cli+version: 0.1.1.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+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 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@@ -37,42 +31,52 @@ OverloadedStrings library- import: common-options- hs-source-dirs: src+ import: common-options+ hs-source-dirs: src exposed-modules: Okf.Cli+ Okf.Cli.Completions+ Okf.Cli.Help+ Okf.Cli.Version + other-modules: Paths_okf_cli+ autogen-modules: Paths_okf_cli build-depends:- aeson >=2.2 && <2.4,- base >=4.20 && <5,- bytestring >=0.11 && <0.13,- okf-core ^>=0.1.0.0,- generic-lens >=2.2 && <2.4,- lens ^>=5.3,- optparse-applicative >=0.18 && <0.20,- text ^>=2.1,+ , aeson >=2.2 && <2.4+ , base >=4.20 && <5+ , bytestring >=0.11 && <0.13+ , containers >=0.6 && <0.8+ , 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.1.1.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:- base >=4.20 && <5,- okf-cli,- optparse-applicative >=0.18,- text ^>=2.1,+ , base >=4.20 && <5+ , directory+ , filepath+ , okf-cli+ , optparse-applicative >=0.18+ , temporary+ , text ^>=2.1 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
@@ -3,41 +3,64 @@ ( Command (..), GraphOptions (..), IndexOptions (..),+ LogAddOptions (..),+ LogOptions (..),+ LogSub (..), Options (..), ShowOptions (..), ValidateOptions (..), parserInfo, runCli, runCommand,+ runLogAdd, ) where +import Control.Exception (IOException, try) import Data.Aeson qualified as Aeson import Data.ByteString.Lazy.Char8 qualified as LazyByteString import Data.Foldable (traverse_)+import Data.List qualified as List+import Data.Set qualified as Set import Data.Text qualified as Text import Data.Text.IO qualified as Text.IO+import Data.Time (defaultTimeLocale, formatTime, getCurrentTime, utctDay) import Okf.Bundle+import Okf.Cli.Completions (CompletionsShell, completionsParser, handleCompletions)+import Okf.Cli.Help (HelpCommand, handleHelpCommand, helpCommandParser)+import Okf.Cli.Version (appVersionWithGit) import Okf.ConceptId import Okf.Document (DocumentParseError (..), body) import Okf.Graph (buildGraph) import Okf.Index+import Okf.Log qualified as Log import Okf.Prelude+import Okf.Profile (ProfileViolation (..), loadProfileFile, validateProfile) import Okf.Validation import Options.Applicative-import System.Exit (exitFailure)+import System.Directory (createDirectoryIfMissing, doesFileExist)+import System.Exit (ExitCode (..), exitFailure)+import System.FilePath ((</>))+import System.FilePath qualified as FilePath import System.IO (stderr)+import System.Process (readProcessWithExitCode) data Command = Validate ValidateOptions | Index IndexOptions+ | Log LogOptions | GraphCommand GraphOptions | ShowConcept ShowOptions+ | Completions CompletionsShell+ | Help HelpCommand deriving stock (Show, Eq) data ValidateOptions = ValidateOptions { bundlePath :: !FilePath,- strictMode :: !Bool+ strictMode :: !Bool,+ profilePath :: !(Maybe FilePath),+ profileEnforce :: !Bool,+ logEnforce :: !Bool } deriving stock (Show, Eq) @@ -47,6 +70,27 @@ } deriving stock (Show, Eq) +data LogOptions = LogOptions+ { bundlePath :: !FilePath,+ checkStale :: !Bool,+ sinceRef :: !(Maybe Text),+ logSub :: !LogSub+ }+ deriving stock (Show, Eq)++data LogSub+ = LogPreview+ | LogAdd LogAddOptions+ deriving stock (Show, Eq)++data LogAddOptions = LogAddOptions+ { conceptId :: !(Maybe Text),+ kind :: !Text,+ message :: !Text,+ date :: !(Maybe Text)+ }+ deriving stock (Show, Eq)+ data GraphOptions = GraphOptions { bundlePath :: !FilePath, json :: !Bool@@ -72,12 +116,18 @@ parserInfo :: ParserInfo Options parserInfo = info- (optionsParser <**> helper)+ (optionsParser <**> helper <**> versionOption) ( fullDesc <> progDesc "Validate, index, inspect, and graph Open Knowledge Format bundles" <> header "okf - Open Knowledge Format bundle tools" ) +versionOption :: Parser (a -> a)+versionOption =+ infoOption+ (Text.unpack appVersionWithGit)+ (long "version" <> help "Show version information and exit")+ optionsParser :: Parser Options optionsParser = Options <$> commandParser @@ -86,8 +136,11 @@ hsubparser ( 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 "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")) ) validateOptionsParser :: Parser ValidateOptions@@ -95,6 +148,15 @@ ValidateOptions <$> bundleArgument <*> switch (long "strict" <> help "Require recommended authoring fields")+ <*> optional+ ( strOption+ ( long "profile"+ <> metavar "PROFILE"+ <> help "Path to a Dhall profile descriptor to check (advisory)"+ )+ )+ <*> switch (long "profile-enforce" <> help "Exit non-zero when profile checks find deviations")+ <*> switch (long "log-enforce" <> help "Exit non-zero when log staleness advisories are found") indexOptionsParser :: Parser IndexOptions indexOptionsParser =@@ -102,6 +164,75 @@ <$> bundleArgument <*> switch (long "write" <> help "Write generated index.md files instead of previewing") +logOptionsParser :: Parser LogOptions+logOptionsParser =+ logAddCommandParser <|> logPreviewOptionsParser++logPreviewOptionsParser :: Parser LogOptions+logPreviewOptionsParser =+ LogOptions+ <$> bundleArgument+ <*> switch (long "check-stale" <> help "Report concepts newer than their nearest log.md")+ <*> optional+ ( Text.pack+ <$> strOption+ ( long "since"+ <> metavar "GIT_REF"+ <> help "Report git drift since a ref (implemented in Milestone 6)"+ )+ )+ <*> pure LogPreview++logAddCommandParser :: Parser LogOptions+logAddCommandParser =+ hsubparser+ ( command+ "add"+ ( info+ (logAddOptionsToCommand <$> bundleArgument <*> logAddOptionsParser <**> helper)+ (progDesc "Append an entry to the nearest log.md")+ )+ )++logAddOptionsToCommand :: FilePath -> LogAddOptions -> LogOptions+logAddOptionsToCommand path addOptions =+ LogOptions+ { bundlePath = path,+ checkStale = False,+ sinceRef = Nothing,+ logSub = LogAdd addOptions+ }++logAddOptionsParser :: Parser LogAddOptions+logAddOptionsParser =+ LogAddOptions+ <$> optional (Text.pack <$> strArgument (metavar "CONCEPT_ID" <> help "Concept ID whose directory log.md should be updated"))+ <*> ( Text.pack+ <$> strOption+ ( long "kind"+ <> metavar "KIND"+ <> value "Update"+ <> showDefault+ <> help "Leading bold log entry kind"+ )+ )+ <*> ( Text.pack+ <$> strOption+ ( short 'm'+ <> long "message"+ <> metavar "MESSAGE"+ <> help "Log entry message"+ )+ )+ <*> optional+ ( Text.pack+ <$> strOption+ ( long "date"+ <> metavar "YYYY-MM-DD"+ <> help "Entry date; defaults to today in UTC"+ )+ )+ graphOptionsParser :: Parser GraphOptions graphOptionsParser = GraphOptions@@ -122,19 +253,54 @@ runCommand = \case Validate options -> runValidate options Index options -> runIndex options+ Log options -> runLog options GraphCommand options -> runGraph options ShowConcept options -> runShow options+ Completions shell -> handleCompletions shell+ Help helpCommand -> handleHelpCommand helpCommand runValidate :: ValidateOptions -> IO ()-runValidate ValidateOptions {bundlePath, strictMode} = do+runValidate ValidateOptions {bundlePath, strictMode, profilePath, profileEnforce, logEnforce} = do concepts <- loadBundleOrExit bundlePath- let profile = if strictMode then StrictAuthoring else PermissiveConformance- case validateBundle profile concepts of- [] -> Text.IO.putStrLn ("OK: " <> Text.pack (show (length concepts)) <> " concepts")- errors -> do- mapM_ (Text.IO.hPutStrLn stderr . renderBundleValidationError) errors- exitFailure+ logs <- loadLogsOrExit bundlePath+ let coreProfile = if strictMode then StrictAuthoring else PermissiveConformance+ coreErrors = validateBundle coreProfile concepts <> validateBundleLogs logs+ mapM_ (Text.IO.hPutStrLn stderr . renderBundleValidationError) coreErrors + let staleness = logStaleness concepts logs+ mapM_ (Text.IO.hPutStrLn stderr . ("log: " <>) . renderLogStaleness) staleness++ profileViolations <- case profilePath of+ Nothing -> pure []+ Just path -> do+ loaded <- loadProfileFile path+ case loaded of+ Left err -> dieText ("Failed to load profile " <> Text.pack path <> ": " <> err)+ Right spec -> do+ let violations = validateProfile spec concepts+ mapM_ (Text.IO.hPutStrLn stderr . ("profile: " <>) . renderProfileViolation) violations+ pure violations++ let coreFailed = any bundleValidationErrorIsFailure coreErrors+ profileFailed = profileEnforce && not (null profileViolations)+ logFailed = logEnforce && (any bundleValidationErrorIsAdvisory coreErrors || not (null staleness))+ if coreFailed || profileFailed || logFailed+ then exitFailure+ else do+ Text.IO.putStrLn ("OK: " <> Text.pack (show (length concepts)) <> " concepts")+ unless (null profileViolations) $+ Text.IO.putStrLn+ ( "profile: "+ <> Text.pack (show (length profileViolations))+ <> " advisory deviation(s) (use --profile-enforce to fail)"+ )+ unless (null staleness) $+ Text.IO.putStrLn+ ( "log: "+ <> Text.pack (show (length staleness))+ <> " stale concept advisory/advisories (use --log-enforce to fail)"+ )+ runIndex :: IndexOptions -> IO () runIndex IndexOptions {bundlePath, write} = if write@@ -147,6 +313,133 @@ indexes <- loadIndexesOrExit bundlePath mapM_ renderIndexPreview indexes +runLog :: LogOptions -> IO ()+runLog LogOptions {bundlePath, checkStale, sinceRef, logSub = LogPreview} = do+ logs <- loadLogsOrExit bundlePath+ mapM_ renderLogPreview logs+ let logErrors = validateBundleLogs logs+ mapM_ (Text.IO.hPutStrLn stderr . renderBundleValidationError) logErrors+ case sinceRef of+ Nothing -> pure ()+ Just ref -> runGitDriftCheck bundlePath ref logs+ staleness <-+ if checkStale+ then do+ concepts <- loadBundleOrExit bundlePath+ pure (logStaleness concepts logs)+ else pure []+ mapM_ (Text.IO.hPutStrLn stderr . ("log: " <>) . renderLogStaleness) staleness+ when (any bundleValidationErrorIsFailure logErrors) exitFailure+runLog LogOptions {bundlePath, logSub = LogAdd addOptions} =+ runLogAdd bundlePath addOptions++runLogAdd :: FilePath -> LogAddOptions -> IO ()+runLogAdd bundlePath LogAddOptions {conceptId, kind, message, date} = do+ entryDate <- maybe todayDate pure date+ targetPath <- resolveLogTarget bundlePath conceptId+ let absolutePath = bundlePath </> targetPath+ entry = Log.LogEntry {Log.logKind = Just kind, Log.logText = message}+ exists <- doesFileExist absolutePath+ existingLog <-+ if exists+ then Log.parseLog <$> Text.IO.readFile absolutePath+ else pure (emptyLogFor targetPath)+ createDirectoryIfMissing True (FilePath.takeDirectory absolutePath)+ Text.IO.writeFile absolutePath (Log.serializeLog (Log.appendLogEntry entryDate entry existingLog))+ Text.IO.putStrLn ("Wrote " <> Text.pack targetPath <> " for " <> entryDate)++resolveLogTarget :: FilePath -> Maybe Text -> IO FilePath+resolveLogTarget _ Nothing =+ pure "log.md"+resolveLogTarget bundlePath (Just rawConceptId) = do+ parsed <- either (dieText . renderConceptIdError rawConceptId) pure (parseConceptId rawConceptId)+ concepts <- loadBundleOrExit bundlePath+ when (isNothing (findConcept parsed concepts)) $+ Text.IO.hPutStrLn stderr ("log: warning: concept not found: " <> rawConceptId)+ pure (logPathForConcept parsed)++logPathForConcept :: ConceptId -> FilePath+logPathForConcept conceptId =+ case FilePath.takeDirectory (conceptIdToFilePath conceptId) of+ "." -> "log.md"+ directory -> directory </> "log.md"++emptyLogFor :: FilePath -> Log.Log+emptyLogFor targetPath =+ Log.Log+ { Log.logTitle = defaultLogTitle targetPath,+ Log.logDays = []+ }++defaultLogTitle :: FilePath -> Text+defaultLogTitle targetPath =+ case FilePath.takeDirectory targetPath of+ "." -> "Bundle Update Log"+ directory -> Text.pack directory <> " Update Log"++todayDate :: IO Text+todayDate =+ Text.pack . formatTime defaultTimeLocale "%Y-%m-%d" . utctDay <$> getCurrentTime++runGitDriftCheck :: FilePath -> Text -> [LogFile] -> IO ()+runGitDriftCheck bundlePath ref logs = do+ result <-+ try+ ( readProcessWithExitCode+ "git"+ ["-C", bundlePath, "diff", "--name-only", "--relative", Text.unpack ref, "--", "."]+ ""+ )+ case result of+ Left (exception :: IOException) ->+ Text.IO.hPutStrLn stderr ("log: skipped git drift check: " <> Text.pack (show exception))+ Right (exitCode, output, errOutput) ->+ case exitCode of+ ExitSuccess ->+ mapM_ (Text.IO.hPutStrLn stderr . ("git: " <>) . renderGitDrift) (gitDriftForChangedPaths logs (Text.lines (Text.pack output)))+ ExitFailure _ ->+ Text.IO.hPutStrLn stderr ("log: skipped git drift check: " <> firstNonEmpty (Text.pack errOutput) (Text.pack output))++data GitDrift = GitDrift+ { driftConceptPath :: !FilePath,+ driftLogPath :: !(Maybe FilePath)+ }+ deriving stock (Generic, Eq, Show)++gitDriftForChangedPaths :: [LogFile] -> [Text] -> [GitDrift]+gitDriftForChangedPaths logs changed =+ [ GitDrift conceptPath nearestLog+ | conceptPath <- changedConcepts,+ let nearestLog = nearestEnclosingLogPath conceptPath allLogPaths,+ maybe True (`Set.notMember` changedSet) nearestLog+ ]+ where+ changedPaths = Text.unpack <$> filter (not . Text.null) changed+ changedSet = Set.fromList changedPaths+ changedConcepts =+ [ path+ | path <- changedPaths,+ FilePath.takeExtension path == ".md",+ not (isReservedMarkdownFile path)+ ]+ changedLogs =+ [ path+ | path <- changedPaths,+ FilePath.takeFileName path == "log.md"+ ]+ allLogPaths = List.nub (changedLogs <> (logSourcePath <$> logs))++renderGitDrift :: GitDrift -> Text+renderGitDrift GitDrift {driftConceptPath, driftLogPath} =+ Text.pack driftConceptPath+ <> " changed without "+ <> maybe "an enclosing log.md" (Text.pack . (<> " changing")) driftLogPath++firstNonEmpty :: Text -> Text -> Text+firstNonEmpty primary fallback+ | Text.null (Text.strip primary) = Text.strip fallback+ | otherwise = Text.strip primary+ runGraph :: GraphOptions -> IO () runGraph GraphOptions {bundlePath} = do concepts <- loadBundleOrExit bundlePath@@ -174,6 +467,13 @@ Left bundleError -> dieText (renderBundleError bundleError) Right indexes -> pure indexes +loadLogsOrExit :: FilePath -> IO [LogFile]+loadLogsOrExit bundlePath = do+ result <- walkLogs bundlePath+ case result of+ Left bundleError -> dieText (renderBundleError bundleError)+ Right logs -> pure logs+ renderBundleValidationError :: BundleValidationError -> Text renderBundleValidationError = \case DocumentInvalid conceptId error_ ->@@ -182,7 +482,42 @@ renderConceptId source <> ": link to missing concept: " <> renderConceptId target DuplicateConceptId conceptId -> "duplicate concept ID: " <> renderConceptId conceptId+ LogInvalid path error_ ->+ Text.pack path <> ": " <> renderLogValidationError error_ +bundleValidationErrorIsFailure :: BundleValidationError -> Bool+bundleValidationErrorIsFailure = \case+ LogInvalid _ error_ -> Log.logErrorIsStructural error_+ _ -> True++bundleValidationErrorIsAdvisory :: BundleValidationError -> Bool+bundleValidationErrorIsAdvisory = not . bundleValidationErrorIsFailure++renderProfileViolation :: ProfileViolation -> Text+renderProfileViolation = \case+ TypeNotInProfile cid ctype ->+ renderConceptId cid <> ": type not in profile vocabulary: " <> ctype+ MissingProfileField cid key ->+ renderConceptId cid <> ": missing profile-required field: " <> key+ PathPatternMismatch cid ctype patternText ->+ renderConceptId cid <> ": " <> ctype <> " must match path pattern: " <> patternText+ MissingResource cid ctype scheme ->+ renderConceptId cid <> ": " <> ctype <> " requires a resource with scheme " <> scheme <> "://"+ ResourceSchemeMismatch cid scheme resourceValue ->+ renderConceptId cid <> ": resource must use scheme " <> scheme <> "://, found: " <> resourceValue+ MissingSchemaSection cid ctype ->+ renderConceptId cid <> ": " <> ctype <> " requires a # Schema section"+ SchemaColumnsMismatch cid ctype expected actual ->+ renderConceptId cid+ <> ": "+ <> ctype+ <> " # Schema columns "+ <> renderList actual+ <> " do not start with required "+ <> renderList expected+ where+ renderList xs = "[" <> Text.intercalate ", " xs <> "]"+ renderValidationErrorText :: ValidationError -> Text renderValidationErrorText = \case MissingRequiredField fieldName -> "missing required field: " <> fieldName@@ -190,10 +525,31 @@ MissingRecommendedField fieldName -> "missing recommended field: " <> fieldName FieldMustBeListOfText fieldName -> "field must be a list of text values: " <> fieldName +renderLogValidationError :: Log.LogValidationError -> Text+renderLogValidationError = \case+ Log.LogDateNotIso dateText -> "log date heading is not YYYY-MM-DD: " <> dateText+ Log.LogDaysOutOfOrder earlier later -> "log dates are not newest first: " <> earlier <> " before " <> later+ Log.LogEmptyDay dateText -> "log date group has no entries: " <> dateText++renderLogStaleness :: LogStaleness -> Text+renderLogStaleness LogStaleness {staleConcept, staleConceptDate, staleLogPath, staleLogDate} =+ renderConceptId staleConcept+ <> ": timestamp date "+ <> staleConceptDate+ <> case (staleLogPath, staleLogDate) of+ (Nothing, Nothing) -> " has no enclosing log.md"+ (Just path, Nothing) -> " is newer than empty log " <> Text.pack path+ (Just path, Just logDate) -> " is newer than " <> Text.pack path <> " newest entry " <> logDate+ (Nothing, Just logDate) -> " is newer than missing log date " <> logDate+ renderIndexPreview :: (FilePath, Text) -> IO () renderIndexPreview (path, content) = do Text.IO.putStrLn ("--- " <> Text.pack path) Text.IO.putStr content++renderLogPreview :: LogFile -> IO ()+renderLogPreview logFile =+ renderIndexPreview (logSourcePath logFile, Log.serializeLog (logContent logFile)) renderConcept :: Concept -> IO () renderConcept concept = do
+ src/Okf/Cli/Completions.hs view
@@ -0,0 +1,148 @@+-- | Shell completion script generation for the @okf@ CLI.+--+-- The generated scripts do not hard-code okf's command list. Instead they call+-- the @okf@ binary back at completion time using optparse-applicative's built-in+-- completion protocol (@--bash-completion-index@, @--bash-completion-word@, and+-- @--bash-completion-enriched@). Because the binary walks its own parser tree to+-- answer, every subcommand and flag is completed automatically and the scripts+-- never need to change when the parser grows.+--+-- Bash uses the plain protocol (one word per line); Zsh and Fish use the enriched+-- protocol (@word<TAB>description@) so each candidate shows its @progDesc@ text.+module Okf.Cli.Completions+ ( CompletionsShell (..),+ completionsParser,+ handleCompletions,+ renderCompletionScript,+ )+where++import Data.Text (Text)+import Data.Text qualified as Text+import Data.Text.IO qualified as Text.IO+import Options.Applicative++-- | The shells for which okf can emit a completion script.+data CompletionsShell+ = Bash+ | Zsh+ | Fish+ deriving stock (Show, Eq)++-- | Parse the @completions <shell>@ subcommand: a single required positional+-- argument naming the shell.+completionsParser :: Parser CompletionsShell+completionsParser =+ argument+ (maybeReader readShell)+ ( metavar "SHELL"+ <> help "Shell to generate a completion script for: bash, zsh, or fish"+ )+ where+ readShell = \case+ "bash" -> Just Bash+ "zsh" -> Just Zsh+ "fish" -> Just Fish+ _ -> Nothing++-- | Print the requested shell's completion script to standard output.+handleCompletions :: CompletionsShell -> IO ()+handleCompletions = Text.IO.putStr . renderCompletionScript++-- | The static completion script for a shell. Each is a pure 'Text' constant;+-- all completion logic is delegated to the @okf@ binary at runtime.+renderCompletionScript :: CompletionsShell -> Text+renderCompletionScript = \case+ Bash -> bashScript+ Zsh -> zshScript+ Fish -> fishScript++-- | Bash completion script. Bash has no native description display, so this uses+-- the plain protocol (@--bash-completion-index@ only).+bashScript :: Text+bashScript =+ Text.unlines+ [ "_okf_completions() {",+ " local CMDLINE",+ " local IFS=$'\\n'",+ " CMDLINE=(--bash-completion-index $COMP_CWORD)",+ "",+ " for arg in ${COMP_WORDS[@]}; do",+ " CMDLINE=(${CMDLINE[@]} --bash-completion-word \"$arg\")",+ " done",+ "",+ " COMPREPLY=( $(okf \"${CMDLINE[@]}\" 2>/dev/null) )",+ "}",+ "",+ "complete -o filenames -F _okf_completions okf"+ ]++-- | Zsh completion script. Uses the enriched protocol and @_describe@ to show+-- descriptions alongside candidates.+zshScript :: Text+zshScript =+ Text.unlines+ [ "#compdef okf",+ "",+ "_okf() {",+ " local -a completions",+ " local CMDLINE",+ " local IFS=$'\\n'",+ "",+ " CMDLINE=(--bash-completion-enriched --bash-completion-index $((CURRENT - 1)))",+ "",+ " for arg in ${words[@]}; do",+ " CMDLINE=(${CMDLINE[@]} --bash-completion-word \"$arg\")",+ " done",+ "",+ " local line",+ " for line in $(okf \"${CMDLINE[@]}\" 2>/dev/null); do",+ " local word=${line%%$'\\t'*}",+ " local desc=${line#*$'\\t'}",+ " if [[ \"$word\" != \"$desc\" ]]; then",+ " completions+=(\"${word//:/\\\\:}:${desc}\")",+ " else",+ " completions+=(\"$word\")",+ " fi",+ " done",+ "",+ " if [[ ${#completions[@]} -gt 0 ]]; then",+ " _describe 'okf' completions",+ " fi",+ "}",+ "",+ "_okf"+ ]++-- | Fish completion script. Uses the enriched protocol and Fish's native+-- @word<TAB>description@ completion format.+fishScript :: Text+fishScript =+ Text.unlines+ [ "# Disable file completion by default",+ "complete -c okf -f",+ "",+ "function __okf_complete",+ " set -l tokens (commandline -cop)",+ " set -l current (commandline -ct)",+ " set -l index (count $tokens)",+ "",+ " set -l args --bash-completion-enriched --bash-completion-index $index",+ " for token in $tokens",+ " set args $args --bash-completion-word $token",+ " end",+ " set args $args --bash-completion-word \"$current\"",+ "",+ " for line in (okf $args 2>/dev/null)",+ " # Split on tab: word<TAB>description",+ " set -l parts (string split \\t -- $line)",+ " if test (count $parts) -ge 2",+ " printf '%s\\t%s\\n' $parts[1] $parts[2]",+ " else",+ " echo $line",+ " end",+ " end",+ "end",+ "",+ "complete -c okf -a '(__okf_complete)'"+ ]
+ src/Okf/Cli/Help.hs view
@@ -0,0 +1,99 @@+{-# LANGUAGE TemplateHaskell #-}++-- | Conceptual @help@ topic guides for the @okf@ CLI.+--+-- Each topic's content lives in a standalone plain-text file under+-- @okf-cli/help/@ and is embedded into the binary at compile time with+-- @file-embed@'s 'embedStringFile' splice. The shipped @okf@ executable is+-- therefore self-contained: @okf help okf@ works with no extra files on disk+-- and no network access.+--+-- Topic files are written as terminal-oriented plain text (ALL-CAPS section+-- headers, 2-space indented bodies) and printed verbatim; there is no Markdown+-- rendering step.+module Okf.Cli.Help+ ( HelpCommand (..),+ HelpTopic (..),+ helpTopics,+ helpCommandParser,+ handleHelpCommand,+ )+where++import Data.FileEmbed (embedStringFile)+import Data.Foldable (find, forM_)+import Data.Text (Text)+import Data.Text qualified as Text+import Data.Text.IO qualified as Text.IO+import Options.Applicative++-- | A bare @help@ lists topics; @help <topic>@ shows one.+data HelpCommand+ = ListTopics+ | ShowTopic !Text+ deriving stock (Show, Eq)++-- | A single help guide: short name, one-line description, embedded content.+data HelpTopic = HelpTopic+ { topicName :: !Text,+ topicDescription :: !Text,+ topicContent :: !Text+ }+ deriving stock (Show, Eq)++-- | All available help topics, in display order.+helpTopics :: [HelpTopic]+helpTopics =+ [ HelpTopic "okf" "What the Open Knowledge Format is" okfTopicContent,+ HelpTopic "format" "Bundle layout, concept IDs, frontmatter, and links" formatTopicContent,+ HelpTopic "validation" "How bundles are validated and referential integrity" validationTopicContent,+ HelpTopic "profiles" "Checking a bundle against house conventions" profilesTopicContent+ ]++okfTopicContent :: Text+okfTopicContent = $(embedStringFile "help/okf.md")++formatTopicContent :: Text+formatTopicContent = $(embedStringFile "help/format.md")++validationTopicContent :: Text+validationTopicContent = $(embedStringFile "help/validation.md")++profilesTopicContent :: Text+profilesTopicContent = $(embedStringFile "help/profiles.md")++-- | Parse @help [TOPIC]@. With no argument, 'pure ListTopics' wins via '<|>'.+helpCommandParser :: Parser HelpCommand+helpCommandParser =+ showTopicParser <|> pure ListTopics+ where+ showTopicParser =+ ShowTopic . Text.pack+ <$> strArgument+ ( metavar "TOPIC"+ <> help ("Help topic: " <> Text.unpack topicList)+ )+ topicList = Text.intercalate ", " (map topicName helpTopics)++-- | Run the @help@ command: list the topic index or print one topic.+handleHelpCommand :: HelpCommand -> IO ()+handleHelpCommand = \case+ ListTopics -> listTopics+ ShowTopic name -> showTopic name++listTopics :: IO ()+listTopics = do+ Text.IO.putStrLn "HELP TOPICS\n"+ forM_ helpTopics $ \t ->+ Text.IO.putStrLn (" " <> padRight 12 (topicName t) <> topicDescription t)+ Text.IO.putStrLn "\nUse 'okf help <topic>' for details."+ where+ padRight n t = t <> Text.replicate (max 0 (n - Text.length t)) " "++showTopic :: Text -> IO ()+showTopic name =+ case find (\t -> topicName t == Text.toLower name) helpTopics of+ Just t -> Text.IO.putStrLn (topicContent t)+ Nothing -> do+ Text.IO.putStrLn ("Unknown topic: " <> name)+ Text.IO.putStrLn ("Available: " <> Text.intercalate ", " (map topicName helpTopics))
+ src/Okf/Cli/Version.hs view
@@ -0,0 +1,55 @@+{-# LANGUAGE CPP #-}+{-# LANGUAGE TemplateHaskell #-}++-- | Version reporting for the @okf@ CLI.+--+-- The short git commit hash is resolved at build time from one of two sources:+--+-- * @cabal build@: the @.git/@ directory is present, so the Template Haskell+-- splice 'tGitInfoCwdTry' reads it directly.+-- * @nix build@: @.git/@ is stripped, so the hash is injected by the Nix+-- expression as the CPP macro @GIT_HASH@ (see @nix/haskell.nix@).+--+-- If neither source is available (e.g. a source tarball), the hash is omitted.+module Okf.Cli.Version+ ( appVersion,+ appVersionWithGit,+ gitCommitShort,+ )+where++import Data.Text (Text, pack)+import Data.Version (showVersion)+import GitHash (GitInfo, giHash, tGitInfoCwdTry)+import Paths_okf_cli (version)++-- | Base package version from @okf-cli.cabal@, e.g. @"0.1.0.0"@.+appVersion :: Text+appVersion = pack (showVersion version)++-- | Git info read at compile time from @.git/@ in the current working directory.+-- 'Right' when building inside a git checkout, 'Left' with a message otherwise.+gitInfo :: Either String GitInfo+gitInfo = $$tGitInfoCwdTry++-- | Fallback hash injected by Nix via @-DGIT_HASH="..."@ when @.git/@ is absent.+nixGitHash :: Maybe Text+#ifdef GIT_HASH+nixGitHash = Just GIT_HASH+#else+nixGitHash = Nothing+#endif++-- | Short git commit hash (first 7 characters). Prefers the compile-time @.git/@+-- read, then the Nix-injected macro, then 'Nothing'.+gitCommitShort :: Maybe Text+gitCommitShort = case gitInfo of+ Right gi -> Just (pack (take 7 (giHash gi)))+ Left _ -> nixGitHash++-- | Full version string, e.g. @"okf v0.1.0.0 (a1b2c3d)"@, or @"okf v0.1.0.0"@ when+-- no commit hash is available.+appVersionWithGit :: Text+appVersionWithGit = "okf v" <> appVersion <> commitSuffix+ where+ commitSuffix = maybe "" (\c -> " (" <> c <> ")") gitCommitShort
test/Main.hs view
@@ -1,20 +1,92 @@ module Main (main) where import Control.Monad (unless)+import Data.Text qualified as Text+import Data.Text.IO qualified as Text.IO+import Okf.Cli+import Okf.Cli.Help (HelpTopic (..), helpTopics) import Options.Applicative+import System.Directory (createDirectoryIfMissing, getTemporaryDirectory, removeDirectoryRecursive) import System.Exit (exitFailure)--import Okf.Cli+import System.FilePath ((</>))+import System.IO.Temp (createTempDirectory) main :: IO () main = do+ logAddWrites <- testLogAddWritesFile let results =- [ parseSucceeds ["validate", "bundle"]- , parseSucceeds ["validate", "bundle", "--strict"]- , parseSucceeds ["index", "bundle", "--write"]- , parseSucceeds ["graph", "bundle", "--json"]- , parseSucceeds ["show", "bundle", "tables/orders"]- , parseFails ["hello"]+ [ parseSucceeds ["validate", "bundle"],+ parseSucceeds ["validate", "bundle", "--strict"],+ parseSucceeds ["validate", "bundle", "--profile", "p.dhall"],+ parseSucceeds ["validate", "bundle", "--profile", "p.dhall", "--profile-enforce"],+ parseSucceeds ["validate", "bundle", "--log-enforce"],+ parseValidateMatches+ ["validate", "b", "--profile", "p.dhall", "--profile-enforce"]+ ValidateOptions+ { bundlePath = "b",+ strictMode = False,+ profilePath = Just "p.dhall",+ profileEnforce = True,+ logEnforce = False+ },+ parseValidateMatches+ ["validate", "b"]+ ValidateOptions+ { bundlePath = "b",+ strictMode = False,+ profilePath = Nothing,+ profileEnforce = False,+ logEnforce = False+ },+ parseValidateMatches+ ["validate", "b", "--log-enforce"]+ ValidateOptions+ { bundlePath = "b",+ strictMode = False,+ profilePath = Nothing,+ profileEnforce = False,+ logEnforce = True+ },+ parseSucceeds ["index", "bundle", "--write"],+ parseSucceeds ["log", "bundle"],+ parseSucceeds ["log", "bundle", "--check-stale"],+ parseLogMatches+ ["log", "b", "--check-stale", "--since", "HEAD~1"]+ LogOptions+ { bundlePath = "b",+ checkStale = True,+ sinceRef = Just "HEAD~1",+ logSub = LogPreview+ },+ parseLogMatches+ ["log", "add", "b", "tables/users", "--kind", "Update", "-m", "Refreshed schema", "--date", "2026-06-23"]+ LogOptions+ { bundlePath = "b",+ checkStale = False,+ sinceRef = Nothing,+ logSub =+ LogAdd+ LogAddOptions+ { conceptId = Just "tables/users",+ kind = "Update",+ message = "Refreshed schema",+ date = Just "2026-06-23"+ }+ },+ parseSucceeds ["graph", "bundle", "--json"],+ parseSucceeds ["show", "bundle", "tables/orders"],+ parseSucceeds ["completions", "bash"],+ parseSucceeds ["completions", "zsh"],+ parseSucceeds ["completions", "fish"],+ parseFails ["completions", "elvish"],+ parseSucceeds ["help"],+ parseSucceeds ["help", "okf"],+ parseSucceeds ["help", "format"],+ any ((== "okf") . topicName) helpTopics,+ all (not . Text.null . topicContent) helpTopics,+ parseShowsInfo ["--version"],+ parseFails ["hello"],+ logAddWrites ] unless (and results) exitFailure @@ -24,8 +96,72 @@ Success _ -> True _ -> False +-- | Parse a @validate@ invocation and check it yields exactly the expected+-- 'ValidateOptions' (so the new @--profile@/@--profile-enforce@ flags map to the+-- right fields).+parseValidateMatches :: [String] -> ValidateOptions -> Bool+parseValidateMatches args expected =+ case execParserPure defaultPrefs parserInfo args of+ Success (Options (Validate opts)) -> opts == expected+ _ -> False++parseLogMatches :: [String] -> LogOptions -> Bool+parseLogMatches args expected =+ case execParserPure defaultPrefs parserInfo args of+ Success (Options (Log opts)) -> opts == expected+ _ -> False++testLogAddWritesFile :: IO Bool+testLogAddWritesFile = do+ temporaryDirectory <- getTemporaryDirectory+ root <- createTempDirectory temporaryDirectory "okf-cli-log-add"+ createDirectoryIfMissing True (root </> "tables")+ Text.IO.writeFile+ (root </> "tables" </> "users.md")+ ( Text.unlines+ [ "---",+ "type: Table",+ "timestamp: 2026-06-23T10:00:00Z",+ "---",+ "",+ "# Users"+ ]+ )+ runCommand+ ( Log+ LogOptions+ { bundlePath = root,+ checkStale = False,+ sinceRef = Nothing,+ logSub =+ LogAdd+ LogAddOptions+ { conceptId = Just "tables/users",+ kind = "Update",+ message = "Refreshed schema",+ date = Just "2026-06-23"+ }+ }+ )+ written <- Text.IO.readFile (root </> "tables" </> "log.md")+ removeDirectoryRecursive root+ pure+ ( "## 2026-06-23" `Text.isInfixOf` written+ && "* **Update**: Refreshed schema" `Text.isInfixOf` written+ )+ parseFails :: [String] -> Bool parseFails args =+ case execParserPure defaultPrefs parserInfo args of+ Failure _ -> True+ CompletionInvoked _ -> True+ Success _ -> False++-- | An info flag such as @--version@ or @--help@ short-circuits parsing: it is+-- recognized (not an unknown-argument error) and reported as a 'Failure' that+-- carries the text to print and a success exit code.+parseShowsInfo :: [String] -> Bool+parseShowsInfo args = case execParserPure defaultPrefs parserInfo args of Failure _ -> True CompletionInvoked _ -> True