@dotdrelle/wiki-manager 0.6.17

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.
@@ -0,0 +1,181 @@
1
+ export function ensurePlanFromActivity(session, activity) {
2
+ if (!activity) return;
3
+ const actKey = activity.key ?? null;
4
+ // Same activity still being tracked — preserve current plan state (polling update).
5
+ if (session.headlessPlan && actKey !== null && session.headlessPlan[0]?._activityKey === actKey) return;
6
+ const steps = activity.plan?.steps;
7
+ if (Array.isArray(steps) && steps.length > 0) {
8
+ session.headlessPlan = steps.map((s, i) => ({
9
+ step: i + 1,
10
+ id: s.id ?? null,
11
+ description: s.label,
12
+ status: 'pending',
13
+ _activityKey: activity.key,
14
+ }));
15
+ } else {
16
+ session.headlessPlan = [{
17
+ step: 1,
18
+ id: null,
19
+ description: activity.label,
20
+ status: 'pending',
21
+ _activityKey: activity.key,
22
+ }];
23
+ }
24
+ session._onPlanUpdate?.();
25
+ }
26
+
27
+ export function extractHeadlessPlan(text) {
28
+ const steps = [];
29
+ for (const line of text.split('\n')) {
30
+ const m = line.match(/^\s*(\d+)[.)]\s+(.+)/);
31
+ if (m) steps.push({ step: Number(m[1]), description: m[2].trim(), status: 'pending' });
32
+ }
33
+ if (steps.length < 2 || steps[0].step !== 1) return null;
34
+ return steps;
35
+ }
36
+
37
+ export function matchCompletedToPlan(plan, completed) {
38
+ if (!plan) return;
39
+ syncActivitiesToPlan(plan, completed.filter((activity) => activity.terminal));
40
+ }
41
+
42
+ export function syncActivitiesToPlan(plan, activities) {
43
+ if (!plan) return;
44
+ for (const activity of activities ?? []) {
45
+ const terminal = Boolean(activity.terminal);
46
+ const failed = ['failed', 'error', 'cancelled', 'canceled'].includes(String(activity.status).toLowerCase());
47
+ const actKey = activity.key ?? activity.id ?? activity.jobId ?? null;
48
+ const matched = findMatchingPlanStepByStructure(plan, activity) ?? findMatchingPlanStep(plan, activity);
49
+ if (!matched) continue;
50
+
51
+ if (terminal && !failed) {
52
+ // Structured plan: mark all steps owned by this activity as done.
53
+ // Legacy plan (no _activityKey): mark all steps up to matched as done (sequential assumption).
54
+ const ownedSteps = actKey ? plan.filter((s) => s._activityKey === actKey) : [];
55
+ if (ownedSteps.length > 0) {
56
+ for (const step of ownedSteps) {
57
+ if (step.status !== 'failed') {
58
+ step.status = 'done';
59
+ step.activityKey = actKey;
60
+ }
61
+ }
62
+ } else {
63
+ for (const step of plan) {
64
+ if (step.status === 'failed') continue;
65
+ if (step.step <= matched.step) {
66
+ step.status = 'done';
67
+ step.activityKey = actKey;
68
+ }
69
+ }
70
+ }
71
+ } else if (terminal && failed) {
72
+ matched.status = 'failed';
73
+ matched.activityKey = actKey;
74
+ } else if (!failed) {
75
+ // Running: matched step is in progress; preceding pending steps are implicitly done.
76
+ for (const step of plan) {
77
+ if (step.status === 'failed') continue;
78
+ if (step.step < matched.step && step.status === 'pending') {
79
+ step.status = 'done';
80
+ step.activityKey = actKey;
81
+ } else if (step.step === matched.step) {
82
+ step.status = 'running';
83
+ step.activityKey = actKey;
84
+ }
85
+ }
86
+ }
87
+ }
88
+ }
89
+
90
+ export function formatPlanStatus(plan) {
91
+ return plan
92
+ .map((s) => {
93
+ const icon = s.status === 'done' ? '✓' : s.status === 'failed' ? '✗' : s.status === 'running' ? '…' : ' ';
94
+ return `${s.step}. [${icon}] ${s.description}`;
95
+ })
96
+ .join('\n');
97
+ }
98
+
99
+ export function formatCompletedActivities(activities) {
100
+ return activities
101
+ .filter((a) => a.terminal)
102
+ .map((a) => `- ${a.source} ${a.id ?? a.kind}: ${a.status}${a.error ? ` (${a.error})` : ''}`)
103
+ .join('\n');
104
+ }
105
+
106
+ function findMatchingPlanStepByStructure(plan, activity) {
107
+ const actKey = activity.key ?? null;
108
+ const stepId = activity.progress?.stepId ?? null;
109
+ const stepIndex = activity.progress?.stepIndex ?? null;
110
+
111
+ function compatible(step) {
112
+ return !step._activityKey || !actKey || step._activityKey === actKey;
113
+ }
114
+
115
+ if (stepId !== null) {
116
+ const found = plan.find((s) => s.id != null && String(s.id) === stepId && compatible(s));
117
+ if (found) return found;
118
+ }
119
+ if (stepIndex !== null && Number.isFinite(Number(stepIndex))) {
120
+ const found = plan.find((s) => s.step === Number(stepIndex) && compatible(s));
121
+ if (found) return found;
122
+ }
123
+ return null;
124
+ }
125
+
126
+ function findMatchingPlanStep(plan, activity) {
127
+ const actKey = activity.key ?? null;
128
+ const activityTokens = tokenize([
129
+ activity?.source,
130
+ activity?.kind,
131
+ activity?.label,
132
+ activity?.progress?.step,
133
+ activity?.progress?.phase,
134
+ activity?.progress?.currentStep,
135
+ activity?.progress?.template,
136
+ activity?.progress?.deliverable,
137
+ ].filter(Boolean).join(' '));
138
+ if (activityTokens.length === 0) return null;
139
+
140
+ const candidates = plan
141
+ .filter((step) => !step._activityKey || !actKey || step._activityKey === actKey)
142
+ .map((step) => ({
143
+ step,
144
+ score: matchScore(tokenize(step.description), activityTokens),
145
+ }))
146
+ .filter((item) => item.score > 0)
147
+ .sort((a, b) => {
148
+ if (b.score !== a.score) return b.score - a.score;
149
+ return statusRank(a.step.status) - statusRank(b.step.status);
150
+ });
151
+
152
+ return candidates[0]?.step ?? null;
153
+ }
154
+
155
+ function tokenize(value) {
156
+ return [
157
+ ...new Set(
158
+ String(value ?? '')
159
+ .toLowerCase()
160
+ .normalize('NFD')
161
+ .replace(/[\u0300-\u036f]/g, '')
162
+ .match(/[a-z0-9-]{4,}/g)
163
+ ?.filter((token) => !['production', 'running', 'queued', 'done', 'failed'].includes(token)) ?? [],
164
+ ),
165
+ ];
166
+ }
167
+
168
+ function matchScore(stepTokens, activityTokens) {
169
+ let score = 0;
170
+ for (const token of stepTokens) {
171
+ if (activityTokens.includes(token)) score += token === 'build' || token === 'polish' || token === 'export' || token === 'ingest' ? 4 : 1;
172
+ }
173
+ return score;
174
+ }
175
+
176
+ function statusRank(status) {
177
+ if (status === 'running') return 0;
178
+ if (status === 'pending') return 1;
179
+ if (status === 'done') return 2;
180
+ return 3;
181
+ }
@@ -0,0 +1,168 @@
1
+ import { test } from 'node:test';
2
+ import assert from 'node:assert/strict';
3
+ import { ensurePlanFromActivity, syncActivitiesToPlan, extractHeadlessPlan } from './plan.js';
4
+
5
+ test('ensurePlanFromActivity: creates multi-step plan from activity.plan.steps', () => {
6
+ const session = { headlessPlan: null };
7
+ const activity = {
8
+ key: 'prod:job-1',
9
+ label: 'Pipeline',
10
+ plan: { steps: [{ id: 'build', label: 'Build' }, { id: 'polish', label: 'Polish' }] },
11
+ progress: {},
12
+ };
13
+ ensurePlanFromActivity(session, activity);
14
+ assert.equal(session.headlessPlan.length, 2);
15
+ assert.equal(session.headlessPlan[0].step, 1);
16
+ assert.equal(session.headlessPlan[0].id, 'build');
17
+ assert.equal(session.headlessPlan[0].description, 'Build');
18
+ assert.equal(session.headlessPlan[0].status, 'pending');
19
+ assert.equal(session.headlessPlan[0]._activityKey, 'prod:job-1');
20
+ assert.equal(session.headlessPlan[1].id, 'polish');
21
+ });
22
+
23
+ test('ensurePlanFromActivity: creates mono-step plan when no plan.steps', () => {
24
+ const session = { headlessPlan: null };
25
+ const activity = { key: 'prod:job-1', label: 'Build EAE', plan: null, progress: {} };
26
+ ensurePlanFromActivity(session, activity);
27
+ assert.equal(session.headlessPlan.length, 1);
28
+ assert.equal(session.headlessPlan[0].description, 'Build EAE');
29
+ assert.equal(session.headlessPlan[0]._activityKey, 'prod:job-1');
30
+ });
31
+
32
+ test('ensurePlanFromActivity: does not overwrite plan for same activity key', () => {
33
+ const existing = [{ step: 1, description: 'Existing', status: 'running', _activityKey: 'prod:job-1' }];
34
+ const session = { headlessPlan: existing };
35
+ const activity = { key: 'prod:job-1', label: 'Updated', plan: null, progress: {} };
36
+ ensurePlanFromActivity(session, activity);
37
+ assert.strictEqual(session.headlessPlan, existing);
38
+ });
39
+
40
+ test('ensurePlanFromActivity: replaces plan for different activity key', () => {
41
+ const existing = [{ step: 1, description: 'Old', status: 'done', _activityKey: 'prod:job-1' }];
42
+ const session = { headlessPlan: existing };
43
+ const activity = { key: 'prod:job-2', label: 'New Job', plan: null, progress: {} };
44
+ ensurePlanFromActivity(session, activity);
45
+ assert.notStrictEqual(session.headlessPlan, existing);
46
+ assert.equal(session.headlessPlan.length, 1);
47
+ assert.equal(session.headlessPlan[0].description, 'New Job');
48
+ assert.equal(session.headlessPlan[0]._activityKey, 'prod:job-2');
49
+ });
50
+
51
+ test('ensurePlanFromActivity: replaces null-key minimal plan for real activity', () => {
52
+ const minimal = [{ step: 1, description: 'cme.cme_export_run', status: 'running', _activityKey: null }];
53
+ const session = { headlessPlan: minimal };
54
+ const activity = { key: 'cme:export-1', label: 'CME Export', plan: null, progress: {} };
55
+ ensurePlanFromActivity(session, activity);
56
+ assert.notStrictEqual(session.headlessPlan, minimal);
57
+ assert.equal(session.headlessPlan[0].description, 'CME Export');
58
+ assert.equal(session.headlessPlan[0]._activityKey, 'cme:export-1');
59
+ });
60
+
61
+ test('ensurePlanFromActivity: no-op on null activity', () => {
62
+ const session = { headlessPlan: null };
63
+ ensurePlanFromActivity(session, null);
64
+ assert.equal(session.headlessPlan, null);
65
+ });
66
+
67
+ test('syncActivitiesToPlan: stepId sets correct step running, preceding pending→done', () => {
68
+ const plan = [
69
+ { step: 1, id: 'extract', description: 'Extract', status: 'pending' },
70
+ { step: 2, id: 'build', description: 'Build', status: 'pending' },
71
+ { step: 3, id: 'polish', description: 'Polish', status: 'pending' },
72
+ ];
73
+ syncActivitiesToPlan(plan, [{
74
+ key: 'prod:1', label: 'Build', status: 'running', terminal: false,
75
+ progress: { stepId: 'build' },
76
+ }]);
77
+ assert.equal(plan[0].status, 'done');
78
+ assert.equal(plan[1].status, 'running');
79
+ assert.equal(plan[2].status, 'pending');
80
+ });
81
+
82
+ test('syncActivitiesToPlan: stepIndex sets preceding steps done', () => {
83
+ const plan = [
84
+ { step: 1, description: 'Step 1', status: 'pending' },
85
+ { step: 2, description: 'Step 2', status: 'pending' },
86
+ { step: 3, description: 'Step 3', status: 'pending' },
87
+ ];
88
+ syncActivitiesToPlan(plan, [{
89
+ key: 'prod:1', label: 'Step 3', status: 'running', terminal: false,
90
+ progress: { stepIndex: 3 },
91
+ }]);
92
+ assert.equal(plan[0].status, 'done');
93
+ assert.equal(plan[1].status, 'done');
94
+ assert.equal(plan[2].status, 'running');
95
+ });
96
+
97
+ test('syncActivitiesToPlan: terminal success marks all activity steps done', () => {
98
+ const plan = [
99
+ { step: 1, id: 'build', description: 'Build', status: 'running', _activityKey: 'prod:1' },
100
+ { step: 2, id: 'polish', description: 'Polish', status: 'pending', _activityKey: 'prod:1' },
101
+ ];
102
+ syncActivitiesToPlan(plan, [{
103
+ key: 'prod:1', label: 'Pipeline', status: 'done', terminal: true,
104
+ progress: { stepId: 'build' },
105
+ }]);
106
+ assert.equal(plan[0].status, 'done');
107
+ assert.equal(plan[1].status, 'done');
108
+ });
109
+
110
+ test('syncActivitiesToPlan: terminal failed marks matched step failed, prior steps preserved', () => {
111
+ const plan = [
112
+ { step: 1, id: 'extract', description: 'Extract', status: 'done' },
113
+ { step: 2, id: 'build', description: 'Build', status: 'running' },
114
+ { step: 3, id: 'polish', description: 'Polish', status: 'pending' },
115
+ ];
116
+ syncActivitiesToPlan(plan, [{
117
+ key: 'prod:1', label: 'Build', status: 'failed', terminal: true,
118
+ progress: { stepId: 'build' },
119
+ }]);
120
+ assert.equal(plan[0].status, 'done');
121
+ assert.equal(plan[1].status, 'failed');
122
+ assert.equal(plan[2].status, 'pending');
123
+ });
124
+
125
+ test('syncActivitiesToPlan: failed step not repassed to done', () => {
126
+ const plan = [
127
+ { step: 1, id: 'build', description: 'Build', status: 'failed' },
128
+ { step: 2, id: 'polish', description: 'Polish', status: 'pending' },
129
+ ];
130
+ syncActivitiesToPlan(plan, [{
131
+ key: 'prod:1', label: 'Polish', status: 'done', terminal: true,
132
+ progress: { stepId: 'polish' },
133
+ }]);
134
+ assert.equal(plan[0].status, 'failed');
135
+ assert.equal(plan[1].status, 'done');
136
+ });
137
+
138
+ test('syncActivitiesToPlan: legacy text matching still works for old agents', () => {
139
+ const plan = [
140
+ { step: 1, description: 'ingest production content', status: 'pending' },
141
+ { step: 2, description: 'build output', status: 'pending' },
142
+ ];
143
+ syncActivitiesToPlan(plan, [{
144
+ key: 'prod:1', label: 'Production build', status: 'running',
145
+ source: 'production', kind: 'build', terminal: false, progress: {},
146
+ }]);
147
+ assert.equal(plan[0].status, 'done');
148
+ assert.equal(plan[1].status, 'running');
149
+ });
150
+
151
+ test('syncActivitiesToPlan: _activityKey mismatch prevents structured match', () => {
152
+ const plan = [
153
+ { step: 1, id: 'build', description: 'Build', status: 'pending', _activityKey: 'prod:job-A' },
154
+ ];
155
+ syncActivitiesToPlan(plan, [{
156
+ key: 'prod:job-B', label: 'Build', status: 'running', terminal: false,
157
+ progress: { stepId: 'build' },
158
+ }]);
159
+ assert.equal(plan[0].status, 'pending');
160
+ });
161
+
162
+ test('extractHeadlessPlan: parses numbered list from text (legacy)', () => {
163
+ const text = '1. CME export\n2. Build pipeline\n3. Send report';
164
+ const plan = extractHeadlessPlan(text);
165
+ assert.equal(plan.length, 3);
166
+ assert.equal(plan[0].description, 'CME export');
167
+ assert.equal(plan[1].step, 2);
168
+ });
@@ -0,0 +1,142 @@
1
+ import { existsSync, readdirSync, readFileSync } from 'node:fs';
2
+ import { basename, join } from 'node:path';
3
+ import { parse as parseYaml } from 'yaml';
4
+
5
+ const SKILL_NAME_RE = /^[a-zA-Z0-9_-]{1,80}$/;
6
+ const DEFAULT_UI_SKILL_DIR = '.wiki/skills';
7
+
8
+ function parseFrontmatter(raw) {
9
+ const match = raw.match(/^---\r?\n([\s\S]*?)\r?\n---\r?\n?([\s\S]*)$/);
10
+ if (!match) return { meta: {}, body: raw.trim() };
11
+ const meta = {};
12
+ let inParams = false;
13
+ const params = [];
14
+ for (const line of match[1].split(/\r?\n/)) {
15
+ const trimmed = line.trim();
16
+ if (!trimmed || trimmed.startsWith('#')) continue;
17
+ if (trimmed === 'params:') {
18
+ inParams = true;
19
+ continue;
20
+ }
21
+ if (inParams && trimmed.startsWith('- ')) {
22
+ params.push(trimmed.slice(2).trim());
23
+ continue;
24
+ }
25
+ inParams = false;
26
+ const sep = trimmed.indexOf(':');
27
+ if (sep === -1) continue;
28
+ meta[trimmed.slice(0, sep).trim()] = trimmed.slice(sep + 1).trim();
29
+ }
30
+ if (params.length) meta.params = params;
31
+ return { meta, body: match[2].trim() };
32
+ }
33
+
34
+ function readSkillFile(filePath, fallbackName, scope) {
35
+ const raw = readFileSync(filePath, 'utf8');
36
+ const { meta, body } = parseFrontmatter(raw);
37
+ const name = String(meta.name || fallbackName).trim();
38
+ if (!SKILL_NAME_RE.test(name)) return null;
39
+ return {
40
+ name,
41
+ description: String(meta.description || '').trim(),
42
+ params: Array.isArray(meta.params) ? meta.params : [],
43
+ body,
44
+ scope,
45
+ path: filePath,
46
+ };
47
+ }
48
+
49
+ function readOptionalText(filePath) {
50
+ if (!existsSync(filePath)) return '';
51
+ return readFileSync(filePath, 'utf8').trim();
52
+ }
53
+
54
+ function safeRelativeEntry(value, fallback) {
55
+ const text = String(value || fallback).trim();
56
+ if (!text || text.startsWith('/') || text.includes('..')) return fallback;
57
+ return text;
58
+ }
59
+
60
+ function readWorkspaceManifest(workspacePath) {
61
+ const manifestPath = join(workspacePath, 'skill.yaml');
62
+ if (!existsSync(manifestPath)) return null;
63
+ try {
64
+ const manifest = parseYaml(readFileSync(manifestPath, 'utf8'));
65
+ if (!manifest || typeof manifest !== 'object' || Array.isArray(manifest)) return null;
66
+ return { manifest, manifestPath };
67
+ } catch {
68
+ return null;
69
+ }
70
+ }
71
+
72
+ function readWorkspaceManifestSkill(workspacePath, loadedManifest = null) {
73
+ const loaded = loadedManifest ?? readWorkspaceManifest(workspacePath);
74
+ if (!loaded) return null;
75
+ const { manifest, manifestPath } = loaded;
76
+ const name = String(manifest.name || basename(workspacePath)).trim();
77
+ if (!SKILL_NAME_RE.test(name)) return null;
78
+ const entrypoints = manifest.entrypoints && typeof manifest.entrypoints === 'object'
79
+ ? manifest.entrypoints
80
+ : {};
81
+ const claude = safeRelativeEntry(entrypoints.claude, 'CLAUDE.md');
82
+ const body = readOptionalText(join(workspacePath, claude));
83
+ return {
84
+ name,
85
+ title: String(manifest.title || name).trim(),
86
+ description: String(manifest.description || '').trim(),
87
+ params: [],
88
+ body,
89
+ scope: 'workspace',
90
+ path: manifestPath,
91
+ manifest,
92
+ entrypoints,
93
+ version: manifest.version ? String(manifest.version) : null,
94
+ language: manifest.language ? String(manifest.language) : null,
95
+ };
96
+ }
97
+
98
+ function workspaceUiSkillDir(loadedManifest = null) {
99
+ if (!loadedManifest) return DEFAULT_UI_SKILL_DIR;
100
+ const { manifest } = loadedManifest;
101
+ const entrypoints = manifest?.entrypoints && typeof manifest.entrypoints === 'object'
102
+ ? manifest.entrypoints
103
+ : {};
104
+ return safeRelativeEntry(entrypoints.uiSkillDir, DEFAULT_UI_SKILL_DIR);
105
+ }
106
+
107
+ function collectDirectorySkills(dir, scope) {
108
+ if (!existsSync(dir)) return [];
109
+ return readdirSync(dir, { withFileTypes: true })
110
+ .filter((entry) => entry.isFile() && entry.name.endsWith('.md'))
111
+ .flatMap((entry) => {
112
+ const filePath = join(dir, entry.name);
113
+ const skill = readSkillFile(filePath, basename(entry.name, '.md'), scope);
114
+ return skill ? [skill] : [];
115
+ });
116
+ }
117
+
118
+ export function listSkills(session = {}) {
119
+ const skills = [];
120
+ if (session.workspacePath) {
121
+ const loadedManifest = readWorkspaceManifest(session.workspacePath);
122
+ const manifestSkill = readWorkspaceManifestSkill(session.workspacePath, loadedManifest);
123
+ if (manifestSkill) skills.push(manifestSkill);
124
+ skills.push(...collectDirectorySkills(join(session.workspacePath, workspaceUiSkillDir(loadedManifest)), 'workspace'));
125
+ }
126
+
127
+ const byName = new Map();
128
+ for (const skill of skills) byName.set(skill.name, skill);
129
+ return [...byName.values()].sort((a, b) => a.name.localeCompare(b.name));
130
+ }
131
+
132
+ export function findSkill(session, name) {
133
+ return listSkills(session).find((skill) => skill.name === name) ?? null;
134
+ }
135
+
136
+ export function formatSkillsForAgent(session) {
137
+ const skills = listSkills(session);
138
+ if (!skills.length) return 'No skills discovered.';
139
+ return skills
140
+ .map((skill) => `/${skill.name}: ${skill.description || 'workflow skill'} (${skill.scope})`)
141
+ .join('\n');
142
+ }
@@ -0,0 +1,65 @@
1
+ import { existsSync, readFileSync, readdirSync } from 'node:fs';
2
+ import { basename, join } from 'node:path';
3
+ import YAML from 'yaml';
4
+
5
+ const DEFAULT_WIKIRC = '.wikirc.yaml';
6
+
7
+ export function listWikircProfiles(workspacePath) {
8
+ if (!workspacePath || !existsSync(workspacePath)) return [];
9
+
10
+ return readdirSync(workspacePath, { withFileTypes: true })
11
+ .filter((entry) => entry.isFile())
12
+ .map((entry) => entry.name)
13
+ .filter((name) => name === DEFAULT_WIKIRC || name.startsWith(`${DEFAULT_WIKIRC}.`))
14
+ .sort((a, b) => {
15
+ if (a === DEFAULT_WIKIRC) return -1;
16
+ if (b === DEFAULT_WIKIRC) return 1;
17
+ return a.localeCompare(b);
18
+ })
19
+ .map((fileName) => ({
20
+ name: fileName === DEFAULT_WIKIRC ? 'default' : fileName.slice(`${DEFAULT_WIKIRC}.`.length),
21
+ fileName,
22
+ path: join(workspacePath, fileName),
23
+ default: fileName === DEFAULT_WIKIRC,
24
+ }));
25
+ }
26
+
27
+ export function resolveWikircProfile(workspacePath, profileName = 'default') {
28
+ const profiles = listWikircProfiles(workspacePath);
29
+ const normalized = profileName || 'default';
30
+ const found = profiles.find((profile) => profile.name === normalized || profile.fileName === normalized);
31
+ if (!found) {
32
+ const available = profiles.map((profile) => profile.name).join(', ') || 'aucun';
33
+ throw new Error(`profil wikirc introuvable: ${normalized} (disponibles: ${available})`);
34
+ }
35
+ return found;
36
+ }
37
+
38
+ export function loadWikircProfile(workspacePath, profileName = 'default') {
39
+ const profile = resolveWikircProfile(workspacePath, profileName);
40
+ const doc = YAML.parseDocument(readFileSync(profile.path, 'utf8'), {
41
+ schema: 'core',
42
+ });
43
+ if (doc.errors.length > 0) {
44
+ throw new Error(`wikirc YAML invalide: ${doc.errors[0].message}`);
45
+ }
46
+ const config = doc.toJSON();
47
+ if (!config || typeof config !== 'object' || Array.isArray(config)) {
48
+ throw new Error('wikirc YAML invalide: objet attendu a la racine');
49
+ }
50
+ return { profile, config };
51
+ }
52
+
53
+ export function summarizeWikircConfig(profile, config) {
54
+ return {
55
+ profile: profile.name,
56
+ fileName: basename(profile.path),
57
+ language: config?.language ?? null,
58
+ provider: config?.llm?.provider ?? null,
59
+ model: config?.llm?.model ?? null,
60
+ baseUrl: config?.llm?.baseUrl ?? null,
61
+ hasApiKey: Boolean(config?.llm?.apiKey),
62
+ vectorEnabled: Boolean(config?.retrieval?.vector?.enabled),
63
+ embeddingModel: config?.retrieval?.vector?.embeddingModel ?? null,
64
+ };
65
+ }
@@ -0,0 +1,81 @@
1
+ import { execFile } from 'node:child_process';
2
+ import { existsSync, readdirSync, realpathSync } from 'node:fs';
3
+ import { dirname, join, resolve } from 'node:path';
4
+ import { fileURLToPath } from 'node:url';
5
+ import { promisify } from 'node:util';
6
+ import { managerEnvFile, readEnvFile, userManagerDir } from './env.js';
7
+
8
+ const __dirname = dirname(fileURLToPath(import.meta.url));
9
+ const packageRoot = resolve(__dirname, '../..');
10
+ const execFileAsync = promisify(execFile);
11
+
12
+ export function managerRoot() {
13
+ return process.env.WIKI_MANAGER_ROOT
14
+ ? resolve(process.env.WIKI_MANAGER_ROOT)
15
+ : packageRoot;
16
+ }
17
+
18
+ export function workspacesDir() {
19
+ return process.env.WIKI_WORKSPACES_DIR
20
+ ? resolve(process.env.WIKI_WORKSPACES_DIR)
21
+ : join(userManagerDir(), 'workspaces');
22
+ }
23
+
24
+ export function listWorkspaces() {
25
+ const root = workspacesDir();
26
+ if (!existsSync(root)) return [];
27
+
28
+ return readdirSync(root, { withFileTypes: true })
29
+ .filter((entry) => entry.isDirectory() || entry.isSymbolicLink())
30
+ .flatMap((entry) => {
31
+ const registryPath = join(root, entry.name);
32
+ const envFile = join(registryPath, '.env');
33
+ if (!existsSync(envFile)) return [];
34
+ const env = readEnvFile(envFile);
35
+ const workspacePath = env.WIKI_WORKSPACE_PATH || registryPath;
36
+ return [
37
+ {
38
+ name: env.WORKSPACE_NAME || entry.name,
39
+ registryPath,
40
+ envFile,
41
+ workspacePath: realpathSync.native?.(workspacePath) ?? realpathSync(workspacePath),
42
+ env,
43
+ },
44
+ ];
45
+ });
46
+ }
47
+
48
+ export function findWorkspace(name) {
49
+ return listWorkspaces().find((workspace) => workspace.name === name);
50
+ }
51
+
52
+ function isValidWorkspaceName(name) {
53
+ return (
54
+ typeof name === 'string' &&
55
+ /^[a-zA-Z0-9][a-zA-Z0-9_.-]*[a-zA-Z0-9]$|^[a-zA-Z0-9]$/.test(name) &&
56
+ !name.includes('..')
57
+ );
58
+ }
59
+
60
+ export async function createWorkspace(name, targetPath = null, options = {}) {
61
+ if (!isValidWorkspaceName(name)) {
62
+ throw new Error('Usage: /workspace init <name> [path]');
63
+ }
64
+ const args = ['config', name];
65
+ if (targetPath) args.push(targetPath);
66
+ const { stdout, stderr } = await execFileAsync(
67
+ join(managerRoot(), 'wiki-workspace'),
68
+ args,
69
+ {
70
+ cwd: managerRoot(),
71
+ env: {
72
+ ...process.env,
73
+ WIKI_WORKSPACES_DIR: workspacesDir(),
74
+ WIKI_MANAGER_ENV_FILE: managerEnvFile(),
75
+ },
76
+ maxBuffer: options.maxBuffer ?? 1024 * 1024 * 8,
77
+ timeout: options.timeout ?? 600_000,
78
+ },
79
+ );
80
+ return [stdout, stderr].filter(Boolean).join('\n').trim();
81
+ }