lliki 0.2.1__py3-none-any.whl

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.
Files changed (45) hide show
  1. lliki/__init__.py +3 -0
  2. lliki/__main__.py +4 -0
  3. lliki/branding.py +61 -0
  4. lliki/cli.py +524 -0
  5. lliki/core/__init__.py +1 -0
  6. lliki/core/append.py +24 -0
  7. lliki/core/bootstrap.py +187 -0
  8. lliki/core/context.py +33 -0
  9. lliki/core/doctor.py +71 -0
  10. lliki/core/inspection.py +61 -0
  11. lliki/core/models.py +51 -0
  12. lliki/core/patching.py +59 -0
  13. lliki/core/prompts.py +60 -0
  14. lliki/core/resources.py +103 -0
  15. lliki/core/runtime.py +124 -0
  16. lliki/core/tasks.py +138 -0
  17. lliki/hooks.py +70 -0
  18. lliki/integrations/__init__.py +1 -0
  19. lliki/integrations/install.py +101 -0
  20. lliki/templates/CLAUDE.md +98 -0
  21. lliki/templates/integrations/claude/SKILL.md +26 -0
  22. lliki/templates/integrations/generic/AGENTS.md +23 -0
  23. lliki/templates/integrations/hermes/HERMES.md +20 -0
  24. lliki/templates/prompts/complete-task.md +16 -0
  25. lliki/templates/prompts/explore-impact.md +14 -0
  26. lliki/templates/prompts/initialize-project.md +26 -0
  27. lliki/templates/prompts/migrate-legacy.md +14 -0
  28. lliki/templates/prompts/review-wiki.md +13 -0
  29. lliki/templates/template-pack.json +81 -0
  30. lliki/templates/wiki/decisions.md +16 -0
  31. lliki/templates/wiki/docs/README.md +13 -0
  32. lliki/templates/wiki/docs/development-workflow.md +25 -0
  33. lliki/templates/wiki/docs/project-overview.md +20 -0
  34. lliki/templates/wiki/docs/repository-rules.md +23 -0
  35. lliki/templates/wiki/exploratory/index.md +13 -0
  36. lliki/templates/wiki/index.md +38 -0
  37. lliki/templates/wiki/lessons_learned.md +15 -0
  38. lliki/templates/wiki/tasks/dashboard.md +22 -0
  39. lliki/templates/wiki/wiki-rules.md +87 -0
  40. lliki-0.2.1.dist-info/METADATA +333 -0
  41. lliki-0.2.1.dist-info/RECORD +45 -0
  42. lliki-0.2.1.dist-info/WHEEL +5 -0
  43. lliki-0.2.1.dist-info/entry_points.txt +2 -0
  44. lliki-0.2.1.dist-info/licenses/LICENSE +21 -0
  45. lliki-0.2.1.dist-info/top_level.txt +1 -0
lliki/__init__.py ADDED
@@ -0,0 +1,3 @@
1
+ """lliki package."""
2
+
3
+ __version__ = "0.2.1"
lliki/__main__.py ADDED
@@ -0,0 +1,4 @@
1
+ from .cli import main
2
+
3
+ if __name__ == "__main__":
4
+ raise SystemExit(main())
lliki/branding.py ADDED
@@ -0,0 +1,61 @@
1
+ """Terminal branding for Lliki's interactive setup."""
2
+
3
+ from __future__ import annotations
4
+
5
+ import os
6
+ import shutil
7
+ import sys
8
+ from typing import TextIO
9
+
10
+
11
+ LARGE_LOGO = r"""
12
+ ____ ____ ____ ____ ___ ____
13
+ |\ \ |\ \ |\ \|\ \|\ \|\ \
14
+ \ \ \ \ \ \ \ \ \\ \ \ \ \\ \ \
15
+ \ \ \ \ \ \ \ \ \\ \ \ \ \\ \ \
16
+ \ \ \____ \ \ \____ \ \ \\ \ \ \ \\ \ \____
17
+ \ \________\ \ \________\ \ \__\\ \__\ \__\\ \________\
18
+ \|________| \|________| \|__| \|__|\|__| \|________|
19
+ """.strip("\n").splitlines()
20
+
21
+ COMPACT_LOGO = r"""
22
+ _ _ ___ _ __ ___
23
+ | | | | |_ _|| |/ /|_ _|
24
+ | | | | | | | ' / | |
25
+ | |___ | |___ | | | . \ | |
26
+ |_____| |_____| |___||_|\_\|___|
27
+ """.strip("\n").splitlines()
28
+
29
+ PURPOSE = "Local-first repository wiki setup and maintenance for humans and coding tools."
30
+ PRIVACY = "No LLM API required; repository content stays local by default."
31
+ AUTHOR = "brunofbloq"
32
+
33
+
34
+ def banner_enabled() -> bool:
35
+ """Return whether the interactive welcome banner is enabled."""
36
+ value = os.environ.get("LLIKI_NO_BANNER", "").strip().lower()
37
+ return value not in {"1", "true", "yes", "on"}
38
+
39
+
40
+ def render_welcome(width: int | None = None) -> str:
41
+ """Render a width-aware welcome block without terminal control sequences."""
42
+ columns = width or shutil.get_terminal_size(fallback=(100, 24)).columns
43
+ logo = LARGE_LOGO if columns >= 78 else COMPACT_LOGO
44
+ rule_width = min(max(len(PURPOSE), len(PRIVACY), 48), max(columns, 48))
45
+ rule = "─" * rule_width
46
+ return "\n".join(
47
+ (
48
+ *logo,
49
+ "",
50
+ PURPOSE,
51
+ PRIVACY,
52
+ f"Author: {AUTHOR}",
53
+ rule,
54
+ )
55
+ )
56
+
57
+
58
+ def print_welcome(stream: TextIO = sys.stdout, width: int | None = None) -> None:
59
+ """Print the welcome block for a human-facing interactive setup."""
60
+ print(render_welcome(width=width), file=stream)
61
+ print(file=stream)
lliki/cli.py ADDED
@@ -0,0 +1,524 @@
1
+ from __future__ import annotations
2
+
3
+ import argparse
4
+ import json
5
+ import os
6
+ import sys
7
+ from dataclasses import asdict
8
+ from pathlib import Path
9
+ from typing import Iterable, Optional, Sequence
10
+
11
+ from . import __version__
12
+ from .branding import banner_enabled, print_welcome
13
+ from .core.append import append_entry
14
+ from .core.bootstrap import apply_template_pack, initialize_repository
15
+ from .core.context import context_routes
16
+ from .core.doctor import run_doctor
17
+ from .core.inspection import find_legacy_locations, repository_signals
18
+ from .core.models import SetupConfig
19
+ from .core.patching import extract_section
20
+ from .core.prompts import format_prompt, load_prompts
21
+ from .core.resources import (
22
+ export_built_in_templates,
23
+ load_template_pack,
24
+ read_template,
25
+ validate_template_pack,
26
+ )
27
+ from .core.tasks import refresh_dashboard
28
+ from .core.runtime import read_state, set_state_fields
29
+ from .hooks import run_hook
30
+
31
+
32
+ def _root(value: str) -> Path:
33
+ return Path(value).expanduser().resolve()
34
+
35
+
36
+ def _print_inspection(root: Path, max_depth: int) -> dict:
37
+ found = find_legacy_locations(root, max_depth=max_depth)
38
+ print(f"Repository: {root}")
39
+ print("\nDetected context locations:")
40
+ for label, key in (
41
+ ("Legacy directories", "legacy_dirs"),
42
+ ("CLAUDE.md files", "claude_files"),
43
+ ("Wiki directories", "wiki_dirs"),
44
+ ):
45
+ values = found[key]
46
+ print(f" {label}:")
47
+ if values:
48
+ for value in values:
49
+ print(f" - {value}")
50
+ else:
51
+ print(" - None")
52
+ signals = repository_signals(root)
53
+ print(" Repository signals: " + (", ".join(signals) if signals else "None detected"))
54
+ return found
55
+
56
+
57
+ def _ask_choice(prompt: str, options: Sequence[str], default: int = 1) -> int:
58
+ print(prompt)
59
+ for index, option in enumerate(options, 1):
60
+ print(f" [{index}] {option}")
61
+ while True:
62
+ raw = input(f"Select [{default}]: ").strip()
63
+ if not raw:
64
+ return default
65
+ if raw.isdigit() and 1 <= int(raw) <= len(options):
66
+ return int(raw)
67
+ print("Please select a valid number.")
68
+
69
+
70
+ def _ask_yes_no(prompt: str, default: bool = True) -> bool:
71
+ suffix = " [Y/n]: " if default else " [y/N]: "
72
+ while True:
73
+ raw = input(prompt + suffix).strip().lower()
74
+ if not raw:
75
+ return default
76
+ if raw in {"y", "yes"}:
77
+ return True
78
+ if raw in {"n", "no"}:
79
+ return False
80
+ print("Please answer y or n.")
81
+
82
+
83
+ def _parse_integrations(raw: Iterable[str]) -> tuple[str, ...]:
84
+ values: list[str] = []
85
+ for item in raw:
86
+ for value in item.split(","):
87
+ value = value.strip().lower()
88
+ if value and value not in values:
89
+ values.append(value)
90
+ allowed = {"generic", "claude", "hermes"}
91
+ unknown = [value for value in values if value not in allowed]
92
+ if unknown:
93
+ raise ValueError(f"Unknown integrations: {', '.join(unknown)}")
94
+ return tuple(values)
95
+
96
+
97
+ def _interactive_config(root: Path, force_mode: Optional[str], legacy_found: bool) -> SetupConfig:
98
+ if force_mode:
99
+ mode = force_mode
100
+ else:
101
+ choice = _ask_choice(
102
+ "\nChoose setup mode:",
103
+ [
104
+ "Default — create/repair wiki and update CLAUDE.md",
105
+ "Custom — optional runtime state and repository-local agent integrations",
106
+ ],
107
+ default=1,
108
+ )
109
+ mode = "default" if choice == 1 else "custom"
110
+
111
+ if mode == "default":
112
+ return SetupConfig(setup_mode="default")
113
+
114
+ runtime_choice = _ask_choice(
115
+ "\nRuntime assistance:",
116
+ [
117
+ "Disabled — wiki only",
118
+ "Assisted — lightweight local resume state",
119
+ "Debug — resume state plus optional scratchpad and logs",
120
+ ],
121
+ default=1,
122
+ )
123
+ runtime_mode = {1: "off", 2: "assisted", 3: "debug"}[runtime_choice]
124
+ scratchpad = runtime_mode == "debug" and _ask_yes_no(
125
+ "Create optional .lliki/scratchpad.md?", default=False
126
+ )
127
+
128
+ print("\nRepository-local agent integrations (comma-separated):")
129
+ print(" generic -> AGENTS.md")
130
+ print(" claude -> .claude/skills/lliki/")
131
+ print(" hermes -> .hermes.md")
132
+ raw = input("Select integrations, or leave empty: ").strip()
133
+ integrations = _parse_integrations([raw]) if raw else tuple()
134
+ claude_hooks = False
135
+ if "claude" in integrations:
136
+ claude_hooks = _ask_yes_no(
137
+ "Enable repository-local Claude hooks for mechanical dashboard refresh?",
138
+ default=False,
139
+ )
140
+ legacy_prompt = legacy_found and _ask_yes_no(
141
+ "Also print the optional LLM prompt for reviewing legacy context?",
142
+ default=False,
143
+ )
144
+ return SetupConfig(
145
+ setup_mode="custom",
146
+ runtime_mode=runtime_mode,
147
+ scratchpad=scratchpad,
148
+ integrations=integrations,
149
+ claude_hooks=claude_hooks,
150
+ legacy_prompt=legacy_prompt,
151
+ )
152
+
153
+
154
+ def _noninteractive_config(args: argparse.Namespace) -> SetupConfig:
155
+ integrations = _parse_integrations(args.integrate or [])
156
+ advanced = bool(
157
+ integrations
158
+ or args.runtime != "off"
159
+ or args.scratchpad
160
+ or args.claude_hooks
161
+ or args.legacy_prompt
162
+ )
163
+ if args.default and advanced:
164
+ raise ValueError("Advanced runtime or integration options require --custom")
165
+ setup_mode = "custom" if args.custom or advanced else "default"
166
+ runtime_mode = args.runtime or "off"
167
+ if args.claude_hooks and "claude" not in integrations:
168
+ raise ValueError("--claude-hooks requires --integrate claude")
169
+ return SetupConfig(
170
+ setup_mode=setup_mode,
171
+ runtime_mode=runtime_mode,
172
+ scratchpad=bool(args.scratchpad),
173
+ integrations=integrations,
174
+ claude_hooks=bool(args.claude_hooks),
175
+ legacy_prompt=bool(args.legacy_prompt),
176
+ )
177
+
178
+
179
+ def _show_init_result(data: dict) -> None:
180
+ result = data["result"]
181
+ print("\nPlan result" if data["dry_run"] else "\nSetup result")
182
+ for label, values in (
183
+ ("Created", result.created),
184
+ ("Updated", result.updated),
185
+ ("Preserved", result.preserved),
186
+ ("Backups", result.backups),
187
+ ("Runtime", data["runtime_created"]),
188
+ ):
189
+ if values:
190
+ print(f" {label}:")
191
+ for value in values:
192
+ print(f" - {value}")
193
+ if data["integrations"]:
194
+ print(" Integrations:")
195
+ for item in data["integrations"]:
196
+ print(f" - {item['integration']}: {', '.join(item['files'])}")
197
+ if result.warnings:
198
+ print(" Warnings:")
199
+ for warning in result.warnings:
200
+ print(f" - {warning}")
201
+ if result.needs_context:
202
+ print("\nFiles requiring LLM or human context:")
203
+ for value in result.needs_context:
204
+ print(f" - {value}")
205
+ for prompt in data["prompts"]:
206
+ print(prompt)
207
+
208
+
209
+ def command_init(args: argparse.Namespace) -> int:
210
+ root = _root(args.root)
211
+ if args.template_dir:
212
+ os.environ["LLIKI_TEMPLATE_DIR"] = str(_root(args.template_dir))
213
+ interactive = not args.yes and sys.stdin.isatty() and sys.stdout.isatty()
214
+ if interactive and banner_enabled():
215
+ print_welcome()
216
+ found = _print_inspection(root, args.max_depth)
217
+ force_mode = "default" if args.default else "custom" if args.custom else None
218
+ if interactive:
219
+ config = _interactive_config(root, force_mode, bool(found["legacy_dirs"]))
220
+ print("\nSelected configuration:")
221
+ print(f" Setup: {config.setup_mode}")
222
+ print(f" Runtime: {config.runtime_mode}")
223
+ print(f" Scratchpad: {'yes' if config.scratchpad else 'no'}")
224
+ print(f" Integrations: {', '.join(config.integrations) if config.integrations else 'none'}")
225
+ if not _ask_yes_no("Proceed with this setup?", default=True):
226
+ print("Cancelled.")
227
+ return 1
228
+ else:
229
+ config = _noninteractive_config(args)
230
+
231
+ data = initialize_repository(
232
+ root,
233
+ config,
234
+ interactive=interactive,
235
+ yes=args.yes,
236
+ dry_run=args.dry_run,
237
+ max_depth=args.max_depth,
238
+ )
239
+ _show_init_result(data)
240
+ return 0
241
+
242
+
243
+ def command_inspect(args: argparse.Namespace) -> int:
244
+ root = _root(args.root)
245
+ found = find_legacy_locations(root, max_depth=args.max_depth)
246
+ payload = {"root": str(root), "locations": found, "signals": repository_signals(root)}
247
+ if args.json:
248
+ print(json.dumps(payload, indent=2))
249
+ else:
250
+ _print_inspection(root, args.max_depth)
251
+ return 0
252
+
253
+
254
+ def command_doctor(args: argparse.Namespace) -> int:
255
+ report = run_doctor(_root(args.root))
256
+ if args.json:
257
+ print(json.dumps(report, indent=2))
258
+ else:
259
+ summary = report["summary"]
260
+ print(f"Errors: {summary['errors']} Warnings: {summary['warnings']}")
261
+ for issue in report["issues"]:
262
+ detail = f" ({issue['detail']})" if issue.get("detail") else ""
263
+ print(f"- {issue['severity'].upper()}: {issue['code']}: {issue['path']}{detail}")
264
+ return 0 if report["ok"] else 2
265
+
266
+
267
+ def command_prompt(args: argparse.Namespace) -> int:
268
+ prompts = load_prompts()
269
+ if args.prompt_command == "list":
270
+ for prompt_id, prompt in sorted(prompts.items()):
271
+ print(f"{prompt_id:20} {prompt.title} total ~{prompt.expected_total_tokens}")
272
+ return 0
273
+ prompt = prompts.get(args.prompt_id)
274
+ if not prompt:
275
+ print(f"Unknown prompt: {args.prompt_id}", file=sys.stderr)
276
+ return 2
277
+ print(format_prompt(prompt))
278
+ return 0
279
+
280
+
281
+ def _template_diff(root: Path) -> list[dict]:
282
+ pack = load_template_pack()
283
+ diffs: list[dict] = []
284
+ for item in pack.files:
285
+ target = root / item.target
286
+ if not target.exists():
287
+ diffs.append({"path": item.target, "status": "missing"})
288
+ continue
289
+ if item.strategy == "managed-section" and item.section_id:
290
+ existing = target.read_text(encoding="utf-8")
291
+ template = read_template(item.template)
292
+ old = extract_section(existing, item.section_id)
293
+ new = extract_section(template, item.section_id)
294
+ if old is None:
295
+ diffs.append({"path": item.target, "status": "unmanaged"})
296
+ elif old != new:
297
+ diffs.append({"path": item.target, "status": "managed-update-available"})
298
+ return diffs
299
+
300
+
301
+ def command_templates(args: argparse.Namespace) -> int:
302
+ if args.templates_command == "export":
303
+ export_built_in_templates(_root(args.destination), force=args.force)
304
+ print(f"Exported editable templates to {_root(args.destination)}")
305
+ return 0
306
+ if args.templates_command == "validate":
307
+ errors = validate_template_pack(_root(args.template_dir) if args.template_dir else None)
308
+ if errors:
309
+ for error in errors:
310
+ print(f"ERROR: {error}")
311
+ return 2
312
+ print("Template pack is valid.")
313
+ return 0
314
+ if args.template_dir:
315
+ os.environ["LLIKI_TEMPLATE_DIR"] = str(_root(args.template_dir))
316
+ root = _root(args.root)
317
+ if args.templates_command == "diff":
318
+ diffs = _template_diff(root)
319
+ if args.json:
320
+ print(json.dumps(diffs, indent=2))
321
+ elif not diffs:
322
+ print("No managed template updates or missing files detected.")
323
+ else:
324
+ for item in diffs:
325
+ print(f"{item['status']:28} {item['path']}")
326
+ return 1 if diffs else 0
327
+ if args.templates_command == "sync":
328
+ result = apply_template_pack(
329
+ root,
330
+ interactive=not args.yes and sys.stdin.isatty(),
331
+ yes=args.yes,
332
+ dry_run=args.dry_run,
333
+ )
334
+ print(json.dumps(asdict(result), indent=2) if args.json else _result_text(result))
335
+ return 0
336
+ return 2
337
+
338
+
339
+ def _result_text(result) -> str:
340
+ lines = []
341
+ for label, values in (("Created", result.created), ("Updated", result.updated), ("Preserved", result.preserved)):
342
+ if values:
343
+ lines.append(f"{label}: " + ", ".join(values))
344
+ return "\n".join(lines) or "No changes."
345
+
346
+
347
+ def command_tasks(args: argparse.Namespace) -> int:
348
+ result = refresh_dashboard(
349
+ _root(args.root),
350
+ update_index=args.update_index,
351
+ dry_run=args.dry_run,
352
+ )
353
+ print(json.dumps(result, indent=2) if args.json else _mapping_text(result))
354
+ return 0
355
+
356
+
357
+ def _mapping_text(data: dict) -> str:
358
+ return "\n".join(f"{key}: {value}" for key, value in data.items())
359
+
360
+
361
+ def command_context(args: argparse.Namespace) -> int:
362
+ data = context_routes(_root(args.root))
363
+ print(json.dumps(data, indent=2) if args.json else _mapping_text(data))
364
+ return 0
365
+
366
+
367
+
368
+ def command_state(args: argparse.Namespace) -> int:
369
+ root = _root(args.root)
370
+ if args.state_command == "show":
371
+ state = read_state(root)
372
+ if not state:
373
+ print("Runtime state is not enabled for this repository.", file=sys.stderr)
374
+ return 2
375
+ print(json.dumps(state, indent=2) if args.json else _mapping_text(state))
376
+ return 0
377
+ blockers = args.blocker if args.blocker is not None else None
378
+ state = set_state_fields(
379
+ root,
380
+ active_task=args.active_task,
381
+ last_result=args.last_result,
382
+ next_action=args.next_action,
383
+ blockers=blockers,
384
+ clear=args.clear,
385
+ )
386
+ print(json.dumps(state, indent=2) if args.json else _mapping_text(state))
387
+ return 0
388
+
389
+ def command_append(args: argparse.Namespace) -> int:
390
+ content = Path(args.file).read_text(encoding="utf-8") if args.file else sys.stdin.read()
391
+ target = append_entry(_root(args.root), args.kind, content)
392
+ print(f"Appended {args.kind} entry to {target}")
393
+ return 0
394
+
395
+
396
+ def command_hook(args: argparse.Namespace) -> int:
397
+ return run_hook(args.event, _root(args.root))
398
+
399
+
400
+ def build_parser() -> argparse.ArgumentParser:
401
+ parser = argparse.ArgumentParser(prog="lliki", description="Bootstrap and maintain a local repository wiki.")
402
+ parser.add_argument("--version", action="version", version=f"lliki {__version__}")
403
+ sub = parser.add_subparsers(dest="command")
404
+
405
+ init = sub.add_parser("init", help="Create or repair the wiki using the default/custom TUI")
406
+ init.add_argument("--root", default=".")
407
+ modes = init.add_mutually_exclusive_group()
408
+ modes.add_argument("--default", action="store_true", help="Use simple default setup")
409
+ modes.add_argument("--custom", action="store_true", help="Use custom setup")
410
+ init.add_argument("--runtime", choices=["off", "assisted", "debug"], default="off")
411
+ init.add_argument("--scratchpad", action="store_true")
412
+ init.add_argument("--integrate", action="append", help="generic, claude, or hermes; repeatable/comma-separated")
413
+ init.add_argument("--claude-hooks", action="store_true")
414
+ init.add_argument("--legacy-prompt", action="store_true")
415
+ init.add_argument("--template-dir")
416
+ init.add_argument("--dry-run", action="store_true")
417
+ init.add_argument("--yes", "-y", action="store_true")
418
+ init.add_argument("--max-depth", type=int, default=6)
419
+ init.set_defaults(func=command_init)
420
+
421
+ inspect = sub.add_parser("inspect", help="Inspect wiki and legacy context locations")
422
+ inspect.add_argument("--root", default=".")
423
+ inspect.add_argument("--max-depth", type=int, default=6)
424
+ inspect.add_argument("--json", action="store_true")
425
+ inspect.set_defaults(func=command_inspect)
426
+
427
+ doctor = sub.add_parser("doctor", help="Run token-free structural wiki checks")
428
+ doctor.add_argument("--root", default=".")
429
+ doctor.add_argument("--json", action="store_true")
430
+ doctor.set_defaults(func=command_doctor)
431
+
432
+ prompt = sub.add_parser("prompt", help="Print recommended LLM prompts and token estimates")
433
+ prompt_sub = prompt.add_subparsers(dest="prompt_command", required=True)
434
+ prompt_list = prompt_sub.add_parser("list")
435
+ prompt_list.set_defaults(func=command_prompt)
436
+ prompt_show = prompt_sub.add_parser("show")
437
+ prompt_show.add_argument("prompt_id")
438
+ prompt_show.set_defaults(func=command_prompt)
439
+
440
+ templates = sub.add_parser("templates", help="Export, validate, compare, or sync editable templates")
441
+ template_sub = templates.add_subparsers(dest="templates_command", required=True)
442
+ export = template_sub.add_parser("export")
443
+ export.add_argument("destination")
444
+ export.add_argument("--force", action="store_true")
445
+ export.set_defaults(func=command_templates)
446
+ validate = template_sub.add_parser("validate")
447
+ validate.add_argument("--template-dir")
448
+ validate.set_defaults(func=command_templates)
449
+ for name in ("diff", "sync"):
450
+ p = template_sub.add_parser(name)
451
+ p.add_argument("--root", default=".")
452
+ p.add_argument("--template-dir")
453
+ p.add_argument("--json", action="store_true")
454
+ if name == "sync":
455
+ p.add_argument("--dry-run", action="store_true")
456
+ p.add_argument("--yes", "-y", action="store_true")
457
+ p.set_defaults(func=command_templates)
458
+
459
+ tasks = sub.add_parser("tasks", help="Mechanical task dashboard operations")
460
+ task_sub = tasks.add_subparsers(dest="tasks_command", required=True)
461
+ refresh = task_sub.add_parser("refresh")
462
+ refresh.add_argument("--root", default=".")
463
+ refresh.add_argument("--update-index", action="store_true")
464
+ refresh.add_argument("--dry-run", action="store_true")
465
+ refresh.add_argument("--json", action="store_true")
466
+ refresh.set_defaults(func=command_tasks)
467
+
468
+ context = sub.add_parser("context", help="Return bounded context routes without reading full content")
469
+ context.add_argument("--root", default=".")
470
+ context.add_argument("--json", action="store_true")
471
+ context.set_defaults(func=command_context)
472
+
473
+
474
+ state = sub.add_parser("state", help="Agent-facing lightweight resume-state operations")
475
+ state_sub = state.add_subparsers(dest="state_command", required=True)
476
+ state_show = state_sub.add_parser("show")
477
+ state_show.add_argument("--root", default=".")
478
+ state_show.add_argument("--json", action="store_true")
479
+ state_show.set_defaults(func=command_state)
480
+ state_update = state_sub.add_parser("update")
481
+ state_update.add_argument("--root", default=".")
482
+ state_update.add_argument("--active-task")
483
+ state_update.add_argument("--last-result")
484
+ state_update.add_argument("--next-action")
485
+ state_update.add_argument("--blocker", action="append")
486
+ state_update.add_argument("--clear", action="store_true")
487
+ state_update.add_argument("--json", action="store_true")
488
+ state_update.set_defaults(func=command_state)
489
+
490
+ append = sub.add_parser("append", help="Safely append a decision or lesson entry")
491
+ append.add_argument("kind", choices=["decision", "lesson"])
492
+ append.add_argument("--root", default=".")
493
+ append.add_argument("--file", help="Read entry from file; otherwise stdin")
494
+ append.set_defaults(func=command_append)
495
+
496
+ hook = sub.add_parser("hook", help="Internal repository-integration lifecycle hook")
497
+ hook.add_argument("event", choices=["claude-session-start", "claude-task-completed", "claude-stop"])
498
+ hook.add_argument("--root", default=".")
499
+ hook.set_defaults(func=command_hook)
500
+ return parser
501
+
502
+
503
+ def _configure_stdio() -> None:
504
+ for stream in (sys.stdout, sys.stderr):
505
+ try:
506
+ stream.reconfigure(errors="replace")
507
+ except (AttributeError, ValueError):
508
+ pass
509
+
510
+
511
+ def main(argv: Optional[Sequence[str]] = None) -> int:
512
+ _configure_stdio()
513
+ parser = build_parser()
514
+ args = parser.parse_args(argv)
515
+ if not args.command:
516
+ args = parser.parse_args(["init"] + list(argv or []))
517
+ try:
518
+ return int(args.func(args))
519
+ except KeyboardInterrupt:
520
+ print("\nCancelled.", file=sys.stderr)
521
+ return 130
522
+ except (ValueError, FileNotFoundError, PermissionError, OSError) as exc:
523
+ print(f"ERROR: {exc}", file=sys.stderr)
524
+ return 2
lliki/core/__init__.py ADDED
@@ -0,0 +1 @@
1
+ """Core operations for lliki."""
lliki/core/append.py ADDED
@@ -0,0 +1,24 @@
1
+ from __future__ import annotations
2
+
3
+ from pathlib import Path
4
+
5
+ from .patching import atomic_write
6
+
7
+ TARGETS = {
8
+ "decision": "wiki/decisions.md",
9
+ "lesson": "wiki/lessons_learned.md",
10
+ }
11
+
12
+
13
+ def append_entry(root: Path, kind: str, content: str) -> str:
14
+ if kind not in TARGETS:
15
+ raise ValueError(f"Unsupported append kind: {kind}")
16
+ target = root / TARGETS[kind]
17
+ if not target.exists():
18
+ raise FileNotFoundError(f"Missing target file: {TARGETS[kind]}")
19
+ content = content.strip()
20
+ if not content.startswith("## "):
21
+ raise ValueError("Entry must begin with a level-2 Markdown heading ('## ')")
22
+ existing = target.read_text(encoding="utf-8").rstrip()
23
+ atomic_write(target, f"{existing}\n\n{content}\n")
24
+ return TARGETS[kind]