@quadient/skillshare 1.10.2 → 1.10.3
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 +15 -0
- package/dist/agents-service.js +288 -0
- package/dist/agents-session.js +2 -0
- package/dist/agents-update.js +254 -0
- package/dist/agents.js +128 -8
- package/dist/help.js +7 -5
- package/dist/index.js +13 -3
- package/package.json +2 -2
package/README.md
CHANGED
|
@@ -40,6 +40,21 @@ skillshare agents listen --workdir ~/Research
|
|
|
40
40
|
skillshare agents listen --name desktop --max-concurrency 2
|
|
41
41
|
```
|
|
42
42
|
|
|
43
|
+
The listener updates itself: when Skillshare releases a newer CLI, it downloads it into
|
|
44
|
+
`~/.config/skillshare/versions` (on Windows `%APPDATA%\skillshare\versions`) and restarts on
|
|
45
|
+
it once no turn is running. `--no-update` turns that off.
|
|
46
|
+
|
|
47
|
+
To keep it running without a terminal, install it as a background service that starts at
|
|
48
|
+
every login: a launchd agent on macOS, a systemd user service on Linux and a scheduled task
|
|
49
|
+
on Windows. It takes the same options as `listen`; run it again to change them.
|
|
50
|
+
|
|
51
|
+
```sh
|
|
52
|
+
skillshare agents service install --workdir ~/Research
|
|
53
|
+
skillshare agents service status
|
|
54
|
+
skillshare agents service logs --lines 100
|
|
55
|
+
skillshare agents service uninstall
|
|
56
|
+
```
|
|
57
|
+
|
|
43
58
|
`--sandbox` keeps every agent read-only. Claude Code may only read files, search the web
|
|
44
59
|
and use the Skillshare MCP; Codex runs in its read-only sandbox. A profile can do the same
|
|
45
60
|
with `"sandbox": true`.
|
|
@@ -0,0 +1,288 @@
|
|
|
1
|
+
import { existsSync } from 'node:fs';
|
|
2
|
+
import { mkdir, readFile, rm, writeFile } from 'node:fs/promises';
|
|
3
|
+
import { homedir } from 'node:os';
|
|
4
|
+
import { dirname, join } from 'node:path';
|
|
5
|
+
import { describeOtherListener, installVersion, readListenerLock, runCommand, } from './agents-update.js';
|
|
6
|
+
export const LAUNCHD_LABEL = 'com.quadient.skillshare.agents';
|
|
7
|
+
export const SYSTEMD_UNIT = 'skillshare-agents.service';
|
|
8
|
+
export const WINDOWS_TASK = 'Skillshare Agents';
|
|
9
|
+
export function defaultServiceEnvironment(appDir) {
|
|
10
|
+
const platform = process.platform;
|
|
11
|
+
if (platform !== 'darwin' && platform !== 'linux' && platform !== 'win32')
|
|
12
|
+
throw new Error(`The agents service is not supported on ${platform}.`);
|
|
13
|
+
return {
|
|
14
|
+
appDir, platform, home: homedir(), run: runCommand, uid: process.getuid?.(),
|
|
15
|
+
windowsUser: process.env.USERDOMAIN ? `${process.env.USERDOMAIN}\\${process.env.USERNAME}` : process.env.USERNAME,
|
|
16
|
+
};
|
|
17
|
+
}
|
|
18
|
+
export function servicePaths(appDir) {
|
|
19
|
+
return {
|
|
20
|
+
config: join(appDir, 'agents-service.json'),
|
|
21
|
+
launcher: join(appDir, 'agents-service.mjs'),
|
|
22
|
+
log: join(appDir, 'logs', 'agents.log'),
|
|
23
|
+
// What the service manager catches before the listener's own log opens, such as a launcher that cannot start.
|
|
24
|
+
output: join(appDir, 'logs', 'agents-service.out'),
|
|
25
|
+
windowsTask: join(appDir, 'agents-service-task.xml'),
|
|
26
|
+
};
|
|
27
|
+
}
|
|
28
|
+
/** Where the service manager keeps the service definition. */
|
|
29
|
+
export function definitionPath(environment) {
|
|
30
|
+
switch (environment.platform) {
|
|
31
|
+
case 'darwin': return join(environment.home, 'Library', 'LaunchAgents', `${LAUNCHD_LABEL}.plist`);
|
|
32
|
+
case 'linux': return join(process.env.XDG_CONFIG_HOME ?? join(environment.home, '.config'), 'systemd', 'user', SYSTEMD_UNIT);
|
|
33
|
+
case 'win32': return servicePaths(environment.appDir).windowsTask;
|
|
34
|
+
}
|
|
35
|
+
}
|
|
36
|
+
/**
|
|
37
|
+
* The launcher the service manager starts. It does not depend on any CLI version: it picks the newest installed one, so an
|
|
38
|
+
* update downloaded by the listener also applies after a restart of the computer.
|
|
39
|
+
*/
|
|
40
|
+
export function launcherSource() {
|
|
41
|
+
return `// Starts the Skillshare agents listener as a background service. Written by skillshare agents service install.
|
|
42
|
+
import { existsSync, readFileSync, readdirSync } from 'node:fs'
|
|
43
|
+
import { dirname, join } from 'node:path'
|
|
44
|
+
import { fileURLToPath, pathToFileURL } from 'node:url'
|
|
45
|
+
|
|
46
|
+
const appDir = dirname(fileURLToPath(import.meta.url))
|
|
47
|
+
const config = JSON.parse(readFileSync(join(appDir, 'agents-service.json'), 'utf8'))
|
|
48
|
+
const versions = join(appDir, 'versions')
|
|
49
|
+
const parts = (version) => version.split('.').map(Number)
|
|
50
|
+
const compare = (a, b) => { const [x, y] = [parts(a), parts(b)]; return x[0] - y[0] || x[1] - y[1] || x[2] - y[2] }
|
|
51
|
+
const installed = existsSync(versions)
|
|
52
|
+
? readdirSync(versions).filter((name) => /^\\d+\\.\\d+\\.\\d+$/.test(name) && existsSync(join(versions, name, '.installed'))).sort(compare)
|
|
53
|
+
: []
|
|
54
|
+
const version = installed.at(-1)
|
|
55
|
+
if (!version) {
|
|
56
|
+
console.error('No Skillshare CLI is installed for the agents service. Run skillshare agents service install again.')
|
|
57
|
+
process.exit(1)
|
|
58
|
+
}
|
|
59
|
+
const entry = join(versions, version, 'node_modules', '@quadient', 'skillshare', 'dist', 'index.js')
|
|
60
|
+
process.env.PATH = config.path
|
|
61
|
+
process.env.SKILLSHARE_AGENTS_SERVICE = '1'
|
|
62
|
+
process.chdir(appDir)
|
|
63
|
+
process.argv = [process.argv[0], entry, 'agents', 'listen', '--server', config.server, '--workdir', config.workdir,
|
|
64
|
+
'--log-file', join(appDir, 'logs', 'agents.log'), ...config.args]
|
|
65
|
+
await import(pathToFileURL(entry).href)
|
|
66
|
+
`;
|
|
67
|
+
}
|
|
68
|
+
function xml(value) {
|
|
69
|
+
return value.replace(/&/g, '&').replace(/</g, '<').replace(/>/g, '>').replace(/"/g, '"');
|
|
70
|
+
}
|
|
71
|
+
/** A launchd agent in the user's login session, so agents reach the keychain and the browser; started again if it fails. */
|
|
72
|
+
export function launchdPlist(config, appDir) {
|
|
73
|
+
const paths = servicePaths(appDir);
|
|
74
|
+
return `<?xml version="1.0" encoding="UTF-8"?>
|
|
75
|
+
<!DOCTYPE plist PUBLIC "-//Apple//DTD PLIST 1.0//EN" "http://www.apple.com/DTDs/PropertyList-1.0.dtd">
|
|
76
|
+
<plist version="1.0">
|
|
77
|
+
<dict>
|
|
78
|
+
<key>Label</key>
|
|
79
|
+
<string>${LAUNCHD_LABEL}</string>
|
|
80
|
+
<key>ProgramArguments</key>
|
|
81
|
+
<array>
|
|
82
|
+
<string>${xml(config.node)}</string>
|
|
83
|
+
<string>${xml(paths.launcher)}</string>
|
|
84
|
+
</array>
|
|
85
|
+
<key>WorkingDirectory</key>
|
|
86
|
+
<string>${xml(appDir)}</string>
|
|
87
|
+
<key>RunAtLoad</key>
|
|
88
|
+
<true/>
|
|
89
|
+
<key>KeepAlive</key>
|
|
90
|
+
<dict>
|
|
91
|
+
<key>SuccessfulExit</key>
|
|
92
|
+
<false/>
|
|
93
|
+
</dict>
|
|
94
|
+
<key>ThrottleInterval</key>
|
|
95
|
+
<integer>30</integer>
|
|
96
|
+
<key>ProcessType</key>
|
|
97
|
+
<string>Interactive</string>
|
|
98
|
+
<key>StandardOutPath</key>
|
|
99
|
+
<string>${xml(paths.output)}</string>
|
|
100
|
+
<key>StandardErrorPath</key>
|
|
101
|
+
<string>${xml(paths.output)}</string>
|
|
102
|
+
</dict>
|
|
103
|
+
</plist>
|
|
104
|
+
`;
|
|
105
|
+
}
|
|
106
|
+
function systemdQuote(value) {
|
|
107
|
+
return `"${value.replace(/\\/g, '\\\\').replace(/"/g, '\\"').replace(/%/g, '%%')}"`;
|
|
108
|
+
}
|
|
109
|
+
/** A systemd user service, started with the user's session and again whenever it fails. */
|
|
110
|
+
export function systemdUnit(config, appDir) {
|
|
111
|
+
const paths = servicePaths(appDir);
|
|
112
|
+
return `[Unit]
|
|
113
|
+
Description=Skillshare agents listener
|
|
114
|
+
After=network-online.target
|
|
115
|
+
|
|
116
|
+
[Service]
|
|
117
|
+
Type=simple
|
|
118
|
+
ExecStart=${systemdQuote(config.node)} ${systemdQuote(paths.launcher)}
|
|
119
|
+
WorkingDirectory=${systemdQuote(appDir)}
|
|
120
|
+
Restart=on-failure
|
|
121
|
+
RestartSec=30
|
|
122
|
+
StandardOutput=append:${paths.output}
|
|
123
|
+
StandardError=append:${paths.output}
|
|
124
|
+
|
|
125
|
+
[Install]
|
|
126
|
+
WantedBy=default.target
|
|
127
|
+
`;
|
|
128
|
+
}
|
|
129
|
+
/**
|
|
130
|
+
* A scheduled task that starts at logon in the user's session, without a time limit and also on battery. conhost
|
|
131
|
+
* --headless runs Node without a console window.
|
|
132
|
+
*/
|
|
133
|
+
export function windowsTaskXml(config, appDir, user) {
|
|
134
|
+
const paths = servicePaths(appDir);
|
|
135
|
+
return `<?xml version="1.0" encoding="UTF-16"?>
|
|
136
|
+
<Task version="1.2" xmlns="http://schemas.microsoft.com/windows/2004/02/mit/task">
|
|
137
|
+
<RegistrationInfo>
|
|
138
|
+
<Description>Skillshare agents listener</Description>
|
|
139
|
+
</RegistrationInfo>
|
|
140
|
+
<Triggers>
|
|
141
|
+
<LogonTrigger>
|
|
142
|
+
<Enabled>true</Enabled>
|
|
143
|
+
<UserId>${xml(user)}</UserId>
|
|
144
|
+
</LogonTrigger>
|
|
145
|
+
</Triggers>
|
|
146
|
+
<Principals>
|
|
147
|
+
<Principal id="Author">
|
|
148
|
+
<UserId>${xml(user)}</UserId>
|
|
149
|
+
<LogonType>InteractiveToken</LogonType>
|
|
150
|
+
<RunLevel>LeastPrivilege</RunLevel>
|
|
151
|
+
</Principal>
|
|
152
|
+
</Principals>
|
|
153
|
+
<Settings>
|
|
154
|
+
<MultipleInstancesPolicy>IgnoreNew</MultipleInstancesPolicy>
|
|
155
|
+
<DisallowStartIfOnBatteries>false</DisallowStartIfOnBatteries>
|
|
156
|
+
<StopIfGoingOnBatteries>false</StopIfGoingOnBatteries>
|
|
157
|
+
<AllowHardTerminate>true</AllowHardTerminate>
|
|
158
|
+
<StartWhenAvailable>true</StartWhenAvailable>
|
|
159
|
+
<AllowStartOnDemand>true</AllowStartOnDemand>
|
|
160
|
+
<Enabled>true</Enabled>
|
|
161
|
+
<Hidden>false</Hidden>
|
|
162
|
+
<ExecutionTimeLimit>PT0S</ExecutionTimeLimit>
|
|
163
|
+
<RestartOnFailure>
|
|
164
|
+
<Interval>PT1M</Interval>
|
|
165
|
+
<Count>999</Count>
|
|
166
|
+
</RestartOnFailure>
|
|
167
|
+
</Settings>
|
|
168
|
+
<Actions Context="Author">
|
|
169
|
+
<Exec>
|
|
170
|
+
<Command>conhost.exe</Command>
|
|
171
|
+
<Arguments>--headless "${xml(config.node)}" "${xml(paths.launcher)}"</Arguments>
|
|
172
|
+
<WorkingDirectory>${xml(appDir)}</WorkingDirectory>
|
|
173
|
+
</Exec>
|
|
174
|
+
</Actions>
|
|
175
|
+
</Task>
|
|
176
|
+
`;
|
|
177
|
+
}
|
|
178
|
+
async function must(environment, command, args, what) {
|
|
179
|
+
const result = await environment.run(command, args);
|
|
180
|
+
if (result.code !== 0)
|
|
181
|
+
throw new Error(`${what} failed: ${(result.stderr || result.stdout).trim() || `exit code ${result.code}`}`);
|
|
182
|
+
return result;
|
|
183
|
+
}
|
|
184
|
+
/**
|
|
185
|
+
* Installs the CLI version for the service, writes the launcher and registers it with the service manager, which starts it
|
|
186
|
+
* now and at every login. Installing again replaces the service, for example with other options.
|
|
187
|
+
*/
|
|
188
|
+
export async function installService(environment, settings, options = {}) {
|
|
189
|
+
const other = await readListenerLock(environment.appDir);
|
|
190
|
+
if (other && !other.service)
|
|
191
|
+
throw new Error(describeOtherListener(other));
|
|
192
|
+
await (options.install ?? (version => installVersion(environment.appDir, version)))(settings.version);
|
|
193
|
+
const config = { ...settings, path: process.env.PATH ?? '', node: process.execPath, installedAt: new Date().toISOString() };
|
|
194
|
+
const paths = servicePaths(environment.appDir);
|
|
195
|
+
await mkdir(dirname(paths.log), { recursive: true });
|
|
196
|
+
await writeFile(paths.config, JSON.stringify(config, null, 2));
|
|
197
|
+
await writeFile(paths.launcher, launcherSource());
|
|
198
|
+
const definition = definitionPath(environment);
|
|
199
|
+
await mkdir(dirname(definition), { recursive: true });
|
|
200
|
+
switch (environment.platform) {
|
|
201
|
+
case 'darwin': {
|
|
202
|
+
const domain = `gui/${environment.uid}`;
|
|
203
|
+
await environment.run('launchctl', ['bootout', `${domain}/${LAUNCHD_LABEL}`]);
|
|
204
|
+
await writeFile(definition, launchdPlist(config, environment.appDir));
|
|
205
|
+
await must(environment, 'launchctl', ['bootstrap', domain, definition], 'launchctl bootstrap');
|
|
206
|
+
break;
|
|
207
|
+
}
|
|
208
|
+
case 'linux':
|
|
209
|
+
await writeFile(definition, systemdUnit(config, environment.appDir));
|
|
210
|
+
await must(environment, 'systemctl', ['--user', 'daemon-reload'], 'systemctl daemon-reload');
|
|
211
|
+
await must(environment, 'systemctl', ['--user', 'enable', SYSTEMD_UNIT], 'systemctl enable');
|
|
212
|
+
await must(environment, 'systemctl', ['--user', 'restart', SYSTEMD_UNIT], 'systemctl restart');
|
|
213
|
+
break;
|
|
214
|
+
case 'win32':
|
|
215
|
+
// schtasks reads task definitions as UTF-16.
|
|
216
|
+
await writeFile(definition, Buffer.concat([Buffer.from([0xff, 0xfe]), Buffer.from(windowsTaskXml(config, environment.appDir, environment.windowsUser ?? ''), 'utf16le')]));
|
|
217
|
+
await environment.run('schtasks', ['/End', '/TN', WINDOWS_TASK]);
|
|
218
|
+
await must(environment, 'schtasks', ['/Create', '/TN', WINDOWS_TASK, '/XML', definition, '/F'], 'schtasks /Create');
|
|
219
|
+
await must(environment, 'schtasks', ['/Run', '/TN', WINDOWS_TASK], 'schtasks /Run');
|
|
220
|
+
break;
|
|
221
|
+
}
|
|
222
|
+
return config;
|
|
223
|
+
}
|
|
224
|
+
/** Stops the service and removes it; downloaded CLI versions and logs stay. */
|
|
225
|
+
export async function uninstallService(environment) {
|
|
226
|
+
const definition = definitionPath(environment);
|
|
227
|
+
switch (environment.platform) {
|
|
228
|
+
case 'darwin':
|
|
229
|
+
await environment.run('launchctl', ['bootout', `gui/${environment.uid}/${LAUNCHD_LABEL}`]);
|
|
230
|
+
break;
|
|
231
|
+
case 'linux':
|
|
232
|
+
await environment.run('systemctl', ['--user', 'disable', '--now', SYSTEMD_UNIT]);
|
|
233
|
+
break;
|
|
234
|
+
case 'win32':
|
|
235
|
+
await environment.run('schtasks', ['/End', '/TN', WINDOWS_TASK]);
|
|
236
|
+
await environment.run('schtasks', ['/Delete', '/TN', WINDOWS_TASK, '/F']);
|
|
237
|
+
break;
|
|
238
|
+
}
|
|
239
|
+
const existed = existsSync(definition) || existsSync(servicePaths(environment.appDir).config);
|
|
240
|
+
const paths = servicePaths(environment.appDir);
|
|
241
|
+
for (const path of [definition, paths.config, paths.launcher])
|
|
242
|
+
await rm(path, { force: true });
|
|
243
|
+
if (environment.platform === 'linux')
|
|
244
|
+
await environment.run('systemctl', ['--user', 'daemon-reload']);
|
|
245
|
+
return existed;
|
|
246
|
+
}
|
|
247
|
+
export async function serviceStatus(environment) {
|
|
248
|
+
let config;
|
|
249
|
+
try {
|
|
250
|
+
config = JSON.parse(await readFile(servicePaths(environment.appDir).config, 'utf8'));
|
|
251
|
+
}
|
|
252
|
+
catch { }
|
|
253
|
+
const lock = await readListenerLock(environment.appDir);
|
|
254
|
+
const running = lock?.service === true;
|
|
255
|
+
return {
|
|
256
|
+
installed: config !== undefined && existsSync(definitionPath(environment)), config, running,
|
|
257
|
+
pid: running ? lock.pid : undefined, version: running ? lock.version : undefined, since: running ? lock.startedAt : undefined,
|
|
258
|
+
};
|
|
259
|
+
}
|
|
260
|
+
export function renderServiceStatus(status, appDir) {
|
|
261
|
+
if (!status.installed)
|
|
262
|
+
return 'The agents service is not installed. Install it with skillshare agents service install.';
|
|
263
|
+
const lines = [
|
|
264
|
+
status.running
|
|
265
|
+
? `The agents service is running (process ${status.pid}, Skillshare CLI ${status.version}, since ${status.since}).`
|
|
266
|
+
: 'The agents service is installed but not running. See skillshare agents service logs.',
|
|
267
|
+
`Server: ${status.config.server}`,
|
|
268
|
+
`Working directory: ${status.config.workdir}`,
|
|
269
|
+
];
|
|
270
|
+
if (status.config.args.length)
|
|
271
|
+
lines.push(`Options: ${status.config.args.join(' ')}`);
|
|
272
|
+
lines.push(`Log: ${servicePaths(appDir).log}`);
|
|
273
|
+
return lines.join('\n');
|
|
274
|
+
}
|
|
275
|
+
/** The end of the listener's log, and of the service manager's output when the listener could not start. */
|
|
276
|
+
export async function serviceLogs(appDir, lines) {
|
|
277
|
+
const paths = servicePaths(appDir);
|
|
278
|
+
const tail = async (path) => {
|
|
279
|
+
try {
|
|
280
|
+
return (await readFile(path, 'utf8')).trimEnd().split('\n').slice(-lines).join('\n');
|
|
281
|
+
}
|
|
282
|
+
catch {
|
|
283
|
+
return '';
|
|
284
|
+
}
|
|
285
|
+
};
|
|
286
|
+
const [log, output] = [await tail(paths.log), await tail(paths.output)];
|
|
287
|
+
return [log, output ? `--- ${paths.output}\n${output}` : ''].filter(Boolean).join('\n') || 'The agents service has not written a log yet.';
|
|
288
|
+
}
|
package/dist/agents-session.js
CHANGED
|
@@ -135,6 +135,8 @@ export class SessionRunner {
|
|
|
135
135
|
});
|
|
136
136
|
}
|
|
137
137
|
get stopping() { return this.closing !== null; }
|
|
138
|
+
/** A turn runs or a message waits for one; restarting the listener now would cut it off. */
|
|
139
|
+
get busy() { return this.turnRunning || this.steering.size > 0 || this.pending.length > 0; }
|
|
138
140
|
/**
|
|
139
141
|
* Queues messages for the next turns, or steers them into the running turn; the server may send a message again, so
|
|
140
142
|
* repeats are ignored.
|
|
@@ -0,0 +1,254 @@
|
|
|
1
|
+
import { spawn } from 'node:child_process';
|
|
2
|
+
import { appendFileSync, existsSync, mkdirSync, renameSync, statSync } from 'node:fs';
|
|
3
|
+
import { mkdir, readFile, readdir, rename, rm, writeFile } from 'node:fs/promises';
|
|
4
|
+
import { dirname, join } from 'node:path';
|
|
5
|
+
import { setTimeout as delay } from 'node:timers/promises';
|
|
6
|
+
export const PACKAGE_NAME = '@quadient/skillshare';
|
|
7
|
+
/** A listener exits with this code to be started again on the newest installed CLI. */
|
|
8
|
+
export const RESTART_FOR_UPDATE = 75;
|
|
9
|
+
/** Set for the listener the supervisor runs, so it listens instead of supervising again. */
|
|
10
|
+
export const WORKER_ENV = 'SKILLSHARE_AGENTS_WORKER';
|
|
11
|
+
/** Set when the listener runs as a background service, where nobody can answer a browser sign-in. */
|
|
12
|
+
export const SERVICE_ENV = 'SKILLSHARE_AGENTS_SERVICE';
|
|
13
|
+
const INSTALLED_MARKER = '.installed';
|
|
14
|
+
const MAX_LOG_BYTES = 5 * 1024 * 1024;
|
|
15
|
+
// A listener that fails this soon after its first start has a problem a restart will not fix, such as a bad option.
|
|
16
|
+
const EARLY_FAILURE_MS = 30_000;
|
|
17
|
+
/** Compares x.y.z versions numerically; anything after - or + is ignored. */
|
|
18
|
+
export function compareVersions(a, b) {
|
|
19
|
+
const parse = (version) => version.split(/[-+]/)[0].split('.').map(part => Number(part) || 0);
|
|
20
|
+
const [left, right] = [parse(a), parse(b)];
|
|
21
|
+
for (let index = 0; index < Math.max(left.length, right.length); index++) {
|
|
22
|
+
const difference = (left[index] ?? 0) - (right[index] ?? 0);
|
|
23
|
+
if (difference !== 0)
|
|
24
|
+
return Math.sign(difference);
|
|
25
|
+
}
|
|
26
|
+
return 0;
|
|
27
|
+
}
|
|
28
|
+
export function versionsDirectory(appDir) {
|
|
29
|
+
return join(appDir, 'versions');
|
|
30
|
+
}
|
|
31
|
+
/** The CLI entry point of an installed version. */
|
|
32
|
+
export function versionEntry(appDir, version) {
|
|
33
|
+
return join(versionsDirectory(appDir), version, 'node_modules', '@quadient', 'skillshare', 'dist', 'index.js');
|
|
34
|
+
}
|
|
35
|
+
/** Completely installed versions, oldest first. */
|
|
36
|
+
export async function installedVersions(appDir) {
|
|
37
|
+
let names = [];
|
|
38
|
+
try {
|
|
39
|
+
names = await readdir(versionsDirectory(appDir));
|
|
40
|
+
}
|
|
41
|
+
catch {
|
|
42
|
+
return [];
|
|
43
|
+
}
|
|
44
|
+
return names.filter(name => /^\d+\.\d+\.\d+$/.test(name) && existsSync(join(versionsDirectory(appDir), name, INSTALLED_MARKER)))
|
|
45
|
+
.sort(compareVersions);
|
|
46
|
+
}
|
|
47
|
+
/** Runs a command without a shell, except for Windows .cmd scripts, which Node only starts through one. */
|
|
48
|
+
export const runCommand = (command, args, options = {}) => new Promise((resolve, reject) => {
|
|
49
|
+
const shell = process.platform === 'win32' && /\.cmd$/i.test(command);
|
|
50
|
+
const child = spawn(shell ? quoteForCmd(command) : command, shell ? args.map(quoteForCmd) : args, { cwd: options.cwd, shell, windowsHide: true, stdio: ['ignore', 'pipe', 'pipe'] });
|
|
51
|
+
let stdout = '';
|
|
52
|
+
let stderr = '';
|
|
53
|
+
child.stdout.on('data', chunk => { stdout += chunk; });
|
|
54
|
+
child.stderr.on('data', chunk => { stderr += chunk; });
|
|
55
|
+
child.on('error', reject);
|
|
56
|
+
child.on('close', code => resolve({ code: code ?? 1, stdout, stderr }));
|
|
57
|
+
});
|
|
58
|
+
function quoteForCmd(value) {
|
|
59
|
+
return /[\s"&|<>^]/.test(value) ? `"${value.replace(/"/g, '""')}"` : value;
|
|
60
|
+
}
|
|
61
|
+
/** npm next to the running Node, so a service with a bare PATH still finds it; otherwise npm on PATH. */
|
|
62
|
+
export function npmCommand() {
|
|
63
|
+
const bin = dirname(process.execPath);
|
|
64
|
+
for (const cli of [join(bin, 'node_modules', 'npm', 'bin', 'npm-cli.js'), join(bin, '..', 'lib', 'node_modules', 'npm', 'bin', 'npm-cli.js')])
|
|
65
|
+
if (existsSync(cli))
|
|
66
|
+
return { command: process.execPath, args: [cli] };
|
|
67
|
+
return { command: process.platform === 'win32' ? 'npm.cmd' : 'npm', args: [] };
|
|
68
|
+
}
|
|
69
|
+
/**
|
|
70
|
+
* Installs a CLI version into its own directory under versions, without touching a global install, so no administrator
|
|
71
|
+
* rights are needed. It lands under a temporary name first, so a listener never starts a half-installed version.
|
|
72
|
+
*/
|
|
73
|
+
export async function installVersion(appDir, version, options = {}) {
|
|
74
|
+
if ((await installedVersions(appDir)).includes(version))
|
|
75
|
+
return;
|
|
76
|
+
const target = join(versionsDirectory(appDir), version);
|
|
77
|
+
const staging = `${target}.partial-${process.pid}`;
|
|
78
|
+
await rm(staging, { recursive: true, force: true });
|
|
79
|
+
await mkdir(staging, { recursive: true });
|
|
80
|
+
try {
|
|
81
|
+
// An empty project of its own keeps npm from installing into a parent directory's project.
|
|
82
|
+
await writeFile(join(staging, 'package.json'), JSON.stringify({ private: true }));
|
|
83
|
+
const npm = npmCommand();
|
|
84
|
+
const result = await (options.run ?? runCommand)(npm.command, [...npm.args, 'install', '--prefix', staging, '--no-audit', '--no-fund',
|
|
85
|
+
'--omit=dev', '--no-package-lock', options.spec ?? `${PACKAGE_NAME}@${version}`], { cwd: staging });
|
|
86
|
+
if (result.code !== 0)
|
|
87
|
+
throw new Error(`npm could not install ${PACKAGE_NAME}@${version}: ${lastLine(result.stderr) || `exit code ${result.code}`}`);
|
|
88
|
+
const installed = join(staging, 'node_modules', '@quadient', 'skillshare');
|
|
89
|
+
const actual = JSON.parse(await readFile(join(installed, 'package.json'), 'utf8'));
|
|
90
|
+
if (actual.version !== version)
|
|
91
|
+
throw new Error(`npm installed ${PACKAGE_NAME}@${actual.version ?? '?'} instead of ${version}.`);
|
|
92
|
+
await writeFile(join(staging, INSTALLED_MARKER), version);
|
|
93
|
+
await rm(target, { recursive: true, force: true });
|
|
94
|
+
await rename(staging, target);
|
|
95
|
+
}
|
|
96
|
+
catch (error) {
|
|
97
|
+
await rm(staging, { recursive: true, force: true }).catch(() => { });
|
|
98
|
+
throw error;
|
|
99
|
+
}
|
|
100
|
+
}
|
|
101
|
+
function lastLine(text) {
|
|
102
|
+
return text.trim().split('\n').filter(line => line.trim()).at(-1)?.trim() ?? '';
|
|
103
|
+
}
|
|
104
|
+
/** Removes installed versions other than the ones kept, and leftovers of interrupted installs. */
|
|
105
|
+
export async function removeOtherVersions(appDir, keep) {
|
|
106
|
+
let names = [];
|
|
107
|
+
try {
|
|
108
|
+
names = await readdir(versionsDirectory(appDir));
|
|
109
|
+
}
|
|
110
|
+
catch {
|
|
111
|
+
return;
|
|
112
|
+
}
|
|
113
|
+
for (const name of names)
|
|
114
|
+
if (!keep.includes(name))
|
|
115
|
+
await rm(join(versionsDirectory(appDir), name), { recursive: true, force: true }).catch(() => { });
|
|
116
|
+
}
|
|
117
|
+
/** Appends timestamped lines to a log file, starting a new one when it grows past a few megabytes. */
|
|
118
|
+
export function fileLog(path) {
|
|
119
|
+
mkdirSync(dirname(path), { recursive: true });
|
|
120
|
+
return (text) => {
|
|
121
|
+
try {
|
|
122
|
+
if (existsSync(path) && statSync(path).size > MAX_LOG_BYTES)
|
|
123
|
+
renameSync(path, `${path}.1`);
|
|
124
|
+
const stamp = new Date().toISOString();
|
|
125
|
+
appendFileSync(path, text.split(/\r?\n/).filter(line => line.length > 0).map(line => `${stamp} ${line}\n`).join(''));
|
|
126
|
+
}
|
|
127
|
+
catch {
|
|
128
|
+
// Logging must never stop the listener.
|
|
129
|
+
}
|
|
130
|
+
};
|
|
131
|
+
}
|
|
132
|
+
/** Where a supervisor that holds the listener records itself, so a second one does not poll as the same listener. */
|
|
133
|
+
export function listenerLockPath(appDir) {
|
|
134
|
+
return join(appDir, 'agents-listener.json');
|
|
135
|
+
}
|
|
136
|
+
export async function readListenerLock(appDir) {
|
|
137
|
+
try {
|
|
138
|
+
const lock = JSON.parse(await readFile(listenerLockPath(appDir), 'utf8'));
|
|
139
|
+
return isAlive(lock.pid) ? lock : null;
|
|
140
|
+
}
|
|
141
|
+
catch {
|
|
142
|
+
return null;
|
|
143
|
+
}
|
|
144
|
+
}
|
|
145
|
+
/** Records this process as the one listening; returns the other listener instead when one is already running. */
|
|
146
|
+
export async function acquireListenerLock(appDir, lock) {
|
|
147
|
+
const existing = await readListenerLock(appDir);
|
|
148
|
+
if (existing && existing.pid !== process.pid)
|
|
149
|
+
return existing;
|
|
150
|
+
await mkdir(appDir, { recursive: true });
|
|
151
|
+
await writeFile(listenerLockPath(appDir), JSON.stringify({ ...lock, pid: process.pid, startedAt: new Date().toISOString() }, null, 2));
|
|
152
|
+
return null;
|
|
153
|
+
}
|
|
154
|
+
export async function releaseListenerLock(appDir) {
|
|
155
|
+
const lock = await readListenerLock(appDir);
|
|
156
|
+
if (lock?.pid === process.pid)
|
|
157
|
+
await rm(listenerLockPath(appDir), { force: true }).catch(() => { });
|
|
158
|
+
}
|
|
159
|
+
export function isAlive(pid) {
|
|
160
|
+
try {
|
|
161
|
+
process.kill(pid, 0);
|
|
162
|
+
return true;
|
|
163
|
+
}
|
|
164
|
+
catch (error) {
|
|
165
|
+
// EPERM: the process exists but belongs to someone else.
|
|
166
|
+
return error.code === 'EPERM';
|
|
167
|
+
}
|
|
168
|
+
}
|
|
169
|
+
/**
|
|
170
|
+
* Keeps a listener running on the newest installed CLI. The listener downloads a new version the server announces and
|
|
171
|
+
* exits with RESTART_FOR_UPDATE once no turn is running; the supervisor then starts the new version. A listener that
|
|
172
|
+
* crashes later is restarted with a growing pause; one that fails right after its first start ends the supervisor with its
|
|
173
|
+
* exit code, because the problem is in how it was started.
|
|
174
|
+
*/
|
|
175
|
+
export async function superviseListener(options) {
|
|
176
|
+
const write = options.logFile ? fileLog(options.logFile) : undefined;
|
|
177
|
+
const log = options.log ?? ((message) => write ? write(message) : console.log(message));
|
|
178
|
+
const existing = await acquireListenerLock(options.appDir, { version: options.ownVersion, service: options.service === true });
|
|
179
|
+
if (existing) {
|
|
180
|
+
log(describeOtherListener(existing));
|
|
181
|
+
return 1;
|
|
182
|
+
}
|
|
183
|
+
let child = null;
|
|
184
|
+
let stopping = false;
|
|
185
|
+
const stop = () => {
|
|
186
|
+
if (stopping)
|
|
187
|
+
return;
|
|
188
|
+
stopping = true;
|
|
189
|
+
// In a terminal, Ctrl+C reaches the listener directly; elsewhere it is told here. Windows cannot signal gracefully.
|
|
190
|
+
if (child && process.platform !== 'win32')
|
|
191
|
+
child.kill('SIGTERM');
|
|
192
|
+
if (child)
|
|
193
|
+
setTimeout(() => child?.kill('SIGKILL'), 30_000).unref();
|
|
194
|
+
};
|
|
195
|
+
process.on('SIGINT', stop);
|
|
196
|
+
process.on('SIGTERM', stop);
|
|
197
|
+
options.signal?.addEventListener('abort', stop, { once: true });
|
|
198
|
+
let failures = 0;
|
|
199
|
+
let first = true;
|
|
200
|
+
try {
|
|
201
|
+
while (!stopping) {
|
|
202
|
+
const newest = (await installedVersions(options.appDir)).at(-1);
|
|
203
|
+
const useInstalled = newest !== undefined && compareVersions(newest, options.ownVersion) > 0;
|
|
204
|
+
const version = useInstalled ? newest : options.ownVersion;
|
|
205
|
+
const entry = useInstalled ? [versionEntry(options.appDir, newest)] : options.ownEntry ?? [...process.execArgv, process.argv[1]];
|
|
206
|
+
// Older versions are not needed any more; this CLI's own copy stays while it runs.
|
|
207
|
+
await removeOtherVersions(options.appDir, [version, options.ownVersion]);
|
|
208
|
+
log(`Starting the listener with Skillshare CLI ${version}.`);
|
|
209
|
+
await acquireListenerLock(options.appDir, { version, service: options.service === true });
|
|
210
|
+
const startedAt = Date.now();
|
|
211
|
+
child = spawn(process.execPath, [...entry, ...options.args], {
|
|
212
|
+
env: { ...process.env, [WORKER_ENV]: '1', ...(options.service ? { [SERVICE_ENV]: '1' } : {}) },
|
|
213
|
+
stdio: write ? ['ignore', 'pipe', 'pipe'] : 'inherit',
|
|
214
|
+
windowsHide: true,
|
|
215
|
+
});
|
|
216
|
+
if (write) {
|
|
217
|
+
child.stdout?.on('data', chunk => write(String(chunk)));
|
|
218
|
+
child.stderr?.on('data', chunk => write(String(chunk)));
|
|
219
|
+
}
|
|
220
|
+
const code = await new Promise(resolve => {
|
|
221
|
+
child.on('error', () => resolve(1));
|
|
222
|
+
child.on('exit', (exitCode, signal) => resolve(exitCode ?? (signal ? 128 : 1)));
|
|
223
|
+
});
|
|
224
|
+
child = null;
|
|
225
|
+
if (stopping)
|
|
226
|
+
return 0;
|
|
227
|
+
if (code === RESTART_FOR_UPDATE) {
|
|
228
|
+
failures = 0;
|
|
229
|
+
first = false;
|
|
230
|
+
continue;
|
|
231
|
+
}
|
|
232
|
+
if (code === 0)
|
|
233
|
+
return 0;
|
|
234
|
+
if (first && Date.now() - startedAt < EARLY_FAILURE_MS)
|
|
235
|
+
return code;
|
|
236
|
+
first = false;
|
|
237
|
+
failures = Date.now() - startedAt > 10 * 60_000 ? 1 : failures + 1;
|
|
238
|
+
const pause = Math.min(5 * 60_000, 5000 * 2 ** (failures - 1));
|
|
239
|
+
log(`The listener stopped with exit code ${code}; starting it again in ${Math.round(pause / 1000)} s.`);
|
|
240
|
+
await delay(pause, undefined, { signal: options.signal }).catch(() => { });
|
|
241
|
+
}
|
|
242
|
+
return 0;
|
|
243
|
+
}
|
|
244
|
+
finally {
|
|
245
|
+
process.removeListener('SIGINT', stop);
|
|
246
|
+
process.removeListener('SIGTERM', stop);
|
|
247
|
+
await releaseListenerLock(options.appDir);
|
|
248
|
+
}
|
|
249
|
+
}
|
|
250
|
+
export function describeOtherListener(lock) {
|
|
251
|
+
return lock.service
|
|
252
|
+
? `The Skillshare agents service already listens on this computer (process ${lock.pid}). Stop it with skillshare agents service uninstall, or leave it running.`
|
|
253
|
+
: `Another skillshare agents listen already runs on this computer (process ${lock.pid}). Stop it first; both would answer as the same listener.`;
|
|
254
|
+
}
|
package/dist/agents.js
CHANGED
|
@@ -4,9 +4,11 @@ import { hostname, platform, tmpdir } from 'node:os';
|
|
|
4
4
|
import { dirname, join, resolve } from 'node:path';
|
|
5
5
|
import { setTimeout as delay } from 'node:timers/promises';
|
|
6
6
|
import { createClaudeDriver } from './agents-claude.js';
|
|
7
|
+
import { defaultServiceEnvironment, installService, renderServiceStatus, serviceLogs, serviceStatus, uninstallService, } from './agents-service.js';
|
|
7
8
|
import { createCodexDriver } from './agents-codex.js';
|
|
8
9
|
import { detectAgents, profilesPath } from './agents-detect.js';
|
|
9
10
|
import { SessionRunner, errorMessage, } from './agents-session.js';
|
|
11
|
+
import { RESTART_FOR_UPDATE, SERVICE_ENV, WORKER_ENV, acquireListenerLock, compareVersions, describeOtherListener, installVersion, releaseListenerLock, superviseListener, } from './agents-update.js';
|
|
10
12
|
import { readCliVersion } from './help.js';
|
|
11
13
|
const root = '/api/agents';
|
|
12
14
|
const PROFILE_HINT = 'Declare named profiles in ' + profilesPath() + ' to set a working directory or extra arguments.';
|
|
@@ -92,19 +94,34 @@ export async function runAgentsCommand(client, args, flags, appDirectory, teamsW
|
|
|
92
94
|
console.log(renderDetected(listenerId, client.baseUrl, detected));
|
|
93
95
|
return;
|
|
94
96
|
}
|
|
97
|
+
if (action === 'service')
|
|
98
|
+
return runServiceCommand(args[1], flags, appDirectory, client.baseUrl);
|
|
95
99
|
if (action !== 'listen')
|
|
96
|
-
throw new Error('agents requires listen or
|
|
97
|
-
const max =
|
|
98
|
-
|
|
99
|
-
|
|
100
|
+
throw new Error('agents requires listen, status or service.');
|
|
101
|
+
const max = maxConcurrency(flags);
|
|
102
|
+
const worker = process.env[WORKER_ENV] === '1';
|
|
103
|
+
const service = process.env[SERVICE_ENV] === '1';
|
|
104
|
+
// The listener runs under a supervisor that starts it again on a newer CLI, unless updates are turned off.
|
|
105
|
+
if (!worker && flags['no-update'] !== 'true') {
|
|
106
|
+
const code = await superviseListener({ appDir: appDirectory, args: process.argv.slice(2), ownVersion: readCliVersion(), logFile: flags['log-file'], service });
|
|
107
|
+
if (code !== 0)
|
|
108
|
+
process.exitCode = code;
|
|
109
|
+
return;
|
|
110
|
+
}
|
|
111
|
+
if (!worker) {
|
|
112
|
+
const other = await acquireListenerLock(appDirectory, { version: readCliVersion(), service });
|
|
113
|
+
if (other)
|
|
114
|
+
throw new Error(describeOtherListener(other));
|
|
115
|
+
}
|
|
100
116
|
const workdir = resolve(flags.workdir ?? process.cwd());
|
|
101
117
|
const sandbox = flags.sandbox === 'true';
|
|
102
118
|
const user = await client.get('/api/auth/me');
|
|
103
119
|
const listenerId = await loadListenerId(appDirectory, client.baseUrl, user.id);
|
|
104
120
|
const abort = new AbortController();
|
|
121
|
+
// A supervisor may pass on a signal the listener already got from the terminal; stopping twice is harmless.
|
|
105
122
|
const stop = () => abort.abort();
|
|
106
|
-
process.
|
|
107
|
-
process.
|
|
123
|
+
process.on('SIGINT', stop);
|
|
124
|
+
process.on('SIGTERM', stop);
|
|
108
125
|
console.log(sandbox
|
|
109
126
|
? 'Sandboxed: agents may read files, search and use the Skillshare MCP tools, but cannot change files or run commands that modify anything.'
|
|
110
127
|
: 'Agents run with your full local permissions and configuration, without permission prompts. Use --sandbox to keep them read-only.');
|
|
@@ -114,14 +131,82 @@ export async function runAgentsCommand(client, args, flags, appDirectory, teamsW
|
|
|
114
131
|
? teamsWatch(abort.signal, message => console.log(`[teams] ${message}`))
|
|
115
132
|
.catch(error => console.log(`[teams] Teams renewal stopped: ${error instanceof Error ? error.message : String(error)}`))
|
|
116
133
|
: Promise.resolve();
|
|
134
|
+
let result = 'stopped';
|
|
117
135
|
try {
|
|
118
|
-
await listen({
|
|
136
|
+
result = await listen({
|
|
137
|
+
client, listenerId, name: flags.name, workdir, maxConcurrency: max, sandbox, signal: abort.signal, onProgress: console.log, service,
|
|
138
|
+
updater: worker ? { currentVersion: readCliVersion(), prepare: version => installVersion(appDirectory, version) } : undefined,
|
|
139
|
+
});
|
|
119
140
|
}
|
|
120
141
|
finally {
|
|
121
142
|
abort.abort();
|
|
122
143
|
await teams;
|
|
123
144
|
process.removeListener('SIGINT', stop);
|
|
124
145
|
process.removeListener('SIGTERM', stop);
|
|
146
|
+
if (!worker)
|
|
147
|
+
await releaseListenerLock(appDirectory);
|
|
148
|
+
}
|
|
149
|
+
if (result === 'update') {
|
|
150
|
+
process.exitCode = RESTART_FOR_UPDATE;
|
|
151
|
+
// Nothing may keep the old version alive; the supervisor waits for this process to end.
|
|
152
|
+
setTimeout(() => process.exit(RESTART_FOR_UPDATE), 5000).unref();
|
|
153
|
+
}
|
|
154
|
+
}
|
|
155
|
+
function maxConcurrency(flags) {
|
|
156
|
+
const max = flags['max-concurrency'] === undefined ? undefined : Number(flags['max-concurrency']);
|
|
157
|
+
if (max !== undefined && (!Number.isInteger(max) || max < 1 || max > 4))
|
|
158
|
+
throw new Error('--max-concurrency must be between 1 and 4.');
|
|
159
|
+
return max;
|
|
160
|
+
}
|
|
161
|
+
/** Listen options a service keeps; the server and the working directory are stored on their own. */
|
|
162
|
+
export function serviceListenArgs(flags) {
|
|
163
|
+
const args = [];
|
|
164
|
+
for (const key of ['name', 'max-concurrency', 'browser', 'browser-profile'])
|
|
165
|
+
if (flags[key] !== undefined)
|
|
166
|
+
args.push(`--${key}`, flags[key]);
|
|
167
|
+
for (const key of ['sandbox', 'no-teams-watch', 'headless'])
|
|
168
|
+
if (flags[key] === 'true')
|
|
169
|
+
args.push(`--${key}`);
|
|
170
|
+
return args;
|
|
171
|
+
}
|
|
172
|
+
/** install needs the server, so it runs after sign-in; the other actions work offline. */
|
|
173
|
+
export async function runServiceCommand(action, flags, appDirectory, server) {
|
|
174
|
+
const environment = defaultServiceEnvironment(appDirectory);
|
|
175
|
+
switch (action) {
|
|
176
|
+
case 'install': {
|
|
177
|
+
if (!server)
|
|
178
|
+
throw new Error('agents service install needs a Skillshare server.');
|
|
179
|
+
maxConcurrency(flags);
|
|
180
|
+
const workdir = resolve(flags.workdir ?? process.cwd());
|
|
181
|
+
const version = readCliVersion();
|
|
182
|
+
console.log(`Installing Skillshare CLI ${version} for the agents service…`);
|
|
183
|
+
await installService(environment, { server, workdir, args: serviceListenArgs(flags), version });
|
|
184
|
+
// The service manager starts it right away; it takes a moment to sign in and report.
|
|
185
|
+
let status = await serviceStatus(environment);
|
|
186
|
+
for (let attempt = 0; attempt < 30 && !status.running; attempt++) {
|
|
187
|
+
await delay(500);
|
|
188
|
+
status = await serviceStatus(environment);
|
|
189
|
+
}
|
|
190
|
+
console.log(renderServiceStatus(status, appDirectory));
|
|
191
|
+
console.log('It starts at every login and updates itself when Skillshare releases a new CLI.' +
|
|
192
|
+
(environment.platform === 'linux' ? ' To keep it running after you log out, run loginctl enable-linger.' : ''));
|
|
193
|
+
return;
|
|
194
|
+
}
|
|
195
|
+
case 'uninstall':
|
|
196
|
+
console.log(await uninstallService(environment) ? 'The agents service was removed.' : 'The agents service was not installed.');
|
|
197
|
+
return;
|
|
198
|
+
case 'status':
|
|
199
|
+
console.log(renderServiceStatus(await serviceStatus(environment), appDirectory));
|
|
200
|
+
return;
|
|
201
|
+
case 'logs': {
|
|
202
|
+
const lines = flags.lines === undefined ? 50 : Number(flags.lines);
|
|
203
|
+
if (!Number.isInteger(lines) || lines < 1)
|
|
204
|
+
throw new Error('--lines must be a positive number.');
|
|
205
|
+
console.log(await serviceLogs(appDirectory, lines));
|
|
206
|
+
return;
|
|
207
|
+
}
|
|
208
|
+
default:
|
|
209
|
+
throw new Error('agents service requires install, uninstall, status or logs.');
|
|
125
210
|
}
|
|
126
211
|
}
|
|
127
212
|
export function renderDetected(listenerId, server, detected) {
|
|
@@ -159,12 +244,40 @@ export async function listen(options) {
|
|
|
159
244
|
let registeredFingerprint = '';
|
|
160
245
|
let detectedAt = now();
|
|
161
246
|
let failures = 0;
|
|
247
|
+
// A downloaded update waits until no turn runs; a failed download is tried again later.
|
|
248
|
+
let updateReady = null;
|
|
249
|
+
let updating = null;
|
|
250
|
+
let updateRetryAt = 0;
|
|
251
|
+
let updateMentioned = '';
|
|
252
|
+
let result = 'stopped';
|
|
253
|
+
function considerUpdate(version) {
|
|
254
|
+
const current = options.updater?.currentVersion ?? readCliVersion();
|
|
255
|
+
if (!version || compareVersions(version, current) <= 0 || updateReady === version || updating === version)
|
|
256
|
+
return;
|
|
257
|
+
if (!options.updater) {
|
|
258
|
+
if (updateMentioned !== version)
|
|
259
|
+
log(`Skillshare CLI ${version} is available. Update with npm install -g @quadient/skillshare@${version}.`);
|
|
260
|
+
updateMentioned = version;
|
|
261
|
+
return;
|
|
262
|
+
}
|
|
263
|
+
if (now() < updateRetryAt)
|
|
264
|
+
return;
|
|
265
|
+
updating = version;
|
|
266
|
+
log(`Downloading Skillshare CLI ${version}…`);
|
|
267
|
+
options.updater.prepare(version).then(() => {
|
|
268
|
+
updateReady = version;
|
|
269
|
+
log(`Skillshare CLI ${version} is ready; the listener restarts with it once no turn is running.`);
|
|
270
|
+
}).catch(error => {
|
|
271
|
+
updateRetryAt = now() + 30 * 60_000;
|
|
272
|
+
log(`! Could not download Skillshare CLI ${version} (${error instanceof Error ? error.message : String(error)}). Trying again in 30 minutes.`);
|
|
273
|
+
}).finally(() => { updating = null; });
|
|
274
|
+
}
|
|
162
275
|
const capabilities = () => options.sandbox ? { ...detected.capabilities, sandbox: true } : detected.capabilities;
|
|
163
276
|
async function register() {
|
|
164
277
|
const fingerprint = JSON.stringify(capabilities());
|
|
165
278
|
const registered = await send(options.client, 'PUT', `${root}/listeners/${options.listenerId}`, {
|
|
166
279
|
name: options.name ?? hostname(), host: hostname(), os: platform(), cliVersion: readCliVersion(), maxConcurrency: options.maxConcurrency,
|
|
167
|
-
capabilities: capabilities(),
|
|
280
|
+
capabilities: capabilities(), service: options.service === true, autoUpdate: options.updater !== undefined,
|
|
168
281
|
}, options.signal);
|
|
169
282
|
registeredFingerprint = fingerprint;
|
|
170
283
|
for (const problem of detected.problems)
|
|
@@ -232,6 +345,11 @@ export async function listen(options) {
|
|
|
232
345
|
}
|
|
233
346
|
try {
|
|
234
347
|
while (!options.signal.aborted) {
|
|
348
|
+
if (updateReady && ![...sessions.values()].some(runner => runner.busy)) {
|
|
349
|
+
log(`Restarting with Skillshare CLI ${updateReady}.`);
|
|
350
|
+
result = 'update';
|
|
351
|
+
break;
|
|
352
|
+
}
|
|
235
353
|
try {
|
|
236
354
|
if (now() - detectedAt > (options.redetectMs ?? 5 * 60_000)) {
|
|
237
355
|
detected = await detect();
|
|
@@ -248,6 +366,7 @@ export async function listen(options) {
|
|
|
248
366
|
failures = 0;
|
|
249
367
|
for (const command of response.commands)
|
|
250
368
|
handle(command);
|
|
369
|
+
considerUpdate(response.cliVersion);
|
|
251
370
|
if (response.commands.length === 0 && now() - polledAt < 1000)
|
|
252
371
|
await wait(1000, options.signal).catch(() => { });
|
|
253
372
|
}
|
|
@@ -274,6 +393,7 @@ export async function listen(options) {
|
|
|
274
393
|
// Closing reports each session as closed, so the server knows at once that the next message must resume it.
|
|
275
394
|
await Promise.all([...sessions.values()].map(runner => runner.close()));
|
|
276
395
|
}
|
|
396
|
+
return result;
|
|
277
397
|
}
|
|
278
398
|
function label(start) {
|
|
279
399
|
const { configuration } = start;
|
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', 'json', 'no-teams-watch', 'sandbox']);
|
|
3
|
+
export const BOOLEAN_FLAGS = new Set(['dry-run', 'force', 'headless', 'help', 'json', 'no-teams-watch', 'no-update', 'sandbox']);
|
|
4
4
|
export const COMMANDS = [
|
|
5
5
|
{
|
|
6
6
|
name: 'login',
|
|
@@ -112,12 +112,14 @@ export const COMMANDS = [
|
|
|
112
112
|
name: 'agents',
|
|
113
113
|
summary: 'Run Claude Code or Codex sessions from Skillshare on this computer',
|
|
114
114
|
usage: [
|
|
115
|
-
'agents listen [local] [--name <name>] [--workdir <directory>] [--max-concurrency <1-4>] [--sandbox] [--no-teams-watch]',
|
|
115
|
+
'agents listen [local] [--name <name>] [--workdir <directory>] [--max-concurrency <1-4>] [--sandbox] [--no-teams-watch] [--no-update]',
|
|
116
116
|
'agents status [local] [--json]',
|
|
117
|
+
'agents service install [local] [--name <name>] [--workdir <directory>] [--max-concurrency <1-4>] [--sandbox] [--no-teams-watch]',
|
|
118
|
+
'agents service status | logs [--lines <n>] | uninstall',
|
|
117
119
|
],
|
|
118
|
-
description: 'A listener reports the installed agents, their models and reasoning levels, and the local profiles from ~/.config/skillshare/agents.json. It runs Claude Code and Codex sessions that you start and continue under Sessions in Skillshare; under Connected agents you choose which listener and configuration runs Teams 👀 sessions. Agents use your own login and run with your full local permissions, without permission prompts, unless --sandbox (or "sandbox": true in a profile) keeps them read-only. An idle agent is stopped after 15 minutes and resumed by the next message. With a Teams connection, the listener also keeps the Teams session renewed through local browser SSO, as skillshare teams watch does; --no-teams-watch turns that off.',
|
|
119
|
-
examples: ['skillshare agents status', 'skillshare agents listen --workdir ~/Research'],
|
|
120
|
-
flags: ['name', 'workdir', 'max-concurrency', 'sandbox', 'no-teams-watch', 'browser', 'headless', 'browser-profile', 'json'],
|
|
120
|
+
description: 'A listener reports the installed agents, their models and reasoning levels, and the local profiles from ~/.config/skillshare/agents.json. It runs Claude Code and Codex sessions that you start and continue under Sessions in Skillshare; under Connected agents you choose which listener and configuration runs Teams 👀 sessions. Agents use your own login and run with your full local permissions, without permission prompts, unless --sandbox (or "sandbox": true in a profile) keeps them read-only. An idle agent is stopped after 15 minutes and resumed by the next message. With a Teams connection, the listener also keeps the Teams session renewed through local browser SSO, as skillshare teams watch does; --no-teams-watch turns that off. The listener updates itself: when Skillshare releases a newer CLI, it downloads it and restarts on it once no turn is running (--no-update turns that off). agents service install runs the listener in the background, started at every login (a launchd agent on macOS, a systemd user service on Linux, a scheduled task on Windows); status, logs and uninstall manage it.',
|
|
121
|
+
examples: ['skillshare agents status', 'skillshare agents listen --workdir ~/Research', 'skillshare agents service install --workdir ~/Research'],
|
|
122
|
+
flags: ['name', 'workdir', 'max-concurrency', 'sandbox', 'no-teams-watch', 'no-update', 'log-file', 'lines', 'browser', 'headless', 'browser-profile', 'json'],
|
|
121
123
|
},
|
|
122
124
|
{
|
|
123
125
|
name: 'git',
|
package/dist/index.js
CHANGED
|
@@ -1,5 +1,6 @@
|
|
|
1
1
|
import { assertCredentialDestination, resolveTeamsObjectId, runTeamsCommand, watchTeamsInBackground } from './teams.js';
|
|
2
|
-
import { runAgentsCommand } from './agents.js';
|
|
2
|
+
import { runAgentsCommand, runServiceCommand } from './agents.js';
|
|
3
|
+
import { SERVICE_ENV } from './agents-update.js';
|
|
3
4
|
import { formatUnknownCommand, parseCliArgs } from './cli-args.js';
|
|
4
5
|
import { findCommand, readCliVersion, renderCommandHelp, renderUsage } from './help.js';
|
|
5
6
|
import { inspectInstallTarget, installFilesAtomically } from './install-files.js';
|
|
@@ -145,10 +146,19 @@ async function main() {
|
|
|
145
146
|
return;
|
|
146
147
|
}
|
|
147
148
|
case 'agents': {
|
|
148
|
-
|
|
149
|
+
// Managing the background service needs no server, except to install it.
|
|
150
|
+
if (options.positionals[0] === 'service' && options.positionals[1] !== 'install') {
|
|
151
|
+
await runServiceCommand(options.positionals[1], options.flags, APP_DIR);
|
|
152
|
+
return;
|
|
153
|
+
}
|
|
154
|
+
const baseUrl = options.flags.server ?? (options.positionals.slice(1).includes('local') ? LOCAL_URL : await resolveActiveBaseUrl());
|
|
149
155
|
assertCredentialDestination(baseUrl);
|
|
150
|
-
if (!options.token && !await loadSession(baseUrl))
|
|
156
|
+
if (!options.token && !await loadSession(baseUrl)) {
|
|
157
|
+
// A background service has nobody to finish a browser sign-in.
|
|
158
|
+
if (process.env[SERVICE_ENV] === '1')
|
|
159
|
+
throw new Error(`Not signed in to ${baseUrl}. Run skillshare login, then skillshare agents service install again.`);
|
|
151
160
|
await login(baseUrl);
|
|
161
|
+
}
|
|
152
162
|
const agentsClient = await createClient(baseUrl, options.token);
|
|
153
163
|
await runAgentsCommand(agentsClient, options.positionals, options.flags, APP_DIR, (signal, log) => watchTeamsInBackground(agentsClient, options.flags, APP_DIR, signal, log));
|
|
154
164
|
return;
|
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "@quadient/skillshare",
|
|
3
|
-
"version": "1.10.
|
|
3
|
+
"version": "1.10.3",
|
|
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 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"
|
|
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 agents-update.test.ts"
|
|
20
20
|
},
|
|
21
21
|
"publishConfig": {
|
|
22
22
|
"access": "public"
|