skillvault 0.9.2 → 0.11.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.
@@ -0,0 +1,108 @@
1
+ /**
2
+ * Customer credentials and per-install project config storage for the
3
+ * customer-facing agent CLI.
4
+ *
5
+ * Two files, two concerns:
6
+ *
7
+ * ~/.skillvault/credentials.json (always global)
8
+ * Customer identity — JWT, email, every publisher this customer can
9
+ * access. Never project-scoped, even when the install lives in a
10
+ * project directory.
11
+ *
12
+ * <roots.configDir>/project.json (per install, project- or global-scoped)
13
+ * Active publishers in this specific install plus install metadata.
14
+ */
15
+ import { mkdirSync, readFileSync, writeFileSync, existsSync } from 'node:fs';
16
+ import { join, dirname } from 'node:path';
17
+ import { homedir } from 'node:os';
18
+ import { credentialsPath } from './scope.js';
19
+ /**
20
+ * Load customer credentials from ~/.skillvault/credentials.json.
21
+ *
22
+ * Falls back to a one-time migration from the legacy
23
+ * ~/.skillvault/agent-config.json file (the v0.9.x storage layout).
24
+ * On a successful migration the new credentials.json file is written
25
+ * but the legacy file is left in place so a downgrade still works.
26
+ *
27
+ * Returns null if neither file exists.
28
+ */
29
+ export function loadCredentials() {
30
+ const path = credentialsPath();
31
+ try {
32
+ if (existsSync(path)) {
33
+ return JSON.parse(readFileSync(path, 'utf8'));
34
+ }
35
+ }
36
+ catch {
37
+ // fall through to legacy reader
38
+ }
39
+ // Legacy fallback: read the old agent-config.json shape and convert.
40
+ const legacyPath = join(homedir(), '.skillvault', 'agent-config.json');
41
+ if (!existsSync(legacyPath))
42
+ return null;
43
+ try {
44
+ const raw = JSON.parse(readFileSync(legacyPath, 'utf8'));
45
+ let publishers;
46
+ if (raw.token && !raw.publishers) {
47
+ // Pre-multi-publisher schema: a single token + publisher_id pair.
48
+ publishers = [{
49
+ id: raw.publisher_id,
50
+ name: raw.publisher_id,
51
+ token: raw.token,
52
+ added_at: raw.setup_at || new Date().toISOString(),
53
+ }];
54
+ }
55
+ else {
56
+ publishers = (raw.publishers || []);
57
+ }
58
+ const migrated = {
59
+ customer_token: raw.customer_token || raw.token || null,
60
+ customer_email: raw.customer_email || raw.email || null,
61
+ publishers,
62
+ api_url: raw.api_url || 'https://api.getskillvault.com',
63
+ created_at: raw.setup_at || new Date().toISOString(),
64
+ };
65
+ // Persist in the new format so the next call hits the fast path.
66
+ try {
67
+ saveCredentials(migrated);
68
+ }
69
+ catch { }
70
+ return migrated;
71
+ }
72
+ catch {
73
+ return null;
74
+ }
75
+ }
76
+ /**
77
+ * Persist customer credentials to ~/.skillvault/credentials.json with mode 0600.
78
+ */
79
+ export function saveCredentials(creds) {
80
+ const path = credentialsPath();
81
+ mkdirSync(dirname(path), { recursive: true });
82
+ writeFileSync(path, JSON.stringify(creds, null, 2), { mode: 0o600 });
83
+ }
84
+ function projectConfigPath(roots) {
85
+ return join(roots.configDir, 'project.json');
86
+ }
87
+ /**
88
+ * Load this install's project config from <roots.configDir>/project.json.
89
+ * Returns null if no config exists yet.
90
+ */
91
+ export function loadProjectConfig(roots) {
92
+ const path = projectConfigPath(roots);
93
+ try {
94
+ if (!existsSync(path))
95
+ return null;
96
+ return JSON.parse(readFileSync(path, 'utf8'));
97
+ }
98
+ catch {
99
+ return null;
100
+ }
101
+ }
102
+ /**
103
+ * Persist project config to <roots.configDir>/project.json with mode 0600.
104
+ */
105
+ export function saveProjectConfig(roots, config) {
106
+ mkdirSync(roots.configDir, { recursive: true });
107
+ writeFileSync(projectConfigPath(roots), JSON.stringify(config, null, 2), { mode: 0o600 });
108
+ }
@@ -0,0 +1,111 @@
1
+ /**
2
+ * Projects registry — tracks every known project install path.
3
+ *
4
+ * Lives at ~/.skillvault/projects.json (always global, regardless of install
5
+ * scope). Used by `--all` operations like `uninstall --all` and by
6
+ * `getCurrentProject()` to figure out whether the current CWD already has a
7
+ * registered SkillVault install.
8
+ */
9
+ import { mkdirSync, readFileSync, writeFileSync, existsSync, statSync } from 'node:fs';
10
+ import { dirname, resolve } from 'node:path';
11
+ import { projectsRegistryPath } from './scope.js';
12
+ const REGISTRY_VERSION = 1;
13
+ function readRegistry() {
14
+ const path = projectsRegistryPath();
15
+ if (!existsSync(path))
16
+ return { version: REGISTRY_VERSION, projects: [] };
17
+ try {
18
+ const raw = JSON.parse(readFileSync(path, 'utf8'));
19
+ if (Array.isArray(raw)) {
20
+ // Pre-versioned format: a bare array of entries.
21
+ return { version: REGISTRY_VERSION, projects: raw };
22
+ }
23
+ if (raw && typeof raw === 'object' && Array.isArray(raw.projects)) {
24
+ return raw;
25
+ }
26
+ }
27
+ catch { }
28
+ return { version: REGISTRY_VERSION, projects: [] };
29
+ }
30
+ function writeRegistry(reg) {
31
+ const path = projectsRegistryPath();
32
+ mkdirSync(dirname(path), { recursive: true });
33
+ writeFileSync(path, JSON.stringify(reg, null, 2), { mode: 0o600 });
34
+ }
35
+ /**
36
+ * Return all registered project installs. Stale entries (paths that no longer
37
+ * exist on disk OR exist but are missing the .skillvault directory) are
38
+ * filtered out and the registry is rewritten on the way out so the next call
39
+ * is faster.
40
+ *
41
+ * Returns an empty array if the registry file does not exist or is malformed.
42
+ */
43
+ export function listProjects() {
44
+ const reg = readRegistry();
45
+ const live = [];
46
+ let pruned = false;
47
+ for (const entry of reg.projects) {
48
+ const path = resolve(entry.path);
49
+ let stillThere = false;
50
+ try {
51
+ const st = statSync(path);
52
+ if (st.isDirectory()) {
53
+ // The .skillvault subdir is the install marker. If a customer rm -rf'd
54
+ // their project but kept the parent path, we shouldn't keep this in
55
+ // the registry either.
56
+ stillThere = existsSync(`${path}/.skillvault`);
57
+ }
58
+ }
59
+ catch { }
60
+ if (stillThere) {
61
+ live.push(entry);
62
+ }
63
+ else {
64
+ pruned = true;
65
+ }
66
+ }
67
+ if (pruned) {
68
+ try {
69
+ writeRegistry({ ...reg, projects: live });
70
+ }
71
+ catch { }
72
+ }
73
+ return live;
74
+ }
75
+ /**
76
+ * Insert or update a project entry. Matching is by absolute path; if an entry
77
+ * with the same path already exists, it is replaced.
78
+ */
79
+ export function registerProject(entry) {
80
+ const reg = readRegistry();
81
+ const normalized = { ...entry, path: resolve(entry.path) };
82
+ const idx = reg.projects.findIndex((p) => resolve(p.path) === normalized.path);
83
+ if (idx >= 0) {
84
+ reg.projects[idx] = normalized;
85
+ }
86
+ else {
87
+ reg.projects.push(normalized);
88
+ }
89
+ writeRegistry(reg);
90
+ }
91
+ /**
92
+ * Remove the project entry whose path matches. No-op if not present.
93
+ */
94
+ export function unregisterProject(path) {
95
+ const reg = readRegistry();
96
+ const target = resolve(path);
97
+ const before = reg.projects.length;
98
+ reg.projects = reg.projects.filter((p) => resolve(p.path) !== target);
99
+ if (reg.projects.length !== before)
100
+ writeRegistry(reg);
101
+ }
102
+ /**
103
+ * Look up the registry entry that matches the current working directory (or
104
+ * the provided cwd). Returns null if there is no install registered for that
105
+ * directory.
106
+ */
107
+ export function getCurrentProject(cwd) {
108
+ const target = resolve(cwd ?? process.cwd());
109
+ const reg = readRegistry();
110
+ return reg.projects.find((p) => resolve(p.path) === target) ?? null;
111
+ }
@@ -0,0 +1,193 @@
1
+ /**
2
+ * Interactive TTY prompts for the customer-facing CLI.
3
+ *
4
+ * Two prompts live here:
5
+ *
6
+ * confirmInstall(scope, roots) → Y/n/g — show install plan, get confirmation
7
+ * selectPublishers(available) → string[] — checkbox list for multi-publisher
8
+ *
9
+ * Both use raw stdin + ANSI escape codes (no extra dependencies). When stdin
10
+ * is not a TTY (CI, scripts, audit harness), they short-circuit:
11
+ * - confirmInstall returns 'yes'
12
+ * - selectPublishers returns every available publisher (caller can override
13
+ * with --publisher / --all flags before calling)
14
+ */
15
+ import { join } from 'node:path';
16
+ import { homedir } from 'node:os';
17
+ /**
18
+ * Render the install plan for the resolved scope and ask the customer to
19
+ * confirm. Returns:
20
+ * 'yes' — Y or Enter (default)
21
+ * 'no' — n, cancels without writing anything
22
+ * 'global' — g, switches to global scope
23
+ *
24
+ * In non-TTY mode (CI, scripts, audit harness), returns 'yes' immediately
25
+ * without rendering the prompt.
26
+ */
27
+ export async function confirmInstall(scope, roots, cwd = process.cwd()) {
28
+ const lines = renderInstallPlan(scope, roots, cwd);
29
+ for (const line of lines)
30
+ console.error(line);
31
+ if (!process.stdin.isTTY) {
32
+ console.error(' (non-TTY: auto-confirming)');
33
+ return 'yes';
34
+ }
35
+ process.stderr.write('Continue? [Y/n/g] (Y=yes, n=cancel, g=install globally instead): ');
36
+ const key = await readSingleKey();
37
+ console.error('');
38
+ if (key === 'n' || key === 'N')
39
+ return 'no';
40
+ if (key === 'g' || key === 'G')
41
+ return 'global';
42
+ // Y, Enter, or anything else → yes (the safe default)
43
+ return 'yes';
44
+ }
45
+ /**
46
+ * Build the human-readable preview lines for the install plan. Returned as
47
+ * an array so callers can also render via dry-run output if they want.
48
+ */
49
+ export function renderInstallPlan(scope, roots, cwd) {
50
+ const lines = [];
51
+ lines.push('');
52
+ lines.push('🔐 SkillVault Setup');
53
+ lines.push('');
54
+ lines.push(`Installing for: ${scope === 'project' ? 'PROJECT (this directory)' : 'GLOBAL (~/.claude, ~/.skillvault)'}`);
55
+ lines.push(`Location: ${scope === 'project' ? cwd : homedir()}`);
56
+ lines.push('');
57
+ lines.push('Will write:');
58
+ if (scope === 'project') {
59
+ lines.push(` ./.claude/settings.json (Claude Code hooks)`);
60
+ lines.push(` ./.claude/skills/<skill>/ (skill stubs)`);
61
+ lines.push(` ./.skillvault/project.json (project config)`);
62
+ lines.push(` ./.skillvault/vaults/ (encrypted skill cache)`);
63
+ }
64
+ else {
65
+ lines.push(` ~/.claude/settings.json (Claude Code hooks)`);
66
+ lines.push(` ~/.claude/skills/<skill>/ (skill stubs)`);
67
+ lines.push(` ~/.skillvault/project.json (install metadata)`);
68
+ lines.push(` ~/.skillvault/vaults/ (encrypted skill cache)`);
69
+ lines.push(` ~/.agents/skills/<skill>/ (agent-agnostic mirror)`);
70
+ }
71
+ lines.push('');
72
+ lines.push('Plus user-level (always global):');
73
+ lines.push(` ~/.skillvault/credentials.json (your customer identity)`);
74
+ lines.push(` ~/.skillvault/projects.json (project install registry)`);
75
+ lines.push('');
76
+ // Helps callers debug — paths from the resolved roots, in case the spec
77
+ // ever drifts from what we render above.
78
+ void join(roots.configDir, 'project.json');
79
+ return lines;
80
+ }
81
+ /**
82
+ * Render a checkbox list of publishers and let the customer toggle which
83
+ * to enable in this install. Returns the selected publisher IDs.
84
+ *
85
+ * Controls:
86
+ * ↑ / ↓ navigate
87
+ * space toggle the focused entry
88
+ * a toggle all
89
+ * enter confirm and exit
90
+ * q / esc cancel (returns the original `preselected` set)
91
+ *
92
+ * In non-TTY mode this throws — callers should catch and use --publisher /
93
+ * --all flags or fall back to a sensible default.
94
+ */
95
+ export async function selectPublishers(available, preselected = []) {
96
+ if (!process.stdin.isTTY) {
97
+ throw new Error('selectPublishers: not a TTY (use --publisher or --all flags)');
98
+ }
99
+ if (available.length === 0)
100
+ return [];
101
+ const selected = new Set(preselected);
102
+ let cursor = 0;
103
+ const draw = () => {
104
+ process.stderr.write('\x1b[?25l'); // hide cursor
105
+ process.stderr.write(`\nYou have access to ${available.length} publishers. Which to enable in this project?\n\n`);
106
+ for (let i = 0; i < available.length; i++) {
107
+ const p = available[i];
108
+ const focused = i === cursor;
109
+ const checked = selected.has(p.id);
110
+ const box = checked ? '[x]' : '[ ]';
111
+ const arrow = focused ? '>' : ' ';
112
+ const skillSuffix = p.skillCount !== undefined ? ` — ${p.skillCount} skill${p.skillCount !== 1 ? 's' : ''}` : '';
113
+ process.stderr.write(` ${arrow} ${box} ${p.name.padEnd(20)} ${skillSuffix}\n`);
114
+ }
115
+ process.stderr.write('\nSpace=toggle a=toggle all Enter=confirm q=cancel\n');
116
+ };
117
+ const erase = () => {
118
+ // Clear the rendered lines so the next draw replaces them in place.
119
+ const lines = available.length + 4;
120
+ for (let i = 0; i < lines; i++) {
121
+ process.stderr.write('\x1b[1A\x1b[2K');
122
+ }
123
+ };
124
+ draw();
125
+ return new Promise((resolve) => {
126
+ const stdin = process.stdin;
127
+ stdin.setRawMode(true);
128
+ stdin.resume();
129
+ stdin.setEncoding('utf8');
130
+ const cleanup = () => {
131
+ stdin.setRawMode(false);
132
+ stdin.pause();
133
+ stdin.removeListener('data', onData);
134
+ process.stderr.write('\x1b[?25h'); // show cursor
135
+ };
136
+ const onData = (key) => {
137
+ if (key === '\u0003' || key === 'q' || key === '\u001b') {
138
+ // Ctrl-C, q, or Esc — cancel
139
+ erase();
140
+ cleanup();
141
+ resolve([...preselected]);
142
+ return;
143
+ }
144
+ if (key === '\r' || key === '\n') {
145
+ erase();
146
+ cleanup();
147
+ resolve([...selected]);
148
+ return;
149
+ }
150
+ if (key === ' ') {
151
+ const id = available[cursor]?.id;
152
+ if (id) {
153
+ if (selected.has(id))
154
+ selected.delete(id);
155
+ else
156
+ selected.add(id);
157
+ }
158
+ }
159
+ else if (key === 'a' || key === 'A') {
160
+ if (selected.size === available.length)
161
+ selected.clear();
162
+ else
163
+ for (const p of available)
164
+ selected.add(p.id);
165
+ }
166
+ else if (key === '\u001b[A' || key === 'k') {
167
+ cursor = (cursor - 1 + available.length) % available.length;
168
+ }
169
+ else if (key === '\u001b[B' || key === 'j') {
170
+ cursor = (cursor + 1) % available.length;
171
+ }
172
+ erase();
173
+ draw();
174
+ };
175
+ stdin.on('data', onData);
176
+ });
177
+ }
178
+ /** Read a single keystroke from stdin. Used by confirmInstall. */
179
+ function readSingleKey() {
180
+ return new Promise((resolve) => {
181
+ const stdin = process.stdin;
182
+ stdin.setRawMode(true);
183
+ stdin.resume();
184
+ stdin.setEncoding('utf8');
185
+ const onData = (key) => {
186
+ stdin.setRawMode(false);
187
+ stdin.pause();
188
+ stdin.removeListener('data', onData);
189
+ resolve(key);
190
+ };
191
+ stdin.on('data', onData);
192
+ });
193
+ }
package/dist/scope.js ADDED
@@ -0,0 +1,99 @@
1
+ /**
2
+ * Install scope resolution.
3
+ *
4
+ * SkillVault supports two install scopes:
5
+ * - "project": files live in the current working directory (./.skillvault, ./.claude)
6
+ * - "global": files live under the user's home directory (~/.skillvault, ~/.claude)
7
+ *
8
+ * Customer credentials (~/.skillvault/credentials.json) are ALWAYS global —
9
+ * they represent the user's identity, not the install. The scope only affects
10
+ * where vault data, project config, hooks, and skill stubs are written.
11
+ */
12
+ import { homedir } from 'node:os';
13
+ import { join } from 'node:path';
14
+ import { loadCredentials, loadProjectConfig } from './credentials.js';
15
+ import { getCurrentProject } from './projects-registry.js';
16
+ /**
17
+ * Determine install scope from explicit flags and the current working directory.
18
+ *
19
+ * Priority:
20
+ * 1. --global flag → global
21
+ * 2. --project flag → project
22
+ * 3. CWD === $HOME or '/' → global (no sane "project" location)
23
+ * 4. Otherwise → project
24
+ */
25
+ export function resolveScope(opts = {}) {
26
+ if (opts.global)
27
+ return 'global';
28
+ if (opts.project)
29
+ return 'project';
30
+ const cwd = opts.cwd ?? process.cwd();
31
+ const home = homedir();
32
+ if (cwd === home || cwd === '/')
33
+ return 'global';
34
+ return 'project';
35
+ }
36
+ /**
37
+ * Resolve filesystem roots for a given scope.
38
+ *
39
+ * For project scope, roots are based on `cwd` (defaults to process.cwd()).
40
+ * For global scope, roots are based on the user's home directory.
41
+ */
42
+ export function resolveRoots(scope, cwd) {
43
+ if (scope === 'project') {
44
+ const base = cwd ?? process.cwd();
45
+ const configDir = join(base, '.skillvault');
46
+ const claudeDir = join(base, '.claude');
47
+ return {
48
+ scope,
49
+ configDir,
50
+ claudeDir,
51
+ vaultDir: join(configDir, 'vaults'),
52
+ settingsPath: join(claudeDir, 'settings.json'),
53
+ };
54
+ }
55
+ const home = homedir();
56
+ const configDir = join(home, '.skillvault');
57
+ const claudeDir = join(home, '.claude');
58
+ return {
59
+ scope,
60
+ configDir,
61
+ claudeDir,
62
+ vaultDir: join(configDir, 'vaults'),
63
+ settingsPath: join(claudeDir, 'settings.json'),
64
+ };
65
+ }
66
+ /**
67
+ * Always-global path to the customer credentials file.
68
+ * Credentials represent the user's identity and are never project-scoped.
69
+ */
70
+ export function credentialsPath() {
71
+ return join(homedir(), '.skillvault', 'credentials.json');
72
+ }
73
+ /**
74
+ * Always-global path to the projects registry.
75
+ * Tracks every known project install path for `--all` operations.
76
+ */
77
+ export function projectsRegistryPath() {
78
+ return join(homedir(), '.skillvault', 'projects.json');
79
+ }
80
+ /**
81
+ * Report the current install state at CWD.
82
+ *
83
+ * Resolves scope using the same rules as resolveScope() (no flags), looks
84
+ * up matching credentials, project config, and registry entry, and returns
85
+ * a single object suitable for the `--status` command and confirmation
86
+ * prompts. Fields that don't exist are returned as null/empty rather than
87
+ * throwing.
88
+ */
89
+ export function reportInstallStatus(opts = {}) {
90
+ const scope = resolveScope(opts);
91
+ const roots = resolveRoots(scope, opts.cwd);
92
+ const credentials = loadCredentials();
93
+ const projectConfig = loadProjectConfig(roots);
94
+ const projectEntry = scope === 'project' ? getCurrentProject(opts.cwd) : null;
95
+ const activePublishers = projectConfig?.active_publishers
96
+ ?? projectEntry?.active_publishers
97
+ ?? [];
98
+ return { scope, roots, projectEntry, credentials, projectConfig, activePublishers };
99
+ }
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "skillvault",
3
- "version": "0.9.2",
3
+ "version": "0.11.0",
4
4
  "description": "SkillVault — secure skill distribution for Claude Code",
5
5
  "type": "module",
6
6
  "bin": {