@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
package/README.md
CHANGED
|
@@ -1,7 +1,52 @@
|
|
|
1
1
|
# @contextq/cli
|
|
2
2
|
|
|
3
|
-
|
|
3
|
+
One-command installer for [ContextQ](https://github.com/HENIART/contextq). It wires the ContextQ MCP server, the contextq SessionStart boot hook, and the task manifest into a project — or prints a manual connect guide when the surface is Claude Desktop or Cowork, which have no project files to write into.
|
|
4
4
|
|
|
5
|
-
|
|
5
|
+
## Install
|
|
6
6
|
|
|
7
|
-
|
|
7
|
+
```bash
|
|
8
|
+
npx @contextq/cli init
|
|
9
|
+
```
|
|
10
|
+
|
|
11
|
+
Or install it globally and call the `contextq` binary directly:
|
|
12
|
+
|
|
13
|
+
```bash
|
|
14
|
+
npm install -g @contextq/cli
|
|
15
|
+
contextq init
|
|
16
|
+
```
|
|
17
|
+
|
|
18
|
+
## `contextq init`
|
|
19
|
+
|
|
20
|
+
Detects which agent surfaces the current directory is set up for (Claude Code, Codex, or both — a repo is often both at once) and writes the wiring each one needs.
|
|
21
|
+
|
|
22
|
+
| Option | Meaning |
|
|
23
|
+
|---|---|
|
|
24
|
+
| `--key <sk_live_...>` | API key. Falls back to `CONTEXT_API_KEY` / `CTQ_API_KEY` in the environment, then to an interactive prompt. |
|
|
25
|
+
| `--url <url>` | ContextQ API URL. Defaults to `https://app.contextq.dev`; point it at your own instance when self-hosting. |
|
|
26
|
+
| `--workspace <slug>` | Workspace the boot hook reads from. Defaults to the repo basename, sanitized. Lowercase letters, digits, `-` and `_`; must start with a letter or digit; 64 chars max. Case is folded to lowercase, since workspace slugs always are. |
|
|
27
|
+
| `--agent <slug>` | Agent identity the session boots and checkpoints under. Same defaulting and same character rules as `--workspace`. |
|
|
28
|
+
| `--embed-key` | Write the literal API key into `.mcp.json` instead of the `${CONTEXT_API_KEY}` placeholder. Opt-in on purpose: it trades a secret sitting on disk for not having to export the variable. |
|
|
29
|
+
| `--surface <s>` | Force detection to exactly one of `claude-code` or `codex`, skipping autodetection. |
|
|
30
|
+
| `-h`, `--help` | Show usage. |
|
|
31
|
+
|
|
32
|
+
The API key is a ContextQ key in the form `sk_live_<40 hex>`, issued from your tenant's dashboard. Without `--embed-key`, the generated config references `${CONTEXT_API_KEY}` and the key never lands in a file.
|
|
33
|
+
|
|
34
|
+
## `contextq skills sync <dir>`
|
|
35
|
+
|
|
36
|
+
Pulls this tenant's distilled procedures — the parametrized skills ContextQ mines from repeated, independently verified agent task patterns — into `<dir>` as standard Agent Skills folders, one `SKILL.md` per procedure.
|
|
37
|
+
|
|
38
|
+
```bash
|
|
39
|
+
contextq skills sync .claude/skills
|
|
40
|
+
```
|
|
41
|
+
|
|
42
|
+
Safe to re-run. An existing folder is matched by its own `metadata.cluster_key` and rewritten in place rather than duplicated. A folder whose source context no longer resolves is reported as an orphan and left on disk untouched, so nothing you wrote by hand is ever deleted.
|
|
43
|
+
|
|
44
|
+
It accepts `--workspace`, `--key` and `--url` with the same meanings as `init`.
|
|
45
|
+
|
|
46
|
+
## Requirements
|
|
47
|
+
|
|
48
|
+
Node 18 or newer, and a reachable ContextQ instance — the hosted one at `app.contextq.dev` or your own.
|
|
49
|
+
|
|
50
|
+
## License
|
|
51
|
+
|
|
52
|
+
MIT. Source lives in the [contextq](https://github.com/HENIART/contextq) repository under `cli/`.
|
|
@@ -0,0 +1,8 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* desktop-guide.ts — the Lane 2 (remote MCP) connect guide for Claude
|
|
3
|
+
* Desktop / Cowork / any client that only reads a manually-edited config
|
|
4
|
+
* file. There is no auto-connect path for these clients (verified
|
|
5
|
+
* impossible -- they only read their config on start), so this prints the
|
|
6
|
+
* exact 3-step manual flow instead of pretending to automate it.
|
|
7
|
+
*/
|
|
8
|
+
export declare function printDesktopGuide(apiUrl: string): void;
|
|
@@ -0,0 +1,40 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* desktop-guide.ts — the Lane 2 (remote MCP) connect guide for Claude
|
|
3
|
+
* Desktop / Cowork / any client that only reads a manually-edited config
|
|
4
|
+
* file. There is no auto-connect path for these clients (verified
|
|
5
|
+
* impossible -- they only read their config on start), so this prints the
|
|
6
|
+
* exact 3-step manual flow instead of pretending to automate it.
|
|
7
|
+
*/
|
|
8
|
+
export function printDesktopGuide(apiUrl) {
|
|
9
|
+
console.log(`
|
|
10
|
+
No git repo or existing Claude Code project found here, so there is nothing on
|
|
11
|
+
disk to scaffold. For Claude Desktop, Cowork, or any other MCP-capable client
|
|
12
|
+
that reads a manually-edited config file, connect ContextQ in 3 steps:
|
|
13
|
+
|
|
14
|
+
1. Open the ContextQ dashboard -> Settings -> API keys -> create a key
|
|
15
|
+
${apiUrl}/dashboard/api-keys
|
|
16
|
+
|
|
17
|
+
2. Add this to your client's MCP config file (e.g. claude_desktop_config.json):
|
|
18
|
+
|
|
19
|
+
{
|
|
20
|
+
"mcpServers": {
|
|
21
|
+
"contextq": {
|
|
22
|
+
"command": "npx",
|
|
23
|
+
"args": ["-y", "@contextq/mcp"],
|
|
24
|
+
"env": {
|
|
25
|
+
"CONTEXT_API_URL": "${apiUrl}",
|
|
26
|
+
"CONTEXT_API_KEY": "<your key from step 1>"
|
|
27
|
+
}
|
|
28
|
+
}
|
|
29
|
+
}
|
|
30
|
+
}
|
|
31
|
+
|
|
32
|
+
3. Restart the client so it picks up the new MCP server.
|
|
33
|
+
|
|
34
|
+
There is no auto-connect for Desktop/Cowork today -- these clients only read
|
|
35
|
+
their config file on start, so step 2 has to be a manual paste. Re-run
|
|
36
|
+
"npx @contextq/cli init" inside a git repo (or an existing Claude Code project) to
|
|
37
|
+
get the automatic .mcp.json + hook + task-manifest scaffolding instead.
|
|
38
|
+
`);
|
|
39
|
+
}
|
|
40
|
+
//# sourceMappingURL=desktop-guide.js.map
|
|
@@ -0,0 +1 @@
|
|
|
1
|
+
{"version":3,"file":"desktop-guide.js","sourceRoot":"","sources":["../src/desktop-guide.ts"],"names":[],"mappings":"AAAA;;;;;;GAMG;AAEH,MAAM,UAAU,iBAAiB,CAAC,MAAc;IAC9C,OAAO,CAAC,GAAG,CAAC;;;;;;OAMP,MAAM;;;;;;;;;;mCAUsB,MAAM;;;;;;;;;;;;;CAaxC,CAAC,CAAC;AACH,CAAC"}
|
package/dist/detect.d.ts
ADDED
|
@@ -0,0 +1,42 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* detect.ts — surface detection.
|
|
3
|
+
*
|
|
4
|
+
* Claude Code lane: cwd is inside a git repo, OR a `.claude/` dir already
|
|
5
|
+
* exists at cwd -- either signal means there is a real project here the
|
|
6
|
+
* installer can safely scaffold into.
|
|
7
|
+
*
|
|
8
|
+
* Codex lane (T665): a project is treated as a Codex target when EITHER of
|
|
9
|
+
* two signals holds -- a `.codex/` dir at the repo root (covers both
|
|
10
|
+
* `.codex/config.toml` and `.codex/hooks.json`, since both live inside that
|
|
11
|
+
* one directory -- there is no separate "config at repo level" signal to
|
|
12
|
+
* check, it's the same dir), OR `~/.codex/` existing on this machine at all
|
|
13
|
+
* (Codex CLI installed here, even if this particular repo hasn't been
|
|
14
|
+
* opened with it yet -- the same breadth Claude Code's own `git repo OR
|
|
15
|
+
* .claude/` rule already uses). The write itself is low-risk (idempotent,
|
|
16
|
+
* additive-only, gitignored -- see session-hook.ts) so erring toward
|
|
17
|
+
* detecting Codex is deliberate, not an oversight.
|
|
18
|
+
*
|
|
19
|
+
* Claude Code and Codex are detected INDEPENDENTLY -- a repo can be (and
|
|
20
|
+
* commonly is) both, so `surfaces` is an array, not a single value. Only
|
|
21
|
+
* when NEITHER signal fires does detection fall back to the single
|
|
22
|
+
* `'desktop'` value (nothing on disk to scaffold -- print the manual guide).
|
|
23
|
+
*
|
|
24
|
+
* `forcedSurface` (wired from index.ts's `--surface` flag) bypasses
|
|
25
|
+
* autodetection entirely and pins exactly one surface -- e.g. `--surface
|
|
26
|
+
* codex` on a bare folder with neither a repo-level `.codex/` nor a
|
|
27
|
+
* `~/.codex/` install (a fresh CI sandbox, a machine where Codex isn't
|
|
28
|
+
* installed yet but the hooks file should still be scaffolded ahead of it).
|
|
29
|
+
*/
|
|
30
|
+
export type Surface = 'claude-code' | 'codex' | 'desktop';
|
|
31
|
+
export interface DetectOptions {
|
|
32
|
+
/** Wired from `--surface` -- when set, skip autodetection and pin exactly this one surface. */
|
|
33
|
+
forcedSurface?: Exclude<Surface, 'desktop'>;
|
|
34
|
+
/** Override for os.homedir(), tests only -- production always uses the real home dir. */
|
|
35
|
+
homeDir?: string;
|
|
36
|
+
}
|
|
37
|
+
export interface DetectResult {
|
|
38
|
+
surfaces: Surface[];
|
|
39
|
+
root: string;
|
|
40
|
+
}
|
|
41
|
+
export declare function gitRoot(cwd: string): string;
|
|
42
|
+
export declare function detectSurface(cwd: string, opts?: DetectOptions): DetectResult;
|
package/dist/detect.js
ADDED
|
@@ -0,0 +1,70 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* detect.ts — surface detection.
|
|
3
|
+
*
|
|
4
|
+
* Claude Code lane: cwd is inside a git repo, OR a `.claude/` dir already
|
|
5
|
+
* exists at cwd -- either signal means there is a real project here the
|
|
6
|
+
* installer can safely scaffold into.
|
|
7
|
+
*
|
|
8
|
+
* Codex lane (T665): a project is treated as a Codex target when EITHER of
|
|
9
|
+
* two signals holds -- a `.codex/` dir at the repo root (covers both
|
|
10
|
+
* `.codex/config.toml` and `.codex/hooks.json`, since both live inside that
|
|
11
|
+
* one directory -- there is no separate "config at repo level" signal to
|
|
12
|
+
* check, it's the same dir), OR `~/.codex/` existing on this machine at all
|
|
13
|
+
* (Codex CLI installed here, even if this particular repo hasn't been
|
|
14
|
+
* opened with it yet -- the same breadth Claude Code's own `git repo OR
|
|
15
|
+
* .claude/` rule already uses). The write itself is low-risk (idempotent,
|
|
16
|
+
* additive-only, gitignored -- see session-hook.ts) so erring toward
|
|
17
|
+
* detecting Codex is deliberate, not an oversight.
|
|
18
|
+
*
|
|
19
|
+
* Claude Code and Codex are detected INDEPENDENTLY -- a repo can be (and
|
|
20
|
+
* commonly is) both, so `surfaces` is an array, not a single value. Only
|
|
21
|
+
* when NEITHER signal fires does detection fall back to the single
|
|
22
|
+
* `'desktop'` value (nothing on disk to scaffold -- print the manual guide).
|
|
23
|
+
*
|
|
24
|
+
* `forcedSurface` (wired from index.ts's `--surface` flag) bypasses
|
|
25
|
+
* autodetection entirely and pins exactly one surface -- e.g. `--surface
|
|
26
|
+
* codex` on a bare folder with neither a repo-level `.codex/` nor a
|
|
27
|
+
* `~/.codex/` install (a fresh CI sandbox, a machine where Codex isn't
|
|
28
|
+
* installed yet but the hooks file should still be scaffolded ahead of it).
|
|
29
|
+
*/
|
|
30
|
+
import { existsSync } from 'node:fs';
|
|
31
|
+
import { execSync } from 'node:child_process';
|
|
32
|
+
import { homedir } from 'node:os';
|
|
33
|
+
import { join } from 'node:path';
|
|
34
|
+
export function gitRoot(cwd) {
|
|
35
|
+
try {
|
|
36
|
+
return execSync('git rev-parse --show-toplevel', {
|
|
37
|
+
cwd,
|
|
38
|
+
stdio: ['ignore', 'pipe', 'ignore'],
|
|
39
|
+
encoding: 'utf-8',
|
|
40
|
+
}).trim();
|
|
41
|
+
}
|
|
42
|
+
catch {
|
|
43
|
+
return '';
|
|
44
|
+
}
|
|
45
|
+
}
|
|
46
|
+
function hasCodexProjectDir(root) {
|
|
47
|
+
return existsSync(join(root, '.codex'));
|
|
48
|
+
}
|
|
49
|
+
function hasCodexHome(homeDir) {
|
|
50
|
+
return existsSync(join(homeDir, '.codex'));
|
|
51
|
+
}
|
|
52
|
+
export function detectSurface(cwd, opts = {}) {
|
|
53
|
+
const gitRootPath = gitRoot(cwd);
|
|
54
|
+
const root = gitRootPath || cwd;
|
|
55
|
+
if (opts.forcedSurface) {
|
|
56
|
+
return { surfaces: [opts.forcedSurface], root };
|
|
57
|
+
}
|
|
58
|
+
const hasClaudeDir = existsSync(join(cwd, '.claude'));
|
|
59
|
+
const claudeCode = Boolean(gitRootPath || hasClaudeDir);
|
|
60
|
+
const codex = hasCodexProjectDir(root) || hasCodexHome(opts.homeDir ?? homedir());
|
|
61
|
+
const surfaces = [];
|
|
62
|
+
if (claudeCode)
|
|
63
|
+
surfaces.push('claude-code');
|
|
64
|
+
if (codex)
|
|
65
|
+
surfaces.push('codex');
|
|
66
|
+
if (surfaces.length === 0)
|
|
67
|
+
surfaces.push('desktop');
|
|
68
|
+
return { surfaces, root: claudeCode || codex ? root : cwd };
|
|
69
|
+
}
|
|
70
|
+
//# sourceMappingURL=detect.js.map
|
|
@@ -0,0 +1 @@
|
|
|
1
|
+
{"version":3,"file":"detect.js","sourceRoot":"","sources":["../src/detect.ts"],"names":[],"mappings":"AAAA;;;;;;;;;;;;;;;;;;;;;;;;;;;;GA4BG;AAEH,OAAO,EAAE,UAAU,EAAE,MAAM,SAAS,CAAC;AACrC,OAAO,EAAE,QAAQ,EAAE,MAAM,oBAAoB,CAAC;AAC9C,OAAO,EAAE,OAAO,EAAE,MAAM,SAAS,CAAC;AAClC,OAAO,EAAE,IAAI,EAAE,MAAM,WAAW,CAAC;AAgBjC,MAAM,UAAU,OAAO,CAAC,GAAW;IACjC,IAAI,CAAC;QACH,OAAO,QAAQ,CAAC,+BAA+B,EAAE;YAC/C,GAAG;YACH,KAAK,EAAE,CAAC,QAAQ,EAAE,MAAM,EAAE,QAAQ,CAAC;YACnC,QAAQ,EAAE,OAAO;SAClB,CAAC,CAAC,IAAI,EAAE,CAAC;IACZ,CAAC;IAAC,MAAM,CAAC;QACP,OAAO,EAAE,CAAC;IACZ,CAAC;AACH,CAAC;AAED,SAAS,kBAAkB,CAAC,IAAY;IACtC,OAAO,UAAU,CAAC,IAAI,CAAC,IAAI,EAAE,QAAQ,CAAC,CAAC,CAAC;AAC1C,CAAC;AAED,SAAS,YAAY,CAAC,OAAe;IACnC,OAAO,UAAU,CAAC,IAAI,CAAC,OAAO,EAAE,QAAQ,CAAC,CAAC,CAAC;AAC7C,CAAC;AAED,MAAM,UAAU,aAAa,CAAC,GAAW,EAAE,OAAsB,EAAE;IACjE,MAAM,WAAW,GAAG,OAAO,CAAC,GAAG,CAAC,CAAC;IACjC,MAAM,IAAI,GAAG,WAAW,IAAI,GAAG,CAAC;IAEhC,IAAI,IAAI,CAAC,aAAa,EAAE,CAAC;QACvB,OAAO,EAAE,QAAQ,EAAE,CAAC,IAAI,CAAC,aAAa,CAAC,EAAE,IAAI,EAAE,CAAC;IAClD,CAAC;IAED,MAAM,YAAY,GAAG,UAAU,CAAC,IAAI,CAAC,GAAG,EAAE,SAAS,CAAC,CAAC,CAAC;IACtD,MAAM,UAAU,GAAG,OAAO,CAAC,WAAW,IAAI,YAAY,CAAC,CAAC;IACxD,MAAM,KAAK,GAAG,kBAAkB,CAAC,IAAI,CAAC,IAAI,YAAY,CAAC,IAAI,CAAC,OAAO,IAAI,OAAO,EAAE,CAAC,CAAC;IAElF,MAAM,QAAQ,GAAc,EAAE,CAAC;IAC/B,IAAI,UAAU;QAAE,QAAQ,CAAC,IAAI,CAAC,aAAa,CAAC,CAAC;IAC7C,IAAI,KAAK;QAAE,QAAQ,CAAC,IAAI,CAAC,OAAO,CAAC,CAAC;IAClC,IAAI,QAAQ,CAAC,MAAM,KAAK,CAAC;QAAE,QAAQ,CAAC,IAAI,CAAC,SAAS,CAAC,CAAC;IAEpD,OAAO,EAAE,QAAQ,EAAE,IAAI,EAAE,UAAU,IAAI,KAAK,CAAC,CAAC,CAAC,IAAI,CAAC,CAAC,CAAC,GAAG,EAAE,CAAC;AAC9D,CAAC"}
|
package/dist/index.d.ts
ADDED
|
@@ -0,0 +1,17 @@
|
|
|
1
|
+
#!/usr/bin/env node
|
|
2
|
+
/**
|
|
3
|
+
* contextq — one-command installer.
|
|
4
|
+
*
|
|
5
|
+
* `npx @contextq/cli init` detects which surface(s) are present -- Claude Code
|
|
6
|
+
* project, Codex CLI project (T665), or neither (Desktop/Cowork) -- and
|
|
7
|
+
* scaffolds the matching wiring: for Claude Code, .mcp.json + the contextq
|
|
8
|
+
* SessionStart hook + .contextq/tasks/; for Codex, a `.codex/hooks.json`
|
|
9
|
+
* SessionStart hook (see cli/src/session-hook.ts for why the two harnesses
|
|
10
|
+
* share one command builder and one merge implementation). A repo can be
|
|
11
|
+
* (and often is) both at once -- both get wired in the same run unless
|
|
12
|
+
* `--surface` pins exactly one. Neither detected -> print the manual
|
|
13
|
+
* 3-step connect guide instead. Idempotent throughout: re-running reports
|
|
14
|
+
* "already configured" / "[skip]" for anything already wired and writes
|
|
15
|
+
* nothing new.
|
|
16
|
+
*/
|
|
17
|
+
export {};
|
package/dist/index.js
ADDED
|
@@ -0,0 +1,421 @@
|
|
|
1
|
+
#!/usr/bin/env node
|
|
2
|
+
/**
|
|
3
|
+
* contextq — one-command installer.
|
|
4
|
+
*
|
|
5
|
+
* `npx @contextq/cli init` detects which surface(s) are present -- Claude Code
|
|
6
|
+
* project, Codex CLI project (T665), or neither (Desktop/Cowork) -- and
|
|
7
|
+
* scaffolds the matching wiring: for Claude Code, .mcp.json + the contextq
|
|
8
|
+
* SessionStart hook + .contextq/tasks/; for Codex, a `.codex/hooks.json`
|
|
9
|
+
* SessionStart hook (see cli/src/session-hook.ts for why the two harnesses
|
|
10
|
+
* share one command builder and one merge implementation). A repo can be
|
|
11
|
+
* (and often is) both at once -- both get wired in the same run unless
|
|
12
|
+
* `--surface` pins exactly one. Neither detected -> print the manual
|
|
13
|
+
* 3-step connect guide instead. Idempotent throughout: re-running reports
|
|
14
|
+
* "already configured" / "[skip]" for anything already wired and writes
|
|
15
|
+
* nothing new.
|
|
16
|
+
*/
|
|
17
|
+
import { basename } from 'node:path';
|
|
18
|
+
import { detectSurface } from './detect.js';
|
|
19
|
+
import { decideCredentialWrite, KEY_RE, readExistingCreds } from './platform-cred.js';
|
|
20
|
+
import { wireMcpJson } from './mcp-config.js';
|
|
21
|
+
import { wireCodexSessionHook, wireSessionHook } from './session-hook.js';
|
|
22
|
+
import { initTasksDir } from './tasks-init.js';
|
|
23
|
+
import { printDesktopGuide } from './desktop-guide.js';
|
|
24
|
+
import { promptHidden } from './prompt.js';
|
|
25
|
+
import { SLUG_PATTERN_DESCRIPTION, sanitizeSlugFallback, validateSlug } from './slug.js';
|
|
26
|
+
import { syncSkills } from './skills-sync.js';
|
|
27
|
+
const DEFAULT_URL = 'https://app.contextq.dev';
|
|
28
|
+
function parseArgs(argv) {
|
|
29
|
+
const hasLeadingCommand = argv.length > 0 && !argv[0].startsWith('-');
|
|
30
|
+
const args = { command: hasLeadingCommand ? argv[0] : '', embedKey: false, help: false };
|
|
31
|
+
for (let i = hasLeadingCommand ? 1 : 0; i < argv.length; i++) {
|
|
32
|
+
const a = argv[i];
|
|
33
|
+
switch (a) {
|
|
34
|
+
case '--key':
|
|
35
|
+
args.key = argv[++i];
|
|
36
|
+
break;
|
|
37
|
+
case '--url':
|
|
38
|
+
args.url = argv[++i];
|
|
39
|
+
break;
|
|
40
|
+
case '--workspace':
|
|
41
|
+
args.workspace = argv[++i];
|
|
42
|
+
break;
|
|
43
|
+
case '--agent':
|
|
44
|
+
args.agent = argv[++i];
|
|
45
|
+
break;
|
|
46
|
+
case '--embed-key':
|
|
47
|
+
args.embedKey = true;
|
|
48
|
+
break;
|
|
49
|
+
case '--surface': {
|
|
50
|
+
const v = argv[++i];
|
|
51
|
+
if (v !== 'claude-code' && v !== 'codex') {
|
|
52
|
+
throw new Error(`--surface value "${v}" is invalid -- allowed: claude-code, codex`);
|
|
53
|
+
}
|
|
54
|
+
args.surface = v;
|
|
55
|
+
break;
|
|
56
|
+
}
|
|
57
|
+
case '-h':
|
|
58
|
+
case '--help':
|
|
59
|
+
args.help = true;
|
|
60
|
+
break;
|
|
61
|
+
default:
|
|
62
|
+
throw new Error(`unknown argument: ${a}`);
|
|
63
|
+
}
|
|
64
|
+
}
|
|
65
|
+
return args;
|
|
66
|
+
}
|
|
67
|
+
function printHelp() {
|
|
68
|
+
console.log(`contextq — one-command installer for ContextQ
|
|
69
|
+
|
|
70
|
+
Usage:
|
|
71
|
+
npx @contextq/cli init [options]
|
|
72
|
+
|
|
73
|
+
Options:
|
|
74
|
+
--key <sk_live_...> API key (else CONTEXT_API_KEY/CTQ_API_KEY env, else prompt)
|
|
75
|
+
--url <url> ContextQ API URL (default: ${DEFAULT_URL})
|
|
76
|
+
--workspace <slug> Workspace slug for the contextq boot hook (default: repo basename,
|
|
77
|
+
sanitized). Allowed: ${SLUG_PATTERN_DESCRIPTION}. Case is folded to
|
|
78
|
+
lowercase automatically (workspace slugs are always lowercase).
|
|
79
|
+
--agent <slug> Agent slug for the contextq boot hook (default: repo basename,
|
|
80
|
+
sanitized). Allowed: ${SLUG_PATTERN_DESCRIPTION}.
|
|
81
|
+
--embed-key Write the literal API key into .mcp.json instead of the
|
|
82
|
+
safer \${CONTEXT_API_KEY} placeholder (opt-in: convenience
|
|
83
|
+
vs. a secret sitting on disk)
|
|
84
|
+
--surface <s> Force surface detection to exactly one of: claude-code, codex.
|
|
85
|
+
Skips autodetection entirely (default: autodetect both --
|
|
86
|
+
a repo can be, and often is, both at once).
|
|
87
|
+
-h, --help Show this help
|
|
88
|
+
|
|
89
|
+
Usage:
|
|
90
|
+
npx @contextq/cli skills sync <dir> [options]
|
|
91
|
+
|
|
92
|
+
Pulls this tenant's distilled procedures (T376) as standard Agent Skills
|
|
93
|
+
folders (<dir>/<slug>/SKILL.md). Safe to re-run: an existing folder is
|
|
94
|
+
matched by its own metadata.cluster_key and rewritten in place, never
|
|
95
|
+
duplicated; a folder whose context no longer resolves is reported as an
|
|
96
|
+
orphan and left on disk untouched.
|
|
97
|
+
|
|
98
|
+
Options:
|
|
99
|
+
--workspace <slug> Limit to one workspace's procedures (default: every
|
|
100
|
+
workspace visible to the tenant)
|
|
101
|
+
--key <sk_live_...> API key (else CONTEXT_API_KEY/CTQ_API_KEY env, else the
|
|
102
|
+
credential file written by \`contextq init\`)
|
|
103
|
+
--url <url> ContextQ API URL (else the credential file, else
|
|
104
|
+
CONTEXT_API_URL/CTQ_API_URL env, default: ${DEFAULT_URL})
|
|
105
|
+
`);
|
|
106
|
+
}
|
|
107
|
+
function relative(root, path) {
|
|
108
|
+
return path.startsWith(`${root}/`) ? path.slice(root.length + 1) : path;
|
|
109
|
+
}
|
|
110
|
+
function label(action) {
|
|
111
|
+
const known = {
|
|
112
|
+
created: '[create]',
|
|
113
|
+
added: '[add] ',
|
|
114
|
+
wired: '[create]',
|
|
115
|
+
rotated: '[rotate]',
|
|
116
|
+
skipped: '[skip] ',
|
|
117
|
+
verified: '[check]',
|
|
118
|
+
failed: '[check]',
|
|
119
|
+
};
|
|
120
|
+
return known[action] ?? `[${action}]`;
|
|
121
|
+
}
|
|
122
|
+
/**
|
|
123
|
+
* T474: the installer used to wire .mcp.json / the SessionStart hook / the
|
|
124
|
+
* task manifest and declare success without ever confirming the key + URL it
|
|
125
|
+
* just wrote actually authenticate against the server. Ping a cheap
|
|
126
|
+
* read-scoped endpoint so a typo'd key or URL fails loudly here instead of
|
|
127
|
+
* surfacing later as "the MCP tools aren't showing up".
|
|
128
|
+
*/
|
|
129
|
+
async function verifyConnection(apiUrl, apiKey) {
|
|
130
|
+
try {
|
|
131
|
+
const res = await fetch(`${apiUrl.replace(/\/$/, '')}/api/workspaces`, {
|
|
132
|
+
headers: { Authorization: `Bearer ${apiKey}` },
|
|
133
|
+
});
|
|
134
|
+
return { ok: res.status === 200, status: res.status };
|
|
135
|
+
}
|
|
136
|
+
catch (err) {
|
|
137
|
+
return { ok: false, error: err.message };
|
|
138
|
+
}
|
|
139
|
+
}
|
|
140
|
+
async function runInit(args) {
|
|
141
|
+
const cwd = process.cwd();
|
|
142
|
+
const { surfaces, root } = detectSurface(cwd, { forcedSurface: args.surface });
|
|
143
|
+
console.log('ContextQ init');
|
|
144
|
+
console.log('=============');
|
|
145
|
+
if (surfaces.length === 1 && surfaces[0] === 'desktop') {
|
|
146
|
+
console.log(`Surface detected: desktop/other (no git repo, .claude/, or .codex/ found at ${cwd})`);
|
|
147
|
+
printDesktopGuide(args.url ?? DEFAULT_URL);
|
|
148
|
+
return;
|
|
149
|
+
}
|
|
150
|
+
console.log(`Surface(s) detected: ${surfaces.join(', ')} (root: ${root})`);
|
|
151
|
+
const existing = readExistingCreds();
|
|
152
|
+
const apiUrl = args.url || existing.apiUrl || process.env.CONTEXT_API_URL || DEFAULT_URL;
|
|
153
|
+
// T676: a demo run with only an AMBIENT env var set (direnv-exported
|
|
154
|
+
// CONTEXT_API_KEY from some repo's .env.local, say) used to be treated
|
|
155
|
+
// exactly like an explicit --key, silently overwriting a pre-existing
|
|
156
|
+
// machine-wide credential file with no backup. decideCredentialWrite()
|
|
157
|
+
// (platform-cred.ts) now requires --key for rotation, backs up before any
|
|
158
|
+
// real overwrite, and no-ops when nothing would change -- see its doc
|
|
159
|
+
// comment for the full branch table.
|
|
160
|
+
const ambientVarName = process.env.CONTEXT_API_KEY
|
|
161
|
+
? 'CONTEXT_API_KEY'
|
|
162
|
+
: process.env.CTQ_API_KEY
|
|
163
|
+
? 'CTQ_API_KEY'
|
|
164
|
+
: null;
|
|
165
|
+
const ambientKey = ambientVarName ? process.env[ambientVarName] : '';
|
|
166
|
+
let decision = decideCredentialWrite({ explicitKey: args.key, ambientVarName, ambientKey, apiUrl });
|
|
167
|
+
if (decision.action === null) {
|
|
168
|
+
// No --key, no ambient env var, and no existing file -- the only case
|
|
169
|
+
// decideCredentialWrite() can't resolve on its own.
|
|
170
|
+
const typed = await promptHidden('ContextQ API key (sk_live_...): ');
|
|
171
|
+
if (!KEY_RE.test(typed)) {
|
|
172
|
+
throw new Error('no valid API key available (expected sk_live_<40 hex chars>). Pass --key, set ' +
|
|
173
|
+
'CONTEXT_API_KEY, or re-run from an interactive terminal.');
|
|
174
|
+
}
|
|
175
|
+
decision = decideCredentialWrite({ explicitKey: typed, ambientVarName: null, ambientKey: '', apiUrl });
|
|
176
|
+
}
|
|
177
|
+
const apiKey = decision.apiKey;
|
|
178
|
+
console.log(decision.credLine);
|
|
179
|
+
// T656: --agent/--workspace (and the basename(root) fallback) end up
|
|
180
|
+
// interpolated into a shell command written to the SessionStart hook (see
|
|
181
|
+
// wireSessionHook below) -- validate/sanitize BEFORE that, on top of the
|
|
182
|
+
// shell-quoting session-hook.ts itself applies as defense in depth. An
|
|
183
|
+
// explicit flag value that doesn't fit the slug pattern is a hard error
|
|
184
|
+
// (no silent truncation); the basename(root) fallback is sanitized
|
|
185
|
+
// instead, since the user never typed it.
|
|
186
|
+
let agent;
|
|
187
|
+
if (args.agent) {
|
|
188
|
+
agent = validateSlug(args.agent, '--agent');
|
|
189
|
+
}
|
|
190
|
+
else {
|
|
191
|
+
agent = sanitizeSlugFallback(basename(root));
|
|
192
|
+
}
|
|
193
|
+
let workspace;
|
|
194
|
+
if (args.workspace) {
|
|
195
|
+
// Workspace slugs are always lowercase in this product (CLAUDE.md) --
|
|
196
|
+
// folding case is a documented convention, not a silent rewrite, so an
|
|
197
|
+
// explicit --workspace that differs from the API only by case is
|
|
198
|
+
// lowercased and then validated (never lowercased AWAY a genuine
|
|
199
|
+
// charset violation). --agent gets no such courtesy: it has no
|
|
200
|
+
// documented lowercase-only convention, so an explicit --agent value
|
|
201
|
+
// is validated as typed.
|
|
202
|
+
const lowered = args.workspace.toLowerCase();
|
|
203
|
+
workspace = validateSlug(lowered, '--workspace');
|
|
204
|
+
if (lowered !== args.workspace) {
|
|
205
|
+
console.log(`[info] --workspace lowercased to match slug convention: "${args.workspace}" -> "${lowered}"`);
|
|
206
|
+
}
|
|
207
|
+
}
|
|
208
|
+
else {
|
|
209
|
+
workspace = sanitizeSlugFallback(basename(root));
|
|
210
|
+
}
|
|
211
|
+
console.log(`[info] agent=${agent} workspace=${workspace}`);
|
|
212
|
+
let mcpResult;
|
|
213
|
+
if (surfaces.includes('claude-code')) {
|
|
214
|
+
mcpResult = wireMcpJson(root, apiUrl, args.embedKey, apiKey);
|
|
215
|
+
console.log(`${label(mcpResult.action)} ${relative(root, mcpResult.path)} (${mcpResult.detail})`);
|
|
216
|
+
const hookResult = wireSessionHook(root, agent, workspace);
|
|
217
|
+
console.log(`${label(hookResult.action)} ${relative(root, hookResult.path)} (${hookResult.detail})`);
|
|
218
|
+
const tasksResult = initTasksDir(root);
|
|
219
|
+
console.log(`${label(tasksResult.action)} ${relative(root, tasksResult.path)} (${tasksResult.detail})`);
|
|
220
|
+
}
|
|
221
|
+
if (surfaces.includes('codex')) {
|
|
222
|
+
console.log('');
|
|
223
|
+
console.log('[info] Codex hooks -- assumptions this install makes:');
|
|
224
|
+
console.log(' - Sandbox/approval: the boot hook makes an outbound HTTP call to the ContextQ');
|
|
225
|
+
console.log(' API. Verified empirically (codex-cli 0.146.0, `codex exec --sandbox');
|
|
226
|
+
console.log(' workspace-write`, no approval flag): a TRUSTED hook\'s outbound call is NOT');
|
|
227
|
+
console.log(' gated by sandbox_mode/approval_policy the way model-issued shell commands');
|
|
228
|
+
console.log(' are -- the real gate is hook TRUST (see the note below), not the sandbox.');
|
|
229
|
+
console.log(' Once trusted, this should work under any sandbox_mode/approval_policy combo.');
|
|
230
|
+
console.log(' If it still produces no injected context, `codex doctor` shows current sandbox');
|
|
231
|
+
console.log(' + approval state as a starting point for diagnosis.');
|
|
232
|
+
console.log(' - Native Memories: Codex ships its own Memories feature (off by default). If you');
|
|
233
|
+
console.log(' turn it on, set memories.disable_on_external_context = true in config.toml (or');
|
|
234
|
+
console.log(' its older alias no_memories_if_mcp_or_web_search) -- otherwise a session that');
|
|
235
|
+
console.log(' used MCP tools (including ContextQ\'s own MCP server) still feeds Codex\'s native');
|
|
236
|
+
console.log(' memory extraction too, duplicating what ContextQ already captures.');
|
|
237
|
+
console.log('');
|
|
238
|
+
const codexHookResult = wireCodexSessionHook(root, agent, workspace);
|
|
239
|
+
console.log(`${label(codexHookResult.action)} ${relative(root, codexHookResult.path)} (${codexHookResult.detail})`);
|
|
240
|
+
}
|
|
241
|
+
const verifyResult = await verifyConnection(apiUrl, apiKey);
|
|
242
|
+
if (verifyResult.ok) {
|
|
243
|
+
console.log(`${label('verified')} connection to ${apiUrl} (GET /api/workspaces -> 200)`);
|
|
244
|
+
}
|
|
245
|
+
else {
|
|
246
|
+
const detail = verifyResult.status ? `HTTP ${verifyResult.status}` : verifyResult.error ?? 'unknown error';
|
|
247
|
+
console.log(`${label('failed')} connection to ${apiUrl} (${detail}) -- check your API key and --url`);
|
|
248
|
+
if (verifyResult.status === 401 || verifyResult.status === 403) {
|
|
249
|
+
process.exitCode = 1;
|
|
250
|
+
}
|
|
251
|
+
}
|
|
252
|
+
if (mcpResult && !args.embedKey && mcpResult.action !== 'skipped') {
|
|
253
|
+
console.log('');
|
|
254
|
+
console.log('NOTE: .mcp.json references ${CONTEXT_API_KEY} -- Claude Code expands that from');
|
|
255
|
+
console.log('its OWN process environment, not from the credential file just written.');
|
|
256
|
+
console.log('Export it in your shell profile before starting Claude Code:');
|
|
257
|
+
console.log(' export CONTEXT_API_KEY=<your key>');
|
|
258
|
+
console.log('...or re-run with --embed-key to write the literal key into .mcp.json instead.');
|
|
259
|
+
}
|
|
260
|
+
console.log('');
|
|
261
|
+
const doneSurfaces = surfaces.filter((s) => s !== 'desktop');
|
|
262
|
+
const doneNote = doneSurfaces
|
|
263
|
+
.map((s) => (s === 'claude-code' ? 'a new Claude Code session' : 'a new `codex` run'))
|
|
264
|
+
.join(' / start ');
|
|
265
|
+
console.log(`Done. Open ${doneNote} in this repo to pick up the wiring.`);
|
|
266
|
+
// Claude Code 2.1.268+ can install the same MCP server as a plugin, which
|
|
267
|
+
// collects the API key through the manifest's userConfig (secure storage,
|
|
268
|
+
// never settings.json) instead of needing CONTEXT_API_KEY exported into the
|
|
269
|
+
// shell. This installer still exists for Codex, Desktop, CI, and the
|
|
270
|
+
// contextq SessionStart hook, none of which the plugin covers -- so point
|
|
271
|
+
// at it rather than replacing this path.
|
|
272
|
+
if (surfaces.includes('claude-code')) {
|
|
273
|
+
console.log('');
|
|
274
|
+
console.log('Claude Code 2.1.268+ can install the MCP half of this as a plugin instead,');
|
|
275
|
+
console.log('with no CONTEXT_API_KEY to export:');
|
|
276
|
+
console.log(' claude plugin marketplace add https://app.contextq.dev/plugin/marketplace.json');
|
|
277
|
+
console.log(' claude plugin install contextq@contextq-plugins --config apiKey=<your key>');
|
|
278
|
+
console.log('See docs/mcp-setup.md. This installer stays the path for Codex and CI.');
|
|
279
|
+
}
|
|
280
|
+
}
|
|
281
|
+
/**
|
|
282
|
+
* Separate from parseArgs() on purpose: `skills sync <dir>` has a
|
|
283
|
+
* subcommand + a required positional argument, a shape parseArgs()'s
|
|
284
|
+
* single-command-token model doesn't fit. Dispatched from main() before
|
|
285
|
+
* parseArgs() ever sees the argv.
|
|
286
|
+
*/
|
|
287
|
+
function parseSkillsSyncArgs(argv) {
|
|
288
|
+
const positional = [];
|
|
289
|
+
const args = {};
|
|
290
|
+
for (let i = 0; i < argv.length; i++) {
|
|
291
|
+
const a = argv[i];
|
|
292
|
+
switch (a) {
|
|
293
|
+
case '--workspace':
|
|
294
|
+
args.workspace = argv[++i];
|
|
295
|
+
break;
|
|
296
|
+
case '--key':
|
|
297
|
+
args.key = argv[++i];
|
|
298
|
+
break;
|
|
299
|
+
case '--url':
|
|
300
|
+
args.url = argv[++i];
|
|
301
|
+
break;
|
|
302
|
+
default:
|
|
303
|
+
if (a.startsWith('-')) {
|
|
304
|
+
throw new Error(`unknown argument: ${a}`);
|
|
305
|
+
}
|
|
306
|
+
positional.push(a);
|
|
307
|
+
}
|
|
308
|
+
}
|
|
309
|
+
if (positional.length === 0) {
|
|
310
|
+
throw new Error('missing <dir> argument -- usage: contextq skills sync <dir>');
|
|
311
|
+
}
|
|
312
|
+
if (positional.length > 1) {
|
|
313
|
+
throw new Error(`unexpected extra argument: ${positional[1]}`);
|
|
314
|
+
}
|
|
315
|
+
return { ...args, dir: positional[0] };
|
|
316
|
+
}
|
|
317
|
+
/**
|
|
318
|
+
* Resolve credentials the SAME way `contextq init`'s verifyConnection() call
|
|
319
|
+
* does (explicit flag > ambient env var > the credential file `init`
|
|
320
|
+
* already wrote) but read-only -- `skills sync` never writes credentials,
|
|
321
|
+
* it only needs something to authenticate with.
|
|
322
|
+
*/
|
|
323
|
+
function resolveApiCreds(args) {
|
|
324
|
+
const existing = readExistingCreds();
|
|
325
|
+
const apiUrl = args.url || existing.apiUrl || process.env.CONTEXT_API_URL || process.env.CTQ_API_URL || DEFAULT_URL;
|
|
326
|
+
const apiKey = args.key || process.env.CONTEXT_API_KEY || process.env.CTQ_API_KEY || existing.apiKey;
|
|
327
|
+
if (!apiKey) {
|
|
328
|
+
throw new Error('no API key available -- pass --key, set CONTEXT_API_KEY, or run `contextq init` first to write the credential file');
|
|
329
|
+
}
|
|
330
|
+
return { apiUrl, apiKey };
|
|
331
|
+
}
|
|
332
|
+
/**
|
|
333
|
+
* `format=json` (not the default `zip`) so this never needs an unzip
|
|
334
|
+
* dependency in the published `contextq` npm package -- the bulk export
|
|
335
|
+
* route (src/routes/agent.ts) supports both from the same query.
|
|
336
|
+
*/
|
|
337
|
+
async function fetchSkillsFromApi(apiUrl, apiKey, workspace) {
|
|
338
|
+
const url = new URL('/api/agent/skills/export', apiUrl.replace(/\/$/, '') + '/');
|
|
339
|
+
url.searchParams.set('format', 'json');
|
|
340
|
+
if (workspace)
|
|
341
|
+
url.searchParams.set('workspace', workspace);
|
|
342
|
+
const res = await fetch(url, { headers: { Authorization: `Bearer ${apiKey}` } });
|
|
343
|
+
if (!res.ok) {
|
|
344
|
+
throw new Error(`GET ${url.pathname}${url.search} -> HTTP ${res.status}`);
|
|
345
|
+
}
|
|
346
|
+
const body = (await res.json());
|
|
347
|
+
return body.skills;
|
|
348
|
+
}
|
|
349
|
+
async function runSkillsSync(args) {
|
|
350
|
+
console.log('contextq skills sync');
|
|
351
|
+
console.log('=====================');
|
|
352
|
+
const { apiUrl, apiKey } = resolveApiCreds(args);
|
|
353
|
+
console.log(`[info] syncing to ${args.dir}${args.workspace ? ` (workspace: ${args.workspace})` : ''}`);
|
|
354
|
+
const result = await syncSkills({
|
|
355
|
+
dir: args.dir,
|
|
356
|
+
fetchSkills: () => fetchSkillsFromApi(apiUrl, apiKey, args.workspace),
|
|
357
|
+
});
|
|
358
|
+
for (const f of result.written)
|
|
359
|
+
console.log(`${label('created')} ${f}/SKILL.md`);
|
|
360
|
+
for (const f of result.updated)
|
|
361
|
+
console.log(`${label('added')} ${f}/SKILL.md (rewritten in place)`);
|
|
362
|
+
console.log(`[info] ${result.written.length} written, ${result.updated.length} updated, ${result.unchanged.length} unchanged`);
|
|
363
|
+
if (result.orphans.length > 0) {
|
|
364
|
+
console.log(`[warn] ${result.orphans.length} orphaned folder(s) -- left on disk, not deleted:`);
|
|
365
|
+
for (const f of result.orphans)
|
|
366
|
+
console.log(` ${f}/ (its context no longer resolves)`);
|
|
367
|
+
}
|
|
368
|
+
}
|
|
369
|
+
async function main() {
|
|
370
|
+
const argv = process.argv.slice(2);
|
|
371
|
+
if (argv[0] === 'skills') {
|
|
372
|
+
if (argv[1] === '-h' || argv[1] === '--help') {
|
|
373
|
+
printHelp();
|
|
374
|
+
return;
|
|
375
|
+
}
|
|
376
|
+
if (argv[1] !== 'sync') {
|
|
377
|
+
console.error(`contextq: unknown "skills" subcommand "${argv[1] ?? ''}" -- expected "sync"`);
|
|
378
|
+
printHelp();
|
|
379
|
+
process.exitCode = 1;
|
|
380
|
+
return;
|
|
381
|
+
}
|
|
382
|
+
try {
|
|
383
|
+
const skillsArgs = parseSkillsSyncArgs(argv.slice(2));
|
|
384
|
+
await runSkillsSync(skillsArgs);
|
|
385
|
+
}
|
|
386
|
+
catch (err) {
|
|
387
|
+
console.error(`contextq: ${err.message}`);
|
|
388
|
+
process.exitCode = 1;
|
|
389
|
+
}
|
|
390
|
+
return;
|
|
391
|
+
}
|
|
392
|
+
let args;
|
|
393
|
+
try {
|
|
394
|
+
args = parseArgs(argv);
|
|
395
|
+
}
|
|
396
|
+
catch (err) {
|
|
397
|
+
console.error(`contextq: ${err.message}`);
|
|
398
|
+
printHelp();
|
|
399
|
+
process.exitCode = 1;
|
|
400
|
+
return;
|
|
401
|
+
}
|
|
402
|
+
if (args.help || !args.command) {
|
|
403
|
+
printHelp();
|
|
404
|
+
return;
|
|
405
|
+
}
|
|
406
|
+
if (args.command !== 'init') {
|
|
407
|
+
console.error(`contextq: unknown command "${args.command}"`);
|
|
408
|
+
printHelp();
|
|
409
|
+
process.exitCode = 1;
|
|
410
|
+
return;
|
|
411
|
+
}
|
|
412
|
+
try {
|
|
413
|
+
await runInit(args);
|
|
414
|
+
}
|
|
415
|
+
catch (err) {
|
|
416
|
+
console.error(`contextq: ${err.message}`);
|
|
417
|
+
process.exitCode = 1;
|
|
418
|
+
}
|
|
419
|
+
}
|
|
420
|
+
main();
|
|
421
|
+
//# sourceMappingURL=index.js.map
|