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.
- lliki/__init__.py +3 -0
- lliki/__main__.py +4 -0
- lliki/branding.py +61 -0
- lliki/cli.py +524 -0
- lliki/core/__init__.py +1 -0
- lliki/core/append.py +24 -0
- lliki/core/bootstrap.py +187 -0
- lliki/core/context.py +33 -0
- lliki/core/doctor.py +71 -0
- lliki/core/inspection.py +61 -0
- lliki/core/models.py +51 -0
- lliki/core/patching.py +59 -0
- lliki/core/prompts.py +60 -0
- lliki/core/resources.py +103 -0
- lliki/core/runtime.py +124 -0
- lliki/core/tasks.py +138 -0
- lliki/hooks.py +70 -0
- lliki/integrations/__init__.py +1 -0
- lliki/integrations/install.py +101 -0
- lliki/templates/CLAUDE.md +98 -0
- lliki/templates/integrations/claude/SKILL.md +26 -0
- lliki/templates/integrations/generic/AGENTS.md +23 -0
- lliki/templates/integrations/hermes/HERMES.md +20 -0
- lliki/templates/prompts/complete-task.md +16 -0
- lliki/templates/prompts/explore-impact.md +14 -0
- lliki/templates/prompts/initialize-project.md +26 -0
- lliki/templates/prompts/migrate-legacy.md +14 -0
- lliki/templates/prompts/review-wiki.md +13 -0
- lliki/templates/template-pack.json +81 -0
- lliki/templates/wiki/decisions.md +16 -0
- lliki/templates/wiki/docs/README.md +13 -0
- lliki/templates/wiki/docs/development-workflow.md +25 -0
- lliki/templates/wiki/docs/project-overview.md +20 -0
- lliki/templates/wiki/docs/repository-rules.md +23 -0
- lliki/templates/wiki/exploratory/index.md +13 -0
- lliki/templates/wiki/index.md +38 -0
- lliki/templates/wiki/lessons_learned.md +15 -0
- lliki/templates/wiki/tasks/dashboard.md +22 -0
- lliki/templates/wiki/wiki-rules.md +87 -0
- lliki-0.2.1.dist-info/METADATA +333 -0
- lliki-0.2.1.dist-info/RECORD +45 -0
- lliki-0.2.1.dist-info/WHEEL +5 -0
- lliki-0.2.1.dist-info/entry_points.txt +2 -0
- lliki-0.2.1.dist-info/licenses/LICENSE +21 -0
- lliki-0.2.1.dist-info/top_level.txt +1 -0
lliki/__init__.py
ADDED
lliki/__main__.py
ADDED
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]
|