@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 +37 -1
- package/dist/agents-detect.js +147 -0
- package/dist/agents-run.js +174 -0
- package/dist/agents.js +225 -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/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
|
-
|
|
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. 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
|
-
|
|
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
|
}
|
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
|
-
|
|
76
|
-
|
|
77
|
-
|
|
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
|
-
|
|
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.
|
|
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"
|