@kimchi-dev/kimchictl 0.0.2

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 (231) hide show
  1. package/LICENSE +203 -0
  2. package/README.md +105 -0
  3. package/bin/kimchictl.mjs +17 -0
  4. package/dist/api/http.d.ts +7 -0
  5. package/dist/api/http.d.ts.map +1 -0
  6. package/dist/api/http.js +60 -0
  7. package/dist/api/http.js.map +1 -0
  8. package/dist/api/keys.d.ts +7 -0
  9. package/dist/api/keys.d.ts.map +1 -0
  10. package/dist/api/keys.js +28 -0
  11. package/dist/api/keys.js.map +1 -0
  12. package/dist/api/me.d.ts +12 -0
  13. package/dist/api/me.d.ts.map +1 -0
  14. package/dist/api/me.js +28 -0
  15. package/dist/api/me.js.map +1 -0
  16. package/dist/api/resolver.d.ts +26 -0
  17. package/dist/api/resolver.d.ts.map +1 -0
  18. package/dist/api/resolver.js +62 -0
  19. package/dist/api/resolver.js.map +1 -0
  20. package/dist/api/resources.d.ts +16 -0
  21. package/dist/api/resources.d.ts.map +1 -0
  22. package/dist/api/resources.js +96 -0
  23. package/dist/api/resources.js.map +1 -0
  24. package/dist/api/templates.d.ts +19 -0
  25. package/dist/api/templates.d.ts.map +1 -0
  26. package/dist/api/templates.js +25 -0
  27. package/dist/api/templates.js.map +1 -0
  28. package/dist/api/tokens.d.ts +23 -0
  29. package/dist/api/tokens.d.ts.map +1 -0
  30. package/dist/api/tokens.js +51 -0
  31. package/dist/api/tokens.js.map +1 -0
  32. package/dist/api/types.d.ts +34 -0
  33. package/dist/api/types.d.ts.map +1 -0
  34. package/dist/api/types.js +32 -0
  35. package/dist/api/types.js.map +1 -0
  36. package/dist/api/wait.d.ts +17 -0
  37. package/dist/api/wait.d.ts.map +1 -0
  38. package/dist/api/wait.js +37 -0
  39. package/dist/api/wait.js.map +1 -0
  40. package/dist/api/workspaces.d.ts +33 -0
  41. package/dist/api/workspaces.d.ts.map +1 -0
  42. package/dist/api/workspaces.js +159 -0
  43. package/dist/api/workspaces.js.map +1 -0
  44. package/dist/auth/auth-file.d.ts +37 -0
  45. package/dist/auth/auth-file.d.ts.map +1 -0
  46. package/dist/auth/auth-file.js +100 -0
  47. package/dist/auth/auth-file.js.map +1 -0
  48. package/dist/auth/browser-login.d.ts +45 -0
  49. package/dist/auth/browser-login.d.ts.map +1 -0
  50. package/dist/auth/browser-login.js +195 -0
  51. package/dist/auth/browser-login.js.map +1 -0
  52. package/dist/auth/config-file.d.ts +4 -0
  53. package/dist/auth/config-file.d.ts.map +1 -0
  54. package/dist/auth/config-file.js +52 -0
  55. package/dist/auth/config-file.js.map +1 -0
  56. package/dist/auth/models-providers.d.ts +17 -0
  57. package/dist/auth/models-providers.d.ts.map +1 -0
  58. package/dist/auth/models-providers.js +63 -0
  59. package/dist/auth/models-providers.js.map +1 -0
  60. package/dist/auth/paths.d.ts +15 -0
  61. package/dist/auth/paths.d.ts.map +1 -0
  62. package/dist/auth/paths.js +25 -0
  63. package/dist/auth/paths.js.map +1 -0
  64. package/dist/auth/provider-ids.d.ts +4 -0
  65. package/dist/auth/provider-ids.d.ts.map +1 -0
  66. package/dist/auth/provider-ids.js +12 -0
  67. package/dist/auth/provider-ids.js.map +1 -0
  68. package/dist/auth/resolve.d.ts +19 -0
  69. package/dist/auth/resolve.d.ts.map +1 -0
  70. package/dist/auth/resolve.js +37 -0
  71. package/dist/auth/resolve.js.map +1 -0
  72. package/dist/cli.d.ts +2 -0
  73. package/dist/cli.d.ts.map +1 -0
  74. package/dist/cli.js +15 -0
  75. package/dist/cli.js.map +1 -0
  76. package/dist/commands/completion.d.ts +2 -0
  77. package/dist/commands/completion.d.ts.map +1 -0
  78. package/dist/commands/completion.js +71 -0
  79. package/dist/commands/completion.js.map +1 -0
  80. package/dist/commands/flags.d.ts +16 -0
  81. package/dist/commands/flags.d.ts.map +1 -0
  82. package/dist/commands/flags.js +23 -0
  83. package/dist/commands/flags.js.map +1 -0
  84. package/dist/commands/guard.d.ts +10 -0
  85. package/dist/commands/guard.d.ts.map +1 -0
  86. package/dist/commands/guard.js +41 -0
  87. package/dist/commands/guard.js.map +1 -0
  88. package/dist/commands/help.d.ts +4 -0
  89. package/dist/commands/help.d.ts.map +1 -0
  90. package/dist/commands/help.js +19 -0
  91. package/dist/commands/help.js.map +1 -0
  92. package/dist/commands/login.d.ts +18 -0
  93. package/dist/commands/login.d.ts.map +1 -0
  94. package/dist/commands/login.js +80 -0
  95. package/dist/commands/login.js.map +1 -0
  96. package/dist/commands/logout.d.ts +14 -0
  97. package/dist/commands/logout.d.ts.map +1 -0
  98. package/dist/commands/logout.js +41 -0
  99. package/dist/commands/logout.js.map +1 -0
  100. package/dist/commands/prompt.d.ts +9 -0
  101. package/dist/commands/prompt.d.ts.map +1 -0
  102. package/dist/commands/prompt.js +21 -0
  103. package/dist/commands/prompt.js.map +1 -0
  104. package/dist/commands/registry.d.ts +16 -0
  105. package/dist/commands/registry.d.ts.map +1 -0
  106. package/dist/commands/registry.js +35 -0
  107. package/dist/commands/registry.js.map +1 -0
  108. package/dist/commands/ssh.d.ts +15 -0
  109. package/dist/commands/ssh.d.ts.map +1 -0
  110. package/dist/commands/ssh.js +90 -0
  111. package/dist/commands/ssh.js.map +1 -0
  112. package/dist/commands/table.d.ts +3 -0
  113. package/dist/commands/table.d.ts.map +1 -0
  114. package/dist/commands/table.js +10 -0
  115. package/dist/commands/table.js.map +1 -0
  116. package/dist/commands/update.d.ts +16 -0
  117. package/dist/commands/update.d.ts.map +1 -0
  118. package/dist/commands/update.js +57 -0
  119. package/dist/commands/update.js.map +1 -0
  120. package/dist/commands/version.d.ts +2 -0
  121. package/dist/commands/version.d.ts.map +1 -0
  122. package/dist/commands/version.js +6 -0
  123. package/dist/commands/version.js.map +1 -0
  124. package/dist/commands/whoami.d.ts +7 -0
  125. package/dist/commands/whoami.d.ts.map +1 -0
  126. package/dist/commands/whoami.js +30 -0
  127. package/dist/commands/whoami.js.map +1 -0
  128. package/dist/commands/workspace.d.ts +15 -0
  129. package/dist/commands/workspace.d.ts.map +1 -0
  130. package/dist/commands/workspace.js +206 -0
  131. package/dist/commands/workspace.js.map +1 -0
  132. package/dist/run.d.ts +10 -0
  133. package/dist/run.d.ts.map +1 -0
  134. package/dist/run.js +30 -0
  135. package/dist/run.js.map +1 -0
  136. package/dist/ssh/bridge.d.ts +25 -0
  137. package/dist/ssh/bridge.d.ts.map +1 -0
  138. package/dist/ssh/bridge.js +95 -0
  139. package/dist/ssh/bridge.js.map +1 -0
  140. package/dist/ssh/config.d.ts +65 -0
  141. package/dist/ssh/config.d.ts.map +1 -0
  142. package/dist/ssh/config.js +198 -0
  143. package/dist/ssh/config.js.map +1 -0
  144. package/dist/ssh/connect.d.ts +27 -0
  145. package/dist/ssh/connect.d.ts.map +1 -0
  146. package/dist/ssh/connect.js +67 -0
  147. package/dist/ssh/connect.js.map +1 -0
  148. package/dist/ssh/tunnel.d.ts +29 -0
  149. package/dist/ssh/tunnel.d.ts.map +1 -0
  150. package/dist/ssh/tunnel.js +36 -0
  151. package/dist/ssh/tunnel.js.map +1 -0
  152. package/dist/test-support.d.ts +30 -0
  153. package/dist/test-support.d.ts.map +1 -0
  154. package/dist/test-support.js +67 -0
  155. package/dist/test-support.js.map +1 -0
  156. package/dist/updates/check.d.ts +18 -0
  157. package/dist/updates/check.d.ts.map +1 -0
  158. package/dist/updates/check.js +90 -0
  159. package/dist/updates/check.js.map +1 -0
  160. package/dist/updates/self.d.ts +36 -0
  161. package/dist/updates/self.d.ts.map +1 -0
  162. package/dist/updates/self.js +103 -0
  163. package/dist/updates/self.js.map +1 -0
  164. package/dist/version.d.ts +7 -0
  165. package/dist/version.d.ts.map +1 -0
  166. package/dist/version.js +7 -0
  167. package/dist/version.js.map +1 -0
  168. package/package.json +77 -0
  169. package/src/api/http.ts +70 -0
  170. package/src/api/keys.ts +40 -0
  171. package/src/api/me.ts +48 -0
  172. package/src/api/resolver.test.ts +109 -0
  173. package/src/api/resolver.ts +72 -0
  174. package/src/api/resources.test.ts +56 -0
  175. package/src/api/resources.ts +92 -0
  176. package/src/api/templates.ts +28 -0
  177. package/src/api/tokens.ts +80 -0
  178. package/src/api/types.ts +50 -0
  179. package/src/api/wait.ts +45 -0
  180. package/src/api/workspaces.ts +234 -0
  181. package/src/auth/auth-file.test.ts +176 -0
  182. package/src/auth/auth-file.ts +103 -0
  183. package/src/auth/browser-login.test.ts +93 -0
  184. package/src/auth/browser-login.ts +247 -0
  185. package/src/auth/config-file.test.ts +68 -0
  186. package/src/auth/config-file.ts +50 -0
  187. package/src/auth/models-providers.test.ts +63 -0
  188. package/src/auth/models-providers.ts +76 -0
  189. package/src/auth/paths.ts +28 -0
  190. package/src/auth/provider-ids.ts +15 -0
  191. package/src/auth/resolve.test.ts +72 -0
  192. package/src/auth/resolve.ts +46 -0
  193. package/src/cli.ts +16 -0
  194. package/src/commands/completion.test.ts +31 -0
  195. package/src/commands/completion.ts +77 -0
  196. package/src/commands/flags.ts +29 -0
  197. package/src/commands/guard.ts +44 -0
  198. package/src/commands/help.ts +20 -0
  199. package/src/commands/login.test.ts +159 -0
  200. package/src/commands/login.ts +94 -0
  201. package/src/commands/logout.test.ts +89 -0
  202. package/src/commands/logout.ts +53 -0
  203. package/src/commands/prompt.ts +21 -0
  204. package/src/commands/registry.ts +50 -0
  205. package/src/commands/ssh.test.ts +101 -0
  206. package/src/commands/ssh.ts +112 -0
  207. package/src/commands/table.test.ts +24 -0
  208. package/src/commands/table.ts +10 -0
  209. package/src/commands/update.test.ts +91 -0
  210. package/src/commands/update.ts +78 -0
  211. package/src/commands/version.ts +6 -0
  212. package/src/commands/whoami.test.ts +72 -0
  213. package/src/commands/whoami.ts +36 -0
  214. package/src/commands/workspace.test.ts +360 -0
  215. package/src/commands/workspace.ts +250 -0
  216. package/src/run.test.ts +53 -0
  217. package/src/run.ts +34 -0
  218. package/src/ssh/bridge.test.ts +70 -0
  219. package/src/ssh/bridge.ts +110 -0
  220. package/src/ssh/config.test.ts +213 -0
  221. package/src/ssh/config.ts +233 -0
  222. package/src/ssh/connect.test.ts +123 -0
  223. package/src/ssh/connect.ts +98 -0
  224. package/src/ssh/tunnel.test.ts +119 -0
  225. package/src/ssh/tunnel.ts +64 -0
  226. package/src/test-support.ts +77 -0
  227. package/src/updates/check.test.ts +99 -0
  228. package/src/updates/check.ts +117 -0
  229. package/src/updates/self.test.ts +117 -0
  230. package/src/updates/self.ts +131 -0
  231. package/src/version.ts +6 -0
package/src/cli.ts ADDED
@@ -0,0 +1,16 @@
1
+ import { run } from "./run.js"
2
+ import { maybeNotifyUpdate } from "./updates/check.js"
3
+
4
+ // Executable CLI target: `bun build --compile src/cli.ts`, `bun run dev`,
5
+ // and the published dist/cli.js behind bin/kimchictl.mjs. Self-executes on
6
+ // import; never imported as a library (run.ts is the library surface).
7
+ const argv = process.argv.slice(2)
8
+ const code = await run(argv)
9
+
10
+ // Daily background update notice: prints to stderr, never fails the command.
11
+ // Skipped for `ssh` — its stdio belongs to the SSH session / proxy bridge,
12
+ // where any extra bytes would corrupt the stream.
13
+ if (argv[0] !== "ssh") {
14
+ await maybeNotifyUpdate()
15
+ }
16
+ process.exitCode = code
@@ -0,0 +1,31 @@
1
+ import { afterEach, describe, expect, it, vi } from "vitest"
2
+ import { captureConsole } from "../test-support.js"
3
+ import { runCompletion } from "./completion.js"
4
+ import { guardCommand } from "./guard.js"
5
+
6
+ afterEach(() => {
7
+ vi.restoreAllMocks()
8
+ })
9
+
10
+ const guard = guardCommand(runCompletion)
11
+
12
+ describe("kimchictl completion", () => {
13
+ it.each(["bash", "zsh", "fish"])("prints %s completions with the command surface", async (shell) => {
14
+ const { lines } = captureConsole()
15
+
16
+ const code = await guard([shell])
17
+
18
+ expect(code).toBe(0)
19
+ const script = lines.join("\n")
20
+ for (const name of ["login", "workspace", "ssh", "update"]) {
21
+ expect(script).toContain(name)
22
+ }
23
+ })
24
+
25
+ it("rejects unsupported shells with a usage error", async () => {
26
+ const { errors } = captureConsole()
27
+
28
+ expect(await guard(["powershell"])).toBe(2)
29
+ expect(errors[0]).toContain("bash, zsh, fish")
30
+ })
31
+ })
@@ -0,0 +1,77 @@
1
+ import { parseFlags, UsageError } from "./flags.js"
2
+
3
+ /**
4
+ * Shell completions — deliberately minimal: complete top-level command names
5
+ * plus workspace subverbs; expand on demand rather than building a full spec.
6
+ */
7
+
8
+ const TOP_LEVEL = ["login", "logout", "whoami", "workspace", "ws", "ssh", "update", "completion", "version", "help"]
9
+ const WORKSPACE_VERBS = ["create", "list", "get", "delete", "--output", "--desc", "--force", "--no-wait", "--help"]
10
+
11
+ const BASH = `# kimchictl bash completion — source this file or place it in /etc/bash_completion.d/
12
+ _kimchictl() {
13
+ local cur prev
14
+ cur="\${COMP_WORDS[COMP_CWORD]}"
15
+ prev="\${COMP_WORDS[COMP_CWORD-1]}"
16
+ if [ "$COMP_CWORD" -eq 1 ]; then
17
+ COMPREPLY=( $(compgen -W "${TOP_LEVEL.join(" ")}" -- "$cur") )
18
+ return 0
19
+ fi
20
+ case "\${COMP_WORDS[1]}" in
21
+ workspace|ws)
22
+ [ "$COMP_CWORD" -eq 2 ] && COMPREPLY=( $(compgen -W "${WORKSPACE_VERBS.join(" ")}" -- "$cur") ) ;;
23
+ completion)
24
+ [ "$COMP_CWORD" -eq 2 ] && COMPREPLY=( $(compgen -W "bash zsh fish" -- "$cur") ) ;;
25
+ esac
26
+ return 0
27
+ }
28
+ complete -F _kimchictl kimchictl
29
+ `
30
+
31
+ const ZSH_COMMANDS = TOP_LEVEL.map((c) => `"${c}"`).join(" ")
32
+
33
+ const ZSH = `#compdef kimchictl
34
+ # kimchictl zsh completion — place in a directory on $fpath as _kimchictl
35
+ _kimchictl() {
36
+ local -a commands=${ZSH_COMMANDS}
37
+ if (( CURRENT == 2 )); then
38
+ _arguments "1:command:(\${commands})"
39
+ return
40
+ fi
41
+ case "$words[2]" in
42
+ workspace|ws)
43
+ (( CURRENT == 3 )) && _arguments "2:verb:(${WORKSPACE_VERBS.join(" ")})" ;;
44
+ completion)
45
+ (( CURRENT == 3 )) && _arguments "2:shell:(bash zsh fish)" ;;
46
+ esac
47
+ }
48
+ _kimchictl "$@"
49
+ `
50
+
51
+ const FISH = `# kimchictl fish completion — place in ~/.config/fish/completions/kimchictl.fish
52
+ complete -c kimchictl -f
53
+ complete -c kimchictl -n '__fish_use_subcommand' -a '${TOP_LEVEL.join(" ")}'
54
+ complete -c kimchictl -n '__fish_seen_subcommand_from workspace ws' -a '${WORKSPACE_VERBS.join(" ")}'
55
+ complete -c kimchictl -n '__fish_seen_subcommand_from completion' -a 'bash zsh fish'
56
+ `
57
+
58
+ export async function runCompletion(args: string[]): Promise<number> {
59
+ const { positionals } = parseFlags("completion", args, {})
60
+ const shell = positionals[0]
61
+ if (positionals.length > 1) {
62
+ throw new UsageError("kimchictl completion: expected one shell (bash|zsh|fish)")
63
+ }
64
+ switch (shell) {
65
+ case "bash":
66
+ console.log(BASH)
67
+ return 0
68
+ case "zsh":
69
+ console.log(ZSH)
70
+ return 0
71
+ case "fish":
72
+ console.log(FISH)
73
+ return 0
74
+ default:
75
+ throw new UsageError(`kimchictl completion: unsupported shell '${shell ?? ""}' — one of: bash, zsh, fish`)
76
+ }
77
+ }
@@ -0,0 +1,29 @@
1
+ import { type ParseArgsOptionsConfig, parseArgs } from "node:util"
2
+
3
+ /** Bad CLI input — surfaced with exit code 2, distinct from runtime failures (1). */
4
+ export class UsageError extends Error {
5
+ constructor(message: string) {
6
+ super(message)
7
+ this.name = "UsageError"
8
+ }
9
+ }
10
+
11
+ export type FlagValues = Record<string, string | boolean | undefined>
12
+
13
+ /**
14
+ * Strict flag parsing with a uniform usage-error shape across commands.
15
+ * Values keep their raw type (declared options are TypeScript-blind here —
16
+ * narrow at the call site, relying on the declared option `type`).
17
+ */
18
+ export function parseFlags(
19
+ command: string,
20
+ args: string[],
21
+ options: ParseArgsOptionsConfig,
22
+ ): { values: FlagValues; positionals: string[] } {
23
+ try {
24
+ const parsed = parseArgs({ args, options, strict: true, allowPositionals: true })
25
+ return { values: parsed.values as FlagValues, positionals: parsed.positionals }
26
+ } catch (err) {
27
+ throw new UsageError(`kimchictl ${command}: ${err instanceof Error ? err.message : String(err)}`)
28
+ }
29
+ }
@@ -0,0 +1,44 @@
1
+ import { AmbiguousWorkspaceError, WorkspaceNotFoundError } from "../api/resolver.js"
2
+ import { TemplatesUnavailableError } from "../api/templates.js"
3
+ import { RemoteAuthError, RemoteNetworkError } from "../api/types.js"
4
+ import { ModelsFetchError } from "../auth/models-providers.js"
5
+ import { NotLoggedInError } from "../auth/resolve.js"
6
+ import { BridgeError } from "../ssh/bridge.js"
7
+ import { UsageError } from "./flags.js"
8
+
9
+ /**
10
+ * Convert known domain errors into a clean message + exit code instead of a
11
+ * stack trace. Unknown errors rethrow so real bugs stay loud.
12
+ * A wrapped handler still fits CommandDefinition["run"] (deps optional).
13
+ *
14
+ * ✗ <message> → 1 (auth/network/usage-state errors)
15
+ * kimchictl cmd: <usage> → 2 (flag parsing)
16
+ */
17
+ export function guardCommand<TDeps>(
18
+ handler: (args: string[], deps?: TDeps) => Promise<number>,
19
+ ): (args: string[], deps?: TDeps) => Promise<number> {
20
+ return async (args, deps) => {
21
+ try {
22
+ return await handler(args, deps)
23
+ } catch (err) {
24
+ if (err instanceof UsageError) {
25
+ console.error(err.message)
26
+ return 2
27
+ }
28
+ if (
29
+ err instanceof NotLoggedInError ||
30
+ err instanceof ModelsFetchError ||
31
+ err instanceof TemplatesUnavailableError ||
32
+ err instanceof WorkspaceNotFoundError ||
33
+ err instanceof AmbiguousWorkspaceError ||
34
+ err instanceof BridgeError ||
35
+ err instanceof RemoteAuthError ||
36
+ err instanceof RemoteNetworkError
37
+ ) {
38
+ console.error(`✗ ${err.message}`)
39
+ return 1
40
+ }
41
+ throw err
42
+ }
43
+ }
44
+ }
@@ -0,0 +1,20 @@
1
+ import type { CommandDefinition } from "./registry.js"
2
+
3
+ /** Print top-level usage. Hidden commands are deliberately omitted. */
4
+ export function printHelp(commands: readonly CommandDefinition[], log: (line: string) => void = console.log): void {
5
+ log(`kimchictl — manage kimchi remote workspaces
6
+ `)
7
+ log(`Usage:
8
+ kimchictl <command> [options]
9
+ `)
10
+ log(`Commands:`)
11
+ const visible = commands.filter((c) => !c.hidden)
12
+ const width = Math.max(...visible.map((c) => c.name.length))
13
+ for (const command of visible) {
14
+ log(` ${command.name.padEnd(width)} ${command.summary}`)
15
+ }
16
+ log(`
17
+ Global flags:
18
+ -h, --help Show this help
19
+ -v, --version Print the version`)
20
+ }
@@ -0,0 +1,159 @@
1
+ import { readFileSync, writeFileSync } from "node:fs"
2
+ import { join } from "node:path"
3
+ import { afterEach, describe, expect, it, vi } from "vitest"
4
+ import {
5
+ captureConsole,
6
+ cleanupTempDirs,
7
+ jsonResponse,
8
+ makeTempDir,
9
+ sharedConfigPath,
10
+ stubAgentDirEnv,
11
+ stubFetch,
12
+ } from "../test-support.js"
13
+ import { runLogin } from "./login.js"
14
+
15
+ afterEach(() => {
16
+ cleanupTempDirs()
17
+ vi.unstubAllEnvs()
18
+ vi.restoreAllMocks()
19
+ })
20
+
21
+ const aiEnablerOnlyFetch = stubFetch(async () => jsonResponse({ models: [{ slug: "m1", provider: "ai-enabler" }] }))
22
+
23
+ describe("kimchictl login --api-key", () => {
24
+ it("stores the key in auth.json and config.json (shared with the harness)", async () => {
25
+ const dir = makeTempDir()
26
+ const { home } = stubAgentDirEnv(dir)
27
+ const { lines, errors } = captureConsole()
28
+
29
+ const fetch = stubFetch(async () =>
30
+ jsonResponse({
31
+ models: [
32
+ { slug: "m1", provider: "ai-enabler" },
33
+ { slug: "m2", provider: "anthropic" },
34
+ ],
35
+ }),
36
+ )
37
+
38
+ const code = await runLogin(["--api-key", "test-key"], { fetch })
39
+
40
+ expect(code).toBe(0)
41
+ expect(JSON.parse(readFileSync(join(dir, "auth.json"), "utf-8"))).toEqual({
42
+ "kimchi-dev": { type: "api_key", key: "test-key" },
43
+ "kimchi-dev/anthropic": { type: "api_key", key: "test-key" },
44
+ })
45
+ expect(JSON.parse(readFileSync(sharedConfigPath(home), "utf-8"))).toEqual({ apiKey: "test-key" })
46
+ expect(lines.some((l) => l.includes("Logged in to Kimchi"))).toBe(true)
47
+ // First login finishes the SSH integration automatically (with notice).
48
+ expect(errors.some((l) => l.includes("SSH integration configured"))).toBe(true)
49
+ })
50
+
51
+ it("unions provider ids from an existing harness models.json", async () => {
52
+ const dir = makeTempDir()
53
+ stubAgentDirEnv(dir)
54
+ writeFileSync(
55
+ join(dir, "models.json"),
56
+ JSON.stringify({ providers: { "kimchi-dev": {}, "kimchi-dev/openai": {}, ollama: {} } }),
57
+ )
58
+ captureConsole()
59
+
60
+ const code = await runLogin(["--api-key", "test-key"], { fetch: aiEnablerOnlyFetch })
61
+
62
+ expect(code).toBe(0)
63
+ expect(JSON.parse(readFileSync(join(dir, "auth.json"), "utf-8"))).toEqual({
64
+ "kimchi-dev": { type: "api_key", key: "test-key" },
65
+ "kimchi-dev/openai": { type: "api_key", key: "test-key" },
66
+ })
67
+ })
68
+
69
+ it("rejects an invalid key (401) and writes nothing", async () => {
70
+ const dir = makeTempDir()
71
+ stubAgentDirEnv(dir)
72
+ const { errors } = captureConsole()
73
+ const fetch = stubFetch(async () => new Response("nope", { status: 401, statusText: "Unauthorized" }))
74
+
75
+ const code = await runLogin(["--api-key", "bad-key"], { fetch })
76
+
77
+ expect(code).toBe(1)
78
+ expect(errors[0]).toMatch(/Invalid API key/)
79
+ expect(() => readFileSync(join(dir, "auth.json"))).toThrow()
80
+ })
81
+
82
+ it("fails hard when verification is unreachable for --api-key", async () => {
83
+ const dir = makeTempDir()
84
+ stubAgentDirEnv(dir)
85
+ const { errors } = captureConsole()
86
+ const fetch = stubFetch(async () => new Response("down", { status: 503, statusText: "Unavailable" }))
87
+
88
+ const code = await runLogin(["--api-key", "key"], { fetch })
89
+
90
+ expect(code).toBe(1)
91
+ expect(errors[0]).toMatch(/Could not verify the API key/)
92
+ })
93
+
94
+ it("treats an empty --api-key as a usage error", async () => {
95
+ stubAgentDirEnv(makeTempDir())
96
+ const { errors } = captureConsole()
97
+
98
+ expect(await runLogin(["--api-key", " "])).toBe(2)
99
+ expect(errors[0]).toMatch(/--api-key must not be empty/)
100
+ })
101
+ })
102
+
103
+ describe("kimchictl login (browser)", () => {
104
+ it("completes the OAuth round trip and saves the token", async () => {
105
+ const dir = makeTempDir()
106
+ stubAgentDirEnv(dir)
107
+ captureConsole()
108
+
109
+ const code = await runLogin([], {
110
+ interactive: true,
111
+ fetch: aiEnablerOnlyFetch,
112
+ open: async (url) => {
113
+ const parsed = new URL(url)
114
+ const callback = new URL(decodeURIComponent(parsed.searchParams.get("callback") ?? ""))
115
+ callback.searchParams.set("state", parsed.searchParams.get("state") ?? "")
116
+ callback.searchParams.set("token", "browser-token")
117
+ await fetch(callback)
118
+ },
119
+ })
120
+
121
+ expect(code).toBe(0)
122
+ expect(JSON.parse(readFileSync(join(dir, "auth.json"), "utf-8"))).toEqual({
123
+ "kimchi-dev": { type: "api_key", key: "browser-token" },
124
+ })
125
+ })
126
+
127
+ it("keeps the credential with a warning when the metadata fetch is unreachable", async () => {
128
+ const dir = makeTempDir()
129
+ stubAgentDirEnv(dir)
130
+ const { errors } = captureConsole()
131
+ const downFetch = stubFetch(async () => new Response("down", { status: 503, statusText: "Unavailable" }))
132
+
133
+ const code = await runLogin([], {
134
+ interactive: true,
135
+ fetch: downFetch,
136
+ open: async (url) => {
137
+ const parsed = new URL(url)
138
+ const callback = new URL(decodeURIComponent(parsed.searchParams.get("callback") ?? ""))
139
+ callback.searchParams.set("state", parsed.searchParams.get("state") ?? "")
140
+ callback.searchParams.set("token", "browser-token")
141
+ await fetch(callback)
142
+ },
143
+ })
144
+
145
+ expect(code).toBe(0)
146
+ expect(JSON.parse(readFileSync(join(dir, "auth.json"), "utf-8"))).toEqual({
147
+ "kimchi-dev": { type: "api_key", key: "browser-token" },
148
+ })
149
+ expect(errors.some((l) => l.includes("Could not refresh the Kimchi model list"))).toBe(true)
150
+ })
151
+
152
+ it("requires a TTY without --api-key", async () => {
153
+ stubAgentDirEnv(makeTempDir())
154
+ const { errors } = captureConsole()
155
+ const code = await runLogin([], { interactive: false })
156
+ expect(code).toBe(1)
157
+ expect(errors[0]).toMatch(/requires a TTY/)
158
+ })
159
+ })
@@ -0,0 +1,94 @@
1
+ import { kimchiProviderIdsFromModelsJson, syncSharedAuth } from "../auth/auth-file.js"
2
+ import { authenticateViaBrowser } from "../auth/browser-login.js"
3
+ import { writeApiKeyToConfig } from "../auth/config-file.js"
4
+ import { fetchKimchiProviderIds, ModelsFetchError } from "../auth/models-providers.js"
5
+ import { resolveAuthJsonPath, resolveConfigJsonPath, resolveModelsJsonPath } from "../auth/paths.js"
6
+ import { ensureSshIntegration } from "../ssh/connect.js"
7
+ import { parseFlags } from "./flags.js"
8
+ import { isInteractive } from "./prompt.js"
9
+
10
+ export interface LoginDeps {
11
+ /** Browser opener override (tests). */
12
+ open?: (url: string) => Promise<unknown>
13
+ fetch?: typeof globalThis.fetch
14
+ env?: NodeJS.ProcessEnv
15
+ /** Override TTY interactivity detection (tests). */
16
+ interactive?: boolean
17
+ }
18
+
19
+ /**
20
+ * `kimchictl login` — obtain an API key (browser OAuth or --api-key) and
21
+ * store it in the harness's shared credential files:
22
+ * - auth.json: one `{ type: "api_key", key }` per Kimchi provider id
23
+ * (ids from an existing models.json plus a fresh metadata fetch)
24
+ * - config.json: the `apiKey` field
25
+ * …so the harness picks the login up without its own `kimchi login`.
26
+ */
27
+ export async function runLogin(args: string[], deps: LoginDeps = {}): Promise<number> {
28
+ const { values } = parseFlags("login", args, {
29
+ "api-key": { type: "string" },
30
+ })
31
+ const env = deps.env ?? process.env
32
+
33
+ const authPath = resolveAuthJsonPath(env)
34
+ const configPath = resolveConfigJsonPath(env)
35
+ const modelsPath = resolveModelsJsonPath(env)
36
+
37
+ let apiKey: string
38
+ const apiKeyFlag = values["api-key"]
39
+ const apiKeyProvided = apiKeyFlag !== undefined
40
+ if (apiKeyProvided) {
41
+ // parseArgs guarantees a string here (--api-key is declared type: "string").
42
+ apiKey = typeof apiKeyFlag === "string" ? apiKeyFlag.trim() : ""
43
+ if (!apiKey) {
44
+ console.error("kimchictl login: --api-key must not be empty")
45
+ return 2
46
+ }
47
+ } else {
48
+ const interactive = deps.interactive ?? isInteractive()
49
+ if (!interactive) {
50
+ console.error("kimchictl login: interactive login requires a TTY; use --api-key for non-interactive mode")
51
+ return 1
52
+ }
53
+ const { token } = await authenticateViaBrowser({
54
+ open: deps.open,
55
+ onBrowserUrl: (url) => console.log(`If the browser does not open, visit:\n${url}`),
56
+ })
57
+ apiKey = token
58
+ }
59
+
60
+ // Validate the key against the models metadata API and derive the full
61
+ // Kimchi provider-id set (kimchi-dev + kimchi-dev/<upstream>) in one call.
62
+ let fetchedIds: string[] = []
63
+ try {
64
+ fetchedIds = await fetchKimchiProviderIds(apiKey, { fetch: deps.fetch })
65
+ } catch (err) {
66
+ if (err instanceof ModelsFetchError && err.status === 401) {
67
+ console.error("✗ Invalid API key. Please check the key and try again.")
68
+ return 1
69
+ }
70
+ const detail = err instanceof Error ? err.message : String(err)
71
+ if (apiKeyProvided) {
72
+ // Mirror the harness's strict api-key login: an unverifiable key is not saved.
73
+ console.error(`✗ Could not verify the API key (${detail}). Try again later.`)
74
+ return 1
75
+ }
76
+ // Browser login: the token came from the web app seconds ago, so keep it and
77
+ // fall back to the ids we know. (Harness: non-strict refresh with cache fallback.)
78
+ console.error(
79
+ `⚠ Could not refresh the Kimchi model list (${detail}); saving the base credential. ` +
80
+ `Run \`kimchictl login\` again when the endpoint is reachable to pick up sub-provider credentials.`,
81
+ )
82
+ }
83
+
84
+ const providerIds = [...new Set([...kimchiProviderIdsFromModelsJson(modelsPath), ...fetchedIds])]
85
+ await syncSharedAuth(authPath, apiKey, providerIds)
86
+ writeApiKeyToConfig(configPath, apiKey)
87
+
88
+ console.log(`✓ Logged in to Kimchi — credentials saved to ${authPath}`)
89
+ console.log(" Shared with the kimchi coding harness; no second login needed there.")
90
+ // First login is the natural moment to finish the native SSH integration
91
+ // (automatic-with-notice; KIMCHICTL_NO_SSH_SETUP opts out).
92
+ await ensureSshIntegration({ env })
93
+ return 0
94
+ }
@@ -0,0 +1,89 @@
1
+ import { readFileSync, writeFileSync } from "node:fs"
2
+ import { join } from "node:path"
3
+ import { afterEach, describe, expect, it, vi } from "vitest"
4
+ import {
5
+ captureConsole,
6
+ cleanupTempDirs,
7
+ makeTempDir,
8
+ sharedConfigPath,
9
+ stubAgentDirEnv,
10
+ writeSharedConfigJson,
11
+ } from "../test-support.js"
12
+ import { runLogout } from "./logout.js"
13
+
14
+ afterEach(() => {
15
+ cleanupTempDirs()
16
+ vi.unstubAllEnvs()
17
+ vi.restoreAllMocks()
18
+ })
19
+
20
+ function seedCredentials(dir: string, home: string): void {
21
+ writeFileSync(
22
+ join(dir, "auth.json"),
23
+ JSON.stringify({
24
+ "kimchi-dev": { type: "api_key", key: "key" },
25
+ "kimchi-dev/openai": { type: "api_key", key: "key" },
26
+ anthropic: { type: "api_key", key: "keep" },
27
+ }),
28
+ )
29
+ writeSharedConfigJson(home, { apiKey: "key", other: true })
30
+ }
31
+
32
+ describe("kimchictl logout", () => {
33
+ it("removes shared credentials with --force (both files, other entries preserved)", async () => {
34
+ const dir = makeTempDir()
35
+ const { home } = stubAgentDirEnv(dir)
36
+ seedCredentials(dir, home)
37
+ const { lines } = captureConsole()
38
+
39
+ expect(await runLogout(["--force"])).toBe(0)
40
+
41
+ expect(JSON.parse(readFileSync(join(dir, "auth.json"), "utf-8"))).toEqual({
42
+ anthropic: { type: "api_key", key: "keep" },
43
+ })
44
+ expect(JSON.parse(readFileSync(sharedConfigPath(home), "utf-8"))).toEqual({ other: true })
45
+ expect(lines.some((l) => l.includes("Logged out"))).toBe(true)
46
+ })
47
+
48
+ it("confirms via the prompt by default (interactive path)", async () => {
49
+ const dir = makeTempDir()
50
+ const { home } = stubAgentDirEnv(dir)
51
+ seedCredentials(dir, home)
52
+ const { errors } = captureConsole()
53
+
54
+ expect(await runLogout([], { confirm: async () => true })).toBe(0)
55
+ expect(errors[0]).toMatch(/also signs out the kimchi coding harness/)
56
+ expect(JSON.parse(readFileSync(sharedConfigPath(home), "utf-8"))).toEqual({ other: true })
57
+ })
58
+
59
+ it("leaves everything untouched when the confirmation is declined", async () => {
60
+ const dir = makeTempDir()
61
+ const { home } = stubAgentDirEnv(dir)
62
+ seedCredentials(dir, home)
63
+ const { lines } = captureConsole()
64
+
65
+ expect(await runLogout([], { confirm: async () => false })).toBe(0)
66
+
67
+ expect(lines.some((l) => l.includes("Aborted"))).toBe(true)
68
+ expect(JSON.parse(readFileSync(join(dir, "auth.json"), "utf-8"))["kimchi-dev"]).toBeDefined()
69
+ expect(JSON.parse(readFileSync(sharedConfigPath(home), "utf-8")).apiKey).toBe("key")
70
+ })
71
+
72
+ it("requires --force when non-interactive", async () => {
73
+ const dir = makeTempDir()
74
+ const { home } = stubAgentDirEnv(dir)
75
+ seedCredentials(dir, home)
76
+ const { errors } = captureConsole()
77
+
78
+ expect(await runLogout([], { interactive: false })).toBe(1)
79
+ expect(errors.some((l) => l.includes("--force"))).toBe(true)
80
+ expect(JSON.parse(readFileSync(join(dir, "auth.json"), "utf-8"))["kimchi-dev"]).toBeDefined()
81
+ })
82
+
83
+ it("reports when already logged out", async () => {
84
+ stubAgentDirEnv(makeTempDir())
85
+ const { lines } = captureConsole()
86
+ expect(await runLogout([])).toBe(0)
87
+ expect(lines).toEqual(["Not logged in."])
88
+ })
89
+ })
@@ -0,0 +1,53 @@
1
+ import { clearSharedAuth, readSavedApiKey } from "../auth/auth-file.js"
2
+ import { readApiKeyFromConfig, writeApiKeyToConfig } from "../auth/config-file.js"
3
+ import { resolveAuthJsonPath, resolveConfigJsonPath } from "../auth/paths.js"
4
+ import { parseFlags } from "./flags.js"
5
+ import { confirm, isInteractive } from "./prompt.js"
6
+
7
+ export interface LogoutDeps {
8
+ /** Confirmation override (tests). */
9
+ confirm?: () => Promise<boolean>
10
+ env?: NodeJS.ProcessEnv
11
+ /** Override TTY interactivity detection (tests). */
12
+ interactive?: boolean
13
+ }
14
+
15
+ /**
16
+ * `kimchictl logout` — remove the shared Kimchi credential from BOTH
17
+ * auth.json and config.json. This signs out the kimchi coding harness too
18
+ * (the credential is shared by design), which the user must acknowledge.
19
+ */
20
+ export async function runLogout(args: string[], deps: LogoutDeps = {}): Promise<number> {
21
+ const { values } = parseFlags("logout", args, {
22
+ force: { type: "boolean", short: "f" },
23
+ })
24
+ const env = deps.env ?? process.env
25
+
26
+ const authPath = resolveAuthJsonPath(env)
27
+ const configPath = resolveConfigJsonPath(env)
28
+
29
+ const hasCredential = readSavedApiKey(authPath) !== undefined || readApiKeyFromConfig(configPath) !== undefined
30
+ if (!hasCredential) {
31
+ console.log("Not logged in.")
32
+ return 0
33
+ }
34
+
35
+ if (!values.force) {
36
+ console.error("This also signs out the kimchi coding harness (the login is shared).")
37
+ const interactive = deps.interactive ?? isInteractive()
38
+ const ok = await (deps.confirm ? deps.confirm() : interactive ? confirm("Log out everywhere? [y/N] ") : false)
39
+ if (!ok) {
40
+ if (!deps.confirm && !interactive) {
41
+ console.error("kimchictl logout: not interactive — re-run with --force to confirm")
42
+ return 1
43
+ }
44
+ console.log("Aborted.")
45
+ return 0
46
+ }
47
+ }
48
+
49
+ await clearSharedAuth(authPath)
50
+ writeApiKeyToConfig(configPath, undefined)
51
+ console.log("✓ Logged out of Kimchi (kimchictl and the kimchi harness).")
52
+ return 0
53
+ }
@@ -0,0 +1,21 @@
1
+ import { createInterface } from "node:readline/promises"
2
+
3
+ /** True when an interactive prompt can be shown (both streams are terminals). */
4
+ export function isInteractive(): boolean {
5
+ return Boolean(process.stdin.isTTY && process.stdout.isTTY)
6
+ }
7
+
8
+ /**
9
+ * Ask a y/N question on the TTY. Anything but exactly "y" or "yes"
10
+ * (case-insensitive) counts as no. Only call when isInteractive() — callers
11
+ * must supply the non-interactive fallback (`--force` hint or a default).
12
+ */
13
+ export async function confirm(question: string): Promise<boolean> {
14
+ const rl = createInterface({ input: process.stdin, output: process.stdout })
15
+ try {
16
+ const answer = await rl.question(question)
17
+ return /^(y|yes)$/i.test(answer.trim())
18
+ } finally {
19
+ rl.close()
20
+ }
21
+ }