skillvault-publisher 0.9.3 → 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.
- package/dist/commands/analytics.js +14 -2
- package/dist/commands/audit.js +11 -4
- package/dist/commands/changelog.js +12 -18
- package/dist/commands/customers.js +4 -2
- package/dist/commands/grants.js +15 -2
- package/dist/commands/link.js +112 -0
- package/dist/commands/publish-all.js +104 -15
- package/dist/commands/publish.js +105 -5
- package/dist/commands/revoke-grant.js +4 -2
- package/dist/commands/skill-delete.js +12 -4
- package/dist/commands/skill-status.js +12 -4
- package/dist/commands/skill-unarchive.js +12 -4
- package/dist/commands/unlink.js +67 -0
- package/dist/commands/watchtower.js +4 -2
- package/dist/commands/workspaces.js +108 -0
- package/dist/credentials.js +74 -3
- package/dist/index.js +840 -217
- package/dist/projects-registry.js +112 -0
- package/dist/publisher-workspace.js +165 -0
- package/dist/publisher-workspaces-registry.js +133 -0
- package/dist/scope.js +100 -0
- package/dist/session.js +44 -0
- package/package.json +1 -1
|
@@ -1,12 +1,16 @@
|
|
|
1
1
|
import { Command } from 'commander';
|
|
2
2
|
import chalk from 'chalk';
|
|
3
|
-
import {
|
|
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 =
|
|
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'));
|
package/dist/commands/audit.js
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
import { Command } from 'commander';
|
|
2
2
|
import chalk from 'chalk';
|
|
3
|
-
import {
|
|
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 =
|
|
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 (
|
|
21
|
-
params.set('capability', `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 {
|
|
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('
|
|
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
|
-
.
|
|
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
|
-
|
|
12
|
-
const
|
|
13
|
-
if (!
|
|
14
|
-
process.stderr.write(chalk.red('
|
|
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 =
|
|
18
|
-
|
|
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 {
|
|
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 =
|
|
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;
|
package/dist/commands/grants.js
CHANGED
|
@@ -1,18 +1,31 @@
|
|
|
1
1
|
import { Command } from 'commander';
|
|
2
2
|
import chalk from 'chalk';
|
|
3
|
-
import {
|
|
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 =
|
|
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
|
-
|
|
165
|
-
|
|
166
|
-
|
|
167
|
-
|
|
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
|
-
|
|
173
|
-
|
|
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
|
-
|
|
183
|
-
|
|
184
|
-
|
|
185
|
-
|
|
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
|
|
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 (
|
|
233
|
-
parts.push(chalk.yellow(`${
|
|
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(', ')}`);
|
package/dist/commands/publish.js
CHANGED
|
@@ -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
|
|
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
|
|
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
|
-
|
|
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 {
|
|
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 =
|
|
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}`);
|