klyd 0.1.0__tar.gz

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,25 @@
1
+ # AGENTS.md
2
+
3
+ ## What this project is
4
+ klyd: a CLI tool that wraps coding agents via git hooks to inject architectural memory.
5
+
6
+ ## Stack (frozen — do not deviate)
7
+ - Python 3.11+
8
+ - Click for CLI
9
+ - SQLite via stdlib sqlite3 (no ORM)
10
+ - POSIX shell for hook scripts
11
+ - Anthropic API (claude-sonnet-4-6) via BYOK
12
+
13
+ ## Module responsibilities (do not blur these)
14
+ - cli.py: Click entrypoint only. No business logic.
15
+ - db.py: All SQLite operations. Nothing else.
16
+ - extractor.py: One function — LLM extraction call. Nothing else.
17
+ - injector.py: One function — format injection string. Nothing else.
18
+ - hooks.py: Install/uninstall git hooks. Nothing else.
19
+
20
+ ## Hard rules
21
+ - Two LLM calls per commit cycle maximum
22
+ - No external dependencies beyond Click and anthropic SDK
23
+ - Every function does one thing
24
+ - Shell hooks are dumb — they call klyd CLI, contain no logic themselves
25
+ - Never add a dependency to solve a problem that stdlib solves
klyd-0.1.0/LICENSE ADDED
@@ -0,0 +1,21 @@
1
+ MIT License
2
+
3
+ Copyright (c) 2026 klyd contributors
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.
klyd-0.1.0/MANIFEST.in ADDED
@@ -0,0 +1,2 @@
1
+ include hooks/*.sh
2
+ include AGENTS.md.txt
klyd-0.1.0/PKG-INFO ADDED
@@ -0,0 +1,61 @@
1
+ Metadata-Version: 2.4
2
+ Name: klyd
3
+ Version: 0.1.0
4
+ Summary: A CLI tool that wraps coding agents via git hooks to inject architectural memory.
5
+ Requires-Python: >=3.11
6
+ Description-Content-Type: text/markdown
7
+ License-File: LICENSE
8
+ Requires-Dist: click
9
+ Requires-Dist: anthropic
10
+ Dynamic: license-file
11
+
12
+ <center>
13
+
14
+ ```ascii
15
+
16
+ ▄█ ▄█▄ ▄█ ▄██ ▄ ████████▄
17
+ ███ ▄███▀ ███ ███ ██▄ ███ ▀███
18
+ ███▐██▀ ███ ███▄▄▄███ ███ ███
19
+ ▄█████▀ ███ ▀▀▀▀▀▀███ ███ ███
20
+ ▀▀█████▄ ███ ▄██ ███ ███ ███
21
+ ███▐██▄ ███ ███ ███ ███ ███
22
+ ███ ▀███▄ ███▌ ▄ ███ ███ ███ ▄███
23
+ ███ ▀█▀ █████▄▄██ ▀█████▀ ████████▀
24
+ ▀ ▀
25
+
26
+ ```
27
+ </center>
28
+
29
+ install → klyd init → klyd config --api-key sk-... → klyd run aider
30
+
31
+ ## Quickstart
32
+
33
+ ```bash
34
+ # 1. Install
35
+ pip install .
36
+
37
+ # 2. Initialize in your repo
38
+ cd your-project
39
+ klyd init
40
+
41
+ # 3. Configure an API Key
42
+ # Anthropic (Default)
43
+ klyd config --api-key sk-ant-...
44
+
45
+ # OR OpenAI
46
+ klyd config --openai-key sk-proj-... --model gpt-4o
47
+
48
+ # OR OpenRouter
49
+ klyd config --openrouter-key sk-or-... --model openrouter/auto
50
+
51
+ # OR Gemini
52
+ klyd config --gemini-key AIza... --model gemini-1.5-pro
53
+
54
+ # OR Groq
55
+ klyd config --groq-key gsk_... --model llama3-8b-8192
56
+
57
+ # 4. Use your agent as normal
58
+ klyd run aider
59
+ ```
60
+
61
+ Decisions will automatically populate after you make commits. Review flagged contradictions using `klyd review`. Check current status with `klyd status`.
klyd-0.1.0/README.md ADDED
@@ -0,0 +1,50 @@
1
+ <center>
2
+
3
+ ```ascii
4
+
5
+ ▄█ ▄█▄ ▄█ ▄██ ▄ ████████▄
6
+ ███ ▄███▀ ███ ███ ██▄ ███ ▀███
7
+ ███▐██▀ ███ ███▄▄▄███ ███ ███
8
+ ▄█████▀ ███ ▀▀▀▀▀▀███ ███ ███
9
+ ▀▀█████▄ ███ ▄██ ███ ███ ███
10
+ ███▐██▄ ███ ███ ███ ███ ███
11
+ ███ ▀███▄ ███▌ ▄ ███ ███ ███ ▄███
12
+ ███ ▀█▀ █████▄▄██ ▀█████▀ ████████▀
13
+ ▀ ▀
14
+
15
+ ```
16
+ </center>
17
+
18
+ install → klyd init → klyd config --api-key sk-... → klyd run aider
19
+
20
+ ## Quickstart
21
+
22
+ ```bash
23
+ # 1. Install
24
+ pip install .
25
+
26
+ # 2. Initialize in your repo
27
+ cd your-project
28
+ klyd init
29
+
30
+ # 3. Configure an API Key
31
+ # Anthropic (Default)
32
+ klyd config --api-key sk-ant-...
33
+
34
+ # OR OpenAI
35
+ klyd config --openai-key sk-proj-... --model gpt-4o
36
+
37
+ # OR OpenRouter
38
+ klyd config --openrouter-key sk-or-... --model openrouter/auto
39
+
40
+ # OR Gemini
41
+ klyd config --gemini-key AIza... --model gemini-1.5-pro
42
+
43
+ # OR Groq
44
+ klyd config --groq-key gsk_... --model llama3-8b-8192
45
+
46
+ # 4. Use your agent as normal
47
+ klyd run aider
48
+ ```
49
+
50
+ Decisions will automatically populate after you make commits. Review flagged contradictions using `klyd review`. Check current status with `klyd status`.
@@ -0,0 +1,2 @@
1
+ #!/bin/sh
2
+ kl extract-commit >> .klyd/errors.log 2>&1 &
@@ -0,0 +1,2 @@
1
+ #!/bin/sh
2
+ kl prepare-injection >> .klyd/errors.log 2>&1 || true
@@ -0,0 +1,10 @@
1
+ import sys
2
+
3
+ frame = sys._getframe()
4
+ while frame:
5
+ if frame.f_code.co_name == '_get_module_details' and frame.f_locals.get('mod_name') == 'klyd.__main__':
6
+ from klyd.cli import cli
7
+ sys.argv[0] = 'python -m klyd'
8
+ cli()
9
+ sys.exit(0)
10
+ frame = frame.f_back
klyd-0.1.0/klyd/cli.py ADDED
@@ -0,0 +1,454 @@
1
+ import click
2
+ import subprocess
3
+ import json
4
+ import traceback
5
+ from .hooks import install_hooks
6
+ from .config import init_config, set_config, get_config, get_all_config
7
+ from .db import get_schema_path
8
+ import sqlite3
9
+ from pathlib import Path
10
+ from rich.console import Console
11
+ from rich.panel import Panel
12
+ from rich.table import Table
13
+ from rich.rule import Rule
14
+ from rich.columns import Columns
15
+ from rich.prompt import Prompt
16
+ from rich.text import Text
17
+ import rich.box
18
+
19
+ console = Console()
20
+
21
+ class KlydGroup(click.Group):
22
+ def format_help(self, ctx, formatter):
23
+ console.print()
24
+ console.print()
25
+ console.print(r"[yellow bold]888 / 888 Y88b / 888~-_ [/yellow bold]")
26
+ console.print(r"[yellow bold]888 / 888 Y88b / 888 \ [/yellow bold]")
27
+ console.print(r"[yellow bold]888/\ 888 Y88b/ 888 | [/yellow bold]")
28
+ console.print(r"[yellow bold]888 \ 888 Y8Y 888 | [/yellow bold]")
29
+ console.print(r"[yellow bold]888 \ 888 Y 888 / [/yellow bold]")
30
+ console.print(r"[yellow bold]888 \ 888____ / 888_-~ [/yellow bold]")
31
+ console.print()
32
+ console.print(r"[dim] (An open-source project, not affiliated with the Klyd SaaS)[/dim]")
33
+ console.print()
34
+ console.print()
35
+ super().format_help(ctx, formatter)
36
+
37
+ @click.group(cls=KlydGroup)
38
+ def cli():
39
+ """klyd: a CLI tool that wraps coding agents via git hooks to inject architectural memory."""
40
+ pass
41
+
42
+ def echo_brand(msg, bold=False):
43
+ console.print(f"[cyan bold]klyd[/cyan bold] | {msg}")
44
+
45
+ @cli.command()
46
+ def init():
47
+ """Initialize klyd in the current git repository."""
48
+ console.print()
49
+ console.print()
50
+ console.print(r"[green bold]8 8888 ,88' 8 8888 `8.`8888. ,8' 8 888888888o. [/green bold]")
51
+ console.print(r"[green bold]8 8888 ,88' 8 8888 `8.`8888. ,8' 8 8888 `^888. [/green bold]")
52
+ console.print(r"[green bold]8 8888 ,88' 8 8888 `8.`8888. ,8' 8 8888 `88. [/green bold]")
53
+ console.print(r"[green bold]8 8888 ,88' 8 8888 `8.`8888.,8' 8 8888 `88 [/green bold]")
54
+ console.print(r"[green bold]8 8888 ,88' 8 8888 `8.`88888' 8 8888 88 [/green bold]")
55
+ console.print(r"[green bold]8 8888 88' 8 8888 `8. 8888 8 8888 88 [/green bold]")
56
+ console.print(r"[green bold]8 888888< 8 8888 `8 8888 8 8888 ,88 [/green bold]")
57
+ console.print(r"[green bold]8 8888 `Y8. 8 8888 8 8888 8 8888 ,88' [/green bold]")
58
+ console.print(r"[green bold]8 8888 `Y8. 8 8888 8 8888 8 8888 ,o88P' [/green bold]")
59
+ console.print(r"[green bold]8 8888 `Y8. 8 888888888888 8 8888 8 888888888P' [/green bold]")
60
+ console.print()
61
+ console.print(r" [dim](An open-source project, not affiliated with the Klyd SaaS)[/dim]")
62
+ console.print()
63
+ console.print()
64
+ try:
65
+ with console.status("[bold cyan]Creating .klyd directory, database, and hooks...[/bold cyan]", spinner="dots12"):
66
+ install_hooks()
67
+ init_config()
68
+ klyd_dir = Path('.klyd')
69
+ db_path = klyd_dir / 'memory.db'
70
+ from .db import init_db
71
+ init_db(str(db_path))
72
+
73
+ console.print(Panel(
74
+ "Klyd harness initialised in [cyan].klyd[/cyan]\n\n[dim]Installed git hooks for automatic extraction.[/dim]\n[dim]Errors are logged to .klyd/errors.log[/dim]",
75
+ title="[bold green]SUCCESS[/bold green]", border_style="green", padding=(1, 2)
76
+ ))
77
+ except Exception as e:
78
+ console.print(f"[red]Error: {e}[/red]")
79
+ raise click.Abort()
80
+
81
+ @cli.command()
82
+ @click.option('--api-key', help='Anthropic API key')
83
+ @click.option('--openai-key', help='OpenAI API key')
84
+ @click.option('--openrouter-key', help='OpenRouter API key')
85
+ @click.option('--gemini-key', help='Gemini API key')
86
+ @click.option('--groq-key', help='Groq API key')
87
+ @click.option('--model', help='Model to use (default: claude-sonnet-4-6)')
88
+ @click.option('--show', is_flag=True, help='Show current configuration')
89
+ def config(api_key, openai_key, openrouter_key, gemini_key, groq_key, model, show):
90
+ """Set klyd configuration."""
91
+ if show:
92
+ cfg = get_all_config()
93
+ table = Table(title="[bold cyan]klyd Configuration[/bold cyan]", box=rich.box.SIMPLE, header_style="bold cyan")
94
+ table.add_column("Key", style="cyan")
95
+ table.add_column("Value", style="green")
96
+ if not cfg:
97
+ table.add_row("[dim]No configuration set[/dim]", "[dim]Run: kl config --api-key ...[/dim]")
98
+ else:
99
+ for k, v in cfg.items():
100
+ if 'key' in k and v:
101
+ v = v[:4] + '*' * (len(v) - 8) + v[-4:] if len(v) > 8 else '*' * len(v)
102
+ table.add_row(k, v)
103
+ console.print(table)
104
+ return
105
+
106
+ changes = False
107
+ with console.status("[bold cyan]Saving configuration...[/bold cyan]", spinner="dots12"):
108
+ if api_key:
109
+ set_config('api_key', api_key)
110
+ changes = True
111
+ if openai_key:
112
+ set_config('openai_key', openai_key)
113
+ changes = True
114
+ if openrouter_key:
115
+ set_config('openrouter_key', openrouter_key)
116
+ changes = True
117
+ if gemini_key:
118
+ set_config('gemini_key', gemini_key)
119
+ changes = True
120
+ if groq_key:
121
+ set_config('groq_key', groq_key)
122
+ changes = True
123
+ if model:
124
+ set_config('model', model)
125
+ changes = True
126
+
127
+ if changes:
128
+ console.print(Panel("[bold green]Configuration saved successfully[/bold green]", title="[bold green]DONE[/bold green]", border_style="green", expand=False))
129
+ else:
130
+ console.print("[yellow]Usage:[/yellow] kl config --api-key ... --openai-key ... --model ...\nOr use --show to display current configuration.")
131
+
132
+ @cli.command(context_settings={"ignore_unknown_options": True})
133
+ @click.option('--no-inject', is_flag=True, help='Skip generating injection file')
134
+ @click.argument('cmd', nargs=-1, type=click.UNPROCESSED)
135
+ def run(no_inject, cmd):
136
+ """Run an agent with injected architectural memory."""
137
+ if not cmd:
138
+ console.print("Usage: kl run <agent> [args...]")
139
+ return
140
+
141
+ klyd_dir = Path('.klyd')
142
+ inj_path = klyd_dir / 'injection.txt'
143
+
144
+ if not no_inject:
145
+ with console.status("[bold cyan]Preparing injection context...[/bold cyan]", spinner="dots12"):
146
+ try:
147
+ ctx = click.get_current_context()
148
+ ctx.invoke(prepare_injection)
149
+ except Exception as e:
150
+ pass
151
+
152
+ run_cmd = list(cmd)
153
+ agent_name = run_cmd[0].lower()
154
+
155
+ if inj_path.exists() and inj_path.stat().st_size > 0:
156
+ if agent_name == 'aider':
157
+ run_cmd.extend(['--message-file', str(inj_path)])
158
+ elif agent_name == 'opencode':
159
+ run_cmd.extend(['-m', inj_path.read_text()])
160
+
161
+ console.print(Panel(f"[bold cyan]Launching[/bold cyan] {agent_name}...", title="[bold blue]AGENT START[/bold blue]", border_style="blue", expand=False))
162
+
163
+ try:
164
+ subprocess.run(run_cmd)
165
+ console.print(Panel("[bold green]Agent session ended[/bold green]", title="[bold green]DONE[/bold green]", border_style="green", expand=False))
166
+ except FileNotFoundError:
167
+ console.print(f"[red]Command not found: {cmd[0]}[/red]")
168
+
169
+ @cli.command()
170
+ def extract_commit():
171
+ """Extract decisions from the last commit."""
172
+ from .extractor import extract_decisions
173
+ from .db import get_decisions_for_files, store_decision, reinforce_decision, flag_decision
174
+
175
+ klyd_dir = Path('.klyd')
176
+ if not (klyd_dir / 'memory.db').exists():
177
+ return
178
+
179
+ try:
180
+ try:
181
+ diff = subprocess.check_output(['git', 'diff', 'HEAD~1', 'HEAD'], text=True)
182
+ msg = subprocess.check_output(['git', 'log', '-1', '--format=%B'], text=True)
183
+ files_out = subprocess.check_output(['git', 'diff', '--name-only', 'HEAD~1', 'HEAD'], text=True)
184
+ files = [f for f in files_out.strip().split('\n') if f]
185
+ commit_hash = subprocess.check_output(['git', 'rev-parse', 'HEAD'], text=True).strip()
186
+ except subprocess.CalledProcessError:
187
+ diff = subprocess.check_output(['git', 'show', 'HEAD'], text=True)
188
+ msg = subprocess.check_output(['git', 'log', '-1', '--format=%B'], text=True)
189
+ files_out = subprocess.check_output(['git', 'show', '--name-only', '--format=', 'HEAD'], text=True)
190
+ files = [f for f in files_out.strip().split('\n') if f]
191
+ commit_hash = subprocess.check_output(['git', 'rev-parse', 'HEAD'], text=True).strip()
192
+
193
+ if not files:
194
+ return
195
+
196
+ with console.status("[bold cyan]Extracting architectural decisions via LLM...[/bold cyan]", spinner="dots12"):
197
+ db_path = str(klyd_dir / 'memory.db')
198
+ existing = get_decisions_for_files(db_path, files, top_k=20)
199
+ existing_json = json.dumps(existing, indent=2)
200
+
201
+ config_data = get_all_config()
202
+ model = config_data.get('model', 'claude-sonnet-4-6')
203
+
204
+ decisions = extract_decisions(diff, msg, existing_json, config_data, model)
205
+
206
+ new_count = 0
207
+ reinforced_count = 0
208
+
209
+ for d in decisions:
210
+ event = d.get('event_type')
211
+
212
+ if event == 'REINFORCE':
213
+ match = next((e for e in existing if e['module'] == d['module'] and e['decision'] == d['decision']), None)
214
+ if match:
215
+ reinforce_decision(db_path, match['id'], commit_hash)
216
+ reinforced_count += 1
217
+ else:
218
+ d['event_type'] = 'NEW'
219
+ store_decision(db_path, d)
220
+ new_count += 1
221
+ elif event == 'CONTRADICT':
222
+ did = store_decision(db_path, d)
223
+ flag_decision(db_path, did)
224
+ new_count += 1
225
+ else:
226
+ store_decision(db_path, d)
227
+ new_count += 1
228
+
229
+ if new_count > 0 or reinforced_count > 0:
230
+ console.print(Panel(
231
+ f"[bold green]{new_count}[/bold green] new decisions extracted\n[bold yellow]{reinforced_count}[/bold yellow] decisions reinforced",
232
+ title="[bold cyan]EXTRACTION COMPLETE[/bold cyan]", border_style="cyan", expand=False
233
+ ))
234
+ else:
235
+ console.print(Panel(
236
+ "[dim]No architectural decisions found in this commit[/dim]",
237
+ title="[dim]EXTRACTION COMPLETE[/dim]", border_style="dim", expand=False
238
+ ))
239
+
240
+ except Exception as e:
241
+ err_log = klyd_dir / 'errors.log'
242
+ with open(err_log, 'a') as f:
243
+ f.write(f"Error extracting commit:\n{traceback.format_exc()}\n")
244
+ return
245
+
246
+ @cli.command()
247
+ def prepare_injection():
248
+ """Prepare injection file for agent sessions."""
249
+ from .db import get_decisions_for_files
250
+ from .injector import format_injection
251
+
252
+ klyd_dir = Path('.klyd')
253
+ if not (klyd_dir / 'memory.db').exists():
254
+ return
255
+
256
+ try:
257
+ with console.status("[bold cyan]Preparing injection context...[/bold cyan]", spinner="dots12"):
258
+ files_out = subprocess.check_output(['git', 'diff', '--cached', '--name-only'], text=True)
259
+ files = [f for f in files_out.strip().split('\n') if f]
260
+
261
+ if not files:
262
+ with open(klyd_dir / 'injection.txt', 'w') as f:
263
+ f.write('')
264
+ return
265
+
266
+ db_path = str(klyd_dir / 'memory.db')
267
+ decisions = get_decisions_for_files(db_path, files, top_k=20)
268
+
269
+ injection = format_injection(decisions)
270
+ with open(klyd_dir / 'injection.txt', 'w') as f:
271
+ f.write(injection)
272
+
273
+ console.print(Panel("[green]Injection file ready at .klyd/injection.txt[/green]", border_style="green", expand=False))
274
+
275
+ except Exception as e:
276
+ with open(klyd_dir / 'errors.log', 'a') as f:
277
+ f.write(f"Error preparing injection:\n{traceback.format_exc()}\n")
278
+ return
279
+
280
+ @cli.command()
281
+ def review():
282
+ """Review flagged conflicting decisions."""
283
+ from .db import get_flagged_decisions, get_active_decisions_by_module, resolve_decision
284
+
285
+ klyd_dir = Path('.klyd')
286
+ db_path = klyd_dir / 'memory.db'
287
+ if not db_path.exists():
288
+ console.print("[bold red]klyd is not initialized. Run `kl init`.[/bold red]")
289
+ return
290
+
291
+ db_str = str(db_path)
292
+ flagged = get_flagged_decisions(db_str)
293
+
294
+ if not flagged:
295
+ console.print(Panel(
296
+ "[bold green]All conflicts resolved. Memory is clean.[/bold green]",
297
+ title="[bold green]NO CONFLICTS[/bold green]", border_style="green", expand=False
298
+ ))
299
+ return
300
+
301
+ for i, d in enumerate(flagged):
302
+ console.print()
303
+ console.print(f"[bold cyan]Conflict {i+1} of {len(flagged)}[/bold cyan] in module: [bold white]{d['module']}[/bold white]")
304
+
305
+ commit_ref = d.get('last_seen_commit') or "unknown commit"
306
+
307
+ active = get_active_decisions_by_module(db_str, d['module'])
308
+ old_id = None
309
+ if active:
310
+ old = active[0]
311
+ old_id = old['id']
312
+ conf_color = "bold bright_green" if old['confidence'] == 'HIGH' else ("bold bright_yellow" if old['confidence'] == 'MEDIUM' else "dim white")
313
+ old_panel = Panel(
314
+ f"{old['decision']}\n\n[dim]([/dim][{conf_color}]{old['confidence']}[/{conf_color}][dim] | reinforced x{old['reinforcement_count']})[/dim]",
315
+ title="[bold yellow]Existing Memory[/bold yellow]",
316
+ border_style="yellow",
317
+ expand=True
318
+ )
319
+ else:
320
+ old_panel = Panel(
321
+ "\n[dim]No existing memory found.[/dim]\n",
322
+ title="[bold yellow]Existing Memory[/bold yellow]",
323
+ border_style="yellow",
324
+ expand=True
325
+ )
326
+
327
+ new_conf_color = "bold bright_green" if d['confidence'] == 'HIGH' else ("bold bright_yellow" if d['confidence'] == 'MEDIUM' else "dim white")
328
+ new_panel = Panel(
329
+ f"{d['decision']}\n\n[dim]([/dim][{new_conf_color}]{d['confidence']}[/{new_conf_color}][dim] | commit {commit_ref[:7]})[/dim]",
330
+ title="[bold red]New Conflicting Extraction[/bold red]",
331
+ border_style="red",
332
+ expand=True
333
+ )
334
+
335
+ console.print(Columns([old_panel, new_panel]))
336
+
337
+ console.print()
338
+ console.print(" [bold green][ a ][/bold green] Accept new (archive old) [bold red][ r ][/bold red] Reject new (keep old)")
339
+ console.print(" [bold yellow][ e ][/bold yellow] Edit manually [bold blue][ s ][/bold blue] Skip for now")
340
+ console.print()
341
+
342
+ while True:
343
+ choice = Prompt.ask("[bold cyan]Select action[/bold cyan]", choices=["a", "r", "e", "s"], show_choices=False).lower()
344
+ if choice == 's':
345
+ console.print("[dim]Skipped.[/dim]")
346
+ break
347
+ elif choice == 'a':
348
+ resolve_decision(db_str, d['id'], 'accept', old_id=old_id)
349
+ console.print(Panel(
350
+ "[bold green]Accepted new decision. Memory updated.[/bold green]",
351
+ border_style="green", expand=False
352
+ ))
353
+ break
354
+ elif choice == 'r':
355
+ resolve_decision(db_str, d['id'], 'reject')
356
+ console.print(Panel(
357
+ "[bold red]Rejected new decision. Existing memory preserved.[/bold red]",
358
+ border_style="red", expand=False
359
+ ))
360
+ break
361
+ elif choice == 'e':
362
+ new_text = click.edit(d['decision'])
363
+ if new_text is not None:
364
+ new_text = new_text.strip()
365
+ resolve_decision(db_str, d['id'], 'edit', old_id=old_id, new_text=new_text)
366
+ console.print(Panel(
367
+ "[bold yellow]Saved edited decision. Memory updated.[/bold yellow]",
368
+ border_style="yellow", expand=False
369
+ ))
370
+ break
371
+ else:
372
+ console.print("[red]Edit cancelled. Please choose an option.[/red]")
373
+
374
+ @cli.command()
375
+ def status():
376
+ """Show the current memory store status."""
377
+ klyd_dir = Path('.klyd')
378
+ db_path = klyd_dir / 'memory.db'
379
+ if not db_path.exists():
380
+ console.print("[bold red]klyd is not initialized. Run `kl init`.[/bold red]")
381
+ return
382
+
383
+ conn = sqlite3.connect(str(db_path))
384
+ conn.row_factory = sqlite3.Row
385
+ cur = conn.cursor()
386
+
387
+ cur.execute("SELECT * FROM decisions WHERE archived = 0")
388
+ all_decisions = [dict(r) for r in cur.fetchall()]
389
+ conn.close()
390
+
391
+ active = [d for d in all_decisions if d['flagged'] == 0]
392
+ flagged = [d for d in all_decisions if d['flagged'] == 1]
393
+
394
+ active.sort(key=lambda x: x['reinforcement_count'], reverse=True)
395
+
396
+ table = Table(box=rich.box.SIMPLE, header_style="bold cyan", show_edge=False, padding=(0, 2))
397
+ table.add_column("ID", style="dim", max_width=8)
398
+ table.add_column("Decision", style="white")
399
+ table.add_column("Module", style="bold blue")
400
+ table.add_column("Confidence")
401
+ table.add_column("Event")
402
+ table.add_column("Reinforcements", justify="right")
403
+
404
+ for d in active:
405
+ conf_color = "bold bright_green" if d['confidence'] == 'HIGH' else ("bold bright_yellow" if d['confidence'] == 'MEDIUM' else "dim white")
406
+ conf_styled = f"[{conf_color}]{d['confidence']}[/{conf_color}]"
407
+
408
+ event_color = "bright_blue" if d.get('event_type') == 'NEW' else ("bright_magenta" if d.get('event_type') == 'REINFORCE' else "bold bright_red")
409
+ event_styled = f"[{event_color}]{d.get('event_type', 'NEW')}[/{event_color}]"
410
+
411
+ table.add_row(
412
+ str(d['id'])[:8],
413
+ d['decision'],
414
+ d['module'],
415
+ conf_styled,
416
+ event_styled,
417
+ f"x{d['reinforcement_count']}"
418
+ )
419
+
420
+ if not active:
421
+ table.add_row("", "[dim]No active architectural decisions stored yet.[/dim]", "", "", "", "")
422
+
423
+ console.print(Panel(table, title="[bold cyan] Architectural Memory [/bold cyan]", border_style="cyan", expand=False, padding=(1, 2)))
424
+
425
+ if flagged:
426
+ console.print()
427
+ flagged_table = Table(box=rich.box.SIMPLE, show_edge=False, padding=(0, 2))
428
+ flagged_table.add_column("ID", style="dim", max_width=8)
429
+ flagged_table.add_column("Decision", style="bold white")
430
+ flagged_table.add_column("Module", style="bold blue")
431
+ flagged_table.add_column("Confidence")
432
+
433
+ for d in flagged:
434
+ conf_color = "bold bright_green" if d['confidence'] == 'HIGH' else ("bold bright_yellow" if d['confidence'] == 'MEDIUM' else "dim white")
435
+ conf_styled = f"[{conf_color}]{d['confidence']}[/{conf_color}]"
436
+
437
+ flagged_table.add_row(
438
+ str(d['id'])[:8],
439
+ d['decision'],
440
+ d['module'],
441
+ conf_styled
442
+ )
443
+
444
+ warning_panel = Panel(
445
+ flagged_table,
446
+ title="[bold white on red] NEEDS REVIEW - CONFLICTS DETECTED [/bold white on red]",
447
+ border_style="red",
448
+ expand=False,
449
+ padding=(1, 2)
450
+ )
451
+ console.print(warning_panel)
452
+
453
+ console.print()
454
+ console.print(f"[dim]Summary:[/dim] [cyan]{len(active)} active[/cyan] | [red]{len(flagged)} pending review[/red]")
@@ -0,0 +1,44 @@
1
+ import json
2
+ from pathlib import Path
3
+
4
+ def get_config_path():
5
+ return Path('.klyd') / 'config.json'
6
+
7
+ def init_config():
8
+ klyd_dir = Path('.klyd')
9
+ klyd_dir.mkdir(exist_ok=True)
10
+
11
+ config_path = get_config_path()
12
+ if not config_path.exists():
13
+ with open(config_path, 'w') as f:
14
+ json.dump({}, f, indent=2)
15
+
16
+ def set_config(key, value):
17
+ init_config()
18
+ config_path = get_config_path()
19
+ with open(config_path, 'r') as f:
20
+ data = json.load(f)
21
+ data[key] = value
22
+ with open(config_path, 'w') as f:
23
+ json.dump(data, f, indent=2)
24
+
25
+ def get_config(key, default=None):
26
+ config_path = get_config_path()
27
+ if not config_path.exists():
28
+ return default
29
+ try:
30
+ with open(config_path, 'r') as f:
31
+ data = json.load(f)
32
+ return data.get(key, default)
33
+ except:
34
+ return default
35
+
36
+ def get_all_config():
37
+ config_path = get_config_path()
38
+ if not config_path.exists():
39
+ return {}
40
+ try:
41
+ with open(config_path, 'r') as f:
42
+ return json.load(f)
43
+ except:
44
+ return {}
klyd-0.1.0/klyd/db.py ADDED
@@ -0,0 +1,124 @@
1
+ import sqlite3
2
+ import fnmatch
3
+ from pathlib import Path
4
+
5
+ def get_schema_path():
6
+ return Path(__file__).resolve().parent.parent / 'schema' / 'v1.sql'
7
+
8
+ def init_db(db_path):
9
+ conn = sqlite3.connect(db_path)
10
+ schema = get_schema_path().read_text()
11
+ conn.executescript(schema)
12
+ conn.commit()
13
+ conn.close()
14
+
15
+ def store_decision(db_path, decision_dict):
16
+ conn = sqlite3.connect(db_path)
17
+ cur = conn.cursor()
18
+ cur.execute('''
19
+ INSERT INTO decisions (decision, module, file_patterns, confidence, event_type)
20
+ VALUES (?, ?, ?, ?, ?)
21
+ ''', (
22
+ decision_dict['decision'],
23
+ decision_dict['module'],
24
+ decision_dict['file_patterns'],
25
+ decision_dict['confidence'],
26
+ decision_dict.get('event_type', 'NEW')
27
+ ))
28
+ conn.commit()
29
+ decision_id = cur.lastrowid
30
+ conn.close()
31
+ return decision_id
32
+
33
+ def _match_any_file(file_patterns, file_list_str):
34
+ patterns = [p.strip() for p in file_patterns.split(',')]
35
+ files = file_list_str.split('|')
36
+ for f in files:
37
+ for p in patterns:
38
+ if fnmatch.fnmatch(f, p):
39
+ return 1
40
+ return 0
41
+
42
+ def get_decisions_for_files(db_path, file_list, top_k=5):
43
+ conn = sqlite3.connect(db_path)
44
+ conn.row_factory = sqlite3.Row
45
+ conn.create_function("match_files", 2, _match_any_file)
46
+
47
+ file_list_str = '|'.join(file_list)
48
+
49
+ cur = conn.cursor()
50
+ cur.execute('''
51
+ SELECT * FROM decisions
52
+ WHERE flagged = 0 AND archived = 0
53
+ AND match_files(file_patterns, ?) = 1
54
+ ORDER BY (reinforcement_count * CASE confidence WHEN 'HIGH' THEN 3 WHEN 'MEDIUM' THEN 2 ELSE 1 END) DESC, last_seen_commit DESC
55
+ LIMIT ?
56
+ ''', (file_list_str, top_k))
57
+
58
+ results = [dict(row) for row in cur.fetchall()]
59
+ conn.close()
60
+ return results
61
+
62
+ def reinforce_decision(db_path, decision_id, commit_hash):
63
+ conn = sqlite3.connect(db_path)
64
+ conn.execute('''
65
+ UPDATE decisions
66
+ SET reinforcement_count = reinforcement_count + 1, last_seen_commit = ?
67
+ WHERE id = ?
68
+ ''', (commit_hash, decision_id))
69
+ conn.commit()
70
+ conn.close()
71
+
72
+ def flag_decision(db_path, decision_id):
73
+ conn = sqlite3.connect(db_path)
74
+ conn.execute('UPDATE decisions SET flagged = 1 WHERE id = ?', (decision_id,))
75
+ conn.commit()
76
+ conn.close()
77
+
78
+ def archive_decision(db_path, decision_id):
79
+ conn = sqlite3.connect(db_path)
80
+ conn.execute('UPDATE decisions SET archived = 1 WHERE id = ?', (decision_id,))
81
+ conn.commit()
82
+ conn.close()
83
+
84
+ def get_flagged_decisions(db_path):
85
+ conn = sqlite3.connect(db_path)
86
+ conn.row_factory = sqlite3.Row
87
+ cur = conn.cursor()
88
+ cur.execute("SELECT * FROM decisions WHERE flagged = 1 AND archived = 0")
89
+ res = [dict(r) for r in cur.fetchall()]
90
+ conn.close()
91
+ return res
92
+
93
+ def get_active_decisions_by_module(db_path, module):
94
+ conn = sqlite3.connect(db_path)
95
+ conn.row_factory = sqlite3.Row
96
+ cur = conn.cursor()
97
+ cur.execute("SELECT * FROM decisions WHERE module = ? AND flagged = 0 AND archived = 0 ORDER BY reinforcement_count DESC", (module,))
98
+ res = [dict(r) for r in cur.fetchall()]
99
+ conn.close()
100
+ return res
101
+
102
+ def resolve_decision(db_path, decision_id, action, old_id=None, new_text=None):
103
+ conn = sqlite3.connect(db_path)
104
+ if action == 'accept':
105
+ if old_id:
106
+ conn.execute('UPDATE decisions SET archived = 1 WHERE id = ?', (old_id,))
107
+ conn.execute("UPDATE decisions SET flagged = 0, confidence = 'MEDIUM' WHERE id = ?", (decision_id,))
108
+ elif action == 'reject':
109
+ conn.execute('UPDATE decisions SET archived = 1 WHERE id = ?', (decision_id,))
110
+ elif action == 'edit':
111
+ if old_id:
112
+ conn.execute('UPDATE decisions SET archived = 1 WHERE id = ?', (old_id,))
113
+ conn.execute("UPDATE decisions SET flagged = 0, confidence = 'MEDIUM', decision = ? WHERE id = ?", (new_text, decision_id))
114
+ conn.commit()
115
+ conn.close()
116
+
117
+ def get_existing_decisions_for_files(file_paths: list[str]) -> list[dict]:
118
+ db_path = str(Path('.klyd/memory.db').resolve())
119
+ if not Path(db_path).exists():
120
+ return []
121
+ decisions = get_decisions_for_files(db_path, file_paths, top_k=50)
122
+ high_conf = [d for d in decisions if d['confidence'] == 'HIGH']
123
+ return [{'id': d['id'], 'decision': d['decision']} for d in high_conf[:5]]
124
+
@@ -0,0 +1,129 @@
1
+ import json
2
+ import urllib.request
3
+ from anthropic import Anthropic
4
+ from .db import get_existing_decisions_for_files
5
+
6
+ def _call_openai_compatible(url, key, model, prompt):
7
+ data = json.dumps({
8
+ "model": model,
9
+ "messages": [{"role": "user", "content": prompt}]
10
+ }).encode('utf-8')
11
+ headers = {
12
+ "Authorization": f"Bearer {key}",
13
+ "Content-Type": "application/json"
14
+ }
15
+ if "openrouter.ai" in url:
16
+ headers["HTTP-Referer"] = "https://github.com/getKlyd/klyd"
17
+ headers["X-Title"] = "klyd"
18
+
19
+ req = urllib.request.Request(url, data=data, headers=headers)
20
+ with urllib.request.urlopen(req) as response:
21
+ res = json.loads(response.read().decode('utf-8'))
22
+ return res['choices'][0]['message']['content']
23
+
24
+ def extract_decisions(diff, commit_message, existing_decisions, config_data, model='claude-sonnet-4-6'):
25
+ files = []
26
+ for line in diff.split('\n'):
27
+ if line.startswith('diff --git a/'):
28
+ parts = line.split(' b/')
29
+ if len(parts) == 2:
30
+ files.append(parts[1].strip())
31
+
32
+ invariants = get_existing_decisions_for_files(list(set(files)))
33
+ invariants_text = "\n".join([f"- [{inv['id']}] {inv['decision']}" for inv in invariants]) if invariants else "None"
34
+
35
+ prompt = f"""You are an architectural decision extractor for a software project.
36
+
37
+ You will receive a git diff, a commit message, and a list of previously recorded architectural decisions for the files touched in this diff.
38
+
39
+ Your job: extract the literal text of the architectural decisions that this commit clearly enacts.
40
+
41
+ Architectural decisions are: data store choice, auth strategy, API boundary contracts, module responsibility assignments, dependency/library choices, error handling patterns.
42
+
43
+ Rules:
44
+ - Only record if the diff explicitly introduces, changes, or contradicts a decision
45
+ - Do not guess. Do not infer. Only record what is clearly shown in the diff.
46
+ - If the commit is a style fix, test update, or minor refactor with no architectural significance: return []
47
+ - For each decision, classify as NEW (not seen before), REINFORCE (confirms an existing decision), or CONTRADICT (conflicts with an existing decision)
48
+ - Assign confidence: HIGH (unmistakable from diff), MEDIUM (clear but could have context), LOW (possible but uncertain)
49
+ - If this commit contradicts any of the listed invariants, set event_type to 'CONTRADICT' and add a note in the 'decision' field describing the violation. Otherwise, proceed with NEW or REINFORCE as before.
50
+
51
+ Return ONLY valid JSON. No prose. No markdown. No explanation.
52
+ - The 'decision' field must contain the exact architectural rule or invariant written in the diff, quoted verbatim if possible. Do not summarise module responsibilities. For example, prefer 'Authentication uses JWT access tokens exclusively; no server-side sessions' over 'Authentication is handled by auth.py'.
53
+ Schema: [{{"decision": str, "module": str, "file_patterns": str, "confidence": "LOW"|"MEDIUM"|"HIGH", "event": "NEW"|"REINFORCE"|"CONTRADICT"}}]
54
+ If no decisions: return []
55
+
56
+ EXISTING DECISIONS FOR TOUCHED FILES:
57
+ {existing_decisions}
58
+
59
+ COMMIT MESSAGE:
60
+ {commit_message}
61
+
62
+ DIFF:
63
+ {diff}
64
+
65
+ Existing architectural invariants (do not violate unless explicitly instructed):
66
+ {invariants_text}
67
+ """
68
+ try:
69
+ # Determine provider
70
+ if model.startswith('claude-') or 'api_key' in config_data and not any(k in config_data for k in ['openai_key', 'openrouter_key', 'gemini_key', 'groq_key']):
71
+ if 'api_key' not in config_data:
72
+ raise ValueError("Anthropic API key is required.")
73
+ client = Anthropic(api_key=config_data['api_key'])
74
+ response = client.messages.create(
75
+ model=model,
76
+ max_tokens=1024,
77
+ messages=[{"role": "user", "content": prompt}]
78
+ )
79
+ content = response.content[0].text.strip()
80
+ else:
81
+ # OpenAI compatible providers
82
+ if model.startswith('gpt-') or model.startswith('o1') or model.startswith('o3'):
83
+ if 'openai_key' not in config_data: raise ValueError("OpenAI API key missing.")
84
+ url, key = "https://api.openai.com/v1/chat/completions", config_data['openai_key']
85
+ elif model.startswith('gemini-') or model.startswith('gemma-'):
86
+ if 'gemini_key' not in config_data: raise ValueError("Gemini API key missing.")
87
+ url, key = "https://generativelanguage.googleapis.com/v1beta/openai/chat/completions", config_data['gemini_key']
88
+ elif '/' in model:
89
+ if 'openrouter_key' not in config_data: raise ValueError("OpenRouter API key missing.")
90
+ url, key = "https://openrouter.ai/api/v1/chat/completions", config_data['openrouter_key']
91
+ elif config_data.get('groq_key'):
92
+ url, key = "https://api.groq.com/openai/v1/chat/completions", config_data['groq_key']
93
+ elif config_data.get('openai_key'):
94
+ url, key = "https://api.openai.com/v1/chat/completions", config_data['openai_key']
95
+ elif config_data.get('openrouter_key'):
96
+ url, key = "https://openrouter.ai/api/v1/chat/completions", config_data['openrouter_key']
97
+ else:
98
+ raise ValueError(f"No valid API key configured for model: {model}")
99
+
100
+ content = _call_openai_compatible(url, key, model, prompt)
101
+
102
+ # Parse output as JSON
103
+ content = content.strip()
104
+ if content.startswith('```json'):
105
+ content = content[7:]
106
+ if content.endswith('```'):
107
+ content = content[:-3]
108
+
109
+ content = content.strip()
110
+
111
+ result = json.loads(content)
112
+ if not isinstance(result, list):
113
+ return []
114
+
115
+ normalized = []
116
+ for r in result:
117
+ event_val = r.get('event') or r.get('event_type') or 'NEW'
118
+ normalized.append({
119
+ 'decision': r.get('decision', 'Unknown'),
120
+ 'module': r.get('module', '/'),
121
+ 'file_patterns': r.get('file_patterns', '*'),
122
+ 'confidence': r.get('confidence', 'LOW'),
123
+ 'event_type': event_val
124
+ })
125
+
126
+ return normalized
127
+
128
+ except Exception as e:
129
+ raise
@@ -0,0 +1,40 @@
1
+ import os
2
+ import shutil
3
+ import stat
4
+ from pathlib import Path
5
+
6
+ def get_template_path(hook_name):
7
+ # Try to find the hooks directory relative to this file
8
+ base_dir = Path(__file__).resolve().parent.parent
9
+ hook_path = base_dir / 'hooks' / f"{hook_name}.sh"
10
+ return hook_path
11
+
12
+ def install_hooks():
13
+ git_dir = Path('.git')
14
+ if not git_dir.is_dir():
15
+ raise RuntimeError("Not a git repository. Please run in the root of a git repo.")
16
+
17
+ git_hooks_dir = git_dir / 'hooks'
18
+ git_hooks_dir.mkdir(exist_ok=True)
19
+
20
+ for hook in ['post-commit', 'pre-commit']:
21
+ src = get_template_path(hook)
22
+ if not src.exists():
23
+ raise RuntimeError(f"Hook template not found: {src}")
24
+
25
+ dest = git_hooks_dir / hook
26
+ shutil.copy2(src, dest)
27
+
28
+ # chmod +x
29
+ dest.chmod(dest.stat().st_mode | stat.S_IEXEC)
30
+
31
+ def uninstall_hooks():
32
+ git_dir = Path('.git')
33
+ if not git_dir.is_dir():
34
+ return
35
+
36
+ git_hooks_dir = git_dir / 'hooks'
37
+ for hook in ['post-commit', 'pre-commit']:
38
+ dest = git_hooks_dir / hook
39
+ if dest.exists() and "klyd" in dest.read_text():
40
+ dest.unlink()
@@ -0,0 +1,18 @@
1
+ def format_injection(decisions):
2
+ if not decisions:
3
+ return ""
4
+
5
+ lines = [
6
+ "[klyd] Architectural decisions governing files in this session:\n"
7
+ ]
8
+
9
+ for i, d in enumerate(decisions, 1):
10
+ mod = f"[{d['module']}]"
11
+ conf = f"{d['confidence']} confidence"
12
+ count = f"confirmed {d['reinforcement_count']} times"
13
+ dec = f"{d['decision']}."
14
+ lines.append(f"{i}. {mod} {dec} ({conf}, {count})")
15
+
16
+ lines.append("\nDo not contradict these decisions unless the user explicitly instructs you to change them.")
17
+
18
+ return "\n".join(lines)
@@ -0,0 +1,61 @@
1
+ Metadata-Version: 2.4
2
+ Name: klyd
3
+ Version: 0.1.0
4
+ Summary: A CLI tool that wraps coding agents via git hooks to inject architectural memory.
5
+ Requires-Python: >=3.11
6
+ Description-Content-Type: text/markdown
7
+ License-File: LICENSE
8
+ Requires-Dist: click
9
+ Requires-Dist: anthropic
10
+ Dynamic: license-file
11
+
12
+ <center>
13
+
14
+ ```ascii
15
+
16
+ ▄█ ▄█▄ ▄█ ▄██ ▄ ████████▄
17
+ ███ ▄███▀ ███ ███ ██▄ ███ ▀███
18
+ ███▐██▀ ███ ███▄▄▄███ ███ ███
19
+ ▄█████▀ ███ ▀▀▀▀▀▀███ ███ ███
20
+ ▀▀█████▄ ███ ▄██ ███ ███ ███
21
+ ███▐██▄ ███ ███ ███ ███ ███
22
+ ███ ▀███▄ ███▌ ▄ ███ ███ ███ ▄███
23
+ ███ ▀█▀ █████▄▄██ ▀█████▀ ████████▀
24
+ ▀ ▀
25
+
26
+ ```
27
+ </center>
28
+
29
+ install → klyd init → klyd config --api-key sk-... → klyd run aider
30
+
31
+ ## Quickstart
32
+
33
+ ```bash
34
+ # 1. Install
35
+ pip install .
36
+
37
+ # 2. Initialize in your repo
38
+ cd your-project
39
+ klyd init
40
+
41
+ # 3. Configure an API Key
42
+ # Anthropic (Default)
43
+ klyd config --api-key sk-ant-...
44
+
45
+ # OR OpenAI
46
+ klyd config --openai-key sk-proj-... --model gpt-4o
47
+
48
+ # OR OpenRouter
49
+ klyd config --openrouter-key sk-or-... --model openrouter/auto
50
+
51
+ # OR Gemini
52
+ klyd config --gemini-key AIza... --model gemini-1.5-pro
53
+
54
+ # OR Groq
55
+ klyd config --groq-key gsk_... --model llama3-8b-8192
56
+
57
+ # 4. Use your agent as normal
58
+ klyd run aider
59
+ ```
60
+
61
+ Decisions will automatically populate after you make commits. Review flagged contradictions using `klyd review`. Check current status with `klyd status`.
@@ -0,0 +1,20 @@
1
+ AGENTS.md.txt
2
+ LICENSE
3
+ MANIFEST.in
4
+ README.md
5
+ pyproject.toml
6
+ hooks/post-commit.sh
7
+ hooks/pre-commit.sh
8
+ klyd/__init__.py
9
+ klyd/cli.py
10
+ klyd/config.py
11
+ klyd/db.py
12
+ klyd/extractor.py
13
+ klyd/hooks.py
14
+ klyd/injector.py
15
+ klyd.egg-info/PKG-INFO
16
+ klyd.egg-info/SOURCES.txt
17
+ klyd.egg-info/dependency_links.txt
18
+ klyd.egg-info/entry_points.txt
19
+ klyd.egg-info/requires.txt
20
+ klyd.egg-info/top_level.txt
@@ -0,0 +1,2 @@
1
+ [console_scripts]
2
+ kl = klyd.cli:cli
@@ -0,0 +1,2 @@
1
+ click
2
+ anthropic
@@ -0,0 +1 @@
1
+ klyd
@@ -0,0 +1,20 @@
1
+ [build-system]
2
+ requires = ["setuptools>=61.0"]
3
+ build-backend = "setuptools.build_meta"
4
+
5
+ [project]
6
+ name = "klyd"
7
+ version = "0.1.0"
8
+ description = "A CLI tool that wraps coding agents via git hooks to inject architectural memory."
9
+ readme = "README.md"
10
+ requires-python = ">=3.11"
11
+ dependencies = [
12
+ "click",
13
+ "anthropic"
14
+ ]
15
+
16
+ [tool.setuptools.packages.find]
17
+ include = ["klyd", "klyd.*"]
18
+
19
+ [project.scripts]
20
+ kl = "klyd.cli:cli"
klyd-0.1.0/setup.cfg ADDED
@@ -0,0 +1,4 @@
1
+ [egg_info]
2
+ tag_build =
3
+ tag_date = 0
4
+