@kaynooo/cli-kit 0.11.0 → 0.13.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/package.json CHANGED
@@ -1,7 +1,7 @@
1
1
  {
2
2
  "name": "@kaynooo/cli-kit",
3
3
  "type": "module",
4
- "version": "0.11.0",
4
+ "version": "0.13.0",
5
5
  "description": "What every @kaynooo CLI shares: argument parsing with --help, a command dispatcher with typo hints and shell completion, and output formatting.",
6
6
  "author": "Kaynooo <contact@kaynooo.fr>",
7
7
  "license": "MIT",
package/src/index.ts CHANGED
@@ -9,6 +9,7 @@ export * from './limit'
9
9
  export * from './logs'
10
10
  export * from './process'
11
11
  export * from './secrets'
12
+ export * from './sessions'
12
13
  export * from './style'
13
14
  export * from './text'
14
15
  export * from './tool'
@@ -0,0 +1,107 @@
1
+ import { homedir } from 'node:os'
2
+ import { join } from 'node:path'
3
+ import process from 'node:process'
4
+ import { Glob } from 'bun'
5
+
6
+ // A running Claude Code session, as it registers itself in <config dir>/sessions/<pid>.json.
7
+ export interface Session { name: string, pid: number, status: string, cwd: string }
8
+
9
+ interface Entry { name?: string, pid?: number, procStart?: string, status?: string, cwd?: string }
10
+
11
+ const SESSION_FILES = new Glob('*.json')
12
+ const ACCOUNT_DIRS = new Glob('*/sessions')
13
+
14
+ async function sessionDirs(): Promise<string[]> {
15
+ const home = homedir()
16
+ const dirs = new Set([join(home, '.claude', 'sessions')])
17
+
18
+ if (process.env.CLAUDE_CONFIG_DIR !== undefined)
19
+ dirs.add(join(process.env.CLAUDE_CONFIG_DIR, 'sessions'))
20
+
21
+ try {
22
+ for await (const dir of ACCOUNT_DIRS.scan({ cwd: join(home, '.claude-accounts'), onlyFiles: false, absolute: true }))
23
+ dirs.add(dir)
24
+ }
25
+ catch {
26
+ // No ~/.claude-accounts: the default config dir is the only one.
27
+ }
28
+
29
+ return [...dirs]
30
+ }
31
+
32
+ // The fields of /proc/<pid>/stat after the command name, which may itself hold spaces and parentheses.
33
+ async function procStat(pid: number): Promise<string[] | undefined> {
34
+ try {
35
+ const stat = await Bun.file(`/proc/${pid}/stat`).text()
36
+
37
+ return stat.slice(stat.lastIndexOf(')') + 2).split(' ')
38
+ }
39
+ catch {
40
+ return undefined
41
+ }
42
+ }
43
+
44
+ // Alive and the same process the session registered, not a later one given its pid; starttime is field 22, the 20th after the name.
45
+ async function isAlive(pid: number, procStart: string | undefined): Promise<boolean> {
46
+ const stat = await procStat(pid)
47
+
48
+ if (stat === undefined)
49
+ return false
50
+
51
+ return procStart === undefined || stat[19] === procStart
52
+ }
53
+
54
+ export async function liveSessions(): Promise<Session[]> {
55
+ const sessions = new Map<number, Session>()
56
+
57
+ for (const dir of await sessionDirs()) {
58
+ let files: string[]
59
+
60
+ try {
61
+ files = await Array.fromAsync(SESSION_FILES.scan({ cwd: dir, absolute: true }))
62
+ }
63
+ catch {
64
+ continue
65
+ }
66
+
67
+ for (const file of files) {
68
+ let entry: Entry
69
+
70
+ try {
71
+ entry = await Bun.file(file).json() as Entry
72
+ }
73
+ catch {
74
+ continue
75
+ }
76
+
77
+ if (entry.name === undefined || entry.pid === undefined || sessions.has(entry.pid) || !await isAlive(entry.pid, entry.procStart))
78
+ continue
79
+
80
+ sessions.set(entry.pid, { name: entry.name, pid: entry.pid, status: entry.status ?? 'unknown', cwd: entry.cwd ?? '' })
81
+ }
82
+ }
83
+
84
+ return [...sessions.values()]
85
+ }
86
+
87
+ // The session this process runs under, found by walking up its parent pids.
88
+ export async function currentSession(sessions: Session[]): Promise<Session | undefined> {
89
+ const byPid = new Map(sessions.map(session => [session.pid, session]))
90
+ let pid = process.ppid
91
+
92
+ for (let depth = 0; depth < 32 && pid > 1; depth++) {
93
+ const session = byPid.get(pid)
94
+
95
+ if (session !== undefined)
96
+ return session
97
+
98
+ const parent = Number((await procStat(pid))?.[1])
99
+
100
+ if (!Number.isInteger(parent))
101
+ return undefined
102
+
103
+ pid = parent
104
+ }
105
+
106
+ return undefined
107
+ }
package/src/shell.ts CHANGED
@@ -592,8 +592,13 @@ export function parseCommand(text: string): Invocation {
592
592
  return { program: program.split('/').pop() ?? '', args, assignments, isWrapped }
593
593
  }
594
594
 
595
+ // A word as a POSIX shell or zsh reads it back unchanged: left bare when it holds nothing the shell would act on (zsh expands a leading `=`), else single-quoted.
596
+ export function shellQuote(word: string): string {
597
+ return SAFE_WORD.test(word) && !word.startsWith('=') ? word : `'${word.replaceAll('\'', '\'\\\'\'')}'`
598
+ }
599
+
595
600
  function quoted(word: string): string {
596
- return SAFE_WORD.test(word) || word === '{}' ? word : `'${word.replaceAll('\'', '\'\\\'\'')}'`
601
+ return word === '{}' ? word : shellQuote(word)
597
602
  }
598
603
 
599
604
  // The command strings a piece hands to a shell with `-c`, wherever the shell sits in it (`docker exec db sh -c '...'`, `su -c '...'`).