nostos 1.0.0__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.
core/__init__.py ADDED
@@ -0,0 +1,4 @@
1
+ # nostos - Batch-update multiple git repositories in parallel
2
+ # Copyright (c) 2026 prodrom3 / radamic
3
+ # License: MIT
4
+ # https://github.com/prodrom3/nostos
core/auth.py ADDED
@@ -0,0 +1,165 @@
1
+ """Auth config for nostos upstream probes.
2
+
3
+ Reads $XDG_CONFIG_HOME/nostos/auth.toml and exposes a per-host view:
4
+
5
+ [hosts."github.com"]
6
+ token_env = "GITHUB_TOKEN" # preferred
7
+ # token = "ghp_..." # inline (discouraged; use env var)
8
+ # provider = "github" # optional override; default derived from host
9
+
10
+ [hosts."gitlab.internal.corp"]
11
+ provider = "gitlab" # required for non-standard hosts
12
+ token_env = "CORP_GITLAB_TOKEN"
13
+
14
+ [defaults]
15
+ allow_unknown = false # fail-closed for hosts not listed above
16
+
17
+ Security
18
+ - The file must be owned by the current user and not world-readable
19
+ on Unix. Failure of either check causes the loader to refuse to
20
+ read it and return an empty / fail-closed config with a warning.
21
+ - A missing file is NOT an error; upstream probes will simply fail
22
+ closed for every host.
23
+ - Inline tokens are supported but discouraged; token_env is always
24
+ preferred and is the only form that is redacted from audit output.
25
+ """
26
+
27
+ from __future__ import annotations
28
+
29
+ import logging
30
+ import os
31
+ import stat
32
+ import sys
33
+ from typing import Any
34
+
35
+ from .paths import auth_config_path
36
+
37
+ # Python 3.11+ ships tomllib in the stdlib. On 3.10 we fall back to
38
+ # tomli if it happens to be installed; otherwise the TOML loader is
39
+ # disabled entirely and load_auth() returns a fail-closed config with
40
+ # a clear warning. This keeps nostos importable with zero runtime
41
+ # dependencies on every supported Python, and keeps the opsec posture
42
+ # correct: no TOML parser means no auth.toml is read, which means
43
+ # every upstream probe is skipped fail-closed.
44
+ _toml: Any = None
45
+ try:
46
+ import tomllib as _toml # type: ignore[import-not-found,no-redef,unused-ignore]
47
+ except ModuleNotFoundError: # pragma: no cover - Python 3.10
48
+ try:
49
+ import tomli as _toml # type: ignore[import-not-found,no-redef]
50
+ except ModuleNotFoundError:
51
+ pass
52
+
53
+
54
+ _DEFAULT_PROVIDERS: dict[str, str] = {
55
+ "github.com": "github",
56
+ "gitlab.com": "gitlab",
57
+ }
58
+
59
+
60
+ class AuthConfig:
61
+ """In-memory view of the auth.toml file.
62
+
63
+ Hosts not present in the config produce (None, False) from
64
+ resolve_host_token() unless defaults.allow_unknown is True.
65
+ """
66
+
67
+ def __init__(
68
+ self,
69
+ hosts: dict[str, dict[str, Any]] | None = None,
70
+ allow_unknown: bool = False,
71
+ ) -> None:
72
+ self.hosts = hosts or {}
73
+ self.allow_unknown = allow_unknown
74
+
75
+ def provider_for(self, host: str) -> str | None:
76
+ """Return the provider name for a host, or None if unknown."""
77
+ cfg = self.hosts.get(host)
78
+ if cfg and cfg.get("provider"):
79
+ return str(cfg["provider"]).lower()
80
+ return _DEFAULT_PROVIDERS.get(host)
81
+
82
+ def resolve_token(self, host: str) -> str | None:
83
+ """Return the token for a host, or None.
84
+
85
+ token_env takes precedence over inline token. Unknown hosts
86
+ always return None.
87
+ """
88
+ cfg = self.hosts.get(host)
89
+ if not cfg:
90
+ return None
91
+ env_name = cfg.get("token_env")
92
+ if env_name:
93
+ return os.environ.get(str(env_name)) or None
94
+ inline = cfg.get("token")
95
+ return str(inline) if inline else None
96
+
97
+ def is_allowed(self, host: str) -> bool:
98
+ """Should a probe against this host be permitted?
99
+
100
+ True when the host is explicitly configured OR when
101
+ defaults.allow_unknown is True.
102
+ """
103
+ if host in self.hosts:
104
+ return True
105
+ return self.allow_unknown
106
+
107
+
108
+ def _is_auth_file_safe(path: str) -> bool:
109
+ """Reject if not owned by current user or world-readable/writable (Unix)."""
110
+ try:
111
+ st = os.stat(path)
112
+ except OSError:
113
+ return False
114
+ if sys.platform == "win32":
115
+ return True
116
+ if st.st_uid != os.getuid():
117
+ logging.warning(
118
+ f"Ignoring {path}: owned by uid {st.st_uid}, not current user"
119
+ )
120
+ return False
121
+ if st.st_mode & (stat.S_IWOTH | stat.S_IROTH | stat.S_IWGRP | stat.S_IRGRP):
122
+ logging.warning(
123
+ f"Ignoring {path}: permissions too loose "
124
+ f"(fix with: chmod 600 {path})"
125
+ )
126
+ return False
127
+ return True
128
+
129
+
130
+ def load_auth(path: str | None = None) -> AuthConfig:
131
+ """Load the auth config. Returns an empty fail-closed config if
132
+ the file is missing, unsafe, or malformed."""
133
+ cfg_path = path or auth_config_path()
134
+ if not os.path.isfile(cfg_path):
135
+ return AuthConfig()
136
+ if not _is_auth_file_safe(cfg_path):
137
+ return AuthConfig()
138
+ if _toml is None:
139
+ logging.warning(
140
+ f"Ignoring {cfg_path}: no TOML parser available on this Python. "
141
+ f"Upgrade to Python 3.11+ (preferred) or install 'tomli' "
142
+ f"for 3.10. Upstream probes will fail closed until then."
143
+ )
144
+ return AuthConfig()
145
+
146
+ try:
147
+ with open(cfg_path, "rb") as f:
148
+ data = _toml.load(f)
149
+ except (OSError, _toml.TOMLDecodeError) as e:
150
+ logging.warning(f"Ignoring {cfg_path}: parse error ({e})")
151
+ return AuthConfig()
152
+
153
+ hosts_raw = data.get("hosts", {})
154
+ hosts: dict[str, dict[str, Any]] = {}
155
+ if isinstance(hosts_raw, dict):
156
+ for name, cfg in hosts_raw.items():
157
+ if isinstance(cfg, dict):
158
+ hosts[str(name)] = {k: v for k, v in cfg.items()}
159
+
160
+ defaults = data.get("defaults", {})
161
+ allow_unknown = False
162
+ if isinstance(defaults, dict):
163
+ allow_unknown = bool(defaults.get("allow_unknown", False))
164
+
165
+ return AuthConfig(hosts=hosts, allow_unknown=allow_unknown)
core/cli.py ADDED
@@ -0,0 +1,226 @@
1
+ """Argparse / subcommand dispatch for nostos.
2
+
3
+ The top-level command is a verb-first subparser tree: `nostos pull`,
4
+ `nostos add`, `nostos list`, etc. Invocations without a verb are
5
+ treated as an implicit `pull` for backward compatibility. The legacy
6
+ top-level flags (--add, --remove, --list) are rewritten to their
7
+ subcommand equivalents and emit a deprecation notice.
8
+ """
9
+
10
+ # PYTHON_ARGCOMPLETE_OK
11
+
12
+ from __future__ import annotations
13
+
14
+ import argparse
15
+ import os
16
+ import sys
17
+ from importlib.metadata import PackageNotFoundError
18
+ from importlib.metadata import version as _pkg_version
19
+
20
+ from .commands import add as _cmd_add
21
+ from .commands import attack as _cmd_attack
22
+ from .commands import dashboard as _cmd_dashboard
23
+ from .commands import digest as _cmd_digest
24
+ from .commands import doctor as _cmd_doctor
25
+ from .commands import export_cmd as _cmd_export
26
+ from .commands import import_cmd as _cmd_import
27
+ from .commands import list_cmd as _cmd_list
28
+ from .commands import note as _cmd_note
29
+ from .commands import pull as _cmd_pull
30
+ from .commands import refresh as _cmd_refresh
31
+ from .commands import rm as _cmd_rm
32
+ from .commands import show as _cmd_show
33
+ from .commands import tag as _cmd_tag
34
+ from .commands import triage as _cmd_triage
35
+ from .commands import update as _cmd_update
36
+ from .commands import vault as _cmd_vault
37
+
38
+ _KNOWN_VERBS: frozenset[str] = frozenset(
39
+ {
40
+ "pull", "add", "list", "show", "tag", "note", "triage",
41
+ "rm", "refresh", "digest", "vault", "export", "import",
42
+ "update", "doctor", "attack", "dashboard",
43
+ }
44
+ )
45
+
46
+
47
+ def get_version() -> str:
48
+ """Return the nostos version.
49
+
50
+ Reads the VERSION file at the repo root first so source-tree edits
51
+ are reflected immediately. Falls back to installed package metadata
52
+ for pip-installed runs.
53
+ """
54
+ version_file = os.path.join(os.path.dirname(os.path.dirname(__file__)), "VERSION")
55
+ try:
56
+ with open(version_file) as f:
57
+ value = f.read().strip()
58
+ if value:
59
+ return value
60
+ except OSError:
61
+ pass
62
+ try:
63
+ return _pkg_version("nostos")
64
+ except PackageNotFoundError:
65
+ return "unknown"
66
+
67
+
68
+ def build_parser() -> argparse.ArgumentParser:
69
+ parser = argparse.ArgumentParser(
70
+ prog="nostos",
71
+ formatter_class=argparse.RawDescriptionHelpFormatter,
72
+ description=(
73
+ "nostos - manage a fleet of git repositories.\n"
74
+ "\n"
75
+ "Pull updates in parallel, maintain a curated index with tags, "
76
+ "status, and notes, probe upstream metadata (stars, archived, "
77
+ "last push), generate weekly digests and HTML dashboards, "
78
+ "bridge to an Obsidian vault, and export portable bundles "
79
+ "that move cleanly between machines."
80
+ ),
81
+ epilog=(
82
+ "Examples:\n"
83
+ " nostos pull ~/code Pull every repo under ~/code\n"
84
+ " nostos pull --from-index --tags Pull indexed repos + all their git tags\n"
85
+ " nostos add URL --tag python Clone a remote repo and register it\n"
86
+ " nostos list --tag security Show indexed repos carrying a tag\n"
87
+ " nostos refresh Fetch upstream metadata for the index\n"
88
+ " nostos export --out fleet.json Export the full index for portability\n"
89
+ "\n"
90
+ "Run `nostos <command> --help` for flags on a specific command.\n"
91
+ "Docs: https://github.com/prodrom3/nostos"
92
+ ),
93
+ )
94
+ parser.add_argument(
95
+ "-v",
96
+ "--version",
97
+ action="version",
98
+ version=f"%(prog)s {get_version()}",
99
+ )
100
+ subparsers = parser.add_subparsers(
101
+ title="commands",
102
+ dest="command",
103
+ metavar="COMMAND",
104
+ )
105
+ # Group the verbs in a logical order so `--help` reads top to bottom.
106
+ # Core pull / index intake:
107
+ _cmd_pull.add_parser(subparsers)
108
+ _cmd_add.add_parser(subparsers)
109
+ # Index inspection & editing:
110
+ _cmd_list.add_parser(subparsers)
111
+ _cmd_show.add_parser(subparsers)
112
+ _cmd_tag.add_parser(subparsers)
113
+ _cmd_note.add_parser(subparsers)
114
+ _cmd_triage.add_parser(subparsers)
115
+ _cmd_rm.add_parser(subparsers)
116
+ # Upstream intelligence & reporting:
117
+ _cmd_refresh.add_parser(subparsers)
118
+ _cmd_digest.add_parser(subparsers)
119
+ _cmd_dashboard.add_parser(subparsers)
120
+ _cmd_attack.add_parser(subparsers)
121
+ # External bridges & portability:
122
+ _cmd_vault.add_parser(subparsers)
123
+ _cmd_export.add_parser(subparsers)
124
+ _cmd_import.add_parser(subparsers)
125
+ # Operational / housekeeping:
126
+ _cmd_update.add_parser(subparsers)
127
+ _cmd_doctor.add_parser(subparsers)
128
+ return parser
129
+
130
+
131
+ def _rewrite_legacy_argv(argv: list[str]) -> list[str]:
132
+ """Translate legacy top-level flags to subcommand form.
133
+
134
+ Keeps `nostos --add X`, `nostos --remove X`, `nostos --list`,
135
+ and `nostos --watchlist` working for one release. Users get a
136
+ one-line deprecation notice to stderr when a legacy flag fires.
137
+ """
138
+ if not argv:
139
+ return argv
140
+
141
+ # --list with no other verb -> `list`
142
+ if argv and argv[0] == "--list":
143
+ _deprecate("--list", "list")
144
+ return ["list"] + argv[1:]
145
+
146
+ # --add PATH ... -> `add PATH ...`
147
+ if "--add" in argv:
148
+ idx = argv.index("--add")
149
+ if idx + 1 < len(argv):
150
+ target = argv[idx + 1]
151
+ _deprecate("--add", "add")
152
+ rest = argv[:idx] + argv[idx + 2 :]
153
+ return ["add", target] + rest
154
+
155
+ # --remove PATH ... -> `rm PATH ...`
156
+ if "--remove" in argv:
157
+ idx = argv.index("--remove")
158
+ if idx + 1 < len(argv):
159
+ target = argv[idx + 1]
160
+ _deprecate("--remove", "rm")
161
+ rest = argv[:idx] + argv[idx + 2 :]
162
+ return ["rm", target] + rest
163
+
164
+ return argv
165
+
166
+
167
+ def _deprecate(old: str, new_verb: str) -> None:
168
+ print(
169
+ f"nostos: warning: {old} is deprecated; use `nostos {new_verb}` instead",
170
+ file=sys.stderr,
171
+ flush=True,
172
+ )
173
+
174
+
175
+ def _inject_default_verb(argv: list[str]) -> list[str]:
176
+ """If the first positional arg is not a known verb, prepend `pull`."""
177
+ for a in argv:
178
+ if a in {"-h", "--help", "-v", "--version"}:
179
+ return argv
180
+ if not a.startswith("-"):
181
+ if a in _KNOWN_VERBS:
182
+ return argv
183
+ return ["pull"] + argv
184
+ # All flags, no positional -> implicit pull
185
+ return ["pull"] + argv
186
+
187
+
188
+ def run(argv: list[str] | None = None) -> int:
189
+ if argv is None:
190
+ argv = sys.argv[1:]
191
+ argv = _rewrite_legacy_argv(list(argv))
192
+ argv = _inject_default_verb(argv)
193
+
194
+ parser = build_parser()
195
+
196
+ # Optional shell tab-completion. Only active if argcomplete is installed
197
+ # and the shell invoked us in completion mode. Silently no-ops otherwise,
198
+ # so we don't make argcomplete a hard dependency.
199
+ try:
200
+ import argcomplete # type: ignore[import-not-found]
201
+
202
+ argcomplete.autocomplete(parser)
203
+ except ImportError:
204
+ pass
205
+
206
+ args = parser.parse_args(argv)
207
+
208
+ func = getattr(args, "func", None)
209
+ if func is None:
210
+ parser.print_help()
211
+ return 2
212
+ return int(func(args))
213
+
214
+
215
+ # Back-compat: keep the old parse_args() symbol for anyone importing it.
216
+ def parse_args() -> argparse.Namespace:
217
+ """Legacy entry used by older tests. Returns a Namespace for the
218
+ rewritten argv, without executing the command."""
219
+ argv = _rewrite_legacy_argv(list(sys.argv[1:]))
220
+ argv = _inject_default_verb(argv)
221
+ return build_parser().parse_args(argv)
222
+
223
+
224
+ def main_entry() -> None:
225
+ """Entry point for the pip-installed `nostos` console script."""
226
+ sys.exit(run(sys.argv[1:]))
@@ -0,0 +1,6 @@
1
+ """Subcommand modules for nostos.
2
+
3
+ Each module in this package defines two callables:
4
+ - add_parser(subparsers): register the subparser for the command
5
+ - run(args) -> int: execute the command and return a process exit code
6
+ """
@@ -0,0 +1,51 @@
1
+ """Shared helpers for subcommand modules."""
2
+
3
+ from __future__ import annotations
4
+
5
+ import os
6
+ import sys
7
+
8
+
9
+ def fail(msg: str, *, code: int = 1) -> int:
10
+ """Print an error to stderr and return an exit code."""
11
+ print(f"nostos: error: {msg}", file=sys.stderr, flush=True)
12
+ return code
13
+
14
+
15
+ def maybe_migrate_watchlist() -> None:
16
+ """One-shot migration from ~/.nostos_repos into the index.
17
+
18
+ If the legacy watchlist file still exists (and has not already been
19
+ renamed to .migrated), its entries are imported into the index with
20
+ source='legacy-watchlist' and status='reviewed'. The file is then
21
+ renamed to ~/.nostos_repos.migrated and a one-line notice is
22
+ printed to stderr.
23
+ """
24
+ legacy = os.path.join(os.path.expanduser("~"), ".nostos_repos")
25
+ if not os.path.isfile(legacy):
26
+ return
27
+ from .. import index as _index
28
+
29
+ try:
30
+ with _index.connect() as conn:
31
+ n = _index.migrate_watchlist(conn, legacy)
32
+ except OSError:
33
+ return
34
+
35
+ try:
36
+ os.rename(legacy, legacy + ".migrated")
37
+ except OSError:
38
+ # Rename failed - leave the file but don't re-migrate next run
39
+ # by rewriting it with a header comment. Best-effort.
40
+ try:
41
+ with open(legacy, "w", encoding="utf-8") as f:
42
+ f.write("# migrated into nostos index; safe to delete\n")
43
+ except OSError:
44
+ pass
45
+
46
+ print(
47
+ f"nostos: migrated {n} watchlist entries into the metadata index "
48
+ f"({legacy} -> {legacy}.migrated)",
49
+ file=sys.stderr,
50
+ flush=True,
51
+ )
core/commands/add.py ADDED
@@ -0,0 +1,118 @@
1
+ """`nostos add` - ingest a repo into the metadata index.
2
+
3
+ Accepts either a local path to an existing git repository or a remote
4
+ URL. When given a URL, the repo is cloned first (via the hardened
5
+ clone routine, with hooks disabled) and then the resulting local path
6
+ is registered.
7
+ """
8
+
9
+ from __future__ import annotations
10
+
11
+ import argparse
12
+ import os
13
+ import sys
14
+ from typing import Any
15
+
16
+ from .. import index as _index
17
+ from ..config import load_config
18
+ from ..watchlist import clone_repo, is_remote_url
19
+ from ._common import fail, maybe_migrate_watchlist
20
+
21
+
22
+ def add_parser(subparsers: Any) -> None:
23
+ p = subparsers.add_parser(
24
+ "add",
25
+ help="Register a repository in the metadata index",
26
+ description="Register a local git repository, or clone a remote URL and register it.",
27
+ )
28
+ p.add_argument(
29
+ "target",
30
+ metavar="PATH_OR_URL",
31
+ help="Local path to a git repository, or a remote URL to clone",
32
+ )
33
+ p.add_argument(
34
+ "--tag",
35
+ action="append",
36
+ default=[],
37
+ metavar="TAG",
38
+ help="Tag to attach (repeatable, or comma-separated)",
39
+ )
40
+ p.add_argument(
41
+ "--source",
42
+ default=None,
43
+ help="Free-text provenance (e.g. 'blog:orange.tw, 2026-04-12')",
44
+ )
45
+ p.add_argument(
46
+ "--note",
47
+ default=None,
48
+ help="Initial free-text note",
49
+ )
50
+ p.add_argument(
51
+ "--status",
52
+ default="new",
53
+ choices=sorted(_index.VALID_STATUSES),
54
+ help="Initial triage status (default: new)",
55
+ )
56
+ p.add_argument(
57
+ "--quiet-upstream",
58
+ action="store_true",
59
+ help="Opsec flag: never query upstream metadata for this repo",
60
+ )
61
+ p.add_argument(
62
+ "--clone-dir",
63
+ default=None,
64
+ metavar="DIR",
65
+ help="Directory to clone into when target is a URL (default: cwd or config)",
66
+ )
67
+ p.set_defaults(func=run)
68
+
69
+
70
+ def _flatten_tags(raw: list[str]) -> list[str]:
71
+ tags: list[str] = []
72
+ for item in raw:
73
+ for piece in item.split(","):
74
+ piece = piece.strip()
75
+ if piece:
76
+ tags.append(piece)
77
+ return tags
78
+
79
+
80
+ def run(args: argparse.Namespace) -> int:
81
+ maybe_migrate_watchlist()
82
+
83
+ target = args.target
84
+ tags = _flatten_tags(args.tag)
85
+
86
+ if is_remote_url(target):
87
+ clone_dir = args.clone_dir
88
+ if clone_dir is None:
89
+ clone_dir = load_config()["clone_dir"] or os.getcwd()
90
+ local_path = clone_repo(target, clone_dir)
91
+ if local_path is None:
92
+ return fail(f"clone failed: {target}")
93
+ repo_path = local_path
94
+ remote_url = target
95
+ else:
96
+ repo_path = target
97
+ if not os.path.isdir(os.path.join(os.path.expanduser(repo_path), ".git")):
98
+ return fail(f"not a git repository: {repo_path}")
99
+ remote_url = None
100
+
101
+ try:
102
+ with _index.connect() as conn:
103
+ repo_id = _index.add_repo(
104
+ conn,
105
+ repo_path,
106
+ remote_url=remote_url,
107
+ source=args.source,
108
+ status=args.status,
109
+ quiet=args.quiet_upstream,
110
+ tags=tags,
111
+ note=args.note,
112
+ )
113
+ except (OSError, ValueError) as e:
114
+ return fail(str(e))
115
+
116
+ print(f"Added to index (id={repo_id}): {os.path.realpath(os.path.expanduser(repo_path))}",
117
+ file=sys.stderr, flush=True)
118
+ return 0
@@ -0,0 +1,83 @@
1
+ """`nostos attack` - ATT&CK technique helpers.
2
+
3
+ Sub-verbs:
4
+ - `attack list` print the built-in technique lookup table.
5
+ - `attack tag <repo> T1059 [T1071 ...]` shorthand for
6
+ `nostos tag <repo> +attack:t1059 +attack:t1071`.
7
+ """
8
+
9
+ from __future__ import annotations
10
+
11
+ import argparse
12
+ import sys
13
+ from typing import Any
14
+
15
+ from .. import index as _index
16
+ from .. import taxonomy as _tax
17
+ from ._common import fail, maybe_migrate_watchlist
18
+
19
+
20
+ def add_parser(subparsers: Any) -> None:
21
+ p = subparsers.add_parser(
22
+ "attack",
23
+ help="ATT&CK technique helpers (list taxonomy, tag repos)",
24
+ description="Work with MITRE ATT&CK technique tags.",
25
+ )
26
+ sub = p.add_subparsers(dest="attack_command", metavar="SUBCOMMAND", required=True)
27
+
28
+ # attack list
29
+ lst = sub.add_parser(
30
+ "list",
31
+ help="Print the built-in ATT&CK technique lookup table",
32
+ )
33
+ lst.set_defaults(func=run_list)
34
+
35
+ # attack tag <repo> T1059 [T1071 ...]
36
+ tg = sub.add_parser(
37
+ "tag",
38
+ help="Tag a repo with one or more ATT&CK technique IDs",
39
+ description=(
40
+ "Shorthand for `nostos tag <repo> +attack:TNNNN`. "
41
+ "Each ID is validated against the built-in lookup table."
42
+ ),
43
+ )
44
+ tg.add_argument("target", metavar="PATH_OR_ID")
45
+ tg.add_argument("techniques", nargs="+", metavar="TNNNN")
46
+ tg.set_defaults(func=run_tag)
47
+
48
+
49
+ def run_list(args: argparse.Namespace) -> int:
50
+ print(f"MITRE ATT&CK techniques ({len(_tax.TECHNIQUES)} in the built-in table):\n")
51
+ sys.stdout.write(_tax.render_table())
52
+ return 0
53
+
54
+
55
+ def run_tag(args: argparse.Namespace) -> int:
56
+ maybe_migrate_watchlist()
57
+
58
+ tags_to_add: list[str] = []
59
+ for raw in args.techniques:
60
+ tag = _tax.normalize_attack_tag(raw)
61
+ info = _tax.lookup(tag)
62
+ if info is None:
63
+ print(
64
+ f"warning: {raw} is not in the built-in lookup table; "
65
+ f"adding as {tag} anyway",
66
+ file=sys.stderr,
67
+ )
68
+ tags_to_add.append(tag)
69
+
70
+ try:
71
+ with _index.connect() as conn:
72
+ if not _index.add_tags(conn, args.target, tags_to_add):
73
+ return fail(f"not in index: {args.target}")
74
+ final = _index.get_tags(conn, args.target)
75
+ except OSError as e:
76
+ return fail(str(e))
77
+
78
+ attack_tags = [t for t in final if t.startswith("attack:")]
79
+ other_tags = [t for t in final if not t.startswith("attack:")]
80
+ print(f"attack tags: {', '.join(attack_tags) or '(none)'}", file=sys.stderr)
81
+ if other_tags:
82
+ print(f"other tags: {', '.join(other_tags)}", file=sys.stderr)
83
+ return 0