@shigureni/minion 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.
package/README.md ADDED
@@ -0,0 +1,65 @@
1
+ # minion
2
+
3
+ A tiny desktop pet that walks around your screen and reacts to your
4
+ [Claude Code](https://claude.com/claude-code) sessions — idle while you're idle,
5
+ running around while a session is busy.
6
+
7
+ It's two parts: a Swift menu-less overlay app (`swift/`) that draws the pet, and a
8
+ CLI (`cli/`) that builds/runs it and watches `~/.claude/sessions` to decide how the
9
+ pet should behave.
10
+
11
+ ## Requirements
12
+
13
+ - macOS
14
+ - [Bun](https://bun.sh)
15
+ - Xcode Command Line Tools (`xcode-select --install`) — needed once, to compile the
16
+ Swift app on first run
17
+
18
+ ## Usage
19
+
20
+ ```sh
21
+ bunx @shigureni/minion
22
+ ```
23
+
24
+ This builds the app on first run (subsequent runs reuse the build unless the Swift
25
+ source changed) and starts it in the background, alongside a small local gateway
26
+ that watches your Claude Code sessions.
27
+
28
+ Or install it globally so the `minion` command is always available:
29
+
30
+ ```sh
31
+ bun add -g @shigureni/minion
32
+ minion
33
+ ```
34
+
35
+ ### Commands
36
+
37
+ | Command | Description |
38
+ | --------------------------------- | ------------------------------------------------ |
39
+ | `minion` / `minion start` | Start (release build). No-op if already running. |
40
+ | `minion dev` | Restart with a debug build. |
41
+ | `minion kill` | Stop the app and its gateway. |
42
+ | `minion reboot` | Restart with a release build. |
43
+ | `minion status` | Show whether the app/gateway are running. |
44
+ | `minion config list` | List config values. |
45
+ | `minion config get <key>` | Get a config value. |
46
+ | `minion config set <key> <value>` | Set a config value. |
47
+
48
+ Add `-h` to any command for its help text (e.g. `minion start -h`).
49
+
50
+ State (pid files, config, and the Swift build output) lives in `~/.minion`,
51
+ independent of wherever the package itself is installed.
52
+
53
+ ## Development
54
+
55
+ ```sh
56
+ bun install
57
+ bun run check # format + lint + type-check (vite-plus)
58
+ bun run test
59
+ bun run fmt
60
+ bun run lint
61
+ ```
62
+
63
+ `cli/src/routes/<command>/route.ts` is one file per command (hono-routed, mirrors
64
+ the structure of jobantenna-cli) — adding a command means adding a route file and
65
+ wiring it into `cli/src/app.ts`.
package/cli/src/app.ts ADDED
@@ -0,0 +1,27 @@
1
+ import { factory } from "@/factory"
2
+ import { onError } from "@/on-error"
3
+ import configGet from "@/routes/config/get/[key]/route"
4
+ import configList from "@/routes/config/list/route"
5
+ import configSet from "@/routes/config/set/[key]/[value]/route"
6
+ import dev from "@/routes/dev/route"
7
+ import kill from "@/routes/kill/route"
8
+ import { notFound } from "@/routes/not-found"
9
+ import reboot from "@/routes/reboot/route"
10
+ import start from "@/routes/start/route"
11
+ import status from "@/routes/status/route"
12
+
13
+ const base = factory.createApp()
14
+ base.onError(onError)
15
+ base.notFound(notFound)
16
+
17
+ export const app = base
18
+ .post("/start", ...start)
19
+ .post("/dev", ...dev)
20
+ .post("/kill", ...kill)
21
+ .post("/reboot", ...reboot)
22
+ .post("/status", ...status)
23
+ .post("/config/list", ...configList)
24
+ .post("/config/get/:key", ...configGet)
25
+ .post("/config/set/:key/:value", ...configSet)
26
+
27
+ export type AppType = typeof app
@@ -0,0 +1,3 @@
1
+ import { createFactory } from "hono/factory"
2
+
3
+ export const factory = createFactory()
@@ -0,0 +1,204 @@
1
+ #!/usr/bin/env bun
2
+
3
+ import { readdirSync, readFileSync } from "node:fs"
4
+ import { homedir } from "node:os"
5
+ import { join } from "node:path"
6
+
7
+ const SESSIONS_DIR = join(homedir(), ".claude", "sessions")
8
+ const STALE_MS = 8 * 60 * 1000
9
+ const TICK_MS = 250
10
+ const PORT = Number(process.env.MINION_GATEWAY_PORT) || 4756
11
+
12
+ // Swift側 PetView.clips と同じ並び順・重み(合計100)。座標や速度はSwiftが持つ
13
+ // clip定義とスクリーン形状に依存するため、ここでは「どの行動を・どれだけ続けるか」だけを決める。
14
+ const ACTIONS: { durationMs: [number, number]; weight: number }[] = [
15
+ { weight: 14, durationMs: [3000, 6000] }, // 0 待機
16
+ { weight: 10, durationMs: [3000, 6000] }, // 1 歩く
17
+ { weight: 28, durationMs: [4000, 7000] }, // 2 走る
18
+ { weight: 3, durationMs: [1000, 2000] }, // 3 ジャンプ1
19
+ { weight: 3, durationMs: [1000, 2000] }, // 4 ジャンプ2
20
+ { weight: 3, durationMs: [1000, 2000] }, // 5 ジャンプ3
21
+ { weight: 3, durationMs: [1000, 2000] }, // 6 ジャンプ4
22
+ { weight: 8, durationMs: [4000, 7000] }, // 7 座る1
23
+ { weight: 8, durationMs: [4000, 7000] }, // 8 座る2
24
+ { weight: 8, durationMs: [4000, 7000] }, // 9 座る3
25
+ { weight: 8, durationMs: [4000, 7000] }, // 10 座る4
26
+ { weight: 2, durationMs: [2000, 3000] }, // 11 食べる1
27
+ { weight: 2, durationMs: [2000, 3000] }, // 12 食べる2
28
+ ]
29
+ const IDLE_CLIP = 0
30
+
31
+ function pickAction(): { clipIndex: number; durationMs: number } {
32
+ const roll = Math.random() * 100
33
+ let cumulative = 0
34
+ let chosen = IDLE_CLIP
35
+ for (let i = 0; i < ACTIONS.length; i++) {
36
+ cumulative += ACTIONS[i].weight
37
+ if (roll < cumulative) {
38
+ chosen = i
39
+ break
40
+ }
41
+ }
42
+ const [min, max] = ACTIONS[chosen].durationMs
43
+ return { clipIndex: chosen, durationMs: min + Math.random() * (max - min) }
44
+ }
45
+
46
+ function isPidAlive(pid: number): boolean {
47
+ try {
48
+ process.kill(pid, 0)
49
+ return true
50
+ } catch {
51
+ return false
52
+ }
53
+ }
54
+
55
+ // updatedAt はステータスが切り替わった時にしか更新されないため、
56
+ // ずっと busy のまま長時間動いているセッションは経過時間だけでは判定できない。
57
+ // プロセスが生きている間は経過時間を無視し、死んでから8分の猶予で消す。
58
+ interface SessionInfo {
59
+ running: boolean
60
+ name: string
61
+ }
62
+
63
+ function readActiveSessions(): Map<string, SessionInfo> {
64
+ let files: string[]
65
+ try {
66
+ files = readdirSync(SESSIONS_DIR).filter((f) => f.endsWith(".json"))
67
+ } catch {
68
+ return new Map()
69
+ }
70
+
71
+ const now = Date.now()
72
+ const result = new Map<string, SessionInfo>()
73
+
74
+ for (const file of files) {
75
+ try {
76
+ const raw = JSON.parse(readFileSync(join(SESSIONS_DIR, file), "utf8"))
77
+ if (typeof raw.sessionId !== "string" || typeof raw.pid !== "number") continue
78
+
79
+ const alive = isPidAlive(raw.pid)
80
+ if (!alive) {
81
+ const updatedAt = raw.updatedAt ?? raw.statusUpdatedAt ?? raw.startedAt
82
+ if (typeof updatedAt !== "number" || now - updatedAt > STALE_MS) continue
83
+ }
84
+
85
+ result.set(raw.sessionId, {
86
+ running: alive && raw.status === "busy",
87
+ name: typeof raw.name === "string" ? raw.name : "",
88
+ })
89
+ } catch {
90
+ // 書き込み途中などで壊れているファイルは無視
91
+ }
92
+ }
93
+
94
+ return result
95
+ }
96
+
97
+ interface PetBehavior {
98
+ running: boolean
99
+ name: string
100
+ clipIndex: number
101
+ actionEndsAt: number
102
+ }
103
+
104
+ const behaviors = new Map<string, PetBehavior>()
105
+ const clients = new Set()
106
+
107
+ function snapshot() {
108
+ return Array.from(behaviors.entries()).map(([id, b]) => ({
109
+ id,
110
+ state: b.running ? "running" : "sleeping",
111
+ clipIndex: b.clipIndex,
112
+ name: b.name,
113
+ }))
114
+ }
115
+
116
+ function broadcast(): void {
117
+ const payload = JSON.stringify({ sessions: snapshot() })
118
+ for (const ws of clients) {
119
+ ;(ws as { send: (data: string) => void }).send(payload)
120
+ }
121
+ }
122
+
123
+ function tick(): void {
124
+ const now = Date.now()
125
+ const activeSessions = readActiveSessions()
126
+ let dirty = false
127
+
128
+ for (const id of behaviors.keys()) {
129
+ if (!activeSessions.has(id)) {
130
+ behaviors.delete(id)
131
+ dirty = true
132
+ }
133
+ }
134
+
135
+ for (const [id, info] of activeSessions) {
136
+ const existing = behaviors.get(id)
137
+
138
+ if (!existing) {
139
+ const action = info.running ? pickAction() : { clipIndex: IDLE_CLIP, durationMs: 0 }
140
+ behaviors.set(id, {
141
+ running: info.running,
142
+ name: info.name,
143
+ clipIndex: action.clipIndex,
144
+ actionEndsAt: now + action.durationMs,
145
+ })
146
+ dirty = true
147
+ continue
148
+ }
149
+
150
+ if (existing.name !== info.name) {
151
+ existing.name = info.name
152
+ dirty = true
153
+ }
154
+
155
+ if (existing.running !== info.running) {
156
+ existing.running = info.running
157
+ if (info.running) {
158
+ const action = pickAction()
159
+ existing.clipIndex = action.clipIndex
160
+ existing.actionEndsAt = now + action.durationMs
161
+ }
162
+ dirty = true
163
+ } else if (info.running && now >= existing.actionEndsAt) {
164
+ const action = pickAction()
165
+ existing.clipIndex = action.clipIndex
166
+ existing.actionEndsAt = now + action.durationMs
167
+ dirty = true
168
+ }
169
+ }
170
+
171
+ if (dirty) broadcast()
172
+ }
173
+
174
+ setInterval(tick, TICK_MS)
175
+ tick()
176
+
177
+ Bun.serve({
178
+ port: PORT,
179
+ fetch(req, server) {
180
+ const url = new URL(req.url)
181
+ if (url.pathname === "/sessions") {
182
+ return Response.json({ sessions: snapshot() })
183
+ }
184
+ if (url.pathname === "/ws") {
185
+ if (server.upgrade(req)) return undefined
186
+ return new Response("upgrade failed", { status: 400 })
187
+ }
188
+ return new Response("not found", { status: 404 })
189
+ },
190
+ websocket: {
191
+ open(ws) {
192
+ clients.add(ws)
193
+ ws.send(JSON.stringify({ sessions: snapshot() }))
194
+ },
195
+ close(ws) {
196
+ clients.delete(ws)
197
+ },
198
+ message() {
199
+ // クライアントからのメッセージは使わない
200
+ },
201
+ },
202
+ })
203
+
204
+ console.log(`gateway listening on :${PORT}`)
@@ -0,0 +1,63 @@
1
+ #!/usr/bin/env bun
2
+ import { app } from "@/app"
3
+ import { postJson } from "@/lib/post-json"
4
+ import { toRequest } from "@/router"
5
+
6
+ import pkg from "../../package.json" with { type: "json" }
7
+
8
+ const HELP = `minion - open-minion CLI
9
+
10
+ Usage:
11
+ minion [command] [options]
12
+ minion <command> -h Show help for a command
13
+
14
+ Commands:
15
+ start 起動する (デフォルト)
16
+ dev 停止してデバッグビルドで起動し直す
17
+ kill 停止する
18
+ reboot 停止してリリースビルドで起動し直す
19
+ status 起動状況を表示する
20
+ config list 設定値を一覧表示する
21
+ config get <key> 設定値を取得する
22
+ config set <key> <value> 設定値を書き込む
23
+
24
+ Global flags:
25
+ -h, --help Show help
26
+ -v, --version Show version`
27
+
28
+ const argv = process.argv.slice(2)
29
+ const args = argv.length === 0 ? ["start"] : argv
30
+
31
+ const request = toRequest(args)
32
+
33
+ if (request.global.version) {
34
+ console.log(pkg.version)
35
+ process.exit()
36
+ }
37
+
38
+ if (request.global.help) {
39
+ if (request.path === "/") {
40
+ console.log(HELP)
41
+ process.exit()
42
+ }
43
+
44
+ const helpResponse = await app.request(request.path, postJson({ ...request.body, help: "true" }))
45
+ console.log(helpResponse.ok ? await helpResponse.text() : HELP)
46
+ process.exit()
47
+ }
48
+
49
+ try {
50
+ const response = await app.request(request.path, postJson(request.body))
51
+ const text = await response.text()
52
+
53
+ if (!response.ok) {
54
+ console.error(text || `Error ${response.status}`)
55
+ process.exit(1)
56
+ }
57
+
58
+ console.log(text)
59
+ } catch (err) {
60
+ const error = err instanceof Error ? err : new Error(String(err))
61
+ console.error(error.message)
62
+ process.exit(1)
63
+ }
@@ -0,0 +1,15 @@
1
+ import type { MiddlewareHandler } from "hono"
2
+
3
+ // --help を zValidator より先に処理する。必須フィールドを持つコマンドでも
4
+ // `minion <command> --help` が検証エラー(400)で弾かれず help を返せるようにする。
5
+ export function helpGuard(help: string): MiddlewareHandler {
6
+ return async (c, next) => {
7
+ const body = await c.req.json().catch(() => null)
8
+
9
+ if (body && typeof body === "object" && "help" in body && body.help) {
10
+ return c.text(help)
11
+ }
12
+
13
+ await next()
14
+ }
15
+ }
@@ -0,0 +1,8 @@
1
+ // POST + JSON body の RequestInit を作る。Hono の app.request に渡す。
2
+ export function postJson(body: Record<string, unknown> = {}) {
3
+ return {
4
+ method: "POST",
5
+ headers: { "content-type": "application/json" },
6
+ body: JSON.stringify(body),
7
+ }
8
+ }
@@ -0,0 +1,204 @@
1
+ import { createHash } from "node:crypto"
2
+ import {
3
+ closeSync,
4
+ existsSync,
5
+ mkdirSync,
6
+ openSync,
7
+ readFileSync,
8
+ rmSync,
9
+ writeFileSync,
10
+ } from "node:fs"
11
+ import { homedir } from "node:os"
12
+ import { join } from "node:path"
13
+
14
+ const PACKAGE_ROOT = join(import.meta.dir, "..", "..", "..")
15
+ const APP_ROOT = join(PACKAGE_ROOT, "swift")
16
+ // bunx はパッケージ本体をキャッシュディレクトリに展開するため、状態とビルド
17
+ // 成果物はパッケージの場所に依存しないよう ~/.minion に置く。
18
+ const DATA_DIR = join(homedir(), ".minion")
19
+ const BUILD_PATH = join(DATA_DIR, "build")
20
+ const BIN_PATH = join(BUILD_PATH, "release", "open-minion")
21
+ const DEBUG_BIN_PATH = join(BUILD_PATH, "debug", "open-minion")
22
+ const DATA_FILE = join(DATA_DIR, "data.json")
23
+ const PID_FILE = join(DATA_DIR, "pid")
24
+ const CONFIG_FILE = join(DATA_DIR, "config.json")
25
+ const GATEWAY_SCRIPT = join(import.meta.dir, "..", "gateway.ts")
26
+ const GATEWAY_PID_FILE = join(DATA_DIR, "gateway.pid")
27
+
28
+ interface BuildData {
29
+ sourceHash: string
30
+ }
31
+
32
+ function computeSourceHash(): string {
33
+ const files = [
34
+ join(APP_ROOT, "Package.swift"),
35
+ ...Array.from(new Bun.Glob("Sources/**/*.swift").scanSync({ cwd: APP_ROOT })).map((f) =>
36
+ join(APP_ROOT, f),
37
+ ),
38
+ ].sort()
39
+ const hash = createHash("sha256")
40
+ for (const file of files) {
41
+ hash.update(readFileSync(file))
42
+ }
43
+ return hash.digest("hex")
44
+ }
45
+
46
+ function readBuildData(): BuildData | null {
47
+ if (!existsSync(DATA_FILE)) return null
48
+ try {
49
+ return JSON.parse(readFileSync(DATA_FILE, "utf8"))
50
+ } catch {
51
+ return null
52
+ }
53
+ }
54
+
55
+ function writeBuildData(data: BuildData): void {
56
+ mkdirSync(DATA_DIR, { recursive: true })
57
+ writeFileSync(DATA_FILE, JSON.stringify(data, null, 2))
58
+ }
59
+
60
+ function readPid(pidFile: string = PID_FILE): number | null {
61
+ if (!existsSync(pidFile)) return null
62
+ const pid = parseInt(readFileSync(pidFile, "utf8").trim(), 10)
63
+ return Number.isNaN(pid) ? null : pid
64
+ }
65
+
66
+ function isRunning(pid: number): boolean {
67
+ try {
68
+ process.kill(pid, 0)
69
+ return true
70
+ } catch {
71
+ return false
72
+ }
73
+ }
74
+
75
+ function acquireStartLock(): boolean {
76
+ mkdirSync(DATA_DIR, { recursive: true })
77
+ try {
78
+ closeSync(openSync(PID_FILE, "wx"))
79
+ return true
80
+ } catch {
81
+ return false
82
+ }
83
+ }
84
+
85
+ async function build(debug: boolean): Promise<boolean> {
86
+ console.log("ビルド中...")
87
+ const args = debug
88
+ ? ["swift", "build", "--scratch-path", BUILD_PATH]
89
+ : ["swift", "build", "-c", "release", "--scratch-path", BUILD_PATH]
90
+ const proc = Bun.spawn(args, {
91
+ cwd: APP_ROOT,
92
+ stdout: "inherit",
93
+ stderr: "inherit",
94
+ })
95
+ const code = await proc.exited
96
+ if (code !== 0) {
97
+ console.error("ビルド失敗")
98
+ return false
99
+ }
100
+ return true
101
+ }
102
+
103
+ function startGateway(): void {
104
+ const pid = readPid(GATEWAY_PID_FILE)
105
+ if (pid !== null && isRunning(pid)) return
106
+
107
+ mkdirSync(DATA_DIR, { recursive: true })
108
+ const proc = Bun.spawn(["bun", GATEWAY_SCRIPT], {
109
+ stdout: "ignore",
110
+ stderr: "ignore",
111
+ stdin: "ignore",
112
+ })
113
+ proc.unref()
114
+ writeFileSync(GATEWAY_PID_FILE, String(proc.pid))
115
+ }
116
+
117
+ function killGateway(): void {
118
+ const pid = readPid(GATEWAY_PID_FILE)
119
+ if (pid !== null && isRunning(pid)) {
120
+ process.kill(pid, "SIGTERM")
121
+ }
122
+ rmSync(GATEWAY_PID_FILE, { force: true })
123
+ }
124
+
125
+ export async function startApp(debug: boolean): Promise<string> {
126
+ const existingPid = readPid()
127
+ if (existingPid !== null) {
128
+ if (isRunning(existingPid)) {
129
+ return `すでに起動中 (pid ${existingPid})`
130
+ }
131
+ rmSync(PID_FILE, { force: true })
132
+ }
133
+
134
+ if (!acquireStartLock()) {
135
+ return "他の起動処理と競合したため中止"
136
+ }
137
+
138
+ const binPath = debug ? DEBUG_BIN_PATH : BIN_PATH
139
+
140
+ if (debug) {
141
+ if (!(await build(true))) {
142
+ rmSync(PID_FILE, { force: true })
143
+ return "ビルド失敗"
144
+ }
145
+ } else {
146
+ const currentHash = computeSourceHash()
147
+ const buildData = readBuildData()
148
+ if (!existsSync(binPath) || buildData?.sourceHash !== currentHash) {
149
+ if (!(await build(false))) {
150
+ rmSync(PID_FILE, { force: true })
151
+ return "ビルド失敗"
152
+ }
153
+ writeBuildData({ sourceHash: currentHash })
154
+ }
155
+ }
156
+
157
+ const proc = Bun.spawn([binPath], {
158
+ cwd: APP_ROOT,
159
+ stdout: "ignore",
160
+ stderr: "ignore",
161
+ stdin: "ignore",
162
+ })
163
+ proc.unref()
164
+ writeFileSync(PID_FILE, String(proc.pid))
165
+ startGateway()
166
+ return `起動した (pid ${proc.pid})`
167
+ }
168
+
169
+ export function killApp(): string {
170
+ killGateway()
171
+ const pid = readPid()
172
+ if (!pid || !isRunning(pid)) {
173
+ if (existsSync(PID_FILE)) rmSync(PID_FILE, { force: true })
174
+ return "起動していない"
175
+ }
176
+ process.kill(pid, "SIGTERM")
177
+ rmSync(PID_FILE, { force: true })
178
+ return `停止した (pid ${pid})`
179
+ }
180
+
181
+ export function statusApp(): string {
182
+ const pid = readPid()
183
+ const appLine = pid && isRunning(pid) ? `起動中 (pid ${pid})` : "停止中"
184
+
185
+ const gatewayPid = readPid(GATEWAY_PID_FILE)
186
+ const gatewayLine =
187
+ gatewayPid && isRunning(gatewayPid) ? `gateway起動中 (pid ${gatewayPid})` : "gateway停止中"
188
+
189
+ return [appLine, gatewayLine].join("\n")
190
+ }
191
+
192
+ export function readConfig(): Record<string, string> {
193
+ if (!existsSync(CONFIG_FILE)) return {}
194
+ try {
195
+ return JSON.parse(readFileSync(CONFIG_FILE, "utf8"))
196
+ } catch {
197
+ return {}
198
+ }
199
+ }
200
+
201
+ export function writeConfig(config: Record<string, string>): void {
202
+ mkdirSync(DATA_DIR, { recursive: true })
203
+ writeFileSync(CONFIG_FILE, JSON.stringify(config, null, 2))
204
+ }
@@ -0,0 +1,10 @@
1
+ import type { ErrorHandler } from "hono"
2
+ import { HTTPException } from "hono/http-exception"
3
+
4
+ export const onError: ErrorHandler = (err, c) => {
5
+ if (err instanceof HTTPException) {
6
+ return c.text(err.message, err.status)
7
+ }
8
+
9
+ return c.text(err.message, 500)
10
+ }
@@ -0,0 +1,45 @@
1
+ const SHORT_FLAGS: Record<string, string> = {
2
+ h: "help",
3
+ v: "version",
4
+ }
5
+
6
+ const GLOBAL_FLAGS = new Set(["help", "version"])
7
+
8
+ export function toRequest(args: string[]) {
9
+ const segments: string[] = []
10
+ const body: Record<string, string> = {}
11
+
12
+ let i = 0
13
+ while (i < args.length) {
14
+ const arg = args[i]
15
+ if (arg.startsWith("--")) {
16
+ const key = arg.slice(2)
17
+ const next = args[i + 1]
18
+ if (next && !next.startsWith("-")) {
19
+ body[key] = next
20
+ i += 2
21
+ } else {
22
+ body[key] = "true"
23
+ i++
24
+ }
25
+ } else if (arg.startsWith("-") && arg.length === 2) {
26
+ const long = SHORT_FLAGS[arg[1]]
27
+ if (long) body[long] = "true"
28
+ i++
29
+ } else {
30
+ segments.push(arg)
31
+ i++
32
+ }
33
+ }
34
+
35
+ const global: Record<string, boolean> = {}
36
+ for (const flag of GLOBAL_FLAGS) {
37
+ if (flag in body) {
38
+ global[flag] = true
39
+ delete body[flag]
40
+ }
41
+ }
42
+
43
+ const path = `/${segments.join("/")}`
44
+ return { path, body, global }
45
+ }
@@ -0,0 +1,17 @@
1
+ import { zValidator } from "@hono/zod-validator"
2
+ import { z } from "zod"
3
+ import { factory } from "@/factory"
4
+ import { helpGuard } from "@/lib/help-guard"
5
+ import { readConfig } from "@/lib/process"
6
+
7
+ const schema = z.object({})
8
+
9
+ export const help = `Usage: minion config get <key>
10
+
11
+ 設定値を取得する。`
12
+
13
+ export default factory.createHandlers(helpGuard(help), zValidator("json", schema), async (c) => {
14
+ const key = c.req.param("key")
15
+ const config = readConfig()
16
+ return c.text(config[key ?? ""] ?? "")
17
+ })
@@ -0,0 +1,17 @@
1
+ import { zValidator } from "@hono/zod-validator"
2
+ import { z } from "zod"
3
+ import { factory } from "@/factory"
4
+ import { helpGuard } from "@/lib/help-guard"
5
+ import { readConfig } from "@/lib/process"
6
+
7
+ const schema = z.object({})
8
+
9
+ export const help = `Usage: minion config list
10
+
11
+ 設定値を一覧表示する。`
12
+
13
+ export default factory.createHandlers(helpGuard(help), zValidator("json", schema), async (c) => {
14
+ const config = readConfig()
15
+ const lines = Object.entries(config).map(([k, v]) => `${k} = ${v}`)
16
+ return c.text(lines.join("\n"))
17
+ })