@quadient/skillshare 1.10.0 → 1.10.1

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
package/README.md CHANGED
@@ -59,7 +59,8 @@ segment are sent to Skillshare.
59
59
  Starting with 1.7.0, `skillshare teams login` opens a dedicated local browser profile,
60
60
  connects your own Teams account to Skillshare and verifies the connection. Sign in with
61
61
  the same Microsoft account as Skillshare. The server refreshes captured tokens in the
62
- background. `teams watch` renews an expired Microsoft grant through local browser SSO.
62
+ background. `teams watch` renews an expired Microsoft grant through local browser SSO; `agents listen` does the same
63
+ while it runs, so a computer that runs agents needs no separate `teams watch` (`--no-teams-watch` turns it off).
63
64
  Keep that process running for automatic recovery beyond the SPA refresh token lifetime;
64
65
  another interactive CLI login is needed only when SSO cannot complete, for example MFA.
65
66
 
@@ -13,7 +13,8 @@ export function claudeArgs(context, mcpConfigPath) {
13
13
  const { start } = context;
14
14
  const sandbox = isSandboxed(start.configuration, context);
15
15
  const servers = start.mcpServers ?? [];
16
- const args = ['-p', '--input-format', 'stream-json', '--output-format', 'stream-json', '--verbose', '--include-partial-messages',
16
+ // Replayed user messages tell when Claude Code took a message, which is how a steered message is known to have joined the turn.
17
+ const args = ['-p', '--input-format', 'stream-json', '--output-format', 'stream-json', '--verbose', '--include-partial-messages', '--replay-user-messages',
17
18
  ...(sandbox
18
19
  // A sandboxed session never prompts: anything outside the read-only allowlist is denied.
19
20
  ? ['--permission-mode', 'dontAsk', '--allowedTools', ...CLAUDE_SANDBOX_TOOLS, ...servers.map((server, index) => `mcp__${mcpServerName(server, index)}`)]
@@ -80,6 +81,12 @@ class ClaudeDriver {
80
81
  blocks = new Map();
81
82
  streamed = new Map();
82
83
  messageId = '';
84
+ /** Messages written to Claude Code whose replay has not come back yet, in order. */
85
+ echoes = [];
86
+ /** Steered messages Claude Code will run as their own turn; turn() then only waits for their outcome. */
87
+ submitted = new Set();
88
+ /** Outcomes of such turns that ended before turn() asked for them. */
89
+ early = [];
83
90
  costSoFar = 0;
84
91
  closing = false;
85
92
  constructor(context, options) {
@@ -114,16 +121,55 @@ class ClaudeDriver {
114
121
  }
115
122
  turn(message) {
116
123
  return new Promise((resolve, reject) => {
124
+ if (this.submitted.delete(message.id)) {
125
+ const early = this.early.shift();
126
+ if (early)
127
+ resolve(early);
128
+ else
129
+ this.current = { resolve, interrupted: false };
130
+ return;
131
+ }
117
132
  this.current = { resolve, interrupted: false };
118
- const sent = this.process?.write(JSON.stringify({
119
- type: 'user', message: { role: 'user', content: message.text }, parent_tool_use_id: null, session_id: this.sessionId ?? '',
120
- }));
121
- if (!sent) {
133
+ if (!this.write(message.text)) {
122
134
  this.current = null;
123
135
  reject(new Error('Claude Code is not accepting input.'));
124
136
  }
125
137
  });
126
138
  }
139
+ /**
140
+ * Claude Code takes a message written during a turn at its next tool boundary. If the turn ends first, the message starts
141
+ * a turn of its own; its replay arriving before or after the turn's result tells which happened.
142
+ */
143
+ steer(message) {
144
+ if (!this.current)
145
+ return Promise.resolve('notSent');
146
+ return new Promise(resolve => {
147
+ if (!this.write(message.text, outcome => {
148
+ if (outcome === 'steered')
149
+ this.submitted.delete(message.id);
150
+ resolve(outcome);
151
+ }))
152
+ return resolve('notSent');
153
+ this.submitted.add(message.id);
154
+ });
155
+ }
156
+ write(text, steered) {
157
+ const sent = this.process?.write(JSON.stringify({
158
+ type: 'user', message: { role: 'user', content: text }, parent_tool_use_id: null, session_id: this.sessionId ?? '',
159
+ }));
160
+ if (sent)
161
+ this.echoes.push({ text, steered });
162
+ return Boolean(sent);
163
+ }
164
+ replayed(content) {
165
+ const text = typeof content === 'string' ? content
166
+ : Array.isArray(content) ? content.map((block) => typeof block?.text === 'string' ? block.text : '').join('') : '';
167
+ const index = this.echoes.findIndex(echo => echo.text === text);
168
+ if (index < 0)
169
+ return;
170
+ const [echo] = this.echoes.splice(index, 1);
171
+ echo.steered?.(this.current ? 'steered' : 'nextTurn');
172
+ }
127
173
  async interrupt() {
128
174
  if (!this.current)
129
175
  return;
@@ -191,7 +237,7 @@ class ClaudeDriver {
191
237
  case 'assistant':
192
238
  return this.assistant(frame.message ?? {});
193
239
  case 'user':
194
- return this.toolResults(frame.message?.content);
240
+ return frame.isReplay === true ? this.replayed(frame.message?.content) : this.toolResults(frame.message?.content);
195
241
  case 'result':
196
242
  return this.result(frame);
197
243
  }
@@ -242,8 +288,7 @@ class ClaudeDriver {
242
288
  this.adopt(frame.session_id);
243
289
  const current = this.current;
244
290
  this.current = null;
245
- if (!current)
246
- return;
291
+ const record = (outcome) => current ? current.resolve(outcome) : this.early.push(outcome);
247
292
  // Costs are totals for the process; a turn's share is the difference.
248
293
  const cost = typeof frame.total_cost_usd === 'number' ? frame.total_cost_usd : undefined;
249
294
  const turnCost = cost === undefined ? undefined : Math.max(0, cost - this.costSoFar);
@@ -251,21 +296,24 @@ class ClaudeDriver {
251
296
  this.costSoFar = cost;
252
297
  const usage = frame.usage ?? {};
253
298
  const input = [usage.input_tokens, usage.cache_creation_input_tokens, usage.cache_read_input_tokens].filter((n) => typeof n === 'number');
299
+ const count = (value) => typeof value === 'number' ? value : undefined;
254
300
  const stats = { costUsd: turnCost, inputTokens: input.length > 0 ? input.reduce((a, b) => a + b, 0) : undefined,
255
- outputTokens: typeof usage.output_tokens === 'number' ? usage.output_tokens : undefined };
301
+ cachedInputTokens: count(usage.cache_read_input_tokens), cacheWriteTokens: count(usage.cache_creation_input_tokens), outputTokens: count(usage.output_tokens) };
256
302
  if (frame.subtype === 'success' && frame.is_error !== true)
257
- return current.resolve({ status: 'completed', stats });
258
- if (current.interrupted)
259
- return current.resolve({ status: 'interrupted', stats });
303
+ return record({ status: 'completed', stats });
304
+ if (current?.interrupted)
305
+ return record({ status: 'interrupted', stats });
260
306
  const detail = typeof frame.result === 'string' && frame.result ? frame.result
261
307
  : Array.isArray(frame.errors) && frame.errors.length > 0 ? frame.errors.join(' ') : `Claude Code ended the turn with ${frame.subtype ?? 'an error'}.`;
262
- current.resolve({ status: 'failed', error: clip(detail, 2000), stats });
308
+ record({ status: 'failed', error: clip(detail, 2000), stats });
263
309
  }
264
310
  onExit(code) {
265
311
  const reason = `Claude Code exited${code === null ? '' : ` with code ${code}`}${this.process?.stderrTail() ? `: ${this.process.stderrTail()}` : '.'}`;
266
312
  for (const waiter of this.controls.values())
267
313
  waiter.reject(new Error(reason));
268
314
  this.controls.clear();
315
+ for (const echo of this.echoes.splice(0))
316
+ echo.steered?.('notSent');
269
317
  this.current?.resolve({ status: 'failed', error: reason });
270
318
  this.current = null;
271
319
  if (!this.closing)
@@ -126,7 +126,7 @@ class CodexDriver {
126
126
  turn(message) {
127
127
  const { configuration } = this.context.start;
128
128
  return new Promise((resolve, reject) => {
129
- this.current = { resolve, usage: { input: 0, output: 0 } };
129
+ this.current = { resolve, usage: { input: 0, cached: 0, cacheWrite: 0, output: 0 } };
130
130
  this.request('turn/start', {
131
131
  threadId: this.threadId, input: [{ type: 'text', text: message.text }],
132
132
  ...(configuration.reasoning !== DEFAULT ? { effort: configuration.reasoning } : {}),
@@ -143,6 +143,19 @@ class CodexDriver {
143
143
  if (this.current?.id && this.threadId)
144
144
  await this.request('turn/interrupt', { threadId: this.threadId, turnId: this.current.id });
145
145
  }
146
+ /** turn/steer adds input to the active turn; Codex refuses it once that turn is over, and the message then runs as the next turn. */
147
+ async steer(message) {
148
+ const turnId = this.current?.id;
149
+ if (!turnId || !this.threadId)
150
+ return 'notSent';
151
+ try {
152
+ await this.request('turn/steer', { threadId: this.threadId, expectedTurnId: turnId, input: [{ type: 'text', text: message.text }] });
153
+ return 'steered';
154
+ }
155
+ catch {
156
+ return 'notSent';
157
+ }
158
+ }
146
159
  async close() {
147
160
  this.closing = true;
148
161
  await this.process?.kill();
@@ -234,6 +247,8 @@ class CodexDriver {
234
247
  const last = params.tokenUsage?.last;
235
248
  if (last && this.current) {
236
249
  this.current.usage.input += Number(last.inputTokens) || 0;
250
+ this.current.usage.cached += Number(last.cachedInputTokens) || 0;
251
+ this.current.usage.cacheWrite += Number(last.cacheWriteInputTokens) || 0;
237
252
  this.current.usage.output += Number(last.outputTokens) || 0;
238
253
  }
239
254
  return;
@@ -255,7 +270,8 @@ class CodexDriver {
255
270
  return;
256
271
  this.current = null;
257
272
  const status = turn.status === 'completed' ? 'completed' : turn.status === 'interrupted' ? 'interrupted' : 'failed';
258
- const stats = { inputTokens: current.usage.input, outputTokens: current.usage.output };
273
+ const { usage } = current;
274
+ const stats = { inputTokens: usage.input, cachedInputTokens: usage.cached, cacheWriteTokens: usage.cacheWrite, outputTokens: usage.output };
259
275
  current.resolve(status === 'failed'
260
276
  ? { status, error: codexErrorText(turn.error?.message) ?? current.error ?? 'The turn failed.', stats }
261
277
  : { status, stats });
@@ -2,6 +2,7 @@ import { execFile } from 'node:child_process';
2
2
  import { readFile } from 'node:fs/promises';
3
3
  import { homedir } from 'node:os';
4
4
  import { basename, isAbsolute, join, resolve } from 'node:path';
5
+ import { startAgentProcess } from './agents-process.js';
5
6
  /** Model aliases Claude Code resolves to the latest release; Claude Code itself has no command that lists models. */
6
7
  export const CLAUDE_MODELS = ['fable', 'opus', 'sonnet', 'haiku'];
7
8
  export const CLAUDE_EFFORT_LEVELS = ['low', 'medium', 'high', 'xhigh', 'max'];
@@ -30,19 +31,11 @@ export async function detectAgents(options = {}) {
30
31
  if (codexVersion !== null) {
31
32
  const codexHome = env.CODEX_HOME ?? join(home, '.codex');
32
33
  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) : [];
34
+ // Codex's own model/list reflects the account; its cache file is the fallback when the app-server cannot be asked.
35
+ const listed = await (options.codexModels ?? (() => codexModelList()))().catch(() => null);
36
+ const models = listed ?? cachedCodexModels(parseJson(await read(join(codexHome, 'models_cache.json'))));
44
37
  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.');
38
+ warnings.push('Codex did not list its models. Run codex once to sign in or refresh it; only the default model is offered.');
46
39
  const configured = defaultCodexModel(await read(join(codexHome, 'config.toml')));
47
40
  if (configured && models.length > 0 && !models.some(m => m.id === configured))
48
41
  warnings.push(`The default model "${configured}" from config.toml is not in the Codex model list and may be rejected; pick a model explicitly.`);
@@ -59,6 +52,74 @@ export async function detectAgents(options = {}) {
59
52
  problems,
60
53
  };
61
54
  }
55
+ /** Keeps the list within what the server accepts: valid names, no "default", and bounded sizes. */
56
+ function codexModel(id, displayName, levels, fallback) {
57
+ if (typeof id !== 'string' || !NAME.test(id) || id === 'default')
58
+ return null;
59
+ const reasoningLevels = levels.filter((level) => typeof level === 'string' && NAME.test(level) && level !== 'default').slice(0, 16);
60
+ return { id, displayName: typeof displayName === 'string' ? displayName.slice(0, 100) : undefined, reasoningLevels,
61
+ defaultReasoning: typeof fallback === 'string' && reasoningLevels.includes(fallback) ? fallback : undefined };
62
+ }
63
+ /** Models from model/list of `codex app-server`, the same list the Codex UI offers; hidden models are left out. */
64
+ export function codexModelsFromList(data) {
65
+ return (Array.isArray(data) ? data : []).filter((m) => m && m.hidden !== true)
66
+ .map((m) => codexModel(m.id ?? m.model, m.displayName, (Array.isArray(m.supportedReasoningEfforts) ? m.supportedReasoningEfforts : []).map((e) => e?.reasoningEffort), m.defaultReasoningEffort))
67
+ .filter((m) => m !== null).slice(0, 64);
68
+ }
69
+ function cachedCodexModels(cache) {
70
+ return (Array.isArray(cache?.models) ? cache.models : []).filter((m) => m?.visibility === 'list')
71
+ .map((m) => codexModel(m.slug, m.display_name, (Array.isArray(m.supported_reasoning_levels) ? m.supported_reasoning_levels : []).map((level) => typeof level === 'string' ? level : level?.effort), m.default_reasoning_level))
72
+ .filter((m) => m !== null).slice(0, 64);
73
+ }
74
+ /** Asks a short-lived `codex app-server` for model/list, following pages, and gives up after the timeout. */
75
+ export async function codexModelList(options = {}) {
76
+ const pending = new Map();
77
+ let nextId = 0;
78
+ const agent = startAgentProcess({
79
+ command: options.command ?? 'codex', args: ['app-server'], cwd: homedir(), env: {}, spawnAgent: options.spawnAgent,
80
+ onLine: line => {
81
+ try {
82
+ const message = JSON.parse(line);
83
+ if (message.id !== undefined && message.method === undefined)
84
+ pending.get(message.id)?.(message);
85
+ }
86
+ catch { }
87
+ },
88
+ });
89
+ const request = (method, params) => new Promise((resolvePromise, reject) => {
90
+ const id = ++nextId;
91
+ pending.set(id, message => message.error ? reject(new Error(String(message.error.message))) : resolvePromise(message.result ?? {}));
92
+ if (!agent.write(JSON.stringify({ id, method, params })))
93
+ reject(new Error('Codex is not accepting input.'));
94
+ });
95
+ const ask = async () => {
96
+ await request('initialize', { clientInfo: { name: 'skillshare', title: 'Skillshare', version: '0.0.0' }, capabilities: { experimentalApi: true } });
97
+ agent.write(JSON.stringify({ method: 'initialized' }));
98
+ const data = [];
99
+ let cursor;
100
+ for (let page = 0; page < 10; page++) {
101
+ const result = await request('model/list', cursor ? { cursor } : {});
102
+ data.push(...(Array.isArray(result.data) ? result.data : []));
103
+ cursor = typeof result.nextCursor === 'string' && result.nextCursor ? result.nextCursor : undefined;
104
+ if (!cursor)
105
+ break;
106
+ }
107
+ return codexModelsFromList(data);
108
+ };
109
+ let timer;
110
+ try {
111
+ const models = await Promise.race([ask(), agent.exited.then(() => null),
112
+ new Promise(resolvePromise => { timer = setTimeout(() => resolvePromise(null), options.timeoutMs ?? 20_000); })]);
113
+ return models && models.length > 0 ? models : null;
114
+ }
115
+ catch {
116
+ return null;
117
+ }
118
+ finally {
119
+ clearTimeout(timer);
120
+ await agent.kill();
121
+ }
122
+ }
62
123
  export function parseProfiles(text, home, problems = []) {
63
124
  if (text === null)
64
125
  return [];
@@ -104,6 +104,7 @@ export class SessionRunner {
104
104
  driver;
105
105
  seen = new Set();
106
106
  pending = [];
107
+ steering = new Set();
107
108
  now;
108
109
  opened = null;
109
110
  openSucceeded = false;
@@ -134,16 +135,43 @@ export class SessionRunner {
134
135
  });
135
136
  }
136
137
  get stopping() { return this.closing !== null; }
137
- /** Queues messages for the next turns; the server may send a message again, so repeats are ignored. */
138
+ /**
139
+ * Queues messages for the next turns, or steers them into the running turn; the server may send a message again, so
140
+ * repeats are ignored.
141
+ */
138
142
  send(messages) {
139
143
  for (const message of messages) {
140
144
  if (this.seen.has(message.id))
141
145
  continue;
142
146
  this.seen.add(message.id);
143
- this.pending.push(message);
147
+ if (message.steer && this.turnRunning && this.openSucceeded && this.driver.steer)
148
+ this.steer(message);
149
+ else
150
+ this.pending.push(message);
144
151
  }
145
152
  void this.pump();
146
153
  }
154
+ /** A message that misses the running turn becomes the next one, ahead of anything queued after it. */
155
+ steer(message) {
156
+ const task = (async () => {
157
+ let outcome = 'notSent';
158
+ try {
159
+ outcome = await this.driver.steer(message);
160
+ }
161
+ catch { }
162
+ if (this.closing)
163
+ return;
164
+ if (outcome === 'steered')
165
+ this.events.push({ type: 'steered', messageId: message.id });
166
+ else
167
+ this.pending.unshift(message);
168
+ })();
169
+ this.steering.add(task);
170
+ void task.finally(() => {
171
+ this.steering.delete(task);
172
+ void this.pump();
173
+ });
174
+ }
147
175
  async interrupt() {
148
176
  if (!this.turnRunning || this.interruptRequested)
149
177
  return;
@@ -160,7 +188,8 @@ export class SessionRunner {
160
188
  return this.finish();
161
189
  }
162
190
  async pump() {
163
- if (this.turnRunning || this.closing)
191
+ // While a steer is still being placed it decides what runs next; it pumps again once it knows.
192
+ if (this.turnRunning || this.closing || this.steering.size > 0)
164
193
  return;
165
194
  const message = this.pending.shift();
166
195
  if (!message) {
package/dist/agents.js CHANGED
@@ -44,7 +44,7 @@ export async function loadListenerId(appDirectory, baseUrl, userId) {
44
44
  await writeFile(file, JSON.stringify({ id }, null, 2), { mode: 0o600 });
45
45
  return id;
46
46
  }
47
- export async function runAgentsCommand(client, args, flags, appDirectory) {
47
+ export async function runAgentsCommand(client, args, flags, appDirectory, teamsWatch) {
48
48
  const action = args[0];
49
49
  if (action === 'status') {
50
50
  const detected = await detectAgents();
@@ -74,10 +74,17 @@ export async function runAgentsCommand(client, args, flags, appDirectory) {
74
74
  ? 'Sandboxed: agents may read files, search and use the Skillshare MCP tools, but cannot change files or run commands that modify anything.'
75
75
  : 'Agents run with your full local permissions and configuration, without permission prompts. Use --sandbox to keep them read-only.');
76
76
  console.log('Sessions keep their agent running between messages and close it after 15 quiet minutes; the next message resumes it.');
77
+ // Teams 👀 sessions need a live Teams session, so the listener keeps it renewed unless told not to.
78
+ const teams = teamsWatch && flags['no-teams-watch'] !== 'true'
79
+ ? teamsWatch(abort.signal, message => console.log(`[teams] ${message}`))
80
+ .catch(error => console.log(`[teams] Teams renewal stopped: ${error instanceof Error ? error.message : String(error)}`))
81
+ : Promise.resolve();
77
82
  try {
78
83
  await listen({ client, listenerId, name: flags.name, workdir, maxConcurrency: max, sandbox, signal: abort.signal, onProgress: console.log });
79
84
  }
80
85
  finally {
86
+ abort.abort();
87
+ await teams;
81
88
  process.removeListener('SIGINT', stop);
82
89
  process.removeListener('SIGTERM', stop);
83
90
  }
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', 'sandbox']);
3
+ export const BOOLEAN_FLAGS = new Set(['dry-run', 'force', 'headless', 'help', 'json', 'no-teams-watch', 'sandbox']);
4
4
  export const COMMANDS = [
5
5
  {
6
6
  name: 'login',
@@ -110,14 +110,14 @@ export const COMMANDS = [
110
110
  },
111
111
  {
112
112
  name: 'agents',
113
- summary: 'Run Claude Code or Codex tasks from Skillshare on this computer',
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]',
115
+ 'agents listen [local] [--name <name>] [--workdir <directory>] [--max-concurrency <1-4>] [--sandbox] [--no-teams-watch]',
116
116
  'agents status [local] [--json]',
117
117
  ],
118
- description: 'A listener reports the installed agents, their models and reasoning levels, and the local profiles from ~/.config/skillshare/agents.json. It runs Claude Code and Codex sessions that you start and continue under Sessions in Skillshare; under Connected agents you choose which listener and configuration runs Teams 👀 sessions. Agents use your own login and run with your full local permissions, without permission prompts, unless --sandbox (or "sandbox": true in a profile) keeps them read-only. An idle agent is stopped after 15 minutes and resumed by the next message.',
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
119
  examples: ['skillshare agents status', 'skillshare agents listen --workdir ~/Research'],
120
- flags: ['name', 'workdir', 'max-concurrency', 'sandbox', 'json'],
120
+ flags: ['name', 'workdir', 'max-concurrency', 'sandbox', 'no-teams-watch', 'browser', 'headless', 'browser-profile', 'json'],
121
121
  },
122
122
  {
123
123
  name: 'git',
package/dist/index.js CHANGED
@@ -1,4 +1,4 @@
1
- import { assertCredentialDestination, resolveTeamsObjectId, runTeamsCommand } from './teams.js';
1
+ import { assertCredentialDestination, resolveTeamsObjectId, runTeamsCommand, watchTeamsInBackground } from './teams.js';
2
2
  import { runAgentsCommand } from './agents.js';
3
3
  import { formatUnknownCommand, parseCliArgs } from './cli-args.js';
4
4
  import { findCommand, readCliVersion, renderCommandHelp, renderUsage } from './help.js';
@@ -149,7 +149,8 @@ async function main() {
149
149
  assertCredentialDestination(baseUrl);
150
150
  if (!options.token && !await loadSession(baseUrl))
151
151
  await login(baseUrl);
152
- await runAgentsCommand(await createClient(baseUrl, options.token), options.positionals, options.flags, APP_DIR);
152
+ const agentsClient = await createClient(baseUrl, options.token);
153
+ await runAgentsCommand(agentsClient, options.positionals, options.flags, APP_DIR, (signal, log) => watchTeamsInBackground(agentsClient, options.flags, APP_DIR, signal, log));
153
154
  return;
154
155
  }
155
156
  case 'git': {
package/dist/teams.js CHANGED
@@ -29,6 +29,44 @@ async function expectOk(response) {
29
29
  catch { }
30
30
  throw new Error(error.error ?? `Skillshare Teams API returned HTTP ${response.status}.`);
31
31
  }
32
+ /**
33
+ * Captures a Teams session from the local browser profile and imports it. A watcher renews headless with a deadline and,
34
+ * given the connection it saw, imports conditionally, so a disconnect or another login in the meantime is not overwritten.
35
+ */
36
+ function teamsConnector(client, flags, appDirectory, user, objectId, watching, signal, log) {
37
+ const key = createHash('sha256').update(client.baseUrl + '\0' + user.id).digest('hex').slice(0, 24);
38
+ const profileDirectory = flags['browser-profile'] ? resolve(flags['browser-profile']) : join(appDirectory, 'teams', key, 'browser-profile');
39
+ return async (connectionId) => {
40
+ const session = await captureTeamsLogin({ profileDirectory, objectId, browser: flags.browser,
41
+ headless: watching ? flags.headless !== 'false' : flags.headless === 'true', recover: watching,
42
+ signal, timeoutMs: watching ? 90_000 : undefined, onProgress: log });
43
+ const headers = { 'Content-Type': 'application/json' };
44
+ if (connectionId)
45
+ headers['If-Match'] = `"${connectionId}"`;
46
+ const response = await client.request(`${root}/session`, { method: 'PUT', headers, body: JSON.stringify(session), signal });
47
+ await expectOk(response);
48
+ await client.post(`${root}/test`);
49
+ };
50
+ }
51
+ /**
52
+ * Keeps an existing Teams connection renewed while another command runs, exactly like `teams watch`. Returns at once when
53
+ * the account has no Teams connection, so commands can always start it.
54
+ */
55
+ export async function watchTeamsInBackground(client, flags, appDirectory, signal, log) {
56
+ assertCredentialDestination(client.baseUrl);
57
+ const status = await client.get(`${root}/settings`).catch(() => null);
58
+ if (!status?.connectionId)
59
+ return;
60
+ const user = await client.get('/api/auth/me');
61
+ const objectId = resolveTeamsObjectId(user);
62
+ if (!objectId) {
63
+ log(UNLINKED_ACCOUNT_MESSAGE);
64
+ return;
65
+ }
66
+ log('Keeping your Teams session renewed through local browser SSO, as skillshare teams watch does. Use --no-teams-watch to turn this off.');
67
+ await watchTeamsConnection({ readStatus: () => client.get(`${root}/settings`),
68
+ reconnect: teamsConnector(client, flags, appDirectory, user, objectId, true, signal, log), signal, onProgress: log });
69
+ }
32
70
  export async function runTeamsCommand(client, args, flags, appDirectory) {
33
71
  // Browser credentials may be uploaded only over HTTPS, with HTTP reserved for loopback development.
34
72
  assertCredentialDestination(client.baseUrl);
@@ -38,21 +76,8 @@ export async function runTeamsCommand(client, args, flags, appDirectory) {
38
76
  const resolvedObjectId = resolveTeamsObjectId(user);
39
77
  if (!resolvedObjectId)
40
78
  throw new Error(UNLINKED_ACCOUNT_MESSAGE);
41
- const objectId = resolvedObjectId;
42
- const key = createHash('sha256').update(client.baseUrl + '\0' + user.id).digest('hex').slice(0, 24);
43
- const profileDirectory = flags['browser-profile'] ? resolve(flags['browser-profile']) : join(appDirectory, 'teams', key, 'browser-profile');
44
79
  const abort = new AbortController();
45
- async function connect(connectionId) {
46
- const session = await captureTeamsLogin({ profileDirectory, objectId, browser: flags.browser,
47
- headless: action === 'watch' ? flags.headless !== 'false' : flags.headless === 'true', recover: action === 'watch',
48
- signal: abort.signal, timeoutMs: action === 'watch' ? 90_000 : undefined, onProgress: console.log });
49
- const headers = { 'Content-Type': 'application/json' };
50
- if (connectionId)
51
- headers['If-Match'] = `"${connectionId}"`;
52
- const response = await client.request(`${root}/session`, { method: 'PUT', headers, body: JSON.stringify(session), signal: abort.signal });
53
- await expectOk(response);
54
- await client.post(`${root}/test`);
55
- }
80
+ const connect = teamsConnector(client, flags, appDirectory, user, resolvedObjectId, action === 'watch', abort.signal, console.log);
56
81
  if (action === 'login') {
57
82
  await connect();
58
83
  console.log(`Teams connected. Skillshare refreshes tokens automatically. Use skillshare teams watch for renewed browser SSO after session expiry.`);
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@quadient/skillshare",
3
- "version": "1.10.0",
3
+ "version": "1.10.1",
4
4
  "description": "Skillshare CLI for installing and publishing Quadient skills",
5
5
  "type": "module",
6
6
  "bin": {