docks-kit 0.1.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.
Files changed (49) hide show
  1. package/AGENTS.md +99 -0
  2. package/LICENSE +21 -0
  3. package/README.md +124 -0
  4. package/SoT/.agents/skills.txt +14 -0
  5. package/SoT/.claude/CLAUDE.md +146 -0
  6. package/SoT/.claude/fetch-usage.sh +66 -0
  7. package/SoT/.claude/hooks/notify.sh +14 -0
  8. package/SoT/.claude/mcp-servers.json +10 -0
  9. package/SoT/.claude/settings.json +258 -0
  10. package/SoT/.claude/statusline.sh +175 -0
  11. package/SoT/.codex/AGENTS.md +75 -0
  12. package/SoT/.codex/agents/.gitkeep +1 -0
  13. package/SoT/.codex/config.toml +45 -0
  14. package/SoT/.codex/plugins/marketplace.json +50 -0
  15. package/SoT/.codex/rules/docks.rules +116 -0
  16. package/SoT/models.json +28 -0
  17. package/SoT/toolchain.json +26 -0
  18. package/cli/docs/flags.md +48 -0
  19. package/cli/docs/install.md +56 -0
  20. package/cli/docs/models.md +42 -0
  21. package/cli/docs/modifiers.md +33 -0
  22. package/cli/docs/overview.md +37 -0
  23. package/cli/docs/platforms.md +31 -0
  24. package/cli/docs/plugins.md +44 -0
  25. package/cli/docs/sync-layers.md +43 -0
  26. package/cli/docs/toolchain.md +54 -0
  27. package/cli/src/commands/docs.ts +65 -0
  28. package/cli/src/commands/model.ts +60 -0
  29. package/cli/src/commands/models.ts +46 -0
  30. package/cli/src/commands/plugins.ts +34 -0
  31. package/cli/src/commands/skills.ts +37 -0
  32. package/cli/src/commands/status.ts +79 -0
  33. package/cli/src/commands/sync.ts +134 -0
  34. package/cli/src/commands/toolchain.ts +42 -0
  35. package/cli/src/engine.ts +36 -0
  36. package/cli/src/kitHome.ts +31 -0
  37. package/cli/src/main.ts +60 -0
  38. package/cli/src/manifests.ts +105 -0
  39. package/cli/src/md.d.ts +4 -0
  40. package/cli/tsconfig.json +14 -0
  41. package/docks-kit +61 -0
  42. package/lib/claude.sh +846 -0
  43. package/lib/codex.sh +518 -0
  44. package/lib/common.sh +229 -0
  45. package/lib/engine.sh +153 -0
  46. package/lib/skills.sh +373 -0
  47. package/lib/toolchain.sh +222 -0
  48. package/notification.mp3 +0 -0
  49. package/package.json +39 -0
@@ -0,0 +1,60 @@
1
+ import { Args, Command, Options, Prompt } from "@effect/cli"
2
+ import { Effect, Option } from "effect"
3
+ import { bail, engine } from "../engine"
4
+ import { modelCatalog, type Tool } from "../manifests"
5
+
6
+ const tool = Args.text({ name: "tool" }).pipe(
7
+ Args.withDescription("Which tool: claude | codex")
8
+ )
9
+ const value = Args.text({ name: "value" }).pipe(
10
+ Args.withDescription("Model to set (omit to view current + pick interactively on a TTY)"),
11
+ Args.optional
12
+ )
13
+ const dryRun = Options.boolean("dry-run").pipe(
14
+ Options.withDescription("Preview without applying")
15
+ )
16
+
17
+ const KEEP = "__keep__"
18
+
19
+ export const modelCommand = Command.make(
20
+ "model",
21
+ { tool, value, dryRun },
22
+ (config) =>
23
+ Effect.gen(function* () {
24
+ if (config.tool !== "claude" && config.tool !== "codex") {
25
+ return yield* bail(`Unknown tool '${config.tool}' (valid: claude, codex)`)
26
+ }
27
+ const t = config.tool as Tool
28
+ const dry = config.dryRun ? ["--dry-run"] : []
29
+
30
+ if (Option.isSome(config.value)) {
31
+ return yield* engine(["model", t, config.value.value, ...dry])
32
+ }
33
+
34
+ // No value: show current (engine prints deployed + SoT + catalog) …
35
+ yield* engine(["model", t])
36
+
37
+ // … and offer an interactive picker when attached to a terminal.
38
+ if (!process.stdin.isTTY || !process.stdout.isTTY) return
39
+
40
+ const catalog = modelCatalog(t)
41
+ const chosen = yield* Prompt.select({
42
+ message: `Set the deployed ${t} model (deployed config only; a flag-less sync reverts to SoT)`,
43
+ choices: [
44
+ { title: "(keep current)", value: KEEP },
45
+ ...catalog.models.map((m) => ({
46
+ title: m.id,
47
+ value: m.id,
48
+ description: m.note ?? ""
49
+ }))
50
+ ]
51
+ })
52
+ if (chosen !== KEEP) {
53
+ yield* engine(["model", t, chosen, ...dry])
54
+ }
55
+ })
56
+ ).pipe(
57
+ Command.withDescription(
58
+ "Get or set the DEPLOYED model for one tool without a full sync (deploy-time modifier semantics: SoT untouched; flag-less sync reverts)."
59
+ )
60
+ )
@@ -0,0 +1,46 @@
1
+ import { Args, Command, Options } from "@effect/cli"
2
+ import { Console, Effect, Option } from "effect"
3
+ import { bail } from "../engine"
4
+ import { modelCatalog, type Tool } from "../manifests"
5
+
6
+ const tool = Args.text({ name: "tool" }).pipe(
7
+ Args.withDescription("claude | codex (omit for both)"),
8
+ Args.optional
9
+ )
10
+ const json = Options.boolean("json").pipe(
11
+ Options.withDescription("Machine-readable output")
12
+ )
13
+
14
+ const renderTool = (t: Tool) =>
15
+ Effect.gen(function* () {
16
+ const catalog = modelCatalog(t)
17
+ yield* Console.log(`${t} models (kit-verified ${catalog.verified}):`)
18
+ for (const m of catalog.models) {
19
+ yield* Console.log(` ${m.id.padEnd(28)} ${m.kind.padEnd(6)} ${m.note ?? ""}`)
20
+ }
21
+ yield* Console.log("")
22
+ })
23
+
24
+ export const modelsCommand = Command.make("models", { tool, json }, (config) =>
25
+ Effect.gen(function* () {
26
+ const requested = Option.getOrUndefined(config.tool)
27
+ if (requested !== undefined && requested !== "claude" && requested !== "codex") {
28
+ return yield* bail(`Unknown tool '${requested}' (valid: claude, codex)`)
29
+ }
30
+ const tools: Array<Tool> = requested !== undefined ? [requested as Tool] : ["claude", "codex"]
31
+
32
+ if (config.json) {
33
+ const out = Object.fromEntries(tools.map((t) => [t, modelCatalog(t)]))
34
+ return yield* Console.log(JSON.stringify(out, null, 2))
35
+ }
36
+
37
+ for (const t of tools) {
38
+ yield* renderTool(t)
39
+ }
40
+ yield* Console.log(
41
+ "Catalog: SoT/models.json (research-verified). Well-formed IDs outside it apply with a warning."
42
+ )
43
+ })
44
+ ).pipe(
45
+ Command.withDescription("List the kit-verified model catalog (SoT/models.json) for claude/codex.")
46
+ )
@@ -0,0 +1,34 @@
1
+ import { Args, Command, Options } from "@effect/cli"
2
+ import { Console, Effect, Option } from "effect"
3
+ import { bail } from "../engine"
4
+ import { pluginsView } from "../manifests"
5
+
6
+ const action = Args.text({ name: "action" }).pipe(
7
+ Args.withDescription("list (default)"),
8
+ Args.optional
9
+ )
10
+ const json = Options.boolean("json").pipe(
11
+ Options.withDescription("Machine-readable output")
12
+ )
13
+
14
+ export const pluginsCommand = Command.make("plugins", { action, json }, (config) =>
15
+ Effect.gen(function* () {
16
+ const act = Option.getOrElse(config.action, () => "list")
17
+ if (act !== "list") {
18
+ return yield* bail(
19
+ `Unknown plugins action '${act}' (valid: list). Install/removal runs through sync: --claude-plugin=<name> opts in, --prune reconciles.`
20
+ )
21
+ }
22
+ const view = pluginsView()
23
+ if (config.json) {
24
+ return yield* Console.log(JSON.stringify(view, null, 2))
25
+ }
26
+ yield* Console.log("SoT tri-state: true = enabled everywhere, false = installed-but-disabled (per-project enable), absent = not kit-managed\n")
27
+ yield* Console.log(`${"PLUGIN".padEnd(44)} ${"SOT".padEnd(7)} INSTALLED`)
28
+ for (const p of view) {
29
+ yield* Console.log(`${p.plugin.padEnd(44)} ${p.sot.padEnd(7)} ${p.installed ? "yes" : "no"}`)
30
+ }
31
+ })
32
+ ).pipe(
33
+ Command.withDescription("Installed plugins vs SoT enabledPlugins tri-state (read-only).")
34
+ )
@@ -0,0 +1,37 @@
1
+ import { Args, Command, Options } from "@effect/cli"
2
+ import { Console, Effect, Option } from "effect"
3
+ import { bail } from "../engine"
4
+ import { skillsView } from "../manifests"
5
+
6
+ const action = Args.text({ name: "action" }).pipe(
7
+ Args.withDescription("list (default)"),
8
+ Args.optional
9
+ )
10
+ const json = Options.boolean("json").pipe(
11
+ Options.withDescription("Machine-readable output")
12
+ )
13
+
14
+ export const skillsCommand = Command.make("skills", { action, json }, (config) =>
15
+ Effect.gen(function* () {
16
+ const act = Option.getOrElse(config.action, () => "list")
17
+ if (act !== "list") {
18
+ return yield* bail(
19
+ `Unknown skills action '${act}' (valid: list). Declare skills in SoT/.agents/skills.txt and run sync; --prune removes undeclared kit-managed ones.`
20
+ )
21
+ }
22
+ const view = skillsView()
23
+ if (config.json) {
24
+ return yield* Console.log(JSON.stringify(view, null, 2))
25
+ }
26
+ yield* Console.log(`${"SKILL".padEnd(28)} ${"DECLARED".padEnd(9)} ${"INSTALLED".padEnd(10)} CLAUDE-SYMLINK`)
27
+ for (const s of view) {
28
+ yield* Console.log(
29
+ `${s.skill.padEnd(28)} ${(s.declared ? "yes" : "no").padEnd(9)} ${(s.installed ? "yes" : "no").padEnd(10)} ${s.claudeSymlink ? "ok" : "-"}`
30
+ )
31
+ }
32
+ })
33
+ ).pipe(
34
+ Command.withDescription(
35
+ "Universal agent skills: SoT/.agents/skills.txt vs ~/.agents/skills + the ~/.claude/skills symlink health (read-only)."
36
+ )
37
+ )
@@ -0,0 +1,79 @@
1
+ import { Command, Options } from "@effect/cli"
2
+ import { Console, Effect } from "effect"
3
+ import { engineCapture } from "../engine"
4
+ import {
5
+ deployedClaudeSettings,
6
+ deployedCodexModel,
7
+ pluginsView,
8
+ skillsView,
9
+ sotClaudeSettings,
10
+ sotCodexModel
11
+ } from "../manifests"
12
+ import { kitHome } from "../kitHome"
13
+
14
+ const json = Options.boolean("json").pipe(
15
+ Options.withDescription("Machine-readable output")
16
+ )
17
+
18
+ interface Drift {
19
+ readonly setting: string
20
+ readonly deployed: string
21
+ readonly sot: string
22
+ readonly drifted: boolean
23
+ }
24
+
25
+ const gatherDrift = (): Array<Drift> => {
26
+ const sot = sotClaudeSettings()
27
+ const dep = deployedClaudeSettings() ?? {}
28
+ const row = (setting: string, deployed: unknown, sotVal: unknown): Drift => {
29
+ const d = String(deployed ?? "(unset)")
30
+ const s = String(sotVal ?? "(unset)")
31
+ return { setting, deployed: d, sot: s, drifted: d !== s }
32
+ }
33
+ return [
34
+ row("claude.model", dep.model, sot.model),
35
+ row("claude.effortLevel", dep.effortLevel, sot.effortLevel),
36
+ row(
37
+ "claude.compactWindow",
38
+ dep.env?.CLAUDE_CODE_AUTO_COMPACT_WINDOW,
39
+ sot.env?.CLAUDE_CODE_AUTO_COMPACT_WINDOW
40
+ ),
41
+ row("codex.model", deployedCodexModel(), sotCodexModel())
42
+ ]
43
+ }
44
+
45
+ export const statusCommand = Command.make("status", { json }, (config) =>
46
+ Effect.gen(function* () {
47
+ const drift = gatherDrift()
48
+ const plugins = pluginsView()
49
+ const skills = skillsView()
50
+ const toolchainTable = yield* engineCapture(["toolchain", "check"])
51
+
52
+ if (config.json) {
53
+ return yield* Console.log(
54
+ JSON.stringify({ kitHome: kitHome(), drift, plugins, skills, toolchainTable }, null, 2)
55
+ )
56
+ }
57
+
58
+ yield* Console.log(`Kit home: ${kitHome()}\n`)
59
+ yield* Console.log("Deployed vs SoT (drift is expected for deploy-time modifiers):")
60
+ for (const d of drift) {
61
+ const mark = d.drifted ? "≠" : "="
62
+ yield* Console.log(` ${d.setting.padEnd(22)} deployed=${d.deployed} ${mark} SoT=${d.sot}`)
63
+ }
64
+ yield* Console.log("\nToolchain:")
65
+ yield* Console.log(toolchainTable.trimEnd())
66
+ const enabled = plugins.filter((p) => p.sot === "true").length
67
+ yield* Console.log(
68
+ `\nPlugins: ${plugins.length} known (${enabled} SoT-enabled) — details: docks-kit plugins list`
69
+ )
70
+ const installed = skills.filter((s) => s.installed).length
71
+ yield* Console.log(
72
+ `Skills: ${skills.length} known (${installed} installed) — details: docks-kit skills list`
73
+ )
74
+ })
75
+ ).pipe(
76
+ Command.withDescription(
77
+ "Doctor view: deployed-vs-SoT drift (model, effort, compact window), toolchain table, plugin/skill counts."
78
+ )
79
+ )
@@ -0,0 +1,134 @@
1
+ import { Args, Command, Options } from "@effect/cli"
2
+ import { Effect, Option } from "effect"
3
+ import { bail, engine } from "../engine"
4
+ import { modelCatalog, type Tool } from "../manifests"
5
+
6
+ const VALID_TARGETS = ["claude", "codex", "agents"]
7
+
8
+ // Renamed sync.sh-era flags: @effect/cli routes unknown flags into the excess
9
+ // positional args, so the rename hints (lib/common.sh's exit-2 arms) are
10
+ // mirrored here to keep the parser contract identical across both front-ends.
11
+ const LEGACY_HINTS: Record<string, string> = {
12
+ "--force": "--force was renamed to --reconcile",
13
+ "--remove-plugins":
14
+ "--remove-plugins was renamed to --prune (it also removes marketplaces + kit-managed skills)",
15
+ "--680k": "--680k was renamed to --claude-compact-window=680k",
16
+ "--permissive": "--permissive was renamed to --claude-permissive",
17
+ "--supabase": "--supabase was renamed to --claude-plugin=supabase",
18
+ "--n8n": "--n8n was renamed to --claude-plugin=n8n",
19
+ "--no-rtk": "--no-rtk was renamed to --skip-rtk",
20
+ "--claude": "--claude was renamed: pass the target as a word, e.g. 'sync claude'",
21
+ "--codex": "--codex was renamed: pass the target as a word, e.g. 'sync codex'",
22
+ "--agents": "--agents was renamed: pass the target as a word, e.g. 'sync agents'"
23
+ }
24
+
25
+ const catalogHint = (t: Tool): string => {
26
+ const c = modelCatalog(t)
27
+ const list = c.models
28
+ .map((m) => ` ${m.id}${m.note !== undefined ? ` — ${m.note}` : ""}`)
29
+ .join("\n")
30
+ return `Available ${t} models (kit-verified ${c.verified} — SoT/models.json):\n${list}`
31
+ }
32
+
33
+ const targets = Args.text({ name: "target" }).pipe(
34
+ Args.withDescription("Sync targets: claude, codex, agents (default: all three)"),
35
+ Args.repeated
36
+ )
37
+
38
+ const dryRun = Options.boolean("dry-run").pipe(
39
+ Options.withDescription("Preview without applying")
40
+ )
41
+ const reconcile = Options.boolean("reconcile").pipe(
42
+ Options.withDescription("Reconcile kit-owned settings with SoT (SoT keys win; user-only keys preserved; permissions arrays replaced)")
43
+ )
44
+ const prune = Options.boolean("prune").pipe(
45
+ Options.withDescription("Uninstall kit-managed installs not in SoT (plugins, marketplaces, universal skills)")
46
+ )
47
+ const skipRtk = Options.boolean("skip-rtk").pipe(
48
+ Options.withDescription("Skip optional tool bootstrap (RTK, bubblewrap)")
49
+ )
50
+ const yes = Options.boolean("yes").pipe(
51
+ Options.withDescription("Auto-accept toolchain prompts (containers/CI)")
52
+ )
53
+ const claudeModel = Options.text("claude-model").pipe(
54
+ Options.withDescription("Deploy-time modifier: set deployed Claude model (see `docks-kit models claude`)"),
55
+ Options.optional
56
+ )
57
+ const claudeCompactWindow = Options.text("claude-compact-window").pipe(
58
+ Options.withDescription("Deploy-time modifier: set deployed autocompact window in tokens (e.g. 680000 or 680k)"),
59
+ Options.optional
60
+ )
61
+ const claudePermissive = Options.boolean("claude-permissive").pipe(
62
+ Options.withDescription("Deploy-time modifier: empty permissions.ask/deny in deployed settings (sandboxes)")
63
+ )
64
+ const claudePlugin = Options.text("claude-plugin").pipe(
65
+ Options.withDescription(
66
+ "Sticky opt-in plugin(s); repeatable and/or comma-separated (known: supabase, n8n)"
67
+ ),
68
+ Options.repeated
69
+ )
70
+ const codexModel = Options.text("codex-model").pipe(
71
+ Options.withDescription("Deploy-time modifier: set deployed Codex model (see `docks-kit models codex`)"),
72
+ Options.optional
73
+ )
74
+
75
+ export const syncCommand = Command.make(
76
+ "sync",
77
+ {
78
+ targets,
79
+ dryRun,
80
+ reconcile,
81
+ prune,
82
+ skipRtk,
83
+ yes,
84
+ claudeModel,
85
+ claudeCompactWindow,
86
+ claudePermissive,
87
+ claudePlugin,
88
+ codexModel
89
+ },
90
+ (config) =>
91
+ Effect.gen(function* () {
92
+ for (const t of config.targets) {
93
+ if (VALID_TARGETS.includes(t)) continue
94
+ if (t === "--claude-model" || t === "--codex-model") {
95
+ const tool: Tool = t === "--claude-model" ? "claude" : "codex"
96
+ return yield* bail(`${catalogHint(tool)}\n${t} requires a value: ${t}=<model>`)
97
+ }
98
+ const hint = LEGACY_HINTS[t]
99
+ if (hint !== undefined) {
100
+ return yield* bail(hint)
101
+ }
102
+ }
103
+ const bad = config.targets.filter((t) => !VALID_TARGETS.includes(t))
104
+ if (bad.length > 0) {
105
+ return yield* bail(
106
+ `Unknown sync target(s): ${bad.join(", ")} (valid: ${VALID_TARGETS.join(", ")})`
107
+ )
108
+ }
109
+
110
+ const args: Array<string> = ["sync", ...config.targets]
111
+ if (config.dryRun) args.push("--dry-run")
112
+ if (config.reconcile) args.push("--reconcile")
113
+ if (config.prune) args.push("--prune")
114
+ if (config.skipRtk) args.push("--skip-rtk")
115
+ if (config.yes) args.push("--yes")
116
+ if (config.claudePermissive) args.push("--claude-permissive")
117
+ Option.map(config.claudeModel, (m) => args.push(`--claude-model=${m}`))
118
+ Option.map(config.claudeCompactWindow, (w) => args.push(`--claude-compact-window=${w}`))
119
+ Option.map(config.codexModel, (m) => args.push(`--codex-model=${m}`))
120
+ for (const occurrence of config.claudePlugin) {
121
+ occurrence
122
+ .split(",")
123
+ .map((p) => p.trim())
124
+ .filter((p) => p.length > 0)
125
+ .forEach((p) => args.push(`--claude-plugin=${p}`))
126
+ }
127
+
128
+ yield* engine(args)
129
+ })
130
+ ).pipe(
131
+ Command.withDescription(
132
+ "Deploy the SoT to this machine (engine: lib/engine.sh — same flags, zero-dependency escape hatch). Deploy-time modifiers touch deployed config only; a later flag-less sync reverts them to SoT."
133
+ )
134
+ )
@@ -0,0 +1,42 @@
1
+ import { Args, Command, Options } from "@effect/cli"
2
+ import { Effect, Option } from "effect"
3
+ import { bail, engine } from "../engine"
4
+
5
+ const MANAGED = ["rtk", "bun", "effect-solutions", "agent-browser"]
6
+
7
+ const op = Args.text({ name: "op" }).pipe(
8
+ Args.withDescription("check (default) | ensure <tool>"),
9
+ Args.optional
10
+ )
11
+ const tool = Args.text({ name: "tool" }).pipe(
12
+ Args.withDescription(`Managed tool for ensure: ${MANAGED.join(", ")}`),
13
+ Args.optional
14
+ )
15
+ const yes = Options.boolean("yes").pipe(
16
+ Options.withDescription("Auto-accept above-verified installs")
17
+ )
18
+
19
+ export const toolchainCommand = Command.make("toolchain", { op, tool, yes }, (config) =>
20
+ Effect.gen(function* () {
21
+ const operation = Option.getOrElse(config.op, () => "check")
22
+ const flags = config.yes ? ["--yes"] : []
23
+
24
+ switch (operation) {
25
+ case "check":
26
+ return yield* engine(["toolchain", "check"])
27
+ case "ensure": {
28
+ const t = Option.getOrUndefined(config.tool)
29
+ if (t === undefined || !MANAGED.includes(t)) {
30
+ return yield* bail(`toolchain ensure needs a managed tool: ${MANAGED.join(", ")}`)
31
+ }
32
+ return yield* engine(["toolchain", "ensure", t, ...flags])
33
+ }
34
+ default:
35
+ return yield* bail(`Unknown toolchain op '${operation}' (valid: check, ensure)`)
36
+ }
37
+ })
38
+ ).pipe(
39
+ Command.withDescription(
40
+ "Verified-version floors for external tools (SoT/toolchain.json): check prints the doctor table; ensure installs/upgrades one managed tool per policy (above-verified versions prompt; --yes accepts)."
41
+ )
42
+ )
@@ -0,0 +1,36 @@
1
+ import { Command as Subprocess } from "@effect/platform"
2
+ import { Console, Effect } from "effect"
3
+ import { kitHome } from "./kitHome"
4
+
5
+ /**
6
+ * The single seam between the typed CLI and the bash engine. All mutation runs
7
+ * through lib/engine.sh (same flag vocabulary as this CLI), so the engine stays
8
+ * independently usable as the zero-dependency escape hatch.
9
+ */
10
+ export const engine = (args: ReadonlyArray<string>) =>
11
+ Effect.gen(function* () {
12
+ const code = yield* Subprocess.make("bash", join("lib/engine.sh"), ...args).pipe(
13
+ Subprocess.workingDirectory(kitHome()),
14
+ Subprocess.stdin("inherit"),
15
+ Subprocess.stdout("inherit"),
16
+ Subprocess.stderr("inherit"),
17
+ Subprocess.exitCode
18
+ )
19
+ if (code !== 0) {
20
+ yield* Effect.sync(() => process.exit(code))
21
+ }
22
+ })
23
+
24
+ /** Run the engine capturing stdout (engine logs/warns go to stderr and pass through). */
25
+ export const engineCapture = (args: ReadonlyArray<string>) =>
26
+ Subprocess.make("bash", join("lib/engine.sh"), ...args).pipe(
27
+ Subprocess.workingDirectory(kitHome()),
28
+ Subprocess.stderr("inherit"),
29
+ Subprocess.string
30
+ )
31
+
32
+ const join = (rel: string): string => `${kitHome()}/${rel}`
33
+
34
+ /** Print a message to stderr and exit — for CLI-side validation failures. */
35
+ export const bail = (message: string, code = 2) =>
36
+ Console.error(message).pipe(Effect.andThen(Effect.sync(() => process.exit(code))))
@@ -0,0 +1,31 @@
1
+ import { existsSync } from "node:fs"
2
+ import { dirname, join, resolve } from "node:path"
3
+
4
+ const isKitHome = (dir: string): boolean =>
5
+ existsSync(join(dir, "SoT")) && existsSync(join(dir, "lib", "engine.sh"))
6
+
7
+ /**
8
+ * Resolve the kit home (the directory holding SoT/ + lib/engine.sh):
9
+ * DOCKS_KIT_HOME env → nearest ancestor of cwd (repo-checkout usage) →
10
+ * the package's own root (bunx / bun add -g usage: main.ts lives at
11
+ * <root>/cli/src, and the npm package bundles SoT/ + lib/ alongside).
12
+ */
13
+ export const kitHome = (): string => {
14
+ const env = process.env["DOCKS_KIT_HOME"]
15
+ if (env !== undefined && env !== "") {
16
+ if (isKitHome(env)) return env
17
+ throw new Error(`DOCKS_KIT_HOME=${env} does not contain SoT/ + lib/engine.sh`)
18
+ }
19
+ let dir = process.cwd()
20
+ for (;;) {
21
+ if (isKitHome(dir)) return dir
22
+ const parent = dirname(dir)
23
+ if (parent === dir) break
24
+ dir = parent
25
+ }
26
+ const packageRoot = resolve(import.meta.dir, "..", "..")
27
+ if (isKitHome(packageRoot)) return packageRoot
28
+ throw new Error(
29
+ "docks-kit home not found — run inside the kit repo or set DOCKS_KIT_HOME"
30
+ )
31
+ }
@@ -0,0 +1,60 @@
1
+ #!/usr/bin/env bun
2
+ import { Command } from "@effect/cli"
3
+ import { BunContext, BunRuntime } from "@effect/platform-bun"
4
+ import { Console, Effect } from "effect"
5
+ import { docsCommand } from "./commands/docs"
6
+ import { modelCommand } from "./commands/model"
7
+ import { modelsCommand } from "./commands/models"
8
+ import { pluginsCommand } from "./commands/plugins"
9
+ import { skillsCommand } from "./commands/skills"
10
+ import { statusCommand } from "./commands/status"
11
+ import { syncCommand } from "./commands/sync"
12
+ import { toolchainCommand } from "./commands/toolchain"
13
+
14
+ const root = Command.make("docks-kit", {}, () =>
15
+ Effect.gen(function* () {
16
+ yield* Console.log("docks-kit — portable AI coding agent config kit")
17
+ yield* Console.log("")
18
+ yield* Console.log(" docks-kit sync [claude] [codex] [agents] deploy the SoT to this machine")
19
+ yield* Console.log(" docks-kit model <claude|codex> [value] get/set the deployed model")
20
+ yield* Console.log(" docks-kit models [tool] kit-verified model catalog")
21
+ yield* Console.log(" docks-kit toolchain [check|ensure <tool>] verified-version floors")
22
+ yield* Console.log(" docks-kit status deployed-vs-SoT doctor view")
23
+ yield* Console.log(" docks-kit plugins list plugin tri-state")
24
+ yield* Console.log(" docks-kit skills list universal skills")
25
+ yield* Console.log(" docks-kit docs [topic] self-documentation")
26
+ yield* Console.log("")
27
+ yield* Console.log("Run 'docks-kit --help' for full option listings (also: --wizard, --completions).")
28
+ yield* Console.log("Zero-dependency escape hatch: bash lib/engine.sh <same subcommands/flags>")
29
+ })
30
+ ).pipe(
31
+ Command.withDescription(
32
+ "Portable AI coding agent config kit — SoT sync engine + helpers for Claude Code, Codex, and universal agent skills."
33
+ ),
34
+ Command.withSubcommands([
35
+ syncCommand,
36
+ modelCommand,
37
+ modelsCommand,
38
+ toolchainCommand,
39
+ statusCommand,
40
+ pluginsCommand,
41
+ skillsCommand,
42
+ docsCommand
43
+ ])
44
+ )
45
+
46
+ const cli = Command.run(root, {
47
+ name: "docks-kit",
48
+ version: "0.1.0"
49
+ })
50
+
51
+ // @effect/cli's Options.repeated does not accept `--flag=value` syntax (only
52
+ // `--flag value`), but the documented form for the repeatable opt-in is
53
+ // `--claude-plugin=<name>` — normalize it here so both spellings work.
54
+ const argv = process.argv.flatMap((a) =>
55
+ a.startsWith("--claude-plugin=")
56
+ ? ["--claude-plugin", a.slice("--claude-plugin=".length)]
57
+ : [a]
58
+ )
59
+
60
+ cli(argv).pipe(Effect.provide(BunContext.layer), BunRuntime.runMain)
@@ -0,0 +1,105 @@
1
+ import { readFileSync, readdirSync, readlinkSync, existsSync } from "node:fs"
2
+ import { join } from "node:path"
3
+ import { kitHome } from "./kitHome"
4
+
5
+ export interface ModelEntry {
6
+ readonly id: string
7
+ readonly kind: "alias" | "id"
8
+ readonly note?: string
9
+ }
10
+
11
+ export interface ModelCatalog {
12
+ readonly verified: string
13
+ readonly models: ReadonlyArray<ModelEntry>
14
+ }
15
+
16
+ export type Tool = "claude" | "codex"
17
+
18
+ const readJson = (path: string): any => JSON.parse(readFileSync(path, "utf8"))
19
+
20
+ export const modelCatalog = (tool: Tool): ModelCatalog =>
21
+ readJson(join(kitHome(), "SoT", "models.json"))[tool]
22
+
23
+ export const toolchainManifest = (): Record<string, any> =>
24
+ readJson(join(kitHome(), "SoT", "toolchain.json")).tools
25
+
26
+ /** SoT settings (claude) — model/effort/env for drift display. */
27
+ export const sotClaudeSettings = (): any =>
28
+ readJson(join(kitHome(), "SoT", ".claude", "settings.json"))
29
+
30
+ export const deployedClaudeSettings = (): any | undefined => {
31
+ const p = join(homedir(), ".claude", "settings.json")
32
+ return existsSync(p) ? readJson(p) : undefined
33
+ }
34
+
35
+ const tomlModel = (path: string): string | undefined => {
36
+ if (!existsSync(path)) return undefined
37
+ const m = readFileSync(path, "utf8").match(/^model\s*=\s*"([^"]+)"/m)
38
+ return m?.[1]
39
+ }
40
+
41
+ export const sotCodexModel = (): string | undefined =>
42
+ tomlModel(join(kitHome(), "SoT", ".codex", "config.toml"))
43
+
44
+ export const deployedCodexModel = (): string | undefined =>
45
+ tomlModel(join(homedir(), ".codex", "config.toml"))
46
+
47
+ export const homedir = (): string => process.env["HOME"] ?? "~"
48
+
49
+ /** Installed plugins (user scope) vs SoT enabledPlugins tri-state. */
50
+ export const pluginsView = (): Array<{
51
+ plugin: string
52
+ sot: "true" | "false" | "absent"
53
+ installed: boolean
54
+ }> => {
55
+ const sot: Record<string, boolean> = sotClaudeSettings().enabledPlugins ?? {}
56
+ const installedPath = join(homedir(), ".claude", "plugins", "installed_plugins.json")
57
+ const installed: Record<string, unknown> = existsSync(installedPath)
58
+ ? readJson(installedPath).plugins ?? {}
59
+ : {}
60
+ const names = new Set([...Object.keys(sot), ...Object.keys(installed)])
61
+ return [...names].sort().map((plugin) => ({
62
+ plugin,
63
+ sot: plugin in sot ? (sot[plugin] ? "true" : "false") : "absent",
64
+ installed: plugin in installed
65
+ }))
66
+ }
67
+
68
+ /** Universal skills: SoT manifest slugs vs ~/.agents/skills contents. */
69
+ export const skillsView = (): Array<{
70
+ skill: string
71
+ declared: boolean
72
+ installed: boolean
73
+ claudeSymlink: boolean
74
+ }> => {
75
+ const manifestPath = join(kitHome(), "SoT", ".agents", "skills.txt")
76
+ const declared = existsSync(manifestPath)
77
+ ? readFileSync(manifestPath, "utf8")
78
+ .split("\n")
79
+ .map((l) => l.replace(/#.*$/, "").trim())
80
+ .filter((l) => l.length > 0)
81
+ .map((slug) => slug.split("/").pop() as string)
82
+ : []
83
+ const skillsDir = join(homedir(), ".agents", "skills")
84
+ const installed = existsSync(skillsDir)
85
+ ? readdirSync(skillsDir, { withFileTypes: true })
86
+ .filter((e) => e.isDirectory())
87
+ .map((e) => e.name)
88
+ : []
89
+ const names = new Set([...declared, ...installed])
90
+ return [...names].sort().map((skill) => {
91
+ const link = join(homedir(), ".claude", "skills", skill)
92
+ let claudeSymlink = false
93
+ try {
94
+ claudeSymlink = readlinkSync(link).includes(".agents/skills")
95
+ } catch {
96
+ /* not a symlink or missing */
97
+ }
98
+ return {
99
+ skill,
100
+ declared: declared.includes(skill),
101
+ installed: installed.includes(skill),
102
+ claudeSymlink
103
+ }
104
+ })
105
+ }
@@ -0,0 +1,4 @@
1
+ declare module "*.md" {
2
+ const content: string
3
+ export default content
4
+ }