@quadient/skillshare 1.8.0 → 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 +41 -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 +89 -51
- package/dist/output.js +167 -0
- package/dist/pages.js +30 -1
- package/dist/teams-auth.js +1 -1
- package/dist/teams.js +28 -5
- 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,
|
|
@@ -41,6 +70,10 @@ skillshare teams access readOnly
|
|
|
41
70
|
skillshare teams logout
|
|
42
71
|
```
|
|
43
72
|
|
|
73
|
+
Starting with 1.8.1, Teams uses the Microsoft account object id that Skillshare records at
|
|
74
|
+
web sign-in, not the Skillshare user id. If the server does not know it yet, `teams login`
|
|
75
|
+
opens one more Skillshare sign-in in the browser to link the account.
|
|
76
|
+
|
|
44
77
|
Use `teams login local` for localhost or `--server <https-origin>` for another host.
|
|
45
78
|
`login` accepts `--server` too. Browser options: `--browser chrome|msedge|chromium`,
|
|
46
79
|
`--headless`, and `--browser-profile <dedicated-profile-directory>`.
|
|
@@ -50,6 +83,13 @@ permissions, Microsoft session lifetime and file-tool differences in the hosted
|
|
|
50
83
|
|
|
51
84
|
## Behavior
|
|
52
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
|
+
|
|
53
93
|
Starting with 1.8.0:
|
|
54
94
|
|
|
55
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.
|
|
@@ -209,7 +249,7 @@ npx @quadient/skillshare update 12 --target ./installed
|
|
|
209
249
|
|
|
210
250
|
Publish an authenticated static page. The upload replaces the page's complete
|
|
211
251
|
content, and the directory must contain `index.html` at its root. By default the
|
|
212
|
-
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`:
|
|
213
253
|
|
|
214
254
|
```bash
|
|
215
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
|
-
import { assertCredentialDestination, runTeamsCommand } from './teams.js';
|
|
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;
|
|
@@ -122,7 +133,23 @@ async function main() {
|
|
|
122
133
|
assertCredentialDestination(baseUrl);
|
|
123
134
|
if (!options.token && !await loadSession(baseUrl))
|
|
124
135
|
await login(baseUrl);
|
|
125
|
-
|
|
136
|
+
const teamsClient = await createClient(baseUrl, options.token);
|
|
137
|
+
const action = options.positionals[0];
|
|
138
|
+
if (!options.token && (action === 'login' || action === 'watch')
|
|
139
|
+
&& !resolveTeamsObjectId(await teamsClient.get('/api/auth/me'))) {
|
|
140
|
+
// A browser sign-in lets the server record the Entra object id for this account.
|
|
141
|
+
console.log('Linking your Microsoft account to Skillshare. Sign in once more in the browser.');
|
|
142
|
+
await login(baseUrl);
|
|
143
|
+
}
|
|
144
|
+
await runTeamsCommand(teamsClient, options.positionals, options.flags, APP_DIR);
|
|
145
|
+
return;
|
|
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);
|
|
126
153
|
return;
|
|
127
154
|
}
|
|
128
155
|
case 'git': {
|
|
@@ -151,27 +178,28 @@ async function main() {
|
|
|
151
178
|
switch (command) {
|
|
152
179
|
case 'whoami': {
|
|
153
180
|
const user = await client.get('/api/auth/me');
|
|
154
|
-
|
|
181
|
+
print(user, () => renderUser(user, client.baseUrl));
|
|
155
182
|
return;
|
|
156
183
|
}
|
|
157
184
|
case 'list': {
|
|
158
185
|
const skills = await client.get('/api/skills');
|
|
159
|
-
|
|
160
|
-
console.log(`${skill.id}\t${skill.latestVersion}\t${skill.name}`);
|
|
161
|
-
}
|
|
186
|
+
print(skills, () => renderSkillList(skills));
|
|
162
187
|
return;
|
|
163
188
|
}
|
|
164
189
|
case 'show': {
|
|
165
|
-
const
|
|
190
|
+
const skillRef = options.positionals[0];
|
|
166
191
|
const version = options.positionals[1];
|
|
167
|
-
if (!
|
|
168
|
-
throw new Error('show requires <skillId>');
|
|
192
|
+
if (!skillRef) {
|
|
193
|
+
throw new Error('show requires <skillId|skillName>');
|
|
194
|
+
}
|
|
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;
|
|
169
200
|
}
|
|
170
|
-
const
|
|
171
|
-
|
|
172
|
-
: `/api/skills/${skillId}`;
|
|
173
|
-
const data = await client.get(resource);
|
|
174
|
-
console.log(JSON.stringify(data, null, 2));
|
|
201
|
+
const detail = await client.get(`/api/skills/${skill.id}`);
|
|
202
|
+
print(detail, () => renderSkillDetail(detail));
|
|
175
203
|
return;
|
|
176
204
|
}
|
|
177
205
|
case 'upload': {
|
|
@@ -190,14 +218,14 @@ async function main() {
|
|
|
190
218
|
description: options.flags.description,
|
|
191
219
|
version,
|
|
192
220
|
});
|
|
193
|
-
|
|
221
|
+
print(created.response, () => `Created skill #${created.detail.id} "${created.detail.name}" with version ${version}.`);
|
|
194
222
|
}
|
|
195
223
|
else {
|
|
196
224
|
const published = await publishSkillFromDirectory(client, localSkill, {
|
|
197
225
|
skillId: existingSkill.id,
|
|
198
226
|
version,
|
|
199
227
|
});
|
|
200
|
-
|
|
228
|
+
print(published.response, () => `Published ${published.detail.name}@${published.response.version} (skill #${published.detail.id}).`);
|
|
201
229
|
}
|
|
202
230
|
return;
|
|
203
231
|
}
|
|
@@ -301,16 +329,21 @@ async function main() {
|
|
|
301
329
|
const action = options.positionals[0] ?? 'list';
|
|
302
330
|
if (action === 'list') {
|
|
303
331
|
const pages = await client.get('/api/pages');
|
|
304
|
-
|
|
305
|
-
console.log(`${page.slug}\t${page.kind}\t${page.fileCount}\t${page.ownerDisplayName}`);
|
|
306
|
-
}
|
|
332
|
+
print(pages, () => renderPageList(pages));
|
|
307
333
|
return;
|
|
308
334
|
}
|
|
309
335
|
if (action === 'show' || action === 'runtime') {
|
|
310
336
|
const slug = options.positionals[1];
|
|
311
337
|
if (!slug)
|
|
312
338
|
throw new Error(`pages ${action} requires <slug>`);
|
|
313
|
-
|
|
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
|
+
}
|
|
314
347
|
return;
|
|
315
348
|
}
|
|
316
349
|
if (action === 'upload') {
|
|
@@ -318,14 +351,15 @@ async function main() {
|
|
|
318
351
|
if (!directory)
|
|
319
352
|
throw new Error('pages upload requires <directory>');
|
|
320
353
|
const resolvedDirectory = resolve(directory);
|
|
321
|
-
const slug =
|
|
354
|
+
const slug = resolvePageSlug(resolvedDirectory, options.flags.slug);
|
|
355
|
+
const existing = await describePageUploadTarget(client, slug);
|
|
322
356
|
const pageFiles = await loadLocalPageFiles(resolvedDirectory);
|
|
323
|
-
const uploadStatus = spinner({ indicator: 'timer' });
|
|
324
|
-
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)})`);
|
|
325
359
|
try {
|
|
326
360
|
const detail = await client.replacePageContent(slug, pageFiles.files);
|
|
327
|
-
uploadStatus.stop(`Published /p/${detail.slug}/ with ${detail.files.length}
|
|
328
|
-
|
|
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)}`);
|
|
329
363
|
}
|
|
330
364
|
catch (error) {
|
|
331
365
|
uploadStatus.clear();
|
|
@@ -401,7 +435,7 @@ async function checkInstalledSkillsForUpdates(client) {
|
|
|
401
435
|
if (records.length === 0) {
|
|
402
436
|
return [];
|
|
403
437
|
}
|
|
404
|
-
const status = spinner();
|
|
438
|
+
const status = spinner({ output: statusOutput });
|
|
405
439
|
status.start(`Checking ${records.length} installed skill${records.length === 1 ? '' : 's'} for updates`);
|
|
406
440
|
const problems = [];
|
|
407
441
|
try {
|
|
@@ -859,7 +893,7 @@ async function promptConfirm(message, initialValue = false) {
|
|
|
859
893
|
}
|
|
860
894
|
async function loadLocalSkillContext(directory, baseUrl) {
|
|
861
895
|
const resolvedDirectory = resolve(directory);
|
|
862
|
-
const scanStatus = spinner({ indicator: 'timer' });
|
|
896
|
+
const scanStatus = spinner({ indicator: 'timer', output: statusOutput });
|
|
863
897
|
scanStatus.start(`Scanning ${resolvedDirectory}`);
|
|
864
898
|
let filePlans;
|
|
865
899
|
try {
|
|
@@ -871,7 +905,7 @@ async function loadLocalSkillContext(directory, baseUrl) {
|
|
|
871
905
|
}
|
|
872
906
|
scanStatus.stop(`Found ${filePlans.length} file${filePlans.length === 1 ? '' : 's'} in ${resolvedDirectory}`);
|
|
873
907
|
const totalBytes = filePlans.reduce((sum, file) => sum + file.size, 0);
|
|
874
|
-
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 });
|
|
875
909
|
if (filePlans.length > 0) {
|
|
876
910
|
readProgress.start(`Reading files 0/${filePlans.length}`);
|
|
877
911
|
}
|
|
@@ -911,7 +945,7 @@ async function loadLocalPageFiles(directory) {
|
|
|
911
945
|
async function createSkillFromDirectory(client, localSkill, input) {
|
|
912
946
|
const name = normalizeText(input.name) ?? localSkill.defaults.name;
|
|
913
947
|
const description = normalizeText(input.description) ?? localSkill.defaults.description;
|
|
914
|
-
const uploadStatus = spinner({ indicator: 'timer' });
|
|
948
|
+
const uploadStatus = spinner({ indicator: 'timer', output: statusOutput });
|
|
915
949
|
uploadStatus.start(`Uploading ${localSkill.files.length} file${localSkill.files.length === 1 ? '' : 's'} (${formatByteSize(localSkill.totalBytes)})`);
|
|
916
950
|
try {
|
|
917
951
|
const response = await client.createSkill({
|
|
@@ -932,7 +966,7 @@ async function createSkillFromDirectory(client, localSkill, input) {
|
|
|
932
966
|
}
|
|
933
967
|
}
|
|
934
968
|
async function publishSkillFromDirectory(client, localSkill, input) {
|
|
935
|
-
const uploadStatus = spinner({ indicator: 'timer' });
|
|
969
|
+
const uploadStatus = spinner({ indicator: 'timer', output: statusOutput });
|
|
936
970
|
uploadStatus.start(`Uploading ${localSkill.files.length} file${localSkill.files.length === 1 ? '' : 's'} (${formatByteSize(localSkill.totalBytes)})`);
|
|
937
971
|
try {
|
|
938
972
|
const response = await client.publishVersion({
|
|
@@ -1056,7 +1090,7 @@ async function downloadResponseToFile(response, destination, onBytesTransferred)
|
|
|
1056
1090
|
return hash.digest('hex');
|
|
1057
1091
|
}
|
|
1058
1092
|
function createInstallProgressReporter(input) {
|
|
1059
|
-
const installStatus = spinner({ indicator: 'timer' });
|
|
1093
|
+
const installStatus = spinner({ indicator: 'timer', output: statusOutput });
|
|
1060
1094
|
const state = {
|
|
1061
1095
|
completedFiles: 0,
|
|
1062
1096
|
completedTargets: 0,
|
|
@@ -1319,12 +1353,30 @@ function normalizeText(value) {
|
|
|
1319
1353
|
const trimmed = value?.trim();
|
|
1320
1354
|
return trimmed ? trimmed : null;
|
|
1321
1355
|
}
|
|
1322
|
-
|
|
1323
|
-
|
|
1324
|
-
|
|
1325
|
-
|
|
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)}`);
|
|
1326
1364
|
}
|
|
1327
|
-
|
|
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;
|
|
1328
1380
|
}
|
|
1329
1381
|
async function resolveExistingUploadSkill(client, localSkill, options) {
|
|
1330
1382
|
const explicitSkillId = normalizeText(options?.explicitSkillId);
|
|
@@ -2154,20 +2206,6 @@ function guessContentType(path) {
|
|
|
2154
2206
|
return 'font/woff2';
|
|
2155
2207
|
return 'application/octet-stream';
|
|
2156
2208
|
}
|
|
2157
|
-
function formatByteSize(bytes) {
|
|
2158
|
-
if (!Number.isFinite(bytes) || bytes <= 0) {
|
|
2159
|
-
return '0 B';
|
|
2160
|
-
}
|
|
2161
|
-
const units = ['B', 'KB', 'MB', 'GB', 'TB'];
|
|
2162
|
-
let value = bytes;
|
|
2163
|
-
let unitIndex = 0;
|
|
2164
|
-
while (value >= 1024 && unitIndex < units.length - 1) {
|
|
2165
|
-
value /= 1024;
|
|
2166
|
-
unitIndex += 1;
|
|
2167
|
-
}
|
|
2168
|
-
const decimals = value >= 10 || unitIndex === 0 ? 0 : 1;
|
|
2169
|
-
return `${value.toFixed(decimals)} ${units[unitIndex]}`;
|
|
2170
|
-
}
|
|
2171
2209
|
function normalizeBaseUrl(url) {
|
|
2172
2210
|
return url.replace(/\/+$/, '');
|
|
2173
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-auth.js
CHANGED
|
@@ -16,7 +16,7 @@ export function decodeTeamsJwt(value) {
|
|
|
16
16
|
/** Select only credentials belonging to the Skillshare account. No full browser state leaves this machine. */
|
|
17
17
|
export function extractTeamsSession(state, expectedObjectId, now = Date.now(), allowExpired = false) {
|
|
18
18
|
if (!uuid.test(expectedObjectId))
|
|
19
|
-
throw new Error('
|
|
19
|
+
throw new Error('The Microsoft account object id is missing. Sign in to Skillshare in the browser, then run skillshare teams login again.');
|
|
20
20
|
const stores = state.origins.filter(origin => origins.has(origin.origin));
|
|
21
21
|
const entries = stores.flatMap(origin => origin.localStorage.flatMap(item => {
|
|
22
22
|
try {
|
package/dist/teams.js
CHANGED
|
@@ -6,7 +6,19 @@ 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';
|
|
11
|
+
const guid = /^[a-f\d]{8}-[a-f\d]{4}-[a-f\d]{4}-[a-f\d]{4}-[a-f\d]{12}$/i;
|
|
12
|
+
/**
|
|
13
|
+
* Skillshare identifies users by the pairwise subject; Teams credentials carry the Entra
|
|
14
|
+
* object id, which the server records at web sign-in. Older servers only return a GUID id.
|
|
15
|
+
*/
|
|
16
|
+
export function resolveTeamsObjectId(account) {
|
|
17
|
+
if (account.entraObjectId && guid.test(account.entraObjectId))
|
|
18
|
+
return account.entraObjectId;
|
|
19
|
+
return guid.test(account.id) ? account.id : null;
|
|
20
|
+
}
|
|
21
|
+
export const UNLINKED_ACCOUNT_MESSAGE = 'Skillshare does not know your Microsoft account yet. Run "skillshare login" (or open Skillshare in the browser and sign in), then run "skillshare teams login" again.';
|
|
10
22
|
async function expectOk(response) {
|
|
11
23
|
if (response.ok)
|
|
12
24
|
return;
|
|
@@ -23,11 +35,15 @@ export async function runTeamsCommand(client, args, flags, appDirectory) {
|
|
|
23
35
|
const action = args[0];
|
|
24
36
|
if (action === 'login' || action === 'watch') {
|
|
25
37
|
const user = await client.get('/api/auth/me');
|
|
38
|
+
const resolvedObjectId = resolveTeamsObjectId(user);
|
|
39
|
+
if (!resolvedObjectId)
|
|
40
|
+
throw new Error(UNLINKED_ACCOUNT_MESSAGE);
|
|
41
|
+
const objectId = resolvedObjectId;
|
|
26
42
|
const key = createHash('sha256').update(client.baseUrl + '\0' + user.id).digest('hex').slice(0, 24);
|
|
27
43
|
const profileDirectory = flags['browser-profile'] ? resolve(flags['browser-profile']) : join(appDirectory, 'teams', key, 'browser-profile');
|
|
28
44
|
const abort = new AbortController();
|
|
29
45
|
async function connect(connectionId) {
|
|
30
|
-
const session = await captureTeamsLogin({ profileDirectory, objectId
|
|
46
|
+
const session = await captureTeamsLogin({ profileDirectory, objectId, browser: flags.browser,
|
|
31
47
|
headless: action === 'watch' ? flags.headless !== 'false' : flags.headless === 'true', recover: action === 'watch',
|
|
32
48
|
signal: abort.signal, timeoutMs: action === 'watch' ? 90_000 : undefined, onProgress: console.log });
|
|
33
49
|
const headers = { 'Content-Type': 'application/json' };
|
|
@@ -57,9 +73,15 @@ export async function runTeamsCommand(client, args, flags, appDirectory) {
|
|
|
57
73
|
}
|
|
58
74
|
return;
|
|
59
75
|
}
|
|
60
|
-
|
|
61
|
-
|
|
62
|
-
|
|
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}` : ''}.`);
|
|
63
85
|
return;
|
|
64
86
|
}
|
|
65
87
|
if (action === 'logout') {
|
|
@@ -92,7 +114,8 @@ export async function runTeamsCommand(client, args, flags, appDirectory) {
|
|
|
92
114
|
body: Readable.toWeb(stream), duplex: 'half',
|
|
93
115
|
});
|
|
94
116
|
await expectOk(response);
|
|
95
|
-
|
|
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}` : ''}`);
|
|
96
119
|
}
|
|
97
120
|
finally {
|
|
98
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"
|