@omnitype-code/cli 0.1.0 → 0.1.2

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/CHANGELOG.md ADDED
@@ -0,0 +1,50 @@
1
+ # Changelog
2
+
3
+ All notable changes to the **@omnitype-code/cli** package are documented here.
4
+
5
+ ---
6
+
7
+ ## [0.1.2] — 2026-05-16
8
+
9
+ ### Fixed
10
+ - **Sentinel race condition** — Replaced 10s time-window TTL with path-gated matching. Sentinel is only trusted if `sentinel.file === changedPath`, eliminating cross-file attribution contamination.
11
+ - **TranscriptScanner** — Added as a detection tier between env vars and IDE config parsing. Reads the 50KB tail of the most recent JSONL session file per tool, with a 60s cache.
12
+ - **Claude Code hook** — Added to `installAllToolHooks` — was missing from the CLI installer in v0.1.1.
13
+ - **Hook model fallbacks removed** — Hook writer now skips write if the model cannot be resolved. No more hardcoded `claude-sonnet-4-6` fallback.
14
+ - **`SENTINEL_MAX_AGE_MS`** raised from 10s to 30s for the strict (no file field) fallback path.
15
+
16
+ ### Changed
17
+ - Detection pipeline order is now: sentinel (universal) → sentinel (hooks) → env vars → transcript scan → IDE configs → ps → lsof.
18
+
19
+ ### Added
20
+ - **Cline hook installer** — drops a `PreToolUse` script into `~/Documents/Cline/Hooks/` on startup. Idempotent, fires only for file-modifying tools.
21
+ - `installAllToolHooks` now covers Claude Code, Cursor, Windsurf, Codex, and Cline.
22
+
23
+ ---
24
+
25
+ ## [0.1.1] — 2026-05-14
26
+
27
+ ### Added
28
+ - **`omnitype signal --model <name>`** — Manually report active AI models from scripts or unsupported tools.
29
+ - **`omnitype blame <file>`** — ANSI-colored per-line attribution with model names.
30
+ - **`omnitype daemon`** — Editor-agnostic file watching for Cursor, Windsurf, JetBrains, Neovim, and any other editor. Auto-yields to the VS Code extension for live-tracked workspaces (30-second yield guard).
31
+ - **`omnitype hooks install`** — Installs a pre-push git hook that writes git notes and syncs attribution to the cloud.
32
+ - **`omnitype syncNow`** — Manual provenance sync command.
33
+ - **Smart Claude hook** — Automatically detects model preferences from `~/.claude/settings.json`.
34
+ - **Protocol v1.1 support** — Writes `file` and `genId` fields in sentinel output.
35
+ - **Rich CLI UI** — Colors, progress spinners, and structured error reporting.
36
+ - **Org-only cloud storage** — Individual users remain local-only. Cloud sync gates on organization membership.
37
+ - **Provenance routes modularized** — Internal route handlers split into separate files.
38
+
39
+ ---
40
+
41
+ ## [0.1.0] — 2026-05-13
42
+
43
+ ### Added
44
+ - Initial CLI package.
45
+ - Git hook manager for push coverage — hooks written per-repo on install.
46
+ - AI model attribution tracking wired into personal vs. org project storage.
47
+ - File deletion tracking with per-line timestamps in provenance metadata.
48
+ - Cloud sync to S3 blobs with MongoDB manifest references.
49
+
50
+ ---
package/LICENSE ADDED
@@ -0,0 +1,21 @@
1
+ MIT License
2
+
3
+ Copyright (c) 2026 OmniType
4
+
5
+ Permission is hereby granted, free of charge, to any person obtaining a copy
6
+ of this software and associated documentation files (the "Software"), to deal
7
+ in the Software without restriction, including without limitation the rights
8
+ to use, copy, modify, merge, publish, distribute, sublicense, and/or sell
9
+ copies of the Software, and to permit persons to whom the Software is
10
+ furnished to do so, subject to the following conditions:
11
+
12
+ The above copyright notice and this permission notice shall be included in all
13
+ copies or substantial portions of the Software.
14
+
15
+ THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
16
+ IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY,
17
+ FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE
18
+ AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER
19
+ LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM,
20
+ OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE
21
+ SOFTWARE.
package/README.md ADDED
@@ -0,0 +1,104 @@
1
+ # OmniType CLI
2
+
3
+ **Know exactly who wrote every line of code — you, your AI, or a paste.**
4
+
5
+ OmniType is an editor-agnostic code provenance engine. It tracks which lines were written by a human, generated by an AI model (Claude, GPT-4, Gemini, etc.), or pasted in — across every tool you use.
6
+
7
+ No vendor lock-in. Works with Claude Code, Cursor, Windsurf, VS Code, Aider, Continue, and any AI coding tool.
8
+
9
+ ---
10
+
11
+ ## Install
12
+
13
+ ```bash
14
+ npm install -g @omnitype-code/cli
15
+ ```
16
+
17
+ ---
18
+
19
+ ## Quick Start
20
+
21
+ ```bash
22
+ # Sign in
23
+ omnitype login
24
+
25
+ # Start tracking in your project
26
+ omnitype daemon
27
+
28
+ # See who wrote what
29
+ omnitype blame src/app.ts
30
+
31
+ # Check your AI vs human split
32
+ omnitype stats
33
+ ```
34
+
35
+ ---
36
+
37
+ ## Commands
38
+
39
+ | Command | Description |
40
+ |---|---|
41
+ | `omnitype login` | Sign in to OmniType Cloud |
42
+ | `omnitype logout` | Sign out |
43
+ | `omnitype status` | Show auth, detected model, and repo info |
44
+ | `omnitype whoami` | Display your profile |
45
+ | `omnitype daemon` | Watch a directory and track provenance in real time |
46
+ | `omnitype blame <file>` | Enhanced `git blame` with AI/model attribution overlay |
47
+ | `omnitype stats` | Personal provenance stats across all projects |
48
+ | `omnitype hooks install` | Install git hooks for commit-level tracking |
49
+ | `omnitype hooks uninstall` | Remove git hooks |
50
+ | `omnitype notes fetch` | Pull attribution notes from remote |
51
+ | `omnitype notes push` | Push attribution notes to remote |
52
+ | `omnitype setup-claude-hook` | Auto-detect model in Claude Code sessions |
53
+ | `omnitype setup-vscode-hook` | Install into VS Code, Cursor, Windsurf, and other forks |
54
+ | `omnitype signal -m <model>` | Manually report the active AI model |
55
+
56
+ ---
57
+
58
+ ## Editor Integrations
59
+
60
+ ### Claude Code
61
+ ```bash
62
+ omnitype setup-claude-hook
63
+ ```
64
+ Installs a hook into `~/.claude/settings.json` that captures the active model on every file edit — automatically.
65
+
66
+ ### VS Code / Cursor / Windsurf / and more
67
+ ```bash
68
+ omnitype setup-vscode-hook
69
+ ```
70
+ Detects every installed VS Code fork and enables the OmniType extension across all of them in one shot.
71
+
72
+ ### Any AI tool
73
+ ```bash
74
+ omnitype signal --model gpt-4o --tool aider
75
+ ```
76
+ Works with any tool that doesn't have a native integration yet.
77
+
78
+ ---
79
+
80
+ ## How It Works
81
+
82
+ 1. **Daemon** watches your project directory for file changes and records which AI model was active at the time of each edit.
83
+ 2. **Git hooks** capture provenance at commit time, stored as git notes so they travel with your repo.
84
+ 3. **Blame** overlays model attribution on top of standard `git blame` output.
85
+ 4. **Stats** aggregates your AI vs. human vs. paste ratios across all tracked projects.
86
+
87
+ Provenance data is stored locally and optionally synced to OmniType Cloud for team dashboards and org-level analytics.
88
+
89
+ ---
90
+
91
+ ## Options
92
+
93
+ ```bash
94
+ omnitype daemon --path ./src --project my-app
95
+ omnitype blame src/index.ts --stats
96
+ omnitype stats --top 20
97
+ omnitype notes push --remote upstream
98
+ ```
99
+
100
+ ---
101
+
102
+ ## License
103
+
104
+ MIT
@@ -1,15 +1,4 @@
1
1
  "use strict";
2
- /**
3
- * CLI ModelDetector — editor-agnostic model detection.
4
- *
5
- * Detection tiers:
6
- * 1. Universal sentinel (~/.omnitype/active-model.json)
7
- * 2. Hooks sentinel file (~/.claude/provenance-hook.json)
8
- * 3. Host IDE config (Cursor/Windsurf/Zed settings.json — scanned generically)
9
- * 4. Config files (per-tool config paths: .aider.conf.yml, etc.)
10
- * 5. Environment variables (CLAUDE_MODEL, AIDER_MODEL, etc.)
11
- * 6. Process detection (lsof / ps — identifies the writing process)
12
- */
13
2
  var __createBinding = (this && this.__createBinding) || (Object.create ? (function(o, m, k, k2) {
14
3
  if (k2 === undefined) k2 = k;
15
4
  var desc = Object.getOwnPropertyDescriptor(m, k);
@@ -49,116 +38,111 @@ const fs = __importStar(require("fs"));
49
38
  const os = __importStar(require("os"));
50
39
  const path = __importStar(require("path"));
51
40
  const child_process_1 = require("child_process");
41
+ const TranscriptScanner_1 = require("./TranscriptScanner");
52
42
  const UNKNOWN = { model: 'unknown', tool: 'unknown', confidence: 'low' };
53
- const HOOKS_SENTINEL_PATH = path.join(os.homedir(), '.claude', 'provenance-hook.json');
54
- const UNIVERSAL_SENTINEL_PATH = path.join(os.homedir(), '.omnitype', 'active-model.json');
55
- const SENTINEL_MAX_AGE_MS = 10000;
56
- const MODEL_PATTERN = /\b(claude-[\w.-]+|gpt-[\w.-]+|o[1234](?:-[\w.-]+)?|gemini-[\w.-]+|gemma[\w.-]*|llama-[\w.-]+|mistral[\w.-]*|codestral[\w.-]*|deepseek[\w.-]*|qwen[\w.-]+|command[\w.-]*|phi[\w.-]+|grok[\w.-]*|kimi[\w.-]*|moonshot[\w.-]*)\b/i;
57
- // Maps known CLI/IDE env vars to their tool
43
+ const SENTINEL_MAX_AGE_MS = 30000; // 30 s — covers slow multi-file AI diffs
44
+ const UNIVERSAL_PATH = path.join(os.homedir(), '.omnitype', 'active-model.json');
45
+ const HOOKS_PATH = path.join(os.homedir(), '.claude', 'provenance-hook.json');
46
+ // Matches model identifier strings from all major providers.
47
+ const MODEL_PATTERN = /\b(claude-[\w.-]+|gpt-[\w.-]+|o[134](?:-[\w.-]+)?|gemini-[\w.-]+|gemma[\w.-]*|llama-[\w.-]+|mistral[\w.-]*|codestral[\w.-]*|deepseek[\w.-]*|qwen[\w.-]+|command[\w.-]*|phi[\w.-]+|grok[\w.-]*|kimi[\w.-]*|moonshot[\w.-]*)\b/i;
48
+ // Each tool lists its OWN env vars only. Shared vars (OPENAI_MODEL) are NOT duplicated
49
+ // across tools — the first matching entry wins, so ambiguous vars are assigned to
50
+ // the most common owner (openai). Tools with a dedicated var (CODEX_MODEL) are checked
51
+ // earlier so they can claim edits even when OPENAI_MODEL is also set.
58
52
  const ENV_VARS = [
59
- { vars: ['CLAUDE_MODEL', 'CLAUDE_CODE_MODEL', 'ANTHROPIC_MODEL'], tool: 'claude-code' },
53
+ { vars: ['CLAUDE_CODE_MODEL', 'CLAUDE_MODEL', 'ANTHROPIC_MODEL'], tool: 'claude-code' },
60
54
  { vars: ['AIDER_MODEL'], tool: 'aider' },
55
+ { vars: ['CODEX_MODEL'], tool: 'codex' }, // before generic OPENAI_MODEL
61
56
  { vars: ['OPENAI_MODEL', 'OPENAI_API_MODEL'], tool: 'openai' },
62
- { vars: ['GEMINI_MODEL', 'GEMINI_API_KEY'], tool: 'gemini-cli' },
57
+ { vars: ['GEMINI_MODEL'], tool: 'gemini-cli' },
63
58
  { vars: ['OLLAMA_MODEL'], tool: 'ollama' },
64
59
  { vars: ['COPILOT_MODEL'], tool: 'copilot' },
65
60
  { vars: ['LLM_MODEL'], tool: 'openhands' },
66
61
  { vars: ['TABBY_MODEL'], tool: 'tabby' },
67
62
  ];
68
- const KNOWN_FORKS = [
69
- 'Cursor', 'Windsurf', 'PearAI', 'Void', 'Trae', 'Zed', 'Antigravity'
70
- ];
71
- // lsof command-name tool mapping
72
- const LSOF_CMD_MAP = [
63
+ const KNOWN_FORKS = ['Cursor', 'Windsurf', 'PearAI', 'Void', 'Trae', 'Zed', 'Antigravity'];
64
+ const AUTO_SENTINELS = new Set(['auto', 'cursor-auto', 'windsurf-auto', 'default', 'best']);
65
+ // Host IDE processes: always running — their presence does NOT mean they caused the edit.
66
+ const HOST_IDE_PROCS = new Set(['cursor', 'windsurf', 'antigravity', 'pearai', 'void', 'trae', 'zed']);
67
+ const PROC_MAP = [
73
68
  { match: 'claude', tool: 'claude-code' },
74
69
  { match: 'aider', tool: 'aider' },
75
- { match: 'cursor', tool: 'cursor' },
76
- { match: 'windsurf', tool: 'windsurf' },
77
- { match: 'zed', tool: 'zed' },
78
- { match: 'pearai', tool: 'pearai' },
79
- { match: 'void', tool: 'void' },
80
- { match: 'tabby', tool: 'tabby' },
81
70
  { match: 'goose', tool: 'goose' },
82
- { match: 'node', tool: 'unknown-cli' },
83
- { match: 'python', tool: 'unknown-cli' },
71
+ { match: 'codex', tool: 'codex' },
72
+ { match: 'tabby', tool: 'tabby' },
84
73
  ];
85
74
  class ModelDetector {
86
75
  detect(changedFilePath) {
87
- return (this._fromUniversalSentinel() ??
88
- this._fromHooksSentinel() ??
89
- this._fromIdeConfigs() ??
76
+ return (this._sentinel(UNIVERSAL_PATH, changedFilePath) ??
77
+ this._sentinel(HOOKS_PATH, changedFilePath) ??
90
78
  this._fromEnv() ??
91
- this._fromPsPatterns() ??
79
+ this._fromTranscripts() ??
80
+ this._fromIdeConfigs() ??
81
+ this._fromPs() ??
92
82
  (changedFilePath ? this._fromLsof(changedFilePath) : undefined) ??
93
83
  UNKNOWN);
94
84
  }
95
- _fromUniversalSentinel() {
96
- try {
97
- const stat = fs.statSync(UNIVERSAL_SENTINEL_PATH);
98
- if (Date.now() - stat.mtimeMs > SENTINEL_MAX_AGE_MS)
99
- return undefined;
100
- const data = JSON.parse(fs.readFileSync(UNIVERSAL_SENTINEL_PATH, 'utf8'));
101
- if (!data?.model || data.model === 'unknown')
102
- return undefined;
103
- return { model: data.model, tool: data.tool ?? 'unknown-tool', confidence: 'deterministic' };
104
- }
105
- catch {
85
+ _fromTranscripts() {
86
+ const r = (0, TranscriptScanner_1.scanTranscripts)();
87
+ if (!r)
106
88
  return undefined;
107
- }
89
+ return { model: r.model, tool: r.tool, confidence: 'high' };
108
90
  }
109
- _fromHooksSentinel() {
91
+ _sentinel(filePath, changedPath) {
110
92
  try {
111
- const stat = fs.statSync(HOOKS_SENTINEL_PATH);
112
- if (Date.now() - stat.mtimeMs > SENTINEL_MAX_AGE_MS)
93
+ const mtime = fs.statSync(filePath).mtimeMs;
94
+ const d = JSON.parse(fs.readFileSync(filePath, 'utf8'));
95
+ if (!d?.model || d.model === 'unknown')
113
96
  return undefined;
114
- const data = JSON.parse(fs.readFileSync(HOOKS_SENTINEL_PATH, 'utf8'));
115
- if (!data?.model || data.model === 'unknown')
97
+ if (d.file && changedPath) {
98
+ // File-path gated: trust only if the sentinel targets this exact file.
99
+ // Use a generous TTL since path specificity eliminates cross-file contamination.
100
+ if (d.file !== changedPath)
101
+ return undefined;
102
+ if (Date.now() - mtime > 120000)
103
+ return undefined; // 2 min max
104
+ }
105
+ else {
106
+ // No path info: fall back to strict TTL to minimise false attribution.
107
+ if (Date.now() - mtime > SENTINEL_MAX_AGE_MS)
108
+ return undefined;
109
+ }
110
+ if (!d?.model || d.model === 'unknown')
116
111
  return undefined;
117
- return { model: data.model, tool: data.tool ?? 'claude-code', confidence: 'deterministic' };
112
+ return { model: d.model, tool: d.tool ?? 'unknown-tool', confidence: 'deterministic' };
118
113
  }
119
114
  catch {
120
115
  return undefined;
121
116
  }
122
117
  }
123
- _fromIdeConfigs() {
124
- for (const appName of KNOWN_FORKS) {
125
- const scan = this._scanIdeSettings(appName);
126
- if (scan) {
127
- return {
128
- model: scan.model,
129
- tool: appName.toLowerCase(),
130
- confidence: scan.isAuto ? 'medium' : 'high'
131
- };
132
- }
133
- }
134
- return undefined;
135
- }
136
118
  _fromEnv() {
137
- for (const { vars, tool } of ENV_VARS) {
119
+ for (const { vars, tool } of ENV_VARS)
138
120
  for (const v of vars) {
139
121
  const val = process.env[v];
140
- if (val && MODEL_PATTERN.test(val)) {
122
+ if (val && MODEL_PATTERN.test(val))
141
123
  return { model: val, tool, confidence: 'high' };
142
- }
143
124
  }
125
+ return undefined;
126
+ }
127
+ _fromIdeConfigs() {
128
+ for (const appName of KNOWN_FORKS) {
129
+ const scan = this._scanIdeSettings(appName);
130
+ if (scan)
131
+ return { model: scan.model, tool: appName.toLowerCase(), confidence: scan.isAuto ? 'medium' : 'high' };
144
132
  }
145
133
  return undefined;
146
134
  }
147
- _fromPsPatterns() {
135
+ _fromPs() {
148
136
  if (process.platform === 'win32')
149
137
  return undefined;
150
138
  try {
151
139
  const lines = (0, child_process_1.execFileSync)('ps', ['ax', '-o', 'args='], { timeout: 800, encoding: 'utf8' }).split('\n');
152
- for (const line of lines) {
153
- for (const entry of LSOF_CMD_MAP) {
154
- if (line.toLowerCase().includes(entry.match)) {
155
- // Fallback model name
156
- return { model: `${entry.tool}-default`, tool: entry.tool, confidence: 'low' };
157
- }
158
- }
159
- }
140
+ for (const line of lines)
141
+ for (const { match, tool } of PROC_MAP)
142
+ if (line.toLowerCase().includes(match) && !HOST_IDE_PROCS.has(tool))
143
+ return { model: `${tool}-default`, tool, confidence: 'low' };
160
144
  }
161
- catch { /* ps unavailable */ }
145
+ catch { }
162
146
  return undefined;
163
147
  }
164
148
  _fromLsof(filePath) {
@@ -170,53 +154,42 @@ class ModelDetector {
170
154
  if (!line.startsWith('c'))
171
155
  continue;
172
156
  const cmd = line.slice(1).toLowerCase();
173
- for (const entry of LSOF_CMD_MAP) {
174
- if (cmd.includes(entry.match)) {
175
- return { model: `${entry.tool}-default`, tool: entry.tool, confidence: 'low' };
176
- }
177
- }
157
+ for (const { match, tool } of PROC_MAP)
158
+ if (cmd.includes(match) && !HOST_IDE_PROCS.has(tool))
159
+ return { model: `${tool}-default`, tool, confidence: 'low' };
178
160
  }
179
161
  }
180
- catch { /* lsof unavailable */ }
162
+ catch { }
181
163
  return undefined;
182
164
  }
183
165
  _scanIdeSettings(appName) {
184
- for (const p of this._getIdeSettingsPaths(appName)) {
166
+ for (const p of this._idePaths(appName)) {
185
167
  try {
186
- const raw = fs.readFileSync(p, 'utf8');
187
- const json = JSON.parse(raw);
188
- const flat = this._flattenObject(json);
189
- const AUTO_SENTINELS = new Set(['auto', 'cursor-auto', 'windsurf-auto', 'default', 'best']);
168
+ const flat = this._flatten(JSON.parse(fs.readFileSync(p, 'utf8')));
190
169
  const candidates = [];
191
170
  for (const [key, value] of Object.entries(flat)) {
192
- if (typeof value !== 'string' || !value)
193
- continue;
194
- if (!key.toLowerCase().includes('model'))
171
+ if (typeof value !== 'string' || !key.toLowerCase().includes('model'))
195
172
  continue;
196
173
  if (AUTO_SENTINELS.has(value.toLowerCase())) {
197
174
  candidates.push({ key, value: `${appName.toLowerCase()}-auto` });
198
175
  continue;
199
176
  }
200
- if (MODEL_PATTERN.test(value)) {
177
+ if (MODEL_PATTERN.test(value))
201
178
  candidates.push({ key, value: value.toLowerCase() });
202
- }
203
- }
204
- if (candidates.length > 0) {
205
- const real = candidates.filter(c => !c.value.endsWith('-auto'));
206
- const pick = real.length > 0
207
- ? real.sort((a, b) => b.key.length - a.key.length)[0]
208
- : candidates.sort((a, b) => b.key.length - a.key.length)[0];
209
- return { model: pick.value, isAuto: pick.value.endsWith('-auto') };
210
179
  }
180
+ if (!candidates.length)
181
+ continue;
182
+ const real = candidates.filter(c => !c.value.endsWith('-auto'));
183
+ const pick = (real.length ? real : candidates).sort((a, b) => b.key.length - a.key.length)[0];
184
+ return { model: pick.value, isAuto: pick.value.endsWith('-auto') };
211
185
  }
212
- catch { /* absent */ }
186
+ catch { }
213
187
  }
214
188
  return undefined;
215
189
  }
216
- _getIdeSettingsPaths(appName) {
217
- const toolId = appName.toLowerCase().replace(/\s+/g, '-');
218
- const dirCandidates = [...new Set([appName, toolId])];
219
- return dirCandidates.map(dir => {
190
+ _idePaths(appName) {
191
+ const id = appName.toLowerCase().replace(/\s+/g, '-');
192
+ return [...new Set([appName, id])].map(dir => {
220
193
  switch (process.platform) {
221
194
  case 'darwin': return path.join(os.homedir(), 'Library', 'Application Support', dir, 'User', 'settings.json');
222
195
  case 'win32': return path.join(process.env.APPDATA ?? '', dir, 'User', 'settings.json');
@@ -224,18 +197,16 @@ class ModelDetector {
224
197
  }
225
198
  });
226
199
  }
227
- _flattenObject(obj, prefix = '', depth = 0) {
200
+ _flatten(obj, prefix = '', depth = 0) {
228
201
  if (depth > 5)
229
202
  return {};
230
203
  const out = {};
231
204
  for (const [k, v] of Object.entries(obj)) {
232
205
  const key = prefix ? `${prefix}.${k}` : k;
233
- if (v !== null && typeof v === 'object' && !Array.isArray(v)) {
234
- Object.assign(out, this._flattenObject(v, key, depth + 1));
235
- }
236
- else {
206
+ if (v !== null && typeof v === 'object' && !Array.isArray(v))
207
+ Object.assign(out, this._flatten(v, key, depth + 1));
208
+ else
237
209
  out[key] = v;
238
- }
239
210
  }
240
211
  return out;
241
212
  }