Skip to content

Commit 5aedb74

Browse files
authored
Merge pull request #184 from dandye/cli_arg_order
feat(cli): prioritize local config and fix arg parsing
2 parents 018124d + 45ca855 commit 5aedb74

10 files changed

Lines changed: 246 additions & 50 deletions

File tree

CHANGELOG.md

Lines changed: 8 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -5,6 +5,14 @@ All notable changes to this project will be documented in this file.
55
The format is based on [Keep a Changelog](https://keepachangelog.com/en/1.0.0/),
66
and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0.html).
77

8+
## [0.38.0] - 2026-03-31
9+
### Added
10+
- CLI local configuration support with `--local` flag for config set and view commands
11+
- `SECOPS_LOCAL_CONFIG_DIR` environment variable support for managing multiple local configurations
12+
13+
### Updated
14+
- CLI argument parsing to properly handle global flags placed after subcommands
15+
816
## [0.37.0] - 2026-03-11
917
### Added
1018
- Comprehensive case management functionality for Chronicle

CLI.md

Lines changed: 45 additions & 4 deletions
Original file line numberDiff line numberDiff line change
@@ -23,11 +23,18 @@ gcloud auth application-default login
2323

2424
## Configuration
2525

26-
The CLI allows you to save your credentials and other common settings in a configuration file, so you don't have to specify them in every command.
26+
The CLI allows you to save your credentials and other common settings in configuration files. The CLI supports two configuration scopes:
27+
28+
- **Global configuration**: Stored in `~/.secops/config.json` and applies to all projects
29+
- **Local configuration**: Stored in `./.secops/config.json` in the current directory and applies only to the current project
30+
31+
Local configuration takes precedence over global configuration when both are present.
2732

2833
### Saving Configuration
2934

30-
Save your Chronicle instance ID, project ID, and region:
35+
#### Global Configuration
36+
37+
Save your Chronicle instance ID, project ID, and region globally:
3138

3239
```bash
3340
secops config set --customer-id "your-instance-id" --project-id "your-project-id" --region "us"
@@ -60,16 +67,50 @@ secops config set --time-window 48
6067
secops config set --start-time "2023-07-01T00:00:00Z" --end-time "2023-07-02T00:00:00Z"
6168
```
6269

63-
The configuration is stored in `~/.secops/config.json`.
70+
#### Local Configuration
71+
72+
Use the `--local` flag to save configuration for the current project only:
73+
74+
```bash
75+
secops config set --local --customer-id "project-specific-id" --project-id "project-a"
76+
```
77+
78+
This is useful when working with multiple projects or environments.
79+
80+
#### Managing Multiple Projects
81+
82+
You can use the `SECOPS_LOCAL_CONFIG_DIR` environment variable to switch between different project configurations:
83+
84+
```bash
85+
# Setup configuration for Project A
86+
export SECOPS_LOCAL_CONFIG_DIR=/path/to/project-a/.secops
87+
mkdir -p $SECOPS_LOCAL_CONFIG_DIR
88+
secops config set --local --project-id project-a --customer-id instance-a
89+
90+
# Setup configuration for Project B
91+
export SECOPS_LOCAL_CONFIG_DIR=/path/to/project-b/.secops
92+
mkdir -p $SECOPS_LOCAL_CONFIG_DIR
93+
secops config set --local --project-id project-b --customer-id instance-b
94+
95+
# Use Project A config
96+
export SECOPS_LOCAL_CONFIG_DIR=/path/to/project-a/.secops
97+
secops search --query "..."
98+
```
6499

65100
### Viewing Configuration
66101

67-
View your current configuration settings:
102+
View your current global configuration:
68103

69104
```bash
70105
secops config view
71106
```
72107

108+
View local configuration:
109+
110+
```bash
111+
secops config view --local
112+
```
113+
73114
### Clearing Configuration
74115

75116
Clear all saved configuration:

pyproject.toml

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -4,7 +4,7 @@ build-backend = "hatchling.build"
44

55
[project]
66
name = "secops"
7-
version = "0.37.0"
7+
version = "0.38.0"
88
description = "Python SDK for wrapping the Google SecOps API for common use cases"
99
readme = "README.md"
1010
requires-python = ">=3.10"

requirements-dev.txt

Lines changed: 13 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,13 @@
1+
pytest>=7.0.0
2+
pytest-cov>=3.0.0
3+
tox>=3.24.0
4+
python-dotenv>=0.17.1
5+
build
6+
black
7+
packaging
8+
pathspec
9+
protobuf
10+
pylint
11+
twine
12+
sphinx>=4.0.0
13+
sphinx-rtd-theme>=1.0.0

requirements.txt

Lines changed: 4 additions & 11 deletions
Original file line numberDiff line numberDiff line change
@@ -1,11 +1,4 @@
1-
pytest
2-
pytest-cov
3-
build
4-
black
5-
packaging
6-
pathspec
7-
protobuf
8-
pylint
9-
twine
10-
python-dotenv
11-
requests
1+
google-auth>=2.0.0
2+
google-auth-httplib2>=0.1.0
3+
google-api-python-client>=2.0.0
4+
requests>=2.25.1

src/secops/cli/cli_client.py

Lines changed: 21 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -190,9 +190,30 @@ def build_parser() -> argparse.ArgumentParser:
190190
setup_watchlist_command(subparsers)
191191
setup_rule_retrohunt_command(subparsers)
192192

193+
# Add common args to all subparsers to support global flags after subcommand
194+
# e.g. "secops search ... --output json"
195+
# We use suppress_defaults=True so that if the flag is NOT provided,
196+
# it doesn't override the global default (or the one from the specific
197+
# command if it exists)
198+
_apply_common_args_recursively(parser)
199+
193200
return parser
194201

195202

203+
def _apply_common_args_recursively(parser: argparse.ArgumentParser) -> None:
204+
"""Recursively add common args to all subparsers.
205+
206+
Args:
207+
parser: Parser to traverse
208+
"""
209+
for action in getattr(parser, "_actions", []):
210+
if hasattr(action, "choices") and isinstance(action.choices, dict):
211+
for subparser in action.choices.values():
212+
add_common_args(subparser, suppress_defaults=True)
213+
add_chronicle_args(subparser, suppress_defaults=True)
214+
_apply_common_args_recursively(subparser)
215+
216+
196217
def run(args: argparse.Namespace, parser: argparse.ArgumentParser) -> None:
197218
"""Run the CLI
198219

src/secops/cli/commands/config.py

Lines changed: 18 additions & 5 deletions
Original file line numberDiff line numberDiff line change
@@ -39,6 +39,11 @@ def setup_config_command(subparsers):
3939
set_parser = config_subparsers.add_parser(
4040
"set", help="Set configuration values"
4141
)
42+
set_parser.add_argument(
43+
"--local",
44+
action="store_true",
45+
help="Save configuration to current directory (.secops/config.json)",
46+
)
4247
add_chronicle_args(set_parser)
4348
add_common_args(set_parser)
4449
add_time_range_args(set_parser)
@@ -48,6 +53,11 @@ def setup_config_command(subparsers):
4853
view_parser = config_subparsers.add_parser(
4954
"view", help="View current configuration"
5055
)
56+
view_parser.add_argument(
57+
"--local",
58+
action="store_true",
59+
help="View configuration of current directory (.secops/config.json)",
60+
)
5161
view_parser.set_defaults(func=handle_config_view_command)
5262

5363
# Clear config command
@@ -64,7 +74,8 @@ def handle_config_set_command(args, chronicle=None):
6474
args: Command line arguments
6575
chronicle: Not used for this command
6676
"""
67-
config = load_config()
77+
scope = "local" if args.local else "global"
78+
config = load_config(scope=scope)
6879

6980
# Update config with new values
7081
if args.customer_id:
@@ -84,8 +95,9 @@ def handle_config_set_command(args, chronicle=None):
8495
if args.time_window is not None:
8596
config["time_window"] = args.time_window
8697

87-
save_config(config)
88-
print(f"Configuration saved to {CONFIG_FILE}")
98+
save_config(config, local=args.local)
99+
target = "local" if args.local else "global"
100+
print(f"Configuration saved to {target} config file")
89101

90102
# Unused argument
91103
_ = (chronicle,)
@@ -98,13 +110,14 @@ def handle_config_view_command(args, chronicle=None):
98110
args: Command line arguments
99111
chronicle: Not used for this command
100112
"""
101-
config = load_config()
113+
scope = "local" if args.local else "global"
114+
config = load_config(scope=scope)
102115

103116
if not config:
104117
print("No configuration found.")
105118
return
106119

107-
print("Current configuration:")
120+
print(f"Current {scope} configuration:")
108121
for key, value in config.items():
109122
print(f" {key}: {value}")
110123

src/secops/cli/constants.py

Lines changed: 13 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -1,7 +1,20 @@
11
"""Constants for CLI"""
22

3+
import os
34
from pathlib import Path
45

56
# Define config directory and file paths
7+
# Global config (user home)
68
CONFIG_DIR = Path.home() / ".secops"
79
CONFIG_FILE = CONFIG_DIR / "config.json"
10+
11+
# Local config (current working directory or from env var)
12+
# If SECOPS_LOCAL_CONFIG_DIR is set, use it.
13+
# Otherwise, default to current working directory + .secops
14+
_local_config_env = os.environ.get("SECOPS_LOCAL_CONFIG_DIR")
15+
if _local_config_env:
16+
LOCAL_CONFIG_DIR = Path(_local_config_env)
17+
else:
18+
LOCAL_CONFIG_DIR = Path.cwd() / ".secops"
19+
20+
LOCAL_CONFIG_FILE = LOCAL_CONFIG_DIR / "config.json"

src/secops/cli/utils/common_args.py

Lines changed: 46 additions & 14 deletions
Original file line numberDiff line numberDiff line change
@@ -19,62 +19,94 @@
1919
from secops.cli.utils.config_utils import load_config
2020

2121

22-
def add_common_args(parser: argparse.ArgumentParser) -> None:
22+
def _add_argument_if_not_exists(
23+
parser: argparse.ArgumentParser, *args: str, **kwargs
24+
) -> None:
25+
"""Add argument to parser only if it typically doesn't exist.
26+
27+
Args:
28+
parser: Parser to add argument to
29+
*args: Positional arguments (flags)
30+
**kwargs: Keyword arguments
31+
"""
32+
try:
33+
parser.add_argument(*args, **kwargs)
34+
except argparse.ArgumentError:
35+
# Argument already exists, so we can skip it
36+
return
37+
38+
39+
def add_common_args(
40+
parser: argparse.ArgumentParser, suppress_defaults: bool = False
41+
) -> None:
2342
"""Add common arguments to a parser.
2443
2544
Args:
2645
parser: Parser to add arguments to
46+
suppress_defaults: If True, do not set default values
47+
(let parent parser handle it)
2748
"""
2849
config = load_config()
50+
default_base = argparse.SUPPRESS if suppress_defaults else None
2951

30-
parser.add_argument(
52+
_add_argument_if_not_exists(
53+
parser,
3154
"--service-account",
3255
"--service_account",
3356
dest="service_account",
34-
default=config.get("service_account"),
57+
default=default_base or config.get("service_account"),
3558
help="Path to service account JSON file",
3659
)
37-
parser.add_argument(
60+
_add_argument_if_not_exists(
61+
parser,
3862
"--output",
3963
choices=["json", "text"],
40-
default="json",
64+
default=default_base or "json",
4165
help="Output format",
4266
)
4367

4468

45-
def add_chronicle_args(parser: argparse.ArgumentParser) -> None:
69+
def add_chronicle_args(
70+
parser: argparse.ArgumentParser, suppress_defaults: bool = False
71+
) -> None:
4672
"""Add Chronicle-specific arguments to a parser.
4773
4874
Args:
4975
parser: Parser to add arguments to
76+
suppress_defaults: If True, set default values to argparse.SUPPRESS
5077
"""
5178
config = load_config()
79+
default_base = argparse.SUPPRESS if suppress_defaults else None
5280

53-
parser.add_argument(
81+
_add_argument_if_not_exists(
82+
parser,
5483
"--customer-id",
5584
"--customer_id",
5685
dest="customer_id",
57-
default=config.get("customer_id"),
86+
default=default_base or config.get("customer_id"),
5887
help="Chronicle instance ID",
5988
)
60-
parser.add_argument(
89+
_add_argument_if_not_exists(
90+
parser,
6191
"--project-id",
6292
"--project_id",
6393
dest="project_id",
64-
default=config.get("project_id"),
94+
default=default_base or config.get("project_id"),
6595
help="GCP project ID",
6696
)
67-
parser.add_argument(
97+
_add_argument_if_not_exists(
98+
parser,
6899
"--region",
69-
default=config.get("region", "us"),
100+
default=default_base or config.get("region", "us"),
70101
help="Chronicle API region",
71102
)
72-
parser.add_argument(
103+
_add_argument_if_not_exists(
104+
parser,
73105
"--api-version",
74106
"--api_version",
75107
dest="api_version",
76108
choices=["v1", "v1beta", "v1alpha"],
77-
default=config.get("api_version", "v1alpha"),
109+
default=default_base or config.get("api_version", "v1alpha"),
78110
help=(
79111
"Default API version for Chronicle requests " "(default: v1alpha)"
80112
),

0 commit comments

Comments
 (0)