Skip to content

Commit bd26843

Browse files
committed
feat: launch Skiller from terminal header
1 parent 87ee717 commit bd26843

19 files changed

Lines changed: 334 additions & 3 deletions

docs/integrations/skiller.md

Lines changed: 7 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -38,6 +38,12 @@ Run Skiller checks:
3838
bun run skiller:check
3939
```
4040

41+
## docker-git Web Launch
42+
43+
The docker-git web terminal header includes a `Skiller` button next to `Open browser`. The button calls `POST /skiller/open`, which launches the pinned submodule Electron app as a separate process and writes launcher output to `~/.docker-git/logs/skiller.log`.
44+
45+
When the API process has no `$DISPLAY`, the launcher uses `xvfb-run` if it is available so Skiller can still start in a headless controller environment.
46+
4147
## Updating the Pin
4248

4349
Update Skiller only as an explicit dependency change:
@@ -58,4 +64,4 @@ bun run skiller:check
5864

5965
## Integration Boundary
6066

61-
This integration makes Skiller part of the docker-git checkout and developer workflow. It does not embed Skiller's Electron UI into the docker-git web UI yet. Any future bridge should adapt Skiller's skills registry, scanner, and marketplace behavior through docker-git API services instead of importing the Electron app into the existing web bundle.
67+
This integration makes Skiller part of the docker-git checkout and developer workflow. It launches Skiller from docker-git, but it does not embed Skiller's Electron UI into the docker-git web UI. Any future deeper bridge should adapt Skiller's skills registry, scanner, and marketplace behavior through docker-git API services instead of importing the Electron app into the existing web bundle.
33.8 KB
Loading
34.7 KB
Loading
147 KB
Loading

packages/api/src/http.ts

Lines changed: 8 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -132,6 +132,7 @@ import {
132132
readProjectTerminalImage,
133133
startTerminalSession
134134
} from "./services/terminal-sessions.js"
135+
import { openSkiller } from "./services/skiller.js"
135136
import {
136137
commitStateFromRequest,
137138
initStateFromRequest,
@@ -570,6 +571,13 @@ export const makeRouter = () => {
570571
const projectsRoot = defaultProjectsRoot(cwd)
571572
return yield* _(jsonResponse({ ok: true, revision: controllerRevision, cwd, projectsRoot }, 200))
572573
}).pipe(Effect.catchAll(errorResponse))
574+
),
575+
HttpRouter.post(
576+
"/skiller/open",
577+
openSkiller().pipe(
578+
Effect.flatMap((launch) => jsonResponse({ ok: true, ...launch }, 202)),
579+
Effect.catchAll(errorResponse)
580+
)
573581
)
574582
)
575583

Lines changed: 122 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,122 @@
1+
import { spawn, type ChildProcess } from "node:child_process"
2+
import { closeSync, existsSync, mkdirSync, openSync } from "node:fs"
3+
import { homedir } from "node:os"
4+
import { dirname, join, resolve } from "node:path"
5+
import { Effect } from "effect"
6+
7+
import { ApiInternalError, ApiNotFoundError } from "../api/errors.js"
8+
9+
export type SkillerLaunch = {
10+
readonly alreadyRunning: boolean
11+
readonly logPath: string
12+
readonly pid: number | null
13+
readonly startedAtIso: string
14+
}
15+
16+
type SkillerProcess = {
17+
readonly logPath: string
18+
readonly process: ChildProcess
19+
readonly startedAtIso: string
20+
}
21+
22+
const submoduleRelativePath = join("third_party", "skiller-desktop-skills-manager")
23+
const launchLogPath = join(homedir(), ".docker-git", "logs", "skiller.log")
24+
25+
let currentProcess: SkillerProcess | null = null
26+
27+
const isRunning = (process: ChildProcess): boolean =>
28+
process.exitCode === null && process.signalCode === null && !process.killed
29+
30+
const findWorkspaceRoot = (startDir: string): string | null => {
31+
let current = resolve(startDir)
32+
for (;;) {
33+
if (existsSync(join(current, ".gitmodules")) && existsSync(join(current, submoduleRelativePath))) {
34+
return current
35+
}
36+
const parent = dirname(current)
37+
if (parent === current) {
38+
return null
39+
}
40+
current = parent
41+
}
42+
}
43+
44+
const resolveSkillerDir = (): Effect.Effect<string, ApiNotFoundError> =>
45+
Effect.gen(function*(_) {
46+
const root = findWorkspaceRoot(process.cwd())
47+
if (root === null) {
48+
return yield* _(Effect.fail(new ApiNotFoundError({
49+
message: "docker-git workspace root with Skiller submodule was not found."
50+
})))
51+
}
52+
const skillerDir = join(root, submoduleRelativePath)
53+
if (!existsSync(join(skillerDir, "package.json"))) {
54+
return yield* _(Effect.fail(new ApiNotFoundError({
55+
message: `Skiller submodule is not initialized at ${skillerDir}. Run bun run skiller:init first.`
56+
})))
57+
}
58+
return skillerDir
59+
})
60+
61+
const launchScript = [
62+
"set -euo pipefail",
63+
"if [ ! -d node_modules ]; then bun install --frozen-lockfile; fi",
64+
"bun run build",
65+
"ln -sf index.mjs out/preload/index.js",
66+
"if [ -z \"${DISPLAY:-}\" ] && command -v xvfb-run >/dev/null 2>&1; then",
67+
" exec xvfb-run -a ./node_modules/electron/dist/electron --no-sandbox out/main/index.js",
68+
"fi",
69+
"exec ./node_modules/electron/dist/electron --no-sandbox out/main/index.js"
70+
].join("\n")
71+
72+
const launchSkillerProcess = (skillerDir: string): SkillerLaunch => {
73+
mkdirSync(dirname(launchLogPath), { recursive: true })
74+
const logFd = openSync(launchLogPath, "a")
75+
try {
76+
const child = spawn("bash", ["-lc", launchScript], {
77+
cwd: skillerDir,
78+
detached: true,
79+
env: {
80+
...process.env,
81+
ELECTRON_ENABLE_LOGGING: "1"
82+
},
83+
stdio: ["ignore", logFd, logFd]
84+
})
85+
const startedAtIso = new Date().toISOString()
86+
currentProcess = { logPath: launchLogPath, process: child, startedAtIso }
87+
child.once("exit", () => {
88+
if (currentProcess?.process.pid === child.pid) {
89+
currentProcess = null
90+
}
91+
})
92+
child.unref()
93+
return {
94+
alreadyRunning: false,
95+
logPath: launchLogPath,
96+
pid: child.pid ?? null,
97+
startedAtIso
98+
}
99+
} finally {
100+
closeSync(logFd)
101+
}
102+
}
103+
104+
export const openSkiller = (): Effect.Effect<SkillerLaunch, ApiInternalError | ApiNotFoundError> =>
105+
Effect.gen(function*(_) {
106+
if (currentProcess !== null && isRunning(currentProcess.process)) {
107+
return {
108+
alreadyRunning: true,
109+
logPath: currentProcess.logPath,
110+
pid: currentProcess.process.pid ?? null,
111+
startedAtIso: currentProcess.startedAtIso
112+
}
113+
}
114+
const skillerDir = yield* _(resolveSkillerDir())
115+
return yield* _(Effect.try({
116+
catch: (cause) => new ApiInternalError({
117+
message: "Failed to launch Skiller.",
118+
cause
119+
}),
120+
try: () => launchSkillerProcess(skillerDir)
121+
}))
122+
})
Lines changed: 26 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,26 @@
1+
import { type BrowserActionContext, withBusy } from "./actions-shared.js"
2+
import { openSkiller } from "./api.js"
3+
4+
type SkillerLaunch = {
5+
readonly alreadyRunning: boolean
6+
readonly logPath: string
7+
readonly pid: number | null
8+
}
9+
10+
const skillerLaunchMessage = (launch: SkillerLaunch): string => {
11+
const pid = launch.pid === null ? "unknown pid" : `pid ${launch.pid}`
12+
return launch.alreadyRunning
13+
? `Skiller is already running (${pid}). Log: ${launch.logPath}`
14+
: `Skiller launch started (${pid}). Log: ${launch.logPath}`
15+
}
16+
17+
export const openSkillerApp = (context: BrowserActionContext): void => {
18+
withBusy({
19+
context,
20+
effect: openSkiller(),
21+
label: "Opening Skiller",
22+
onSuccess: (launch) => {
23+
context.setMessage(skillerLaunchMessage(launch))
24+
}
25+
})
26+
}

packages/app/src/web/actions.ts

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -43,6 +43,7 @@ export {
4343
loadSelectedProjectPrompts,
4444
saveSelectedProjectPrompt
4545
} from "./actions-prompts.js"
46+
export { openSkillerApp } from "./actions-skiller.js"
4647
export { deleteSelectedProjectSkill, loadSelectedProjectSkills, saveSelectedProjectSkill } from "./actions-skills.js"
4748
export {
4849
loadProjectTasksById,

packages/app/src/web/api-schema.ts

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -10,6 +10,7 @@ export {
1010
ProjectPromptsSnapshotSchema,
1111
ProjectPromptUpdateResponseSchema
1212
} from "./api-prompts-schema.js"
13+
export { SkillerLaunchResponseSchema } from "./api-skiller-schema.js"
1314
export {
1415
ProjectSkillFileSchema,
1516
ProjectSkillScopeInfoSchema,
Lines changed: 9 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,9 @@
1+
import * as Schema from "@effect/schema/Schema"
2+
3+
export const SkillerLaunchResponseSchema = Schema.Struct({
4+
alreadyRunning: Schema.Boolean,
5+
logPath: Schema.String,
6+
ok: Schema.Boolean,
7+
pid: Schema.NullOr(Schema.Number),
8+
startedAtIso: Schema.String
9+
})

0 commit comments

Comments
 (0)