@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/README.md +43 -1
- package/dist/agents-claude.js +274 -0
- package/dist/agents-codex.js +276 -0
- package/dist/agents-detect.js +147 -0
- package/dist/agents-process.js +50 -0
- package/dist/agents-session.js +267 -0
- package/dist/agents.js +230 -0
- package/dist/help.js +31 -15
- package/dist/index.js +79 -49
- package/dist/output.js +167 -0
- package/dist/pages.js +30 -1
- package/dist/teams.js +12 -4
- package/package.json +2 -2
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
|
-
|
|
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
|
|
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
|
-
|
|
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
|
-
|
|
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
|
|
190
|
+
const skillRef = options.positionals[0];
|
|
174
191
|
const version = options.positionals[1];
|
|
175
|
-
if (!
|
|
176
|
-
throw new Error('show requires <skillId>');
|
|
192
|
+
if (!skillRef) {
|
|
193
|
+
throw new Error('show requires <skillId|skillName>');
|
|
177
194
|
}
|
|
178
|
-
const
|
|
179
|
-
|
|
180
|
-
|
|
181
|
-
|
|
182
|
-
|
|
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
|
-
|
|
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
|
-
|
|
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
|
-
|
|
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
|
-
|
|
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 =
|
|
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(
|
|
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}
|
|
336
|
-
|
|
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
|
-
|
|
1331
|
-
|
|
1332
|
-
|
|
1333
|
-
|
|
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
|
-
|
|
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
|
}
|