@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.
@@ -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
@@ -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"}