@quadient/skillshare 1.8.1 → 1.9.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/README.md CHANGED
@@ -19,6 +19,35 @@ bun run index.ts install 12 0.1.0 --target ./installed
19
19
  bun run index.ts install --skill-version-id 42 --target ./installed
20
20
  ```
21
21
 
22
+ ## Connected agents
23
+
24
+ `skillshare agents listen` lets Skillshare run Claude Code (`claude`) or Codex (`codex`) tasks
25
+ on this computer. The listener reports the installed agents with their models and reasoning
26
+ levels. Choose which computer and configuration runs each kind of task under **Connected
27
+ agents** in Skillshare. Agents run with your full local permissions and configuration,
28
+ without permission prompts.
29
+
30
+ ```sh
31
+ skillshare agents status
32
+ skillshare agents listen --workdir ~/Research
33
+ skillshare agents listen --name desktop --max-concurrency 2
34
+ ```
35
+
36
+ `--sandbox` keeps every agent read-only. Claude Code may only read files, search the web
37
+ and use the Skillshare MCP; Codex runs in its read-only sandbox. A profile can do the same
38
+ with `"sandbox": true`.
39
+
40
+ Optional named profiles in `~/.config/skillshare/agents.json` set a working directory and
41
+ extra arguments. Only the name, agent, model, reasoning and the working directory's last
42
+ segment are sent to Skillshare.
43
+
44
+ ```json
45
+ { "profiles": [
46
+ { "name": "piq-opus-high", "agent": "claude", "model": "opus", "reasoning": "high",
47
+ "workdir": "~/Research/piq-modules", "args": ["--add-dir", "~/Research/piq-projects"] }
48
+ ] }
49
+ ```
50
+
22
51
  ## Teams MCP
23
52
 
24
53
  Starting with 1.7.0, `skillshare teams login` opens a dedicated local browser profile,
@@ -54,6 +83,13 @@ permissions, Microsoft session lifetime and file-tool differences in the hosted
54
83
 
55
84
  ## Behavior
56
85
 
86
+ Starting with 1.9.0:
87
+
88
+ - `agents listen` and `agents status` run Claude Code and Codex tasks from Skillshare on this computer (see [Connected agents](#connected-agents)); `--sandbox` keeps them read-only.
89
+ - Commands print readable text. Scripts that parsed JSON from `whoami`, `list`, `show`, `upload`, `pages list|show|runtime|upload` or `teams status|test|upload` must add `--json`; with `--json` progress goes to stderr and stdout contains only JSON.
90
+ - `show` accepts a skill name as well as an id.
91
+ - `pages upload` refuses build output folder names such as `dist` or `build` without `--slug`, says whether it creates or replaces a page, refuses pages owned by someone else unless you are an admin, and prints the page URL.
92
+
57
93
  Starting with 1.8.0:
58
94
 
59
95
  - `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.
@@ -213,7 +249,7 @@ npx @quadient/skillshare update 12 --target ./installed
213
249
 
214
250
  Publish an authenticated static page. The upload replaces the page's complete
215
251
  content, and the directory must contain `index.html` at its root. By default the
216
- directory name is used as the page slug:
252
+ directory name is used as the page slug; build output folders such as `dist` need `--slug`:
217
253
 
218
254
  ```bash
219
255
  npx @quadient/skillshare pages upload ./release-notes
@@ -0,0 +1,147 @@
1
+ import { execFile } from 'node:child_process';
2
+ import { readFile } from 'node:fs/promises';
3
+ import { homedir } from 'node:os';
4
+ import { basename, isAbsolute, join, resolve } from 'node:path';
5
+ /** Model aliases Claude Code resolves to the latest release; Claude Code itself has no command that lists models. */
6
+ export const CLAUDE_MODELS = ['fable', 'opus', 'sonnet', 'haiku'];
7
+ export const CLAUDE_EFFORT_LEVELS = ['low', 'medium', 'high', 'xhigh', 'max'];
8
+ const NAME = /^[A-Za-z0-9][A-Za-z0-9._:-]{0,99}$/;
9
+ export function profilesPath(home = homedir()) {
10
+ return process.platform === 'win32'
11
+ ? join(process.env.APPDATA ?? join(home, 'AppData', 'Roaming'), 'skillshare', 'agents.json')
12
+ : join(home, '.config', 'skillshare', 'agents.json');
13
+ }
14
+ export async function detectAgents(options = {}) {
15
+ const home = options.home ?? homedir();
16
+ const env = options.env ?? process.env;
17
+ const version = options.version ?? commandVersion;
18
+ const read = options.read ?? readOptional;
19
+ const agents = [];
20
+ const problems = [];
21
+ const claudeVersion = await version('claude');
22
+ if (claudeVersion !== null) {
23
+ const settings = parseJson(await read(join(env.CLAUDE_CONFIG_DIR ?? join(home, '.claude'), 'settings.json')));
24
+ const configured = Array.isArray(settings?.availableModels) ? settings.availableModels.filter((m) => typeof m === 'string') : [];
25
+ const models = [...new Set([...CLAUDE_MODELS, ...configured])].filter(id => NAME.test(id) && id !== 'default')
26
+ .map(id => ({ id, reasoningLevels: CLAUDE_EFFORT_LEVELS }));
27
+ agents.push({ kind: 'claude', version: parseVersion(claudeVersion), models, warnings: [] });
28
+ }
29
+ const codexVersion = await version('codex');
30
+ if (codexVersion !== null) {
31
+ const codexHome = env.CODEX_HOME ?? join(home, '.codex');
32
+ const warnings = [];
33
+ const cache = parseJson(await read(join(codexHome, 'models_cache.json')));
34
+ const models = Array.isArray(cache?.models) ? cache.models
35
+ .filter((m) => m?.visibility === 'list' && typeof m.slug === 'string' && NAME.test(m.slug))
36
+ .map((m) => {
37
+ const levels = (Array.isArray(m.supported_reasoning_levels) ? m.supported_reasoning_levels : [])
38
+ .map((level) => typeof level === 'string' ? level : level?.effort)
39
+ .filter((level) => typeof level === 'string' && NAME.test(level) && level !== 'default');
40
+ const fallback = typeof m.default_reasoning_level === 'string' && levels.includes(m.default_reasoning_level) ? m.default_reasoning_level : undefined;
41
+ return { id: m.slug, displayName: typeof m.display_name === 'string' ? m.display_name.slice(0, 100) : undefined,
42
+ reasoningLevels: levels.slice(0, 16), defaultReasoning: fallback };
43
+ }).slice(0, 64) : [];
44
+ if (models.length === 0)
45
+ warnings.push('No Codex model list found in models_cache.json. Run codex once to refresh it; only the default model is offered.');
46
+ const configured = defaultCodexModel(await read(join(codexHome, 'config.toml')));
47
+ if (configured && models.length > 0 && !models.some(m => m.id === configured))
48
+ warnings.push(`The default model "${configured}" from config.toml is not in the Codex model list and may be rejected; pick a model explicitly.`);
49
+ agents.push({ kind: 'codex', version: parseVersion(codexVersion), models, warnings });
50
+ }
51
+ const profiles = parseProfiles(await read(profilesPath(home)), home, problems);
52
+ for (const profile of profiles)
53
+ if (!agents.some(agent => agent.kind === profile.agent))
54
+ problems.push(`Profile "${profile.name}" uses ${profile.agent}, which is not installed.`);
55
+ return {
56
+ capabilities: { agents, profiles: profiles.map(p => ({ name: p.name, agent: p.agent, model: p.model, reasoning: p.reasoning,
57
+ workdir: p.workdir ? basename(p.workdir) : undefined, ...(p.sandbox ? { sandbox: true } : {}) })) },
58
+ profiles,
59
+ problems,
60
+ };
61
+ }
62
+ export function parseProfiles(text, home, problems = []) {
63
+ if (text === null)
64
+ return [];
65
+ const parsed = parseJson(text);
66
+ if (!Array.isArray(parsed?.profiles)) {
67
+ problems.push(`${profilesPath(home)} must contain { "profiles": [...] }.`);
68
+ return [];
69
+ }
70
+ const profiles = [];
71
+ for (const raw of parsed.profiles) {
72
+ const name = raw?.name;
73
+ if (typeof name !== 'string' || !NAME.test(name)) {
74
+ problems.push('Skipped a profile without a valid name.');
75
+ continue;
76
+ }
77
+ if (raw.agent !== 'claude' && raw.agent !== 'codex') {
78
+ problems.push(`Profile "${name}" needs "agent": "claude" or "codex".`);
79
+ continue;
80
+ }
81
+ if (profiles.some(p => p.name === name)) {
82
+ problems.push(`Profile "${name}" is declared twice.`);
83
+ continue;
84
+ }
85
+ const optional = (key) => typeof raw[key] === 'string' && NAME.test(raw[key]) ? raw[key] : undefined;
86
+ if (raw.model !== undefined && !optional('model') || raw.reasoning !== undefined && !optional('reasoning')) {
87
+ problems.push(`Profile "${name}" has an invalid model or reasoning.`);
88
+ continue;
89
+ }
90
+ if (raw.sandbox !== undefined && typeof raw.sandbox !== 'boolean') {
91
+ problems.push(`Profile "${name}" sandbox must be true or false.`);
92
+ continue;
93
+ }
94
+ if (raw.args !== undefined && (!Array.isArray(raw.args) || raw.args.some(arg => typeof arg !== 'string'))) {
95
+ problems.push(`Profile "${name}" args must be a list of strings.`);
96
+ continue;
97
+ }
98
+ profiles.push({ name, agent: raw.agent, model: optional('model'), reasoning: optional('reasoning'),
99
+ workdir: typeof raw.workdir === 'string' && raw.workdir.length > 0 ? expandHome(raw.workdir, home) : undefined,
100
+ args: raw.args ?? [], ...(raw.sandbox === true ? { sandbox: true } : {}) });
101
+ }
102
+ return profiles.slice(0, 32);
103
+ }
104
+ export function expandHome(path, home = homedir()) {
105
+ const expanded = path === '~' ? home : path.startsWith('~/') ? join(home, path.slice(2)) : path;
106
+ return isAbsolute(expanded) ? expanded : resolve(expanded);
107
+ }
108
+ /** Reads the top-level model key only; profiles and providers further down the file are ignored. */
109
+ export function defaultCodexModel(toml) {
110
+ if (!toml)
111
+ return undefined;
112
+ for (const line of toml.split(/\r?\n/)) {
113
+ if (/^\s*\[/.test(line))
114
+ return undefined;
115
+ const match = /^\s*model\s*=\s*"([^"]+)"/.exec(line);
116
+ if (match)
117
+ return match[1];
118
+ }
119
+ return undefined;
120
+ }
121
+ function parseVersion(output) {
122
+ return (/\d+\.\d+(?:\.\d+)?(?:[-+][\w.]+)?/.exec(output)?.[0] ?? output.trim()).slice(0, 100) || undefined;
123
+ }
124
+ function parseJson(text) {
125
+ if (text === null)
126
+ return null;
127
+ try {
128
+ const value = JSON.parse(text);
129
+ return value && typeof value === 'object' ? value : null;
130
+ }
131
+ catch {
132
+ return null;
133
+ }
134
+ }
135
+ async function readOptional(path) {
136
+ try {
137
+ return await readFile(path, 'utf8');
138
+ }
139
+ catch {
140
+ return null;
141
+ }
142
+ }
143
+ function commandVersion(command) {
144
+ return new Promise(resolvePromise => {
145
+ execFile(command, ['--version'], { timeout: 15_000, windowsHide: true, shell: process.platform === 'win32' }, (error, stdout) => resolvePromise(error ? null : stdout.toString()));
146
+ });
147
+ }
@@ -0,0 +1,174 @@
1
+ import { spawn } from 'node:child_process';
2
+ import { mkdtemp, readFile, rm, writeFile } from 'node:fs/promises';
3
+ import { tmpdir } from 'node:os';
4
+ import { join } from 'node:path';
5
+ const DEFAULT = 'default';
6
+ /** Read-only tools Claude Code may use in a sandboxed run; MCP servers from the assignment are added per server. */
7
+ export const CLAUDE_SANDBOX_TOOLS = ['Read', 'Grep', 'Glob', 'WebSearch', 'WebFetch'];
8
+ export const SANDBOX_NOTE = '\n\nThis run is sandboxed: you can read files, search and use the provided MCP tools, but you cannot change files or run ' +
9
+ 'commands that modify anything. Give your final answer as your last message; writing SKILLSHARE_AGENT_RESULT may not be possible.';
10
+ /**
11
+ * Builds the agent invocation. The prompt goes to stdin and tokens to environment variables, so neither
12
+ * appears in the process list. Agents run without permission prompts, with the user's own configuration.
13
+ */
14
+ export function isSandboxed(assignment, options) {
15
+ return options.sandbox === true || options.profile?.sandbox === true || assignment.configuration.sandbox === true;
16
+ }
17
+ export function buildAgentCommand(assignment, options) {
18
+ const sandbox = isSandboxed(assignment, options);
19
+ const { configuration } = assignment;
20
+ const env = { SKILLSHARE_AGENT_RESULT: join(options.directory, 'result.md'), SKILLSHARE_TASK_ID: assignment.taskId };
21
+ const servers = assignment.mcpServers.map((server, index) => {
22
+ const variable = `SKILLSHARE_MCP_TOKEN_${index}`;
23
+ env[variable] = server.bearerToken;
24
+ return { name: server.name.replace(/[^A-Za-z0-9_-]/g, '_') || `server_${index}`, url: server.url, variable };
25
+ });
26
+ const cwd = options.profile?.workdir ?? options.workdir;
27
+ const extra = options.profile?.args ?? [];
28
+ if (configuration.agent === 'claude') {
29
+ // A sandboxed run never prompts: anything outside the read-only allowlist is denied.
30
+ const args = ['-p', '--output-format', 'json', ...(sandbox
31
+ ? ['--permission-mode', 'dontAsk', '--allowedTools', ...CLAUDE_SANDBOX_TOOLS, ...servers.map(server => `mcp__${server.name}`)]
32
+ : ['--dangerously-skip-permissions'])];
33
+ if (configuration.model !== DEFAULT)
34
+ args.push('--model', configuration.model);
35
+ if (configuration.reasoning !== DEFAULT)
36
+ args.push('--effort', configuration.reasoning);
37
+ if (servers.length > 0)
38
+ args.push('--mcp-config', join(options.directory, 'mcp.json'));
39
+ return { command: 'claude', args: [...args, ...extra], env, cwd };
40
+ }
41
+ const args = ['exec', '--skip-git-repo-check', ...(sandbox ? ['--sandbox', 'read-only', '-c', 'approval_policy="never"'] : ['--dangerously-bypass-approvals-and-sandbox']),
42
+ '--output-last-message', join(options.directory, 'last-message.md')];
43
+ if (configuration.model !== DEFAULT)
44
+ args.push('-m', configuration.model);
45
+ if (configuration.reasoning !== DEFAULT)
46
+ args.push('-c', `model_reasoning_effort=${JSON.stringify(configuration.reasoning)}`);
47
+ for (const server of servers)
48
+ args.push('-c', `mcp_servers.${server.name}.url=${JSON.stringify(server.url)}`, '-c', `mcp_servers.${server.name}.bearer_token_env_var=${JSON.stringify(server.variable)}`);
49
+ return { command: 'codex', args: [...args, ...extra, '-'], env, cwd };
50
+ }
51
+ export function claudeMcpConfig(servers) {
52
+ return JSON.stringify({
53
+ mcpServers: Object.fromEntries(servers.map((server, index) => [server.name.replace(/[^A-Za-z0-9_-]/g, '_') || `server_${index}`,
54
+ { type: 'http', url: server.url, headers: { Authorization: `Bearer \${SKILLSHARE_MCP_TOKEN_${index}}` } }])),
55
+ }, null, 2);
56
+ }
57
+ /** Reads Claude Code's --output-format json summary. */
58
+ export function parseClaudeOutput(stdout) {
59
+ try {
60
+ const data = JSON.parse(stdout.trim().split(/\r?\n/).filter(Boolean).at(-1) ?? '');
61
+ const usage = data.usage ?? {};
62
+ const input = [usage.input_tokens, usage.cache_creation_input_tokens, usage.cache_read_input_tokens]
63
+ .filter((value) => typeof value === 'number');
64
+ return {
65
+ result: typeof data.result === 'string' ? data.result : undefined,
66
+ isError: data.is_error === true,
67
+ stats: {
68
+ durationMs: typeof data.duration_ms === 'number' ? data.duration_ms : undefined,
69
+ turns: typeof data.num_turns === 'number' ? data.num_turns : undefined,
70
+ costUsd: typeof data.total_cost_usd === 'number' ? data.total_cost_usd : undefined,
71
+ inputTokens: input.length > 0 ? input.reduce((a, b) => a + b, 0) : undefined,
72
+ outputTokens: typeof usage.output_tokens === 'number' ? usage.output_tokens : undefined,
73
+ },
74
+ };
75
+ }
76
+ catch {
77
+ return { isError: false, stats: {} };
78
+ }
79
+ }
80
+ export async function runAgent(assignment, options) {
81
+ const now = options.now ?? Date.now;
82
+ const started = now();
83
+ const directory = await mkdtemp(join(tmpdir(), 'skillshare-agent-'));
84
+ try {
85
+ const command = buildAgentCommand(assignment, { directory, workdir: options.workdir, profile: options.profile, sandbox: options.sandbox });
86
+ if (assignment.configuration.agent === 'claude' && assignment.mcpServers.length > 0)
87
+ await writeFile(join(directory, 'mcp.json'), claudeMcpConfig(assignment.mcpServers), { mode: 0o600 });
88
+ const prompt = assignment.prompt + (isSandboxed(assignment, options) ? SANDBOX_NOTE : '');
89
+ const outcome = await execute(command, prompt, Math.max(1, assignment.timeoutSeconds) * 1000, options.signal, options.spawnAgent ?? spawn);
90
+ const measured = { durationMs: now() - started };
91
+ const claude = assignment.configuration.agent === 'claude' ? parseClaudeOutput(outcome.stdout) : undefined;
92
+ const stats = { ...measured, ...Object.fromEntries(Object.entries(claude?.stats ?? {}).filter(([, value]) => value !== undefined)) };
93
+ if (outcome.cancelled)
94
+ return { ok: false, reason: 'Cancelled on request.', cancelled: true, stats };
95
+ if (outcome.timedOut)
96
+ return { ok: false, reason: `The agent did not finish within ${assignment.timeoutSeconds} seconds.`, cancelled: false, stats };
97
+ if (outcome.spawnError)
98
+ return { ok: false, reason: `Could not start ${command.command}: ${outcome.spawnError}`, cancelled: false, stats };
99
+ const answer = await readAnswer(directory, assignment.configuration.agent, claude?.result);
100
+ if (outcome.code !== 0 || claude?.isError) {
101
+ const detail = (claude?.isError ? claude.result : undefined) ?? lastLines(outcome.stderr) ?? `exit code ${outcome.code}`;
102
+ return { ok: false, reason: `${command.command} failed: ${detail}`.slice(0, 500), cancelled: false, stats };
103
+ }
104
+ if (!answer)
105
+ return { ok: false, reason: 'The agent finished without an answer.', cancelled: false, stats };
106
+ return { ok: true, markdown: answer, stats };
107
+ }
108
+ finally {
109
+ await rm(directory, { recursive: true, force: true });
110
+ }
111
+ }
112
+ async function readAnswer(directory, agent, claudeResult) {
113
+ for (const file of ['result.md', ...(agent === 'codex' ? ['last-message.md'] : [])]) {
114
+ try {
115
+ const text = (await readFile(join(directory, file), 'utf8')).trim();
116
+ if (text)
117
+ return text;
118
+ }
119
+ catch { }
120
+ }
121
+ return claudeResult?.trim() || undefined;
122
+ }
123
+ function lastLines(text) {
124
+ const lines = text.trim().split(/\r?\n/).filter(line => line.trim()).slice(-3).join(' ').trim();
125
+ return lines ? lines.slice(-300) : undefined;
126
+ }
127
+ function execute(command, input, timeoutMs, signal, spawnAgent) {
128
+ return new Promise(resolvePromise => {
129
+ const posix = process.platform !== 'win32';
130
+ const child = spawnAgent(command.command, command.args, {
131
+ cwd: command.cwd, env: { ...process.env, ...command.env }, stdio: ['pipe', 'pipe', 'pipe'],
132
+ // A separate process group lets cancellation stop the agent's own child processes too.
133
+ detached: posix, windowsHide: true, shell: !posix,
134
+ });
135
+ let stdout = '';
136
+ let stderr = '';
137
+ let cancelled = false;
138
+ let timedOut = false;
139
+ let settled = false;
140
+ const stop = () => {
141
+ const kill = (sig) => {
142
+ try {
143
+ if (posix && child.pid)
144
+ process.kill(-child.pid, sig);
145
+ else
146
+ child.kill(sig);
147
+ }
148
+ catch { }
149
+ };
150
+ kill('SIGTERM');
151
+ setTimeout(() => kill('SIGKILL'), 5000).unref();
152
+ };
153
+ const onAbort = () => { cancelled = true; stop(); };
154
+ const timer = setTimeout(() => { timedOut = true; stop(); }, timeoutMs);
155
+ signal.addEventListener('abort', onAbort, { once: true });
156
+ const finish = (outcome) => {
157
+ if (settled)
158
+ return;
159
+ settled = true;
160
+ clearTimeout(timer);
161
+ signal.removeEventListener('abort', onAbort);
162
+ resolvePromise(outcome);
163
+ };
164
+ child.stdout?.on('data', chunk => { if (stdout.length < 2_000_000)
165
+ stdout += chunk; });
166
+ child.stderr?.on('data', chunk => { stderr = (stderr + chunk).slice(-4000); });
167
+ child.on('error', error => finish({ code: null, stdout, stderr, cancelled, timedOut, spawnError: error.message }));
168
+ child.on('close', code => finish({ code, stdout, stderr, cancelled, timedOut }));
169
+ child.stdin?.on('error', () => { });
170
+ child.stdin?.end(input);
171
+ if (signal.aborted)
172
+ onAbort();
173
+ });
174
+ }
package/dist/agents.js ADDED
@@ -0,0 +1,225 @@
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 { detectAgents, profilesPath } from './agents-detect.js';
7
+ import { runAgent } from './agents-run.js';
8
+ import { readCliVersion } from './help.js';
9
+ const root = '/api/agents';
10
+ const PROFILE_HINT = 'Declare named profiles in ' + profilesPath() + ' to set a working directory or extra arguments.';
11
+ export class AgentsHttpError extends Error {
12
+ status;
13
+ constructor(status, message) {
14
+ super(message);
15
+ this.status = status;
16
+ }
17
+ }
18
+ async function send(client, method, path, body, signal) {
19
+ const response = await client.request(path, { method, headers: { 'Content-Type': 'application/json', Accept: 'application/json' }, body: JSON.stringify(body), signal });
20
+ if (!response.ok) {
21
+ let message = `Skillshare returned HTTP ${response.status}.`;
22
+ try {
23
+ message = (await response.json()).error ?? message;
24
+ }
25
+ catch { }
26
+ throw new AgentsHttpError(response.status, message);
27
+ }
28
+ return response.status === 204 ? undefined : await response.json();
29
+ }
30
+ /** One stable listener id per Skillshare server and account, so the web keeps routes across restarts. */
31
+ export async function loadListenerId(appDirectory, baseUrl, userId) {
32
+ const key = createHash('sha256').update(baseUrl + '\0' + userId).digest('hex').slice(0, 24);
33
+ const file = join(appDirectory, 'agents', `listener-${key}.json`);
34
+ try {
35
+ const stored = JSON.parse(await readFile(file, 'utf8'));
36
+ if (typeof stored.id === 'string' && /^[A-Za-z0-9_-]{8,64}$/.test(stored.id))
37
+ return stored.id;
38
+ }
39
+ catch { }
40
+ const id = randomBytes(12).toString('base64url');
41
+ await mkdir(dirname(file), { recursive: true, mode: 0o700 });
42
+ await writeFile(file, JSON.stringify({ id }, null, 2), { mode: 0o600 });
43
+ return id;
44
+ }
45
+ export async function runAgentsCommand(client, args, flags, appDirectory) {
46
+ const action = args[0];
47
+ if (action === 'status') {
48
+ const detected = await detectAgents();
49
+ const user = await client.get('/api/auth/me');
50
+ const listenerId = await loadListenerId(appDirectory, client.baseUrl, user.id);
51
+ if (flags.json === 'true') {
52
+ console.log(JSON.stringify({ listenerId, server: client.baseUrl, ...detected.capabilities, problems: detected.problems }, null, 2));
53
+ return;
54
+ }
55
+ console.log(renderDetected(listenerId, client.baseUrl, detected));
56
+ return;
57
+ }
58
+ if (action !== 'listen')
59
+ throw new Error('agents requires listen or status.');
60
+ const max = flags['max-concurrency'] === undefined ? undefined : Number(flags['max-concurrency']);
61
+ if (max !== undefined && (!Number.isInteger(max) || max < 1 || max > 4))
62
+ throw new Error('--max-concurrency must be between 1 and 4.');
63
+ const workdir = resolve(flags.workdir ?? process.cwd());
64
+ const sandbox = flags.sandbox === 'true';
65
+ const user = await client.get('/api/auth/me');
66
+ const listenerId = await loadListenerId(appDirectory, client.baseUrl, user.id);
67
+ const abort = new AbortController();
68
+ const stop = () => abort.abort();
69
+ process.once('SIGINT', stop);
70
+ process.once('SIGTERM', stop);
71
+ console.log(sandbox
72
+ ? 'Sandboxed: agents may read files, search and use the Skillshare MCP tools, but cannot change files or run commands that modify anything.'
73
+ : 'Agents run with your full local permissions and configuration, without permission prompts. Use --sandbox to keep them read-only.');
74
+ try {
75
+ await listen({ client, listenerId, name: flags.name, workdir, maxConcurrency: max, sandbox, signal: abort.signal, onProgress: console.log });
76
+ }
77
+ finally {
78
+ process.removeListener('SIGINT', stop);
79
+ process.removeListener('SIGTERM', stop);
80
+ }
81
+ }
82
+ export function renderDetected(listenerId, server, detected) {
83
+ const lines = [`Listener ${listenerId} on ${server}`];
84
+ if (detected.capabilities.agents.length === 0)
85
+ lines.push('No supported agent found. Install Claude Code (claude) or Codex (codex) and make sure it is on PATH.');
86
+ for (const agent of detected.capabilities.agents) {
87
+ lines.push(`${agent.kind === 'claude' ? 'Claude Code' : 'Codex'} ${agent.version ?? ''}`.trimEnd());
88
+ for (const model of agent.models)
89
+ lines.push(` ${model.id}${model.reasoningLevels.length ? ` (${model.reasoningLevels.join(', ')})` : ''}`);
90
+ for (const warning of agent.warnings)
91
+ lines.push(` ! ${warning}`);
92
+ }
93
+ if (detected.profiles.length === 0)
94
+ lines.push(PROFILE_HINT);
95
+ for (const profile of detected.profiles)
96
+ lines.push(`Profile ${profile.name}: ${profile.agent}${profile.model ? ' ' + profile.model : ''}${profile.reasoning ? ' ' + profile.reasoning : ''}${profile.workdir ? ' in ' + profile.workdir : ''}${profile.sandbox ? ' (sandbox)' : ''}`);
97
+ for (const problem of detected.problems)
98
+ lines.push(`! ${problem}`);
99
+ return lines.join('\n');
100
+ }
101
+ export async function listen(options) {
102
+ const detect = options.detect ?? (() => detectAgents());
103
+ const run = options.run ?? runAgent;
104
+ const wait = options.wait ?? (async (ms, signal) => { await delay(ms, undefined, { signal }); });
105
+ const now = options.now ?? Date.now;
106
+ const log = options.onProgress ?? (() => { });
107
+ const running = new Map();
108
+ let detected = await detect();
109
+ let registeredFingerprint = '';
110
+ let detectedAt = now();
111
+ let failures = 0;
112
+ const capabilities = () => options.sandbox ? { ...detected.capabilities, sandbox: true } : detected.capabilities;
113
+ async function register() {
114
+ const fingerprint = JSON.stringify(capabilities());
115
+ const registered = await send(options.client, 'PUT', `${root}/listeners/${options.listenerId}`, {
116
+ name: options.name ?? hostname(), host: hostname(), os: platform(), cliVersion: readCliVersion(), maxConcurrency: options.maxConcurrency,
117
+ capabilities: capabilities(),
118
+ }, options.signal);
119
+ registeredFingerprint = fingerprint;
120
+ for (const problem of detected.problems)
121
+ log(`! ${problem}`);
122
+ log(`Listening as "${registered.name}" with ${registered.effectiveConcurrency} slot(s): ${describe(detected)}. Choose what runs where under Connected agents on ${options.client.baseUrl}.`);
123
+ }
124
+ async function report(assignment, result) {
125
+ const path = `${root}/tasks/${assignment.taskId}/${result.ok ? 'result' : 'failure'}`;
126
+ const body = result.ok ? { leaseToken: assignment.leaseToken, markdown: result.markdown, stats: result.stats }
127
+ : { leaseToken: assignment.leaseToken, reason: result.reason, cancelled: result.cancelled, stats: result.stats };
128
+ for (let attempt = 0; attempt < 5; attempt++) {
129
+ try {
130
+ await send(options.client, 'POST', path, body);
131
+ return;
132
+ }
133
+ catch (error) {
134
+ // A rejected lease means the server already moved on; retrying cannot help.
135
+ if (error instanceof AgentsHttpError && error.status >= 400 && error.status < 500) {
136
+ log(`Task ${assignment.taskId} result was not accepted: ${error.message}`);
137
+ return;
138
+ }
139
+ if (attempt < 4)
140
+ await delay(2000 * (attempt + 1)).catch(() => { });
141
+ }
142
+ }
143
+ log(`Task ${assignment.taskId} result could not be delivered; the server will retry the task after its lease expires.`);
144
+ }
145
+ function start(assignment) {
146
+ if (running.has(assignment.taskId))
147
+ return;
148
+ const abort = new AbortController();
149
+ const profile = assignment.configuration.profile ? detected.profiles.find(p => p.name === assignment.configuration.profile) : undefined;
150
+ log(`Started task ${assignment.taskId} "${assignment.title}" with ${label(assignment)}.`);
151
+ const done = (async () => {
152
+ let result;
153
+ try {
154
+ result = await run(assignment, { workdir: options.workdir, profile, sandbox: options.sandbox, signal: abort.signal });
155
+ }
156
+ catch (error) {
157
+ result = { ok: false, reason: `The listener could not run the agent: ${error instanceof Error ? error.message : String(error)}`, cancelled: false, stats: {} };
158
+ }
159
+ log(result.ok ? `Finished task ${assignment.taskId}.` : `${result.cancelled ? 'Cancelled' : 'Failed'} task ${assignment.taskId}: ${result.reason}`);
160
+ await report(assignment, result);
161
+ running.delete(assignment.taskId);
162
+ })();
163
+ running.set(assignment.taskId, { assignment, abort, done });
164
+ }
165
+ try {
166
+ while (!options.signal.aborted) {
167
+ try {
168
+ if (now() - detectedAt > (options.redetectMs ?? 5 * 60_000)) {
169
+ detected = await detect();
170
+ detectedAt = now();
171
+ }
172
+ if (registeredFingerprint !== JSON.stringify(capabilities()))
173
+ await register();
174
+ const polledAt = now();
175
+ const response = await send(options.client, 'POST', `${root}/listeners/${options.listenerId}/poll`, {
176
+ // A task that is already stopping is not reported, otherwise the server would answer every poll with its cancellation.
177
+ running: [...running.values()].filter(r => !r.abort.signal.aborted)
178
+ .map(r => ({ taskId: Number(r.assignment.taskId), leaseToken: r.assignment.leaseToken })),
179
+ waitSeconds: options.pollWaitSeconds ?? 25,
180
+ }, options.signal);
181
+ failures = 0;
182
+ for (const id of response.cancel)
183
+ running.get(id)?.abort.abort();
184
+ for (const assignment of response.assignments)
185
+ start(assignment);
186
+ if (response.assignments.length === 0 && now() - polledAt < 1000)
187
+ await wait(1000, options.signal).catch(() => { });
188
+ }
189
+ catch (error) {
190
+ if (options.signal.aborted)
191
+ break;
192
+ if (error instanceof AgentsHttpError && error.status === 404) {
193
+ registeredFingerprint = '';
194
+ continue;
195
+ }
196
+ failures++;
197
+ if (failures === 1)
198
+ log(`Skillshare is unreachable or rejected the listener (${error instanceof Error ? error.message : String(error)}). Retrying.`);
199
+ try {
200
+ await wait(Math.min(60_000, 2000 * 2 ** Math.min(failures, 5)), options.signal);
201
+ }
202
+ catch {
203
+ break;
204
+ }
205
+ }
206
+ }
207
+ }
208
+ finally {
209
+ for (const task of running.values())
210
+ task.abort.abort();
211
+ await Promise.all([...running.values()].map(task => task.done));
212
+ }
213
+ }
214
+ function label(assignment) {
215
+ const { configuration } = assignment;
216
+ if (configuration.profile)
217
+ return `profile ${configuration.profile}`;
218
+ return [configuration.agent === 'claude' ? 'Claude Code' : 'Codex', configuration.model, configuration.reasoning]
219
+ .filter(part => part !== 'default').join(' ');
220
+ }
221
+ function describe(detected) {
222
+ const agents = detected.capabilities.agents.map(a => `${a.kind === 'claude' ? 'Claude Code' : 'Codex'} ${a.version ?? ''}`.trim());
223
+ const profiles = detected.profiles.length ? `, profiles ${detected.profiles.map(p => p.name).join(', ')}` : '';
224
+ return (agents.length ? agents.join(', ') : 'no supported agent found') + profiles;
225
+ }
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. Choose which listener and configuration runs each kind of task under Connected agents in Skillshare. Agents run with your full local permissions, without permission prompts, unless --sandbox (or "sandbox": true in a profile) keeps them read-only.',
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
  }
package/dist/output.js ADDED
@@ -0,0 +1,167 @@
1
+ // Human-readable renderers for CLI commands. Every command also supports --json for scripts.
2
+ export function formatTable(headers, rows) {
3
+ const widths = headers.map((header, index) => Math.max(header.length, ...rows.map((row) => (row[index] ?? '').length)));
4
+ const line = (cells) => cells
5
+ .map((cell, index) => (index === cells.length - 1 ? cell : cell.padEnd(widths[index] ?? 0)))
6
+ .join(' ')
7
+ .trimEnd();
8
+ return [line(headers), ...rows.map(line)].join('\n');
9
+ }
10
+ export function formatDateTime(value) {
11
+ if (!value)
12
+ return '—';
13
+ const date = new Date(value);
14
+ if (Number.isNaN(date.getTime()))
15
+ return value;
16
+ const pad = (part) => String(part).padStart(2, '0');
17
+ return `${date.getFullYear()}-${pad(date.getMonth() + 1)}-${pad(date.getDate())} ${pad(date.getHours())}:${pad(date.getMinutes())}`;
18
+ }
19
+ export function formatByteSize(bytes) {
20
+ if (!Number.isFinite(bytes) || bytes <= 0) {
21
+ return '0 B';
22
+ }
23
+ const units = ['B', 'KB', 'MB', 'GB', 'TB'];
24
+ let value = bytes;
25
+ let unitIndex = 0;
26
+ while (value >= 1024 && unitIndex < units.length - 1) {
27
+ value /= 1024;
28
+ unitIndex += 1;
29
+ }
30
+ const decimals = value >= 10 || unitIndex === 0 ? 0 : 1;
31
+ return `${value.toFixed(decimals)} ${units[unitIndex]}`;
32
+ }
33
+ function fields(entries) {
34
+ const present = entries.filter((entry) => entry[1] !== null && entry[1] !== undefined && entry[1] !== '');
35
+ const width = Math.max(...present.map(([label]) => label.length + 1));
36
+ return present.map(([label, value]) => `${`${label}:`.padEnd(width)} ${value}`);
37
+ }
38
+ export function plural(count, noun) {
39
+ return `${count} ${noun}${count === 1 ? '' : 's'}`;
40
+ }
41
+ function fileLines(files) {
42
+ const width = Math.max(0, ...files.map((file) => file.nameWithPath.length));
43
+ return files.map((file) => ` ${file.nameWithPath.padEnd(width)} ${formatByteSize(file.length)}`);
44
+ }
45
+ function limitedList(lines, limit, what) {
46
+ return lines.length > limit
47
+ ? [...lines.slice(0, limit), ` … and ${lines.length - limit} more ${what} (use --json for all)`]
48
+ : lines;
49
+ }
50
+ export function renderUser(user, server) {
51
+ return [
52
+ `${user.displayName}${user.email ? ` <${user.email}>` : ''}`,
53
+ ...fields([
54
+ ['Server', server],
55
+ ['Role', user.isAdmin ? 'admin' : 'user'],
56
+ ['Microsoft account', user.entraObjectId ? 'linked' : 'not linked yet (open Skillshare in the browser once)'],
57
+ ]),
58
+ ].join('\n');
59
+ }
60
+ export function renderSkillList(skills) {
61
+ if (skills.length === 0)
62
+ return 'No skills in the registry yet.';
63
+ return formatTable(['ID', 'NAME', 'LATEST', 'UPDATED'], skills.map((skill) => [
64
+ String(skill.id),
65
+ `${skill.name}${skill.deprecated ? ' (deprecated)' : ''}`,
66
+ skill.latestVersion ?? '—',
67
+ formatDateTime(skill.updated),
68
+ ]));
69
+ }
70
+ export function renderSkillDetail(skill) {
71
+ const lines = [
72
+ `${skill.name} (#${skill.id})${skill.deprecated ? ' — deprecated' : ''}`,
73
+ skill.description,
74
+ '',
75
+ ...fields([
76
+ ['Owner', skill.ownerDisplayName],
77
+ ['Latest', skill.latestVersion ?? 'no published version'],
78
+ ['Updated', skill.updated ? formatDateTime(skill.updated) : undefined],
79
+ ['Install', `skillshare install ${skill.id}`],
80
+ ]),
81
+ ];
82
+ if (skill.versions.length > 0) {
83
+ lines.push('', formatTable(['VERSION', 'PUBLISHED', 'BY', 'FILES', 'DOWNLOADS'], skill.versions.map((version) => [
84
+ `${version.version}${version.isLatest ? ' (latest)' : ''}`,
85
+ formatDateTime(version.created),
86
+ version.createdByDisplayName ?? '—',
87
+ String(version.files.length),
88
+ version.downloadCount === undefined ? '—' : String(version.downloadCount),
89
+ ])));
90
+ }
91
+ return lines.join('\n');
92
+ }
93
+ export function renderSkillVersion(skillName, version) {
94
+ const totalBytes = version.files.reduce((sum, file) => sum + file.length, 0);
95
+ return [
96
+ `${skillName} ${version.version}${version.isLatest ? ' (latest)' : ''}`,
97
+ ...fields([
98
+ ['Published', `${formatDateTime(version.created)}${version.createdByDisplayName ? ` by ${version.createdByDisplayName}` : ''}`],
99
+ ['Downloads', version.downloadCount],
100
+ ]),
101
+ '',
102
+ `Files (${version.files.length}, ${formatByteSize(totalBytes)}):`,
103
+ ...limitedList(fileLines(version.files), 50, 'files'),
104
+ ].join('\n');
105
+ }
106
+ export function renderPageList(pages) {
107
+ if (pages.length === 0)
108
+ return 'No pages published yet.';
109
+ return formatTable(['SLUG', 'KIND', 'FILES', 'SIZE', 'OWNER', 'UPDATED'], pages.map((page) => [
110
+ page.slug,
111
+ page.kind === 'celld' ? 'app' : 'static',
112
+ String(page.fileCount),
113
+ formatByteSize(page.totalBytes),
114
+ page.ownerDisplayName,
115
+ formatDateTime(page.updated),
116
+ ]));
117
+ }
118
+ export function renderPageDetail(page, baseUrl, now = new Date()) {
119
+ const month = `${now.getUTCFullYear()}-${String(now.getUTCMonth() + 1).padStart(2, '0')}`;
120
+ const totalBytes = page.files.reduce((sum, file) => sum + file.length, 0);
121
+ return [
122
+ `${page.slug} (${page.kind === 'celld' ? 'application' : 'static site'})`,
123
+ ...fields([
124
+ ['URL', pageUrl(baseUrl, page.slug)],
125
+ ['Owner', page.ownerDisplayName],
126
+ ['Revision', `${page.contentRevision}, updated ${formatDateTime(page.updated)}`],
127
+ ['Users this month', page.monthlyUsage.find((usage) => usage.month === month)?.uniqueUsers ?? 0],
128
+ ]),
129
+ '',
130
+ `Files (${page.files.length}, ${formatByteSize(totalBytes)}):`,
131
+ ...limitedList(fileLines(page.files), 30, 'files'),
132
+ ].join('\n');
133
+ }
134
+ export function renderPageRuntime(slug, runtime, logLimit = 20) {
135
+ const lines = [
136
+ `${slug}: ${runtime.state} · revision ${runtime.revision}${runtime.processId ? ` · pid ${runtime.processId}` : ''}`,
137
+ ];
138
+ if (runtime.error)
139
+ lines.push(`Error: ${runtime.error}`);
140
+ if (runtime.logs.length > 0) {
141
+ const shown = runtime.logs.slice(-logLimit);
142
+ lines.push('', `Last ${shown.length} of ${runtime.logs.length} log lines${runtime.logs.length > shown.length ? ' (use --json for all)' : ''}:`, ...shown);
143
+ }
144
+ else {
145
+ lines.push('No log output yet.');
146
+ }
147
+ return lines.join('\n');
148
+ }
149
+ export function renderTeamsStatus(status) {
150
+ const state = status.requiresReconnect
151
+ ? 'reconnect required — run skillshare teams login'
152
+ : status.configured
153
+ ? `connected${status.identity ? ` as ${status.identity}` : ''}`
154
+ : 'not connected — run skillshare teams login';
155
+ return [
156
+ `Teams: ${state}`,
157
+ ...fields([
158
+ ['Access', status.configured ? status.accessMode : undefined],
159
+ ['Session expires', status.configured && status.expiresAt ? formatDateTime(status.expiresAt) : undefined],
160
+ ['Updated', status.updatedAt ? formatDateTime(status.updatedAt) : undefined],
161
+ ['MCP endpoint', status.endpoint],
162
+ ]),
163
+ ].join('\n');
164
+ }
165
+ export function pageUrl(baseUrl, slug) {
166
+ return `${baseUrl.replace(/\/+$/, '')}/p/${encodeURIComponent(slug)}/`;
167
+ }
package/dist/pages.js CHANGED
@@ -1,7 +1,36 @@
1
1
  import { builtinModules } from 'node:module';
2
2
  import { build } from 'esbuild';
3
3
  import { parse, printParseErrorCode } from 'jsonc-parser';
4
- import { resolve, relative } from 'node:path';
4
+ import { basename, dirname, resolve, relative } from 'node:path';
5
+ // Build output folders say nothing about the page, so publishing them under their own name is almost always a mistake.
6
+ const GENERIC_DIRECTORY_NAMES = new Set(['dist', 'build', 'out', 'output', 'public', 'www', 'static', 'site', '_site', 'html', 'web', 'export']);
7
+ const SLUG_PATTERN = /^[a-z0-9](?:[a-z0-9-]{0,62}[a-z0-9])?$/;
8
+ export function normalizePageSlug(value) {
9
+ const normalized = value.trim().toLowerCase();
10
+ if (!SLUG_PATTERN.test(normalized)) {
11
+ throw new Error('Page slug must be 1-64 lowercase letters, numbers, or hyphens, and cannot start or end with a hyphen.');
12
+ }
13
+ return normalized;
14
+ }
15
+ export function resolvePageSlug(directory, explicitSlug) {
16
+ if (explicitSlug !== undefined)
17
+ return normalizePageSlug(explicitSlug);
18
+ const resolvedDirectory = resolve(directory);
19
+ const name = basename(resolvedDirectory);
20
+ if (GENERIC_DIRECTORY_NAMES.has(name.toLowerCase().replace(/^\./, ''))) {
21
+ const suggestion = toSlugCandidate(basename(dirname(resolvedDirectory)));
22
+ throw new Error(`'${name}' looks like a build output folder, not a page name. Pass --slug, for example --slug ${suggestion && !GENERIC_DIRECTORY_NAMES.has(suggestion) ? suggestion : 'my-page'}.`);
23
+ }
24
+ const candidate = toSlugCandidate(name);
25
+ if (!candidate) {
26
+ throw new Error(`Cannot derive a page slug from '${name}'. Pass --slug <slug>.`);
27
+ }
28
+ return candidate;
29
+ }
30
+ function toSlugCandidate(value) {
31
+ const candidate = value.toLowerCase().replace(/[^a-z0-9]+/g, '-').replace(/^-+|-+$/g, '').slice(0, 64).replace(/-+$/, '');
32
+ return SLUG_PATTERN.test(candidate) ? candidate : '';
33
+ }
5
34
  export async function preparePageFiles(directory, files) {
6
35
  const configurations = files.filter(file => file.relativePath === 'wrangler.json' || file.relativePath === 'wrangler.jsonc');
7
36
  if (configurations.length > 1)
package/dist/teams.js CHANGED
@@ -6,6 +6,7 @@ import { Readable } from 'node:stream';
6
6
  import { pipeline } from 'node:stream/promises';
7
7
  import { captureTeamsLogin } from './teams-auth.js';
8
8
  import { watchTeamsConnection } from './teams-watch.js';
9
+ import { renderTeamsStatus } from './output.js';
9
10
  const root = '/api/mcps/providers/teams';
10
11
  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
  /**
@@ -72,9 +73,15 @@ export async function runTeamsCommand(client, args, flags, appDirectory) {
72
73
  }
73
74
  return;
74
75
  }
75
- if (action === 'status' || action === 'test') {
76
- const result = action === 'test' ? await client.post(`${root}/test`) : await client.get(`${root}/settings`);
77
- console.log(JSON.stringify(result, null, 2));
76
+ const json = flags.json === 'true';
77
+ if (action === 'status') {
78
+ const status = await client.get(`${root}/settings`);
79
+ console.log(json ? JSON.stringify(status, null, 2) : renderTeamsStatus(status));
80
+ return;
81
+ }
82
+ if (action === 'test') {
83
+ const result = await client.post(`${root}/test`);
84
+ console.log(json ? JSON.stringify(result, null, 2) : `Teams connection works${result.identity ? ` for ${result.identity}` : ''}.`);
78
85
  return;
79
86
  }
80
87
  if (action === 'logout') {
@@ -107,7 +114,8 @@ export async function runTeamsCommand(client, args, flags, appDirectory) {
107
114
  body: Readable.toWeb(stream), duplex: 'half',
108
115
  });
109
116
  await expectOk(response);
110
- console.log(JSON.stringify(await response.json(), null, 2));
117
+ const item = await response.json();
118
+ console.log(json ? JSON.stringify(item, null, 2) : `Uploaded ${item.name ?? basename(path)} to your Teams files.${item.webUrl ? `\n ${item.webUrl}` : ''}`);
111
119
  }
112
120
  finally {
113
121
  stream.destroy();
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@quadient/skillshare",
3
- "version": "1.8.1",
3
+ "version": "1.9.0",
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 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"
19
+ "test": "tsx --test cli-args.test.ts install-files.test.ts output.test.ts pages.test.ts pages-dev.test.ts teams-auth.test.ts teams-watch.test.ts teams-browser.test.ts agents.test.ts"
20
20
  },
21
21
  "publishConfig": {
22
22
  "access": "public"