diff --git a/CHANGELOG.md b/CHANGELOG.md
new file mode 100644
--- /dev/null
+++ b/CHANGELOG.md
@@ -0,0 +1,22 @@
+# Changelog
+
+All notable changes to okf are recorded here.
+
+The format follows [Keep a Changelog](https://keepachangelog.com/en/1.1.0/), and
+this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0.html).
+
+## [Unreleased]
+
+## [0.1.0.0] - 2026-06-19
+
+Initial release.
+
+### Added
+
+- `okf-core` library: OKF document parser (`Okf.Document`), bundle graph
+  indexing (`Okf.Index`, `Okf.Graph`), bundle validation with referential
+  integrity (`Okf.Validation`, `Okf.Bundle`), concept construction and bundle
+  writing, concept-link rendering with a round-trip guarantee, and a frontmatter
+  authoring API.
+- `okf-cli` library and `okf` executable: bundle validation and document
+  authoring commands over the core API.
diff --git a/LICENSE b/LICENSE
new file mode 100644
--- /dev/null
+++ b/LICENSE
@@ -0,0 +1,30 @@
+Copyright (c) 2026 Nadeem Bitar
+
+All rights reserved.
+
+Redistribution and use in source and binary forms, with or without
+modification, are permitted provided that the following conditions are met:
+
+    * Redistributions of source code must retain the above copyright
+      notice, this list of conditions and the following disclaimer.
+
+    * Redistributions in binary form must reproduce the above
+      copyright notice, this list of conditions and the following
+      disclaimer in the documentation and/or other materials provided
+      with the distribution.
+
+    * Neither the name of Nadeem Bitar nor the names of other
+      contributors may be used to endorse or promote products derived
+      from this software without specific prior written permission.
+
+THIS SOFTWARE IS PROVIDED BY THE COPYRIGHT HOLDERS AND CONTRIBUTORS
+"AS IS" AND ANY EXPRESS OR IMPLIED WARRANTIES, INCLUDING, BUT NOT
+LIMITED TO, THE IMPLIED WARRANTIES OF MERCHANTABILITY AND FITNESS FOR
+A PARTICULAR PURPOSE ARE DISCLAIMED. IN NO EVENT SHALL THE COPYRIGHT
+OWNER OR CONTRIBUTORS BE LIABLE FOR ANY DIRECT, INDIRECT, INCIDENTAL,
+SPECIAL, EXEMPLARY, OR CONSEQUENTIAL DAMAGES (INCLUDING, BUT NOT
+LIMITED TO, PROCUREMENT OF SUBSTITUTE GOODS OR SERVICES; LOSS OF USE,
+DATA, OR PROFITS; OR BUSINESS INTERRUPTION) HOWEVER CAUSED AND ON ANY
+THEORY OF LIABILITY, WHETHER IN CONTRACT, STRICT LIABILITY, OR TORT
+(INCLUDING NEGLIGENCE OR OTHERWISE) ARISING IN ANY WAY OUT OF THE USE
+OF THIS SOFTWARE, EVEN IF ADVISED OF THE POSSIBILITY OF SUCH DAMAGE.
diff --git a/app/Main.hs b/app/Main.hs
new file mode 100644
--- /dev/null
+++ b/app/Main.hs
@@ -0,0 +1,6 @@
+module Main (main) where
+
+import Okf.Cli (runCli)
+
+main :: IO ()
+main = runCli
diff --git a/okf-cli.cabal b/okf-cli.cabal
new file mode 100644
--- /dev/null
+++ b/okf-cli.cabal
@@ -0,0 +1,78 @@
+cabal-version: 3.4
+name: okf-cli
+version: 0.1.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
+
+common common-options
+  ghc-options:
+    -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-extensions:
+    DeriveAnyClass
+    DuplicateRecordFields
+    OverloadedLabels
+    OverloadedStrings
+
+library
+  import: common-options
+  hs-source-dirs: src
+  exposed-modules:
+    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,
+
+test-suite okf-cli-test
+  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,
+
+executable okf
+  import: common-options
+  main-is: Main.hs
+  hs-source-dirs: app
+  ghc-options:
+    -threaded
+    -rtsopts
+    -with-rtsopts=-N
+
+  build-depends:
+    base >=4.20 && <5,
+    okf-cli,
diff --git a/src/Okf/Cli.hs b/src/Okf/Cli.hs
new file mode 100644
--- /dev/null
+++ b/src/Okf/Cli.hs
@@ -0,0 +1,237 @@
+-- | Top-level CLI entry point for okf.
+module Okf.Cli
+  ( Command (..),
+    GraphOptions (..),
+    IndexOptions (..),
+    Options (..),
+    ShowOptions (..),
+    ValidateOptions (..),
+    parserInfo,
+    runCli,
+    runCommand,
+  )
+where
+
+import Data.Aeson qualified as Aeson
+import Data.ByteString.Lazy.Char8 qualified as LazyByteString
+import Data.Foldable (traverse_)
+import Data.Text qualified as Text
+import Data.Text.IO qualified as Text.IO
+import Okf.Bundle
+import Okf.ConceptId
+import Okf.Document (DocumentParseError (..), body)
+import Okf.Graph (buildGraph)
+import Okf.Index
+import Okf.Prelude
+import Okf.Validation
+import Options.Applicative
+import System.Exit (exitFailure)
+import System.IO (stderr)
+
+data Command
+  = Validate ValidateOptions
+  | Index IndexOptions
+  | GraphCommand GraphOptions
+  | ShowConcept ShowOptions
+  deriving stock (Show, Eq)
+
+data ValidateOptions = ValidateOptions
+  { bundlePath :: !FilePath,
+    strictMode :: !Bool
+  }
+  deriving stock (Show, Eq)
+
+data IndexOptions = IndexOptions
+  { bundlePath :: !FilePath,
+    write :: !Bool
+  }
+  deriving stock (Show, Eq)
+
+data GraphOptions = GraphOptions
+  { bundlePath :: !FilePath,
+    json :: !Bool
+  }
+  deriving stock (Show, Eq)
+
+data ShowOptions = ShowOptions
+  { bundlePath :: !FilePath,
+    conceptIdText :: !Text
+  }
+  deriving stock (Show, Eq)
+
+data Options = Options
+  { cmd :: !Command
+  }
+  deriving stock (Show, Eq)
+
+runCli :: IO ()
+runCli = do
+  Options {cmd} <- execParser parserInfo
+  runCommand cmd
+
+parserInfo :: ParserInfo Options
+parserInfo =
+  info
+    (optionsParser <**> helper)
+    ( fullDesc
+        <> progDesc "Validate, index, inspect, and graph Open Knowledge Format bundles"
+        <> header "okf - Open Knowledge Format bundle tools"
+    )
+
+optionsParser :: Parser Options
+optionsParser = Options <$> commandParser
+
+commandParser :: Parser Command
+commandParser =
+  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 "graph" (info (GraphCommand <$> graphOptionsParser <**> helper) (progDesc "Print a bundle graph"))
+        <> command "show" (info (ShowConcept <$> showOptionsParser <**> helper) (progDesc "Show one concept"))
+    )
+
+validateOptionsParser :: Parser ValidateOptions
+validateOptionsParser =
+  ValidateOptions
+    <$> bundleArgument
+    <*> switch (long "strict" <> help "Require recommended authoring fields")
+
+indexOptionsParser :: Parser IndexOptions
+indexOptionsParser =
+  IndexOptions
+    <$> bundleArgument
+    <*> switch (long "write" <> help "Write generated index.md files instead of previewing")
+
+graphOptionsParser :: Parser GraphOptions
+graphOptionsParser =
+  GraphOptions
+    <$> bundleArgument
+    <*> switch (long "json" <> help "Print JSON graph output")
+
+showOptionsParser :: Parser ShowOptions
+showOptionsParser =
+  ShowOptions
+    <$> bundleArgument
+    <*> (Text.pack <$> strArgument (metavar "CONCEPT_ID" <> help "Concept ID such as tables/users"))
+
+bundleArgument :: Parser FilePath
+bundleArgument =
+  strArgument (metavar "BUNDLE" <> help "Path to an OKF bundle directory")
+
+runCommand :: Command -> IO ()
+runCommand = \case
+  Validate options -> runValidate options
+  Index options -> runIndex options
+  GraphCommand options -> runGraph options
+  ShowConcept options -> runShow options
+
+runValidate :: ValidateOptions -> IO ()
+runValidate ValidateOptions {bundlePath, strictMode} = 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
+
+runIndex :: IndexOptions -> IO ()
+runIndex IndexOptions {bundlePath, write} =
+  if write
+    then do
+      result <- writeBundleIndexes bundlePath
+      case result of
+        Left bundleError -> dieText (renderBundleError bundleError)
+        Right () -> Text.IO.putStrLn "Wrote index.md files"
+    else do
+      indexes <- loadIndexesOrExit bundlePath
+      mapM_ renderIndexPreview indexes
+
+runGraph :: GraphOptions -> IO ()
+runGraph GraphOptions {bundlePath} = do
+  concepts <- loadBundleOrExit bundlePath
+  LazyByteString.putStrLn (Aeson.encode (buildGraph concepts))
+
+runShow :: ShowOptions -> IO ()
+runShow ShowOptions {bundlePath, conceptIdText} = do
+  conceptId <- either (dieText . renderConceptIdError conceptIdText) pure (parseConceptId conceptIdText)
+  concepts <- loadBundleOrExit bundlePath
+  case findConcept conceptId concepts of
+    Nothing -> dieText ("Concept not found: " <> conceptIdText)
+    Just concept -> renderConcept concept
+
+loadBundleOrExit :: FilePath -> IO [Concept]
+loadBundleOrExit bundlePath = do
+  result <- walkBundle bundlePath
+  case result of
+    Left bundleError -> dieText (renderBundleError bundleError)
+    Right concepts -> pure concepts
+
+loadIndexesOrExit :: FilePath -> IO [(FilePath, Text)]
+loadIndexesOrExit bundlePath = do
+  result <- renderBundleIndexes bundlePath
+  case result of
+    Left bundleError -> dieText (renderBundleError bundleError)
+    Right indexes -> pure indexes
+
+renderBundleValidationError :: BundleValidationError -> Text
+renderBundleValidationError = \case
+  DocumentInvalid conceptId error_ ->
+    renderConceptId conceptId <> ": " <> renderValidationErrorText error_
+  DanglingReference source target ->
+    renderConceptId source <> ": link to missing concept: " <> renderConceptId target
+  DuplicateConceptId conceptId ->
+    "duplicate concept ID: " <> renderConceptId conceptId
+
+renderValidationErrorText :: ValidationError -> Text
+renderValidationErrorText = \case
+  MissingRequiredField fieldName -> "missing required field: " <> fieldName
+  FieldMustBeNonEmptyText fieldName -> "field must be non-empty text: " <> fieldName
+  MissingRecommendedField fieldName -> "missing recommended field: " <> fieldName
+  FieldMustBeListOfText fieldName -> "field must be a list of text values: " <> fieldName
+
+renderIndexPreview :: (FilePath, Text) -> IO ()
+renderIndexPreview (path, content) = do
+  Text.IO.putStrLn ("--- " <> Text.pack path)
+  Text.IO.putStr content
+
+renderConcept :: Concept -> IO ()
+renderConcept concept = do
+  Text.IO.putStrLn ("id: " <> renderConceptId (conceptIdOf concept))
+  Text.IO.putStrLn ("type: " <> conceptType concept)
+  traverse_ (Text.IO.putStrLn . ("title: " <>)) (conceptTitle concept)
+  traverse_ (Text.IO.putStrLn . ("description: " <>)) (conceptDescription concept)
+  traverse_ (Text.IO.putStrLn . ("resource: " <>)) (conceptResource concept)
+  unless (null (conceptTags concept)) (Text.IO.putStrLn ("tags: " <> Text.intercalate ", " (conceptTags concept)))
+  Text.IO.putStrLn ""
+  Text.IO.putStr (bodyText concept)
+
+bodyText :: Concept -> Text
+bodyText concept =
+  body (conceptDocument concept)
+
+renderBundleError :: BundleError -> Text
+renderBundleError = \case
+  InvalidConceptPath path error_ -> Text.pack path <> ": " <> renderConceptIdParseError error_
+  InvalidConceptDocument path error_ -> Text.pack path <> ": " <> renderDocumentParseError error_
+  BundleIoError path message -> Text.pack path <> ": " <> message
+
+renderConceptIdError :: Text -> ConceptIdError -> Text
+renderConceptIdError rawId error_ =
+  "Invalid concept ID " <> rawId <> ": " <> renderConceptIdParseError error_
+
+renderConceptIdParseError :: ConceptIdError -> Text
+renderConceptIdParseError = \case
+  EmptyConceptId -> "empty concept ID"
+  InvalidConceptIdSegment segment -> "invalid concept ID segment: " <> segment
+
+renderDocumentParseError :: DocumentParseError -> Text
+renderDocumentParseError = \case
+  UnterminatedFrontmatter -> "unterminated YAML frontmatter"
+  InvalidYaml message -> "invalid YAML frontmatter: " <> message
+  FrontmatterNotMapping -> "frontmatter must be a YAML mapping"
+
+dieText :: Text -> IO a
+dieText message = do
+  Text.IO.hPutStrLn stderr message
+  exitFailure
diff --git a/test/Main.hs b/test/Main.hs
new file mode 100644
--- /dev/null
+++ b/test/Main.hs
@@ -0,0 +1,32 @@
+module Main (main) where
+
+import Control.Monad (unless)
+import Options.Applicative
+import System.Exit (exitFailure)
+
+import Okf.Cli
+
+main :: IO ()
+main = do
+  let results =
+        [ parseSucceeds ["validate", "bundle"]
+        , parseSucceeds ["validate", "bundle", "--strict"]
+        , parseSucceeds ["index", "bundle", "--write"]
+        , parseSucceeds ["graph", "bundle", "--json"]
+        , parseSucceeds ["show", "bundle", "tables/orders"]
+        , parseFails ["hello"]
+        ]
+  unless (and results) exitFailure
+
+parseSucceeds :: [String] -> Bool
+parseSucceeds args =
+  case execParserPure defaultPrefs parserInfo args of
+    Success _ -> True
+    _ -> False
+
+parseFails :: [String] -> Bool
+parseFails args =
+  case execParserPure defaultPrefs parserInfo args of
+    Failure _ -> True
+    CompletionInvoked _ -> True
+    Success _ -> False
