packages feed

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 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