Skip to content

Commit 11dac2e

Browse files
committed
fix: add browser folder picker for scoped Skiller
1 parent 89c04cb commit 11dac2e

10 files changed

Lines changed: 645 additions & 13 deletions

File tree

docs/integrations/skiller.md

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -57,6 +57,7 @@ The latest Playwright proof screenshots are checked in under `docs/screenshots/i
5757
- `pr238-proof-27-terminal-skiller-same-session.png` shows the attached terminal with the `Skiller` button.
5858
- `pr238-proof-28-skiller-session-scoped-ui.png` shows the real Skiller UI opened from that button.
5959
- `pr238-proof-29-skiller-codex-container-skill.png` shows a Codex skill discovered from the selected container's `/home/dev/.codex/skills` tree.
60+
- `pr238-proof-30-skiller-add-project-folder-browser-picker.png` shows the browser-visible project folder picker opened from `Add project folder...` with selected-container paths.
6061

6162
## Updating the Pin
6263

116 KB
Loading

package.json

Lines changed: 2 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -26,10 +26,10 @@
2626
"clone": "bash -lc 'bun run --cwd packages/app build:docker-git && bun ./packages/app/dist/src/docker-git/main.js clone \"$@\"' --",
2727
"open": "bash -lc 'bun run --cwd packages/app build:docker-git && bun ./packages/app/dist/src/docker-git/main.js open \"$@\"' --",
2828
"docker-git": "bash -lc 'bun run --cwd packages/app build:docker-git && bun ./packages/app/dist/src/docker-git/main.js \"$@\"' --",
29-
"skiller:init": "git submodule update --init --checkout third_party/skiller-desktop-skills-manager",
29+
"skiller:init": "git submodule update --init --checkout third_party/skiller-desktop-skills-manager && bun scripts/skiller-apply-docker-git-patches.mjs",
3030
"skiller:install": "bun install --cwd third_party/skiller-desktop-skills-manager --frozen-lockfile",
3131
"skiller:dev": "bun run --cwd third_party/skiller-desktop-skills-manager dev",
32-
"skiller:check": "bun run --cwd third_party/skiller-desktop-skills-manager typecheck && bun run --cwd third_party/skiller-desktop-skills-manager test",
32+
"skiller:check": "bun scripts/skiller-apply-docker-git-patches.mjs && bun run --cwd third_party/skiller-desktop-skills-manager typecheck && bun run --cwd third_party/skiller-desktop-skills-manager test",
3333
"e2e": "bash scripts/e2e/run-all.sh",
3434
"e2e:clone-cache": "bash scripts/e2e/clone-cache.sh",
3535
"e2e:browser-command": "bash scripts/e2e/browser-command.sh",

packages/api/Dockerfile

Lines changed: 2 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -62,10 +62,12 @@ RUN set -eu; \
6262
exit 1
6363
RUN bun run --cwd packages/lib build
6464
RUN bun run --cwd packages/api build
65+
RUN bun scripts/skiller-apply-docker-git-patches.mjs
6566
RUN test -f third_party/skiller-desktop-skills-manager/package.json \
6667
&& cd third_party/skiller-desktop-skills-manager \
6768
&& bun install --frozen-lockfile --silent \
6869
&& bun run build \
70+
&& touch out/.docker-git-browser-folder-picker.patch \
6971
&& mkdir -p out/preload \
7072
&& ln -sf index.mjs out/preload/index.js
7173

packages/api/src/services/skiller-core.ts

Lines changed: 101 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -19,6 +19,21 @@ export type SkillerContainerScope = {
1919
readonly sshUser: string
2020
}
2121

22+
export type SkillerBrowserPathRoot = {
23+
readonly containerPath: string
24+
readonly hostPath: string
25+
readonly id: "project" | "home" | "codexSkills"
26+
readonly label: string
27+
}
28+
29+
export type SkillerBrowserScope = {
30+
readonly containerName: string
31+
readonly currentProject: SkillerBrowserPathRoot
32+
readonly projectKey: string
33+
readonly roots: ReadonlyArray<SkillerBrowserPathRoot>
34+
readonly sessionId: string
35+
}
36+
2237
export const parseDockerMountLines = (output: string): ReadonlyArray<DockerContainerMount> =>
2338
output
2439
.split(/\r?\n/u)
@@ -49,6 +64,15 @@ export const containerCodexSkillsPath = (containerHomePath: string): string =>
4964
const isPathInside = (basePath: string, targetPath: string): boolean =>
5065
targetPath === basePath || targetPath.startsWith(`${basePath}/`)
5166

67+
const normalizeHostPath = (path: string): string =>
68+
join(path, ".")
69+
70+
const isHostPathInside = (basePath: string, targetPath: string): boolean => {
71+
const base = normalizeHostPath(basePath)
72+
const target = normalizeHostPath(targetPath)
73+
return target === base || target.startsWith(`${base}/`)
74+
}
75+
5276
const mountedPathDepth = (mount: DockerContainerMount): number =>
5377
mount.destination.split("/").filter((part) => part.length > 0).length
5478

@@ -91,3 +115,80 @@ export const sameSkillerScope = (
91115
left.hostHomePath === right.hostHomePath &&
92116
left.hostProjectPath === right.hostProjectPath
93117
}
118+
119+
const skillerBrowserRoots = (scope: SkillerContainerScope): ReadonlyArray<SkillerBrowserPathRoot> => [
120+
{
121+
containerPath: normalizeContainerPath(scope.containerProjectPath),
122+
hostPath: scope.hostProjectPath,
123+
id: "project",
124+
label: "Current project"
125+
},
126+
{
127+
containerPath: normalizeContainerPath(scope.containerHomePath),
128+
hostPath: scope.hostHomePath,
129+
id: "home",
130+
label: "Home"
131+
},
132+
{
133+
containerPath: normalizeContainerPath(scope.containerCodexSkillsPath),
134+
hostPath: scope.hostCodexSkillsPath,
135+
id: "codexSkills",
136+
label: "Codex skills"
137+
}
138+
]
139+
140+
const rootDepth = (root: Pick<SkillerBrowserPathRoot, "containerPath" | "hostPath">, key: "containerPath" | "hostPath"): number =>
141+
root[key].split("/").filter((part) => part.length > 0).length
142+
143+
export const skillerBrowserScopeForContainer = (
144+
scope: SkillerContainerScope,
145+
sessionId: string
146+
): SkillerBrowserScope => {
147+
const roots = skillerBrowserRoots(scope)
148+
return {
149+
containerName: scope.containerName,
150+
currentProject: roots[0] ?? {
151+
containerPath: normalizeContainerPath(scope.containerProjectPath),
152+
hostPath: scope.hostProjectPath,
153+
id: "project",
154+
label: "Current project"
155+
},
156+
projectKey: scope.projectKey,
157+
roots,
158+
sessionId
159+
}
160+
}
161+
162+
export const remapSkillerBrowserContainerPath = (
163+
browserScope: SkillerBrowserScope,
164+
containerPath: string
165+
): string | null => {
166+
const normalizedPath = normalizeContainerPath(containerPath)
167+
const root = [...browserScope.roots]
168+
.filter((candidate) => isPathInside(normalizeContainerPath(candidate.containerPath), normalizedPath))
169+
.sort((left, right) => rootDepth(right, "containerPath") - rootDepth(left, "containerPath"))[0]
170+
if (root === undefined) {
171+
return null
172+
}
173+
const relativePath = posix.relative(normalizeContainerPath(root.containerPath), normalizedPath)
174+
return relativePath.length === 0
175+
? root.hostPath
176+
: join(root.hostPath, ...relativePath.split(posix.sep))
177+
}
178+
179+
export const remapSkillerBrowserHostPath = (
180+
browserScope: SkillerBrowserScope,
181+
hostPath: string
182+
): string => {
183+
const normalizedHostPath = normalizeHostPath(hostPath)
184+
const root = [...browserScope.roots]
185+
.filter((candidate) => isHostPathInside(candidate.hostPath, normalizedHostPath))
186+
.sort((left, right) => rootDepth(right, "hostPath") - rootDepth(left, "hostPath"))[0]
187+
if (root === undefined) {
188+
return hostPath
189+
}
190+
const relativePath = normalizedHostPath.slice(normalizeHostPath(root.hostPath).length).replace(/^\/+/u, "")
191+
return relativePath.length === 0
192+
? normalizeContainerPath(root.containerPath)
193+
: posix.join(normalizeContainerPath(root.containerPath), ...relativePath.split("/"))
194+
}

packages/api/src/services/skiller.ts

Lines changed: 43 additions & 10 deletions
Original file line numberDiff line numberDiff line change
@@ -21,6 +21,7 @@ import {
2121
parseDockerMountLines,
2222
remapContainerPathToMountedHost,
2323
sameSkillerScope,
24+
skillerBrowserScopeForContainer,
2425
type SkillerContainerScope
2526
} from "./skiller-core.js"
2627
import { getProjectItemByKey } from "./projects.js"
@@ -53,7 +54,7 @@ type SkillerProcessUser = {
5354
}
5455

5556
type SkillerRoute =
56-
| { readonly _tag: "App"; readonly relativePath: string }
57+
| { readonly _tag: "App"; readonly relativePath: string; readonly sessionId: string | null }
5758
| { readonly _tag: "Trpc"; readonly sessionId: string | null; readonly upstreamPath: string }
5859

5960
const submoduleRelativePath = join("third_party", "skiller-desktop-skills-manager")
@@ -279,8 +280,15 @@ const waitForSkillerReady = (trpcPort: number): Effect.Effect<void, ApiInternalE
279280

280281
const launchScript = [
281282
"set -euo pipefail",
283+
"DOCKER_GIT_SKILLER_PATCH=../../patches/skiller/docker-git-browser-folder-picker.patch",
284+
"DOCKER_GIT_SKILLER_PATCH_MARKER=out/.docker-git-browser-folder-picker.patch",
285+
"if [ -f ../../scripts/skiller-apply-docker-git-patches.mjs ]; then bun ../../scripts/skiller-apply-docker-git-patches.mjs; fi",
282286
"if [ ! -d node_modules ]; then bun install --frozen-lockfile; fi",
283-
"if [ ! -f out/main/index.js ] || [ ! -f out/renderer/index.html ]; then bun run build; fi",
287+
"if [ ! -f out/main/index.js ] || [ ! -f out/renderer/index.html ] || { [ -f \"$DOCKER_GIT_SKILLER_PATCH\" ] && [ ! -f \"$DOCKER_GIT_SKILLER_PATCH_MARKER\" ]; } || { [ -f \"$DOCKER_GIT_SKILLER_PATCH\" ] && [ \"$DOCKER_GIT_SKILLER_PATCH\" -nt \"$DOCKER_GIT_SKILLER_PATCH_MARKER\" ]; }; then",
288+
" bun run build",
289+
" mkdir -p out",
290+
" touch \"$DOCKER_GIT_SKILLER_PATCH_MARKER\"",
291+
"fi",
284292
"if [ ! -e out/preload/index.js ]; then ln -sf index.mjs out/preload/index.js; fi",
285293
"if [ -z \"${DISPLAY:-}\" ] && command -v xvfb-run >/dev/null 2>&1; then",
286294
" exec xvfb-run -a ./node_modules/electron/dist/electron --no-sandbox out/main/index.js",
@@ -522,17 +530,17 @@ export const parseSkillerRoute = (pathname: string): SkillerRoute | null => {
522530
const routeKind = sessionMatch[2] ?? ""
523531
const tail = sessionMatch[3] ?? ""
524532
if (routeKind === "app" || routeKind === "") {
525-
return { _tag: "App", relativePath: tail.length === 0 ? "/" : tail }
533+
return { _tag: "App", relativePath: tail.length === 0 ? "/" : tail, sessionId }
526534
}
527535
if (routeKind === "trpc") {
528536
return { _tag: "Trpc", sessionId, upstreamPath: `/trpc${tail}` }
529537
}
530538
}
531539
if (normalized === "/skiller/app" || normalized === "/skiller/app/") {
532-
return { _tag: "App", relativePath: "/" }
540+
return { _tag: "App", relativePath: "/", sessionId: null }
533541
}
534542
if (normalized.startsWith("/skiller/app/")) {
535-
return { _tag: "App", relativePath: normalized.slice("/skiller/app".length) }
543+
return { _tag: "App", relativePath: normalized.slice("/skiller/app".length), sessionId: null }
536544
}
537545
if (normalized === "/skiller/trpc" || normalized.startsWith("/skiller/trpc/")) {
538546
return { _tag: "Trpc", sessionId: null, upstreamPath: normalized.slice("/skiller".length) || "/trpc" }
@@ -575,10 +583,35 @@ const browserTrpcBaseBootstrap = [
575583
"</script>"
576584
].join("")
577585

578-
const injectBrowserTrpcBase = (html: string): string =>
579-
html.includes("<head>")
580-
? html.replace("<head>", `<head>${browserTrpcBaseBootstrap}`)
581-
: `${browserTrpcBaseBootstrap}${html}`
586+
const scriptJson = (value: unknown): string =>
587+
JSON.stringify(value).replaceAll("<", "\\u003c")
588+
589+
const browserDockerGitScopeBootstrap = (
590+
route: Extract<SkillerRoute, { readonly _tag: "App" }>
591+
): string => {
592+
if (route.sessionId === null) {
593+
return ""
594+
}
595+
const scope = sessionScopes.get(route.sessionId)
596+
if (scope === undefined || scope === null) {
597+
return ""
598+
}
599+
return [
600+
"<script>",
601+
`window.__DOCKER_GIT_SKILLER_SCOPE__=${scriptJson(skillerBrowserScopeForContainer(scope, route.sessionId))};`,
602+
"</script>"
603+
].join("")
604+
}
605+
606+
const injectBrowserBootstrap = (
607+
html: string,
608+
route: Extract<SkillerRoute, { readonly _tag: "App" }>
609+
): string => {
610+
const bootstrap = `${browserTrpcBaseBootstrap}${browserDockerGitScopeBootstrap(route)}`
611+
return html.includes("<head>")
612+
? html.replace("<head>", `<head>${bootstrap}`)
613+
: `${bootstrap}${html}`
614+
}
582615

583616
const safeRendererPath = (skillerDir: string, relativePath: string): string => {
584617
const rendererDir = resolve(skillerDir, "out", "renderer")
@@ -611,7 +644,7 @@ export const serveSkillerApp = (
611644
try: () => readFileSync(filePath)
612645
}))
613646
if (filePath.endsWith(".html")) {
614-
return HttpServerResponse.text(injectBrowserTrpcBase(content.toString("utf8")), {
647+
return HttpServerResponse.text(injectBrowserBootstrap(content.toString("utf8"), route), {
615648
contentType: "text/html; charset=utf-8",
616649
headers: { "cache-control": "no-store" }
617650
})

packages/api/tests/skiller-core.test.ts

Lines changed: 32 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -4,7 +4,10 @@ import {
44
containerCodexSkillsPath,
55
parseDockerMountLines,
66
remapContainerPathToMountedHost,
7-
sameSkillerScope
7+
remapSkillerBrowserContainerPath,
8+
remapSkillerBrowserHostPath,
9+
sameSkillerScope,
10+
skillerBrowserScopeForContainer
811
} from "../src/services/skiller-core.js"
912

1013
describe("skiller container filesystem mapping", () => {
@@ -50,4 +53,32 @@ describe("skiller container filesystem mapping", () => {
5053
expect(sameSkillerScope(scope, null)).toBe(false)
5154
expect(sameSkillerScope(null, null)).toBe(true)
5255
})
56+
57+
it("builds a browser picker scope that remaps selected container paths to host volume paths", () => {
58+
const browserScope = skillerBrowserScopeForContainer({
59+
containerCodexSkillsPath: "/home/dev/.codex/skills",
60+
containerHomePath: "/home/dev",
61+
containerName: "dg-project",
62+
containerProjectPath: "/home/dev/app",
63+
hostCodexSkillsPath: "/var/lib/docker/volumes/project-home/_data/.codex/skills",
64+
hostHomePath: "/var/lib/docker/volumes/project-home/_data",
65+
hostProjectPath: "/var/lib/docker/volumes/project-home/_data/app",
66+
projectId: "/home/dev/.docker-git/project",
67+
projectKey: "abc123",
68+
sshUser: "dev"
69+
}, "terminal-session")
70+
71+
expect(browserScope.currentProject.containerPath).toBe("/home/dev/app")
72+
expect(remapSkillerBrowserContainerPath(browserScope, "/home/dev/app/packages")).toBe(
73+
"/var/lib/docker/volumes/project-home/_data/app/packages"
74+
)
75+
expect(remapSkillerBrowserContainerPath(browserScope, "/home/dev/.codex/skills/demo")).toBe(
76+
"/var/lib/docker/volumes/project-home/_data/.codex/skills/demo"
77+
)
78+
expect(remapSkillerBrowserContainerPath(browserScope, "/tmp/outside")).toBeNull()
79+
expect(remapSkillerBrowserHostPath(
80+
browserScope,
81+
"/var/lib/docker/volumes/project-home/_data/app/packages"
82+
)).toBe("/home/dev/app/packages")
83+
})
5384
})
Lines changed: 23 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,23 @@
1+
import { describe, expect, it } from "@effect/vitest"
2+
3+
import { parseSkillerRoute } from "../src/services/skiller.js"
4+
5+
describe("skiller routes", () => {
6+
it("keeps the terminal session id on session-scoped app routes", () => {
7+
expect(parseSkillerRoute("/api/ssh/session/terminal-proof/skiller/app/")).toEqual({
8+
_tag: "App",
9+
relativePath: "/",
10+
sessionId: "terminal-proof"
11+
})
12+
expect(parseSkillerRoute("/ssh/session/terminal-proof/skiller/trpc/list_projects")).toEqual({
13+
_tag: "Trpc",
14+
sessionId: "terminal-proof",
15+
upstreamPath: "/trpc/list_projects"
16+
})
17+
expect(parseSkillerRoute("/api/skiller/app/")).toEqual({
18+
_tag: "App",
19+
relativePath: "/",
20+
sessionId: null
21+
})
22+
})
23+
})

0 commit comments

Comments
 (0)