@okfit/cli 0.1.0 → 0.3.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
package/README.md CHANGED
@@ -1,12 +1,341 @@
1
1
  # @okfit/cli
2
2
 
3
- The `okfit` command line for [Open Knowledge Format (OKF)](https://github.com/GoogleCloudPlatform/knowledge-catalog/blob/main/okf/SPEC.md) bundles.
3
+ The `okfit` command line for [Open Knowledge Format (OKF)](https://github.com/GoogleCloudPlatform/knowledge-catalog/blob/main/okf/SPEC.md) v0.2 bundles: `okfit validate`, `okfit init`, `okfit context`, `okfit verify`, and `okfit sync`. The full subcommand list is `okf/interfaces/cli-commands.md`'s to keep, not this sentence's to count.
4
4
 
5
5
  > **Part of the okfit kit.** Most users want **[@okfit/plugin](https://www.npmjs.com/package/@okfit/plugin)**, which pulls this package in automatically.
6
6
 
7
- ## Status
7
+ ## Usage
8
8
 
9
- Skeleton. `okfit --version` and `okfit --help` work; no subcommands yet.
9
+ ```text
10
+ okfit [--help] [--version]
11
+ okfit validate [path] [--config <file>] [--format human|json] [--skip-provenance] [--help]
12
+ okfit init [path] [--profile <name>] [--config <file>] [--help]
13
+ okfit context [path] [--config <file>] [--format human|json] [--help]
14
+ okfit verify <id> [path] [--config <file>] [--at <iso>] [--dry-run] [--format human|json] [--help]
15
+ ```
16
+
17
+ `[path]` is the **project root** on every subcommand — the directory discovery
18
+ starts from, and, for `init`, where `.config/okfit.toml` is written.
19
+ It is never the bundle root; the bundle root is `<project root>/<bundle.path>`
20
+ (`okf` by default). Default `[path]` is the current directory.
21
+
22
+ ### `okfit validate`
23
+
24
+ Loads the config, loads the bundle, runs conformance and lint checks against
25
+ it, runs the resolved profile's own checks, and renders every diagnostic.
26
+
27
+ ```console
28
+ $ cd my-repo && okfit validate
29
+ 0 errors, 0 warnings, 0 info in 1 concepts (okf)
30
+ $ echo $?
31
+ 0
32
+ ```
33
+
34
+ A run that finds diagnostics prints one line per diagnostic to stdout, then
35
+ the summary to stderr:
36
+
37
+ ```console
38
+ $ okfit validate --config ./ci-config.toml
39
+ warning: unknown profile "legacy-project"; continuing with defaults
40
+ (bundle) warning config-unknown-key unknown top-level key "extra_section"
41
+ modules/router.md:12:1 error required-key-missing missing required key "description"
42
+ 1 errors, 1 warnings, 0 info in 4 concepts (okf)
43
+ $ echo $?
44
+ 1
45
+ ```
46
+
47
+ Each diagnostic line is `<file>:<line>:<col> <severity> <code> <message>`
48
+ (one-based line and column) when the diagnostic carries a range, or
49
+ `<file> <severity> <code> <message>` when it does not; a bundle-level
50
+ diagnostic (no file at all) prints `(bundle)` in place of `<file>`.
51
+ Diagnostics sort by file (so `(bundle)` leads), then range-less before
52
+ ranged, then by offset, then by code.
53
+
54
+ `--skip-provenance` skips the `generated-at-drift` lint's git tier
55
+ (`Provenance.lint`) for this one invocation, without touching the
56
+ project's `[lint]` table — the same effect as `generated_at_drift = "off"`
57
+ in config, scoped to a single run. The Claude Code plugin's PostToolUse
58
+ hook passes it on every edit-time `validate` call so a git spawn per
59
+ concept never runs on every keystroke-level edit; CI and the MCP
60
+ `validate_bundle` tool omit the flag and keep the lint.
61
+
62
+ ### `okfit init`
63
+
64
+ Scaffolds a fresh OKF bundle: a thin `.config/okfit.toml`, the
65
+ bundle's root and per-directory `index.md` files, a `project.md` stub, and an
66
+ initial `log.md` entry — then self-validates the result and exits with
67
+ `validate`'s own exit code, so a scaffold that does not validate clean is a
68
+ defect.
69
+
70
+ ```console
71
+ $ mkdir my-repo && cd my-repo && okfit init
72
+ Initialized okf with the software-project profile
73
+ 0 errors, 0 warnings, 0 info in 1 concepts (okf)
74
+ $ echo $?
75
+ 0
76
+ $ find . -type f | sort
77
+ ./.config/okfit.toml
78
+ ./okf/conventions/index.md
79
+ ./okf/decisions/index.md
80
+ ./okf/index.md
81
+ ./okf/interfaces/index.md
82
+ ./okf/log.md
83
+ ./okf/modules/index.md
84
+ ./okf/project.md
85
+ ./okf/references/index.md
86
+ ```
87
+
88
+ `init` never overwrites. If any target path already exists, nothing is
89
+ written:
90
+
91
+ ```console
92
+ $ okfit init
93
+ error: refusing to overwrite existing files:
94
+ .config/okfit.toml
95
+ okf/index.md
96
+ okf/log.md
97
+ okf/project.md
98
+ okf/modules/index.md
99
+ okf/decisions/index.md
100
+ okf/conventions/index.md
101
+ okf/interfaces/index.md
102
+ okf/references/index.md
103
+ Nothing was written.
104
+ $ echo $?
105
+ 3
106
+ ```
107
+
108
+ `init` also refuses when any of the other two project-level names --
109
+ `.okfit.toml` or `okfit.toml` -- already exists in the target directory,
110
+ and names every colliding file in one error. An ancestor directory's config
111
+ is a legitimate discovery hit, not a collision, and is never probed.
112
+
113
+ `--profile <name>` picks the profile `init` scaffolds for (default: the
114
+ config's `bundle.profile`, itself defaulting to `software-project`); an
115
+ unrecognised name is a warning, not a failure — `init` continues with the
116
+ default profile.
117
+
118
+ ### `okfit context`
119
+
120
+ Prints the resolved project root, bundle root, config path, profile, and
121
+ vocabulary (`types` and `tags` from the merged config) without loading the
122
+ bundle. Useful for a script or a Claude Code hook that needs to know where
123
+ the bundle lives before deciding whether to run `okfit validate`.
124
+
125
+ ```console
126
+ $ okfit context
127
+ project root: /abs/path/to/my-repo
128
+ bundle root: /abs/path/to/my-repo/okf
129
+ config: (none)
130
+ profile: software-project
131
+ index.md: /abs/path/to/my-repo/okf/index.md (exists)
132
+ agent: (unset)
133
+
134
+ types:
135
+ Convention A rule contributors and agents must follow.
136
+ Decision A choice made, the alternatives rejected, and why.
137
+ Interface A contract others depend on.
138
+ Module A unit of code with an owner and a boundary.
139
+ Project The repository's root concept: its purpose, boundaries, and non-goals.
140
+ Reference Mirrored external material kept under the references directory.
141
+
142
+ tags:
143
+ architecture Concerns the shape of the system rather than one module.
144
+ performance Concerns speed, memory, or resource cost and the trade-offs made for them.
145
+ release Concerns how changes ship: versioning, changelogs, publishing, and tagging.
146
+ security Concerns trust boundaries, secrets, permissions, or attack surface.
147
+ testing Concerns how the system is verified: strategy, fixtures, and coverage policy.
148
+ ```
149
+
150
+ All paths `humanContext` prints are absolute, exactly as the resolved envelope carries them
151
+ (never relativized to the current directory the way `validate`'s summary line is) — the
152
+ example above reflects that, not a shortened path for readability.
153
+
154
+ `--format json` uses its own envelope, `ContextEnvelope` (schema 1),
155
+ documented in `## --format json` below — never `validate`'s `JsonEnvelope`.
156
+ There is no `--profile` flag on `context`; that one belongs to `init`
157
+ alone. Config discovery, the `--config` pre-flight, and the K-4/K-15
158
+ warnings all behave exactly as `## Config discovery` describes below.
159
+
160
+ ### `okfit verify`
161
+
162
+ Appends one attestation, `{ by: human:<id>, at: <now> }`, to a concept's
163
+ `verified` list and writes the file back. `<id>` is a concept id with or
164
+ without a leading slash or trailing `.md`. The actor is always your own git
165
+ identity, resolved from `user.name`/`user.email` and `[actors].humans`;
166
+ there is no `--by`. Existing entries are never touched or replaced — every
167
+ run appends, including a repeat by the same person. `--at <iso>` records a
168
+ different instant; `--dry-run` runs the same splice and prints the exact
169
+ fragment it would write, under a `would write:` line, without touching the
170
+ file. Exit `0` on success (a dry run included), `3` on any failure.
171
+
172
+ A read-only concept is overwritten anyway — the write goes through a temp
173
+ file and an atomic rename, and the target's mode is preserved on the
174
+ replacement, but the file is not skipped just because it is `chmod`-ed
175
+ read-only.
176
+
177
+ This is a human-run command: it records **your** attestation that you
178
+ reviewed the concept, so no agent, hook, or MCP tool ever invokes it.
179
+
180
+ ## Config discovery
181
+
182
+ With no `--config` flag, `okfit` walks upward from `[path]` (default:
183
+ the current directory). In each directory it checks `<dir>/.okfit.toml`,
184
+ then `<dir>/okfit.toml`, then `<dir>/.config/okfit.toml` before moving
185
+ up one level, so a child directory's `okfit.toml` always beats a
186
+ parent's `.okfit.toml`. Past the project it falls back to
187
+ `$XDG_CONFIG_HOME/okfit/config.toml` (and `$XDG_CONFIG_DIRS`), then the
188
+ OS-native config directory
189
+ (`~/Library/Application Support/okfit/config.toml` on macOS,
190
+ `%APPDATA%\okfit\config.toml` on Windows), then `/etc/okfit/config.toml`
191
+ on Linux and macOS. First match wins; nothing merges across levels, and
192
+ `okfit` never probes for a `.git` directory.
193
+
194
+ The project root anchors on the matched file's own directory -- except
195
+ for `.config/okfit.toml`, which anchors on the parent of `.config`. An
196
+ XDG, native, system-tier or absent config anchors on the current
197
+ directory instead.
198
+
199
+ Because the upward walk never stops at `$HOME`, it reaches `$HOME` itself
200
+ before falling through to the XDG tier. `~/.config/okfit.toml` and
201
+ `~/okfit.toml` are therefore **project-tier** files, not the personal
202
+ defaults they look like: the walk finds them like any other project
203
+ config, wins over the XDG tier, and anchors the project root at `$HOME`
204
+ -- so every project under `$HOME` with no config of its own resolves
205
+ against a stray `~/.config/okfit.toml`. Personal defaults belong at
206
+ `$XDG_CONFIG_HOME/okfit/config.toml` (note the extra `okfit` directory),
207
+ not directly under `~/.config/`.
208
+
209
+ `--config <file>` bypasses discovery entirely — no upward walk, no XDG probe
210
+ — and anchors the project root the same way. An explicit path inside a
211
+ `.config` directory anchors on that directory's parent, exactly as a
212
+ discovered one does. A path that does not exist is a hard failure:
213
+
214
+ ```console
215
+ $ okfit validate --config ./missing.toml
216
+ error: config path not found: /abs/missing.toml
217
+ $ echo $?
218
+ 3
219
+ ```
220
+
221
+ A config whose `okf_version` disagrees with the version this `okfit` speaks
222
+ is a warning, not a failure; the run continues.
223
+
224
+ ## Exit codes
225
+
226
+ | Code | Meaning |
227
+ | --- | --- |
228
+ | `130` | Interrupted (Ctrl-C) |
229
+ | `64` | Usage error: an unknown flag or subcommand |
230
+ | `3` | Infrastructure failure: bad `--config` path, malformed or unreadable config, unset `HOME`, unreadable bundle root, or `init`'s overwrite refusal |
231
+ | `2` | One or more conformance diagnostics of severity error |
232
+ | `1` | One or more lint or profile diagnostics of severity error |
233
+ | `0` | Otherwise |
234
+
235
+ Higher wins when several apply. Warnings and info never change the exit
236
+ code.
237
+
238
+ `okfit context` never produces `1` or `2`: it prints orientation data and
239
+ never runs conformance or lint checks.
240
+
241
+ ## `--format json`
242
+
243
+ `okfit validate --format json` prints exactly one JSON document to stdout and
244
+ nothing else (no summary line, no warnings — those still go to stderr):
245
+
246
+ ```json
247
+ {
248
+ "schema": 1,
249
+ "okfit_version": "0.1.0",
250
+ "okf_version": "0.2",
251
+ "root": "/abs/path/to/my-repo/okf",
252
+ "profile": "software-project",
253
+ "exit_code": 1,
254
+ "summary": {
255
+ "conformance_errors": 0,
256
+ "lint_errors": 1,
257
+ "lint_warnings": 1,
258
+ "lint_info": 0,
259
+ "profile_errors": 0,
260
+ "concepts": 4
261
+ },
262
+ "diagnostics": [
263
+ {
264
+ "source": "core.lint",
265
+ "file": "",
266
+ "code": "config-unknown-key",
267
+ "severity": "warning",
268
+ "message": "unknown top-level key \"extra_section\""
269
+ },
270
+ {
271
+ "source": "core.lint",
272
+ "file": "modules/router.md",
273
+ "code": "required-key-missing",
274
+ "severity": "error",
275
+ "message": "missing required key \"description\"",
276
+ "range": { "offset": 87, "length": 9, "line": 11, "character": 0 }
277
+ }
278
+ ]
279
+ }
280
+ ```
281
+
282
+ `profile` is `null` when the config sets `bundle.profile = "none"` or names a
283
+ profile `okfit` does not recognise. `range` is zero-based, exactly as
284
+ `@okfit/core` computed it, and omitted for a range-less diagnostic.
285
+
286
+ An infrastructure failure under `--format json` prints a different, smaller
287
+ envelope to stdout and exits `3`:
288
+
289
+ ```json
290
+ { "schema": 1, "okfit_version": "0.1.0", "exit_code": 3, "error": { "tag": "ConfigPathNotFoundError", "message": "config path not found: /abs/ci-config.toml" } }
291
+ ```
292
+
293
+ `init` has no `--format`; it is human output only.
294
+
295
+ `okfit context --format json` prints its own envelope, distinct from the
296
+ one above:
297
+
298
+ ```json
299
+ {
300
+ "schema": 1,
301
+ "project_root": "/abs/path/to/my-repo",
302
+ "bundle_root": "/abs/path/to/my-repo/okf",
303
+ "config_path": null,
304
+ "profile": "software-project",
305
+ "profile_requested": null,
306
+ "index_path": "/abs/path/to/my-repo/okf/index.md",
307
+ "index_exists": true,
308
+ "actors": { "agent": null },
309
+ "types": [
310
+ { "name": "Project", "description": "The repository's root concept: its purpose, boundaries, and non-goals.", "guidance": "..." }
311
+ ],
312
+ "tags": [
313
+ { "name": "architecture", "description": "Concerns the shape of the system rather than one module." }
314
+ ]
315
+ }
316
+ ```
317
+
318
+ Every field is present, even when unset (`config_path`, `profile`,
319
+ `profile_requested`, and `actors.agent` are `null`, never an omitted key) --
320
+ unlike `JsonDiagnostic`'s `range`, this envelope has no optional keys at all.
321
+
322
+ `profile_requested` is the profile name the config asked for after the
323
+ default rule, and is `null` only when no config file was found at all.
324
+ `profile` keeps its original meaning: the resolved profile's name, or
325
+ `null` when the requested name is unknown (or the config sets
326
+ `bundle.profile = "none"`). The two differ only when a config names a
327
+ profile `okfit` does not recognise — `profile` is `null` but
328
+ `profile_requested` still names what was asked for, so `okfit context
329
+ --format human` prints `profile: (none) (requested <name>, unknown)` in
330
+ that one case.
331
+
332
+ ## Message conventions
333
+
334
+ Every message `okfit` prints is lowercase, starts `error:` or `warning:`,
335
+ never ends with a trailing period, and renders a path relative to the
336
+ current directory when the path is under it, absolute otherwise. Colour, when
337
+ stdout is a TTY and `NO_COLOR` is not `1`, wraps only the severity word —
338
+ never the code, the path, or the message text.
10
339
 
11
340
  ## License
12
341
 
package/bin/okfit.js CHANGED
@@ -1,10 +1,5 @@
1
1
  #!/usr/bin/env node
2
- import { rootCommand } from "../commands/root.js";
3
- import { CLI_VERSION } from "../index.js";
4
- import { Effect } from "effect";
5
- import { Command } from "effect/unstable/cli";
6
- import * as NodeRuntime from "@effect/platform-node/NodeRuntime";
7
- import * as NodeServices from "@effect/platform-node/NodeServices";
2
+ import { main } from "../main.js";
8
3
 
9
4
  //#region src/bin.ts
10
5
  /**
@@ -12,8 +7,7 @@ import * as NodeServices from "@effect/platform-node/NodeServices";
12
7
  *
13
8
  * @packageDocumentation
14
9
  */
15
- const cli = Command.run(rootCommand, { version: CLI_VERSION });
16
- NodeRuntime.runMain(cli.pipe(Effect.provide(NodeServices.layer)));
10
+ main();
17
11
 
18
12
  //#endregion
19
13
  export { };
@@ -0,0 +1,75 @@
1
+ import { setExitCode } from "../internal/exit.js";
2
+ import { humanContext } from "../render/context.js";
3
+ import { CLI_VERSION } from "../version.js";
4
+ import { Argument, Command, Flag } from "effect/unstable/cli";
5
+ import { ContextEnvelope, contextEnvelope, jsonError, provideConfig, resolveProjectConfig, runContext } from "@okfit/engine";
6
+ import { Console, Effect, Option, Schema } from "effect";
7
+
8
+ //#region src/commands/context.ts
9
+ /** `[path]` is the PROJECT root (K-2), never the bundle root. Absolute at parse time (K-50). */
10
+ const pathArg = Argument.path("path", { pathType: "directory" }).pipe(Argument.optional, Argument.withDescription("project root to start config discovery from (default: current directory); never the bundle root"));
11
+ /** K-1: no `mustExist` — the handler stats the path itself, before building any layer. */
12
+ const configFlag = Flag.file("config").pipe(Flag.optional, Flag.withDescription("explicit config file; skips discovery"));
13
+ /** No `--profile` flag: M-15's exact ruling; that flag belongs to `init` alone. */
14
+ const formatFlag = Flag.choice("format", ["human", "json"]).pipe(Flag.withDefault("human"), Flag.withDescription("output format: human (default) or json"));
15
+ /**
16
+ * `okfit context [path] [--config <file>] [--format human|json]`.
17
+ *
18
+ * Handler order fixed by the contract (§8.3): steps 1–7 are
19
+ * `validateCommand`'s handler in substance (both now share
20
+ * `@okfit/engine`'s `resolveProjectConfig` — A4) — stat `--config` (K-1) via
21
+ * `provideConfig`, discover (`OkfitConfigFile.discover`), resolve the
22
+ * profile with the K-4 warning, merge `DEFAULTS < profile < file` (D-28),
23
+ * warn on an `okf_version` mismatch (K-15), resolve the project and bundle
24
+ * roots (K-12) — then it diverges: `runContext` (index.md stat only,
25
+ * never `Bundle.load`), build the envelope, render, and always exit `0`
26
+ * (there is no content tier: `context` never runs conformance or lint
27
+ * checks, so C-3.5's `1`/`2` codes have no analogue here).
28
+ *
29
+ * @public
30
+ */
31
+ const contextCommand = Command.make("context", {
32
+ path: pathArg,
33
+ config: configFlag,
34
+ format: formatFlag
35
+ }, (input) => Effect.gen(function* () {
36
+ const cwd = process.cwd();
37
+ const discoveryCwd = Option.getOrElse(input.path, () => cwd);
38
+ const body = Effect.gen(function* () {
39
+ const { projectRoot, bundleRoot, config: merged, profile, profileName, discovered } = yield* resolveProjectConfig({
40
+ pathArg: input.path,
41
+ explicitConfigPath: input.config,
42
+ cwd
43
+ });
44
+ const { indexPath, indexExists } = yield* runContext({ bundleRoot });
45
+ const configPath = Option.match(discovered, {
46
+ onNone: () => Option.isSome(input.config) ? input.config.value : null,
47
+ onSome: (source) => source.path
48
+ });
49
+ const profileRequested = configPath === null ? null : profileName;
50
+ const envelope = contextEnvelope({
51
+ projectRoot,
52
+ bundleRoot,
53
+ configPath,
54
+ profile: Option.match(profile, {
55
+ onNone: () => null,
56
+ onSome: (p) => p.name
57
+ }),
58
+ profileRequested,
59
+ indexPath,
60
+ indexExists,
61
+ config: merged
62
+ });
63
+ if (input.format === "json") yield* Console.log(JSON.stringify(Schema.encodeSync(ContextEnvelope)(envelope)));
64
+ else for (const contextLine of humanContext(envelope)) yield* Console.log(contextLine);
65
+ setExitCode(0);
66
+ }).pipe(provideConfig({
67
+ explicitConfigPath: input.config,
68
+ discoveryCwd
69
+ }));
70
+ if (input.format === "json") return yield* body.pipe(Effect.tapError((error) => Console.log(JSON.stringify(jsonError(error, CLI_VERSION)))));
71
+ return yield* body;
72
+ })).pipe(Command.withDescription("Print the resolved project root, bundle root, config path, profile, and type/tag vocabulary without loading the bundle."));
73
+
74
+ //#endregion
75
+ export { contextCommand };
@@ -0,0 +1,171 @@
1
+ import { setExitCode } from "../internal/exit.js";
2
+ import { useColor } from "../internal/tty.js";
3
+ import { displayRoot, human, summary } from "../render/human.js";
4
+ import { Argument, Command, Flag } from "effect/unstable/cli";
5
+ import { CONFIG_RELATIVE_PATH, InitOverwriteError, Now, SCHEMA_DIRECTIVE, collect, configValue, files, forDiagnostics, provideConfig, resolveBundleRoot, resolveProjectRoot, run, targetPaths } from "@okfit/engine";
6
+ import { Console, DateTime, Effect, FileSystem, Layer, Option, Path, Schema } from "effect";
7
+ import { TomlCodec } from "@effected/config-file";
8
+ import { Git } from "@effected/git";
9
+ import { OKF_SPEC_VERSION, OkfitConfig, OkfitConfigFile } from "@okfit/core";
10
+ import { GitHistory, Profiles } from "@okfit/profiles";
11
+
12
+ //#region src/commands/init.ts
13
+ /**
14
+ * `[path]` is the PROJECT root (K-2), never the bundle root — identical to
15
+ * `validate`'s own argument (`Argument.path` resolves it absolute at the
16
+ * parse boundary, satisfying K-50).
17
+ */
18
+ const pathArg = Argument.path("path", { pathType: "directory" }).pipe(Argument.optional, Argument.withDescription("project root to start config discovery from (default: current directory); never the bundle root"));
19
+ /** K-1: no `mustExist` — existence is checked by `provideConfig`, identical to `validate`'s flag. */
20
+ const configFlag = Flag.file("config").pipe(Flag.optional, Flag.withDescription("explicit config file; skips discovery"));
21
+ /**
22
+ * K-3: `Flag.string`, deliberately not `Flag.choice` against
23
+ * `PROFILE_NAMES` (`PROFILES/Profile.ts:10`) — an unrecognised name is a
24
+ * CLI-rendered warning (K-4), matching `Profiles.get`'s own `Option.none`
25
+ * contract (P-38), not a parser-level `CliError.InvalidValue`.
26
+ */
27
+ const profileFlag = Flag.string("profile").pipe(Flag.optional, Flag.withDescription("profile to scaffold with (default: the config's bundle.profile or software-project)"));
28
+ /**
29
+ * Stands in for `profile.config` when `Profiles.get` returns `None`.
30
+ * `OkfitConfig.merge` only visits `Object.keys(override)`
31
+ * (`CORE/OkfitConfig.ts:229-245`), so merging this in leaves every other key
32
+ * at the base's value — it exists only to satisfy `merge`'s signature.
33
+ */
34
+ const NO_PROFILE_CONFIG = { extensions: {} };
35
+ /**
36
+ * `bundle` and `bundle.profile`/`bundle.path` are all `optionalKey` in the
37
+ * schema (`CORE/OkfitConfig.ts`), so the type checker sees `string |
38
+ * undefined` two levels deep even though `OkfitConfig.DEFAULTS` always sets
39
+ * both at runtime — the same `??` fallback `commands/validate.ts`'s own
40
+ * `DEFAULT_PROFILE_NAME` uses.
41
+ */
42
+ const DEFAULT_PROFILE_NAME = OkfitConfig.DEFAULTS.bundle?.profile ?? "software-project";
43
+ /** See {@link DEFAULT_PROFILE_NAME}. */
44
+ const DEFAULT_BUNDLE_PATH = OkfitConfig.DEFAULTS.bundle?.path ?? "okf";
45
+ const countsOf = (diagnostics, concepts) => ({
46
+ errors: diagnostics.filter((diagnostic) => diagnostic.severity === "error").length,
47
+ warnings: diagnostics.filter((diagnostic) => diagnostic.severity === "warning").length,
48
+ info: diagnostics.filter((diagnostic) => diagnostic.severity === "info").length,
49
+ concepts
50
+ });
51
+ /**
52
+ * `okfit init [path] [--profile <name>] [--config <file>]` (K-2, K-3; no
53
+ * `--format`, human output only, K-6).
54
+ *
55
+ * Handler order, fixed by the contract:
56
+ *
57
+ * 1. `cwd = process.cwd()`; `discoveryCwd = Option.getOrElse(path, () => cwd)`.
58
+ * 2. `provideConfig({ explicitConfigPath: config, discoveryCwd })` wraps
59
+ * everything from step 3 on, identical to `validate`'s own pre-flight
60
+ * (K-57).
61
+ * 3. `sources = yield* (yield* OkfitConfigFile).discover`; the winner is
62
+ * `sources[0]`.
63
+ * 4. `profileName = Option.getOrElse(input.profile, () =>
64
+ * fileConfig.bundle?.profile ?? DEFAULTS.bundle.profile)` (K-3, the one
65
+ * difference from `validate`'s step 4). `Profiles.get(profileName)`.
66
+ * `None` and `profileName !== "none"` warns (K-4); `"none"` is silent.
67
+ * 5. `merged = OkfitConfig.merge(OkfitConfig.merge(DEFAULTS, profileConfig),
68
+ * fileConfig)`, `profileConfig` falling back to `NO_PROFILE_CONFIG` when
69
+ * no profile resolved.
70
+ * 6. `merged.okf_version !== OKF_SPEC_VERSION` warns (K-15).
71
+ * 7. `projectRoot`/`bundleRoot` via `@okfit/engine`'s `config/anchor.ts`; `now = yield* Now`;
72
+ * `layout` falls back to `Profiles.softwareProject.layout` when no
73
+ * profile resolved (this file's own decision 2 above — `Layout` has no
74
+ * `DEFAULTS` equivalent).
75
+ * 8. `paths = targetPaths(...)`; every path stat-ed; ANY existing fails
76
+ * with `InitOverwriteError` before a single byte is written (K-28).
77
+ * 9. `mkdir -p` every target's parent, THEN write `configValue(...)`
78
+ * through `OkfitConfigFile.write` and every `files(...)` entry through
79
+ * `fs.writeFileString` — `OkfitConfigFile.write` deliberately does not
80
+ * create its parent (`CF/index.d.ts:517-521`), and `save` is unusable
81
+ * here since it targets the XDG `defaultPath`, which K-14 forbids.
82
+ * 10. `Console.log` the K-51 success line.
83
+ * 11. self-validate (K-29): `run({ root: bundleRoot, config: merged,
84
+ * profile, now })` over the bundle just written, rendered exactly as
85
+ * `validate --format human` does, `setExitCode` to its
86
+ * `forDiagnostics` result. The handler SUCCEEDS (K-7); the failure path
87
+ * is only `InitOverwriteError` at step 8, or an infrastructure error
88
+ * that already carries its own `[Runtime.errorExitCode]`.
89
+ *
90
+ * @public
91
+ */
92
+ const initCommand = Command.make("init", {
93
+ path: pathArg,
94
+ config: configFlag,
95
+ profile: profileFlag
96
+ }, (input) => Effect.gen(function* () {
97
+ const cwd = process.cwd();
98
+ const discoveryCwd = Option.getOrElse(input.path, () => cwd);
99
+ yield* provideConfig({
100
+ explicitConfigPath: input.config,
101
+ discoveryCwd
102
+ })(Effect.gen(function* () {
103
+ const winner = (yield* (yield* OkfitConfigFile).discover)[0];
104
+ const fileConfig = winner === void 0 ? { extensions: {} } : winner.value;
105
+ const profileName = Option.getOrElse(input.profile, () => fileConfig.bundle?.profile ?? DEFAULT_PROFILE_NAME);
106
+ const profile = Profiles.get(profileName);
107
+ if (Option.isNone(profile) && profileName !== "none") yield* Console.error(`warning: unknown profile "${profileName}"; continuing with defaults`);
108
+ const profileConfig = Option.match(profile, {
109
+ onNone: () => NO_PROFILE_CONFIG,
110
+ onSome: (resolved) => resolved.config
111
+ });
112
+ const merged = OkfitConfig.merge(OkfitConfig.merge(OkfitConfig.DEFAULTS, profileConfig), fileConfig);
113
+ if (merged.okf_version !== void 0 && merged.okf_version !== OKF_SPEC_VERSION) yield* Console.error(`warning: okf_version "${merged.okf_version}" does not match this okfit's spec version "${OKF_SPEC_VERSION}"; continuing`);
114
+ const path = yield* Path.Path;
115
+ const fs = yield* FileSystem.FileSystem;
116
+ const projectRoot = resolveProjectRoot({
117
+ pathArg: input.path,
118
+ explicitConfigPath: input.config,
119
+ discovered: winner === void 0 ? Option.none() : Option.some({
120
+ path: winner.path,
121
+ resolver: winner.resolver
122
+ }),
123
+ cwd,
124
+ path
125
+ });
126
+ const bundleRoot = resolveBundleRoot(projectRoot, merged, path);
127
+ const now = yield* Now;
128
+ const scaffoldOptions = {
129
+ projectRoot,
130
+ bundleRoot,
131
+ layout: Option.match(profile, {
132
+ onNone: () => Profiles.softwareProject.layout,
133
+ onSome: (resolved) => resolved.layout
134
+ }),
135
+ profileName,
136
+ projectTitle: path.basename(projectRoot),
137
+ today: DateTime.formatIso(now).slice(0, 10)
138
+ };
139
+ const paths = targetPaths(scaffoldOptions);
140
+ const existing = [];
141
+ for (const target of paths) if (yield* fs.exists(target)) existing.push(target);
142
+ if (existing.length > 0) return yield* new InitOverwriteError({
143
+ paths: existing,
144
+ cwd
145
+ });
146
+ for (const target of paths) yield* fs.makeDirectory(path.dirname(target), { recursive: true });
147
+ const bundlePath = merged.bundle?.path ?? DEFAULT_BUNDLE_PATH;
148
+ const encoded = yield* Schema.encodeEffect(OkfitConfig)(configValue({
149
+ ...scaffoldOptions,
150
+ bundlePath
151
+ }));
152
+ const toml = yield* TomlCodec.stringify(encoded);
153
+ yield* fs.writeFileString(`${projectRoot}/${CONFIG_RELATIVE_PATH}`, `${SCHEMA_DIRECTIVE}${toml}`);
154
+ const scaffoldFiles = yield* files(scaffoldOptions);
155
+ for (const file of scaffoldFiles) yield* fs.writeFileString(file.path, file.contents);
156
+ yield* Console.log(`Initialized ${displayRoot(cwd, bundleRoot, path)} with the ${profileName} profile`);
157
+ const result = yield* run({
158
+ root: bundleRoot,
159
+ config: merged,
160
+ profile,
161
+ now
162
+ }).pipe(Effect.provide(Layer.mergeAll(Git.layer, GitHistory.layer)));
163
+ const diagnostics = collect(result.report.conformance, result.report.lint, result.profileDiagnostics);
164
+ for (const line of human(diagnostics, { color: useColor() })) yield* Console.log(line);
165
+ yield* Console.error(summary(countsOf(diagnostics, result.bundle.concepts.size), displayRoot(cwd, bundleRoot, path)));
166
+ setExitCode(forDiagnostics(diagnostics));
167
+ }));
168
+ })).pipe(Command.withDescription("Scaffold a new OKF bundle: a config file, the bundle's root and per-directory index files, a project stub, and an initial log entry."));
169
+
170
+ //#endregion
171
+ export { initCommand };
package/commands/root.js CHANGED
@@ -1,13 +1,36 @@
1
- import { Console } from "effect";
1
+ import { contextCommand } from "./context.js";
2
+ import { initCommand } from "./init.js";
3
+ import { syncCommand } from "./sync.js";
4
+ import { validateCommand } from "./validate.js";
5
+ import { verifyCommand } from "./verify.js";
2
6
  import { Command } from "effect/unstable/cli";
3
7
 
4
8
  //#region src/commands/root.ts
5
9
  /**
6
- * The `okfit` root command. Subcommands are attached in later releases.
10
+ * K-5. No handler: the framework's own default for a command with neither a
11
+ * `handle` nor a matched subcommand is
12
+ * `Effect.fail(new CliError.ShowHelp({ commandPath, errors: [] }))`, and a
13
+ * `ShowHelp` with no errors carries `[Runtime.errorExitCode] = 0`. Bare
14
+ * `okfit` therefore prints the root help and exits `0` with no code in this
15
+ * package at all.
16
+ *
17
+ * `validate`, `init`, `context`, `verify`, and `sync` are the whole command tree;
18
+ * nothing else is registered here. Each is appended in introduction order,
19
+ * never reordered in, so `--help`'s subcommand list reads that way too
20
+ * (contract §4.2). The top-level description is left unchanged: neither
21
+ * `context` nor `verify` validates or scaffolds, but widening the sentence
22
+ * for the orientation- and attestation-only commands buys nothing
23
+ * (contract §4.2).
7
24
  *
8
25
  * @public
9
26
  */
10
- const rootCommand = Command.make("okfit", {}, () => Console.log("okfit: no subcommands are available yet. Run `okfit --help`."));
27
+ const rootCommand = Command.make("okfit", {}).pipe(Command.withDescription("Open Knowledge Format (OKF) v0.2 tooling: validate and scaffold bundles."), Command.withSubcommands([
28
+ validateCommand,
29
+ initCommand,
30
+ contextCommand,
31
+ verifyCommand,
32
+ syncCommand
33
+ ]));
11
34
 
12
35
  //#endregion
13
36
  export { rootCommand };