@imstudium/cli 0.1.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/dist/cli.d.ts +2 -0
- package/dist/cli.js +1484 -0
- package/mcp.json +11 -0
- package/package.json +60 -0
- package/skills/imstudium-cli/SKILL.md +65 -0
- package/skills/imstudium-klausuren/SKILL.md +33 -0
- package/skills/imstudium-mcp/SKILL.md +59 -0
- package/skills/imstudium-studium/SKILL.md +33 -0
- package/skills/imstudium-sync/SKILL.md +45 -0
- package/skills/imstudium-visual/SKILL.md +63 -0
- package/skills/workflows/klausur-prep/SKILL.md +31 -0
- package/skills/workflows/semester-refresh/SKILL.md +29 -0
package/dist/cli.js
ADDED
|
@@ -0,0 +1,1484 @@
|
|
|
1
|
+
#!/usr/bin/env node
|
|
2
|
+
|
|
3
|
+
// src/cli.ts
|
|
4
|
+
import { readFile as readFile2 } from "fs/promises";
|
|
5
|
+
import { basename, join as join4 } from "path";
|
|
6
|
+
import { Command } from "commander";
|
|
7
|
+
import {
|
|
8
|
+
buildAgentVisualPayload,
|
|
9
|
+
buildSlideViewer,
|
|
10
|
+
createSdk as createSdk2,
|
|
11
|
+
detectTools,
|
|
12
|
+
ImstudiumError as ImstudiumError2,
|
|
13
|
+
expandPath as expandPath3,
|
|
14
|
+
extractSlug,
|
|
15
|
+
isChromeRunning,
|
|
16
|
+
probeOAuthServer,
|
|
17
|
+
pickCourse,
|
|
18
|
+
rebuildSearchIndexes,
|
|
19
|
+
resolveCourseDir,
|
|
20
|
+
toolHints,
|
|
21
|
+
ensureMigrated as ensureMigrated2,
|
|
22
|
+
archiveLegacyConfigDir
|
|
23
|
+
} from "@imstudium/sdk";
|
|
24
|
+
|
|
25
|
+
// src/setup.ts
|
|
26
|
+
import { existsSync as existsSync2 } from "fs";
|
|
27
|
+
import { cp, mkdir, readFile, rm, writeFile } from "fs/promises";
|
|
28
|
+
import { homedir as homedir2 } from "os";
|
|
29
|
+
import { dirname as dirname2, join as join3 } from "path";
|
|
30
|
+
import { fileURLToPath as fileURLToPath2 } from "url";
|
|
31
|
+
import { expandPath, paths } from "@imstudium/sdk";
|
|
32
|
+
|
|
33
|
+
// src/agents.ts
|
|
34
|
+
import { createHash } from "crypto";
|
|
35
|
+
import { existsSync } from "fs";
|
|
36
|
+
import { homedir, platform } from "os";
|
|
37
|
+
import { join as join2 } from "path";
|
|
38
|
+
|
|
39
|
+
// src/version.ts
|
|
40
|
+
import { createRequire } from "module";
|
|
41
|
+
import { realpathSync } from "fs";
|
|
42
|
+
import { execSync } from "child_process";
|
|
43
|
+
import { fileURLToPath } from "url";
|
|
44
|
+
import { dirname, join } from "path";
|
|
45
|
+
var require2 = createRequire(import.meta.url);
|
|
46
|
+
var CLI_PACKAGES = ["@imstudium/cli", "@imstudium/mcp"];
|
|
47
|
+
function getCliVersion() {
|
|
48
|
+
try {
|
|
49
|
+
const pkgPath = join(dirname(fileURLToPath(import.meta.url)), "..", "package.json");
|
|
50
|
+
return require2(pkgPath).version;
|
|
51
|
+
} catch {
|
|
52
|
+
return "0.0.0";
|
|
53
|
+
}
|
|
54
|
+
}
|
|
55
|
+
function detectInstallMode(entryPath) {
|
|
56
|
+
try {
|
|
57
|
+
const resolved = realpathSync(entryPath);
|
|
58
|
+
if (resolved.includes(join("packages", "cli"))) {
|
|
59
|
+
return "development";
|
|
60
|
+
}
|
|
61
|
+
const globalRoot = execSync("npm root -g", { encoding: "utf-8", stdio: ["pipe", "pipe", "ignore"] }).trim();
|
|
62
|
+
if (resolved.startsWith(globalRoot)) return "global";
|
|
63
|
+
} catch {
|
|
64
|
+
}
|
|
65
|
+
return "npx";
|
|
66
|
+
}
|
|
67
|
+
function semverLt(current, latest) {
|
|
68
|
+
const parse = (v) => v.replace(/^v/, "").split(".").map((n) => parseInt(n, 10) || 0);
|
|
69
|
+
const a = parse(current);
|
|
70
|
+
const b = parse(latest);
|
|
71
|
+
for (let i = 0; i < 3; i++) {
|
|
72
|
+
if (a[i] < b[i]) return true;
|
|
73
|
+
if (a[i] > b[i]) return false;
|
|
74
|
+
}
|
|
75
|
+
return false;
|
|
76
|
+
}
|
|
77
|
+
async function fetchNpmLatest(name) {
|
|
78
|
+
const registryPath = name.startsWith("@") ? name.replace("/", "%2F") : name;
|
|
79
|
+
try {
|
|
80
|
+
const res = await fetch(`https://registry.npmjs.org/${registryPath}/latest`, {
|
|
81
|
+
headers: { Accept: "application/json" },
|
|
82
|
+
signal: AbortSignal.timeout(1e4)
|
|
83
|
+
});
|
|
84
|
+
if (!res.ok) return null;
|
|
85
|
+
const data = await res.json();
|
|
86
|
+
return data.version ?? null;
|
|
87
|
+
} catch {
|
|
88
|
+
return null;
|
|
89
|
+
}
|
|
90
|
+
}
|
|
91
|
+
async function checkForUpdates(entryPath) {
|
|
92
|
+
const current = getCliVersion();
|
|
93
|
+
const installMode = detectInstallMode(entryPath);
|
|
94
|
+
const packages = [];
|
|
95
|
+
let latestMain = null;
|
|
96
|
+
for (const name of CLI_PACKAGES) {
|
|
97
|
+
const latest = await fetchNpmLatest(name);
|
|
98
|
+
if (name === "@imstudium/cli") latestMain = latest;
|
|
99
|
+
packages.push({
|
|
100
|
+
name,
|
|
101
|
+
current: name === "@imstudium/cli" ? current : current,
|
|
102
|
+
latest,
|
|
103
|
+
updateAvailable: latest ? semverLt(current, latest) : false
|
|
104
|
+
});
|
|
105
|
+
}
|
|
106
|
+
const updateAvailable = latestMain ? semverLt(current, latestMain) : false;
|
|
107
|
+
return {
|
|
108
|
+
schemaVersion: "1",
|
|
109
|
+
current,
|
|
110
|
+
latest: latestMain,
|
|
111
|
+
updateAvailable,
|
|
112
|
+
installMode,
|
|
113
|
+
packages,
|
|
114
|
+
recommendedAction: "imstudium update"
|
|
115
|
+
};
|
|
116
|
+
}
|
|
117
|
+
async function runGlobalUpdate() {
|
|
118
|
+
try {
|
|
119
|
+
const out = execSync("npm install -g @imstudium/cli@latest @imstudium/mcp@latest", {
|
|
120
|
+
encoding: "utf-8",
|
|
121
|
+
stdio: ["pipe", "pipe", "pipe"]
|
|
122
|
+
});
|
|
123
|
+
return { ok: true, output: out.trim() };
|
|
124
|
+
} catch (err) {
|
|
125
|
+
const e = err;
|
|
126
|
+
return { ok: false, output: e.stderr || e.stdout || e.message || "npm install failed" };
|
|
127
|
+
}
|
|
128
|
+
}
|
|
129
|
+
|
|
130
|
+
// src/agents.ts
|
|
131
|
+
function claudeDesktopConfigPath() {
|
|
132
|
+
if (platform() === "darwin") {
|
|
133
|
+
return join2(homedir(), "Library", "Application Support", "Claude", "claude_desktop_config.json");
|
|
134
|
+
}
|
|
135
|
+
if (platform() === "win32") {
|
|
136
|
+
return join2(process.env.APPDATA || homedir(), "Claude", "claude_desktop_config.json");
|
|
137
|
+
}
|
|
138
|
+
return join2(homedir(), ".config", "Claude", "claude_desktop_config.json");
|
|
139
|
+
}
|
|
140
|
+
var AGENT_REGISTRY = [
|
|
141
|
+
{
|
|
142
|
+
id: "cursor",
|
|
143
|
+
label: "Cursor",
|
|
144
|
+
detect: () => existsSync(join2(homedir(), ".cursor")) || Boolean(process.env.CURSOR_TRACE_ID || process.env.CURSOR_AGENT),
|
|
145
|
+
skillsDir: () => join2(homedir(), ".cursor", "skills"),
|
|
146
|
+
mcpFiles: () => [join2(homedir(), ".cursor", "mcp.json")]
|
|
147
|
+
},
|
|
148
|
+
{
|
|
149
|
+
id: "claude-code",
|
|
150
|
+
label: "Claude Code",
|
|
151
|
+
detect: () => existsSync(join2(homedir(), ".claude")) || Boolean(process.env.CLAUDE_CODE),
|
|
152
|
+
skillsDir: () => join2(homedir(), ".claude", "skills"),
|
|
153
|
+
mcpFiles: () => [join2(homedir(), ".claude", "mcp.json")]
|
|
154
|
+
},
|
|
155
|
+
{
|
|
156
|
+
id: "claude-desktop",
|
|
157
|
+
label: "Claude Desktop / Cowork",
|
|
158
|
+
detect: () => {
|
|
159
|
+
const cfg = claudeDesktopConfigPath();
|
|
160
|
+
const dir = join2(cfg, "..");
|
|
161
|
+
return existsSync(cfg) || existsSync(dir);
|
|
162
|
+
},
|
|
163
|
+
skillsDir: () => join2(homedir(), ".claude", "skills"),
|
|
164
|
+
mcpFiles: () => [claudeDesktopConfigPath()]
|
|
165
|
+
},
|
|
166
|
+
{
|
|
167
|
+
id: "codex",
|
|
168
|
+
label: "OpenAI Codex",
|
|
169
|
+
detect: () => existsSync(join2(homedir(), ".codex")) || Boolean(process.env.CODEX_HOME),
|
|
170
|
+
skillsDir: () => join2(homedir(), ".codex", "skills"),
|
|
171
|
+
mcpFiles: () => [join2(homedir(), ".codex", "mcp.json")]
|
|
172
|
+
},
|
|
173
|
+
{
|
|
174
|
+
id: "windsurf",
|
|
175
|
+
label: "Windsurf",
|
|
176
|
+
detect: () => existsSync(join2(homedir(), ".codeium", "windsurf")),
|
|
177
|
+
skillsDir: () => join2(homedir(), ".codeium", "windsurf", "skills"),
|
|
178
|
+
mcpFiles: () => [join2(homedir(), ".codeium", "windsurf", "mcp.json")]
|
|
179
|
+
},
|
|
180
|
+
{
|
|
181
|
+
id: "opencode",
|
|
182
|
+
label: "OpenCode",
|
|
183
|
+
detect: () => existsSync(join2(homedir(), ".config", "opencode")) || existsSync(join2(homedir(), ".opencode")),
|
|
184
|
+
skillsDir: () => join2(homedir(), ".config", "opencode", "skills"),
|
|
185
|
+
mcpFiles: () => [join2(homedir(), ".config", "opencode", "mcp.json")]
|
|
186
|
+
}
|
|
187
|
+
];
|
|
188
|
+
function detectAgents(all = false) {
|
|
189
|
+
if (all) return [...AGENT_REGISTRY];
|
|
190
|
+
const detected = AGENT_REGISTRY.filter((a) => a.detect());
|
|
191
|
+
return detected.length > 0 ? detected : [AGENT_REGISTRY[0]];
|
|
192
|
+
}
|
|
193
|
+
function getAgent(id) {
|
|
194
|
+
return AGENT_REGISTRY.find((a) => a.id === id);
|
|
195
|
+
}
|
|
196
|
+
function resolveAgentProfiles(agent, allAgents = false) {
|
|
197
|
+
if (agent && agent !== "all") {
|
|
198
|
+
const one = getAgent(agent);
|
|
199
|
+
return one ? [one] : [];
|
|
200
|
+
}
|
|
201
|
+
return detectAgents(allAgents || agent === "all");
|
|
202
|
+
}
|
|
203
|
+
function uniqueSkillsDirs(agents) {
|
|
204
|
+
const map = /* @__PURE__ */ new Map();
|
|
205
|
+
for (const a of agents) {
|
|
206
|
+
const dir = a.skillsDir();
|
|
207
|
+
const list = map.get(dir) ?? [];
|
|
208
|
+
list.push(a);
|
|
209
|
+
map.set(dir, list);
|
|
210
|
+
}
|
|
211
|
+
return map;
|
|
212
|
+
}
|
|
213
|
+
function hashContent(content) {
|
|
214
|
+
return createHash("sha256").update(content).digest("hex").slice(0, 16);
|
|
215
|
+
}
|
|
216
|
+
var SETUP_MANIFEST_VERSION = "1";
|
|
217
|
+
var IMSTUDIUM_SETUP_VERSION = getCliVersion();
|
|
218
|
+
function emptyManifest(workspace) {
|
|
219
|
+
return {
|
|
220
|
+
schemaVersion: SETUP_MANIFEST_VERSION,
|
|
221
|
+
imstudiumVersion: IMSTUDIUM_SETUP_VERSION,
|
|
222
|
+
workspace,
|
|
223
|
+
skills: {},
|
|
224
|
+
mcp: {},
|
|
225
|
+
updatedAt: (/* @__PURE__ */ new Date()).toISOString()
|
|
226
|
+
};
|
|
227
|
+
}
|
|
228
|
+
|
|
229
|
+
// src/setup.ts
|
|
230
|
+
import { convertLegacyManifest, ensureMigrated, legacyPaths } from "@imstudium/sdk";
|
|
231
|
+
var PKG_ROOT = join3(dirname2(fileURLToPath2(import.meta.url)), "..");
|
|
232
|
+
var SKILLS_ROOT = join3(PKG_ROOT, "skills");
|
|
233
|
+
var WORKFLOWS_ROOT = join3(SKILLS_ROOT, "workflows");
|
|
234
|
+
var MANIFEST_FILE = join3(paths.configDir, "setup-manifest.json");
|
|
235
|
+
function resolveMcpLaunch() {
|
|
236
|
+
if (process.env.IMSTUDIUM_MCP_LAUNCH === "npx") {
|
|
237
|
+
return { command: "npx", args: ["-y", "@imstudium/mcp"] };
|
|
238
|
+
}
|
|
239
|
+
const local = join3(homedir2(), ".local", "bin", "imstudium-mcp");
|
|
240
|
+
if (existsSync2(local)) {
|
|
241
|
+
return { command: local, args: [] };
|
|
242
|
+
}
|
|
243
|
+
return { command: "npx", args: ["-y", "@imstudium/mcp"] };
|
|
244
|
+
}
|
|
245
|
+
function mergeMcpConfig(existing, workspace) {
|
|
246
|
+
const servers = existing.mcpServers ?? {};
|
|
247
|
+
const { digicampus: _legacy, ...rest } = servers;
|
|
248
|
+
const launch = resolveMcpLaunch();
|
|
249
|
+
return {
|
|
250
|
+
...existing,
|
|
251
|
+
mcpServers: {
|
|
252
|
+
...rest,
|
|
253
|
+
imstudium: {
|
|
254
|
+
...launch,
|
|
255
|
+
env: { IMSTUDIUM_WORKSPACE: expandPath(workspace) }
|
|
256
|
+
}
|
|
257
|
+
}
|
|
258
|
+
};
|
|
259
|
+
}
|
|
260
|
+
function imstudiumMcpEntry(workspace) {
|
|
261
|
+
const merged = mergeMcpConfig({}, workspace);
|
|
262
|
+
return merged.mcpServers.imstudium;
|
|
263
|
+
}
|
|
264
|
+
function mcpEntryMatches(existing, desired) {
|
|
265
|
+
if (!existing) return false;
|
|
266
|
+
return stableJson(existing) === stableJson(desired);
|
|
267
|
+
}
|
|
268
|
+
function stableJson(obj) {
|
|
269
|
+
const sortKeys = (value) => {
|
|
270
|
+
if (Array.isArray(value)) return value.map(sortKeys);
|
|
271
|
+
if (value && typeof value === "object") {
|
|
272
|
+
const record = value;
|
|
273
|
+
return Object.keys(record).sort().reduce((acc, key) => {
|
|
274
|
+
acc[key] = sortKeys(record[key]);
|
|
275
|
+
return acc;
|
|
276
|
+
}, {});
|
|
277
|
+
}
|
|
278
|
+
return value;
|
|
279
|
+
};
|
|
280
|
+
return JSON.stringify(sortKeys(obj));
|
|
281
|
+
}
|
|
282
|
+
async function loadManifest() {
|
|
283
|
+
try {
|
|
284
|
+
return JSON.parse(await readFile(MANIFEST_FILE, "utf-8"));
|
|
285
|
+
} catch {
|
|
286
|
+
try {
|
|
287
|
+
const legacyFile = join3(legacyPaths.configDir, "setup-manifest.json");
|
|
288
|
+
const raw = JSON.parse(await readFile(legacyFile, "utf-8"));
|
|
289
|
+
const converted = convertLegacyManifest(raw);
|
|
290
|
+
await saveManifest(converted);
|
|
291
|
+
return converted;
|
|
292
|
+
} catch {
|
|
293
|
+
return null;
|
|
294
|
+
}
|
|
295
|
+
}
|
|
296
|
+
}
|
|
297
|
+
async function saveManifest(manifest) {
|
|
298
|
+
await mkdir(dirname2(MANIFEST_FILE), { recursive: true });
|
|
299
|
+
manifest.updatedAt = (/* @__PURE__ */ new Date()).toISOString();
|
|
300
|
+
await writeFile(MANIFEST_FILE, `${JSON.stringify(manifest, null, 2)}
|
|
301
|
+
`);
|
|
302
|
+
}
|
|
303
|
+
async function listSkillDirs(root) {
|
|
304
|
+
try {
|
|
305
|
+
const { readdir } = await import("fs/promises");
|
|
306
|
+
const entries = await readdir(root, { withFileTypes: true });
|
|
307
|
+
return entries.filter((e) => e.isDirectory()).map((e) => e.name);
|
|
308
|
+
} catch {
|
|
309
|
+
return [];
|
|
310
|
+
}
|
|
311
|
+
}
|
|
312
|
+
async function cleanupLegacySkills(skillsDir) {
|
|
313
|
+
const removed = [];
|
|
314
|
+
for (const name of await listSkillDirs(skillsDir)) {
|
|
315
|
+
if (!name.startsWith("digicampus-")) continue;
|
|
316
|
+
await rm(join3(skillsDir, name), { recursive: true, force: true });
|
|
317
|
+
removed.push(name);
|
|
318
|
+
}
|
|
319
|
+
return removed;
|
|
320
|
+
}
|
|
321
|
+
async function installSkillIfChanged(src, dest, force) {
|
|
322
|
+
const srcFile = join3(src, "SKILL.md");
|
|
323
|
+
const destFile = join3(dest, "SKILL.md");
|
|
324
|
+
const srcContent = await readFile(srcFile, "utf-8");
|
|
325
|
+
const srcHash = hashContent(srcContent);
|
|
326
|
+
if (!force) {
|
|
327
|
+
try {
|
|
328
|
+
const destContent = await readFile(destFile, "utf-8");
|
|
329
|
+
if (hashContent(destContent) === srcHash) {
|
|
330
|
+
return { action: "skipped", path: dest };
|
|
331
|
+
}
|
|
332
|
+
} catch {
|
|
333
|
+
}
|
|
334
|
+
}
|
|
335
|
+
let hadPrevious = false;
|
|
336
|
+
try {
|
|
337
|
+
await readFile(destFile, "utf-8");
|
|
338
|
+
hadPrevious = true;
|
|
339
|
+
} catch {
|
|
340
|
+
}
|
|
341
|
+
await mkdir(dest, { recursive: true });
|
|
342
|
+
await cp(src, dest, { recursive: true, force: true });
|
|
343
|
+
return { action: hadPrevious ? "updated" : "installed", path: dest };
|
|
344
|
+
}
|
|
345
|
+
async function installMcpFile(file, workspace, force) {
|
|
346
|
+
const desired = imstudiumMcpEntry(workspace);
|
|
347
|
+
let existing = {};
|
|
348
|
+
try {
|
|
349
|
+
existing = JSON.parse(await readFile(file, "utf-8"));
|
|
350
|
+
} catch {
|
|
351
|
+
}
|
|
352
|
+
const servers = existing.mcpServers ?? {};
|
|
353
|
+
if (!force && mcpEntryMatches(servers.imstudium, desired)) {
|
|
354
|
+
return { action: "skipped", path: file };
|
|
355
|
+
}
|
|
356
|
+
const merged = mergeMcpConfig(existing, workspace);
|
|
357
|
+
await mkdir(dirname2(file), { recursive: true });
|
|
358
|
+
await writeFile(file, `${JSON.stringify(merged, null, 2)}
|
|
359
|
+
`);
|
|
360
|
+
return { action: servers.imstudium ? "updated" : "installed", path: file };
|
|
361
|
+
}
|
|
362
|
+
async function installProjectMcp(workspace, force = false) {
|
|
363
|
+
const file = join3(expandPath(workspace), "mcp.json");
|
|
364
|
+
return installMcpFile(file, workspace, force);
|
|
365
|
+
}
|
|
366
|
+
async function installSkillsForAgents(agents, manifest, force) {
|
|
367
|
+
const results = [];
|
|
368
|
+
const names = await listSkillDirs(SKILLS_ROOT);
|
|
369
|
+
const byDir = uniqueSkillsDirs(agents);
|
|
370
|
+
for (const [skillsDir, group] of byDir) {
|
|
371
|
+
await mkdir(skillsDir, { recursive: true });
|
|
372
|
+
await cleanupLegacySkills(skillsDir);
|
|
373
|
+
for (const name of names) {
|
|
374
|
+
if (name === "workflows") continue;
|
|
375
|
+
const result = await installSkillIfChanged(
|
|
376
|
+
join3(SKILLS_ROOT, name),
|
|
377
|
+
join3(skillsDir, name),
|
|
378
|
+
force
|
|
379
|
+
);
|
|
380
|
+
results.push(result);
|
|
381
|
+
for (const a of group) {
|
|
382
|
+
manifest.skills[a.id] ??= {};
|
|
383
|
+
manifest.skills[a.id][name] = hashContent(
|
|
384
|
+
await readFile(join3(SKILLS_ROOT, name, "SKILL.md"), "utf-8")
|
|
385
|
+
);
|
|
386
|
+
}
|
|
387
|
+
}
|
|
388
|
+
}
|
|
389
|
+
return results;
|
|
390
|
+
}
|
|
391
|
+
async function installWorkflowsForAgents(agents, force) {
|
|
392
|
+
const results = [];
|
|
393
|
+
const names = await listSkillDirs(WORKFLOWS_ROOT);
|
|
394
|
+
const byDir = uniqueSkillsDirs(agents);
|
|
395
|
+
for (const [skillsDir] of byDir) {
|
|
396
|
+
for (const name of names) {
|
|
397
|
+
results.push(
|
|
398
|
+
await installSkillIfChanged(join3(WORKFLOWS_ROOT, name), join3(skillsDir, name), force)
|
|
399
|
+
);
|
|
400
|
+
}
|
|
401
|
+
}
|
|
402
|
+
return results;
|
|
403
|
+
}
|
|
404
|
+
async function installMcpForAgents(agents, workspace, manifest, force) {
|
|
405
|
+
const results = [];
|
|
406
|
+
const seen = /* @__PURE__ */ new Set();
|
|
407
|
+
for (const agent of agents) {
|
|
408
|
+
manifest.mcp[agent.id] ??= [];
|
|
409
|
+
for (const file of agent.mcpFiles()) {
|
|
410
|
+
if (seen.has(file)) continue;
|
|
411
|
+
seen.add(file);
|
|
412
|
+
const result = await installMcpFile(file, workspace, force);
|
|
413
|
+
results.push(result);
|
|
414
|
+
if (result.action !== "skipped") manifest.mcp[agent.id].push(file);
|
|
415
|
+
}
|
|
416
|
+
}
|
|
417
|
+
return results;
|
|
418
|
+
}
|
|
419
|
+
async function runSetup(what, opts = {}) {
|
|
420
|
+
await ensureMigrated();
|
|
421
|
+
const workspace = expandPath(opts.workspace ?? homedir2() + "/Studium");
|
|
422
|
+
const agents = resolveAgentProfiles(opts.agent, opts.allAgents || opts.agent === "all");
|
|
423
|
+
const prev = await loadManifest();
|
|
424
|
+
const manifest = prev?.workspace === workspace && !opts.force ? { ...prev, workspace } : emptyManifest(workspace);
|
|
425
|
+
manifest.imstudiumVersion = IMSTUDIUM_SETUP_VERSION;
|
|
426
|
+
const result = {
|
|
427
|
+
agents: agents.map((a) => a.id),
|
|
428
|
+
skills: [],
|
|
429
|
+
mcp: [],
|
|
430
|
+
workflows: [],
|
|
431
|
+
manifest
|
|
432
|
+
};
|
|
433
|
+
if (what === "migrate") {
|
|
434
|
+
opts.force = true;
|
|
435
|
+
what = "all";
|
|
436
|
+
}
|
|
437
|
+
if (what === "status") return result;
|
|
438
|
+
const force = Boolean(opts.force);
|
|
439
|
+
if (what === "skills" || what === "all") {
|
|
440
|
+
result.skills = await installSkillsForAgents(agents, manifest, force);
|
|
441
|
+
}
|
|
442
|
+
if (what === "workflows" || what === "all") {
|
|
443
|
+
result.workflows = await installWorkflowsForAgents(agents, force);
|
|
444
|
+
}
|
|
445
|
+
if (what === "mcp" || what === "all") {
|
|
446
|
+
result.mcp = await installMcpForAgents(agents, workspace, manifest, force);
|
|
447
|
+
if (opts.project !== false) {
|
|
448
|
+
result.project = [await installProjectMcp(workspace, force)];
|
|
449
|
+
}
|
|
450
|
+
}
|
|
451
|
+
await saveManifest(manifest);
|
|
452
|
+
return result;
|
|
453
|
+
}
|
|
454
|
+
async function bundledSkillHashes() {
|
|
455
|
+
const hashes = {};
|
|
456
|
+
const core = await listSkillDirs(SKILLS_ROOT);
|
|
457
|
+
for (const name of core) {
|
|
458
|
+
if (name === "workflows") continue;
|
|
459
|
+
const content = await readFile(join3(SKILLS_ROOT, name, "SKILL.md"), "utf-8");
|
|
460
|
+
hashes[name] = hashContent(content);
|
|
461
|
+
}
|
|
462
|
+
const workflows = await listSkillDirs(WORKFLOWS_ROOT);
|
|
463
|
+
for (const name of workflows) {
|
|
464
|
+
const content = await readFile(join3(WORKFLOWS_ROOT, name, "SKILL.md"), "utf-8");
|
|
465
|
+
hashes[name] = hashContent(content);
|
|
466
|
+
}
|
|
467
|
+
return hashes;
|
|
468
|
+
}
|
|
469
|
+
async function findStaleSkills(agents) {
|
|
470
|
+
const bundled = await bundledSkillHashes();
|
|
471
|
+
const stale = /* @__PURE__ */ new Set();
|
|
472
|
+
const byDir = uniqueSkillsDirs(agents);
|
|
473
|
+
for (const [skillsDir] of byDir) {
|
|
474
|
+
for (const [name, expected] of Object.entries(bundled)) {
|
|
475
|
+
try {
|
|
476
|
+
const content = await readFile(join3(skillsDir, name, "SKILL.md"), "utf-8");
|
|
477
|
+
if (hashContent(content) !== expected) stale.add(name);
|
|
478
|
+
} catch {
|
|
479
|
+
stale.add(name);
|
|
480
|
+
}
|
|
481
|
+
}
|
|
482
|
+
}
|
|
483
|
+
return [...stale].sort();
|
|
484
|
+
}
|
|
485
|
+
async function getSetupStatus(workspace) {
|
|
486
|
+
const ws = expandPath(workspace ?? homedir2() + "/Studium");
|
|
487
|
+
const manifest = await loadManifest();
|
|
488
|
+
const agentProfiles = detectAgents();
|
|
489
|
+
const detected = agentProfiles.map((a) => ({
|
|
490
|
+
id: a.id,
|
|
491
|
+
label: a.label,
|
|
492
|
+
skillsDir: a.skillsDir(),
|
|
493
|
+
mcpFiles: a.mcpFiles()
|
|
494
|
+
}));
|
|
495
|
+
const configured = manifest ? Object.keys(manifest.mcp).filter((id) => (manifest.mcp[id]?.length ?? 0) > 0) : [];
|
|
496
|
+
const agentsToCheck = configured.length > 0 ? resolveAgentProfiles("all", true).filter((a) => configured.includes(a.id)) : agentProfiles;
|
|
497
|
+
const staleSkills = await findStaleSkills(agentsToCheck.length > 0 ? agentsToCheck : agentProfiles);
|
|
498
|
+
const versionOk = manifest?.imstudiumVersion === IMSTUDIUM_SETUP_VERSION;
|
|
499
|
+
const workspaceOk = manifest?.workspace === ws;
|
|
500
|
+
const skillsOk = staleSkills.length === 0;
|
|
501
|
+
const upToDate = Boolean(manifest && versionOk && workspaceOk && skillsOk);
|
|
502
|
+
const stale = !upToDate;
|
|
503
|
+
return { detected, configured, manifest, upToDate, stale, staleSkills, workspace: ws };
|
|
504
|
+
}
|
|
505
|
+
function formatSetupSummary(result) {
|
|
506
|
+
const count = (items) => ({
|
|
507
|
+
installed: items.filter((i) => i.action === "installed").length,
|
|
508
|
+
updated: items.filter((i) => i.action === "updated").length,
|
|
509
|
+
skipped: items.filter((i) => i.action === "skipped").length
|
|
510
|
+
});
|
|
511
|
+
const s = count(result.skills);
|
|
512
|
+
const m = count(result.mcp);
|
|
513
|
+
const w = count(result.workflows);
|
|
514
|
+
const parts = [
|
|
515
|
+
`Agents: ${result.agents.join(", ") || "none"}`,
|
|
516
|
+
`Skills: ${s.installed} new, ${s.updated} updated, ${s.skipped} unchanged`,
|
|
517
|
+
`MCP: ${m.installed} new, ${m.updated} updated, ${m.skipped} unchanged`
|
|
518
|
+
];
|
|
519
|
+
if (result.workflows.length) {
|
|
520
|
+
parts.push(`Workflows: ${w.installed + w.updated} changed, ${w.skipped} unchanged`);
|
|
521
|
+
}
|
|
522
|
+
return parts.join(" \xB7 ");
|
|
523
|
+
}
|
|
524
|
+
|
|
525
|
+
// src/onboard-flow.ts
|
|
526
|
+
import {
|
|
527
|
+
createSdk,
|
|
528
|
+
ImstudiumError,
|
|
529
|
+
expandPath as expandPath2,
|
|
530
|
+
OnboardUI
|
|
531
|
+
} from "@imstudium/sdk";
|
|
532
|
+
function shouldShowOnboardUI(opts) {
|
|
533
|
+
return !opts.json && !opts.quiet && !opts.yes;
|
|
534
|
+
}
|
|
535
|
+
async function runOnboardFlow(opts) {
|
|
536
|
+
const partial = opts.instance ? {
|
|
537
|
+
baseUrl: `${opts.instance.replace(/\/$/, "")}/jsonapi.php/v1`,
|
|
538
|
+
instanceOrigin: opts.instance.replace(/\/$/, "")
|
|
539
|
+
} : void 0;
|
|
540
|
+
const sdk = await createSdk(partial);
|
|
541
|
+
const out = expandPath2(opts.output || sdk.workspacePath);
|
|
542
|
+
await sdk.saveConfig({ workspacePath: out });
|
|
543
|
+
const ui = shouldShowOnboardUI(opts) ? new OnboardUI(out) : null;
|
|
544
|
+
if (ui && opts.agentsOnly) ui.configureForAgentsOnly();
|
|
545
|
+
try {
|
|
546
|
+
if (ui) await ui.start(true);
|
|
547
|
+
ui?.updateStep("config", { status: "active" });
|
|
548
|
+
ui?.updateStep("config", { status: "done", detail: out });
|
|
549
|
+
let report;
|
|
550
|
+
let studium;
|
|
551
|
+
if (!opts.agentsOnly) {
|
|
552
|
+
const session = await sdk.auth.loadSession();
|
|
553
|
+
if (!session) {
|
|
554
|
+
if (opts.yes) {
|
|
555
|
+
throw new ImstudiumError(
|
|
556
|
+
"Not logged in.",
|
|
557
|
+
"NO_SESSION",
|
|
558
|
+
void 0,
|
|
559
|
+
"Run: imstudium login"
|
|
560
|
+
);
|
|
561
|
+
}
|
|
562
|
+
ui?.updateStep("login", { status: "active" });
|
|
563
|
+
ui?.setLiveMessage("Opening sign-in browser \u2014 complete MFA if prompted\u2026");
|
|
564
|
+
await opts.runLogin(sdk, (text) => ui?.setLiveMessage(text));
|
|
565
|
+
ui?.updateStep("login", { status: "done", detail: "Signed in" });
|
|
566
|
+
} else {
|
|
567
|
+
ui?.updateStep("login", { status: "skipped", detail: "Already signed in" });
|
|
568
|
+
}
|
|
569
|
+
if (!opts.skipSync) {
|
|
570
|
+
ui?.updateStep("sync", { status: "active" });
|
|
571
|
+
ui?.setLiveMessage("Downloading course files from Stud.IP\u2026");
|
|
572
|
+
const semOpts = {
|
|
573
|
+
semesterId: sdk.config.semesterId
|
|
574
|
+
};
|
|
575
|
+
report = await sdk.sync.sync({
|
|
576
|
+
outputDir: out,
|
|
577
|
+
include: ["wiki", "news", "calendar"],
|
|
578
|
+
...semOpts
|
|
579
|
+
});
|
|
580
|
+
ui?.updateStep("sync", {
|
|
581
|
+
status: "done",
|
|
582
|
+
detail: `${report.downloaded} downloaded \xB7 ${report.skipped} unchanged`
|
|
583
|
+
});
|
|
584
|
+
ui?.updateStep("studium", { status: "active" });
|
|
585
|
+
ui?.setLiveMessage("Building studium.json and study overview\u2026");
|
|
586
|
+
studium = await sdk.studium.build(out, sdk.config.instanceOrigin, semOpts);
|
|
587
|
+
ui?.updateStep("studium", {
|
|
588
|
+
status: "done",
|
|
589
|
+
detail: `${studium.courses.length} courses`
|
|
590
|
+
});
|
|
591
|
+
} else {
|
|
592
|
+
ui?.updateStep("sync", { status: "skipped", detail: "--skip-sync" });
|
|
593
|
+
ui?.updateStep("studium", { status: "skipped", detail: "--skip-sync" });
|
|
594
|
+
}
|
|
595
|
+
}
|
|
596
|
+
ui?.updateStep("agents", { status: "active" });
|
|
597
|
+
ui?.setLiveMessage("Installing skills and MCP for detected AI tools\u2026");
|
|
598
|
+
const setup = await runSetup("all", {
|
|
599
|
+
yes: opts.yes,
|
|
600
|
+
workspace: out,
|
|
601
|
+
force: opts.force,
|
|
602
|
+
allAgents: opts.allAgents
|
|
603
|
+
});
|
|
604
|
+
const summary = formatSetupSummary(setup);
|
|
605
|
+
ui?.updateStep("agents", { status: "done", detail: setup.agents.join(", ") || "none" });
|
|
606
|
+
ui?.complete({
|
|
607
|
+
workspacePath: out,
|
|
608
|
+
courses: studium?.courses.length,
|
|
609
|
+
downloaded: report?.downloaded,
|
|
610
|
+
agents: setup.agents
|
|
611
|
+
});
|
|
612
|
+
if (ui) {
|
|
613
|
+
await ui.closeAfter(12e3);
|
|
614
|
+
}
|
|
615
|
+
return { workspace: out, report, studium, setup, summary };
|
|
616
|
+
} catch (err) {
|
|
617
|
+
const message = err instanceof ImstudiumError ? err.message : err instanceof Error ? err.message : String(err);
|
|
618
|
+
ui?.fail(message);
|
|
619
|
+
if (ui) await ui.closeAfter(8e3);
|
|
620
|
+
throw err;
|
|
621
|
+
}
|
|
622
|
+
}
|
|
623
|
+
|
|
624
|
+
// src/cli.ts
|
|
625
|
+
import { fileURLToPath as fileURLToPath3 } from "url";
|
|
626
|
+
var globalOpts = {};
|
|
627
|
+
function output(data, text) {
|
|
628
|
+
if (globalOpts.json) {
|
|
629
|
+
console.log(JSON.stringify({ schemaVersion: "1", ...typeof data === "object" && data || { data } }, null, 2));
|
|
630
|
+
} else if (text) {
|
|
631
|
+
console.log(text);
|
|
632
|
+
} else if (typeof data === "string") {
|
|
633
|
+
console.log(data);
|
|
634
|
+
}
|
|
635
|
+
}
|
|
636
|
+
function fail(err) {
|
|
637
|
+
const error = err instanceof ImstudiumError2 ? err : new ImstudiumError2(err instanceof Error ? err.message : String(err), "UNKNOWN");
|
|
638
|
+
if (globalOpts.json) {
|
|
639
|
+
console.error(JSON.stringify(error.toJSON(), null, 2));
|
|
640
|
+
} else {
|
|
641
|
+
console.error(`Error: ${error.message}`);
|
|
642
|
+
if (error.hint) console.error(`Hint: ${error.hint}`);
|
|
643
|
+
}
|
|
644
|
+
process.exit(1);
|
|
645
|
+
}
|
|
646
|
+
async function getSdk() {
|
|
647
|
+
const partial = globalOpts.instance ? { baseUrl: `${globalOpts.instance.replace(/\/$/, "")}/jsonapi.php/v1`, instanceOrigin: globalOpts.instance.replace(/\/$/, "") } : void 0;
|
|
648
|
+
return createSdk2(partial);
|
|
649
|
+
}
|
|
650
|
+
function semesterSyncOptions(sdk, opts) {
|
|
651
|
+
if (opts.allSemesters) return { allSemesters: true };
|
|
652
|
+
if (opts.semester) return { semesterLabel: opts.semester };
|
|
653
|
+
if (sdk.config.semesterId) return { semesterId: sdk.config.semesterId };
|
|
654
|
+
return {};
|
|
655
|
+
}
|
|
656
|
+
function courseDirFor(ws, workspaceRoot, slug) {
|
|
657
|
+
const course = ws.courses.find((c) => c.slug === slug);
|
|
658
|
+
if (course) return resolveCourseDir(workspaceRoot, course, ws);
|
|
659
|
+
return join4(workspaceRoot, ws.activeSemester?.slug || "current", slug);
|
|
660
|
+
}
|
|
661
|
+
async function runLogin(sdk, opts) {
|
|
662
|
+
const loginOpts = {
|
|
663
|
+
instanceOrigin: sdk.config.instanceOrigin,
|
|
664
|
+
onMessage: (text) => {
|
|
665
|
+
opts.onMessage?.(text);
|
|
666
|
+
if (!opts.onMessage && !globalOpts.quiet) output(null, text);
|
|
667
|
+
}
|
|
668
|
+
};
|
|
669
|
+
const forceBrowser = opts.force ?? opts.relogin ?? false;
|
|
670
|
+
if (!forceBrowser && !opts.manual) {
|
|
671
|
+
const valid = await sdk.auth.ensureValidSession({
|
|
672
|
+
instanceOrigin: sdk.config.instanceOrigin,
|
|
673
|
+
clientId: sdk.config.oauth?.clientId
|
|
674
|
+
});
|
|
675
|
+
if (valid) {
|
|
676
|
+
if (!globalOpts.quiet) output(null, "Already signed in \u2014 session is valid.");
|
|
677
|
+
return;
|
|
678
|
+
}
|
|
679
|
+
}
|
|
680
|
+
const clientId = sdk.config.oauth?.clientId;
|
|
681
|
+
const useOAuth = opts.oauth || Boolean(clientId);
|
|
682
|
+
if (useOAuth && !opts.manual) {
|
|
683
|
+
if (!clientId) {
|
|
684
|
+
fail(new ImstudiumError2("Set IMSTUDIUM_OAUTH_CLIENT_ID or config oauth.clientId", "NO_CLIENT_ID"));
|
|
685
|
+
}
|
|
686
|
+
const probe = await probeOAuthServer(sdk.config.instanceOrigin, clientId);
|
|
687
|
+
if (probe.status === "ready") {
|
|
688
|
+
await sdk.auth.loginWithOAuth({
|
|
689
|
+
instanceOrigin: sdk.config.instanceOrigin,
|
|
690
|
+
clientId,
|
|
691
|
+
onMessage: loginOpts.onMessage
|
|
692
|
+
});
|
|
693
|
+
return;
|
|
694
|
+
}
|
|
695
|
+
if (probe.status === "broken_setup") {
|
|
696
|
+
if (!globalOpts.quiet) {
|
|
697
|
+
output(
|
|
698
|
+
null,
|
|
699
|
+
"Stud.IP OAuth is not set up on this instance yet \u2014 using browser sign-in instead."
|
|
700
|
+
);
|
|
701
|
+
output(null, `University admin: ${probe.endpoints.admin}`);
|
|
702
|
+
}
|
|
703
|
+
} else if (probe.status === "no_client") {
|
|
704
|
+
fail(
|
|
705
|
+
new ImstudiumError2(
|
|
706
|
+
"OAuth client_id was rejected by Stud.IP.",
|
|
707
|
+
"OAUTH_CLIENT_INVALID",
|
|
708
|
+
void 0,
|
|
709
|
+
"Check oauth.clientId in ~/.config/imstudium/config.json"
|
|
710
|
+
)
|
|
711
|
+
);
|
|
712
|
+
} else if (opts.oauth) {
|
|
713
|
+
fail(
|
|
714
|
+
new ImstudiumError2(
|
|
715
|
+
probe.message || "OAuth is unavailable on this Stud.IP instance.",
|
|
716
|
+
"OAUTH_UNAVAILABLE"
|
|
717
|
+
)
|
|
718
|
+
);
|
|
719
|
+
} else if (!globalOpts.quiet) {
|
|
720
|
+
output(null, `${probe.message || "OAuth unavailable"} \u2014 using browser sign-in.`);
|
|
721
|
+
}
|
|
722
|
+
}
|
|
723
|
+
if (opts.manual) {
|
|
724
|
+
if (globalOpts.json) {
|
|
725
|
+
output(null, "Starting manual login helper\u2026");
|
|
726
|
+
const result = await sdk.auth.loginInteractive(loginOpts);
|
|
727
|
+
output({ ok: true, sessionType: result.type }, "Session saved");
|
|
728
|
+
return;
|
|
729
|
+
}
|
|
730
|
+
output(null, "Opening manual login helper\u2026");
|
|
731
|
+
await sdk.auth.loginInteractive(loginOpts);
|
|
732
|
+
return;
|
|
733
|
+
}
|
|
734
|
+
const browserBase = {
|
|
735
|
+
instanceOrigin: sdk.config.instanceOrigin,
|
|
736
|
+
profileDirectory: opts.profileDirectory,
|
|
737
|
+
force: forceBrowser,
|
|
738
|
+
onMessage: loginOpts.onMessage
|
|
739
|
+
};
|
|
740
|
+
if (!globalOpts.quiet) {
|
|
741
|
+
output(null, "Opening sign-in browser\u2026");
|
|
742
|
+
}
|
|
743
|
+
if (opts.chromeProfile) {
|
|
744
|
+
if (await isChromeRunning()) {
|
|
745
|
+
fail(
|
|
746
|
+
new ImstudiumError2(
|
|
747
|
+
"Google Chrome is still running.",
|
|
748
|
+
"CHROME_PROFILE_LOCKED",
|
|
749
|
+
void 0,
|
|
750
|
+
"Quit Chrome completely, then run: imstudium login --chrome-profile"
|
|
751
|
+
)
|
|
752
|
+
);
|
|
753
|
+
}
|
|
754
|
+
await sdk.auth.loginWithBrowser({ ...browserBase, profile: "system" });
|
|
755
|
+
return;
|
|
756
|
+
}
|
|
757
|
+
await sdk.auth.loginWithBrowser({ ...browserBase, profile: "isolated" });
|
|
758
|
+
}
|
|
759
|
+
var CLI_ENTRY = fileURLToPath3(import.meta.url);
|
|
760
|
+
var program = new Command();
|
|
761
|
+
program.name("imstudium").description("ImStudium \u2014 extract, sync, and plan your studies").version(getCliVersion()).option("--json", "JSON output for agents").option("--quiet", "Minimal output").option("--instance <url>", "Stud.IP instance origin (default: digicampus.uni-augsburg.de)").hook("preAction", (thisCommand) => {
|
|
762
|
+
globalOpts = thisCommand.opts();
|
|
763
|
+
});
|
|
764
|
+
program.command("onboard").description("First-time setup: login, sync workspace, wire agents (idempotent)").option("-o, --output <dir>", "Workspace directory", "~/Studium").option("-y, --yes", "Non-interactive").option(
|
|
765
|
+
"--agents-only",
|
|
766
|
+
"Skip login/sync \u2014 only configure Cursor, Claude Code, Claude Desktop, Codex, Windsurf, OpenCode"
|
|
767
|
+
).option("--skip-sync", "Login + agent setup, no file sync").option("--force", "Re-install skills/MCP even when unchanged").option("--all-agents", "Configure every harness path, not only detected ones").action(async (opts) => {
|
|
768
|
+
try {
|
|
769
|
+
const result = await runOnboardFlow({
|
|
770
|
+
...opts,
|
|
771
|
+
json: globalOpts.json,
|
|
772
|
+
quiet: globalOpts.quiet,
|
|
773
|
+
instance: globalOpts.instance,
|
|
774
|
+
runLogin: async (sdk, onMessage) => {
|
|
775
|
+
await runLogin(sdk, {
|
|
776
|
+
onMessage: (text) => {
|
|
777
|
+
onMessage?.(text);
|
|
778
|
+
if (!globalOpts.quiet && !onMessage) output(null, text);
|
|
779
|
+
}
|
|
780
|
+
});
|
|
781
|
+
}
|
|
782
|
+
});
|
|
783
|
+
output(
|
|
784
|
+
{
|
|
785
|
+
workspace: result.workspace,
|
|
786
|
+
report: result.report,
|
|
787
|
+
studium: result.studium,
|
|
788
|
+
setup: result.setup,
|
|
789
|
+
summary: result.summary
|
|
790
|
+
},
|
|
791
|
+
`Onboarded \u2192 ${result.workspace}
|
|
792
|
+
${result.summary}`
|
|
793
|
+
);
|
|
794
|
+
} catch (e) {
|
|
795
|
+
fail(e);
|
|
796
|
+
}
|
|
797
|
+
});
|
|
798
|
+
program.command("init").description("Login + sync Studium workspace (use onboard for first-time agent setup)").option("-o, --output <dir>", "Workspace directory").option("-y, --yes", "Non-interactive (skip prompts)").option("--relogin", "Sign in again even if a session is already stored").option("--chrome-profile", "Use your Chrome profile with saved passwords (quit Chrome first)").option("--extract", "Extract text + slide images after sync").action(async (opts) => {
|
|
799
|
+
try {
|
|
800
|
+
const sdk = await getSdk();
|
|
801
|
+
const out = expandPath3(opts.output || sdk.workspacePath);
|
|
802
|
+
const session = await sdk.auth.loadSession();
|
|
803
|
+
if (!session || opts.relogin) {
|
|
804
|
+
if (opts.yes && !session) {
|
|
805
|
+
fail(
|
|
806
|
+
new ImstudiumError2("Not logged in.", "NO_SESSION", void 0, "Run: imstudium login")
|
|
807
|
+
);
|
|
808
|
+
}
|
|
809
|
+
await runLogin(sdk, { chromeProfile: opts.chromeProfile });
|
|
810
|
+
}
|
|
811
|
+
await sdk.saveConfig({ workspacePath: out });
|
|
812
|
+
const semOpts = semesterSyncOptions(sdk, {});
|
|
813
|
+
const report = await sdk.sync.sync({
|
|
814
|
+
outputDir: out,
|
|
815
|
+
include: ["wiki", "news", "calendar"],
|
|
816
|
+
...semOpts
|
|
817
|
+
});
|
|
818
|
+
const workspace = await sdk.studium.build(out, sdk.config.instanceOrigin, semOpts);
|
|
819
|
+
let extracted = 0;
|
|
820
|
+
if (opts.extract) {
|
|
821
|
+
for (const course of workspace.courses) {
|
|
822
|
+
const courseDir = courseDirFor(workspace, out, course.slug);
|
|
823
|
+
const results = await sdk.extractor.extractDir(
|
|
824
|
+
join4(courseDir, "files"),
|
|
825
|
+
join4(courseDir, "extracted"),
|
|
826
|
+
{
|
|
827
|
+
course: { title: course.title, path: course.path || `${workspace.activeSemester?.slug}/${course.slug}` },
|
|
828
|
+
workspaceRoot: out
|
|
829
|
+
}
|
|
830
|
+
);
|
|
831
|
+
extracted += results.filter((r) => !r.error && !r.skipped).length;
|
|
832
|
+
}
|
|
833
|
+
await rebuildSearchIndexes(out, workspace);
|
|
834
|
+
await sdk.studium.refreshSearchArtifacts(out, workspace);
|
|
835
|
+
}
|
|
836
|
+
output(
|
|
837
|
+
{ workspace, report, extracted: opts.extract ? extracted : void 0, next: "imstudium onboard --agents-only" },
|
|
838
|
+
`Studium workspace ready at ${out} \u2014 ${workspace.activeSemester.title} in ${workspace.workspaceFolder}/${opts.extract ? ` \xB7 ${extracted} extracted` : ""}
|
|
839
|
+
Run: imstudium onboard --agents-only (once, idempotent)`
|
|
840
|
+
);
|
|
841
|
+
} catch (e) {
|
|
842
|
+
fail(e);
|
|
843
|
+
}
|
|
844
|
+
});
|
|
845
|
+
program.command("login").description("Sign in via browser \u2014 opens Stud.IP login every time").option("--oauth", "Use OAuth2 PKCE (requires registered client_id)").option("--chrome-profile", "Use your Chrome profile with saved passwords (quit Chrome first)").option("--profile-directory <name>", "Chrome profile folder with --chrome-profile", "Default").option("--manual", "Expert fallback: local helper page with manual session paste").option("--relogin", "Alias for login (browser sign-in always runs)").action(async (opts) => {
|
|
846
|
+
try {
|
|
847
|
+
const sdk = await getSdk();
|
|
848
|
+
await runLogin(sdk, { ...opts, force: true });
|
|
849
|
+
const me = await sdk.courses.getMe();
|
|
850
|
+
const name = me.attributes?.["formatted-name"] || me.id;
|
|
851
|
+
output({ user: { id: me.id, name } }, `Logged in as ${name}`);
|
|
852
|
+
} catch (e) {
|
|
853
|
+
fail(e);
|
|
854
|
+
}
|
|
855
|
+
});
|
|
856
|
+
program.command("logout").description("Clear stored session").action(async () => {
|
|
857
|
+
try {
|
|
858
|
+
const sdk = await getSdk();
|
|
859
|
+
await sdk.auth.clearSession();
|
|
860
|
+
output({ ok: true }, "Logged out");
|
|
861
|
+
} catch (e) {
|
|
862
|
+
fail(e);
|
|
863
|
+
}
|
|
864
|
+
});
|
|
865
|
+
program.command("status").description("Auth and instance status").action(async () => {
|
|
866
|
+
try {
|
|
867
|
+
const sdk = await getSdk();
|
|
868
|
+
const oauth = await probeOAuthServer(sdk.config.instanceOrigin, sdk.config.oauth?.clientId);
|
|
869
|
+
const session = await sdk.auth.loadSession();
|
|
870
|
+
if (!session) {
|
|
871
|
+
output(
|
|
872
|
+
{
|
|
873
|
+
authenticated: false,
|
|
874
|
+
cliVersion: getCliVersion(),
|
|
875
|
+
oauth: {
|
|
876
|
+
status: oauth.status,
|
|
877
|
+
message: oauth.message,
|
|
878
|
+
recommended: oauth.status === "ready" ? "imstudium login --oauth" : "imstudium login",
|
|
879
|
+
adminUrl: oauth.endpoints.admin
|
|
880
|
+
}
|
|
881
|
+
},
|
|
882
|
+
[
|
|
883
|
+
"Not logged in. Run: imstudium login",
|
|
884
|
+
oauth.status === "broken_setup" ? "Note: Stud.IP OAuth is not configured on this instance \u2014 browser sign-in is used." : oauth.status === "ready" && sdk.config.oauth?.clientId ? "OAuth is ready \u2014 imstudium login will use it automatically." : oauth.status === "ready" ? "OAuth is available \u2014 register a client (see docs/OAUTH.md) for one-click login." : ""
|
|
885
|
+
].filter(Boolean).join("\n")
|
|
886
|
+
);
|
|
887
|
+
return;
|
|
888
|
+
}
|
|
889
|
+
const me = await sdk.courses.getMe();
|
|
890
|
+
const props = await sdk.discovery.getProperties();
|
|
891
|
+
output({
|
|
892
|
+
authenticated: true,
|
|
893
|
+
cliVersion: getCliVersion(),
|
|
894
|
+
user: { id: me.id, name: me.attributes?.["formatted-name"] },
|
|
895
|
+
studipVersion: props["studip-version"],
|
|
896
|
+
workspace: sdk.workspacePath,
|
|
897
|
+
sessionType: session.type,
|
|
898
|
+
oauth: {
|
|
899
|
+
status: oauth.status,
|
|
900
|
+
adminUrl: oauth.endpoints.admin
|
|
901
|
+
},
|
|
902
|
+
next: "imstudium update"
|
|
903
|
+
});
|
|
904
|
+
} catch (e) {
|
|
905
|
+
fail(e);
|
|
906
|
+
}
|
|
907
|
+
});
|
|
908
|
+
program.command("update").description("Upgrade CLI (if needed) and refresh agent skills/MCP").action(async () => {
|
|
909
|
+
try {
|
|
910
|
+
const sdk = await getSdk();
|
|
911
|
+
const before = await checkForUpdates(CLI_ENTRY);
|
|
912
|
+
let upgraded = false;
|
|
913
|
+
let upgradeNote = "";
|
|
914
|
+
if (before.installMode === "global" && before.updateAvailable) {
|
|
915
|
+
const result = await runGlobalUpdate();
|
|
916
|
+
if (!result.ok) {
|
|
917
|
+
fail(
|
|
918
|
+
new ImstudiumError2(
|
|
919
|
+
"npm global upgrade failed",
|
|
920
|
+
"UPDATE_FAILED",
|
|
921
|
+
void 0,
|
|
922
|
+
result.output.slice(0, 500)
|
|
923
|
+
)
|
|
924
|
+
);
|
|
925
|
+
}
|
|
926
|
+
upgraded = true;
|
|
927
|
+
upgradeNote = `CLI ${before.current} \u2192 ${before.latest}
|
|
928
|
+
`;
|
|
929
|
+
}
|
|
930
|
+
const setup = await runSetup("all", {
|
|
931
|
+
yes: true,
|
|
932
|
+
workspace: sdk.workspacePath,
|
|
933
|
+
force: upgraded
|
|
934
|
+
});
|
|
935
|
+
const after = await checkForUpdates(CLI_ENTRY);
|
|
936
|
+
const summary = formatSetupSummary(setup);
|
|
937
|
+
const text = before.installMode === "npx" && before.updateAvailable ? `${summary}
|
|
938
|
+
Tip: npx -y @imstudium/cli@latest for newest CLI` : `${upgradeNote}${summary}`.trim();
|
|
939
|
+
output({ ...after, upgraded, setup, summary }, text);
|
|
940
|
+
} catch (e) {
|
|
941
|
+
fail(e);
|
|
942
|
+
}
|
|
943
|
+
});
|
|
944
|
+
program.command("studium").description("Show STUDIUM.md or studium.json").option("-o, --output <dir>", "Workspace directory").action(async (opts) => {
|
|
945
|
+
try {
|
|
946
|
+
const sdk = await getSdk();
|
|
947
|
+
const dir = expandPath3(opts.output || sdk.workspacePath);
|
|
948
|
+
if (globalOpts.json) {
|
|
949
|
+
const ws = await sdk.studium.loadWorkspace(dir);
|
|
950
|
+
if (!ws) fail(new ImstudiumError2("No workspace. Run imstudium init", "NO_WORKSPACE"));
|
|
951
|
+
output(ws);
|
|
952
|
+
} else {
|
|
953
|
+
const md = await readFile2(join4(dir, "STUDIUM.md"), "utf-8");
|
|
954
|
+
output(md);
|
|
955
|
+
}
|
|
956
|
+
} catch (e) {
|
|
957
|
+
fail(e);
|
|
958
|
+
}
|
|
959
|
+
});
|
|
960
|
+
program.command("klausuren").description("Exam timeline").option("-o, --output <dir>", "Workspace directory").action(async (opts) => {
|
|
961
|
+
try {
|
|
962
|
+
const sdk = await getSdk();
|
|
963
|
+
const dir = expandPath3(opts.output || sdk.workspacePath);
|
|
964
|
+
if (globalOpts.json) {
|
|
965
|
+
const ws = await sdk.studium.loadWorkspace(dir);
|
|
966
|
+
if (!ws) fail(new ImstudiumError2("No workspace", "NO_WORKSPACE"));
|
|
967
|
+
const exams = ws.courses.flatMap((c) => c.examDates);
|
|
968
|
+
output({ exams, nextDeadlines: ws.nextDeadlines });
|
|
969
|
+
} else {
|
|
970
|
+
const md = await readFile2(join4(dir, "klausuren.md"), "utf-8");
|
|
971
|
+
output(md);
|
|
972
|
+
}
|
|
973
|
+
} catch (e) {
|
|
974
|
+
fail(e);
|
|
975
|
+
}
|
|
976
|
+
});
|
|
977
|
+
var courses = program.command("courses").description("Course commands");
|
|
978
|
+
courses.command("list").description("List my courses").option("--semester <name>", "Semester title or slug (default: calendar current)").option("--all-semesters", "List every enrollment").action(async (opts) => {
|
|
979
|
+
try {
|
|
980
|
+
const sdk = await getSdk();
|
|
981
|
+
const me = await sdk.courses.getMe();
|
|
982
|
+
const semOpts = semesterSyncOptions(sdk, opts);
|
|
983
|
+
const ctx = await sdk.courses.resolveSemesterContext(semOpts);
|
|
984
|
+
let list = await sdk.courses.listMine(me.id, {
|
|
985
|
+
semesterId: semOpts.semesterId ?? ctx?.semester.id,
|
|
986
|
+
allSemesters: opts.allSemesters
|
|
987
|
+
});
|
|
988
|
+
output(
|
|
989
|
+
{
|
|
990
|
+
courses: list.map((c) => ({
|
|
991
|
+
id: c.id,
|
|
992
|
+
title: c.attributes?.title,
|
|
993
|
+
type: c.attributes?.["course-type"],
|
|
994
|
+
number: c.attributes?.["course-number"]
|
|
995
|
+
})),
|
|
996
|
+
semester: ctx ? { id: ctx.semester.id, title: ctx.title, slug: ctx.slug } : void 0,
|
|
997
|
+
count: list.length
|
|
998
|
+
},
|
|
999
|
+
ctx ? `${list.length} courses in ${ctx.title}` : `${list.length} courses (all semesters)`
|
|
1000
|
+
);
|
|
1001
|
+
} catch (e) {
|
|
1002
|
+
fail(e);
|
|
1003
|
+
}
|
|
1004
|
+
});
|
|
1005
|
+
courses.command("show <idOrName>").description("Show course details").action(async (idOrName) => {
|
|
1006
|
+
try {
|
|
1007
|
+
const sdk = await getSdk();
|
|
1008
|
+
const me = await sdk.courses.getMe();
|
|
1009
|
+
const list = await sdk.courses.listMine(me.id);
|
|
1010
|
+
const course = list.find((c) => c.id === idOrName) || list.find(
|
|
1011
|
+
(c) => (c.attributes?.title || "").toLowerCase().includes(idOrName.toLowerCase())
|
|
1012
|
+
);
|
|
1013
|
+
if (!course) fail(new ImstudiumError2("Course not found", "NOT_FOUND"));
|
|
1014
|
+
const full = await sdk.courses.get(course.id);
|
|
1015
|
+
const fileCount = (await sdk.files.listAllFileRefs(course.id)).length;
|
|
1016
|
+
output({ course: full, fileCount });
|
|
1017
|
+
} catch (e) {
|
|
1018
|
+
fail(e);
|
|
1019
|
+
}
|
|
1020
|
+
});
|
|
1021
|
+
var files = program.command("files").description("File commands");
|
|
1022
|
+
files.command("tree <course>").description("Folder/file tree for a course").action(async (courseName) => {
|
|
1023
|
+
try {
|
|
1024
|
+
const sdk = await getSdk();
|
|
1025
|
+
const me = await sdk.courses.getMe();
|
|
1026
|
+
const list = await sdk.courses.listMine(me.id);
|
|
1027
|
+
const course = list.find(
|
|
1028
|
+
(c) => c.id === courseName || (c.attributes?.title || "").toLowerCase().includes(courseName.toLowerCase())
|
|
1029
|
+
);
|
|
1030
|
+
if (!course) fail(new ImstudiumError2("Course not found", "NOT_FOUND"));
|
|
1031
|
+
const tree = await sdk.files.buildFolderTree(course.id);
|
|
1032
|
+
output({ courseId: course.id, tree });
|
|
1033
|
+
} catch (e) {
|
|
1034
|
+
fail(e);
|
|
1035
|
+
}
|
|
1036
|
+
});
|
|
1037
|
+
files.command("download <fileRefId>").description("Download a single file").option("-o, --output <path>", "Output path").action(async (fileRefId, opts) => {
|
|
1038
|
+
try {
|
|
1039
|
+
const sdk = await getSdk();
|
|
1040
|
+
const meta = await sdk.files.getFileRef(fileRefId);
|
|
1041
|
+
const name = meta.attributes?.name || fileRefId;
|
|
1042
|
+
const dest = opts.output || name;
|
|
1043
|
+
const bytes = await sdk.files.downloadFileRef(fileRefId, dest);
|
|
1044
|
+
output({ path: dest, bytes }, `Downloaded ${dest} (${bytes} bytes)`);
|
|
1045
|
+
} catch (e) {
|
|
1046
|
+
fail(e);
|
|
1047
|
+
}
|
|
1048
|
+
});
|
|
1049
|
+
program.command("sync").description("Sync files and rebuild Studium workspace").option("-o, --output <dir>", "Output directory").option("--course <name>", "Sync single course").option("--semester <label>", "Semester title or slug (e.g. SS2026, WS2025-26)").option("--all-semesters", "Sync every enrollment (all semesters)").option("--layout <mode>", "flat or tree", "flat").option("--dry-run", "List changes without downloading").option("--include <items>", "Comma-separated: wiki,news,calendar").option("--extract", "Extract text + page images from new/changed files after sync").action(async (opts) => {
|
|
1050
|
+
try {
|
|
1051
|
+
const sdk = await getSdk();
|
|
1052
|
+
const out = expandPath3(opts.output || sdk.workspacePath);
|
|
1053
|
+
const include = opts.include?.split(",").map((s) => s.trim());
|
|
1054
|
+
const semOpts = semesterSyncOptions(sdk, opts);
|
|
1055
|
+
const report = await sdk.sync.sync({
|
|
1056
|
+
outputDir: out,
|
|
1057
|
+
layout: opts.layout || "flat",
|
|
1058
|
+
dryRun: opts.dryRun,
|
|
1059
|
+
include,
|
|
1060
|
+
...semOpts,
|
|
1061
|
+
courseFilter: opts.course ? (c) => c.title.toLowerCase().includes(opts.course.toLowerCase()) || c.id === opts.course : void 0
|
|
1062
|
+
});
|
|
1063
|
+
let extracted = 0;
|
|
1064
|
+
let folderNote = "";
|
|
1065
|
+
if (!opts.dryRun) {
|
|
1066
|
+
const workspace = await sdk.studium.build(out, sdk.config.instanceOrigin, semOpts);
|
|
1067
|
+
folderNote = ` \u2014 ${workspace.activeSemester.title} in ${workspace.workspaceFolder}/`;
|
|
1068
|
+
if (opts.extract) {
|
|
1069
|
+
for (const course of workspace.courses) {
|
|
1070
|
+
const courseDir = courseDirFor(workspace, out, course.slug);
|
|
1071
|
+
const results = await sdk.extractor.extractDir(
|
|
1072
|
+
join4(courseDir, "files"),
|
|
1073
|
+
join4(courseDir, "extracted"),
|
|
1074
|
+
{
|
|
1075
|
+
course: { title: course.title, path: course.path || `${workspace.activeSemester?.slug}/${course.slug}` },
|
|
1076
|
+
workspaceRoot: out
|
|
1077
|
+
}
|
|
1078
|
+
);
|
|
1079
|
+
extracted += results.filter((r) => !r.error && !r.skipped).length;
|
|
1080
|
+
}
|
|
1081
|
+
await rebuildSearchIndexes(out, workspace);
|
|
1082
|
+
await sdk.studium.refreshSearchArtifacts(out, workspace);
|
|
1083
|
+
}
|
|
1084
|
+
}
|
|
1085
|
+
output(
|
|
1086
|
+
{ ...report, extracted: opts.extract ? extracted : void 0 },
|
|
1087
|
+
`Sync: ${report.downloaded} downloaded, ${report.skipped} skipped, ${report.failed} failed${opts.extract ? `, ${extracted} extracted` : ""}${folderNote}`
|
|
1088
|
+
);
|
|
1089
|
+
} catch (e) {
|
|
1090
|
+
fail(e);
|
|
1091
|
+
}
|
|
1092
|
+
});
|
|
1093
|
+
program.command("extract").description("Extract text + page images from synced materials (PDF, PPTX, DOCX, images, ...)").option("--course <name>", "Only one course (title substring or slug)").option("--file <path>", "Extract a single file to <file>-extracted/").option("--no-images", "Text only, skip page image rendering").option("--dpi <n>", "Image resolution", "144").option("--force", "Re-extract even if unchanged").option("-o, --output <dir>", "Workspace directory").action(async (opts) => {
|
|
1094
|
+
try {
|
|
1095
|
+
const sdk = await getSdk();
|
|
1096
|
+
const extractOpts = {
|
|
1097
|
+
images: opts.images,
|
|
1098
|
+
dpi: parseInt(opts.dpi || "144", 10),
|
|
1099
|
+
force: opts.force
|
|
1100
|
+
};
|
|
1101
|
+
if (opts.file) {
|
|
1102
|
+
const src = expandPath3(opts.file);
|
|
1103
|
+
const outDir = src.replace(/\.[^.]+$/, "") + "-extracted";
|
|
1104
|
+
const result = await sdk.extractor.extractFile(src, outDir, extractOpts);
|
|
1105
|
+
output(result, `Extracted ${result.pages} page(s) \u2192 ${result.outDir}${result.hint ? `
|
|
1106
|
+
Hint: ${result.hint}` : ""}`);
|
|
1107
|
+
return;
|
|
1108
|
+
}
|
|
1109
|
+
const ws = expandPath3(opts.output || sdk.workspacePath);
|
|
1110
|
+
const workspace = await sdk.studium.loadWorkspace(ws);
|
|
1111
|
+
if (!workspace) fail(new ImstudiumError2("No workspace. Run imstudium init", "NO_WORKSPACE"));
|
|
1112
|
+
const courses2 = opts.course ? [pickCourse(workspace, opts.course)].filter(Boolean) : workspace.courses.filter((c) => c.semesterId === workspace.activeSemester?.id || !workspace.activeSemester?.id);
|
|
1113
|
+
if (courses2.length === 0) fail(new ImstudiumError2("Course not found", "NOT_FOUND"));
|
|
1114
|
+
const tools = await sdk.extractor.getTools();
|
|
1115
|
+
const results = [];
|
|
1116
|
+
for (const course of courses2) {
|
|
1117
|
+
if (!course) continue;
|
|
1118
|
+
const courseDir = courseDirFor(workspace, ws, course.slug);
|
|
1119
|
+
const courseResults = await sdk.extractor.extractDir(
|
|
1120
|
+
join4(courseDir, "files"),
|
|
1121
|
+
join4(courseDir, "extracted"),
|
|
1122
|
+
{
|
|
1123
|
+
...extractOpts,
|
|
1124
|
+
course: { title: course.title, path: course.path },
|
|
1125
|
+
workspaceRoot: ws
|
|
1126
|
+
}
|
|
1127
|
+
);
|
|
1128
|
+
results.push(...courseResults);
|
|
1129
|
+
}
|
|
1130
|
+
const searchIndex = await rebuildSearchIndexes(ws, workspace);
|
|
1131
|
+
if (searchIndex) await sdk.studium.refreshSearchArtifacts(ws, workspace);
|
|
1132
|
+
const done = results.filter((r) => !r.error && !r.skipped).length;
|
|
1133
|
+
const skipped = results.filter((r) => r.skipped).length;
|
|
1134
|
+
const failed = results.filter((r) => r.error).length;
|
|
1135
|
+
const hints = toolHints(tools);
|
|
1136
|
+
output(
|
|
1137
|
+
{ extracted: done, skipped, failed, hints, results, searchIndex },
|
|
1138
|
+
`Extracted ${done}, skipped ${skipped} (unchanged), failed ${failed}${searchIndex ? ` \xB7 ${searchIndex.chunkCount} search chunks` : ""}${hints.length ? `
|
|
1139
|
+
${hints.join("\n")}` : ""}`
|
|
1140
|
+
);
|
|
1141
|
+
} catch (e) {
|
|
1142
|
+
fail(e);
|
|
1143
|
+
}
|
|
1144
|
+
});
|
|
1145
|
+
program.command("index").description("Build/rebuild page index (corpus.jsonl) \u2014 for agent grep / semantic search, not a custom search engine").option("--course <name>", "Only one course").option("-o, --output <dir>", "Workspace directory").action(async (opts) => {
|
|
1146
|
+
try {
|
|
1147
|
+
const sdk = await getSdk();
|
|
1148
|
+
const ws = expandPath3(opts.output || sdk.workspacePath);
|
|
1149
|
+
const workspace = await sdk.studium.loadWorkspace(ws);
|
|
1150
|
+
if (!workspace) fail(new ImstudiumError2("No workspace. Run imstudium init", "NO_WORKSPACE"));
|
|
1151
|
+
if (opts.course) {
|
|
1152
|
+
const course = pickCourse(workspace, opts.course);
|
|
1153
|
+
if (!course?.path) fail(new ImstudiumError2("Course not found", "NOT_FOUND"));
|
|
1154
|
+
const { buildCourseSearchIndex, buildMaterialsManifest } = await import("@imstudium/sdk");
|
|
1155
|
+
const idx2 = await buildCourseSearchIndex(join4(ws, course.path), course, ws);
|
|
1156
|
+
if (!idx2) fail(new ImstudiumError2("No extractions for this course. Run: imstudium extract --course ...", "NO_CORPUS"));
|
|
1157
|
+
await buildMaterialsManifest(join4(ws, course.path), course);
|
|
1158
|
+
await sdk.studium.refreshSearchArtifacts(ws, workspace);
|
|
1159
|
+
output(idx2, `Indexed ${idx2.chunkCount} chunks from ${idx2.materialCount} files \u2192 ${course.path}/search/corpus.jsonl`);
|
|
1160
|
+
return;
|
|
1161
|
+
}
|
|
1162
|
+
const idx = await rebuildSearchIndexes(ws, workspace);
|
|
1163
|
+
if (!idx) fail(new ImstudiumError2("No extractions yet. Run: imstudium extract", "NO_CORPUS"));
|
|
1164
|
+
await sdk.studium.refreshSearchArtifacts(ws, workspace);
|
|
1165
|
+
output(idx, `Indexed ${idx.chunkCount} chunks across ${idx.courseCount} courses \u2192 per-course search/corpus.jsonl + materials.json`);
|
|
1166
|
+
} catch (e) {
|
|
1167
|
+
fail(e);
|
|
1168
|
+
}
|
|
1169
|
+
});
|
|
1170
|
+
program.command("view").description("Build viewer.html for slide PNGs \u2014 fast path for Playwright / browser agents").option("--dir <path>", "Already-extracted directory (contains pages/)").option("--course <name>", "Course title substring").option("--file <name>", "Material filename substring").option("-o, --output <dir>", "Workspace directory").action(async (opts) => {
|
|
1171
|
+
try {
|
|
1172
|
+
let outDir = opts.dir ? expandPath3(opts.dir) : void 0;
|
|
1173
|
+
let title = outDir ? basename(outDir) : "slides";
|
|
1174
|
+
if (!outDir) {
|
|
1175
|
+
if (!opts.course || !opts.file) {
|
|
1176
|
+
fail(new ImstudiumError2("Provide --dir OR both --course and --file", "MISSING_ARG"));
|
|
1177
|
+
}
|
|
1178
|
+
const sdk = await getSdk();
|
|
1179
|
+
const ws = expandPath3(opts.output || sdk.workspacePath);
|
|
1180
|
+
const workspace = await sdk.studium.loadWorkspace(ws);
|
|
1181
|
+
if (!workspace) fail(new ImstudiumError2("No workspace. Run imstudium init", "NO_WORKSPACE"));
|
|
1182
|
+
const course = pickCourse(workspace, opts.course);
|
|
1183
|
+
if (!course) fail(new ImstudiumError2("Course not found", "NOT_FOUND"));
|
|
1184
|
+
const courseDir = resolveCourseDir(ws, course, workspace);
|
|
1185
|
+
const filesDir = join4(courseDir, "files");
|
|
1186
|
+
const { readdir: rd } = await import("fs/promises");
|
|
1187
|
+
const matches = [];
|
|
1188
|
+
async function walk(d) {
|
|
1189
|
+
const entries = await rd(d, { withFileTypes: true }).catch(() => []);
|
|
1190
|
+
for (const e of entries) {
|
|
1191
|
+
const p = join4(d, e.name);
|
|
1192
|
+
if (e.isDirectory()) await walk(p);
|
|
1193
|
+
else if (e.name.toLowerCase().includes(opts.file.toLowerCase())) matches.push(p);
|
|
1194
|
+
}
|
|
1195
|
+
}
|
|
1196
|
+
await walk(filesDir);
|
|
1197
|
+
if (!matches.length) fail(new ImstudiumError2("File not found", "NOT_FOUND"));
|
|
1198
|
+
const src = matches[0];
|
|
1199
|
+
title = basename(src);
|
|
1200
|
+
outDir = join4(courseDir, "extracted", extractSlug(title));
|
|
1201
|
+
await sdk.extractor.extractFile(src, outDir);
|
|
1202
|
+
}
|
|
1203
|
+
const { viewerHtml, pageImages } = await buildSlideViewer(outDir, title);
|
|
1204
|
+
const visual = buildAgentVisualPayload(pageImages, viewerHtml);
|
|
1205
|
+
output(
|
|
1206
|
+
{ ...visual, viewerHtml, viewerFileUrl: `file://${viewerHtml}` },
|
|
1207
|
+
pageImages.length ? `Viewer: file://${viewerHtml}
|
|
1208
|
+
${pageImages.length} slide(s) \u2014 agents must read pageImages or open viewer in Playwright` : `No page images in ${outDir}. Run: imstudium extract --file <path>`
|
|
1209
|
+
);
|
|
1210
|
+
} catch (e) {
|
|
1211
|
+
fail(e);
|
|
1212
|
+
}
|
|
1213
|
+
});
|
|
1214
|
+
program.command("doctor").description("Check extraction tool availability").action(async () => {
|
|
1215
|
+
try {
|
|
1216
|
+
const sdk = await getSdk();
|
|
1217
|
+
const oauth = await probeOAuthServer(sdk.config.instanceOrigin, sdk.config.oauth?.clientId);
|
|
1218
|
+
const tools = await detectTools();
|
|
1219
|
+
const hints = toolHints(tools);
|
|
1220
|
+
const update = await checkForUpdates(CLI_ENTRY);
|
|
1221
|
+
const lines = [
|
|
1222
|
+
`imstudium: ${getCliVersion()} (${update.installMode})`,
|
|
1223
|
+
update.updateAvailable ? `update: ${update.current} \u2192 ${update.latest} \u2014 run: imstudium update` : `update: up to date`,
|
|
1224
|
+
"",
|
|
1225
|
+
`oauth: ${oauth.status}${oauth.message ? ` \u2014 ${oauth.message}` : ""}`,
|
|
1226
|
+
oauth.status === "broken_setup" ? `oauth fix: university admin \u2192 ${oauth.endpoints.admin}` : oauth.status === "ready" && !sdk.config.oauth?.clientId ? `oauth tip: register client \u2192 docs/OAUTH.md` : "",
|
|
1227
|
+
"",
|
|
1228
|
+
`pdftotext: ${tools.pdftotext || "MISSING"}`,
|
|
1229
|
+
`pdftoppm: ${tools.pdftoppm || "MISSING"}`,
|
|
1230
|
+
`libreoffice: ${tools.soffice || "MISSING (native PPTX/DOCX text fallback active)"}`,
|
|
1231
|
+
`tesseract: ${tools.tesseract || "MISSING (no OCR for scanned PDFs/photos \u2014 agents can still read page images)"}`,
|
|
1232
|
+
`unzip: ${tools.unzip || "MISSING"}`
|
|
1233
|
+
];
|
|
1234
|
+
if (hints.length) lines.push("", ...hints);
|
|
1235
|
+
output({ cliVersion: getCliVersion(), update, oauth, tools, hints }, lines.filter(Boolean).join("\n"));
|
|
1236
|
+
} catch (e) {
|
|
1237
|
+
fail(e);
|
|
1238
|
+
}
|
|
1239
|
+
});
|
|
1240
|
+
var exportCmd = program.command("export").description("Export rich content");
|
|
1241
|
+
exportCmd.command("calendar").option("-o, --output <dir>").action(async (opts) => {
|
|
1242
|
+
try {
|
|
1243
|
+
const sdk = await getSdk();
|
|
1244
|
+
const me = await sdk.courses.getMe();
|
|
1245
|
+
const ics = await sdk.schedule.exportCalendarIcs(me.id);
|
|
1246
|
+
const dir = expandPath3(opts.output || sdk.workspacePath);
|
|
1247
|
+
const { writeFile: writeFile2, mkdir: mkdir2 } = await import("fs/promises");
|
|
1248
|
+
await mkdir2(dir, { recursive: true });
|
|
1249
|
+
const path = join4(dir, "calendar.ics");
|
|
1250
|
+
await writeFile2(path, ics);
|
|
1251
|
+
output({ path }, `Wrote ${path}`);
|
|
1252
|
+
} catch (e) {
|
|
1253
|
+
fail(e);
|
|
1254
|
+
}
|
|
1255
|
+
});
|
|
1256
|
+
program.command("watch").description("Continuous sync loop").option("--interval <duration>", "Interval e.g. 1h, 30m", "1h").option("-o, --output <dir>").action(async (opts) => {
|
|
1257
|
+
const ms = parseInterval(opts.interval || "1h");
|
|
1258
|
+
const sdk = await getSdk();
|
|
1259
|
+
const out = expandPath3(opts.output || sdk.workspacePath);
|
|
1260
|
+
output(null, `Watching every ${opts.interval} \u2192 ${out} (Ctrl+C to stop)`);
|
|
1261
|
+
const semOpts = semesterSyncOptions(sdk, {});
|
|
1262
|
+
for (; ; ) {
|
|
1263
|
+
try {
|
|
1264
|
+
const report = await sdk.sync.sync({ outputDir: out, include: ["wiki", "news", "calendar"], ...semOpts });
|
|
1265
|
+
await sdk.studium.build(out, sdk.config.instanceOrigin, semOpts);
|
|
1266
|
+
if (!globalOpts.quiet) {
|
|
1267
|
+
output(report, `[${(/* @__PURE__ */ new Date()).toISOString()}] ${report.downloaded} new files`);
|
|
1268
|
+
}
|
|
1269
|
+
} catch (e) {
|
|
1270
|
+
console.error(e);
|
|
1271
|
+
}
|
|
1272
|
+
await sleep(ms);
|
|
1273
|
+
}
|
|
1274
|
+
});
|
|
1275
|
+
program.command("semesters").description("List Stud.IP semesters and show the calendar-current one").action(async () => {
|
|
1276
|
+
try {
|
|
1277
|
+
const sdk = await getSdk();
|
|
1278
|
+
const { SemestersResource, computeSemesterProgress } = await import("@imstudium/sdk");
|
|
1279
|
+
const semesters = new SemestersResource(sdk.client);
|
|
1280
|
+
const all = await semesters.listAll();
|
|
1281
|
+
const current = await semesters.getCurrent();
|
|
1282
|
+
const progress = current ? computeSemesterProgress(current) : void 0;
|
|
1283
|
+
output(
|
|
1284
|
+
{
|
|
1285
|
+
current: current ? {
|
|
1286
|
+
id: current.id,
|
|
1287
|
+
title: current.attributes?.title,
|
|
1288
|
+
start: current.attributes?.start,
|
|
1289
|
+
end: current.attributes?.end,
|
|
1290
|
+
...progress
|
|
1291
|
+
} : null,
|
|
1292
|
+
semesters: all.slice(-12).map((s) => ({
|
|
1293
|
+
id: s.id,
|
|
1294
|
+
title: s.attributes?.title,
|
|
1295
|
+
start: s.attributes?.start,
|
|
1296
|
+
end: s.attributes?.end,
|
|
1297
|
+
current: s.id === current?.id
|
|
1298
|
+
}))
|
|
1299
|
+
},
|
|
1300
|
+
[
|
|
1301
|
+
current ? `Current: ${current.attributes?.title} (${current.id})${progress ? ` \u2014 week ${progress.weekNumber}, day ${progress.daysElapsed}/${progress.daysTotal} (${progress.progressPercent}%)` : ""}` : "No current semester",
|
|
1302
|
+
'Default sync/studium/courses commands focus on this semester. Use --semester "<name>" or --all-semesters for others.',
|
|
1303
|
+
"",
|
|
1304
|
+
...all.slice(-8).map((s) => {
|
|
1305
|
+
const mark = s.id === current?.id ? " \u2190 current" : "";
|
|
1306
|
+
return `${s.attributes?.title}${mark}`;
|
|
1307
|
+
})
|
|
1308
|
+
].join("\n")
|
|
1309
|
+
);
|
|
1310
|
+
} catch (e) {
|
|
1311
|
+
fail(e);
|
|
1312
|
+
}
|
|
1313
|
+
});
|
|
1314
|
+
program.command("discover").description("List JSON:API routes").action(async () => {
|
|
1315
|
+
try {
|
|
1316
|
+
const sdk = await getSdk();
|
|
1317
|
+
const routes = await sdk.discovery.listRoutes();
|
|
1318
|
+
output({ routes, count: routes.length });
|
|
1319
|
+
} catch (e) {
|
|
1320
|
+
fail(e);
|
|
1321
|
+
}
|
|
1322
|
+
});
|
|
1323
|
+
var sessionCmd = program.command("session").description("Session management");
|
|
1324
|
+
sessionCmd.command("import").option("--cookie <value>", "Seminar_Session cookie value").option("--file <path>", "Netscape cookie file").action(async (opts) => {
|
|
1325
|
+
try {
|
|
1326
|
+
const sdk = await getSdk();
|
|
1327
|
+
if (opts.cookie) {
|
|
1328
|
+
await sdk.auth.importCookies({ Seminar_Session: opts.cookie });
|
|
1329
|
+
} else if (opts.file) {
|
|
1330
|
+
const content = await readFile2(opts.file, "utf-8");
|
|
1331
|
+
await sdk.auth.importFromNetscapeCookieFile(content);
|
|
1332
|
+
} else {
|
|
1333
|
+
fail(new ImstudiumError2("Provide --cookie or --file", "MISSING_ARG"));
|
|
1334
|
+
}
|
|
1335
|
+
output({ ok: true }, "Session imported");
|
|
1336
|
+
} catch (e) {
|
|
1337
|
+
fail(e);
|
|
1338
|
+
}
|
|
1339
|
+
});
|
|
1340
|
+
var configCmd = program.command("config").description("Configuration");
|
|
1341
|
+
configCmd.command("show").action(async () => {
|
|
1342
|
+
try {
|
|
1343
|
+
const sdk = await getSdk();
|
|
1344
|
+
output(sdk.config);
|
|
1345
|
+
} catch (e) {
|
|
1346
|
+
fail(e);
|
|
1347
|
+
}
|
|
1348
|
+
});
|
|
1349
|
+
configCmd.command("set <key> <value>").action(async (key, value) => {
|
|
1350
|
+
try {
|
|
1351
|
+
const sdk = await getSdk();
|
|
1352
|
+
if (key === "base-url") {
|
|
1353
|
+
await sdk.saveConfig({ baseUrl: value, instanceOrigin: value.replace(/\/jsonapi\.php\/v1$/, "") });
|
|
1354
|
+
} else if (key === "workspace") {
|
|
1355
|
+
await sdk.saveConfig({ workspacePath: value });
|
|
1356
|
+
} else {
|
|
1357
|
+
fail(new ImstudiumError2(`Unknown key: ${key}`, "INVALID_KEY"));
|
|
1358
|
+
}
|
|
1359
|
+
output({ ok: true }, `Set ${key}`);
|
|
1360
|
+
} catch (e) {
|
|
1361
|
+
fail(e);
|
|
1362
|
+
}
|
|
1363
|
+
});
|
|
1364
|
+
var AGENT_HELP = "cursor | claude-code | claude-desktop | codex | windsurf | opencode | all";
|
|
1365
|
+
var setupCmd = program.command("setup").description("Idempotent agent wiring (skills, MCP) \u2014 only updates what changed");
|
|
1366
|
+
setupCmd.command("status").description("Show detected agents and setup manifest").action(async () => {
|
|
1367
|
+
try {
|
|
1368
|
+
const sdk = await getSdk();
|
|
1369
|
+
const status = await getSetupStatus(sdk.workspacePath);
|
|
1370
|
+
const lines = [
|
|
1371
|
+
`Workspace: ${status.workspace}`,
|
|
1372
|
+
`Manifest: ${status.manifest ? status.manifest.updatedAt : "none"}`,
|
|
1373
|
+
`Up to date: ${status.upToDate ? "yes" : "no"}`,
|
|
1374
|
+
`Stale: ${status.stale ? "yes" : "no"}`,
|
|
1375
|
+
"",
|
|
1376
|
+
`Configured agents: ${status.configured.length ? status.configured.join(", ") : "none"}`,
|
|
1377
|
+
"",
|
|
1378
|
+
"Detected agents:",
|
|
1379
|
+
...status.detected.map(
|
|
1380
|
+
(a) => ` ${a.label} (${a.id}) \u2014 skills: ${a.skillsDir}`
|
|
1381
|
+
)
|
|
1382
|
+
];
|
|
1383
|
+
if (status.staleSkills.length) {
|
|
1384
|
+
lines.push("", `Stale skills: ${status.staleSkills.join(", ")}`);
|
|
1385
|
+
lines.push("Run: imstudium setup all -y");
|
|
1386
|
+
}
|
|
1387
|
+
output(status, lines.join("\n"));
|
|
1388
|
+
} catch (e) {
|
|
1389
|
+
fail(e);
|
|
1390
|
+
}
|
|
1391
|
+
});
|
|
1392
|
+
setupCmd.command("skills").option("--agent <agent>", AGENT_HELP).option("--all-agents", "Configure every harness, not only detected").option("--force", "Overwrite unchanged skills").option("-y, --yes", "Non-interactive").action(async (opts) => {
|
|
1393
|
+
try {
|
|
1394
|
+
const sdk = await getSdk();
|
|
1395
|
+
const result = await runSetup("skills", {
|
|
1396
|
+
yes: opts.yes,
|
|
1397
|
+
agent: opts.agent || void 0,
|
|
1398
|
+
workspace: sdk.workspacePath,
|
|
1399
|
+
allAgents: opts.allAgents,
|
|
1400
|
+
force: opts.force
|
|
1401
|
+
});
|
|
1402
|
+
output(result, formatSetupSummary(result));
|
|
1403
|
+
} catch (e) {
|
|
1404
|
+
fail(e);
|
|
1405
|
+
}
|
|
1406
|
+
});
|
|
1407
|
+
setupCmd.command("workflows").option("--agent <agent>", AGENT_HELP).option("--all-agents", "Configure every harness, not only detected").option("--force", "Overwrite unchanged workflows").action(async (opts) => {
|
|
1408
|
+
try {
|
|
1409
|
+
const sdk = await getSdk();
|
|
1410
|
+
const result = await runSetup("workflows", {
|
|
1411
|
+
agent: opts.agent || void 0,
|
|
1412
|
+
workspace: sdk.workspacePath,
|
|
1413
|
+
allAgents: opts.allAgents,
|
|
1414
|
+
force: opts.force
|
|
1415
|
+
});
|
|
1416
|
+
output(result, formatSetupSummary(result));
|
|
1417
|
+
} catch (e) {
|
|
1418
|
+
fail(e);
|
|
1419
|
+
}
|
|
1420
|
+
});
|
|
1421
|
+
setupCmd.command("mcp").option("--agent <agent>", AGENT_HELP).option("--all-agents", "Configure every harness, not only detected").option("-o, --workspace <dir>", "Studium workspace path").option("--force", "Rewrite MCP config even if identical").action(async (opts) => {
|
|
1422
|
+
try {
|
|
1423
|
+
const sdk = await getSdk();
|
|
1424
|
+
const result = await runSetup("mcp", {
|
|
1425
|
+
agent: opts.agent || void 0,
|
|
1426
|
+
workspace: opts.workspace || sdk.workspacePath,
|
|
1427
|
+
allAgents: opts.allAgents,
|
|
1428
|
+
force: opts.force
|
|
1429
|
+
});
|
|
1430
|
+
output(result, formatSetupSummary(result));
|
|
1431
|
+
} catch (e) {
|
|
1432
|
+
fail(e);
|
|
1433
|
+
}
|
|
1434
|
+
});
|
|
1435
|
+
setupCmd.command("migrate").description("Migrate DigiCampus config/skills/MCP to ImStudium (idempotent)").option("-o, --workspace <dir>", "Studium workspace path").option("--all-agents", "Configure every harness, not only detected").option("-y, --yes", "Non-interactive").action(async (opts) => {
|
|
1436
|
+
try {
|
|
1437
|
+
const sdk = await getSdk();
|
|
1438
|
+
const migration = await ensureMigrated2();
|
|
1439
|
+
const result = await runSetup("migrate", {
|
|
1440
|
+
yes: opts.yes,
|
|
1441
|
+
workspace: opts.workspace || sdk.workspacePath,
|
|
1442
|
+
allAgents: opts.allAgents,
|
|
1443
|
+
force: true
|
|
1444
|
+
});
|
|
1445
|
+
const archived = await archiveLegacyConfigDir();
|
|
1446
|
+
output(
|
|
1447
|
+
{ migration, setup: result, archivedLegacyConfig: archived },
|
|
1448
|
+
[
|
|
1449
|
+
`Migration: ${migration.migrated.length ? migration.migrated.join(", ") : "already up to date"}`,
|
|
1450
|
+
formatSetupSummary(result),
|
|
1451
|
+
archived ? "Archived ~/.config/digicampus \u2192 digicampus.archived" : ""
|
|
1452
|
+
].filter(Boolean).join("\n")
|
|
1453
|
+
);
|
|
1454
|
+
} catch (e) {
|
|
1455
|
+
fail(e);
|
|
1456
|
+
}
|
|
1457
|
+
});
|
|
1458
|
+
setupCmd.command("all").description("Skills + workflows + MCP (idempotent \u2014 skips unchanged)").option("--agent <agent>", AGENT_HELP).option("-o, --workspace <dir>", "Studium workspace path").option("--all-agents", "Configure every harness, not only detected").option("--force", "Overwrite even when unchanged").option("-y, --yes", "Non-interactive").action(async (opts) => {
|
|
1459
|
+
try {
|
|
1460
|
+
const sdk = await getSdk();
|
|
1461
|
+
const result = await runSetup("all", {
|
|
1462
|
+
yes: opts.yes,
|
|
1463
|
+
agent: opts.agent || void 0,
|
|
1464
|
+
workspace: opts.workspace || sdk.workspacePath,
|
|
1465
|
+
allAgents: opts.allAgents,
|
|
1466
|
+
force: opts.force
|
|
1467
|
+
});
|
|
1468
|
+
output(result, formatSetupSummary(result));
|
|
1469
|
+
} catch (e) {
|
|
1470
|
+
fail(e);
|
|
1471
|
+
}
|
|
1472
|
+
});
|
|
1473
|
+
function parseInterval(s) {
|
|
1474
|
+
const m = s.match(/^(\d+)([hms])$/);
|
|
1475
|
+
if (!m) return 36e5;
|
|
1476
|
+
const n = parseInt(m[1], 10);
|
|
1477
|
+
if (m[2] === "h") return n * 36e5;
|
|
1478
|
+
if (m[2] === "m") return n * 6e4;
|
|
1479
|
+
return n * 1e3;
|
|
1480
|
+
}
|
|
1481
|
+
function sleep(ms) {
|
|
1482
|
+
return new Promise((r) => setTimeout(r, ms));
|
|
1483
|
+
}
|
|
1484
|
+
program.parse();
|