docks-kit 0.15.1 → 0.15.3

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 +57 -17
  2. package/README.md +33 -31
  3. package/cli/docs/flags.md +0 -1
  4. package/cli/docs/install.md +28 -13
  5. package/cli/docs/overview.md +2 -2
  6. package/cli/docs/platforms.md +5 -2
  7. package/cli/docs/sync-layers.md +3 -4
  8. package/cli/docs/toolchain.md +26 -34
  9. package/cli/src/commands/docs.ts +3 -3
  10. package/cli/src/commands/model.ts +3 -0
  11. package/cli/src/commands/models.ts +5 -3
  12. package/cli/src/commands/status.ts +145 -32
  13. package/cli/src/commands/sync.ts +4 -5
  14. package/cli/src/commands/toolchain.ts +4 -7
  15. package/cli/src/commands/update.ts +177 -32
  16. package/cli/src/efforts.ts +5 -5
  17. package/cli/src/engine-native/DESIGN.md +31 -22
  18. package/cli/src/engine-native/bun.ts +42 -14
  19. package/cli/src/engine-native/claudeRuntime.ts +17 -9
  20. package/cli/src/engine-native/claudeSettingsModifiers.ts +29 -11
  21. package/cli/src/engine-native/claudeSync.ts +91 -55
  22. package/cli/src/engine-native/codexSync.ts +173 -49
  23. package/cli/src/engine-native/codexToml.ts +12 -7
  24. package/cli/src/engine-native/deps.ts +36 -95
  25. package/cli/src/engine-native/exec.ts +42 -24
  26. package/cli/src/engine-native/index.ts +17 -6
  27. package/cli/src/engine-native/models.ts +2 -9
  28. package/cli/src/engine-native/modes.ts +54 -35
  29. package/cli/src/engine-native/os/darwin.ts +62 -0
  30. package/cli/src/engine-native/os/index.ts +42 -0
  31. package/cli/src/engine-native/os/linux.ts +62 -0
  32. package/cli/src/engine-native/os/targets.ts +73 -0
  33. package/cli/src/engine-native/os/types.ts +75 -0
  34. package/cli/src/engine-native/os/windows.ts +176 -0
  35. package/cli/src/engine-native/parseArgs.ts +147 -48
  36. package/cli/src/engine-native/services.ts +1 -11
  37. package/cli/src/engine-native/settings.ts +3 -2
  38. package/cli/src/engine-native/skillsSync.ts +141 -89
  39. package/cli/src/engine-native/toolchain.ts +5 -147
  40. package/cli/src/engine.ts +41 -11
  41. package/cli/src/generated/sotPayload.ts +7 -7
  42. package/cli/src/kitHome.ts +42 -5
  43. package/cli/src/main.ts +12 -2
  44. package/cli/src/manifests.ts +28 -11
  45. package/cli/src/payload.ts +2 -5
  46. package/docks-kit +4 -4
  47. package/docks-kit.ps1 +123 -0
  48. package/package.json +9 -5
  49. package/cli/src/engine-native/os.ts +0 -24
@@ -4,8 +4,9 @@
4
4
  * substitution: stdout with trailing newlines stripped, empty on failure.
5
5
  */
6
6
  import { spawn, type ChildProcess, type SpawnOptions } from "node:child_process"
7
- import { accessSync, chmodSync, constants, existsSync, readFileSync, statSync, writeFileSync } from "node:fs"
8
- import { delimiter, isAbsolute, join } from "node:path"
7
+ import { accessSync, constants, existsSync, readFileSync, statSync, writeFileSync } from "node:fs"
8
+ import { delimiter, extname, isAbsolute, join } from "node:path"
9
+ import { hostOs, type HostOs } from "./os"
9
10
 
10
11
  /** Keep engine paths slash-separated so rendered output is host-stable. */
11
12
  export function p(...parts: Array<string>): string {
@@ -21,6 +22,8 @@ export interface AsyncProcessResult {
21
22
 
22
23
  export interface AsyncProcessOptions {
23
24
  readonly stdio?: SpawnOptions["stdio"]
25
+ /** Host whose executable resolution and argv shaping apply; tests inject it. */
26
+ readonly host?: HostOs
24
27
  }
25
28
 
26
29
  export function spawnProcess(
@@ -29,9 +32,25 @@ export function spawnProcess(
29
32
  options: AsyncProcessOptions = {}
30
33
  ): Promise<AsyncProcessResult> {
31
34
  const { promise, resolve } = Promise.withResolvers<AsyncProcessResult>()
35
+ const host = options.host ?? hostOs()
36
+ const resolvesSuffixes = host.executableSuffixes.some((suffix) => suffix !== "")
37
+ const executablePath = resolvesSuffixes ? which(cmd, host.executableSuffixes) : cmd
38
+ if (executablePath === "") {
39
+ // Never hand a pathless name to a host that resolves suffixes: CreateProcess
40
+ // searches the parent's current directory before the system one, so an
41
+ // untrusted checkout could answer for a missing tool.
42
+ resolve({ exitCode: null, stdout: "", stderr: "", error: new Error(`command not found on PATH: ${cmd}`) })
43
+ return promise
44
+ }
32
45
  let child: ChildProcess
33
46
  try {
34
- child = spawn(cmd, [...args], { stdio: options.stdio ?? ["ignore", "pipe", "ignore"] })
47
+ // Inside the try: a host whose invocation encoding rejects a value it
48
+ // cannot represent reports it like any other spawn failure.
49
+ const invocation = host.invoke(executablePath, args)
50
+ child = spawn(invocation.command, [...invocation.args], {
51
+ stdio: options.stdio ?? ["ignore", "pipe", "ignore"],
52
+ windowsVerbatimArguments: invocation.windowsVerbatimArguments
53
+ })
35
54
  } catch (cause) {
36
55
  const error = cause instanceof Error ? cause : new Error(String(cause))
37
56
  resolve({ exitCode: null, stdout: "", stderr: "", error })
@@ -75,14 +94,20 @@ export async function capture(cmd: string, args: ReadonlyArray<string>): Promise
75
94
  }
76
95
 
77
96
  /** `command -v` — resolve an executable name on PATH. */
78
- export function which(name: string): string {
79
- if (isAbsolute(name) || name.includes("/")) {
80
- return isExecutable(name) ? name : ""
97
+ export function which(name: string, suffixes: ReadonlyArray<string> = hostOs().executableSuffixes): string {
98
+ const runnableCandidate = (base: string): string => {
99
+ for (const suffix of suffixes) {
100
+ const candidate = `${base}${suffix}`
101
+ if (isExecutable(candidate, suffixes)) return candidate
102
+ }
103
+ return ""
81
104
  }
105
+
106
+ if (isAbsolute(name) || name.includes("/")) return runnableCandidate(name)
82
107
  for (const dir of (process.env["PATH"] ?? "").split(delimiter)) {
83
108
  if (dir === "") continue
84
- const candidate = join(dir, name)
85
- if (isExecutable(candidate)) return candidate
109
+ const candidate = runnableCandidate(join(dir, name))
110
+ if (candidate !== "") return candidate
86
111
  }
87
112
  return ""
88
113
  }
@@ -91,33 +116,26 @@ export function commandExists(name: string): boolean {
91
116
  return which(name) !== ""
92
117
  }
93
118
 
94
- export function isExecutable(p: string): boolean {
119
+ export function isExecutable(path: string, suffixes: ReadonlyArray<string> = hostOs().executableSuffixes): boolean {
95
120
  try {
96
- if (!statSync(p).isFile()) return false
97
- accessSync(p, constants.X_OK)
121
+ if (!statSync(path).isFile()) return false
122
+ if (suffixes.some((suffix) => suffix !== "")) {
123
+ const lowerPath = path.toLowerCase()
124
+ return suffixes.some((suffix) =>
125
+ suffix === "" ? extname(path) === "" : lowerPath.endsWith(suffix.toLowerCase())
126
+ )
127
+ }
128
+ accessSync(path, constants.X_OK)
98
129
  return true
99
130
  } catch {
100
131
  return false
101
132
  }
102
133
  }
103
134
 
104
- export function fileExists(p: string): boolean {
105
- return existsSync(p)
106
- }
107
-
108
135
  // Change-detection primitives (Output Policy in DESIGN.md): operations report
109
136
  // changed:boolean so unchanged repeat runs log at verbose instead of [ok].
110
137
 
111
138
  /** Write only when the content differs; returns whether a write happened. */
112
- /** Add missing +x bits; returns whether a repair actually happened. */
113
- export function ensureExecutable(path: string): boolean {
114
- const mode = statSync(path).mode
115
- const want = mode | 0o111
116
- if (mode === want) return false
117
- chmodSync(path, want)
118
- return true
119
- }
120
-
121
139
  export function writeTextIfChanged(path: string, content: string): boolean {
122
140
  if (existsSync(path) && readFileSync(path, "utf8") === content) return false
123
141
  writeFileSync(path, content)
@@ -17,12 +17,15 @@ import { claudeNextSteps, claudeSummary, claudeSync, type ClaudeRuntimeState } f
17
17
  import { codexNextSteps, codexSummary, codexSync } from "./codexSync"
18
18
  import { normalizeManifest, skillsNextSteps, skillsSummary, skillsSync, type SkillsState } from "./skillsSync"
19
19
  import { modeModel, modeToolchain } from "./modes"
20
- import { ExitError, parseArgs, validateModifierFlags } from "./parseArgs"
20
+ import { ExitError, parseArgs, parseClaudePlugin, parseCompactWindow, validateModifierFlags } from "./parseArgs"
21
21
 
22
22
  export type ModifierFlag =
23
23
  | "--claude-model"
24
24
  | "--claude-effort"
25
25
  | "--claude-advisor"
26
+ | "--claude-compact-window"
27
+ | "--claude-permissive"
28
+ | "--claude-plugin"
26
29
  | "--codex-model"
27
30
  | "--codex-effort"
28
31
 
@@ -84,7 +87,6 @@ export interface Ctx {
84
87
  skipPluginRefresh?: boolean
85
88
  reconcile: boolean
86
89
  prune: boolean
87
- assumeYes: boolean
88
90
  claudeCompactWindow: string
89
91
  claudePermissive: boolean
90
92
  claudePlugins: Array<string>
@@ -117,6 +119,16 @@ export interface Ctx {
117
119
  function makeCtx(services: EngineServices): Ctx {
118
120
  const env = process.env
119
121
  const home = env["HOME"] !== undefined && env["HOME"] !== "" ? env["HOME"] : homedir()
122
+ const compactWindowSource = env["CLAUDE_COMPACT_WINDOW"] ?? ""
123
+ const claudeCompactWindow = compactWindowSource === "" ? "" : parseCompactWindow(compactWindowSource)
124
+ if (claudeCompactWindow === undefined) {
125
+ services.logger.err("CLAUDE_COMPACT_WINDOW expects a token count (e.g. 680000 or 680k)")
126
+ throw new ExitError(2)
127
+ }
128
+ const claudePlugins = (env["CLAUDE_PLUGINS"] ?? "")
129
+ .split(" ")
130
+ .filter((plugin) => plugin !== "")
131
+ .map((plugin) => parseClaudePlugin(plugin, services.logger.err))
120
132
  return {
121
133
  repoDir: kitHome(),
122
134
  home,
@@ -127,10 +139,9 @@ function makeCtx(services: EngineServices): Ctx {
127
139
  skipPluginRefresh: false,
128
140
  reconcile: env["RECONCILE"] === "1",
129
141
  prune: env["PRUNE"] === "1",
130
- assumeYes: env["ASSUME_YES"] === "1",
131
- claudeCompactWindow: env["CLAUDE_COMPACT_WINDOW"] ?? "",
142
+ claudeCompactWindow,
132
143
  claudePermissive: env["CLAUDE_PERMISSIVE"] === "1",
133
- claudePlugins: (env["CLAUDE_PLUGINS"] ?? "").split(" ").filter((s) => s !== ""),
144
+ claudePlugins,
134
145
  claudeModel: env["CLAUDE_MODEL"] ?? "",
135
146
  claudeEffort: "",
136
147
  claudeAdvisor: "",
@@ -278,8 +289,8 @@ export async function runEngineNative(argv: ReadonlyArray<string>, services?: En
278
289
  deps: baseServices.deps,
279
290
  platform: baseServices.platform
280
291
  }
281
- ctx = makeCtx(runServices)
282
292
  try {
293
+ ctx = makeCtx(runServices)
283
294
  switch (argv[0]) {
284
295
  case "model":
285
296
  return modeModel(ctx, argv.slice(1))
@@ -6,16 +6,9 @@ import type { Ctx } from "./index"
6
6
  import { isObject, parseJson, type Json } from "./jq"
7
7
  import { payloadDisplayPath, payloadText } from "../payload"
8
8
 
9
- function catalog(): Json | undefined {
10
- try {
11
- return parseJson(payloadText("SoT/models.json"))
12
- } catch {
13
- return undefined
14
- }
15
- }
16
9
 
17
10
  function toolEntry(tool: string): { [k: string]: Json } | undefined {
18
- const doc = catalog()
11
+ const doc = parseJson(payloadText("SoT/models.json"))
19
12
  if (doc === undefined || !isObject(doc)) return undefined
20
13
  const entry = doc[tool]
21
14
  return entry !== undefined && isObject(entry) ? entry : undefined
@@ -37,7 +30,7 @@ export function printModels(ctx: Ctx, tool: string): void {
37
30
  const { echo, warn } = ctx.services.logger
38
31
  const entry = toolEntry(tool)
39
32
  if (entry === undefined) {
40
- warn(`Model catalog unavailable (${payloadDisplayPath("SoT/models.json", ctx.repoDir)})`)
33
+ warn(`Model catalog unavailable (${payloadDisplayPath("SoT/models.json")})`)
41
34
  return
42
35
  }
43
36
  const verified = typeof entry["verified"] === "string" ? entry["verified"] : "?"
@@ -12,8 +12,7 @@ import type { Ctx } from "./index"
12
12
  import { isObject, parseJson, type Json } from "./jq"
13
13
  import { printModels, validateClaudeModel, validateCodexModel } from "./models"
14
14
  import { bunBootstrap } from "./bun"
15
- import { effectSolutionsInstall } from "./skillsSync"
16
- import { ensure, report } from "./toolchain"
15
+ import { installedVersion, present, report } from "./toolchain"
17
16
 
18
17
  export function modeModel(ctx: Ctx, args: ReadonlyArray<string>): number {
19
18
  const { echo, err, warn } = ctx.services.logger
@@ -37,19 +36,29 @@ export function modeModel(ctx: Ctx, args: ReadonlyArray<string>): number {
37
36
  if (value === "") {
38
37
  if (tool === "claude") {
39
38
  const deployed = p(ctx.home, ".claude", "settings.json")
40
- if (!fileReadable(deployed)) {
39
+ const result = readConfig(deployed)
40
+ if (result.kind === "missing") {
41
41
  warn("~/.claude/settings.json missing")
42
42
  return 0
43
43
  }
44
- echo(`deployed: ${jsonModelField(deployed)}`)
44
+ if (result.kind === "read-error") {
45
+ err(`Failed to read ~/.claude/settings.json: ${String(result.error)}`)
46
+ return 1
47
+ }
48
+ echo(`deployed: ${jsonModelText(result.data)}`)
45
49
  echo(`SoT: ${jsonModelText(payloadText("SoT/.claude/settings.json"))}`)
46
50
  } else {
47
51
  const deployed = p(ctx.home, ".codex", "config.toml")
48
- if (!fileReadable(deployed)) {
52
+ const result = readConfig(deployed)
53
+ if (result.kind === "missing") {
49
54
  warn("~/.codex/config.toml missing")
50
55
  return 0
51
56
  }
52
- echo(`deployed: ${tomlModelField(deployed)}`)
57
+ if (result.kind === "read-error") {
58
+ err(`Failed to read ~/.codex/config.toml: ${String(result.error)}`)
59
+ return 1
60
+ }
61
+ echo(`deployed: ${tomlModelText(result.data)}`)
53
62
  echo(`SoT: ${tomlModelText(payloadText("SoT/.codex/config.toml"))}`)
54
63
  }
55
64
  printModels(ctx, tool)
@@ -74,20 +83,23 @@ export function modeModel(ctx: Ctx, args: ReadonlyArray<string>): number {
74
83
  return 0
75
84
  }
76
85
 
77
- function fileReadable(p: string): boolean {
86
+ type ConfigReadResult =
87
+ | { readonly kind: "missing" }
88
+ | { readonly kind: "read-error"; readonly error: unknown }
89
+ | { readonly kind: "data"; readonly data: string }
90
+
91
+ function readConfig(file: string): ConfigReadResult {
78
92
  try {
79
- readFileSync(p)
80
- return true
81
- } catch {
82
- return false
93
+ return { kind: "data", data: readFileSync(file, "utf8") }
94
+ } catch (error) {
95
+ const code =
96
+ typeof error === "object" && error !== null && "code" in error && typeof error.code === "string"
97
+ ? error.code
98
+ : undefined
99
+ return code === "ENOENT" ? { kind: "missing" } : { kind: "read-error", error }
83
100
  }
84
101
  }
85
102
 
86
- /** `jq -r '.model // "default (unset)"'` — empty on unparseable input. */
87
- function jsonModelField(file: string): string {
88
- return jsonModelText(readFileSync(file, "utf8"))
89
- }
90
-
91
103
  function jsonModelText(text: string): string {
92
104
  const doc = parseJson(text)
93
105
  if (doc === undefined) return ""
@@ -97,9 +109,6 @@ function jsonModelText(text: string): string {
97
109
  }
98
110
 
99
111
  /** `awk -F'"' '/^model[[:space:]]*=/{print $2; exit}'`. */
100
- function tomlModelField(file: string): string {
101
- return tomlModelText(readFileSync(file, "utf8"))
102
- }
103
112
 
104
113
  function tomlModelText(text: string): string {
105
114
  for (const line of text.split("\n")) {
@@ -109,15 +118,12 @@ function tomlModelText(text: string): string {
109
118
  }
110
119
 
111
120
  export async function modeToolchain(ctx: Ctx, args: ReadonlyArray<string>): Promise<number> {
112
- const { err } = ctx.services.logger
113
- const words = args.filter((a) => !a.startsWith("--"))
114
- const op = words[0] ?? args[0] ?? "check"
115
- const tool = words[1] ?? args[1] ?? ""
121
+ const { echo, err, verbose } = ctx.services.logger
122
+ const words = args.filter((arg) => !arg.startsWith("--"))
123
+ const op = words[0] ?? "check"
124
+ const tool = words[1] ?? ""
116
125
  for (const arg of args) {
117
- if (arg === "--yes") ctx.assumeYes = true
118
- else if (arg === "--verbose") {
119
- ctx.verbose = true
120
- }
126
+ if (arg === "--verbose") ctx.verbose = true
121
127
  }
122
128
 
123
129
  if (op === "check") {
@@ -125,20 +131,33 @@ export async function modeToolchain(ctx: Ctx, args: ReadonlyArray<string>): Prom
125
131
  return 0
126
132
  }
127
133
  if (op !== "ensure") {
128
- err("Usage: toolchain [check|ensure <tool>] [--yes]")
134
+ err("Usage: toolchain [check|ensure <tool>]")
129
135
  return 2
130
136
  }
131
- if (tool === "" || tool === "--yes") {
132
- err("Usage: toolchain ensure <tool> [--yes]")
137
+ if (tool === "") {
138
+ err("Usage: toolchain ensure <tool>")
133
139
  return 2
134
140
  }
135
141
  switch (tool) {
136
- case "bun":
137
- return (await bunBootstrap(ctx, ctx.services)).kind === "ready" ? 0 : 1
138
- case "effect-solutions":
139
- return await ensure(ctx, "effect-solutions", effectSolutionsInstall(ctx))
142
+ case "bun": {
143
+ // `bun` is the one managed tool, and its bootstrap is silent when Bun is
144
+ // already installed. Probe first so the no-op confirmation the `--verbose`
145
+ // contract promises is not mistaken for a fresh install.
146
+ const alreadyInstalled = present(ctx, "bun")
147
+ if ((await bunBootstrap(ctx, ctx.services)).kind !== "ready") return 1
148
+ if (alreadyInstalled) {
149
+ // A present tool whose --version cannot be read reports `unknown` in the
150
+ // doctor table; keep the same vocabulary rather than an empty pair of
151
+ // parentheses.
152
+ const probed = await installedVersion(ctx, "bun")
153
+ const installed = probed === "" ? "version unknown" : probed
154
+ if (ctx.dryRun) echo(`[dry-run] bun up to date (${installed})`)
155
+ else verbose(`bun up to date (${installed})`)
156
+ }
157
+ return 0
158
+ }
140
159
  default:
141
- err("toolchain ensure supports managed tools only (bun, effect-solutions)")
160
+ err("toolchain ensure supports managed tools only (bun)")
142
161
  return 2
143
162
  }
144
163
  }
@@ -0,0 +1,62 @@
1
+ import { p } from "../exec"
2
+ import type { HostOs } from "./types"
3
+
4
+ function posixLiteral(value: string): string {
5
+ return `'${value.replaceAll("'", `'"'"'`)}'`
6
+ }
7
+
8
+ export const darwin: HostOs = {
9
+ id: "darwin",
10
+ toolchainOs: "darwin",
11
+ supportsBubblewrap: false,
12
+ directoryLinkKinds: ["symlink"],
13
+ executableSuffixes: [""],
14
+ invoke: (executablePath, args) => ({ command: executablePath, args }),
15
+ bunExecutableName: "bun",
16
+ bunInstaller: (pin, directory) => {
17
+ const scriptPath = p(directory, "install.sh")
18
+ return {
19
+ scriptPath,
20
+ download: {
21
+ command: "curl",
22
+ args: ["-fsSL", "https://bun.sh/install", "-o", scriptPath]
23
+ },
24
+ run: {
25
+ command: "bash",
26
+ args: [scriptPath, `bun-v${pin}`]
27
+ }
28
+ }
29
+ },
30
+ environmentSetting: (name, value) => ({
31
+ kind: "profile",
32
+ candidates: [".zshrc", ".bashrc", ".bash_profile", ".profile", ".zshenv"],
33
+ target: (shell) => {
34
+ const shellPath = shell ?? "bash"
35
+ const shellName = shellPath.slice(shellPath.lastIndexOf("/") + 1)
36
+ return shellName === "zsh" ? ".zshrc" : shellName === "bash" ? ".bashrc" : ".profile"
37
+ },
38
+ line: `export ${name}=${value}`
39
+ }),
40
+ statusLineCommand: (bun, script) => {
41
+ const bunLiteral = posixLiteral(bun)
42
+ const scriptLiteral = posixLiteral(script)
43
+ return `test -x ${bunLiteral} && test -f ${scriptLiteral} && exec ${bunLiteral} ${scriptLiteral} || true`
44
+ },
45
+ failureHookCommand: (command) => command,
46
+ installHint: (tool) => {
47
+ switch (tool) {
48
+ case "git":
49
+ return "brew install git"
50
+ case "jq":
51
+ return "brew install jq"
52
+ case "curl":
53
+ return "brew install curl"
54
+ case "ffplay":
55
+ return "brew install ffmpeg"
56
+ case "claude":
57
+ return "curl -fsSL https://claude.ai/install.sh -o /tmp/claude-install.sh && bash /tmp/claude-install.sh"
58
+ case "codex":
59
+ return 'tmp=$(mktemp) && curl -fsSL https://chatgpt.com/codex/install.sh -o "$tmp" && CODEX_NON_INTERACTIVE=1 sh "$tmp"'
60
+ }
61
+ }
62
+ }
@@ -0,0 +1,42 @@
1
+ import { darwin } from "./darwin"
2
+ import { linux } from "./linux"
3
+ import type { HostOs, PlatformName } from "./types"
4
+ import { windows } from "./windows"
5
+
6
+ export * from "./types"
7
+
8
+ export function rawPlatform(): NodeJS.Platform {
9
+ return process.platform
10
+ }
11
+
12
+ export function platformName(pf: NodeJS.Platform = rawPlatform()): PlatformName {
13
+ switch (pf) {
14
+ case "linux":
15
+ return "linux"
16
+ case "darwin":
17
+ return "darwin"
18
+ case "win32":
19
+ return "windows"
20
+ default:
21
+ return "unknown"
22
+ }
23
+ }
24
+
25
+ // An unrecognized host keeps Linux hint text, filters no toolchain row, and never claims bubblewrap — exactly today's behavior.
26
+ const unknown: HostOs = {
27
+ ...linux,
28
+ id: "unknown",
29
+ toolchainOs: "",
30
+ supportsBubblewrap: false
31
+ }
32
+
33
+ const HOSTS: Readonly<Record<PlatformName, HostOs>> = {
34
+ linux,
35
+ darwin,
36
+ windows,
37
+ unknown
38
+ }
39
+
40
+ export function hostOs(id: PlatformName = platformName()): HostOs {
41
+ return HOSTS[id]
42
+ }
@@ -0,0 +1,62 @@
1
+ import { p } from "../exec"
2
+ import type { HostOs } from "./types"
3
+
4
+ function posixLiteral(value: string): string {
5
+ return `'${value.replaceAll("'", `'"'"'`)}'`
6
+ }
7
+
8
+ export const linux: HostOs = {
9
+ id: "linux",
10
+ toolchainOs: "linux",
11
+ supportsBubblewrap: true,
12
+ directoryLinkKinds: ["symlink"],
13
+ executableSuffixes: [""],
14
+ invoke: (executablePath, args) => ({ command: executablePath, args }),
15
+ bunExecutableName: "bun",
16
+ bunInstaller: (pin, directory) => {
17
+ const scriptPath = p(directory, "install.sh")
18
+ return {
19
+ scriptPath,
20
+ download: {
21
+ command: "curl",
22
+ args: ["-fsSL", "https://bun.sh/install", "-o", scriptPath]
23
+ },
24
+ run: {
25
+ command: "bash",
26
+ args: [scriptPath, `bun-v${pin}`]
27
+ }
28
+ }
29
+ },
30
+ environmentSetting: (name, value) => ({
31
+ kind: "profile",
32
+ candidates: [".zshrc", ".bashrc", ".bash_profile", ".profile", ".zshenv"],
33
+ target: (shell) => {
34
+ const shellPath = shell ?? "bash"
35
+ const shellName = shellPath.slice(shellPath.lastIndexOf("/") + 1)
36
+ return shellName === "zsh" ? ".zshrc" : shellName === "bash" ? ".bashrc" : ".profile"
37
+ },
38
+ line: `export ${name}=${value}`
39
+ }),
40
+ statusLineCommand: (bun, script) => {
41
+ const bunLiteral = posixLiteral(bun)
42
+ const scriptLiteral = posixLiteral(script)
43
+ return `test -x ${bunLiteral} && test -f ${scriptLiteral} && exec ${bunLiteral} ${scriptLiteral} || true`
44
+ },
45
+ failureHookCommand: (command) => command,
46
+ installHint: (tool) => {
47
+ switch (tool) {
48
+ case "git":
49
+ return "sudo apt install -y git (or your distro's package manager)"
50
+ case "jq":
51
+ return "sudo apt install -y jq"
52
+ case "curl":
53
+ return "sudo apt install -y curl"
54
+ case "ffplay":
55
+ return "sudo apt install -y ffmpeg"
56
+ case "claude":
57
+ return "curl -fsSL https://claude.ai/install.sh -o /tmp/claude-install.sh && bash /tmp/claude-install.sh"
58
+ case "codex":
59
+ return 'tmp=$(mktemp) && curl -fsSL https://chatgpt.com/codex/install.sh -o "$tmp" && CODEX_NON_INTERACTIVE=1 sh "$tmp"'
60
+ }
61
+ }
62
+ }
@@ -0,0 +1,73 @@
1
+ /**
2
+ * Host-to-artifact map (Platform seam in DESIGN.md). Every launcher, installer,
3
+ * build script, and release workflow selects a compiled binary from this one
4
+ * table; cli/test/unit/hostTargets.test.ts parses those scripts and fails when
5
+ * any of them drifts from it.
6
+ *
7
+ * Membership here is the support matrix: adding a row is how a host becomes
8
+ * supported, and `requireSupportedHost` in cli/src/engine.ts admits exactly the
9
+ * platform/arch pairs this table names.
10
+ */
11
+ import { platformName, type PlatformName } from "./index"
12
+
13
+ export type TargetId =
14
+ | "linux-x64"
15
+ | "linux-arm64"
16
+ | "darwin-x64"
17
+ | "darwin-arm64"
18
+ | "windows-x64"
19
+ | "windows-arm64"
20
+
21
+ export type TargetArch = "x64" | "arm64"
22
+
23
+ export interface HostTarget {
24
+ readonly id: TargetId
25
+ readonly platform: PlatformName
26
+ readonly arch: TargetArch
27
+ /** `bun build --compile --target=` value. */
28
+ readonly bunTarget: string
29
+ /** Compiled binary file name, including the Windows `.exe` suffix. */
30
+ readonly artifact: string
31
+ /** `uname -s`-`uname -m` keys the Bash launcher matches; empty on Windows. */
32
+ readonly unameKeys: ReadonlyArray<string>
33
+ /**
34
+ * `$env:PROCESSOR_ARCHITECTURE` values the PowerShell launcher matches; empty
35
+ * off Windows. Windows PowerShell 5.1 runs emulated on ARM64 and reports
36
+ * `AMD64` there, so a launcher must read `PROCESSOR_ARCHITEW6432` first.
37
+ */
38
+ readonly processorArchitectures: ReadonlyArray<string>
39
+ }
40
+
41
+ const target = (
42
+ id: TargetId,
43
+ platform: PlatformName,
44
+ arch: TargetArch,
45
+ unameKeys: ReadonlyArray<string>,
46
+ processorArchitectures: ReadonlyArray<string>
47
+ ): HostTarget => ({
48
+ id,
49
+ platform,
50
+ arch,
51
+ bunTarget: `bun-${id}`,
52
+ artifact: platform === "windows" ? `docks-kit-${id}.exe` : `docks-kit-${id}`,
53
+ unameKeys,
54
+ processorArchitectures
55
+ })
56
+
57
+ export const HOST_TARGETS: ReadonlyArray<HostTarget> = [
58
+ target("linux-x64", "linux", "x64", ["Linux-x86_64"], []),
59
+ target("linux-arm64", "linux", "arm64", ["Linux-aarch64"], []),
60
+ target("darwin-x64", "darwin", "x64", ["Darwin-x86_64"], []),
61
+ target("darwin-arm64", "darwin", "arm64", ["Darwin-arm64"], []),
62
+ target("windows-x64", "windows", "x64", [], ["AMD64"]),
63
+ target("windows-arm64", "windows", "arm64", [], ["ARM64"])
64
+ ]
65
+
66
+ export function targetFor(platform: PlatformName, arch: string): HostTarget | undefined {
67
+ return HOST_TARGETS.find((t) => t.platform === platform && t.arch === arch)
68
+ }
69
+
70
+ /** Resolve the artifact for a raw Node platform/arch pair. */
71
+ export function targetForHost(platform: NodeJS.Platform, arch: string): HostTarget | undefined {
72
+ return targetFor(platformName(platform), arch)
73
+ }
@@ -0,0 +1,75 @@
1
+ /**
2
+ * Host modules own only facts that cannot be probed. Anything that can fail at
3
+ * runtime stays try-then-fallback at the call site; a host module contributes
4
+ * only the order of that fallback chain.
5
+ */
6
+
7
+ export type PlatformName = "linux" | "darwin" | "windows" | "unknown"
8
+ /** Directory-link mechanisms, tried in order before the copy fallback. */
9
+ export type DirectoryLinkKind = "symlink" | "junction"
10
+ /** Tools whose install command differs per host OS. */
11
+ export type HintedTool = "git" | "jq" | "curl" | "ffplay" | "claude" | "codex"
12
+
13
+ /** A command plus argv, already shaped for how this host must invoke it. */
14
+ export interface Invocation {
15
+ readonly command: string
16
+ readonly args: ReadonlyArray<string>
17
+ /** Preserve a fully quoted Windows command line instead of applying libuv quoting. */
18
+ readonly windowsVerbatimArguments?: boolean
19
+ }
20
+
21
+ /** How this host persists a user-level environment variable. */
22
+ export type EnvironmentSetting =
23
+ | {
24
+ readonly kind: "profile"
25
+ /** Home-relative files scanned for an existing setting. */
26
+ readonly candidates: ReadonlyArray<string>
27
+ /** Home-relative file that receives the write for this shell. */
28
+ readonly target: (shell: string | undefined) => string
29
+ /** Line appended to the target file. */
30
+ readonly line: string
31
+ }
32
+ | {
33
+ readonly kind: "command"
34
+ /** Exits zero when the variable is already persisted. */
35
+ readonly probe: Invocation
36
+ /** Persists the variable. */
37
+ readonly apply: Invocation
38
+ /** Where the value lands, named for the log line. */
39
+ readonly location: string
40
+ /** Manual recovery when `apply` fails. */
41
+ readonly manualHint: string
42
+ }
43
+
44
+ /** The two steps that fetch and run a pinned Bun installer. */
45
+ export interface BunInstaller {
46
+ readonly scriptPath: string
47
+ readonly download: Invocation
48
+ readonly run: Invocation
49
+ }
50
+
51
+ export interface HostOs {
52
+ readonly id: PlatformName
53
+ /** `SoT/toolchain.json` `os` field this host matches; "" matches every row. */
54
+ readonly toolchainOs: string
55
+ /** Bubblewrap is the Linux-only Codex sandbox runtime. */
56
+ readonly supportsBubblewrap: boolean
57
+ /** Directory-link mechanisms tried in order before `skillsSync.ts linkOrCopy` copies. */
58
+ readonly directoryLinkKinds: ReadonlyArray<DirectoryLinkKind>
59
+ /** One-line install command for a tool whose package differs per OS. */
60
+ readonly installHint: (tool: HintedTool) => string
61
+ /** Filename suffixes tried in order when resolving a bare tool name on PATH. */
62
+ readonly executableSuffixes: ReadonlyArray<string>
63
+ /** Shape the argv that actually runs a resolved executable on this host. */
64
+ readonly invoke: (executablePath: string, args: ReadonlyArray<string>) => Invocation
65
+ /** Bun executable filename inside `<bunRoot>/bin`. */
66
+ readonly bunExecutableName: string
67
+ /** Download-then-run installer for a pinned Bun version placed in `directory`. */
68
+ readonly bunInstaller: (pin: string, directory: string) => BunInstaller
69
+ /** How this host persists a user-level environment variable. */
70
+ readonly environmentSetting: (name: string, value: string) => EnvironmentSetting
71
+ /** Claude `statusLine.command` for a resolved bun executable and script. */
72
+ readonly statusLineCommand: (bun: string, script: string) => string
73
+ /** Reshape the SoT `PostToolUseFailure` command for this host's shell. */
74
+ readonly failureHookCommand: (command: string) => string
75
+ }