@ghl-ai/aw 0.1.44-beta.1 → 0.1.44-beta.11

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,142 @@
1
+ /**
2
+ * c4/claudePluginRegistry.mjs — Claude marketplace + plugin enable + Skill
3
+ * permission registration.
4
+ *
5
+ * Three settings.json mutations driven by `ensureClaudeMarketplace`:
6
+ *
7
+ * 1. extraKnownMarketplaces["aw-marketplace"] = {
8
+ * source: { source: "directory", path: <eccDir> } // NESTED, easy to get wrong
9
+ * }
10
+ * Pilot empirically established the nested source.source shape; a flat
11
+ * `source: "directory"` does not register the marketplace.
12
+ *
13
+ * 2. enabledPlugins["aw@aw-marketplace"] = true
14
+ * Activates the plugin (without this Claude knows about the marketplace
15
+ * but does not surface its commands/skills).
16
+ *
17
+ * 3. permissions.allow includes "Skill"
18
+ * Without this Claude refuses to dispatch the Skill tool. Append-only
19
+ * to preserve user-authored entries; idempotent (no duplicates, no
20
+ * reordering).
21
+ *
22
+ * Atomic write + 0600 mode. Returns { changed: false } when every mutation
23
+ * is a no-op.
24
+ *
25
+ * Contract: spec.md::§"c4/claudePluginRegistry.mjs", tasks.md::3.4b.
26
+ */
27
+
28
+ import {
29
+ existsSync,
30
+ readFileSync,
31
+ writeFileSync,
32
+ renameSync,
33
+ chmodSync,
34
+ mkdirSync,
35
+ unlinkSync,
36
+ } from 'node:fs';
37
+ import { dirname, basename, join } from 'node:path';
38
+
39
+ const FILE_MODE = 0o600;
40
+ const MARKETPLACE_NAME = 'aw-marketplace';
41
+ const PLUGIN_KEY = 'aw@aw-marketplace';
42
+ const SKILL_PERMISSION = 'Skill';
43
+
44
+ function ensureDir(d) {
45
+ mkdirSync(d, { recursive: true });
46
+ }
47
+
48
+ function readJsonOrEmpty(filePath) {
49
+ if (!existsSync(filePath)) return {};
50
+ const raw = readFileSync(filePath, 'utf8');
51
+ if (raw.trim() === '') return {};
52
+ try {
53
+ return JSON.parse(raw);
54
+ } catch (err) {
55
+ throw new Error(`Failed to parse JSON at ${filePath}: ${err.message}`);
56
+ }
57
+ }
58
+
59
+ function atomicWriteIfChanged(filePath, serialized) {
60
+ if (existsSync(filePath)) {
61
+ const prev = readFileSync(filePath, 'utf8');
62
+ if (prev === serialized) return false;
63
+ }
64
+
65
+ ensureDir(dirname(filePath));
66
+
67
+ const tmp = join(
68
+ dirname(filePath),
69
+ `.${basename(filePath)}.tmp.${process.pid}.${Math.random().toString(36).slice(2)}`
70
+ );
71
+
72
+ try {
73
+ writeFileSync(tmp, serialized, { mode: FILE_MODE });
74
+ renameSync(tmp, filePath);
75
+ } catch (err) {
76
+ if (existsSync(tmp)) {
77
+ try { unlinkSync(tmp); } catch { /* best-effort */ }
78
+ }
79
+ throw err;
80
+ }
81
+
82
+ try { chmodSync(filePath, FILE_MODE); } catch { /* best-effort */ }
83
+ return true;
84
+ }
85
+
86
+ function isPlainObject(v) {
87
+ return v != null && typeof v === 'object' && !Array.isArray(v);
88
+ }
89
+
90
+ function ensureMarketplace(root, eccDir) {
91
+ if (!isPlainObject(root.extraKnownMarketplaces)) root.extraKnownMarketplaces = {};
92
+ // Nested source.source schema — pilot-verified.
93
+ root.extraKnownMarketplaces[MARKETPLACE_NAME] = {
94
+ ...(isPlainObject(root.extraKnownMarketplaces[MARKETPLACE_NAME])
95
+ ? root.extraKnownMarketplaces[MARKETPLACE_NAME]
96
+ : {}),
97
+ source: { source: 'directory', path: eccDir },
98
+ };
99
+ }
100
+
101
+ function ensurePluginEnabled(root) {
102
+ if (!isPlainObject(root.enabledPlugins)) root.enabledPlugins = {};
103
+ root.enabledPlugins[PLUGIN_KEY] = true;
104
+ }
105
+
106
+ function ensureSkillPermission(root) {
107
+ if (!isPlainObject(root.permissions)) root.permissions = {};
108
+ const existing = Array.isArray(root.permissions.allow) ? root.permissions.allow : [];
109
+ if (existing.includes(SKILL_PERMISSION)) {
110
+ root.permissions.allow = existing;
111
+ } else {
112
+ root.permissions.allow = [...existing, SKILL_PERMISSION];
113
+ }
114
+ }
115
+
116
+ /**
117
+ * Ensure Claude is wired up to surface ECC's `aw:<stage>` skills via plugin
118
+ * marketplace + Skill permission.
119
+ *
120
+ * @param {string} home User home (e.g. tmp dir in tests).
121
+ * @param {string} eccDir Path to the ECC plugin directory (~/.aw-ecc).
122
+ * @returns {{ changed: boolean }}
123
+ */
124
+ export function ensureClaudeMarketplace(home, eccDir) {
125
+ if (!home || typeof home !== 'string') {
126
+ throw new Error('ensureClaudeMarketplace: home is required');
127
+ }
128
+ if (!eccDir || typeof eccDir !== 'string') {
129
+ throw new Error('ensureClaudeMarketplace: eccDir is required');
130
+ }
131
+
132
+ const settingsPath = join(home, '.claude/settings.json');
133
+ const root = readJsonOrEmpty(settingsPath);
134
+
135
+ ensureMarketplace(root, eccDir);
136
+ ensurePluginEnabled(root);
137
+ ensureSkillPermission(root);
138
+
139
+ const serialized = JSON.stringify(root, null, 2) + '\n';
140
+ const changed = atomicWriteIfChanged(settingsPath, serialized);
141
+ return { changed };
142
+ }
@@ -0,0 +1,148 @@
1
+ /**
2
+ * c4/codexConfig.mjs — Codex-only ~/.codex/config.toml editor.
3
+ *
4
+ * Codex hooks silently no-op without `[features] codex_hooks = true` in this
5
+ * file (validated in pilot). The MCP block lives in the same file as a
6
+ * `[mcp_servers.<name>]` table with optional `[mcp_servers.<name>.headers]`
7
+ * sub-table.
8
+ *
9
+ * We use @iarna/toml's parse + stringify for full round-trip fidelity.
10
+ * Atomic write (tmp file + rename, chmod 0600) keeps Codex from reading a
11
+ * partial file mid-write.
12
+ *
13
+ * Contract: spec.md::§"c4/codexConfig.mjs", tasks.md::3.3.
14
+ */
15
+
16
+ import {
17
+ existsSync,
18
+ readFileSync,
19
+ writeFileSync,
20
+ renameSync,
21
+ chmodSync,
22
+ mkdirSync,
23
+ unlinkSync,
24
+ } from 'node:fs';
25
+ import { dirname, basename, join } from 'node:path';
26
+ import TOML from '@iarna/toml';
27
+
28
+ export const MCP_URL_DEFAULT =
29
+ 'https://services.leadconnectorhq.com/agentic-workspace/mcp';
30
+
31
+ const FILE_MODE = 0o600;
32
+
33
+ function ensureDir(d) {
34
+ mkdirSync(d, { recursive: true });
35
+ }
36
+
37
+ function readTomlOrEmpty(filePath) {
38
+ if (!existsSync(filePath)) return {};
39
+ const raw = readFileSync(filePath, 'utf8');
40
+ if (raw.trim() === '') return {};
41
+ try {
42
+ return TOML.parse(raw);
43
+ } catch (err) {
44
+ throw new Error(`Failed to parse TOML at ${filePath}: ${err.message}`);
45
+ }
46
+ }
47
+
48
+ function atomicWriteIfChanged(filePath, serialized) {
49
+ if (existsSync(filePath)) {
50
+ const prev = readFileSync(filePath, 'utf8');
51
+ if (prev === serialized) return false;
52
+ }
53
+
54
+ ensureDir(dirname(filePath));
55
+
56
+ const tmp = join(
57
+ dirname(filePath),
58
+ `.${basename(filePath)}.tmp.${process.pid}.${Math.random().toString(36).slice(2)}`
59
+ );
60
+
61
+ try {
62
+ writeFileSync(tmp, serialized, { mode: FILE_MODE });
63
+ renameSync(tmp, filePath);
64
+ } catch (err) {
65
+ if (existsSync(tmp)) {
66
+ try { unlinkSync(tmp); } catch { /* best-effort */ }
67
+ }
68
+ throw err;
69
+ }
70
+
71
+ try { chmodSync(filePath, FILE_MODE); } catch { /* best-effort */ }
72
+ return true;
73
+ }
74
+
75
+ function configPathFor(home) {
76
+ return join(home, '.codex/config.toml');
77
+ }
78
+
79
+ /**
80
+ * Ensure `[features] codex_hooks = true` in ~/.codex/config.toml.
81
+ * Preserves all other keys (round-trip via @iarna/toml).
82
+ *
83
+ * @param {string} home
84
+ * @returns {{ changed: boolean }}
85
+ */
86
+ export function ensureCodexHooksFlag(home) {
87
+ if (!home || typeof home !== 'string') {
88
+ throw new Error('ensureCodexHooksFlag: home is required');
89
+ }
90
+ const filePath = configPathFor(home);
91
+ const root = readTomlOrEmpty(filePath);
92
+ if (root.features == null || typeof root.features !== 'object' || Array.isArray(root.features)) {
93
+ root.features = {};
94
+ }
95
+ root.features.codex_hooks = true;
96
+ const serialized = TOML.stringify(root);
97
+ const changed = atomicWriteIfChanged(filePath, serialized);
98
+ return { changed };
99
+ }
100
+
101
+ /**
102
+ * Ensure `[mcp_servers.ghl-ai]` block with url + Bearer token + transport.
103
+ * URL falls back to env MCP_URL → MCP_URL_DEFAULT.
104
+ *
105
+ * @param {string} home
106
+ * @param {string} token
107
+ * @returns {{ changed: boolean }}
108
+ */
109
+ export function ensureCodexMcpServer(home, token) {
110
+ if (!home || typeof home !== 'string') {
111
+ throw new Error('ensureCodexMcpServer: home is required');
112
+ }
113
+ if (!token || typeof token !== 'string') {
114
+ throw new Error('ensureCodexMcpServer: token is required');
115
+ }
116
+
117
+ const url = process.env.MCP_URL || MCP_URL_DEFAULT;
118
+ const filePath = configPathFor(home);
119
+ const root = readTomlOrEmpty(filePath);
120
+
121
+ if (
122
+ root.mcp_servers == null ||
123
+ typeof root.mcp_servers !== 'object' ||
124
+ Array.isArray(root.mcp_servers)
125
+ ) {
126
+ root.mcp_servers = {};
127
+ }
128
+
129
+ const ghlAi = root.mcp_servers['ghl-ai'] ?? {};
130
+ ghlAi.url = url;
131
+ ghlAi.transport = 'http';
132
+ // Match the canonical `aw init` reference (libs/aw/mcp.mjs::tomlMcpServerBlock)
133
+ // which always sets startup_timeout_sec = 30 for HTTP MCP servers.
134
+ ghlAi.startup_timeout_sec = 30;
135
+ if (
136
+ ghlAi.headers == null ||
137
+ typeof ghlAi.headers !== 'object' ||
138
+ Array.isArray(ghlAi.headers)
139
+ ) {
140
+ ghlAi.headers = {};
141
+ }
142
+ ghlAi.headers.Authorization = `Bearer ${token}`;
143
+ root.mcp_servers['ghl-ai'] = ghlAi;
144
+
145
+ const serialized = TOML.stringify(root);
146
+ const changed = atomicWriteIfChanged(filePath, serialized);
147
+ return { changed };
148
+ }
@@ -0,0 +1,187 @@
1
+ /**
2
+ * c4/codexPromptInjector.mjs — Bug B (Codex non-persistence) workaround.
3
+ *
4
+ * Codex Web does not persist `additionalContext` across turns; the slim card
5
+ * approach used for Claude/Cursor is insufficient. Instead, we wrap ECC's
6
+ * existing `~/.codex/hooks/aw-user-prompt-submit.sh` so that EVERY
7
+ * UserPromptSubmit emits the full using-aw-skills SKILL.md body wrapped in
8
+ * `<EXTREMELY_IMPORTANT>` framing.
9
+ *
10
+ * The wrapper:
11
+ * 1. Reads stdin (Codex's UserPromptSubmit payload).
12
+ * 2. Calls the original hook (renamed to `<hookPath>.upstream`) with the
13
+ * same stdin to capture its output.
14
+ * 3. If env AW_PROMPT_ROUTER_INJECT=0, passes the upstream output through
15
+ * verbatim (escape hatch for debugging Bug B itself).
16
+ * 4. Otherwise reads the router skill body, builds the merged envelope:
17
+ * { hookSpecificOutput: { hookEventName: "UserPromptSubmit",
18
+ * additionalContext: "<EXTREMELY_IMPORTANT>" + skill +
19
+ * "</EXTREMELY_IMPORTANT>\n\n" + upstream_additionalContext } }
20
+ * and prints it to stdout.
21
+ *
22
+ * Idempotency: the wrapper carries a `# aw-c4 router injector v1` header
23
+ * marker; reinstalls detect this and no-op (the upstream backup is NOT
24
+ * overwritten on the second run, which would otherwise destroy the original
25
+ * hook).
26
+ *
27
+ * Source-of-truth path: `aw-ecc/scripts/lib/codex-aw-hook-files.js` lists
28
+ * `aw-user-prompt-submit.sh` as one of the files materialized into
29
+ * `~/.codex/hooks/` during `aw init`. NOT in the registry path.
30
+ *
31
+ * Contract: spec.md::§"c4/codexPromptInjector.mjs", tasks.md::3.2.
32
+ */
33
+
34
+ import {
35
+ existsSync,
36
+ readFileSync,
37
+ writeFileSync,
38
+ renameSync,
39
+ chmodSync,
40
+ mkdirSync,
41
+ } from 'node:fs';
42
+ import { dirname, join } from 'node:path';
43
+ import { homedir } from 'node:os';
44
+
45
+ const WRAPPER_HEADER_MARKER = '# aw-c4 router injector v1';
46
+
47
+ /**
48
+ * @returns {string} Default hook path: ${os.homedir()}/.codex/hooks/aw-user-prompt-submit.sh
49
+ */
50
+ export function defaultHookPath() {
51
+ return join(homedir(), '.codex/hooks/aw-user-prompt-submit.sh');
52
+ }
53
+
54
+ /**
55
+ * Render the wrapper script that re-emits the router skill on every prompt.
56
+ *
57
+ * Implementation note: the wrapper is pure bash + python3 (which Codex Web
58
+ * always has available). It uses `python3 -c` because envelope merging
59
+ * requires reading-then-mutating a JSON object — a non-trivial jq call.
60
+ * Embedding the python is significantly less brittle than depending on jq
61
+ * existing in every Codex Web image.
62
+ *
63
+ * @param {object} opts
64
+ * @param {string} opts.routerSkillPath Absolute path to using-aw-skills/SKILL.md
65
+ * @param {string} opts.escapeEnvVar Env var name (e.g. AW_PROMPT_ROUTER_INJECT)
66
+ * @returns {string} Full bash wrapper script.
67
+ */
68
+ function buildWrapperScript({ routerSkillPath, escapeEnvVar }) {
69
+ return `#!/usr/bin/env bash
70
+ ${WRAPPER_HEADER_MARKER}
71
+ set -euo pipefail
72
+
73
+ # Re-emit using-aw-skills SKILL.md on every UserPromptSubmit so Codex sees
74
+ # the router every turn (Bug B workaround).
75
+
76
+ UPSTREAM="$0.upstream"
77
+ ROUTER_SKILL_PATH="${routerSkillPath}"
78
+ ESCAPE_VAR="${escapeEnvVar}"
79
+
80
+ # Capture stdin once — both the upstream call and any direct emit need it.
81
+ PAYLOAD="$(cat)"
82
+
83
+ # Run upstream (if it exists) with the same stdin; capture stdout.
84
+ UPSTREAM_OUT=""
85
+ if [ -x "$UPSTREAM" ]; then
86
+ UPSTREAM_OUT="$(printf '%s' "$PAYLOAD" | "$UPSTREAM" || true)"
87
+ fi
88
+
89
+ # Escape: pass upstream through verbatim (or empty {} if no upstream).
90
+ escape_value="\${!ESCAPE_VAR:-}"
91
+ if [ "$escape_value" = "0" ]; then
92
+ if [ -n "$UPSTREAM_OUT" ]; then
93
+ printf '%s' "$UPSTREAM_OUT"
94
+ else
95
+ printf '%s' '{}'
96
+ fi
97
+ exit 0
98
+ fi
99
+
100
+ export ROUTER_SKILL_PATH UPSTREAM_OUT
101
+
102
+ python3 -c '
103
+ import json, os, sys
104
+ skill_path = os.environ["ROUTER_SKILL_PATH"]
105
+ try:
106
+ skill = open(skill_path).read()
107
+ except OSError:
108
+ skill = ""
109
+
110
+ upstream_raw = os.environ.get("UPSTREAM_OUT", "").strip()
111
+ upstream_ctx = ""
112
+ if upstream_raw:
113
+ try:
114
+ u = json.loads(upstream_raw)
115
+ upstream_ctx = (u.get("hookSpecificOutput") or {}).get("additionalContext", "") or ""
116
+ except Exception:
117
+ upstream_ctx = ""
118
+
119
+ framed = "<EXTREMELY_IMPORTANT>\\n" + skill + "\\n</EXTREMELY_IMPORTANT>\\n\\n" + upstream_ctx
120
+ out = {
121
+ "hookSpecificOutput": {
122
+ "hookEventName": "UserPromptSubmit",
123
+ "additionalContext": framed,
124
+ }
125
+ }
126
+ print(json.dumps(out))
127
+ '
128
+ `;
129
+ }
130
+
131
+ function isExistingWrapper(filePath) {
132
+ if (!existsSync(filePath)) return false;
133
+ try {
134
+ const head = readFileSync(filePath, 'utf8').slice(0, 256);
135
+ return head.includes(WRAPPER_HEADER_MARKER);
136
+ } catch {
137
+ // Read failure (e.g. permission denied or transient FS error): treat as
138
+ // "no wrapper present" so the caller falls through to the install path.
139
+ return false;
140
+ }
141
+ }
142
+
143
+ function ensureDir(d) {
144
+ mkdirSync(d, { recursive: true });
145
+ }
146
+
147
+ /**
148
+ * Install (or re-install) the prompt-injector wrapper.
149
+ *
150
+ * @param {object} opts
151
+ * @param {string} [opts.hookPath] Default: defaultHookPath().
152
+ * @param {string} opts.routerSkillPath Absolute path to using-aw-skills SKILL.md.
153
+ * @param {string} [opts.escapeEnvVar] Default: 'AW_PROMPT_ROUTER_INJECT'.
154
+ * @returns {{ wrapperPath: string, backupPath: string }}
155
+ */
156
+ export function installCodexPromptInjector(opts = {}) {
157
+ const hookPath = opts.hookPath ?? defaultHookPath();
158
+ const routerSkillPath = opts.routerSkillPath;
159
+ const escapeEnvVar = opts.escapeEnvVar ?? 'AW_PROMPT_ROUTER_INJECT';
160
+
161
+ if (!routerSkillPath || typeof routerSkillPath !== 'string') {
162
+ throw new Error('installCodexPromptInjector: routerSkillPath is required');
163
+ }
164
+
165
+ const backupPath = `${hookPath}.upstream`;
166
+
167
+ ensureDir(dirname(hookPath));
168
+
169
+ if (isExistingWrapper(hookPath)) {
170
+ // Idempotent no-op: leave wrapper + backup unchanged.
171
+ return { wrapperPath: hookPath, backupPath };
172
+ }
173
+
174
+ // First-run install: if the original hook exists and isn't already the
175
+ // wrapper, move it to .upstream. If neither exists, the wrapper still
176
+ // installs and the .upstream branch is just absent (wrapper handles it).
177
+ if (existsSync(hookPath) && !existsSync(backupPath)) {
178
+ renameSync(hookPath, backupPath);
179
+ try { chmodSync(backupPath, 0o755); } catch { /* best-effort */ }
180
+ }
181
+
182
+ const script = buildWrapperScript({ routerSkillPath, escapeEnvVar });
183
+ writeFileSync(hookPath, script, { mode: 0o755 });
184
+ try { chmodSync(hookPath, 0o755); } catch { /* best-effort */ }
185
+
186
+ return { wrapperPath: hookPath, backupPath };
187
+ }