@contextq/cli 0.0.1 → 0.1.0
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/README.md +48 -3
- package/dist/desktop-guide.d.ts +8 -0
- package/dist/desktop-guide.js +40 -0
- package/dist/desktop-guide.js.map +1 -0
- package/dist/detect.d.ts +42 -0
- package/dist/detect.js +70 -0
- package/dist/detect.js.map +1 -0
- package/dist/index.d.ts +17 -0
- package/dist/index.js +421 -0
- package/dist/index.js.map +1 -0
- package/dist/mcp-config.d.ts +17 -0
- package/dist/mcp-config.js +79 -0
- package/dist/mcp-config.js.map +1 -0
- package/dist/platform-cred.d.ts +105 -0
- package/dist/platform-cred.js +210 -0
- package/dist/platform-cred.js.map +1 -0
- package/dist/prompt.d.ts +8 -0
- package/dist/prompt.js +53 -0
- package/dist/prompt.js.map +1 -0
- package/dist/session-hook.d.ts +95 -0
- package/dist/session-hook.js +217 -0
- package/dist/session-hook.js.map +1 -0
- package/dist/skills-sync.d.ts +62 -0
- package/dist/skills-sync.js +129 -0
- package/dist/skills-sync.js.map +1 -0
- package/dist/slug.d.ts +48 -0
- package/dist/slug.js +62 -0
- package/dist/slug.js.map +1 -0
- package/dist/tasks-init.d.ts +14 -0
- package/dist/tasks-init.js +51 -0
- package/dist/tasks-init.js.map +1 -0
- package/package.json +31 -7
|
@@ -0,0 +1,217 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* session-hook.ts — idempotent SessionStart boot-hook merge, for BOTH
|
|
3
|
+
* Claude Code and Codex CLI (T665).
|
|
4
|
+
*
|
|
5
|
+
* Targets <root>/.claude/settings.local.json for Claude Code, NOT
|
|
6
|
+
* settings.json -- mirroring skills/contextq/scripts/install-hook.sh's own
|
|
7
|
+
* explicit rule: the baked hook command embeds an absolute $HOME/... path,
|
|
8
|
+
* which is per-machine and MUST stay in a gitignored file, never a
|
|
9
|
+
* shared/global settings file (see install-hook.sh header comment).
|
|
10
|
+
*
|
|
11
|
+
* Codex's own convention treats project-level `.codex/config.toml` /
|
|
12
|
+
* `.codex/hooks.json` as "trusted-repo settings" meant to be COMMITTED and
|
|
13
|
+
* shared across a team (verified against developers.openai.com/codex docs,
|
|
14
|
+
* 2026-08-20: "Project `.codex/config.toml` -> trusted-repo Codex settings
|
|
15
|
+
* such as sandbox, MCP, hooks, model, or reasoning defaults") -- there is no
|
|
16
|
+
* Codex-native equivalent of Claude's settings.json/settings.local.json
|
|
17
|
+
* split. Since the command we write embeds the SAME per-machine absolute
|
|
18
|
+
* path either way, wireCodexSessionHook() deliberately breaks from that
|
|
19
|
+
* convention and gitignores `.codex/hooks.json` itself (see
|
|
20
|
+
* ensureGitignored below) rather than let a per-machine path get committed
|
|
21
|
+
* and silently break for every teammate whose contextq skill lives
|
|
22
|
+
* somewhere else (or isn't installed at all).
|
|
23
|
+
*
|
|
24
|
+
* This installer POINTS AT the contextq skill's boot script when it is
|
|
25
|
+
* already installed globally at ~/.claude/skills/contextq -- it never
|
|
26
|
+
* copies/vendors the skill tree into the target project (out of this
|
|
27
|
+
* installer's declared write-scope: .mcp.json, .claude/settings*.json,
|
|
28
|
+
* .codex/hooks.json, .contextq/, and the credential file only).
|
|
29
|
+
*
|
|
30
|
+
* ONE shared code path for both harnesses (T665 requirement), in two
|
|
31
|
+
* layers:
|
|
32
|
+
* - buildBootCommand() is the single command-string builder both writers
|
|
33
|
+
* call -- same env vars, same shellQuote()-based escaping, same target
|
|
34
|
+
* script.
|
|
35
|
+
* - mergeSessionStartHook() is the single read-merge-idempotent-write
|
|
36
|
+
* implementation both writers call. Claude's `.claude/settings.local.json`
|
|
37
|
+
* and Codex's `.codex/hooks.json` use the IDENTICAL JSON shape for a
|
|
38
|
+
* SessionStart entry -- { hooks: { SessionStart: [ { hooks: [ { type:
|
|
39
|
+
* 'command', command } ] } ] } } (verified against Codex's `hooks.json`
|
|
40
|
+
* schema at developers.openai.com/codex/hooks, 2026-08-20) -- so there is
|
|
41
|
+
* exactly one implementation of "does this file already have our hook,
|
|
42
|
+
* and if not, append it", parameterized by which file path to merge
|
|
43
|
+
* into. The two exported writers (wireSessionHook, wireCodexSessionHook)
|
|
44
|
+
* are thin wrappers supplying only the path + an optional top-level
|
|
45
|
+
* default field Codex's schema additionally supports ("description").
|
|
46
|
+
*/
|
|
47
|
+
import { appendFileSync, existsSync, mkdirSync, readFileSync, writeFileSync } from 'node:fs';
|
|
48
|
+
import { homedir } from 'node:os';
|
|
49
|
+
import { join } from 'node:path';
|
|
50
|
+
const MARKER = /contextq-boot\.sh|contextq.*boot/;
|
|
51
|
+
/**
|
|
52
|
+
* T656: POSIX single-quote wrap, safe for any input including shell
|
|
53
|
+
* metacharacters, unbalanced quotes, backticks, `$()`, `;`, etc. This is the
|
|
54
|
+
* defense-in-depth layer: slug.ts's validateSlug()/sanitizeSlugFallback()
|
|
55
|
+
* already constrain --agent/--workspace to a safe charset before they reach
|
|
56
|
+
* here, but this function must make the written command safe on its own,
|
|
57
|
+
* because a future caller of wireSessionHook()/wireCodexSessionHook() might
|
|
58
|
+
* bypass that validation (it takes plain strings, not a branded/validated
|
|
59
|
+
* type) -- every value substituted into the command string, including
|
|
60
|
+
* bootScript itself, goes through this.
|
|
61
|
+
*
|
|
62
|
+
* Standard technique: wrap in single quotes (nothing is interpolated inside
|
|
63
|
+
* single quotes in POSIX sh/bash), and for each embedded single quote,
|
|
64
|
+
* close the quote, emit an escaped literal quote, then reopen: ' -> '\''.
|
|
65
|
+
*/
|
|
66
|
+
export function shellQuote(value) {
|
|
67
|
+
return `'${value.split("'").join("'\\''")}'`;
|
|
68
|
+
}
|
|
69
|
+
export function ctqBrainBootScript() {
|
|
70
|
+
return join(homedir(), '.claude', 'skills', 'contextq', 'scripts', 'contextq-boot.sh');
|
|
71
|
+
}
|
|
72
|
+
/**
|
|
73
|
+
* buildBootCommand — the ONE harness-agnostic command builder (T665). Both
|
|
74
|
+
* wireSessionHook (Claude) and wireCodexSessionHook (Codex) call this to
|
|
75
|
+
* produce the exact same shell command string. Codex's SessionStart stdin
|
|
76
|
+
* contract and its `hookSpecificOutput.additionalContext` output shape are
|
|
77
|
+
* verified (2026-08-20, developers.openai.com/codex/hooks) to match Claude's
|
|
78
|
+
* field-for-field (session_id, transcript_path, cwd, hook_event_name on
|
|
79
|
+
* stdin; the same hookSpecificOutput.additionalContext JSON, or plain text,
|
|
80
|
+
* on stdout) -- so contextq-boot.sh's existing --hook output needs no
|
|
81
|
+
* per-harness branching, and this builder needs none either.
|
|
82
|
+
*/
|
|
83
|
+
export function buildBootCommand(agentSlug, workspace, bootScript) {
|
|
84
|
+
return (`CONTEXTQ_AGENT_SLUG=${shellQuote(agentSlug)} CONTEXTQ_WORKSPACE=${shellQuote(workspace)} ` +
|
|
85
|
+
`CONTEXTQ_WRITE_WORKSPACE=${shellQuote(workspace)} CONTEXTQ_READ_SCOPE=own+global ` +
|
|
86
|
+
`bash ${shellQuote(bootScript)} --hook`);
|
|
87
|
+
}
|
|
88
|
+
/**
|
|
89
|
+
* mergeSessionStartHook — the ONE shared read-merge-idempotent-write core
|
|
90
|
+
* (T665). See the file header for why this is safe to share verbatim
|
|
91
|
+
* between Claude's settings.local.json and Codex's hooks.json.
|
|
92
|
+
*/
|
|
93
|
+
function mergeSessionStartHook(target, command, wiredDetail) {
|
|
94
|
+
const { path, dir, topLevelDefaults } = target;
|
|
95
|
+
const existed = existsSync(path);
|
|
96
|
+
// eslint-disable-next-line @typescript-eslint/no-explicit-any
|
|
97
|
+
let doc = {};
|
|
98
|
+
if (existed) {
|
|
99
|
+
const raw = readFileSync(path, 'utf-8');
|
|
100
|
+
try {
|
|
101
|
+
doc = raw.trim() === '' ? {} : JSON.parse(raw);
|
|
102
|
+
}
|
|
103
|
+
catch (err) {
|
|
104
|
+
throw new Error(`${path} exists but is not valid JSON (${err.message}) -- refusing to touch it`);
|
|
105
|
+
}
|
|
106
|
+
}
|
|
107
|
+
if (topLevelDefaults) {
|
|
108
|
+
for (const [key, value] of Object.entries(topLevelDefaults)) {
|
|
109
|
+
if (doc[key] === undefined)
|
|
110
|
+
doc[key] = value;
|
|
111
|
+
}
|
|
112
|
+
}
|
|
113
|
+
doc.hooks ??= {};
|
|
114
|
+
doc.hooks.SessionStart ??= [];
|
|
115
|
+
// eslint-disable-next-line @typescript-eslint/no-explicit-any
|
|
116
|
+
const already = doc.hooks.SessionStart.some((entry) => (entry?.hooks ?? []).some((h) => typeof h.command === 'string' && MARKER.test(h.command)));
|
|
117
|
+
if (already) {
|
|
118
|
+
return { path, action: 'skipped', detail: 'SessionStart contextq-boot hook already present' };
|
|
119
|
+
}
|
|
120
|
+
doc.hooks.SessionStart.push({ hooks: [{ type: 'command', command }] });
|
|
121
|
+
mkdirSync(dir, { recursive: true });
|
|
122
|
+
writeFileSync(path, `${JSON.stringify(doc, null, 2)}\n`);
|
|
123
|
+
return { path, action: 'wired', detail: wiredDetail };
|
|
124
|
+
}
|
|
125
|
+
/**
|
|
126
|
+
* `bootScriptOverride` exists purely for tests: production callers (index.ts)
|
|
127
|
+
* always call this with 3 args and get ctqBrainBootScript(), which points at
|
|
128
|
+
* a real, per-machine, possibly-absent path under $HOME -- tests need to
|
|
129
|
+
* exercise the "wired" branch deterministically without depending on the
|
|
130
|
+
* host actually having contextq installed.
|
|
131
|
+
*/
|
|
132
|
+
export function wireSessionHook(root, agentSlug, workspace, bootScriptOverride) {
|
|
133
|
+
const path = join(root, '.claude', 'settings.local.json');
|
|
134
|
+
const bootScript = bootScriptOverride ?? ctqBrainBootScript();
|
|
135
|
+
if (!existsSync(bootScript)) {
|
|
136
|
+
return {
|
|
137
|
+
path,
|
|
138
|
+
action: 'skipped',
|
|
139
|
+
detail: 'contextq skill not found at ~/.claude/skills/contextq -- MCP tools and the task ' +
|
|
140
|
+
'manifest are wired, but the memory boot hook needs the skill installed separately first',
|
|
141
|
+
};
|
|
142
|
+
}
|
|
143
|
+
const command = buildBootCommand(agentSlug, workspace, bootScript);
|
|
144
|
+
return mergeSessionStartHook({ path, dir: join(root, '.claude') }, command, `SessionStart boot hook wired (agent=${agentSlug}, workspace=${workspace})`);
|
|
145
|
+
}
|
|
146
|
+
/**
|
|
147
|
+
* T665: whether `<root>/.codex/config.toml` ALSO declares an inline
|
|
148
|
+
* `[hooks]`/`[[hooks.X]]` table. Codex's own docs (developers.openai.com/
|
|
149
|
+
* codex/hooks, 2026-08-20) state that when one config layer holds both a
|
|
150
|
+
* `hooks.json` file and an inline `[hooks]` table, "Codex loads both and
|
|
151
|
+
* warns. Prefer one representation per layer." -- we always write
|
|
152
|
+
* `hooks.json`, so this surfaces that collision to the installer's own
|
|
153
|
+
* output instead of leaving it as a silent Codex-side warning nobody sees.
|
|
154
|
+
*/
|
|
155
|
+
function hasInlineHooksTable(root) {
|
|
156
|
+
const configPath = join(root, '.codex', 'config.toml');
|
|
157
|
+
if (!existsSync(configPath))
|
|
158
|
+
return false;
|
|
159
|
+
const raw = readFileSync(configPath, 'utf-8');
|
|
160
|
+
return /^\s*\[{1,2}hooks(\.|\])/m.test(raw);
|
|
161
|
+
}
|
|
162
|
+
/**
|
|
163
|
+
* T665: `.codex/hooks.json` is gitignored the same way `.claude/settings.local.json`
|
|
164
|
+
* effectively is (see the file header) -- but Codex has no built-in
|
|
165
|
+
* settings.json/settings.local.json split, so THIS installer has to be the
|
|
166
|
+
* one to add the .gitignore line, not Codex itself.
|
|
167
|
+
*/
|
|
168
|
+
function ensureGitignored(root, pattern) {
|
|
169
|
+
if (!existsSync(join(root, '.git')))
|
|
170
|
+
return;
|
|
171
|
+
const gitignorePath = join(root, '.gitignore');
|
|
172
|
+
const existing = existsSync(gitignorePath) ? readFileSync(gitignorePath, 'utf-8') : '';
|
|
173
|
+
const existingLines = new Set(existing.split('\n').map((line) => line.trim()));
|
|
174
|
+
if (existingLines.has(pattern))
|
|
175
|
+
return;
|
|
176
|
+
const block = '\n# .codex/hooks.json embeds a per-machine absolute path (the contextq boot\n' +
|
|
177
|
+
'# hook command) -- gitignored the same way .claude/settings.local.json is.\n' +
|
|
178
|
+
'# Each teammate who wants it needs to run `npx contextq init` themselves.\n' +
|
|
179
|
+
`${pattern}\n`;
|
|
180
|
+
appendFileSync(gitignorePath, block);
|
|
181
|
+
}
|
|
182
|
+
/**
|
|
183
|
+
* wireCodexSessionHook — the Codex writer (T665). Same command, same merge
|
|
184
|
+
* core as wireSessionHook above; only the target path, the top-level
|
|
185
|
+
* "description" default, and the gitignore step differ. `bootScriptOverride`
|
|
186
|
+
* exists for the same test-determinism reason as wireSessionHook's.
|
|
187
|
+
*/
|
|
188
|
+
export function wireCodexSessionHook(root, agentSlug, workspace, bootScriptOverride) {
|
|
189
|
+
const path = join(root, '.codex', 'hooks.json');
|
|
190
|
+
const bootScript = bootScriptOverride ?? ctqBrainBootScript();
|
|
191
|
+
if (!existsSync(bootScript)) {
|
|
192
|
+
return {
|
|
193
|
+
path,
|
|
194
|
+
action: 'skipped',
|
|
195
|
+
detail: 'contextq skill not found at ~/.claude/skills/contextq -- the memory boot hook needs ' +
|
|
196
|
+
'the skill installed separately first',
|
|
197
|
+
};
|
|
198
|
+
}
|
|
199
|
+
const command = buildBootCommand(agentSlug, workspace, bootScript);
|
|
200
|
+
const trustNote = 'Codex requires this hook to be TRUSTED before it runs -- start `codex` in this repo and ' +
|
|
201
|
+
'approve it at the hook-trust prompt (or review/trust via `/hooks` in a session)';
|
|
202
|
+
const inlineWarning = hasInlineHooksTable(root)
|
|
203
|
+
? ` -- WARNING: .codex/config.toml also declares an inline [hooks] table; Codex loads both hooks.json and the inline table and warns on every run. Prefer one representation per layer.`
|
|
204
|
+
: '';
|
|
205
|
+
const result = mergeSessionStartHook({
|
|
206
|
+
path,
|
|
207
|
+
dir: join(root, '.codex'),
|
|
208
|
+
topLevelDefaults: {
|
|
209
|
+
description: 'ContextQ boot hook (managed by `npx contextq init` -- re-run the installer to update, do not hand-edit)',
|
|
210
|
+
},
|
|
211
|
+
}, command, `SessionStart boot hook wired (agent=${agentSlug}, workspace=${workspace}). ${trustNote}.${inlineWarning}`);
|
|
212
|
+
if (result.action === 'wired') {
|
|
213
|
+
ensureGitignored(root, '.codex/hooks.json');
|
|
214
|
+
}
|
|
215
|
+
return result;
|
|
216
|
+
}
|
|
217
|
+
//# sourceMappingURL=session-hook.js.map
|
|
@@ -0,0 +1 @@
|
|
|
1
|
+
{"version":3,"file":"session-hook.js","sourceRoot":"","sources":["../src/session-hook.ts"],"names":[],"mappings":"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"}
|
|
@@ -0,0 +1,62 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* skills-sync.ts — `contextq skills sync <dir>` core logic.
|
|
3
|
+
*
|
|
4
|
+
* T698: pulls the caller's tenant-distilled `type='procedure'` contexts
|
|
5
|
+
* (T376) from the ContextQ API (`GET /api/agent/skills/export?format=json`,
|
|
6
|
+
* server-side rendering lives in src/services/skill-export.service.ts) and
|
|
7
|
+
* writes them onto disk as standard Agent Skills folders: `<dir>/<slug>/SKILL.md`.
|
|
8
|
+
*
|
|
9
|
+
* THE IDEMPOTENCY KEY IS `metadata.cluster_key`, NOT the folder name or the
|
|
10
|
+
* skill's title. `runSkillDistillation()` upserts on cluster_key and
|
|
11
|
+
* rewrites `content`/`name`/`description` IN PLACE on every scheduler tick
|
|
12
|
+
* that adds a verified session to an existing cluster -- and because the
|
|
13
|
+
* server-side slug includes a human-readable title prefix, a title rewrite
|
|
14
|
+
* can change the freshly-computed slug even though the underlying cluster
|
|
15
|
+
* (and its intended on-disk folder) is unchanged. A sync that keyed off the
|
|
16
|
+
* freshly-computed slug/title would silently FORK a second folder on the
|
|
17
|
+
* next run. This module instead reads each existing local folder's own
|
|
18
|
+
* `metadata.cluster_key` back out of its `SKILL.md` frontmatter (no
|
|
19
|
+
* separate sidecar index file to go stale) and, when a remote skill's
|
|
20
|
+
* cluster_key matches an existing folder, REWRITES THAT FOLDER IN PLACE --
|
|
21
|
+
* even if the server just handed back a different slug for it.
|
|
22
|
+
*
|
|
23
|
+
* Orphans (a local folder whose cluster_key is no longer present in the
|
|
24
|
+
* remote payload -- the source context was deleted, archived, or its
|
|
25
|
+
* consent was revoked) are REPORTED, never auto-deleted: the file may carry
|
|
26
|
+
* user edits.
|
|
27
|
+
*
|
|
28
|
+
* Network-free and fully unit-testable: `fetchSkills` is injected rather
|
|
29
|
+
* than hard-wired to `fetch()`, so tests exercise the on-disk merge logic
|
|
30
|
+
* against an in-memory payload with no server needed.
|
|
31
|
+
*/
|
|
32
|
+
export interface SkillPayload {
|
|
33
|
+
contextId: number;
|
|
34
|
+
/** Freshly computed by the server for THIS render -- may differ from the
|
|
35
|
+
* folder a matching cluster_key already lives in; see module doc. */
|
|
36
|
+
slug: string;
|
|
37
|
+
clusterKey: string;
|
|
38
|
+
markdown: string;
|
|
39
|
+
}
|
|
40
|
+
export interface SyncResult {
|
|
41
|
+
written: string[];
|
|
42
|
+
updated: string[];
|
|
43
|
+
unchanged: string[];
|
|
44
|
+
/** Folder names present on disk before this run whose cluster_key is not
|
|
45
|
+
* in the remote payload this run. Left on disk untouched. */
|
|
46
|
+
orphans: string[];
|
|
47
|
+
}
|
|
48
|
+
/**
|
|
49
|
+
* Pull a scalar value out of the `metadata:` frontmatter block this project
|
|
50
|
+
* renders (src/services/skill-export.service.ts / yamlString()). Format is
|
|
51
|
+
* fixed and self-controlled (both write and read side live in this repo),
|
|
52
|
+
* so a small anchored regex is enough -- no general YAML parser needed.
|
|
53
|
+
* `^ <key>: "..."$` in multiline mode only matches an indented entry under
|
|
54
|
+
* `metadata:`, never the top-level `name:`/`description:` lines.
|
|
55
|
+
*/
|
|
56
|
+
export declare function extractFrontmatterMetadataValue(markdown: string, key: string): string | null;
|
|
57
|
+
export declare function extractClusterKey(markdown: string): string | null;
|
|
58
|
+
export interface SyncOptions {
|
|
59
|
+
dir: string;
|
|
60
|
+
fetchSkills: () => Promise<SkillPayload[]>;
|
|
61
|
+
}
|
|
62
|
+
export declare function syncSkills(opts: SyncOptions): Promise<SyncResult>;
|
|
@@ -0,0 +1,129 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* skills-sync.ts — `contextq skills sync <dir>` core logic.
|
|
3
|
+
*
|
|
4
|
+
* T698: pulls the caller's tenant-distilled `type='procedure'` contexts
|
|
5
|
+
* (T376) from the ContextQ API (`GET /api/agent/skills/export?format=json`,
|
|
6
|
+
* server-side rendering lives in src/services/skill-export.service.ts) and
|
|
7
|
+
* writes them onto disk as standard Agent Skills folders: `<dir>/<slug>/SKILL.md`.
|
|
8
|
+
*
|
|
9
|
+
* THE IDEMPOTENCY KEY IS `metadata.cluster_key`, NOT the folder name or the
|
|
10
|
+
* skill's title. `runSkillDistillation()` upserts on cluster_key and
|
|
11
|
+
* rewrites `content`/`name`/`description` IN PLACE on every scheduler tick
|
|
12
|
+
* that adds a verified session to an existing cluster -- and because the
|
|
13
|
+
* server-side slug includes a human-readable title prefix, a title rewrite
|
|
14
|
+
* can change the freshly-computed slug even though the underlying cluster
|
|
15
|
+
* (and its intended on-disk folder) is unchanged. A sync that keyed off the
|
|
16
|
+
* freshly-computed slug/title would silently FORK a second folder on the
|
|
17
|
+
* next run. This module instead reads each existing local folder's own
|
|
18
|
+
* `metadata.cluster_key` back out of its `SKILL.md` frontmatter (no
|
|
19
|
+
* separate sidecar index file to go stale) and, when a remote skill's
|
|
20
|
+
* cluster_key matches an existing folder, REWRITES THAT FOLDER IN PLACE --
|
|
21
|
+
* even if the server just handed back a different slug for it.
|
|
22
|
+
*
|
|
23
|
+
* Orphans (a local folder whose cluster_key is no longer present in the
|
|
24
|
+
* remote payload -- the source context was deleted, archived, or its
|
|
25
|
+
* consent was revoked) are REPORTED, never auto-deleted: the file may carry
|
|
26
|
+
* user edits.
|
|
27
|
+
*
|
|
28
|
+
* Network-free and fully unit-testable: `fetchSkills` is injected rather
|
|
29
|
+
* than hard-wired to `fetch()`, so tests exercise the on-disk merge logic
|
|
30
|
+
* against an in-memory payload with no server needed.
|
|
31
|
+
*/
|
|
32
|
+
import { existsSync, mkdirSync, readdirSync, readFileSync, statSync, writeFileSync } from 'node:fs';
|
|
33
|
+
import { join } from 'node:path';
|
|
34
|
+
/**
|
|
35
|
+
* Pull a scalar value out of the `metadata:` frontmatter block this project
|
|
36
|
+
* renders (src/services/skill-export.service.ts / yamlString()). Format is
|
|
37
|
+
* fixed and self-controlled (both write and read side live in this repo),
|
|
38
|
+
* so a small anchored regex is enough -- no general YAML parser needed.
|
|
39
|
+
* `^ <key>: "..."$` in multiline mode only matches an indented entry under
|
|
40
|
+
* `metadata:`, never the top-level `name:`/`description:` lines.
|
|
41
|
+
*/
|
|
42
|
+
export function extractFrontmatterMetadataValue(markdown, key) {
|
|
43
|
+
const re = new RegExp(`^ {2}${key}: "((?:[^"\\\\]|\\\\.)*)"$`, 'm');
|
|
44
|
+
const match = markdown.match(re);
|
|
45
|
+
if (!match)
|
|
46
|
+
return null;
|
|
47
|
+
// Reverses yamlString()'s escaping (`\` -> `\\`, `"` -> `\"`): a backslash
|
|
48
|
+
// followed by any one character collapses to that character.
|
|
49
|
+
return match[1].replace(/\\(.)/g, '$1');
|
|
50
|
+
}
|
|
51
|
+
export function extractClusterKey(markdown) {
|
|
52
|
+
return extractFrontmatterMetadataValue(markdown, 'cluster_key');
|
|
53
|
+
}
|
|
54
|
+
/**
|
|
55
|
+
* Discover existing skill folders directly under `dir` by reading each
|
|
56
|
+
* `SKILL.md`'s own `cluster_key` back out -- this IS the persisted index;
|
|
57
|
+
* there is no separate sidecar file to go stale. A subdirectory with no
|
|
58
|
+
* `SKILL.md`, or a `SKILL.md` with no parseable `cluster_key` (not one this
|
|
59
|
+
* tool wrote, or hand-edited past recognition), is left alone entirely --
|
|
60
|
+
* never treated as ours to manage.
|
|
61
|
+
*/
|
|
62
|
+
function scanLocalSkills(dir) {
|
|
63
|
+
if (!existsSync(dir))
|
|
64
|
+
return [];
|
|
65
|
+
const entries = [];
|
|
66
|
+
for (const name of readdirSync(dir)) {
|
|
67
|
+
const folderPath = join(dir, name);
|
|
68
|
+
if (!statSync(folderPath).isDirectory())
|
|
69
|
+
continue;
|
|
70
|
+
const skillPath = join(folderPath, 'SKILL.md');
|
|
71
|
+
if (!existsSync(skillPath))
|
|
72
|
+
continue;
|
|
73
|
+
const content = readFileSync(skillPath, 'utf-8');
|
|
74
|
+
const clusterKey = extractClusterKey(content);
|
|
75
|
+
if (!clusterKey)
|
|
76
|
+
continue;
|
|
77
|
+
entries.push({ folder: name, clusterKey });
|
|
78
|
+
}
|
|
79
|
+
return entries;
|
|
80
|
+
}
|
|
81
|
+
/** Rewrite the top-level `name: <slug>` frontmatter line to match `folder`,
|
|
82
|
+
* so `frontmatter.name` stays equal to the actual directory name (Agent
|
|
83
|
+
* Skills convention) even when an existing folder is being reused under a
|
|
84
|
+
* different name than the server's freshly-computed slug. Anchored to
|
|
85
|
+
* start-of-line so it can never touch the indented `metadata:` entries. */
|
|
86
|
+
function withFrontmatterName(markdown, folder) {
|
|
87
|
+
return markdown.replace(/^name: .+$/m, `name: ${folder}`);
|
|
88
|
+
}
|
|
89
|
+
export async function syncSkills(opts) {
|
|
90
|
+
mkdirSync(opts.dir, { recursive: true });
|
|
91
|
+
const local = scanLocalSkills(opts.dir);
|
|
92
|
+
const localByClusterKey = new Map(local.map((e) => [e.clusterKey, e.folder]));
|
|
93
|
+
const usedFolders = new Set(local.map((e) => e.folder));
|
|
94
|
+
const remote = await opts.fetchSkills();
|
|
95
|
+
const result = { written: [], updated: [], unchanged: [], orphans: [] };
|
|
96
|
+
const seenClusterKeys = new Set();
|
|
97
|
+
for (const skill of remote) {
|
|
98
|
+
seenClusterKeys.add(skill.clusterKey);
|
|
99
|
+
const existingFolder = localByClusterKey.get(skill.clusterKey);
|
|
100
|
+
let folder = existingFolder;
|
|
101
|
+
if (!folder) {
|
|
102
|
+
// Brand-new cluster. Prefer the server-computed slug; only fall back
|
|
103
|
+
// to a suffixed name if an UNRELATED local folder already occupies
|
|
104
|
+
// it (astronomically unlikely given the slug's hash suffix, but
|
|
105
|
+
// deterministic and collision-safe rather than silently colliding).
|
|
106
|
+
folder = usedFolders.has(skill.slug) ? `${skill.slug}-c${skill.contextId}` : skill.slug;
|
|
107
|
+
usedFolders.add(folder);
|
|
108
|
+
localByClusterKey.set(skill.clusterKey, folder);
|
|
109
|
+
}
|
|
110
|
+
const markdown = folder === skill.slug ? skill.markdown : withFrontmatterName(skill.markdown, folder);
|
|
111
|
+
const folderPath = join(opts.dir, folder);
|
|
112
|
+
const skillPath = join(folderPath, 'SKILL.md');
|
|
113
|
+
const previous = existsSync(skillPath) ? readFileSync(skillPath, 'utf-8') : null;
|
|
114
|
+
if (previous === markdown) {
|
|
115
|
+
result.unchanged.push(folder);
|
|
116
|
+
continue;
|
|
117
|
+
}
|
|
118
|
+
mkdirSync(folderPath, { recursive: true });
|
|
119
|
+
writeFileSync(skillPath, markdown, 'utf-8');
|
|
120
|
+
result[previous === null ? 'written' : 'updated'].push(folder);
|
|
121
|
+
}
|
|
122
|
+
for (const entry of local) {
|
|
123
|
+
if (!seenClusterKeys.has(entry.clusterKey)) {
|
|
124
|
+
result.orphans.push(entry.folder);
|
|
125
|
+
}
|
|
126
|
+
}
|
|
127
|
+
return result;
|
|
128
|
+
}
|
|
129
|
+
//# sourceMappingURL=skills-sync.js.map
|
|
@@ -0,0 +1 @@
|
|
|
1
|
+
{"version":3,"file":"skills-sync.js","sourceRoot":"","sources":["../src/skills-sync.ts"],"names":[],"mappings":"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"}
|
package/dist/slug.d.ts
ADDED
|
@@ -0,0 +1,48 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* slug.ts — shared slug validation/sanitization for --agent / --workspace.
|
|
3
|
+
*
|
|
4
|
+
* T656: the installer used to accept --agent / --workspace verbatim (or fall
|
|
5
|
+
* back to basename(root), a directory name that can legally contain shell
|
|
6
|
+
* metacharacters) and interpolate them, unescaped, into a shell command
|
|
7
|
+
* string written into .claude/settings.local.json's SessionStart hook. That
|
|
8
|
+
* command runs through a shell on every future session start in the target
|
|
9
|
+
* repo, so an unsanitized value there is an auto-executing injection.
|
|
10
|
+
*
|
|
11
|
+
* SLUG_PATTERN mirrors the slug shape the server itself converges on: every
|
|
12
|
+
* `slugify()` in the API (src/services/workspace.service.ts,
|
|
13
|
+
* context.service.ts, tenant-slug.ts) lowercases, then collapses any run of
|
|
14
|
+
* non [a-z0-9] chars to a single '-', then trims leading/trailing '-'. That
|
|
15
|
+
* output is always a subset of [a-z0-9][a-z0-9_-]{0,63} — this pattern adds
|
|
16
|
+
* '_' too (never produced by slugify(), but a common human-typed separator)
|
|
17
|
+
* and stays deliberately ASCII-lowercase-only so the exact same pattern can
|
|
18
|
+
* gate both --agent and --workspace with one rule: CLAUDE.md documents
|
|
19
|
+
* "Workspace slugs are always lowercase" as a product convention, and this
|
|
20
|
+
* installer extends that same lowercase-only discipline to --agent too
|
|
21
|
+
* rather than maintaining two different regexes for two fields that both
|
|
22
|
+
* end up as shell-interpolated env var values. This is a deliberately
|
|
23
|
+
* STRICTER-than-the-API client-side gate (the boot API's `agent` field is
|
|
24
|
+
* just `z.string().min(1).max(64)` with no charset check server-side) — the
|
|
25
|
+
* goal here is never producing a value the API would reject, not permitting
|
|
26
|
+
* everything the API happens to tolerate.
|
|
27
|
+
*/
|
|
28
|
+
export declare const SLUG_PATTERN: RegExp;
|
|
29
|
+
export declare const SLUG_PATTERN_DESCRIPTION = "lowercase letters, digits, \"-\", \"_\" (must start with a letter/digit, max 64 chars)";
|
|
30
|
+
/**
|
|
31
|
+
* Validates an EXPLICIT (user-typed) flag value against SLUG_PATTERN.
|
|
32
|
+
* Throws a clear, actionable error naming the flag, the offending value, and
|
|
33
|
+
* the allowed pattern -- never silently truncates or mutates an explicit
|
|
34
|
+
* value (the caller may choose to lowercase --workspace first, since
|
|
35
|
+
* workspace slugs are documented as always-lowercase in this product; see
|
|
36
|
+
* index.ts). No such courtesy applies to --agent, which has no such
|
|
37
|
+
* documented casing convention.
|
|
38
|
+
*/
|
|
39
|
+
export declare function validateSlug(value: string, flagName: string): string;
|
|
40
|
+
/**
|
|
41
|
+
* Sanitizes a FALLBACK value (basename(root) -- the user never typed this,
|
|
42
|
+
* it came from the filesystem) into something that always satisfies
|
|
43
|
+
* SLUG_PATTERN: lowercase, replace every disallowed run with a single '-',
|
|
44
|
+
* trim leading/trailing separators, cap at 64 chars. An empty result (e.g.
|
|
45
|
+
* a directory name made entirely of disallowed characters) falls back to
|
|
46
|
+
* the fixed default 'default' rather than producing an empty/invalid slug.
|
|
47
|
+
*/
|
|
48
|
+
export declare function sanitizeSlugFallback(raw: string): string;
|
package/dist/slug.js
ADDED
|
@@ -0,0 +1,62 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* slug.ts — shared slug validation/sanitization for --agent / --workspace.
|
|
3
|
+
*
|
|
4
|
+
* T656: the installer used to accept --agent / --workspace verbatim (or fall
|
|
5
|
+
* back to basename(root), a directory name that can legally contain shell
|
|
6
|
+
* metacharacters) and interpolate them, unescaped, into a shell command
|
|
7
|
+
* string written into .claude/settings.local.json's SessionStart hook. That
|
|
8
|
+
* command runs through a shell on every future session start in the target
|
|
9
|
+
* repo, so an unsanitized value there is an auto-executing injection.
|
|
10
|
+
*
|
|
11
|
+
* SLUG_PATTERN mirrors the slug shape the server itself converges on: every
|
|
12
|
+
* `slugify()` in the API (src/services/workspace.service.ts,
|
|
13
|
+
* context.service.ts, tenant-slug.ts) lowercases, then collapses any run of
|
|
14
|
+
* non [a-z0-9] chars to a single '-', then trims leading/trailing '-'. That
|
|
15
|
+
* output is always a subset of [a-z0-9][a-z0-9_-]{0,63} — this pattern adds
|
|
16
|
+
* '_' too (never produced by slugify(), but a common human-typed separator)
|
|
17
|
+
* and stays deliberately ASCII-lowercase-only so the exact same pattern can
|
|
18
|
+
* gate both --agent and --workspace with one rule: CLAUDE.md documents
|
|
19
|
+
* "Workspace slugs are always lowercase" as a product convention, and this
|
|
20
|
+
* installer extends that same lowercase-only discipline to --agent too
|
|
21
|
+
* rather than maintaining two different regexes for two fields that both
|
|
22
|
+
* end up as shell-interpolated env var values. This is a deliberately
|
|
23
|
+
* STRICTER-than-the-API client-side gate (the boot API's `agent` field is
|
|
24
|
+
* just `z.string().min(1).max(64)` with no charset check server-side) — the
|
|
25
|
+
* goal here is never producing a value the API would reject, not permitting
|
|
26
|
+
* everything the API happens to tolerate.
|
|
27
|
+
*/
|
|
28
|
+
export const SLUG_PATTERN = /^[a-z0-9][a-z0-9_-]{0,63}$/;
|
|
29
|
+
export const SLUG_PATTERN_DESCRIPTION = 'lowercase letters, digits, "-", "_" (must start with a letter/digit, max 64 chars)';
|
|
30
|
+
/**
|
|
31
|
+
* Validates an EXPLICIT (user-typed) flag value against SLUG_PATTERN.
|
|
32
|
+
* Throws a clear, actionable error naming the flag, the offending value, and
|
|
33
|
+
* the allowed pattern -- never silently truncates or mutates an explicit
|
|
34
|
+
* value (the caller may choose to lowercase --workspace first, since
|
|
35
|
+
* workspace slugs are documented as always-lowercase in this product; see
|
|
36
|
+
* index.ts). No such courtesy applies to --agent, which has no such
|
|
37
|
+
* documented casing convention.
|
|
38
|
+
*/
|
|
39
|
+
export function validateSlug(value, flagName) {
|
|
40
|
+
if (!SLUG_PATTERN.test(value)) {
|
|
41
|
+
throw new Error(`${flagName} value "${value}" is not a valid slug -- allowed: ${SLUG_PATTERN_DESCRIPTION}`);
|
|
42
|
+
}
|
|
43
|
+
return value;
|
|
44
|
+
}
|
|
45
|
+
/**
|
|
46
|
+
* Sanitizes a FALLBACK value (basename(root) -- the user never typed this,
|
|
47
|
+
* it came from the filesystem) into something that always satisfies
|
|
48
|
+
* SLUG_PATTERN: lowercase, replace every disallowed run with a single '-',
|
|
49
|
+
* trim leading/trailing separators, cap at 64 chars. An empty result (e.g.
|
|
50
|
+
* a directory name made entirely of disallowed characters) falls back to
|
|
51
|
+
* the fixed default 'default' rather than producing an empty/invalid slug.
|
|
52
|
+
*/
|
|
53
|
+
export function sanitizeSlugFallback(raw) {
|
|
54
|
+
let out = raw
|
|
55
|
+
.toLowerCase()
|
|
56
|
+
.replace(/[^a-z0-9_-]+/g, '-')
|
|
57
|
+
.replace(/^[-_]+|[-_]+$/g, '');
|
|
58
|
+
out = out.slice(0, 64);
|
|
59
|
+
out = out.replace(/[-_]+$/g, '');
|
|
60
|
+
return out === '' ? 'default' : out;
|
|
61
|
+
}
|
|
62
|
+
//# sourceMappingURL=slug.js.map
|
package/dist/slug.js.map
ADDED
|
@@ -0,0 +1 @@
|
|
|
1
|
+
{"version":3,"file":"slug.js","sourceRoot":"","sources":["../src/slug.ts"],"names":[],"mappings":"AAAA;;;;;;;;;;;;;;;;;;;;;;;;;;GA0BG;AAEH,MAAM,CAAC,MAAM,YAAY,GAAG,4BAA4B,CAAC;AAEzD,MAAM,CAAC,MAAM,wBAAwB,GAAG,oFAAoF,CAAC;AAE7H;;;;;;;;GAQG;AACH,MAAM,UAAU,YAAY,CAAC,KAAa,EAAE,QAAgB;IAC1D,IAAI,CAAC,YAAY,CAAC,IAAI,CAAC,KAAK,CAAC,EAAE,CAAC;QAC9B,MAAM,IAAI,KAAK,CACb,GAAG,QAAQ,WAAW,KAAK,qCAAqC,wBAAwB,EAAE,CAC3F,CAAC;IACJ,CAAC;IACD,OAAO,KAAK,CAAC;AACf,CAAC;AAED;;;;;;;GAOG;AACH,MAAM,UAAU,oBAAoB,CAAC,GAAW;IAC9C,IAAI,GAAG,GAAG,GAAG;SACV,WAAW,EAAE;SACb,OAAO,CAAC,eAAe,EAAE,GAAG,CAAC;SAC7B,OAAO,CAAC,gBAAgB,EAAE,EAAE,CAAC,CAAC;IAEjC,GAAG,GAAG,GAAG,CAAC,KAAK,CAAC,CAAC,EAAE,EAAE,CAAC,CAAC;IACvB,GAAG,GAAG,GAAG,CAAC,OAAO,CAAC,SAAS,EAAE,EAAE,CAAC,CAAC;IAEjC,OAAO,GAAG,KAAK,EAAE,CAAC,CAAC,CAAC,SAAS,CAAC,CAAC,CAAC,GAAG,CAAC;AACtC,CAAC"}
|
|
@@ -0,0 +1,14 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* tasks-init.ts — scaffold .contextq/tasks/TASKS.md.
|
|
3
|
+
*
|
|
4
|
+
* Mirrors skills/contextq/scripts/ctq-tasks-init.sh 1:1 (same manifest
|
|
5
|
+
* header, same idempotent "leave it alone if it exists" behavior, same
|
|
6
|
+
* narrow 3-line .gitignore patch for the churny per-machine overlay files).
|
|
7
|
+
* Does not touch anything else in .gitignore.
|
|
8
|
+
*/
|
|
9
|
+
export interface TasksInitResult {
|
|
10
|
+
path: string;
|
|
11
|
+
action: 'created' | 'skipped';
|
|
12
|
+
detail: string;
|
|
13
|
+
}
|
|
14
|
+
export declare function initTasksDir(root: string): TasksInitResult;
|
|
@@ -0,0 +1,51 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* tasks-init.ts — scaffold .contextq/tasks/TASKS.md.
|
|
3
|
+
*
|
|
4
|
+
* Mirrors skills/contextq/scripts/ctq-tasks-init.sh 1:1 (same manifest
|
|
5
|
+
* header, same idempotent "leave it alone if it exists" behavior, same
|
|
6
|
+
* narrow 3-line .gitignore patch for the churny per-machine overlay files).
|
|
7
|
+
* Does not touch anything else in .gitignore.
|
|
8
|
+
*/
|
|
9
|
+
import { appendFileSync, existsSync, mkdirSync, readFileSync, writeFileSync } from 'node:fs';
|
|
10
|
+
import { basename, join } from 'node:path';
|
|
11
|
+
function manifestHeader(project) {
|
|
12
|
+
return `# Tasks: ${project}
|
|
13
|
+
<!-- ctq-tasks manifest v1 (T442, repo-local fork of nf-tasks). Status tokens: [backlog] [todo] [doing] [done] [forbidden]. -->
|
|
14
|
+
<!-- "blocked" is DERIVED — never a manual status. Two kinds: deps not all [done], OR a -->
|
|
15
|
+
<!-- "blocker: <reason>" line on a [todo] (external wait); clear the line to release it. -->
|
|
16
|
+
<!-- [backlog] = not ready yet; excluded from run planning. Promote with: flip todo <id> -->
|
|
17
|
+
<!-- [forbidden] = too dangerous to ever run — permanent tombstone. Add 'reason: <why>'. -->
|
|
18
|
+
<!-- Never archived. Flip back to todo only if mis-classified. -->
|
|
19
|
+
<!-- Set with: flip forbidden <id> -->
|
|
20
|
+
<!-- Task header format is parsed: "## T<NN> [status] <free-text title>". Keep the id+status shape. -->
|
|
21
|
+
<!-- See docs/tech/ctq-tasks-sync.md for the field mapping this manifest syncs against. -->
|
|
22
|
+
|
|
23
|
+
`;
|
|
24
|
+
}
|
|
25
|
+
function patchGitignore(root) {
|
|
26
|
+
if (!existsSync(join(root, '.git')))
|
|
27
|
+
return;
|
|
28
|
+
const gitignorePath = join(root, '.gitignore');
|
|
29
|
+
const patterns = ['.contextq/tasks/status.log', '.contextq/tasks/.reconcile.lock/', '.contextq/tasks/sync-map.json'];
|
|
30
|
+
const existing = existsSync(gitignorePath) ? readFileSync(gitignorePath, 'utf-8') : '';
|
|
31
|
+
const existingLines = new Set(existing.split('\n').map((line) => line.trim()));
|
|
32
|
+
const missing = patterns.filter((p) => !existingLines.has(p));
|
|
33
|
+
if (missing.length === 0)
|
|
34
|
+
return;
|
|
35
|
+
const block = '\n# .contextq/tasks/ — TASKS.md + TASKS_ARCHIVE.md are tracked; the\n' +
|
|
36
|
+
'# overlay/lock/sync-map below are machine-local and ephemeral\n' +
|
|
37
|
+
missing.map((p) => `${p}\n`).join('');
|
|
38
|
+
appendFileSync(gitignorePath, block);
|
|
39
|
+
}
|
|
40
|
+
export function initTasksDir(root) {
|
|
41
|
+
const tasksDir = join(root, '.contextq', 'tasks');
|
|
42
|
+
const manifest = join(tasksDir, 'TASKS.md');
|
|
43
|
+
mkdirSync(tasksDir, { recursive: true });
|
|
44
|
+
patchGitignore(root);
|
|
45
|
+
if (existsSync(manifest)) {
|
|
46
|
+
return { path: manifest, action: 'skipped', detail: 'manifest already exists — left untouched' };
|
|
47
|
+
}
|
|
48
|
+
writeFileSync(manifest, manifestHeader(basename(root)));
|
|
49
|
+
return { path: manifest, action: 'created', detail: 'task manifest scaffolded' };
|
|
50
|
+
}
|
|
51
|
+
//# sourceMappingURL=tasks-init.js.map
|
|
@@ -0,0 +1 @@
|
|
|
1
|
+
{"version":3,"file":"tasks-init.js","sourceRoot":"","sources":["../src/tasks-init.ts"],"names":[],"mappings":"AAAA;;;;;;;GAOG;AAEH,OAAO,EAAE,cAAc,EAAE,UAAU,EAAE,SAAS,EAAE,YAAY,EAAE,aAAa,EAAE,MAAM,SAAS,CAAC;AAC7F,OAAO,EAAE,QAAQ,EAAE,IAAI,EAAE,MAAM,WAAW,CAAC;AAQ3C,SAAS,cAAc,CAAC,OAAe;IACrC,OAAO,YAAY,OAAO;;;;;;;;;;;CAW3B,CAAC;AACF,CAAC;AAED,SAAS,cAAc,CAAC,IAAY;IAClC,IAAI,CAAC,UAAU,CAAC,IAAI,CAAC,IAAI,EAAE,MAAM,CAAC,CAAC;QAAE,OAAO;IAE5C,MAAM,aAAa,GAAG,IAAI,CAAC,IAAI,EAAE,YAAY,CAAC,CAAC;IAC/C,MAAM,QAAQ,GAAG,CAAC,4BAA4B,EAAE,kCAAkC,EAAE,+BAA+B,CAAC,CAAC;IAErH,MAAM,QAAQ,GAAG,UAAU,CAAC,aAAa,CAAC,CAAC,CAAC,CAAC,YAAY,CAAC,aAAa,EAAE,OAAO,CAAC,CAAC,CAAC,CAAC,EAAE,CAAC;IACvF,MAAM,aAAa,GAAG,IAAI,GAAG,CAAC,QAAQ,CAAC,KAAK,CAAC,IAAI,CAAC,CAAC,GAAG,CAAC,CAAC,IAAI,EAAE,EAAE,CAAC,IAAI,CAAC,IAAI,EAAE,CAAC,CAAC,CAAC;IAC/E,MAAM,OAAO,GAAG,QAAQ,CAAC,MAAM,CAAC,CAAC,CAAC,EAAE,EAAE,CAAC,CAAC,aAAa,CAAC,GAAG,CAAC,CAAC,CAAC,CAAC,CAAC;IAC9D,IAAI,OAAO,CAAC,MAAM,KAAK,CAAC;QAAE,OAAO;IAEjC,MAAM,KAAK,GACT,uEAAuE;QACvE,iEAAiE;QACjE,OAAO,CAAC,GAAG,CAAC,CAAC,CAAC,EAAE,EAAE,CAAC,GAAG,CAAC,IAAI,CAAC,CAAC,IAAI,CAAC,EAAE,CAAC,CAAC;IAExC,cAAc,CAAC,aAAa,EAAE,KAAK,CAAC,CAAC;AACvC,CAAC;AAED,MAAM,UAAU,YAAY,CAAC,IAAY;IACvC,MAAM,QAAQ,GAAG,IAAI,CAAC,IAAI,EAAE,WAAW,EAAE,OAAO,CAAC,CAAC;IAClD,MAAM,QAAQ,GAAG,IAAI,CAAC,QAAQ,EAAE,UAAU,CAAC,CAAC;IAE5C,SAAS,CAAC,QAAQ,EAAE,EAAE,SAAS,EAAE,IAAI,EAAE,CAAC,CAAC;IACzC,cAAc,CAAC,IAAI,CAAC,CAAC;IAErB,IAAI,UAAU,CAAC,QAAQ,CAAC,EAAE,CAAC;QACzB,OAAO,EAAE,IAAI,EAAE,QAAQ,EAAE,MAAM,EAAE,SAAS,EAAE,MAAM,EAAE,0CAA0C,EAAE,CAAC;IACnG,CAAC;IAED,aAAa,CAAC,QAAQ,EAAE,cAAc,CAAC,QAAQ,CAAC,IAAI,CAAC,CAAC,CAAC,CAAC;IACxD,OAAO,EAAE,IAAI,EAAE,QAAQ,EAAE,MAAM,EAAE,SAAS,EAAE,MAAM,EAAE,0BAA0B,EAAE,CAAC;AACnF,CAAC"}
|