@quadient/skillshare 1.8.1 → 1.10.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/agents.js ADDED
@@ -0,0 +1,230 @@
1
+ import { createHash, randomBytes } from 'node:crypto';
2
+ import { mkdir, readFile, writeFile } from 'node:fs/promises';
3
+ import { hostname, platform } from 'node:os';
4
+ import { dirname, join, resolve } from 'node:path';
5
+ import { setTimeout as delay } from 'node:timers/promises';
6
+ import { createClaudeDriver } from './agents-claude.js';
7
+ import { createCodexDriver } from './agents-codex.js';
8
+ import { detectAgents, profilesPath } from './agents-detect.js';
9
+ import { SessionRunner } from './agents-session.js';
10
+ import { readCliVersion } from './help.js';
11
+ const root = '/api/agents';
12
+ const PROFILE_HINT = 'Declare named profiles in ' + profilesPath() + ' to set a working directory or extra arguments.';
13
+ export class AgentsHttpError extends Error {
14
+ status;
15
+ constructor(status, message) {
16
+ super(message);
17
+ this.status = status;
18
+ }
19
+ }
20
+ async function send(client, method, path, body, signal) {
21
+ const response = await client.request(path, { method, headers: { 'Content-Type': 'application/json', Accept: 'application/json' }, body: JSON.stringify(body), signal });
22
+ if (!response.ok) {
23
+ let message = `Skillshare returned HTTP ${response.status}.`;
24
+ try {
25
+ message = (await response.json()).error ?? message;
26
+ }
27
+ catch { }
28
+ throw new AgentsHttpError(response.status, message);
29
+ }
30
+ return response.status === 204 ? undefined : await response.json();
31
+ }
32
+ /** One stable listener id per Skillshare server and account, so the web keeps routes across restarts. */
33
+ export async function loadListenerId(appDirectory, baseUrl, userId) {
34
+ const key = createHash('sha256').update(baseUrl + '\0' + userId).digest('hex').slice(0, 24);
35
+ const file = join(appDirectory, 'agents', `listener-${key}.json`);
36
+ try {
37
+ const stored = JSON.parse(await readFile(file, 'utf8'));
38
+ if (typeof stored.id === 'string' && /^[A-Za-z0-9_-]{8,64}$/.test(stored.id))
39
+ return stored.id;
40
+ }
41
+ catch { }
42
+ const id = randomBytes(12).toString('base64url');
43
+ await mkdir(dirname(file), { recursive: true, mode: 0o700 });
44
+ await writeFile(file, JSON.stringify({ id }, null, 2), { mode: 0o600 });
45
+ return id;
46
+ }
47
+ export async function runAgentsCommand(client, args, flags, appDirectory) {
48
+ const action = args[0];
49
+ if (action === 'status') {
50
+ const detected = await detectAgents();
51
+ const user = await client.get('/api/auth/me');
52
+ const listenerId = await loadListenerId(appDirectory, client.baseUrl, user.id);
53
+ if (flags.json === 'true') {
54
+ console.log(JSON.stringify({ listenerId, server: client.baseUrl, ...detected.capabilities, problems: detected.problems }, null, 2));
55
+ return;
56
+ }
57
+ console.log(renderDetected(listenerId, client.baseUrl, detected));
58
+ return;
59
+ }
60
+ if (action !== 'listen')
61
+ throw new Error('agents requires listen or status.');
62
+ const max = flags['max-concurrency'] === undefined ? undefined : Number(flags['max-concurrency']);
63
+ if (max !== undefined && (!Number.isInteger(max) || max < 1 || max > 4))
64
+ throw new Error('--max-concurrency must be between 1 and 4.');
65
+ const workdir = resolve(flags.workdir ?? process.cwd());
66
+ const sandbox = flags.sandbox === 'true';
67
+ const user = await client.get('/api/auth/me');
68
+ const listenerId = await loadListenerId(appDirectory, client.baseUrl, user.id);
69
+ const abort = new AbortController();
70
+ const stop = () => abort.abort();
71
+ process.once('SIGINT', stop);
72
+ process.once('SIGTERM', stop);
73
+ console.log(sandbox
74
+ ? 'Sandboxed: agents may read files, search and use the Skillshare MCP tools, but cannot change files or run commands that modify anything.'
75
+ : 'Agents run with your full local permissions and configuration, without permission prompts. Use --sandbox to keep them read-only.');
76
+ console.log('Sessions keep their agent running between messages and close it after 15 quiet minutes; the next message resumes it.');
77
+ try {
78
+ await listen({ client, listenerId, name: flags.name, workdir, maxConcurrency: max, sandbox, signal: abort.signal, onProgress: console.log });
79
+ }
80
+ finally {
81
+ process.removeListener('SIGINT', stop);
82
+ process.removeListener('SIGTERM', stop);
83
+ }
84
+ }
85
+ export function renderDetected(listenerId, server, detected) {
86
+ const lines = [`Listener ${listenerId} on ${server}`];
87
+ if (detected.capabilities.agents.length === 0)
88
+ lines.push('No supported agent found. Install Claude Code (claude) or Codex (codex) and make sure it is on PATH.');
89
+ for (const agent of detected.capabilities.agents) {
90
+ lines.push(`${agent.kind === 'claude' ? 'Claude Code' : 'Codex'} ${agent.version ?? ''}`.trimEnd());
91
+ for (const model of agent.models)
92
+ lines.push(` ${model.id}${model.reasoningLevels.length ? ` (${model.reasoningLevels.join(', ')})` : ''}`);
93
+ for (const warning of agent.warnings)
94
+ lines.push(` ! ${warning}`);
95
+ }
96
+ if (detected.profiles.length === 0)
97
+ lines.push(PROFILE_HINT);
98
+ for (const profile of detected.profiles)
99
+ lines.push(`Profile ${profile.name}: ${profile.agent}${profile.model ? ' ' + profile.model : ''}${profile.reasoning ? ' ' + profile.reasoning : ''}${profile.workdir ? ' in ' + profile.workdir : ''}${profile.sandbox ? ' (sandbox)' : ''}`);
100
+ for (const problem of detected.problems)
101
+ lines.push(`! ${problem}`);
102
+ return lines.join('\n');
103
+ }
104
+ export async function listen(options) {
105
+ const detect = options.detect ?? (() => detectAgents());
106
+ const codex = createCodexDriver({ clientVersion: readCliVersion() });
107
+ const claude = createClaudeDriver();
108
+ const driver = options.driver ?? (start => start.configuration.agent === 'codex' ? codex : claude);
109
+ const wait = options.wait ?? (async (ms, signal) => { await delay(ms, undefined, { signal }); });
110
+ const now = options.now ?? Date.now;
111
+ const log = options.onProgress ?? (() => { });
112
+ const sessions = new Map();
113
+ let detected = await detect();
114
+ let registeredFingerprint = '';
115
+ let detectedAt = now();
116
+ let failures = 0;
117
+ const capabilities = () => options.sandbox ? { ...detected.capabilities, sandbox: true } : detected.capabilities;
118
+ async function register() {
119
+ const fingerprint = JSON.stringify(capabilities());
120
+ const registered = await send(options.client, 'PUT', `${root}/listeners/${options.listenerId}`, {
121
+ name: options.name ?? hostname(), host: hostname(), os: platform(), cliVersion: readCliVersion(), maxConcurrency: options.maxConcurrency,
122
+ capabilities: capabilities(),
123
+ }, options.signal);
124
+ registeredFingerprint = fingerprint;
125
+ for (const problem of detected.problems)
126
+ log(`! ${problem}`);
127
+ log(`Listening as "${registered.name}" with ${registered.effectiveConcurrency} slot(s): ${describe(detected)}. Start sessions and choose routes under Connected agents on ${options.client.baseUrl}.`);
128
+ }
129
+ /** Posts a batch of session events: a rejected lease means the server moved on and the agent must stop. */
130
+ function uploader(sessionId, leaseToken) {
131
+ return async (events) => {
132
+ try {
133
+ await send(options.client, 'POST', `${root}/sessions/${sessionId}/events`, { leaseToken, events });
134
+ return 'ok';
135
+ }
136
+ catch (error) {
137
+ if (error instanceof AgentsHttpError && error.status >= 400 && error.status < 500)
138
+ return 'gone';
139
+ return 'retry';
140
+ }
141
+ };
142
+ }
143
+ function start(command) {
144
+ if (!command.start || !command.leaseToken)
145
+ return;
146
+ // A start for a session already held replaces its agent, for example to hand it fresh credentials.
147
+ const previous = sessions.get(command.sessionId);
148
+ if (previous)
149
+ void previous.stop();
150
+ const profile = command.start.configuration.profile ? detected.profiles.find(p => p.name === command.start.configuration.profile) : undefined;
151
+ const runner = new SessionRunner({
152
+ sessionId: command.sessionId, leaseToken: command.leaseToken, start: command.start, workdir: options.workdir, profile, sandbox: options.sandbox === true,
153
+ driver: driver(command.start), upload: uploader(command.sessionId, command.leaseToken), idleMs: options.idleMs, flushIntervalMs: options.flushIntervalMs,
154
+ now,
155
+ });
156
+ sessions.set(command.sessionId, runner);
157
+ log(`${command.start.resumeThreadId ? 'Resumed' : 'Started'} session ${command.sessionId} "${command.start.title}" with ${label(command.start)}.`);
158
+ void runner.done.then(() => {
159
+ if (sessions.get(command.sessionId) === runner)
160
+ sessions.delete(command.sessionId);
161
+ log(`Closed session ${command.sessionId}.`);
162
+ });
163
+ runner.send(command.messages ?? []);
164
+ }
165
+ function handle(command) {
166
+ const runner = sessions.get(command.sessionId);
167
+ switch (command.type) {
168
+ case 'start': return start(command);
169
+ case 'send': return runner?.send(command.messages ?? []);
170
+ case 'interrupt': return void runner?.interrupt();
171
+ case 'close': return void runner?.close();
172
+ }
173
+ }
174
+ try {
175
+ while (!options.signal.aborted) {
176
+ try {
177
+ if (now() - detectedAt > (options.redetectMs ?? 5 * 60_000)) {
178
+ detected = await detect();
179
+ detectedAt = now();
180
+ }
181
+ if (registeredFingerprint !== JSON.stringify(capabilities()))
182
+ await register();
183
+ const polledAt = now();
184
+ const response = await send(options.client, 'POST', `${root}/listeners/${options.listenerId}/poll`, {
185
+ // A session that is already closing is not reported, so the server treats its agent as gone.
186
+ sessions: [...sessions.values()].filter(r => !r.stopping).map(r => ({ sessionId: r.sessionId, leaseToken: r.leaseToken })),
187
+ waitSeconds: options.pollWaitSeconds ?? 25,
188
+ }, options.signal);
189
+ failures = 0;
190
+ for (const command of response.commands)
191
+ handle(command);
192
+ if (response.commands.length === 0 && now() - polledAt < 1000)
193
+ await wait(1000, options.signal).catch(() => { });
194
+ }
195
+ catch (error) {
196
+ if (options.signal.aborted)
197
+ break;
198
+ if (error instanceof AgentsHttpError && error.status === 404) {
199
+ registeredFingerprint = '';
200
+ continue;
201
+ }
202
+ failures++;
203
+ if (failures === 1)
204
+ log(`Skillshare is unreachable or rejected the listener (${error instanceof Error ? error.message : String(error)}). Retrying.`);
205
+ try {
206
+ await wait(Math.min(60_000, 2000 * 2 ** Math.min(failures, 5)), options.signal);
207
+ }
208
+ catch {
209
+ break;
210
+ }
211
+ }
212
+ }
213
+ }
214
+ finally {
215
+ // Closing reports each session as closed, so the server knows at once that the next message must resume it.
216
+ await Promise.all([...sessions.values()].map(runner => runner.close()));
217
+ }
218
+ }
219
+ function label(start) {
220
+ const { configuration } = start;
221
+ if (configuration.profile)
222
+ return `profile ${configuration.profile}`;
223
+ return [configuration.agent === 'claude' ? 'Claude Code' : 'Codex', configuration.model, configuration.reasoning]
224
+ .filter(part => part !== 'default').join(' ');
225
+ }
226
+ function describe(detected) {
227
+ const agents = detected.capabilities.agents.map(a => `${a.kind === 'claude' ? 'Claude Code' : 'Codex'} ${a.version ?? ''}`.trim());
228
+ const profiles = detected.profiles.length ? `, profiles ${detected.profiles.map(p => p.name).join(', ')}` : '';
229
+ return (agents.length ? agents.join(', ') : 'no supported agent found') + profiles;
230
+ }
package/dist/help.js CHANGED
@@ -1,6 +1,6 @@
1
1
  import { readFileSync } from 'node:fs';
2
2
  export const GLOBAL_FLAGS = ['token', 'server', 'help'];
3
- export const BOOLEAN_FLAGS = new Set(['dry-run', 'force', 'headless', 'help']);
3
+ export const BOOLEAN_FLAGS = new Set(['dry-run', 'force', 'headless', 'help', 'json', 'sandbox']);
4
4
  export const COMMANDS = [
5
5
  {
6
6
  name: 'login',
@@ -19,20 +19,21 @@ export const COMMANDS = [
19
19
  {
20
20
  name: 'whoami',
21
21
  summary: 'Show the signed-in user',
22
- usage: ['whoami'],
23
- flags: [],
22
+ usage: ['whoami [--json]'],
23
+ flags: ['json'],
24
24
  },
25
25
  {
26
26
  name: 'list',
27
27
  summary: 'List skills in the registry',
28
- usage: ['list'],
29
- flags: [],
28
+ usage: ['list [--json]'],
29
+ flags: ['json'],
30
30
  },
31
31
  {
32
32
  name: 'show',
33
33
  summary: 'Show a skill or one of its versions',
34
- usage: ['show <skillId> [version]'],
35
- flags: [],
34
+ usage: ['show <skillId|skillName> [version] [--json]'],
35
+ examples: ['skillshare show "Prompt Lint"', 'skillshare show 12 0.1.0 --json'],
36
+ flags: ['json'],
36
37
  },
37
38
  {
38
39
  name: 'install',
@@ -73,23 +74,25 @@ export const COMMANDS = [
73
74
  ['--skill-id <id>', 'Publish to this existing skill'],
74
75
  ['--name <name>', 'Name for a new skill (defaults to SKILL.md)'],
75
76
  ['--description <text>', 'Description for a new skill (defaults to SKILL.md)'],
77
+ ['--json', 'Print the server response as JSON'],
76
78
  ],
77
79
  examples: ['skillshare upload ./prompt-lint --version 0.2.0'],
78
- flags: ['version', 'skill-id', 'name', 'description'],
80
+ flags: ['version', 'skill-id', 'name', 'description', 'json'],
79
81
  },
80
82
  {
81
83
  name: 'pages',
82
84
  summary: 'Publish and inspect authenticated pages',
83
85
  usage: [
84
- 'pages list',
85
- 'pages show <slug>',
86
- 'pages runtime <slug>',
87
- 'pages upload <directory> [--slug <slug>]',
86
+ 'pages list [--json]',
87
+ 'pages show <slug> [--json]',
88
+ 'pages runtime <slug> [--json]',
89
+ 'pages upload <directory> [--slug <slug>] [--json]',
88
90
  'pages dev [directory] [--slug <slug>] [--port <port>] [--build <command> --watch-dir <sources>]',
89
91
  ' [--celld <executable>] [--user-id <id>] [--user-name <name>] [--user-email <email>]',
90
92
  ],
93
+ description: 'An upload replaces all content of the page. Without --slug the directory name is used; build output folders such as dist need an explicit --slug. Pages owned by someone else are refused unless you are an admin.',
91
94
  examples: ['skillshare pages upload ./site --slug release-notes', 'skillshare pages dev . --slug my-app'],
92
- flags: ['slug'],
95
+ flags: ['slug', 'json'],
93
96
  },
94
97
  {
95
98
  name: 'teams',
@@ -97,12 +100,24 @@ export const COMMANDS = [
97
100
  usage: [
98
101
  'teams login [local] [--browser chrome|msedge|chromium] [--headless] [--browser-profile <directory>]',
99
102
  'teams watch [local] [--browser chrome|msedge|chromium]',
100
- 'teams status | test | logout',
103
+ 'teams status | test [--json]',
104
+ 'teams logout',
101
105
  'teams access readOnly|fullAccess',
102
106
  'teams upload <file>',
103
107
  'teams download <url> --output <new-file>',
104
108
  ],
105
- flags: ['browser', 'headless', 'browser-profile', 'output'],
109
+ flags: ['browser', 'headless', 'browser-profile', 'output', 'json'],
110
+ },
111
+ {
112
+ name: 'agents',
113
+ summary: 'Run Claude Code or Codex tasks from Skillshare on this computer',
114
+ usage: [
115
+ 'agents listen [local] [--name <name>] [--workdir <directory>] [--max-concurrency <1-4>] [--sandbox]',
116
+ 'agents status [local] [--json]',
117
+ ],
118
+ description: 'A listener reports the installed agents, their models and reasoning levels, and the local profiles from ~/.config/skillshare/agents.json. It runs Claude Code and Codex sessions that you start and continue under Sessions in Skillshare; under Connected agents you choose which listener and configuration runs Teams 👀 sessions. Agents use your own login and run with your full local permissions, without permission prompts, unless --sandbox (or "sandbox": true in a profile) keeps them read-only. An idle agent is stopped after 15 minutes and resumed by the next message.',
119
+ examples: ['skillshare agents status', 'skillshare agents listen --workdir ~/Research'],
120
+ flags: ['name', 'workdir', 'max-concurrency', 'sandbox', 'json'],
106
121
  },
107
122
  {
108
123
  name: 'git',
@@ -131,6 +146,7 @@ export function renderUsage(defaultUrl) {
131
146
  'Options:',
132
147
  ' --token <token> Bearer token for API requests (overrides SKILLSHARE_TOKEN)',
133
148
  ' --server <url> Skillshare origin for login and Teams commands',
149
+ ' --json Print machine-readable JSON (whoami, list, show, upload, pages, teams, agents)',
134
150
  ' -h, --help Show help for a command',
135
151
  ' -v, --version Show the CLI version',
136
152
  '',
package/dist/index.js CHANGED
@@ -1,8 +1,10 @@
1
1
  import { assertCredentialDestination, resolveTeamsObjectId, runTeamsCommand } from './teams.js';
2
+ import { runAgentsCommand } from './agents.js';
2
3
  import { formatUnknownCommand, parseCliArgs } from './cli-args.js';
3
4
  import { findCommand, readCliVersion, renderCommandHelp, renderUsage } from './help.js';
4
5
  import { inspectInstallTarget, installFilesAtomically } from './install-files.js';
5
- import { preparePageFiles } from './pages.js';
6
+ import { preparePageFiles, resolvePageSlug } from './pages.js';
7
+ import { formatByteSize, formatDateTime, pageUrl, plural, renderPageDetail, renderPageList, renderPageRuntime, renderSkillDetail, renderSkillList, renderSkillVersion, renderUser, } from './output.js';
6
8
  import { runPageDev } from './pages-dev.js';
7
9
  import { createServer } from 'node:http';
8
10
  import { createHash, randomBytes } from 'node:crypto';
@@ -72,6 +74,8 @@ const AGENTS = [
72
74
  { value: 'adal', label: 'AdaL', projectPath: '.adal/skills', globalPath: '.adal/skills' },
73
75
  ];
74
76
  const LOGIN_TIMEOUT_MS = 5 * 60_000;
77
+ // Progress goes to stderr with --json so stdout stays machine-readable.
78
+ let statusOutput = process.stdout;
75
79
  async function main() {
76
80
  const args = process.argv.slice(2);
77
81
  const command = args[0];
@@ -102,6 +106,13 @@ async function main() {
102
106
  console.log(renderCommandHelp(commandSpec));
103
107
  return;
104
108
  }
109
+ const json = isFlagEnabled(options.flags, 'json');
110
+ if (json) {
111
+ statusOutput = process.stderr;
112
+ }
113
+ const print = (value, human) => {
114
+ console.log(json ? JSON.stringify(value, null, 2) : human());
115
+ };
105
116
  if ((command === 'pages' || command === 'page') && options.positionals[0] === 'dev') {
106
117
  await runPageDev(options.positionals[1] ?? '.', options.flags);
107
118
  return;
@@ -133,6 +144,14 @@ async function main() {
133
144
  await runTeamsCommand(teamsClient, options.positionals, options.flags, APP_DIR);
134
145
  return;
135
146
  }
147
+ case 'agents': {
148
+ const baseUrl = options.flags.server ?? (options.positionals[1] === 'local' ? LOCAL_URL : await resolveActiveBaseUrl());
149
+ assertCredentialDestination(baseUrl);
150
+ if (!options.token && !await loadSession(baseUrl))
151
+ await login(baseUrl);
152
+ await runAgentsCommand(await createClient(baseUrl, options.token), options.positionals, options.flags, APP_DIR);
153
+ return;
154
+ }
136
155
  case 'git': {
137
156
  const action = options.positionals[0];
138
157
  const baseUrl = options.positionals[1] === 'local' ? LOCAL_URL : DEFAULT_URL;
@@ -159,27 +178,28 @@ async function main() {
159
178
  switch (command) {
160
179
  case 'whoami': {
161
180
  const user = await client.get('/api/auth/me');
162
- console.log(JSON.stringify(user, null, 2));
181
+ print(user, () => renderUser(user, client.baseUrl));
163
182
  return;
164
183
  }
165
184
  case 'list': {
166
185
  const skills = await client.get('/api/skills');
167
- for (const skill of skills) {
168
- console.log(`${skill.id}\t${skill.latestVersion}\t${skill.name}`);
169
- }
186
+ print(skills, () => renderSkillList(skills));
170
187
  return;
171
188
  }
172
189
  case 'show': {
173
- const skillId = options.positionals[0];
190
+ const skillRef = options.positionals[0];
174
191
  const version = options.positionals[1];
175
- if (!skillId) {
176
- throw new Error('show requires <skillId>');
192
+ if (!skillRef) {
193
+ throw new Error('show requires <skillId|skillName>');
177
194
  }
178
- const resource = version
179
- ? `/api/skills/${skillId}/versions/${version}`
180
- : `/api/skills/${skillId}`;
181
- const data = await client.get(resource);
182
- console.log(JSON.stringify(data, null, 2));
195
+ const skill = resolveSkillReference(await client.get('/api/skills'), skillRef);
196
+ if (version) {
197
+ const detail = await client.get(`/api/skills/${skill.id}/versions/${encodeURIComponent(version)}`);
198
+ print(detail, () => renderSkillVersion(skill.name, detail));
199
+ return;
200
+ }
201
+ const detail = await client.get(`/api/skills/${skill.id}`);
202
+ print(detail, () => renderSkillDetail(detail));
183
203
  return;
184
204
  }
185
205
  case 'upload': {
@@ -198,14 +218,14 @@ async function main() {
198
218
  description: options.flags.description,
199
219
  version,
200
220
  });
201
- console.log(JSON.stringify(created.response, null, 2));
221
+ print(created.response, () => `Created skill #${created.detail.id} "${created.detail.name}" with version ${version}.`);
202
222
  }
203
223
  else {
204
224
  const published = await publishSkillFromDirectory(client, localSkill, {
205
225
  skillId: existingSkill.id,
206
226
  version,
207
227
  });
208
- console.log(JSON.stringify(published.response, null, 2));
228
+ print(published.response, () => `Published ${published.detail.name}@${published.response.version} (skill #${published.detail.id}).`);
209
229
  }
210
230
  return;
211
231
  }
@@ -309,16 +329,21 @@ async function main() {
309
329
  const action = options.positionals[0] ?? 'list';
310
330
  if (action === 'list') {
311
331
  const pages = await client.get('/api/pages');
312
- for (const page of pages) {
313
- console.log(`${page.slug}\t${page.kind}\t${page.fileCount}\t${page.ownerDisplayName}`);
314
- }
332
+ print(pages, () => renderPageList(pages));
315
333
  return;
316
334
  }
317
335
  if (action === 'show' || action === 'runtime') {
318
336
  const slug = options.positionals[1];
319
337
  if (!slug)
320
338
  throw new Error(`pages ${action} requires <slug>`);
321
- console.log(JSON.stringify(await client.get(`/api/pages/${encodeURIComponent(slug)}${action === 'runtime' ? '/runtime' : ''}`), null, 2));
339
+ if (action === 'show') {
340
+ const page = await client.get(`/api/pages/${encodeURIComponent(slug)}`);
341
+ print(page, () => renderPageDetail(page, client.baseUrl));
342
+ }
343
+ else {
344
+ const runtime = await client.get(`/api/pages/${encodeURIComponent(slug)}/runtime`);
345
+ print(runtime, () => renderPageRuntime(slug, runtime));
346
+ }
322
347
  return;
323
348
  }
324
349
  if (action === 'upload') {
@@ -326,14 +351,15 @@ async function main() {
326
351
  if (!directory)
327
352
  throw new Error('pages upload requires <directory>');
328
353
  const resolvedDirectory = resolve(directory);
329
- const slug = normalizePageSlug(options.flags.slug ?? basename(resolvedDirectory));
354
+ const slug = resolvePageSlug(resolvedDirectory, options.flags.slug);
355
+ const existing = await describePageUploadTarget(client, slug);
330
356
  const pageFiles = await loadLocalPageFiles(resolvedDirectory);
331
- const uploadStatus = spinner({ indicator: 'timer' });
332
- uploadStatus.start(`Uploading ${pageFiles.files.length} files (${formatByteSize(pageFiles.totalBytes)}) to /p/${slug}/`);
357
+ const uploadStatus = spinner({ indicator: 'timer', output: statusOutput });
358
+ uploadStatus.start(`${existing ? 'Replacing' : 'Creating'} /p/${slug}/ with ${plural(pageFiles.files.length, 'file')} (${formatByteSize(pageFiles.totalBytes)})`);
333
359
  try {
334
360
  const detail = await client.replacePageContent(slug, pageFiles.files);
335
- uploadStatus.stop(`Published /p/${detail.slug}/ with ${detail.files.length} files`);
336
- console.log(JSON.stringify(detail, null, 2));
361
+ uploadStatus.stop(`Published /p/${detail.slug}/ as revision ${detail.contentRevision} with ${plural(detail.files.length, 'file')}`);
362
+ print(detail, () => `Open ${pageUrl(client.baseUrl, detail.slug)}`);
337
363
  }
338
364
  catch (error) {
339
365
  uploadStatus.clear();
@@ -409,7 +435,7 @@ async function checkInstalledSkillsForUpdates(client) {
409
435
  if (records.length === 0) {
410
436
  return [];
411
437
  }
412
- const status = spinner();
438
+ const status = spinner({ output: statusOutput });
413
439
  status.start(`Checking ${records.length} installed skill${records.length === 1 ? '' : 's'} for updates`);
414
440
  const problems = [];
415
441
  try {
@@ -867,7 +893,7 @@ async function promptConfirm(message, initialValue = false) {
867
893
  }
868
894
  async function loadLocalSkillContext(directory, baseUrl) {
869
895
  const resolvedDirectory = resolve(directory);
870
- const scanStatus = spinner({ indicator: 'timer' });
896
+ const scanStatus = spinner({ indicator: 'timer', output: statusOutput });
871
897
  scanStatus.start(`Scanning ${resolvedDirectory}`);
872
898
  let filePlans;
873
899
  try {
@@ -879,7 +905,7 @@ async function loadLocalSkillContext(directory, baseUrl) {
879
905
  }
880
906
  scanStatus.stop(`Found ${filePlans.length} file${filePlans.length === 1 ? '' : 's'} in ${resolvedDirectory}`);
881
907
  const totalBytes = filePlans.reduce((sum, file) => sum + file.size, 0);
882
- const readProgress = progress({ max: Math.max(filePlans.length, 1), size: 32 });
908
+ const readProgress = progress({ max: Math.max(filePlans.length, 1), size: 32, output: statusOutput });
883
909
  if (filePlans.length > 0) {
884
910
  readProgress.start(`Reading files 0/${filePlans.length}`);
885
911
  }
@@ -919,7 +945,7 @@ async function loadLocalPageFiles(directory) {
919
945
  async function createSkillFromDirectory(client, localSkill, input) {
920
946
  const name = normalizeText(input.name) ?? localSkill.defaults.name;
921
947
  const description = normalizeText(input.description) ?? localSkill.defaults.description;
922
- const uploadStatus = spinner({ indicator: 'timer' });
948
+ const uploadStatus = spinner({ indicator: 'timer', output: statusOutput });
923
949
  uploadStatus.start(`Uploading ${localSkill.files.length} file${localSkill.files.length === 1 ? '' : 's'} (${formatByteSize(localSkill.totalBytes)})`);
924
950
  try {
925
951
  const response = await client.createSkill({
@@ -940,7 +966,7 @@ async function createSkillFromDirectory(client, localSkill, input) {
940
966
  }
941
967
  }
942
968
  async function publishSkillFromDirectory(client, localSkill, input) {
943
- const uploadStatus = spinner({ indicator: 'timer' });
969
+ const uploadStatus = spinner({ indicator: 'timer', output: statusOutput });
944
970
  uploadStatus.start(`Uploading ${localSkill.files.length} file${localSkill.files.length === 1 ? '' : 's'} (${formatByteSize(localSkill.totalBytes)})`);
945
971
  try {
946
972
  const response = await client.publishVersion({
@@ -1064,7 +1090,7 @@ async function downloadResponseToFile(response, destination, onBytesTransferred)
1064
1090
  return hash.digest('hex');
1065
1091
  }
1066
1092
  function createInstallProgressReporter(input) {
1067
- const installStatus = spinner({ indicator: 'timer' });
1093
+ const installStatus = spinner({ indicator: 'timer', output: statusOutput });
1068
1094
  const state = {
1069
1095
  completedFiles: 0,
1070
1096
  completedTargets: 0,
@@ -1327,12 +1353,30 @@ function normalizeText(value) {
1327
1353
  const trimmed = value?.trim();
1328
1354
  return trimmed ? trimmed : null;
1329
1355
  }
1330
- function normalizePageSlug(value) {
1331
- const normalized = value.trim().toLowerCase();
1332
- if (!/^[a-z0-9](?:[a-z0-9-]{0,62}[a-z0-9])?$/.test(normalized)) {
1333
- throw new Error('Page slug must be 1-64 lowercase letters, numbers, or hyphens, and cannot start or end with a hyphen.');
1356
+ /**
1357
+ * Reports whether an upload replaces an existing page and stops before uploading when the
1358
+ * page belongs to someone else, so a reused slug is noticed before any files are sent.
1359
+ */
1360
+ async function describePageUploadTarget(client, slug) {
1361
+ let page;
1362
+ try {
1363
+ page = await client.get(`/api/pages/${encodeURIComponent(slug)}`);
1334
1364
  }
1335
- return normalized;
1365
+ catch (error) {
1366
+ if (error instanceof HttpError && error.status === 404) {
1367
+ statusOutput.write(`Creating a new page /p/${slug}/.\n`);
1368
+ return null;
1369
+ }
1370
+ throw error;
1371
+ }
1372
+ const me = await client.get('/api/auth/me');
1373
+ const isOwner = me.storedUserId != null && me.storedUserId === page.ownerUserId;
1374
+ if (!isOwner && !me.isAdmin) {
1375
+ throw new Error(`Page /p/${slug}/ belongs to ${page.ownerDisplayName}. Choose another --slug.`);
1376
+ }
1377
+ statusOutput.write(`Replacing all content of /p/${slug}/ (revision ${page.contentRevision}, ${plural(page.files.length, 'file')}, updated ${formatDateTime(page.updated)}).\n`
1378
+ + (isOwner ? '' : `Warning: this page belongs to ${page.ownerDisplayName}; you are replacing it as an admin.\n`));
1379
+ return page;
1336
1380
  }
1337
1381
  async function resolveExistingUploadSkill(client, localSkill, options) {
1338
1382
  const explicitSkillId = normalizeText(options?.explicitSkillId);
@@ -2162,20 +2206,6 @@ function guessContentType(path) {
2162
2206
  return 'font/woff2';
2163
2207
  return 'application/octet-stream';
2164
2208
  }
2165
- function formatByteSize(bytes) {
2166
- if (!Number.isFinite(bytes) || bytes <= 0) {
2167
- return '0 B';
2168
- }
2169
- const units = ['B', 'KB', 'MB', 'GB', 'TB'];
2170
- let value = bytes;
2171
- let unitIndex = 0;
2172
- while (value >= 1024 && unitIndex < units.length - 1) {
2173
- value /= 1024;
2174
- unitIndex += 1;
2175
- }
2176
- const decimals = value >= 10 || unitIndex === 0 ? 0 : 1;
2177
- return `${value.toFixed(decimals)} ${units[unitIndex]}`;
2178
- }
2179
2209
  function normalizeBaseUrl(url) {
2180
2210
  return url.replace(/\/+$/, '');
2181
2211
  }