@quadient/skillshare 1.7.0 → 1.8.1

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/README.md CHANGED
@@ -41,6 +41,10 @@ skillshare teams access readOnly
41
41
  skillshare teams logout
42
42
  ```
43
43
 
44
+ Starting with 1.8.1, Teams uses the Microsoft account object id that Skillshare records at
45
+ web sign-in, not the Skillshare user id. If the server does not know it yet, `teams login`
46
+ opens one more Skillshare sign-in in the browser to link the account.
47
+
44
48
  Use `teams login local` for localhost or `--server <https-origin>` for another host.
45
49
  `login` accepts `--server` too. Browser options: `--browser chrome|msedge|chromium`,
46
50
  `--headless`, and `--browser-profile <dedicated-profile-directory>`.
@@ -50,6 +54,14 @@ permissions, Microsoft session lifetime and file-tool differences in the hosted
50
54
 
51
55
  ## Behavior
52
56
 
57
+ Starting with 1.8.0:
58
+
59
+ - `install` and `update` download into a staging directory first, so a failed transfer leaves the existing install untouched. Files that a new version no longer contains are removed.
60
+ - `installed.json` records a SHA-256 manifest of installed files. `update` skips installs with local changes and `install` refuses to overwrite them, or a directory that Skillshare did not install, unless you confirm interactively or pass `--force`. Installs recorded by older versions are not checked until they are updated once.
61
+ - Interactive mode shows the signed-in user and server, checks installed skills in parallel, and reports removed skills or failed checks as warnings instead of stopping.
62
+ - `login` prints the sign-in URL in case no browser opens, and stops waiting after 5 minutes.
63
+ - Unknown options are rejected with a suggestion. `skillshare <command> --help`, `skillshare help <command>` and `skillshare --version` are available.
64
+
53
65
  - Starting with 1.6.2, `login`, `git configure` and API commands without a stored session default to `https://skillshare.quadientcloudpreprod.eu`. `login local` and `git configure local` still select `http://localhost:5050`.
54
66
  - Existing sessions keep their saved server. After preprod cutover, run `skillshare login` again to replace a UAT session with a preprod session; stored tokens are not reused across hosts.
55
67
  - Uploaded/installed records retain their registry URL. Legacy records without a URL still belong to UAT and are not silently reassigned to preprod.
@@ -0,0 +1,86 @@
1
+ import { BOOLEAN_FLAGS, COMMANDS, GLOBAL_FLAGS, findCommand } from './help.js';
2
+ /**
3
+ * Parses options for a command. With a command name, unknown options are rejected
4
+ * before a value is read, so a typo gets a suggestion instead of a missing-value error.
5
+ */
6
+ export function parseCliArgs(args, commandName) {
7
+ const flags = {};
8
+ const positionals = [];
9
+ for (let index = 0; index < args.length; index += 1) {
10
+ const current = args[index];
11
+ if (!current)
12
+ continue;
13
+ if (current === '-h') {
14
+ flags.help = 'true';
15
+ continue;
16
+ }
17
+ if (!current.startsWith('--') || current === '--') {
18
+ positionals.push(current);
19
+ continue;
20
+ }
21
+ const separator = current.indexOf('=');
22
+ const key = separator > 0 ? current.slice(2, separator) : current.slice(2);
23
+ if (commandName) {
24
+ assertKnownFlag(commandName, key);
25
+ }
26
+ if (separator > 0) {
27
+ flags[key] = current.slice(separator + 1);
28
+ continue;
29
+ }
30
+ const value = args[index + 1];
31
+ if (BOOLEAN_FLAGS.has(key)) {
32
+ // Booleans never consume a following positional, only an explicit true/false.
33
+ if (value === 'true' || value === 'false') {
34
+ flags[key] = value;
35
+ index += 1;
36
+ }
37
+ else {
38
+ flags[key] = 'true';
39
+ }
40
+ continue;
41
+ }
42
+ if (value === undefined || value.startsWith('--')) {
43
+ throw new Error(`Missing value for ${current}.`);
44
+ }
45
+ flags[key] = value;
46
+ index += 1;
47
+ }
48
+ return { flags, positionals };
49
+ }
50
+ function assertKnownFlag(commandName, name) {
51
+ const command = findCommand(commandName);
52
+ if (!command)
53
+ return;
54
+ const allowed = [...GLOBAL_FLAGS, ...command.flags];
55
+ if (allowed.includes(name))
56
+ return;
57
+ const suggestion = suggest(name, allowed);
58
+ throw new Error(`Unknown option --${name} for "${command.name}".${suggestion ? ` Did you mean --${suggestion}?` : ''} Run "skillshare ${command.name} --help" for options.`);
59
+ }
60
+ export function formatUnknownCommand(name) {
61
+ const suggestion = suggest(name, COMMANDS.map((command) => command.name));
62
+ return `Unknown command '${name}'.${suggestion ? ` Did you mean '${suggestion}'?` : ''} Run "skillshare --help" for the list of commands.`;
63
+ }
64
+ export function suggest(input, candidates) {
65
+ let best = null;
66
+ for (const candidate of candidates) {
67
+ const distance = candidate.startsWith(input) || input.startsWith(candidate) ? 1 : levenshtein(input, candidate);
68
+ if (distance <= 2 && (!best || distance < best.distance)) {
69
+ best = { candidate, distance };
70
+ }
71
+ }
72
+ return best?.candidate ?? null;
73
+ }
74
+ function levenshtein(left, right) {
75
+ const previous = Array.from({ length: right.length + 1 }, (_, index) => index);
76
+ for (let i = 1; i <= left.length; i += 1) {
77
+ let diagonal = previous[0];
78
+ previous[0] = i;
79
+ for (let j = 1; j <= right.length; j += 1) {
80
+ const above = previous[j];
81
+ previous[j] = Math.min(above + 1, previous[j - 1] + 1, diagonal + (left[i - 1] === right[j - 1] ? 0 : 1));
82
+ diagonal = above;
83
+ }
84
+ }
85
+ return previous[right.length];
86
+ }
package/dist/help.js ADDED
@@ -0,0 +1,167 @@
1
+ import { readFileSync } from 'node:fs';
2
+ export const GLOBAL_FLAGS = ['token', 'server', 'help'];
3
+ export const BOOLEAN_FLAGS = new Set(['dry-run', 'force', 'headless', 'help']);
4
+ export const COMMANDS = [
5
+ {
6
+ name: 'login',
7
+ summary: 'Sign in through the browser and store a CLI session',
8
+ usage: ['login [local] [--server <url>]'],
9
+ description: 'Opens the Skillshare sign-in page. If no browser opens, copy the printed URL into one.',
10
+ examples: ['skillshare login', 'skillshare login local'],
11
+ flags: [],
12
+ },
13
+ {
14
+ name: 'logout',
15
+ summary: 'Remove the stored CLI session',
16
+ usage: ['logout'],
17
+ flags: [],
18
+ },
19
+ {
20
+ name: 'whoami',
21
+ summary: 'Show the signed-in user',
22
+ usage: ['whoami'],
23
+ flags: [],
24
+ },
25
+ {
26
+ name: 'list',
27
+ summary: 'List skills in the registry',
28
+ usage: ['list'],
29
+ flags: [],
30
+ },
31
+ {
32
+ name: 'show',
33
+ summary: 'Show a skill or one of its versions',
34
+ usage: ['show <skillId> [version]'],
35
+ flags: [],
36
+ },
37
+ {
38
+ name: 'install',
39
+ summary: 'Install a skill for your coding agents',
40
+ usage: [
41
+ 'install [skillId|skillName] [version]',
42
+ 'install <skillId|skillName> [version] --target <directory> [--force]',
43
+ 'install --skill-version-id <id> --target <directory> [--force]',
44
+ ],
45
+ description: 'Without --target the CLI asks for the version, scope and agents. Files that you changed locally are not overwritten unless you confirm or pass --force.',
46
+ options: [
47
+ ['--target <directory>', 'Install into this directory instead of agent skill folders'],
48
+ ['--skill-version-id <id>', 'Install an exact published skill version'],
49
+ ['--force', 'Overwrite local changes in the target directory'],
50
+ ],
51
+ examples: ['skillshare install', 'skillshare install "Prompt Lint"', 'skillshare install 12 0.1.0 --target ./installed'],
52
+ flags: ['target', 'skill-version-id', 'force'],
53
+ },
54
+ {
55
+ name: 'update',
56
+ summary: 'Update installed skills to their latest versions',
57
+ usage: ['update [skillId|skillName] [--target <directory>] [--dry-run] [--force]'],
58
+ description: 'Removes files that the new version no longer contains. Installs with local changes are skipped unless you pass --force.',
59
+ options: [
60
+ ['--target <directory>', 'Only update the install in this directory'],
61
+ ['--dry-run', 'Print planned changes without writing files'],
62
+ ['--force', 'Overwrite local changes'],
63
+ ],
64
+ examples: ['skillshare update', 'skillshare update "Prompt Lint" --dry-run'],
65
+ flags: ['target', 'dry-run', 'force'],
66
+ },
67
+ {
68
+ name: 'upload',
69
+ summary: 'Create a skill or publish a new version from a directory',
70
+ usage: ['upload <directory> --version <version> [--skill-id <id>] [--name <name>] [--description <text>]'],
71
+ options: [
72
+ ['--version <version>', 'Version to publish'],
73
+ ['--skill-id <id>', 'Publish to this existing skill'],
74
+ ['--name <name>', 'Name for a new skill (defaults to SKILL.md)'],
75
+ ['--description <text>', 'Description for a new skill (defaults to SKILL.md)'],
76
+ ],
77
+ examples: ['skillshare upload ./prompt-lint --version 0.2.0'],
78
+ flags: ['version', 'skill-id', 'name', 'description'],
79
+ },
80
+ {
81
+ name: 'pages',
82
+ summary: 'Publish and inspect authenticated pages',
83
+ usage: [
84
+ 'pages list',
85
+ 'pages show <slug>',
86
+ 'pages runtime <slug>',
87
+ 'pages upload <directory> [--slug <slug>]',
88
+ 'pages dev [directory] [--slug <slug>] [--port <port>] [--build <command> --watch-dir <sources>]',
89
+ ' [--celld <executable>] [--user-id <id>] [--user-name <name>] [--user-email <email>]',
90
+ ],
91
+ examples: ['skillshare pages upload ./site --slug release-notes', 'skillshare pages dev . --slug my-app'],
92
+ flags: ['slug'],
93
+ },
94
+ {
95
+ name: 'teams',
96
+ summary: 'Connect Microsoft Teams to the Skillshare Teams MCP',
97
+ usage: [
98
+ 'teams login [local] [--browser chrome|msedge|chromium] [--headless] [--browser-profile <directory>]',
99
+ 'teams watch [local] [--browser chrome|msedge|chromium]',
100
+ 'teams status | test | logout',
101
+ 'teams access readOnly|fullAccess',
102
+ 'teams upload <file>',
103
+ 'teams download <url> --output <new-file>',
104
+ ],
105
+ flags: ['browser', 'headless', 'browser-profile', 'output'],
106
+ },
107
+ {
108
+ name: 'git',
109
+ summary: 'Register Skillshare in Git Credential Manager',
110
+ usage: ['git configure [local]', 'git unconfigure [local]'],
111
+ flags: [],
112
+ },
113
+ ];
114
+ const COMMAND_ALIASES = { page: 'pages' };
115
+ export function findCommand(name) {
116
+ if (!name)
117
+ return undefined;
118
+ const resolved = COMMAND_ALIASES[name] ?? name;
119
+ return COMMANDS.find((command) => command.name === resolved);
120
+ }
121
+ export function renderUsage(defaultUrl) {
122
+ const width = Math.max(...COMMANDS.map((command) => command.name.length));
123
+ return [
124
+ 'Usage: skillshare [command] [options]',
125
+ '',
126
+ 'Run without a command for the interactive mode.',
127
+ '',
128
+ 'Commands:',
129
+ ...COMMANDS.map((command) => ` ${command.name.padEnd(width)} ${command.summary}`),
130
+ '',
131
+ 'Options:',
132
+ ' --token <token> Bearer token for API requests (overrides SKILLSHARE_TOKEN)',
133
+ ' --server <url> Skillshare origin for login and Teams commands',
134
+ ' -h, --help Show help for a command',
135
+ ' -v, --version Show the CLI version',
136
+ '',
137
+ `Default server: ${defaultUrl}`,
138
+ 'A stored session keeps its server; run "skillshare login" to switch to the default.',
139
+ 'Run "skillshare <command> --help" for details.',
140
+ ].join('\n');
141
+ }
142
+ export function renderCommandHelp(command) {
143
+ const lines = [`${command.summary}.`, '', 'Usage:', ...command.usage.map((usage) => ` skillshare ${usage}`)];
144
+ if (command.description)
145
+ lines.push('', command.description);
146
+ if (command.options?.length) {
147
+ const width = Math.max(...command.options.map(([flag]) => flag.length));
148
+ lines.push('', 'Options:', ...command.options.map(([flag, text]) => ` ${flag.padEnd(width)} ${text}`));
149
+ }
150
+ if (command.examples?.length)
151
+ lines.push('', 'Examples:', ...command.examples.map((example) => ` ${example}`));
152
+ return lines.join('\n');
153
+ }
154
+ export function readCliVersion() {
155
+ // dist/index.js and the source index.ts sit at different depths below package.json.
156
+ for (const candidate of ['../package.json', './package.json']) {
157
+ try {
158
+ const parsed = JSON.parse(readFileSync(new URL(candidate, import.meta.url), 'utf8'));
159
+ if (parsed.name === '@quadient/skillshare' && parsed.version)
160
+ return parsed.version;
161
+ }
162
+ catch {
163
+ // Try the next location.
164
+ }
165
+ }
166
+ return 'unknown';
167
+ }
package/dist/index.js CHANGED
@@ -1,4 +1,7 @@
1
- import { assertCredentialDestination, runTeamsCommand } from './teams.js';
1
+ import { assertCredentialDestination, resolveTeamsObjectId, runTeamsCommand } from './teams.js';
2
+ import { formatUnknownCommand, parseCliArgs } from './cli-args.js';
3
+ import { findCommand, readCliVersion, renderCommandHelp, renderUsage } from './help.js';
4
+ import { inspectInstallTarget, installFilesAtomically } from './install-files.js';
2
5
  import { preparePageFiles } from './pages.js';
3
6
  import { runPageDev } from './pages-dev.js';
4
7
  import { createServer } from 'node:http';
@@ -6,9 +9,9 @@ import { createHash, randomBytes } from 'node:crypto';
6
9
  import { spawn } from 'node:child_process';
7
10
  import { existsSync } from 'node:fs';
8
11
  import { mkdir, open, readFile, readdir, rm, stat, writeFile } from 'node:fs/promises';
9
- import { basename, dirname, join, relative, resolve } from 'node:path';
12
+ import { basename, join, relative, resolve } from 'node:path';
10
13
  import { homedir } from 'node:os';
11
- import { cancel, confirm, intro, isCancel, multiselect, outro, progress, select, spinner, text } from '@clack/prompts';
14
+ import { cancel, confirm, intro, isCancel, log, multiselect, outro, progress, select, spinner, text } from '@clack/prompts';
12
15
  const DEFAULT_URL = 'https://skillshare.quadientcloudpreprod.eu';
13
16
  // Records without a registry URL were created against UAT before the preprod migration.
14
17
  const LEGACY_UAT_URL = 'https://skillshare.inspireclouduat.net';
@@ -68,45 +71,7 @@ const AGENTS = [
68
71
  { value: 'pochi', label: 'Pochi', projectPath: '.pochi/skills', globalPath: '.pochi/skills' },
69
72
  { value: 'adal', label: 'AdaL', projectPath: '.adal/skills', globalPath: '.adal/skills' },
70
73
  ];
71
- const usage = `
72
- skillshare <command> [options]
73
-
74
- Commands:
75
- login [local]
76
- logout
77
- whoami
78
- list
79
- show <skillId> [version]
80
- upload <directory> --version <version> [--skill-id <id>] [--name <name> --description <desc>]
81
- install
82
- install <skillId|skillName> [version]
83
- install <skillId|skillName> [version] --target <directory>
84
- install --skill-version-id <id> --target <directory>
85
- update [skillId|skillName] [--target <directory>] [--dry-run]
86
- pages list
87
- pages show <slug>
88
- pages upload <directory> [--slug <slug>]
89
- pages dev [directory] [--slug <slug>] [--port <port>]
90
- [--build <command> --watch-dir <sources>] [--celld <executable>]
91
- [--user-id <id>] [--user-name <name>] [--user-email <email>]
92
- teams login [local] [--browser chrome|msedge|chromium] [--headless]
93
- teams watch [local] [--browser chrome|msedge|chromium]
94
- teams logout
95
- teams status
96
- teams test
97
- teams access readOnly|fullAccess
98
- teams upload <file>
99
- teams download <url> --output <new-file>
100
- git configure [local]
101
- git unconfigure [local]
102
-
103
- Options:
104
- --token <token> Bearer token for API requests (overrides SKILLSHARE_TOKEN)
105
- --server <url> Skillshare origin for login and Teams commands
106
-
107
- Default server: ${DEFAULT_URL}
108
- A stored session keeps its server; run "skillshare login" to switch to the default.
109
- `;
74
+ const LOGIN_TIMEOUT_MS = 5 * 60_000;
110
75
  async function main() {
111
76
  const args = process.argv.slice(2);
112
77
  const command = args[0];
@@ -114,11 +79,29 @@ async function main() {
114
79
  await runInteractive();
115
80
  return;
116
81
  }
117
- if (command === '--help' || command === '-h') {
118
- console.log(usage.trim());
119
- process.exit(0);
82
+ if (command === '--version' || command === '-v') {
83
+ console.log(readCliVersion());
84
+ return;
85
+ }
86
+ if (command === '--help' || command === '-h' || command === 'help') {
87
+ const topic = command === 'help' ? findCommand(args[1]) : undefined;
88
+ if (command === 'help' && args[1] && !topic) {
89
+ throw new Error(formatUnknownCommand(args[1]));
90
+ }
91
+ console.log(topic ? renderCommandHelp(topic) : renderUsage(DEFAULT_URL));
92
+ return;
93
+ }
94
+ const commandSpec = findCommand(command);
95
+ if (!commandSpec) {
96
+ throw new Error(formatUnknownCommand(command));
97
+ }
98
+ // pages dev validates its own options.
99
+ const isPageDev = (command === 'pages' || command === 'page') && args[1] === 'dev';
100
+ const options = await parseOptions(args.slice(1), isPageDev ? undefined : command);
101
+ if (isFlagEnabled(options.flags, 'help')) {
102
+ console.log(renderCommandHelp(commandSpec));
103
+ return;
120
104
  }
121
- const options = await parseOptions(args.slice(1));
122
105
  if ((command === 'pages' || command === 'page') && options.positionals[0] === 'dev') {
123
106
  await runPageDev(options.positionals[1] ?? '.', options.flags);
124
107
  return;
@@ -139,7 +122,15 @@ async function main() {
139
122
  assertCredentialDestination(baseUrl);
140
123
  if (!options.token && !await loadSession(baseUrl))
141
124
  await login(baseUrl);
142
- await runTeamsCommand(await createClient(baseUrl, options.token), options.positionals, options.flags, APP_DIR);
125
+ const teamsClient = await createClient(baseUrl, options.token);
126
+ const action = options.positionals[0];
127
+ if (!options.token && (action === 'login' || action === 'watch')
128
+ && !resolveTeamsObjectId(await teamsClient.get('/api/auth/me'))) {
129
+ // A browser sign-in lets the server record the Entra object id for this account.
130
+ console.log('Linking your Microsoft account to Skillshare. Sign in once more in the browser.');
131
+ await login(baseUrl);
132
+ }
133
+ await runTeamsCommand(teamsClient, options.positionals, options.flags, APP_DIR);
143
134
  return;
144
135
  }
145
136
  case 'git': {
@@ -223,6 +214,7 @@ async function main() {
223
214
  const skillRef = options.positionals[0];
224
215
  const version = options.positionals[1];
225
216
  const target = options.flags.target;
217
+ const force = isFlagEnabled(options.flags, 'force');
226
218
  if (skillVersionId) {
227
219
  if (!target) {
228
220
  throw new Error('install with --skill-version-id requires --target <directory>');
@@ -233,6 +225,7 @@ async function main() {
233
225
  }
234
226
  const detail = await client.get(`/api/skill-versions/${parsedSkillVersionId}`);
235
227
  const skill = await client.get(`/api/skills/${detail.skillId}`);
228
+ await ensureTargetsWritable(client.baseUrl, [target], { force });
236
229
  await client.post(`/api/skills/${detail.skillId}/versions/${detail.version}/downloads`);
237
230
  const installProgress = createInstallProgressReporter({
238
231
  skillName: skill.name,
@@ -262,6 +255,7 @@ async function main() {
262
255
  await runInteractiveInstall(client, {
263
256
  skillRef,
264
257
  version,
258
+ force,
265
259
  });
266
260
  return;
267
261
  }
@@ -275,6 +269,7 @@ async function main() {
275
269
  throw new Error(`Skill '${skill.name}' does not have a published version.`);
276
270
  }
277
271
  const detail = await client.get(`/api/skills/${skill.id}/versions/${effectiveVersion}`);
272
+ await ensureTargetsWritable(client.baseUrl, [target], { force });
278
273
  await client.post(`/api/skills/${skill.id}/versions/${effectiveVersion}/downloads`);
279
274
  const installProgress = createInstallProgressReporter({
280
275
  skillName: skill.name,
@@ -305,6 +300,7 @@ async function main() {
305
300
  skillRef: options.positionals[0],
306
301
  target: options.flags.target,
307
302
  dryRun: isFlagEnabled(options.flags, 'dry-run'),
303
+ force: isFlagEnabled(options.flags, 'force'),
308
304
  });
309
305
  return;
310
306
  }
@@ -351,23 +347,22 @@ async function main() {
351
347
  return;
352
348
  }
353
349
  default:
354
- throw new Error(`Unknown command '${command}'`);
350
+ throw new Error(formatUnknownCommand(command));
355
351
  }
356
352
  }
357
353
  async function runInteractive() {
358
- intro('Skillshare');
354
+ intro(`Skillshare ${readCliVersion()}`);
359
355
  const baseUrl = await resolveActiveBaseUrl();
360
- await ensureInteractiveLogin(baseUrl);
356
+ const user = await ensureInteractiveLogin(baseUrl);
361
357
  const client = await createClient(baseUrl);
362
- const pendingInstalledUpdates = await collectInstalledSkillUpdateCandidates(client);
358
+ log.info(`Signed in as ${user.displayName}${user.email ? ` <${user.email}>` : ''} · ${new URL(client.baseUrl).host}`);
359
+ const pendingInstalledUpdates = await checkInstalledSkillsForUpdates(client);
363
360
  if (pendingInstalledUpdates.length > 0) {
364
361
  const shouldUpdateInstalled = await promptConfirm(pendingInstalledUpdates.length === 1
365
362
  ? `There is 1 installed skill with a newer version available. Update it now?`
366
363
  : `There are ${pendingInstalledUpdates.length} installed skills with newer versions available. Update all of them now?`, true);
367
364
  if (shouldUpdateInstalled) {
368
365
  await updateInstalledSkills(client, { candidates: pendingInstalledUpdates });
369
- outro('Done.');
370
- return;
371
366
  }
372
367
  }
373
368
  if (existsSync(join(process.cwd(), SKILL_FILE_NAME))) {
@@ -387,12 +382,17 @@ async function runInteractive() {
387
382
  { value: 'install', label: 'Install a skill', hint: 'Browse registry and install to agents' },
388
383
  { value: 'update', label: 'Update installed skills', hint: 'Refresh recorded installs to the latest registry versions' },
389
384
  { value: 'upload', label: 'Upload a skill', hint: 'Create a new skill or publish a version' },
385
+ { value: 'exit', label: 'Exit' },
390
386
  ],
391
387
  });
392
388
  if (isCancel(action)) {
393
389
  cancel('Cancelled.');
394
390
  process.exit(0);
395
391
  }
392
+ if (action === 'exit') {
393
+ outro('Bye.');
394
+ return;
395
+ }
396
396
  if (action === 'install') {
397
397
  await runInteractiveInstall(client);
398
398
  }
@@ -404,22 +404,53 @@ async function runInteractive() {
404
404
  }
405
405
  outro('Done.');
406
406
  }
407
+ async function checkInstalledSkillsForUpdates(client) {
408
+ const records = (await readInstalledSkillRecords()).filter((record) => record.baseUrl === client.baseUrl);
409
+ if (records.length === 0) {
410
+ return [];
411
+ }
412
+ const status = spinner();
413
+ status.start(`Checking ${records.length} installed skill${records.length === 1 ? '' : 's'} for updates`);
414
+ const problems = [];
415
+ try {
416
+ const candidates = await collectInstalledSkillUpdateCandidates(client, {
417
+ records,
418
+ onSkipped(skip) {
419
+ if (skip.reason === 'removed' || skip.reason === 'error') {
420
+ problems.push(skip.message);
421
+ }
422
+ },
423
+ });
424
+ status.stop(candidates.length > 0
425
+ ? `${candidates.length} installed skill${candidates.length === 1 ? ' has' : 's have'} a newer version`
426
+ : 'Installed skills are up to date');
427
+ if (problems.length > 0) {
428
+ log.warn(problems.join('\n'));
429
+ }
430
+ return candidates;
431
+ }
432
+ catch (error) {
433
+ // The update check is optional; never let it block the main menu.
434
+ status.stop('Could not check installed skills for updates');
435
+ log.warn(error instanceof Error ? error.message : String(error));
436
+ return [];
437
+ }
438
+ }
407
439
  async function ensureInteractiveLogin(baseUrl) {
408
440
  const session = await loadSession(baseUrl);
409
- if (!session) {
410
- await login(baseUrl);
411
- return;
412
- }
413
- if (new Date(session.refreshTokenExpiresAt).getTime() <= Date.now()) {
414
- await login(baseUrl);
415
- return;
441
+ if (!session || new Date(session.refreshTokenExpiresAt).getTime() <= Date.now()) {
442
+ return login(baseUrl);
416
443
  }
417
444
  try {
418
445
  const client = await createClient(baseUrl);
419
- await client.get('/api/auth/me');
446
+ return await client.get('/api/auth/me');
420
447
  }
421
- catch {
422
- await login(baseUrl);
448
+ catch (error) {
449
+ // Offline is not an expired session; signing in again would not help.
450
+ if (error instanceof NetworkError) {
451
+ throw error;
452
+ }
453
+ return login(baseUrl);
423
454
  }
424
455
  }
425
456
  async function runInteractiveInstall(client, options) {
@@ -440,9 +471,13 @@ async function runInteractiveInstall(client, options) {
440
471
  throw new Error('Select at least one agent.');
441
472
  }
442
473
  const versionDetail = await client.get(`/api/skills/${skill.id}/versions/${selectedVersion}`);
443
- await client.post(`/api/skills/${skill.id}/versions/${selectedVersion}/downloads`);
444
474
  const installName = createInstalledSkillDirectoryName(skill);
445
475
  const installTargets = resolveInstallTargets(selectedAgents, scope, installName);
476
+ await ensureTargetsWritable(client.baseUrl, installTargets.map((target) => target.targetRoot), {
477
+ force: options?.force,
478
+ interactive: true,
479
+ });
480
+ await client.post(`/api/skills/${skill.id}/versions/${selectedVersion}/downloads`);
446
481
  const installProgress = createInstallProgressReporter({
447
482
  skillName: skill.name,
448
483
  version: selectedVersion,
@@ -466,7 +501,11 @@ async function runInteractiveInstall(client, options) {
466
501
  installProgress.clear();
467
502
  throw error;
468
503
  }
469
- console.log(`Installed ${skill.name}@${selectedVersion} to ${installTargets.length} location(s) for ${selectedAgents.length} agent(s) in ${scope} scope.`);
504
+ log.success([
505
+ `Installed ${skill.name}@${selectedVersion} for ${selectedAgents.length} agent${selectedAgents.length === 1 ? '' : 's'}:`,
506
+ ...installTargets.map((target) => ` ${formatInstallTargetRoot(target.targetRoot)}`),
507
+ 'Restart running agents to pick up the skill.',
508
+ ].join('\n'));
470
509
  }
471
510
  async function runInteractiveUpload(client, initialDirectory) {
472
511
  const directory = initialDirectory
@@ -524,6 +563,7 @@ async function updateInstalledSkills(client, options) {
524
563
  const targetFilter = normalizeText(options?.target);
525
564
  const normalizedTargetFilter = targetFilter ? resolve(targetFilter) : null;
526
565
  const dryRun = options?.dryRun ?? false;
566
+ const force = options?.force ?? false;
527
567
  let installedRecords = (await readInstalledSkillRecords())
528
568
  .filter((record) => record.baseUrl === client.baseUrl);
529
569
  if (skillRef) {
@@ -547,11 +587,11 @@ async function updateInstalledSkills(client, options) {
547
587
  const candidates = options?.candidates
548
588
  ?? await collectInstalledSkillUpdateCandidates(client, {
549
589
  records: installedRecords,
550
- onSkipped(message) {
551
- console.log(message);
590
+ onSkipped(skip) {
591
+ console.log(skip.message);
552
592
  },
553
593
  });
554
- const skippedCount = installedRecords.length - candidates.length;
594
+ let skippedCount = installedRecords.length - candidates.length;
555
595
  if (candidates.length === 0) {
556
596
  if (dryRun) {
557
597
  console.log(`Dry run finished. Would update 0, skipped ${skippedCount}.`);
@@ -565,9 +605,16 @@ async function updateInstalledSkills(client, options) {
565
605
  let plannedCount = 0;
566
606
  for (const candidate of candidates) {
567
607
  const { record, detail, latestVersion } = candidate;
608
+ const targetState = force ? null : await inspectInstallTarget(record.targetRoot, record);
609
+ if (targetState?.state === 'modified') {
610
+ skippedCount += 1;
611
+ console.log(`Skipping ${detail.name} in ${formatInstallTargetRoot(record.targetRoot)}: local changes in ${formatChangedPaths(targetState.paths)}. `
612
+ + `Run "skillshare update ${record.skillId} --target ${formatCliPath(record.targetRoot)} --force" to overwrite them.`);
613
+ continue;
614
+ }
568
615
  if (dryRun) {
569
616
  plannedCount += 1;
570
- console.log(`Would update ${detail.name} from ${record.skillVersion} to ${latestVersion} in ${record.targetRoot}`);
617
+ console.log(`Would update ${detail.name} from ${record.skillVersion} to ${latestVersion} in ${formatInstallTargetRoot(record.targetRoot)}`);
571
618
  continue;
572
619
  }
573
620
  const versionDetail = await client.get(`/api/skills/${record.skillId}/versions/${latestVersion}`);
@@ -594,7 +641,7 @@ async function updateInstalledSkills(client, options) {
594
641
  throw error;
595
642
  }
596
643
  updatedCount += 1;
597
- console.log(`Updated ${detail.name} from ${record.skillVersion} to ${latestVersion} in ${record.targetRoot}`);
644
+ console.log(`Updated ${detail.name} from ${record.skillVersion} to ${latestVersion} in ${formatInstallTargetRoot(record.targetRoot)}`);
598
645
  }
599
646
  if (dryRun) {
600
647
  console.log(`Dry run finished. Would update ${plannedCount}, skipped ${skippedCount}.`);
@@ -605,30 +652,59 @@ async function updateInstalledSkills(client, options) {
605
652
  async function collectInstalledSkillUpdateCandidates(client, options) {
606
653
  const records = options?.records
607
654
  ?? (await readInstalledSkillRecords()).filter((record) => record.baseUrl === client.baseUrl);
608
- const candidates = [];
609
- for (const record of records) {
610
- if (record.skillId == null) {
611
- options?.onSkipped?.(`Skipping ${record.skillName}: installed record in ${record.targetRoot} does not contain a skill id.`);
612
- continue;
655
+ const details = new Map();
656
+ // Each record is checked independently, so one removed skill or failed request does not hide the others.
657
+ const results = await mapWithConcurrency(records, 6, async (record) => {
658
+ const skillId = record.skillId;
659
+ if (skillId == null) {
660
+ return { record, reason: 'no-id', message: `Skipping ${record.skillName}: installed record in ${record.targetRoot} does not contain a skill id.` };
661
+ }
662
+ let detail;
663
+ try {
664
+ if (!details.has(skillId)) {
665
+ details.set(skillId, client.get(`/api/skills/${skillId}`));
666
+ }
667
+ detail = await details.get(skillId);
668
+ }
669
+ catch (error) {
670
+ if (error instanceof HttpError && error.status === 404) {
671
+ return { record, reason: 'removed', message: `${record.skillName || `Skill ${skillId}`} in ${formatInstallTargetRoot(record.targetRoot)} no longer exists in the registry.` };
672
+ }
673
+ return { record, reason: 'error', message: `Could not check ${record.skillName || `skill ${skillId}`}: ${error instanceof Error ? error.message : String(error)}` };
613
674
  }
614
- const detail = await client.get(`/api/skills/${record.skillId}`);
615
675
  const latestVersion = normalizeText(detail.latestVersion);
616
676
  if (!latestVersion) {
617
- options?.onSkipped?.(`Skipping ${record.skillName}: no latest version is published.`);
618
- continue;
677
+ return { record, reason: 'no-version', message: `Skipping ${record.skillName}: no latest version is published.` };
619
678
  }
620
679
  if (latestVersion === normalizeText(record.skillVersion)) {
621
- options?.onSkipped?.(`Skipping ${record.skillName}: ${latestVersion} is already installed in ${record.targetRoot}.`);
622
- continue;
680
+ return { record, reason: 'current', message: `Skipping ${record.skillName}: ${latestVersion} is already installed in ${formatInstallTargetRoot(record.targetRoot)}.` };
681
+ }
682
+ return { record, detail, latestVersion };
683
+ });
684
+ const candidates = [];
685
+ for (const result of results) {
686
+ if ('reason' in result) {
687
+ options?.onSkipped?.(result);
688
+ }
689
+ else {
690
+ candidates.push(result);
623
691
  }
624
- candidates.push({
625
- record,
626
- detail,
627
- latestVersion,
628
- });
629
692
  }
630
693
  return candidates;
631
694
  }
695
+ async function mapWithConcurrency(items, limit, worker) {
696
+ const results = new Array(items.length);
697
+ let nextIndex = 0;
698
+ async function run() {
699
+ while (nextIndex < items.length) {
700
+ const index = nextIndex;
701
+ nextIndex += 1;
702
+ results[index] = await worker(items[index]);
703
+ }
704
+ }
705
+ await Promise.all(Array.from({ length: Math.min(limit, items.length) }, run));
706
+ return results;
707
+ }
632
708
  function formatUpdateSelectionDescription(options) {
633
709
  const parts = [];
634
710
  if (options.skillRef) {
@@ -749,7 +825,7 @@ async function promptForAgents(scope) {
749
825
  })),
750
826
  ],
751
827
  initialValues,
752
- required: false,
828
+ required: true,
753
829
  });
754
830
  if (isCancel(selected)) {
755
831
  cancel('Cancelled.');
@@ -885,31 +961,81 @@ async function publishSkillFromDirectory(client, localSkill, input) {
885
961
  }
886
962
  async function writeSkillVersionToDirectory(client, input, installProgress) {
887
963
  installProgress?.onTargetStart(input.targetRoot);
888
- for (const file of input.files) {
889
- installProgress?.onFileStart(input.targetRoot, file.nameWithPath, file.length);
890
- const response = await client.rawGet(`/api/skills/${input.skillId}/versions/${input.version}/files/${file.nameWithPath}`);
891
- const destination = join(input.targetRoot, file.nameWithPath);
892
- await mkdir(dirname(destination), { recursive: true });
893
- await downloadResponseToFile(response, destination, (bytes) => {
894
- installProgress?.onBytesTransferred(bytes);
895
- });
896
- installProgress?.onFileComplete();
897
- }
964
+ const previous = await findInstalledSkillRecord(client.baseUrl, input.targetRoot);
965
+ const expectedSizes = new Map(input.files.map((file) => [file.nameWithPath, file.length]));
966
+ const manifest = await installFilesAtomically({
967
+ targetRoot: input.targetRoot,
968
+ files: input.files.map((file) => ({ relativePath: file.nameWithPath })),
969
+ previousManifest: previous?.files,
970
+ async download(relativePath, destination) {
971
+ installProgress?.onFileStart(input.targetRoot, relativePath, expectedSizes.get(relativePath) ?? 0);
972
+ const response = await client.rawGet(`/api/skills/${input.skillId}/versions/${input.version}/files/${relativePath}`);
973
+ const hash = await downloadResponseToFile(response, destination, (bytes) => {
974
+ installProgress?.onBytesTransferred(bytes);
975
+ });
976
+ installProgress?.onFileComplete();
977
+ return hash;
978
+ },
979
+ });
898
980
  await upsertInstalledSkillRecord(client.baseUrl, input.targetRoot, {
899
981
  skillId: input.skillId,
900
982
  skillName: input.skillName,
901
983
  skillDescription: input.skillDescription,
902
984
  skillVersion: input.version,
903
- });
985
+ }, manifest);
904
986
  installProgress?.onTargetComplete();
905
987
  }
988
+ async function findInstalledSkillRecord(baseUrl, targetRoot) {
989
+ const resolvedTargetRoot = resolve(targetRoot);
990
+ const records = (await readInstalledSkillRecords()).filter((record) => record.targetRoot === resolvedTargetRoot);
991
+ return records.find((record) => record.baseUrl === normalizeBaseUrl(baseUrl)) ?? records[0] ?? null;
992
+ }
993
+ async function ensureTargetsWritable(baseUrl, targetRoots, options) {
994
+ if (options.force) {
995
+ return;
996
+ }
997
+ const conflicts = [];
998
+ for (const targetRoot of targetRoots) {
999
+ // Directories installed before installed.json existed still carry the legacy metadata file.
1000
+ const record = await findInstalledSkillRecord(baseUrl, targetRoot)
1001
+ ?? (existsSync(join(targetRoot, SKILL_METADATA_FILE_NAME)) ? {} : null);
1002
+ const state = await inspectInstallTarget(targetRoot, record);
1003
+ if (state.state === 'modified') {
1004
+ conflicts.push(` ${formatInstallTargetRoot(targetRoot)}: local changes in ${formatChangedPaths(state.paths)}`);
1005
+ }
1006
+ else if (state.state === 'untracked') {
1007
+ conflicts.push(` ${formatInstallTargetRoot(targetRoot)}: contains files not installed by Skillshare`);
1008
+ }
1009
+ }
1010
+ if (conflicts.length === 0) {
1011
+ return;
1012
+ }
1013
+ if (options.interactive) {
1014
+ log.warn(`These install locations would be overwritten:\n${conflicts.join('\n')}`);
1015
+ if (await promptConfirm('Overwrite them?', false)) {
1016
+ return;
1017
+ }
1018
+ cancel('Install cancelled. Nothing was changed.');
1019
+ process.exit(0);
1020
+ }
1021
+ throw new Error(`Refusing to overwrite:\n${conflicts.join('\n')}\nRe-run with --force to overwrite.`);
1022
+ }
1023
+ function formatChangedPaths(paths) {
1024
+ const shown = paths.slice(0, 3).join(', ');
1025
+ return paths.length > 3 ? `${shown} and ${paths.length - 3} more` : shown;
1026
+ }
1027
+ function formatCliPath(path) {
1028
+ const display = formatInstallTargetRoot(path);
1029
+ return /^[A-Za-z0-9._/~-]+$/.test(display) ? display : JSON.stringify(display);
1030
+ }
906
1031
  async function downloadResponseToFile(response, destination, onBytesTransferred) {
1032
+ const hash = createHash('sha256');
907
1033
  const body = response.body;
908
1034
  if (!body) {
909
1035
  const buffer = Buffer.from(await response.arrayBuffer());
910
1036
  await writeFile(destination, buffer);
911
1037
  onBytesTransferred?.(buffer.length);
912
- return;
1038
+ return hash.update(buffer).digest('hex');
913
1039
  }
914
1040
  const fileHandle = await open(destination, 'w');
915
1041
  let failed = true;
@@ -925,6 +1051,7 @@ async function downloadResponseToFile(response, destination, onBytesTransferred)
925
1051
  continue;
926
1052
  }
927
1053
  await fileHandle.writeFile(value);
1054
+ hash.update(value);
928
1055
  onBytesTransferred?.(value.length);
929
1056
  }
930
1057
  }
@@ -934,6 +1061,7 @@ async function downloadResponseToFile(response, destination, onBytesTransferred)
934
1061
  await rm(destination, { force: true });
935
1062
  }
936
1063
  }
1064
+ return hash.digest('hex');
937
1065
  }
938
1066
  function createInstallProgressReporter(input) {
939
1067
  const installStatus = spinner({ indicator: 'timer' });
@@ -1349,9 +1477,15 @@ async function login(baseUrl) {
1349
1477
  const loginUrl = `${baseUrl}/api/cli/login?redirectUri=${encodeURIComponent(callback.redirectUri)}` +
1350
1478
  `&codeChallenge=${encodeURIComponent(codeChallenge)}` +
1351
1479
  `&state=${encodeURIComponent(state)}`;
1352
- await openBrowser(loginUrl);
1353
- console.log(`Opening browser for ${baseUrl}`);
1354
- const authCode = await callback.wait(state);
1480
+ console.log(`Opening the browser to sign in to ${baseUrl}.`);
1481
+ console.log(`If it does not open, visit this URL on this computer:\n ${loginUrl}`);
1482
+ try {
1483
+ await openBrowser(loginUrl);
1484
+ }
1485
+ catch {
1486
+ console.log('Could not open a browser automatically. Use the URL above.');
1487
+ }
1488
+ const authCode = await callback.wait(state, LOGIN_TIMEOUT_MS);
1355
1489
  const response = await fetchJson(`${baseUrl}/api/cli/token`, {
1356
1490
  method: 'POST',
1357
1491
  headers: {
@@ -1373,6 +1507,7 @@ async function login(baseUrl) {
1373
1507
  user: response.user,
1374
1508
  });
1375
1509
  console.log(`Logged in as ${response.user.displayName} (${baseUrl})`);
1510
+ return response.user;
1376
1511
  }
1377
1512
  const GCM_MINIMUM_VERSION = [2, 0, 931];
1378
1513
  async function configureGitCredentialManager(baseUrl) {
@@ -1434,36 +1569,8 @@ function compareVersions(left, right) {
1434
1569
  function formatVersion(version) {
1435
1570
  return version.join('.');
1436
1571
  }
1437
- async function parseOptions(args) {
1438
- const booleanFlags = new Set(['dry-run', 'headless']);
1439
- const flags = {};
1440
- const positionals = [];
1441
- for (let index = 0; index < args.length; index += 1) {
1442
- const current = args[index];
1443
- if (!current) {
1444
- continue;
1445
- }
1446
- if (current.startsWith('--')) {
1447
- const key = current.slice(2);
1448
- const value = args[index + 1];
1449
- if (key === 'headless' && value !== 'true' && value !== 'false') {
1450
- flags[key] = 'true';
1451
- continue;
1452
- }
1453
- if (!value || value.startsWith('--')) {
1454
- if (booleanFlags.has(key)) {
1455
- flags[key] = 'true';
1456
- continue;
1457
- }
1458
- throw new Error(`Missing value for ${current}`);
1459
- }
1460
- flags[key] = value;
1461
- index += 1;
1462
- }
1463
- else {
1464
- positionals.push(current);
1465
- }
1466
- }
1572
+ async function parseOptions(args, commandName) {
1573
+ const { flags, positionals } = parseCliArgs(args, commandName);
1467
1574
  const tokenFromFlag = normalizeText(flags.token);
1468
1575
  const tokenFromEnvironment = normalizeText(process.env.SKILLSHARE_TOKEN);
1469
1576
  return {
@@ -1500,7 +1607,7 @@ async function createClient(baseUrl, explicitToken) {
1500
1607
  if (new Date(currentSession.refreshTokenExpiresAt).getTime() <= Date.now()) {
1501
1608
  throw new Error(`Stored session for ${resolvedBaseUrl} is no longer valid. Run "skillshare login${resolvedBaseUrl === LOCAL_URL ? ' local' : ''}" again.`);
1502
1609
  }
1503
- const response = await fetch(`${resolvedBaseUrl}/api/cli/refresh`, {
1610
+ const response = await fetchFromServer(`${resolvedBaseUrl}/api/cli/refresh`, {
1504
1611
  method: 'POST',
1505
1612
  headers: {
1506
1613
  'Content-Type': 'application/json',
@@ -1537,7 +1644,7 @@ async function createClient(baseUrl, explicitToken) {
1537
1644
  return refreshedSession.accessToken;
1538
1645
  }
1539
1646
  async function sendAuthorized(path, init, retryUnauthorized = true) {
1540
- const response = await fetch(`${resolvedBaseUrl}${path}`, {
1647
+ const response = await fetchFromServer(`${resolvedBaseUrl}${path}`, {
1541
1648
  ...init,
1542
1649
  headers: await buildHeaders(() => getAccessToken(), init?.headers),
1543
1650
  });
@@ -1545,7 +1652,7 @@ async function createClient(baseUrl, explicitToken) {
1545
1652
  return response;
1546
1653
  }
1547
1654
  const refreshedToken = await getAccessToken({ forceRefresh: true });
1548
- return fetch(`${resolvedBaseUrl}${path}`, {
1655
+ return fetchFromServer(`${resolvedBaseUrl}${path}`, {
1549
1656
  ...init,
1550
1657
  headers: await buildHeaders(() => Promise.resolve(refreshedToken), init?.headers),
1551
1658
  });
@@ -1569,7 +1676,7 @@ async function createClient(baseUrl, explicitToken) {
1569
1676
  async rawGet(path) {
1570
1677
  const response = await sendAuthorized(path);
1571
1678
  if (!response.ok) {
1572
- throw new Error(await formatErrorResponse(response));
1679
+ throw new HttpError(response.status, await formatErrorResponse(response));
1573
1680
  }
1574
1681
  return response;
1575
1682
  },
@@ -1624,9 +1731,34 @@ async function buildHeaders(getAccessToken, initialHeaders) {
1624
1731
  headers.set('Authorization', `Bearer ${await getAccessToken()}`);
1625
1732
  return headers;
1626
1733
  }
1734
+ class HttpError extends Error {
1735
+ status;
1736
+ constructor(status, message) {
1737
+ super(message);
1738
+ this.status = status;
1739
+ this.name = 'HttpError';
1740
+ }
1741
+ }
1742
+ class NetworkError extends Error {
1743
+ constructor(origin, cause) {
1744
+ super(`Cannot reach ${origin}. Check your network or VPN connection and try again.`, { cause });
1745
+ this.name = 'NetworkError';
1746
+ }
1747
+ }
1748
+ async function fetchFromServer(input, init) {
1749
+ try {
1750
+ return await fetch(input, init);
1751
+ }
1752
+ catch (error) {
1753
+ if (init?.signal?.aborted) {
1754
+ throw error;
1755
+ }
1756
+ throw new NetworkError(new URL(input).origin, error);
1757
+ }
1758
+ }
1627
1759
  async function parseJson(response) {
1628
1760
  if (!response.ok) {
1629
- throw new Error(await formatErrorResponse(response));
1761
+ throw new HttpError(response.status, await formatErrorResponse(response));
1630
1762
  }
1631
1763
  return response.json();
1632
1764
  }
@@ -1661,7 +1793,7 @@ async function formatErrorResponse(response) {
1661
1793
  : `${fallbackMessage} ${body}`;
1662
1794
  }
1663
1795
  async function fetchJson(input, init) {
1664
- const response = await fetch(input, init);
1796
+ const response = await fetchFromServer(input, init);
1665
1797
  return parseJson(response);
1666
1798
  }
1667
1799
  async function loadSession(baseUrl) {
@@ -1767,15 +1899,22 @@ async function waitForLoginCallback() {
1767
1899
  }
1768
1900
  return {
1769
1901
  redirectUri: `http://127.0.0.1:${address.port}/callback`,
1770
- async wait(expectedState) {
1902
+ async wait(expectedState, timeoutMs) {
1903
+ let timer;
1904
+ const timeout = new Promise((_, reject) => {
1905
+ timer = setTimeout(() => {
1906
+ reject(new Error(`Sign-in did not complete within ${Math.round(timeoutMs / 60_000)} minutes. Run "skillshare login" again.`));
1907
+ }, timeoutMs);
1908
+ });
1771
1909
  try {
1772
- const payload = JSON.parse(await statePromise);
1910
+ const payload = JSON.parse(await Promise.race([statePromise, timeout]));
1773
1911
  if (payload.state !== expectedState) {
1774
1912
  throw new Error('Login state mismatch.');
1775
1913
  }
1776
1914
  return payload.code;
1777
1915
  }
1778
1916
  finally {
1917
+ clearTimeout(timer);
1779
1918
  server.close();
1780
1919
  }
1781
1920
  },
@@ -1832,7 +1971,7 @@ async function upsertUploadedSkillMetadata(baseUrl, directory, metadata) {
1832
1971
  await writeStateFile(UPLOADED_STATE_PATH, records);
1833
1972
  await removeLegacyLocalSkillMetadata(resolvedDirectory);
1834
1973
  }
1835
- async function upsertInstalledSkillRecord(baseUrl, targetRoot, metadata) {
1974
+ async function upsertInstalledSkillRecord(baseUrl, targetRoot, metadata, files) {
1836
1975
  const normalizedBaseUrl = normalizeBaseUrl(baseUrl);
1837
1976
  const resolvedTargetRoot = resolve(targetRoot);
1838
1977
  const records = (await readInstalledSkillRecords())
@@ -1842,6 +1981,7 @@ async function upsertInstalledSkillRecord(baseUrl, targetRoot, metadata) {
1842
1981
  targetRoot: resolvedTargetRoot,
1843
1982
  baseUrl: normalizedBaseUrl,
1844
1983
  updatedAt: new Date().toISOString(),
1984
+ ...(files ? { files } : {}),
1845
1985
  });
1846
1986
  await writeStateFile(INSTALLED_STATE_PATH, records);
1847
1987
  await removeLegacyLocalSkillMetadata(resolvedTargetRoot);
@@ -1883,9 +2023,17 @@ async function readInstalledSkillRecords() {
1883
2023
  skillName: typeof entry.skillName === 'string' ? entry.skillName : '',
1884
2024
  skillDescription: typeof entry.skillDescription === 'string' ? entry.skillDescription : '',
1885
2025
  skillVersion: typeof entry.skillVersion === 'string' ? entry.skillVersion : '',
2026
+ files: readFileManifest(entry.files),
1886
2027
  }].filter((record) => record.targetRoot.length > 0);
1887
2028
  });
1888
2029
  }
2030
+ function readFileManifest(value) {
2031
+ if (!value || typeof value !== 'object' || Array.isArray(value)) {
2032
+ return undefined;
2033
+ }
2034
+ const entries = Object.entries(value).filter((entry) => typeof entry[1] === 'string');
2035
+ return Object.fromEntries(entries);
2036
+ }
1889
2037
  async function readStateFile(path) {
1890
2038
  try {
1891
2039
  const parsed = JSON.parse(await readFile(path, 'utf8'));
@@ -0,0 +1,111 @@
1
+ import { createHash, randomBytes } from 'node:crypto';
2
+ import { createReadStream } from 'node:fs';
3
+ import { mkdir, readdir, rename, rm, rmdir, stat } from 'node:fs/promises';
4
+ import { basename, dirname, join, relative, resolve, sep } from 'node:path';
5
+ export function resolveInsideRoot(root, relativePath) {
6
+ const resolvedRoot = resolve(root);
7
+ const destination = resolve(resolvedRoot, relativePath);
8
+ if (destination === resolvedRoot || !destination.startsWith(resolvedRoot + sep)) {
9
+ throw new Error(`Refusing to write '${relativePath}' outside ${resolvedRoot}.`);
10
+ }
11
+ return destination;
12
+ }
13
+ export async function hashFile(path) {
14
+ const hash = createHash('sha256');
15
+ for await (const chunk of createReadStream(path)) {
16
+ hash.update(chunk);
17
+ }
18
+ return hash.digest('hex');
19
+ }
20
+ /**
21
+ * Compares a target directory with the manifest recorded at install time.
22
+ * A record without a manifest predates change tracking and cannot be verified.
23
+ */
24
+ export async function inspectInstallTarget(targetRoot, record) {
25
+ if (!await isNonEmptyDirectory(targetRoot)) {
26
+ return { state: 'missing' };
27
+ }
28
+ if (!record) {
29
+ return { state: 'untracked' };
30
+ }
31
+ if (!record.files) {
32
+ return { state: 'legacy' };
33
+ }
34
+ const modified = [];
35
+ for (const [relativePath, expectedHash] of Object.entries(record.files)) {
36
+ const path = resolveInsideRoot(targetRoot, relativePath);
37
+ try {
38
+ if (await hashFile(path) !== expectedHash)
39
+ modified.push(relativePath);
40
+ }
41
+ catch (error) {
42
+ if (error?.code !== 'ENOENT')
43
+ throw error;
44
+ modified.push(relativePath);
45
+ }
46
+ }
47
+ return modified.length > 0 ? { state: 'modified', paths: modified } : { state: 'clean' };
48
+ }
49
+ /**
50
+ * Downloads every file into a sibling staging directory first, so a failed transfer
51
+ * leaves the existing install untouched. Files from the previous manifest that the
52
+ * new version no longer contains are removed afterwards.
53
+ */
54
+ export async function installFilesAtomically(input) {
55
+ const targetRoot = resolve(input.targetRoot);
56
+ const stagingRoot = join(dirname(targetRoot), `.${basename(targetRoot)}.skillshare-${randomBytes(4).toString('hex')}`);
57
+ const manifest = {};
58
+ await mkdir(stagingRoot, { recursive: true });
59
+ try {
60
+ for (const file of input.files) {
61
+ const stagedPath = resolveInsideRoot(stagingRoot, file.relativePath);
62
+ resolveInsideRoot(targetRoot, file.relativePath);
63
+ await mkdir(dirname(stagedPath), { recursive: true });
64
+ manifest[normalizeRelativePath(file.relativePath)] = await input.download(file.relativePath, stagedPath);
65
+ }
66
+ await mkdir(targetRoot, { recursive: true });
67
+ for (const relativePath of Object.keys(manifest)) {
68
+ const destination = resolveInsideRoot(targetRoot, relativePath);
69
+ await mkdir(dirname(destination), { recursive: true });
70
+ await rename(resolveInsideRoot(stagingRoot, relativePath), destination);
71
+ }
72
+ for (const relativePath of Object.keys(input.previousManifest ?? {})) {
73
+ if (relativePath in manifest)
74
+ continue;
75
+ const stalePath = resolveInsideRoot(targetRoot, relativePath);
76
+ await rm(stalePath, { force: true });
77
+ await removeEmptyParents(dirname(stalePath), targetRoot);
78
+ }
79
+ }
80
+ finally {
81
+ await rm(stagingRoot, { recursive: true, force: true });
82
+ }
83
+ return manifest;
84
+ }
85
+ function normalizeRelativePath(path) {
86
+ return relative('.', join('.', path)).replaceAll('\\', '/');
87
+ }
88
+ async function isNonEmptyDirectory(path) {
89
+ try {
90
+ if (!(await stat(path)).isDirectory())
91
+ return true;
92
+ return (await readdir(path)).length > 0;
93
+ }
94
+ catch (error) {
95
+ if (error?.code === 'ENOENT')
96
+ return false;
97
+ throw error;
98
+ }
99
+ }
100
+ async function removeEmptyParents(directory, root) {
101
+ let current = resolve(directory);
102
+ while (current !== root && current.startsWith(root + sep)) {
103
+ try {
104
+ await rmdir(current);
105
+ }
106
+ catch {
107
+ return;
108
+ }
109
+ current = dirname(current);
110
+ }
111
+ }
@@ -16,7 +16,7 @@ export function decodeTeamsJwt(value) {
16
16
  /** Select only credentials belonging to the Skillshare account. No full browser state leaves this machine. */
17
17
  export function extractTeamsSession(state, expectedObjectId, now = Date.now(), allowExpired = false) {
18
18
  if (!uuid.test(expectedObjectId))
19
- throw new Error('Skillshare must be signed in with a Microsoft account before connecting Teams.');
19
+ throw new Error('The Microsoft account object id is missing. Sign in to Skillshare in the browser, then run skillshare teams login again.');
20
20
  const stores = state.origins.filter(origin => origins.has(origin.origin));
21
21
  const entries = stores.flatMap(origin => origin.localStorage.flatMap(item => {
22
22
  try {
package/dist/teams.js CHANGED
@@ -7,6 +7,17 @@ import { pipeline } from 'node:stream/promises';
7
7
  import { captureTeamsLogin } from './teams-auth.js';
8
8
  import { watchTeamsConnection } from './teams-watch.js';
9
9
  const root = '/api/mcps/providers/teams';
10
+ const guid = /^[a-f\d]{8}-[a-f\d]{4}-[a-f\d]{4}-[a-f\d]{4}-[a-f\d]{12}$/i;
11
+ /**
12
+ * Skillshare identifies users by the pairwise subject; Teams credentials carry the Entra
13
+ * object id, which the server records at web sign-in. Older servers only return a GUID id.
14
+ */
15
+ export function resolveTeamsObjectId(account) {
16
+ if (account.entraObjectId && guid.test(account.entraObjectId))
17
+ return account.entraObjectId;
18
+ return guid.test(account.id) ? account.id : null;
19
+ }
20
+ export const UNLINKED_ACCOUNT_MESSAGE = 'Skillshare does not know your Microsoft account yet. Run "skillshare login" (or open Skillshare in the browser and sign in), then run "skillshare teams login" again.';
10
21
  async function expectOk(response) {
11
22
  if (response.ok)
12
23
  return;
@@ -23,11 +34,15 @@ export async function runTeamsCommand(client, args, flags, appDirectory) {
23
34
  const action = args[0];
24
35
  if (action === 'login' || action === 'watch') {
25
36
  const user = await client.get('/api/auth/me');
37
+ const resolvedObjectId = resolveTeamsObjectId(user);
38
+ if (!resolvedObjectId)
39
+ throw new Error(UNLINKED_ACCOUNT_MESSAGE);
40
+ const objectId = resolvedObjectId;
26
41
  const key = createHash('sha256').update(client.baseUrl + '\0' + user.id).digest('hex').slice(0, 24);
27
42
  const profileDirectory = flags['browser-profile'] ? resolve(flags['browser-profile']) : join(appDirectory, 'teams', key, 'browser-profile');
28
43
  const abort = new AbortController();
29
44
  async function connect(connectionId) {
30
- const session = await captureTeamsLogin({ profileDirectory, objectId: user.id, browser: flags.browser,
45
+ const session = await captureTeamsLogin({ profileDirectory, objectId, browser: flags.browser,
31
46
  headless: action === 'watch' ? flags.headless !== 'false' : flags.headless === 'true', recover: action === 'watch',
32
47
  signal: abort.signal, timeoutMs: action === 'watch' ? 90_000 : undefined, onProgress: console.log });
33
48
  const headers = { 'Content-Type': 'application/json' };
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@quadient/skillshare",
3
- "version": "1.7.0",
3
+ "version": "1.8.1",
4
4
  "description": "Skillshare CLI for installing and publishing Quadient skills",
5
5
  "type": "module",
6
6
  "bin": {
@@ -16,7 +16,7 @@
16
16
  "build": "rm -rf dist && tsc -p tsconfig.build.json",
17
17
  "start": "node ./dist/index.js",
18
18
  "prepublishOnly": "npm run build",
19
- "test": "tsx --test pages.test.ts pages-dev.test.ts teams-auth.test.ts teams-watch.test.ts teams-browser.test.ts"
19
+ "test": "tsx --test cli-args.test.ts install-files.test.ts pages.test.ts pages-dev.test.ts teams-auth.test.ts teams-watch.test.ts teams-browser.test.ts"
20
20
  },
21
21
  "publishConfig": {
22
22
  "access": "public"