licos-platform-cli 0.2.12__tar.gz → 0.2.13__tar.gz

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.
@@ -40,6 +40,8 @@ crates/industrial/bin/
40
40
 
41
41
  /tmp
42
42
  tools/android-sdk-cache/*.zip
43
+ tools/codegraph-cache/*.tgz
44
+ tools/codegraph-cache/*.tgz
43
45
 
44
46
  *.codex-*
45
47
 
@@ -1,6 +1,6 @@
1
1
  Metadata-Version: 2.4
2
2
  Name: licos-platform-cli
3
- Version: 0.2.12
3
+ Version: 0.2.13
4
4
  Summary: LICOS platform CLI
5
5
  Requires-Python: >=3.10
6
6
  Requires-Dist: licos-platform-sdk>=0.3.0
@@ -4,11 +4,11 @@ build-backend = "hatchling.build"
4
4
 
5
5
  [project]
6
6
  name = "licos-platform-cli"
7
- version = "0.2.12"
7
+ version = "0.2.13"
8
8
  description = "LICOS platform CLI"
9
9
  requires-python = ">=3.10"
10
10
  dependencies = [
11
- "licos-platform-sdk>=0.3.0",
11
+ "licos-platform-sdk>=0.3.0",
12
12
  ]
13
13
 
14
14
  [project.scripts]
@@ -355,7 +355,7 @@ DATABASE_HELP = {
355
355
  }
356
356
 
357
357
 
358
- KNOWLEDGE_HELP = {
358
+ KNOWLEDGE_HELP = {
359
359
  "topic": "knowledge",
360
360
  "description": "Knowledge dataset helpers for importing text, URLs, object-storage file references, and semantic retrieval.",
361
361
  "runtime": RUNTIME_ENV,
@@ -448,62 +448,62 @@ KNOWLEDGE_HELP = {
448
448
  "example": "licos-platform knowledge delete-documents dataset-id doc-1 doc-2",
449
449
  },
450
450
  },
451
- }
452
-
453
-
454
- OAUTH_HELP = {
455
- "topic": "oauth",
456
- "description": "Manage LICOS OAuth2/OIDC login applications from trusted project runtime code.",
457
- "runtime": RUNTIME_ENV,
458
- "rules": [
459
- "Use only when the user explicitly requests LICOS platform OAuth2/OIDC login.",
460
- "Before configure-project, confirm auto-apply versus an existing app, login audience, and all callbacks.",
461
- "Personal users use ALL. Enterprise users explicitly choose ALL or OWNER_TENANT.",
462
- "Generated config uses PKCE and never contains a client secret.",
463
- "Do not rotate a secret unless the user explicitly asks for rotation.",
464
- ],
465
- "sdk": {
466
- "python": "from licos_platform_sdk import oauth\napps = oauth.list_apps()\napp = oauth.get_app('app-id')",
467
- "node": "import { oauth } from '@kmlckj/licos-platform-sdk';\nconst apps = await oauth.listApps();",
468
- },
469
- "commands": {
470
- "apps": {
471
- "description": "List OAuth2 applications manageable by the current project owner.",
472
- "parameters": [],
473
- "example": "licos-platform oauth apps",
474
- },
475
- "get": {
476
- "description": "Get one manageable OAuth2 application.",
477
- "parameters": [{"name": "app_id", "required": True, "description": "OAuth2 application ID."}],
478
- "example": "licos-platform oauth get app-id",
479
- },
480
- "configure-project": {
481
- "description": "After explicit confirmation, apply or update one OAuth2 app and write config/licos-oauth.json without a secret.",
482
- "parameters": [
483
- {"name": "--auto-apply / --app-id", "required": True, "description": "Explicit application selection mode."},
484
- {"name": "--name", "required": True, "description": "Application display name."},
485
- {"name": "--login-audience", "required": True, "description": "ALL or OWNER_TENANT."},
486
- {"name": "--redirect", "required": True, "description": "Confirmed dev/prod callback; can repeat."},
487
- {"name": "--public-base-url", "required": True, "description": "Public LICOS OAuth2 origin."},
488
- ],
489
- "example": "licos-platform oauth configure-project --auto-apply --name 'Project login' --login-audience ALL --redirect prod=https://app.example/auth/callback --public-base-url https://platform.example",
490
- },
491
- "delete": {"description": "Delete an OAuth2 application.", "parameters": [], "example": "licos-platform oauth delete app-id"},
492
- "enable": {"description": "Enable an OAuth2 application.", "parameters": [], "example": "licos-platform oauth enable app-id"},
493
- "disable": {"description": "Disable an OAuth2 application.", "parameters": [], "example": "licos-platform oauth disable app-id"},
494
- "rotate-secret": {"description": "Explicitly rotate a confidential-client secret.", "parameters": [], "example": "licos-platform oauth rotate-secret app-id"},
495
- "grants": {"description": "List application grants.", "parameters": [], "example": "licos-platform oauth grants app-id"},
496
- "revoke-grant": {"description": "Revoke one grant.", "parameters": [], "example": "licos-platform oauth revoke-grant grant-id"},
497
- },
498
- }
451
+ }
499
452
 
500
453
 
501
- HELP_TOPICS = {
454
+ OAUTH_HELP = {
455
+ "topic": "oauth",
456
+ "description": "Manage LICOS OAuth2/OIDC login applications from trusted project runtime code.",
457
+ "runtime": RUNTIME_ENV,
458
+ "rules": [
459
+ "Use only when the user explicitly requests LICOS platform OAuth2/OIDC login.",
460
+ "Before configure-project, confirm auto-apply versus an existing app, login audience, and all callbacks.",
461
+ "Personal users use ALL. Enterprise users explicitly choose ALL or OWNER_TENANT.",
462
+ "Generated config uses PKCE and never contains a client secret.",
463
+ "Do not rotate a secret unless the user explicitly asks for rotation.",
464
+ ],
465
+ "sdk": {
466
+ "python": "from licos_platform_sdk import oauth\napps = oauth.list_apps()\napp = oauth.get_app('app-id')",
467
+ "node": "import { oauth } from '@kmlckj/licos-platform-sdk';\nconst apps = await oauth.listApps();",
468
+ },
469
+ "commands": {
470
+ "apps": {
471
+ "description": "List OAuth2 applications manageable by the current project owner.",
472
+ "parameters": [],
473
+ "example": "licos-platform oauth apps",
474
+ },
475
+ "get": {
476
+ "description": "Get one manageable OAuth2 application.",
477
+ "parameters": [{"name": "app_id", "required": True, "description": "OAuth2 application ID."}],
478
+ "example": "licos-platform oauth get app-id",
479
+ },
480
+ "configure-project": {
481
+ "description": "After explicit confirmation, apply or update one OAuth2 app and write config/licos-oauth.json without a secret.",
482
+ "parameters": [
483
+ {"name": "--auto-apply / --app-id", "required": True, "description": "Explicit application selection mode."},
484
+ {"name": "--name", "required": True, "description": "Application display name."},
485
+ {"name": "--login-audience", "required": True, "description": "ALL or OWNER_TENANT."},
486
+ {"name": "--redirect", "required": False, "description": "Optional explicit callback override; otherwise derive stable dev/prod callbacks from project runtime metadata."},
487
+ {"name": "--public-base-url", "required": False, "description": "Optional public LICOS OAuth2 origin override."},
488
+ ],
489
+ "example": "licos-platform oauth configure-project --auto-apply --name 'Project login' --login-audience ALL",
490
+ },
491
+ "delete": {"description": "Delete an OAuth2 application.", "parameters": [], "example": "licos-platform oauth delete app-id"},
492
+ "enable": {"description": "Enable an OAuth2 application.", "parameters": [], "example": "licos-platform oauth enable app-id"},
493
+ "disable": {"description": "Disable an OAuth2 application.", "parameters": [], "example": "licos-platform oauth disable app-id"},
494
+ "rotate-secret": {"description": "Explicitly rotate a confidential-client secret.", "parameters": [], "example": "licos-platform oauth rotate-secret app-id"},
495
+ "grants": {"description": "List application grants.", "parameters": [], "example": "licos-platform oauth grants app-id"},
496
+ "revoke-grant": {"description": "Revoke one grant.", "parameters": [], "example": "licos-platform oauth revoke-grant grant-id"},
497
+ },
498
+ }
499
+
500
+
501
+ HELP_TOPICS = {
502
502
  "storage": STORAGE_HELP,
503
503
  "database": DATABASE_HELP,
504
- "knowledge": KNOWLEDGE_HELP,
505
- "oauth": OAUTH_HELP,
506
- }
504
+ "knowledge": KNOWLEDGE_HELP,
505
+ "oauth": OAUTH_HELP,
506
+ }
507
507
 
508
508
 
509
509
  def _set_handler(parser: argparse.ArgumentParser, handler: Handler) -> None:
@@ -522,10 +522,10 @@ def _cmd_help(args: argparse.Namespace) -> dict[str, Any]:
522
522
  "licos-platform help database export-orm",
523
523
  "licos-platform help database studio-ensure",
524
524
  "licos-platform help knowledge",
525
- "licos-platform help knowledge search",
526
- "licos-platform help oauth",
527
- "licos-platform help oauth configure-project",
528
- ],
525
+ "licos-platform help knowledge search",
526
+ "licos-platform help oauth",
527
+ "licos-platform help oauth configure-project",
528
+ ],
529
529
  "topics": sorted(HELP_TOPICS),
530
530
  }
531
531
  topic = HELP_TOPICS[args.topic]
@@ -9,9 +9,9 @@ from licos_platform_sdk import PlatformSdkError
9
9
 
10
10
  from .database_commands import add_database_parser
11
11
  from .help_commands import add_help_parser
12
- from .knowledge_commands import add_knowledge_parser
13
- from .oauth_commands import add_oauth_parser
14
- from .storage_commands import add_storage_parser
12
+ from .knowledge_commands import add_knowledge_parser
13
+ from .oauth_commands import add_oauth_parser
14
+ from .storage_commands import add_storage_parser
15
15
 
16
16
 
17
17
  def _print_json(value: Any) -> None:
@@ -32,8 +32,8 @@ def _build_parser() -> argparse.ArgumentParser:
32
32
  add_help_parser(subparsers)
33
33
  add_storage_parser(subparsers)
34
34
  add_database_parser(subparsers)
35
- add_knowledge_parser(subparsers)
36
- add_oauth_parser(subparsers)
35
+ add_knowledge_parser(subparsers)
36
+ add_oauth_parser(subparsers)
37
37
  return parser
38
38
 
39
39
 
@@ -1,216 +1,252 @@
1
- from __future__ import annotations
2
-
3
- import argparse
4
- import json
5
- import os
6
- from pathlib import Path
7
- from typing import Any, Callable
8
- from urllib.parse import urlsplit, urlunsplit
9
-
10
- from licos_platform_sdk import oauth
11
-
12
-
13
- Handler = Callable[[argparse.Namespace], Any]
14
- DEFAULT_SCOPES = ["openid", "profile", "email"]
15
-
16
-
17
- def _set_handler(parser: argparse.ArgumentParser, handler: Handler) -> None:
18
- parser.set_defaults(handler=handler)
19
-
20
-
21
- def _parse_http_url(value: str, *, option: str) -> str:
22
- candidate = value.strip()
23
- parsed = urlsplit(candidate)
24
- if parsed.scheme not in {"http", "https"} or not parsed.netloc:
25
- raise argparse.ArgumentTypeError(f"{option} must be an absolute http(s) URL")
26
- if parsed.fragment:
27
- raise argparse.ArgumentTypeError(f"{option} must not contain a fragment")
28
- return candidate
29
-
30
-
31
- def _parse_redirect(value: str) -> dict[str, str]:
32
- environment = "prod"
33
- redirect_uri = value
34
- prefix, separator, remainder = value.partition("=")
35
- if separator:
36
- environment = prefix.strip().lower()
37
- redirect_uri = remainder
38
- if environment not in {"dev", "prod"}:
39
- raise argparse.ArgumentTypeError("redirect environment must be dev or prod")
40
- return {
41
- "environment": environment,
42
- "redirectUri": _parse_http_url(redirect_uri, option="--redirect"),
43
- }
44
-
45
-
1
+ from __future__ import annotations
2
+
3
+ import argparse
4
+ import json
5
+ import os
6
+ from pathlib import Path
7
+ from typing import Any, Callable
8
+ from urllib.parse import quote, urlsplit, urlunsplit
9
+
10
+ from licos_platform_sdk import oauth
11
+
12
+
13
+ Handler = Callable[[argparse.Namespace], Any]
14
+ DEFAULT_SCOPES = ["openid", "profile", "email"]
15
+
16
+
17
+ def _set_handler(parser: argparse.ArgumentParser, handler: Handler) -> None:
18
+ parser.set_defaults(handler=handler)
19
+
20
+
21
+ def _parse_http_url(value: str, *, option: str) -> str:
22
+ candidate = value.strip()
23
+ parsed = urlsplit(candidate)
24
+ if parsed.scheme not in {"http", "https"} or not parsed.netloc:
25
+ raise argparse.ArgumentTypeError(f"{option} must be an absolute http(s) URL")
26
+ if parsed.fragment:
27
+ raise argparse.ArgumentTypeError(f"{option} must not contain a fragment")
28
+ return candidate
29
+
30
+
31
+ def _parse_redirect(value: str) -> dict[str, str]:
32
+ environment = "prod"
33
+ redirect_uri = value
34
+ prefix, separator, remainder = value.partition("=")
35
+ if separator:
36
+ environment = prefix.strip().lower()
37
+ redirect_uri = remainder
38
+ if environment not in {"dev", "prod"}:
39
+ raise argparse.ArgumentTypeError("redirect environment must be dev or prod")
40
+ return {
41
+ "environment": environment,
42
+ "redirectUri": _parse_http_url(redirect_uri, option="--redirect"),
43
+ }
44
+
45
+
46
46
  def _public_base_url(value: str) -> str:
47
- parsed = urlsplit(_parse_http_url(value, option="--public-base-url"))
48
- path = parsed.path.rstrip("/")
49
- if path.endswith("/api/v1"):
50
- path = path[: -len("/api/v1")]
47
+ parsed = urlsplit(_parse_http_url(value, option="--public-base-url"))
48
+ path = parsed.path.rstrip("/")
49
+ if path.endswith("/api/v1"):
50
+ path = path[: -len("/api/v1")]
51
51
  return urlunsplit((parsed.scheme, parsed.netloc, path, "", "")).rstrip("/")
52
52
 
53
53
 
54
- def _project_root() -> Path:
55
- return Path(os.environ.get("LICOS_PROJECT_PATH") or Path.cwd()).resolve()
56
-
57
-
58
- def _config_path(output: str) -> Path:
59
- root = _project_root()
60
- candidate = Path(output)
61
- resolved = (candidate if candidate.is_absolute() else root / candidate).resolve()
62
- try:
63
- resolved.relative_to(root)
64
- except ValueError as exc:
65
- raise argparse.ArgumentTypeError("--out must stay inside LICOS_PROJECT_PATH") from exc
66
- return resolved
67
-
68
-
69
- def _without_secret(value: Any) -> Any:
70
- if isinstance(value, dict):
71
- return {
72
- key: _without_secret(item)
73
- for key, item in value.items()
74
- if "secret" not in key.lower().replace("_", "")
75
- }
76
- if isinstance(value, list):
77
- return [_without_secret(item) for item in value]
54
+ def _required_env(name: str) -> str:
55
+ value = (os.environ.get(name) or "").strip()
56
+ if not value:
57
+ raise argparse.ArgumentTypeError(
58
+ f"{name} is required when --redirect is not provided"
59
+ )
78
60
  return value
79
61
 
80
62
 
81
- def _cmd_apps(args: argparse.Namespace) -> Any:
82
- return _without_secret(oauth.list_apps(page=args.page, page_size=args.page_size))
83
-
84
-
85
- def _cmd_get(args: argparse.Namespace) -> Any:
86
- return _without_secret(oauth.get_app(args.app_id))
87
-
88
-
89
- def _cmd_delete(args: argparse.Namespace) -> Any:
90
- return oauth.delete_app(args.app_id)
91
-
92
-
93
- def _cmd_set_enabled(args: argparse.Namespace) -> Any:
94
- return _without_secret(oauth.set_enabled(args.app_id, args.enabled))
95
-
96
-
97
- def _cmd_rotate_secret(args: argparse.Namespace) -> Any:
98
- return oauth.rotate_secret(args.app_id)
99
-
100
-
101
- def _cmd_grants(args: argparse.Namespace) -> Any:
102
- return oauth.list_grants(args.app_id)
103
-
104
-
105
- def _cmd_revoke_grant(args: argparse.Namespace) -> Any:
106
- return oauth.revoke_grant(args.grant_id)
107
-
108
-
63
+ def _automatic_redirects() -> list[dict[str, str]]:
64
+ project_id = _required_env("AGENT_PROJECT_ID")
65
+ encoded_project_id = quote(project_id, safe="")
66
+ platform_public_base = _public_base_url(_required_env("LICOS_PLATFORM_PUBLIC_BASE_URL"))
67
+ deploy_public_base = _public_base_url(_required_env("LICOS_DEPLOY_PUBLIC_BASE_URL"))
68
+ return [
69
+ {
70
+ "environment": "dev",
71
+ "redirectUri": (
72
+ f"{platform_public_base}/agent/api/v1/projects/"
73
+ f"{encoded_project_id}/oauth/callback"
74
+ ),
75
+ },
76
+ {
77
+ "environment": "prod",
78
+ "redirectUri": (
79
+ f"{deploy_public_base}/p/{encoded_project_id}/api/auth/licos/callback"
80
+ ),
81
+ },
82
+ ]
83
+
84
+
85
+ def _project_root() -> Path:
86
+ return Path(os.environ.get("LICOS_PROJECT_PATH") or Path.cwd()).resolve()
87
+
88
+
89
+ def _config_path(output: str) -> Path:
90
+ root = _project_root()
91
+ candidate = Path(output)
92
+ resolved = (candidate if candidate.is_absolute() else root / candidate).resolve()
93
+ try:
94
+ resolved.relative_to(root)
95
+ except ValueError as exc:
96
+ raise argparse.ArgumentTypeError("--out must stay inside LICOS_PROJECT_PATH") from exc
97
+ return resolved
98
+
99
+
100
+ def _without_secret(value: Any) -> Any:
101
+ if isinstance(value, dict):
102
+ return {
103
+ key: _without_secret(item)
104
+ for key, item in value.items()
105
+ if "secret" not in key.lower().replace("_", "")
106
+ }
107
+ if isinstance(value, list):
108
+ return [_without_secret(item) for item in value]
109
+ return value
110
+
111
+
112
+ def _cmd_apps(args: argparse.Namespace) -> Any:
113
+ return _without_secret(oauth.list_apps(page=args.page, page_size=args.page_size))
114
+
115
+
116
+ def _cmd_get(args: argparse.Namespace) -> Any:
117
+ return _without_secret(oauth.get_app(args.app_id))
118
+
119
+
120
+ def _cmd_delete(args: argparse.Namespace) -> Any:
121
+ return oauth.delete_app(args.app_id)
122
+
123
+
124
+ def _cmd_set_enabled(args: argparse.Namespace) -> Any:
125
+ return _without_secret(oauth.set_enabled(args.app_id, args.enabled))
126
+
127
+
128
+ def _cmd_rotate_secret(args: argparse.Namespace) -> Any:
129
+ return oauth.rotate_secret(args.app_id)
130
+
131
+
132
+ def _cmd_grants(args: argparse.Namespace) -> Any:
133
+ return oauth.list_grants(args.app_id)
134
+
135
+
136
+ def _cmd_revoke_grant(args: argparse.Namespace) -> Any:
137
+ return oauth.revoke_grant(args.grant_id)
138
+
139
+
109
140
  def _cmd_configure_project(args: argparse.Namespace) -> dict[str, Any]:
110
- public_base_url = args.public_base_url or os.environ.get("LICOS_OAUTH_PUBLIC_BASE_URL")
141
+ public_base_url = (
142
+ args.public_base_url
143
+ or os.environ.get("LICOS_PLATFORM_PUBLIC_BASE_URL")
144
+ or os.environ.get("LICOS_OAUTH_PUBLIC_BASE_URL")
145
+ )
111
146
  if not public_base_url:
112
147
  raise argparse.ArgumentTypeError(
113
- "--public-base-url or LICOS_OAUTH_PUBLIC_BASE_URL is required"
148
+ "--public-base-url, LICOS_PLATFORM_PUBLIC_BASE_URL, or LICOS_OAUTH_PUBLIC_BASE_URL is required"
114
149
  )
115
150
  normalized_public_base_url = _public_base_url(public_base_url)
151
+ redirects = args.redirects or _automatic_redirects()
116
152
  path = _config_path(args.out)
117
- app = oauth.ensure_project_app(
118
- app_id=args.app_id,
119
- name=args.name,
120
- description=args.description,
121
- login_audience=args.login_audience,
122
- redirect_uri_configs=args.redirects,
123
- allowed_scopes=args.scopes or DEFAULT_SCOPES,
124
- )
125
- app_id = str(app.get("id") or "").strip()
126
- if not app_id:
127
- raise argparse.ArgumentTypeError("platform OAuth2 app response is missing id")
128
- client_id = str(app.get("clientId") or "").strip()
129
- if not client_id:
130
- raise argparse.ArgumentTypeError("platform OAuth2 app response is missing clientId")
131
-
132
- config = {
133
- "provider": "licos-oauth2",
134
- "appId": app_id,
135
- "appCode": app.get("appCode"),
136
- "clientId": client_id,
137
- "loginAudience": args.login_audience,
138
- "scopes": args.scopes or DEFAULT_SCOPES,
139
- "redirectUriConfigs": args.redirects,
140
- "pkce": True,
141
- "publicBaseUrl": normalized_public_base_url,
142
- "endpoints": {
143
- "authorization": "/oauth2/authorize",
144
- "token": "/oauth2/token",
145
- "userinfo": "/oauth2/userinfo",
146
- "revoke": "/oauth2/revoke",
147
- "discovery": "/.well-known/openid-configuration",
148
- "jwks": "/oauth2/jwks",
149
- },
150
- }
151
- path.parent.mkdir(parents=True, exist_ok=True)
152
- path.write_text(json.dumps(config, ensure_ascii=False, indent=2) + "\n", encoding="utf-8")
153
- return {
154
- "app": _without_secret(app),
155
- "configPath": str(path),
156
- "selectionMode": "existing" if args.app_id else "auto-apply",
157
- }
158
-
159
-
160
- def add_oauth_parser(subparsers: argparse._SubParsersAction[argparse.ArgumentParser]) -> None:
161
- parser = subparsers.add_parser("oauth", help="Manage LICOS platform OAuth2 applications")
162
- commands = parser.add_subparsers(dest="oauth_command", required=True)
163
-
164
- apps = commands.add_parser("apps", help="List manageable OAuth2 applications")
165
- apps.add_argument("--page", type=int, default=1)
166
- apps.add_argument("--page-size", type=int, default=100)
167
- _set_handler(apps, _cmd_apps)
168
-
169
- get_app = commands.add_parser("get", help="Get one OAuth2 application")
170
- get_app.add_argument("app_id")
171
- _set_handler(get_app, _cmd_get)
172
-
173
- configure = commands.add_parser(
174
- "configure-project",
175
- help="Configure a confirmed OAuth2 application and write public PKCE project settings",
176
- )
177
- selection = configure.add_mutually_exclusive_group(required=True)
178
- selection.add_argument("--auto-apply", action="store_true", help="Create or update the app for this project ID")
179
- selection.add_argument("--app-id", default=None, help="Use and update an existing manageable OAuth2 app")
180
- configure.add_argument("--name", required=True)
181
- configure.add_argument("--description", default=None)
182
- configure.add_argument("--login-audience", required=True, choices=["ALL", "OWNER_TENANT"])
183
- configure.add_argument(
184
- "--redirect",
153
+ app = oauth.ensure_project_app(
154
+ app_id=args.app_id,
155
+ name=args.name,
156
+ description=args.description,
157
+ login_audience=args.login_audience,
158
+ redirect_uri_configs=redirects,
159
+ allowed_scopes=args.scopes or DEFAULT_SCOPES,
160
+ )
161
+ app_id = str(app.get("id") or "").strip()
162
+ if not app_id:
163
+ raise argparse.ArgumentTypeError("platform OAuth2 app response is missing id")
164
+ client_id = str(app.get("clientId") or "").strip()
165
+ if not client_id:
166
+ raise argparse.ArgumentTypeError("platform OAuth2 app response is missing clientId")
167
+
168
+ config = {
169
+ "provider": "licos-oauth2",
170
+ "appId": app_id,
171
+ "appCode": app.get("appCode"),
172
+ "clientId": client_id,
173
+ "loginAudience": args.login_audience,
174
+ "scopes": args.scopes or DEFAULT_SCOPES,
175
+ "redirectUriConfigs": redirects,
176
+ "pkce": True,
177
+ "publicBaseUrl": normalized_public_base_url,
178
+ "endpoints": {
179
+ "authorization": "/oauth2/authorize",
180
+ "token": "/oauth2/token",
181
+ "userinfo": "/oauth2/userinfo",
182
+ "revoke": "/oauth2/revoke",
183
+ "discovery": "/.well-known/openid-configuration",
184
+ "jwks": "/oauth2/jwks",
185
+ },
186
+ }
187
+ path.parent.mkdir(parents=True, exist_ok=True)
188
+ path.write_text(json.dumps(config, ensure_ascii=False, indent=2) + "\n", encoding="utf-8")
189
+ return {
190
+ "app": _without_secret(app),
191
+ "configPath": str(path),
192
+ "selectionMode": "existing" if args.app_id else "auto-apply",
193
+ }
194
+
195
+
196
+ def add_oauth_parser(subparsers: argparse._SubParsersAction[argparse.ArgumentParser]) -> None:
197
+ parser = subparsers.add_parser("oauth", help="Manage LICOS platform OAuth2 applications")
198
+ commands = parser.add_subparsers(dest="oauth_command", required=True)
199
+
200
+ apps = commands.add_parser("apps", help="List manageable OAuth2 applications")
201
+ apps.add_argument("--page", type=int, default=1)
202
+ apps.add_argument("--page-size", type=int, default=100)
203
+ _set_handler(apps, _cmd_apps)
204
+
205
+ get_app = commands.add_parser("get", help="Get one OAuth2 application")
206
+ get_app.add_argument("app_id")
207
+ _set_handler(get_app, _cmd_get)
208
+
209
+ configure = commands.add_parser(
210
+ "configure-project",
211
+ help="Configure a confirmed OAuth2 application and write public PKCE project settings",
212
+ )
213
+ selection = configure.add_mutually_exclusive_group(required=True)
214
+ selection.add_argument("--auto-apply", action="store_true", help="Create or update the app for this project ID")
215
+ selection.add_argument("--app-id", default=None, help="Use and update an existing manageable OAuth2 app")
216
+ configure.add_argument("--name", required=True)
217
+ configure.add_argument("--description", default=None)
218
+ configure.add_argument("--login-audience", required=True, choices=["ALL", "OWNER_TENANT"])
219
+ configure.add_argument(
220
+ "--redirect",
185
221
  action="append",
186
222
  dest="redirects",
187
223
  type=_parse_redirect,
188
- required=True,
189
- help="Callback as dev=URL, prod=URL, or URL (defaults to prod); can repeat",
190
- )
191
- configure.add_argument("--scope", action="append", dest="scopes", default=None, help="OAuth2 scope; can repeat")
192
- configure.add_argument("--public-base-url", default=None, help="Public LICOS OAuth2 origin; /api/v1 is removed")
193
- configure.add_argument("--out", default="config/licos-oauth.json", help="Config path under LICOS_PROJECT_PATH")
194
- _set_handler(configure, _cmd_configure_project)
195
-
196
- delete = commands.add_parser("delete", help="Delete an OAuth2 application")
197
- delete.add_argument("app_id")
198
- _set_handler(delete, _cmd_delete)
199
-
200
- for name, enabled in (("enable", True), ("disable", False)):
201
- command = commands.add_parser(name, help=f"{name.capitalize()} an OAuth2 application")
202
- command.add_argument("app_id")
203
- command.set_defaults(enabled=enabled)
204
- _set_handler(command, _cmd_set_enabled)
205
-
206
- rotate = commands.add_parser("rotate-secret", help="Explicitly rotate and print a new confidential-client secret")
207
- rotate.add_argument("app_id")
208
- _set_handler(rotate, _cmd_rotate_secret)
209
-
210
- grants = commands.add_parser("grants", help="List grants for an OAuth2 application")
211
- grants.add_argument("app_id")
212
- _set_handler(grants, _cmd_grants)
213
-
214
- revoke = commands.add_parser("revoke-grant", help="Revoke one OAuth2 grant")
215
- revoke.add_argument("grant_id")
216
- _set_handler(revoke, _cmd_revoke_grant)
224
+ default=None,
225
+ help="Explicit callback override as dev=URL, prod=URL, or URL (defaults to prod); can repeat",
226
+ )
227
+ configure.add_argument("--scope", action="append", dest="scopes", default=None, help="OAuth2 scope; can repeat")
228
+ configure.add_argument("--public-base-url", default=None, help="Public LICOS OAuth2 origin; /api/v1 is removed")
229
+ configure.add_argument("--out", default="config/licos-oauth.json", help="Config path under LICOS_PROJECT_PATH")
230
+ _set_handler(configure, _cmd_configure_project)
231
+
232
+ delete = commands.add_parser("delete", help="Delete an OAuth2 application")
233
+ delete.add_argument("app_id")
234
+ _set_handler(delete, _cmd_delete)
235
+
236
+ for name, enabled in (("enable", True), ("disable", False)):
237
+ command = commands.add_parser(name, help=f"{name.capitalize()} an OAuth2 application")
238
+ command.add_argument("app_id")
239
+ command.set_defaults(enabled=enabled)
240
+ _set_handler(command, _cmd_set_enabled)
241
+
242
+ rotate = commands.add_parser("rotate-secret", help="Explicitly rotate and print a new confidential-client secret")
243
+ rotate.add_argument("app_id")
244
+ _set_handler(rotate, _cmd_rotate_secret)
245
+
246
+ grants = commands.add_parser("grants", help="List grants for an OAuth2 application")
247
+ grants.add_argument("app_id")
248
+ _set_handler(grants, _cmd_grants)
249
+
250
+ revoke = commands.add_parser("revoke-grant", help="Revoke one OAuth2 grant")
251
+ revoke.add_argument("grant_id")
252
+ _set_handler(revoke, _cmd_revoke_grant)
@@ -14,7 +14,7 @@ sys.path.insert(0, str(Path(__file__).resolve().parents[2] / "licos-platform-sdk
14
14
  sys.path.insert(0, str(Path(__file__).resolve().parents[1] / "src"))
15
15
 
16
16
  from licos_platform_cli import main as cli_main
17
- from licos_platform_cli import database_commands, knowledge_commands, oauth_commands, storage_commands
17
+ from licos_platform_cli import database_commands, knowledge_commands, oauth_commands, storage_commands
18
18
  from licos_platform_sdk import ApiError
19
19
 
20
20
 
@@ -46,7 +46,7 @@ class PlatformCliTests(unittest.TestCase):
46
46
  self.assertEqual(code, 0)
47
47
  self.assertEqual(stderr, "")
48
48
  payload = json.loads(stdout)
49
- self.assertEqual(payload["topics"], ["database", "knowledge", "oauth", "storage"])
49
+ self.assertEqual(payload["topics"], ["database", "knowledge", "oauth", "storage"])
50
50
  self.assertIn("licos-platform help storage", payload["usage"])
51
51
 
52
52
  def test_structured_help_storage_command_describes_parameters(self) -> None:
@@ -140,129 +140,164 @@ class PlatformCliTests(unittest.TestCase):
140
140
  self.assertEqual(stdout, "")
141
141
  self.assertIn("invalid choice", stderr)
142
142
 
143
- def test_sdk_errors_return_cli_failure(self) -> None:
143
+ def test_sdk_errors_return_cli_failure(self) -> None:
144
144
  with mock.patch.object(storage_commands.storage, "summary", side_effect=ApiError("platform failed")):
145
145
  code, stdout, stderr = run_cli("storage", "summary")
146
146
 
147
147
  self.assertEqual(code, 1)
148
148
  self.assertEqual(stdout, "")
149
- self.assertIn("platform failed", stderr)
150
-
151
- def test_oauth_configure_project_requires_explicit_app_selection(self) -> None:
152
- code, stdout, stderr = run_cli(
153
- "oauth",
154
- "configure-project",
155
- "--name",
156
- "Project login",
157
- "--login-audience",
158
- "ALL",
159
- "--redirect",
160
- "prod=https://app.example/callback",
161
- "--public-base-url",
162
- "https://platform.example",
163
- )
164
-
165
- self.assertEqual(code, 2)
166
- self.assertEqual(stdout, "")
167
- self.assertIn("--auto-apply", stderr)
168
- self.assertIn("--app-id", stderr)
169
-
170
- def test_oauth_configure_project_requires_explicit_audience(self) -> None:
171
- code, stdout, stderr = run_cli(
172
- "oauth",
173
- "configure-project",
174
- "--auto-apply",
175
- "--name",
176
- "Project login",
177
- "--redirect",
178
- "prod=https://app.example/callback",
179
- "--public-base-url",
180
- "https://platform.example",
181
- )
182
-
183
- self.assertEqual(code, 2)
184
- self.assertEqual(stdout, "")
185
- self.assertIn("--login-audience", stderr)
186
-
187
- def test_oauth_list_never_prints_secret_fields(self) -> None:
188
- result = {
189
- "list": [
190
- {
191
- "id": "app-1",
192
- "secret": "secret-1",
193
- "clientSecret": "secret-2",
194
- "client_secret": "secret-3",
195
- }
196
- ]
197
- }
198
- with mock.patch.object(oauth_commands.oauth, "list_apps", return_value=result):
199
- code, stdout, stderr = run_cli("oauth", "apps")
200
-
201
- self.assertEqual(code, 0)
202
- self.assertEqual(stderr, "")
203
- self.assertEqual(json.loads(stdout), {"list": [{"id": "app-1"}]})
204
-
205
- def test_oauth_configure_existing_app_writes_pkce_config_without_secret(self) -> None:
206
- app = {
207
- "id": "app-1",
208
- "appCode": "project-project-1",
209
- "clientId": "client-1",
210
- "secret": "must-not-be-written",
211
- }
212
- with tempfile.TemporaryDirectory() as tmp:
213
- with mock.patch.dict(os.environ, {"LICOS_PROJECT_PATH": tmp}, clear=False):
214
- with mock.patch.object(oauth_commands.oauth, "ensure_project_app", return_value=app) as ensure:
215
- code, stdout, stderr = run_cli(
216
- "oauth",
217
- "configure-project",
218
- "--app-id",
219
- "app-1",
220
- "--name",
221
- "Project login",
222
- "--login-audience",
223
- "OWNER_TENANT",
224
- "--redirect",
225
- "dev=http://localhost:3000/auth/callback",
226
- "--redirect",
227
- "prod=https://app.example/auth/callback",
228
- "--public-base-url",
229
- "https://platform.example/api/v1",
230
- )
231
-
232
- self.assertEqual(code, 0)
233
- self.assertEqual(stderr, "")
234
- payload = json.loads(stdout)
235
- config_path = Path(payload["configPath"])
236
- config = json.loads(config_path.read_text(encoding="utf-8"))
237
- self.assertEqual(config["clientId"], "client-1")
238
- self.assertEqual(config["loginAudience"], "OWNER_TENANT")
239
- self.assertEqual(config["publicBaseUrl"], "https://platform.example")
240
- self.assertTrue(config["pkce"])
241
- self.assertNotIn("secret", config)
242
- ensure.assert_called_once_with(
243
- app_id="app-1",
244
- name="Project login",
245
- description=None,
246
- login_audience="OWNER_TENANT",
247
- redirect_uri_configs=[
248
- {"environment": "dev", "redirectUri": "http://localhost:3000/auth/callback"},
249
- {"environment": "prod", "redirectUri": "https://app.example/auth/callback"},
250
- ],
251
- allowed_scopes=["openid", "profile", "email"],
252
- )
253
-
149
+ self.assertIn("platform failed", stderr)
150
+
151
+ def test_oauth_configure_project_requires_explicit_app_selection(self) -> None:
152
+ code, stdout, stderr = run_cli(
153
+ "oauth",
154
+ "configure-project",
155
+ "--name",
156
+ "Project login",
157
+ "--login-audience",
158
+ "ALL",
159
+ "--redirect",
160
+ "prod=https://app.example/callback",
161
+ "--public-base-url",
162
+ "https://platform.example",
163
+ )
164
+
165
+ self.assertEqual(code, 2)
166
+ self.assertEqual(stdout, "")
167
+ self.assertIn("--auto-apply", stderr)
168
+ self.assertIn("--app-id", stderr)
169
+
170
+ def test_oauth_configure_project_requires_explicit_audience(self) -> None:
171
+ code, stdout, stderr = run_cli(
172
+ "oauth",
173
+ "configure-project",
174
+ "--auto-apply",
175
+ "--name",
176
+ "Project login",
177
+ "--redirect",
178
+ "prod=https://app.example/callback",
179
+ "--public-base-url",
180
+ "https://platform.example",
181
+ )
182
+
183
+ self.assertEqual(code, 2)
184
+ self.assertEqual(stdout, "")
185
+ self.assertIn("--login-audience", stderr)
186
+
187
+ def test_oauth_list_never_prints_secret_fields(self) -> None:
188
+ result = {
189
+ "list": [
190
+ {
191
+ "id": "app-1",
192
+ "secret": "secret-1",
193
+ "clientSecret": "secret-2",
194
+ "client_secret": "secret-3",
195
+ }
196
+ ]
197
+ }
198
+ with mock.patch.object(oauth_commands.oauth, "list_apps", return_value=result):
199
+ code, stdout, stderr = run_cli("oauth", "apps")
200
+
201
+ self.assertEqual(code, 0)
202
+ self.assertEqual(stderr, "")
203
+ self.assertEqual(json.loads(stdout), {"list": [{"id": "app-1"}]})
204
+
205
+ def test_oauth_configure_existing_app_writes_pkce_config_without_secret(self) -> None:
206
+ app = {
207
+ "id": "app-1",
208
+ "appCode": "project-project-1",
209
+ "clientId": "client-1",
210
+ "secret": "must-not-be-written",
211
+ }
212
+ with tempfile.TemporaryDirectory() as tmp:
213
+ with mock.patch.dict(os.environ, {"LICOS_PROJECT_PATH": tmp}, clear=False):
214
+ with mock.patch.object(oauth_commands.oauth, "ensure_project_app", return_value=app) as ensure:
215
+ code, stdout, stderr = run_cli(
216
+ "oauth",
217
+ "configure-project",
218
+ "--app-id",
219
+ "app-1",
220
+ "--name",
221
+ "Project login",
222
+ "--login-audience",
223
+ "OWNER_TENANT",
224
+ "--redirect",
225
+ "dev=http://localhost:3000/auth/callback",
226
+ "--redirect",
227
+ "prod=https://app.example/auth/callback",
228
+ "--public-base-url",
229
+ "https://platform.example/api/v1",
230
+ )
231
+
232
+ self.assertEqual(code, 0)
233
+ self.assertEqual(stderr, "")
234
+ payload = json.loads(stdout)
235
+ config_path = Path(payload["configPath"])
236
+ config = json.loads(config_path.read_text(encoding="utf-8"))
237
+ self.assertEqual(config["clientId"], "client-1")
238
+ self.assertEqual(config["loginAudience"], "OWNER_TENANT")
239
+ self.assertEqual(config["publicBaseUrl"], "https://platform.example")
240
+ self.assertTrue(config["pkce"])
241
+ self.assertNotIn("secret", config)
242
+ ensure.assert_called_once_with(
243
+ app_id="app-1",
244
+ name="Project login",
245
+ description=None,
246
+ login_audience="OWNER_TENANT",
247
+ redirect_uri_configs=[
248
+ {"environment": "dev", "redirectUri": "http://localhost:3000/auth/callback"},
249
+ {"environment": "prod", "redirectUri": "https://app.example/auth/callback"},
250
+ ],
251
+ allowed_scopes=["openid", "profile", "email"],
252
+ )
253
+
254
254
  def test_oauth_configure_auto_apply_uses_environment_public_base(self) -> None:
255
+ app = {
256
+ "id": "app-2",
257
+ "appCode": "project-project-1",
258
+ "clientId": "client-2",
259
+ }
260
+ with tempfile.TemporaryDirectory() as tmp:
261
+ with mock.patch.dict(
262
+ os.environ,
263
+ {"LICOS_PROJECT_PATH": tmp, "LICOS_OAUTH_PUBLIC_BASE_URL": "https://platform.example/api/v1"},
264
+ clear=False,
265
+ ):
266
+ with mock.patch.object(oauth_commands.oauth, "ensure_project_app", return_value=app) as ensure:
267
+ code, stdout, stderr = run_cli(
268
+ "oauth",
269
+ "configure-project",
270
+ "--auto-apply",
271
+ "--name",
272
+ "Project login",
273
+ "--login-audience",
274
+ "ALL",
275
+ "--redirect",
276
+ "https://app.example/auth/callback",
277
+ )
278
+
279
+ self.assertEqual(code, 0)
280
+ self.assertEqual(stderr, "")
281
+ config = json.loads((Path(tmp) / "config" / "licos-oauth.json").read_text(encoding="utf-8"))
282
+ self.assertEqual(config["publicBaseUrl"], "https://platform.example")
283
+ self.assertEqual(config["redirectUriConfigs"][0]["environment"], "prod")
284
+ self.assertIsNone(ensure.call_args.kwargs["app_id"])
285
+ self.assertEqual(json.loads(stdout)["selectionMode"], "auto-apply")
286
+
287
+ def test_oauth_configure_auto_generates_stable_project_callbacks(self) -> None:
255
288
  app = {
256
- "id": "app-2",
289
+ "id": "app-3",
257
290
  "appCode": "project-project-1",
258
- "clientId": "client-2",
291
+ "clientId": "client-3",
259
292
  }
260
293
  with tempfile.TemporaryDirectory() as tmp:
261
- with mock.patch.dict(
262
- os.environ,
263
- {"LICOS_PROJECT_PATH": tmp, "LICOS_OAUTH_PUBLIC_BASE_URL": "https://platform.example/api/v1"},
264
- clear=False,
265
- ):
294
+ env = {
295
+ "LICOS_PROJECT_PATH": tmp,
296
+ "AGENT_PROJECT_ID": "project/1",
297
+ "LICOS_PLATFORM_PUBLIC_BASE_URL": "https://platform.example",
298
+ "LICOS_DEPLOY_PUBLIC_BASE_URL": "https://deploy.example",
299
+ }
300
+ with mock.patch.dict(os.environ, env, clear=False):
266
301
  with mock.patch.object(oauth_commands.oauth, "ensure_project_app", return_value=app) as ensure:
267
302
  code, stdout, stderr = run_cli(
268
303
  "oauth",
@@ -272,22 +307,43 @@ class PlatformCliTests(unittest.TestCase):
272
307
  "Project login",
273
308
  "--login-audience",
274
309
  "ALL",
275
- "--redirect",
276
- "https://app.example/auth/callback",
277
310
  )
278
311
 
279
312
  self.assertEqual(code, 0)
280
313
  self.assertEqual(stderr, "")
314
+ redirects = ensure.call_args.kwargs["redirect_uri_configs"]
315
+ self.assertEqual(
316
+ redirects,
317
+ [
318
+ {
319
+ "environment": "dev",
320
+ "redirectUri": (
321
+ "https://platform.example/agent/api/v1/projects/"
322
+ "project%2F1/oauth/callback"
323
+ ),
324
+ },
325
+ {
326
+ "environment": "prod",
327
+ "redirectUri": (
328
+ "https://deploy.example/p/project%2F1/api/auth/licos/callback"
329
+ ),
330
+ },
331
+ ],
332
+ )
281
333
  config = json.loads((Path(tmp) / "config" / "licos-oauth.json").read_text(encoding="utf-8"))
334
+ self.assertEqual(config["redirectUriConfigs"], redirects)
282
335
  self.assertEqual(config["publicBaseUrl"], "https://platform.example")
283
- self.assertEqual(config["redirectUriConfigs"][0]["environment"], "prod")
284
- self.assertIsNone(ensure.call_args.kwargs["app_id"])
285
336
  self.assertEqual(json.loads(stdout)["selectionMode"], "auto-apply")
286
337
 
287
- def test_oauth_invalid_output_path_does_not_mutate_platform(self) -> None:
338
+ def test_oauth_configure_without_redirect_requires_stable_project_metadata(self) -> None:
288
339
  with tempfile.TemporaryDirectory() as tmp:
289
- outside = str(Path(tmp).parent / "outside.json")
290
- with mock.patch.dict(os.environ, {"LICOS_PROJECT_PATH": tmp}, clear=False):
340
+ env = {
341
+ "LICOS_PROJECT_PATH": tmp,
342
+ "LICOS_PLATFORM_PUBLIC_BASE_URL": "https://platform.example",
343
+ "LICOS_DEPLOY_PUBLIC_BASE_URL": "https://deploy.example",
344
+ "AGENT_PROJECT_ID": "",
345
+ }
346
+ with mock.patch.dict(os.environ, env, clear=False):
291
347
  with mock.patch.object(oauth_commands.oauth, "ensure_project_app") as ensure:
292
348
  code, stdout, stderr = run_cli(
293
349
  "oauth",
@@ -297,19 +353,39 @@ class PlatformCliTests(unittest.TestCase):
297
353
  "Project login",
298
354
  "--login-audience",
299
355
  "ALL",
300
- "--redirect",
301
- "prod=https://app.example/auth/callback",
302
- "--public-base-url",
303
- "https://platform.example",
304
- "--out",
305
- outside,
306
356
  )
307
357
 
308
358
  self.assertEqual(code, 2)
309
359
  self.assertEqual(stdout, "")
310
- self.assertIn("must stay inside", stderr)
360
+ self.assertIn("AGENT_PROJECT_ID", stderr)
311
361
  ensure.assert_not_called()
312
362
 
363
+ def test_oauth_invalid_output_path_does_not_mutate_platform(self) -> None:
364
+ with tempfile.TemporaryDirectory() as tmp:
365
+ outside = str(Path(tmp).parent / "outside.json")
366
+ with mock.patch.dict(os.environ, {"LICOS_PROJECT_PATH": tmp}, clear=False):
367
+ with mock.patch.object(oauth_commands.oauth, "ensure_project_app") as ensure:
368
+ code, stdout, stderr = run_cli(
369
+ "oauth",
370
+ "configure-project",
371
+ "--auto-apply",
372
+ "--name",
373
+ "Project login",
374
+ "--login-audience",
375
+ "ALL",
376
+ "--redirect",
377
+ "prod=https://app.example/auth/callback",
378
+ "--public-base-url",
379
+ "https://platform.example",
380
+ "--out",
381
+ outside,
382
+ )
383
+
384
+ self.assertEqual(code, 2)
385
+ self.assertEqual(stdout, "")
386
+ self.assertIn("must stay inside", stderr)
387
+ ensure.assert_not_called()
388
+
313
389
  def test_database_query_forwards_runtime_options(self) -> None:
314
390
  with mock.patch.object(database_commands.database, "query", return_value={"rows": []}) as query:
315
391
  code, stdout, stderr = run_cli(