claude-code-session-manager 0.27.0 → 0.29.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.
Files changed (32) hide show
  1. package/.claude-plugin/marketplace.json +21 -0
  2. package/dist/assets/{TiptapBody-BsNr6F0B.js → TiptapBody-DyiZVN0v.js} +1 -1
  3. package/dist/assets/index-DMIi9YZH.css +32 -0
  4. package/dist/assets/{index-DU1pkhIQ.js → index-qS7GdCsL.js} +391 -389
  5. package/dist/index.html +2 -2
  6. package/package.json +3 -1
  7. package/plugins/session-manager-dev/.claude-plugin/plugin.json +19 -0
  8. package/plugins/session-manager-dev/skills/develop/SKILL.md +112 -0
  9. package/plugins/session-manager-dev/skills/develop/standards.md +67 -0
  10. package/plugins/session-manager-dev/skills/explain-to-me/SKILL.md +192 -0
  11. package/plugins/session-manager-dev/skills/explain-to-me/assets/style-reference.html +163 -0
  12. package/plugins/session-manager-dev/skills/local-project-health/SKILL.md +58 -0
  13. package/plugins/session-manager-dev/skills/my-feedback/SKILL.md +132 -0
  14. package/plugins/session-manager-dev/skills/optimize-kpi/SKILL.md +289 -0
  15. package/plugins/session-manager-dev/skills/prd/SKILL.md +134 -0
  16. package/plugins/session-manager-dev/skills/process-feedback/SKILL.md +183 -0
  17. package/plugins/session-manager-dev/skills/project-status/SKILL.md +244 -0
  18. package/plugins/session-manager-dev/skills/requesting-code-review/SKILL.md +105 -0
  19. package/plugins/session-manager-dev/skills/requesting-code-review/code-reviewer.md +146 -0
  20. package/plugins/session-manager-dev/skills/security-review/SKILL.md +105 -0
  21. package/src/main/__tests__/scheduler-autofix-select.test.cjs +95 -0
  22. package/src/main/__tests__/scheduler-autopromote.test.cjs +33 -0
  23. package/src/main/index.cjs +7 -0
  24. package/src/main/ipcSchemas.cjs +10 -0
  25. package/src/main/lib/credentials.cjs +96 -11
  26. package/src/main/pluginInstall.cjs +114 -32
  27. package/src/main/scheduler.cjs +109 -5
  28. package/src/main/seedDevPlugin.cjs +95 -0
  29. package/src/main/templates/PRD_AUTHORING.md +316 -0
  30. package/src/main/usage.cjs +34 -1
  31. package/src/preload/api.d.ts +8 -2
  32. package/dist/assets/index-Dwb94Uxm.css +0 -32
@@ -0,0 +1,105 @@
1
+ ---
2
+ name: security-review
3
+ description: >-
4
+ Check for common security vulnerabilities before shipping code — focus on what
5
+ actually gets exploited (OWASP Top 10: injection, auth, secrets, access control),
6
+ not compliance theater. Use before committing code that handles user input, auth,
7
+ or data storage; when adding API endpoints or external integrations; when
8
+ reviewing dependencies; or when asked to do a security review. Keywords: security,
9
+ vulnerability, OWASP, injection, auth, secrets, review.
10
+ ---
11
+
12
+ # Security Review
13
+
14
+ ## Overview
15
+
16
+ Check for common security vulnerabilities before shipping code. Focus on what actually gets exploited, not compliance theater.
17
+
18
+ ## When to Use
19
+
20
+ - Before committing code that handles user input, auth, or data storage
21
+ - When adding new API endpoints or external integrations
22
+ - When reviewing dependencies or updating packages
23
+ - When asked to do a security review
24
+
25
+ ## OWASP Top 10 Checklist
26
+
27
+ ### Injection (SQL, NoSQL, OS Command, LDAP)
28
+ - All user input parameterized or escaped before use in queries
29
+ - No string concatenation in SQL/NoSQL queries
30
+ - No `eval()`, `exec()`, `shell=True`, or `child_process.exec()` with user input
31
+ - ORM/query builder used instead of raw queries where possible
32
+
33
+ ### Broken Authentication
34
+ - Passwords hashed with bcrypt/argon2 (never MD5/SHA1)
35
+ - Session tokens are random, long, and expire
36
+ - MFA available for sensitive operations
37
+ - No credentials in URLs, logs, or error messages
38
+
39
+ ### Sensitive Data Exposure
40
+ - No secrets in code, config files, or git history (.env, API keys, tokens)
41
+ - Data encrypted at rest (AES-256) and in transit (TLS)
42
+ - Sensitive fields excluded from logs and API responses
43
+ - PII handled according to data classification
44
+
45
+ ### XSS (Cross-Site Scripting)
46
+ - All output HTML-encoded by default (React JSX handles this)
47
+ - No `dangerouslySetInnerHTML` or `v-html` without sanitization
48
+ - Content-Security-Policy headers set
49
+ - User input never inserted into `<script>` tags or event handlers
50
+
51
+ ### Broken Access Control
52
+ - Every API endpoint checks authentication AND authorization
53
+ - No direct object references without ownership verification
54
+ - CORS configured to specific allowed origins (not `*`)
55
+ - Rate limiting on auth and sensitive endpoints
56
+
57
+ ### Security Misconfiguration
58
+ - No default credentials or debug modes in production
59
+ - Error messages don't leak stack traces or internal details
60
+ - HTTP security headers set (HSTS, X-Frame-Options, X-Content-Type-Options)
61
+ - Unnecessary features and endpoints disabled
62
+
63
+ ### CSRF (Cross-Site Request Forgery)
64
+ - State-changing requests use CSRF tokens or SameSite cookies
65
+ - POST/PUT/DELETE endpoints reject requests without valid tokens
66
+
67
+ ## Dependency Check
68
+
69
+ ```bash
70
+ # Node.js
71
+ npm audit
72
+ # Check for known vulnerabilities
73
+ npx audit-ci --moderate
74
+
75
+ # Python
76
+ pip-audit
77
+ safety check
78
+ ```
79
+
80
+ Flag: outdated packages with known CVEs, unmaintained dependencies, packages with suspiciously few downloads or recent ownership transfers.
81
+
82
+ ## Secrets Scan
83
+
84
+ Look for accidentally committed secrets:
85
+ - API keys, tokens, passwords in source files
86
+ - `.env` files not in `.gitignore`
87
+ - Hardcoded connection strings
88
+ - Private keys or certificates
89
+
90
+ ## Input Validation Rules
91
+
92
+ - Validate at system boundaries (API endpoints, form handlers, file uploads)
93
+ - Reject unexpected input types and sizes
94
+ - Whitelist over blacklist
95
+ - Validate on the server, never trust client-only validation
96
+
97
+ ## When Reporting Issues
98
+
99
+ Classify findings:
100
+ - **Critical**: Exploitable now, data at risk (injection, auth bypass, exposed secrets)
101
+ - **High**: Exploitable with effort (XSS, CSRF, broken access control)
102
+ - **Medium**: Defense-in-depth gap (missing headers, weak config)
103
+ - **Low**: Best practice deviation (could become a problem later)
104
+
105
+ Always provide the fix, not just the finding.
@@ -0,0 +1,95 @@
1
+ /**
2
+ * scheduler-autofix-select.test.cjs — unit tests for selectAutoFixTargets.
3
+ *
4
+ * Run: timeout 120 node --test src/main/__tests__/scheduler-autofix-select.test.cjs
5
+ */
6
+
7
+ 'use strict';
8
+
9
+ const { test } = require('node:test');
10
+ const assert = require('node:assert/strict');
11
+ const { selectAutoFixTargets } = require('../scheduler.cjs');
12
+
13
+ const noSiblingOnDisk = () => false;
14
+
15
+ function makeJob(overrides = {}) {
16
+ return {
17
+ slug: '05-my-feature',
18
+ status: 'needs_review',
19
+ runId: '2026-06-16T10-00-00-000Z',
20
+ parallelGroup: 5,
21
+ ...overrides,
22
+ };
23
+ }
24
+
25
+ test('selects a fresh needs_review job', () => {
26
+ const jobs = [makeJob()];
27
+ const result = selectAutoFixTargets(jobs, { fixSlugExists: noSiblingOnDisk });
28
+ assert.strictEqual(result.length, 1);
29
+ assert.strictEqual(result[0].slug, '05-my-feature');
30
+ });
31
+
32
+ test('excludes job with autoFixAttempted: true', () => {
33
+ const jobs = [makeJob({ autoFixAttempted: true })];
34
+ const result = selectAutoFixTargets(jobs, { fixSlugExists: noSiblingOnDisk });
35
+ assert.strictEqual(result.length, 0);
36
+ });
37
+
38
+ test('excludes a fix-plan slug (05-fix-foo)', () => {
39
+ const jobs = [makeJob({ slug: '05-fix-foo' })];
40
+ const result = selectAutoFixTargets(jobs, { fixSlugExists: noSiblingOnDisk });
41
+ assert.strictEqual(result.length, 0);
42
+ });
43
+
44
+ test('excludes a failed job', () => {
45
+ const jobs = [makeJob({ status: 'failed' })];
46
+ const result = selectAutoFixTargets(jobs, { fixSlugExists: noSiblingOnDisk });
47
+ assert.strictEqual(result.length, 0);
48
+ });
49
+
50
+ test('excludes a completed job', () => {
51
+ const jobs = [makeJob({ status: 'completed' })];
52
+ const result = selectAutoFixTargets(jobs, { fixSlugExists: noSiblingOnDisk });
53
+ assert.strictEqual(result.length, 0);
54
+ });
55
+
56
+ test('excludes a job missing runId', () => {
57
+ const jobs = [makeJob({ runId: null })];
58
+ const result = selectAutoFixTargets(jobs, { fixSlugExists: noSiblingOnDisk });
59
+ assert.strictEqual(result.length, 0);
60
+ });
61
+
62
+ test('excludes when fix sibling exists on disk', () => {
63
+ const jobs = [makeJob()];
64
+ // fixSlug = '05-fix-my-feature'
65
+ const result = selectAutoFixTargets(jobs, { fixSlugExists: (s) => s === '05-fix-my-feature' });
66
+ assert.strictEqual(result.length, 0);
67
+ });
68
+
69
+ test('excludes when fix sibling already in the queue', () => {
70
+ const sibling = { slug: '05-fix-my-feature', status: 'pending', runId: null };
71
+ const jobs = [makeJob(), sibling];
72
+ const result = selectAutoFixTargets(jobs, { fixSlugExists: noSiblingOnDisk });
73
+ assert.strictEqual(result.length, 0);
74
+ });
75
+
76
+ test('fixSlug uses padded parallelGroup and strips leading digits from slug', () => {
77
+ const jobs = [makeJob({ slug: '07-some-task', parallelGroup: 7 })];
78
+ // Expected fixSlug: '07-fix-some-task'
79
+ const seen = [];
80
+ selectAutoFixTargets(jobs, {
81
+ fixSlugExists: (s) => { seen.push(s); return false; },
82
+ });
83
+ assert.strictEqual(seen[0], '07-fix-some-task');
84
+ });
85
+
86
+ test('defaults parallelGroup to 99 when absent', () => {
87
+ const jobs = [makeJob({ slug: '05-my-feature', parallelGroup: undefined })];
88
+ const seen = [];
89
+ selectAutoFixTargets(jobs, {
90
+ fixSlugExists: (s) => { seen.push(s); return false; },
91
+ });
92
+ assert.strictEqual(seen[0], '99-fix-my-feature');
93
+ });
94
+
95
+ console.log('scheduler-autofix-select tests: PASS');
@@ -0,0 +1,33 @@
1
+ /**
2
+ * scheduler-autopromote.test.cjs — unit tests for isPromotableOriginal.
3
+ *
4
+ * Run: timeout 120 node --test src/main/__tests__/scheduler-autopromote.test.cjs
5
+ */
6
+
7
+ 'use strict';
8
+
9
+ const { test } = require('node:test');
10
+ const assert = require('node:assert/strict');
11
+ const { isPromotableOriginal } = require('../scheduler.cjs');
12
+
13
+ test('isPromotableOriginal: failed → true', () => {
14
+ assert.strictEqual(isPromotableOriginal('failed'), true);
15
+ });
16
+
17
+ test('isPromotableOriginal: needs_review → true', () => {
18
+ assert.strictEqual(isPromotableOriginal('needs_review'), true);
19
+ });
20
+
21
+ test('isPromotableOriginal: completed → false', () => {
22
+ assert.strictEqual(isPromotableOriginal('completed'), false);
23
+ });
24
+
25
+ test('isPromotableOriginal: running → false', () => {
26
+ assert.strictEqual(isPromotableOriginal('running'), false);
27
+ });
28
+
29
+ test('isPromotableOriginal: pending → false', () => {
30
+ assert.strictEqual(isPromotableOriginal('pending'), false);
31
+ });
32
+
33
+ console.log('scheduler-autopromote tests: PASS');
@@ -26,6 +26,7 @@ const watchers = require('./watchers.cjs');
26
26
  const teams = require('./teams.cjs');
27
27
  const queueOps = require('./queueOps.cjs');
28
28
  const pluginInstall = require('./pluginInstall.cjs');
29
+ const { seedDevPlugin } = require('./seedDevPlugin.cjs');
29
30
  const otel = require('./otel.cjs');
30
31
  const otelSettings = require('./otelSettings.cjs');
31
32
  const { registerHistoryAggregatorHandlers } = require('./historyAggregator.cjs');
@@ -948,6 +949,12 @@ app.whenReady().then(async () => {
948
949
  scheduler.init().catch((e) => {
949
950
  logs.writeLine({ scope: 'scheduler', level: 'error', message: 'init failed', meta: { error: e?.message } });
950
951
  });
952
+ // First-boot default: install the bundled session-manager-dev plugin (its 10
953
+ // dev skills) from the app's own marketplace. One-shot + idempotent; never
954
+ // throws. SM_SEED_DEV_PLUGIN_DISABLE=1 to opt out.
955
+ seedDevPlugin({ logger: console }).catch((e) => {
956
+ logs.writeLine({ scope: 'seed-dev-plugin', level: 'error', message: 'seed failed', meta: { error: e?.message } });
957
+ });
951
958
  webRemote.init().catch((e) => {
952
959
  logs.writeLine({ scope: 'webRemote', level: 'error', message: 'init failed', meta: { error: e?.message } });
953
960
  });
@@ -358,8 +358,18 @@ const repoAnalyze = z.object({
358
358
  // Plugin install: mirrors pluginInstall.cjs SLUG_RE + length cap. Defense in
359
359
  // depth — install() re-checks; the schema rejects earlier.
360
360
  const PLUGIN_SLUG_RE = /^[a-z0-9\-/]+$/;
361
+ const PLUGIN_MKT_ADD_RE = /^[A-Za-z0-9][A-Za-z0-9._\-]*\/[A-Za-z0-9._\-]+$/;
361
362
  const pluginsInstall = z.object({
362
363
  slug: z.string().regex(PLUGIN_SLUG_RE).min(1).max(128),
364
+ // Optional non-official marketplace: registered via `plugin marketplace add`
365
+ // before install. pluginInstall.cjs re-validates (defense in depth).
366
+ marketplace: z.object({
367
+ // `owner/repo`, or the literal `bundled` sentinel (app's own packaged
368
+ // marketplace — pluginInstall.cjs resolves it to an absolute path).
369
+ add: z.string().min(1).max(200)
370
+ .refine((v) => v === 'bundled' || PLUGIN_MKT_ADD_RE.test(v), 'invalid marketplace source'),
371
+ name: z.string().regex(PLUGIN_SLUG_RE).min(1).max(128),
372
+ }).optional(),
363
373
  }).passthrough();
364
374
  const pluginsAbort = z.object({
365
375
  slug: z.string().regex(PLUGIN_SLUG_RE).min(1).max(128),
@@ -4,10 +4,55 @@ const fsp = require('node:fs/promises');
4
4
  const fs = require('node:fs');
5
5
  const path = require('node:path');
6
6
  const os = require('node:os');
7
- const { spawn } = require('node:child_process');
7
+ const { spawn, execFileSync } = require('node:child_process');
8
8
  const { cleanChildEnv } = require('./cleanEnv.cjs');
9
9
 
10
10
  const CREDS_PATH = path.join(os.homedir(), '.claude', '.credentials.json');
11
+
12
+ // macOS stores Claude Code credentials in the login Keychain, not on disk —
13
+ // there is no ~/.claude/.credentials.json there. The Keychain item is a
14
+ // generic password under this service whose secret is the same JSON blob
15
+ // ({ claudeAiOauth: { accessToken, … } }) the Linux/WSL file holds.
16
+ const KEYCHAIN_SERVICE = 'Claude Code-credentials';
17
+
18
+ /** Read the raw credential JSON string from the macOS Keychain, or null. */
19
+ function readKeychainRaw() {
20
+ if (process.platform !== 'darwin') return null;
21
+ try {
22
+ const out = execFileSync(
23
+ 'security',
24
+ ['find-generic-password', '-s', KEYCHAIN_SERVICE, '-w'],
25
+ { encoding: 'utf8', timeout: 10_000, stdio: ['ignore', 'pipe', 'ignore'] },
26
+ );
27
+ const trimmed = out.trim();
28
+ return trimmed.length ? trimmed : null;
29
+ } catch {
30
+ return null; // not found / locked — caller treats as "no creds here"
31
+ }
32
+ }
33
+
34
+ /** Discover the account the Keychain item is stored under (for write-back). */
35
+ function keychainAccount() {
36
+ try {
37
+ const out = execFileSync(
38
+ 'security',
39
+ ['find-generic-password', '-s', KEYCHAIN_SERVICE],
40
+ { encoding: 'utf8', timeout: 10_000, stdio: ['ignore', 'pipe', 'ignore'] },
41
+ );
42
+ const m = out.match(/"acct"<blob>="([^"]*)"/);
43
+ if (m && m[1]) return m[1];
44
+ } catch { /* fall through to login user */ }
45
+ return os.userInfo().username;
46
+ }
47
+
48
+ /** Write the credential JSON back into the Keychain (-U upserts in place). */
49
+ function writeKeychainRaw(value) {
50
+ execFileSync(
51
+ 'security',
52
+ ['add-generic-password', '-U', '-s', KEYCHAIN_SERVICE, '-a', keychainAccount(), '-w', value],
53
+ { timeout: 10_000, stdio: 'ignore' },
54
+ );
55
+ }
11
56
  const REFRESH_LOG_PATH = path.join(os.homedir(), '.claude', 'session-manager', 'credential-refresh.log');
12
57
  const REFRESH_LOG_MAX_BYTES = 100 * 1024;
13
58
 
@@ -16,17 +61,42 @@ const REFRESH_LOG_MAX_BYTES = 100 * 1024;
16
61
  // allowing the caller to fall back gracefully.
17
62
  const OAUTH_TOKEN_URL = 'https://claude.ai/api/auth/oauth/token';
18
63
 
64
+ /** Parse a raw credential JSON blob (from file or Keychain) into a result. */
65
+ function parseCredsRaw(raw, source) {
66
+ let data;
67
+ try {
68
+ data = JSON.parse(raw);
69
+ } catch (e) {
70
+ return { kind: 'config', message: `cannot parse ${source} credentials: ${e.message}` };
71
+ }
72
+ const oa = data?.claudeAiOauth;
73
+ if (!oa?.accessToken) return { kind: 'config', message: `missing accessToken in ${source} credentials` };
74
+ return { kind: 'ok', creds: oa, raw: data, source };
75
+ }
76
+
19
77
  async function readCredentials() {
78
+ // 1) File — Linux / WSL (and macOS in the rare case a file exists).
20
79
  try {
21
80
  const raw = await fsp.readFile(CREDS_PATH, 'utf8');
22
- const data = JSON.parse(raw);
23
- const oa = data?.claudeAiOauth;
24
- if (!oa?.accessToken) return { kind: 'config', message: 'missing accessToken in credentials file' };
25
- return { kind: 'ok', creds: oa, raw: data };
81
+ return parseCredsRaw(raw, 'file');
26
82
  } catch (e) {
27
- if (e?.code === 'ENOENT') return { kind: 'config', message: 'credentials file not found' };
28
- return { kind: 'config', message: `cannot read credentials: ${e.message}` };
83
+ if (e?.code !== 'ENOENT') {
84
+ return { kind: 'config', message: `cannot read credentials: ${e.message}` };
85
+ }
86
+ // ENOENT — fall through to the macOS Keychain before giving up.
29
87
  }
88
+
89
+ // 2) macOS Keychain — the canonical store on darwin (no file there).
90
+ if (process.platform === 'darwin') {
91
+ const kc = readKeychainRaw();
92
+ if (kc) return parseCredsRaw(kc, 'keychain');
93
+ return {
94
+ kind: 'config',
95
+ message: `credentials not found (no ${CREDS_PATH}; no Keychain item "${KEYCHAIN_SERVICE}" — run \`claude\` to log in)`,
96
+ };
97
+ }
98
+
99
+ return { kind: 'config', message: 'credentials file not found' };
30
100
  }
31
101
 
32
102
  function expiresAtMs(creds) {
@@ -49,8 +119,14 @@ function isExpiringSoon(creds, withinMs = 5 * 60_000) {
49
119
  return ms !== null && ms - Date.now() < withinMs;
50
120
  }
51
121
 
52
- async function writeCredentials(rawData, freshOauth) {
122
+ async function writeCredentials(rawData, freshOauth, source = 'file') {
53
123
  const next = { ...rawData, claudeAiOauth: { ...rawData.claudeAiOauth, ...freshOauth } };
124
+ if (source === 'keychain') {
125
+ // macOS: upsert back into the Keychain. Sync + may throw — caller catches
126
+ // and falls back to the `claude --version` CLI refresh path.
127
+ writeKeychainRaw(JSON.stringify(next));
128
+ return;
129
+ }
54
130
  const tmp = `${CREDS_PATH}.${process.pid}.${Date.now()}.tmp`;
55
131
  await fsp.writeFile(tmp, JSON.stringify(next, null, 2), { encoding: 'utf8', mode: 0o600 });
56
132
  try { await fsp.chmod(tmp, 0o600); } catch { /* umask may have already set it */ }
@@ -130,7 +206,7 @@ function tryCliFallback() {
130
206
  async function refreshIfNeeded(forceRefresh = false) {
131
207
  const cr = await readCredentials();
132
208
  if (cr.kind !== 'ok') return cr;
133
- const { creds, raw } = cr;
209
+ const { creds, raw, source } = cr;
134
210
 
135
211
  if (!forceRefresh && !isExpiringSoon(creds)) {
136
212
  return { kind: 'ok', creds };
@@ -144,7 +220,7 @@ async function refreshIfNeeded(forceRefresh = false) {
144
220
 
145
221
  if (oauthResult.kind === 'ok') {
146
222
  try {
147
- await writeCredentials(raw, oauthResult.fresh);
223
+ await writeCredentials(raw, oauthResult.fresh, source);
148
224
  const freshCr = await readCredentials();
149
225
  if (freshCr.kind === 'ok') {
150
226
  appendRefreshLog({ event: 'oauth_refresh_written_ok' });
@@ -188,4 +264,13 @@ async function refreshIfNeeded(forceRefresh = false) {
188
264
  return { kind: 'unsupported', message: 'Auto-refresh failed; token still valid for now', creds };
189
265
  }
190
266
 
191
- module.exports = { readCredentials, expiresAtMs, isExpired, isExpiringSoon, refreshIfNeeded };
267
+ module.exports = {
268
+ readCredentials,
269
+ expiresAtMs,
270
+ isExpired,
271
+ isExpiringSoon,
272
+ refreshIfNeeded,
273
+ parseCredsRaw,
274
+ KEYCHAIN_SERVICE,
275
+ CREDS_PATH,
276
+ };
@@ -20,13 +20,29 @@ const { ipcMain } = require('electron');
20
20
  const pty = require('node-pty');
21
21
  const path = require('node:path');
22
22
  const os = require('node:os');
23
+ const fs = require('node:fs');
23
24
  const { cleanChildEnv } = require('./lib/cleanEnv.cjs');
24
25
  const { resolveClaudeBin } = require('./lib/claudeBin.cjs');
25
26
  const { sendIfAlive } = require('./lib/sendToRenderer.cjs');
26
27
  const { schemas } = require('./ipcSchemas.cjs');
27
28
 
28
29
  const SLUG_RE = /^[a-z0-9\-/]+$/;
30
+ // Marketplace name: same shape as a plugin slug. Marketplace source (`add`):
31
+ // a GitHub `owner/repo` (case-sensitive) or a dotted/hyphenated path segment.
32
+ // Deliberately excludes whitespace, flags (leading `-`), and shell metachars.
33
+ const MKT_NAME_RE = /^[a-z0-9\-/]+$/;
34
+ const MKT_ADD_RE = /^[A-Za-z0-9][A-Za-z0-9._\-]*\/[A-Za-z0-9._\-]+$/;
35
+ // Sentinel: register the marketplace that ships inside this app's own files
36
+ // (the npx distribution). Resolved to an absolute path in-process so the
37
+ // renderer never supplies a filesystem path. Works offline — no GitHub/npm.
38
+ const BUNDLED_ADD = 'bundled';
29
39
  const MAX_LINE_BYTES = 16 * 1024;
40
+
41
+ /** Absolute path to the packaged marketplace root (package dir holding
42
+ * `.claude-plugin/marketplace.json`). src/main/ → ../../ */
43
+ function bundledMarketplaceDir() {
44
+ return path.join(__dirname, '..', '..');
45
+ }
30
46
  const KILL_AFTER_MS = 5 * 60 * 1000; // 5 min hard ceiling per install
31
47
  const KILL_GRACE_MS = 5_000; // SIGTERM → SIGKILL escalation window
32
48
 
@@ -41,52 +57,51 @@ function send(channel, payload) {
41
57
  sendIfAlive(mainWindow, channel, payload);
42
58
  }
43
59
 
44
- function install({ slug }) {
45
- if (typeof slug !== 'string' || !SLUG_RE.test(slug) || slug.length > 128) {
46
- return Promise.resolve({ ok: false, exitCode: -1, error: 'invalid slug' });
47
- }
48
- if (inFlight.has(slug)) {
49
- return Promise.resolve({ ok: false, exitCode: -1, error: 'install already in progress' });
50
- }
60
+ function childEnv() {
61
+ const home = os.homedir();
62
+ const extraPath = [
63
+ path.join(home, '.local', 'bin'),
64
+ path.join(home, '.npm-global', 'bin'),
65
+ '/usr/local/bin',
66
+ '/usr/bin',
67
+ '/bin',
68
+ ].join(':');
69
+ return cleanChildEnv({
70
+ PATH: `${extraPath}:${process.env.PATH || ''}`,
71
+ TERM: 'xterm-256color',
72
+ FORCE_COLOR: '0', // strip ANSI so the renderer doesn't have to.
73
+ });
74
+ }
51
75
 
76
+ /**
77
+ * Run one `claude plugin …` pty step, streaming output under `slug` and
78
+ * resolving with the exit code. `register`/`unregister` thread the live proc
79
+ * into the inFlight map so plugins:abort can kill whichever step is current.
80
+ */
81
+ function runStep({ slug, args, register, unregister }) {
52
82
  return new Promise((resolve) => {
53
- const home = os.homedir();
54
- const extraPath = [
55
- path.join(home, '.local', 'bin'),
56
- path.join(home, '.npm-global', 'bin'),
57
- '/usr/local/bin',
58
- '/usr/bin',
59
- '/bin',
60
- ].join(':');
61
- const env = cleanChildEnv({
62
- PATH: `${extraPath}:${process.env.PATH || ''}`,
63
- TERM: 'xterm-256color',
64
- FORCE_COLOR: '0', // strip ANSI so the renderer doesn't have to.
65
- });
66
-
67
83
  const claudeBin = resolveClaudeBin();
68
84
  let proc;
69
85
  try {
70
- proc = pty.spawn(claudeBin, ['plugin', 'install', slug], {
86
+ proc = pty.spawn(claudeBin, args, {
71
87
  name: 'xterm-256color',
72
88
  cols: 120,
73
89
  rows: 30,
74
- cwd: home,
75
- env,
90
+ cwd: os.homedir(),
91
+ env: childEnv(),
76
92
  });
77
93
  } catch (err) {
78
94
  resolve({ ok: false, exitCode: -1, error: `spawn failed: ${err?.message ?? String(err)}` });
79
95
  return;
80
96
  }
81
97
 
82
- inFlight.set(slug, proc);
98
+ register(proc);
83
99
 
84
100
  let lineBuf = '';
85
101
  let settled = false;
86
102
 
87
103
  const killTimer = setTimeout(() => {
88
104
  try { proc.kill('SIGTERM'); } catch { /* */ }
89
- // Escalate to SIGKILL after KILL_GRACE_MS if the pty hasn't exited.
90
105
  const escalate = setTimeout(() => {
91
106
  try { proc.kill('SIGKILL'); } catch { /* already dead */ }
92
107
  }, KILL_GRACE_MS);
@@ -96,11 +111,11 @@ function install({ slug }) {
96
111
 
97
112
  // Belt-and-suspenders: if onExit never fires (broken pty event path after
98
113
  // SIGKILL — analogous to anthropics/claude-code #61735's unreachable pts),
99
- // force-release the inFlight lock so the slug isn't permanently stuck.
114
+ // force-resolve so the step (and its inFlight lock) isn't permanently stuck.
100
115
  const deadman = setTimeout(() => {
101
116
  if (settled) return;
102
117
  settled = true;
103
- inFlight.delete(slug);
118
+ unregister();
104
119
  resolve({ ok: false, exitCode: -1, error: 'install hung — pty onExit never fired' });
105
120
  }, KILL_AFTER_MS + KILL_GRACE_MS + 30_000);
106
121
  if (deadman.unref) deadman.unref();
@@ -113,7 +128,6 @@ function install({ slug }) {
113
128
  lineBuf = lineBuf.slice(nl + 1);
114
129
  send('plugins:install-progress', { slug, line });
115
130
  }
116
- // Guard runaway buffers without newlines.
117
131
  if (lineBuf.length > MAX_LINE_BYTES) {
118
132
  send('plugins:install-progress', { slug, line: lineBuf });
119
133
  lineBuf = '';
@@ -129,13 +143,81 @@ function install({ slug }) {
129
143
  send('plugins:install-progress', { slug, line: lineBuf });
130
144
  lineBuf = '';
131
145
  }
132
- inFlight.delete(slug);
146
+ unregister();
133
147
  const code = typeof exitCode === 'number' ? exitCode : -1;
134
148
  resolve({ ok: code === 0, exitCode: code });
135
149
  });
136
150
  });
137
151
  }
138
152
 
153
+ /**
154
+ * Install a plugin. Without `marketplace`, runs `claude plugin install <slug>`
155
+ * (official catalog is pre-registered). With `marketplace: { add, name }`,
156
+ * first runs `claude plugin marketplace add <add>` to register the source, then
157
+ * installs `<slug>@<name>` — this is what a non-official plugin (e.g. the
158
+ * bundled session-manager-dev) needs on a fresh machine.
159
+ */
160
+ async function install({ slug, marketplace }) {
161
+ if (typeof slug !== 'string' || !SLUG_RE.test(slug) || slug.length > 128) {
162
+ return { ok: false, exitCode: -1, error: 'invalid slug' };
163
+ }
164
+ let mkt = null;
165
+ if (marketplace != null) {
166
+ const { add, name } = marketplace;
167
+ const addValid = typeof add === 'string' && add.length <= 200 &&
168
+ (add === BUNDLED_ADD || MKT_ADD_RE.test(add));
169
+ if (!addValid || typeof name !== 'string' || !MKT_NAME_RE.test(name) || name.length > 128) {
170
+ return { ok: false, exitCode: -1, error: 'invalid marketplace' };
171
+ }
172
+ // Resolve the `bundled` sentinel to the app's own packaged marketplace dir.
173
+ let addArg = add;
174
+ if (add === BUNDLED_ADD) {
175
+ const dir = bundledMarketplaceDir();
176
+ if (!fs.existsSync(path.join(dir, '.claude-plugin', 'marketplace.json'))) {
177
+ return { ok: false, exitCode: -1, error: 'bundled marketplace not found' };
178
+ }
179
+ addArg = dir;
180
+ }
181
+ mkt = { add: addArg, name };
182
+ }
183
+ if (inFlight.has(slug)) {
184
+ return { ok: false, exitCode: -1, error: 'install already in progress' };
185
+ }
186
+
187
+ // Reserve the slug for the whole sequence; each step swaps in its live proc.
188
+ inFlight.set(slug, null);
189
+ const register = (proc) => inFlight.set(slug, proc);
190
+ const release = () => inFlight.delete(slug);
191
+ // Between steps the slug stays reserved (set to null) so a concurrent call
192
+ // can't slip in; runStep's unregister only clears the per-step proc handle.
193
+ const unregisterStep = () => inFlight.set(slug, null);
194
+
195
+ try {
196
+ if (mkt) {
197
+ const added = await runStep({
198
+ slug,
199
+ args: ['plugin', 'marketplace', 'add', mkt.add],
200
+ register,
201
+ unregister: unregisterStep,
202
+ });
203
+ // Tolerate "already added" — the add command exits non-zero when the
204
+ // marketplace is already registered; fall through to install regardless.
205
+ if (!added.ok) {
206
+ send('plugins:install-progress', { slug, line: `[marketplace add exit ${added.exitCode} — continuing to install]` });
207
+ }
208
+ }
209
+ const installTarget = mkt ? `${slug}@${mkt.name}` : slug;
210
+ return await runStep({
211
+ slug,
212
+ args: ['plugin', 'install', installTarget],
213
+ register,
214
+ unregister: unregisterStep,
215
+ });
216
+ } finally {
217
+ release();
218
+ }
219
+ }
220
+
139
221
  function registerPluginInstallHandlers() {
140
222
  ipcMain.handle('plugins:install', async (_e, payload) => {
141
223
  // safeParse to preserve the existing `{ ok:false, exitCode:-1, error }`
@@ -145,7 +227,7 @@ function registerPluginInstallHandlers() {
145
227
  if (!parsed.success) {
146
228
  return { ok: false, exitCode: -1, error: 'invalid slug' };
147
229
  }
148
- return install({ slug: parsed.data.slug });
230
+ return install({ slug: parsed.data.slug, marketplace: parsed.data.marketplace });
149
231
  });
150
232
 
151
233
  // plugins:abort — send SIGKILL to a stuck install and release the inFlight
@@ -164,4 +246,4 @@ function registerPluginInstallHandlers() {
164
246
  });
165
247
  }
166
248
 
167
- module.exports = { registerPluginInstallHandlers, attachWindow };
249
+ module.exports = { registerPluginInstallHandlers, attachWindow, install, bundledMarketplaceDir };