@brunobracaioli/aibrain 0.4.0-beta.2 → 0.4.0-beta.4

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.
@@ -1,13 +1,15 @@
1
1
  // @ts-check
2
2
 
3
3
  /**
4
- * AISAC Engineering pack for Claude Code (ADR-0047 wave E4b, spec FR4/FR8).
4
+ * AISAC Engineering pack for Claude Code (ADR-0047 wave E4b) and Codex (E4c),
5
+ * spec FR4/FR8.
5
6
  *
6
7
  * One request per launch fetches the account's pack; it doubles as the live
7
8
  * license check. The pack is trusted instruction for the user's agent, so it
8
9
  * is accepted only when it is signed by a pinned key, matches the requested
9
10
  * locale and passes the FR3 rules again here. It is written into the launch's
10
- * private directory and handed to Claude Code as a second `--plugin-dir`.
11
+ * private directory and handed to Claude Code as a second `--plugin-dir`, or
12
+ * to the Codex app-server this launch owns (see codex-engineering.mjs).
11
13
  * Any failure starts the session without the pack; nothing here can block a
12
14
  * launch or treat a failure as licensed.
13
15
  */
@@ -35,6 +37,13 @@ import {
35
37
  unavailableReasonFor,
36
38
  } from './engineering.mjs';
37
39
  import { atomicJson } from './state.mjs';
40
+ import {
41
+ AGENT_NAME,
42
+ CODEX_AGENTS_DIRECTORY,
43
+ MAX_AGENT_DESCRIPTION_LENGTH,
44
+ tomlBasicString,
45
+ validateCodexEngineering,
46
+ } from './codex-engineering.mjs';
38
47
 
39
48
  const PACK_PATH = '/engineering/v1/pack';
40
49
  export const PACK_TIMEOUT_MS = 10_000;
@@ -86,6 +95,10 @@ const PLUGIN_NAME = 'aisac-engineering';
86
95
  const PLUGIN_LICENSE = 'LicenseRef-AISAC-Engineering';
87
96
  const PACK_DIRECTORY = 'engineering';
88
97
  const OUTPUT_STYLES_PREFIX = 'output-styles/';
98
+ const SKILLS_DIRECTORY = 'skills';
99
+ const AGENT_FILE = /^agents\/([a-z][a-z0-9-]{0,63})\.md$/u;
100
+ const WATERMARK_KEY = 'aisac_watermark';
101
+ const WATERMARK_VALUE = /^[0-9a-f]{16}$/u;
89
102
  export const PUBLIC_KEY_FILE = 'engineering-pack-public-key.json';
90
103
  const LOCALE_FILE = 'engineering-locale.json';
91
104
  const LAST_LAUNCH_FILE = 'engineering-last-launch.json';
@@ -113,11 +126,12 @@ const SAFE_REASON = /^[a-z][a-z0-9_]{0,63}$/u;
113
126
  * | { outcome: 'unavailable', reason: string }
114
127
  * | { outcome: 'rejected', reason: string }} PackOutcome
115
128
  */
129
+ /** @typedef {'claude' | 'codex'} PackClient */
116
130
  /**
117
131
  * @typedef {{
118
132
  * schemaVersion: 1,
119
133
  * at: string,
120
- * client: 'claude',
134
+ * client: PackClient,
121
135
  * state: PackOutcome['outcome'],
122
136
  * reason: string | null,
123
137
  * packVersion: string | null,
@@ -198,7 +212,7 @@ export async function readLastLaunch(root) {
198
212
  value.schemaVersion !== 1 ||
199
213
  typeof value.at !== 'string' ||
200
214
  !ISO_INSTANT.test(value.at) ||
201
- value.client !== 'claude' ||
215
+ (value.client !== 'claude' && value.client !== 'codex') ||
202
216
  !['loaded', 'inactive', 'not_offered', 'unavailable', 'rejected'].includes(
203
217
  /** @type {string} */ (value.state),
204
218
  ) ||
@@ -519,12 +533,13 @@ const PLUGIN_DESCRIPTION = {
519
533
  };
520
534
 
521
535
  /**
522
- * Write a verified pack as a Claude Code plugin inside the launch's private
523
- * directory and return the plugin root. The manifest is the client's own.
536
+ * Write the pack files into `engineering/` of the launch's private directory:
537
+ * `0700` directories, `0600` files, exclusive creation, contained paths.
524
538
  * @param {VerifiedPack} pack
525
539
  * @param {string} launchDirectory
540
+ * @returns {Promise<string>} the pack root
526
541
  */
527
- export async function materializePack(pack, launchDirectory) {
542
+ async function writePackFiles(pack, launchDirectory) {
528
543
  const base = resolve(launchDirectory, PACK_DIRECTORY);
529
544
  await mkdir(base, { mode: 0o700 });
530
545
  for (const file of pack.files) {
@@ -534,6 +549,17 @@ export async function materializePack(pack, launchDirectory) {
534
549
  await mkdir(dirname(target), { recursive: true, mode: 0o700 });
535
550
  await writeFile(target, file.content, { mode: 0o600, flag: 'wx' });
536
551
  }
552
+ return base;
553
+ }
554
+
555
+ /**
556
+ * Write a verified pack as a Claude Code plugin inside the launch's private
557
+ * directory and return the plugin root. The manifest is the client's own.
558
+ * @param {VerifiedPack} pack
559
+ * @param {string} launchDirectory
560
+ */
561
+ export async function materializePack(pack, launchDirectory) {
562
+ const base = await writePackFiles(pack, launchDirectory);
537
563
  const manifest = {
538
564
  name: PLUGIN_NAME,
539
565
  version: `0.0.0-${pack.packVersion}`,
@@ -553,6 +579,92 @@ export async function materializePack(pack, launchDirectory) {
553
579
  return base;
554
580
  }
555
581
 
582
+ /**
583
+ * Front-matter values (one `key: value` per line, lower-cased keys, matching
584
+ * outer quotes removed) and the body after it; null without front matter.
585
+ * @param {string} content
586
+ * @returns {{ values: Map<string, string>, body: string } | null}
587
+ */
588
+ export function splitFrontMatter(content) {
589
+ if (!content.startsWith(FRONT_MATTER_FENCE)) return null;
590
+ const end = content.indexOf(FRONT_MATTER_CLOSE, FRONT_MATTER_FENCE.length - 1);
591
+ if (end === -1) return null;
592
+ /** @type {Map<string, string>} */
593
+ const values = new Map();
594
+ for (const line of content.slice(FRONT_MATTER_FENCE.length, end + 1).split('\n')) {
595
+ const key = FRONT_MATTER_KEY.exec(line)?.[1];
596
+ if (key === undefined) continue;
597
+ let value = line.slice(line.indexOf(':') + 1).trim();
598
+ if (value.length >= 2 && (value[0] === '"' || value[0] === "'") && value.at(-1) === value[0])
599
+ value = value.slice(1, -1);
600
+ values.set(key.toLowerCase(), value);
601
+ }
602
+ return { values, body: content.slice(end + FRONT_MATTER_CLOSE.length).replace(/^\n+/u, '') };
603
+ }
604
+
605
+ /**
606
+ * A Codex agent role file from a pack agent (`agents/<name>.md`): the front
607
+ * matter's name and description, the body as developer instructions, and the
608
+ * origin mark kept as a comment. Null when the agent cannot be a role.
609
+ * @param {PackFile} file
610
+ * @returns {{ name: string, description: string, toml: string } | null}
611
+ */
612
+ export function codexRoleOf(file) {
613
+ const fileName = AGENT_FILE.exec(file.path)?.[1];
614
+ const parsed = splitFrontMatter(file.content);
615
+ if (fileName === undefined || parsed === null) return null;
616
+ const name = parsed.values.get('name');
617
+ const description = parsed.values.get('description');
618
+ if (name !== fileName || !AGENT_NAME.test(name)) return null;
619
+ if (
620
+ description === undefined ||
621
+ description.length === 0 ||
622
+ description.length > MAX_AGENT_DESCRIPTION_LENGTH ||
623
+ description === '>' ||
624
+ description === '|' ||
625
+ parsed.body.trim().length === 0
626
+ )
627
+ return null;
628
+ const watermark = parsed.values.get(WATERMARK_KEY);
629
+ const lines = [
630
+ '# AISAC Engineering. Licensed to your account; do not redistribute.',
631
+ ...(watermark !== undefined && WATERMARK_VALUE.test(watermark)
632
+ ? [`# ${WATERMARK_KEY}: ${watermark}`]
633
+ : []),
634
+ `name = ${tomlBasicString(name)}`,
635
+ `description = ${tomlBasicString(description)}`,
636
+ `developer_instructions = ${tomlBasicString(parsed.body)}`,
637
+ ];
638
+ return { name, description, toml: `${lines.join('\n')}\n` };
639
+ }
640
+
641
+ /**
642
+ * Write a verified pack for the Codex app-server this launch owns: the files
643
+ * as for Claude (without the Claude manifest), one role file per agent in
644
+ * `codex-agents/`, and the description the monitor turns into session
645
+ * arguments. Output styles have no Codex equivalent and stay unused.
646
+ * @param {VerifiedPack} pack
647
+ * @param {string} launchDirectory
648
+ * @returns {Promise<import('./codex-engineering.mjs').CodexEngineering>}
649
+ */
650
+ export async function materializeCodexPack(pack, launchDirectory) {
651
+ const base = await writePackFiles(pack, launchDirectory);
652
+ const skillsRoot = join(base, SKILLS_DIRECTORY);
653
+ await mkdir(skillsRoot, { recursive: true, mode: 0o700 });
654
+ const rolesDirectory = join(base, CODEX_AGENTS_DIRECTORY);
655
+ await mkdir(rolesDirectory, { mode: 0o700 });
656
+ /** @type {import('./codex-engineering.mjs').CodexAgentRole[]} */
657
+ const agents = [];
658
+ for (const file of pack.files) {
659
+ const role = codexRoleOf(file);
660
+ if (role === null) continue;
661
+ const configFile = join(rolesDirectory, `${role.name}.toml`);
662
+ await writeFile(configFile, role.toml, { mode: 0o600, flag: 'wx' });
663
+ agents.push({ name: role.name, description: role.description, configFile });
664
+ }
665
+ return validateCodexEngineering({ schemaVersion: 1, skillsRoot, agents });
666
+ }
667
+
556
668
  /**
557
669
  * One line for the launch, in the pack locale; null when the capability is
558
670
  * not offered, so memory-only users see no change.
@@ -594,13 +706,14 @@ export function packNotice(outcome, locale) {
594
706
  /**
595
707
  * @param {PackOutcome} outcome
596
708
  * @param {PackLocale} locale
709
+ * @param {PackClient} client
597
710
  * @returns {LastLaunch}
598
711
  */
599
- function lastLaunchOf(outcome, locale) {
712
+ function lastLaunchOf(outcome, locale, client) {
600
713
  return {
601
714
  schemaVersion: 1,
602
715
  at: new Date().toISOString(),
603
- client: 'claude',
716
+ client,
604
717
  state: outcome.outcome,
605
718
  reason:
606
719
  outcome.outcome === 'unavailable' || outcome.outcome === 'rejected' ? outcome.reason : null,
@@ -610,13 +723,17 @@ function lastLaunchOf(outcome, locale) {
610
723
  }
611
724
 
612
725
  /**
613
- * Fetch, verify and write the pack for one Claude Code launch. Never throws.
726
+ * Fetch, verify and write the pack for one Claude Code or Codex launch. Never
727
+ * throws. `hostSupported: false` (a Codex release older than the verified
728
+ * mechanism) still checks the license but loads nothing.
614
729
  * @param {{ endpoint: string, token: string, root: string, directory: string,
615
- * packageRoot: string, env?: NodeJS.ProcessEnv, fetchImpl?: typeof fetch,
616
- * timeoutMs?: number }} input
617
- * @returns {Promise<{ pluginRoot: string | null, line: string | null }>}
730
+ * packageRoot: string, client?: PackClient, hostSupported?: boolean,
731
+ * env?: NodeJS.ProcessEnv, fetchImpl?: typeof fetch, timeoutMs?: number }} input
732
+ * @returns {Promise<{ pluginRoot: string | null,
733
+ * codex: import('./codex-engineering.mjs').CodexEngineering | null, line: string | null }>}
618
734
  */
619
735
  export async function prepareEngineeringPack(input) {
736
+ const client = input.client ?? 'claude';
620
737
  const locale = resolvePackLocale(await readStoredLocale(input.root), input.env);
621
738
  /** @type {PackOutcome} */
622
739
  let outcome;
@@ -632,21 +749,27 @@ export async function prepareEngineeringPack(input) {
632
749
  } catch {
633
750
  outcome = { outcome: 'unavailable', reason: 'client_error' };
634
751
  }
752
+ if (outcome.outcome === 'loaded' && input.hostSupported === false)
753
+ outcome = { outcome: 'unavailable', reason: `${client}_outdated` };
635
754
  let pluginRoot = null;
755
+ /** @type {import('./codex-engineering.mjs').CodexEngineering | null} */
756
+ let codex = null;
636
757
  if (outcome.outcome === 'loaded') {
637
758
  try {
638
- pluginRoot = await materializePack(outcome.pack, input.directory);
759
+ if (client === 'codex') codex = await materializeCodexPack(outcome.pack, input.directory);
760
+ else pluginRoot = await materializePack(outcome.pack, input.directory);
639
761
  } catch {
762
+ codex = null;
640
763
  await rm(join(input.directory, PACK_DIRECTORY), { recursive: true, force: true }).catch(
641
764
  () => undefined,
642
765
  );
643
766
  outcome = { outcome: 'rejected', reason: 'materialize_failed' };
644
767
  }
645
768
  }
646
- await atomicJson(join(input.root, LAST_LAUNCH_FILE), lastLaunchOf(outcome, locale)).catch(
769
+ await atomicJson(join(input.root, LAST_LAUNCH_FILE), lastLaunchOf(outcome, locale, client)).catch(
647
770
  () => undefined,
648
771
  );
649
- return { pluginRoot, line: packNotice(outcome, locale) };
772
+ return { pluginRoot, codex, line: packNotice(outcome, locale) };
650
773
  }
651
774
 
652
775
  /**
@@ -0,0 +1,140 @@
1
+ // @ts-check
2
+ /**
3
+ * The AISAC Engineering extension catalog (spec aisac-engineering-extensions,
4
+ * FR1, FR2). It ships with the client and is the only source of hooks: the
5
+ * server and the licensed pack can neither add nor change entries (ADR-0048).
6
+ * The MCP catalog arrives in E6c.
7
+ */
8
+
9
+ /** @typedef {'pt-BR' | 'en'} CatalogLocale */
10
+ /** @typedef {'PreToolUse' | 'PostToolUse' | 'UserPromptSubmit' | 'SessionStart' | 'PreCompact' | 'Stop'} HookEvent */
11
+ /** @typedef {'block' | 'advise' | 'context'} HookMode */
12
+ /**
13
+ * @typedef {object} HookBinding
14
+ * @property {HookEvent} event
15
+ * @property {string} [matcher] Tool names (PreToolUse, PostToolUse) or SessionStart sources.
16
+ */
17
+ /**
18
+ * @typedef {object} HookEntry
19
+ * @property {string} id
20
+ * @property {HookMode} mode
21
+ * @property {boolean} defaultOn
22
+ * @property {readonly ('claude' | 'codex')[]} clients
23
+ * @property {readonly HookBinding[]} bindings
24
+ * @property {Readonly<Record<CatalogLocale, string>>} description
25
+ */
26
+
27
+ export const CATALOG_ID = /^[a-z][a-z0-9-]{0,31}$/u;
28
+
29
+ const EDIT_TOOLS = 'Edit|MultiEdit|Write';
30
+
31
+ /** @type {readonly HookEntry[]} */
32
+ export const HOOK_CATALOG = Object.freeze([
33
+ {
34
+ id: 'secrets-guard',
35
+ mode: 'block',
36
+ defaultOn: true,
37
+ clients: ['claude'],
38
+ bindings: [
39
+ { event: 'PreToolUse', matcher: 'Read|Edit|MultiEdit|Write|NotebookEdit|Grep|Bash' },
40
+ ],
41
+ description: {
42
+ en: 'Refuses reading or writing secret files (.env, keys, credentials); decides from names only.',
43
+ 'pt-BR':
44
+ 'Recusa ler ou gravar arquivos de segredo (.env, chaves, credenciais); decide só pelo nome.',
45
+ },
46
+ },
47
+ {
48
+ id: 'git-guard',
49
+ mode: 'block',
50
+ defaultOn: false,
51
+ clients: ['claude'],
52
+ bindings: [{ event: 'PreToolUse', matcher: 'Bash' }],
53
+ description: {
54
+ en: 'Refuses git push to main or master and force pushes (--force-with-lease passes).',
55
+ 'pt-BR': 'Recusa git push na main ou master e push forçado (--force-with-lease passa).',
56
+ },
57
+ },
58
+ {
59
+ id: 'destructive-guard',
60
+ mode: 'block',
61
+ defaultOn: false,
62
+ clients: ['claude'],
63
+ bindings: [{ event: 'PreToolUse', matcher: 'Bash' }],
64
+ description: {
65
+ en: 'Refuses rm -rf on /, ~, . or .. and DROP DATABASE, DROP TABLE or TRUNCATE.',
66
+ 'pt-BR': 'Recusa rm -rf em /, ~, . ou .. e DROP DATABASE, DROP TABLE ou TRUNCATE.',
67
+ },
68
+ },
69
+ {
70
+ id: 'format-on-edit',
71
+ mode: 'advise',
72
+ defaultOn: false,
73
+ clients: ['claude'],
74
+ bindings: [{ event: 'PostToolUse', matcher: EDIT_TOOLS }],
75
+ description: {
76
+ en: "Formats the edited file with the project's own formatter, when one is installed.",
77
+ 'pt-BR': 'Formata o arquivo editado com o formatador do próprio projeto, quando houver.',
78
+ },
79
+ },
80
+ {
81
+ id: 'docs-reminder',
82
+ mode: 'advise',
83
+ defaultOn: true,
84
+ clients: ['claude'],
85
+ bindings: [{ event: 'PostToolUse', matcher: EDIT_TOOLS }, { event: 'UserPromptSubmit' }],
86
+ description: {
87
+ en: 'After a source file changes in a project with docs/, reminds once per turn to update the docs.',
88
+ 'pt-BR':
89
+ 'Quando um arquivo de código muda num projeto com docs/, lembra uma vez por turno de atualizar a documentação.',
90
+ },
91
+ },
92
+ {
93
+ id: 'session-context',
94
+ mode: 'context',
95
+ defaultOn: true,
96
+ clients: ['claude'],
97
+ bindings: [{ event: 'SessionStart', matcher: 'startup|resume|clear' }],
98
+ description: {
99
+ en: 'Starts the session with a short repository snapshot (branch, status, recent commits).',
100
+ 'pt-BR': 'Abre a sessão com um resumo do repositório (branch, status, commits recentes).',
101
+ },
102
+ },
103
+ {
104
+ id: 'compact-continuity',
105
+ mode: 'context',
106
+ defaultOn: true,
107
+ clients: ['claude'],
108
+ bindings: [{ event: 'PreCompact' }, { event: 'SessionStart', matcher: 'compact' }],
109
+ description: {
110
+ en: 'Saves a repository snapshot before a compaction and restores it right after.',
111
+ 'pt-BR': 'Salva um resumo do repositório antes do compact e o devolve logo depois.',
112
+ },
113
+ },
114
+ {
115
+ id: 'continuity-reminder',
116
+ mode: 'block',
117
+ defaultOn: true,
118
+ clients: ['claude'],
119
+ bindings: [
120
+ { event: 'PostToolUse', matcher: EDIT_TOOLS },
121
+ { event: 'UserPromptSubmit' },
122
+ { event: 'Stop' },
123
+ ],
124
+ description: {
125
+ en: 'In a project with NOTES.md, asks once per turn to update NOTES.md, the spec or the CHANGELOG after code changes.',
126
+ 'pt-BR':
127
+ 'Num projeto com NOTES.md, pede uma vez por turno para atualizar NOTES.md, a spec ou o CHANGELOG depois de mudanças no código.',
128
+ },
129
+ },
130
+ ]);
131
+
132
+ /** @param {string} id @returns {HookEntry | undefined} */
133
+ export function hookById(id) {
134
+ return HOOK_CATALOG.find((entry) => entry.id === id);
135
+ }
136
+
137
+ /** @returns {string[]} */
138
+ export function hookIds() {
139
+ return HOOK_CATALOG.map((entry) => entry.id);
140
+ }
@@ -0,0 +1,214 @@
1
+ // @ts-check
2
+ /**
3
+ * `aisac hooks` (spec aisac-engineering-extensions FR4): list, enable,
4
+ * disable and reset guardrail hooks, offline. On a TTY without arguments it
5
+ * opens a checklist. Choices apply to the next session; running sessions
6
+ * keep their set.
7
+ */
8
+ import { emitKeypressEvents } from 'node:readline';
9
+ import { HOOK_CATALOG, hookById, hookIds } from './extensions-catalog.mjs';
10
+ import {
11
+ readPreferences,
12
+ resolveHooks,
13
+ unknownHookIds,
14
+ writePreferences,
15
+ } from './extensions-preferences.mjs';
16
+ import { readLastLaunch, readStoredLocale, resolvePackLocale } from './engineering-pack.mjs';
17
+ import { stateRoot } from './state.mjs';
18
+
19
+ const USAGE_EXIT_CODE = 2;
20
+ const ID_WIDTH = Math.max(...HOOK_CATALOG.map((entry) => entry.id.length));
21
+ const NEXT_SESSION =
22
+ 'Applies to the next `aisac claude` session; running sessions keep their current set.';
23
+ const LICENSE_NOTE = 'Hooks load only with an active AISAC Engineering license.';
24
+
25
+ /**
26
+ * @typedef {object} ChecklistState
27
+ * @property {number} cursor
28
+ * @property {boolean[]} enabled
29
+ */
30
+
31
+ /**
32
+ * One key press in the checklist (pure, tested without a TTY).
33
+ * @param {ChecklistState} state
34
+ * @param {{ name?: string, ctrl?: boolean, sequence?: string }} key
35
+ * @returns {{ state: ChecklistState, done: 'save' | 'cancel' | null }}
36
+ */
37
+ export function checklistStep(state, key) {
38
+ const last = state.enabled.length - 1;
39
+ if ((key.ctrl === true && key.name === 'c') || key.name === 'escape' || key.name === 'q')
40
+ return { state, done: 'cancel' };
41
+ if (key.name === 'return' || key.name === 'enter') return { state, done: 'save' };
42
+ if (key.name === 'up' || key.name === 'k')
43
+ return { state: { ...state, cursor: Math.max(0, state.cursor - 1) }, done: null };
44
+ if (key.name === 'down' || key.name === 'j')
45
+ return { state: { ...state, cursor: Math.min(last, state.cursor + 1) }, done: null };
46
+ if (key.name === 'space' || key.sequence === ' ') {
47
+ const enabled = state.enabled.map((value, index) => (index === state.cursor ? !value : value));
48
+ return { state: { ...state, enabled }, done: null };
49
+ }
50
+ return { state, done: null };
51
+ }
52
+
53
+ /** @param {'pt-BR' | 'en'} locale @param {ReturnType<typeof resolveHooks>} hooks @param {ChecklistState} [state] */
54
+ function renderLines(locale, hooks, state) {
55
+ return hooks.map((hook, index) => {
56
+ const enabled = state === undefined ? hook.enabled : state.enabled[index];
57
+ const pointer = state === undefined ? ' ' : index === state.cursor ? '>' : ' ';
58
+ const source = state === undefined ? ` ${hook.source.padEnd(7)}` : '';
59
+ return `${pointer} [${enabled ? 'x' : ' '}] ${hook.entry.id.padEnd(ID_WIDTH)} ${hook.entry.mode.padEnd(8)}${source} ${hook.entry.description[locale]}`;
60
+ });
61
+ }
62
+
63
+ /** @param {string} root */
64
+ async function licensedLastLaunch(root) {
65
+ const last = await readLastLaunch(root);
66
+ return last?.state === 'loaded';
67
+ }
68
+
69
+ /**
70
+ * Store explicit choices, keeping MCP choices as they are.
71
+ * @param {Record<string, boolean>} hooks
72
+ */
73
+ async function saveHooks(hooks) {
74
+ const current = await readPreferences(stateRoot());
75
+ await writePreferences({ hooks, mcp: current.status === 'valid' ? current.mcp : {} });
76
+ if (current.status === 'invalid')
77
+ console.log('The previous extensions file was invalid and was replaced.');
78
+ }
79
+
80
+ /** @param {string[]} ids */
81
+ function unknownIds(ids) {
82
+ return ids.filter((id) => hookById(id) === undefined);
83
+ }
84
+
85
+ /** @param {boolean} licensed */
86
+ function afterChange(licensed) {
87
+ console.log(NEXT_SESSION);
88
+ if (!licensed) console.log(LICENSE_NOTE);
89
+ }
90
+
91
+ /**
92
+ * Interactive checklist. Resolves to the new enabled flags, or null on cancel.
93
+ * @param {'pt-BR' | 'en'} locale @param {ReturnType<typeof resolveHooks>} hooks
94
+ * @returns {Promise<boolean[] | null>}
95
+ */
96
+ function runChecklist(locale, hooks) {
97
+ return new Promise((resolve) => {
98
+ let state = { cursor: 0, enabled: hooks.map((hook) => hook.enabled) };
99
+ const header = 'AISAC Engineering hooks: space toggles, enter saves, q cancels.';
100
+ const draw = (/** @type {boolean} */ first) => {
101
+ if (!first) process.stdout.write(`\u001b[${hooks.length}A\u001b[J`);
102
+ process.stdout.write(`${renderLines(locale, hooks, state).join('\n')}\n`);
103
+ };
104
+ console.log(header);
105
+ draw(true);
106
+ emitKeypressEvents(process.stdin);
107
+ process.stdin.setRawMode(true);
108
+ process.stdin.resume();
109
+ /** @param {string} _text @param {{ name?: string, ctrl?: boolean, sequence?: string }} key */
110
+ const onKey = (_text, key) => {
111
+ const step = checklistStep(state, key ?? {});
112
+ state = step.state;
113
+ if (step.done === null) return draw(false);
114
+ process.stdin.off('keypress', onKey);
115
+ process.stdin.setRawMode(false);
116
+ process.stdin.pause();
117
+ resolve(step.done === 'save' ? state.enabled : null);
118
+ };
119
+ process.stdin.on('keypress', onKey);
120
+ });
121
+ }
122
+
123
+ /**
124
+ * `aisac hooks [list|enable|disable|reset] [ids...] [--json]`.
125
+ * @param {string[]} args
126
+ */
127
+ export async function hooksCommand(args) {
128
+ const json = args.includes('--json');
129
+ const words = args.filter((arg) => arg !== '--json');
130
+ const [action = 'list', ...ids] = words;
131
+ const root = stateRoot();
132
+ const locale = resolvePackLocale(await readStoredLocale(root));
133
+ const preferences = await readPreferences(root);
134
+ const hooks = resolveHooks(preferences);
135
+ const licensed = await licensedLastLaunch(root);
136
+
137
+ if (action === 'list' && ids.length === 0) {
138
+ if (json) {
139
+ console.log(
140
+ JSON.stringify({
141
+ preferences: preferences.status,
142
+ licensedLastLaunch: licensed,
143
+ hooks: hooks.map((hook) => ({
144
+ id: hook.entry.id,
145
+ enabled: hook.enabled,
146
+ source: hook.source,
147
+ mode: hook.entry.mode,
148
+ clients: hook.entry.clients,
149
+ description: hook.entry.description[locale],
150
+ })),
151
+ unknown: unknownHookIds(preferences),
152
+ }),
153
+ );
154
+ return;
155
+ }
156
+ if (args.length === 0 && process.stdin.isTTY && process.stdout.isTTY) {
157
+ const chosen = await runChecklist(locale, hooks);
158
+ if (chosen === null) return console.log('No change.');
159
+ /** @type {Record<string, boolean>} */
160
+ const choices = preferences.status === 'valid' ? { ...preferences.hooks } : {};
161
+ hooks.forEach((hook, index) => {
162
+ if (chosen[index] !== hook.enabled) choices[hook.entry.id] = chosen[index] === true;
163
+ });
164
+ await saveHooks(choices);
165
+ return afterChange(licensed);
166
+ }
167
+ console.log('AISAC Engineering hooks (Claude Code):');
168
+ console.log(renderLines(locale, hooks).join('\n'));
169
+ if (preferences.status === 'invalid')
170
+ console.log('The extensions file is invalid: the defaults apply. Run `aisac hooks reset`.');
171
+ console.log(
172
+ 'Change with `aisac hooks enable <id>`, `aisac hooks disable <id>` or `aisac hooks reset`.',
173
+ );
174
+ if (!licensed) console.log(LICENSE_NOTE);
175
+ return;
176
+ }
177
+ if (action === 'reset' && ids.length === 0) {
178
+ await saveHooks({});
179
+ console.log('Hooks reset to the defaults.');
180
+ return afterChange(licensed);
181
+ }
182
+ if ((action === 'enable' || action === 'disable') && ids.length > 0) {
183
+ const unknown = unknownIds(ids);
184
+ if (unknown.length > 0) {
185
+ console.error(
186
+ `aisac: unknown hook id: ${unknown.join(', ')}. Valid: ${hookIds().join(', ')}.`,
187
+ );
188
+ process.exitCode = USAGE_EXIT_CODE;
189
+ return;
190
+ }
191
+ /** @type {Record<string, boolean>} */
192
+ const choices = preferences.status === 'valid' ? { ...preferences.hooks } : {};
193
+ for (const id of ids) choices[id] = action === 'enable';
194
+ await saveHooks(choices);
195
+ console.log(`${action === 'enable' ? 'Enabled' : 'Disabled'}: ${ids.join(', ')}.`);
196
+ return afterChange(licensed);
197
+ }
198
+ console.error(
199
+ 'aisac: usage: aisac hooks [--json] | aisac hooks enable <id>... | aisac hooks disable <id>... | aisac hooks reset',
200
+ );
201
+ process.exitCode = USAGE_EXIT_CODE;
202
+ }
203
+
204
+ /** Offline extension summary for `status` and `doctor`. @param {string} root */
205
+ export async function extensionsSummary(root) {
206
+ const preferences = await readPreferences(root);
207
+ return {
208
+ preferences: preferences.status,
209
+ hooks: resolveHooks(preferences)
210
+ .filter((hook) => hook.enabled)
211
+ .map((hook) => hook.entry.id),
212
+ unknown: unknownHookIds(preferences),
213
+ };
214
+ }