loki-mode 7.16.1 → 7.17.0
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/SKILL.md +2 -2
- package/VERSION +1 -1
- package/agents/hub_install.py +652 -0
- package/autonomy/lib/assets_bundle.py +446 -0
- package/autonomy/loki +450 -8
- package/dashboard/__init__.py +1 -1
- package/docs/INSTALLATION.md +1 -1
- package/docs/R10-MARKETPLACE-PLAN.md +137 -0
- package/docs/R8-SHAREABLE-TEAM-ASSETS-PLAN.md +129 -0
- package/loki-ts/dist/loki.js +2 -2
- package/mcp/__init__.py +1 -1
- package/package.json +1 -1
|
@@ -0,0 +1,446 @@
|
|
|
1
|
+
"""Shareable team-asset bundler for Loki Mode (R8).
|
|
2
|
+
|
|
3
|
+
Bundles a team's invested, reusable assets into a portable, REDACTED tarball
|
|
4
|
+
that can be re-imported into another project or a fresh clone. This is the
|
|
5
|
+
"individual setup -> org lock-in" mechanism: setup compounds into shared value.
|
|
6
|
+
|
|
7
|
+
Scope (what is and is NOT a duplicate):
|
|
8
|
+
- `loki export` (autonomy/loki cmd_export) produces a SESSION SNAPSHOT
|
|
9
|
+
(json/md/csv/timeline) of one .loki/ run. It is per-run, not portable
|
|
10
|
+
across teams, and not redacted for sharing.
|
|
11
|
+
- `loki assets` (this module) produces a PORTABLE, REDACTED team-asset
|
|
12
|
+
tarball: cross-project + project memory/learnings, the agent registry,
|
|
13
|
+
PRD templates, council config, and (optionally) the R5 wiki. Different
|
|
14
|
+
scope, different artifact. It REUSES the proof_redact chokepoint rather
|
|
15
|
+
than introducing a second redactor.
|
|
16
|
+
|
|
17
|
+
Redaction:
|
|
18
|
+
- Single chokepoint: autonomy/lib/proof_redact.py. JSON/JSONL go through
|
|
19
|
+
redact_tree(); Markdown/text go through redact_value(). set_context() is
|
|
20
|
+
called first so absolute home/repo paths collapse to ~ / . before the
|
|
21
|
+
bundle leaves the machine. No second redactor is defined here.
|
|
22
|
+
|
|
23
|
+
Asset map (source-of-truth -> bundle path -> restore root):
|
|
24
|
+
- ~/.loki/learnings/*.jsonl -> learnings/*.jsonl -> $HOME/.loki/learnings
|
|
25
|
+
- <project>/.loki/memory/** -> memory/** -> <project>/.loki/memory
|
|
26
|
+
- <repo>/agents/types.json -> agents/types.json -> <repo>/agents
|
|
27
|
+
- <repo>/templates/*.md -> templates/*.md -> <repo>/templates
|
|
28
|
+
- <project>/.loki/council/*.json -> council/*.json -> <project>/.loki/council
|
|
29
|
+
- <project>/.loki/wiki/* -> wiki/* -> <project>/.loki/wiki (opt-in)
|
|
30
|
+
|
|
31
|
+
The export and import callers pass DIFFERENT repo_root values deliberately
|
|
32
|
+
(see autonomy/loki cmd_assets): export reads agents/templates from the loki
|
|
33
|
+
install ($SKILL_DIR, where team edits live and are read at runtime); import
|
|
34
|
+
writes them under the caller's cwd (the target clone root), never back into
|
|
35
|
+
the install. This module just honors the repo_root it is handed; the
|
|
36
|
+
asymmetry is enforced by the bash caller.
|
|
37
|
+
|
|
38
|
+
Honest limitations (stated, not hidden):
|
|
39
|
+
- There is no separate per-user "custom agent" store today. "Custom agents"
|
|
40
|
+
== the agents/types.json registry (41 shipped types plus any team edits).
|
|
41
|
+
Bundling captures team edits/additions but also re-ships the defaults.
|
|
42
|
+
- Likewise templates/ and agents/types.json are repo-shipped defaults; a
|
|
43
|
+
bundle from a fresh checkout carries the stock set. That is expected: the
|
|
44
|
+
value is captured team DELTAS travelling with the stock baseline.
|
|
45
|
+
- agents/templates restore relative to the import caller's cwd. loki reads
|
|
46
|
+
them from its install dir at runtime, so a global-install user must copy
|
|
47
|
+
the restored agents/ + templates/ into their install. memory, learnings,
|
|
48
|
+
council, and wiki restore to $HOME/.loki or <project>/.loki and take
|
|
49
|
+
effect immediately.
|
|
50
|
+
"""
|
|
51
|
+
|
|
52
|
+
import io
|
|
53
|
+
import json
|
|
54
|
+
import os
|
|
55
|
+
import sys
|
|
56
|
+
import tarfile
|
|
57
|
+
|
|
58
|
+
# proof_redact lives next to this file (autonomy/lib/). Import it as the single
|
|
59
|
+
# redaction chokepoint -- do NOT reimplement any redaction here.
|
|
60
|
+
sys.path.insert(0, os.path.dirname(os.path.abspath(__file__)))
|
|
61
|
+
import proof_redact # noqa: E402
|
|
62
|
+
|
|
63
|
+
SCHEMA_VERSION = "1.0"
|
|
64
|
+
|
|
65
|
+
# Asset categories included by default. "wiki" is opt-in (can be large / noisy).
|
|
66
|
+
DEFAULT_CATEGORIES = ["learnings", "memory", "agents", "templates", "council"]
|
|
67
|
+
OPTIONAL_CATEGORIES = ["wiki"]
|
|
68
|
+
|
|
69
|
+
|
|
70
|
+
def _redact_json_text(text):
|
|
71
|
+
"""Redact a JSON document string. Returns (redacted_text, count).
|
|
72
|
+
|
|
73
|
+
Falls back to plain-string redaction when the text is not valid JSON.
|
|
74
|
+
"""
|
|
75
|
+
try:
|
|
76
|
+
obj = json.loads(text)
|
|
77
|
+
except (ValueError, TypeError):
|
|
78
|
+
return proof_redact.redact_value(text), 0
|
|
79
|
+
red, n = proof_redact.redact_tree(obj)
|
|
80
|
+
return json.dumps(red, indent=2, ensure_ascii=False), n
|
|
81
|
+
|
|
82
|
+
|
|
83
|
+
def _redact_jsonl_text(text):
|
|
84
|
+
"""Redact a JSONL document line-by-line. Returns (redacted_text, count).
|
|
85
|
+
|
|
86
|
+
Each non-blank line is parsed, redacted via redact_tree, reserialized.
|
|
87
|
+
Lines that are not valid JSON are redacted as raw strings (best effort).
|
|
88
|
+
"""
|
|
89
|
+
out_lines = []
|
|
90
|
+
total = 0
|
|
91
|
+
for line in text.splitlines():
|
|
92
|
+
if not line.strip():
|
|
93
|
+
out_lines.append(line)
|
|
94
|
+
continue
|
|
95
|
+
try:
|
|
96
|
+
obj = json.loads(line)
|
|
97
|
+
red, n = proof_redact.redact_tree(obj)
|
|
98
|
+
out_lines.append(json.dumps(red, ensure_ascii=False))
|
|
99
|
+
total += n
|
|
100
|
+
except (ValueError, TypeError):
|
|
101
|
+
out_lines.append(proof_redact.redact_value(line))
|
|
102
|
+
return "\n".join(out_lines) + ("\n" if text.endswith("\n") else ""), total
|
|
103
|
+
|
|
104
|
+
|
|
105
|
+
def _redact_text(text):
|
|
106
|
+
"""Redact a Markdown / plain-text string. Returns (redacted_text, count).
|
|
107
|
+
|
|
108
|
+
redact_value returns only the string, so we cannot get a per-call count
|
|
109
|
+
here; we report 0 and rely on the aggregate manifest count from structured
|
|
110
|
+
files. The redaction itself still happens.
|
|
111
|
+
"""
|
|
112
|
+
return proof_redact.redact_value(text), 0
|
|
113
|
+
|
|
114
|
+
|
|
115
|
+
def _redact_for_path(rel_path, text):
|
|
116
|
+
"""Dispatch redaction by file extension. Returns (redacted_text, count)."""
|
|
117
|
+
lower = rel_path.lower()
|
|
118
|
+
if lower.endswith(".jsonl"):
|
|
119
|
+
return _redact_jsonl_text(text)
|
|
120
|
+
if lower.endswith(".json"):
|
|
121
|
+
return _redact_json_text(text)
|
|
122
|
+
# .md, .txt, and everything else: plain string redaction.
|
|
123
|
+
return _redact_text(text)
|
|
124
|
+
|
|
125
|
+
|
|
126
|
+
def _iter_category_sources(category, home, repo_root, project_dir):
|
|
127
|
+
"""Yield (abs_source_path, bundle_rel_path) pairs for a category.
|
|
128
|
+
|
|
129
|
+
bundle_rel_path is always relative to the bundle's category dir.
|
|
130
|
+
"""
|
|
131
|
+
if category == "learnings":
|
|
132
|
+
src_dir = os.path.join(home, ".loki", "learnings")
|
|
133
|
+
if os.path.isdir(src_dir):
|
|
134
|
+
for name in sorted(os.listdir(src_dir)):
|
|
135
|
+
p = os.path.join(src_dir, name)
|
|
136
|
+
if os.path.isfile(p):
|
|
137
|
+
yield p, os.path.join("learnings", name)
|
|
138
|
+
elif category == "memory":
|
|
139
|
+
src_dir = os.path.join(project_dir, ".loki", "memory")
|
|
140
|
+
for abs_p, rel_p in _walk_files(src_dir, "memory"):
|
|
141
|
+
yield abs_p, rel_p
|
|
142
|
+
elif category == "agents":
|
|
143
|
+
p = os.path.join(repo_root, "agents", "types.json")
|
|
144
|
+
if os.path.isfile(p):
|
|
145
|
+
yield p, os.path.join("agents", "types.json")
|
|
146
|
+
elif category == "templates":
|
|
147
|
+
src_dir = os.path.join(repo_root, "templates")
|
|
148
|
+
if os.path.isdir(src_dir):
|
|
149
|
+
for name in sorted(os.listdir(src_dir)):
|
|
150
|
+
p = os.path.join(src_dir, name)
|
|
151
|
+
if os.path.isfile(p) and name.endswith(".md"):
|
|
152
|
+
yield p, os.path.join("templates", name)
|
|
153
|
+
elif category == "council":
|
|
154
|
+
src_dir = os.path.join(project_dir, ".loki", "council")
|
|
155
|
+
if os.path.isdir(src_dir):
|
|
156
|
+
for name in sorted(os.listdir(src_dir)):
|
|
157
|
+
p = os.path.join(src_dir, name)
|
|
158
|
+
if os.path.isfile(p) and name.endswith(".json"):
|
|
159
|
+
yield p, os.path.join("council", name)
|
|
160
|
+
elif category == "wiki":
|
|
161
|
+
src_dir = os.path.join(project_dir, ".loki", "wiki")
|
|
162
|
+
for abs_p, rel_p in _walk_files(src_dir, "wiki"):
|
|
163
|
+
yield abs_p, rel_p
|
|
164
|
+
|
|
165
|
+
|
|
166
|
+
def _walk_files(src_dir, bundle_prefix):
|
|
167
|
+
"""Walk src_dir, yielding (abs_path, bundle_rel_path) for every file."""
|
|
168
|
+
if not os.path.isdir(src_dir):
|
|
169
|
+
return
|
|
170
|
+
for root, _dirs, files in os.walk(src_dir):
|
|
171
|
+
for name in sorted(files):
|
|
172
|
+
abs_p = os.path.join(root, name)
|
|
173
|
+
rel = os.path.relpath(abs_p, src_dir)
|
|
174
|
+
yield abs_p, os.path.join(bundle_prefix, rel)
|
|
175
|
+
|
|
176
|
+
|
|
177
|
+
def export_bundle(out_path, home, repo_root, project_dir, categories):
|
|
178
|
+
"""Build a redacted tarball at out_path. Returns a manifest dict.
|
|
179
|
+
|
|
180
|
+
All string content is redacted through proof_redact before it is written
|
|
181
|
+
into the tarball; the original files on disk are never modified.
|
|
182
|
+
"""
|
|
183
|
+
proof_redact.reset_context()
|
|
184
|
+
proof_redact.set_context(home=home, repo_root=repo_root)
|
|
185
|
+
|
|
186
|
+
manifest = {
|
|
187
|
+
"schema_version": SCHEMA_VERSION,
|
|
188
|
+
"redaction_rules_version": proof_redact.RULES_VERSION,
|
|
189
|
+
"categories": [],
|
|
190
|
+
"files": [],
|
|
191
|
+
"redactions": 0,
|
|
192
|
+
}
|
|
193
|
+
|
|
194
|
+
staged = [] # (bundle_rel_path, redacted_bytes)
|
|
195
|
+
for category in categories:
|
|
196
|
+
had_any = False
|
|
197
|
+
for abs_src, bundle_rel in _iter_category_sources(
|
|
198
|
+
category, home, repo_root, project_dir
|
|
199
|
+
):
|
|
200
|
+
try:
|
|
201
|
+
with open(abs_src, "r", encoding="utf-8", errors="replace") as f:
|
|
202
|
+
text = f.read()
|
|
203
|
+
except OSError:
|
|
204
|
+
continue
|
|
205
|
+
red_text, count = _redact_for_path(bundle_rel, text)
|
|
206
|
+
staged.append((bundle_rel, red_text.encode("utf-8")))
|
|
207
|
+
manifest["files"].append(bundle_rel)
|
|
208
|
+
manifest["redactions"] += count
|
|
209
|
+
had_any = True
|
|
210
|
+
if had_any:
|
|
211
|
+
manifest["categories"].append(category)
|
|
212
|
+
|
|
213
|
+
# Write tarball: manifest.json first, then redacted assets.
|
|
214
|
+
parent = os.path.dirname(os.path.abspath(out_path))
|
|
215
|
+
if parent and not os.path.isdir(parent):
|
|
216
|
+
os.makedirs(parent, exist_ok=True)
|
|
217
|
+
|
|
218
|
+
manifest_bytes = json.dumps(manifest, indent=2, ensure_ascii=False).encode(
|
|
219
|
+
"utf-8"
|
|
220
|
+
)
|
|
221
|
+
with tarfile.open(out_path, "w:gz") as tar:
|
|
222
|
+
_add_bytes(tar, "manifest.json", manifest_bytes)
|
|
223
|
+
for bundle_rel, data in staged:
|
|
224
|
+
_add_bytes(tar, os.path.join("assets", bundle_rel), data)
|
|
225
|
+
|
|
226
|
+
return manifest
|
|
227
|
+
|
|
228
|
+
|
|
229
|
+
def _add_bytes(tar, arcname, data):
|
|
230
|
+
"""Add an in-memory bytes blob to a tarfile under arcname."""
|
|
231
|
+
info = tarfile.TarInfo(name=arcname)
|
|
232
|
+
info.size = len(data)
|
|
233
|
+
info.mode = 0o644
|
|
234
|
+
tar.addfile(info, io.BytesIO(data))
|
|
235
|
+
|
|
236
|
+
|
|
237
|
+
# Restore-root mapping for each bundle category. $HOME-scoped vs project-scoped
|
|
238
|
+
# is deliberate (see module docstring asset map).
|
|
239
|
+
def _restore_root(category, home, target_repo, target_project):
|
|
240
|
+
if category == "learnings":
|
|
241
|
+
return os.path.join(home, ".loki", "learnings")
|
|
242
|
+
if category == "memory":
|
|
243
|
+
return os.path.join(target_project, ".loki", "memory")
|
|
244
|
+
if category == "agents":
|
|
245
|
+
return os.path.join(target_repo, "agents")
|
|
246
|
+
if category == "templates":
|
|
247
|
+
return os.path.join(target_repo, "templates")
|
|
248
|
+
if category == "council":
|
|
249
|
+
return os.path.join(target_project, ".loki", "council")
|
|
250
|
+
if category == "wiki":
|
|
251
|
+
return os.path.join(target_project, ".loki", "wiki")
|
|
252
|
+
return None
|
|
253
|
+
|
|
254
|
+
|
|
255
|
+
def _category_of(bundle_rel):
|
|
256
|
+
"""Top-level bundle dir == category name."""
|
|
257
|
+
return bundle_rel.split("/", 1)[0]
|
|
258
|
+
|
|
259
|
+
|
|
260
|
+
def _safe_extract_member(member):
|
|
261
|
+
"""Reject path-traversal members (zip-slip / tar-slip)."""
|
|
262
|
+
name = member.name
|
|
263
|
+
if name.startswith("/") or ".." in name.split("/"):
|
|
264
|
+
return False
|
|
265
|
+
return True
|
|
266
|
+
|
|
267
|
+
|
|
268
|
+
def _merge_jsonl(existing_text, incoming_text):
|
|
269
|
+
"""Append-with-dedupe two JSONL bodies. Returns merged text.
|
|
270
|
+
|
|
271
|
+
Dedupe key is the canonicalized line (json round-trip with sorted keys when
|
|
272
|
+
parseable). Preserves existing order, then appends new unique lines.
|
|
273
|
+
"""
|
|
274
|
+
def _norm_lines(text):
|
|
275
|
+
norm = []
|
|
276
|
+
for line in text.splitlines():
|
|
277
|
+
if not line.strip():
|
|
278
|
+
continue
|
|
279
|
+
try:
|
|
280
|
+
norm.append(
|
|
281
|
+
json.dumps(json.loads(line), sort_keys=True, ensure_ascii=False)
|
|
282
|
+
)
|
|
283
|
+
except (ValueError, TypeError):
|
|
284
|
+
norm.append(line.strip())
|
|
285
|
+
return norm
|
|
286
|
+
|
|
287
|
+
existing = _norm_lines(existing_text)
|
|
288
|
+
seen = set(existing)
|
|
289
|
+
merged = list(existing)
|
|
290
|
+
for line in _norm_lines(incoming_text):
|
|
291
|
+
if line not in seen:
|
|
292
|
+
seen.add(line)
|
|
293
|
+
merged.append(line)
|
|
294
|
+
return "\n".join(merged) + "\n" if merged else ""
|
|
295
|
+
|
|
296
|
+
|
|
297
|
+
def import_bundle(bundle_path, home, target_repo, target_project, merge=True):
|
|
298
|
+
"""Restore a bundle's assets to their mapped roots. Returns a result dict.
|
|
299
|
+
|
|
300
|
+
merge=True: JSONL learnings are append-with-dedupe; all other files are
|
|
301
|
+
overwritten. merge=False: every file is overwritten.
|
|
302
|
+
"""
|
|
303
|
+
result = {"restored": [], "skipped": [], "merged": [], "schema_version": None}
|
|
304
|
+
|
|
305
|
+
with tarfile.open(bundle_path, "r:gz") as tar:
|
|
306
|
+
# Read manifest.
|
|
307
|
+
try:
|
|
308
|
+
mf = tar.extractfile("manifest.json")
|
|
309
|
+
manifest = json.load(mf) if mf else {}
|
|
310
|
+
except KeyError:
|
|
311
|
+
manifest = {}
|
|
312
|
+
result["schema_version"] = manifest.get("schema_version")
|
|
313
|
+
|
|
314
|
+
for member in tar.getmembers():
|
|
315
|
+
if not member.isfile():
|
|
316
|
+
continue
|
|
317
|
+
if not _safe_extract_member(member):
|
|
318
|
+
result["skipped"].append(member.name)
|
|
319
|
+
continue
|
|
320
|
+
name = member.name
|
|
321
|
+
if name == "manifest.json":
|
|
322
|
+
continue
|
|
323
|
+
if not name.startswith("assets/"):
|
|
324
|
+
continue
|
|
325
|
+
bundle_rel = name[len("assets/"):]
|
|
326
|
+
category = _category_of(bundle_rel)
|
|
327
|
+
root = _restore_root(category, home, target_repo, target_project)
|
|
328
|
+
if root is None:
|
|
329
|
+
result["skipped"].append(name)
|
|
330
|
+
continue
|
|
331
|
+
sub_rel = (
|
|
332
|
+
bundle_rel.split("/", 1)[1] if "/" in bundle_rel else bundle_rel
|
|
333
|
+
)
|
|
334
|
+
# SECURITY: a malicious bundle can carry a member like
|
|
335
|
+
# "assets/council//abs/path" whose sub_rel is absolute (or contains
|
|
336
|
+
# ".."). os.path.join(root, abs) silently DISCARDS root, so validate
|
|
337
|
+
# the FINAL resolved destination is inside the restore root --
|
|
338
|
+
# member-name string checks alone are insufficient. Reject any escape;
|
|
339
|
+
# this is an untrusted shared bundle (R8's threat model).
|
|
340
|
+
root_real = os.path.realpath(root)
|
|
341
|
+
dest_real = os.path.realpath(os.path.join(root, sub_rel))
|
|
342
|
+
if dest_real != root_real and not dest_real.startswith(root_real + os.sep):
|
|
343
|
+
result["skipped"].append(name)
|
|
344
|
+
continue
|
|
345
|
+
dest = dest_real
|
|
346
|
+
os.makedirs(os.path.dirname(dest), exist_ok=True)
|
|
347
|
+
|
|
348
|
+
fobj = tar.extractfile(member)
|
|
349
|
+
if fobj is None:
|
|
350
|
+
continue
|
|
351
|
+
data = fobj.read().decode("utf-8", errors="replace")
|
|
352
|
+
|
|
353
|
+
if merge and dest.lower().endswith(".jsonl") and os.path.isfile(dest):
|
|
354
|
+
with open(dest, "r", encoding="utf-8", errors="replace") as f:
|
|
355
|
+
existing = f.read()
|
|
356
|
+
merged = _merge_jsonl(existing, data)
|
|
357
|
+
with open(dest, "w", encoding="utf-8") as f:
|
|
358
|
+
f.write(merged)
|
|
359
|
+
result["merged"].append(dest)
|
|
360
|
+
else:
|
|
361
|
+
with open(dest, "w", encoding="utf-8") as f:
|
|
362
|
+
f.write(data)
|
|
363
|
+
result["restored"].append(dest)
|
|
364
|
+
|
|
365
|
+
return result
|
|
366
|
+
|
|
367
|
+
|
|
368
|
+
def inspect_bundle(bundle_path):
|
|
369
|
+
"""Return the manifest dict from a bundle without extracting assets."""
|
|
370
|
+
with tarfile.open(bundle_path, "r:gz") as tar:
|
|
371
|
+
try:
|
|
372
|
+
mf = tar.extractfile("manifest.json")
|
|
373
|
+
return json.load(mf) if mf else {}
|
|
374
|
+
except KeyError:
|
|
375
|
+
return {}
|
|
376
|
+
|
|
377
|
+
|
|
378
|
+
def _main(argv):
|
|
379
|
+
"""CLI shim used by autonomy/loki cmd_assets.
|
|
380
|
+
|
|
381
|
+
Subcommands:
|
|
382
|
+
export <out_path> [--categories a,b,c] [--wiki]
|
|
383
|
+
import <bundle_path> [--no-merge]
|
|
384
|
+
inspect <bundle_path>
|
|
385
|
+
|
|
386
|
+
Roots are taken from the environment so the bash caller controls them:
|
|
387
|
+
LOKI_ASSETS_HOME, LOKI_ASSETS_REPO, LOKI_ASSETS_PROJECT
|
|
388
|
+
"""
|
|
389
|
+
if not argv:
|
|
390
|
+
sys.stderr.write("usage: assets_bundle.py <export|import|inspect> ...\n")
|
|
391
|
+
return 2
|
|
392
|
+
|
|
393
|
+
sub = argv[0]
|
|
394
|
+
rest = argv[1:]
|
|
395
|
+
|
|
396
|
+
home = os.environ.get("LOKI_ASSETS_HOME", os.path.expanduser("~"))
|
|
397
|
+
repo = os.environ.get("LOKI_ASSETS_REPO", os.getcwd())
|
|
398
|
+
project = os.environ.get("LOKI_ASSETS_PROJECT", os.getcwd())
|
|
399
|
+
|
|
400
|
+
if sub == "export":
|
|
401
|
+
if not rest:
|
|
402
|
+
sys.stderr.write("export: missing output path\n")
|
|
403
|
+
return 2
|
|
404
|
+
out_path = rest[0]
|
|
405
|
+
categories = list(DEFAULT_CATEGORIES)
|
|
406
|
+
i = 1
|
|
407
|
+
while i < len(rest):
|
|
408
|
+
if rest[i] == "--categories" and i + 1 < len(rest):
|
|
409
|
+
categories = [
|
|
410
|
+
c.strip() for c in rest[i + 1].split(",") if c.strip()
|
|
411
|
+
]
|
|
412
|
+
i += 2
|
|
413
|
+
continue
|
|
414
|
+
if rest[i] == "--wiki":
|
|
415
|
+
if "wiki" not in categories:
|
|
416
|
+
categories.append("wiki")
|
|
417
|
+
i += 1
|
|
418
|
+
continue
|
|
419
|
+
i += 1
|
|
420
|
+
manifest = export_bundle(out_path, home, repo, project, categories)
|
|
421
|
+
sys.stdout.write(json.dumps(manifest, indent=2) + "\n")
|
|
422
|
+
return 0
|
|
423
|
+
|
|
424
|
+
if sub == "import":
|
|
425
|
+
if not rest:
|
|
426
|
+
sys.stderr.write("import: missing bundle path\n")
|
|
427
|
+
return 2
|
|
428
|
+
bundle_path = rest[0]
|
|
429
|
+
merge = "--no-merge" not in rest[1:]
|
|
430
|
+
result = import_bundle(bundle_path, home, repo, project, merge=merge)
|
|
431
|
+
sys.stdout.write(json.dumps(result, indent=2) + "\n")
|
|
432
|
+
return 0
|
|
433
|
+
|
|
434
|
+
if sub == "inspect":
|
|
435
|
+
if not rest:
|
|
436
|
+
sys.stderr.write("inspect: missing bundle path\n")
|
|
437
|
+
return 2
|
|
438
|
+
sys.stdout.write(json.dumps(inspect_bundle(rest[0]), indent=2) + "\n")
|
|
439
|
+
return 0
|
|
440
|
+
|
|
441
|
+
sys.stderr.write("unknown subcommand: " + sub + "\n")
|
|
442
|
+
return 2
|
|
443
|
+
|
|
444
|
+
|
|
445
|
+
if __name__ == "__main__":
|
|
446
|
+
sys.exit(_main(sys.argv[1:]))
|