skillvault-publisher 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.
@@ -1,12 +1,16 @@
1
1
  import { Command } from 'commander';
2
2
  import chalk from 'chalk';
3
- import { requireSession, sessionFetch } from '../session.js';
3
+ import { requireCommandContext, sessionFetch } from '../session.js';
4
4
  export const analyticsCommand = new Command('analytics')
5
5
  .description('View publisher analytics')
6
6
  .option('--days <n>', 'Number of days to look back', '30')
7
+ .option('--skill <name>', 'Filter top skills/stats to a specific skill')
7
8
  .option('--json', 'Output as JSON')
9
+ .option('--workspace <path>', 'Use workspace at <path> to default --skill')
10
+ .option('--quiet', 'Suppress the "(using workspace …)" stderr note')
11
+ .option('--all', 'Disable workspace --skill default; show analytics for all skills')
8
12
  .action(async (options) => {
9
- const ctx = requireSession();
13
+ const ctx = requireCommandContext({ workspaceFlag: options.workspace, quiet: options.quiet });
10
14
  try {
11
15
  const days = parseInt(options.days, 10);
12
16
  if (isNaN(days) || days < 1) {
@@ -28,8 +32,16 @@ export const analyticsCommand = new Command('analytics')
28
32
  console.log(` ${chalk.bold('Active Licenses:')} ${data.active_licenses}`);
29
33
  console.log(` ${chalk.bold('Total Decryptions:')} ${data.total_decryptions}`);
30
34
  console.log(` ${chalk.bold('Period:')} Last ${days} day${days !== 1 ? 's' : ''} (${start.toLocaleDateString()} \u2013 ${end.toLocaleDateString()})`);
35
+ // Apply workspace --skill filter (client-side) so analytics from a
36
+ // workspace dir defaults to that skill's stats. --all disables it.
37
+ const effectiveSkill = options.skill
38
+ ?? (options.all ? undefined : ctx.defaultSkillName ?? undefined);
39
+ const filterSkill = (skillName) => !effectiveSkill || skillName.toLowerCase() === effectiveSkill.toLowerCase();
31
40
  // Merge top_skills (decryptions) with skill_stats (licenses) by name
32
41
  const statsMap = new Map((data.skill_stats || []).map(s => [s.skill_name, s]));
42
+ if (effectiveSkill) {
43
+ data.top_skills = (data.top_skills || []).filter((s) => filterSkill(s.name));
44
+ }
33
45
  if (data.top_skills && data.top_skills.length > 0) {
34
46
  console.log();
35
47
  console.log(chalk.bold(' Top Skills'));
@@ -1,6 +1,6 @@
1
1
  import { Command } from 'commander';
2
2
  import chalk from 'chalk';
3
- import { requireSession, sessionFetch } from '../session.js';
3
+ import { requireCommandContext, sessionFetch } from '../session.js';
4
4
  export const auditCommand = new Command('audit')
5
5
  .description('View audit log events')
6
6
  .option('--type <event_type>', 'Filter by event type')
@@ -9,16 +9,23 @@ export const auditCommand = new Command('audit')
9
9
  .option('--limit <n>', 'Number of events to return', '20')
10
10
  .option('--export <format>', 'Export as csv or json')
11
11
  .option('--json', 'Output as JSON')
12
+ .option('--workspace <path>', 'Use workspace at <path> to default --skill')
13
+ .option('--quiet', 'Suppress the "(using workspace …)" stderr note')
14
+ .option('--all', 'Disable workspace --skill default; show events for all skills')
12
15
  .action(async (options) => {
13
16
  try {
14
- const ctx = requireSession();
17
+ const ctx = requireCommandContext({ workspaceFlag: options.workspace, quiet: options.quiet });
18
+ // If --skill not passed, --all not passed, and a workspace is active,
19
+ // default the filter to the workspace's skill.
20
+ const effectiveSkill = options.skill
21
+ ?? (options.all ? undefined : ctx.defaultSkillName ?? undefined);
15
22
  // Build query params shared by both normal and export flows
16
23
  const params = new URLSearchParams();
17
24
  params.set('limit', options.limit || '20');
18
25
  if (options.type)
19
26
  params.set('event_type', options.type);
20
- if (options.skill)
21
- params.set('capability', `skill/${options.skill}`);
27
+ if (effectiveSkill)
28
+ params.set('capability', `skill/${effectiveSkill}`);
22
29
  if (options.since)
23
30
  params.set('since', options.since);
24
31
  // ── Export flow ──────────────────────────────────────────────────
@@ -1,30 +1,24 @@
1
1
  import { Command } from 'commander';
2
2
  import chalk from 'chalk';
3
- import { getConfig } from '../credentials.js';
3
+ import { requireCommandContext } from '../session.js';
4
4
  export const changelogCommand = new Command('changelog')
5
5
  .description('View version history and changelogs for a skill')
6
- .argument('<skill-name>', 'Name of the skill (without skill/ prefix)')
6
+ .argument('[skill-name]', 'Name of the skill (defaults to workspace skill)')
7
7
  .option('--limit <n>', 'Show last N versions', '10')
8
8
  .option('--json', 'Output as JSON')
9
- .action(async (skillName, options) => {
9
+ .option('--workspace <path>', 'Use workspace at <path> to default skill-name')
10
+ .option('--quiet', 'Suppress the "(using workspace …)" stderr note')
11
+ .action(async (skillNameArg, options) => {
10
12
  try {
11
- // ── Auth ──
12
- const config = getConfig();
13
- if (!config) {
14
- process.stderr.write(chalk.red('Not logged in. Run `skillvault-publisher login` first.\n'));
13
+ const ctx = requireCommandContext({ workspaceFlag: options.workspace, quiet: options.quiet });
14
+ const skillName = skillNameArg ?? ctx.defaultSkillName ?? undefined;
15
+ if (!skillName) {
16
+ process.stderr.write(chalk.red('Error: No skill name provided and no workspace context.\n'));
17
+ process.stderr.write(chalk.dim(' Pass <skill-name> or run from a linked workspace directory.\n'));
15
18
  process.exit(1);
16
19
  }
17
- const serverUrl = config.server_url;
18
- // These endpoints require a publisher session token, not a host JWT
19
- if (!config.session_token) {
20
- process.stderr.write(chalk.red('No session token. Re-login with --email to get a publisher session:\n'));
21
- process.stderr.write(chalk.cyan(' skillvault-publisher login --email you@example.com\n'));
22
- process.exit(1);
23
- }
24
- const sessionHeaders = {
25
- 'Content-Type': 'application/json',
26
- Authorization: `Bearer ${config.session_token}`,
27
- };
20
+ const serverUrl = ctx.serverUrl;
21
+ const sessionHeaders = ctx.headers;
28
22
  // ── Look up the skill by listing publisher's skills ──
29
23
  const skillsRes = await fetch(`${serverUrl}/skills`, { headers: sessionHeaders });
30
24
  if (!skillsRes.ok) {
@@ -1,11 +1,13 @@
1
1
  import { Command } from 'commander';
2
2
  import chalk from 'chalk';
3
- import { requireSession, sessionFetch } from '../session.js';
3
+ import { requireCommandContext, sessionFetch } from '../session.js';
4
4
  export const customersCommand = new Command('customers')
5
5
  .description('List your customers')
6
6
  .option('--json', 'Output as JSON')
7
+ .option('--workspace <path>', 'Resolve workspace context (defaults from CWD)')
8
+ .option('--quiet', 'Suppress the "(using workspace …)" stderr note')
7
9
  .action(async (options) => {
8
- const ctx = requireSession();
10
+ const ctx = requireCommandContext({ workspaceFlag: options.workspace, quiet: options.quiet });
9
11
  try {
10
12
  const data = await sessionFetch(ctx, '/customers');
11
13
  const customers = data.customers;
@@ -1,18 +1,31 @@
1
1
  import { Command } from 'commander';
2
2
  import chalk from 'chalk';
3
- import { requireSession, sessionFetch } from '../session.js';
3
+ import { requireCommandContext, sessionFetch } from '../session.js';
4
4
  export const grantsCommand = new Command('grants')
5
5
  .description('List capability grants issued to customers')
6
6
  .option('--status <status>', 'Filter by status: active, revoked, expired')
7
+ .option('--skill <name>', 'Filter to grants for a specific skill (capability)')
7
8
  .option('--json', 'Output as JSON')
9
+ .option('--workspace <path>', 'Use workspace at <path> to default --skill')
10
+ .option('--quiet', 'Suppress the "(using workspace …)" stderr note')
11
+ .option('--all', 'Disable workspace --skill default; show grants for all skills')
8
12
  .action(async (options) => {
9
13
  try {
10
- const ctx = requireSession();
14
+ const ctx = requireCommandContext({ workspaceFlag: options.workspace, quiet: options.quiet });
11
15
  const data = await sessionFetch(ctx, '/grants');
16
+ // Resolve effective skill filter — explicit --skill > workspace default
17
+ // (unless --all). Default is suppressed when --all is passed so the
18
+ // publisher can see every grant from a workspace dir without re-cd'ing.
19
+ const effectiveSkill = options.skill
20
+ ?? (options.all ? undefined : ctx.defaultSkillName ?? undefined);
12
21
  let grants = data.grants;
13
22
  if (options.status) {
14
23
  grants = grants.filter(g => g.status === options.status);
15
24
  }
25
+ if (effectiveSkill) {
26
+ const target = `skill/${effectiveSkill.toLowerCase()}`;
27
+ grants = grants.filter(g => g.capability.toLowerCase() === target);
28
+ }
16
29
  if (options.json) {
17
30
  console.log(JSON.stringify(grants, null, 2));
18
31
  return;
@@ -0,0 +1,112 @@
1
+ import { Command } from 'commander';
2
+ import chalk from 'chalk';
3
+ import { existsSync, readFileSync, statSync } from 'node:fs';
4
+ import { resolve, join } from 'node:path';
5
+ import { requireSession } from '../session.js';
6
+ import { loadWorkspace, saveWorkspace, } from '../publisher-workspace.js';
7
+ import { registerWorkspace } from '../publisher-workspaces-registry.js';
8
+ /** Minimal frontmatter parser for SKILL.md (mirrors publish.ts). */
9
+ function parseFrontmatter(content) {
10
+ const match = content.match(/^---\r?\n([\s\S]*?)\r?\n---/);
11
+ if (!match)
12
+ return {};
13
+ const result = {};
14
+ for (const line of match[1].split('\n')) {
15
+ const idx = line.indexOf(':');
16
+ if (idx === -1)
17
+ continue;
18
+ const key = line.slice(0, idx).trim();
19
+ let value = line.slice(idx + 1).trim();
20
+ if ((value.startsWith('"') && value.endsWith('"')) || (value.startsWith("'") && value.endsWith("'"))) {
21
+ value = value.slice(1, -1);
22
+ }
23
+ result[key] = value;
24
+ }
25
+ return result;
26
+ }
27
+ export const linkCommand = new Command('link')
28
+ .description('Link a skill source directory to your publisher account')
29
+ .argument('[directory]', 'Skill directory to link (defaults to CWD)')
30
+ .option('--workspace <path>', 'Explicit workspace directory (precedence over positional)')
31
+ .option('--force', 'Overwrite an existing link to a different publisher_id')
32
+ .option('--name <name>', 'Override skill name (defaults to SKILL.md frontmatter name)')
33
+ .option('--capability <cap>', 'Override capability_name (advanced — usually matches skill name)')
34
+ .action(async (directory, options) => {
35
+ try {
36
+ const ctx = requireSession();
37
+ // Precedence: --workspace > positional > CWD
38
+ const dirPath = options.workspace
39
+ ? resolve(options.workspace)
40
+ : directory
41
+ ? resolve(directory)
42
+ : process.cwd();
43
+ if (!existsSync(dirPath) || !statSync(dirPath).isDirectory()) {
44
+ process.stderr.write(chalk.red(`Error: "${dirPath}" is not a valid directory\n`));
45
+ process.exit(1);
46
+ }
47
+ const skillMdPath = join(dirPath, 'SKILL.md');
48
+ const hasSkillMd = existsSync(skillMdPath);
49
+ const frontmatter = hasSkillMd
50
+ ? parseFrontmatter(readFileSync(skillMdPath, 'utf8'))
51
+ : {};
52
+ const existing = loadWorkspace(dirPath);
53
+ // ── Resolve skill_name (precedence: --name > existing manifest > frontmatter > error) ──
54
+ const skillName = options.name ||
55
+ existing?.skill_name ||
56
+ frontmatter.name;
57
+ if (!skillName) {
58
+ process.stderr.write(chalk.red('Error: Cannot determine skill name.\n'));
59
+ process.stderr.write(chalk.dim(' Pass --name <name> or set `name:` in SKILL.md frontmatter.\n'));
60
+ process.exit(1);
61
+ }
62
+ // ── Resolve capability_name (precedence: --capability > existing manifest > computed) ──
63
+ const capabilityName = options.capability ||
64
+ existing?.capability_name ||
65
+ `skill/${skillName.toLowerCase()}`;
66
+ // ── Cross-account guard ──
67
+ if (existing &&
68
+ existing.publisher_id !== ctx.publisherId &&
69
+ !options.force) {
70
+ process.stderr.write(chalk.red(`Error: This directory is already linked to publisher ${existing.publisher_id}.\n`));
71
+ process.stderr.write(chalk.dim(` You are logged in as ${ctx.publisherId}.\n`));
72
+ process.stderr.write(chalk.cyan(' Use --force to re-link to your account.\n'));
73
+ process.exit(1);
74
+ }
75
+ // ── Build the manifest ──
76
+ // Spread existing first so source/github and any other forward-compat
77
+ // fields survive a re-link. linked_at is preserved on re-link.
78
+ const linkedAt = existing?.linked_at ?? new Date().toISOString();
79
+ const manifest = {
80
+ ...(existing ?? {}),
81
+ version: 1,
82
+ publisher_id: ctx.publisherId,
83
+ skill_name: skillName,
84
+ capability_name: capabilityName,
85
+ linked_at: linkedAt,
86
+ };
87
+ saveWorkspace(dirPath, manifest);
88
+ registerWorkspace({
89
+ path: dirPath,
90
+ publisher_id: manifest.publisher_id,
91
+ skill_name: manifest.skill_name,
92
+ capability_name: manifest.capability_name,
93
+ linked_at: manifest.linked_at,
94
+ last_published_at: manifest.last_published_at,
95
+ });
96
+ const action = existing ? 'Re-linked' : 'Linked';
97
+ console.log(chalk.green(`${action} "${skillName}" → ${capabilityName}`));
98
+ console.log(chalk.dim(` Directory: ${dirPath}`));
99
+ console.log(chalk.dim(` Publisher: ${ctx.publisherId}`));
100
+ console.log(chalk.dim(` Manifest: .skillvault-publisher/workspace.json`));
101
+ if (!existing) {
102
+ console.log();
103
+ console.log(chalk.cyan(' Tip: commit .skillvault-publisher/workspace.json so teammates and CI can find this skill.'));
104
+ }
105
+ }
106
+ catch (err) {
107
+ const message = err instanceof Error ? err.message : String(err);
108
+ process.stderr.write(chalk.red(`Link failed: ${message}\n`));
109
+ process.exit(1);
110
+ }
111
+ });
112
+ //# sourceMappingURL=link.js.map
@@ -5,6 +5,8 @@ import { resolve, join, basename } from 'node:path';
5
5
  import { createHash } from 'node:crypto';
6
6
  import { packSkillDirectory, writeVault, generateCEK } from 'skillvault-shared';
7
7
  import { requireSession } from '../session.js';
8
+ import { loadWorkspace } from '../publisher-workspace.js';
9
+ import { listWorkspaces } from '../publisher-workspaces-registry.js';
8
10
  /** Minimal frontmatter parser for SKILL.md */
9
11
  function parseFrontmatter(content) {
10
12
  const match = content.match(/^---\r?\n([\s\S]*?)\r?\n---/);
@@ -157,20 +159,44 @@ export const publishAllCommand = new Command('publish-all')
157
159
  .option('--force', 'Force re-publish all, even if unchanged')
158
160
  .option('--changelog <text>', 'Apply same changelog to all skills')
159
161
  .option('--server <url>', 'Override server URL')
162
+ .option('--all-workspaces', 'Iterate registered workspaces instead of walking the filesystem')
163
+ .option('--current', 'With --all-workspaces, only publish workspaces matching the current account')
160
164
  .action(async (directory, options) => {
161
165
  try {
162
166
  const ctx = requireSession();
163
167
  const config = ctx.config;
164
- const rootDir = resolve(directory);
165
- if (!existsSync(rootDir) || !statSync(rootDir).isDirectory()) {
166
- process.stderr.write(chalk.red(`Error: "${rootDir}" is not a valid directory\n`));
167
- process.exit(1);
168
+ // ── Determine the list of skill directories to publish ──
169
+ let skillDirs;
170
+ let usingWorkspaces = false;
171
+ if (options.allWorkspaces) {
172
+ usingWorkspaces = true;
173
+ let entries = listWorkspaces();
174
+ if (options.current) {
175
+ entries = entries.filter((e) => e.publisher_id === ctx.publisherId);
176
+ }
177
+ skillDirs = entries.map((e) => e.path);
178
+ console.log(chalk.dim(`Iterating ${skillDirs.length} registered workspace${skillDirs.length === 1 ? '' : 's'}` +
179
+ (options.current ? ' (current account only)' : '') + '...'));
180
+ }
181
+ else {
182
+ const rootDir = resolve(directory);
183
+ if (!existsSync(rootDir) || !statSync(rootDir).isDirectory()) {
184
+ process.stderr.write(chalk.red(`Error: "${rootDir}" is not a valid directory\n`));
185
+ process.exit(1);
186
+ }
187
+ console.log(chalk.dim(`Discovering skills in ${rootDir}...`));
188
+ skillDirs = findSkillDirs(rootDir);
168
189
  }
169
- console.log(chalk.dim(`Discovering skills in ${rootDir}...`));
170
- const skillDirs = findSkillDirs(rootDir);
171
190
  if (skillDirs.length === 0) {
172
- console.log(chalk.yellow('\n No skill directories found (directories containing SKILL.md).'));
173
- console.log(chalk.dim(' Create a SKILL.md in each skill directory.\n'));
191
+ if (usingWorkspaces) {
192
+ console.log(chalk.yellow('\n No registered workspaces found.'));
193
+ console.log(chalk.dim(' Run `skillvault-publisher link` from a skill source dir, or'));
194
+ console.log(chalk.dim(' `skillvault-publisher publish` (auto-links on first publish).\n'));
195
+ }
196
+ else {
197
+ console.log(chalk.yellow('\n No skill directories found (directories containing SKILL.md).'));
198
+ console.log(chalk.dim(' Create a SKILL.md in each skill directory.\n'));
199
+ }
174
200
  process.exit(0);
175
201
  }
176
202
  console.log(chalk.bold(`Found ${skillDirs.length} skill${skillDirs.length === 1 ? '' : 's'}:\n`));
@@ -179,10 +205,20 @@ export const publishAllCommand = new Command('publish-all')
179
205
  for (let i = 0; i < skillDirs.length; i++) {
180
206
  const dir = skillDirs[i];
181
207
  const skillMdPath = join(dir, 'SKILL.md');
182
- const content = readFileSync(skillMdPath, 'utf8');
183
- const fm = parseFrontmatter(content);
184
- const name = fm.name || basename(dir);
185
- const version = fm.version || '?';
208
+ let name;
209
+ let version;
210
+ if (existsSync(skillMdPath)) {
211
+ const content = readFileSync(skillMdPath, 'utf8');
212
+ const fm = parseFrontmatter(content);
213
+ name = fm.name || basename(dir);
214
+ version = fm.version || '?';
215
+ }
216
+ else {
217
+ // Workspace mode without SKILL.md — surface manifest identity.
218
+ const manifest = loadWorkspace(dir);
219
+ name = manifest?.skill_name ?? basename(dir);
220
+ version = manifest?.last_published_version ?? '?';
221
+ }
186
222
  console.log(` [${i + 1}/${skillDirs.length}] ${chalk.cyan(name)} v${version} ${chalk.dim(`(${dir})`)}`);
187
223
  }
188
224
  console.log(chalk.dim('\n Dry run — nothing was published.\n'));
@@ -196,6 +232,52 @@ export const publishAllCommand = new Command('publish-all')
196
232
  for (let i = 0; i < skillDirs.length; i++) {
197
233
  const dir = skillDirs[i];
198
234
  const skillMdPath = join(dir, 'SKILL.md');
235
+ // Check the workspace manifest first so we can pre-skip cross-account
236
+ // and source=github entries without paying for the SKILL.md read.
237
+ const manifest = loadWorkspace(dir);
238
+ // Cross-account skip — never publish someone else's skill.
239
+ if (manifest && manifest.publisher_id !== ctx.publisherId) {
240
+ process.stdout.write(` [${i + 1}/${skillDirs.length}] ${manifest.skill_name} ... `);
241
+ console.log(chalk.dim('SKIPPED [other account]'));
242
+ results.push({
243
+ name: manifest.skill_name,
244
+ version: manifest.last_published_version ?? '?',
245
+ dir,
246
+ success: true,
247
+ skipped: true,
248
+ skipReason: 'cross_account',
249
+ source: manifest.source ?? 'local',
250
+ });
251
+ continue;
252
+ }
253
+ // source=github skip (without --force).
254
+ if (manifest?.source === 'github' && !options.force) {
255
+ process.stdout.write(` [${i + 1}/${skillDirs.length}] ${manifest.skill_name} ... `);
256
+ console.log(chalk.dim(`SKIPPED [source=github, tag ${manifest.github?.tag_pattern ?? '<unset>'}]`));
257
+ results.push({
258
+ name: manifest.skill_name,
259
+ version: manifest.last_published_version ?? '?',
260
+ dir,
261
+ success: true,
262
+ skipped: true,
263
+ skipReason: 'github_source',
264
+ source: 'github',
265
+ });
266
+ continue;
267
+ }
268
+ if (!existsSync(skillMdPath)) {
269
+ results.push({
270
+ name: manifest?.skill_name ?? basename(dir),
271
+ version: '?',
272
+ dir,
273
+ success: false,
274
+ error: 'SKILL.md not found',
275
+ source: manifest?.source ?? 'local',
276
+ });
277
+ process.stdout.write(` [${i + 1}/${skillDirs.length}] ${basename(dir)} ... `);
278
+ console.log(chalk.red('FAILED: SKILL.md not found'));
279
+ continue;
280
+ }
199
281
  const content = readFileSync(skillMdPath, 'utf8');
200
282
  const fm = parseFrontmatter(content);
201
283
  const name = fm.name || basename(dir);
@@ -208,6 +290,7 @@ export const publishAllCommand = new Command('publish-all')
208
290
  force: options.force,
209
291
  changelog: options.changelog,
210
292
  });
293
+ result.source = manifest?.source ?? 'local';
211
294
  results.push(result);
212
295
  if (result.success) {
213
296
  if (result.skipped) {
@@ -223,14 +306,20 @@ export const publishAllCommand = new Command('publish-all')
223
306
  }
224
307
  // Summary
225
308
  const published = results.filter(r => r.success && !r.skipped).length;
226
- const skipped = results.filter(r => r.skipped).length;
309
+ const unchanged = results.filter(r => r.skipped && !r.skipReason).length;
310
+ const skippedCrossAccount = results.filter(r => r.skipReason === 'cross_account').length;
311
+ const skippedGithub = results.filter(r => r.skipReason === 'github_source').length;
227
312
  const failed = results.filter(r => !r.success).length;
228
313
  console.log();
229
314
  const parts = [];
230
315
  if (published > 0)
231
316
  parts.push(chalk.green(`${published} published`));
232
- if (skipped > 0)
233
- parts.push(chalk.yellow(`${skipped} unchanged`));
317
+ if (unchanged > 0)
318
+ parts.push(chalk.yellow(`${unchanged} unchanged`));
319
+ if (skippedCrossAccount > 0)
320
+ parts.push(chalk.dim(`${skippedCrossAccount} cross-account`));
321
+ if (skippedGithub > 0)
322
+ parts.push(chalk.dim(`${skippedGithub} github-source`));
234
323
  if (failed > 0)
235
324
  parts.push(chalk.red(`${failed} failed`));
236
325
  console.log(` Summary: ${parts.join(', ')}`);
@@ -5,6 +5,8 @@ import { resolve, join } from 'node:path';
5
5
  import { createHash } from 'node:crypto';
6
6
  import { packSkillDirectory, writeVault, generateCEK } from 'skillvault-shared';
7
7
  import { requireSession } from '../session.js';
8
+ import { loadWorkspace, saveWorkspace, resolveWorkspaceContext, } from '../publisher-workspace.js';
9
+ import { registerWorkspace } from '../publisher-workspaces-registry.js';
8
10
  /** Minimal frontmatter parser for SKILL.md */
9
11
  function parseFrontmatter(content) {
10
12
  const match = content.match(/^---\r?\n([\s\S]*?)\r?\n---/);
@@ -27,17 +29,29 @@ function parseFrontmatter(content) {
27
29
  }
28
30
  export const publishCommand = new Command('publish')
29
31
  .description('Encrypt and publish a skill to the registry')
30
- .argument('[directory]', 'Skill directory to publish', '.')
32
+ .argument('[directory]', 'Skill directory to publish')
31
33
  .option('--name <name>', 'Skill name (overrides SKILL.md frontmatter)')
32
34
  .option('--description <desc>', 'Description (overrides SKILL.md frontmatter)')
33
- .option('--force', 'Force re-publish even if content unchanged')
35
+ .option('--force', 'Force re-publish; bypass rename and source=github guards')
34
36
  .option('--changelog <message>', 'Changelog message for this version')
35
- .option('--publisher <id>', 'Publisher ID (defaults to host_id from login)')
37
+ .option('--publisher <id>', 'Publisher ID (defaults to publisher_id from login)')
38
+ .option('--workspace <path>', 'Explicit workspace directory (overrides CWD walk-up)')
39
+ .option('--no-link', 'Do not create or update .skillvault-publisher/workspace.json')
36
40
  .action(async (directory, options) => {
37
41
  try {
38
42
  const ctx = requireSession();
39
43
  const config = ctx.config;
40
- const dirPath = resolve(directory);
44
+ // ── Resolve target directory ──
45
+ // Precedence: --workspace > positional > workspace walk-up from CWD > CWD
46
+ const workspaceCtx = resolveWorkspaceContext({
47
+ workspaceFlag: options.workspace,
48
+ cwd: directory ? resolve(directory) : process.cwd(),
49
+ });
50
+ const dirPath = options.workspace
51
+ ? resolve(options.workspace)
52
+ : directory
53
+ ? resolve(directory)
54
+ : workspaceCtx?.dir ?? process.cwd();
41
55
  if (!existsSync(dirPath) || !statSync(dirPath).isDirectory()) {
42
56
  process.stderr.write(chalk.red(`Error: "${dirPath}" is not a valid directory\n`));
43
57
  process.exit(1);
@@ -67,6 +81,49 @@ export const publishCommand = new Command('publish')
67
81
  process.stderr.write(chalk.red('Error: Version required in SKILL.md frontmatter (e.g. version: 1.0.0)\n'));
68
82
  process.exit(1);
69
83
  }
84
+ // ── Workspace manifest guards ──
85
+ const existingManifest = loadWorkspace(dirPath);
86
+ const computedCapability = `skill/${skillName.toLowerCase()}`;
87
+ const sessionPublisherId = options.publisher || config.publisher_id;
88
+ // Cross-account guard — refuses even with --force.
89
+ if (existingManifest && sessionPublisherId && existingManifest.publisher_id !== sessionPublisherId) {
90
+ process.stderr.write(chalk.red(`Error: This directory is linked to publisher ${existingManifest.publisher_id}, but you are logged in as ${sessionPublisherId}.\n`));
91
+ process.stderr.write('Either:\n');
92
+ process.stderr.write(chalk.cyan(` 1. Re-link this directory: skillvault-publisher link --force\n`));
93
+ process.stderr.write(chalk.cyan(` 2. Log in as the correct publisher\n`));
94
+ process.stderr.write(chalk.cyan(` 3. Run with --no-link to publish without updating any manifest\n`));
95
+ process.exit(1);
96
+ }
97
+ // Source-is-github guard — bypassable with --force.
98
+ if (existingManifest?.source === 'github') {
99
+ const tagPattern = existingManifest.github?.tag_pattern ?? '<unset>';
100
+ if (!options.force) {
101
+ process.stderr.write(chalk.yellow(`Warning: This skill publishes from GitHub via tag push (tag pattern: ${tagPattern}).\n`));
102
+ process.stderr.write(chalk.yellow(' Running `publish` locally will be overridden the next time the webhook fires.\n'));
103
+ process.stderr.write(chalk.cyan(' Use --force to publish locally anyway.\n'));
104
+ process.exit(1);
105
+ }
106
+ process.stderr.write(chalk.yellow(`Warning: --force overriding source=github guard (tag pattern: ${tagPattern}).\n`));
107
+ }
108
+ // Capability rename guard — bypassable with --force; preserves the
109
+ // original capability_name when bypassed.
110
+ let capabilityName = computedCapability;
111
+ let renameBypassed = false;
112
+ if (existingManifest && existingManifest.capability_name !== computedCapability) {
113
+ if (!options.force) {
114
+ process.stderr.write(chalk.red(`Error: SKILL.md name (${skillName}) does not match the linked capability (${existingManifest.capability_name}).\n`));
115
+ process.stderr.write('Did you rename the skill? If so, either:\n');
116
+ process.stderr.write(chalk.cyan(` 1. Change SKILL.md frontmatter name back to "${existingManifest.skill_name}"\n`));
117
+ process.stderr.write(chalk.cyan(' 2. Run `skillvault-publisher unlink && skillvault-publisher link` to create a new skill\n'));
118
+ process.stderr.write(chalk.cyan(` 3. Use --force to publish under the existing capability_name\n`));
119
+ process.exit(1);
120
+ }
121
+ // --force: keep the original capability_name, but use the new
122
+ // frontmatter name for display going forward.
123
+ capabilityName = existingManifest.capability_name;
124
+ renameBypassed = true;
125
+ process.stderr.write(chalk.yellow(`Warning: --force keeping original capability ${capabilityName}; new frontmatter name (${skillName}) will display to customers next version.\n`));
126
+ }
70
127
  // Pack directory
71
128
  const packed = packSkillDirectory(dirPath);
72
129
  const fileCount = Object.keys(packed.files).length;
@@ -108,7 +165,6 @@ export const publishCommand = new Command('publish')
108
165
  // Compute SHA-256 of vault for integrity
109
166
  const vaultHash = createHash('sha256').update(vaultData).digest('hex');
110
167
  // Register capability with server
111
- const capabilityName = `skill/${skillName.toLowerCase()}`;
112
168
  let capabilityJustRegistered = false;
113
169
  const registerBody = {
114
170
  name: capabilityName,
@@ -220,6 +276,44 @@ export const publishCommand = new Command('publish')
220
276
  process.exit(1);
221
277
  }
222
278
  const publishResult = await publishRes.json().catch(() => ({}));
279
+ // ── Workspace manifest update (after successful publish) ──
280
+ const wasUnlinked = existingManifest === null;
281
+ if (!options.noLink && sessionPublisherId) {
282
+ // Spread existing first so source/github and any other forward-compat
283
+ // fields survive. linked_at is preserved when re-linking, set fresh
284
+ // on first link.
285
+ const linkedAt = existingManifest?.linked_at ?? new Date().toISOString();
286
+ const newManifest = {
287
+ ...(existingManifest ?? {}),
288
+ version: 1,
289
+ publisher_id: sessionPublisherId,
290
+ // When --force bypassed a rename, keep the original skill_name as
291
+ // the canonical handle but capability_name is already preserved.
292
+ skill_name: renameBypassed ? existingManifest.skill_name : skillName,
293
+ capability_name: capabilityName,
294
+ linked_at: linkedAt,
295
+ last_published_version: version,
296
+ last_published_at: new Date().toISOString(),
297
+ last_published_hash: vaultHash,
298
+ };
299
+ try {
300
+ saveWorkspace(dirPath, newManifest);
301
+ registerWorkspace({
302
+ path: dirPath,
303
+ publisher_id: newManifest.publisher_id,
304
+ skill_name: newManifest.skill_name,
305
+ capability_name: newManifest.capability_name,
306
+ linked_at: newManifest.linked_at,
307
+ last_published_at: newManifest.last_published_at,
308
+ });
309
+ }
310
+ catch (manifestErr) {
311
+ // Manifest write failures are non-fatal — the publish already
312
+ // succeeded. Surface the error but exit 0.
313
+ const message = manifestErr instanceof Error ? manifestErr.message : String(manifestErr);
314
+ process.stderr.write(chalk.yellow(`Warning: failed to update workspace manifest: ${message}\n`));
315
+ }
316
+ }
223
317
  if (publishResult.status === 'unchanged') {
224
318
  console.log(chalk.yellow(`Content unchanged for ${skillName} v${version}. No new version created.`));
225
319
  console.log(chalk.dim(` Use --force to publish anyway.`));
@@ -231,6 +325,12 @@ export const publishCommand = new Command('publish')
231
325
  console.log(chalk.dim(` Files: ${fileCount}`));
232
326
  console.log(chalk.dim(` Capability: ${capabilityName}`));
233
327
  }
328
+ // First-publish auto-link tip — printed AFTER the publish result so the
329
+ // user sees the success line first, then the actionable tip.
330
+ if (wasUnlinked && !options.noLink && sessionPublisherId) {
331
+ process.stderr.write(chalk.cyan(`\nLinked this directory to skill "${skillName}" (${sessionPublisherId}).\n`));
332
+ process.stderr.write(chalk.dim(' Tip: commit .skillvault-publisher/workspace.json so teammates and CI can find this skill.\n'));
333
+ }
234
334
  }
235
335
  catch (err) {
236
336
  const message = err instanceof Error ? err.message : String(err);
@@ -1,14 +1,16 @@
1
1
  import { Command } from 'commander';
2
2
  import chalk from 'chalk';
3
- import { requireSession, sessionFetch } from '../session.js';
3
+ import { requireCommandContext, sessionFetch } from '../session.js';
4
4
  export const revokeGrantCommand = new Command('revoke-grant')
5
5
  .description('Revoke a capability grant by ID')
6
6
  .argument('<grant-id>', 'Grant ID to revoke (e.g., gnt_xxx)')
7
7
  .option('--yes', 'Skip confirmation and revoke immediately')
8
8
  .option('--json', 'Output as JSON')
9
+ .option('--workspace <path>', 'Resolve workspace context (informational only — grant ID is authoritative)')
10
+ .option('--quiet', 'Suppress the "(using workspace …)" stderr note')
9
11
  .action(async (grantId, options) => {
10
12
  try {
11
- const ctx = requireSession();
13
+ const ctx = requireCommandContext({ workspaceFlag: options.workspace, quiet: options.quiet });
12
14
  if (!options.yes) {
13
15
  // Fetch grant details so the user can verify before confirming
14
16
  const data = await sessionFetch(ctx, `/grants/${grantId}`);