@okfit/cli 0.1.0 → 0.2.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,14 @@
1
1
  #!/usr/bin/env node
2
+ import { renderFailure } from "../errors.js";
3
+ import { CLI_VERSION } from "../version.js";
4
+ import { Now } from "../validate/run.js";
2
5
  import { rootCommand } from "../commands/root.js";
3
- import { CLI_VERSION } from "../index.js";
4
- import { Effect } from "effect";
5
6
  import { Command } from "effect/unstable/cli";
7
+ import { DateTime, Effect, Layer, Option } from "effect";
8
+ import { CliLogger, CliRuntime } from "@effected/cli";
6
9
  import * as NodeRuntime from "@effect/platform-node/NodeRuntime";
7
10
  import * as NodeServices from "@effect/platform-node/NodeServices";
11
+ import { AppDirs, Xdg } from "@effected/xdg";
8
12
 
9
13
  //#region src/bin.ts
10
14
  /**
@@ -12,8 +16,47 @@ import * as NodeServices from "@effect/platform-node/NodeServices";
12
16
  *
13
17
  * @packageDocumentation
14
18
  */
15
- const cli = Command.run(rootCommand, { version: CLI_VERSION });
16
- NodeRuntime.runMain(cli.pipe(Effect.provide(NodeServices.layer)));
19
+ /**
20
+ * K-9: `AppConfig.layer` only, built by Group B's `config/layer.ts` —
21
+ * `Xdg` and `AppDirs` are provided once, here, for both commands; no
22
+ * `Store`, no `Cache`, so no `store.db`/`cache.db` is ever created.
23
+ *
24
+ * `AppDirs.layer(options)` requires `Xdg | FileSystem | Path`
25
+ * (`XDG/index.d.ts:320`), so `Layer.provide(Xdg.layer)` alone does not close
26
+ * it: `Layer.provideMerge(NodeServices.layer)` supplies `FileSystem`/`Path`
27
+ * to both members and keeps every service in the output. `NodeServices.layer`
28
+ * provides `ChildProcessSpawner | Crypto | FileSystem | Path | Stdio |
29
+ * Terminal`, a superset of `Command.Environment`.
30
+ *
31
+ * `Xdg.layer` fails with `XdgEnvError` when `HOME` is unset (K-13). That
32
+ * error reaches `reportFailures` and takes its `exitCode: 3` fallback, with
33
+ * no special case anywhere — but only because `Effect.provide(PlatformLayer)`
34
+ * is applied INSIDE the region `CliRuntime.reportFailures` wraps, below.
35
+ * `@effected/cli`'s own doc example provides its layer after
36
+ * `reportFailures`; doing that here would let a failure while building this
37
+ * layer (an unset `HOME`, say) escape reportFailures entirely and fall to
38
+ * `NodeRuntime.runMain`'s own fatal-error path — a stack trace on stdout and
39
+ * exit `1`, not the rendered `exitCode: 3` this module promises.
40
+ */
41
+ const PlatformLayer = Layer.mergeAll(Xdg.layer, AppDirs.layer({ namespace: "okfit" }).pipe(Layer.provide(Xdg.layer))).pipe(Layer.provideMerge(NodeServices.layer));
42
+ /**
43
+ * K-47: an ISO-8601 `OKFIT_NOW` when set, else the wall clock. A documented
44
+ * test hook, not user-facing. Resolved exactly once, here, and provided to
45
+ * the whole command tree through the `Now` tag so no command handler ever
46
+ * reads `process.env["OKFIT_NOW"]` itself.
47
+ */
48
+ const nowEffect = Option.fromNullishOr(process.env.OKFIT_NOW).pipe(Option.flatMap((iso) => DateTime.make(iso)), Option.match({
49
+ onNone: () => DateTime.now,
50
+ onSome: Effect.succeed
51
+ }));
52
+ const program = Effect.gen(function* () {
53
+ const now = yield* nowEffect;
54
+ return yield* Command.run(rootCommand, { version: CLI_VERSION }).pipe(Effect.provideService(Now, now), Effect.catchTag("ShowHelp", (help) => Effect.fail(CliRuntime.reported(help, help.errors.length > 0 ? 64 : 0))));
55
+ }).pipe(Effect.provide(PlatformLayer), CliRuntime.reportFailures({
56
+ exitCode: 3,
57
+ render: renderFailure
58
+ }));
59
+ NodeRuntime.runMain(program.pipe(Effect.provide(CliLogger.layer())));
17
60
 
18
61
  //#endregion
19
62
  export { };
@@ -0,0 +1,78 @@
1
+ import { provideConfig } from "../config/layer.js";
2
+ import { resolveProjectConfig } from "../config/resolve.js";
3
+ import { runContext } from "../context/run.js";
4
+ import { setExitCode } from "../internal/exit.js";
5
+ import { ContextEnvelope, contextEnvelope, humanContext } from "../render/context.js";
6
+ import { jsonError } from "../render/json.js";
7
+ import { CLI_VERSION } from "../version.js";
8
+ import { Argument, Command, Flag } from "effect/unstable/cli";
9
+ import { Console, Effect, Option, Schema } from "effect";
10
+
11
+ //#region src/commands/context.ts
12
+ /** `[path]` is the PROJECT root (K-2), never the bundle root. Absolute at parse time (K-50). */
13
+ 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"));
14
+ /** K-1: no `mustExist` — the handler stats the path itself, before building any layer. */
15
+ const configFlag = Flag.file("config").pipe(Flag.optional, Flag.withDescription("explicit config file; skips discovery"));
16
+ /** No `--profile` flag: M-15's exact ruling; that flag belongs to `init` alone. */
17
+ const formatFlag = Flag.choice("format", ["human", "json"]).pipe(Flag.withDefault("human"), Flag.withDescription("output format: human (default) or json"));
18
+ /**
19
+ * `okfit context [path] [--config <file>] [--format human|json]`.
20
+ *
21
+ * Handler order fixed by the contract (§8.3): steps 1–7 are
22
+ * `validateCommand`'s handler in substance (both now share
23
+ * `config/resolve.ts#resolveProjectConfig` — A4) — stat `--config` (K-1) via
24
+ * `provideConfig`, discover (`OkfitConfigFile.discover`), resolve the
25
+ * profile with the K-4 warning, merge `DEFAULTS < profile < file` (D-28),
26
+ * warn on an `okf_version` mismatch (K-15), resolve the project and bundle
27
+ * roots (K-12) — then it diverges: `runContext` (index.md stat only,
28
+ * never `Bundle.load`), build the envelope, render, and always exit `0`
29
+ * (there is no content tier: `context` never runs conformance or lint
30
+ * checks, so C-3.5's `1`/`2` codes have no analogue here).
31
+ *
32
+ * @public
33
+ */
34
+ const contextCommand = Command.make("context", {
35
+ path: pathArg,
36
+ config: configFlag,
37
+ format: formatFlag
38
+ }, (input) => Effect.gen(function* () {
39
+ const cwd = process.cwd();
40
+ const discoveryCwd = Option.getOrElse(input.path, () => cwd);
41
+ const body = Effect.gen(function* () {
42
+ const { projectRoot, bundleRoot, config: merged, profile, profileName, discovered } = yield* resolveProjectConfig({
43
+ pathArg: input.path,
44
+ explicitConfigPath: input.config,
45
+ cwd
46
+ });
47
+ const { indexPath, indexExists } = yield* runContext({ bundleRoot });
48
+ const configPath = Option.match(discovered, {
49
+ onNone: () => Option.isSome(input.config) ? input.config.value : null,
50
+ onSome: (source) => source.path
51
+ });
52
+ const profileRequested = configPath === null ? null : profileName;
53
+ const envelope = contextEnvelope({
54
+ projectRoot,
55
+ bundleRoot,
56
+ configPath,
57
+ profile: Option.match(profile, {
58
+ onNone: () => null,
59
+ onSome: (p) => p.name
60
+ }),
61
+ profileRequested,
62
+ indexPath,
63
+ indexExists,
64
+ config: merged
65
+ });
66
+ if (input.format === "json") yield* Console.log(JSON.stringify(Schema.encodeSync(ContextEnvelope)(envelope)));
67
+ else for (const contextLine of humanContext(envelope)) yield* Console.log(contextLine);
68
+ setExitCode(0);
69
+ }).pipe(provideConfig({
70
+ explicitConfigPath: input.config,
71
+ discoveryCwd
72
+ }));
73
+ if (input.format === "json") return yield* body.pipe(Effect.tapError((error) => Console.log(JSON.stringify(jsonError(error, CLI_VERSION)))));
74
+ return yield* body;
75
+ })).pipe(Command.withDescription("Print the resolved project root, bundle root, config path, profile, and type/tag vocabulary without loading the bundle."));
76
+
77
+ //#endregion
78
+ export { contextCommand };
@@ -0,0 +1,177 @@
1
+ import { InitOverwriteError } from "../errors.js";
2
+ import { provideConfig } from "../config/layer.js";
3
+ import { resolveBundleRoot, resolveProjectRoot } from "../config/anchor.js";
4
+ import { setExitCode } from "../internal/exit.js";
5
+ import { forDiagnostics } from "../render/exit.js";
6
+ import { collect } from "../render/sort.js";
7
+ import { CONFIG_RELATIVE_PATH, SCHEMA_DIRECTIVE, configValue, files, targetPaths } from "../init/scaffold.js";
8
+ import { useColor } from "../internal/tty.js";
9
+ import { displayRoot, human, summary } from "../render/human.js";
10
+ import { Now, run } from "../validate/run.js";
11
+ import { Argument, Command, Flag } from "effect/unstable/cli";
12
+ import { Console, DateTime, Effect, FileSystem, Layer, Option, Path, Schema } from "effect";
13
+ import { TomlCodec } from "@effected/config-file";
14
+ import { OKF_SPEC_VERSION, OkfitConfig, OkfitConfigFile } from "@okfit/core";
15
+ import { GitHistory, Profiles } from "@okfit/profiles";
16
+ import { Git } from "@effected/git";
17
+
18
+ //#region src/commands/init.ts
19
+ /**
20
+ * `[path]` is the PROJECT root (K-2), never the bundle root — identical to
21
+ * `validate`'s own argument (`Argument.path` resolves it absolute at the
22
+ * parse boundary, satisfying K-50).
23
+ */
24
+ 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"));
25
+ /** K-1: no `mustExist` — existence is checked by `provideConfig`, identical to `validate`'s flag. */
26
+ const configFlag = Flag.file("config").pipe(Flag.optional, Flag.withDescription("explicit config file; skips discovery"));
27
+ /**
28
+ * K-3: `Flag.string`, deliberately not `Flag.choice` against
29
+ * `PROFILE_NAMES` (`PROFILES/Profile.ts:10`) — an unrecognised name is a
30
+ * CLI-rendered warning (K-4), matching `Profiles.get`'s own `Option.none`
31
+ * contract (P-38), not a parser-level `CliError.InvalidValue`.
32
+ */
33
+ const profileFlag = Flag.string("profile").pipe(Flag.optional, Flag.withDescription("profile to scaffold with (default: the config's bundle.profile or software-project)"));
34
+ /**
35
+ * Stands in for `profile.config` when `Profiles.get` returns `None`.
36
+ * `OkfitConfig.merge` only visits `Object.keys(override)`
37
+ * (`CORE/OkfitConfig.ts:229-245`), so merging this in leaves every other key
38
+ * at the base's value — it exists only to satisfy `merge`'s signature.
39
+ */
40
+ const NO_PROFILE_CONFIG = { extensions: {} };
41
+ /**
42
+ * `bundle` and `bundle.profile`/`bundle.path` are all `optionalKey` in the
43
+ * schema (`CORE/OkfitConfig.ts`), so the type checker sees `string |
44
+ * undefined` two levels deep even though `OkfitConfig.DEFAULTS` always sets
45
+ * both at runtime — the same `??` fallback `commands/validate.ts`'s own
46
+ * `DEFAULT_PROFILE_NAME` uses.
47
+ */
48
+ const DEFAULT_PROFILE_NAME = OkfitConfig.DEFAULTS.bundle?.profile ?? "software-project";
49
+ /** See {@link DEFAULT_PROFILE_NAME}. */
50
+ const DEFAULT_BUNDLE_PATH = OkfitConfig.DEFAULTS.bundle?.path ?? "okf";
51
+ const countsOf = (diagnostics, concepts) => ({
52
+ errors: diagnostics.filter((diagnostic) => diagnostic.severity === "error").length,
53
+ warnings: diagnostics.filter((diagnostic) => diagnostic.severity === "warning").length,
54
+ info: diagnostics.filter((diagnostic) => diagnostic.severity === "info").length,
55
+ concepts
56
+ });
57
+ /**
58
+ * `okfit init [path] [--profile <name>] [--config <file>]` (K-2, K-3; no
59
+ * `--format`, human output only, K-6).
60
+ *
61
+ * Handler order, fixed by the contract:
62
+ *
63
+ * 1. `cwd = process.cwd()`; `discoveryCwd = Option.getOrElse(path, () => cwd)`.
64
+ * 2. `provideConfig({ explicitConfigPath: config, discoveryCwd })` wraps
65
+ * everything from step 3 on, identical to `validate`'s own pre-flight
66
+ * (K-57).
67
+ * 3. `sources = yield* (yield* OkfitConfigFile).discover`; the winner is
68
+ * `sources[0]`.
69
+ * 4. `profileName = Option.getOrElse(input.profile, () =>
70
+ * fileConfig.bundle?.profile ?? DEFAULTS.bundle.profile)` (K-3, the one
71
+ * difference from `validate`'s step 4). `Profiles.get(profileName)`.
72
+ * `None` and `profileName !== "none"` warns (K-4); `"none"` is silent.
73
+ * 5. `merged = OkfitConfig.merge(OkfitConfig.merge(DEFAULTS, profileConfig),
74
+ * fileConfig)`, `profileConfig` falling back to `NO_PROFILE_CONFIG` when
75
+ * no profile resolved.
76
+ * 6. `merged.okf_version !== OKF_SPEC_VERSION` warns (K-15).
77
+ * 7. `projectRoot`/`bundleRoot` via `config/anchor.ts`; `now = yield* Now`;
78
+ * `layout` falls back to `Profiles.softwareProject.layout` when no
79
+ * profile resolved (this file's own decision 2 above — `Layout` has no
80
+ * `DEFAULTS` equivalent).
81
+ * 8. `paths = targetPaths(...)`; every path stat-ed; ANY existing fails
82
+ * with `InitOverwriteError` before a single byte is written (K-28).
83
+ * 9. `mkdir -p` every target's parent, THEN write `configValue(...)`
84
+ * through `OkfitConfigFile.write` and every `files(...)` entry through
85
+ * `fs.writeFileString` — `OkfitConfigFile.write` deliberately does not
86
+ * create its parent (`CF/index.d.ts:517-521`), and `save` is unusable
87
+ * here since it targets the XDG `defaultPath`, which K-14 forbids.
88
+ * 10. `Console.log` the K-51 success line.
89
+ * 11. self-validate (K-29): `run({ root: bundleRoot, config: merged,
90
+ * profile, now })` over the bundle just written, rendered exactly as
91
+ * `validate --format human` does, `setExitCode` to its
92
+ * `forDiagnostics` result. The handler SUCCEEDS (K-7); the failure path
93
+ * is only `InitOverwriteError` at step 8, or an infrastructure error
94
+ * that already carries its own `[Runtime.errorExitCode]`.
95
+ *
96
+ * @public
97
+ */
98
+ const initCommand = Command.make("init", {
99
+ path: pathArg,
100
+ config: configFlag,
101
+ profile: profileFlag
102
+ }, (input) => Effect.gen(function* () {
103
+ const cwd = process.cwd();
104
+ const discoveryCwd = Option.getOrElse(input.path, () => cwd);
105
+ yield* provideConfig({
106
+ explicitConfigPath: input.config,
107
+ discoveryCwd
108
+ })(Effect.gen(function* () {
109
+ const winner = (yield* (yield* OkfitConfigFile).discover)[0];
110
+ const fileConfig = winner === void 0 ? { extensions: {} } : winner.value;
111
+ const profileName = Option.getOrElse(input.profile, () => fileConfig.bundle?.profile ?? DEFAULT_PROFILE_NAME);
112
+ const profile = Profiles.get(profileName);
113
+ if (Option.isNone(profile) && profileName !== "none") yield* Console.error(`warning: unknown profile "${profileName}"; continuing with defaults`);
114
+ const profileConfig = Option.match(profile, {
115
+ onNone: () => NO_PROFILE_CONFIG,
116
+ onSome: (resolved) => resolved.config
117
+ });
118
+ const merged = OkfitConfig.merge(OkfitConfig.merge(OkfitConfig.DEFAULTS, profileConfig), fileConfig);
119
+ 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`);
120
+ const path = yield* Path.Path;
121
+ const fs = yield* FileSystem.FileSystem;
122
+ const projectRoot = resolveProjectRoot({
123
+ pathArg: input.path,
124
+ explicitConfigPath: input.config,
125
+ discovered: winner === void 0 ? Option.none() : Option.some({
126
+ path: winner.path,
127
+ resolver: winner.resolver
128
+ }),
129
+ cwd,
130
+ path
131
+ });
132
+ const bundleRoot = resolveBundleRoot(projectRoot, merged, path);
133
+ const now = yield* Now;
134
+ const scaffoldOptions = {
135
+ projectRoot,
136
+ bundleRoot,
137
+ layout: Option.match(profile, {
138
+ onNone: () => Profiles.softwareProject.layout,
139
+ onSome: (resolved) => resolved.layout
140
+ }),
141
+ profileName,
142
+ projectTitle: path.basename(projectRoot),
143
+ today: DateTime.formatIso(now).slice(0, 10)
144
+ };
145
+ const paths = targetPaths(scaffoldOptions);
146
+ const existing = [];
147
+ for (const target of paths) if (yield* fs.exists(target)) existing.push(target);
148
+ if (existing.length > 0) return yield* new InitOverwriteError({
149
+ paths: existing,
150
+ cwd
151
+ });
152
+ for (const target of paths) yield* fs.makeDirectory(path.dirname(target), { recursive: true });
153
+ const bundlePath = merged.bundle?.path ?? DEFAULT_BUNDLE_PATH;
154
+ const encoded = yield* Schema.encodeEffect(OkfitConfig)(configValue({
155
+ ...scaffoldOptions,
156
+ bundlePath
157
+ }));
158
+ const toml = yield* TomlCodec.stringify(encoded);
159
+ yield* fs.writeFileString(`${projectRoot}/${CONFIG_RELATIVE_PATH}`, `${SCHEMA_DIRECTIVE}${toml}`);
160
+ const scaffoldFiles = yield* files(scaffoldOptions);
161
+ for (const file of scaffoldFiles) yield* fs.writeFileString(file.path, file.contents);
162
+ yield* Console.log(`Initialized ${displayRoot(cwd, bundleRoot, path)} with the ${profileName} profile`);
163
+ const result = yield* run({
164
+ root: bundleRoot,
165
+ config: merged,
166
+ profile,
167
+ now
168
+ }).pipe(Effect.provide(Layer.mergeAll(Git.layer, GitHistory.layer)));
169
+ const diagnostics = collect(result.report.conformance, result.report.lint, result.profileDiagnostics);
170
+ for (const line of human(diagnostics, { color: useColor() })) yield* Console.log(line);
171
+ yield* Console.error(summary(countsOf(diagnostics, result.bundle.concepts.size), displayRoot(cwd, bundleRoot, path)));
172
+ setExitCode(forDiagnostics(diagnostics));
173
+ }));
174
+ })).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."));
175
+
176
+ //#endregion
177
+ export { initCommand };