licos-platform-cli 0.2.0__tar.gz → 0.2.4__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.
@@ -31,6 +31,7 @@ Thumbs.db
31
31
 
32
32
  # Build
33
33
  *.log
34
+ *.pid
34
35
  .licos
35
36
 
36
37
  /tmp
@@ -43,4 +44,6 @@ Thumbs.db
43
44
 
44
45
  dist
45
46
 
46
- project-20260423_130440
47
+ project-20260423_130440
48
+ projects-20260425_workflow
49
+ coze-skills
@@ -0,0 +1,6 @@
1
+ Metadata-Version: 2.4
2
+ Name: licos-platform-cli
3
+ Version: 0.2.4
4
+ Summary: LICOS platform CLI
5
+ Requires-Python: >=3.10
6
+ Requires-Dist: licos-platform-sdk>=0.2.3
@@ -1,18 +1,18 @@
1
- [build-system]
2
- requires = ["hatchling"]
3
- build-backend = "hatchling.build"
4
-
5
- [project]
6
- name = "licos-platform-cli"
7
- version = "0.2.0"
8
- description = "LICOS platform CLI for storage preview and download operations"
9
- requires-python = ">=3.10"
10
- dependencies = [
11
- "licos-platform-sdk>=0.2.0",
12
- ]
13
-
14
- [project.scripts]
15
- licos-platform = "licos_platform_cli.main:main"
16
-
17
- [tool.hatch.build.targets.wheel]
18
- packages = ["src/licos_platform_cli"]
1
+ [build-system]
2
+ requires = ["hatchling"]
3
+ build-backend = "hatchling.build"
4
+
5
+ [project]
6
+ name = "licos-platform-cli"
7
+ version = "0.2.4"
8
+ description = "LICOS platform CLI"
9
+ requires-python = ">=3.10"
10
+ dependencies = [
11
+ "licos-platform-sdk>=0.2.3",
12
+ ]
13
+
14
+ [project.scripts]
15
+ licos-platform = "licos_platform_cli.main:main"
16
+
17
+ [tool.hatch.build.targets.wheel]
18
+ packages = ["src/licos_platform_cli"]
@@ -1 +1 @@
1
- __all__ = ["main"]
1
+ __all__ = ["main"]
@@ -0,0 +1,248 @@
1
+ from __future__ import annotations
2
+
3
+ import argparse
4
+ import json
5
+ from typing import Any, Callable
6
+
7
+ from licos_platform_sdk import database
8
+
9
+
10
+ Handler = Callable[[argparse.Namespace], Any]
11
+
12
+
13
+ def _set_handler(parser: argparse.ArgumentParser, handler: Handler) -> None:
14
+ parser.set_defaults(handler=handler)
15
+
16
+
17
+ def _json_value(value: str) -> Any:
18
+ try:
19
+ return json.loads(value)
20
+ except json.JSONDecodeError:
21
+ return value
22
+
23
+
24
+ def _json_object(value: str | None) -> dict[str, Any] | None:
25
+ if not value:
26
+ return None
27
+ parsed = _json_value(value)
28
+ if not isinstance(parsed, dict):
29
+ raise argparse.ArgumentTypeError("value must be a JSON object")
30
+ return parsed
31
+
32
+
33
+ def _json_list(value: str | None) -> list[Any] | None:
34
+ if not value:
35
+ return None
36
+ parsed = _json_value(value)
37
+ if not isinstance(parsed, list):
38
+ raise argparse.ArgumentTypeError("value must be a JSON array")
39
+ return parsed
40
+
41
+
42
+ def _filters(args: argparse.Namespace) -> list[dict[str, Any]] | None:
43
+ raw_filters = getattr(args, "filter", None) or []
44
+ filters = []
45
+ for item in raw_filters:
46
+ field, op, raw_value = item
47
+ filters.append({"field": field, "op": op, "value": _json_value(raw_value)})
48
+ return filters or None
49
+
50
+
51
+ def _order_by(args: argparse.Namespace) -> list[dict[str, str]] | None:
52
+ raw_orders = getattr(args, "order", None) or []
53
+ orders = []
54
+ for item in raw_orders:
55
+ if ":" in item:
56
+ field, direction = item.rsplit(":", 1)
57
+ else:
58
+ field, direction = item, "asc"
59
+ orders.append({"field": field, "direction": direction})
60
+ return orders or None
61
+
62
+
63
+ def _range(args: argparse.Namespace) -> dict[str, int] | None:
64
+ if not getattr(args, "range", None):
65
+ return None
66
+ start, end = args.range
67
+ return {"from": start, "to": end}
68
+
69
+
70
+ def _query_options(args: argparse.Namespace) -> dict[str, Any]:
71
+ return {
72
+ "select": args.select,
73
+ "filters": _filters(args),
74
+ "order_by": _order_by(args),
75
+ "range": _range(args),
76
+ "limit": args.limit,
77
+ "offset": args.offset,
78
+ "count": args.count,
79
+ "head": args.head,
80
+ }
81
+
82
+
83
+ def _cmd_query(args: argparse.Namespace) -> Any:
84
+ return database.query(args.table, **_query_options(args))
85
+
86
+
87
+ def _cmd_single(args: argparse.Namespace) -> Any:
88
+ return database.single(
89
+ args.table,
90
+ maybe=args.maybe,
91
+ select=args.select,
92
+ filters=_filters(args),
93
+ order_by=_order_by(args),
94
+ )
95
+
96
+
97
+ def _cmd_aggregate(args: argparse.Namespace) -> Any:
98
+ return database.aggregate(
99
+ args.table,
100
+ args.aggregate,
101
+ field=args.field,
102
+ group_by=args.group_by,
103
+ filters=_filters(args),
104
+ )
105
+
106
+
107
+ def _cmd_insert(args: argparse.Namespace) -> Any:
108
+ if not args.row and not args.rows:
109
+ raise argparse.ArgumentTypeError("--row or --rows is required")
110
+ return database.insert(
111
+ args.table,
112
+ row=_json_object(args.row),
113
+ rows=_json_list(args.rows),
114
+ returning=not args.no_returning,
115
+ )
116
+
117
+
118
+ def _cmd_update(args: argparse.Namespace) -> Any:
119
+ if not args.values and not args.updates:
120
+ raise argparse.ArgumentTypeError("--values or --updates is required")
121
+ return database.update(
122
+ args.table,
123
+ values=_json_object(args.values),
124
+ filters=_filters(args),
125
+ updates=_json_list(args.updates),
126
+ returning=not args.no_returning,
127
+ )
128
+
129
+
130
+ def _cmd_delete_rows(args: argparse.Namespace) -> Any:
131
+ filters = _filters(args)
132
+ if not filters and not args.deletes:
133
+ raise argparse.ArgumentTypeError("--filter or --deletes is required")
134
+ return database.delete(
135
+ args.table,
136
+ filters=filters,
137
+ deletes=_json_list(args.deletes),
138
+ returning=not args.no_returning,
139
+ )
140
+
141
+
142
+ def _cmd_upsert(args: argparse.Namespace) -> Any:
143
+ if not args.row and not args.rows:
144
+ raise argparse.ArgumentTypeError("--row or --rows is required")
145
+ return database.upsert(
146
+ args.table,
147
+ row=_json_object(args.row),
148
+ rows=_json_list(args.rows),
149
+ conflict_keys=args.conflict_keys,
150
+ returning=not args.no_returning,
151
+ )
152
+
153
+
154
+ def _cmd_transaction(args: argparse.Namespace) -> Any:
155
+ operations = _json_list(args.operations)
156
+ if operations is None:
157
+ raise argparse.ArgumentTypeError("--operations is required")
158
+ return database.transaction(operations)
159
+
160
+
161
+ def _cmd_rpc(args: argparse.Namespace) -> Any:
162
+ return database.rpc(args.function_name, args=_json_object(args.args) or {})
163
+
164
+
165
+ def _add_filter_args(parser: argparse.ArgumentParser) -> None:
166
+ parser.add_argument(
167
+ "--filter",
168
+ nargs=3,
169
+ action="append",
170
+ metavar=("FIELD", "OP", "VALUE"),
171
+ help="Add a filter. VALUE is parsed as JSON when possible.",
172
+ )
173
+
174
+
175
+ def _add_query_args(parser: argparse.ArgumentParser) -> None:
176
+ parser.add_argument("--select", default=None, help="Comma-separated fields")
177
+ _add_filter_args(parser)
178
+ parser.add_argument("--order", action="append", default=[], help="Order by field or field:desc")
179
+ parser.add_argument("--range", nargs=2, type=int, metavar=("FROM", "TO"))
180
+ parser.add_argument("--limit", type=int, default=None)
181
+ parser.add_argument("--offset", type=int, default=None)
182
+ parser.add_argument("--count", action="store_true")
183
+ parser.add_argument("--head", action="store_true")
184
+
185
+
186
+ def add_database_parser(subparsers: argparse._SubParsersAction[argparse.ArgumentParser]) -> None:
187
+ database_parser = subparsers.add_parser("database", help="Call LICOS runtime database APIs")
188
+ database_subparsers = database_parser.add_subparsers(dest="database_command", required=True)
189
+
190
+ query = database_subparsers.add_parser("query", help="Query rows")
191
+ query.add_argument("table")
192
+ _add_query_args(query)
193
+ _set_handler(query, _cmd_query)
194
+
195
+ single = database_subparsers.add_parser("single", help="Query a single row")
196
+ single.add_argument("table")
197
+ single.add_argument("--maybe", action="store_true", help="Allow zero rows")
198
+ single.add_argument("--select", default=None, help="Comma-separated fields")
199
+ _add_filter_args(single)
200
+ single.add_argument("--order", action="append", default=[], help="Order by field or field:desc")
201
+ _set_handler(single, _cmd_single)
202
+
203
+ aggregate = database_subparsers.add_parser("aggregate", help="Run aggregate query")
204
+ aggregate.add_argument("table")
205
+ aggregate.add_argument("aggregate")
206
+ aggregate.add_argument("--field", default=None)
207
+ aggregate.add_argument("--group-by", action="append", default=None)
208
+ _add_filter_args(aggregate)
209
+ _set_handler(aggregate, _cmd_aggregate)
210
+
211
+ insert = database_subparsers.add_parser("insert", help="Insert row(s)")
212
+ insert.add_argument("table")
213
+ insert.add_argument("--row", default=None, help="JSON object")
214
+ insert.add_argument("--rows", default=None, help="JSON array")
215
+ insert.add_argument("--no-returning", action="store_true")
216
+ _set_handler(insert, _cmd_insert)
217
+
218
+ update = database_subparsers.add_parser("update", help="Update row(s)")
219
+ update.add_argument("table")
220
+ update.add_argument("--values", default=None, help="JSON object")
221
+ update.add_argument("--updates", default=None, help="JSON array of mutation batches")
222
+ update.add_argument("--no-returning", action="store_true")
223
+ _add_filter_args(update)
224
+ _set_handler(update, _cmd_update)
225
+
226
+ delete_rows = database_subparsers.add_parser("delete-rows", help="Delete row(s)")
227
+ delete_rows.add_argument("table")
228
+ delete_rows.add_argument("--deletes", default=None, help="JSON array of mutation batches")
229
+ delete_rows.add_argument("--no-returning", action="store_true")
230
+ _add_filter_args(delete_rows)
231
+ _set_handler(delete_rows, _cmd_delete_rows)
232
+
233
+ upsert = database_subparsers.add_parser("upsert", help="Upsert row(s)")
234
+ upsert.add_argument("table")
235
+ upsert.add_argument("--row", default=None, help="JSON object")
236
+ upsert.add_argument("--rows", default=None, help="JSON array")
237
+ upsert.add_argument("--conflict-keys", action="append", default=None)
238
+ upsert.add_argument("--no-returning", action="store_true")
239
+ _set_handler(upsert, _cmd_upsert)
240
+
241
+ transaction = database_subparsers.add_parser("transaction", help="Run transaction")
242
+ transaction.add_argument("--operations", required=True, help="JSON array")
243
+ _set_handler(transaction, _cmd_transaction)
244
+
245
+ rpc = database_subparsers.add_parser("rpc", help="Call database function")
246
+ rpc.add_argument("function_name")
247
+ rpc.add_argument("--args", default=None, help="JSON object")
248
+ _set_handler(rpc, _cmd_rpc)
@@ -0,0 +1,282 @@
1
+ from __future__ import annotations
2
+
3
+ import argparse
4
+ from typing import Any, Callable
5
+
6
+
7
+ Handler = Callable[[argparse.Namespace], Any]
8
+
9
+
10
+ RUNTIME_ENV = [
11
+ {"name": "LICOS_PLATFORM_API_BASE_URL", "description": "Platform API base URL; /api/v1 suffix is optional."},
12
+ {"name": "LICOS_RUNTIME_TOKEN", "description": "Runtime authorization token. Preferred over LICOS_USER_TOKEN."},
13
+ {"name": "LICOS_USER_TOKEN", "description": "Fallback user token for development/runtime compatibility."},
14
+ {"name": "LICOS_PROJECT_ID / AGENT_PROJECT_ID", "description": "Current project ID."},
15
+ {"name": "LICOS_WORKSPACE_ID / AGENT_WORKSPACE_ID", "description": "Current workspace ID, forwarded as X-Workspace-Id."},
16
+ {"name": "LICOS_USER_ID / AGENT_USER_ID", "description": "Current user ID, forwarded as X-User-Id."},
17
+ {"name": "LICOS_PROJECT_ENV", "description": "Project environment. prod/production/release maps to prod; otherwise dev."},
18
+ ]
19
+
20
+
21
+ STORAGE_HELP = {
22
+ "topic": "storage",
23
+ "description": "Project object storage for folders, files, uploads, share URLs, object existence checks, moves, renames, and deletes.",
24
+ "runtime": [*RUNTIME_ENV, {"name": "LICOS_STORAGE_SCOPE", "description": "Optional storage scope override: dev or prod."}],
25
+ "rules": [
26
+ "Use SDK from server/runtime code; do not call from browser bundles.",
27
+ "Do not ask for S3 endpoint, bucket, access key, or secret key.",
28
+ "Do not build file URLs manually; use share-url/shareUrl.",
29
+ "Do not persist temporary share URLs as durable data.",
30
+ ],
31
+ "sdk": {
32
+ "python": "from licos_platform_sdk import storage\nfile = storage.upload_file('/workspace/projects/report.pdf')\nlink = storage.share_url(file['id'])",
33
+ "node": "import { storage } from '@kmlckj/licos-platform-sdk';\nconst file = await storage.uploadFile('/workspace/projects/report.pdf');\nconst link = await storage.shareUrl(file.id);",
34
+ },
35
+ "commands": {
36
+ "summary": {
37
+ "description": "Return project storage summary.",
38
+ "parameters": [],
39
+ "example": "licos-platform storage summary",
40
+ },
41
+ "folders": {
42
+ "description": "List all folders in the current project storage scope.",
43
+ "parameters": [],
44
+ "example": "licos-platform storage folders",
45
+ },
46
+ "entries": {
47
+ "description": "List folders and files under a folder; omit folder-id for root.",
48
+ "parameters": [{"name": "--folder-id", "required": False, "description": "Folder ID. Omit for root."}],
49
+ "example": "licos-platform storage entries --folder-id folder-id",
50
+ },
51
+ "files": {
52
+ "description": "List all files in the current project storage scope.",
53
+ "parameters": [],
54
+ "example": "licos-platform storage files",
55
+ },
56
+ "create-folder": {
57
+ "description": "Create a folder under root or a parent folder.",
58
+ "parameters": [
59
+ {"name": "folder_name", "required": True, "description": "New folder name."},
60
+ {"name": "--parent-id", "required": False, "description": "Parent folder ID. Omit for root."},
61
+ ],
62
+ "example": "licos-platform storage create-folder reports --parent-id folder-id",
63
+ },
64
+ "rename-folder": {
65
+ "description": "Rename a folder by folder ID.",
66
+ "parameters": [
67
+ {"name": "folder_id", "required": True, "description": "Folder ID."},
68
+ {"name": "folder_name", "required": True, "description": "New folder name."},
69
+ ],
70
+ "example": "licos-platform storage rename-folder folder-id reports",
71
+ },
72
+ "delete-folder": {
73
+ "description": "Delete a folder by folder ID.",
74
+ "parameters": [{"name": "folder_id", "required": True, "description": "Folder ID."}],
75
+ "example": "licos-platform storage delete-folder folder-id",
76
+ },
77
+ "upload-file": {
78
+ "description": "Upload a local file, bytes are sent as multipart form data, and the returned file object must be used.",
79
+ "parameters": [
80
+ {"name": "file", "required": True, "description": "Local file path."},
81
+ {"name": "--folder-id", "required": False, "description": "Target folder ID. Omit for root."},
82
+ {"name": "--file-name", "required": False, "description": "Override uploaded display file name."},
83
+ {"name": "--content-type", "required": False, "description": "MIME type, for example application/pdf."},
84
+ ],
85
+ "example": "licos-platform storage upload-file ./report.pdf --folder-id folder-id --file-name report.pdf --content-type application/pdf",
86
+ },
87
+ "rename-file": {
88
+ "description": "Rename a file by file ID.",
89
+ "parameters": [
90
+ {"name": "file_id", "required": True, "description": "File ID."},
91
+ {"name": "file_name", "required": True, "description": "New file name."},
92
+ ],
93
+ "example": "licos-platform storage rename-file file-id final.pdf",
94
+ },
95
+ "move-file": {
96
+ "description": "Move a file to a folder; omit folder-id to move to root.",
97
+ "parameters": [
98
+ {"name": "file_id", "required": True, "description": "File ID."},
99
+ {"name": "--folder-id", "required": False, "description": "Target folder ID. Omit for root."},
100
+ ],
101
+ "example": "licos-platform storage move-file file-id --folder-id folder-id",
102
+ },
103
+ "share-url": {
104
+ "description": "Generate a temporary share URL for a file.",
105
+ "parameters": [
106
+ {"name": "file_id", "required": True, "description": "File ID."},
107
+ {"name": "--expiry-seconds", "required": False, "description": "URL lifetime in seconds."},
108
+ ],
109
+ "example": "licos-platform storage share-url file-id --expiry-seconds 3600",
110
+ },
111
+ "object-exists": {
112
+ "description": "Check whether an object exists by file ID or object key.",
113
+ "parameters": [
114
+ {"name": "--file-id", "required": False, "description": "File ID."},
115
+ {"name": "--object-key", "required": False, "description": "Storage object key."},
116
+ ],
117
+ "example": "licos-platform storage object-exists --file-id file-id",
118
+ },
119
+ "delete-file": {
120
+ "description": "Delete a file by file ID.",
121
+ "parameters": [{"name": "file_id", "required": True, "description": "File ID."}],
122
+ "example": "licos-platform storage delete-file file-id",
123
+ },
124
+ },
125
+ }
126
+
127
+
128
+ DATABASE_HELP = {
129
+ "topic": "database",
130
+ "description": "Runtime database data-plane helpers for structured data CRUD, filtering, pagination, aggregation, transactions, and RPC.",
131
+ "runtime": RUNTIME_ENV,
132
+ "rules": [
133
+ "Use SDK from server/runtime code; do not call from browser bundles.",
134
+ "Do not ask for database host, port, username, password, or direct database URL.",
135
+ "Do not call database control-plane APIs from application code.",
136
+ "Require filters or explicit mutation batches for destructive update/delete operations.",
137
+ ],
138
+ "sdk": {
139
+ "python": "from licos_platform_sdk import database\nrows = database.table('todos').eq('done', False).limit(10).execute()",
140
+ "node": "import { database } from '@kmlckj/licos-platform-sdk';\nconst rows = await database.table('todos').eq('done', false).limit(10).execute();",
141
+ },
142
+ "filters": {
143
+ "operators": ["eq", "neq", "gt", "gte", "lt", "lte", "in", "not_in", "is_null", "is_not_null", "like", "ilike"],
144
+ "value_parsing": "CLI filter VALUE is parsed as JSON when possible. Use '\"open\"' for string values, false for boolean, and 3 for number.",
145
+ },
146
+ "commands": {
147
+ "query": {
148
+ "description": "Query rows with select fields, filters, ordering, range, limit, offset, and optional count/head.",
149
+ "parameters": [
150
+ {"name": "table", "required": True, "description": "Table name."},
151
+ {"name": "--select", "required": False, "description": "Comma-separated fields."},
152
+ {"name": "--filter FIELD OP VALUE", "required": False, "description": "Add filter. Can be repeated."},
153
+ {"name": "--order", "required": False, "description": "field or field:desc. Can be repeated."},
154
+ {"name": "--range FROM TO", "required": False, "description": "Closed zero-based range."},
155
+ {"name": "--limit", "required": False, "description": "Maximum row count."},
156
+ {"name": "--offset", "required": False, "description": "Offset row count."},
157
+ {"name": "--count", "required": False, "description": "Return total count."},
158
+ {"name": "--head", "required": False, "description": "Return count only without rows."},
159
+ ],
160
+ "example": "licos-platform database query todos --select id,title --filter done eq false --order created_at:desc --limit 10 --count",
161
+ },
162
+ "single": {
163
+ "description": "Query exactly one row, or zero/one row with --maybe.",
164
+ "parameters": [
165
+ {"name": "table", "required": True, "description": "Table name."},
166
+ {"name": "--maybe", "required": False, "description": "Allow zero rows."},
167
+ {"name": "--select", "required": False, "description": "Comma-separated fields."},
168
+ {"name": "--filter FIELD OP VALUE", "required": False, "description": "Add filter. Can be repeated."},
169
+ {"name": "--order", "required": False, "description": "field or field:desc. Can be repeated."},
170
+ ],
171
+ "example": "licos-platform database single todos --maybe --filter id eq '\"todo-1\"'",
172
+ },
173
+ "aggregate": {
174
+ "description": "Run aggregate query such as count, sum, avg, min, or max.",
175
+ "parameters": [
176
+ {"name": "table", "required": True, "description": "Table name."},
177
+ {"name": "aggregate", "required": True, "description": "Aggregate function name."},
178
+ {"name": "--field", "required": False, "description": "Aggregate field. count may omit it."},
179
+ {"name": "--group-by", "required": False, "description": "Group-by field. Can be repeated."},
180
+ {"name": "--filter FIELD OP VALUE", "required": False, "description": "Add filter. Can be repeated."},
181
+ ],
182
+ "example": "licos-platform database aggregate todos count --filter done eq false",
183
+ },
184
+ "insert": {
185
+ "description": "Insert one row or multiple rows.",
186
+ "parameters": [
187
+ {"name": "table", "required": True, "description": "Table name."},
188
+ {"name": "--row", "required": False, "description": "JSON object for one row."},
189
+ {"name": "--rows", "required": False, "description": "JSON array for batch insert."},
190
+ {"name": "--no-returning", "required": False, "description": "Do not return inserted rows."},
191
+ ],
192
+ "example": "licos-platform database insert todos --row '{\"title\":\"A\",\"done\":false}'",
193
+ },
194
+ "update": {
195
+ "description": "Update rows by filters or mutation batches.",
196
+ "parameters": [
197
+ {"name": "table", "required": True, "description": "Table name."},
198
+ {"name": "--values", "required": False, "description": "JSON object of fields to update."},
199
+ {"name": "--updates", "required": False, "description": "JSON array of update batches."},
200
+ {"name": "--filter FIELD OP VALUE", "required": False, "description": "Add filter. Can be repeated."},
201
+ {"name": "--no-returning", "required": False, "description": "Do not return updated rows."},
202
+ ],
203
+ "example": "licos-platform database update todos --values '{\"done\":true}' --filter id eq '\"todo-1\"'",
204
+ },
205
+ "delete-rows": {
206
+ "description": "Delete rows by filters or delete batches.",
207
+ "parameters": [
208
+ {"name": "table", "required": True, "description": "Table name."},
209
+ {"name": "--filter FIELD OP VALUE", "required": False, "description": "Add filter. Can be repeated."},
210
+ {"name": "--deletes", "required": False, "description": "JSON array of delete batches."},
211
+ {"name": "--no-returning", "required": False, "description": "Do not return deleted rows."},
212
+ ],
213
+ "example": "licos-platform database delete-rows todos --filter id eq '\"todo-1\"'",
214
+ },
215
+ "upsert": {
216
+ "description": "Insert or update rows by conflict keys.",
217
+ "parameters": [
218
+ {"name": "table", "required": True, "description": "Table name."},
219
+ {"name": "--row", "required": False, "description": "JSON object for one row."},
220
+ {"name": "--rows", "required": False, "description": "JSON array for batch upsert."},
221
+ {"name": "--conflict-keys", "required": False, "description": "Conflict key. Can be repeated."},
222
+ {"name": "--no-returning", "required": False, "description": "Do not return upserted rows."},
223
+ ],
224
+ "example": "licos-platform database upsert todos --row '{\"id\":\"todo-1\",\"title\":\"A\"}' --conflict-keys id",
225
+ },
226
+ "transaction": {
227
+ "description": "Run multiple runtime database operations in one transaction.",
228
+ "parameters": [{"name": "--operations", "required": True, "description": "JSON array of operations."}],
229
+ "example": "licos-platform database transaction --operations '[{\"type\":\"insert\",\"request\":{\"table\":\"todos\",\"row\":{\"title\":\"A\"}}}]'",
230
+ },
231
+ "rpc": {
232
+ "description": "Call a platform database function.",
233
+ "parameters": [
234
+ {"name": "function_name", "required": True, "description": "Function name."},
235
+ {"name": "--args", "required": False, "description": "JSON object of function arguments."},
236
+ ],
237
+ "example": "licos-platform database rpc recalculate_stats --args '{\"user_id\":\"user-1\"}'",
238
+ },
239
+ },
240
+ }
241
+
242
+
243
+ HELP_TOPICS = {
244
+ "storage": STORAGE_HELP,
245
+ "database": DATABASE_HELP,
246
+ }
247
+
248
+
249
+ def _set_handler(parser: argparse.ArgumentParser, handler: Handler) -> None:
250
+ parser.set_defaults(handler=handler)
251
+
252
+
253
+ def _cmd_help(args: argparse.Namespace) -> dict[str, Any]:
254
+ if not args.topic:
255
+ return {
256
+ "description": "Structured help for LICOS platform CLI topics.",
257
+ "usage": [
258
+ "licos-platform help storage",
259
+ "licos-platform help storage upload-file",
260
+ "licos-platform help database",
261
+ "licos-platform help database query",
262
+ ],
263
+ "topics": sorted(HELP_TOPICS),
264
+ }
265
+ topic = HELP_TOPICS[args.topic]
266
+ if not args.command:
267
+ return topic
268
+ command = topic["commands"].get(args.command)
269
+ if not command:
270
+ raise argparse.ArgumentTypeError(f"unknown {args.topic} command: {args.command}")
271
+ return {
272
+ "topic": args.topic,
273
+ "command": args.command,
274
+ **command,
275
+ }
276
+
277
+
278
+ def add_help_parser(subparsers: argparse._SubParsersAction[argparse.ArgumentParser]) -> None:
279
+ help_parser = subparsers.add_parser("help", help="Show structured JSON help for LICOS platform CLI topics")
280
+ help_parser.add_argument("topic", nargs="?", choices=sorted(HELP_TOPICS), help="Help topic")
281
+ help_parser.add_argument("command", nargs="?", help="Optional command name inside the topic")
282
+ _set_handler(help_parser, _cmd_help)
@@ -0,0 +1,50 @@
1
+ from __future__ import annotations
2
+
3
+ import argparse
4
+ import json
5
+ import sys
6
+ from typing import Any, Sequence
7
+
8
+ from licos_platform_sdk import PlatformSdkError
9
+
10
+ from .database_commands import add_database_parser
11
+ from .help_commands import add_help_parser
12
+ from .storage_commands import add_storage_parser
13
+
14
+
15
+ def _print_json(value: Any) -> None:
16
+ print(json.dumps(value, ensure_ascii=False, indent=2))
17
+
18
+
19
+ def _handle_result(value: Any) -> int:
20
+ _print_json({"success": True} if value is None else value)
21
+ return 0
22
+
23
+
24
+ def _build_parser() -> argparse.ArgumentParser:
25
+ parser = argparse.ArgumentParser(
26
+ prog="licos-platform",
27
+ description="LICOS platform CLI. Runtime configuration is read from LICOS_* environment variables.",
28
+ )
29
+ subparsers = parser.add_subparsers(dest="command", required=True)
30
+ add_help_parser(subparsers)
31
+ add_storage_parser(subparsers)
32
+ add_database_parser(subparsers)
33
+ return parser
34
+
35
+
36
+ def main(argv: Sequence[str] | None = None) -> int:
37
+ parser = _build_parser()
38
+ try:
39
+ args = parser.parse_args(argv)
40
+ return _handle_result(args.handler(args))
41
+ except argparse.ArgumentTypeError as exc:
42
+ print(f"error: {exc}", file=sys.stderr)
43
+ return 2
44
+ except PlatformSdkError as exc:
45
+ print(f"error: {exc}", file=sys.stderr)
46
+ return 1
47
+
48
+
49
+ if __name__ == "__main__":
50
+ raise SystemExit(main())
@@ -0,0 +1,131 @@
1
+ from __future__ import annotations
2
+
3
+ import argparse
4
+ from typing import Any, Callable
5
+
6
+ from licos_platform_sdk import storage
7
+
8
+
9
+ Handler = Callable[[argparse.Namespace], Any]
10
+
11
+
12
+ def _set_handler(parser: argparse.ArgumentParser, handler: Handler) -> None:
13
+ parser.set_defaults(handler=handler)
14
+
15
+
16
+ def _cmd_summary(_args: argparse.Namespace) -> Any:
17
+ return storage.summary()
18
+
19
+
20
+ def _cmd_folders(_args: argparse.Namespace) -> Any:
21
+ return storage.list_folders()
22
+
23
+
24
+ def _cmd_entries(args: argparse.Namespace) -> Any:
25
+ return storage.list_entries(folder_id=args.folder_id)
26
+
27
+
28
+ def _cmd_files(_args: argparse.Namespace) -> Any:
29
+ return storage.list_files()
30
+
31
+
32
+ def _cmd_create_folder(args: argparse.Namespace) -> Any:
33
+ return storage.create_folder(args.folder_name, parent_id=args.parent_id)
34
+
35
+
36
+ def _cmd_rename_folder(args: argparse.Namespace) -> Any:
37
+ return storage.rename_folder(args.folder_id, args.folder_name)
38
+
39
+
40
+ def _cmd_delete_folder(args: argparse.Namespace) -> Any:
41
+ return storage.delete_folder(args.folder_id)
42
+
43
+
44
+ def _cmd_upload_file(args: argparse.Namespace) -> Any:
45
+ return storage.upload_file(
46
+ args.file,
47
+ folder_id=args.folder_id,
48
+ file_name=args.file_name,
49
+ content_type=args.content_type,
50
+ )
51
+
52
+
53
+ def _cmd_rename_file(args: argparse.Namespace) -> Any:
54
+ return storage.rename_file(args.file_id, args.file_name)
55
+
56
+
57
+ def _cmd_move_file(args: argparse.Namespace) -> Any:
58
+ return storage.move_file(args.file_id, folder_id=args.folder_id)
59
+
60
+
61
+ def _cmd_share_url(args: argparse.Namespace) -> Any:
62
+ return storage.share_url(args.file_id, expiry_seconds=args.expiry_seconds)
63
+
64
+
65
+ def _cmd_object_exists(args: argparse.Namespace) -> Any:
66
+ if not args.file_id and not args.object_key:
67
+ raise argparse.ArgumentTypeError("--file-id or --object-key is required")
68
+ return storage.object_exists(file_id=args.file_id, object_key=args.object_key)
69
+
70
+
71
+ def _cmd_delete_file(args: argparse.Namespace) -> Any:
72
+ return storage.delete_file(args.file_id)
73
+
74
+
75
+ def add_storage_parser(subparsers: argparse._SubParsersAction[argparse.ArgumentParser]) -> None:
76
+ storage_parser = subparsers.add_parser("storage", help="Call LICOS Studio object storage APIs")
77
+ storage_subparsers = storage_parser.add_subparsers(dest="storage_command", required=True)
78
+
79
+ _set_handler(storage_subparsers.add_parser("summary", help="Show storage summary"), _cmd_summary)
80
+ _set_handler(storage_subparsers.add_parser("folders", help="List folders"), _cmd_folders)
81
+
82
+ entries = storage_subparsers.add_parser("entries", help="List entries in a folder")
83
+ entries.add_argument("--folder-id", default=None, help="Folder ID; omit for root")
84
+ _set_handler(entries, _cmd_entries)
85
+
86
+ _set_handler(storage_subparsers.add_parser("files", help="List files"), _cmd_files)
87
+
88
+ create_folder = storage_subparsers.add_parser("create-folder", help="Create a folder")
89
+ create_folder.add_argument("folder_name")
90
+ create_folder.add_argument("--parent-id", default=None)
91
+ _set_handler(create_folder, _cmd_create_folder)
92
+
93
+ rename_folder = storage_subparsers.add_parser("rename-folder", help="Rename a folder")
94
+ rename_folder.add_argument("folder_id")
95
+ rename_folder.add_argument("folder_name")
96
+ _set_handler(rename_folder, _cmd_rename_folder)
97
+
98
+ delete_folder = storage_subparsers.add_parser("delete-folder", help="Delete a folder")
99
+ delete_folder.add_argument("folder_id")
100
+ _set_handler(delete_folder, _cmd_delete_folder)
101
+
102
+ upload_file = storage_subparsers.add_parser("upload-file", help="Upload a file")
103
+ upload_file.add_argument("file", help="Local file path")
104
+ upload_file.add_argument("--folder-id", default=None)
105
+ upload_file.add_argument("--file-name", default=None, help="Override uploaded file name")
106
+ upload_file.add_argument("--content-type", default=None)
107
+ _set_handler(upload_file, _cmd_upload_file)
108
+
109
+ rename_file = storage_subparsers.add_parser("rename-file", help="Rename a file")
110
+ rename_file.add_argument("file_id")
111
+ rename_file.add_argument("file_name")
112
+ _set_handler(rename_file, _cmd_rename_file)
113
+
114
+ move_file = storage_subparsers.add_parser("move-file", help="Move a file")
115
+ move_file.add_argument("file_id")
116
+ move_file.add_argument("--folder-id", default=None, help="Target folder ID; omit for root")
117
+ _set_handler(move_file, _cmd_move_file)
118
+
119
+ share_url = storage_subparsers.add_parser("share-url", help="Create a file share URL")
120
+ share_url.add_argument("file_id")
121
+ share_url.add_argument("--expiry-seconds", type=int, default=None)
122
+ _set_handler(share_url, _cmd_share_url)
123
+
124
+ exists = storage_subparsers.add_parser("object-exists", help="Check object existence")
125
+ exists.add_argument("--file-id", default=None)
126
+ exists.add_argument("--object-key", default=None)
127
+ _set_handler(exists, _cmd_object_exists)
128
+
129
+ delete_file = storage_subparsers.add_parser("delete-file", help="Delete a file")
130
+ delete_file.add_argument("file_id")
131
+ _set_handler(delete_file, _cmd_delete_file)
@@ -0,0 +1,180 @@
1
+ from __future__ import annotations
2
+
3
+ import io
4
+ import json
5
+ import sys
6
+ import unittest
7
+ from contextlib import redirect_stderr, redirect_stdout
8
+ from pathlib import Path
9
+ from unittest import mock
10
+
11
+ sys.path.insert(0, str(Path(__file__).resolve().parents[2] / "licos-platform-sdk" / "src"))
12
+ sys.path.insert(0, str(Path(__file__).resolve().parents[1] / "src"))
13
+
14
+ from licos_platform_cli import main as cli_main
15
+ from licos_platform_cli import database_commands, storage_commands
16
+ from licos_platform_sdk import ApiError
17
+
18
+
19
+ def run_cli(*args: str) -> tuple[int, str, str]:
20
+ stdout = io.StringIO()
21
+ stderr = io.StringIO()
22
+ with redirect_stdout(stdout), redirect_stderr(stderr):
23
+ try:
24
+ code = cli_main.main(list(args))
25
+ except SystemExit as exc:
26
+ code = int(exc.code or 0)
27
+ return code, stdout.getvalue(), stderr.getvalue()
28
+
29
+
30
+ class PlatformCliTests(unittest.TestCase):
31
+ def test_help_exposes_storage_commands(self) -> None:
32
+ stdout = io.StringIO()
33
+ with self.assertRaises(SystemExit) as caught, redirect_stdout(stdout):
34
+ cli_main.main(["--help"])
35
+
36
+ self.assertEqual(caught.exception.code, 0)
37
+ self.assertIn("help", stdout.getvalue())
38
+ self.assertIn("storage", stdout.getvalue())
39
+ self.assertIn("database", stdout.getvalue())
40
+
41
+ def test_structured_help_lists_topics(self) -> None:
42
+ code, stdout, stderr = run_cli("help")
43
+
44
+ self.assertEqual(code, 0)
45
+ self.assertEqual(stderr, "")
46
+ payload = json.loads(stdout)
47
+ self.assertEqual(payload["topics"], ["database", "storage"])
48
+ self.assertIn("licos-platform help storage", payload["usage"])
49
+
50
+ def test_structured_help_storage_command_describes_parameters(self) -> None:
51
+ code, stdout, stderr = run_cli("help", "storage", "upload-file")
52
+
53
+ self.assertEqual(code, 0)
54
+ self.assertEqual(stderr, "")
55
+ payload = json.loads(stdout)
56
+ self.assertEqual(payload["topic"], "storage")
57
+ self.assertEqual(payload["command"], "upload-file")
58
+ self.assertIn("Upload a local file", payload["description"])
59
+ self.assertIn("--content-type", [item["name"] for item in payload["parameters"]])
60
+
61
+ def test_structured_help_database_topic_includes_sdk_and_filter_docs(self) -> None:
62
+ code, stdout, stderr = run_cli("help", "database")
63
+
64
+ self.assertEqual(code, 0)
65
+ self.assertEqual(stderr, "")
66
+ payload = json.loads(stdout)
67
+ self.assertEqual(payload["topic"], "database")
68
+ self.assertIn("python", payload["sdk"])
69
+ self.assertIn("node", payload["sdk"])
70
+ self.assertIn("eq", payload["filters"]["operators"])
71
+ self.assertNotIn("LICOS_DATABASE_ENVIRONMENT", {item["name"] for item in payload["runtime"]})
72
+
73
+ def test_storage_files_wraps_sdk_and_outputs_json(self) -> None:
74
+ with mock.patch.object(storage_commands.storage, "list_files", return_value=[{"id": "file-1"}]) as list_files:
75
+ code, stdout, stderr = run_cli("storage", "files")
76
+
77
+ self.assertEqual(code, 0)
78
+ self.assertEqual(stderr, "")
79
+ self.assertEqual(json.loads(stdout), [{"id": "file-1"}])
80
+ list_files.assert_called_once_with()
81
+
82
+ def test_storage_upload_file_forwards_only_user_level_options(self) -> None:
83
+ with mock.patch.object(storage_commands.storage, "upload_file", return_value={"id": "file-1"}) as upload_file:
84
+ code, stdout, stderr = run_cli(
85
+ "storage",
86
+ "upload-file",
87
+ "report.pdf",
88
+ "--folder-id",
89
+ "folder-1",
90
+ "--file-name",
91
+ "final.pdf",
92
+ "--content-type",
93
+ "application/pdf",
94
+ )
95
+
96
+ self.assertEqual(code, 0)
97
+ self.assertEqual(stderr, "")
98
+ self.assertEqual(json.loads(stdout), {"id": "file-1"})
99
+ upload_file.assert_called_once_with(
100
+ "report.pdf",
101
+ folder_id="folder-1",
102
+ file_name="final.pdf",
103
+ content_type="application/pdf",
104
+ )
105
+
106
+ def test_storage_share_url_forwards_expiry(self) -> None:
107
+ with mock.patch.object(storage_commands.storage, "share_url", return_value={"url": "https://example.test"}) as share_url:
108
+ code, stdout, stderr = run_cli("storage", "share-url", "file-1", "--expiry-seconds", "60")
109
+
110
+ self.assertEqual(code, 0)
111
+ self.assertEqual(stderr, "")
112
+ self.assertEqual(json.loads(stdout), {"url": "https://example.test"})
113
+ share_url.assert_called_once_with("file-1", expiry_seconds=60)
114
+
115
+ def test_storage_object_exists_requires_lookup_key(self) -> None:
116
+ code, stdout, stderr = run_cli("storage", "object-exists")
117
+
118
+ self.assertEqual(code, 2)
119
+ self.assertEqual(stdout, "")
120
+ self.assertIn("--file-id or --object-key is required", stderr)
121
+
122
+ def test_storage_delete_service_is_not_exposed(self) -> None:
123
+ code, stdout, stderr = run_cli("storage", "delete-service", "--yes")
124
+
125
+ self.assertEqual(code, 2)
126
+ self.assertEqual(stdout, "")
127
+ self.assertIn("invalid choice", stderr)
128
+
129
+ def test_sdk_errors_return_cli_failure(self) -> None:
130
+ with mock.patch.object(storage_commands.storage, "summary", side_effect=ApiError("platform failed")):
131
+ code, stdout, stderr = run_cli("storage", "summary")
132
+
133
+ self.assertEqual(code, 1)
134
+ self.assertEqual(stdout, "")
135
+ self.assertIn("platform failed", stderr)
136
+
137
+ def test_database_query_forwards_runtime_options(self) -> None:
138
+ with mock.patch.object(database_commands.database, "query", return_value={"rows": []}) as query:
139
+ code, stdout, stderr = run_cli(
140
+ "database",
141
+ "query",
142
+ "todos",
143
+ "--select",
144
+ "id,title",
145
+ "--filter",
146
+ "done",
147
+ "eq",
148
+ "false",
149
+ "--order",
150
+ "created_at:desc",
151
+ "--limit",
152
+ "10",
153
+ "--count",
154
+ )
155
+
156
+ self.assertEqual(code, 0)
157
+ self.assertEqual(stderr, "")
158
+ self.assertEqual(json.loads(stdout), {"rows": []})
159
+ query.assert_called_once_with(
160
+ "todos",
161
+ select="id,title",
162
+ filters=[{"field": "done", "op": "eq", "value": False}],
163
+ order_by=[{"field": "created_at", "direction": "desc"}],
164
+ range=None,
165
+ limit=10,
166
+ offset=None,
167
+ count=True,
168
+ head=False,
169
+ )
170
+
171
+ def test_database_transaction_requires_operations_array(self) -> None:
172
+ code, stdout, stderr = run_cli("database", "transaction", "--operations", "{}")
173
+
174
+ self.assertEqual(code, 2)
175
+ self.assertEqual(stdout, "")
176
+ self.assertIn("value must be a JSON array", stderr)
177
+
178
+
179
+ if __name__ == "__main__":
180
+ unittest.main()
@@ -1,6 +0,0 @@
1
- Metadata-Version: 2.4
2
- Name: licos-platform-cli
3
- Version: 0.2.0
4
- Summary: LICOS platform CLI for storage preview and download operations
5
- Requires-Python: >=3.10
6
- Requires-Dist: licos-platform-sdk>=0.2.0
@@ -1,70 +0,0 @@
1
- from __future__ import annotations
2
-
3
- import argparse
4
- import json
5
- from pathlib import Path
6
- from typing import Sequence
7
-
8
- from licos_platform_sdk import download, download_url, preview_url
9
-
10
-
11
- def _build_parser() -> argparse.ArgumentParser:
12
- parser = argparse.ArgumentParser(prog="licos-platform")
13
- subparsers = parser.add_subparsers(dest="command", required=True)
14
-
15
- storage = subparsers.add_parser("storage", help="Platform storage operations")
16
- storage_subparsers = storage.add_subparsers(dest="storage_command", required=True)
17
-
18
- preview_url_cmd = storage_subparsers.add_parser("preview-url", help="Build a public preview URL")
19
- preview_url_cmd.add_argument("bucket", help="Bucket name")
20
- preview_url_cmd.add_argument("object_key", help="Object key")
21
-
22
- download_url_cmd = storage_subparsers.add_parser("download-url", help="Build a public download URL")
23
- download_url_cmd.add_argument("bucket", help="Bucket name")
24
- download_url_cmd.add_argument("object_key", help="Object key")
25
-
26
- download_cmd = storage_subparsers.add_parser("download", help="Download a public file URL to disk")
27
- download_cmd.add_argument("url", help="Public storage URL")
28
- download_cmd.add_argument("--output", required=True, help="Destination path")
29
-
30
- return parser
31
-
32
-
33
- def _print_json(payload: dict) -> None:
34
- print(json.dumps(payload, ensure_ascii=False))
35
-
36
-
37
- def main(argv: Sequence[str] | None = None) -> int:
38
- parser = _build_parser()
39
- args = parser.parse_args(argv)
40
-
41
- if args.command != "storage":
42
- parser.error(f"Unsupported command: {args.command}")
43
-
44
- if args.storage_command == "preview-url":
45
- _print_json(
46
- {
47
- "preview_url": preview_url(args.bucket, args.object_key),
48
- }
49
- )
50
- return 0
51
-
52
- if args.storage_command == "download-url":
53
- _print_json(
54
- {
55
- "download_url": download_url(args.bucket, args.object_key),
56
- }
57
- )
58
- return 0
59
-
60
- if args.storage_command == "download":
61
- saved_path = download(args.url, Path(args.output))
62
- _print_json({"path": str(saved_path)})
63
- return 0
64
-
65
- parser.error(f"Unsupported storage command: {args.storage_command}")
66
- return 2
67
-
68
-
69
- if __name__ == "__main__":
70
- raise SystemExit(main())
@@ -1,81 +0,0 @@
1
- from __future__ import annotations
2
-
3
- import json
4
- import os
5
- import subprocess
6
- import sys
7
- import tempfile
8
- import unittest
9
- from pathlib import Path
10
-
11
-
12
- class PlatformCliTests(unittest.TestCase):
13
- def test_storage_help_does_not_expose_minio_upload_commands(self) -> None:
14
- env = os.environ.copy()
15
- env["PYTHONPATH"] = os.pathsep.join(
16
- [
17
- str(Path(__file__).resolve().parents[2] / "licos-platform-sdk" / "src"),
18
- str(Path(__file__).resolve().parents[1] / "src"),
19
- env.get("PYTHONPATH", ""),
20
- ]
21
- ).strip(os.pathsep)
22
-
23
- proc = subprocess.run(
24
- [
25
- sys.executable,
26
- "-m",
27
- "licos_platform_cli.main",
28
- "storage",
29
- "--help",
30
- ],
31
- capture_output=True,
32
- check=True,
33
- text=True,
34
- env=env,
35
- )
36
-
37
- self.assertNotIn("upload-user", proc.stdout)
38
- self.assertNotIn("upload-project", proc.stdout)
39
-
40
- def test_preview_url_command_prints_json(self) -> None:
41
- with tempfile.TemporaryDirectory() as tmp_dir:
42
- file_path = Path(tmp_dir) / "demo.txt"
43
- file_path.write_text("hello world", encoding="utf-8")
44
- env = os.environ.copy()
45
- env["PYTHONPATH"] = os.pathsep.join(
46
- [
47
- str(Path(__file__).resolve().parents[2] / "licos-platform-sdk" / "src"),
48
- str(Path(__file__).resolve().parents[1] / "src"),
49
- env.get("PYTHONPATH", ""),
50
- ]
51
- ).strip(os.pathsep)
52
- env["LICOS_ORCHESTRATOR_API_BASE_URL"] = "http://127.0.0.1:9"
53
- env["LICOS_USER_TOKEN"] = "user_token"
54
- env["LICOS_USER_ID"] = "demo-user"
55
- env["LICOS_PROJECT_ID"] = "demo-project"
56
-
57
- proc = subprocess.run(
58
- [
59
- sys.executable,
60
- "-m",
61
- "licos_platform_cli.main",
62
- "storage",
63
- "preview-url",
64
- "demo-bucket",
65
- "folder/report.txt",
66
- ],
67
- capture_output=True,
68
- check=True,
69
- text=True,
70
- env=env,
71
- )
72
-
73
- payload = json.loads(proc.stdout)
74
- self.assertEqual(
75
- payload["preview_url"],
76
- "http://127.0.0.1:9/api/v1/public/storage/demo-bucket/folder/report.txt",
77
- )
78
-
79
-
80
- if __name__ == "__main__":
81
- unittest.main()