@gramatr/client 0.6.10 → 0.6.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.
package/CLAUDE.md CHANGED
@@ -10,6 +10,18 @@ ISC scaffold, capability audit, phase templates, and composed agents.
10
10
  **Memory:** Use gramatr MCP tools (`search_semantic`, `create_entity`, `add_observation`),
11
11
  not local markdown files.
12
12
 
13
+ **Entity types (33-type taxonomy):** When calling `create_entity`, `entity_type` MUST be
14
+ one of: user_profile, organization, team, project, decision, task, milestone, prd,
15
+ steering_rule, skill, agent_definition, playbook, standard, reference, blog_idea,
16
+ agent_diary, session, conversation, turn, classification_record, agent_execution,
17
+ intelligence_packet, learning_signal, learning_reflection, learning_pattern,
18
+ learning_correction, attachment, execution_record, model_evaluation, benchmark,
19
+ external_connection, infrastructure, audit_log. Invalid types will be blocked by
20
+ the PreToolUse hook. Call `gramatr_list_entity_types` for descriptions.
21
+
22
+ **Required fields:** task/milestone status: open|in_progress|blocked|review|done.
23
+ Decision requires status + project_id. Session requires status + project_id.
24
+
13
25
  **Identity:** Read from `~/.gramatr/settings.json` — `daidentity` for your name,
14
26
  `principal` for the user's name.
15
27
 
package/README.md CHANGED
@@ -64,7 +64,7 @@ Every interaction trains the classifier. Memory powers routing, routing generate
64
64
  gramatr is a thin client + smart server:
65
65
 
66
66
  - **Client** (this package): 29 files, ~290KB. Hooks into your AI agent, forwards to server.
67
- - **Server**: Decision routing engine (BERT + Qwen), knowledge graph (PostgreSQL + pgvector), pattern learning.
67
+ - **Server**: Decision routing engine (BERT + Llama 3.2 3B), knowledge graph (PostgreSQL + pgvector), pattern learning.
68
68
  - **Protocol**: MCP (Model Context Protocol) for Claude Code/Codex, REST API for web/mobile.
69
69
 
70
70
  The client never stores intelligence locally. The server delivers everything: behavioral rules, skill routing, agent composition, ISC scaffolds, capability audits.
package/core/install.ts CHANGED
@@ -31,6 +31,7 @@ const CLAUDE_HOOKS: HookSpec[] = [
31
31
  { event: 'PreToolUse', matcher: 'Edit', relativeCommand: 'hooks/gramatr-security-validator.hook.ts' },
32
32
  { event: 'PreToolUse', matcher: 'Write', relativeCommand: 'hooks/gramatr-security-validator.hook.ts' },
33
33
  { event: 'PreToolUse', matcher: 'Read', relativeCommand: 'hooks/gramatr-security-validator.hook.ts' },
34
+ { event: 'PreToolUse', matcher: 'mcp__.*gramatr.*__', relativeCommand: 'hooks/gramatr-input-validator.hook.ts' },
34
35
  { event: 'PostToolUse', matcher: 'mcp__.*gramatr.*__', relativeCommand: 'hooks/gramatr-tool-tracker.hook.ts' },
35
36
  { event: 'UserPromptSubmit', relativeCommand: 'hooks/gramatr-rating-capture.hook.ts' },
36
37
  { event: 'UserPromptSubmit', relativeCommand: 'hooks/gramatr-prompt-enricher.hook.ts' },
@@ -0,0 +1,100 @@
1
+ /**
2
+ * AUTO-GENERATED — do not edit manually.
3
+ * Source: @gramatr/core/contracts/mcp-schemas.ts
4
+ * Generator: scripts/generate-schema-constants.ts
5
+ * Generated: 2026-04-09T18:14:19.706Z
6
+ *
7
+ * These constants are extracted from canonical Zod schemas at build time
8
+ * so client hooks can validate MCP tool inputs without depending on
9
+ * @gramatr/core at runtime.
10
+ */
11
+
12
+ // ── Classifier enums ──
13
+ export const EFFORT_LEVELS = ["instant","fast","standard","extended","deep","advanced","comprehensive"] as const;
14
+ export const INTENT_TYPES = ["search","retrieve","create","update","analyze","generate"] as const;
15
+ export const MEMORY_TIERS = ["hot","warm","cold","none"] as const;
16
+
17
+ // ── Entity type enum (33-type taxonomy) ──
18
+ export const ENTITY_TYPES = ["user_profile","organization","team","project","decision","task","milestone","prd","steering_rule","skill","agent_definition","playbook","standard","reference","blog_idea","agent_diary","session","conversation","turn","classification_record","agent_execution","intelligence_packet","learning_signal","learning_reflection","learning_pattern","learning_correction","attachment","execution_record","model_evaluation","benchmark","external_connection","infrastructure","audit_log"] as const;
19
+
20
+ // ── Lifecycle enums ──
21
+ export const WORK_STATUSES = ["open","in_progress","blocked","review","done"] as const;
22
+ export const PROJECT_STATUSES = ["active","archived","planning"] as const;
23
+ export const SESSION_STATUSES = ["active","completed","abandoned"] as const;
24
+ export const DECISION_STATUSES = ["proposed","accepted","superseded","deprecated"] as const;
25
+ export const PRD_STATUSES = ["draft","criteria_defined","planned","in_progress","verifying","complete","failed","blocked"] as const;
26
+ export const OUTCOMES = ["success","partial","failed","abandoned"] as const;
27
+ export const EMBEDDING_STATUSES = ["pending","processing","complete","failed"] as const;
28
+
29
+ // ── Domain & classification ──
30
+ export const DOMAINS = ["data-engineering","platform-engineering","ai-infrastructure","product","operations","security","frontend","backend","devops","design"] as const;
31
+ export const ALGORITHM_PHASES = ["observe","think","plan","build","execute","verify","learn"] as const;
32
+ export const AGENT_TYPES = ["Architect","Engineer","Researcher","QATester","Designer","Intern","Pentester","Artist","custom"] as const;
33
+ export const PATTERN_TYPES = ["sequential_actions","tool_preference","effort_pattern","time_pattern","domain_affinity","skill_chain"] as const;
34
+ export const DIARY_SCOPES = ["project","user","team","org"] as const;
35
+
36
+ // ── Infrastructure & connection ──
37
+ export const INFRA_TYPES = ["compute","database","storage","network","workstation","cluster","service"] as const;
38
+ export const EXTERNAL_SERVICES = ["superset","salesforce","github","jira","linear","slack","discord","notion","custom"] as const;
39
+ export const AUTH_TYPES = ["oauth2","api_key","basic","token"] as const;
40
+ export const CONNECTION_STATUSES = ["connected","expired","revoked","error"] as const;
41
+
42
+ // ── Knowledge & content ──
43
+ export const ENFORCEMENT_LEVELS = ["mandatory","recommended","informational"] as const;
44
+ export const DECISION_TYPES = ["architecture","tooling","process","security","data","infrastructure","product","operational"] as const;
45
+ export const REFERENCE_TYPES = ["documentation","api_spec","diagram","article","url"] as const;
46
+ export const MODEL_PREFERENCES = ["opus","sonnet","haiku","gpt-4o","gpt-4o-mini","o3","llama-3.2","qwen-3","gemini-2.5"] as const;
47
+
48
+ // ── Audit ──
49
+ export const AUDIT_EVENT_TYPES = ["auth","entity_crud","search","admin","api_key","config","oauth","export"] as const;
50
+ export const AUDIT_ACTIONS = ["create","read","update","delete","login","logout","grant","revoke","export","import"] as const;
51
+ export const AUDIT_RESOURCE_TYPES = ["entity","observation","relation","api_key","session","oauth_client","user","embedding_model"] as const;
52
+
53
+ // ── Lookup sets (for O(1) validation in hooks) ──
54
+ export const ENTITY_TYPE_SET = new Set<string>(ENTITY_TYPES);
55
+ export const EFFORT_LEVEL_SET = new Set<string>(EFFORT_LEVELS);
56
+ export const INTENT_TYPE_SET = new Set<string>(INTENT_TYPES);
57
+ export const MEMORY_TIER_SET = new Set<string>(MEMORY_TIERS);
58
+ export const WORK_STATUS_SET = new Set<string>(WORK_STATUSES);
59
+
60
+ /**
61
+ * Per-tool required field validation rules.
62
+ * Maps tool name -> array of { field, validValues? } checks.
63
+ * If validValues is provided, the field must be one of those values.
64
+ * If validValues is omitted, the field just needs to be present and non-empty.
65
+ */
66
+ export const TOOL_INPUT_RULES: Record<string, Array<{ field: string; validValues?: readonly string[] }>> = {
67
+ create_entity: [
68
+ { field: 'name' },
69
+ { field: 'entity_type', validValues: ENTITY_TYPES },
70
+ ],
71
+ update_entity: [
72
+ { field: 'entity_id' },
73
+ ],
74
+ add_observation: [
75
+ { field: 'entity_id' },
76
+ { field: 'content' },
77
+ ],
78
+ search_semantic: [
79
+ { field: 'query' },
80
+ ],
81
+ search_entities: [], // all fields optional
82
+ create_relation: [
83
+ { field: 'source_entity_id' },
84
+ { field: 'target_entity_id' },
85
+ { field: 'relation_type' },
86
+ ],
87
+ mark_entity_inactive: [
88
+ { field: 'entity_id' },
89
+ ],
90
+ mark_observation_inactive: [
91
+ { field: 'observation_id' },
92
+ ],
93
+ reactivate_entity: [
94
+ { field: 'entity_id' },
95
+ ],
96
+ get_entities: [
97
+ { field: 'entity_ids' },
98
+ ],
99
+ list_entities: [], // all fields optional
100
+ };
@@ -0,0 +1,133 @@
1
+ #!/usr/bin/env npx tsx
2
+ /**
3
+ * gramatr-input-validator.hook.ts — PreToolUse Schema Validation
4
+ *
5
+ * Validates MCP tool inputs BEFORE the call reaches the server.
6
+ * Blocks invalid inputs (wrong entity_type, missing required fields)
7
+ * to save a wasted round trip and tokens.
8
+ *
9
+ * TRIGGER: PreToolUse (matcher: mcp__.*gramatr.*__)
10
+ * PERFORMANCE: <2ms — Set lookups only, no network calls.
11
+ * SAFETY: On any error, allows the call through (fail-open).
12
+ *
13
+ * Issue #459 — Strong typed MCP tool I/O shapes
14
+ */
15
+
16
+ import { TOOL_INPUT_RULES, ENTITY_TYPE_SET } from './generated/schema-constants.ts';
17
+
18
+ // ── Types ──
19
+
20
+ interface HookInput {
21
+ tool_name: string;
22
+ tool_input: Record<string, unknown>;
23
+ session_id?: string;
24
+ hook_event_name?: string;
25
+ }
26
+
27
+ interface HookOutput {
28
+ decision: 'allow' | 'block';
29
+ reason?: string;
30
+ }
31
+
32
+ // ── Stdin Reader ──
33
+
34
+ function readStdin(timeoutMs: number): Promise<string> {
35
+ return new Promise((resolve) => {
36
+ let data = '';
37
+ const timer = setTimeout(() => resolve(data), timeoutMs);
38
+ process.stdin.setEncoding('utf-8');
39
+ process.stdin.on('data', (chunk: string) => { data += chunk; });
40
+ process.stdin.on('end', () => { clearTimeout(timer); resolve(data); });
41
+ process.stdin.on('error', () => { clearTimeout(timer); resolve(data); });
42
+ process.stdin.resume();
43
+ });
44
+ }
45
+
46
+ // ── Validation ──
47
+
48
+ function extractToolShortName(fullName: string): string {
49
+ // mcp__gramatr__create_entity -> create_entity
50
+ const parts = fullName.split('__');
51
+ return parts.length >= 3 ? parts.slice(2).join('__') : fullName;
52
+ }
53
+
54
+ function validate(toolName: string, input: Record<string, unknown>): HookOutput {
55
+ const shortName = extractToolShortName(toolName);
56
+ const rules = TOOL_INPUT_RULES[shortName];
57
+
58
+ // No rules for this tool — allow
59
+ if (!rules || rules.length === 0) {
60
+ return { decision: 'allow' };
61
+ }
62
+
63
+ for (const rule of rules) {
64
+ const value = input[rule.field];
65
+
66
+ // Check presence
67
+ if (value === undefined || value === null || value === '') {
68
+ return {
69
+ decision: 'block',
70
+ reason: `Missing required field "${rule.field}" for ${shortName}. Provide a non-empty value.`,
71
+ };
72
+ }
73
+
74
+ // Check enum values
75
+ if (rule.validValues && typeof value === 'string') {
76
+ const validSet = rule.field === 'entity_type' ? ENTITY_TYPE_SET : new Set(rule.validValues);
77
+ if (!validSet.has(value)) {
78
+ return {
79
+ decision: 'block',
80
+ reason: `Invalid ${rule.field}="${value}" for ${shortName}. Valid values: ${rule.validValues.join(', ')}`,
81
+ };
82
+ }
83
+ }
84
+ }
85
+
86
+ // Additional: if any tool passes entity_type, validate it even if not in rules
87
+ if ('entity_type' in input && typeof input.entity_type === 'string') {
88
+ if (!ENTITY_TYPE_SET.has(input.entity_type)) {
89
+ return {
90
+ decision: 'block',
91
+ reason: `Invalid entity_type="${input.entity_type}". Valid types: ${[...ENTITY_TYPE_SET].slice(0, 10).join(', ')}... (33 total). Call gramatr_list_entity_types for the full list.`,
92
+ };
93
+ }
94
+ }
95
+
96
+ return { decision: 'allow' };
97
+ }
98
+
99
+ // ── Main ──
100
+
101
+ async function main() {
102
+ const raw = await readStdin(2000);
103
+
104
+ // Fail-open on any parse error
105
+ if (!raw.trim()) {
106
+ console.log(JSON.stringify({ decision: 'allow' }));
107
+ return;
108
+ }
109
+
110
+ let input: HookInput;
111
+ try {
112
+ input = JSON.parse(raw);
113
+ } catch {
114
+ console.log(JSON.stringify({ decision: 'allow' }));
115
+ return;
116
+ }
117
+
118
+ const { tool_name, tool_input } = input;
119
+ if (!tool_name || !tool_input) {
120
+ console.log(JSON.stringify({ decision: 'allow' }));
121
+ return;
122
+ }
123
+
124
+ try {
125
+ const result = validate(tool_name, tool_input);
126
+ console.log(JSON.stringify(result));
127
+ } catch {
128
+ // Fail-open on any validation error
129
+ console.log(JSON.stringify({ decision: 'allow' }));
130
+ }
131
+ }
132
+
133
+ main();
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@gramatr/client",
3
- "version": "0.6.10",
3
+ "version": "0.6.11",
4
4
  "publishConfig": {
5
5
  "access": "public"
6
6
  },
@@ -43,6 +43,7 @@
43
43
  "build-mcpb": "npx tsx desktop/build-mcpb.ts",
44
44
  "install-web": "echo 'No local install needed. See packages/client/web/README.md for setup instructions.'",
45
45
  "migrate-clean-install": "npx tsx bin/clean-legacy-install.ts --apply",
46
+ "generate-schemas": "npx tsx scripts/generate-schema-constants.ts",
46
47
  "gramatr": "npx tsx bin/gramatr.ts",
47
48
  "lint": "pnpm exec biome lint --files-ignore-unknown=true package.json bin chatgpt codex core desktop gemini hooks lib tools web vitest.config.ts",
48
49
  "test": "vitest run",
@@ -0,0 +1,164 @@
1
+ #!/usr/bin/env npx tsx
2
+ /**
3
+ * Build-time schema constant generator.
4
+ *
5
+ * Reads canonical Zod enums from @gramatr/core and writes plain TypeScript
6
+ * constants into hooks/generated/schema-constants.ts. This keeps the client
7
+ * hooks self-contained (no runtime dependency on @gramatr/core) while
8
+ * maintaining a single source of truth for enum values.
9
+ *
10
+ * Run: npx tsx scripts/generate-schema-constants.ts
11
+ * Or: pnpm generate-schemas
12
+ *
13
+ * Issue #459 — Strong typed MCP tool I/O shapes
14
+ */
15
+
16
+ import { writeFileSync, mkdirSync } from 'fs';
17
+ import { dirname, join } from 'path';
18
+ import { fileURLToPath } from 'url';
19
+
20
+ // Import canonical schemas from @gramatr/core
21
+ import {
22
+ EntityTypeEnum,
23
+ EffortLevelEnum,
24
+ IntentTypeEnum,
25
+ MemoryTierEnum,
26
+ WorkStatusEnum,
27
+ ProjectStatusEnum,
28
+ SessionStatusEnum,
29
+ DecisionStatusEnum,
30
+ PrdStatusEnum,
31
+ OutcomeEnum,
32
+ DomainEnum,
33
+ AlgorithmPhaseEnum,
34
+ AgentTypeEnum,
35
+ PatternTypeEnum,
36
+ DiaryScopeEnum,
37
+ InfraTypeEnum,
38
+ ExternalServiceEnum,
39
+ AuthTypeEnum,
40
+ ConnectionStatusEnum,
41
+ EnforcementEnum,
42
+ DecisionTypeEnum,
43
+ ReferenceTypeEnum,
44
+ AuditEventTypeEnum,
45
+ AuditActionEnum,
46
+ AuditResourceTypeEnum,
47
+ EmbeddingStatusEnum,
48
+ ModelPreferenceEnum,
49
+ } from '@gramatr/core';
50
+
51
+ const __dirname = dirname(fileURLToPath(import.meta.url));
52
+ const outDir = join(__dirname, '..', 'hooks', 'generated');
53
+ const outFile = join(outDir, 'schema-constants.ts');
54
+
55
+ // Extract .options from each Zod enum
56
+ function vals(zodEnum: { options: readonly string[] }): string {
57
+ return JSON.stringify([...zodEnum.options]);
58
+ }
59
+
60
+ const content = `/**
61
+ * AUTO-GENERATED — do not edit manually.
62
+ * Source: @gramatr/core/contracts/mcp-schemas.ts
63
+ * Generator: scripts/generate-schema-constants.ts
64
+ * Generated: ${new Date().toISOString()}
65
+ *
66
+ * These constants are extracted from canonical Zod schemas at build time
67
+ * so client hooks can validate MCP tool inputs without depending on
68
+ * @gramatr/core at runtime.
69
+ */
70
+
71
+ // ── Classifier enums ──
72
+ export const EFFORT_LEVELS = ${vals(EffortLevelEnum)} as const;
73
+ export const INTENT_TYPES = ${vals(IntentTypeEnum)} as const;
74
+ export const MEMORY_TIERS = ${vals(MemoryTierEnum)} as const;
75
+
76
+ // ── Entity type enum (33-type taxonomy) ──
77
+ export const ENTITY_TYPES = ${vals(EntityTypeEnum)} as const;
78
+
79
+ // ── Lifecycle enums ──
80
+ export const WORK_STATUSES = ${vals(WorkStatusEnum)} as const;
81
+ export const PROJECT_STATUSES = ${vals(ProjectStatusEnum)} as const;
82
+ export const SESSION_STATUSES = ${vals(SessionStatusEnum)} as const;
83
+ export const DECISION_STATUSES = ${vals(DecisionStatusEnum)} as const;
84
+ export const PRD_STATUSES = ${vals(PrdStatusEnum)} as const;
85
+ export const OUTCOMES = ${vals(OutcomeEnum)} as const;
86
+ export const EMBEDDING_STATUSES = ${vals(EmbeddingStatusEnum)} as const;
87
+
88
+ // ── Domain & classification ──
89
+ export const DOMAINS = ${vals(DomainEnum)} as const;
90
+ export const ALGORITHM_PHASES = ${vals(AlgorithmPhaseEnum)} as const;
91
+ export const AGENT_TYPES = ${vals(AgentTypeEnum)} as const;
92
+ export const PATTERN_TYPES = ${vals(PatternTypeEnum)} as const;
93
+ export const DIARY_SCOPES = ${vals(DiaryScopeEnum)} as const;
94
+
95
+ // ── Infrastructure & connection ──
96
+ export const INFRA_TYPES = ${vals(InfraTypeEnum)} as const;
97
+ export const EXTERNAL_SERVICES = ${vals(ExternalServiceEnum)} as const;
98
+ export const AUTH_TYPES = ${vals(AuthTypeEnum)} as const;
99
+ export const CONNECTION_STATUSES = ${vals(ConnectionStatusEnum)} as const;
100
+
101
+ // ── Knowledge & content ──
102
+ export const ENFORCEMENT_LEVELS = ${vals(EnforcementEnum)} as const;
103
+ export const DECISION_TYPES = ${vals(DecisionTypeEnum)} as const;
104
+ export const REFERENCE_TYPES = ${vals(ReferenceTypeEnum)} as const;
105
+ export const MODEL_PREFERENCES = ${vals(ModelPreferenceEnum)} as const;
106
+
107
+ // ── Audit ──
108
+ export const AUDIT_EVENT_TYPES = ${vals(AuditEventTypeEnum)} as const;
109
+ export const AUDIT_ACTIONS = ${vals(AuditActionEnum)} as const;
110
+ export const AUDIT_RESOURCE_TYPES = ${vals(AuditResourceTypeEnum)} as const;
111
+
112
+ // ── Lookup sets (for O(1) validation in hooks) ──
113
+ export const ENTITY_TYPE_SET = new Set<string>(ENTITY_TYPES);
114
+ export const EFFORT_LEVEL_SET = new Set<string>(EFFORT_LEVELS);
115
+ export const INTENT_TYPE_SET = new Set<string>(INTENT_TYPES);
116
+ export const MEMORY_TIER_SET = new Set<string>(MEMORY_TIERS);
117
+ export const WORK_STATUS_SET = new Set<string>(WORK_STATUSES);
118
+
119
+ /**
120
+ * Per-tool required field validation rules.
121
+ * Maps tool name -> array of { field, validValues? } checks.
122
+ * If validValues is provided, the field must be one of those values.
123
+ * If validValues is omitted, the field just needs to be present and non-empty.
124
+ */
125
+ export const TOOL_INPUT_RULES: Record<string, Array<{ field: string; validValues?: readonly string[] }>> = {
126
+ create_entity: [
127
+ { field: 'name' },
128
+ { field: 'entity_type', validValues: ENTITY_TYPES },
129
+ ],
130
+ update_entity: [
131
+ { field: 'entity_id' },
132
+ ],
133
+ add_observation: [
134
+ { field: 'entity_id' },
135
+ { field: 'content' },
136
+ ],
137
+ search_semantic: [
138
+ { field: 'query' },
139
+ ],
140
+ search_entities: [], // all fields optional
141
+ create_relation: [
142
+ { field: 'source_entity_id' },
143
+ { field: 'target_entity_id' },
144
+ { field: 'relation_type' },
145
+ ],
146
+ mark_entity_inactive: [
147
+ { field: 'entity_id' },
148
+ ],
149
+ mark_observation_inactive: [
150
+ { field: 'observation_id' },
151
+ ],
152
+ reactivate_entity: [
153
+ { field: 'entity_id' },
154
+ ],
155
+ get_entities: [
156
+ { field: 'entity_ids' },
157
+ ],
158
+ list_entities: [], // all fields optional
159
+ };
160
+ `;
161
+
162
+ mkdirSync(outDir, { recursive: true });
163
+ writeFileSync(outFile, content, 'utf-8');
164
+ console.log(`Generated ${outFile} (${content.length} bytes)`);
@@ -0,0 +1,25 @@
1
+ FROM ubuntu:24.04
2
+
3
+ RUN apt-get update \
4
+ && apt-get install -y --no-install-recommends \
5
+ curl ca-certificates git \
6
+ && rm -rf /var/lib/apt/lists/*
7
+
8
+ # Install Node.js 20.x
9
+ RUN curl -fsSL https://deb.nodesource.com/setup_20.x | bash - \
10
+ && apt-get install -y --no-install-recommends nodejs \
11
+ && rm -rf /var/lib/apt/lists/*
12
+
13
+ # Create unprivileged test user (mirrors fresh Linux install)
14
+ RUN useradd -m -s /bin/bash testuser
15
+
16
+ USER testuser
17
+ WORKDIR /home/testuser
18
+
19
+ # Pre-create .claude so the installer doesn't fail on prerequisite check
20
+ RUN mkdir -p /home/testuser/.claude
21
+
22
+ ENV HOME=/home/testuser
23
+ ENV CI=1
24
+
25
+ ENTRYPOINT ["/bin/bash"]
@@ -0,0 +1,32 @@
1
+ FROM ubuntu:24.04
2
+
3
+ RUN apt-get update \
4
+ && apt-get install -y --no-install-recommends \
5
+ curl ca-certificates git \
6
+ && rm -rf /var/lib/apt/lists/*
7
+
8
+ # Install Node.js 20.x
9
+ RUN curl -fsSL https://deb.nodesource.com/setup_20.x | bash - \
10
+ && apt-get install -y --no-install-recommends nodejs \
11
+ && rm -rf /var/lib/apt/lists/*
12
+
13
+ # Create unprivileged test user
14
+ RUN useradd -m -s /bin/bash testuser
15
+
16
+ USER testuser
17
+ WORKDIR /home/testuser
18
+
19
+ # Pre-create .claude so the installer doesn't fail on prerequisite check
20
+ RUN mkdir -p /home/testuser/.claude
21
+
22
+ ENV HOME=/home/testuser
23
+ ENV CI=1
24
+
25
+ # WSL simulation: these env vars trigger WSL-specific paths in installer
26
+ ENV WSL_DISTRO_NAME=Ubuntu-24.04
27
+ ENV WSLENV=1
28
+
29
+ # Ensure no display (headless WSL scenario — no X11/Wayland)
30
+ # DISPLAY and WAYLAND_DISPLAY are intentionally unset
31
+
32
+ ENTRYPOINT ["/bin/bash"]
@@ -0,0 +1,164 @@
1
+ #!/usr/bin/env node
2
+ /**
3
+ * Mock auth server for install-matrix CI tests.
4
+ *
5
+ * Simulates the gramatr API server endpoints used by the installer and
6
+ * gmtr-login device flow. Uses only Node built-ins (node:http) so it can
7
+ * run inside Docker containers without tsx or any npm dependencies.
8
+ *
9
+ * Binds to port 18930 (or MOCK_PORT env).
10
+ *
11
+ * Endpoints:
12
+ * GET /health -> server health check
13
+ * POST /device/start -> device authorization initiation
14
+ * POST /device/token -> device token polling (pending -> success)
15
+ * POST /mcp -> minimal JSON-RPC success (validates token)
16
+ * GET /verify -> mock verification page
17
+ */
18
+
19
+ import { createServer } from 'node:http';
20
+
21
+ const PORT = parseInt(process.env.MOCK_PORT || '18930', 10);
22
+
23
+ // In-memory state for device flow polling.
24
+ // First poll returns authorization_pending, second returns the token.
25
+ let pollCount = 0;
26
+
27
+ const MOCK_TOKEN = 'mock-access-token-for-ci';
28
+
29
+ function json(res, status, body) {
30
+ const payload = JSON.stringify(body);
31
+ res.writeHead(status, {
32
+ 'Content-Type': 'application/json',
33
+ 'Content-Length': Buffer.byteLength(payload),
34
+ });
35
+ res.end(payload);
36
+ }
37
+
38
+ function html(res, status, body) {
39
+ res.writeHead(status, { 'Content-Type': 'text/html' });
40
+ res.end(body);
41
+ }
42
+
43
+ function readBody(req) {
44
+ return new Promise((resolve) => {
45
+ const chunks = [];
46
+ req.on('data', (c) => chunks.push(c));
47
+ req.on('end', () => resolve(Buffer.concat(chunks).toString('utf8')));
48
+ });
49
+ }
50
+
51
+ const server = createServer(async (req, res) => {
52
+ const url = new URL(req.url || '/', `http://localhost:${PORT}`);
53
+ const method = req.method || 'GET';
54
+
55
+ // Log every request for CI debugging
56
+ process.stderr.write(`[mock-auth] ${method} ${url.pathname}\n`);
57
+
58
+ // ── GET /health ──
59
+ if (method === 'GET' && url.pathname === '/health') {
60
+ json(res, 200, { status: 'ok', version: '0.0.0-mock' });
61
+ return;
62
+ }
63
+
64
+ // ── POST /device/start ──
65
+ if (method === 'POST' && url.pathname === '/device/start') {
66
+ // Reset poll counter for each new device flow
67
+ pollCount = 0;
68
+ json(res, 200, {
69
+ device_code: 'mock-device-code-ci',
70
+ user_code: 'MOCK-1234',
71
+ verification_uri: `http://localhost:${PORT}/verify`,
72
+ verification_uri_complete: `http://localhost:${PORT}/verify?code=MOCK-1234`,
73
+ expires_in: 300,
74
+ interval: 1,
75
+ });
76
+ return;
77
+ }
78
+
79
+ // ── POST /device/token ──
80
+ if (method === 'POST' && url.pathname === '/device/token') {
81
+ pollCount++;
82
+ if (pollCount < 2) {
83
+ // First poll: still pending
84
+ json(res, 428, {
85
+ error: 'authorization_pending',
86
+ error_description: 'The user has not yet authorized the device.',
87
+ interval: 1,
88
+ });
89
+ } else {
90
+ // Second poll: success
91
+ json(res, 200, {
92
+ access_token: MOCK_TOKEN,
93
+ token_type: 'bearer',
94
+ expires_in: 31536000,
95
+ });
96
+ }
97
+ return;
98
+ }
99
+
100
+ // ── POST /mcp ──
101
+ // Minimal JSON-RPC response so testToken() in gmtr-login.ts considers the
102
+ // token valid. The client sends a tools/call request and parses SSE lines.
103
+ if (method === 'POST' && url.pathname === '/mcp') {
104
+ const body = await readBody(req);
105
+ let id = 1;
106
+ try {
107
+ const parsed = JSON.parse(body);
108
+ id = parsed.id || 1;
109
+ } catch { /* ignore */ }
110
+
111
+ // Check authorization header
112
+ const auth = req.headers.authorization || '';
113
+ if (!auth.includes(MOCK_TOKEN)) {
114
+ // Reject invalid tokens
115
+ const ssePayload = `data: ${JSON.stringify({
116
+ jsonrpc: '2.0',
117
+ id,
118
+ error: { code: -32000, message: 'JWT token is required' },
119
+ })}\n\n`;
120
+ res.writeHead(200, {
121
+ 'Content-Type': 'text/event-stream',
122
+ 'Cache-Control': 'no-cache',
123
+ });
124
+ res.end(ssePayload);
125
+ return;
126
+ }
127
+
128
+ // Valid token: return a successful aggregate_stats response
129
+ const ssePayload = `data: ${JSON.stringify({
130
+ jsonrpc: '2.0',
131
+ id,
132
+ result: {
133
+ content: [{ type: 'text', text: JSON.stringify({ entities: 0, observations: 0, relations: 0 }) }],
134
+ },
135
+ })}\n\n`;
136
+ res.writeHead(200, {
137
+ 'Content-Type': 'text/event-stream',
138
+ 'Cache-Control': 'no-cache',
139
+ });
140
+ res.end(ssePayload);
141
+ return;
142
+ }
143
+
144
+ // ── GET /verify ──
145
+ if (method === 'GET' && url.pathname === '/verify') {
146
+ html(res, 200, `<!DOCTYPE html>
147
+ <html><head><title>Mock Verification</title></head>
148
+ <body><h1>Mock verification complete</h1>
149
+ <p>This is the CI mock auth server. Device code approved automatically.</p>
150
+ </body></html>`);
151
+ return;
152
+ }
153
+
154
+ // ── Catch-all ──
155
+ json(res, 404, { error: 'not_found', path: url.pathname });
156
+ });
157
+
158
+ server.listen(PORT, '127.0.0.1', () => {
159
+ process.stderr.write(`[mock-auth] Listening on http://127.0.0.1:${PORT}\n`);
160
+ });
161
+
162
+ // Graceful shutdown
163
+ process.on('SIGTERM', () => { server.close(); process.exit(0); });
164
+ process.on('SIGINT', () => { server.close(); process.exit(0); });
@@ -0,0 +1,335 @@
1
+ #!/usr/bin/env bash
2
+ set -euo pipefail
3
+
4
+ # ──────────────────────────────────────────────────────────────────────────────
5
+ # run-install-test.sh — Fresh-profile install harness for @gramatr/client
6
+ #
7
+ # Validates that `npx @gramatr/client install claude-code` works correctly
8
+ # on a clean machine with no prior gramatr installation.
9
+ #
10
+ # Usage:
11
+ # ./run-install-test.sh # auto-find tarball
12
+ # ./run-install-test.sh /path/to/gramatr.tgz # explicit tarball
13
+ #
14
+ # Exit codes:
15
+ # 0 — all phases passed
16
+ # 1 — one or more phases failed
17
+ # ──────────────────────────────────────────────────────────────────────────────
18
+
19
+ PASS_COUNT=0
20
+ FAIL_COUNT=0
21
+ MOCK_PID=""
22
+
23
+ cleanup() {
24
+ if [ -n "$MOCK_PID" ] && kill -0 "$MOCK_PID" 2>/dev/null; then
25
+ kill "$MOCK_PID" 2>/dev/null || true
26
+ wait "$MOCK_PID" 2>/dev/null || true
27
+ fi
28
+ }
29
+ trap cleanup EXIT
30
+
31
+ # ── Helpers ──
32
+
33
+ pass() {
34
+ echo " PASS: $1"
35
+ PASS_COUNT=$((PASS_COUNT + 1))
36
+ }
37
+
38
+ fail() {
39
+ echo " FAIL: $1"
40
+ FAIL_COUNT=$((FAIL_COUNT + 1))
41
+ }
42
+
43
+ assert_file_exists() {
44
+ local path="$1"
45
+ local label="${2:-$1}"
46
+ if [ -f "$path" ]; then
47
+ pass "$label exists"
48
+ else
49
+ fail "$label missing ($path)"
50
+ fi
51
+ }
52
+
53
+ assert_json_field() {
54
+ local file="$1"
55
+ local query="$2"
56
+ local label="$3"
57
+ local val
58
+ val=$(node -e "
59
+ const fs = require('fs');
60
+ const data = JSON.parse(fs.readFileSync('$file', 'utf8'));
61
+ const val = $query;
62
+ if (val !== undefined && val !== null) {
63
+ if (typeof val === 'object') process.stdout.write(JSON.stringify(val));
64
+ else process.stdout.write(String(val));
65
+ }
66
+ " 2>/dev/null || true)
67
+ if [ -n "$val" ]; then
68
+ pass "$label (= $val)"
69
+ else
70
+ fail "$label — field missing or null in $file"
71
+ fi
72
+ }
73
+
74
+ assert_no_substring() {
75
+ local file="$1"
76
+ local needle="$2"
77
+ local label="$3"
78
+ if grep -q "$needle" "$file" 2>/dev/null; then
79
+ fail "$label — found '$needle' in $file"
80
+ else
81
+ pass "$label"
82
+ fi
83
+ }
84
+
85
+ # ── Locate tarball ──
86
+
87
+ TARBALL="${1:-}"
88
+
89
+ if [ -z "$TARBALL" ]; then
90
+ for candidate in /artifact/*.tgz ./artifact/*.tgz; do
91
+ if [ -f "$candidate" ]; then
92
+ TARBALL="$(cd "$(dirname "$candidate")" && pwd)/$(basename "$candidate")"
93
+ break
94
+ fi
95
+ done
96
+ fi
97
+
98
+ if [ -z "$TARBALL" ] || [ ! -f "$TARBALL" ]; then
99
+ echo "ERROR: No tarball found. Pass path as \$1 or place in /artifact/ or ./artifact/"
100
+ exit 1
101
+ fi
102
+
103
+ # ── Install tarball into temp dir to get the bin ──
104
+ # npx cannot run a local tarball directly — it tries to execute the .tgz file.
105
+ # Instead: npm install the tarball, then call the bin via node_modules/.bin/.
106
+
107
+ INSTALL_DIR=$(mktemp -d)
108
+ cd "$INSTALL_DIR"
109
+ npm init -y > /dev/null 2>&1
110
+ npm install "$TARBALL" --no-fund --no-audit > /dev/null 2>&1 || {
111
+ echo "ERROR: npm install of tarball failed"
112
+ exit 1
113
+ }
114
+ GRAMATR_BIN="$INSTALL_DIR/node_modules/.bin/gramatr"
115
+ if [ ! -f "$GRAMATR_BIN" ]; then
116
+ echo "ERROR: gramatr bin not found after npm install"
117
+ exit 1
118
+ fi
119
+
120
+ echo "========================================"
121
+ echo " gramatr install-matrix test harness"
122
+ echo "========================================"
123
+ echo ""
124
+ echo " Tarball: $TARBALL"
125
+ echo " Bin: $GRAMATR_BIN"
126
+ echo " HOME: $HOME"
127
+ echo " Node: $(node --version)"
128
+ echo " Platform: $(uname -s) $(uname -m)"
129
+ echo ""
130
+
131
+ # ── Phase 0: Start mock auth server ──
132
+
133
+ echo "--- Phase 0: Mock auth server ---"
134
+
135
+ MOCK_SERVER_SCRIPT=""
136
+ # Look for mock-auth-server.mjs in known locations
137
+ for candidate in \
138
+ /test/mock-auth-server.mjs \
139
+ "$(dirname "$0")/mock-auth-server.mjs" \
140
+ ./packages/client/test/install-matrix/mock-auth-server.mjs; do
141
+ if [ -f "$candidate" ]; then
142
+ MOCK_SERVER_SCRIPT="$candidate"
143
+ break
144
+ fi
145
+ done
146
+
147
+ if [ -z "$MOCK_SERVER_SCRIPT" ]; then
148
+ echo " WARNING: mock-auth-server.mjs not found — skipping auth phases"
149
+ else
150
+ # Check if already running
151
+ if curl -sf http://127.0.0.1:18930/health >/dev/null 2>&1; then
152
+ pass "Mock auth server already running"
153
+ else
154
+ node "$MOCK_SERVER_SCRIPT" &
155
+ MOCK_PID=$!
156
+ # Wait for server to be ready (up to 5 seconds)
157
+ for i in $(seq 1 50); do
158
+ if curl -sf http://127.0.0.1:18930/health >/dev/null 2>&1; then
159
+ break
160
+ fi
161
+ sleep 0.1
162
+ done
163
+ if curl -sf http://127.0.0.1:18930/health >/dev/null 2>&1; then
164
+ pass "Mock auth server started (PID $MOCK_PID)"
165
+ else
166
+ fail "Mock auth server failed to start"
167
+ exit 1
168
+ fi
169
+ fi
170
+ fi
171
+
172
+ echo ""
173
+
174
+ # ── Phase 1: Fresh install ──
175
+
176
+ echo "--- Phase 1: Fresh install ---"
177
+
178
+ # Set env so installer uses mock server and runs non-interactively
179
+ export GRAMATR_URL="http://127.0.0.1:18930/mcp"
180
+ export GRAMATR_API_KEY="mock-access-token-for-ci"
181
+ export CI=1
182
+
183
+ # Run the installer via the bin entry installed from the tarball
184
+ "$GRAMATR_BIN" install claude-code --yes --name TestUser --timezone UTC 2>&1 || {
185
+ fail "Installer exited with non-zero status"
186
+ exit 1
187
+ }
188
+ pass "Installer completed successfully"
189
+
190
+ GMTR_DIR="$HOME/.gramatr"
191
+
192
+ # Verify critical files
193
+ assert_file_exists "$GMTR_DIR/hooks/gramatr-prompt-enricher.hook.ts" "hooks/gramatr-prompt-enricher.hook.ts"
194
+ assert_file_exists "$GMTR_DIR/hooks/gramatr-tool-tracker.hook.ts" "hooks/gramatr-tool-tracker.hook.ts"
195
+ assert_file_exists "$GMTR_DIR/hooks/gramatr-security-validator.hook.ts" "hooks/gramatr-security-validator.hook.ts"
196
+ assert_file_exists "$GMTR_DIR/hooks/gramatr-rating-capture.hook.ts" "hooks/gramatr-rating-capture.hook.ts"
197
+ assert_file_exists "$GMTR_DIR/core/routing.ts" "core/routing.ts"
198
+ assert_file_exists "$GMTR_DIR/core/types.ts" "core/types.ts"
199
+ assert_file_exists "$GMTR_DIR/core/session.ts" "core/session.ts"
200
+ assert_file_exists "$GMTR_DIR/core/version.ts" "core/version.ts"
201
+ assert_file_exists "$GMTR_DIR/core/install.ts" "core/install.ts"
202
+ assert_file_exists "$GMTR_DIR/bin/statusline.ts" "bin/statusline.ts"
203
+ assert_file_exists "$GMTR_DIR/bin/gmtr-login.ts" "bin/gmtr-login.ts"
204
+ assert_file_exists "$GMTR_DIR/CLAUDE.md" "CLAUDE.md"
205
+
206
+ echo ""
207
+
208
+ # ── Phase 2: Settings integrity ──
209
+
210
+ echo "--- Phase 2: Settings integrity ---"
211
+
212
+ SETTINGS="$HOME/.claude/settings.json"
213
+ CLAUDE_JSON="$HOME/.claude.json"
214
+
215
+ assert_file_exists "$SETTINGS" "settings.json"
216
+ assert_file_exists "$CLAUDE_JSON" ".claude.json"
217
+
218
+ # Hooks present
219
+ assert_json_field "$SETTINGS" "data.hooks?.PreToolUse?.length" "PreToolUse hooks registered"
220
+ assert_json_field "$SETTINGS" "data.hooks?.PostToolUse?.length" "PostToolUse hooks registered"
221
+ assert_json_field "$SETTINGS" "data.hooks?.UserPromptSubmit?.length" "UserPromptSubmit hooks registered"
222
+ assert_json_field "$SETTINGS" "data.hooks?.SessionStart?.length" "SessionStart hooks registered"
223
+ assert_json_field "$SETTINGS" "data.hooks?.SessionEnd?.length" "SessionEnd hooks registered"
224
+ assert_json_field "$SETTINGS" "data.hooks?.Stop?.length" "Stop hooks registered"
225
+
226
+ # MCP server registered
227
+ assert_json_field "$CLAUDE_JSON" "data.mcpServers?.gramatr?.url" "MCP server 'gramatr' in .claude.json"
228
+
229
+ # GRAMATR_DIR env set
230
+ assert_json_field "$SETTINGS" "data.env?.GRAMATR_DIR" "GRAMATR_DIR env in settings.json"
231
+
232
+ # Status line configured
233
+ assert_json_field "$SETTINGS" "data.statusLine?.command" "statusLine command in settings.json"
234
+
235
+ # Identity config
236
+ assert_file_exists "$GMTR_DIR/settings.json" "gramatr settings.json (identity)"
237
+ assert_json_field "$GMTR_DIR/settings.json" "data.principal?.name" "principal.name set"
238
+
239
+ echo ""
240
+
241
+ # ── Phase 3: Headless login (mock device flow) ──
242
+
243
+ echo "--- Phase 3: Headless login validation ---"
244
+
245
+ # The installer used GRAMATR_API_KEY which resolveAuthToken picks up first.
246
+ # Verify the token made it through to the config files.
247
+ GMTR_JSON="$HOME/.gramatr.json"
248
+ if [ -f "$GMTR_JSON" ]; then
249
+ pass ".gramatr.json created"
250
+ assert_json_field "$GMTR_JSON" "data.token" "Token stored in .gramatr.json"
251
+ else
252
+ # Token may have been set only in settings.json env — that's also valid
253
+ pass ".gramatr.json not needed (token via env)"
254
+ fi
255
+
256
+ # Verify mock server accepted the token (smoke test the /mcp endpoint)
257
+ if [ -n "$MOCK_SERVER_SCRIPT" ]; then
258
+ MCP_RESULT=$(curl -sf -X POST http://127.0.0.1:18930/mcp \
259
+ -H "Content-Type: application/json" \
260
+ -H "Authorization: Bearer mock-access-token-for-ci" \
261
+ -d '{"jsonrpc":"2.0","id":1,"method":"tools/call","params":{"name":"aggregate_stats","arguments":{}}}' \
262
+ 2>/dev/null || true)
263
+ if echo "$MCP_RESULT" | grep -q '"result"'; then
264
+ pass "Mock MCP endpoint accepted token"
265
+ else
266
+ fail "Mock MCP endpoint rejected token"
267
+ fi
268
+ fi
269
+
270
+ echo ""
271
+
272
+ # ── Phase 4: Idempotent reinstall ──
273
+
274
+ echo "--- Phase 4: Idempotent reinstall ---"
275
+
276
+ # Run install again — should not corrupt settings
277
+ "$GRAMATR_BIN" install claude-code --yes --name TestUser --timezone UTC 2>&1 || {
278
+ fail "Reinstall exited with non-zero status"
279
+ }
280
+
281
+ # Re-verify critical invariants
282
+ assert_file_exists "$GMTR_DIR/hooks/gramatr-prompt-enricher.hook.ts" "hooks/gramatr-prompt-enricher.hook.ts (after reinstall)"
283
+ assert_json_field "$SETTINGS" "data.hooks?.PreToolUse?.length" "PreToolUse hooks still registered (after reinstall)"
284
+ assert_json_field "$CLAUDE_JSON" "data.mcpServers?.gramatr?.url" "MCP server still registered (after reinstall)"
285
+
286
+ # Verify settings.json is valid JSON
287
+ if node -e "JSON.parse(require('fs').readFileSync('$SETTINGS', 'utf8'))" 2>/dev/null; then
288
+ pass "settings.json is valid JSON after reinstall"
289
+ else
290
+ fail "settings.json is corrupt after reinstall"
291
+ fi
292
+
293
+ echo ""
294
+
295
+ # ── Phase 5: No stale paths ──
296
+
297
+ echo "--- Phase 5: No stale paths ---"
298
+
299
+ assert_no_substring "$SETTINGS" "PAI_DIR" "No PAI_DIR in settings.json"
300
+ assert_no_substring "$SETTINGS" "aios-v2-client" "No aios-v2-client in settings.json"
301
+
302
+ if [ -f "$CLAUDE_JSON" ]; then
303
+ assert_no_substring "$CLAUDE_JSON" "PAI_DIR" "No PAI_DIR in .claude.json"
304
+ assert_no_substring "$CLAUDE_JSON" "aios-v2-client" "No aios-v2-client in .claude.json"
305
+ fi
306
+
307
+ # CLAUDE.md should contain GMTR section
308
+ CLAUDE_MD="$HOME/.claude/CLAUDE.md"
309
+ if [ -f "$CLAUDE_MD" ]; then
310
+ if grep -q "GMTR-START" "$CLAUDE_MD"; then
311
+ pass "CLAUDE.md contains GMTR-START marker"
312
+ else
313
+ fail "CLAUDE.md missing GMTR-START marker"
314
+ fi
315
+ else
316
+ fail "CLAUDE.md not created"
317
+ fi
318
+
319
+ echo ""
320
+
321
+ # ── Summary ──
322
+
323
+ echo "========================================"
324
+ echo " Results: $PASS_COUNT passed, $FAIL_COUNT failed"
325
+ echo "========================================"
326
+
327
+ if [ "$FAIL_COUNT" -gt 0 ]; then
328
+ echo ""
329
+ echo " SOME TESTS FAILED"
330
+ exit 1
331
+ else
332
+ echo ""
333
+ echo " ALL TESTS PASSED"
334
+ exit 0
335
+ fi