@@ -14,58 +14,109 @@ import type { JupyterKernelSpec, JupyterSession, JupyterKernel, PositronSupervis
1414import { log } from './extension' ;
1515
1616/**
17- * Get the path to the ggsql-jupyter kernel executable
17+ * A discovered ggsql-jupyter kernel candidate
18+ */
19+ interface KernelCandidate {
20+ /** Absolute path to the ggsql-jupyter binary (or bare name for PATH fallback) */
21+ kernelPath : string ;
22+ /** Human-readable label for where this was found */
23+ source : string ;
24+ }
25+
26+ /**
27+ * Discover all available ggsql-jupyter kernel binaries
1828 *
19- * Checks in order:
29+ * Checks in priority order:
2030 * 1. Configured path in settings
21- * 2. Jupyter kernelspec location (user)
22- * 3. Jupyter kernelspec location (system)
31+ * 2. Jupyter kernelspec locations (user and system )
32+ * 3. Cargo-packager install locations
2333 * 4. Fall back to PATH
34+ *
35+ * Returns deduplicated candidates, keeping the highest-priority occurrence.
2436 */
25- function getKernelPath ( ) : string {
37+ function discoverKernelPaths ( ) : KernelCandidate [ ] {
38+ const candidates : KernelCandidate [ ] = [ ] ;
39+ const binaryName = process . platform === 'win32' ? 'ggsql-jupyter.exe' : 'ggsql-jupyter' ;
40+
41+ // 1. User-configured setting (highest priority)
2642 const config = vscode . workspace . getConfiguration ( 'ggsql' ) ;
2743 const configuredPath = config . get < string > ( 'kernelPath' , '' ) ;
28-
2944 if ( configuredPath && configuredPath . trim ( ) !== '' ) {
30- return configuredPath ;
45+ candidates . push ( { kernelPath : configuredPath , source : 'setting' } ) ;
3146 }
3247
33- // Check Jupyter kernelspec locations
48+ // 2. Jupyter kernelspec locations
3449 const homeDir = process . env . HOME || process . env . USERPROFILE || '' ;
35- const kernelName = 'ggsql' ;
36- const binaryName = process . platform === 'win32' ? 'ggsql-jupyter.exe' : 'ggsql-jupyter' ;
37-
38- // Common Jupyter kernel locations
39- const possiblePaths = [
40- // User kernelspec (macOS/Linux)
41- path . join ( homeDir , 'Library' , 'Jupyter' , 'kernels' , kernelName , binaryName ) ,
50+ const kernelspecPaths = [
51+ // User kernelspec (macOS)
52+ path . join ( homeDir , 'Library' , 'Jupyter' , 'kernels' , 'ggsql' , binaryName ) ,
4253 // User kernelspec (Linux)
43- path . join ( homeDir , '.local' , 'share' , 'jupyter' , 'kernels' , kernelName , binaryName ) ,
54+ path . join ( homeDir , '.local' , 'share' , 'jupyter' , 'kernels' , 'ggsql' , binaryName ) ,
4455 // System kernelspec (macOS)
45- path . join ( '/usr' , 'local' , 'share' , 'jupyter' , 'kernels' , kernelName , binaryName ) ,
56+ path . join ( '/usr' , 'local' , 'share' , 'jupyter' , 'kernels' , 'ggsql' , binaryName ) ,
4657 // System kernelspec (Linux)
47- path . join ( '/usr' , 'share' , 'jupyter' , 'kernels' , kernelName , binaryName ) ,
58+ path . join ( '/usr' , 'share' , 'jupyter' , 'kernels' , 'ggsql' , binaryName ) ,
4859 ] ;
60+ for ( const p of kernelspecPaths ) {
61+ if ( fs . existsSync ( p ) ) {
62+ candidates . push ( { kernelPath : p , source : 'Jupyter' } ) ;
63+ }
64+ }
65+
66+ // 3. Cargo-packager install locations
67+ const packagerPaths : string [ ] = [ ] ;
68+ if ( process . platform === 'darwin' ) {
69+ packagerPaths . push ( '/Applications/ggsql.app/Contents/MacOS/ggsql-jupyter' ) ;
70+ } else if ( process . platform === 'win32' ) {
71+ const programFiles = process . env . PROGRAMFILES || 'C:\\Program Files' ;
72+ packagerPaths . push ( path . join ( programFiles , 'ggsql' , 'ggsql-jupyter.exe' ) ) ;
73+ const localAppData = process . env . LOCALAPPDATA ;
74+ if ( localAppData ) {
75+ packagerPaths . push ( path . join ( localAppData , 'ggsql' , 'ggsql-jupyter.exe' ) ) ;
76+ }
77+ } else {
78+ // Linux deb package
79+ packagerPaths . push ( '/usr/bin/ggsql-jupyter' ) ;
80+ }
81+ for ( const p of packagerPaths ) {
82+ if ( fs . existsSync ( p ) ) {
83+ candidates . push ( { kernelPath : p , source : 'System' } ) ;
84+ }
85+ }
4986
50- for ( const kernelPath of possiblePaths ) {
51- if ( fs . existsSync ( kernelPath ) ) {
52- log ( `Found kernel at: ${ kernelPath } ` ) ;
53- return kernelPath ;
87+ // 4. PATH fallback (last resort)
88+ candidates . push ( { kernelPath : binaryName , source : 'Path' } ) ;
89+
90+ // Deduplicate by resolved absolute path
91+ const seen = new Set < string > ( ) ;
92+ const deduped : KernelCandidate [ ] = [ ] ;
93+ for ( const candidate of candidates ) {
94+ if ( ! path . isAbsolute ( candidate . kernelPath ) ) {
95+ // Non-absolute paths (PATH fallback) can't be deduplicated
96+ deduped . push ( candidate ) ;
97+ continue ;
98+ }
99+ let resolved : string ;
100+ try {
101+ resolved = fs . realpathSync ( candidate . kernelPath ) ;
102+ } catch {
103+ resolved = candidate . kernelPath ;
104+ }
105+ if ( ! seen . has ( resolved ) ) {
106+ seen . add ( resolved ) ;
107+ deduped . push ( candidate ) ;
108+ } else {
109+ log ( `Skipping duplicate kernel path: ${ candidate . kernelPath } (resolves to ${ resolved } )` ) ;
54110 }
55111 }
56112
57- // Fall back to PATH
58- log ( 'Kernel not found in standard locations, falling back to PATH' ) ;
59- return 'ggsql-jupyter' ;
113+ return deduped ;
60114}
61115
62116/**
63- * Check if the kernel executable exists and is accessible
117+ * Check if a kernel executable exists and is accessible
64118 */
65- async function isKernelAvailable ( ) : Promise < boolean > {
66- const kernelPath = getKernelPath ( ) ;
67-
68- // If it's an absolute path, check if the file exists
119+ async function isKernelAccessible ( kernelPath : string ) : Promise < boolean > {
69120 if ( path . isAbsolute ( kernelPath ) ) {
70121 try {
71122 await fs . promises . access ( kernelPath , fs . constants . X_OK ) ;
@@ -81,21 +132,22 @@ async function isKernelAvailable(): Promise<boolean> {
81132}
82133
83134/**
84- * Generate runtime metadata for ggsql
135+ * Generate runtime metadata for a ggsql kernel candidate
85136 */
86137function generateMetadata (
87- context : vscode . ExtensionContext
138+ context : vscode . ExtensionContext ,
139+ candidate : KernelCandidate ,
140+ index : number
88141) : positron . LanguageRuntimeMetadata {
89- const kernelPath = getKernelPath ( ) ;
90142 const version = context . extension . packageJSON . version as string ;
91143
92144 const iconPath = path . join ( context . extensionPath , 'resources' , 'ggsql-icon.svg' ) ;
93145 const base64Icon = fs . readFileSync ( iconPath ) . toString ( 'base64' ) ;
94146
95147 return {
96- runtimeId : 'ggsql-jupyter' ,
97- runtimePath : kernelPath ,
98- runtimeName : `ggsql ${ version } ` ,
148+ runtimeId : index === 0 ? 'ggsql-jupyter' : `ggsql-jupyter- ${ index } ` ,
149+ runtimePath : candidate . kernelPath ,
150+ runtimeName : `ggsql ( ${ candidate . source } ) ` ,
99151 runtimeShortName : 'ggsql' ,
100152 runtimeVersion : version ,
101153 runtimeSource : 'ggsql' ,
@@ -115,11 +167,10 @@ function generateMetadata(
115167 * Uses the startKernel callback to manually start the kernel process,
116168 * giving us more control over the launch process.
117169 *
170+ * @param kernelPath - Path to the ggsql-jupyter executable
118171 * @param workspacePath - Optional workspace path to use as the kernel's working directory
119172 */
120- function createKernelSpec ( workspacePath ?: string ) : JupyterKernelSpec {
121- const kernelPath = getKernelPath ( ) ;
122-
173+ function createKernelSpec ( kernelPath : string , workspacePath ?: string ) : JupyterKernelSpec {
123174 return {
124175 // argv is empty when using startKernel callback
125176 argv : [ ] ,
@@ -180,6 +231,95 @@ function createKernelSpec(workspacePath?: string): JupyterKernelSpec {
180231 } ;
181232}
182233
234+ /**
235+ * Get the user-level Jupyter kernelspec directory for ggsql.
236+ */
237+ function getUserJupyterKernelDir ( ) : string {
238+ const homeDir = process . env . HOME || process . env . USERPROFILE || '' ;
239+ switch ( process . platform ) {
240+ case 'darwin' :
241+ return path . join ( homeDir , 'Library' , 'Jupyter' , 'kernels' , 'ggsql' ) ;
242+ case 'win32' :
243+ return path . join (
244+ process . env . APPDATA || path . join ( homeDir , 'AppData' , 'Roaming' ) ,
245+ 'jupyter' , 'kernels' , 'ggsql'
246+ ) ;
247+ default :
248+ return path . join ( homeDir , '.local' , 'share' , 'jupyter' , 'kernels' , 'ggsql' ) ;
249+ }
250+ }
251+
252+ /**
253+ * Get the Jupyter kernelspec directory for ggsql.
254+ *
255+ * If a Python virtual environment or non-base conda environment is active
256+ * (detected via process.env), uses the environment-level path so that
257+ * Jupyter's `prefer_environment_over_user()` precedence applies naturally.
258+ * Otherwise falls back to the user-level kernelspec directory.
259+ */
260+ function getJupyterKernelDir ( ) : string {
261+ // Prefer virtual environment path when active. Jupyter gives these
262+ // precedence over user-level paths when running inside the same env.
263+ const virtualEnv = process . env . VIRTUAL_ENV ;
264+ if ( virtualEnv ) {
265+ return path . join ( virtualEnv , 'share' , 'jupyter' , 'kernels' , 'ggsql' ) ;
266+ }
267+
268+ const condaPrefix = process . env . CONDA_PREFIX ;
269+ const condaEnv = process . env . CONDA_DEFAULT_ENV ;
270+ if ( condaPrefix && condaEnv && condaEnv !== 'base' ) {
271+ return path . join ( condaPrefix , 'share' , 'jupyter' , 'kernels' , 'ggsql' ) ;
272+ }
273+
274+ return getUserJupyterKernelDir ( ) ;
275+ }
276+
277+ /**
278+ * Write a ggsql kernel.json to the given directory.
279+ *
280+ * Only writes if the content has changed to avoid unnecessary disk writes.
281+ */
282+ function writeKernelJson ( kernelDir : string , kernelPath : string ) : void {
283+ const kernelSpec = {
284+ argv : [ kernelPath , '-f' , '{connection_file}' ] ,
285+ display_name : 'ggsql' ,
286+ language : 'ggsql' ,
287+ interrupt_mode : 'message' ,
288+ env : { RUST_LOG : 'error' } ,
289+ metadata : { debugger : false }
290+ } ;
291+
292+ const kernelJsonPath = path . join ( kernelDir , 'kernel.json' ) ;
293+ const kernelSpecJson = JSON . stringify ( kernelSpec , null , 2 ) ;
294+
295+ try {
296+ const existing = fs . existsSync ( kernelJsonPath )
297+ ? fs . readFileSync ( kernelJsonPath , 'utf8' )
298+ : null ;
299+
300+ if ( existing !== kernelSpecJson ) {
301+ fs . mkdirSync ( kernelDir , { recursive : true } ) ;
302+ fs . writeFileSync ( kernelJsonPath , kernelSpecJson ) ;
303+ log ( `Wrote ggsql kernel spec to ${ kernelJsonPath } ` ) ;
304+ }
305+ } catch ( err ) {
306+ log ( `Failed to write ggsql kernel spec: ${ err } ` ) ;
307+ }
308+ }
309+
310+ /**
311+ * Install a Jupyter kernel spec for ggsql so that external tools like Quarto
312+ * can discover it via `jupyter kernelspec list`.
313+ *
314+ * Writes kernel.json to the appropriate Jupyter kernelspec directory: the
315+ * active virtualenv/conda env if detected, otherwise the user-level dir.
316+ *
317+ * @param kernelPath Absolute path to the ggsql-jupyter binary
318+ */
319+ function installJupyterKernelSpec ( kernelPath : string ) : void {
320+ writeKernelJson ( getJupyterKernelDir ( ) , kernelPath ) ;
321+ }
322+
183323/**
184324 * ggsql Language Runtime Manager
185325 *
@@ -196,22 +336,35 @@ export class GgsqlRuntimeManager implements positron.LanguageRuntimeManager {
196336 /**
197337 * Discover available ggsql runtimes.
198338 *
199- * Returns a single ggsql runtime if the kernel is available .
339+ * Returns all accessible ggsql kernel binaries found on the system .
200340 */
201341 discoverAllRuntimes ( ) : AsyncGenerator < positron . LanguageRuntimeMetadata > {
202342 const context = this . _context ;
203343
204344 const generator = async function * discoverGgsqlRuntimes ( ) {
205345 log ( 'Discovering ggsql runtimes...' ) ;
206346
207- // Check if the kernel is available
208- const available = await isKernelAvailable ( ) ;
209- log ( `Kernel available: ${ available } ` ) ;
210-
211- if ( available ) {
212- const metadata = generateMetadata ( context ) ;
213- log ( `Yielding runtime: ${ metadata . runtimeName } (${ metadata . runtimeId } )` ) ;
214- yield metadata ;
347+ const candidates = discoverKernelPaths ( ) ;
348+ log ( `Found ${ candidates . length } kernel candidate(s)` ) ;
349+
350+ let index = 0 ;
351+ for ( const candidate of candidates ) {
352+ const accessible = await isKernelAccessible ( candidate . kernelPath ) ;
353+ if ( accessible ) {
354+ // When a system install is found, write the kernel spec to
355+ // the user kernelspec dir immediately so that Quarto/Jupyter
356+ // can discover ggsql even if no session is ever started.
357+ if ( candidate . source === 'System' ) {
358+ writeKernelJson ( getUserJupyterKernelDir ( ) , candidate . kernelPath ) ;
359+ }
360+
361+ const metadata = generateMetadata ( context , candidate , index ) ;
362+ log ( `Yielding runtime: ${ metadata . runtimeName } (${ metadata . runtimeId } ) at ${ candidate . kernelPath } ` ) ;
363+ yield metadata ;
364+ index ++ ;
365+ } else {
366+ log ( `Skipping inaccessible kernel: ${ candidate . kernelPath } ` ) ;
367+ }
215368 }
216369
217370 log ( 'Runtime discovery complete' ) ;
@@ -252,8 +405,8 @@ export class GgsqlRuntimeManager implements positron.LanguageRuntimeManager {
252405 const workspaceFolders = vscode . workspace . workspaceFolders ;
253406 const workspacePath = workspaceFolders ?. [ 0 ] ?. uri . fsPath ;
254407
255- // Create the kernel spec
256- const kernelSpec = createKernelSpec ( workspacePath ) ;
408+ // Create the kernel spec using the runtime's kernel path
409+ const kernelSpec = createKernelSpec ( runtimeMetadata . runtimePath , workspacePath ) ;
257410
258411 // Create the dynamic state
259412 const dynState : positron . LanguageRuntimeDynState = {
@@ -262,6 +415,9 @@ export class GgsqlRuntimeManager implements positron.LanguageRuntimeManager {
262415 sessionName : 'ggsql'
263416 } ;
264417
418+ // Advertise this kernel to external tools (Quarto, Jupyter)
419+ installJupyterKernelSpec ( runtimeMetadata . runtimePath ) ;
420+
265421 // Create the session using the supervisor
266422 const session = await supervisorApi . createSession (
267423 runtimeMetadata ,
@@ -305,6 +461,9 @@ export class GgsqlRuntimeManager implements positron.LanguageRuntimeManager {
305461 sessionName : 'ggsql'
306462 } ;
307463
464+ // Re-advertise this kernel on restore
465+ installJupyterKernelSpec ( runtimeMetadata . runtimePath ) ;
466+
308467 const session = await supervisorApi . restoreSession (
309468 runtimeMetadata ,
310469 sessionMetadata ,
0 commit comments